mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
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:
co-authored by
Claude Opus 5.5
parent
386d33df6e
commit
4437a671d2
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user