mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
New version 1.2.6
Restores AI Assistant support for OpenAI-compatible endpoints on private IPs, loopback and Docker networks (LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute, self-hosted proxies) and surfaces the server's error under the Load button (#325). Aligns the Secure Gateway wizard's Alpine template selection and pct create with the host's real architecture on x86_64 and arm64 (#324). Consolidates changes landing on develop: atomic notification delivery, custom SSH port for Borg remote targets (#236), optional GitHub API token for app version tracking (#306), and richer replication failure notifications.
This commit is contained in:
@@ -340,6 +340,12 @@ export function NotificationSettings() {
|
||||
const [testingAI, setTestingAI] = useState(false)
|
||||
const [aiTestResult, setAiTestResult] = useState<{ success: boolean; message: string; model?: string } | null>(null)
|
||||
const [providerModels, setProviderModels] = useState<string[]>([])
|
||||
// Surfaces the message the backend returns when the models fetch
|
||||
// fails (bad key, unreachable endpoint, SSRF guard blocking the URL,
|
||||
// network error). Cleared on the next successful fetch, on provider
|
||||
// change and on unmount. Without this the dropdown just goes empty
|
||||
// and the user has no clue why (issue #325).
|
||||
const [providerModelsError, setProviderModelsError] = useState<string | null>(null)
|
||||
const [loadingProviderModels, setLoadingProviderModels] = useState(false)
|
||||
const [showCustomPromptInfo, setShowCustomPromptInfo] = useState(false)
|
||||
const [editingCustomPrompt, setEditingCustomPrompt] = useState(false)
|
||||
@@ -992,11 +998,12 @@ export function NotificationSettings() {
|
||||
}
|
||||
|
||||
setLoadingProviderModels(true)
|
||||
setProviderModelsError(null)
|
||||
try {
|
||||
const data = await fetchApi<{ success: boolean; models: string[]; recommended: string; message: string }>("/api/notifications/provider-models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
api_key: apiKey,
|
||||
ollama_url: config.ai_ollama_url,
|
||||
@@ -1009,8 +1016,8 @@ export function NotificationSettings() {
|
||||
updateConfig(prev => {
|
||||
if (!prev.ai_model || !data.models.includes(prev.ai_model)) {
|
||||
const modelToSelect = data.recommended || data.models[0]
|
||||
return {
|
||||
...prev,
|
||||
return {
|
||||
...prev,
|
||||
ai_model: modelToSelect,
|
||||
ai_models: { ...prev.ai_models, [provider]: modelToSelect }
|
||||
}
|
||||
@@ -1019,9 +1026,13 @@ export function NotificationSettings() {
|
||||
})
|
||||
} else {
|
||||
setProviderModels([])
|
||||
// Surface the backend's error message so the user can act on
|
||||
// it (bad key, SSRF-blocked URL, unreachable endpoint …).
|
||||
setProviderModelsError(data.message || t("settings.notifications.ai.loadModelsFailed"))
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
setProviderModels([])
|
||||
setProviderModelsError(err instanceof Error ? err.message : t("settings.notifications.ai.loadModelsFailed"))
|
||||
} finally {
|
||||
setLoadingProviderModels(false)
|
||||
}
|
||||
@@ -2427,6 +2438,12 @@ export function NotificationSettings() {
|
||||
{providerModels.length > 0 && (
|
||||
<p className="text-xs text-green-500">{t("settings.notifications.ai.modelsAvailable", { count: providerModels.length })}</p>
|
||||
)}
|
||||
{/* Surface the backend's error message when a Load attempt
|
||||
returns empty — silent dropdown was invisible to the user
|
||||
(issue #325). Cleared when the next successful load lands. */}
|
||||
{providerModels.length === 0 && providerModelsError && !loadingProviderModels && (
|
||||
<p className="text-xs text-red-400">{providerModelsError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt Mode section */}
|
||||
|
||||
@@ -18,6 +18,23 @@ interface ReleaseNote {
|
||||
}
|
||||
|
||||
export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
"1.2.6": {
|
||||
date: "September 2, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Borg remote target — the Add Borg destination dialog in the Monitor and the shell TUI (menu → Host Backup → New Borg target) accept a custom SSH port; the default stays at 22 and existing entries created without a port keep working. BORG_RSH, the auto key install flow and the capacity probe all honour the custom port (suggested by @songochain in discussion #236).",
|
||||
"GitHub API — Settings → GitHub API accepts an optional personal access token for release and tag checks when GitHub's anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser (suggested by @SystemIdleProcess in discussion #306).",
|
||||
],
|
||||
changed: [
|
||||
"Notification delivery is atomic — events reserve their deduplication fingerprint before AI processing and channel delivery, so concurrent collectors or parallel Monitor processes cannot send the same event twice. The reservation is shared through SQLite and released when no channel succeeds, preserving retries after temporary transport failures.",
|
||||
"Native Proxmox replication failure notifications now resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job's failures deduplicate independently (reported by Ale R.).",
|
||||
],
|
||||
fixed: [
|
||||
"AI Assistant custom OpenAI endpoint — endpoints reachable on private IPs, loopback or Docker networks (LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute, self-hosted proxies…) are accepted when loading the model catalogue and validating the AI configuration. The dropdown surfaces the reason returned by the server (or the underlying network error) directly under the Load button, in every Monitor language (#325, reported by @jorgeffonte).",
|
||||
"Secure Gateway wizard — Alpine template download and local template selection filter by the host's real architecture (via dpkg --print-architecture, falling back to uname -m); pct create is invoked with an explicit --arch so container metadata matches the host on both x86_64 and arm64 (#324, reported by @N0X4DD0).",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.2.5": {
|
||||
date: "September 1, 2026",
|
||||
changes: {
|
||||
@@ -289,28 +306,33 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.appsDashboard",
|
||||
text: "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
|
||||
key: "releaseNotes.currentFeatures.aiCustomEndpoint",
|
||||
text: "AI Assistant custom OpenAI endpoint — LiteLLM, LM Studio, LocalAI, vLLM, OmniRoute and any self-hosted proxy on private IPs, loopback or Docker networks are recognised when loading the model catalogue. The dropdown surfaces the server's error (or the underlying network reason) directly under the Load button (#325, reported by @jorgeffonte).",
|
||||
},
|
||||
{
|
||||
icon: <Cpu className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.lxcAppsUpdates",
|
||||
text: "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
|
||||
icon: <Wrench className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.secureGatewayArch",
|
||||
text: "Secure Gateway wizard — the Alpine template download, local template selection and pct create all match the host's real architecture, so x86_64 hosts receive amd64 containers and arm64 hosts receive arm64 containers (#324, reported by @N0X4DD0).",
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.appCatalog",
|
||||
text: "New application detection catalog with over 380 tracked workloads, generated live from community-scripts across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
|
||||
icon: <Bell className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.atomicNotifications",
|
||||
text: "Notification events reserve their deduplication fingerprint atomically before AI processing and channel delivery, so concurrent collectors, completion callbacks or parallel Monitor processes cannot send the same event twice. The reservation is released when no channel succeeds, preserving retries.",
|
||||
},
|
||||
{
|
||||
icon: <Languages className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.multilingual",
|
||||
text: "The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.",
|
||||
icon: <DatabaseBackup className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.borgSshPort",
|
||||
text: "Borg remote target — the Add Borg destination dialog and the shell TUI accept a custom SSH port. BORG_RSH, the auto key install flow and the capacity probe all honour it. Fully backwards compatible with existing entries created without an explicit port (suggested by @songochain in discussion #236).",
|
||||
},
|
||||
{
|
||||
icon: <Server className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.nvidiaMultiGpu",
|
||||
text: "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298).",
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.githubToken",
|
||||
text: "Settings → GitHub API accepts an optional personal access token for release and tag checks when the anonymous quota is exhausted. The token is encrypted at rest and never returned to the browser; the rate-limit error is translated in every Monitor language (suggested by @SystemIdleProcess in discussion #306).",
|
||||
},
|
||||
{
|
||||
icon: <RefreshCw className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.replicationContext",
|
||||
text: "Native Proxmox replication failure notifications resolve the replication job ID, affected VM/LXC ID and guest name; the exact error block from Proxmox is preserved as the reason, and each replication job deduplicates independently (reported by Ale R.).",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1087,6 +1087,9 @@ export function VirtualMachines() {
|
||||
cache.firewall.delete(vm.vmid)
|
||||
dockerInventoryRequestedRef.current.delete(vm.vmid)
|
||||
invalidateLxcApps(vm.vmid)
|
||||
if (selectedVMRef.current?.vmid === vm.vmid) {
|
||||
setScheduleLoaded(null)
|
||||
}
|
||||
setVmConfigs((existing) => {
|
||||
if (!(vm.vmid in existing)) return existing
|
||||
const next = { ...existing }
|
||||
@@ -1830,6 +1833,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const [scheduleLastRunAt, setScheduleLastRunAt] = useState<string | null>(null)
|
||||
const [scheduleLastRunStatus, setScheduleLastRunStatus] = useState<string | null>(null)
|
||||
const [scheduleLastRunReason, setScheduleLastRunReason] = useState<string | null>(null)
|
||||
const [scheduleLastRunLog, setScheduleLastRunLog] = useState<string | null>(null)
|
||||
const [scheduleLastRunRebootRequired, setScheduleLastRunRebootRequired] = useState(false)
|
||||
const [scheduleLastRunRebootPackages, setScheduleLastRunRebootPackages] = useState<string[]>([])
|
||||
const [scheduleLogOpen, setScheduleLogOpen] = useState(false)
|
||||
const [scheduleLogLoading, setScheduleLogLoading] = useState(false)
|
||||
const [scheduleLogContent, setScheduleLogContent] = useState("")
|
||||
const [scheduleLogError, setScheduleLogError] = useState<string | null>(null)
|
||||
const [scheduleLogTruncated, setScheduleLogTruncated] = useState(false)
|
||||
const [scheduleSaving, setScheduleSaving] = useState(false)
|
||||
const [scheduleError, setScheduleError] = useState<string | null>(null)
|
||||
const [externalCron, setExternalCron] = useState<{
|
||||
@@ -1897,6 +1908,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setScheduleLastRunAt(s.last_run_at || null)
|
||||
setScheduleLastRunStatus(s.last_run_status || null)
|
||||
setScheduleLastRunReason(s.last_run_reason || null)
|
||||
setScheduleLastRunLog(s.last_run_log || null)
|
||||
setScheduleLastRunRebootRequired(s.last_run_reboot_required === true)
|
||||
setScheduleLastRunRebootPackages(Array.isArray(s.last_run_reboot_packages)
|
||||
? s.last_run_reboot_packages.map((value: any) => String(value))
|
||||
: [])
|
||||
setExternalCron(s.external_cron || null)
|
||||
}
|
||||
|
||||
@@ -1943,6 +1959,23 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const openScheduleLog = async (vmid: number) => {
|
||||
setScheduleLogOpen(true)
|
||||
setScheduleLogLoading(true)
|
||||
setScheduleLogContent("")
|
||||
setScheduleLogError(null)
|
||||
setScheduleLogTruncated(false)
|
||||
try {
|
||||
const payload: any = await fetchApi(`/api/vms/${vmid}/schedule/log`)
|
||||
setScheduleLogContent(String(payload?.content || ""))
|
||||
setScheduleLogTruncated(payload?.truncated === true)
|
||||
} catch (e: any) {
|
||||
setScheduleLogError(e?.message || t("vmLxc.scheduled.logFailed"))
|
||||
} finally {
|
||||
setScheduleLogLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveBulkUpdate = async (vmid: number) => {
|
||||
setBulkSaving(true)
|
||||
setBulkError(null)
|
||||
@@ -2094,6 +2127,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setScheduleLastRunAt(null)
|
||||
setScheduleLastRunStatus(null)
|
||||
setScheduleLastRunReason(null)
|
||||
setScheduleLastRunLog(null)
|
||||
setScheduleLastRunRebootRequired(false)
|
||||
setScheduleLastRunRebootPackages([])
|
||||
setScheduleReleaseDelayDays(0)
|
||||
} catch (e: any) {
|
||||
setScheduleError(e?.message || "Delete failed")
|
||||
@@ -2127,6 +2163,59 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
return expr
|
||||
}
|
||||
|
||||
const renderScheduleRunDetails = (indented = false) => {
|
||||
if (!scheduleLastRunAt || !selectedVM) return null
|
||||
const spacing = indented ? "pl-4" : ""
|
||||
return (
|
||||
<div className={`space-y-1.5 ${spacing}`}>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("vmLxc.scheduled.lastRun", { date: new Date(scheduleLastRunAt).toLocaleString() })}
|
||||
{scheduleLastRunStatus && (
|
||||
<> · <span className={scheduleLastRunStatus === "success" ? "text-green-400" : scheduleLastRunStatus === "partial" || scheduleLastRunStatus === "deferred" || scheduleLastRunStatus === "skipped" ? "text-amber-400" : "text-red-400"}>
|
||||
{scheduleLastRunStatus === "success"
|
||||
? t("vmLxc.scheduled.runSuccess")
|
||||
: scheduleLastRunStatus === "partial"
|
||||
? t("vmLxc.scheduled.runPartial")
|
||||
: scheduleLastRunStatus === "deferred"
|
||||
? t("vmLxc.scheduled.runDeferred")
|
||||
: scheduleLastRunStatus === "skipped"
|
||||
? t("vmLxc.scheduled.runSkipped")
|
||||
: t("vmLxc.scheduled.runFailed")}
|
||||
</span></>
|
||||
)}
|
||||
</div>
|
||||
{scheduleLastRunReason && (
|
||||
<div className="text-xs text-muted-foreground break-words">
|
||||
{scheduleLastRunReason}
|
||||
</div>
|
||||
)}
|
||||
{scheduleLastRunRebootRequired && (
|
||||
<div className="text-xs text-amber-400 flex items-start gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<div>{t("vmLxc.scheduled.rebootRequired")}</div>
|
||||
{scheduleLastRunRebootPackages.length > 0 && (
|
||||
<div className="text-muted-foreground mt-0.5 break-words">
|
||||
{t("vmLxc.scheduled.rebootPackages", { packages: scheduleLastRunRebootPackages.join(", ") })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{scheduleLastRunLog && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openScheduleLog(selectedVM.vmid)}
|
||||
className="h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{t("vmLxc.scheduled.viewLog")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Load the schedule once whenever the user opens the Updates tab
|
||||
// of a specific LXC. Keying on vmid keeps us from re-fetching on
|
||||
// every render but also refetches after switching CTs.
|
||||
@@ -2134,9 +2223,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
if (activeModalTab !== "updates") return
|
||||
if (!selectedVM || selectedVM.type !== "lxc") return
|
||||
if (scheduleLoaded !== selectedVM.vmid) loadSchedule(selectedVM.vmid)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeModalTab, selectedVM?.vmid, selectedVM?.modal_cache_revision, scheduleLoaded])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeModalTab !== "updates") return
|
||||
if (!selectedVM || selectedVM.type !== "lxc") return
|
||||
if (bulkLoaded !== selectedVM.vmid) loadBulkUpdate(selectedVM.vmid)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeModalTab, selectedVM?.vmid])
|
||||
}, [activeModalTab, selectedVM?.vmid, bulkLoaded])
|
||||
|
||||
// Docker drift is opt-in: read it only after Docker has been registered and
|
||||
// only when the user opens Updates. This request deliberately DOES NOT use
|
||||
@@ -6198,29 +6293,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<> · {t("vmLxc.scheduled.releaseDelaySummary", { days: scheduleReleaseDelayDays })}</>
|
||||
)}
|
||||
</div>
|
||||
{scheduleLastRunAt && (
|
||||
<div className="text-xs text-muted-foreground pl-4">
|
||||
{t("vmLxc.scheduled.lastRun", { date: new Date(scheduleLastRunAt).toLocaleString() })}
|
||||
{scheduleLastRunStatus && (
|
||||
<> · <span className={scheduleLastRunStatus === "success" ? "text-green-400" : scheduleLastRunStatus === "partial" || scheduleLastRunStatus === "deferred" || scheduleLastRunStatus === "skipped" ? "text-amber-400" : "text-red-400"}>
|
||||
{scheduleLastRunStatus === "success"
|
||||
? t("vmLxc.scheduled.runSuccess")
|
||||
: scheduleLastRunStatus === "partial"
|
||||
? t("vmLxc.scheduled.runPartial")
|
||||
: scheduleLastRunStatus === "deferred"
|
||||
? t("vmLxc.scheduled.runDeferred")
|
||||
: scheduleLastRunStatus === "skipped"
|
||||
? t("vmLxc.scheduled.runSkipped")
|
||||
: t("vmLxc.scheduled.runFailed")}
|
||||
</span></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{scheduleLastRunReason && (
|
||||
<div className="text-xs text-muted-foreground pl-4 break-words">
|
||||
{scheduleLastRunReason}
|
||||
</div>
|
||||
)}
|
||||
{renderScheduleRunDetails(true)}
|
||||
</div>
|
||||
)}
|
||||
{!optionsEditMode && !scheduleConfigured && !externalCron && (
|
||||
@@ -6351,25 +6424,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{scheduleLastRunAt && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("vmLxc.scheduled.lastRun", { date: new Date(scheduleLastRunAt).toLocaleString() })}
|
||||
{scheduleLastRunStatus && (
|
||||
<> · <span className={scheduleLastRunStatus === "success" ? "text-green-400" : scheduleLastRunStatus === "partial" || scheduleLastRunStatus === "deferred" || scheduleLastRunStatus === "skipped" ? "text-amber-400" : "text-red-400"}>
|
||||
{scheduleLastRunStatus === "success"
|
||||
? t("vmLxc.scheduled.runSuccess")
|
||||
: scheduleLastRunStatus === "partial"
|
||||
? t("vmLxc.scheduled.runPartial")
|
||||
: scheduleLastRunStatus === "deferred"
|
||||
? t("vmLxc.scheduled.runDeferred")
|
||||
: scheduleLastRunStatus === "skipped"
|
||||
? t("vmLxc.scheduled.runSkipped")
|
||||
: t("vmLxc.scheduled.runFailed")}
|
||||
</span></>
|
||||
)}
|
||||
{scheduleLastRunReason && <div className="mt-1 break-words">{scheduleLastRunReason}</div>}
|
||||
</div>
|
||||
)}
|
||||
{renderScheduleRunDetails()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6996,6 +7051,51 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={scheduleLogOpen} onOpenChange={setScheduleLogOpen}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
{t("vmLxc.scheduled.logTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("vmLxc.scheduled.logDescription", {
|
||||
name: selectedVM?.name || "LXC",
|
||||
vmid: selectedVM?.vmid || "—",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1">
|
||||
{scheduleLogLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
{t("vmLxc.scheduled.logLoading")}
|
||||
</div>
|
||||
) : scheduleLogError ? (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">
|
||||
{scheduleLogError}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{scheduleLogTruncated && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-2 text-xs text-amber-400">
|
||||
{t("vmLxc.scheduled.logTruncated")}
|
||||
</div>
|
||||
)}
|
||||
<pre className="max-h-[58vh] overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background p-4 text-xs font-mono text-foreground">
|
||||
{scheduleLogContent || t("vmLxc.scheduled.logEmpty")}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setScheduleLogOpen(false)}>
|
||||
{t("vmLxc.scheduled.closeLog")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* LXC Terminal Modal */}
|
||||
{terminalVmid !== null && (
|
||||
<LxcTerminalModal
|
||||
|
||||
Reference in New Issue
Block a user