ProxMenux 1.2.6.2-beta: OCI containers in the Monitor, docs and fixes

OCI manager Apps
- App tab: containers installed from an OCI image are identified from their
  installation record; the application and image versions are shown and an
  update is detected by image digest; repository link; Refresh data.
- Updates tab for OCI containers: Update and Recreate run the same flow as the
  OCI menu in the Monitor terminal; the pre-update backup can be kept in a
  backup storage; scheduled image updates with an optional minimum age.
- Logs tab: console output of the application, kept on the host
  (lxc.console.logfile + logrotate) and followed live.
- The Proxmox console opens a shell (cmode: shell) when the image has one.
- A damaged image download is fetched again before failing.
- Multi-container applications open at their LAN address; volume mount
  points on block storage report their usage.

Monitor
- Proxmox notifications are delivered to a loopback-only HTTP listener when
  HTTPS is enabled, so they no longer fail certificate verification.
- Log persistence counts recurring patterns only; an ended burst is not
  reported as persistent and its warning clears on its own (#386).
- Proxmox notification config backups are deduplicated and capped at three.
- The update icon on the Apps page opens the container on its Updates tab.
- Version 1.2.6.2-beta and its release notes in every Monitor language.

Docs
- OCI manager Apps and Audit & Report rebuilt as per-page message files,
  with a new page for OCI containers in the Monitor.
- Seven pages fixed where rich-text tags were missing from t.rich.

Translations
- Spanish fixes across the OCI engine, the Monitor and the TUI menus.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MacRimi
2026-09-25 21:51:12 +02:00
co-authored by Claude Opus 5.5
parent 386d33df6e
commit 4437a671d2
524 changed files with 14459 additions and 3841 deletions
+25 -3
View File
@@ -595,9 +595,9 @@ function CardsGrid({
// `changeTab` switches the outer tab (dashboard-level) and
// `openLxcAppModal` tells VirtualMachines which guest to open and on
// which inner tab to land. Both fire in the same tick.
function openLxcModalOnAppTab(vmid: number) {
function openLxcModalOnAppTab(vmid: number, tab: "app" | "updates" = "app") {
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid } }))
window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid, tab } }))
}
// Same pattern for a QEMU guest: land on the modal's Status tab
@@ -637,6 +637,15 @@ function AppCard({
}
}
// The update icon leads to where the update is applied: the Updates
// tab of the container.
const goToUpdates = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (link.vmid == null) return
openLxcModalOnAppTab(link.vmid, "updates")
}
const goToEditor = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
@@ -682,7 +691,20 @@ function AppCard({
<Pencil className="h-4 w-4" />
</button>
) : link.updateAvailable && (
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0 self-start mt-0.5" aria-hidden="true" />
link.vmid != null && link.guestType !== "qemu" ? (
<button
type="button"
onClick={goToUpdates}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToUpdates(e) }}
className="rounded-full flex-shrink-0 self-start mt-0.5 text-purple-400 hover:text-purple-300 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t("apps.openUpdatesAria", { name: link.appName })}
title={t("apps.openUpdatesTitle")}
>
<ArrowUpCircle className="h-5 w-5" />
</button>
) : (
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0 self-start mt-0.5" aria-hidden="true" />
)
)}
</div>
+1 -1
View File
@@ -629,7 +629,7 @@ export default function Hardware() {
const hasRealtimeData = (): boolean => {
if (!realtimeGPUData) return false
// Esto permite mostrar datos incluso cuando la GPU está inactiva (valores en 0 o null)
// Show the data even while the GPU is idle, when values are 0 or null
return realtimeGPUData.has_monitoring_tool === true
}
+205 -23
View File
@@ -43,7 +43,7 @@ import { useT } from "@/lib/i18n/provider"
// apps and casual "just want a link" registrations use this default.
type InstalledVia = "" | "dpkg" | "apk" | "file" | "binary" |
"python_dist" | "docker_label" | "docker_exec" |
"command" | "manual"
"command" | "manual" | "oci_image"
type GithubSource = "releases" | "tags"
interface PortEntry {
@@ -120,6 +120,10 @@ interface DetectedApp {
// auto-fill the Web Link editor when the user clicks "Register".
category?: string | null
tracking_suggestion?: TrackingSuggestion | null
// The scheme the application is served on, when it is known rather than
// guessed. A ProxMenux OCI install records it; Chromium answers on 3001
// over https, which no port heuristic can tell from 3000 over http.
scheme?: "http" | "https" | null
}
interface AppState {
@@ -129,8 +133,31 @@ interface AppState {
update_available: boolean | null
error: string | null
checked_at: string | null
// An application installed from an OCI image: the image it runs and the
// one its registry publishes today. The image decides whether there is an
// update; the version above only says which application it carries.
installed_digest?: string | null
latest_digest?: string | null
image_created?: string | null
latest_image_created?: string | null
image_reference?: string | null
image_repository?: string | null
}
// The repository of an OCI image, shown the way the repository of a tracked
// application is: owner/name for GitHub, the image name for Docker Hub.
export const ociRepoLabel = (url: string) =>
url.replace(/^https?:\/\/(www\.)?/, "")
.replace(/^github\.com\//, "")
.replace(/^hub\.docker\.com\/(r|_)\//, "")
.replace(/\/$/, "")
// "2026-09-24 · a3f21c08": an image as a reader can compare two of them.
export const ociImageLabel = (created?: string | null, digest?: string | null) =>
[created ? created.slice(0, 10) : null, digest ? digest.split(":").pop()!.slice(0, 8) : null]
.filter(Boolean)
.join(" · ")
interface AppEntry extends AppConfig {
id: string
state?: AppState
@@ -219,10 +246,25 @@ interface DockerWebLinkSuggestion {
logo_url?: string | null
}
// Identity of a container ProxMenux installed from an OCI image. It comes
// from the installation record rather than a probe, so it names the
// application even when the guest also ships something else — CT 152 runs
// Chromium and carries a docker client the probe reported instead.
interface OciInstance {
template_id: string | null
image_reference: string | null
repository: string | null
scheme: string | null
port: number | null
path: string | null
website: string | null
}
interface Suggestions {
ready?: boolean
name_suggestion: string | null
helper_slug: string | null
oci_instance?: OciInstance | null
port_suggestions: number[]
web_path_hint: string | null
tracking_suggestion?: TrackingSuggestion | null
@@ -400,6 +442,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
const [loading, setLoading] = useState(!seed)
const [sidecar, setSidecar] = useState<SidecarResponse | null>(seed?.sidecar ?? null)
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchingApplications, setSearchingApplications] = useState(false)
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
@@ -589,14 +632,19 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
const detectedList: DetectedApp[] = useMemo(() => {
if (!suggestions) return []
const out: DetectedApp[] = []
if (suggestions.helper_slug && suggestions.name_suggestion) {
// A ProxMenux OCI install has no helper slug — nothing was installed by a
// community script — so its template id identifies it instead.
const primarySlug = suggestions.helper_slug || suggestions.oci_instance?.template_id
if (primarySlug && suggestions.name_suggestion) {
out.push({
slug: suggestions.helper_slug,
slug: primarySlug,
name: suggestions.name_suggestion,
logo_url: suggestions.logo_url,
default_ports: suggestions.default_ports,
category: suggestions.category,
tracking_suggestion: suggestions.tracking_suggestion,
scheme: suggestions.oci_instance?.scheme === "https" ? "https"
: suggestions.oci_instance?.scheme === "http" ? "http" : null,
})
}
const seen = new Set(out.map((d) => d.slug))
@@ -639,6 +687,43 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
const hiddenDetections = [...detectedList, ...(suggestions?.docker_workloads || [])]
.filter((d, index, items) => dismissedSlugs.has(d.slug) && items.findIndex(item => item.slug === d.slug) === index)
// A container installed from an OCI image holds exactly the application its
// record names, so there is nothing to search for and nothing else to add.
// What can go stale is what the record and the registry say, and this
// reads both again.
const isOciInstall = !!suggestions?.oci_instance
const isOciAdguard = suggestions?.oci_instance?.template_id === "image-adguard-home"
useEffect(() => {
if (!isOciAdguard) return
let cancelled = false
fetchApi<{ available: boolean }>(`/api/vms/${vmid}/apps/adguard-setup`)
.then((result) => { if (!cancelled) setAdguardSetupAvailable(result.available === true) })
.catch(() => { if (!cancelled) setAdguardSetupAvailable(false) })
return () => { cancelled = true }
}, [vmid, isOciAdguard])
const refreshOciData = async () => {
setSearchingApplications(true)
setDetectionNotice(null)
setError(null)
try {
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
method: "POST",
})
setSuggestions(result)
let next = sidecar
if (apps.length > 0) {
next = await fetchApi(`/api/vms/${vmid}/apps/check`, { method: "POST" })
setSidecar(next)
}
if (next) setLxcAppsCached(vmid, next, result)
onChange?.()
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.checkFailed"))
} finally {
setSearchingApplications(false)
}
}
const searchInstalledApplications = async () => {
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
setSearchingApplications(true)
@@ -756,10 +841,16 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
// Docker endpoints come from the real published host-port mappings
// listed under Web links. Do not pre-save a catalog default such as
// 9000; the user explicitly chooses which workload links to add.
if (p.slug !== "docker" && p.default_ports?.length) {
seed.ports = p.default_ports.map((port) => ({
const suggestedPorts = isOciAdguard && p.slug === "image-adguard-home"
? [80, ...(adguardSetupAvailable ? [3000] : [])]
: p.default_ports || []
if (p.slug !== "docker" && suggestedPorts.length) {
seed.ports = suggestedPorts.map((port) => ({
port,
scheme: defaultSchemeFor(port),
...(isOciAdguard && port === 3000 ? { description: "Config" } : {}),
// A recorded scheme beats the port heuristic: getting this wrong
// hands the user a link that cannot connect.
scheme: p.scheme || defaultSchemeFor(port),
web_path: s?.web_path_hint || "",
// Auto-fill Categoría from helpers_cache.category_names[0]
// when the catalog entry carries one. User can still change
@@ -822,7 +913,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
setEditing({ appId: existing?.id || null, draft: seed })
setDetectorTest(null)
setError(null)
}, [suggestions, vmid, sidecar])
}, [suggestions, vmid, sidecar, isOciAdguard, adguardSetupAvailable])
const closeEditor = () => {
setEditing(null)
@@ -1686,10 +1777,26 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
<SelectItem value="docker_exec">{t("vmLxc.appEditor.methodDockerExec")}</SelectItem>
<SelectItem value="command">{t("vmLxc.appEditor.methodCommand")}</SelectItem>
<SelectItem value="manual">{t("vmLxc.appEditor.methodManual")}</SelectItem>
{/* Only meaningful on a container ProxMenux installed
from an image: the record it reads exists nowhere else. */}
{(method === "oci_image" || suggestions?.oci_instance) && (
<SelectItem value="oci_image">{t("vmLxc.appEditor.methodOciImage")}</SelectItem>
)}
</SelectContent>
</Select>
</div>
{method === "oci_image" && (
<div className="sm:col-span-2 text-xs text-muted-foreground leading-relaxed">
{t("vmLxc.appEditor.ociImageHelp")}
{suggestions?.oci_instance?.image_reference && (
<div className="mt-1">
<code className="text-foreground/80">{suggestions.oci_instance.image_reference}</code>
</div>
)}
</div>
)}
{isPackaged && (
<div>
<Label htmlFor="app-package">{t("vmLxc.appEditor.packageIdentifierLabel")}</Label>
@@ -1892,7 +1999,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
)}
{method && (() => {
{/* The registry of the image is the upstream of an OCI
install, and the record already names it. */}
{method && method !== "oci_image" && (() => {
// Upstream source selector — 3 methods (github,
// http_json, docker_hub). Legacy sidecars with a
// `repo` set but no `upstream_type` default to
@@ -2466,17 +2575,20 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
)}
<div className="pt-1 flex flex-wrap justify-center gap-2">
<Button
onClick={searchInstalledApplications}
onClick={isOciInstall ? refreshOciData : searchInstalledApplications}
disabled={searchingApplications}
className="bg-blue-500 hover:bg-blue-600 text-white"
>
{searchingApplications
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
: <Search className="h-4 w-4 mr-1.5" />}
{searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
: isOciInstall ? <RefreshCw className="h-4 w-4 mr-1.5" /> : <Search className="h-4 w-4 mr-1.5" />}
{isOciInstall
? t(searchingApplications ? "vmLxc.appEditor.refreshingData" : "vmLxc.appEditor.refreshData")
: searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
</Button>
{!isOciInstall && (
<Button
onClick={openBrowseOrEditor}
className="bg-blue-500 hover:bg-blue-600 text-white"
@@ -2489,6 +2601,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</span>
)}
</Button>
)}
</div>
{detectionNotice && (
<p className={`text-xs text-center ${detectionNotice.found ? "text-emerald-400" : "text-muted-foreground"}`}>
@@ -2524,6 +2637,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
{app.installed_via === "apk" && app.package && <>apk · <code className="text-foreground/80">{app.package}</code></>}
{app.installed_via === "file" && app.file_path && <>file · <code className="text-foreground/80">{app.file_path}</code></>}
{app.installed_via === "binary" && app.binary_path && <>binary · <code className="text-foreground/80">{app.binary_path}</code></>}
{app.installed_via === "oci_image" && st?.image_reference && <>OCI · <code className="text-foreground/80 break-all">{st.image_reference}</code></>}
</div>
)}
{tracking && st?.checked_at && (
@@ -2535,6 +2649,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
stack below Checked, full-width so long repo
names wrap cleanly instead of competing with
the top-right on narrow screens. */}
{app.installed_via === "oci_image" && tracking && st?.image_repository && (
<a
href={st.image_repository}
target="_blank"
rel="noopener noreferrer"
className="md:hidden mt-1 text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1 min-w-0"
>
<span className="truncate">{ociRepoLabel(st.image_repository)}</span>
<ExternalLink className="h-3 w-3 flex-shrink-0" />
</a>
)}
{app.repo && tracking && (
<a
href={`https://github.com/${app.repo}`}
@@ -2551,6 +2676,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
{/* Desktop-only repo link: same row as the title on md+,
hidden on mobile where the stacked variant above
handles it. */}
{app.installed_via === "oci_image" && tracking && st?.image_repository && (
<a
href={st.image_repository}
target="_blank"
rel="noopener noreferrer"
className="hidden md:inline-flex text-xs text-muted-foreground hover:text-foreground items-center gap-1 flex-shrink-0 mt-1"
>
{ociRepoLabel(st.image_repository)}
<ExternalLink className="h-3 w-3" />
</a>
)}
{app.repo && tracking && (
<a
href={`https://github.com/${app.repo}`}
@@ -2565,6 +2701,41 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
{(() => {
// An application installed from an OCI image changes when its
// image does. Both are shown — the application version it
// carries and the image it runs — and the image decides.
if (app.installed_via === "oci_image") {
if (!tracking) return null
const newImage = st?.update_available === true
const appMoved = !!(st?.latest_version && st.latest_version !== st.installed_version)
return (
<div className="mb-3 grid gap-3 grid-cols-1 sm:grid-cols-2">
<div className="p-3 rounded-md bg-muted/40">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.ociAppVersion")}</div>
<div className="text-lg font-semibold font-mono text-foreground break-all">
{st?.installed_version || "—"}
</div>
{appMoved && (
<div className="text-xs font-mono text-purple-400 mt-1 break-all">→ {st!.latest_version}</div>
)}
</div>
<div className="p-3 rounded-md bg-muted/40">
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{t("vmLxc.appEditor.ociImage")}</div>
<div className="text-sm font-semibold font-mono text-foreground">
{ociImageLabel(st?.image_created, st?.installed_digest) || "—"}
</div>
{newImage ? (
<div className="text-sm font-semibold font-mono text-purple-400 mt-1 flex items-center gap-2">
{t("vmLxc.appEditor.ociNewImage")}: {ociImageLabel(st?.latest_image_created, st?.latest_digest)}
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
</div>
) : st?.latest_digest ? (
<div className="text-xs text-muted-foreground mt-1">{t("vmLxc.appEditor.ociImageCurrent")}</div>
) : null}
</div>
</div>
)
}
// A delegated app has no upstream of its own; the version to
// compare against comes from the image it updates with.
const delegated = app.update_via === "docker"
@@ -2614,9 +2785,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
Logo is optional (per-port `logo_url`); when absent
the row indents naturally to align with the text.
If we can't resolve an IP for the CT we hide the row. */}
{app.ports && app.ports.length > 0 && (
{(isOciAdguard ? true : !!app.ports?.length) && (
<div className="mb-3 space-y-4">
{app.ports.map((p) => {
{(isOciAdguard
? [
{ port: 80, description: app.name, scheme: "http" as const },
...(adguardSetupAvailable ? [{ port: 3000, description: "Config", scheme: "http" as const }] : []),
]
: app.ports).map((p) => {
const url = buildWebUrl(ctIp, p.port, p.scheme, p.custom_url)
if (!url) return null
const label = p.description || app.name
@@ -2766,22 +2942,27 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
<Button
variant="outline"
size="sm"
onClick={searchInstalledApplications}
onClick={isOciInstall ? refreshOciData : searchInstalledApplications}
disabled={searchingApplications || editMode}
className="min-w-[7rem] sm:min-w-0 px-2.5 sm:px-3"
aria-label={searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
aria-label={isOciInstall
? t(searchingApplications ? "vmLxc.appEditor.refreshingData" : "vmLxc.appEditor.refreshData")
: searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
>
{searchingApplications
? <Loader2 className="h-4 w-4 sm:mr-1.5 animate-spin" />
: <Search className="h-4 w-4 sm:mr-1.5" />}
: isOciInstall ? <RefreshCw className="h-4 w-4 sm:mr-1.5" /> : <Search className="h-4 w-4 sm:mr-1.5" />}
<span className="hidden sm:inline">
{searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
{isOciInstall
? t(searchingApplications ? "vmLxc.appEditor.refreshingData" : "vmLxc.appEditor.refreshData")
: searchingApplications
? t("vmLxc.appEditor.searchingApplications")
: t("vmLxc.appEditor.searchApplications")}
</span>
</Button>
{!isOciInstall && (
<Button
variant="outline"
size="sm"
@@ -2800,6 +2981,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
)}
</span>
</Button>
)}
<button
type="button"
onClick={() => setEditMode((v) => !v)}
+1 -2
View File
@@ -178,8 +178,7 @@ export function LxcTerminalModal({
// `cancelled` short-circuits the async init if the modal closes
// before the dynamic xterm import resolves. Without this, we'd
// construct a Terminal instance, attach it to a now-stale ref, and
// open a WebSocket that nobody listens to. Audit Tier 6 — useEffect
// con `import("xterm")` sin cancelación.
// open a WebSocket that nobody listens to.
let cancelled = false
// Small delay to ensure Dialog content is rendered
+2 -3
View File
@@ -665,9 +665,8 @@ function renderVertical(data: NetworkFlowData, labels: FlowLabels, unit: Network
const NIC_PATH_START_OFFSET = 46 // clears SUB_OFFSET_Y + text height
const NIC_VERTICAL_LEG = 56
const HOST_GAP_FROM_CONVERGE = 56
const GUEST_ROW_H = 100 // más separación vertical entre
// guests para que sub no toque
// el círculo del siguiente
const GUEST_ROW_H = 100 // enough vertical room that one guest's
// sub clears the next guest's circle
const BRIDGE_PITCH_PAD = 36
// Vertical positions of the label and the sub (rate) BELOW each
// node's circle. Both grew when the font went up to 12.5 px; this
+62 -89
View File
@@ -15,7 +15,7 @@ import { useT } from "../lib/i18n/provider"
import {
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
Trash2, ChevronDown, ChevronUp, ChevronRight, TestTube2, Mail, Webhook,
Trash2, ChevronRight, TestTube2, Mail, Webhook,
Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload,
Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb,
Moon, Newspaper
@@ -343,7 +343,6 @@ export function NotificationSettings() {
const [testing, setTesting] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ channel: string; success: boolean; message: string } | null>(null)
const [showHistory, setShowHistory] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({})
// Cleartext secrets cached only while the eye toggle is "on" for
// that field. Settings GET returns "************" for everything in
@@ -509,13 +508,6 @@ export function NotificationSettings() {
if (showHistory) loadHistory()
}, [showHistory, loadHistory])
// Auto-expand AI section when AI is enabled
useEffect(() => {
if (config.ai_enabled) {
setShowAdvanced(true)
}
}, [config.ai_enabled])
const updateConfig = (updater: (prev: NotificationConfig) => NotificationConfig) => {
setConfig(prev => {
const next = updater(prev)
@@ -919,13 +911,13 @@ export function NotificationSettings() {
return flat
}
const handleSave = async () => {
const saveConfig = async (nextConfig: NotificationConfig): Promise<boolean> => {
setSaving(true)
setSaveError(null)
try {
// If notifications are being disabled, clean up PVE webhook first
const wasEnabled = originalConfig.enabled
const isNowDisabled = !config.enabled
const isNowDisabled = !nextConfig.enabled
if (wasEnabled && isNowDisabled) {
try {
@@ -935,26 +927,33 @@ export function NotificationSettings() {
}
}
const payload = flattenConfig(config)
const payload = flattenConfig(nextConfig)
await fetchApi("/api/notifications/settings", {
method: "POST",
body: JSON.stringify(payload),
})
setOriginalConfig(config)
setConfig(nextConfig)
setOriginalConfig(nextConfig)
setHasChanges(false)
setEditMode(false)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
loadStatus()
return true
} catch (err) {
console.error("Failed to save notification settings:", err)
const msg = err instanceof Error ? err.message : t("settings.notifications.errors.saveFailed")
setSaveError(msg)
return false
} finally {
setSaving(false)
}
}
const handleSave = async () => {
await saveConfig(config)
}
const handleCancel = () => {
setConfig(originalConfig)
setHasChanges(false)
@@ -2226,74 +2225,45 @@ export function NotificationSettings() {
</p>
</div>
{/* ── Advanced: AI Enhancement ── */}
<div>
<div className="flex items-center justify-between py-1">
<button
className="flex items-center gap-2 text-sm text-foreground hover:bg-muted/60 rounded-md px-2 py-1.5 -mx-2 transition-colors"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<Sparkles className="h-4 w-4 text-purple-400" />
<span className="font-medium">{t("settings.notifications.ai.title")}</span>
{config.ai_enabled ? (
<Badge variant="outline" className="text-[10px] border-purple-500/40 text-purple-400 ml-1">
{t("status.active")}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] border-border text-muted-foreground ml-1">
{t("settings.notifications.ui.optional")}
</Badge>
)}
</button>
{showAdvanced && (
<div className="flex items-center gap-2">
{editMode ? (
<>
<button
className="h-6 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={handleCancel}
disabled={saving}
>
{t("actions.cancel")}
</button>
<button
className="h-6 px-2 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1"
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
{t("actions.save")}
</button>
</>
) : (
<button
className="h-6 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1"
onClick={() => setEditMode(true)}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
)}
{/* ── AI Enhancement ── */}
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between py-1">
<div className="flex items-start gap-3">
<Sparkles className="h-5 w-5 text-purple-400 mt-0.5 shrink-0" />
<div>
<span className="text-sm font-medium">{t("settings.notifications.ai.title")}</span>
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.ai.enhancedMessagesDescription")}</p>
</div>
)}
</div>
{showAdvanced && (
<div className="space-y-4 mt-3 p-4 rounded-lg bg-muted/30 border border-border/50">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<Sparkles className="h-5 w-5 text-purple-400 mt-0.5 shrink-0" />
<div>
<span className="text-sm font-medium">{t("settings.notifications.ai.enhancedMessages")}</span>
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.ai.enhancedMessagesDescription")}</p>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2">
{editMode ? (
<>
<button
className="h-7 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
onClick={handleCancel}
disabled={saving}
>
{t("actions.cancel")}
</button>
<button
className="h-7 px-2 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1"
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
{t("actions.save")}
</button>
</>
) : (
<button
className="h-7 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1"
onClick={() => setEditMode(true)}
>
<Settings2 className="h-3 w-3" />
{t("actions.edit")}
</button>
)}
<button
className={`relative w-9 h-[18px] rounded-full transition-colors ${
config.ai_enabled ? "bg-purple-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
@@ -2305,10 +2275,12 @@ export function NotificationSettings() {
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
config.ai_enabled ? "translate-x-[18px]" : "translate-x-0"
}`} />
</button>
</div>
</button>
</div>
</div>
{config.ai_enabled && (
{config.ai_enabled && (
<div className="space-y-4 p-4 rounded-lg bg-muted/30 border border-border/50">
<>
{/* Provider + Info button */}
<div className="space-y-2">
@@ -2661,10 +2633,12 @@ export function NotificationSettings() {
<Button
variant="outline"
size="sm"
onClick={() => {
updateConfig(p => ({ ...p, ai_custom_prompt: customPromptDraft }))
setEditingCustomPrompt(false)
handleSave()
onClick={async () => {
const nextConfig = { ...config, ai_custom_prompt: customPromptDraft }
if (await saveConfig(nextConfig)) {
setEditingCustomPrompt(false)
setCustomPromptDraft("")
}
}}
className="h-7 px-2 text-xs flex items-center gap-1 bg-blue-600 hover:bg-blue-700 text-white border-blue-600"
>
@@ -2714,8 +2688,8 @@ export function NotificationSettings() {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
const text = await file.text()
updateConfig(p => ({ ...p, ai_custom_prompt: text }))
handleSave()
const nextConfig = { ...config, ai_custom_prompt: text }
await saveConfig(nextConfig)
}
}
input.click()
@@ -2783,7 +2757,6 @@ export function NotificationSettings() {
)}
</div>
</>
)}
</div>
)}
</div>
@@ -0,0 +1,255 @@
"use client"
// Console output of a native OCI container: what the image's main process
// writes to stdout/stderr, kept on the host by liblxc. The first request
// brings the last N lines; while following, each poll sends back the byte
// offset and inode it holds and receives only what was appended. Nothing is
// cached — the backend reads the file on every request.
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Card, CardContent } from "./ui/card"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { AlertCircle, Download, FileText, Loader2, Pause, Play, RefreshCw } from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { useT } from "@/lib/i18n/provider"
interface ConsoleLogResponse {
ok: boolean
enabled: boolean
lines: string[]
offset: number
inode: number | null
reset?: boolean
head_truncated?: boolean
error?: string
}
const LINE_OPTIONS = [100, 500, 1000] as const
const POLL_MS = 2000
const lineTone = (line: string) => {
if (/\b(error|fatal|panic|critical|exception)\b/i.test(line)) return "text-red-400"
if (/\bwarn(ing)?\b/i.test(line)) return "text-amber-400"
return "text-foreground/90"
}
export function OciConsoleLogPanel({ vmid, running }: { vmid: number; running: boolean }) {
const t = useT()
const [lineCount, setLineCount] = useState<number>(500)
const [lines, setLines] = useState<string[]>([])
const [enabled, setEnabled] = useState(true)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [follow, setFollow] = useState(true)
const [filter, setFilter] = useState("")
const position = useRef<{ offset: number; inode: number | null } | null>(null)
const scroller = useRef<HTMLDivElement>(null)
const inFlight = useRef(false)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const r = await fetchApi<ConsoleLogResponse>(`/api/lxc/${vmid}/console-log?lines=${lineCount}`)
setEnabled(r.enabled)
setLines(r.lines || [])
position.current = { offset: r.offset, inode: r.inode }
if (r.error) setError(r.error)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [vmid, lineCount])
const poll = useCallback(async () => {
const pos = position.current
if (!pos || inFlight.current) return
inFlight.current = true
try {
const inode = pos.inode === null ? "" : `&inode=${pos.inode}`
const r = await fetchApi<ConsoleLogResponse>(
`/api/lxc/${vmid}/console-log?lines=${lineCount}&offset=${pos.offset}${inode}`,
)
position.current = { offset: r.offset, inode: r.inode }
if (r.reset) {
setLines(r.lines || [])
} else if (r.lines?.length) {
setLines((prev) => {
const next = prev.concat(r.lines)
return next.length > lineCount ? next.slice(next.length - lineCount) : next
})
}
} catch {
// A failed poll leaves the view as it is; the next one retries.
} finally {
inFlight.current = false
}
}, [vmid, lineCount])
useEffect(() => {
load()
}, [load])
useEffect(() => {
if (!follow || !running || !enabled || loading) return
const timer = setInterval(() => {
if (document.visibilityState === "visible") poll()
}, POLL_MS)
poll()
return () => clearInterval(timer)
}, [follow, running, enabled, loading, poll])
const shown = useMemo(() => {
const needle = filter.trim().toLowerCase()
return needle ? lines.filter((l) => l.toLowerCase().includes(needle)) : lines
}, [lines, filter])
useEffect(() => {
if (follow && scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight
}, [shown, follow])
// Scrolling up to read stops following; the Follow button resumes it.
const onScroll = () => {
const el = scroller.current
if (!el || !follow) return
if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false)
}
const download = () => {
const blob = new Blob([lines.join("\n") + "\n"], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `ct-${vmid}-console.log`
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="h-full flex flex-col min-h-0">
<Card className="border border-border bg-card/50 flex flex-col flex-1 min-h-0">
<CardContent className="p-4 flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-between mb-2 gap-2 flex-wrap shrink-0">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-sky-500/10">
<FileText className="h-4 w-4 text-sky-500" />
</div>
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.consoleLog.title")}</h3>
{enabled && shown.length > 0 && (
<Badge variant="secondary" className="text-xs h-5 ml-1">{shown.length}</Badge>
)}
</div>
{enabled && (
<div className="flex items-center gap-2 flex-wrap">
<Select value={String(lineCount)} onValueChange={(v) => setLineCount(Number(v))}>
<SelectTrigger className="h-7 w-auto text-xs gap-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LINE_OPTIONS.map((n) => (
<SelectItem key={n} value={String(n)} className="text-xs">
{t("vmLxc.consoleLog.lastLines", { count: n })}
</SelectItem>
))}
</SelectContent>
</Select>
{running && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1"
onClick={() => setFollow((f) => !f)}
aria-pressed={follow}
>
{follow ? <Pause className="h-3 w-3" /> : <Play className="h-3 w-3" />}
<span>{follow ? t("vmLxc.consoleLog.pause") : t("vmLxc.consoleLog.follow")}</span>
</Button>
)}
<Button size="sm" variant="outline" className="h-7 text-xs gap-1" onClick={load} disabled={loading}>
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
<span>{t("vmLxc.consoleLog.refresh")}</span>
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1"
onClick={download}
disabled={lines.length === 0}
>
<Download className="h-3 w-3" />
<span>{t("vmLxc.consoleLog.download")}</span>
</Button>
</div>
)}
</div>
<p className="text-xs text-muted-foreground mb-3 shrink-0">
{t("vmLxc.consoleLog.description")}
{enabled && !running && <> {t("vmLxc.consoleLog.stopped")}</>}
</p>
{enabled && (
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("vmLxc.consoleLog.filter")}
className="h-8 text-xs mb-3 shrink-0"
/>
)}
{loading && lines.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm">{t("vmLxc.consoleLog.loading")}</span>
</div>
) : error ? (
<div className="rounded-md border border-red-500/30 bg-red-500/5 p-4 text-sm shrink-0">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-500 mb-1">{t("vmLxc.consoleLog.readFailed")}</p>
<p className="text-xs text-muted-foreground break-all">{error}</p>
</div>
</div>
</div>
) : !enabled ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-4 text-sm shrink-0">
<div className="flex items-start gap-2">
<FileText className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<div className="space-y-2">
<p className="font-medium text-amber-500">{t("vmLxc.consoleLog.notAvailableTitle")}</p>
<p className="text-xs text-muted-foreground leading-relaxed">{t("vmLxc.consoleLog.notAvailableHint")}</p>
</div>
</div>
</div>
) : lines.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-sm text-muted-foreground min-h-0">
{t("vmLxc.consoleLog.empty")}
<div className="text-xs mt-1">{t("vmLxc.consoleLog.emptyHint")}</div>
</div>
) : shown.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground min-h-0">
{t("vmLxc.consoleLog.noMatches")}
</div>
) : (
<div
ref={scroller}
onScroll={onScroll}
className="rounded-md border border-border bg-background/50 flex-1 overflow-y-auto min-h-0"
>
<pre className="text-[11px] font-mono leading-snug whitespace-pre-wrap break-all p-3">
{shown.map((line, idx) => (
<div key={idx} className={lineTone(line)}>{line || " "}</div>
))}
</pre>
</div>
)}
</CardContent>
</Card>
</div>
)
}
+1 -2
View File
@@ -239,8 +239,7 @@ export function ProxmoxDashboard() {
fetchHealthInfoCount()
fetchUpdateStatus()
// En overview: cada 30 segundos para actualización frecuente del estado de salud
// En otras tabs: cada 60 segundos para reducir carga
// Overview polls health every 30 s; the other tabs every 60 s.
let interval: ReturnType<typeof setInterval> | null = null
let healthInterval: ReturnType<typeof setInterval> | null = null
if (activeTab === "overview") {
+23 -18
View File
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react"
import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone, Languages } from "lucide-react"
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone, Languages, Package, ScrollText, ArrowUpCircle } from "lucide-react"
import { Checkbox } from "./ui/checkbox"
import { useT } from "../lib/i18n/provider"
import { APP_VERSION } from "../lib/version"
@@ -305,34 +305,39 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
// that haven't been curated by hand.
const CURRENT_VERSION_FEATURES = [
{
icon: <Activity className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.auditAssessment",
text: "Audit & Report — a new page that documents and assesses the node. The Assessment runs a catalogue of checks across security, backups, capacity, storage, network, hardware, guests and system, classifying each finding as critical, warning, observation or conformant and stating what it read rather than judging it.",
icon: <Package className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.ociAppTab",
text: "OCI containers in the App tab — a container installed by OCI manager Apps is recognised from its installation record: the application and its image, a new image detected by digest, and a link to the image repository.",
},
{
icon: <Sliders className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.changeJournal",
text: "Change journal — a Changes view that lists exactly what ProxMenux modified on this host: every configuration file, package and service it touched, with the prior state of each, deduplicated to a current-state view that shows the host as it stands now rather than a log of every run.",
icon: <RefreshCw className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.ociUpdatesTab",
text: "Updates for OCI containers — Update and Recreate open the same flow as the OCI menu, the backup taken before updating can be kept in a backup storage, and the image can be updated on a schedule.",
},
{
icon: <Sparkles className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.auditReports",
text: "Reports — a full audit plus focused Security review, Backup assurance and Capacity & wear reports, each opening on its own posture header, and a printable Inventory; all export to PDF. A security assessment asks before running Lynis so a run stays fast.",
icon: <ScrollText className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.ociLogsTab",
text: "Logs tab for OCI containers — the console output of the application, kept on the host and followed live, with a filter and a download. The Proxmox console of these containers opens a shell.",
},
{
icon: <Calendar className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.auditPolicyBaseline",
text: "Policy and baseline — declare what the host is expected to be (backup requirements, firewall, root SSH login) so findings grade against it, mark a run as the reference, and see what changed since, with new, resolved and accepted findings kept apart.",
icon: <ArrowUpCircle className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.appsUpdateShortcut",
text: "The update icon on the Apps page opens the container straight on its Updates tab.",
},
{
icon: <Bell className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.groupedAppUpdates",
text: "Grouped application update notifications — one complete message per scan instead of one per application, grouped by LXC with the installed and available versions.",
key: "releaseNotes.currentFeatures.webhookHttps",
text: "Proxmox notifications reach the Monitor with HTTPS enabled — they are delivered on a local-only address and no longer fail with a certificate error.",
},
{
icon: <Shield className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.adminTokenScope",
text: "Administrative scope — opening a Monitor terminal and disabling authentication now require a full-admin token, so a read-only API token issued to a monitoring integration stays read-only (reported by @f3rs3n).",
icon: <Activity className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.persistentLogs",
text: "A burst of log errors that ended is no longer reported as persistent: a pattern has to keep appearing for 15 minutes, and its warning clears on its own (reported by @Joshua1264).",
},
{
icon: <HardDrive className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.mountsLanAddress",
text: "Mount points on LVM-thin and other block storage show their usage, and multi-container applications open at their LAN address.",
},
]
@@ -287,8 +287,7 @@ const initMessage = {
// Snapshot the open-state at call time. After the dynamic xterm
// imports resolve, bail out if the modal has since been closed —
// otherwise we attach a Terminal to a stale ref and open a WS that
// nobody reads. Audit Tier 6 — useEffect con `import("xterm")` sin
// cancelación.
// nobody reads.
const wasOpenAtCall = isOpenRef.current
const [TerminalClass, FitAddonClass] = await Promise.all([
import("xterm").then((mod) => mod.Terminal),
+1 -1
View File
@@ -639,7 +639,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
}
const handleKeyButton = (key: string, e?: React.MouseEvent | React.TouchEvent) => {
// Prevenir comportamientos por defecto del navegador
// Stop the browser default for this key
if (e) {
e.preventDefault()
e.stopPropagation()
+407 -11
View File
@@ -21,7 +21,8 @@ import useSWR from "swr"
import { MetricsView } from "./metrics-dialog"
import { LxcTerminalModal } from "./lxc-terminal-modal"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { LxcAppPanel, ThemeAwareLogo } from "./lxc-app-panel"
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
import { OciConsoleLogPanel } from "./oci-console-log-panel"
import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
import { formatStorage } from "../lib/utils"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
@@ -40,6 +41,17 @@ interface LxcPackageUpdate {
latest: string
security: boolean
}
// A container installed by OCI manager Apps, read from its record.
interface OciInstanceInfo {
oci_instance: boolean
console_log: boolean
stack: boolean
primary_vmid: number
members: number[]
host_directories: boolean
pending: boolean
}
interface LxcUpdateCheck {
available: boolean
count: number
@@ -114,6 +126,12 @@ interface LxcAppWatch {
error: string | null
checked_at: string | null
has_repo?: boolean
// OCI image of an application installed by OCI manager Apps.
image_reference?: string | null
image_created?: string | null
installed_digest?: string | null
latest_image_created?: string | null
latest_digest?: string | null
// Set for the synthetic entry that represents a ProxMenux-managed
// OCI app (Secure Gateway). The frontend renders it read-only +
// wires the Update action to /api/oci/installed/<id>/update.
@@ -951,7 +969,15 @@ export function VirtualMachines() {
const [backupPbsChangeMode, setBackupPbsChangeMode] = useState<string>("default")
// Tab state for modal
const [activeModalTab, setActiveModalTab] = useState<"status" | "mounts" | "backups" | "app" | "updates" | "firewall">("status")
const [activeModalTab, setActiveModalTab] = useState<"status" | "mounts" | "backups" | "app" | "updates" | "firewall" | "logs">("status")
// OCI containers keep their console output on the host; the Logs tab is
// offered only when the backend finds that log for the open container.
const [consoleLogAvailable, setConsoleLogAvailable] = useState(false)
// Set when the open container was installed by OCI manager Apps: its
// Updates tab replaces the image instead of updating packages.
const [ociInstance, setOciInstance] = useState<OciInstanceInfo | null>(null)
const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "recreate" } | null>(null)
const [scheduleAckExternal, setScheduleAckExternal] = useState(false)
// Firewall log state — fetched only when the operator opens that tab
// so a CT/VM without firewall use doesn't pay the pvesh cost on every
@@ -1094,9 +1120,10 @@ export function VirtualMachines() {
if (!vm) return
handleVMClick(vm)
// handleVMClick resets the inner tab to "status"; override to
// "app" in the same render tick — React batches these and the
// the requested tab ("app" by default, "updates" from the update
// icon) in the same render tick — React batches these and the
// last setActiveModalTab wins.
setActiveModalTab("app")
setActiveModalTab(detail.tab === "updates" ? "updates" : "app")
}
window.addEventListener("openLxcAppModal", handler as EventListener)
return () => window.removeEventListener("openLxcAppModal", handler as EventListener)
@@ -1249,6 +1276,8 @@ export function VirtualMachines() {
setFirewallLogs([])
setFirewallLogError(null)
setFirewallEnabled(true)
setConsoleLogAvailable(false)
setOciInstance(null)
// Prime UI from last-known payloads so a reopened guest never
// flashes "Loading…" — the backend and this cache both revalidate
@@ -1290,6 +1319,7 @@ export function VirtualMachines() {
// extends the same idea to the two tabs still on lazy-fetch.
if (vm.type === "lxc") {
fetchMountPoints(vm.vmid)
fetchOciInstance(vm.vmid)
// Shared cache dedup: if a hover already fired this, we share
// the same promise (no duplicate backend work). The App panel
// reads from the same cache, so switching to that tab either
@@ -1403,6 +1433,17 @@ export function VirtualMachines() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vmidsKey])
const fetchOciInstance = (vmid: number) =>
fetchApi<OciInstanceInfo>(`/api/lxc/${vmid}/oci-instance`)
.then((r) => {
setConsoleLogAvailable(!!r?.console_log)
setOciInstance(r?.oci_instance ? r : null)
})
.catch(() => {
setConsoleLogAvailable(false)
setOciInstance(null)
})
const fetchMountPoints = async (vmid: number) => {
// Two fetches in parallel:
// 1) STATIC — configured mp entries + PVE classification. Backed
@@ -1993,6 +2034,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
? s.targets.map((value: any) => String(value))
: ([...(legacyTarget !== "app" ? ["os"] : []), ...(legacyTarget !== "os" ? ["apps"] : [])]))
setScheduleReleaseDelayDays(Number.isInteger(Number(s.release_delay_days)) ? Number(s.release_delay_days) : 0)
setScheduleAckExternal(s.acknowledge_external_data === true)
if (s.backup !== undefined) setApplyBackup(!!s.backup)
if (s.backup_storage) setApplyBackupStorage(s.backup_storage)
if (s.restart !== undefined) setApplyRestart(!!s.restart)
@@ -2144,8 +2186,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// leave scheduleConfigured=false and Save PUTs cron="" so the
// backend doesn't resurrect the schedule.
const cronToSave = scheduleEnabled || scheduleConfigured ? scheduleCron : ""
const hasOsTarget = scheduleTargets.includes("os")
const hasAppTarget = scheduleTargets.some((value) => value !== "os")
// A container installed by OCI manager Apps has one thing to update on
// a schedule: its image.
const targetsToSave = ociInstance ? ["oci_image"] : scheduleTargets
const hasOsTarget = targetsToSave.includes("os")
const hasAppTarget = targetsToSave.some((value) => value !== "os")
const derivedTarget: "os" | "app" | "both" = hasOsTarget && hasAppTarget ? "both" : hasOsTarget ? "os" : "app"
try {
await fetchApi(`/api/vms/${vmid}/schedule`, {
@@ -2155,11 +2200,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
enabled: scheduleEnabled,
cron: cronToSave,
target: derivedTarget,
targets: scheduleTargets,
targets: targetsToSave,
release_delay_days: scheduleReleaseDelayDays,
backup: applyBackup,
backup_storage: applyBackupStorage || selectedBackupStorage || "",
restart: applyRestart,
restart: ociInstance ? false : applyRestart,
acknowledge_external_data: ociInstance ? scheduleAckExternal : false,
}),
})
if (cronToSave.trim()) setScheduleConfigured(true)
@@ -2181,6 +2227,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
scheduleTarget: scheduleTarget,
scheduleTargets: [...scheduleTargets],
scheduleReleaseDelayDays: scheduleReleaseDelayDays,
scheduleAckExternal: scheduleAckExternal,
})
setOptionsEditMode(true)
}
@@ -2195,6 +2242,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setScheduleTarget(optionsSnapshot.scheduleTarget)
setScheduleTargets(optionsSnapshot.scheduleTargets || ["os", "apps"])
setScheduleReleaseDelayDays(optionsSnapshot.scheduleReleaseDelayDays)
setScheduleAckExternal(!!optionsSnapshot.scheduleAckExternal)
}
setOptionsSnapshot(null)
setOptionsEditMode(false)
@@ -3761,6 +3809,24 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</Badge>
</button>
)}
{/* Logs tab — native OCI containers only: the console
output of the image's main process, read live from
the host on every open. */}
{selectedVM?.type === "lxc" && (consoleLogAvailable || selectedVM?.update_check?.is_oci_lxc) && (
<button
onClick={() => setActiveModalTab("logs")}
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
activeModalTab === "logs"
? "border-sky-500 text-sky-500"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<FileText className="h-4 w-4" />
<span className={activeModalTab === "logs" ? "" : "hidden sm:inline"}>
{t("vmLxc.tabs.logs")}
</span>
</button>
)}
<button
onClick={() => setActiveModalTab("backups")}
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px whitespace-nowrap shrink-0 ${
@@ -5130,8 +5196,299 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)
})()}
{/* Branch 1 — OCI-image container */}
{!selectedVM.update_check?.managed_oci_app &&
{/* Branch 0b — container installed by OCI manager Apps. It is
updated by replacing its image, through the same flow as
the OCI menu; nothing of the package or app updaters of an
ordinary LXC applies. */}
{!selectedVM.update_check?.managed_oci_app && ociInstance && (() => {
const ociApp = (selectedVM.app_watches || []).find((a) => a.installed_via === "oci_image")
const hasUpdate = ociApp?.update_available === true
const upToDate = ociApp?.update_available === false
const installedLabel = ociApp ? ociImageLabel(ociApp.image_created, ociApp.installed_digest) : ""
const latestLabel = ociApp ? ociImageLabel(ociApp.latest_image_created, ociApp.latest_digest) : ""
const updateBtnCls = hasUpdate
? "bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
: "bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 text-green-400 hover:text-green-300"
const neutralBtnCls = "border border-input bg-background text-foreground/80 hover:bg-accent hover:text-accent-foreground"
const storageForBackup = applyBackupStorage || selectedBackupStorage
return (
<>
<Card className="border border-border bg-card/50">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3 min-w-0">
<Package className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<h3 className="text-sm font-semibold text-foreground truncate">
{ociApp?.name || selectedVM.name}
</h3>
</div>
{ociApp?.checked_at && (
<div className="text-xs text-muted-foreground mb-3 leading-relaxed">
{t("vmLxc.updates.lastCheckedPrefix")} {new Date(ociApp.checked_at).toLocaleString()}
</div>
)}
{!ociApp ? (
<div className="text-sm text-muted-foreground">{t("vmLxc.ociUpdates.notTracked")}</div>
) : hasUpdate ? (
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span>{t("vmLxc.ociUpdates.installedImage")} <code className="text-foreground/80">{installedLabel}</code></span>
</div>
<div className="flex items-center gap-2">
<ArrowUpCircle className="h-4 w-4 text-purple-400 flex-shrink-0" />
<span>{t("vmLxc.ociUpdates.newImage", { image: latestLabel })}</span>
</div>
</div>
) : upToDate ? (
<div className="text-sm text-muted-foreground flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
<span>{t("vmLxc.updates.upToDateAtLabel")} <code className="text-foreground/80">{installedLabel}</code></span>
</div>
) : (
<div className="text-sm text-muted-foreground flex items-center gap-2">
<Package className="h-4 w-4 flex-shrink-0" />
<span>{t("vmLxc.ociUpdates.installedImage")} <code className="text-foreground/80">{installedLabel || "—"}</code></span>
</div>
)}
{ociInstance.stack && (
<div className="text-xs text-muted-foreground mt-3 leading-relaxed">
{t("vmLxc.ociUpdates.stackNote", { primary: ociInstance.primary_vmid, count: ociInstance.members.length })}
</div>
)}
{ociInstance.pending && (
<div className="text-xs text-amber-400 mt-3 leading-relaxed">{t("vmLxc.ociUpdates.pendingNote")}</div>
)}
<div className="mt-4 pt-4 border-t border-border/50 flex flex-wrap justify-end gap-2">
{!ociInstance.stack && !ociInstance.pending && (
<Button
size="sm"
className={neutralBtnCls}
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "recreate" })}
>
<RotateCcw className="h-4 w-4 mr-1.5" />
{t("vmLxc.ociUpdates.recreate")}
</Button>
)}
<Button
size="sm"
className={updateBtnCls}
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "update" })}
>
{hasUpdate
? <ArrowUpCircle className="h-4 w-4 mr-1.5" />
: <RefreshCw className="h-4 w-4 mr-1.5" />}
{ociInstance.pending ? t("vmLxc.ociUpdates.recover") : t("vmLxc.ociUpdates.update")}
</Button>
</div>
</CardContent>
</Card>
{/* Options — the backup the update keeps and the
scheduled image update. */}
<Card className={optionsEditMode ? "border border-border bg-card" : "border border-border bg-card/50"}>
<CardContent className="p-4 space-y-4">
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.options.title")}</h3>
{!optionsEditMode ? (
<div className="text-sm flex items-center gap-2">
{applyBackup ? (
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
) : (
<div className="h-4 w-4 rounded-full border border-muted-foreground/40 flex-shrink-0" />
)}
<span>
{applyBackup
? <>{t("vmLxc.ociUpdates.keepBackup")} <span className="text-muted-foreground">{t("vmLxc.options.snapshotOn", { storage: storageForBackup || t("vmLxc.options.storageAuto") })}</span></>
: <span className="text-muted-foreground">{t("vmLxc.ociUpdates.noKeepBackup")}</span>}
</span>
</div>
) : (
<div className="space-y-3">
<div className="flex items-start gap-2 text-sm">
<Checkbox
id="oci-keep-backup"
checked={applyBackup}
onCheckedChange={(v) => setApplyBackup(Boolean(v))}
className="mt-0.5"
/>
<Label htmlFor="oci-keep-backup" className="leading-tight cursor-pointer">
<span>{t("vmLxc.ociUpdates.keepBackup")}</span>
<div className="text-xs text-muted-foreground mt-0.5">{t("vmLxc.ociUpdates.keepBackupHelp")}</div>
</Label>
</div>
{applyBackup && backupStorages.length > 0 && (
<div className="pl-6">
<Label className="text-xs text-muted-foreground">{t("vmLxc.options.backupStorage")}</Label>
<Select value={storageForBackup} onValueChange={setApplyBackupStorage}>
<SelectTrigger className="h-8 text-sm mt-1 max-w-xs">
<SelectValue placeholder={t("vmLxc.options.pickStorage")} />
</SelectTrigger>
<SelectContent>
{backupStorages.map((st) => (
<SelectItem key={st.storage} value={st.storage}>{st.storage}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
<div className="pt-4 border-t border-border/50 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{t("vmLxc.scheduled.title")}</div>
{optionsEditMode && (
<div className="text-xs text-muted-foreground mt-0.5">{t("vmLxc.ociUpdates.scheduleHelp")}</div>
)}
</div>
<Switch
checked={scheduleEnabled}
onCheckedChange={(v) => { if (optionsEditMode) setScheduleEnabled(v) }}
disabled={!optionsEditMode}
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
/>
</div>
{!optionsEditMode && scheduleConfigured && (
<div className="text-sm space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<span className={"h-2 w-2 rounded-full flex-shrink-0 " + (scheduleEnabled ? "bg-green-500" : "bg-muted-foreground/40")} />
<span className={scheduleEnabled ? "" : "text-muted-foreground"}>
<span className="text-foreground/80">{t("vmLxc.scheduled.chipLabel")}</span> — {humanCron(scheduleCron)}
{!scheduleEnabled && <span className="text-muted-foreground"> {t("vmLxc.scheduled.disabledSuffix")}</span>}
</span>
</div>
{scheduleReleaseDelayDays > 0 && (
<div className="text-xs text-muted-foreground pl-4">
{t("vmLxc.scheduled.releaseDelaySummary", { days: scheduleReleaseDelayDays })}
</div>
)}
{renderScheduleRunDetails(true)}
</div>
)}
{!optionsEditMode && !scheduleConfigured && (
<div className="text-sm flex items-center gap-2 text-muted-foreground">
<div className="h-4 w-4 rounded-full border border-muted-foreground/40 flex-shrink-0" />
<span>{t("vmLxc.scheduled.notScheduled")}</span>
</div>
)}
{optionsEditMode && scheduleEnabled && (
<div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<Label className="text-xs text-muted-foreground">{t("vmLxc.scheduled.frequency")}</Label>
<Select
value={schedulePreset}
onValueChange={(v) => {
setSchedulePreset(v)
const preset = CRON_PRESETS.find((p) => p.value === v)
if (preset && preset.cron) setScheduleCron(preset.cron)
}}
>
<SelectTrigger className="h-8 text-sm mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{CRON_PRESETS.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground">{t("vmLxc.scheduled.cronExpression")}</Label>
<Input
value={scheduleCron}
onChange={(e) => { setScheduleCron(e.target.value); setSchedulePreset("custom") }}
placeholder={t("vmLxc.scheduled.cronPlaceholder")}
className="h-8 text-sm mt-1 font-mono"
/>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground">{t("vmLxc.scheduled.releaseDelayLabel")}</Label>
<Select value={String(scheduleReleaseDelayDays)} onValueChange={(v) => setScheduleReleaseDelayDays(Number(v))}>
<SelectTrigger className="h-8 text-sm mt-1 max-w-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0">{t("vmLxc.scheduled.releaseDelayNone")}</SelectItem>
{[1, 3, 7, 14].map((days) => (
<SelectItem key={days} value={String(days)}>{t("vmLxc.scheduled.releaseDelayDays", { days })}</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-[10px] text-muted-foreground mt-1 max-w-xl">{t("vmLxc.ociUpdates.releaseDelayHelp")}</div>
</div>
{ociInstance.host_directories && (
<div className="flex items-start gap-2 text-sm">
<Checkbox
id="oci-ack-external"
checked={scheduleAckExternal}
onCheckedChange={(v) => setScheduleAckExternal(Boolean(v))}
className="mt-0.5"
/>
<Label htmlFor="oci-ack-external" className="leading-tight cursor-pointer">
<span>{t("vmLxc.ociUpdates.ackExternal")}</span>
<div className="text-xs text-muted-foreground mt-0.5">{t("vmLxc.ociUpdates.ackExternalHelp")}</div>
</Label>
</div>
)}
{renderScheduleRunDetails()}
</div>
)}
{optionsEditMode && scheduleConfigured && (
<div className="flex justify-end">
<button
type="button"
onClick={deleteScheduleFromOptions}
disabled={scheduleSaving}
className="h-8 px-3 text-xs rounded-md border border-red-500/30 bg-red-500/10 hover:bg-red-500/20 text-red-400 transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
>
<Trash2 className="h-3.5 w-3.5" />
{t("vmLxc.scheduled.deleteButton")}
</button>
</div>
)}
{scheduleError && <div className="text-xs text-red-400">{scheduleError}</div>}
</div>
<div className="pt-4 border-t border-border/50 flex items-center justify-end gap-2">
{!optionsEditMode ? (
<button
type="button"
onClick={enterOptionsEdit}
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"
>
<Settings2 className="h-3.5 w-3.5" />
{t("vmLxc.options.editButton")}
</button>
) : (
<>
<button
type="button"
onClick={cancelOptionsEdit}
disabled={scheduleSaving}
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 disabled:opacity-60"
>
{t("vmLxc.options.cancelButton")}
</button>
<button
type="button"
onClick={saveOptionsEdit}
disabled={scheduleSaving || (scheduleEnabled && ociInstance.host_directories && !scheduleAckExternal)}
className="h-8 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-40 inline-flex items-center gap-1.5"
>
{scheduleSaving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
{t("vmLxc.options.saveButton")}
</button>
</>
)}
</div>
</CardContent>
</Card>
</>
)
})()}
{/* Branch 1 — OCI-image container not installed by
OCI manager Apps */}
{!selectedVM.update_check?.managed_oci_app && !ociInstance &&
selectedVM.update_check?.is_oci_lxc && (
<Card className="border border-border bg-card/50">
<CardContent className="p-4 space-y-2">
@@ -5164,7 +5521,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
Individual actions stay with their section. The
optional reusable bulk action is configured in its
own card immediately before Options. */}
{!selectedVM.update_check?.managed_oci_app &&
{!selectedVM.update_check?.managed_oci_app && !ociInstance &&
!selectedVM.update_check?.is_oci_lxc && (() => {
const uc = selectedVM.update_check
const osUpdateStatusKnown = !!uc && !uc.error
@@ -6957,6 +7314,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</Card>
</div>
)}
{activeModalTab === "logs" && selectedVM?.type === "lxc" && (
<OciConsoleLogPanel
key={selectedVM.vmid}
vmid={selectedVM.vmid}
running={selectedVM.status === "running"}
/>
)}
</div>
<div className="border-t border-border bg-background px-6 py-4 mt-auto shrink-0">
@@ -7390,6 +7755,37 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
}}
/>
)}
{/* Update or recreate an OCI instance: the same flow as OCI manager
Apps -> Manage installed OCI applications, for this container. */}
{ociAction && (
<ScriptTerminalModal
open={!!ociAction}
onClose={() => {
const vmid = ociAction.vmid
setOciAction(null)
// The image, the digest and the record changed: read them again.
fetchApi(`/api/vms/${vmid}/apps/check`, { method: "POST" })
.catch(() => undefined)
.finally(() => {
invalidateLxcApps(vmid)
vmModalCacheRef.current.mountPoints.delete(vmid)
fetchOciInstance(vmid)
fetchMountPoints(vmid)
mutate()
})
}}
scriptPath="/usr/local/share/proxmenux/scripts/oci/manage_instance.sh"
scriptName="oci_manage_instance"
title={ociAction.action === "update" ? t("vmLxc.ociUpdates.terminalTitleUpdate") : t("vmLxc.ociUpdates.terminalTitleRecreate")}
description={t("vmLxc.ociUpdates.terminalDescription")}
params={{
VMID: String(ociAction.vmid),
ACTION: ociAction.action,
KEEP_BACKUP: ociAction.action === "update" && applyBackup ? (applyBackupStorage || selectedBackupStorage || "") : "",
}}
/>
)}
</div>
)
}