Merge remote-tracking branch 'origin/develop' into HEAD

# Conflicts:
#	AppImage/messages/es/common.json
This commit is contained in:
MacRimi
2026-10-18 17:49:06 +02:00
1073 changed files with 135316 additions and 4690 deletions
@@ -0,0 +1,32 @@
"use client"
import { ExternalLink } from "lucide-react"
import { useT } from "../lib/i18n/provider"
// These are examples, never defaults: opening help must not replace a saved
// destination or put placeholder credentials into the notification settings.
export function AppriseServiceHelp() {
const t = useT()
return <details className="rounded-md border border-border bg-muted/20 p-2 text-xs">
<summary className="cursor-pointer font-medium">{t("settings.notifications.ui.appriseServiceHelp")}</summary>
<div className="grid gap-3 pt-3 sm:grid-cols-2">
<div className="space-y-1.5 min-w-0">
<h4 className="font-medium">Slack</h4>
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseSlackSteps")}</p>
<code className="block break-all rounded bg-background p-2 text-[10px]">https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C</code>
<a href="https://appriseit.com/services/slack/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
{t("settings.notifications.ui.appriseSlackDocs")} <ExternalLink className="h-3 w-3" />
</a>
</div>
<div className="space-y-1.5 min-w-0">
<h4 className="font-medium">Matrix</h4>
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseMatrixSteps")}</p>
<code className="block break-all rounded bg-background p-2 text-[10px]">matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com</code>
<a href="https://appriseit.com/services/matrix/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
{t("settings.notifications.ui.appriseMatrixDocs")} <ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
<p className="mt-3 text-muted-foreground">{t("settings.notifications.ui.appriseServiceFinish")}</p>
</details>
}
@@ -0,0 +1,105 @@
"use client"
import { useState } from "react"
import useSWR from "swr"
import { HardDrive, Loader2, Pencil, RefreshCw } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Checkbox } from "./ui/checkbox"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { fetchApi } from "../lib/api-config"
import { buildRetention, parseRetention, RETENTION } from "../lib/backup-retention"
import { useT } from "../lib/i18n/provider"
interface StorageRetention { id: string; type: string; prune_backups: string; digest: string }
const fetcher = (url: string) => fetchApi<{ storages: StorageRetention[] }>(url)
export function BackupStorageRetention() {
const t = useT()
const { data, error, mutate, isValidating } = useSWR("/api/vm-backup-storages", fetcher)
const [editing, setEditing] = useState<StorageRetention | null>(null)
const [fields, setFields] = useState<Record<string, string>>({})
const [keepAll, setKeepAll] = useState(false)
const [useDefault, setUseDefault] = useState(false)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState("")
const [saveError, setSaveError] = useState("")
function edit(storage: StorageRetention) {
const policy = parseRetention(storage.prune_backups)
setFields(policy.fields)
setKeepAll(policy.keepAll)
setUseDefault(!storage.prune_backups)
setSaveError("")
setMessage("")
setEditing(storage)
}
async function save() {
if (!editing || busy) return
const prune = useDefault ? "" : buildRetention(fields, keepAll)
if (!useDefault && !prune) return setSaveError(t("backup.storageRetention.choosePolicy"))
setBusy(true)
setSaveError("")
try {
await fetchApi(`/api/vm-backup-storages/${encodeURIComponent(editing.id)}`, {
method: "PUT", body: JSON.stringify({ digest: editing.digest, prune_backups: prune }),
})
setMessage(t("backup.storageRetention.saved"))
setEditing(null)
await mutate()
} catch (error) {
setSaveError(`${t("backup.storageRetention.saveFailed")} ${error instanceof Error ? error.message : ""}`)
} finally { setBusy(false) }
}
return <Card className="border-border bg-card/50">
<CardHeader className="pb-3 flex-row items-center justify-between gap-2">
<CardTitle className="text-base flex items-center gap-2"><HardDrive className="h-4 w-4" />{t("backup.storageRetention.title")}</CardTitle>
<Button variant="ghost" size="sm" disabled={isValidating} onClick={() => { void mutate().catch(() => {}) }}>
<RefreshCw className={`h-4 w-4 mr-2 ${isValidating ? "animate-spin" : ""}`} />{t("backup.storageRetention.refresh")}
</Button>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">{t("backup.storageRetention.description")}</p>
{message && <p role="status" className="text-xs text-emerald-500">{message}</p>}
{error ? <p role="alert" className="text-xs text-destructive">{t("backup.storageRetention.loadFailed")}</p> : !data ?
<Loader2 className="h-4 w-4 animate-spin" /> : !data.storages.length ?
<p className="text-xs text-muted-foreground">{t("backup.storageRetention.empty")}</p> :
data.storages.map(storage => <div key={storage.id} className="flex flex-wrap gap-2 items-center border rounded-lg p-3">
<span className="font-medium text-sm">{storage.id}</span><Badge variant="outline">{storage.type}</Badge>
<span className="text-xs font-mono text-muted-foreground break-all">{storage.prune_backups || t("backup.storageRetention.default")}</span>
<Button variant="outline" size="sm" className="ml-auto gap-1" onClick={() => edit(storage)} disabled={!storage.digest || busy}>
<Pencil className="h-3 w-3" />{t("backup.storageRetention.edit")}
</Button>
</div>)}
</CardContent>
<Dialog open={!!editing} onOpenChange={open => { if (!open && !busy) setEditing(null) }}>
<DialogContent className="bg-accent [&_input]:bg-background">
<DialogHeader><DialogTitle>{t("backup.storageRetention.editTitle", { storage: editing?.id ?? "" })}</DialogTitle>
<DialogDescription>{t("backup.storageRetention.effect")}</DialogDescription></DialogHeader>
<Label className="flex items-center gap-2"><Checkbox checked={useDefault} disabled={busy} onCheckedChange={checked => {
setUseDefault(checked === true)
if (checked) { setKeepAll(false); setFields({}) }
}} />{t("backup.storageRetention.useDefault")}</Label>
<Label className="flex items-center gap-2"><Checkbox checked={keepAll} disabled={useDefault || busy} onCheckedChange={checked => {
setKeepAll(checked === true)
if (checked) setFields({})
}} />{t("backup.storageRetention.keepAll")}</Label>
<div className="grid grid-cols-2 gap-3">
{RETENTION.map(key => <div key={key} className="space-y-1">
<Label htmlFor={`storage-retention-${key}`}>{t(`backup.retention.keep${key[0].toUpperCase()}${key.slice(1)}`)}</Label>
<Input id={`storage-retention-${key}`} type="number" min={1} max={99999} step={1} disabled={useDefault || keepAll || busy}
value={fields[key] ?? ""} onChange={event => setFields(previous => ({ ...previous, [key]: event.target.value }))} />
</div>)}
</div>
{saveError && <p role="alert" className="text-xs text-destructive break-words">{saveError}</p>}
<DialogFooter><Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>{t("backup.storageRetention.cancel")}</Button>
<Button disabled={busy} onClick={save}>{busy && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}{t("backup.storageRetention.save")}</Button></DialogFooter>
</DialogContent>
</Dialog>
</Card>
}
+7 -4
View File
@@ -7,9 +7,11 @@ import { useState } from "react"
import { DatabaseBackup } from "lucide-react"
import { HostBackup } from "./host-backup"
import { VmBackupJobs } from "./vm-backup-jobs"
import { PBSPanel } from "./pbs-panel"
import { BackupStorageRetention } from "./backup-storage-retention"
import { useT } from "../lib/i18n/provider"
type BackupView = "host" | "guests"
type BackupView = "host" | "guests" | "pbs"
export function BackupTab() {
const t = useT()
@@ -29,7 +31,7 @@ export function BackupTab() {
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
sm:inline-flex sm:w-auto"
>
{(["host", "guests"] as const).map((key) => (
{(["host", "guests", "pbs"] as const).map((key) => (
<button
key={key}
type="button"
@@ -41,7 +43,7 @@ export function BackupTab() {
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
}`}
>
{t(`backup.viewSwitch.${key}`)}
{key === "pbs" ? t("pbs.title") : t(`backup.viewSwitch.${key}`)}
</button>
))}
</div>
@@ -51,7 +53,8 @@ export function BackupTab() {
<div className={view === "host" ? "" : "hidden"}>
<HostBackup />
</div>
{view === "guests" && <VmBackupJobs />}
{view === "guests" && <><VmBackupJobs /><BackupStorageRetention /></>}
{view === "pbs" && <PBSPanel />}
</div>
)
}
+135
View File
@@ -0,0 +1,135 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Archive, Clock, Loader2, LockKeyhole, RefreshCw, Trash2 } from "lucide-react"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Checkbox } from "./ui/checkbox"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
import { useT } from "@/lib/i18n/provider"
import { deleteGuestBackups, type GuestBackup } from "../lib/guest-backup-archives"
export function GuestBackupList({ vmid, backups, loading, onRefresh, storageColor }: {
vmid: number
backups: GuestBackup[]
loading: boolean
onRefresh: () => Promise<void>
storageColor?: (storage: string) => { bg: string; text: string; border: string }
}) {
const t = useT()
const [selected, setSelected] = useState<string[]>([])
const [confirm, setConfirm] = useState(false)
const [busy, setBusy] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [message, setMessage] = useState("")
const [error, setError] = useState("")
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false }
}, [])
// A fresh list can remove a selection or reveal a newly protected archive.
useEffect(() => {
setSelected(previous => previous.filter(volid => backups.some(backup => backup.volid === volid && !backup.protected)))
}, [backups])
const chosen = backups.filter(backup => selected.includes(backup.volid) && !backup.protected)
const selectable = backups.filter(backup => !backup.protected)
const disabled = loading || busy || refreshing
const storageClass = (storage: string) => {
const color = storageColor?.(storage)
return color ? `${color.bg} ${color.text} ${color.border}` : ""
}
async function refresh() {
setRefreshing(true)
setError("")
try { await onRefresh() } catch { if (mounted.current) setError(t("vmLxc.backups.refreshFailed")) }
finally { if (mounted.current) setRefreshing(false) }
}
async function remove() {
if (!chosen.length || busy) return
setBusy(true)
setError("")
setMessage("")
try {
const results = await deleteGuestBackups(vmid, chosen)
if (!mounted.current) return
const deleted = results.filter(result => result.status === "deleted").length
const unconfirmed = results.length - deleted
setMessage(t("vmLxc.backups.deletedCount", { count: deleted }))
if (unconfirmed) setError(t("vmLxc.backups.unconfirmedCount", { count: unconfirmed }))
setSelected([])
} catch (error) {
if (mounted.current) setError(`${t("vmLxc.backups.deleteFailed")} ${error instanceof Error ? error.message : ""}`)
} finally {
// A timeout can hide a successful native task. Always bypass both caches.
try { await onRefresh() } catch { if (mounted.current) setError(t("vmLxc.backups.refreshFailed")) }
if (mounted.current) { setBusy(false); setConfirm(false) }
}
}
return <>
<div className="flex items-center justify-between gap-2 mb-3 shrink-0 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
<Badge variant="secondary" className="text-xs h-5">{backups.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" disabled={disabled} onClick={refresh}>
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{t("vmLxc.backups.refresh")}
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs gap-1" disabled={disabled || !chosen.length} onClick={() => setConfirm(true)}>
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
{t("vmLxc.backups.deleteSelected", { count: chosen.length })}
</Button>
</div>
</div>
{message && <p role="status" className="text-xs text-muted-foreground mb-2">{message}</p>}
{error && <p role="alert" className="text-xs text-destructive mb-2 break-words">{error}</p>}
{loading ? <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.backups.loading")}</span>
</div> : backups.length === 0 ? <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground min-h-0">
<Archive className="h-12 w-12 mb-3 opacity-30" /><span className="text-sm">{t("vmLxc.backups.empty")}</span>
<span className="text-xs mt-1">{t("vmLxc.backups.emptyHint")}</span>
</div> : <>
<label className="flex items-center gap-2 mb-3 text-xs text-muted-foreground">
<Checkbox checked={selectable.length > 0 && chosen.length === Math.min(20, selectable.length)} disabled={disabled || !selectable.length}
onCheckedChange={checked => setSelected(checked ? selectable.slice(0, 20).map(backup => backup.volid) : [])} />
{t("vmLxc.backups.selectHint")}
</label>
<div className="space-y-2 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
{backups.map(backup => <label key={backup.volid} className="flex items-center gap-2 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
<Checkbox aria-label={`${t("vmLxc.backups.selectBackup")} ${backup.date} ${backup.storage}`} checked={selected.includes(backup.volid)}
disabled={disabled || !!backup.protected || (chosen.length >= 20 && !selected.includes(backup.volid))}
onCheckedChange={checked => setSelected(previous => checked ? [...previous, backup.volid] : previous.filter(id => id !== backup.volid))} />
<div className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm min-w-0 truncate" title={backup.volid}>{backup.date}</span>
{backup.protected && <Badge variant="secondary" className="gap-1 text-xs"><LockKeyhole className="h-3 w-3" />{t("vmLxc.backups.protected")}</Badge>}
<Badge variant="outline" className={`text-xs ml-auto shrink-0 ${storageClass(backup.storage)}`}>{backup.storage}</Badge>
<Badge variant="outline" className="font-mono shrink-0">{backup.size_human}</Badge>
</label>)}
</div>
</>}
<Dialog open={confirm} onOpenChange={value => { if (!busy) setConfirm(value) }}>
<DialogContent onEscapeKeyDown={event => { if (busy) event.preventDefault() }} onPointerDownOutside={event => { if (busy) event.preventDefault() }}>
<DialogHeader>
<DialogTitle>{t("vmLxc.backups.confirmTitle", { count: chosen.length })}</DialogTitle>
<DialogDescription>{t("vmLxc.backups.confirmDescription", { vmid })}</DialogDescription>
</DialogHeader>
<ul className="max-h-48 overflow-auto text-xs space-y-2 break-all">
{chosen.map(backup => <li key={backup.volid}>{backup.volid}</li>)}
</ul>
{busy && <p role="status" className="text-sm text-muted-foreground">{t("vmLxc.backups.deleting")}</p>}
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={() => setConfirm(false)}>{t("vmLxc.backups.cancel")}</Button>
<Button variant="destructive" disabled={busy || !chosen.length} onClick={remove}>
{busy && <Loader2 className="h-4 w-4 animate-spin mr-2" />}{t("vmLxc.backups.confirmDelete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
}
+13 -6
View File
@@ -42,6 +42,7 @@ import {
} from "lucide-react"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { useT } from "@/lib/i18n/provider"
import { useIsPbs } from "@/lib/use-platform"
interface CategoryCheck {
status: string
@@ -119,6 +120,7 @@ const CATEGORIES = [
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
const t = useT()
const isPbs = useIsPbs()
const [loading, setLoading] = useState(true)
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
@@ -460,6 +462,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
return originalStatus?.toUpperCase() || "UNKNOWN"
}
// Checks the host reports as not applicable (guests on PBS) are not listed or counted.
const visibleCategories = CATEGORIES.filter(({ key }) =>
!(healthData?.details?.[key as keyof typeof healthData.details] as { not_applicable?: boolean } | undefined)?.not_applicable)
const categoryLabel = (key: string) => t(`healthStatus.categories.${isPbs && key === "services" ? "pbsServices" : key}`)
const getHealthStats = () => {
if (!healthData?.details) return { total: 0, healthy: 0, info: 0, warnings: 0, critical: 0, unknown: 0 }
@@ -469,7 +476,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
let critical = 0
let unknown = 0
CATEGORIES.forEach(({ key }) => {
visibleCategories.forEach(({ key }) => {
const categoryData = healthData.details[key as keyof typeof healthData.details]
if (categoryData) {
const effectiveStatus = getEffectiveStatus(key, categoryData.status)
@@ -481,7 +488,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
}
})
return { total: CATEGORIES.length, healthy, info, warnings, critical, unknown }
return { total: visibleCategories.length, healthy, info, warnings, critical, unknown }
}
const stats = getHealthStats()
@@ -759,7 +766,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{/* Category List */}
<div className="space-y-2">
{CATEGORIES.map(({ key, Icon }) => {
{visibleCategories.map(({ key, Icon }) => {
const categoryData = healthData.details[key as keyof typeof healthData.details]
const originalStatus = categoryData?.status || "UNKNOWN"
const status = getEffectiveStatus(key, originalStatus)
@@ -784,7 +791,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</div>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-1.5 sm:gap-2">
<p className="font-medium text-xs sm:text-sm truncate">{t(`healthStatus.categories.${key}`)}</p>
<p className="font-medium text-xs sm:text-sm truncate">{categoryLabel(key)}</p>
{hasChecks && (
<span className="text-[10px] text-muted-foreground shrink-0">
({Object.values(checks).filter(c => c.installed !== false).length})
@@ -871,7 +878,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{filteredDismissed.map((item) => {
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
const CatIcon = catMeta?.Icon || BellOff
const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category
const catLabel = catMeta ? categoryLabel(catMeta.key) : item.category
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
return (
@@ -936,7 +943,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<div key={cs.key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label}</span>
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? categoryLabel(catMeta.key) : cs.label}</span>
</div>
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
{durationLabel}
+107 -19
View File
@@ -19,7 +19,7 @@
* Secure Gateway.
*/
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
Loader2, Save, RefreshCw, Trash2, Package, ExternalLink,
AlertTriangle, Info, PlusCircle, Pencil, ChevronDown, ChevronRight, EyeOff,
@@ -93,6 +93,7 @@ interface AppConfig {
health_path?: string
logo_url?: string
helper_slug?: string
discovery_id?: string
// Set when the application runs inside Docker: registered for its identity
// and installed version, updated by the image it comes from.
update_via?: string
@@ -121,7 +122,18 @@ interface PortDetail {
logo_url: string
}
interface NativeDiscovery {
vmid: number
status: "not_scanned" | "complete" | "partial" | "error" | "busy"
stale: boolean
candidates: DetectedApp[]
}
interface DetectedApp {
discovery_id?: string
source?: "native_metadata"
installed_version?: string | null
evidence?: Array<{ kind: string; value: string }>
slug: string
name: string
logo_url?: string | null
@@ -462,12 +474,37 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
const [loading, setLoading] = useState(!seed)
const [sidecar, setSidecar] = useState<SidecarResponse | null>(seed?.sidecar ?? null)
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
const [nativeDiscovery, setNativeDiscovery] = useState<NativeDiscovery | null>(null)
const nativeRequest = useRef(0)
const targetRef = useRef(vmid)
targetRef.current = vmid
const targetGeneration = useRef(0)
const previousTarget = useRef(vmid)
if (previousTarget.current !== vmid) {
previousTarget.current = vmid
targetGeneration.current += 1
}
useEffect(() => {
const generation = targetGeneration.current
const request = ++nativeRequest.current
setNativeDiscovery(null)
setEditing(null)
setDetectionNotice(null)
setSearchingApplications(false)
setSaving(false)
if (managed) return
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`).then(result => {
if (targetRef.current === vmid && targetGeneration.current === generation
&& nativeRequest.current === request) setNativeDiscovery(result)
}).catch(() => { /* cached metadata is optional; never initiate a scan */ })
return () => { targetGeneration.current += 1 }
}, [vmid, managed])
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)
// Editor state
const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null)
const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig; targetVmid: number } | null>(null)
const [binaryArgsInput, setBinaryArgsInput] = useState("")
const [commandArgvInput, setCommandArgvInput] = useState("")
const [saving, setSaving] = useState(false)
@@ -522,6 +559,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
// `initialData` or the shared cache, the sidecar is already
// populated and the user shouldn't see a flash of "Loading…"
// while we revalidate.
const generation = targetGeneration.current
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
if (!sidecar) setLoading(true)
setError(null)
try {
@@ -533,14 +572,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
// the "Loading applications…" flash that used to show while a
// second, racing fetch caught up.
const bundle = await fetchLxcApps(vmid)
if (bundle) {
if (bundle && isCurrent()) {
setSidecar(bundle.sidecar)
setSuggestions(bundle.suggestions)
}
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.loadFailed"))
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.loadFailed"))
} finally {
setLoading(false)
if (isCurrent()) setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vmid, managed])
@@ -617,7 +656,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
return msg
}
const editorOpen = !!editing
const editorOpen = !!editing && editing.targetVmid === vmid
// The picker catalog is only needed after the user opens the editor.
useEffect(() => {
@@ -653,8 +692,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
// (`extras[]` from the backend). Both use the same DetectedApp
// shape so the empty state renders them uniformly.
const detectedList: DetectedApp[] = useMemo(() => {
if (!suggestions) return []
const out: DetectedApp[] = []
const out: DetectedApp[] = [...(nativeDiscovery?.vmid === vmid ? nativeDiscovery.candidates : [])]
if (!suggestions) return out
// 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
@@ -679,12 +718,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
}
}
return out
}, [suggestions])
}, [suggestions, nativeDiscovery, vmid])
// Registered slugs — used to filter the detection list down to
// what the user hasn't already registered on this CT.
const registeredSlugs = useMemo(
() => new Set(apps.map((a) => a.helper_slug).filter(Boolean) as string[]),
() => new Set(apps.flatMap((a) => [a.helper_slug, a.discovery_id]).filter(Boolean) as string[]),
[apps],
)
// Dismissed slugs — persisted in the sidecar. Chips the user
@@ -750,14 +789,32 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
}
const searchInstalledApplications = async () => {
const generation = targetGeneration.current
const request = ++nativeRequest.current
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
&& nativeRequest.current === request
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
setSearchingApplications(true)
setDetectionNotice(null)
setError(null)
try {
let native = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`, {
method: "POST",
})
if (!isCurrent()) return
setNativeDiscovery(native)
if (native.status === "error" || native.status === "busy") {
setDetectionNotice({ found: false, text: t("vmLxc.appEditor.nativeScanFailed") })
return
}
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
method: "POST",
})
if (!isCurrent()) return
// Recompose against the refreshed catalog without another native scan.
native = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`)
if (!isCurrent()) return
setNativeDiscovery(native)
setSuggestions(result)
if (sidecar) setLxcAppsCached(vmid, sidecar, result)
@@ -765,11 +822,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
if (result.helper_slug) detected.add(result.helper_slug)
for (const item of result.extras || []) detected.add(item.slug)
for (const item of result.docker_workloads || []) detected.add(item.slug)
for (const item of native.candidates) detected.add(item.slug)
const visible = [...detected].filter(
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
)
const newCount = visible.filter((slug) => !before.has(slug)).length
if (newCount === 1) {
if (native.status === "partial") {
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.nativeScanPartial") })
} else if (newCount === 1) {
setDetectionNotice({ found: true, text: t("vmLxc.appEditor.oneNewApplicationDetected") })
} else if (newCount > 1) {
setDetectionNotice({
@@ -785,9 +845,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
})
}
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.detectionFailed"))
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.detectionFailed"))
} finally {
setSearchingApplications(false)
if (isCurrent()) setSearchingApplications(false)
}
}
@@ -804,6 +864,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
existing?: AppEntry,
opts?: { withTracking?: boolean, preset?: DetectedApp },
) => {
const generation = targetGeneration.current
let seed: AppConfig
if (existing) {
seed = {
@@ -832,6 +893,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
health_path: existing.health_path || "",
logo_url: existing.logo_url || "",
helper_slug: existing.helper_slug || "",
...(existing.discovery_id ? { discovery_id: existing.discovery_id } : {}),
// Preserved explicitly: saving replaces the whole record, so dropping
// it here would silently un-delegate the app the first time someone
// edits one of its ports.
@@ -862,7 +924,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
const p = opts.preset
seed.name = p.name
seed.logo_url = p.logo_url || ""
seed.helper_slug = p.slug
if (p.discovery_id) {
seed.discovery_id = p.discovery_id
seed.update_method = "none"
seed.update_command = ""
} else seed.helper_slug = p.slug
// 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.
@@ -943,9 +1009,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
setShowAdvanced(false)
}
}
if (targetRef.current !== vmid || targetGeneration.current !== generation) return
setBinaryArgsInput((seed.binary_args || []).join(", "))
setCommandArgvInput((seed.command_argv || []).join(", "))
setEditing({ appId: existing?.id || null, draft: seed })
setEditing({ appId: existing?.id || null, draft: seed, targetVmid: vmid })
setDetectorTest(null)
setError(null)
}, [suggestions, vmid, sidecar, isOciAdguard, adguardSetupAvailable])
@@ -959,7 +1026,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
}
const save = async () => {
if (!editing) return
if (!editing || editing.targetVmid !== vmid || targetRef.current !== vmid) return
const generation = targetGeneration.current
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
setSaving(true)
setError(null)
try {
@@ -973,6 +1042,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
body: JSON.stringify(editing.draft),
})
if ((r as any).error) throw new Error((r as any).error)
if (!isCurrent()) return
setSidecar(r)
setLxcAppsCached(vmid, r, suggestions)
setEditing(null)
@@ -980,9 +1050,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
setCommandArgvInput("")
onChange?.()
} catch (e: any) {
setError(e?.message || t("vmLxc.appEditor.saveFailed"))
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.saveFailed"))
} finally {
setSaving(false)
if (isCurrent()) setSaving(false)
}
}
@@ -1283,7 +1353,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
}
// ── Editor ─────────────────────────────────────────────────────
if (editing) {
if (editorOpen && editing) {
const draft = editing.draft
const method = draft.installed_via || ""
const isPackaged = method === "dpkg" || method === "apk"
@@ -2452,6 +2522,24 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" })
: t("vmLxc.appEditor.detectedInContainer")}
</div>
{d.source === "native_metadata" && (
<div className="text-xs text-muted-foreground mt-1 break-all">
<div>{d.installed_version
? t("vmLxc.appEditor.versionDetected", { version: d.installed_version })
: t("vmLxc.appEditor.nativeVersionUnknown")}</div>
{(nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && (
<div className="text-amber-400">{t("vmLxc.appEditor.nativeEvidenceStale")}</div>
)}
{d.evidence?.map((e, index) => <div key={index}>
{t(e.kind === "tcp_listener" ? "vmLxc.appEditor.nativeEvidencePort"
: e.kind === "installation_file" ? "vmLxc.appEditor.nativeEvidenceFile"
: e.kind === "process_executable" ? "vmLxc.appEditor.nativeEvidenceProcess"
: e.kind === "working_directory" ? "vmLxc.appEditor.nativeEvidenceDirectory"
: "vmLxc.appEditor.nativeEvidenceService")}: {e.value}
</div>)}
<div>{t("vmLxc.appEditor.nativeRegisterOnly")}</div>
</div>
)}
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
<div className="text-[10px] text-amber-400/90 mt-0.5">
{t("vmLxc.appEditor.legacyDetectorUsed")}
+283
View File
@@ -0,0 +1,283 @@
"use client"
import { useEffect, useState } from "react"
import useSWR, { mutate as refreshCache } from "swr"
import { ArrowLeft, ChevronDown, DatabaseBackup, ExternalLink, Plus, Server, Trash2 } from "lucide-react"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Switch } from "./ui/switch"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface MonitorLink {
key: string
name: string
product: "pve" | "pbs"
group: "cluster" | "standalone" | "pbs"
url: string | null
status: "online" | "offline" | "unknown"
native_url?: string | null
manual: boolean
hidden: boolean
custom_url: boolean
}
interface MonitorLinks { self: { name: string; product: string }; links: MonitorLink[] }
const GROUPS = ["cluster", "standalone", "pbs"] as const
const fetcher = (url: string) => fetchApi<MonitorLinks>(url)
const ORIGIN_KEY = "proxmenux-came-from"
interface Origin { url: string; name: string; product: "pve" | "pbs" }
// The Monitor a jump came from, as the browser reached it. Only a bare http(s)
// origin is accepted, and the parameters leave the address at once.
function takeOrigin(): (Origin & { fresh?: boolean }) | null {
if (typeof window === "undefined") return null
const params = new URLSearchParams(window.location.search)
const from = params.get("pmx_from")
if (from !== null) {
const name = params.get("pmx_name") || ""
const product = params.get("pmx_product") === "pbs" ? "pbs" : "pve"
for (const key of ["pmx_from", "pmx_name", "pmx_product"]) params.delete(key)
const query = params.toString()
window.history.replaceState(null, "", window.location.pathname + (query ? `?${query}` : "") + window.location.hash)
try {
const parsed = new URL(from)
if (/^https?:$/.test(parsed.protocol) && parsed.origin === from && parsed.origin !== window.location.origin) {
const origin: Origin = { url: parsed.origin, name: (name || parsed.hostname).slice(0, 64), product }
try { sessionStorage.setItem(ORIGIN_KEY, JSON.stringify(origin)) } catch { /* private mode */ }
return { ...origin, fresh: true }
}
} catch { /* not an address */ }
}
try {
const stored = JSON.parse(sessionStorage.getItem(ORIGIN_KEY) || "null")
return stored && typeof stored.url === "string" && /^https?:\/\//.test(stored.url) ? stored : null
} catch { return null }
}
// Where a link leads: its Monitor, or the native PBS interface when no Monitor answers there.
function destination(link: MonitorLink): { href: string; native: boolean } | null {
if (link.product === "pbs" && link.status !== "online" && link.native_url) return { href: link.native_url, native: true }
return link.url ? { href: link.url, native: false } : null
}
function ProductIcon({ product, className }: { product: string; className?: string }) {
return product === "pbs" ? <DatabaseBackup className={className} /> : <Server className={className} />
}
export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: string; compact?: boolean }) {
const t = useT()
const [manageOpen, setManageOpen] = useState(false)
// The backend keeps the list ready, so the menu opens already filled.
const { data, error, isLoading } = useSWR<MonitorLinks>("/api/monitor-links", fetcher, { revalidateOnFocus: false, refreshInterval: 300000 })
const [origin, setOrigin] = useState<(Origin & { fresh?: boolean }) | null>(null)
const [offerOrigin, setOfferOrigin] = useState(false)
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState("")
useEffect(() => { setOrigin(takeOrigin()) }, [])
const originKnown = !!origin && !!data?.links.some(link => link.url === origin.url)
useEffect(() => {
// Offered once, right after arriving, when this Monitor has no way back yet.
if (origin?.fresh && data && !originKnown) setOfferOrigin(true)
}, [origin, data, originKnown])
// The address carries where the jump comes from, so the other Monitor can offer the way back.
const withOrigin = (href: string) => {
const params = new URLSearchParams({ pmx_from: window.location.origin, pmx_name: nodeName, pmx_product: data?.self.product === "pbs" ? "pbs" : "pve" })
return `${href}/?${params.toString()}`
}
const open = (href: string, newTab: boolean) => {
if (newTab) window.open(href, "_blank", "noopener,noreferrer")
else window.location.assign(href)
}
const go = (link: MonitorLink, newTab: boolean) => {
const target = destination(link)
if (!target) return
if (target.native) open(target.href, true)
else open(withOrigin(target.href), newTab)
}
const addOrigin = async () => {
if (!origin) return
setAdding(true); setAddError("")
try {
await fetchApi("/api/monitor-links", { method: "POST", body: JSON.stringify({ name: origin.name, url: origin.url, product: origin.product }) })
await refreshCache("/api/monitor-links")
setOfferOrigin(false)
} catch (err) {
setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
} finally { setAdding(false) }
}
return <div onClick={event => event.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className={`gap-2 border-border bg-transparent hover:bg-secondary ${compact ? "h-8 px-2.5 text-xs" : ""}`}>
<Server className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} />
<span className="max-w-[12rem] truncate">{t("status.node", { node: nodeName })}</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t("monitorLinks.thisNode")}</DropdownMenuLabel>
<DropdownMenuItem disabled className="gap-2 opacity-100">
<span className="h-2 w-2 rounded-full bg-green-500" />
<ProductIcon product={data?.self.product || ""} className="h-4 w-4 text-muted-foreground" />
<span className="truncate font-medium">{nodeName}</span>
</DropdownMenuItem>
{origin && !originKnown && <DropdownMenuItem className="gap-2" onSelect={() => open(withOrigin(origin.url), false)}>
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{t("monitorLinks.backTo", { name: origin.name })}</span>
</DropdownMenuItem>}
{isLoading && <p className="px-2 py-1.5 text-xs text-muted-foreground">{t("monitorLinks.checking")}</p>}
{error && <p className="px-2 py-1.5 text-xs text-amber-500">{t("monitorLinks.loadFailed")}</p>}
{data && data.links.length === 0 && <p className="px-2 py-1.5 text-xs text-muted-foreground">{t("monitorLinks.empty")}</p>}
{data && GROUPS.map(group => {
const rows = data.links.filter(link => link.group === group)
if (rows.length === 0) return null
return <div key={group}>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t(`monitorLinks.groups.${group}`)}</DropdownMenuLabel>
{rows.map(link => {
const target = destination(link)
return <DropdownMenuItem key={link.key} disabled={!target} className="gap-2" onSelect={() => go(link, false)}>
<span className={`h-2 w-2 shrink-0 rounded-full ${link.status === "online" ? "bg-green-500" : "bg-gray-500"}`} />
<ProductIcon product={link.product} className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{link.name}</span>
{target?.native
? <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.pbsInterface")}</span>
: link.status !== "online" && <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.noResponse")}</span>}
{target && <button type="button" aria-label={t("monitorLinks.openNewTab")} title={t("monitorLinks.openNewTab")}
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={event => { event.preventDefault(); event.stopPropagation(); go(link, true) }}>
<ExternalLink className="h-3.5 w-3.5" />
</button>}
</DropdownMenuItem>
})}
</div>
})}
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-2" onSelect={() => setManageOpen(true)}>
<Plus className="h-4 w-4" />{t("monitorLinks.manage")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
{origin && <Dialog open={offerOrigin} onOpenChange={setOfferOrigin}>
<DialogContent className="max-w-md" onClick={event => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>{t("monitorLinks.cameFromTitle")}</DialogTitle>
<DialogDescription>{t("monitorLinks.cameFromBody", { name: origin.name })}</DialogDescription>
</DialogHeader>
<p className="break-all rounded-md border border-border bg-muted/40 px-3 py-2 font-mono text-sm">{origin.url}</p>
{addError && <p role="alert" className="text-sm text-red-500">{addError}</p>}
<div className="flex justify-end gap-2">
<Button variant="outline" disabled={adding} onClick={() => setOfferOrigin(false)}>{t("monitorLinks.notNow")}</Button>
<Button disabled={adding} onClick={addOrigin}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
</div>
</DialogContent>
</Dialog>}
</div>
}
function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const t = useT()
const { data, mutate } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
const [form, setForm] = useState({ name: "", url: "", product: "pve" })
const [addresses, setAddresses] = useState<Record<string, string>>({})
const [busy, setBusy] = useState(false)
const [error, setError] = useState("")
const run = async (action: () => Promise<unknown>) => {
setBusy(true); setError("")
try {
await action()
await mutate()
await refreshCache("/api/monitor-links")
} catch (err) {
setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
} finally { setBusy(false) }
}
const send = (path: string, method: string, body?: unknown) =>
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
return <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto" onClick={event => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
<DialogDescription>{t("monitorLinks.dialogDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-3 rounded-lg border border-border p-4">
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
<div className="grid gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
<div className="space-y-1.5">
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label>
<Input id="monitor-link-name" value={form.name} disabled={busy} onChange={event => setForm({ ...form, name: event.target.value })} />
</div>
<div className="space-y-1.5">
<Label htmlFor="monitor-link-url">{t("monitorLinks.address")}</Label>
<Input id="monitor-link-url" value={form.url} disabled={busy} placeholder="https://192.168.1.20:8008"
onChange={event => setForm({ ...form, url: event.target.value })} />
</div>
<div className="space-y-1.5">
<Label htmlFor="monitor-link-product">{t("monitorLinks.type")}</Label>
<Select value={form.product} disabled={busy} onValueChange={product => setForm({ ...form, product })}>
<SelectTrigger id="monitor-link-product" className="w-full sm:w-56"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pve">Proxmox VE</SelectItem>
<SelectItem value="pbs">Proxmox Backup Server</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button size="sm" disabled={busy || !form.url.trim()} onClick={() => run(async () => {
await send("/api/monitor-links", "POST", form)
setForm({ name: "", url: "", product: "pve" })
})}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
</div>
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
<div className="space-y-3">
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
{data?.links.map(link => {
const address = addresses[link.key] ?? (link.manual || link.custom_url ? link.url || "" : "")
return <div key={link.key} className={`space-y-2 rounded-lg border border-border p-3 ${link.hidden ? "opacity-60" : ""}`}>
<div className="flex items-center gap-2">
<ProductIcon product={link.product} className="h-4 w-4 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium">{link.name}</span>
<span className="text-xs text-muted-foreground">{link.manual ? t("monitorLinks.added") : t("monitorLinks.detected")}</span>
{!link.manual && <label className="flex items-center gap-2 text-xs text-muted-foreground">
{t("monitorLinks.visible")}
<Switch checked={!link.hidden} disabled={busy} className="data-[state=checked]:bg-blue-600"
onCheckedChange={checked => run(() => send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { hidden: !checked }))} />
</label>}
{link.manual && <Button variant="ghost" size="sm" disabled={busy} aria-label={t("monitorLinks.remove")}
onClick={() => run(() => send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "DELETE"))}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>}
</div>
<div className="flex gap-2">
<Input value={address} disabled={busy} aria-label={t("monitorLinks.address")}
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${link.url ? `: ${link.url}` : ""}`}
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
<Button variant="outline" size="sm" className="h-10" disabled={busy || addresses[link.key] === undefined}
onClick={() => run(async () => {
await send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { url: addresses[link.key] })
setAddresses(({ [link.key]: _, ...rest }) => rest)
})}>{t("monitorLinks.save")}</Button>
</div>
</div>
})}
</div>
</DialogContent>
</Dialog>
}
+12 -4
View File
@@ -1,5 +1,6 @@
"use client"
import { AppriseServiceHelp } from "./apprise-service-help"
import { useState, useEffect, useCallback } from "react"
import { useTheme } from "next-themes"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
@@ -12,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
import { useIsPbs } from "../lib/use-platform"
import {
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
@@ -153,6 +155,8 @@ function validateGotifyUrl(url: string): { error?: string; warning?: string } {
}
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "hardware", "system", "other"].map(key => ({ key }))
// Guests, vzdump backups and the cluster do not exist on Proxmox Backup Server.
const PVE_ONLY_CATEGORIES = new Set(["vm_ct", "backup", "cluster"])
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "pushover", "apprise"] as const
@@ -329,6 +333,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
export function NotificationSettings() {
const t = useT()
const isPbs = useIsPbs()
const { resolvedTheme } = useTheme()
const [config, setConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
const [status, setStatus] = useState<ServiceStatus | null>(null)
@@ -702,7 +707,7 @@ export function NotificationSettings() {
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.notificationCategories")}</Label>
</div>
<div className="space-y-2">
{EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => {
{EVENT_CATEGORIES.filter(cat => cat.key !== "other" && !(isPbs && PVE_ONLY_CATEGORIES.has(cat.key))).map(cat => {
const isEnabled = overrides.categories[cat.key] ?? true
const isExpanded = expandedCategories.has(`${chName}.${cat.key}`)
// Hide the LXC update toggle when the user has disabled the
@@ -921,7 +926,7 @@ export function NotificationSettings() {
const wasEnabled = originalConfig.enabled
const isNowDisabled = !nextConfig.enabled
if (wasEnabled && isNowDisabled) {
if (wasEnabled && isNowDisabled && !isPbs) {
try {
await fetchApi("/api/notifications/proxmox/cleanup-webhook", { method: "POST" })
} catch {
@@ -1216,8 +1221,10 @@ export function NotificationSettings() {
setConfig(newConfig)
setOriginalConfig(newConfig)
// 2) Auto-configure PVE webhook
try {
// 2) Auto-configure PVE webhook; PBS has no /etc/pve notifications
if (isPbs) {
setWebhookSetup({ status: "idle", fallback_commands: [], error: "" })
} else try {
const setup = await fetchApi<{
configured: boolean
secret?: string
@@ -2061,6 +2068,7 @@ export function NotificationSettings() {
Enable toggle + Test button pattern as the other
channels. */}
<TabsContent value="apprise" className="space-y-3 pt-2">
<AppriseServiceHelp />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Label className="text-xs font-medium">{t("settings.notifications.ui.enableApprise")}</Label>
+112
View File
@@ -0,0 +1,112 @@
"use client"
import { CalendarClock, Database } from "lucide-react"
import { Badge } from "./ui/badge"
import { Progress } from "./ui/progress"
import { formatStorage } from "../lib/utils"
import { useT } from "../lib/i18n/provider"
export interface PBSDatastore {
name: string
path?: string
total?: number | null
used?: number | null
avail?: number | null
error?: string
"backend-type"?: string
"estimated-full-date"?: number
"gc-status"?: { upid?: string | null; "removed-bytes"?: number; "pending-bytes"?: number }
}
export function datastorePercent(store: PBSDatastore): number | null {
if (store.total == null || store.used == null || store.total <= 0) return null
return (store.used / store.total) * 100
}
// Same thresholds and colours as the PVE storage cards.
export function usageTone(percent: number): { bar: string; text: string; used: string } {
if (percent > 90) return { bar: "[&>div]:bg-red-500", text: "text-red-500", used: "text-red-400" }
if (percent > 75) return { bar: "[&>div]:bg-yellow-500", text: "text-yellow-500", used: "text-yellow-400" }
return { bar: "[&>div]:bg-blue-500", text: "text-muted-foreground", used: "text-blue-400" }
}
const gib = (bytes: number) => formatStorage(bytes / 1024 ** 3)
// A datastore without a readable capacity keeps its message: it is never shown as empty.
export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatastore; compact?: boolean }) {
const t = useT()
const percent = datastorePercent(store)
if (percent == null) {
return <div className="space-y-1">
<div className="flex items-center justify-between gap-2 text-sm">
<span className="font-medium text-foreground truncate">{store.name}</span>
<span className="text-xs text-muted-foreground">—</span>
</div>
<p className="text-xs text-amber-500">{store.error || t("pbs.capacityUnavailable")}</p>
</div>
}
const tone = usageTone(percent)
return <div className="space-y-1">
<div className="flex items-center justify-between gap-2 text-sm">
<span className="font-medium text-foreground truncate">{store.name}</span>
<span className={`text-xs font-semibold tabular-nums ${tone.text}`}>{percent.toFixed(1)}%</span>
</div>
<Progress value={percent} className={`${compact ? "h-1.5" : "h-2"} ${tone.bar}`} />
{!compact && <div className="flex justify-between text-xs text-muted-foreground tabular-nums">
<span>{gib(store.used!)} / {gib(store.total!)}</span>
<span>{t("pbs.free")}: <span className="font-semibold text-green-500">{gib(store.avail ?? store.total! - store.used!)}</span></span>
</div>}
</div>
}
// Same layout as the "Proxmox storage" cards of the PVE Monitor.
export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
const t = useT()
const percent = datastorePercent(store)
const tone = percent == null ? null : usageTone(percent)
const fullDate = store["estimated-full-date"]
const type = store["backend-type"] || "datastore"
return <div className={`border rounded-lg p-4 ${store.error ? "border-red-500/50 bg-red-500/5" : ""}`}>
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 md:gap-3 min-w-0">
<Database className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<h3 className="font-semibold text-base md:text-lg truncate">{store.name}</h3>
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20">{type}</Badge>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge className={store.error ? "bg-red-500/10 text-red-500 border-red-500/20" : "bg-green-500/10 text-green-500 border-green-500/20"}>
{store.error ? t("storage.status.error") : t("storage.status.active")}
</Badge>
{percent != null && <span className="text-sm font-medium">{percent.toFixed(2)}%</span>}
</div>
</div>
{percent != null && tone ? (
<div className="space-y-2">
<Progress value={percent} className={`h-2 ${tone.bar}`} />
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-muted-foreground">{t("storage.total")}</p>
<p className="font-medium">{gib(store.total!)}</p>
</div>
<div>
<p className="text-muted-foreground">{t("storage.used")}</p>
<p className={`font-medium ${tone.used}`}>{gib(store.used!)}</p>
</div>
<div>
<p className="text-muted-foreground">{t("storage.available")}</p>
<p className="font-medium text-green-400">{gib(store.avail ?? store.total! - store.used!)}</p>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">{store.error || t("storage.capacityNotReported")}</p>
)}
{(store.path || fullDate !== undefined) && <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{store.path && <span className="font-mono truncate" title={store.path}>{store.path}</span>}
{fullDate !== undefined && <span className="flex items-center">
<CalendarClock className="mr-1.5 h-3.5 w-3.5" />{t("pbs.estimatedFull")}:
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString() : t("pbs.estimatedFullUnknown")}</span>
</span>}
</div>}
</div>
}
+247
View File
@@ -0,0 +1,247 @@
"use client"
import { useEffect, useState } from "react"
import useSWR from "swr"
import {
Activity, ArchiveRestore, Database, DatabaseBackup, Download, ListChecks,
RefreshCw, Scissors, Server, Settings2, ShieldCheck, Terminal, Trash2,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
import { Button } from "./ui/button"
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
interface Connections { local_available: boolean; servers: Connection[] }
interface Task { upid: string; worker_type?: string; worker_id?: string; starttime?: number; endtime?: number; status?: string }
interface Job { id: string; store?: string; schedule?: string; "last-run-state"?: string }
interface PBSStatus {
name: string; version: string | null; fetched_at: number; services: Record<string, string>;
datastores: PBSDatastore[] | null; tasks: Task[] | null;
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
}
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "" }
const NEW_CONNECTION = "__new__"
const badgeTone = {
ok: "bg-green-500/10 text-green-500 border-green-500/20",
warn: "bg-amber-500/10 text-amber-500 border-amber-500/20",
error: "bg-red-500/10 text-red-500 border-red-500/20",
running: "bg-blue-500/10 text-blue-500 border-blue-500/20",
muted: "bg-muted text-muted-foreground border-border",
}
const TASK_ICONS: Record<string, typeof Activity> = {
backup: DatabaseBackup, reader: ArchiveRestore, verify: ShieldCheck, verificationjob: ShieldCheck,
verify_group: ShieldCheck, verify_snapshot: ShieldCheck, prune: Scissors, prunejob: Scissors,
garbage_collection: Trash2, sync: RefreshCw, syncjob: RefreshCw, aptupdate: Download,
termproxy: Terminal, dircreate: Database, "create-datastore": Database,
}
// PBS reports "OK", "WARNINGS: n" or the error text; a task without one is still running.
function taskState(task: Task): keyof typeof badgeTone {
if (!task.status) return task.endtime ? "muted" : "running"
if (task.status === "OK") return "ok"
if (task.status.startsWith("WARNINGS")) return "warn"
return "error"
}
function jobState(state?: string): keyof typeof badgeTone {
if (!state) return "muted"
if (state === "ok") return "ok"
if (state.toLowerCase().startsWith("warn")) return "warn"
return "error"
}
function formatDuration(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return "—"
if (seconds < 60) return `${Math.round(seconds)}s`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`
const hours = Math.floor(minutes / 60)
return hours < 24 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`
}
// `localOnly` is the PBS host's own Monitor: no connection picker, the
// header Refresh already reloads the page data.
export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
const t = useT()
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
const [selected, setSelected] = useState("")
const [manage, setManage] = useState(false)
const [form, setForm] = useState(emptyForm)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState("")
const [error, setError] = useState("")
const { data, error: statusError, isLoading, mutate } = useSWR<PBSStatus>(
selected ? `/api/pbs/status?server=${encodeURIComponent(selected)}` : null,
url => fetchApi<PBSStatus>(url), { refreshInterval: 30000, revalidateOnFocus: false })
useEffect(() => {
if (!connections) return
if (selected === "local" && connections.local_available) return
if (connections.servers.some(row => row.id === selected)) return
setSelected(connections.local_available ? "local" : connections.servers[0]?.id || "")
}, [connections, selected])
const save = async () => {
setBusy(true); setError(""); setMessage("")
try {
const result = await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
...form, id: form.id || undefined, token_secret: form.token_secret || undefined,
}) })
await reloadConnections()
setSelected(result.id)
setForm(emptyForm)
setMessage(t("pbs.saved"))
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
finally { setBusy(false) }
}
const remove = async () => {
if (!form.id || !window.confirm(t("pbs.removeConfirm"))) return
setBusy(true); setError(""); setMessage("")
try {
await fetchApi(`/api/pbs/servers/${encodeURIComponent(form.id)}`, { method: "DELETE" })
setForm(emptyForm)
await reloadConnections()
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
finally { setBusy(false) }
}
const now = Date.now() / 1000
return <div className="space-y-4 md:space-y-6">
{!localOnly && <div className="flex flex-wrap items-center gap-3">
<h2 className="flex items-center text-2xl font-semibold text-foreground"><Server className="mr-2 h-5 w-5" />{t("pbs.title")}</h2>
<Select value={selected || undefined} onValueChange={setSelected}>
<SelectTrigger aria-label={t("pbs.server")} className="w-full sm:ml-auto sm:w-56"><SelectValue placeholder={t("pbs.chooseServer")} /></SelectTrigger>
<SelectContent>
{connections?.local_available && <SelectItem value="local">{t("pbs.local")}</SelectItem>}
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="outline" size="sm" disabled={isLoading || !selected} onClick={() => mutate()}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />{t("pbs.refresh")}
</Button>
<Button variant="outline" size="sm" onClick={() => setManage(!manage)}>
<Settings2 className="mr-2 h-4 w-4" />{t("pbs.manage")}
</Button>
</div>}
{(connectionError || statusError) && <p role="alert" className="text-sm text-red-500">
{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>}
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</p>}
{!selected && !connectionError && connections && <p className="text-sm text-muted-foreground">{t("pbs.noServer")}</p>}
{manage && !localOnly && <Card>
<CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{t("pbs.permissionsHelp")}</p>
<Select value={form.id || NEW_CONNECTION} onValueChange={value => {
const row = connections?.servers.find(item => item.id === value)
setForm(row ? { ...row, token_secret: "" } : emptyForm)
}}>
<SelectTrigger aria-label={t("pbs.editConnection")} className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={NEW_CONNECTION}>{t("pbs.add")}</SelectItem>
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
</SelectContent>
</Select>
<div className="grid gap-4 md:grid-cols-2">
{(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field =>
<div key={field} className="space-y-2">
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
<Input id={`pbs-${field}`} disabled={busy} type={field === "token_secret" ? "password" : field === "url" ? "url" : "text"}
autoComplete={field === "token_secret" ? "new-password" : "off"}
value={form[field]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
</div>)}
</div>
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
{message && <p role="status" className="text-sm text-green-500">{message}</p>}
<div className="flex gap-2">
<Button disabled={busy} onClick={save}>{busy ? t("pbs.testing") : t("pbs.save")}</Button>
{form.id && <Button variant="destructive" disabled={busy} onClick={remove}>{t("pbs.remove")}</Button>}
</div>
</CardContent>
</Card>}
{data && <>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}</span>
{Object.entries(data.services).map(([service, state]) =>
<Badge key={service} variant="outline" className={state === "active" ? badgeTone.ok : badgeTone.error}>{service}: {state}</Badge>)}
</div>
{Object.entries(data.errors).map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
<Card className="bg-card border-border">
<CardHeader><CardTitle className="flex items-center text-foreground"><Database className="mr-2 h-5 w-5" />{t("pbs.datastores")}</CardTitle></CardHeader>
<CardContent>
{data.datastores?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>}
{data.datastores && data.datastores.length > 0 && <div className="space-y-4">
{data.datastores.map(store => <PBSDatastoreCard key={store.name} store={store} />)}
</div>}
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader><CardTitle className="flex items-center text-foreground"><Activity className="mr-2 h-5 w-5" />{t("pbs.tasks")}</CardTitle></CardHeader>
<CardContent>
{data.tasks?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noTasks")}</p>}
{data.tasks && data.tasks.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="py-2 pr-3 font-medium">{t("pbs.task")}</th>
<th className="py-2 pr-3 font-medium">{t("pbs.started")}</th>
<th className="py-2 pr-3 font-medium">{t("pbs.duration")}</th>
<th className="py-2 font-medium">{t("pbs.status")}</th>
</tr></thead>
<tbody>{data.tasks.map(row => {
const Icon = TASK_ICONS[row.worker_type || ""] || Activity
const state = taskState(row)
const end = row.endtime ?? (state === "running" ? now : undefined)
return <tr key={row.upid} className="border-b border-border/60 last:border-0">
<td className="py-2 pr-3"><span className="flex items-center gap-2">
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-foreground">{row.worker_type}</span>
{row.worker_id && <span className="truncate text-muted-foreground">{row.worker_id}</span>}
</span></td>
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}</td>
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatDuration(end - row.starttime) : "—"}</td>
<td className="py-2"><Badge variant="outline" className={`${badgeTone[state]} max-w-[16rem] truncate`} title={row.status}>
{state === "running" ? t("pbs.running") : row.status || "—"}
</Badge></td>
</tr>
})}</tbody>
</table></div>}
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader><CardTitle className="flex items-center text-foreground"><ListChecks className="mr-2 h-5 w-5" />{t("pbs.jobs")}</CardTitle></CardHeader>
<CardContent>
{(() => {
const jobs = (["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row => ({ kind, row })))
if (jobs.length === 0) return <p className="text-sm text-muted-foreground">{t("pbs.noJobs")}</p>
return <div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="py-2 pr-3 font-medium">{t("pbs.job")}</th>
<th className="py-2 pr-3 font-medium">{t("pbs.datastores")}</th>
<th className="py-2 pr-3 font-medium">{t("pbs.schedule")}</th>
<th className="py-2 font-medium">{t("pbs.status")}</th>
</tr></thead>
<tbody>{jobs.map(({ kind, row }) => <tr key={`${kind}-${row.id}`} className="border-b border-border/60 last:border-0">
<td className="py-2 pr-3"><span className="text-foreground">{t(`pbs.${kind}`)}</span> <span className="text-muted-foreground">· {row.id}</span></td>
<td className="py-2 pr-3 text-muted-foreground">{row.store || "—"}</td>
<td className="py-2 pr-3 font-mono text-xs text-muted-foreground">{row.schedule || "—"}</td>
<td className="py-2"><Badge variant="outline" className={badgeTone[jobState(row["last-run-state"])]}>{row["last-run-state"] || "—"}</Badge></td>
</tr>)}</tbody>
</table></div>
})()}
</CardContent>
</Card>
</>}
</div>
}
+61 -25
View File
@@ -18,6 +18,8 @@ import { Security } from "./security"
import { Profile } from "./profile"
import { About } from "./about"
import { BackupTab } from "./backup-tab"
import { PBSPanel } from "./pbs-panel"
import { MonitorSwitcher } from "./monitor-switcher"
import { OnboardingCarousel } from "./onboarding-carousel"
import { HealthStatusModal } from "./health-status-modal"
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
@@ -45,7 +47,9 @@ import {
ClipboardCheck,
Info,
DatabaseBackup,
Database,
ChevronDown,
Clock,
} from "lucide-react"
import Image from "next/image"
import { ThemeToggle } from "./theme-toggle"
@@ -97,9 +101,34 @@ const _dashboardPrefetchFetcher = (url: string) => fetchApi(url)
export function ProxmoxDashboard() {
const t = useT()
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
const { order: tabOrder } = useTabOrder()
const { data, error } = useSWR<{ product: string }>("/api/platform", url => fetchApi<{ product: string }>(url), { revalidateOnFocus: false })
if (!data && !error) return <p role="status" className="p-8">{t("app.loading")}</p>
if (data?.product === "pbs") return <PBSDashboard />
return <PVEDashboard />
}
function PVEDashboard() {
return <MonitorDashboard product="pve" />
}
function PBSDashboard() {
return <MonitorDashboard product="pbs" />
}
type Product = "pve" | "pbs"
type TopTab = TabId | "datastores"
// Proxmox Backup Server has no guests, apps or PVE storage: it keeps the
// same header, health and administration with its own top-level sections.
const PBS_TABS: TopTab[] = ["overview", "datastores", "terminal", "admin"]
function MonitorDashboard({ product }: { product: Product }) {
const t = useT()
const isPbs = product === "pbs"
useSWR(isPbs ? null : "/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
useSWR(isPbs ? null : "/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
const { order: userTabOrder } = useTabOrder()
const tabOrder: TopTab[] = isPbs ? PBS_TABS : userTabOrder
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
status: "healthy",
uptime: "Loading...",
@@ -117,8 +146,8 @@ export function ProxmoxDashboard() {
// runs post-hydration; the tiny flash is acceptable and matches the
// pattern next-themes uses for the same reason.
useEffect(() => {
setActiveTab(firstActualTab())
}, [])
setActiveTab(isPbs ? "overview" : firstActualTab())
}, [isPbs])
const [infoCount, setInfoCount] = useState(0)
const [updateAvailable, setUpdateAvailable] = useState(false)
const [showNavigation, setShowNavigation] = useState(true)
@@ -391,6 +420,7 @@ export function ProxmoxDashboard() {
const getActiveTabLabel = () => {
switch (activeTab) {
case "overview": return t("navigation.overview")
case "datastores": return t("pbs.datastores")
case "apps": return t("navigation.apps")
case "vms": return t("navigation.virtualMachines")
case "storage": return t("navigation.storage")
@@ -410,8 +440,9 @@ export function ProxmoxDashboard() {
return (
<div className="min-h-screen bg-background">
<OnboardingCarousel />
<ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />
{/* Both describe the PVE Monitor. */}
{!isPbs && <OnboardingCarousel />}
{!isPbs && <ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />}
{!isServerConnected && (
<div className="bg-red-500/10 border-b border-red-500/20 px-6 py-3">
@@ -464,22 +495,17 @@ export function ProxmoxDashboard() {
</div>
<div className="min-w-0">
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">{t("app.title")}</h1>
<p className="text-xs md:text-sm text-muted-foreground">{t("app.description")}</p>
<p className="text-xs md:text-sm text-muted-foreground">{t(isPbs ? "app.descriptionPbs" : "app.description")}</p>
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
<Server className="h-3 w-3" />
<span className="truncate">{t("status.node", { node: displayServerName })}</span>
<Clock className="h-3 w-3" />
<span className="truncate">{t("status.uptime", { uptime: displayUptime })}</span>
</div>
</div>
</div>
{/* Desktop Actions */}
<div className="hidden lg:flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Server className="h-4 w-4 text-muted-foreground" />
<div className="text-sm">
<div className="font-medium text-foreground">{t("status.node", { node: displayServerName })}</div>
</div>
</div>
<MonitorSwitcher nodeName={displayServerName} />
<div className="flex items-center gap-2">
<Badge variant="outline" className={statusColor}>
@@ -580,9 +606,7 @@ export function ProxmoxDashboard() {
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{t("status.uptime", { uptime: displayUptime })}
</span>
<MonitorSwitcher nodeName={displayServerName} compact />
</div>
</div>
</header>
@@ -623,7 +647,7 @@ export function ProxmoxDashboard() {
{ value: "audit", label: t("navigation.audit"), Icon: ClipboardCheck, default: false },
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
]
].filter(item => !isPbs || item.value !== "audit")
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
const isNodeActive = activeNodeItem !== undefined
@@ -689,9 +713,10 @@ export function ProxmoxDashboard() {
</DropdownMenuContent>
</DropdownMenu>
)
const renderTop = (id: TabId) => {
const renderTop = (id: TopTab) => {
switch (id) {
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
case "datastores": return renderDirect("datastores", Database, t("pbs.datastores"))
case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps"))
case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines"))
case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup"))
@@ -701,7 +726,7 @@ export function ProxmoxDashboard() {
}
}
return (
<TabsList className="hidden lg:grid w-full grid-cols-7 bg-card border border-border">
<TabsList className={`hidden lg:grid w-full ${isPbs ? "grid-cols-4" : "grid-cols-7"} bg-card border border-border`}>
{tabOrder.map(renderTop)}
</TabsList>
)
@@ -762,6 +787,7 @@ export function ProxmoxDashboard() {
{tabOrder.map((id): React.ReactNode => {
switch (id) {
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
case "datastores": return btn("datastores", Database, t("pbs.datastores"))
case "apps": return btn("apps", Grid3x3, t("navigation.apps"))
case "vms": return btn("vms", Boxes, t("navigation.virtualMachines"))
case "backup": return btn("backup", DatabaseBackup, t("navigation.backup"))
@@ -777,7 +803,7 @@ export function ProxmoxDashboard() {
<React.Fragment key="admin">
{btn("logs", ScrollText, t("navigation.systemLogs"))}
{btn("security", ShieldCheck, t("navigation.security"))}
{btn("audit", ClipboardCheck, t("navigation.audit"))}
{!isPbs && btn("audit", ClipboardCheck, t("navigation.audit"))}
{btn("settings", SettingsIcon, t("navigation.settings"))}
{btn("about", Info, t("navigation.about"))}
</React.Fragment>
@@ -811,9 +837,16 @@ export function ProxmoxDashboard() {
5 s / 59 s refresh intervals keep the data fresh in the
background — reopening the tab is instant. */}
<TabsContent value="overview" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
<SystemOverview key={`overview-${componentKey}`} />
<SystemOverview key={`overview-${componentKey}`} product={product} />
</TabsContent>
{isPbs && (
<TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0">
<PBSPanel key={`datastores-${componentKey}`} localOnly />
</TabsContent>
)}
{!isPbs && <>
<TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0">
<AppsDashboard key={`apps-${componentKey}`} />
</TabsContent>
@@ -839,11 +872,13 @@ export function ProxmoxDashboard() {
<TabsContent value="hardware" className="space-y-4 md:space-y-6 mt-0">
<Hardware key={`hardware-${componentKey}`} />
</TabsContent>
</>}
<TabsContent value="logs" className="space-y-4 md:space-y-6 mt-0">
<SystemLogs key={`logs-${componentKey}`} />
</TabsContent>
{!isPbs && <>
<TabsContent value="audit" className="space-y-4 md:space-y-6 mt-0">
<AuditReport key={`audit-${componentKey}`} />
</TabsContent>
@@ -851,13 +886,14 @@ export function ProxmoxDashboard() {
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
<BackupTab key={`backup-${componentKey}`} />
</TabsContent>
</>}
<TabsContent value="terminal" className="mt-0">
<TerminalPanel key={`terminal-${componentKey}`} />
</TabsContent>
<TabsContent value="security" className="space-y-4 md:space-y-6 mt-0">
<Security key={`security-${componentKey}`} />
<Security key={`security-${componentKey}`} hostType={product} />
</TabsContent>
{/* Profile tab — not surfaced in the top tabs nav. The only
+9 -4
View File
@@ -66,7 +66,7 @@ function validatePasswordStrength(pw: string, t: (key: string) => string): strin
return null
}
export function Security() {
export function Security({ hostType = "pve" }: { hostType?: "pve" | "pbs" } = {}) {
const { language, t } = useI18n()
const st = (key: string, params?: Record<string, string | number>) => t(`securityPage.${key}`, params)
const interfaceTypeLabel = (type: string) =>
@@ -292,9 +292,11 @@ export function Security() {
checkAuthStatus()
loadApiTokens()
loadSslStatus()
loadFirewallStatus()
loadNetworkInterfaces()
loadSecurityTools()
if (hostType === "pve") {
loadFirewallStatus()
loadNetworkInterfaces()
loadSecurityTools()
}
}, [])
const loadFirewallStatus = async () => {
@@ -2778,6 +2780,7 @@ ${(report.sections && report.sections.length > 0) ? `
)}
{/* ── Proxmox VE Security Group ── */}
{hostType === "pve" && <>
<div className="flex items-center gap-3 mt-4">
<h2 className="text-sm font-semibold uppercase tracking-wider text-orange-500">Proxmox VE</h2>
<div className="flex-1 h-px bg-orange-500/20" />
@@ -4833,6 +4836,8 @@ ${(report.sections && report.sections.length > 0) ? `
</div>
)}
</>}
<TwoFactorSetup
open={show2FASetup}
onClose={() => setShow2FASetup(false)}
+13 -6
View File
@@ -16,6 +16,7 @@ import { Input } from "./ui/input"
import { getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config"
import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider"
import { useIsPbs } from "../lib/use-platform"
import type { LanguageCode } from "../lib/i18n/languages"
import { NavTabOrderCard } from "./nav-tab-order-card"
@@ -312,6 +313,7 @@ interface NetworkInterface {
export function Settings() {
const { language, setLanguage, t } = useI18n()
const isPbs = useIsPbs()
const tFallback = (key: string, fallback: string) => {
const translated = t(key)
return translated === key ? fallback : translated
@@ -1124,7 +1126,7 @@ export function Settings() {
</Card>
{/* Navigation Tab Order — user-orderable top-level tabs */}
<NavTabOrderCard />
{!isPbs && <NavTabOrderCard />}
{/* Network Units Settings */}
<Card>
@@ -1280,7 +1282,7 @@ export function Settings() {
{/* Per-category rows */}
<div className="divide-y divide-border/50">
{suppressionCategories.map((cat) => {
{suppressionCategories.filter(cat => !isPbs || healthCategoryKey(cat) !== "vms").map((cat) => {
const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse
const effectiveHours = getEffectiveHours(cat)
const isCustomMode = effectiveHours === -2 || (cat.key in customValues)
@@ -1288,7 +1290,8 @@ export function Settings() {
const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2
const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours
const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key)
const catLabel = tFallback(`settings.healthMonitor.categories.${healthCategoryKey(cat)}`, cat.label)
const categoryKey = isPbs && healthCategoryKey(cat) === "services" ? "pbsServices" : healthCategoryKey(cat)
const catLabel = tFallback(`settings.healthMonitor.categories.${categoryKey}`, cat.label)
return (
<div key={cat.key}>
@@ -1496,6 +1499,7 @@ export function Settings() {
</CardContent>
</Card>
{!isPbs && <>
{/* Remote Storage Exclusions */}
<Card>
<CardHeader>
@@ -1678,6 +1682,7 @@ export function Settings() {
)}
</CardContent>
</Card>
</>}
{/* Network Interface Exclusions */}
<Card>
@@ -1875,6 +1880,7 @@ export function Settings() {
is re-enabled). */}
<LxcUpdateDetection />
{!isPbs && <>
{/* GitHub API — optional authentication for app upstream checks. */}
<Card>
<CardHeader>
@@ -1981,15 +1987,16 @@ export function Settings() {
)}
</CardContent>
</Card>
</>}
{/* Notification Settings */}
<NotificationSettings />
{/* Notification Settings (PVE only for now) */}
{!isPbs && <NotificationSettings />}
{/* Issue #195: snippets storage selector. Only renders when more
than one storage advertises content=snippets — on a typical
standalone host with just `local` there's nothing to choose,
so showing an empty selector would be noise. */}
{snippetsCandidates.length > 1 && (
{!isPbs && snippetsCandidates.length > 1 && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
+70 -10
View File
@@ -1,10 +1,12 @@
"use client"
import { useState, useEffect } from "react"
import useSWR from "swr"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Progress } from "./ui/progress"
import { Badge } from "./ui/badge"
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight } from "lucide-react"
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight, Database } from "lucide-react"
import { DatastoreUsageRow, type PBSDatastore } from "./pbs-datastore-usage"
import { NodeMetricsCharts } from "./node-metrics-charts"
import { NetworkTrafficChart } from "./network-traffic-chart"
import { TemperatureDetailModal } from "./temperature-detail-modal"
@@ -185,9 +187,17 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes"
}
export function SystemOverview() {
export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" } = {}) {
const t = useT()
const { language } = useI18n()
const isPbs = product === "pbs"
// On Proxmox Backup Server the datastores take the place of guests and PVE storage.
const { data: pbsStatus, error: pbsError } = useSWR<{ datastores: PBSDatastore[] | null }>(
isPbs ? "/api/pbs/status?server=local" : null,
(url: string) => fetchApi<{ datastores: PBSDatastore[] | null }>(url),
{ refreshInterval: 60000, revalidateOnFocus: false },
)
const datastores = pbsStatus?.datastores ?? null
const [systemData, setSystemData] = useState<SystemData | null>(null)
const [vmData, setVmData] = useState<VMData[]>([])
const [storageData, setStorageData] = useState<StorageData | null>(null)
@@ -212,8 +222,8 @@ export function SystemOverview() {
const fetchAllData = async () => {
const [systemResult, vmResult, storageResults, networkResult] = await Promise.all([
fetchSystemData().finally(() => setLoadingStates((prev) => ({ ...prev, system: false }))),
fetchVMData().finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))),
Promise.all([fetchStorageData(), fetchProxmoxStorageData()]).finally(() =>
(isPbs ? Promise.resolve([]) : fetchVMData()).finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))),
Promise.all([fetchStorageData(), isPbs ? Promise.resolve(null) : fetchProxmoxStorageData()]).finally(() =>
setLoadingStates((prev) => ({ ...prev, storage: false })),
),
fetchNetworkData().finally(() => setLoadingStates((prev) => ({ ...prev, network: false }))),
@@ -248,12 +258,13 @@ export function SystemOverview() {
}, 5000)
const vmInterval = setInterval(async () => {
if (isPbs) return
const data = await fetchVMData()
setVmData(data)
}, 59000)
const storageInterval = setInterval(async () => {
const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), fetchProxmoxStorageData()])
const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), isPbs ? Promise.resolve(null) : fetchProxmoxStorageData()])
if (storage) setStorageData(storage)
if (proxmoxStorage) setProxmoxStorageData(proxmoxStorage)
}, 59000)
@@ -278,7 +289,7 @@ export function SystemOverview() {
clearInterval(networkInterval)
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
}
}, [t])
}, [t, isPbs])
if (!hasAttemptedLoad || loadingStates.system) {
return (
@@ -527,7 +538,46 @@ export function SystemOverview() {
</CardContent>
</Card>
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
{isPbs ? (
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{t("pbs.datastores")}</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{!datastores && !pbsError ? (
<div className="space-y-2 animate-pulse">
<div className="h-10 bg-muted rounded w-20"></div>
<div className="h-5 bg-muted rounded w-32"></div>
</div>
) : !datastores ? (
<p className="text-sm text-amber-500">{t("pbs.loadFailed")}</p>
) : datastores.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>
) : (
<div className="space-y-3">
<div className="flex items-end justify-between">
<span className="text-4xl font-bold leading-none text-foreground">{datastores.length}</span>
{datastores.some(store => store.error) ? (
<Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
{t("pbs.datastoresUnavailable", { count: datastores.filter(store => store.error).length })}
</Badge>
) : (
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
{t("pbs.datastoresOnline")}
</Badge>
)}
</div>
{datastores.slice(0, 2).map(store => <DatastoreUsageRow key={store.name} store={store} compact />)}
{datastores.length > 2 && (
<p className="text-xs text-muted-foreground">{t("pbs.moreDatastores", { count: datastores.length - 2 })}</p>
)}
</div>
)}
</CardContent>
</Card>
) : (
/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.activeVmLxc")}</CardTitle>
@@ -565,6 +615,7 @@ export function SystemOverview() {
)}
</CardContent>
</Card>
)}
<Card
className={`bg-card border-border ${systemData.temperature > 0 ? "cursor-pointer hover:bg-white/5 transition-colors" : ""}`}
@@ -708,7 +759,16 @@ export function SystemOverview() {
</div>
</div>
{vmLxcStorages && vmLxcStorages.length > 0 ? (
{isPbs ? (
<div className="space-y-3">
<div className="text-xs font-medium text-muted-foreground mb-2">{t("pbs.datastores")}</div>
{!datastores ? (
<div className="text-center py-4 text-muted-foreground text-sm">{pbsError ? t("pbs.loadFailed") : t("pbs.loading")}</div>
) : datastores.length === 0 ? (
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.noDatastores")}</div>
) : datastores.map(store => <DatastoreUsageRow key={store.name} store={store} />)}
</div>
) : vmLxcStorages && vmLxcStorages.length > 0 ? (
<div className="space-y-2 pb-3 border-b border-border">
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
<div className="flex justify-between items-center">
@@ -744,7 +804,7 @@ export function SystemOverview() {
</div>
)}
{localStorage && (
{!isPbs && localStorage && (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.localStorageSystem")}</div>
<div className="flex justify-between items-center">
@@ -899,7 +959,7 @@ export function SystemOverview() {
<span className="text-foreground">{formatSystemUptime(systemData.uptime)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("overview.proxmoxVersion")}</span>
<span className="text-muted-foreground">{t(isPbs ? "overview.pbsVersion" : "overview.proxmoxVersion")}</span>
<span className="text-foreground">{systemData.proxmox_version || t("app.notAvailable")}</span>
</div>
<div className="flex justify-between">
+27 -120
View File
@@ -11,7 +11,7 @@ import { Badge } from "./ui/badge"
import { Progress } from "./ui/progress"
import { Button } from "./ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, Search, Pin, Tag as TagIcon } from 'lucide-react'
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Database, Shield, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, Search, Pin, Tag as TagIcon } from 'lucide-react'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { Checkbox } from "./ui/checkbox"
import { Switch } from "./ui/switch"
@@ -23,6 +23,8 @@ import { MetricsView } from "./metrics-dialog"
import { LxcTerminalModal } from "./lxc-terminal-modal"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
import { GuestBackupList } from "./guest-backup-list"
import type { GuestBackup } from "../lib/guest-backup-archives"
import { OciConsoleLogPanel } from "./oci-console-log-panel"
import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
import { formatStorage } from "../lib/utils"
@@ -399,16 +401,7 @@ interface BackupStorage {
avail_human?: string
}
interface VMBackup {
volid: string
storage: string
type: string
size: number
size_human: string
timestamp: number
date: string
notes?: string
}
type VMBackup = GuestBackup
// Sprint 13.29: shape returned by /api/lxc/<vmid>/mount-points. Lives
// next to VMBackup since both are LXC-modal data structures.
@@ -970,7 +963,6 @@ export function VirtualMachines() {
const [showBackupModal, setShowBackupModal] = useState(false)
const [backupMode, setBackupMode] = useState<string>("snapshot")
const [backupProtected, setBackupProtected] = useState(false)
const [backupNotification, setBackupNotification] = useState<string>("auto")
const [backupNotes, setBackupNotes] = useState<string>("{{guestname}}")
const [backupPbsChangeMode, setBackupPbsChangeMode] = useState<string>("default")
@@ -1615,7 +1607,7 @@ export function VirtualMachines() {
}
}
const fetchVmBackups = async (vmid: number) => {
const fetchVmBackups = async (vmid: number, force = false) => {
// Stale-while-revalidate with a 6-hour freshness gate.
//
// 1. If we already have backups (bulk hydration or a previous
@@ -1632,15 +1624,16 @@ export function VirtualMachines() {
// 3. Loading spinner only on the true first load.
const GATE_MS = 6 * 60 * 60 * 1000
const seed = vmModalCacheRef.current.backups.get(vmid)
const isStale = !seed || (Date.now() - seed.fetchedAt) > GATE_MS
const isStale = force || !seed || (Date.now() - seed.fetchedAt) > GATE_MS
if (!seed) setLoadingBackups(true)
try {
const url = isStale
? `/api/vms/${vmid}/backups?fresh=1`
: `/api/vms/${vmid}/backups`
const response = await fetchApi<{ backups?: VMBackup[] }>(url)
const response = await fetchApi<{ backups?: VMBackup[]; error?: string }>(url)
if (response.error || !Array.isArray(response.backups)) throw new Error(response.error || "Backup inventory unavailable")
if (response.backups) {
setVmBackups(response.backups)
if (selectedVMRef.current?.vmid === vmid) setVmBackups(response.backups)
vmModalCacheRef.current.backups.set(vmid, { backups: response.backups, fetchedAt: Date.now() })
}
} catch (error) {
@@ -1648,9 +1641,10 @@ export function VirtualMachines() {
// Only clear the visible list if we had nothing to show
// in the first place — a transient network hiccup must not
// wipe the stale-but-useful view the user is looking at.
if (!seed) setVmBackups([])
if (!seed && selectedVMRef.current?.vmid === vmid) setVmBackups([])
if (force) throw error
} finally {
if (!seed) setLoadingBackups(false)
if (!seed && selectedVMRef.current?.vmid === vmid) setLoadingBackups(false)
}
}
@@ -1703,7 +1697,6 @@ export function VirtualMachines() {
// Reset modal to defaults
setBackupMode("snapshot")
setBackupProtected(false)
setBackupNotification("auto")
setBackupNotes("{{guestname}}")
setBackupPbsChangeMode("default")
// Auto-select first storage if none selected
@@ -1727,7 +1720,6 @@ export function VirtualMachines() {
mode: backupMode,
compress: "zstd",
protected: backupProtected,
notification: backupNotification,
notes: backupNotes,
pbs_change_detection: backupPbsChangeMode
}),
@@ -1884,40 +1876,6 @@ export function VirtualMachines() {
setTerminalOpen(true)
}
const handleDownloadLogs = async (vmid: number, vmName: string) => {
try {
const data = await fetchApi(`/api/vms/${vmid}/logs`)
// Format logs as plain text
let logText = `=== ${t("vmLxc.logs.header", { name: vmName, vmid })} ===\n`
logText += `${t("vmLxc.logs.node")}: ${data.node}\n`
logText += `${t("vmLxc.logs.type")}: ${data.type}\n`
logText += `${t("vmLxc.logs.totalLines")}: ${data.log_lines}\n`
logText += `${t("vmLxc.logs.generated")}: ${new Date().toISOString()}\n`
logText += `\n${"=".repeat(80)}\n\n`
if (data.logs && Array.isArray(data.logs)) {
data.logs.forEach((log: any) => {
if (typeof log === "object" && log.t) {
logText += `${log.t}\n`
} else if (typeof log === "string") {
logText += `${log}\n`
}
})
}
const blob = new Blob([logText], { type: "text/plain" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${vmName}-${vmid}-logs.txt`
a.click()
URL.revokeObjectURL(url)
} catch (error) {
console.error("Error downloading logs:", error)
}
}
const getStatusColor = (status: string) => {
switch (status) {
case "running":
@@ -2868,8 +2826,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
onClick?: () => void,
) => {
if (!uc) return null
const stale = uc.stale === true || (!!uc.last_check && isLxcUpdateStale(uc))
if (!stale && (!uc.available || !uc.count || uc.count <= 0)) return null
if (!uc.available || !uc.count || uc.count <= 0) return null
const last = uc.last_check
? new Date(uc.last_check).toLocaleString()
: "—"
@@ -2917,8 +2874,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
space — matters most on the LXC card row where uptime,
IP and other chips compete for width. Count alone with
the up-arrow reads unambiguously as "pending updates". */}
{stale ? <Clock className={iconSize} /> : <ArrowUpCircle className={iconSize} />}
{stale ? t("vmLxc.updates.checkStale") : uc.count}
<ArrowUpCircle className={iconSize} />
{uc.count}
{/* Chevron only when the badge is wired up as a clickable
shortcut — its absence on the dashboard card avoids
implying interactivity where there isn't any (the whole
@@ -7460,7 +7417,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)}
{/* Backups Tab */}
{activeModalTab === "backups" && (
{activeModalTab === "backups" && selectedVM && (
/* Backups tab layout: the Card fills the whole tab
body so the backup list can scroll inside its
bordered card instead of leaving empty space
@@ -7495,48 +7452,17 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{/* Divider */}
<div className="border-t border-border/50 mb-4 shrink-0" />
{/* Backup List */}
<div className="flex items-center justify-between mb-3 shrink-0">
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
<Badge variant="secondary" className="text-xs h-5">{vmBackups.length}</Badge>
</div>
{loadingBackups ? (
<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.backups.loading")}</span>
</div>
) : vmBackups.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground min-h-0">
<Archive className="h-12 w-12 mb-3 opacity-30" />
<span className="text-sm">{t("vmLxc.backups.empty")}</span>
<span className="text-xs mt-1">{t("vmLxc.backups.emptyHint")}</span>
</div>
) : (
<div className="space-y-2 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
{vmBackups.map((backup, index) => (
<div
key={`backup-${backup.volid}-${index}`}
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
<Clock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-foreground">{backup.date}</span>
<Badge
variant="outline"
className={`text-xs ml-auto flex-shrink-0 ${getStorageColor(backup.storage).bg} ${getStorageColor(backup.storage).text} ${getStorageColor(backup.storage).border}`}
>
{backup.storage}
</Badge>
</div>
<Badge variant="outline" className="font-mono ml-2 flex-shrink-0">
{backup.size_human}
</Badge>
</div>
))}
</div>
)}
<GuestBackupList
key={selectedVM.vmid}
vmid={selectedVM.vmid}
backups={vmBackups}
loading={loadingBackups}
onRefresh={async () => {
vmModalCacheRef.current.backups.delete(selectedVM.vmid)
await fetchVmBackups(selectedVM.vmid, true)
}}
storageColor={getStorageColor}
/>
</CardContent>
</Card>
</div>
@@ -7889,25 +7815,6 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
</div>
{/* Notification Row */}
<div className="space-y-2">
<Label className="text-sm flex items-center gap-1.5">
<Bell className="h-3.5 w-3.5" />
{t("vmLxc.backupModal.notification")}
</Label>
<Select value={backupNotification} onValueChange={setBackupNotification}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("vmLxc.backupModal.useGlobalSettings")}</SelectItem>
<SelectItem value="always">{t("vmLxc.backupModal.alwaysNotify")}</SelectItem>
<SelectItem value="failure">{t("vmLxc.backupModal.notifyOnFailure")}</SelectItem>
<SelectItem value="never">{t("vmLxc.backupModal.neverNotify")}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Protected Checkbox */}
<div className="flex items-center space-x-2">
<Checkbox
+1 -18
View File
@@ -21,6 +21,7 @@ import {
import { fetchApi } from "../lib/api-config"
import { cancelButtonClass } from "../lib/utils"
import { useT } from "../lib/i18n/provider"
import { RETENTION, parseRetention, buildRetention } from "../lib/backup-retention"
interface VmBackupJob {
id: string
@@ -70,24 +71,6 @@ const EMPTY_FORM: FormState = {
mode: "snapshot", compress: "zstd", retention: {}, keepAll: false, notes: "{{guestname}}", attached: [],
}
// The limits a job can keep, in the order Proxmox lists them.
const RETENTION = ["last", "hourly", "daily", "weekly", "monthly", "yearly"] as const
const parseRetention = (prune: string) => {
const fields: Record<string, string> = {}
let keepAll = false
for (const part of prune.split(",")) {
const [key, value] = part.trim().split("=")
if (key === "keep-all") keepAll = value === "1"
else if (key?.startsWith("keep-") && Number(value) > 0) fields[key.slice(5)] = String(Number(value))
}
return { fields, keepAll }
}
const buildRetention = (fields: Record<string, string>, keepAll: boolean) => {
const parts = RETENTION.filter((key) => Number(fields[key]) > 0).map((key) => `keep-${key}=${Number(fields[key])}`)
return parts.length ? parts.join(",") : keepAll ? "keep-all=1" : ""
}
const isOn = (value: unknown) => value === 1 || value === true || value === "1"
const jobEnabled = (job: VmBackupJob) => job.enabled === undefined || isOn(job.enabled)
+18
View File
@@ -0,0 +1,18 @@
// Native Proxmox retention properties, shared by storage and job editors.
export const RETENTION = ["last", "hourly", "daily", "weekly", "monthly", "yearly"] as const
export function parseRetention(prune: string) {
const fields: Record<string, string> = {}
let keepAll = false
for (const part of prune.split(",")) {
const [key, value] = part.trim().split("=")
if (key === "keep-all") keepAll = value === "1"
else if (key?.startsWith("keep-") && Number(value) > 0) fields[key.slice(5)] = String(Number(value))
}
return { fields, keepAll }
}
export function buildRetention(fields: Record<string, string>, keepAll: boolean) {
const parts = RETENTION.filter(key => Number(fields[key]) > 0).map(key => `keep-${key}=${Number(fields[key])}`)
return parts.length ? parts.join(",") : keepAll ? "keep-all=1" : ""
}
+55
View File
@@ -0,0 +1,55 @@
import { fetchApi } from './api-config'
export interface GuestBackup {
volid: string
storage: string
type: string
size: number
size_human: string
timestamp: number
date: string
notes?: string
protected?: boolean
}
export interface BackupDeletionResult {
volid: string
status: 'deleted' | 'pending' | 'unconfirmed'
task?: string
error?: string
}
// Exposed separately so callers can invalidate their inventory even after a
// lost POST response. Never retry the destructive request automatically.
export async function deleteGuestBackups(vmid: number, backups: GuestBackup[]): Promise<BackupDeletionResult[]> {
const response = await fetchApi<{ results: BackupDeletionResult[] }>(`/api/vms/${vmid}/backups/delete`, {
method: 'POST',
body: JSON.stringify({ confirm: true, backups: backups.map(({ volid, storage }) => ({ volid, storage })) }),
// Up to 20 archives, checked first and then deleted four at a time.
signal: AbortSignal.timeout(300_000),
})
if (!Array.isArray(response.results) || response.results.length !== backups.length ||
response.results.some((result, index) => result.volid !== backups[index].volid ||
!['deleted', 'pending', 'unconfirmed'].includes(result.status))) throw new Error('deletionUnconfirmed')
return Promise.all(response.results.map(async result => {
if (result.status !== 'pending' || !result.task) return result
const deadline = Date.now() + 120_000
let failures = 0
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 1500))
try {
const status = await fetchApi<BackupDeletionResult>(
`/api/vms/${vmid}/backups/delete-status?task=${encodeURIComponent(result.task!)}`,
{ signal: AbortSignal.timeout(10_000) },
)
if (status.volid !== result.volid || !['deleted', 'pending', 'unconfirmed'].includes(status.status)) break
if (status.status !== 'pending') return status
failures = 0
} catch {
if (++failures >= 3) break
}
}
return { ...result, status: 'unconfirmed', error: 'deletionUnconfirmed' }
}))
}
+8
View File
@@ -0,0 +1,8 @@
import useSWR from "swr"
import { fetchApi } from "./api-config"
// Shares the dashboard's /api/platform request through the SWR cache.
export function useIsPbs(): boolean {
const { data } = useSWR<{ product: string }>("/api/platform", (url: string) => fetchApi<{ product: string }>(url), { revalidateOnFocus: false })
return data?.product === "pbs"
}
+158 -12
View File
@@ -8,7 +8,8 @@
"unknown": "Unbekannt",
"notAvailable": "N/A",
"serverOffline": "Server offline",
"supportProject": "Unterstützen Sie das Projekt und tragen Sie dazu bei"
"supportProject": "Unterstützen Sie das Projekt und tragen Sie dazu bei",
"descriptionPbs": "Proxmox Backup Server Dashboard"
},
"common": {
"notAvailable": "N/A",
@@ -165,7 +166,8 @@
},
"runningCountOne": "{count} läuft",
"runningCountFew": "{count} laufen",
"runningCountMany": "{count} laufen"
"runningCountMany": "{count} laufen",
"pbsVersion": "PBS-Version:"
},
"storage": {
"loadingTitle": "Speicherdaten werden geladen...",
@@ -1110,7 +1112,21 @@
"available": "Verfügbare Backups",
"loading": "Backups werden geladen...",
"empty": "Keine Backups gefunden",
"emptyHint": "Erstellen Sie Ihr erstes Backup über die Schaltfläche oben"
"emptyHint": "Erstellen Sie Ihr erstes Backup über die Schaltfläche oben",
"refresh": "Aktualisieren",
"refreshFailed": "Sicherungen konnten nicht aktualisiert werden. Aktualisieren Sie erneut, um ihren aktuellen Zustand zu bestätigen.",
"deleteSelected": "Ausgewählte löschen ({count})",
"deletedCount": "{count} Sicherung(en) gelöscht.",
"unconfirmedCount": "Die Löschung von {count} Sicherung(en) wurde nicht bestätigt. Aktualisieren Sie die Liste und prüfen Sie die Proxmox-Aufgaben, bevor Sie es erneut versuchen.",
"deleteFailed": "Die Löschung konnte nicht bestätigt werden. Aktualisieren Sie die Liste und prüfen Sie die Proxmox-Aufgaben, bevor Sie es erneut versuchen.",
"selectHint": "Ungeschützte Sicherungen auswählen (bis zu 20 pro Durchgang)",
"selectBackup": "Sicherung auswählen",
"protected": "Geschützt",
"confirmTitle": "{count} Sicherung(en) löschen?",
"confirmDescription": "Diese Sicherungsarchive für VM/CT {vmid} werden dauerhaft gelöscht. Geschützte Sicherungen sind ausgeschlossen. Der Gast wird dadurch nicht gelöscht.",
"deleting": "Warten, bis Proxmox die ausgewählten Archive gelöscht hat…",
"cancel": "Abbrechen",
"confirmDelete": "Sicherungen löschen"
},
"updatesPanel": {
"pendingTitle": "Ausstehende Paketaktualisierungen",
@@ -1721,7 +1737,17 @@
"portCategoryCustomPlaceholder": "Geben Sie eine Kategorie ein und drücken Sie Enter (Esc zum Abbrechen)",
"portCustomUrlPlaceholder": "Benutzerdefinierte URL (z. B. https://vault.example.com) überschreibt IP:port",
"stackMemberTitle": "Teil einer Multi-Container-Anwendung",
"stackMemberBody": "Dieser Container gehört zu einer Anwendung, deren Hauptcontainer {primary} ist. Die Anwendung, ihre Weblinks und ihre Version werden dort angezeigt."
"stackMemberBody": "Dieser Container gehört zu einer Anwendung, deren Hauptcontainer {primary} ist. Die Anwendung, ihre Weblinks und ihre Version werden dort angezeigt.",
"nativeEvidencePort": "Lauschender TCP-Port",
"nativeEvidenceFile": "Installierte Datei",
"nativeEvidenceProcess": "Laufende ausführbare Datei",
"nativeEvidenceDirectory": "Arbeitsverzeichnis",
"nativeEvidenceService": "Dienstdefinition",
"nativeScanFailed": "Die Anwendungssuche konnte nicht abgeschlossen werden. Frühere Ergebnisse sind möglicherweise veraltet; versuchen Sie es erneut.",
"nativeScanPartial": "Die Anwendungssuche ist unvollständig. Prüfen Sie die gefundenen Hinweise und versuchen Sie es erneut.",
"nativeVersionUnknown": "Installierte Version unbekannt",
"nativeEvidenceStale": "Die Hinweise sind möglicherweise veraltet oder unvollständig.",
"nativeRegisterOnly": "Bestätigen Sie die Registrierung im Editor. Eine bekannte Version wird als manueller Wert gespeichert; es wird kein Updater eingerichtet."
},
"statusFilter": {
"ariaLabel": "Virtuelle Maschinen und Container filtern",
@@ -1817,7 +1843,8 @@
"logs": "Systemprotokolle",
"updates": "Systemaktualisierungen",
"security": "Sicherheit & Zertifikate",
"temperature": "Temperatur"
"temperature": "Temperatur",
"pbsServices": "PBS Services"
}
},
"remoteStorage": {
@@ -2078,7 +2105,7 @@
"webhookUrl": "Webhook-URL",
"enableEmail": "E-Mail aktivieren",
"smtpHost": "SMTP-Host",
"port": "Hafen",
"port": "Port",
"tlsMode": "TLS-Modus",
"noTls": "Keine (Port 25)",
"username": "Benutzername",
@@ -2111,7 +2138,13 @@
"inactiveFooter": "Aktivieren Sie Benachrichtigungen, um System-, Gesundheits- und Sicherheitswarnungen über die konfigurierten Kanäle zu erhalten.",
"notificationLanguage": "Benachrichtigungssprache",
"selectNotificationLanguage": "Benachrichtigungssprache auswählen",
"notificationLanguageHint": "Wird für deterministische Laufzeitbenachrichtigungen verwendet, auch wenn KI deaktiviert ist."
"notificationLanguageHint": "Wird für deterministische Laufzeitbenachrichtigungen verwendet, auch wenn KI deaktiviert ist.",
"appriseServiceHelp": "Slack und Matrix einrichten",
"appriseSlackSteps": "Erstellen Sie in Ihrer Slack-App einen eingehenden Webhook für den Zielkanal und fügen Sie dessen vollständige HTTPS-URL in das Feld Apprise-URL ein. Eine Umwandlung ist nicht erforderlich.",
"appriseSlackDocs": "Slack-URL-Formate und Einrichtung",
"appriseMatrixSteps": "Verwenden Sie ein Benachrichtigungskonto, das in den Raum schreiben darf. Ersetzen Sie den Beispielbenutzer, das Passwort, den Homeserver und die vollständige Raum-ID; kodieren Sie Sonderzeichen in Zugangsdaten für URLs. Das Schema matrixs verwendet HTTPS. Informationen zur Unterstützung von Tokens, SSO und verschlüsselten Räumen in Ihrer installierten Apprise-Version finden Sie in der Dokumentation.",
"appriseMatrixDocs": "Matrix-URL-Formate und Einrichtung",
"appriseServiceFinish": "Aktivieren Sie Apprise, geben Sie Ihre URL ein, wählen Sie Benachrichtigungskategorien oder einzelne Ereignisse aus, speichern Sie und verwenden Sie anschließend Test senden. Diese Beispiele ändern Ihre gespeicherte URL nicht. Dieser Apprise-Kanal hat ein Ziel; verwenden Sie die Apprise-API für mehrere Dienste gleichzeitig."
},
"ai": {
"title": "KI-Verbesserung",
@@ -2545,9 +2578,9 @@
"twoFactor": {
"title": "Zwei-Faktor-Authentifizierung",
"hint": "Fügen Sie nach dem Passwort einen Einmalcode aus einer Authentifizierungs-App hinzu.",
"enable": "Aktivieren Sie 2FA",
"enable": "2FA aktivieren",
"enabled": "2FA ist aktiviert.",
"disable": "Deaktivieren Sie 2FA",
"disable": "2FA deaktivieren",
"disableTitle": "Deaktivieren Sie die Zwei-Faktor-Authentifizierung",
"disableDescription": "Bestätigen Sie mit Ihrem Passwort und einem aktuellen 2FA-Code oder Backup-Code.",
"codeOrBackup": "2FA-Code oder Backup-Code",
@@ -2690,7 +2723,7 @@
"interfaceOptional": "Schnittstelle (optional)",
"applyTo": "Gelten",
"commentOptional": "Kommentar (optional)",
"port": "Hafen",
"port": "Port",
"source": "Quelle",
"level": "Ebene",
"interface": "Schnittstelle",
@@ -2982,7 +3015,7 @@
"needQrOrKey": "Ein QR-Code oder manueller Schlüssel aus diesem Setup.",
"needBackupCodes": "Backup-Codes werden an einem sicheren Ort gespeichert.",
"starting": "Beginnt...",
"startSetup": "Starten Sie die Einrichtung",
"startSetup": "Einrichtung starten",
"scanTitle": "Scannen Sie den QR-Code",
"scanDescription": "Öffnen Sie Ihre Authentifizierungs-App und scannen Sie diesen Code.",
"qrCodeAlt": "2FA QR-Code",
@@ -4756,6 +4789,24 @@
"thursday": "Donnerstag",
"tuesday": "Dienstag",
"wednesday": "Mittwoch"
},
"storageRetention": {
"title": "Aufbewahrung im Speicher",
"keepAll": "Alle Sicherungen behalten",
"description": "In Proxmox gespeicherte Standardaufbewahrung für Sicherungen. Sicherungsaufträge mit einer eigenen Richtlinie überschreiben diesen Standard.",
"refresh": "Aktualisieren",
"saved": "Aufbewahrungsrichtlinie des Speichers gespeichert. Es wurde keine Bereinigung ausgeführt.",
"choosePolicy": "Wählen Sie Alle behalten oder mindestens eine Aufbewahrungsanzahl größer als null.",
"saveFailed": "Aufbewahrungsrichtlinie des Speichers konnte nicht gespeichert werden. Aktualisieren Sie die Liste, falls die Konfiguration geändert wurde.",
"loadFailed": "Aufbewahrungsrichtlinie des Speichers konnte nicht geladen werden.",
"empty": "Keine Sicherungsspeicher gefunden.",
"default": "Proxmox-Standard",
"edit": "Bearbeiten",
"editTitle": "Aufbewahrung für {storage}",
"effect": "Gilt für zukünftige Sicherungsläufe, die diese Speicherrichtlinie übernehmen. Das Speichern löscht keine vorhandenen Sicherungen. Geschützte Sicherungen bleiben geschützt.",
"cancel": "Abbrechen",
"save": "Aufbewahrung speichern",
"useDefault": "Proxmox-Standard verwenden (keine Richtlinie für diesen Speicher)"
}
},
"healthStatus": {
@@ -4786,7 +4837,8 @@
"services": "PVE-Dienste",
"logs": "Systemprotokolle",
"updates": "Systemaktualisierungen",
"security": "Sicherheit & Zertifikate"
"security": "Sicherheit & Zertifikate",
"pbsServices": "PBS-Dienste"
},
"checks": {
"cpu_usage": "CPU-Auslastung",
@@ -7230,5 +7282,99 @@
"diagnosticsOmitted": "Weitere Diagnosezeilen oder Text ausgelassen: {count}. Originalbericht bleibt erhalten."
}
}
},
"pbs": {
"title": "Proxmox Backup Server",
"server": "PBS-Server",
"chooseServer": "PBS-Server auswählen",
"local": "Lokaler PBS",
"refresh": "Aktualisieren",
"manage": "PBS-Verbindungen verwalten",
"loadFailed": "PBS-Status konnte nicht geladen werden.",
"loading": "PBS-Daten werden geladen…",
"noServer": "Fügen Sie eine PBS-Verbindung hinzu, um Datenspeicher, Aufgaben und Zeitpläne anzuzeigen.",
"permissionsHelp": "Aktivieren Sie die Monitor-Authentifizierung, bevor Sie Zugangsdaten speichern. Verwenden Sie ein eigenes PBS-API-Token mit der Rolle Audit auf / sowohl für den Benutzer als auch für die Token-ACLs, jeweils mit Vererbung. Dieses Panel liest PBS-Daten ausschließlich.",
"editConnection": "Zu bearbeitende Verbindung",
"add": "Neue Verbindung hinzufügen",
"name": "Name",
"url": "HTTPS-URL (für den nativen PBS-Port :8007 angeben)",
"token_id": "API-Token-ID (user@realm!token)",
"token_secret": "API-Token-Geheimnis (leer lassen, um das gespeicherte Geheimnis beizubehalten)",
"fingerprint": "SHA256-Zertifikatfingerabdruck (optional)",
"tlsHelp": "Lassen Sie den Fingerabdruck für die normale Prüfung von Zertifizierungsstelle und Hostname leer. Kopieren Sie bei einem selbstsignierten Zertifikat dessen SHA256-Fingerabdruck aus der PBS-Konsole oder einem anderen vertrauenswürdigen Kanal. Der Fingerabdruck wird vor der Übermittlung von Zugangsdaten geprüft.",
"saved": "PBS-Verbindung geprüft und gespeichert.",
"saveFailed": "PBS-Verbindung konnte nicht gespeichert werden.",
"testing": "Verbindung wird geprüft…",
"save": "Prüfen und speichern",
"remove": "Verbindung entfernen",
"removeConfirm": "Diese gespeicherte PBS-Verbindung und ihr Token aus Monitor entfernen? PBS-Daten und -Aufträge bleiben unverändert.",
"sampled": "Erfasst am",
"datastores": "Datenspeicher",
"total": "Dateisystemkapazität",
"used": "Belegt",
"free": "Verfügbar",
"noDatastores": "Keine zugänglichen Datenspeicher. Prüfen Sie die Datenspeicherkonfiguration und die Berechtigungen des Benutzers und Tokens.",
"tasks": "Letzte Aufgaben",
"noTasks": "Es wurden keine aktuellen Aufgaben zurückgegeben.",
"task": "Aufgabe",
"started": "Gestartet",
"status": "Status",
"running": "Wird ausgeführt",
"jobs": "Wartungszeitpläne",
"job": "Auftrag",
"schedule": "Zeitplan",
"sync": "Synchronisierung",
"verify": "Verifizierung",
"prune": "Bereinigung",
"overview": "Übersicht",
"logs": "Systemprotokolle",
"terminal": "Terminal",
"security": "Sicherheit",
"logout": "Abmelden",
"navigation": "PBS-Navigation",
"cpu": "CPU",
"memory": "Arbeitsspeicher",
"uptime": "Betriebszeit",
"capacityUnavailable": "Kapazität nicht verfügbar.",
"datastoresUnavailable": "{count} nicht verfügbar",
"datastoresOnline": "Alle verfügbar",
"moreDatastores": "+{count} weitere Datenspeicher",
"estimatedFull": "Voraussichtlich voll",
"estimatedFullUnknown": "Noch nicht genügend Daten",
"duration": "Dauer",
"noJobs": "Es sind keine Synchronisierungs-, Überprüfungs- oder Löschzeitpläne konfiguriert."
},
"monitorLinks": {
"groups": {
"cluster": "Cluster",
"standalone": "Eigenständige Knoten",
"pbs": "Proxmox Backup Server"
},
"thisNode": "Dieser Knoten",
"checking": "Knoten werden überprüft…",
"loadFailed": "Die Knotenliste konnte nicht geladen werden.",
"empty": "Noch keine anderen Knoten. Fügen Sie einen hinzu, um seinen Monitor von hier aus zu öffnen.",
"noResponse": "Keine Antwort",
"pbsInterface": "PBS-Schnittstelle",
"openNewTab": "In einem neuen Tab öffnen",
"manage": "Knoten hinzufügen oder verwalten…",
"dialogTitle": "Verknüpfte Knoten",
"dialogDescription": "Clusterknoten und konfigurierte Proxmox Backup Server werden automatisch erkannt. Jeder Monitor behält seine eigene Anmeldung.",
"addTitle": "Einen Knoten hinzufügen",
"name": "Name",
"address": "Adresse überwachen",
"type": "Geben Sie ein",
"add": "Hinzufügen",
"save": "Speichern",
"remove": "Entfernen",
"visible": "Sichtbar",
"autoAddress": "Automatisch",
"detected": "Erkannt",
"added": "Hinzugefügt",
"saveFailed": "Die Knotenliste konnte nicht gespeichert werden.",
"backTo": "Zurück zu {name}",
"cameFromTitle": "Monitorknoten hinzufügen?",
"cameFromBody": "{name} zu den verknüpften Knoten dieses Monitors hinzufügen.",
"notNow": "Nicht jetzt"
}
}
File diff suppressed because one or more lines are too long
+153 -7
View File
@@ -1,8 +1,70 @@
{
"pbs": {
"title": "Proxmox Backup Server",
"server": "Servidor PBS",
"chooseServer": "Selecciona un servidor PBS",
"local": "PBS local",
"refresh": "Actualizar",
"manage": "Gestionar conexiones PBS",
"loadFailed": "No se pudo consultar el estado de PBS.",
"loading": "Cargando datos de PBS…",
"noServer": "Añade una conexión PBS para ver datastores, tareas y programaciones.",
"permissionsHelp": "Activa la autenticación de Monitor antes de guardar credenciales. Usa un token API dedicado de PBS con el rol Audit en / para el usuario y el token, con propagación. Este panel solo consulta datos de PBS.",
"editConnection": "Conexión que deseas editar",
"add": "Añadir una conexión",
"name": "Nombre",
"url": "URL HTTPS (incluye :8007 para el puerto nativo de PBS)",
"token_id": "ID del token API (usuario@realm!token)",
"token_secret": "Secreto del token API (vacío para conservar el guardado)",
"fingerprint": "Huella SHA256 del certificado (opcional)",
"tlsHelp": "Deja la huella vacía para verificar la CA y el nombre del servidor. Si el certificado es autofirmado, copia su huella SHA256 desde la consola de PBS u otro canal fiable. Se verifica antes de enviar las credenciales.",
"saved": "Conexión PBS verificada y guardada.",
"saveFailed": "No se pudo guardar la conexión PBS.",
"testing": "Verificando conexión…",
"save": "Verificar y guardar",
"remove": "Eliminar conexión",
"removeConfirm": "¿Eliminar de Monitor esta conexión PBS y su token guardado? Los datos y las tareas de PBS no se modifican.",
"sampled": "Consulta realizada a las",
"datastores": "Datastores",
"total": "Capacidad del sistema de archivos",
"used": "Usado",
"free": "Disponible",
"noDatastores": "No hay datastores accesibles. Revisa su configuración y los permisos del usuario y del token.",
"tasks": "Tareas recientes",
"noTasks": "No se han devuelto tareas recientes.",
"task": "Tarea",
"started": "Inicio",
"status": "Estado",
"running": "En ejecución",
"jobs": "Programaciones de mantenimiento",
"job": "Tarea",
"schedule": "Programación",
"sync": "Sincronización",
"verify": "Verificación",
"prune": "Prune",
"overview": "Resumen",
"logs": "Registros del sistema",
"terminal": "Terminal",
"security": "Seguridad",
"logout": "Cerrar sesión",
"navigation": "Navegación de PBS",
"cpu": "CPU",
"memory": "Memoria",
"uptime": "Tiempo de actividad",
"capacityUnavailable": "Capacidad no disponible.",
"datastoresUnavailable": "{count} no disponibles",
"datastoresOnline": "Todos disponibles",
"moreDatastores": "+{count} datastores más",
"estimatedFull": "Lleno previsto",
"estimatedFullUnknown": "Aún no hay datos suficientes",
"duration": "Duración",
"noJobs": "No hay programaciones de sincronización, verificación ni prune."
},
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values.",
"app": {
"title": "ProxMenux Monitor",
"description": "Panel del sistema Proxmox",
"descriptionPbs": "Panel de Proxmox Backup Server",
"loading": "Cargando...",
"connecting": "Conexión al ProxMenux Monitor",
"unknown": "Desconocido",
@@ -140,6 +202,7 @@
"dayMany": "{count} días"
},
"proxmoxVersion": "Versión Proxmox:",
"pbsVersion": "Versión PBS:",
"kernel": "Núcleo:",
"availableUpdates": "Actualizaciones disponibles:",
"packages": "paquetes",
@@ -607,7 +670,7 @@
"controllerBusyTime": "Minutos totales que el controlador pasó procesando comandos de E/S. Los valores altos indican una carga de trabajo pesada y sostenida.",
"errorLogEntries": "Número de entradas en el registro de información de errores. A menudo incluye artefactos benignos de autoevaluación; verifique con Errores de Medios.",
"warningTempTime": "Total de minutos transcurridos por encima del umbral de temperatura de advertencia. Provoca una limitación del rendimiento. El cero es ideal.",
"criticalTempTime": "Minutos totales pasados ​​por encima del umbral de temperatura crítica. La unidad puede apagarse para evitar daños. Siempre debe ser cero.",
"criticalTempTime": "Minutos totales pasados por encima del umbral de temperatura crítica. La unidad puede apagarse para evitar daños. Siempre debe ser cero.",
"warningCompositeTemperatureTime": "Minutos totales que la temperatura compuesta superó el umbral de advertencia.",
"criticalCompositeTemperatureTime": "Minutos totales la temperatura compuesta superó el umbral crítico. Debe ser cero.",
"thermalManagementT1TransCount": "Número de veces que la unidad entró en aceleración térmica ligera (T1). Indica problemas de enfriamiento.",
@@ -1110,7 +1173,21 @@
"available": "Backups disponibles",
"loading": "Cargando backups...",
"empty": "No se encontraron backups",
"emptyHint": "Crea tu primer backup con el botón de arriba"
"emptyHint": "Crea tu primer backup con el botón de arriba",
"refresh": "Actualizar",
"refreshFailed": "No se pudieron actualizar los backups. Vuelve a actualizar para confirmar su estado actual.",
"deleteSelected": "Eliminar los seleccionados ({count})",
"deletedCount": "{count} backup(s) eliminado(s).",
"unconfirmedCount": "No se ha confirmado la eliminación de {count} backup(s). Actualiza la lista y comprueba las tareas de Proxmox antes de volver a intentarlo.",
"deleteFailed": "No se pudo confirmar la eliminación. Actualiza la lista y comprueba las tareas de Proxmox antes de volver a intentarlo.",
"selectHint": "Selecciona backups sin protección (hasta 20 por lote)",
"selectBackup": "Seleccionar backup",
"protected": "Protegido",
"confirmTitle": "¿Eliminar {count} backup(s)?",
"confirmDescription": "Los archivos de backup de la VM/CT {vmid} se eliminarán de forma permanente. Los backups protegidos quedan fuera. La VM o el contenedor no se eliminan.",
"deleting": "Esperando a que Proxmox termine de eliminar los archivos seleccionados…",
"cancel": "Cancelar",
"confirmDelete": "Eliminar backups"
},
"updatesPanel": {
"pendingTitle": "Actualizaciones de paquetes pendientes",
@@ -1646,6 +1723,16 @@
"searchApplications": "Buscar aplicaciones",
"searchingApplications": "Buscando aplicaciones…",
"noApplicationsDetected": "No se han detectado aplicaciones.",
"nativeEvidencePort": "Puerto TCP a la escucha",
"nativeEvidenceFile": "Archivo instalado",
"nativeEvidenceProcess": "Ejecutable en marcha",
"nativeEvidenceDirectory": "Directorio de trabajo",
"nativeEvidenceService": "Definición del servicio",
"nativeScanFailed": "La búsqueda de aplicaciones no ha podido terminar. Lo detectado antes puede estar desactualizado; vuelve a intentarlo.",
"nativeScanPartial": "La búsqueda de aplicaciones está incompleta. Revisa lo que se ha encontrado y vuelve a intentarlo.",
"nativeVersionUnknown": "Versión instalada desconocida",
"nativeEvidenceStale": "Lo detectado puede estar desactualizado o incompleto.",
"nativeRegisterOnly": "Confirma el registro en el editor. Una versión conocida se guarda como valor manual; no se configura ningún actualizador.",
"noNewApplicationsDetected": "No se han detectado aplicaciones nuevas.",
"oneNewApplicationDetected": "Se ha detectado una aplicación nueva.",
"newApplicationsDetected": "Se han detectado {count} aplicaciones nuevas.",
@@ -1814,6 +1901,7 @@
"network": "Interfaces de red",
"vms": "Máquinas virtuales y contenedores",
"services": "Servicios PVE",
"pbsServices": "Servicios PBS",
"logs": "Registros del sistema",
"updates": "Actualizaciones del sistema",
"security": "Seguridad y certificados",
@@ -2111,7 +2199,13 @@
"inactiveFooter": "Habilite notificaciones para recibir alertas del sistema, de salud y de seguridad a través de los canales configurados.",
"notificationLanguage": "Idioma de notificación",
"selectNotificationLanguage": "Seleccione el idioma de notificación",
"notificationLanguageHint": "Se utiliza para notificaciones deterministas en tiempo de ejecución incluso cuando la IA está deshabilitada."
"notificationLanguageHint": "Se utiliza para notificaciones deterministas en tiempo de ejecución incluso cuando la IA está deshabilitada.",
"appriseServiceHelp": "Configuración de Slack y Matrix",
"appriseSlackSteps": "Crea un webhook entrante en tu aplicación de Slack para el canal de destino y pega su URL HTTPS completa en URL de Apprise. No hace falta convertirla.",
"appriseSlackDocs": "Formatos de URL y configuración de Slack",
"appriseMatrixSteps": "Utiliza una cuenta de notificaciones que pueda publicar en la sala. Sustituye el usuario, la contraseña, el servidor de origen y el ID completo de la sala del ejemplo; codifica para URL los caracteres especiales de las credenciales. El esquema matrixs utiliza HTTPS. Consulta en la documentación la compatibilidad con tokens, SSO y salas cifradas de tu versión de Apprise.",
"appriseMatrixDocs": "Formatos de URL y configuración de Matrix",
"appriseServiceFinish": "Activa Apprise, introduce tu URL, elige las categorías de notificación o los eventos individuales, guarda y utiliza Enviar prueba. Estos ejemplos no cambian tu URL guardada. Este canal de Apprise tiene un único destino; utiliza la API de Apprise para varios servicios a la vez."
},
"ai": {
"title": "mejora de la IA",
@@ -2579,7 +2673,7 @@
"expires": "Vence",
"selfSignedWarning": "Los certificados autofirmados pueden mostrar una advertencia en el navegador. Esto es normal para las configuraciones locales de Proxmox.",
"configuring": "Configurando...",
"useProxmoxCertificate": "Utilice el certificado Proxmox",
"useProxmoxCertificate": "Usar certificado de Proxmox",
"noProxmoxCertificate": "No se detectó ningún certificado Proxmox.",
"useCustomCertificate": "Usar certificado personalizado",
"customPaths": "Rutas de certificados personalizadas",
@@ -3566,8 +3660,8 @@
"short": {
"hour": "Pasada 1 hora",
"day": "Pasadas 24h",
"week": "Pasados ​​los 7 días",
"month": "Pasados ​​los 30 días",
"week": "Pasados los 7 días",
"month": "Pasados los 30 días",
"year": "Último 1 año"
},
"last": {
@@ -4756,6 +4850,24 @@
"thursday": "Jueves",
"tuesday": "Martes",
"wednesday": "Miércoles"
},
"storageRetention": {
"useDefault": "Usar el valor por defecto de Proxmox (sin política en este almacenamiento)",
"title": "Retención del almacenamiento",
"keepAll": "Conservar todos los backups",
"description": "Retención por defecto de los backups, guardada en Proxmox. Las tareas de backup con su propia política tienen prioridad sobre ella.",
"refresh": "Actualizar",
"saved": "Retención guardada. No se ha purgado ningún backup.",
"choosePolicy": "Elige Conservar todos o al menos un número de retención mayor que cero.",
"saveFailed": "No se pudo guardar la retención del almacenamiento. Actualiza la lista si ha cambiado la configuración.",
"loadFailed": "No se pudo cargar la retención del almacenamiento.",
"empty": "No hay almacenamientos de backups.",
"default": "Valor por defecto de Proxmox",
"edit": "Editar",
"editTitle": "Retención de {storage}",
"effect": "Se aplica a los próximos backups que hereden la política de este almacenamiento. Guardar no elimina los backups existentes, y los protegidos siguen protegidos.",
"cancel": "Cancelar",
"save": "Guardar retención"
}
},
"healthStatus": {
@@ -4783,7 +4895,8 @@
"disks": "E/S de disco y errores",
"network": "Interfaces de red",
"vms": "Máquinas virtuales y contenedores",
"services": "servicios PVE",
"services": "Servicios PVE",
"pbsServices": "Servicios PBS",
"logs": "Registros del sistema",
"updates": "Actualizaciones del sistema",
"security": "Seguridad y certificados"
@@ -7230,5 +7343,38 @@
"diagnosticsOmitted": "Líneas o texto de diagnóstico omitidos: {count}. Se conserva el informe original."
}
}
},
"monitorLinks": {
"thisNode": "Este nodo",
"checking": "Comprobando nodos…",
"loadFailed": "No se pudo cargar la lista de nodos.",
"empty": "Aún no hay otros nodos. Añade uno para abrir su Monitor desde aquí.",
"groups": {
"cluster": "Clúster",
"standalone": "Nodos independientes",
"pbs": "Proxmox Backup Server"
},
"noResponse": "Sin respuesta",
"pbsInterface": "Interfaz de PBS",
"openNewTab": "Abrir en una pestaña nueva",
"manage": "Añadir o gestionar nodos…",
"dialogTitle": "Nodos enlazados",
"dialogDescription": "Los nodos del clúster y los Proxmox Backup Server configurados se detectan automáticamente. Cada Monitor mantiene su propio inicio de sesión.",
"addTitle": "Añadir un nodo",
"name": "Nombre",
"address": "Dirección del Monitor",
"type": "Tipo",
"add": "Añadir",
"save": "Guardar",
"remove": "Eliminar",
"visible": "Visible",
"autoAddress": "Automática",
"detected": "Detectado",
"added": "Añadido",
"saveFailed": "No se pudo guardar la lista de nodos.",
"backTo": "Volver a {name}",
"cameFromTitle": "¿Añadir nodo Monitor?",
"cameFromBody": "Añade {name} a los nodos enlazados de este Monitor.",
"notNow": "Ahora no"
}
}
+157 -11
View File
@@ -8,7 +8,8 @@
"unknown": "Inconnu",
"notAvailable": "N/A",
"serverOffline": "Serveur hors ligne",
"supportProject": "Soutenir et contribuer au projet"
"supportProject": "Soutenir et contribuer au projet",
"descriptionPbs": "Tableau de bord Proxmox Backup Server"
},
"common": {
"notAvailable": "N/A",
@@ -165,7 +166,8 @@
},
"runningCountOne": "{count} en cours d'exécution",
"runningCountFew": "{count} en cours d'exécution",
"runningCountMany": "{count} en cours d'exécution"
"runningCountMany": "{count} en cours d'exécution",
"pbsVersion": "Version PBS :"
},
"storage": {
"loadingTitle": "Chargement des données de stockage...",
@@ -1110,7 +1112,21 @@
"available": "Sauvegardes disponibles",
"loading": "Chargement des sauvegardes...",
"empty": "Aucune sauvegarde trouvée",
"emptyHint": "Créez votre première sauvegarde en utilisant le bouton ci-dessus"
"emptyHint": "Créez votre première sauvegarde en utilisant le bouton ci-dessus",
"refresh": "Actualiser",
"refreshFailed": "Impossible d'actualiser les sauvegardes. Actualisez à nouveau pour confirmer leur état actuel.",
"deleteSelected": "Supprimer la sélection ({count})",
"deletedCount": "{count} sauvegarde(s) supprimée(s).",
"unconfirmedCount": "La suppression de {count} sauvegarde(s) n'a pas été confirmée. Actualisez la liste et vérifiez les tâches Proxmox avant de réessayer.",
"deleteFailed": "La suppression n'a pas pu être confirmée. Actualisez la liste et vérifiez les tâches Proxmox avant de réessayer.",
"selectHint": "Sélectionnez les sauvegardes non protégées (jusqu'à 20 par lot)",
"selectBackup": "Sélectionner la sauvegarde",
"protected": "Protégée",
"confirmTitle": "Supprimer {count} sauvegarde(s) ?",
"confirmDescription": "Ces archives de sauvegarde de la VM/CT {vmid} seront définitivement supprimées. Les sauvegardes protégées sont exclues. Cela ne supprime pas l'invité.",
"deleting": "En attente de la fin de la suppression des archives sélectionnées par Proxmox…",
"cancel": "Annuler",
"confirmDelete": "Supprimer les sauvegardes"
},
"updatesPanel": {
"pendingTitle": "Mises à jour des packages en attente",
@@ -1721,7 +1737,17 @@
"portCategoryCustomPlaceholder": "Tapez une catégorie et appuyez sur Entrée (Esc pour annuler)",
"portCustomUrlPlaceholder": "URL personnalisée (par exemple https://vault.example.com) — remplace IP:port",
"stackMemberTitle": "Fait partie d'une application multi-conteneurs",
"stackMemberBody": "Ce conteneur fait partie d'une application dont le conteneur principal est {primary}. L'application, ses liens web et sa version y sont affichés."
"stackMemberBody": "Ce conteneur fait partie d'une application dont le conteneur principal est {primary}. L'application, ses liens web et sa version y sont affichés.",
"nativeEvidencePort": "Port TCP en écoute",
"nativeEvidenceFile": "Fichier installé",
"nativeEvidenceProcess": "Exécutable en cours d'exécution",
"nativeEvidenceDirectory": "Répertoire de travail",
"nativeEvidenceService": "Définition du service",
"nativeScanFailed": "La recherche d'applications n'a pas pu se terminer. Les résultats précédents peuvent être obsolètes ; réessayez.",
"nativeScanPartial": "La recherche d'applications est incomplète. Vérifiez les éléments trouvés et réessayez.",
"nativeVersionUnknown": "Version installée inconnue",
"nativeEvidenceStale": "Les éléments trouvés peuvent être obsolètes ou incomplets.",
"nativeRegisterOnly": "Confirmez l'enregistrement dans l'éditeur. Une version connue est enregistrée comme valeur manuelle ; aucun outil de mise à jour n'est configuré."
},
"statusFilter": {
"ariaLabel": "Filtrer les machines virtuelles et les conteneurs",
@@ -1817,7 +1843,8 @@
"logs": "Journaux système",
"updates": "Mises à jour du système",
"security": "Sécurité et certificats",
"temperature": "Température"
"temperature": "Température",
"pbsServices": "Services PBS"
}
},
"remoteStorage": {
@@ -2111,7 +2138,13 @@
"inactiveFooter": "Activez les notifications pour recevoir des alertes système, de santé et de sécurité via les canaux configurés.",
"notificationLanguage": "Langue de notification",
"selectNotificationLanguage": "Sélectionnez la langue de notification",
"notificationLanguageHint": "Utilisé pour les notifications d'exécution déterministes, même lorsque l'IA est désactivée."
"notificationLanguageHint": "Utilisé pour les notifications d'exécution déterministes, même lorsque l'IA est désactivée.",
"appriseServiceHelp": "Configuration de Slack et Matrix",
"appriseSlackSteps": "Créez un webhook entrant dans votre application Slack pour le canal cible, puis collez son URL HTTPS complète dans le champ URL Apprise. Aucune conversion n'est nécessaire.",
"appriseSlackDocs": "Formats d'URL et configuration de Slack",
"appriseMatrixSteps": "Utilisez un compte de notification autorisé à publier dans le salon. Remplacez l'utilisateur, le mot de passe, le serveur d'origine et l'identifiant complet du salon de l'exemple ; encodez les caractères spéciaux des identifiants pour les URL. Le schéma matrixs utilise HTTPS. Consultez la documentation pour la prise en charge des jetons, du SSO et des salons chiffrés dans votre version d'Apprise.",
"appriseMatrixDocs": "Formats d'URL et configuration de Matrix",
"appriseServiceFinish": "Activez Apprise, saisissez votre URL, choisissez les catégories de notification ou les événements individuels, enregistrez, puis utilisez Envoyer un test. Ces exemples ne modifient pas votre URL enregistrée. Ce canal Apprise possède une destination ; utilisez l'API Apprise pour plusieurs services à la fois."
},
"ai": {
"title": "Amélioration de l'IA",
@@ -2980,7 +3013,7 @@
"youWillNeed": "Vous aurez besoin de :",
"needApp": "Une application d'authentification telle que Aegis, 1Password, Bitwarden ou Google Authenticator.",
"needQrOrKey": "Un code QR ou une clé manuelle de cette configuration.",
"needBackupCodes": "Codes de sauvegarde enregistrés dans un endroit sûr.",
"needBackupCodes": "Codes de secours enregistrés dans un endroit sûr.",
"starting": "Départ...",
"startSetup": "Démarrer la configuration",
"scanTitle": "Scannez le code QR",
@@ -2993,10 +3026,10 @@
"verifying": "Vérification...",
"verifyAndEnable": "Vérifier et activer",
"enabledTitle": "2FA est activé.",
"enabledDescription": "Enregistrez les codes de sauvegarde avant de fermer cette fenêtre.",
"saveCodesTitle": "Enregistrez vos codes de sauvegarde",
"enabledDescription": "Enregistrez les codes de secours avant de fermer cette fenêtre.",
"saveCodesTitle": "Enregistrez vos codes de secours",
"saveCodesDescription": "Chaque code peut être utilisé une fois si vous perdez l'accès à votre application d'authentification.",
"backupCodes": "Codes de sauvegarde",
"backupCodes": "Codes de secours",
"copyAll": "Copier tout",
"finish": "Finition"
},
@@ -4756,6 +4789,24 @@
"thursday": "Jeudi",
"tuesday": "Mardi",
"wednesday": "Mercredi"
},
"storageRetention": {
"title": "Rétention du stockage",
"keepAll": "Conserver toutes les sauvegardes",
"description": "Rétention par défaut des sauvegardes enregistrée dans Proxmox. Les tâches de sauvegarde avec une politique explicite remplacent cette valeur par défaut.",
"refresh": "Actualiser",
"saved": "Rétention du stockage enregistrée. Aucune purge n'a été exécutée.",
"choosePolicy": "Choisissez Tout conserver ou au moins un nombre de rétention supérieur à zéro.",
"saveFailed": "Impossible d'enregistrer la rétention du stockage. Actualisez la liste si la configuration a changé.",
"loadFailed": "Impossible de charger la rétention du stockage.",
"empty": "Aucun stockage de sauvegarde trouvé.",
"default": "Valeur par défaut de Proxmox",
"edit": "Modifier",
"editTitle": "Rétention de {storage}",
"effect": "S'applique aux futures sauvegardes qui héritent de cette politique de stockage. L'enregistrement ne supprime pas les sauvegardes existantes. Les sauvegardes protégées restent protégées.",
"cancel": "Annuler",
"save": "Enregistrer la rétention",
"useDefault": "Utiliser la valeur par défaut de Proxmox (aucune politique sur ce stockage)"
}
},
"healthStatus": {
@@ -4786,7 +4837,8 @@
"services": "Services PVE",
"logs": "Journaux système",
"updates": "Mises à jour du système",
"security": "Sécurité et certificats"
"security": "Sécurité et certificats",
"pbsServices": "services PBS"
},
"checks": {
"cpu_usage": "Utilisation du processeur",
@@ -7230,5 +7282,99 @@
"diagnosticsOmitted": "Lignes ou texte de diagnostic omis : {count}. Rapport original conservé."
}
}
},
"pbs": {
"title": "Proxmox Backup Server",
"server": "Serveur PBS",
"chooseServer": "Choisir un serveur PBS",
"local": "PBS local",
"refresh": "Actualiser",
"manage": "Gérer les connexions PBS",
"loadFailed": "Impossible de charger l'état de PBS.",
"loading": "Chargement des données PBS…",
"noServer": "Ajoutez une connexion PBS pour afficher les magasins de données, les tâches et les planifications.",
"permissionsHelp": "Activez l'authentification Monitor avant d'enregistrer des identifiants. Utilisez un jeton API PBS dédié avec le rôle Audit sur / pour les ACL de l'utilisateur et du jeton, avec propagation. Ce panneau accède aux données PBS uniquement en lecture.",
"editConnection": "Connexion à modifier",
"add": "Ajouter une connexion",
"name": "Nom",
"url": "URL HTTPS (inclure :8007 pour le port natif de PBS)",
"token_id": "Identifiant du jeton API (user@realm!token)",
"token_secret": "Secret du jeton API (laisser vide pour conserver le secret enregistré)",
"fingerprint": "Empreinte SHA256 du certificat (facultative)",
"tlsHelp": "Laissez l'empreinte vide pour la vérification habituelle de l'autorité de certification et du nom d'hôte. Pour un certificat autosigné, copiez son empreinte SHA256 depuis la console PBS ou un autre canal de confiance. L'empreinte est vérifiée avant l'envoi des identifiants.",
"saved": "Connexion PBS vérifiée et enregistrée.",
"saveFailed": "Impossible d'enregistrer la connexion PBS.",
"testing": "Vérification de la connexion…",
"save": "Vérifier et enregistrer",
"remove": "Supprimer la connexion",
"removeConfirm": "Supprimer de Monitor cette connexion PBS enregistrée et son jeton ? Les données et tâches PBS restent inchangées.",
"sampled": "Relevé effectué à",
"datastores": "Magasins de données",
"total": "Capacité du système de fichiers",
"used": "Utilisé",
"free": "Disponible",
"noDatastores": "Aucun magasin de données accessible. Vérifiez leur configuration et les permissions de l'utilisateur et du jeton.",
"tasks": "Tâches récentes",
"noTasks": "Aucune tâche récente n'a été renvoyée.",
"task": "Tâche",
"started": "Démarrée",
"status": "État",
"running": "En cours",
"jobs": "Planifications de maintenance",
"job": "Tâche planifiée",
"schedule": "Planification",
"sync": "Synchronisation",
"verify": "Vérification",
"prune": "Purge",
"overview": "Vue d'ensemble",
"logs": "Journaux système",
"terminal": "Terminal",
"security": "Sécurité",
"logout": "Se déconnecter",
"navigation": "Navigation PBS",
"cpu": "CPU",
"memory": "Mémoire",
"uptime": "Durée de fonctionnement",
"capacityUnavailable": "Capacité non disponible.",
"datastoresUnavailable": "{count} indisponible",
"datastoresOnline": "Tous disponibles",
"moreDatastores": "+{count} banques de données supplémentaires",
"estimatedFull": "Estimé complet",
"estimatedFullUnknown": "Pas encore assez de données",
"duration": "Durée",
"noJobs": "Aucune planification de synchronisation, de vérification ou d'élagage n'est configurée."
},
"monitorLinks": {
"groups": {
"cluster": "Cluster",
"standalone": "nœuds autonomes",
"pbs": "Proxmox Backup Server"
},
"thisNode": "Ce nœud",
"checking": "Vérification des nœuds…",
"loadFailed": "Impossible de charger la liste des nœuds.",
"empty": "Aucun autre nœud pour l'instant. Ajoutez-en un pour ouvrir son Monitor depuis ici.",
"noResponse": "Pas de réponse",
"pbsInterface": "interface PBS",
"openNewTab": "Ouvrir dans un nouvel onglet",
"manage": "Ajouter ou gérer des nœuds…",
"dialogTitle": "nœuds liés",
"dialogDescription": "Les nœuds du cluster et les Proxmox Backup Server configurés sont détectés automatiquement. Chaque Monitor conserve sa propre connexion.",
"addTitle": "ajouter un nœud",
"name": "Nom",
"address": "Adresse du moniteur",
"type": "Type",
"add": "Ajouter",
"save": "Enregistrer",
"remove": "Supprimer",
"visible": "Visible",
"autoAddress": "Automatique",
"detected": "Détecté",
"added": "ajouté",
"saveFailed": "Impossible d'enregistrer la liste des nœuds.",
"backTo": "Retour à {name}",
"cameFromTitle": "Ajouter un nœud Moniteur ?",
"cameFromBody": "Ajoutez {name} aux nœuds liés de ce moniteur.",
"notNow": "Pas maintenant"
}
}
+153 -7
View File
@@ -8,7 +8,8 @@
"unknown": "Sconosciuto",
"notAvailable": "N/A",
"serverOffline": "Server offline",
"supportProject": "Sostieni e contribuisci al progetto"
"supportProject": "Sostieni e contribuisci al progetto",
"descriptionPbs": "Dashboard di Proxmox Backup Server"
},
"common": {
"notAvailable": "N/A",
@@ -165,7 +166,8 @@
},
"runningCountOne": "{count} in esecuzione",
"runningCountFew": "{count} in esecuzione",
"runningCountMany": "{count} in esecuzione"
"runningCountMany": "{count} in esecuzione",
"pbsVersion": "Versione PBS:"
},
"storage": {
"loadingTitle": "Caricamento dati storage...",
@@ -1110,7 +1112,21 @@
"available": "Backup disponibili",
"loading": "Caricamento backup...",
"empty": "Nessun backup trovato",
"emptyHint": "Crea il primo backup con il pulsante in alto"
"emptyHint": "Crea il primo backup con il pulsante in alto",
"refresh": "Aggiorna",
"refreshFailed": "Impossibile aggiornare i backup. Aggiorna di nuovo per confermarne lo stato attuale.",
"deleteSelected": "Elimina selezionati ({count})",
"deletedCount": "{count} backup eliminati.",
"unconfirmedCount": "L'eliminazione di {count} backup non è stata confermata. Aggiorna l'elenco e controlla le attività di Proxmox prima di riprovare.",
"deleteFailed": "Impossibile confermare l'eliminazione. Aggiorna l'elenco e controlla le attività di Proxmox prima di riprovare.",
"selectHint": "Seleziona i backup non protetti (fino a 20 per gruppo)",
"selectBackup": "Seleziona backup",
"protected": "Protetto",
"confirmTitle": "Eliminare {count} backup?",
"confirmDescription": "Questi archivi di backup della VM/CT {vmid} verranno eliminati definitivamente. I backup protetti sono esclusi. Questo non elimina l'ospite.",
"deleting": "In attesa che Proxmox termini l'eliminazione degli archivi selezionati…",
"cancel": "Annulla",
"confirmDelete": "Elimina backup"
},
"updatesPanel": {
"pendingTitle": "Aggiornamenti pacchetti in sospeso",
@@ -1721,7 +1737,17 @@
"portCategoryCustomPlaceholder": "Digita una categoria e premi Enter (Esc per annullare)",
"portCustomUrlPlaceholder": "URL personalizzato (ad es. https://vault.example.com) — sostituisce IP:port",
"stackMemberTitle": "Parte di un'applicazione multi-container",
"stackMemberBody": "Questo container supporta un’applicazione il cui container principale è {primary}. L’applicazione, i suoi collegamenti web e la sua versione sono mostrati lì."
"stackMemberBody": "Questo container supporta un’applicazione il cui container principale è {primary}. L’applicazione, i suoi collegamenti web e la sua versione sono mostrati lì.",
"nativeEvidencePort": "Porta TCP in ascolto",
"nativeEvidenceFile": "File installato",
"nativeEvidenceProcess": "Eseguibile in esecuzione",
"nativeEvidenceDirectory": "Directory di lavoro",
"nativeEvidenceService": "Definizione del servizio",
"nativeScanFailed": "La ricerca delle applicazioni non è stata completata. I risultati precedenti potrebbero non essere aggiornati; riprova.",
"nativeScanPartial": "La ricerca delle applicazioni è incompleta. Controlla gli elementi trovati e riprova.",
"nativeVersionUnknown": "Versione installata sconosciuta",
"nativeEvidenceStale": "Gli elementi trovati potrebbero non essere aggiornati o essere incompleti.",
"nativeRegisterOnly": "Conferma la registrazione nell'editor. Una versione nota viene salvata come valore manuale; non viene configurato alcun aggiornamento automatico."
},
"statusFilter": {
"ariaLabel": "Filtra macchine virtuali e container",
@@ -1817,7 +1843,8 @@
"logs": "Log di sistema",
"updates": "Aggiornamenti di sistema",
"security": "Sicurezza e certificati",
"temperature": "Temperatura"
"temperature": "Temperatura",
"pbsServices": "Servizi PBS"
}
},
"remoteStorage": {
@@ -2111,7 +2138,13 @@
"inactiveFooter": "Abilita le notifiche per ricevere avvisi di sistema, integrità e sicurezza attraverso i canali configurati.",
"notificationLanguage": "Lingua notifiche",
"selectNotificationLanguage": "Seleziona lingua notifiche",
"notificationLanguageHint": "Usata per le notifiche runtime deterministiche anche quando l’IA è disabilitata."
"notificationLanguageHint": "Usata per le notifiche runtime deterministiche anche quando l’IA è disabilitata.",
"appriseServiceHelp": "Configurazione di Slack e Matrix",
"appriseSlackSteps": "Crea un webhook in ingresso nell'app Slack per il canale di destinazione, quindi incolla il suo URL HTTPS completo nel campo URL di Apprise. Non è necessaria alcuna conversione.",
"appriseSlackDocs": "Formati URL e configurazione di Slack",
"appriseMatrixSteps": "Usa un account di notifica autorizzato a pubblicare nella stanza. Sostituisci utente, password, homeserver e ID completo della stanza dell'esempio; codifica per URL i caratteri speciali nelle credenziali. Lo schema matrixs usa HTTPS. Consulta la documentazione per il supporto di token, SSO e stanze cifrate nella versione di Apprise installata.",
"appriseMatrixDocs": "Formati URL e configurazione di Matrix",
"appriseServiceFinish": "Abilita Apprise, inserisci il tuo URL, scegli le categorie di notifica o i singoli eventi, salva e poi usa Invia test. Questi esempi non modificano l'URL salvato. Questo canale Apprise ha una sola destinazione; usa l'API Apprise per più servizi contemporaneamente."
},
"ai": {
"title": "Miglioramento con IA",
@@ -4756,6 +4789,24 @@
"thursday": "Giovedì",
"tuesday": "Martedì",
"wednesday": "Mercoledì"
},
"storageRetention": {
"title": "Conservazione dell'archiviazione",
"keepAll": "Mantieni tutti i backup",
"description": "Conservazione predefinita dei backup salvata in Proxmox. Le attività di backup con una politica esplicita hanno la precedenza su questo valore.",
"refresh": "Aggiorna",
"saved": "Politica di conservazione dell'archiviazione salvata. Non è stata eseguita alcuna pulizia.",
"choosePolicy": "Scegli Mantieni tutti oppure almeno un numero di conservazione maggiore di zero.",
"saveFailed": "Impossibile salvare la politica di conservazione dell'archiviazione. Aggiorna l'elenco se la configurazione è cambiata.",
"loadFailed": "Impossibile caricare la politica di conservazione dell'archiviazione.",
"empty": "Nessuno spazio di archiviazione dei backup trovato.",
"default": "Valore predefinito di Proxmox",
"edit": "Modifica",
"editTitle": "Conservazione per {storage}",
"effect": "Si applica alle future esecuzioni dei backup che ereditano questa politica di archiviazione. Il salvataggio non elimina i backup esistenti. I backup protetti rimangono protetti.",
"cancel": "Annulla",
"save": "Salva conservazione",
"useDefault": "Usa l'impostazione predefinita di Proxmox (nessuna policy su questo storage)"
}
},
"healthStatus": {
@@ -4786,7 +4837,8 @@
"services": "Servizi PVE",
"logs": "Log di sistema",
"updates": "Aggiornamenti di sistema",
"security": "Sicurezza e certificati"
"security": "Sicurezza e certificati",
"pbsServices": "servizi PBS"
},
"checks": {
"cpu_usage": "Utilizzo CPU",
@@ -7230,5 +7282,99 @@
"diagnosticsOmitted": "Righe o testo diagnostico omessi: {count}. Il report originale è conservato."
}
}
},
"pbs": {
"title": "Proxmox Backup Server",
"server": "Server PBS",
"chooseServer": "Scegli un server PBS",
"local": "PBS locale",
"refresh": "Aggiorna",
"manage": "Gestisci le connessioni PBS",
"loadFailed": "Impossibile caricare lo stato di PBS.",
"loading": "Caricamento dei dati PBS…",
"noServer": "Aggiungi una connessione PBS per visualizzare datastore, attività e pianificazioni.",
"permissionsHelp": "Abilita l'autenticazione di Monitor prima di salvare le credenziali. Usa un token API PBS dedicato con il ruolo Audit su / sia nelle ACL dell'utente sia in quelle del token, con propagazione. Questo pannello legge soltanto i dati PBS.",
"editConnection": "Connessione da modificare",
"add": "Aggiungi una nuova connessione",
"name": "Nome",
"url": "URL HTTPS (includere :8007 per la porta nativa di PBS)",
"token_id": "ID del token API (user@realm!token)",
"token_secret": "Segreto del token API (lasciare vuoto per mantenere quello salvato)",
"fingerprint": "Impronta SHA256 del certificato (facoltativa)",
"tlsHelp": "Lascia vuota l'impronta per la normale verifica dell'autorità di certificazione e del nome host. Per un certificato autofirmato, copia la sua impronta SHA256 dalla console PBS o da un altro canale attendibile. L'impronta viene verificata prima dell'invio delle credenziali.",
"saved": "Connessione PBS verificata e salvata.",
"saveFailed": "Impossibile salvare la connessione PBS.",
"testing": "Verifica della connessione…",
"save": "Verifica e salva",
"remove": "Rimuovi connessione",
"removeConfirm": "Rimuovere da Monitor questa connessione PBS salvata e il suo token? I dati e le attività PBS non vengono modificati.",
"sampled": "Rilevato alle",
"datastores": "Datastore",
"total": "Capacità del filesystem",
"used": "Usato",
"free": "Disponibile",
"noDatastores": "Nessun datastore accessibile. Controlla la configurazione dei datastore e i permessi dell'utente e del token.",
"tasks": "Attività recenti",
"noTasks": "Non sono state restituite attività recenti.",
"task": "Attività",
"started": "Avviata",
"status": "Stato",
"running": "In esecuzione",
"jobs": "Pianificazioni di manutenzione",
"job": "Attività pianificata",
"schedule": "Pianificazione",
"sync": "Sincronizzazione",
"verify": "Verifica",
"prune": "Pulizia",
"overview": "Panoramica",
"logs": "Registri di sistema",
"terminal": "Terminale",
"security": "Sicurezza",
"logout": "Disconnetti",
"navigation": "Navigazione PBS",
"cpu": "CPU",
"memory": "Memoria",
"uptime": "Tempo di attività",
"capacityUnavailable": "Capacità non disponibile.",
"datastoresUnavailable": "{count} non disponibile",
"datastoresOnline": "Tutti disponibili",
"moreDatastores": "+{count} altri archivi dati",
"estimatedFull": "Pieno stimato",
"estimatedFullUnknown": "dati non ancora sufficienti",
"duration": "Durata",
"noJobs": "non è configurata alcuna pianificazione di sincronizzazione, verifica o eliminazione."
},
"monitorLinks": {
"groups": {
"cluster": "cluster",
"standalone": "nodi autonomi",
"pbs": "Proxmox Backup Server"
},
"thisNode": "questo nodo",
"checking": "Controllo dei nodi…",
"loadFailed": "impossibile caricare l'elenco dei nodi.",
"empty": "Ancora nessun altro nodo. Aggiungine uno per aprire il suo Monitor da qui.",
"noResponse": "Nessuna risposta",
"pbsInterface": "interfaccia PBS",
"openNewTab": "apri in una nuova scheda",
"manage": "aggiungi o gestisci nodi...",
"dialogTitle": "nodi collegati",
"dialogDescription": "I nodi del cluster e i Proxmox Backup Server configurati vengono rilevati automaticamente. Ogni Monitor mantiene il proprio accesso.",
"addTitle": "aggiungi un nodo",
"name": "Nome",
"address": "monitora l'indirizzo",
"type": "digita",
"add": "aggiungi",
"save": "salva",
"remove": "rimuovere",
"visible": "visibile",
"autoAddress": "automatico",
"detected": "rilevato",
"added": "aggiunto",
"saveFailed": "impossibile salvare l'elenco dei nodi.",
"backTo": "torna a {name}",
"cameFromTitle": "aggiungi il nodo Monitor?",
"cameFromBody": "aggiungi {name} ai nodi collegati di questo monitor.",
"notNow": "Non ora"
}
}
+180 -34
View File
@@ -8,7 +8,8 @@
"unknown": "Desconhecido",
"notAvailable": "N/D",
"serverOffline": "Servidor off-line",
"supportProject": "Apoie e contribua para o projeto"
"supportProject": "Apoie e contribua para o projeto",
"descriptionPbs": "Painel do Proxmox Backup Server"
},
"common": {
"notAvailable": "N/D",
@@ -165,7 +166,8 @@
},
"runningCountOne": "{count} em execução",
"runningCountFew": "{count} em execução",
"runningCountMany": "{count} em execução"
"runningCountMany": "{count} em execução",
"pbsVersion": "Versão PBS:"
},
"storage": {
"loadingTitle": "Carregando dados de armazenamento...",
@@ -353,7 +355,7 @@
"downloadJson": "Baixar JSON",
"delete": "Excluir",
"confirmDelete": "Excluir este registro de teste?",
"note": "Os resultados dos testes são armazenados localmente e usados ​​para gerar relatórios SMART detalhados."
"note": "Os resultados dos testes são armazenados localmente e usados para gerar relatórios SMART detalhados."
},
"smartReport": {
"title": "Relatório de saúde SMART",
@@ -595,8 +597,8 @@
"availableSpare": "Porcentagem de blocos NAND sobressalentes restantes para substituição de blocos defeituosos. Um alerta é acionado abaixo do limite.",
"availableSpareThreshold": "Mínimo definido pelo fabricante para peças sobressalentes disponíveis. Abaixo disso, a unidade sinaliza um aviso crítico.",
"percentageUsed": "Impulsione a estimativa da resistência consumida com base nos ciclos de gravação reais versus nominais. 100% significa que o TBW avaliado foi atingido; a unidade pode continuar funcionando além disso.",
"mediaErrors": "Erros de leitura/gravação irrecuperáveis ​​no flash NAND. Qualquer valor diferente de zero indica dano celular permanente. Contagem crescente significa substituição em breve.",
"mediaIntegrityErrors": "Erros irrecuperáveis ​​detectados pelo controlador. Diferente de zero significa risco de corrupção de dados.",
"mediaErrors": "Erros de leitura/gravação irrecuperáveis no flash NAND. Qualquer valor diferente de zero indica dano celular permanente. Contagem crescente significa substituição em breve.",
"mediaIntegrityErrors": "Erros irrecuperáveis detectados pelo controlador. Diferente de zero significa risco de corrupção de dados.",
"unsafeShutdowns": "Perdas de energia sem descarga ou desligamento adequado. Contagens muito altas correm o risco de corrupção de metadados e problemas de firmware.",
"nvmePowerCycles": "Ciclos totais de ligar/desligar. A ciclagem frequente aumenta o desgaste do conector e do capacitor.",
"nvmePowerOnHours": "Total de horas acumuladas em que o inversor esteve ligado desde a fabricação.",
@@ -624,7 +626,7 @@
"reallocatedSectorCount": "Setores defeituosos substituídos por setores sobressalentes do pool de reserva. Uma contagem crescente significa degradação da unidade.",
"retiredBlockCount": "Blocos NAND retirados devido a desgaste ou falha (SSD). Semelhante à contagem de setores realocados para HDDs.",
"reallocatedEventCount": "Número de operações de remapeamento realizadas. Cada evento significa que um setor defeituoso foi substituído.",
"currentPendingSector": "Setores instáveis ​​esperando para serem remapeados na próxima gravação. Eles podem ser resolvidos ou serem realocados permanentemente.",
"currentPendingSector": "Setores instáveis esperando para serem remapeados na próxima gravação. Eles podem ser resolvidos ou serem realocados permanentemente.",
"pendingSectors": "Setores esperando para serem remapeados. Eles podem ser resolvidos ou realocados.",
"offlineUncorrectable": "Setores que falharam durante a verificação offline e não puderam ser corrigidos. Indica possível perda de dados.",
"offlineUncorrectableSectorCount": "Setores incorrigíveis encontrados durante a verificação em segundo plano. Os dados sobre estes setores são perdidos.",
@@ -667,7 +669,7 @@
"endToEndError": "Corrupção de dados detectada entre o cache do controlador e a interface do host. Deve ser sempre zero.",
"endToEndErrorDetectionCount": "Número de erros de paridade no caminho de dados. Diferente de zero indica problemas no controlador.",
"wearLevelingCount": "Média de ciclos de apagamento por bloco NAND (SSD). VALOR mais baixo significa mais desgaste consumido.",
"wearRangeDelta": "Diferença entre blocos mais usados ​​e menos usados ​​(SSD). Valores altos indicam desgaste irregular.",
"wearRangeDelta": "Diferença entre blocos mais usados e menos usados (SSD). Valores altos indicam desgaste irregular.",
"mediaWearoutIndicator": "Estimativa de vida útil restante do SSD Intel. Começa em 100 e diminui para 0 conforme a resistência é consumida.",
"ssdLifeLeft": "Porcentagem estimada de vida útil restante do SSD com base no desgaste da NAND.",
"percentLifetimeRemain": "Porcentagem estimada de vida útil restante. 100 significa novo; 0 significa fim da vida útil nominal.",
@@ -744,7 +746,7 @@
"nvmeWarningTitle": "Aviso crítico NVMe ativo (0x{value})",
"nvmeWarningText": "O controlador NVMe gerou um alerta. Faça backup dos dados imediatamente e investigue mais a fundo.",
"nvmeMediaTitle": "Erros de mídia NVMe detectados ({count})",
"nvmeMediaText": "Erros irrecuperáveis ​​de flash NAND indicam danos físicos. Faça backup dos dados e planeje a substituição.",
"nvmeMediaText": "Erros irrecuperáveis de flash NAND indicam danos físicos. Faça backup dos dados e planeje a substituição.",
"unsafeTitle": "Contagem alta de desligamentos inseguros ({count})",
"unsafeText": "Perdas frequentes de energia aumentam o risco de corrupção do firmware. Garanta energia estável ou use um UPS.",
"seagateTitle": "Seagate Raw_Read_Error_Rate - Comportamento normal",
@@ -1084,7 +1086,7 @@
"start": "Iniciar",
"shutdown": "Desligar",
"reboot": "Reiniciar",
"forceStop": "Forçar parada",
"forceStop": "Forçar paragem",
"working": "Processando...",
"startPending": "Iniciando...",
"shutdownPending": "Desligando...",
@@ -1092,7 +1094,7 @@
"stopPending": "Parando..."
},
"confirm": {
"forceStopTitle": "Forçar parada",
"forceStopTitle": "Forçar paragem",
"rebootTitle": "Reinício",
"forceStopDescription": "Isso ignora a sequência de desligamento do sistema operacional convidado e pode corromper bancos de dados ou sistemas de arquivos em execução. O convidado é morto imediatamente.",
"rebootDescription": "Isso força uma reinicialização sem esperar que o sistema operacional convidado libere as gravações pendentes. Use um desligamento elegante quando possível.",
@@ -1110,7 +1112,21 @@
"available": "Backups disponíveis",
"loading": "Carregando backups...",
"empty": "Nenhum backup encontrado",
"emptyHint": "Crie seu primeiro backup usando o botão acima"
"emptyHint": "Crie seu primeiro backup usando o botão acima",
"refresh": "Atualizar",
"refreshFailed": "Não foi possível atualizar as cópias de segurança. Atualize novamente para confirmar o seu estado atual.",
"deleteSelected": "Eliminar selecionadas ({count})",
"deletedCount": "{count} cópia(s) de segurança eliminada(s).",
"unconfirmedCount": "A eliminação de {count} cópia(s) de segurança não foi confirmada. Atualize a lista e verifique as tarefas do Proxmox antes de tentar novamente.",
"deleteFailed": "Não foi possível confirmar a eliminação. Atualize a lista e verifique as tarefas do Proxmox antes de tentar novamente.",
"selectHint": "Selecione cópias de segurança sem proteção (até 20 por lote)",
"selectBackup": "Selecionar cópia de segurança",
"protected": "Protegida",
"confirmTitle": "Eliminar {count} cópia(s) de segurança?",
"confirmDescription": "Estes arquivos de cópia de segurança da VM/CT {vmid} serão eliminados permanentemente. As cópias protegidas estão excluídas. Isto não elimina o convidado.",
"deleting": "À espera que o Proxmox termine de eliminar os arquivos selecionados…",
"cancel": "Cancelar",
"confirmDelete": "Eliminar cópias de segurança"
},
"updatesPanel": {
"pendingTitle": "Atualizações de pacote pendentes",
@@ -1721,7 +1737,17 @@
"portCategoryCustomPlaceholder": "Digite uma categoria e pressione Enter (Esc para cancelar)",
"portCustomUrlPlaceholder": "URL personalizada (por exemplo, https://vault.example.com) — substitui IP:port",
"stackMemberTitle": "Parte de um aplicativo multicontêiner",
"stackMemberBody": "Este contêiner faz parte de um aplicativo cujo contêiner principal é {primary}. O aplicativo, seus links da web e sua versão são mostrados lá."
"stackMemberBody": "Este contêiner faz parte de um aplicativo cujo contêiner principal é {primary}. O aplicativo, seus links da web e sua versão são mostrados lá.",
"nativeEvidencePort": "Porta TCP em escuta",
"nativeEvidenceFile": "Arquivo instalado",
"nativeEvidenceProcess": "Executável em execução",
"nativeEvidenceDirectory": "Diretório de trabalho",
"nativeEvidenceService": "Definição do serviço",
"nativeScanFailed": "A pesquisa de aplicativos não pôde ser concluída. Os resultados anteriores podem estar desatualizados; tente novamente.",
"nativeScanPartial": "A pesquisa de aplicativos está incompleta. Revise os itens encontrados e tente novamente.",
"nativeVersionUnknown": "Versão instalada desconhecida",
"nativeEvidenceStale": "Os itens encontrados podem estar desatualizados ou incompletos.",
"nativeRegisterOnly": "Confirme o registro no editor. Uma versão conhecida é salva como valor manual; nenhum atualizador é configurado."
},
"statusFilter": {
"ariaLabel": "Filtrar máquinas virtuais e contêineres",
@@ -1771,7 +1797,7 @@
},
"healthMonitor": {
"title": "Monitor de Saúde",
"description": "Configure por quanto tempo os alertas dispensados ​​permanecem ocultos para cada categoria. As alterações se aplicam a alertas ignorados existentes e futuros.",
"description": "Configure por quanto tempo os alertas dispensados permanecem ocultos para cada categoria. As alterações se aplicam a alertas ignorados existentes e futuros.",
"category": "Categoria",
"suppressionDuration": "Duração da Supressão",
"hours": "Horas",
@@ -1781,7 +1807,7 @@
"footerNote": "Essas configurações se aplicam quando você descarta um aviso do Health Monitor. Alertas críticos de temperatura da CPU sempre aparecem para proteger seu hardware.",
"activeSuppressions": "Supressões Ativas",
"activeSuppressionsDescription": "Alertas que você ocultou do Health Monitor. As demissões permanentes só podem ser revertidas aqui. A edição requer o Health Monitor",
"noActiveSuppressions": "Sem supressões ativas. Os alertas dispensados ​​do Health Monitor aparecerão aqui.",
"noActiveSuppressions": "Sem supressões ativas. Os alertas dispensados do Health Monitor aparecerão aqui.",
"permanent": "Permanente",
"daysRemaining": "{count}d restantes",
"hoursRemaining": "{count}h restantes",
@@ -1817,7 +1843,8 @@
"logs": "Registros do sistema",
"updates": "Atualizações do sistema",
"security": "Segurança e Certificados",
"temperature": "Temperatura"
"temperature": "Temperatura",
"pbsServices": "Serviços PBS"
}
},
"remoteStorage": {
@@ -2081,8 +2108,8 @@
"port": "Porta",
"tlsMode": "Modo TLS",
"noTls": "Nenhum (porta 25)",
"username": "Nome de usuário",
"password": "Senha",
"username": "Nome de utilizador",
"password": "Palavra-passe",
"appPassword": "Senha do aplicativo",
"fromAddress": "Do endereço",
"toAddresses": "Para endereços (separados por vírgula)",
@@ -2111,7 +2138,13 @@
"inactiveFooter": "Habilite notificações para receber alertas de sistema, integridade e segurança por meio dos canais configurados.",
"notificationLanguage": "Idioma de notificação",
"selectNotificationLanguage": "Selecione o idioma de notificação",
"notificationLanguageHint": "Usado para notificações determinísticas de tempo de execução, mesmo quando a IA está desabilitada."
"notificationLanguageHint": "Usado para notificações determinísticas de tempo de execução, mesmo quando a IA está desabilitada.",
"appriseServiceHelp": "Configuração do Slack e do Matrix",
"appriseSlackSteps": "Crie um webhook de entrada na sua aplicação Slack para o canal de destino e cole o seu URL HTTPS completo no campo URL do Apprise. Não é necessária qualquer conversão.",
"appriseSlackDocs": "Formatos de URL e configuração do Slack",
"appriseMatrixSteps": "Utilize uma conta de notificações que possa publicar na sala. Substitua o utilizador, a palavra-passe, o homeserver e o ID completo da sala do exemplo; codifique os caracteres especiais das credenciais para URL. O esquema matrixs usa HTTPS. Consulte a documentação sobre o suporte de tokens, SSO e salas cifradas na versão do Apprise instalada.",
"appriseMatrixDocs": "Formatos de URL e configuração do Matrix",
"appriseServiceFinish": "Ative o Apprise, introduza o seu URL, escolha as categorias de notificação ou os eventos individuais, guarde e utilize Enviar teste. Estes exemplos não alteram o URL guardado. Este canal do Apprise tem um destino; utilize a API do Apprise para vários serviços em simultâneo."
},
"ai": {
"title": "Aprimoramento de IA",
@@ -2336,8 +2369,8 @@
},
"login": {
"subtitle": "Faça login para acessar seu painel",
"username": "Nome de usuário",
"password": "Senha",
"username": "Nome de utilizador",
"password": "Palavra-passe",
"usernamePlaceholder": "Digite seu nome de usuário",
"passwordPlaceholder": "Digite sua senha",
"rememberMe": "Lembrar",
@@ -2378,9 +2411,9 @@
"skipFailed": "Falha ao ignorar a autenticação",
"savePreferenceFailed": "Falha ao salvar preferência",
"setupFailed": "Falha ao configurar a autenticação",
"username": "Nome de usuário",
"username": "Nome de utilizador",
"usernamePlaceholder": "Digite o nome de usuário",
"password": "Senha",
"password": "Palavra-passe",
"passwordPlaceholder": "Digite a senha",
"confirmPassword": "Confirme sua senha",
"confirmPasswordPlaceholder": "Confirme sua senha",
@@ -2523,9 +2556,9 @@
"enableHint": "Adicione um nome de usuário e uma senha antes de expor este painel fora da sua rede privada.",
"enable": "Habilitar autenticação",
"setupTitle": "Criar login",
"username": "Nome de usuário",
"username": "Nome de utilizador",
"usernamePlaceholder": "administrador",
"password": "Senha",
"password": "Palavra-passe",
"passwordPlaceholder": "Digite a senha",
"confirmPassword": "Confirme sua senha",
"confirmPasswordPlaceholder": "Digite a senha novamente",
@@ -2545,7 +2578,7 @@
"twoFactor": {
"title": "Autenticação de dois fatores",
"hint": "Adicione um código único de um aplicativo autenticador após a senha.",
"enable": "Habilitar 2FA",
"enable": "Ativar 2FA",
"enabled": "2FA está habilitado.",
"disable": "Desativar 2FA",
"disableTitle": "Desative a autenticação de dois fatores",
@@ -3567,7 +3600,7 @@
"hour": "Passada 1h",
"day": "Passadas 24 horas",
"week": "Passado 7 dias",
"month": "Passados ​​30 dias",
"month": "Passados 30 dias",
"year": "Último 1 ano"
},
"last": {
@@ -4019,7 +4052,7 @@
},
"backends": {
"borgDescription": "Repositório Borg para backups de host desduplicados.",
"borgDescriptionTimerOnly": "Os destinos Borg são usados ​​pelos temporizadores do Monitor, não pelos trabalhos PVE vzdump.",
"borgDescriptionTimerOnly": "Os destinos Borg são usados pelos temporizadores do Monitor, não pelos trabalhos PVE vzdump.",
"borgShortDescription": "Repositório Borg desduplicado",
"local": "Local",
"localDescription": "Pasta neste host ou em uma unidade USB montada.",
@@ -4250,7 +4283,7 @@
"onCalendarExpression": "Expressão OnCalendar",
"ownerLabel": "Proprietário",
"passphrase": "Senha",
"password": "Senha",
"password": "Palavra-passe",
"pathLabel": "Caminho",
"pbsRepository": "Repositório PBS",
"profileLabel": "Perfil",
@@ -4283,7 +4316,7 @@
"timeOfDay": "Hora do dia",
"type": "Tipo",
"used": "Usado",
"username": "Nome de usuário",
"username": "Nome de utilizador",
"vmsLxcs": "VMs/LXCs",
"when": "Quando",
"whenLabel": "Quando",
@@ -4547,7 +4580,7 @@
"customPickDescriptionMiddle": "através do",
"destructiveTitle": "A restauração completa REMOVERÁ o seguinte (criado após o backup)",
"noPathsMatch": "Nenhum caminho corresponde ao filtro.",
"noRestorablePaths": "Este backup não contém caminhos restauráveis ​​— apenas a restauração completa é significativa aqui.",
"noRestorablePaths": "Este backup não contém caminhos restauráveis — apenas a restauração completa é significativa aqui.",
"olderThanTargetKernel": "é mais antigo que o kernel alvo",
"operatorConfigDescription": "As seguintes configurações criadas pelo operador serão mescladas no destino automaticamente, sem alterar os padrões vinculados ao kernel. É executado na restauração completa e personalizada.",
"operatorConfigTitle": "Configuração do operador reaplicada por meio de mesclagem independente de kernel",
@@ -4756,6 +4789,24 @@
"thursday": "Quinta-feira",
"tuesday": "Terça-feira",
"wednesday": "Quarta-feira"
},
"storageRetention": {
"title": "Retenção do armazenamento",
"keepAll": "Manter todas as cópias de segurança",
"description": "Retenção predefinida de cópias de segurança guardada no Proxmox. As tarefas de cópia com uma política explícita substituem este valor predefinido.",
"refresh": "Atualizar",
"saved": "Retenção do armazenamento guardada. Não foi executada nenhuma limpeza.",
"choosePolicy": "Escolha Manter todas ou pelo menos uma quantidade de retenção superior a zero.",
"saveFailed": "Não foi possível guardar a retenção do armazenamento. Atualize a lista se a configuração tiver mudado.",
"loadFailed": "Não foi possível carregar a retenção do armazenamento.",
"empty": "Não foram encontrados armazenamentos de cópias de segurança.",
"default": "Predefinição do Proxmox",
"edit": "Editar",
"editTitle": "Retenção para {storage}",
"effect": "Aplica-se às futuras cópias de segurança que herdem esta política de armazenamento. Guardar não elimina as cópias existentes. As cópias protegidas permanecem protegidas.",
"cancel": "Cancelar",
"save": "Guardar retenção",
"useDefault": "Use o padrão Proxmox (nenhuma política neste armazenamento)"
}
},
"healthStatus": {
@@ -4786,7 +4837,8 @@
"services": "Serviços PVE",
"logs": "Registros do sistema",
"updates": "Atualizações do sistema",
"security": "Segurança e certificados"
"security": "Segurança e certificados",
"pbsServices": "serviços PBS"
},
"checks": {
"cpu_usage": "Uso da CPU",
@@ -4912,7 +4964,7 @@
},
"noIssues": "Nenhum problema detectado",
"updateNow": "Atualizar agora",
"dismissedItems": "Itens dispensados ​​({count})",
"dismissedItems": "Itens dispensados ({count})",
"permanent": "Permanente",
"dismissed": "Dispensado",
"wasStatus": "era {status}",
@@ -4949,7 +5001,7 @@
"hint": "PNG, JPEG, WebP ou GIF. Até 2 MB. Use uma imagem quadrada ou corte-a primeiro para obter o melhor resultado no cabeçalho."
},
"username": {
"label": "Nome de usuário",
"label": "Nome de utilizador",
"help": "Este é o nome de login. Para alterá-lo, desative a autenticação e configure-a novamente em Segurança."
},
"displayName": {
@@ -7182,7 +7234,7 @@
"latency": "Latência",
"event": "Evento",
"sourceIp": "IP de origem",
"username": "Nome de usuário",
"username": "Nome de utilizador",
"service": "Serviço",
"jail": "Jail",
"failures": "Falhas",
@@ -7230,5 +7282,99 @@
"diagnosticsOmitted": "Linhas ou texto de diagnóstico omitidos: {count}. Relatório original preservado."
}
}
},
"pbs": {
"title": "Proxmox Backup Server",
"server": "Servidor PBS",
"chooseServer": "Escolher um servidor PBS",
"local": "PBS local",
"refresh": "Atualizar",
"manage": "Gerir ligações PBS",
"loadFailed": "Não foi possível carregar o estado do PBS.",
"loading": "A carregar dados do PBS…",
"noServer": "Adicione uma ligação PBS para ver os armazenamentos de dados, tarefas e agendamentos.",
"permissionsHelp": "Ative a autenticação do Monitor antes de guardar credenciais. Utilize um token API PBS dedicado com a função Audit em / nas ACL do utilizador e do token, com propagação. Este painel apenas lê dados do PBS.",
"editConnection": "Ligação a editar",
"add": "Adicionar uma nova ligação",
"name": "Nome",
"url": "URL HTTPS (inclua :8007 para a porta nativa do PBS)",
"token_id": "ID do token API (user@realm!token)",
"token_secret": "Segredo do token API (deixe vazio para manter o segredo guardado)",
"fingerprint": "Impressão digital SHA256 do certificado (opcional)",
"tlsHelp": "Deixe a impressão digital vazia para a verificação normal da autoridade de certificação e do nome de anfitrião. Para um certificado autoassinado, copie a sua impressão digital SHA256 da consola PBS ou de outro canal de confiança. A impressão digital é verificada antes do envio de credenciais.",
"saved": "Ligação PBS verificada e guardada.",
"saveFailed": "Não foi possível guardar a ligação PBS.",
"testing": "A verificar a ligação…",
"save": "Verificar e guardar",
"remove": "Remover ligação",
"removeConfirm": "Remover do Monitor esta ligação PBS guardada e o respetivo token? Os dados e as tarefas PBS não são afetados.",
"sampled": "Recolhido em",
"datastores": "Armazenamentos de dados",
"total": "Capacidade do sistema de ficheiros",
"used": "Utilizado",
"free": "Disponível",
"noDatastores": "Nenhum armazenamento de dados acessível. Verifique a configuração e as permissões do utilizador e do token.",
"tasks": "Tarefas recentes",
"noTasks": "Não foram devolvidas tarefas recentes.",
"task": "Tarefa",
"started": "Iniciada",
"status": "Estado",
"running": "Em execução",
"jobs": "Agendamentos de manutenção",
"job": "Tarefa agendada",
"schedule": "Agendamento",
"sync": "Sincronização",
"verify": "Verificação",
"prune": "Limpeza",
"overview": "Visão geral",
"logs": "Registos do sistema",
"terminal": "Terminal",
"security": "Segurança",
"logout": "Terminar sessão",
"navigation": "Navegação PBS",
"cpu": "CPU",
"memory": "Memória",
"uptime": "Tempo de atividade",
"capacityUnavailable": "Capacidade não disponível.",
"datastoresUnavailable": "{count} indisponível",
"datastoresOnline": "Todos disponíveis",
"moreDatastores": "+{count} mais armazenamentos de dados",
"estimatedFull": "estimativa completa",
"estimatedFullUnknown": "Not enough data yet",
"duration": "Duração",
"noJobs": "nenhuma programação de sincronização, verificação ou remoção está configurada."
},
"monitorLinks": {
"groups": {
"cluster": "Cluster",
"standalone": "nós autônomos",
"pbs": "Proxmox Backup Server"
},
"thisNode": "Este nó",
"checking": "Verificando nós…",
"loadFailed": "não foi possível carregar a lista de nós.",
"empty": "Ainda não há outros nós. Adicione um para abrir o seu Monitor a partir daqui.",
"noResponse": "Sem resposta",
"pbsInterface": "interface PBS",
"openNewTab": "abrir em uma nova guia",
"manage": "Adicionar ou gerenciar nós…",
"dialogTitle": "nós vinculados",
"dialogDescription": "Os nós do cluster e os Proxmox Backup Server configurados são detectados automaticamente. Cada Monitor mantém o seu próprio login.",
"addTitle": "Adicionar um nó",
"name": "Nome",
"address": "endereço do monitor",
"type": "Tipo",
"add": "Adicionar",
"save": "Salvar",
"remove": "Remover",
"visible": "Visível",
"autoAddress": "Automático",
"detected": "Detectado",
"added": "Adicionado",
"saveFailed": "não foi possível salvar a lista de nós.",
"backTo": "Voltar para {name}",
"cameFromTitle": "Adicionar nó Monitor?",
"cameFromBody": "Adicione {name} aos nós vinculados deste Monitor.",
"notNow": "agora não"
}
}
+152 -6
View File
@@ -7,7 +7,8 @@
"unknown": "Neznáme",
"notAvailable": "Nedostupné",
"serverOffline": "Server je offline",
"supportProject": "Podporte projekt alebo prispejte vlastnými úpravami"
"supportProject": "Podporte projekt alebo prispejte vlastnými úpravami",
"descriptionPbs": "Proxmox Backup Server Dashboard"
},
"common": {
"notAvailable": "Nedostupné",
@@ -164,7 +165,8 @@
"errors": {
"serverUnavailableTitle": "Server Monitora nie je dostupný",
"serverUnavailableDescription": "Nedá sa pripojiť k serveru Monitora. Skontrolujte, či služba beží, a skúste to znova."
}
},
"pbsVersion": "Verzia PBS:"
},
"storage": {
"loadingTitle": "Načítavam úložiská...",
@@ -1131,7 +1133,21 @@
"available": "Dostupné zálohy",
"loading": "Načítavam zálohy...",
"empty": "Nenašli sa žiadne zálohy",
"emptyHint": "Prvú zálohu vytvoríte tlačidlom vyššie"
"emptyHint": "Prvú zálohu vytvoríte tlačidlom vyššie",
"refresh": "Obnoviť",
"refreshFailed": "Nepodarilo sa obnoviť zálohy. Obnovte ich znova, aby ste potvrdili ich aktuálny stav.",
"deleteSelected": "Odstrániť vybrané ({count})",
"deletedCount": "Odstránené zálohy: {count}.",
"unconfirmedCount": "Odstránenie záloh ({count}) nebolo potvrdené. Pred ďalším pokusom obnovte zoznam a skontrolujte úlohy v Proxmoxe.",
"deleteFailed": "Odstránenie sa nepodarilo potvrdiť. Pred ďalším pokusom obnovte zoznam a skontrolujte úlohy v Proxmoxe.",
"selectHint": "Vyberte nechránené zálohy (najviac 20 v jednej dávke)",
"selectBackup": "Vybrať zálohu",
"protected": "Chránená",
"confirmTitle": "Odstrániť zálohy ({count})?",
"confirmDescription": "Tieto archívy záloh pre VM/CT {vmid} budú natrvalo odstránené. Chránené zálohy sú vylúčené. Hosť sa tým neodstráni.",
"deleting": "Čaká sa, kým Proxmox dokončí odstránenie vybraných archívov…",
"cancel": "Zrušiť",
"confirmDelete": "Odstrániť zálohy"
},
"updatesPanel": {
"pendingTitle": "Čakajúce aktualizácie balíkov",
@@ -1668,6 +1684,16 @@
"searchApplications": "Hľadať aplikácie",
"searchingApplications": "Hľadajú sa aplikácie…",
"noApplicationsDetected": "Neboli zistené žiadne aplikácie.",
"nativeEvidencePort": "Otvorený TCP port",
"nativeEvidenceFile": "Nainštalovaný súbor",
"nativeEvidenceProcess": "Spustený program",
"nativeEvidenceDirectory": "Pracovný priečinok",
"nativeEvidenceService": "Definícia služby",
"nativeScanFailed": "Vyhľadávanie aplikácií sa nepodarilo dokončiť. Predchádzajúce dôkazy môžu byť zastarané; skús to znova.",
"nativeScanPartial": "Vyhľadávanie aplikácií je neúplné. Skontroluj dostupné dôkazy a skús to znova.",
"nativeVersionUnknown": "Nainštalovaná verzia nie je známa",
"nativeEvidenceStale": "Dôkazy môžu byť zastarané alebo neúplné.",
"nativeRegisterOnly": "Registráciu potvrď v editore. Známa verzia je ručný údaj; aktualizátor nie je nastavený.",
"noNewApplicationsDetected": "Neboli zistené žiadne nové aplikácie.",
"oneNewApplicationDetected": "Bola zistená jedna nová aplikácia.",
"newApplicationsDetected": "Boli zistené {count} nové aplikácie.",
@@ -1816,7 +1842,8 @@
"logs": "Systémové záznamy",
"updates": "Aktualizácie systému",
"security": "Bezpečnosť a certifikáty",
"temperature": "Teplota"
"temperature": "Teplota",
"pbsServices": "Služby PBS"
}
},
"remoteStorage": {
@@ -2110,7 +2137,13 @@
"inactiveFooter": "Zapnite notifikácie a dostávajte upozornenia o systéme, stave a bezpečnosti cez nastavené kanály.",
"notificationLanguage": "Jazyk notifikácií",
"selectNotificationLanguage": "Vyberte jazyk notifikácií",
"notificationLanguageHint": "Používa sa pre spoľahlivé runtime notifikácie aj vtedy, keď je AI vypnutá."
"notificationLanguageHint": "Používa sa pre spoľahlivé runtime notifikácie aj vtedy, keď je AI vypnutá.",
"appriseServiceHelp": "Nastavenie Slack a Matrix",
"appriseSlackSteps": "V aplikácii Slack vytvorte prichádzajúci webhook pre cieľový kanál a vložte jeho úplnú HTTPS URL do poľa URL Apprise. Nie je potrebný žiadny prevod.",
"appriseSlackDocs": "Formáty URL a nastavenie Slack",
"appriseMatrixSteps": "Použite účet pre oznámenia, ktorý môže odosielať správy do miestnosti. Nahraďte vzorového používateľa, heslo, domovský server a úplné ID miestnosti; špeciálne znaky v prihlasovacích údajoch zakódujte pre URL. Schéma matrixs používa HTTPS. Podporu tokenov, SSO a šifrovaných miestností v nainštalovanej verzii Apprise nájdete v dokumentácii.",
"appriseMatrixDocs": "Formáty URL a nastavenie Matrix",
"appriseServiceFinish": "Povoľte Apprise, zadajte svoju URL, vyberte kategórie oznámení alebo jednotlivé udalosti, uložte a potom použite Odoslať test. Tieto príklady nemenia uloženú URL. Tento kanál Apprise má jeden cieľ; pre viac služieb naraz použite API Apprise."
},
"ai": {
"title": "AI vylepšenie",
@@ -4755,6 +4788,24 @@
"thursday": "štvrtok",
"tuesday": "utorok",
"wednesday": "streda"
},
"storageRetention": {
"title": "Uchovávanie záloh úložiska",
"keepAll": "Ponechať všetky zálohy",
"description": "Predvolená politika uchovávania záloh uložená v Proxmoxe. Zálohovacie úlohy s vlastnou politikou majú pred týmto predvoleným nastavením prednosť.",
"refresh": "Obnoviť",
"saved": "Politika uchovávania záloh úložiska bola uložená. Prerezávanie sa nevykonalo.",
"choosePolicy": "Vyberte Ponechať všetky alebo aspoň jeden počet uchovávaných záloh väčší ako nula.",
"saveFailed": "Nepodarilo sa uložiť politiku uchovávania záloh úložiska. Ak sa konfigurácia zmenila, obnovte zoznam.",
"loadFailed": "Nepodarilo sa načítať politiku uchovávania záloh úložiska.",
"empty": "Nenašli sa žiadne úložiská záloh.",
"default": "Predvolené nastavenie Proxmoxu",
"edit": "Upraviť",
"editTitle": "Uchovávanie záloh pre {storage}",
"effect": "Platí pre budúce zálohovania, ktoré preberajú túto politiku úložiska. Uložením sa existujúce zálohy neodstránia. Chránené zálohy zostanú chránené.",
"cancel": "Zrušiť",
"save": "Uložiť uchovávanie záloh",
"useDefault": "Použite predvolené nastavenie Proxmox (žiadne pravidlá pre toto úložisko)"
}
},
"healthStatus": {
@@ -4785,7 +4836,8 @@
"services": "Služby PVE",
"logs": "Systémové záznamy",
"updates": "Aktualizácie systému",
"security": "Bezpečnosť a certifikáty"
"security": "Bezpečnosť a certifikáty",
"pbsServices": "Služby PBS"
},
"checks": {
"cpu_usage": "Využitie CPU",
@@ -7229,5 +7281,99 @@
"diagnosticsOmitted": "Ďalšie diagnostické riadky alebo text vynechaný: {count}. Pôvodná správa sa zachovala."
}
}
},
"pbs": {
"title": "Proxmox Backup Server",
"server": "Server PBS",
"chooseServer": "Vybrať server PBS",
"local": "Miestny PBS",
"refresh": "Obnoviť",
"manage": "Spravovať pripojenia PBS",
"loadFailed": "Nepodarilo sa načítať stav PBS.",
"loading": "Načítavajú sa údaje PBS…",
"noServer": "Pridajte pripojenie PBS na zobrazenie dátových úložísk, úloh a plánov.",
"permissionsHelp": "Pred uložením prihlasovacích údajov povoľte overovanie v Monitore. Použite vyhradený API token PBS s rolou Audit na / v ACL používateľa aj tokenu, so zapnutým dedením. Tento panel údaje PBS iba číta.",
"editConnection": "Pripojenie na úpravu",
"add": "Pridať nové pripojenie",
"name": "Názov",
"url": "HTTPS URL (pre pôvodný port PBS uveďte :8007)",
"token_id": "ID API tokenu (user@realm!token)",
"token_secret": "Tajný kľúč API tokenu (ponechajte prázdne, ak chcete zachovať uložený kľúč)",
"fingerprint": "Odtlačok SHA256 certifikátu (voliteľný)",
"tlsHelp": "Pre bežné overenie certifikačnej autority a názvu hostiteľa ponechajte odtlačok prázdny. Pri certifikáte s vlastným podpisom skopírujte jeho odtlačok SHA256 z konzoly PBS alebo iného dôveryhodného kanála. Odtlačok sa kontroluje pred odoslaním prihlasovacích údajov.",
"saved": "Pripojenie PBS bolo overené a uložené.",
"saveFailed": "Nepodarilo sa uložiť pripojenie PBS.",
"testing": "Overuje sa pripojenie…",
"save": "Overiť a uložiť",
"remove": "Odstrániť pripojenie",
"removeConfirm": "Odstrániť z Monitora toto uložené pripojenie PBS a jeho token? Údaje a úlohy PBS zostanú nezmenené.",
"sampled": "Údaje získané",
"datastores": "Dátové úložiská",
"total": "Kapacita súborového systému",
"used": "Použité",
"free": "Dostupné",
"noDatastores": "Žiadne dostupné dátové úložiská. Skontrolujte konfiguráciu úložísk a oprávnenia používateľa a tokenu.",
"tasks": "Nedávne úlohy",
"noTasks": "Neboli vrátené žiadne nedávne úlohy.",
"task": "Úloha",
"started": "Spustené",
"status": "Stav",
"running": "Prebieha",
"jobs": "Plány údržby",
"job": "Plánovaná úloha",
"schedule": "Plán",
"sync": "Synchronizácia",
"verify": "Overenie",
"prune": "Prerezávanie",
"overview": "Prehľad",
"logs": "Systémové záznamy",
"terminal": "Terminál",
"security": "Zabezpečenie",
"logout": "Odhlásiť sa",
"navigation": "Navigácia PBS",
"cpu": "CPU",
"memory": "Pamäť",
"uptime": "Čas prevádzky",
"capacityUnavailable": "Kapacita nie je k dispozícii.",
"datastoresUnavailable": "{count} nie je k dispozícii",
"datastoresOnline": "Všetky dostupné",
"moreDatastores": "+{count} ďalších dátových úložísk",
"estimatedFull": "Odhadovaný plný",
"estimatedFullUnknown": "Zatiaľ nie je dostatok údajov",
"duration": "Trvanie",
"noJobs": "Nie sú nakonfigurované žiadne plány synchronizácie, overovania ani odstraňovania."
},
"monitorLinks": {
"groups": {
"cluster": "Cluster",
"standalone": "Samostatné uzly",
"pbs": "Proxmox Backup Server"
},
"thisNode": "Tento uzol",
"checking": "Kontrolujú sa uzly…",
"loadFailed": "Nepodarilo sa načítať zoznam uzlov.",
"empty": "Zatiaľ žiadne ďalšie uzly. Pridajte jeden, aby ste odtiaľto otvorili jeho Monitor.",
"noResponse": "Žiadna odpoveď",
"pbsInterface": "rozhranie PBS",
"openNewTab": "Otvoriť na novej karte",
"manage": "Pridať alebo spravovať uzly…",
"dialogTitle": "Prepojené uzly",
"dialogDescription": "Uzly klastra a nakonfigurované Proxmox Backup Server sa zisťujú automaticky. Každý Monitor má vlastné prihlásenie.",
"addTitle": "Pridať uzol",
"name": "Meno",
"address": "Adresa monitora",
"type": "Typ",
"add": "Pridať",
"save": "Uložiť",
"remove": "Odstrániť",
"visible": "Viditeľné",
"autoAddress": "Automaticky",
"detected": "Zistené",
"added": "Pridané",
"saveFailed": "Nepodarilo sa uložiť zoznam uzlov.",
"backTo": "Späť na {name}",
"cameFromTitle": "Pridať uzol monitora?",
"cameFromBody": "Pridajte {name} do prepojených uzlov tohto monitora.",
"notNow": "Teraz nie"
}
}
+155 -9
View File
@@ -8,7 +8,8 @@
"unknown": "Okänd",
"notAvailable": "N/A",
"serverOffline": "Servern är frånkopplad",
"supportProject": "Stöd projektet och bidra"
"supportProject": "Stöd projektet och bidra",
"descriptionPbs": "Proxmox Backup Server Dashboard"
},
"common": {
"notAvailable": "N/A",
@@ -165,7 +166,8 @@
},
"runningCountOne": "{count} körs",
"runningCountFew": "{count} körs",
"runningCountMany": "{count} körs"
"runningCountMany": "{count} körs",
"pbsVersion": "PBS-version:"
},
"storage": {
"loadingTitle": "Laddar lagringsdata...",
@@ -1110,7 +1112,21 @@
"available": "Tillgängliga säkerhetskopior",
"loading": "Laddar säkerhetskopior...",
"empty": "Inga säkerhetskopior hittades",
"emptyHint": "Skapa din första säkerhetskopia med knappen ovan"
"emptyHint": "Skapa din första säkerhetskopia med knappen ovan",
"refresh": "Uppdatera",
"refreshFailed": "Det gick inte att uppdatera säkerhetskopiorna. Uppdatera igen för att bekräfta deras aktuella tillstånd.",
"deleteSelected": "Ta bort markerade ({count})",
"deletedCount": "{count} säkerhetskopior har tagits bort.",
"unconfirmedCount": "Borttagning av {count} säkerhetskopior har inte bekräftats. Uppdatera listan och kontrollera Proxmox uppgifter innan du försöker igen.",
"deleteFailed": "Borttagningen kunde inte bekräftas. Uppdatera listan och kontrollera Proxmox uppgifter innan du försöker igen.",
"selectHint": "Välj oskyddade säkerhetskopior (upp till 20 per omgång)",
"selectBackup": "Välj säkerhetskopia",
"protected": "Skyddad",
"confirmTitle": "Ta bort {count} säkerhetskopior?",
"confirmDescription": "Dessa säkerhetskopieringsarkiv för VM/CT {vmid} kommer att tas bort permanent. Skyddade säkerhetskopior är undantagna. Detta tar inte bort gästen.",
"deleting": "Väntar på att Proxmox ska slutföra borttagningen av de valda arkiven…",
"cancel": "Avbryt",
"confirmDelete": "Ta bort säkerhetskopior"
},
"updatesPanel": {
"pendingTitle": "Väntande paketuppdateringar",
@@ -1721,7 +1737,17 @@
"portCategoryCustomPlaceholder": "Skriv en kategori och tryck på Enter (Esc för att avbryta)",
"portCustomUrlPlaceholder": "Anpassad URL (t.ex. https://vault.example.com) - åsidosätter IP:port",
"stackMemberTitle": "En del av en multicontainerapplikation",
"stackMemberBody": "Den här behållaren stöder en applikation vars huvudbehållare är {primary}. Applikationen, dess webblänkar och dess version visas där."
"stackMemberBody": "Den här behållaren stöder en applikation vars huvudbehållare är {primary}. Applikationen, dess webblänkar och dess version visas där.",
"nativeEvidencePort": "Lyssnande TCP-port",
"nativeEvidenceFile": "Installerad fil",
"nativeEvidenceProcess": "Körande programfil",
"nativeEvidenceDirectory": "Arbetskatalog",
"nativeEvidenceService": "Tjänstdefinition",
"nativeScanFailed": "Programsökningen kunde inte slutföras. Tidigare resultat kan vara inaktuella; försök igen.",
"nativeScanPartial": "Programsökningen är ofullständig. Granska det som hittades och försök igen.",
"nativeVersionUnknown": "Installerad version okänd",
"nativeEvidenceStale": "Det som hittades kan vara inaktuellt eller ofullständigt.",
"nativeRegisterOnly": "Bekräfta registreringen i redigeraren. En känd version sparas som ett manuellt värde; ingen uppdatering konfigureras."
},
"statusFilter": {
"ariaLabel": "Filtrera virtuella maskiner och containrar",
@@ -1817,7 +1843,8 @@
"logs": "Systemloggar",
"updates": "Systemuppdateringar",
"security": "Säkerhet och certifikat",
"temperature": "Temperatur"
"temperature": "Temperatur",
"pbsServices": "PBS Services"
}
},
"remoteStorage": {
@@ -2078,7 +2105,7 @@
"webhookUrl": "Webhook URL",
"enableEmail": "Aktivera e-post",
"smtpHost": "SMTP-värd",
"port": "Hamn",
"port": "Port",
"tlsMode": "TLS-läge",
"noTls": "Ingen (port 25)",
"username": "Användarnamn",
@@ -2111,7 +2138,13 @@
"inactiveFooter": "Aktivera aviseringar för att ta emot system-, hälso- och säkerhetsvarningar via de konfigurerade kanalerna.",
"notificationLanguage": "Aviseringsspråk",
"selectNotificationLanguage": "Välj språk för meddelanden",
"notificationLanguageHint": "Används för deterministiska körtidsmeddelanden även när AI är inaktiverat."
"notificationLanguageHint": "Används för deterministiska körtidsmeddelanden även när AI är inaktiverat.",
"appriseServiceHelp": "Konfiguration av Slack och Matrix",
"appriseSlackSteps": "Skapa en inkommande webhook i din Slack-app för målkanalen och klistra sedan in dess fullständiga HTTPS-URL i fältet Apprise-URL. Ingen omvandling behövs.",
"appriseSlackDocs": "URL-format och konfiguration för Slack",
"appriseMatrixSteps": "Använd ett aviseringskonto som får skicka meddelanden till rummet. Ersätt exemplets användare, lösenord, hemserver och fullständiga rums-ID; URL-koda specialtecken i inloggningsuppgifterna. Schemat matrixs använder HTTPS. Se dokumentationen för stöd för token, SSO och krypterade rum i din installerade Apprise-version.",
"appriseMatrixDocs": "URL-format och konfiguration för Matrix",
"appriseServiceFinish": "Aktivera Apprise, ange din URL, välj aviseringskategorier eller enskilda händelser, spara och använd sedan Skicka test. Dessa exempel ändrar inte din sparade URL. Denna Apprise-kanal har ett mål; använd Apprise-API för flera tjänster samtidigt."
},
"ai": {
"title": "AI-förbättring",
@@ -2690,7 +2723,7 @@
"interfaceOptional": "Gränssnitt (valfritt)",
"applyTo": "Ansök till",
"commentOptional": "Kommentar (valfritt)",
"port": "Hamn",
"port": "Port",
"source": "Källa",
"level": "Nivå",
"interface": "Gränssnitt",
@@ -4756,6 +4789,24 @@
"thursday": "torsdag",
"tuesday": "tisdag",
"wednesday": "onsdag"
},
"storageRetention": {
"title": "Lagringens bevarandepolicy",
"keepAll": "Behåll alla säkerhetskopior",
"description": "Standardpolicy för bevarande av säkerhetskopior, sparad i Proxmox. Säkerhetskopieringsjobb med en uttrycklig policy åsidosätter denna standard.",
"refresh": "Uppdatera",
"saved": "Lagringens bevarandepolicy har sparats. Ingen gallring har körts.",
"choosePolicy": "Välj Behåll alla eller minst ett bevarandeantal som är större än noll.",
"saveFailed": "Det gick inte att spara lagringens bevarandepolicy. Uppdatera listan om konfigurationen har ändrats.",
"loadFailed": "Det gick inte att läsa in lagringens bevarandepolicy.",
"empty": "Ingen lagring för säkerhetskopior hittades.",
"default": "Proxmox standard",
"edit": "Redigera",
"editTitle": "Bevarande för {storage}",
"effect": "Gäller framtida säkerhetskopieringar som ärver denna lagringspolicy. Att spara tar inte bort befintliga säkerhetskopior. Skyddade säkerhetskopior förblir skyddade.",
"cancel": "Avbryt",
"save": "Spara bevarandepolicy",
"useDefault": "Använd Proxmox-standard (ingen policy för denna lagring)"
}
},
"healthStatus": {
@@ -4786,7 +4837,8 @@
"services": "PVE-tjänster",
"logs": "Systemloggar",
"updates": "Systemuppdateringar",
"security": "Säkerhet & certifikat"
"security": "Säkerhet & certifikat",
"pbsServices": "PBS-tjänster"
},
"checks": {
"cpu_usage": "CPU-användning",
@@ -7230,5 +7282,99 @@
"diagnosticsOmitted": "Utelämnade diagnosrader eller text: {count}. Originalrapporten bevaras."
}
}
},
"pbs": {
"title": "Proxmox Backup Server",
"server": "PBS-server",
"chooseServer": "Välj en PBS-server",
"local": "Lokal PBS",
"refresh": "Uppdatera",
"manage": "Hantera PBS-anslutningar",
"loadFailed": "Det gick inte att läsa in PBS-status.",
"loading": "Läser in PBS-data…",
"noServer": "Lägg till en PBS-anslutning för att visa datalager, uppgifter och scheman.",
"permissionsHelp": "Aktivera Monitor-autentisering innan du sparar inloggningsuppgifter. Använd en särskild PBS-API-token med rollen Audit på / i både användarens och tokenens ACL, med nedärvning. Denna panel läser endast PBS-data.",
"editConnection": "Anslutning att redigera",
"add": "Lägg till en ny anslutning",
"name": "Namn",
"url": "HTTPS-URL (ta med :8007 för PBS ordinarie port)",
"token_id": "API-token-ID (user@realm!token)",
"token_secret": "API-tokenhemlighet (lämna tomt för att behålla den sparade hemligheten)",
"fingerprint": "Certifikatets SHA256-fingeravtryck (valfritt)",
"tlsHelp": "Lämna fingeravtrycket tomt för vanlig kontroll av certifikatutfärdare och värdnamn. För ett självsignerat certifikat kopierar du dess SHA256-fingeravtryck från PBS-konsolen eller en annan betrodd kanal. Fingeravtrycket kontrolleras innan inloggningsuppgifter skickas.",
"saved": "PBS-anslutningen har verifierats och sparats.",
"saveFailed": "Det gick inte att spara PBS-anslutningen.",
"testing": "Verifierar anslutningen…",
"save": "Verifiera och spara",
"remove": "Ta bort anslutningen",
"removeConfirm": "Ta bort denna sparade PBS-anslutning och dess token från Monitor? PBS-data och jobb påverkas inte.",
"sampled": "Avläst",
"datastores": "Datalager",
"total": "Filsystemets kapacitet",
"used": "Använt",
"free": "Tillgängligt",
"noDatastores": "Inga tillgängliga datalager. Kontrollera datalagrets konfiguration samt användarens och tokenens behörigheter.",
"tasks": "Senaste uppgifter",
"noTasks": "Inga senaste uppgifter returnerades.",
"task": "Uppgift",
"started": "Startad",
"status": "Status",
"running": "Körs",
"jobs": "Underhållsscheman",
"job": "Jobb",
"schedule": "Schema",
"sync": "Synkronisering",
"verify": "Verifiering",
"prune": "Gallring",
"overview": "Översikt",
"logs": "Systemloggar",
"terminal": "Terminal",
"security": "Säkerhet",
"logout": "Logga ut",
"navigation": "PBS-navigering",
"cpu": "CPU",
"memory": "Minne",
"uptime": "Drifttid",
"capacityUnavailable": "Kapacitet inte tillgänglig.",
"datastoresUnavailable": "{count} inte tillgänglig",
"datastoresOnline": "Alla tillgängliga",
"moreDatastores": "+{count} fler databutiker",
"estimatedFull": "Beräknad full",
"estimatedFullUnknown": "Inte tillräckligt med data ännu",
"duration": "Varaktighet",
"noJobs": "Inga synkroniserings-, verifierings- eller beskärningsscheman är konfigurerade."
},
"monitorLinks": {
"groups": {
"cluster": "Kluster",
"standalone": "Fristående noder",
"pbs": "Proxmox Backup Server"
},
"thisNode": "Denna nod",
"checking": "Kontrollerar noder...",
"loadFailed": "Kunde inte ladda nodlistan.",
"empty": "Inga andra noder än. Lägg till en för att öppna dess Monitor härifrån.",
"noResponse": "Inget svar",
"pbsInterface": "PBS-gränssnitt",
"openNewTab": "Öppna i en ny flik",
"manage": "Lägg till eller hantera noder...",
"dialogTitle": "Länkade noder",
"dialogDescription": "Klusternoder och konfigurerade Proxmox Backup Server identifieras automatiskt. Varje Monitor behåller sin egen inloggning.",
"addTitle": "Lägg till en nod",
"name": "Namn",
"address": "Övervaka adress",
"type": "Typ",
"add": "Lägg till",
"save": "Spara",
"remove": "Ta bort",
"visible": "Synlig",
"autoAddress": "Automatisk",
"detected": "Upptäckt",
"added": "Tillagd",
"saveFailed": "Kunde inte spara nodlistan.",
"backTo": "Tillbaka till {name}",
"cameFromTitle": "Lägg till monitornod?",
"cameFromBody": "Lägg till {name} till de länkade noderna på denna monitor.",
"notNow": "Inte nu"
}
}
+3
View File
@@ -1115,6 +1115,9 @@ def _detect_proxmox_certificate_paths():
return PROXMOX_CUSTOM_CERT_PATH, PROXMOX_CUSTOM_KEY_PATH
if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
return PROXMOX_CERT_PATH, PROXMOX_KEY_PATH
pbs_cert, pbs_key = "/etc/proxmox-backup/proxy.pem", "/etc/proxmox-backup/proxy.key"
if os.path.isfile(pbs_cert) and os.path.isfile(pbs_key):
return pbs_cert, pbs_key
return "", ""
+8
View File
@@ -141,6 +141,7 @@ cp "$SCRIPT_DIR/oci_console_logs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
cp "$SCRIPT_DIR/oci_instance_info.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_instance_info.py not found"
cp "$SCRIPT_DIR/oci_operations.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_operations.py not found"
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
cp "$SCRIPT_DIR/lvm_device_scope.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/smartctl_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ smartctl_resolver.py not found"
cp "$SCRIPT_DIR/disk_identity.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_identity.py not found"
cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_thresholds.py not found"
@@ -148,6 +149,7 @@ cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
cp "$SCRIPT_DIR/lxc_update_observation.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/native_app_discovery.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found"
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
@@ -160,6 +162,11 @@ cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || ech
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
cp "$SCRIPT_DIR/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ temperature_sensor_resolver.py not found"
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
cp "$SCRIPT_DIR/pbs_manager.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_pbs_routes.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/monitor_links.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_monitor_links_routes.py" "$APP_DIR/usr/bin/"
cp "$APPIMAGE_ROOT/../scripts/global/proxmox_platform.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
cp "$SCRIPT_DIR/flask_security_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_security_routes.py not found"
@@ -181,6 +188,7 @@ done
cp "$SCRIPT_DIR/oci_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_manager.py not found"
cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_oci_routes.py not found"
cp "$SCRIPT_DIR/flask_vm_backup_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_vm_backup_routes.py not found"
cp "$SCRIPT_DIR/guest_backup_archives.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_audit_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_audit_routes.py not found"
cp "$SCRIPT_DIR/audit_store.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_store.py not found"
cp "$SCRIPT_DIR/audit_checks.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks.py not found"
@@ -526,6 +526,16 @@ def disk_read_policy(disk_name: str, excluded: Optional[set] = None) -> str:
return IDLE
def settle_own_reads(disk_names) -> None:
"""A reader that has just read these disks itself takes their counters as
the new baseline, so its own I/O never counts as activity."""
stats = _read_diskstats()
with _cache_lock:
for name in disk_names:
if name in _last_io and name in stats:
_last_io[name] = stats[name]
def is_disk_idle(disk_name: str) -> bool:
"""True while the disk is being left alone for having no I/O."""
ts = _idle_state.get(disk_name)
+16 -5
View File
@@ -560,10 +560,12 @@ def totp_setup():
"""Initialize TOTP setup for a user"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
username = auth_manager.verify_token(token)
username, scope = auth_manager.verify_token_full(token)
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
if scope != 'full_admin':
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
success, secret, qr_code, backup_codes, message = auth_manager.setup_totp(username)
@@ -586,10 +588,12 @@ def totp_enable():
"""Enable TOTP after verification"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
username = auth_manager.verify_token(token)
username, scope = auth_manager.verify_token_full(token)
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
if scope != 'full_admin':
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
data = request.json
verification_token = data.get('token')
@@ -612,10 +616,12 @@ def totp_disable():
"""Disable TOTP (requires password confirmation)"""
try:
token = request.headers.get('Authorization', '').replace('Bearer ', '')
username = auth_manager.verify_token(token)
username, scope = auth_manager.verify_token_full(token)
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
if scope != 'full_admin':
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
data = request.json or {}
password = data.get('password')
@@ -653,10 +659,12 @@ def generate_api_token():
if not token:
return jsonify({"success": False, "message": "Unauthorized. Please log in first."}), 401
username = auth_manager.verify_token(token)
username, scope = auth_manager.verify_token_full(token)
if not username:
return jsonify({"success": False, "message": "Invalid or expired session. Please log in again."}), 401
if scope != 'full_admin':
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
data = request.json
password = data.get('password')
@@ -749,8 +757,11 @@ def revoke_api_token_route(token_id):
return jsonify({"success": False, "message": "Authentication is not configured"}), 400
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token or not auth_manager.verify_token(token):
username, scope = auth_manager.verify_token_full(token) if token else (None, None)
if not username:
return jsonify({"success": False, "message": "Unauthorized"}), 401
if scope != 'full_admin':
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
success, message = auth_manager.revoke_api_token(token_id)
@@ -0,0 +1,58 @@
"""Links between ProxMenux Monitors for the node selector in the header."""
from flask import Blueprint, jsonify, request
import monitor_links
from jwt_middleware import require_auth, require_admin_scope
monitor_links_bp = Blueprint("monitor_links", __name__)
@monitor_links_bp.after_request
def no_cache(response):
response.headers["Cache-Control"] = "no-store"
return response
@monitor_links_bp.route("/api/monitor-links")
@require_auth
def list_links():
return jsonify(monitor_links.cached_links(include_hidden=request.args.get("all") == "1"))
@monitor_links_bp.route("/api/monitor-links", methods=["POST"])
@require_admin_scope
def add_link():
try:
key = monitor_links.add_link(request.get_json(silent=True))
monitor_links.refresh()
return jsonify(success=True, key=key)
except monitor_links.LinkError as exc:
return jsonify(error=str(exc)), 400
except OSError:
return jsonify(error="Could not save the node list."), 500
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["PATCH"])
@require_admin_scope
def update_link(key):
try:
monitor_links.update_link(key, request.get_json(silent=True))
monitor_links.refresh()
return jsonify(success=True)
except monitor_links.LinkError as exc:
return jsonify(error=str(exc)), 400
except OSError:
return jsonify(error="Could not save the node list."), 500
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["DELETE"])
@require_admin_scope
def delete_link(key):
try:
monitor_links.delete_link(key)
monitor_links.refresh()
return jsonify(success=True)
except monitor_links.LinkError as exc:
return jsonify(error=str(exc)), 400
except OSError:
return jsonify(error="Could not save the node list."), 500
@@ -1156,6 +1156,14 @@ def setup_pve_webhook_core() -> dict:
'fallback_commands': [],
'error': None,
}
try: # Proxmox Backup Server has no /etc/pve notification configuration
from pbs_manager import is_pbs_host
on_pbs = is_pbs_host()
except Exception:
on_pbs = False
if on_pbs:
result['not_applicable'] = True
return result
try:
if not _pve_cluster_fs_mounted():
@@ -1339,6 +1347,13 @@ def cleanup_pve_webhook_core() -> dict:
Only removes blocks named 'proxmenux-webhook' / 'proxmenux-default'.
"""
result = {'cleaned': False, 'error': None}
try: # Proxmox Backup Server has no /etc/pve notification configuration
from pbs_manager import is_pbs_host
on_pbs = is_pbs_host()
except Exception:
on_pbs = False
if on_pbs:
return {'cleaned': True, 'error': None, 'not_applicable': True}
try:
if not _pve_cluster_fs_mounted():
+71
View File
@@ -0,0 +1,71 @@
"""Read-only PBS status and administrative connection management."""
from flask import Blueprint, jsonify, request
import auth_manager
import pbs_manager as pbs
from jwt_middleware import require_auth, require_admin_scope
pbs_bp = Blueprint("pbs", __name__)
@pbs_bp.after_request
def no_cache(response):
response.headers["Cache-Control"] = "no-store"
return response
@pbs_bp.route("/api/platform")
@require_auth
def platform():
return jsonify(pbs.get_platform())
@pbs_bp.route("/api/pbs/servers")
@require_auth
def servers():
try:
return jsonify(servers=pbs.public_servers(), local_available=pbs.is_pbs_host())
except pbs.PBSError as exc:
return jsonify(error=str(exc)), 500
def _protected_configuration():
local = auth_manager.load_auth_config()
return local.get("enabled") and not local.get("declined") and local.get("password_hash")
@pbs_bp.route("/api/pbs/servers", methods=["POST"])
@require_admin_scope
def save_server():
if not _protected_configuration():
return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409
try:
identifier = pbs.save_server(request.get_json(silent=True))
return jsonify(success=True, id=identifier, servers=pbs.public_servers())
except pbs.PBSError as exc:
return jsonify(error=str(exc)), 400
except OSError:
return jsonify(error="Could not save PBS connection."), 500
@pbs_bp.route("/api/pbs/servers/<identifier>", methods=["DELETE"])
@require_admin_scope
def delete_server(identifier):
if not _protected_configuration():
return jsonify(error="Enable local authentication before changing PBS connections."), 409
try:
pbs.delete_server(identifier)
return jsonify(success=True)
except pbs.PBSError as exc:
return jsonify(error=str(exc)), 400
except OSError:
return jsonify(error="Could not remove PBS connection."), 500
@pbs_bp.route("/api/pbs/status")
@require_auth
def status():
try:
return jsonify(pbs.get_status(request.args.get("server", "local")))
except pbs.PBSError as exc:
return jsonify(error=str(exc)), 502
+1 -1
View File
@@ -35,7 +35,7 @@ TOOL_METADATA = {
'system_utils': {'name': 'System Utilities', 'function': 'install_system_utils', 'version': '1.0'},
'network_optimization': {'name': 'Network Optimizations', 'function': 'apply_network_optimizations', 'version': '1.0'},
'bashrc_custom': {'name': 'Bashrc Customization', 'function': 'customize_bashrc', 'version': '1.0'},
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'},
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.2'},
'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', 'version': '1.0'},
'log2ram': {'name': 'Log2ram (SSD Protection)', 'function': 'configure_log2ram', 'version': '1.0'},
'zfs_autotrim': {'name': 'ZFS Autotrim', 'function': 'enable_zfs_autotrim', 'version': '1.0'},
+122 -60
View File
@@ -90,11 +90,16 @@ from flask_terminal_routes import ( # noqa: E402
from flask_health_routes import health_bp # noqa: E402
from flask_auth_routes import auth_bp # noqa: E402
from flask_proxmenux_routes import proxmenux_bp # noqa: E402
from flask_pbs_routes import pbs_bp # noqa: E402
from flask_monitor_links_routes import monitor_links_bp # noqa: E402
from pbs_manager import get_platform, is_pbs_host # noqa: E402
from flask_security_routes import security_bp # noqa: E402
from flask_notification_routes import notification_bp # noqa: E402
from flask_oci_routes import oci_bp # noqa: E402
from flask_audit_routes import audit_bp # noqa: E402
from flask_vm_backup_routes import vm_backup_bp # noqa: E402
from guest_backup_archives import (BackupArchiveError, delete_guest_backups,
guest_backup_deletion_status) # noqa: E402
from notification_manager import notification_manager, resolve_notification_hostname # noqa: E402
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
from jwt_middleware import require_auth, require_auth_or_ticket, require_admin_scope # noqa: E402
@@ -193,6 +198,8 @@ post_install_versions.scan_at_startup()
app.register_blueprint(auth_bp)
app.register_blueprint(health_bp)
app.register_blueprint(proxmenux_bp)
app.register_blueprint(pbs_bp)
app.register_blueprint(monitor_links_bp)
app.register_blueprint(security_bp)
app.register_blueprint(notification_bp)
app.register_blueprint(oci_bp)
@@ -2271,6 +2278,8 @@ def _handle_guest_lifecycle(vmid: str, vm_type: str, action: str) -> None:
# or stops; drop the cached IP so the next /api/vms poll re-reads
# it via `lxc-info`. QEMU guests do not touch this cache.
if guest_type == 'lxc':
import native_app_discovery
native_app_discovery.mark_stale(guest_id)
_invalidate_lxc_ip(guest_id)
if action in ('start', 'reboot'):
if guest_type == 'lxc':
@@ -3474,6 +3483,8 @@ def get_coral_info():
def get_proxmox_version():
"""Get Proxmox version if available. Cached for 6 hours."""
global _system_info_cache
if is_pbs_host():
return get_platform().get('version')
now = time.time()
if _system_info_cache['proxmox_version'] is not None and \
@@ -4186,13 +4197,17 @@ def get_system_disks():
pass
try:
# 4. Check LVM physical volumes. `--devices` (added when a disk
# is parked) keeps pvs from scanning — and waking — standby HDDs.
# 4. Check LVM physical volumes. `--devices` keeps pvs off the disks
# that are excluded, idle or asleep.
dev_args = _lvm_device_args()
result = subprocess.run(
['pvs', *_lvm_device_args(), '--noheadings', '-o', 'pv_name,vg_name'],
['pvs', *dev_args, '--noheadings', '-o', 'pv_name,vg_name'],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
) if dev_args is not None else None
if result is not None:
from lvm_device_scope import settle_scan_reads
settle_scan_reads()
if result is not None and result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if line.strip():
parts = line.split()
@@ -4534,6 +4549,8 @@ def get_storage_info():
disk_info['used'] = round(partition_usage.used / (1024**3), 1)
disk_info['available'] = round(partition_usage.free / (1024**3), 1)
disk_info['usage_percent'] = round(partition_usage.percent, 1)
disk_info['total_bytes'] = partition_usage.total
disk_info['used_bytes'] = partition_usage.used
except PermissionError:
continue
@@ -5002,42 +5019,10 @@ def _hdd_in_standby(disk_name: str) -> bool:
return parked
def _lvm_device_args() -> list:
"""Build the `--devices` argument that keeps an LVM tool (pvs/lvs/
vgs) off parked disks.
LVM tools scan every block device by default, which spins up a
standby HDD (issue #232). When any rotational disk is asleep, hand
LVM an explicit list of every block node EXCEPT those belonging to a
parked disk, so it never opens the sleeping drives. Each partition
must be listed too — `--devices /dev/sda` alone does not cover a PV
on /dev/sda3. Returns [] when nothing is parked, so hosts without
standby disks keep the plain pvs/lvs/vgs call unchanged.
"""
try:
r = subprocess.run(
['lsblk', '-rno', 'NAME,TYPE,PKNAME'],
capture_output=True, text=True, timeout=5)
if r.returncode != 0:
return []
nodes = []
for line in r.stdout.strip().split('\n'):
parts = line.split()
if len(parts) < 2 or parts[1] not in ('disk', 'part'):
continue
name = parts[0]
base = name if parts[1] == 'disk' else (parts[2] if len(parts) > 2 else name)
nodes.append((name, base))
standby_bases = {
name for name, base in nodes
if name == base and _hdd_in_standby(name)
}
if not standby_bases:
return []
devices = [f'/dev/{name}' for name, base in nodes if base not in standby_bases]
return ['--devices', ','.join(devices)] if devices else []
except (subprocess.SubprocessError, OSError):
return []
def _lvm_device_args():
"""None means no automatic scan; [] permits LVM's normal scan."""
from lvm_device_scope import lvm_device_args
return lvm_device_args(_hdd_in_standby)
def get_smart_data(disk_name):
@@ -9863,6 +9848,7 @@ def api_system():
'cpu_cores': cpu_cores,
'cpu_threads': cpu_threads,
'proxmox_version': proxmox_version,
'platform': get_platform(),
'kernel_version': kernel_version,
'available_updates': available_updates
})
@@ -12234,6 +12220,17 @@ def _get_node_metrics_payload(timeframe, max_age=_NODE_METRICS_TTL):
lock.release()
def _node_rrd_command(local_node, timeframe, cf=None):
"""Node RRD query; Proxmox Backup Server keeps the same fields under its own API."""
if is_pbs_host():
return ['proxmox-backup-debug', 'api', 'get', '/nodes/localhost/rrd',
'--timeframe', timeframe, '--cf', cf or 'AVERAGE', '--output-format', 'json']
command = ['pvesh', 'get', f'/nodes/{local_node}/rrddata', '--timeframe', timeframe]
if cf:
command += ['--cf', cf]
return command + ['--output-format', 'json']
def _compute_node_metrics_payload(timeframe: str) -> dict:
"""Fetch and shape one node RRD snapshot within a bounded query budget."""
valid_timeframes = ('hour', 'day', 'week', 'month', 'year')
@@ -12257,8 +12254,7 @@ def _compute_node_metrics_payload(timeframe: str) -> dict:
try:
rrd_result = subprocess.run(
['pvesh', 'get', f'/nodes/{local_node}/rrddata',
'--timeframe', timeframe, '--output-format', 'json'],
_node_rrd_command(local_node, timeframe),
capture_output=True, text=True, timeout=_NODE_METRICS_TIMEOUT,
)
except subprocess.TimeoutExpired:
@@ -12352,9 +12348,7 @@ def _compute_node_metrics_payload(timeframe: str) -> dict:
return None
try:
extra = subprocess.run(
['pvesh', 'get', f'/nodes/{local_node}/rrddata',
'--timeframe', timeframe, '--cf', cf,
'--output-format', 'json'],
_node_rrd_command(local_node, timeframe, cf),
capture_output=True, text=True, timeout=remaining,
)
if extra.returncode == 0 and extra.stdout:
@@ -13103,7 +13097,6 @@ def api_create_backup(vmid):
mode = data.get('mode', 'snapshot') # snapshot, suspend, stop
compress = data.get('compress', 'zstd') # none, lzo, gzip, zstd
protected = data.get('protected', False) # True/False
notification = data.get('notification', 'auto') # always, failure, never, auto
notes = data.get('notes', '') # Backup notes/description
pbs_change_detection = data.get('pbs_change_detection', None) # default, legacy, data (for PBS + LXC)
@@ -13303,6 +13296,32 @@ def api_create_backup(vmid):
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/backups/delete', methods=['POST'])
@require_admin_scope
def api_delete_vm_backups(vmid):
"""Delete an explicitly confirmed selection, revalidated by Proxmox."""
try:
return jsonify(delete_guest_backups(vmid, request.get_json(silent=True)))
except BackupArchiveError as error:
return jsonify({'error': str(error)}), error.code
finally:
# Native deletion may have started even if the pvesh response was lost.
_vm_cache_invalidate(vmid, _vm_backups_cache)
@app.route('/api/vms/<int:vmid>/backups/delete-status', methods=['GET'])
@require_admin_scope
def api_vm_backup_deletion_status(vmid):
"""Only poll deletion tasks started here for this guest."""
try:
result = guest_backup_deletion_status(vmid, request.args.get('task'))
if result['status'] != 'pending':
_vm_cache_invalidate(vmid, _vm_backups_cache)
return jsonify(result)
except BackupArchiveError as error:
return jsonify({'error': str(error)}), error.code
@app.route('/api/vms/<int:vmid>/backups', methods=['GET'])
@require_auth
def api_vm_backups(vmid):
@@ -13383,6 +13402,7 @@ def api_vm_backups(vmid):
'timestamp': ctime,
'date': datetime.fromtimestamp(ctime).strftime('%Y-%m-%d %H:%M') if ctime else '',
'notes': item.get('notes', ''),
'protected': item.get('protected', False) not in (False, 0, '0', '', None),
})
except Exception:
pass
@@ -13595,6 +13615,19 @@ def get_task_log(upid):
traceback.print_exc()
return jsonify({'error': str(e)}), 500
def _installed_proxmenux_version():
"""Installed ProxMenux version; a beta install keeps beta_version.txt."""
for name in ('beta_version.txt', 'version.txt'):
try:
with open(f'/usr/local/share/proxmenux/{name}') as fh:
version = fh.read().strip()
if version:
return version
except OSError:
continue
return ''
@app.route('/api/health', methods=['GET'])
@require_auth
def api_health():
@@ -13602,7 +13635,7 @@ def api_health():
return jsonify({
'status': 'healthy',
'timestamp': datetime.now().isoformat(),
'version': '1.2.2'
'version': _installed_proxmenux_version()
})
# ─── User-configurable health thresholds ─────────────────────────────────────
@@ -14083,6 +14116,20 @@ def api_vm_apps_suggestions(vmid):
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps/native-discovery', methods=['GET', 'POST'])
@require_auth
def api_vm_apps_native_discovery(vmid):
"""Only the Find applications button requests POST. GET never probes."""
import native_app_discovery
import lxc_apps
result = (native_app_discovery.discover(vmid) if request.method == 'POST'
else native_app_discovery.cached(vmid))
suggestions = _vm_cache_get(_vm_app_suggestions_cache, vmid, _VM_APP_SUGGESTIONS_TTL) or {}
sidecar = lxc_apps.load_sidecar(vmid) or {}
return jsonify(native_app_discovery.filter_candidates(
result, suggestions, sidecar.get('apps') or []))
@app.route('/api/vms/<int:vmid>/apps/adguard-setup', methods=['GET'])
@require_auth
def api_vm_apps_adguard_setup(vmid):
@@ -15014,11 +15061,11 @@ def api_prometheus():
disk_name = disk.get('name', 'unknown')
metrics.append(f'# HELP proxmox_disk_total_bytes Total disk space in bytes')
metrics.append(f'# TYPE proxmox_disk_total_bytes gauge')
metrics.append(f'proxmox_disk_total_bytes{{node="{node}",disk="{disk_name}"}} {disk.get("total", 0)} {timestamp}')
metrics.append(f'proxmox_disk_total_bytes{{node="{node}",disk="{disk_name}"}} {disk.get("total_bytes", 0)} {timestamp}')
metrics.append(f'# HELP proxmox_disk_used_bytes Used disk space in bytes')
metrics.append(f'# TYPE proxmox_disk_used_bytes gauge')
metrics.append(f'proxmox_disk_used_bytes{{node="{node}",disk="{disk_name}"}} {disk.get("used", 0)} {timestamp}')
metrics.append(f'proxmox_disk_used_bytes{{node="{node}",disk="{disk_name}"}} {disk.get("used_bytes", 0)} {timestamp}')
metrics.append(f'# HELP proxmox_disk_usage_percent Disk usage percentage')
metrics.append(f'# TYPE proxmox_disk_usage_percent gauge')
@@ -23186,9 +23233,11 @@ if __name__ == '__main__':
except Exception as e:
print(f"[ProxMenux] Disk temperature history init failed: {e}")
try:
import managed_installs
managed_installs.detect_and_register()
print("[ProxMenux] Managed-installs registry initialised")
# A PBS host has no guests whose applications could be tracked.
if not is_pbs_host():
import managed_installs
managed_installs.detect_and_register()
print("[ProxMenux] Managed-installs registry initialised")
except Exception as e:
print(f"[ProxMenux] managed_installs init failed: {e}")
# Docker inventories are derived runtime data and intentionally
@@ -23330,6 +23379,9 @@ if __name__ == '__main__':
# the 24 h PollingCollector cycle. Runs in a daemon thread with
# a delay so it doesn't block startup — notification_manager needs
# a moment to bind channels before we emit.
# The guest services below read VMs and LXCs, which do not exist on a
# Proxmox Backup Server.
pve_host = not is_pbs_host()
try:
def _startup_emit_pending_app_updates():
time.sleep(15) # let channels bind + first metrics collect
@@ -23339,8 +23391,9 @@ if __name__ == '__main__':
except Exception as e:
print(f"[ProxMenux] startup emit_all_pending_updates failed: {e}",
file=sys.stderr, flush=True)
threading.Thread(target=_startup_emit_pending_app_updates, daemon=True,
name='app-updates-startup-emit').start()
if pve_host:
threading.Thread(target=_startup_emit_pending_app_updates, daemon=True,
name='app-updates-startup-emit').start()
except Exception as e:
print(f"[ProxMenux] app-updates startup emitter failed to arm: {e}")
@@ -23352,6 +23405,13 @@ if __name__ == '__main__':
except Exception as e:
print(f"[ProxMenux] Node-metrics prewarmer failed to start: {e}")
# Linked Monitors for the header node selector, renewed every 5 minutes.
try:
import monitor_links
monitor_links.start_prewarmer()
except Exception as e:
print(f"[ProxMenux] Monitor links prewarmer failed to start: {e}")
# ── VM/CT modal-cache prewarmer ──
# Keeps _vm_details_cache / _vm_backups_cache / app snapshots /
# _vm_schedule_cache warm from the backend so the "Loading
@@ -23359,9 +23419,10 @@ if __name__ == '__main__':
# after the tab has been closed for minutes. The React-side
# prefetcher only ran while the page was open.
try:
vm_modal_thread = threading.Thread(target=_vm_modal_prewarmer_loop, daemon=True, name='vm-modal-prewarmer')
vm_modal_thread.start()
print("[ProxMenux] VM-modal prewarmer started (one-shot warm-up; caches refreshed by event invalidation only)")
if pve_host:
vm_modal_thread = threading.Thread(target=_vm_modal_prewarmer_loop, daemon=True, name='vm-modal-prewarmer')
vm_modal_thread.start()
print("[ProxMenux] VM-modal prewarmer started (one-shot warm-up; caches refreshed by event invalidation only)")
except Exception as e:
print(f"[ProxMenux] VM-modal prewarmer failed to start: {e}")
@@ -23385,8 +23446,9 @@ if __name__ == '__main__':
# identical (backup, restart, /usr/bin/update + custom command
# chain) between manual and scheduled invocations.
try:
scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True)
scheduler_thread.start()
if pve_host:
scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True)
scheduler_thread.start()
except Exception as e:
print(f"[ProxMenux] LXC update scheduler failed to start: {e}")
@@ -16,6 +16,9 @@ Endpoints, all under /api/vm-backup-jobs:
DELETE /<id> delete a job (its backups are kept)
POST /<id>/toggle enable / disable
POST /<id>/run run it now, as a Proxmox task on each node
Storage retention is read/edited under /api/vm-backup-storages. Saving a
policy only changes the native storage configuration; it never runs pruning.
"""
import json
@@ -229,6 +232,58 @@ def options():
})
@vm_backup_bp.route("/api/vm-backup-storages", methods=["GET"])
@require_auth
def backup_storage_retention():
ok, storages = _pvesh_json(["get", "/storage"])
if not ok or not isinstance(storages, list):
return _err(storages if not ok else "Invalid storage inventory", 502)
return jsonify({"storages": [
{"id": storage["storage"], "type": storage.get("type", ""),
"prune_backups": _flat(storage.get("prune-backups") or ""),
"digest": storage.get("digest", "")}
for storage in storages
if isinstance(storage, dict) and storage.get("storage")
and "backup" in (storage.get("content") or "").split(",")
]})
@vm_backup_bp.route("/api/vm-backup-storages/<storage_id>", methods=["PUT"])
@require_admin_scope
def update_backup_storage_retention(storage_id):
if not STORAGE_RE.fullmatch(storage_id):
return _err("Invalid storage")
data = request.get_json(silent=True)
if not isinstance(data, dict):
return _err("A retention policy and current configuration digest are required")
digest, prune = data.get("digest"), data.get("prune_backups")
if not isinstance(digest, str) or not re.fullmatch(r"[a-fA-F0-9]{40}", digest):
return _err("Refresh the storage list before editing its retention")
# An empty policy removes the storage setting, so the Proxmox default applies.
if not isinstance(prune, str) or (prune and not PRUNE_RE.fullmatch(prune)):
return _err("Invalid retention policy")
pairs = [part.split("=", 1) for part in prune.split(",")] if prune else []
keys = [key for key, _ in pairs]
if (len(keys) != len(set(keys)) or
("keep-all" in keys and (len(keys) != 1 or pairs[0][1] != "1")) or
any(int(value) < 1 for _, value in pairs)):
return _err("Choose keep-all=1 or one or more positive retention counts")
# Check that this is still a backup storage, then let Proxmox check the
# digest under its storage lock. Other storage settings are never passed.
ok, storage = _pvesh_json(["get", f"/storage/{storage_id}"])
if not ok:
return _err(storage, 502)
if not isinstance(storage, dict) or "backup" not in (storage.get("content") or "").split(","):
return _err("This storage does not accept backups", 409)
if storage.get("digest") != digest:
return _err("Storage configuration changed; refresh before saving", 409)
change = ["--prune-backups", prune] if prune else ["--delete", "prune-backups"]
ok, out = _pvesh(["set", f"/storage/{storage_id}", "--digest", digest, *change])
if not ok:
return _err(out, 409)
return jsonify({"success": True})
@vm_backup_bp.route("/api/vm-backup-jobs", methods=["POST"])
@require_admin_scope
def create_job():
+172
View File
@@ -0,0 +1,172 @@
# ==========================================================
# ProxMenux Monitor - Guest backup archives
# Author : Jonatan
# GitHub : github.com/jcastro
# Maintainer : MacRimi
# Copyright : (c) 2026 MacRimi & contributors
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 16/10/2026
# ==========================================================
# Description:
# Deletes explicitly selected VM/CT backups through the native Proxmox
# storage API after a fresh inventory/protection check. Tracks native
# deletion tasks so an accepted operation is not mistaken for completion.
# No filesystem paths or cached inventory authorize deletion.
# ==========================================================
import json
import re
import socket
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor
MAX_SELECTION = 20
REMOVED_RE = re.compile(r"Removed volume '[^'\n]+'")
STORAGE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
NODE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]{0,62}")
_tasks = {}
_lock = threading.Lock()
class BackupArchiveError(Exception):
def __init__(self, message, code=400):
super().__init__(message)
self.code = code
def _pvesh(args, timeout=20):
try:
result = subprocess.run(["pvesh", *args, "--output-format", "json"],
capture_output=True, text=True, timeout=timeout)
except (OSError, subprocess.TimeoutExpired) as error:
raise BackupArchiveError("Proxmox command unavailable or timed out; outcome unconfirmed", 502) from error
if result.returncode:
raise BackupArchiveError((result.stderr or "Proxmox command failed").strip()[:1000], 502)
output = result.stdout.strip()
try:
# pvesh can print no bytes for the API's successful null result.
return json.loads(output or "null")
except (ValueError, TypeError) as error:
# With --delay, a deletion that ends in time prints the line of its
# task log instead of JSON. Only that exact line is accepted.
if args[0] == "delete" and REMOVED_RE.fullmatch(output):
return output
raise BackupArchiveError("Invalid Proxmox response; outcome unconfirmed", 502) from error
def _node():
# Same local storage/node scope as the guest's existing backup list.
node = socket.gethostname().split(".")[0]
if not NODE_RE.fullmatch(node):
raise BackupArchiveError("Invalid local node name", 502)
return node
def _selection(vmid, data):
if not 100 <= vmid <= 999999999:
raise BackupArchiveError("Invalid VM/CT ID")
if not isinstance(data, dict) or data.get("confirm") is not True:
raise BackupArchiveError("Explicit confirmation is required")
selected = data.get("backups")
if not isinstance(selected, list) or not 1 <= len(selected) <= MAX_SELECTION:
raise BackupArchiveError(f"Select between 1 and {MAX_SELECTION} backups")
seen = set()
for item in selected:
if not isinstance(item, dict):
raise BackupArchiveError("Invalid backup selection")
storage, volid = item.get("storage"), item.get("volid")
if not isinstance(storage, str) or not STORAGE_RE.fullmatch(storage):
raise BackupArchiveError("Invalid backup storage")
if (not isinstance(volid, str) or not volid.startswith(storage + ":")
or len(volid) > 512 or not re.fullmatch(r"[A-Za-z0-9_:./+@-]+", volid)
or any(part in ("", ".", "..") for part in volid.split(":", 1)[1].split("/"))):
raise BackupArchiveError("Invalid backup volume ID")
if volid in seen:
raise BackupArchiveError("Duplicate backup selection")
seen.add(volid)
return [{"storage": item["storage"], "volid": item["volid"]} for item in selected]
def _protected(value):
# Fail closed for an unexpected protection value, including the text 'true'.
return value not in (None, False, 0, "0", "")
def _remember_task(task, vmid, node, volid):
# These are server-returned task IDs, never arbitrary client paths.
if (not isinstance(task, str) or not task.startswith(f"UPID:{node}:")
or ":imgdel:" not in task or "/" in task or len(task) > 1024):
raise BackupArchiveError("Unexpected deletion task; outcome unconfirmed", 502)
with _lock:
now = time.monotonic()
expired = [key for key, entry in _tasks.items() if now - entry["created"] > 3600]
for key in expired:
del _tasks[key]
# Bound even failed/unpolled jobs; expiry does not cancel a PVE task.
if len(_tasks) >= 512:
del _tasks[min(_tasks, key=lambda key: _tasks[key]["created"])]
_tasks[task] = {"vmid": vmid, "node": node, "volid": volid, "created": now}
def delete_guest_backups(vmid, data):
selected = _selection(vmid, data)
node = _node()
inventory = {}
# Preflight the entire batch before deleting anything. An unavailable
# storage or a protected/stale/cross-guest selection fails the whole batch.
for storage in sorted({item["storage"] for item in selected}):
entries = _pvesh(["get", f"/nodes/{node}/storage/{storage}/content",
"--content", "backup", "--vmid", str(vmid)])
if not isinstance(entries, list):
raise BackupArchiveError("Invalid backup inventory", 502)
for entry in entries:
if (isinstance(entry, dict) and entry.get("content") == "backup"
and isinstance(entry.get("volid"), str)):
inventory[(storage, entry.get("volid"))] = entry
for item in selected:
entry = inventory.get((item["storage"], item["volid"]))
if entry is None or str(entry.get("vmid")) != str(vmid):
raise BackupArchiveError("Backup no longer exists or does not belong to this VM/CT", 409)
if _protected(entry.get("protected")):
raise BackupArchiveError("Protected backups cannot be deleted", 409)
def remove(item):
try:
# Proxmox also enforces protection at deletion time. It removes
# associated notes/log files and returns null only after success.
task = _pvesh(["delete", f'/nodes/{node}/storage/{item["storage"]}/content/{item["volid"]}',
"--delay", "1"])
if isinstance(task, str) and task.startswith("UPID:"):
_remember_task(task, vmid, node, item["volid"])
return {**item, "status": "pending", "task": task}
if task is None or task == f"Removed volume '{item['volid']}'":
return {**item, "status": "deleted"}
return {**item, "status": "unconfirmed", "error": "Invalid Proxmox response; outcome unconfirmed"}
except BackupArchiveError as error:
return {**item, "status": "unconfirmed", "error": str(error)}
with ThreadPoolExecutor(max_workers=min(4, len(selected)), thread_name_prefix="backup-delete") as pool:
results = list(pool.map(remove, selected))
return {"results": results}
def guest_backup_deletion_status(vmid, task):
with _lock:
entry = _tasks.get(task) if isinstance(task, str) else None
if not entry or entry["vmid"] != vmid or time.monotonic() - entry["created"] > 3600:
raise BackupArchiveError("Deletion task unknown or expired; refresh the backup list", 409)
entry = entry.copy()
status = _pvesh(["get", f'/nodes/{entry["node"]}/tasks/{task}/status'])
if not isinstance(status, dict) or status.get("status") not in ("running", "stopped"):
raise BackupArchiveError("Invalid deletion task status", 502)
result = {"volid": entry["volid"], "status": "pending"}
if status["status"] == "stopped":
if status.get("exitstatus") == "OK":
result["status"] = "deleted"
else:
result.update(status="unconfirmed", error=status.get("exitstatus") or "Deletion outcome unconfirmed")
return result
+53 -41
View File
@@ -51,6 +51,7 @@ DEBUG_PERF = False
# ─── Startup Grace Period ────────────────────────────────────────────────────
# Import centralized startup grace management for consistent behavior
import startup_grace
from pbs_manager import get_platform, PBS_SERVICES
def _is_startup_health_grace() -> bool:
"""Check if we're within the startup health grace period (5 min).
@@ -153,36 +154,10 @@ def _hdd_in_standby(disk_name: str) -> bool:
return parked
def _lvm_device_args() -> list:
"""`--devices` list scoping pvs/lvs/vgs off parked disks so they
aren't spun up by the LVM scan (issue #232). Empty when nothing is
parked, so the plain command runs unchanged. Each partition is
listed too — a whole-disk path alone doesn't cover a PV on a
partition."""
try:
r = subprocess.run(
['lsblk', '-rno', 'NAME,TYPE,PKNAME'],
capture_output=True, text=True, timeout=5)
if r.returncode != 0:
return []
nodes = []
for line in r.stdout.strip().split('\n'):
parts = line.split()
if len(parts) < 2 or parts[1] not in ('disk', 'part'):
continue
name = parts[0]
base = name if parts[1] == 'disk' else (parts[2] if len(parts) > 2 else name)
nodes.append((name, base))
standby_bases = {
name for name, base in nodes
if name == base and _hdd_in_standby(name)
}
if not standby_bases:
return []
devices = [f'/dev/{name}' for name, base in nodes if base not in standby_bases]
return ['--devices', ','.join(devices)] if devices else []
except Exception:
return []
def _lvm_device_args():
"""None means no automatic scan; [] permits LVM's normal scan."""
from lvm_device_scope import lvm_device_args
return lvm_device_args(_hdd_in_standby)
def _is_disk_removable(disk_name: str) -> bool:
@@ -466,6 +441,7 @@ class HealthMonitor:
def __init__(self):
"""Initialize health monitor with state tracking"""
self.platform_type = get_platform().get("product")
self.state_history = defaultdict(list)
self.last_check_times = {}
self.cached_results = {}
@@ -919,9 +895,9 @@ class HealthMonitor:
_perf_log("services", (time.time() - _t) * 1000)
details['services'] = services_status
if services_status['status'] == 'CRITICAL':
critical_issues.append(f"PVE Services: {services_status.get('reason', 'Service failure')}")
critical_issues.append(f"Proxmox Services: {services_status.get('reason', 'Service failure')}")
elif services_status['status'] == 'WARNING':
warning_issues.append(f"PVE Services: {services_status.get('reason', 'Service issue')}")
warning_issues.append(f"Proxmox Services: {services_status.get('reason', 'Service issue')}")
# Priority 1.5: Proxmox Storage Check (External Module)
_t = time.time()
@@ -1661,7 +1637,7 @@ class HealthMonitor:
mem_critical_samples = [
entry for entry in self.state_history[state_key]
if entry['mem_percent'] >= 90 and
if entry['mem_percent'] >= self.MEMORY_CRITICAL and
current_time - entry['time'] <= self.MEMORY_DURATION
]
@@ -1695,7 +1671,7 @@ class HealthMonitor:
oldest = min(s['time'] for s in mem_critical_samples)
actual_duration = int(current_time - oldest)
status = 'CRITICAL'
reason = f'RAM >90% sustained for {actual_duration}s'
reason = f'RAM >{self.MEMORY_CRITICAL}% sustained for {actual_duration}s'
elif swap_critical >= 2:
status = 'CRITICAL'
reason = (f'Memory pressure: swap {swap_percent:.0f}% used '
@@ -1715,7 +1691,7 @@ class HealthMonitor:
swap_total_gb = round(swap.total / (1024**3), 2)
# Determine per-sub-check status
ram_status = 'CRITICAL' if mem_percent >= 90 and mem_critical_count >= MEM_CRITICAL_MIN_SAMPLES else ('WARNING' if mem_percent >= self.MEMORY_WARNING and mem_warning_count >= MEM_WARNING_MIN_SAMPLES else 'OK')
ram_status = 'CRITICAL' if mem_percent >= self.MEMORY_CRITICAL and mem_critical_count >= MEM_CRITICAL_MIN_SAMPLES else ('WARNING' if mem_percent >= self.MEMORY_WARNING and mem_warning_count >= MEM_WARNING_MIN_SAMPLES else 'OK')
swap_status = 'CRITICAL' if swap_critical >= 2 else 'OK'
result = {
@@ -2303,12 +2279,17 @@ class HealthMonitor:
# `--devices` keeps lvs from scanning parked HDDs (issue #232).
dev_args = _lvm_device_args()
if dev_args is None:
return {'status': 'INFO', 'skipped': True,
'reason': 'Automatic LVM scan skipped by disk read policy or unavailable inventory'}
result = subprocess.run(
['lvs', *dev_args, '--noheadings', '--options', 'lv_name,vg_name,lv_attr'],
capture_output=True,
text=True,
timeout=3
)
from lvm_device_scope import settle_scan_reads
settle_scan_reads()
if result.returncode != 0:
return {'status': 'WARNING', 'reason': 'lvs command failed'}
@@ -2333,6 +2314,7 @@ class HealthMonitor:
text=True,
timeout=3
)
settle_scan_reads()
if vg_result.returncode == 0 and vg_result.stdout.strip():
return {'status': 'WARNING', 'reason': 'No active LVM volumes detected'}
else:
@@ -2347,6 +2329,8 @@ class HealthMonitor:
# The new _check_proxmox_storage function handles this logic better.
def _check_proxmox_storages(self) -> Dict[str, Any]:
"""Check Proxmox-specific storages (only report problems)"""
if getattr(self, "platform_type", "pve") == "pbs":
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
storages = {}
try:
@@ -3515,6 +3499,8 @@ class HealthMonitor:
Optimized VM/CT check - detects qmp failures and startup errors from logs.
Improved detection of container and VM errors from journalctl.
"""
if getattr(self, "platform_type", "pve") == "pbs":
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
try:
# First: auto-resolve any persisted VM/CT errors where the guest
# is now running. This clears stale "Failed to start" / QMP
@@ -3678,6 +3664,8 @@ class HealthMonitor:
Check VMs/CTs with persistent error tracking.
Errors persist until VM starts or 48h elapsed.
"""
if getattr(self, "platform_type", "pve") == "pbs":
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
try:
issues = []
vm_details = {}
@@ -3919,8 +3907,18 @@ class HealthMonitor:
"""
try:
# Build service list: base PVE services + corosync if clustered
services_to_check = list(self.PVE_SERVICES)
is_cluster = os.path.exists('/etc/corosync/corosync.conf')
pbs_host = getattr(self, "platform_type", "pve") == "pbs"
service_prefix = "pbs" if pbs_host else "pve"
product_name = "PBS" if pbs_host else "PVE"
services_to_check = list(PBS_SERVICES) if pbs_host else list(self.PVE_SERVICES)
if pbs_host:
# Old Monitor versions incorrectly treated absent PVE services
# as PBS failures; remove only those known inapplicable errors.
for service in self.PVE_SERVICES:
old_key = f'pve_service_{service}'
if health_persistence.is_error_active(old_key):
health_persistence.clear_error(old_key)
is_cluster = not pbs_host and os.path.exists('/etc/corosync/corosync.conf')
if is_cluster and 'corosync' not in services_to_check:
services_to_check.append('corosync')
@@ -3952,14 +3950,14 @@ class HealthMonitor:
# cleanup loop). Audit Tier 5 — Health stack.
for svc in services_to_check:
if svc not in failed_services:
error_key = f'pve_service_{svc}'
error_key = f'{service_prefix}_service_{svc}'
if health_persistence.is_error_active(error_key):
health_persistence.clear_error(error_key)
# Build checks dict with status per service
checks = {}
for svc in services_to_check:
error_key = f'pve_service_{svc}'
error_key = f'{service_prefix}_service_{svc}'
if svc in failed_services:
state = service_details.get(svc, 'inactive')
checks[svc] = {
@@ -3987,12 +3985,12 @@ class HealthMonitor:
# Record each failed service in persistence, respecting dismiss
active_failed = []
for svc in failed_services:
error_key = f'pve_service_{svc}'
error_key = f'{service_prefix}_service_{svc}'
rec_result = health_persistence.record_error(
error_key=error_key,
category='pve_services',
severity='CRITICAL',
reason=f'PVE service {svc} is {service_details.get(svc, "inactive")}',
reason=f'{product_name} service {svc} is {service_details.get(svc, "inactive")}',
details={'service': svc, 'state': service_details.get(svc, 'inactive')}
)
if rec_result and rec_result.get('type') == 'skipped_acknowledged':
@@ -5816,6 +5814,8 @@ class HealthMonitor:
- No persistent errors are recorded
This prevents false positives when NFS/PBS/remote storage is still mounting.
"""
if getattr(self, "platform_type", "pve") == "pbs":
return None
if not PROXMOX_STORAGE_AVAILABLE:
return None
@@ -6076,6 +6076,8 @@ class HealthMonitor:
if status == 'stale':
checks[check_label] = {
'status': 'CRITICAL',
'error_key': error_key,
'dismissable': True,
'detail': f'{fstype} mount stale: {error or "unreachable"}',
'fstype': fstype,
'source': m.get('source', ''),
@@ -6105,6 +6107,8 @@ class HealthMonitor:
elif status == 'readonly':
checks[check_label] = {
'status': 'WARNING',
'error_key': error_key,
'dismissable': True,
'detail': f'{fstype} mount unexpectedly read-only',
'fstype': fstype,
'source': m.get('source', ''),
@@ -6205,6 +6209,8 @@ class HealthMonitor:
Notification cooldown is the standard 1h via the storage
category, so a CT teetering at 96% won't spam the channel.
"""
if getattr(self, "platform_type", "pve") == "pbs":
return None
# Cheap pre-check: bail out when there are no running LXCs. On
# nodes without CTs, the previous version still paid ~860ms per
# cycle for a `pvesh /cluster/resources` call that produced no
@@ -6365,6 +6371,8 @@ class HealthMonitor:
path, so a slow / dead guest agent can't stretch the health
cycle.
"""
if getattr(self, "platform_type", "pve") == "pbs":
return None
try:
import flask_server # deferred — avoids circular import
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
@@ -6524,6 +6532,8 @@ class HealthMonitor:
check (already covered) because those are usually large data
mounts whose fill rate is driven by user content, not log spam.
"""
if getattr(self, "platform_type", "pve") == "pbs":
return None
if not MOUNT_MONITOR_AVAILABLE:
return None
try:
@@ -6627,6 +6637,8 @@ class HealthMonitor:
check via _check_filesystem. Re-alerting on the same condition
through two channels just clutters the inbox.
"""
if getattr(self, "platform_type", "pve") == "pbs":
return None
if not PROXMOX_STORAGE_AVAILABLE:
return None
try:
+9 -1
View File
@@ -8,6 +8,8 @@ from flask import request, jsonify, g
from functools import wraps
from auth_manager import load_auth_config, verify_token, verify_token_full
_READ_METHODS = ('GET', 'HEAD', 'OPTIONS')
def require_auth(f):
"""
@@ -64,12 +66,18 @@ def require_auth(f):
token = parts[1]
# Verify token
username = verify_token(token)
username, scope = verify_token_full(token)
if not username:
return jsonify({
"error": "Invalid or expired token",
"message": "Please log in again"
}), 401
# read_only tokens may only read; any method that can change
# state needs full_admin.
if request.method not in _READ_METHODS and scope != 'full_admin':
return jsonify({"error": "Insufficient scope",
"message": f"This action requires a full_admin token (your token: {scope})"}), 403
# Token is valid, allow access
return f(*args, **kwargs)
+116
View File
@@ -0,0 +1,116 @@
# ==========================================================
# ProxMenux Monitor - LVM disk read scope
# Author : Jonatan
# GitHub : github.com/jcastro
# Maintainer : MacRimi
# Copyright : (c) 2026 MacRimi & contributors
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 16/10/2026
# ==========================================================
# Description:
# Applies the automatic disk-read policy to LVM metadata scans in both
# Monitor readers. Excluded and idle disks receive no power-mode probe;
# every device built on them is excluded too, and the scan's own reads
# are not taken as activity. No eligible devices means no scan.
# ==========================================================
import json
import re
import subprocess
import threading
_NAME = re.compile(r"[A-Za-z0-9._+-]+")
_scan_lock = threading.Lock()
_scanned_disks = set()
def _topology():
"""Every block device by kernel name: its type, its parents and whether
it is an LVM physical volume. lsblk reads this from udev, not the disks.
A RAID or multipath device appears once per member."""
result = subprocess.run(["lsblk", "-J", "-l", "-o", "KNAME,TYPE,PKNAME,FSTYPE"],
capture_output=True, text=True, timeout=5)
if result.returncode:
return None
types, parents, volumes = {}, {}, set()
for device in json.loads(result.stdout).get("blockdevices") or []:
name, parent = device.get("kname"), device.get("pkname")
if not isinstance(name, str) or not _NAME.fullmatch(name):
return None
types[name] = device.get("type")
parents.setdefault(name, set())
if parent is not None:
if not isinstance(parent, str) or not _NAME.fullmatch(parent):
return None
parents[name].add(parent)
if device.get("fstype") == "LVM2_member" and device.get("type") != "lvm":
volumes.add(name)
return types, parents, volumes
def _disks_under(name, parents, seen=None):
"""The disks a device is built on: itself, or its parents' disks."""
seen = seen if seen is not None else set()
if name in seen:
return set()
seen.add(name)
if not parents.get(name):
return {name}
found = set()
for parent in parents[name]:
found |= _disks_under(parent, parents, seen)
return found
def lvm_device_args(standby_check):
"""Return [] for an unrestricted scan, --devices args, or None to skip.
A failed inventory must never fall back to an unrestricted scan. None
is distinct from []: if every disk is excluded/idle/asleep, callers
must not start pvs/lvs/vgs at all.
"""
try:
from disk_temperature_history import disk_read_policy, excluded_disk_names
topology = _topology()
if topology is None:
return None
types, parents, volumes = topology
disks = {name for name, kind in types.items() if kind == "disk"}
if not disks:
return None
excluded = excluded_disk_names()
blocked = set()
for disk in sorted(disks):
# Test policy first: even CHECK POWER MODE is forbidden for an
# excluded/idle disk. Some USB bridges do not honour -n standby.
if disk_read_policy(disk, excluded) != "read" or standby_check(disk):
blocked.add(disk)
allowed = sorted(f"/dev/{name}" for name in volumes if not (_disks_under(name, parents) & blocked))
with _scan_lock:
_scanned_disks.clear()
_scanned_disks.update(disks - blocked)
if not blocked:
return []
# An explicit list makes LVM open every device in it, so only the
# physical volumes are named: a RAID, multipath or encrypted volume
# built on a blocked disk would read that disk too and stays out.
if not allowed:
return None
return ["--devices", ",".join(allowed)]
except Exception:
# Unavailable exclusions, topology or policy are not permission to
# open devices that may have been deliberately excluded.
return None
def settle_scan_reads():
"""The LVM scan reads the disks it is allowed to. Those reads are its
own, so they must not make an unused disk look busy at the next check."""
try:
from disk_temperature_history import settle_own_reads
with _scan_lock:
disks = set(_scanned_disks)
settle_own_reads(disks)
except Exception:
pass
+7
View File
@@ -1179,6 +1179,13 @@ def validate_config(payload: dict) -> tuple[bool, Any]:
return _err("logo_url must be an http(s) URL (max 512 chars)")
conf["logo_url"] = logo
# Native discovery identity is separate from a curated helper/updater.
discovery_id = payload.get("discovery_id")
if discovery_id is not None:
if not isinstance(discovery_id, str) or not re.fullmatch(r"native-[0-9a-f]{24}", discovery_id):
return _err("invalid discovery_id")
conf["discovery_id"] = discovery_id
# Optional helper_slug — set by the frontend when the user picks
# an auto-detected app (primary or extra). Persisted so we can
# filter the "also detected" chip list against apps already
+321
View File
@@ -0,0 +1,321 @@
"""Other ProxMenux Monitors reachable from this one: cluster nodes, PBS servers
and entries added by hand.
Only links are kept here. No credential is stored or sent: the browser opens
the other Monitor, which keeps its own sign-in.
"""
import ipaddress
import json
import os
import re
import secrets
import socket
import ssl
import subprocess
import threading
import time
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pbs_manager
LINKS_FILE = Path(os.environ.get("PROXMENUX_MONITOR_LINKS", "/usr/local/share/proxmenux/monitor_links.json"))
SSL_CONFIG_FILE = Path(os.environ.get("PROXMENUX_SSL_CONFIG", "/etc/proxmenux/ssl_config.json"))
STORAGE_CFG = Path("/etc/pve/storage.cfg")
MONITOR_PORT = 8008
PBS_UI_PORT = 8007
PROBE_TIMEOUT = 3
PROBE_TTL = 300
MAX_MANUAL = 64
PRODUCTS = ("pve", "pbs")
_lock = threading.Lock()
_probe_cache = {}
_snapshot = {"at": 0.0, "data": None}
_refresh_lock = threading.Lock()
class LinkError(ValueError):
pass
def _load():
try:
data = json.loads(LINKS_FILE.read_text())
except (OSError, ValueError):
return {"manual": [], "overrides": {}}
manual = data.get("manual") if isinstance(data.get("manual"), list) else []
overrides = data.get("overrides") if isinstance(data.get("overrides"), dict) else {}
return {"manual": [row for row in manual if isinstance(row, dict)], "overrides": overrides}
def _save(data):
LINKS_FILE.parent.mkdir(parents=True, exist_ok=True)
temporary = LINKS_FILE.with_suffix(".tmp")
temporary.write_text(json.dumps(data, indent=2))
os.chmod(temporary, 0o600)
os.replace(temporary, LINKS_FILE)
def normalize_url(value):
"""Return the origin of an http(s) address, or raise LinkError."""
if not isinstance(value, str) or not value.strip():
raise LinkError("Enter the address of the Monitor.")
text = value.strip()
if "://" not in text:
text = "https://" + text
parsed = urllib.parse.urlsplit(text)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise LinkError("The address must start with http:// or https:// and name a host.")
if parsed.username or parsed.password:
raise LinkError("The address must not contain a user or password.")
try:
port = parsed.port
except ValueError as exc:
raise LinkError("The port of the address is not valid.") from exc
host = parsed.hostname
if ":" in host:
host = f"[{host}]"
return f"{parsed.scheme}://{host}" + (f":{port}" if port else "")
def _local_scheme():
try:
return "https" if json.loads(SSL_CONFIG_FILE.read_text()).get("enabled") else "http"
except (OSError, ValueError, AttributeError):
return "http"
def _host_url(host, port, scheme=None):
if ":" in host and not host.startswith("["):
host = f"[{host}]"
return f"{scheme or _local_scheme()}://{host}:{port}"
def _is_private(host):
"""Only addresses in private ranges are probed without being asked to."""
try:
addresses = {info[4][0] for info in socket.getaddrinfo(host, None)}
except OSError:
return False
try:
return bool(addresses) and all(ipaddress.ip_address(a.split("%")[0]).is_private for a in addresses)
except ValueError:
return False
def _cluster_nodes():
try:
result = subprocess.run(["pvesh", "get", "/cluster/status", "--output-format", "json"],
capture_output=True, text=True, timeout=8)
rows = json.loads(result.stdout) if result.returncode == 0 else []
except (OSError, subprocess.SubprocessError, ValueError):
return []
nodes = []
for row in rows if isinstance(rows, list) else []:
if isinstance(row, dict) and row.get("type") == "node" and not row.get("local") and row.get("name") and row.get("ip"):
nodes.append({"key": f"cluster:{row['name']}", "name": str(row["name"]), "product": "pve",
"group": "cluster", "host": str(row["ip"])})
return nodes
def _storage_pbs():
try:
text = STORAGE_CFG.read_text()
except OSError:
return []
servers, current = [], None
for line in text.splitlines():
header = re.match(r"^(\w+):\s*(\S+)", line)
if header:
current = {"name": header.group(2), "port": PBS_UI_PORT} if header.group(1) == "pbs" else None
if current is not None:
servers.append(current)
continue
option = re.match(r"^\s+(server|port)\s+(\S+)", line)
if current is not None and option:
current[option.group(1)] = option.group(2)
return [row for row in servers if row.get("server")]
def _pbs_servers():
found = {}
for row in _storage_pbs():
host = row["server"]
port = row.get("port") if str(row.get("port", "")).isdigit() else PBS_UI_PORT
found.setdefault(host, {"name": row["name"], "native_url": _host_url(host, port, "https")})
try:
for row in pbs_manager.public_servers():
host = urllib.parse.urlsplit(row.get("url", "")).hostname
if host:
found.setdefault(host, {"name": row.get("name") or host, "native_url": normalize_url(row["url"])})
except Exception:
pass
return [{"key": f"pbs:{host}", "name": info["name"], "product": "pbs", "group": "pbs",
"host": host, "native_url": info["native_url"]} for host, info in found.items()]
def _probe(url):
"""True when a ProxMenux Monitor answers at url; nothing is sent but the request."""
now = time.monotonic()
with _lock:
cached = _probe_cache.get(url)
if cached and cached[0] > now:
return cached[1]
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE # liveness only: no credential or token is ever sent
online = False
try:
request = urllib.request.Request(url + "/api/auth/status", headers={"Accept": "application/json"})
with urllib.request.urlopen(request, timeout=PROBE_TIMEOUT, context=context) as response:
online = response.status == 200 and "auth_enabled" in json.loads(response.read(4096))
except Exception:
online = False
with _lock:
if len(_probe_cache) > 256:
_probe_cache.clear()
_probe_cache[url] = (now + PROBE_TTL, online)
return online
def _resolve(entry, override):
"""Pick the Monitor address and its state for one discovered or manual entry."""
if override.get("url"):
return override["url"], "online" if _probe(override["url"]) else "offline"
if entry.get("url"):
return entry["url"], "online" if _probe(entry["url"]) else "offline"
host = entry["host"]
if not _is_private(host):
return None, "unknown"
for scheme in (_local_scheme(), "https" if _local_scheme() == "http" else "http"):
url = _host_url(host, MONITOR_PORT, scheme)
if _probe(url):
return url, "online"
return _host_url(host, MONITOR_PORT), "offline"
def list_links(include_hidden=False):
platform = pbs_manager.get_platform()
data = _load()
entries = []
if platform.get("product") != "pbs":
entries += _cluster_nodes() + _pbs_servers()
for row in data["manual"]:
entries.append({"key": f"manual:{row.get('id')}", "name": row.get("name") or row.get("url"),
"product": row.get("product") if row.get("product") in PRODUCTS else "pve",
"group": "pbs" if row.get("product") == "pbs" else "standalone",
"url": row.get("url"), "manual": True})
visible = [e for e in entries if include_hidden or not data["overrides"].get(e["key"], {}).get("hidden")]
with ThreadPoolExecutor(max_workers=8) as executor:
resolved = list(executor.map(lambda e: _resolve(e, data["overrides"].get(e["key"], {})), visible))
links = []
for entry, (url, state) in zip(visible, resolved):
override = data["overrides"].get(entry["key"], {})
links.append({"key": entry["key"], "name": entry["name"], "product": entry["product"],
"group": entry["group"], "url": url, "status": state,
"native_url": entry.get("native_url"), "manual": bool(entry.get("manual")),
"hidden": bool(override.get("hidden")), "custom_url": bool(override.get("url"))})
return {"self": {"name": platform.get("hostname") or socket.gethostname(), "product": platform.get("product")},
"links": links}
def refresh():
"""Recompute the list with fresh probes and keep it for the header."""
with _refresh_lock:
with _lock:
_probe_cache.clear()
data = list_links(include_hidden=True)
_snapshot.update(at=time.monotonic(), data=data)
return data
def _refresh_quietly():
try:
refresh()
except Exception:
pass
def cached_links(include_hidden=False):
"""The last list at once; a stale one is renewed in the background."""
data = _snapshot["data"]
if data is None:
data = refresh()
elif time.monotonic() - _snapshot["at"] > PROBE_TTL and not _refresh_lock.locked():
threading.Thread(target=_refresh_quietly, daemon=True, name="monitor-links-refresh").start()
if include_hidden:
return data
return {**data, "links": [row for row in data["links"] if not row["hidden"]]}
def start_prewarmer():
def loop():
time.sleep(10)
while True:
_refresh_quietly()
time.sleep(PROBE_TTL)
threading.Thread(target=loop, daemon=True, name="monitor-links-prewarmer").start()
def add_link(payload):
if not isinstance(payload, dict):
raise LinkError("Invalid request.")
name = str(payload.get("name") or "").strip()[:64]
url = normalize_url(payload.get("url"))
product = payload.get("product") if payload.get("product") in PRODUCTS else "pve"
with _lock:
data = _load()
if len(data["manual"]) >= MAX_MANUAL:
raise LinkError(f"At most {MAX_MANUAL} nodes can be added.")
if any(row.get("url") == url for row in data["manual"]):
raise LinkError("This address is already in the list.")
identifier = secrets.token_hex(6)
data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname,
"url": url, "product": product})
_save(data)
return f"manual:{identifier}"
def update_link(key, payload):
if not isinstance(payload, dict):
raise LinkError("Invalid request.")
with _lock:
data = _load()
if key.startswith("manual:"):
row = next((r for r in data["manual"] if f"manual:{r.get('id')}" == key), None)
if row is None:
raise LinkError("Node not found.")
if "name" in payload:
row["name"] = str(payload["name"] or "").strip()[:64] or row["name"]
if payload.get("url"):
row["url"] = normalize_url(payload["url"])
if payload.get("product") in PRODUCTS:
row["product"] = payload["product"]
elif re.fullmatch(r"(cluster|pbs):[\w.:\-\[\]]{1,253}", key):
override = data["overrides"].setdefault(key, {})
if "url" in payload:
if payload["url"]:
override["url"] = normalize_url(payload["url"])
else:
override.pop("url", None)
if "hidden" in payload:
override["hidden"] = bool(payload["hidden"])
if not override:
data["overrides"].pop(key, None)
else:
raise LinkError("Node not found.")
_save(data)
def delete_link(key):
with _lock:
data = _load()
remaining = [r for r in data["manual"] if f"manual:{r.get('id')}" != key]
if len(remaining) == len(data["manual"]):
raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.")
data["manual"] = remaining
_save(data)
+750
View File
@@ -0,0 +1,750 @@
#!/usr/bin/env python3
# ==========================================================
# ProxMenux - A menu-driven script for Proxmox VE management
# Author : VAIO73
# GitHub : github.com/Vaso73
# Maintainer : MacRimi
# Copyright : (c) 2026 MacRimi & contributors
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
# Version : 1.0
# Last Updated: 14/10/2026
# ==========================================================
# Description:
# On-demand native LXC discovery from service and installation metadata.
# Reads bounded data only; never executes discovered programs, starts guests,
# registers applications or infers their update mechanism.
# ==========================================================
"""Separate from catalog detection: callers must explicitly request a scan."""
import copy
from contextlib import contextmanager
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import selectors
import signal
import stat
import subprocess
import threading
import time
TOTAL_SECONDS = 20
PROBE_SECONDS = 3
OUTPUT_BYTES = 64 * 1024
MAX_RECORDS = 256
MAX_CANDIDATES = 50
CACHE_SECONDS = 300
LOCAL_CONFIG = Path('/etc/pve/local/lxc')
OCI_ROOT = Path('/usr/local/share/proxmenux/oci/instances')
UPDATE_LOCK_ROOT = Path('/run/lock')
_cache = {}
_locks = {}
_generations = {}
_mutex = threading.Lock()
_PATH = re.compile(r'/[A-Za-z0-9_.+/@-]{1,510}\Z')
_NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.+@-]{0,63}\Z')
_VERSION = re.compile(r'v?\d+(?:\.\d+){1,4}(?:[-+._][A-Za-z0-9.-]+)?\Z')
_SYSTEM = {
'ssh', 'sshd', 'cron', 'crond', 'postfix', 'rsyslog', 'syslog', 'syslog-ng',
# Alpine/OpenRC system logging and service supervision are not apps.
'klogd', 'syslogd', 'openrc-user', 'runit', 'runsvdir', 'runsv', 'supervise-daemon',
'dbus', 'dbus-broker', 'systemd', 'udev', 'networking', 'network', 'networkmanager',
'dhcpcd', 'dhclient', 'chrony', 'chronyd', 'ntp', 'ntpd', 'openntpd', 'agetty',
'getty', 'login', 'polkit', 'polkitd', 'accounts-daemon', 'acpid', 'anacron',
'docker', 'containerd', 'containerd-shim', 'podman', 'qemu-guest-agent', 'rngd',
'auditd', 'logrotate', 'local', 'bootmisc', 'hostname', 'hwclock', 'modules', 'master',
'proxmox-boot-sync', 'pve-container', 'pve-firewall', 'pvefw-logger',
'pvedaemon', 'pvestatd', 'pveproxy', 'pvescheduler', 'spiceproxy', 'lxc',
'mount-ro', 'mdev', 'sysctl', 'sysfs', 'devfs', 'cgroups', 'rc', 'rcs', 'swap',
'sleep', 'true', 'false', 'echo', 'cat', 'head', 'tail', 'tee', 'logger',
'apt', 'apt-get', 'dpkg', 'dpkg-query', 'apk', 'unattended-upgrades',
'fstrim', 'fsck', 'fsckd', 'e2scrub', 'e2scrub-all', 'e2scrub-reap',
'irqbalance', 'smartd', 'smartmontools', 'rpcbind', 'nfs-common', 'nfs-server',
'rpc-statd', 'rpc-statd-notify', 'rpc-gssd', 'rpc-svcgssd', 'nftables', 'iptables',
'ip6tables', 'firewalld', 'ufw', 'resolvconf', 'wpa-supplicant', 'modemmanager',
'console-setup', 'keyboard-setup', 'plymouth', 'halt', 'reboot', 'shutdown',
'rc-local', 'ntpsec', 'networkd-dispatcher', 'busybox', 'atd', 'uuidd',
# Common packaged daemons are infrastructure, not a user-managed app.
'nginx', 'apache', 'apache2', 'httpd', 'lighttpd', 'haproxy', 'traefik',
'redis', 'redis-server', 'memcached', 'mysql', 'mysqld', 'mariadb', 'mariadbd',
'postgres', 'postgresql', 'mongod', 'mongodb', 'rabbitmq-server',
'elasticsearch', 'opensearch', 'fail2ban', 'named', 'bind9', 'dnsmasq',
'avahi-daemon', 'smbd', 'nmbd', 'samba', 'vsftpd', 'proftpd', 'cups', 'tor',
}
_RUNTIMES = re.compile(r'(?:python[0-9.]*|node(?:js)?|npm|pnpm|yarn|bun|sh|bash|dash|env|java|ruby|perl)\Z')
_PACKAGE_ROOT_RUNTIMES = {'node', 'nodejs', 'npm', 'pnpm', 'yarn', 'bun'}
_HELPER_ROLES = {
'backup', 'bootstrap', 'bridge', 'cleanup', 'housekeeping', 'maintenance',
'migrate', 'migration', 'restore', 'rotate', 'update', 'updater', 'upgrade',
'watch', 'watchdog', 'worker',
}
_HESTIA_NGINX = '/usr/local/hestia/nginx/sbin/hestia-nginx'
_HESTIA_ROOT = '/usr/local/hestia'
_HESTIA_PANEL_PORT = 8083
class ProbeError(Exception):
"""Safe error code only; guest output must never become an API error."""
def bounded_run(argv, deadline):
"""Cap combined stdout/stderr while reading, and kill/reap the process group."""
remaining = min(PROBE_SECONDS, deadline - time.monotonic())
if remaining <= 0:
raise ProbeError('deadline')
end = time.monotonic() + remaining
try:
proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
start_new_session=True)
except OSError:
raise ProbeError('unavailable') from None
outputs = [bytearray(), bytearray()]
try:
with selectors.DefaultSelector() as poll:
for index, stream in enumerate((proc.stdout, proc.stderr)):
os.set_blocking(stream.fileno(), False)
poll.register(stream, selectors.EVENT_READ, index)
while poll.get_map():
wait = end - time.monotonic()
if wait <= 0:
raise ProbeError('timeout')
for key, _ in poll.select(wait):
block = os.read(key.fileobj.fileno(), 4096)
if not block:
poll.unregister(key.fileobj)
continue
if sum(map(len, outputs)) + len(block) > OUTPUT_BYTES:
raise ProbeError('output_limit')
outputs[key.data].extend(block)
try:
proc.wait(timeout=max(0.001, end - time.monotonic()))
except subprocess.TimeoutExpired:
raise ProbeError('timeout') from None
if proc.returncode:
raise ProbeError('read_failed')
return outputs[0].decode('utf-8', errors='strict')
except UnicodeError:
raise ProbeError('invalid_data') from None
finally:
# Also clean up descendants that outlive a successfully exited wrapper.
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
proc.wait()
proc.stdout.close()
proc.stderr.close()
# These are fixed read-only scripts, not guest-supplied shell or init scripts.
# awk emits only executable/script paths and WorkingDirectory, never arguments
# or environment values. OpenRC files are parsed as data, never sourced.
SERVICE_SCRIPT = r'''
command -v awk >/dev/null && command -v head >/dev/null || exit 1
n=0
for dir in /etc/systemd/system /usr/lib/systemd/system /lib/systemd/system /etc/init.d; do
# With a merged /usr, /lib is /usr/lib: the same units must not count twice.
[ "$dir" = /lib/systemd/system ] && [ /lib/systemd/system -ef /usr/lib/systemd/system ] && continue
[ ! -d "$dir" ] || [ -r "$dir" ] || exit 1
for f in "$dir"/*; do
[ -f "$f" ] || continue
case "$dir:$f" in /etc/init.d:*) kind=openrc;; *:*.service) kind=systemd;; *) continue;; esac
n=$((n+1)); [ "$n" -le 256 ] || { printf 'LIMIT\n'; exit 0; }
[ -r "$f" ] || exit 1
content=$(timeout -s KILL 1 head -c 16385 "$f") || exit 1
[ "${#content}" -le 16384 ] || { printf 'LIMIT\n'; exit 0; }
printf '%s\n' "$content" | awk -v kind="$kind" -v file="$f" '
function clean(v) {gsub(/^[ \t]+|[ \t]+$/, "", v); gsub(/^["\047]|["\047]$/, "", v); return v}
/^ExecStart=/ {v=substr($0,11); sub(/^[-+!:@]+/, "", v); split(v,a,/[ \t]+/); exe=clean(a[1]); script=clean(a[2]); if(script !~ /^\//) script=""}
/^WorkingDirectory=/ {dir=clean(substr($0,18))}
/^command=/ {exe=clean(substr($0,9))}
/^directory=/ {dir=clean(substr($0,11))}
/^command_args=/ {v=clean(substr($0,14)); split(v,a,/[ \t]+/); script=clean(a[1]); if(script !~ /^\//) script=""}
END {printf "S\t%s\t%s\t%s\t%s\t%s\n", file, kind, exe, script, dir}'
done
done
printf 'END\n'
'''
PROCESS_SCRIPT = r'''
command -v readlink >/dev/null && command -v head >/dev/null || exit 1
n=0
for dir in /proc/[0-9]*; do
[ -d "$dir" ] || continue
n=$((n+1)); [ "$n" -le "$1" ] || { printf 'LIMIT\n'; exit 0; }
# Never inspect Docker/OCI child workloads or read cmdline/environ.
[ -d "$dir" ] || continue
[ -r "$dir/cgroup" ] || exit 1
group=$(timeout -s KILL 1 head -c 8193 "$dir/cgroup") || exit 1
[ "${#group}" -le 8192 ] || { printf 'LIMIT\n'; exit 0; }
case "$group" in *docker*|*libpod*|*kubepods*|*containerd*) continue;; esac
# Kernel threads and vanished processes have no application executable.
[ -L "$dir/exe" ] || continue
exe=$(timeout -s KILL 1 readlink "$dir/exe") || continue
cwd=$(timeout -s KILL 1 readlink "$dir/cwd") || continue
printf 'P\t%s\t%s\t%s\n' "${dir##*/}" "$exe" "$cwd"
done
printf 'END\n'
'''
PORT_SCRIPT = r'''
command -v awk >/dev/null || exit 1
if command -v ss >/dev/null; then
(timeout -s KILL 1 ss -H -lntp || printf 'FAILED\n') | awk -v cap="$1" '
/^FAILED$/ {failed=1; exit}
NR > cap {limited=1; exit}
{port=$4; sub(/^.*:/,"",port); rest=$0
# More than one owner is ambiguous; never choose the first arbitrarily.
if (gsub(/pid=/,"pid=",rest)!=1) next
if (match(rest,/pid=[0-9]+/)) {pid=substr(rest,RSTART+4,RLENGTH-4); printf "L\t%s\t%s\n",pid,port}}
END {print failed ? "FAILED" : (limited ? "LIMIT" : "END")}'
elif command -v netstat >/dev/null; then
(timeout -s KILL 1 netstat -lntp || printf 'FAILED\n') | awk -v cap="$1" '
/^FAILED$/ {failed=1; exit}
/^tcp/ {n++; if(n>cap) {limited=1; exit}; port=$4; sub(/^.*:/,"",port)
pid=$7; sub(/\/.*$/,"",pid); if(pid ~ /^[0-9]+$/) printf "L\t%s\t%s\n",pid,port}
END {print failed ? "FAILED" : (limited ? "LIMIT" : "END")}'
else
# Without ss or netstat there is no listener evidence; a retry would not change it.
printf 'END\n'
fi
'''
HESTIA_PANEL_SCRIPT = r'''
# The generic process collector intentionally stops after a bounded number of
# records and ignores shared listener ownership. Hestia's master/worker pair
# can therefore be outside that generic result. This narrow fallback never
# accepts a name alone: it requires Hestia's exact executable and its panel
# listener on the fixed local port.
[ -x /usr/local/hestia/nginx/sbin/hestia-nginx ] || { printf 'END\n'; exit 0; }
command -v pgrep >/dev/null && command -v readlink >/dev/null && command -v ss >/dev/null && command -v head >/dev/null || exit 1
n=0
for pid in $(pgrep -x hestia-nginx 2>/dev/null | head -n 9); do
case "$pid" in *[!0-9]*|'') continue;; esac
n=$((n+1)); [ "$n" -le 8 ] || { printf 'LIMIT\n'; exit 0; }
exe=$(timeout -s KILL 1 readlink "/proc/$pid/exe") || continue
[ "$exe" = /usr/local/hestia/nginx/sbin/hestia-nginx ] || continue
cwd=$(timeout -s KILL 1 readlink "/proc/$pid/cwd") || continue
if timeout -s KILL 1 ss -H -lntp | awk -v pid="$pid" '
{port=$4; sub(/^.*:/,"",port)
if (port == 8083 && index($0, "pid=" pid ",")) found=1}
END {exit(found ? 0 : 1)}'; then
printf 'H\t%s\t%s\t%s\t8083\n' "$pid" "$exe" "$cwd"
fi
done
printf 'END\n'
'''
FOOTPRINT_SCRIPT = r'''
[ -f "$1" ] && [ -r "$1" ] || exit 1
printf 'FILE\n'
if [ -f "$2/package.json" ]; then
[ -r "$2/package.json" ] || exit 1
timeout -s KILL 1 head -c 8192 "$2/package.json"
fi
'''
MANIFEST_FOOTPRINT_SCRIPT = r'''
command -v head >/dev/null && command -v readlink >/dev/null || exit 1
# Resolve only the operator-owned working directory and its declared parent.
# A release symlink may change the concrete version directory, but it must not
# escape the bounded custom application root supplied by the host.
workdir=$(timeout -s KILL 1 readlink -f "$1") || exit 1
anchor=$(timeout -s KILL 1 readlink -f "$2") || exit 1
parent=$(timeout -s KILL 1 readlink -f "$3") || exit 1
case "$anchor" in "$parent"/*) ;; *) printf 'OUTSIDE\n'; exit 0;; esac
case "$workdir" in "$anchor"|"$anchor"/*) ;; *) printf 'OUTSIDE\n'; exit 0;; esac
for relative in package.json server/package.json; do
candidate="$workdir/$relative"
[ -f "$candidate" ] && [ -r "$candidate" ] || continue
resolved=$(timeout -s KILL 1 readlink -f "$candidate") || exit 1
case "$resolved" in "$workdir"/*) ;; *) printf 'OUTSIDE\n'; exit 0;; esac
printf 'MANIFEST\t%s\n' "$relative"
timeout -s KILL 1 head -c 8192 "$resolved"
exit 0
done
printf 'MISSING\n'
'''
def _path(value):
return bool(isinstance(value, str) and _PATH.fullmatch(value)
and '..' not in value.split('/'))
def _system(name):
raw = name.lower().removesuffix('.service')
stem = raw.split('@')[0]
return (raw.startswith('user@') or stem in _SYSTEM
or (raw.startswith('php') and raw.endswith('-fpm'))
or stem.startswith((
'systemd-', 'dbus-', 'getty', 'containerd-', 'apt-', 'dpkg-', 'e2scrub',
'unattended-', 'ifup', 'ifdown', 'user@', 'btrfs-', 'lvm2-', 'lxc-')))
def _helper(name):
"""Reject service roles that support an application but are not one themselves."""
raw = name.lower().removesuffix('.service')
if raw.endswith('@'):
return True # A systemd template is not a concrete running application.
return any(part in _HELPER_ROLES for part in re.split(r'[._-]+', raw) if part)
def _hestia_panel_process(executable, ports):
"""Recognize only Hestia's own bound control-panel process.
Hestia's generated init unit does not expose an ExecStart that the generic
service parser may treat as an application. Its panel process and port are
stable, local runtime metadata, so this stays narrower than accepting
arbitrary vendor services or a generic web server.
"""
return executable == _HESTIA_NGINX and _HESTIA_PANEL_PORT in ports
def _custom(path):
if not _path(path):
return False
if path.startswith(('/opt/', '/srv/', '/usr/local/')):
return True
# Native apps commonly keep their runtime below /home/<service-user>/<app>.
# Do not accept a home directory itself: a second path component is required.
parts = path.split('/')
return len(parts) >= 4 and parts[1] == 'home' and bool(parts[2]) and bool(parts[3])
def _manifest_bounds(executable, directory):
"""Return a narrow, operator-owned boundary for a runtime manifest probe."""
try:
shared = os.path.commonpath((executable, directory))
except ValueError:
shared = ''
anchor = shared if _custom(shared) else directory
parts = anchor.split('/')
if anchor.startswith(('/opt/', '/srv/')):
parent = '/' + parts[1]
elif anchor.startswith('/usr/local/'):
parent = '/usr/local'
elif len(parts) >= 4 and parts[1] == 'home' and parts[2]:
parent = f'/home/{parts[2]}'
else:
return None
return anchor, parent
def _manifest_footprint(record, run, deadline):
"""Read one of two fixed Node manifest locations without following an escape."""
output = run(['pct', 'exec', str(record['vmid']), '--', 'timeout', '-s', 'KILL', '2',
'sh', '-c', MANIFEST_FOOTPRINT_SCRIPT, 'discovery',
record['root'], record['manifest_anchor'], record['manifest_parent']], deadline)
header, separator, metadata = output.partition('\n')
if header == 'MISSING':
if metadata:
raise ProbeError('invalid_data')
return None, None
if header == 'OUTSIDE':
if metadata:
raise ProbeError('invalid_data')
raise ProbeError('unsafe_manifest_root')
prefix = 'MANIFEST\t'
relative = header.removeprefix(prefix)
if not header.startswith(prefix) or relative not in ('package.json', 'server/package.json'):
raise ProbeError('invalid_data')
if not separator:
raise ProbeError('invalid_data')
candidate_record = dict(record)
candidate_record['path'] = str(Path(record['root']) / relative)
return candidate_record, 'FILE\n' + metadata
def parse_records(text, kind):
lines = text.splitlines()
if not lines or lines[-1] not in ('END', 'LIMIT'):
raise ProbeError('incomplete')
if len(lines) - 1 > MAX_RECORDS:
raise ProbeError('metadata_limit')
records = []
seen_services = set()
for line in lines[:-1]:
parts = line.split('\t')
if kind == 'S' and len(parts) == 6 and parts[0] == 'S':
_, source, manager, exe, script, directory = parts
if not _path(source) or manager not in ('systemd', 'openrc'):
raise ProbeError('invalid_data')
service = Path(source).name.removesuffix('.service')
if service in seen_services:
continue # /etc overrides vendor units; no duplicate installations.
seen_services.add(service)
if (not _NAME.fullmatch(service) or _system(service) or _helper(service)
or not _path(exe)):
continue
runtime_name = Path(exe).name
runtime = bool(_RUNTIMES.fullmatch(runtime_name))
manifest_bounds = None
if runtime and not _path(script):
# A Node/npm wrapper may keep its entry point relative to an
# operator-owned working directory. Only two fixed manifest
# locations can prove that root; no argv, environment, recursive
# search or guest-provided path is ever used.
if runtime_name not in _PACKAGE_ROOT_RUNTIMES or not _custom(directory):
continue
install = str(Path(directory) / 'package.json')
manifest_bounds = _manifest_bounds(exe, directory)
if manifest_bounds is None:
continue
else:
install = script if runtime else exe
if (not _path(install) or _system(Path(install).name)
or install.startswith(('/usr/lib/systemd/', '/lib/systemd/',
'/usr/lib/udev/', '/lib/udev/', '/usr/lib/apt/'))):
continue
# /etc units are operator-owned. Do not infer an application from an
# arbitrary packaged systemd unit: without a package-db probe it is
# impossible to distinguish vendor infrastructure from a user app.
if (manager == 'systemd'
and source.startswith(('/usr/lib/systemd/system/', '/lib/systemd/system/'))
and not _custom(install)):
continue
root = directory if _custom(directory) else str(Path(install).parent)
# Generic runtime without an absolute application script is ambiguous.
if runtime and not _custom(install):
continue
record = {'name': service, 'path': install, 'root': root,
'service': source, 'manager': manager, 'executable': exe}
if manifest_bounds:
record['manifest_anchor'], record['manifest_parent'] = manifest_bounds
records.append(record)
elif kind == 'P' and len(parts) == 4 and parts[0] == 'P':
_, pid, exe, directory = parts
# A process rooted at / is normally too ambiguous for generic
# discovery, but it is valid process metadata for the exact Hestia
# panel signature below. It never becomes a generic candidate.
if not pid.isdigit() or not _path(exe) or (directory != '/' and not _path(directory)):
continue
records.append({'pid': pid, 'executable': exe, 'root': directory})
elif kind == 'L' and len(parts) == 3 and parts[0] == 'L':
_, pid, port = parts
if pid.isdigit() and port.isdigit() and 0 < int(port) < 65536:
records.append({'pid': pid, 'port': int(port)})
else:
raise ProbeError('invalid_data')
elif kind == 'H' and len(parts) == 5 and parts[0] == 'H':
_, pid, exe, directory, port = parts
if (pid.isdigit() and exe == _HESTIA_NGINX
and (directory == '/' or _path(directory))
and port == str(_HESTIA_PANEL_PORT)):
records.append({'pid': pid, 'executable': exe, 'root': directory,
'port': _HESTIA_PANEL_PORT})
else:
raise ProbeError('invalid_data')
else:
raise ProbeError('invalid_data')
return records, lines[-1] == 'LIMIT', len(lines) - 1
def candidate(vmid, record, footprint):
if not footprint.startswith('FILE\n'):
raise ProbeError('invalid_data')
version, metadata_issue = None, None
metadata = footprint[5:].strip()
if metadata:
try:
manifest = json.loads(metadata)
except (ValueError, TypeError):
manifest = None
# Only the whitelisted version field leaves this function. Never retain
# manifest scripts, repository guesses, config or arbitrary extra fields.
if not isinstance(manifest, dict):
metadata_issue = 'invalid_metadata'
manifest = {}
value = manifest.get('version')
if (isinstance(value, str) and len(value) <= 64 and _VERSION.fullmatch(value)
and record['path'].startswith(record['root'].rstrip('/') + '/')):
version = value
identity = 'native-' + hashlib.sha256(
f'{int(vmid)}:{record["path"]}:{record["root"]}'.encode()).hexdigest()[:24]
evidence = [{'kind': 'installation_file', 'value': record['path']}]
if record.get('service'):
evidence.append({'kind': record['manager'], 'value': record['service']})
else:
evidence.extend([{'kind': 'process_executable', 'value': record['process']},
{'kind': 'working_directory',
'value': record.get('process_root', record['root'])}])
ports = sorted(set(record.get('listening_ports') or []))
# Hestia's control panel is a narrow, verified exception to generic port
# inference: its own executable serves the panel on fixed HTTPS port 8083.
# Keep this declaration on the exact runtime signature so an unrelated
# application merely listening on 8083 never receives an HTTPS guess.
hestia_panel = (record['name'] == 'Hestia'
and record['path'] == _HESTIA_NGINX
and record['root'] == _HESTIA_ROOT
and _HESTIA_PANEL_PORT in ports)
evidence.extend({'kind': 'tcp_listener', 'value': str(port)} for port in ports)
return {'slug': identity, 'discovery_id': identity, 'source': 'native_metadata',
'name': record['name'], 'installed_version': version, 'evidence': evidence,
'installation_path': record['path'],
'default_ports': [_HESTIA_PANEL_PORT] if hestia_panel else [],
'listening_ports': ports,
**({'scheme': 'https'} if hestia_panel else {}),
'metadata_issue': metadata_issue,
'tracking_suggestion': ({'installed_via': 'manual', 'installed_version': version,
'detected_version': version, 'detector_verified': True}
if version else None)}
def _guard(vmid, deadline, run):
# The local node path rejects QEMU, missing and remote CTs before pct exec.
path = LOCAL_CONFIG / f'{vmid}.conf'
if not path.is_file():
raise ProbeError('not_local_lxc')
# Presence, including a damaged/in-progress record, excludes generic probes.
try:
os.lstat(OCI_ROOT / str(vmid))
except FileNotFoundError:
pass
except OSError:
raise ProbeError('oci_unknown') from None
else:
raise ProbeError('managed_oci')
try:
with path.open('rb') as handle:
raw = handle.read(OUTPUT_BYTES + 1)
if len(raw) > OUTPUT_BYTES:
raise ProbeError('config_limit')
config = raw.decode('utf-8')
except (OSError, UnicodeError):
raise ProbeError('config_unavailable') from None
if re.search(r'^lock:', config, re.M):
raise ProbeError('guest_busy')
update_lock = UPDATE_LOCK_ROOT / f'proxmenux-lxc-update-{vmid}.lock'
try:
fd = os.open(update_lock, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, 'rb') as handle:
info = os.fstat(handle.fileno())
if not stat.S_ISREG(info.st_mode):
raise ProbeError('lock_unavailable')
if info.st_size:
raise ProbeError('guest_busy')
try:
fcntl.flock(handle, fcntl.LOCK_SH | fcntl.LOCK_NB)
except BlockingIOError:
raise ProbeError('guest_busy') from None
finally:
fcntl.flock(handle, fcntl.LOCK_UN)
except FileNotFoundError:
pass
except OSError:
raise ProbeError('lock_unavailable') from None
if re.search(r'^ostype:\s*unmanaged\s*$', config, re.M):
raise ProbeError('unsupported_guest')
if not re.search(r'^ostype:\s*(?:debian|ubuntu|alpine|archlinux|centos|fedora|opensuse|gentoo)\s*$', config, re.M):
raise ProbeError('unsupported_guest')
status = run(['pct', 'status', str(vmid)], deadline).strip()
if status != 'status: running':
raise ProbeError('guest_stopped' if status == 'status: stopped' else 'status_unknown')
@contextmanager
def _scan_reservation(vmid):
"""Exclude cooperating updates/control tasks for this bounded scan only.
Keep an empty lock inode after first use, as the update runner does.
Never truncate a pending control-task reservation or unlink its inode.
"""
path = UPDATE_LOCK_ROOT / f'proxmenux-lxc-update-{vmid}.lock'
fd = None
try:
fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
if not stat.S_ISREG(os.fstat(fd).st_mode):
raise ProbeError('lock_unavailable')
try:
fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
except BlockingIOError:
raise ProbeError('guest_busy') from None
if os.fstat(fd).st_size:
raise ProbeError('guest_busy')
yield
except OSError:
raise ProbeError('lock_unavailable') from None
finally:
if fd is not None:
os.close(fd)
def scan(vmid, run=bounded_run):
deadline = time.monotonic() + TOTAL_SECONDS
_guard(vmid, deadline, run)
with _scan_reservation(vmid):
result = _scan_metadata(vmid, deadline, run)
# External PVE lifecycle operations do not share our flock. Fail closed
# if the guest stopped, changed ownership or became locked meanwhile.
_guard(vmid, deadline, run)
return result
def _scan_metadata(vmid, deadline, run):
records, issues, consumed, processes, listeners, hestia_panels = [], [], 0, [], [], []
for kind, script in (('S', SERVICE_SCRIPT), ('P', PROCESS_SCRIPT), ('L', PORT_SCRIPT)):
try:
if consumed >= MAX_RECORDS:
issues.append('metadata_limit')
break
parsed, limited, count = parse_records(run(
['pct', 'exec', str(vmid), '--', 'timeout', '-s', 'KILL', '2',
'sh', '-c', script, 'discovery', str(MAX_RECORDS - consumed)], deadline), kind)
if consumed + count > MAX_RECORDS:
issues.append('metadata_limit')
parsed = parsed[:MAX_RECORDS - consumed]
consumed = min(MAX_RECORDS, consumed + count)
# The entire collector, not each metadata source, has 256 records.
capacity = MAX_RECORDS - len(records)
if kind == 'S':
records.extend(parsed[:capacity])
elif kind == 'P':
processes = parsed[:capacity]
else:
listeners = parsed[:capacity]
if limited or len(parsed) > capacity:
issues.append('metadata_limit')
except ProbeError as exc:
issues.append(str(exc))
try:
# This is outside the generic record budget: it returns only an exact,
# fixed Hestia signature, not additional arbitrary process metadata.
hestia_panels, limited, _ = parse_records(run(
['pct', 'exec', str(vmid), '--', 'timeout', '-s', 'KILL', '2',
'sh', '-c', HESTIA_PANEL_SCRIPT, 'discovery'], deadline), 'H')
if limited:
issues.append('metadata_limit')
except ProbeError as exc:
issues.append(str(exc))
for process in processes:
exe, directory = process['executable'], process['root']
related = [record for record in records if record['executable'] == exe and (
not _RUNTIMES.fullmatch(Path(exe).name) or record['root'] == directory)]
ports = [entry['port'] for entry in listeners if entry['pid'] == process['pid']]
if len(related) == 1:
related[0].setdefault('listening_ports', []).extend(ports)
elif not related and _hestia_panel_process(exe, ports):
records.append({'name': 'Hestia', 'path': exe, 'root': _HESTIA_ROOT,
'process': exe, 'process_root': directory, 'executable': exe,
'listening_ports': ports})
elif not related and (_custom(exe) and _custom(directory)
and not _system(Path(exe).name)
and not _helper(Path(exe).name)
and not _RUNTIMES.fullmatch(Path(exe).name)
and exe.startswith(directory.rstrip('/') + '/')
and _NAME.fullmatch(Path(exe).name)):
records.append({'name': Path(exe).name, 'path': exe, 'root': directory,
'process': exe, 'executable': exe, 'listening_ports': ports})
for panel in hestia_panels:
records.append({'name': 'Hestia', 'path': panel['executable'], 'root': _HESTIA_ROOT,
'process': panel['executable'], 'process_root': panel['root'],
'executable': panel['executable'],
'listening_ports': [panel['port']]})
items, seen = [], set()
for record in records:
key = (record['path'], record['root'])
if key in seen:
continue
seen.add(key)
if len(items) >= MAX_CANDIDATES:
issues.append('candidate_limit')
break
try:
probe_record = dict(record)
probe_record['vmid'] = vmid
if 'manifest_anchor' in record:
probe_record, footprint = _manifest_footprint(probe_record, run, deadline)
if probe_record is None:
continue
else:
footprint = run(['pct', 'exec', str(vmid), '--', 'timeout', '-s', 'KILL', '2',
'sh', '-c', FOOTPRINT_SCRIPT,
'discovery', record['path'], record['root']], deadline)
item = candidate(vmid, probe_record, footprint)
items.append(item)
if item['metadata_issue']:
issues.append(item['metadata_issue'])
except ProbeError as exc:
issues.append(str(exc))
if str(exc) == 'deadline':
break
return {'vmid': vmid, 'status': 'partial' if issues else 'complete',
'candidates': items, 'issues': sorted(set(issues)), 'stale': False,
'scanned_at': time.time()}
def cached(vmid):
with _mutex:
value = copy.deepcopy(_cache.get(int(vmid)))
if value is None:
return {'vmid': int(vmid), 'status': 'not_scanned', 'candidates': [], 'stale': False}
if time.time() - value['scanned_at'] >= CACHE_SECONDS:
value['stale'] = True
return value
def mark_stale(vmid):
"""Lifecycle events retire freshness, never collect new guest metadata."""
with _mutex:
_generations[int(vmid)] = _generations.get(int(vmid), 0) + 1
if int(vmid) in _cache:
_cache[int(vmid)]['stale'] = True
def discover(vmid):
vmid = int(vmid)
with _mutex:
lock = _locks.setdefault(vmid, threading.Lock())
if not lock.acquire(blocking=False):
return {**cached(vmid), 'status': 'busy', 'stale': True}
try:
with _mutex:
generation = _generations.get(vmid, 0)
try:
result = scan(vmid)
except ProbeError as exc:
result = {**cached(vmid), 'status': 'error', 'issues': [str(exc)], 'stale': True}
result.setdefault('scanned_at', time.time())
# Comparing the generation and publishing must be atomic with lifecycle
# invalidation, including the first scan when no cache exists yet.
with _mutex:
previous = copy.deepcopy(_cache.get(vmid))
if _generations.get(vmid, 0) != generation:
result = {**(previous or {'vmid': vmid, 'candidates': [],
'scanned_at': time.time()}),
'status': 'error', 'issues': ['guest_changed'], 'stale': True}
elif result['status'] != 'complete' and previous and previous['candidates']:
# Partial output cannot prove that previously seen apps disappeared.
ids = {item['discovery_id'] for item in result['candidates']}
result['candidates'].extend(item for item in previous['candidates']
if item['discovery_id'] not in ids)
result['candidates'] = result['candidates'][:MAX_CANDIDATES]
result['stale'] = True
_cache[vmid] = copy.deepcopy(result)
return result
finally:
lock.release()
def filter_candidates(result, suggestions, apps):
"""Pure composition: never run catalog probes, load or write registries."""
known = [*apps, *(suggestions.get('extras') or []),
*(suggestions.get('docker_workloads') or [])]
if suggestions.get('helper_slug'):
known.append({'name': suggestions.get('name_suggestion'),
'helper_slug': suggestions['helper_slug'],
'tracking_suggestion': suggestions.get('tracking_suggestion')})
identities = {a.get('discovery_id') for a in known if a.get('discovery_id')}
names, paths = set(), set()
for app in known:
for field in ('name', 'slug', 'helper_slug', 'package'):
if app.get(field):
names.add(str(app[field]).casefold())
detector = app.get('tracking_suggestion') or app
for field in ('binary_path', 'file_path'):
if detector.get(field):
paths.add(detector[field])
items = [item for item in result['candidates']
if item['discovery_id'] not in identities
and item['name'].casefold() not in names
and item['installation_path'] not in paths]
return {**result, 'candidates': items}
+1 -1
View File
@@ -1564,7 +1564,7 @@ class AppriseChannel(NotificationChannel):
return apprise_mod.NotifyType.FAILURE
if sev == 'WARNING':
return apprise_mod.NotifyType.WARNING
if sev == 'SUCCESS':
if sev in ('SUCCESS', 'OK'):
return apprise_mod.NotifyType.SUCCESS
return apprise_mod.NotifyType.INFO
+7
View File
@@ -3850,6 +3850,13 @@ class PollingCollector:
return
self._last_managed_check = now
try:
from pbs_manager import is_pbs_host
if is_pbs_host():
return # a Proxmox Backup Server has no guests or integrations to track
except Exception:
pass
try:
import managed_installs
except Exception:
+3 -1
View File
@@ -3335,6 +3335,7 @@ def _ai_cache_key(title, body, ai_config, detail_level, use_emojis):
str(ai_config.get('ai_provider', '')), '\x1f',
str(ai_config.get('ai_model', '')), '\x1f',
str(ai_config.get('ai_language', '')), '\x1f',
str(ai_config.get('channel_type', '')).lower(), '\x1f',
detail_level, '\x1f', '1' if use_emojis else '0',
]
return _hash_ai_cache.sha256(''.join(parts).encode('utf-8', 'replace')).hexdigest()
@@ -3428,7 +3429,8 @@ def format_with_ai_full(title: str, body: str, severity: str,
channel_type = ai_config.get('channel_type', '').lower()
is_email = channel_type == 'email'
if is_email and detail_level == 'detailed' and body and len(body) > 50:
if (is_email and detail_level == 'detailed' and body and len(body) > 50
and result_body != body):
# Only append if original has substantial content
result_body += "\n\n" + "-" * 40 + "\n"
result_body += "Original message:\n"
+288
View File
@@ -0,0 +1,288 @@
"""Local PBS maintenance information and remote, read-only PBS monitoring."""
from concurrent.futures import ThreadPoolExecutor
import hashlib
import hmac
import http.client
import importlib.util
import json
import os
from pathlib import Path
import re
import secrets
import shutil
import ssl
import subprocess
import tempfile
import threading
import time
from urllib.parse import urlsplit
import auth_manager
try:
from proxmox_platform import detect_platform
except ModuleNotFoundError:
# Development checkout; AppImage bundles the canonical shared detector.
_path = Path(__file__).resolve().parents[2] / "scripts/global/proxmox_platform.py"
_spec = importlib.util.spec_from_file_location("proxmox_platform", _path)
_platform_module = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_platform_module)
detect_platform = _platform_module.detect_platform
_lock = threading.RLock()
_platform_cache = (0, {})
_status_cache = {}
PBS_SERVICES = ("proxmox-backup", "proxmox-backup-proxy")
MAX_RESPONSE = 4 * 1024 * 1024
class PBSError(ValueError):
pass
def get_platform():
global _platform_cache
with _lock:
if _platform_cache[0] <= time.monotonic():
_platform_cache = (time.monotonic() + 60, detect_platform())
return dict(_platform_cache[1])
def is_pbs_host():
return get_platform().get("product") == "pbs"
def _servers_path():
return auth_manager.CONFIG_DIR / "pbs_servers.json"
def _load_servers():
try:
data = json.loads(_servers_path().read_text())
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
raise ValueError()
return data
except FileNotFoundError:
return []
except (OSError, ValueError) as exc:
raise PBSError("Could not read the PBS server configuration.") from exc
def _save_servers(servers):
auth_manager.ensure_config_dir()
descriptor, temporary = tempfile.mkstemp(prefix=".pbs-servers-", dir=auth_manager.CONFIG_DIR)
try:
with os.fdopen(descriptor, "w") as handle:
os.fchmod(handle.fileno(), 0o600)
json.dump(servers, handle, indent=2)
os.replace(temporary, _servers_path())
finally:
if os.path.exists(temporary):
os.unlink(temporary)
_status_cache.clear()
def public_servers():
fields = ("id", "name", "url", "token_id", "fingerprint")
with _lock:
return [{**{key: row.get(key, "") for key in fields}, "has_token": bool(row.get("token_secret"))}
for row in _load_servers()]
def _validate_server(data, previous=None):
if not isinstance(data, dict):
raise PBSError("Invalid PBS server configuration.")
previous = previous or {}
row = {field: data.get(field, previous.get(field, ""))
for field in ("name", "url", "token_id", "token_secret", "fingerprint")}
if any(not isinstance(value, str) or len(value) > 4096 for value in row.values()):
raise PBSError("Invalid PBS connection field.")
row["name"] = row["name"].strip()
row["url"] = row["url"].strip().rstrip("/")
try:
parts = urlsplit(row["url"])
parts.port
if (parts.scheme != "https" or not parts.hostname or parts.username or parts.password
or parts.query or parts.fragment):
raise ValueError()
except ValueError as exc:
raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.") from exc
if not row["name"] or len(row["name"]) > 128:
raise PBSError("A server name of at most 128 characters is required.")
row["token_id"] = row["token_id"].strip()
if not re.fullmatch(r"[^\s:!]+@[^\s:!]+![^\s:!]+", row["token_id"]):
raise PBSError("Enter a PBS API token ID in user@realm!token format.")
if (not row["token_secret"] or not row["token_secret"].isascii()
or any(char.isspace() or ord(char) < 32 for char in row["token_secret"])):
raise PBSError("A valid API token secret is required.")
if not row["token_id"].isascii():
raise PBSError("Invalid API token ID.")
fingerprint = row["fingerprint"].strip().lower().replace(":", "")
if fingerprint and not re.fullmatch("[a-f0-9]{64}", fingerprint):
raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).")
row["fingerprint"] = fingerprint
return row
def _remote_get(server, endpoint):
"""Validate the TLS peer BEFORE transmitting the PBS token (no redirects)."""
parts = urlsplit(server["url"])
fingerprint = server.get("fingerprint")
if fingerprint:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE # Authenticated by the explicit pin below.
else:
context = ssl.create_default_context()
connection = http.client.HTTPSConnection(parts.hostname, parts.port or 443,
timeout=5, context=context)
try:
connection.connect()
if fingerprint:
actual = hashlib.sha256(connection.sock.getpeercert(binary_form=True)).hexdigest()
if not hmac.compare_digest(actual, fingerprint):
raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.")
path = parts.path.rstrip("/") + "/api2/json" + endpoint
connection.request("GET", path, headers={
"Accept": "application/json",
"Authorization": "PBSAPIToken=" + server["token_id"] + ":" + server["token_secret"],
})
response = connection.getresponse()
content = response.read(MAX_RESPONSE + 1)
if response.status in (401, 403):
raise PBSError("PBS rejected the API token or its permissions. Check both the user and token ACLs.")
if response.status != 200 or len(content) > MAX_RESPONSE:
raise PBSError("PBS returned an invalid response. Redirects are not followed.")
document = json.loads(content)
if not isinstance(document, dict) or "data" not in document:
raise PBSError("PBS returned an invalid JSON document.")
return document["data"]
except PBSError:
raise
except (OSError, http.client.HTTPException, ValueError) as exc:
raise PBSError("Could not connect to PBS over verified HTTPS. Check the address, certificate and service.") from exc
finally:
connection.close()
def save_server(data):
identifier = data.get("id") if isinstance(data, dict) else None
if identifier is not None and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", str(identifier)):
raise PBSError("Invalid PBS server ID.")
with _lock:
previous = next((row for row in _load_servers() if row["id"] == identifier), None)
row = _validate_server(data, previous)
# /version may be public; validate credentials on the protected status API.
usage = _remote_get(row, "/status/datastore-usage")
if not isinstance(usage, list):
raise PBSError("PBS returned an invalid datastore list.")
row.update(id=identifier or "pbs-" + secrets.token_hex(6), revision=secrets.token_hex(16))
with _lock:
servers = _load_servers()
if previous is None and len(servers) >= 32:
raise PBSError("At most 32 PBS connections may be configured.")
servers = [existing for existing in servers if existing["id"] != row["id"]]
_save_servers([*servers, row])
return row["id"]
def delete_server(identifier):
with _lock:
servers = _load_servers()
remaining = [row for row in servers if row["id"] != identifier]
if len(remaining) == len(servers):
raise PBSError("PBS server not found.")
_save_servers(remaining)
def _cli(*arguments):
try:
result = subprocess.run(["proxmox-backup-manager", *arguments, "--output-format", "json"],
check=True, text=True, capture_output=True, timeout=8)
data = json.loads(result.stdout)
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
raise ValueError()
return data
except (OSError, subprocess.SubprocessError, ValueError) as exc:
raise PBSError("Could not read PBS " + " ".join(arguments[:2]) + ". Check its service and permissions.") from exc
def _local_datastores():
stores = _cli("datastore", "list")
result = []
for store in stores:
row = {"name": store.get("name"), "path": store.get("path"),
"maintenance_mode": store.get("maintenance-mode"), "total": None, "used": None, "avail": None}
path = store.get("path")
# A removable datastore directory may survive unmounting. Do not report
# its underlying root filesystem as the missing datastore's capacity.
inactive = store.get("backing-device") and not (path and os.path.ismount(path))
if not path or inactive or store.get("maintenance-mode"):
row["error"] = "Datastore is offline, in maintenance or has no mounted backing device."
else:
try:
capacity = shutil.disk_usage(path)
row.update(total=capacity.total, used=capacity.used, avail=capacity.free)
except OSError:
row["error"] = "Datastore filesystem is not available."
result.append(row)
return result
def _local_services():
result = {}
for service in PBS_SERVICES:
try:
check = subprocess.run(["systemctl", "is-active", service], capture_output=True, text=True, timeout=3)
result[service] = check.stdout.strip() if check.returncode == 0 else "inactive"
except (OSError, subprocess.SubprocessError):
result[service] = "unknown"
return result
def get_status(identifier):
with _lock:
cached = _status_cache.get(identifier)
if cached and cached[0] > time.monotonic():
return cached[1]
errors = {}
if identifier == "local":
if not is_pbs_host():
raise PBSError("The local host is not a PBS server.")
name, version = "Local PBS", get_platform().get("version")
services = _local_services()
operations = {"datastores": _local_datastores,
"tasks": lambda: _cli("task", "list", "--all", "true", "--limit", "20"),
"sync": lambda: _cli("sync-job", "list"),
"verify": lambda: _cli("verify-job", "list"),
"prune": lambda: _cli("prune-job", "list")}
else:
with _lock:
server = next((row for row in _load_servers() if row["id"] == identifier), None)
if not server:
raise PBSError("PBS server not found.")
metadata = _remote_get(server, "/version")
name, version = server["name"], metadata.get("version") if isinstance(metadata, dict) else None
services = {}
endpoints = {"datastores": "/status/datastore-usage", "tasks": "/nodes/localhost/tasks?limit=20",
"sync": "/admin/sync", "verify": "/admin/verify", "prune": "/admin/prune"}
operations = {key: (lambda endpoint=path: _remote_get(server, endpoint)) for key, path in endpoints.items()}
result = {"server": identifier, "name": name, "version": version, "services": services,
"fetched_at": int(time.time()), "errors": errors}
with ThreadPoolExecutor(max_workers=5) as executor:
futures = {key: executor.submit(operation) for key, operation in operations.items()}
for key, future in futures.items():
try:
data = future.result()
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
raise PBSError("PBS returned an invalid list.")
result[key] = [{**row, "name": row.get("store", row.get("name"))} for row in data] if key == "datastores" else data
except PBSError as exc:
result[key] = None # Unknown/forbidden is never presented as zero.
errors[key] = str(exc)
with _lock:
if len(_status_cache) >= 33:
_status_cache.clear()
_status_cache[identifier] = (time.monotonic() + 15, result)
return result
@@ -26,18 +26,18 @@ class FwbrUdevRuleOrderTests(unittest.TestCase):
def test_auto_post_install_uses_late_rule_and_removes_legacy_rule(self):
source = network_optimization_block(AUTO_POST_INSTALL)
self.assertIn('local FUNC_VERSION="1.2"', source)
self.assertIn(f"rm -f /etc/udev/rules.d/{OLD_RULE}", source)
self.assertIn(f"cat > /etc/udev/rules.d/{NEW_RULE}", source)
self.assertIn(f"pmx_remove_file /etc/udev/rules.d/{OLD_RULE}", source)
self.assertIn(f"pmx_write_file /etc/udev/rules.d/{NEW_RULE}", source)
self.assertIn(f"chmod 0644 /etc/udev/rules.d/{NEW_RULE}", source)
self.assertNotIn(f"cat > /etc/udev/rules.d/{OLD_RULE}", source)
self.assertNotIn(f"pmx_write_file /etc/udev/rules.d/{OLD_RULE}", source)
def test_customizable_post_install_uses_late_rule_and_removes_legacy_rule(self):
source = network_optimization_block(CUSTOM_POST_INSTALL)
self.assertIn('local FUNC_VERSION="1.2"', source)
self.assertIn(f"rm -f /etc/udev/rules.d/{OLD_RULE}", source)
self.assertIn(f"cat > /etc/udev/rules.d/{NEW_RULE}", source)
self.assertIn(f"pmx_remove_file /etc/udev/rules.d/{OLD_RULE}", source)
self.assertIn(f"pmx_write_file /etc/udev/rules.d/{NEW_RULE}", source)
self.assertIn(f"chmod 0644 /etc/udev/rules.d/{NEW_RULE}", source)
self.assertNotIn(f"cat > /etc/udev/rules.d/{OLD_RULE}", source)
self.assertNotIn(f"pmx_write_file /etc/udev/rules.d/{OLD_RULE}", source)
def test_uninstall_removes_new_and_legacy_rule_names(self):
source = UNINSTALL_TOOLS.read_text()
@@ -0,0 +1,150 @@
import json
from pathlib import Path
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
import monitor_links as links
class MonitorLinksTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
storage = self.root / "storage.cfg"
storage.write_text("dir: local\n\tpath /var/lib/vz\n\npbs: Lab\n\tdatastore store1\n\tserver 10.0.0.49\n\n"
"pbs: Cloud\n\tdatastore x\n\tserver pbs.example.com\n\tport 8443\n")
patches = [
mock.patch.object(links, "LINKS_FILE", self.root / "links.json"),
mock.patch.object(links, "SSL_CONFIG_FILE", self.root / "ssl.json"),
mock.patch.object(links, "STORAGE_CFG", storage),
mock.patch.object(links.pbs_manager, "get_platform", return_value={"product": "pve", "hostname": "amd"}),
mock.patch.object(links.pbs_manager, "public_servers", return_value=[]),
]
for patch in patches:
patch.start()
self.addCleanup(patch.stop)
links._probe_cache.clear()
cluster = [{"type": "cluster", "name": "Casa"},
{"type": "node", "name": "amd", "ip": "10.0.0.55", "local": 1},
{"type": "node", "name": "constructor", "ip": "10.0.0.50", "local": 0}]
self.run_patch = mock.patch.object(links.subprocess, "run",
return_value=SimpleNamespace(returncode=0, stdout=json.dumps(cluster)))
self.run_patch.start()
self.addCleanup(self.run_patch.stop)
def test_normalize_url_keeps_only_the_origin_and_rejects_credentials(self):
self.assertEqual(links.normalize_url("192.168.0.49:8008/dashboard?x=1"), "https://192.168.0.49:8008")
self.assertEqual(links.normalize_url("http://pve.lan"), "http://pve.lan")
for bad in ("", "ftp://host", "https://user:pass@host", "https://host:99999", "javascript:alert(1)"):
with self.subTest(bad=bad), self.assertRaises(links.LinkError):
links.normalize_url(bad)
def test_discovers_other_cluster_nodes_and_pbs_without_the_local_node(self):
probed = []
with mock.patch.object(links, "_probe", side_effect=lambda url: probed.append(url) or url.endswith("10.0.0.50:8008")), \
mock.patch.object(links, "_is_private", side_effect=lambda host: host.startswith("10.")):
result = links.list_links()
by_key = {row["key"]: row for row in result["links"]}
self.assertEqual(result["self"]["name"], "amd")
self.assertNotIn("cluster:amd", by_key)
self.assertEqual(by_key["cluster:constructor"]["status"], "online")
self.assertEqual(by_key["pbs:10.0.0.49"]["native_url"], "https://10.0.0.49:8007")
self.assertEqual(by_key["pbs:10.0.0.49"]["status"], "offline")
# A public PBS is listed with its own interface but never probed unasked.
self.assertEqual(by_key["pbs:pbs.example.com"]["status"], "unknown")
self.assertEqual(by_key["pbs:pbs.example.com"]["native_url"], "https://pbs.example.com:8443")
self.assertFalse(any("example.com" in url for url in probed))
def test_pbs_host_lists_only_manual_entries(self):
links.pbs_manager.get_platform.return_value = {"product": "pbs", "hostname": "pbs-lab"}
links.add_link({"name": "amd", "url": "https://10.0.0.55:8008", "product": "pve"})
with mock.patch.object(links, "_probe", return_value=True):
result = links.list_links()
self.assertEqual([row["name"] for row in result["links"]], ["amd"])
self.links_run_not_called()
def links_run_not_called(self):
self.assertFalse(any(call.args and call.args[0][:1] == ["pvesh"] for call in links.subprocess.run.call_args_list))
def test_manual_entries_overrides_and_hidden_detected_nodes(self):
key = links.add_link({"name": "lab", "url": "10.0.0.46:8008", "product": "pve"})
with self.assertRaises(links.LinkError):
links.add_link({"url": "https://10.0.0.46:8008"})
links.update_link("cluster:constructor", {"url": "https://constructor.lan:8008"})
links.update_link("pbs:10.0.0.49", {"hidden": True})
with mock.patch.object(links, "_probe", return_value=False), \
mock.patch.object(links, "_is_private", return_value=True):
visible = {row["key"]: row for row in links.list_links()["links"]}
everything = {row["key"]: row for row in links.list_links(include_hidden=True)["links"]}
self.assertEqual(visible[key]["url"], "https://10.0.0.46:8008")
self.assertEqual(visible["cluster:constructor"]["url"], "https://constructor.lan:8008")
self.assertNotIn("pbs:10.0.0.49", visible)
self.assertTrue(everything["pbs:10.0.0.49"]["hidden"])
with self.assertRaises(links.LinkError):
links.delete_link("cluster:constructor")
links.delete_link(key)
self.assertEqual(json.loads((self.root / "links.json").read_text())["manual"], [])
self.assertEqual((self.root / "links.json").stat().st_mode & 0o777, 0o600)
def test_cached_list_is_served_without_new_probes_and_renewed_when_stale(self):
links._snapshot.update(at=0.0, data=None)
self.addCleanup(links._snapshot.update, at=0.0, data=None)
links.update_link("pbs:10.0.0.49", {"hidden": True})
with mock.patch.object(links, "_probe", return_value=True) as probe, \
mock.patch.object(links, "_is_private", return_value=True):
first = links.cached_links()
calls = probe.call_count
again = links.cached_links()
self.assertEqual(probe.call_count, calls)
self.assertEqual(first, again)
self.assertNotIn("pbs:10.0.0.49", [row["key"] for row in first["links"]])
self.assertIn("pbs:10.0.0.49", [row["key"] for row in links.cached_links(include_hidden=True)["links"]])
links._snapshot["at"] -= links.PROBE_TTL + 1
with mock.patch.object(links.threading, "Thread") as thread:
stale = links.cached_links()
self.assertEqual(stale, first)
thread.assert_called_once()
def test_unknown_keys_are_rejected(self):
for key in ("other:x", "cluster:../../etc", "manual:nope"):
with self.subTest(key=key), self.assertRaises(links.LinkError):
links.update_link(key, {"hidden": True})
def test_probe_sends_no_credentials_and_needs_a_monitor_answer(self):
seen = []
class Response:
status = 200
def __init__(self, body):
self.body = body
def read(self, _size):
return self.body
def __enter__(self):
return self
def __exit__(self, *_):
return False
def urlopen(request, timeout, context):
seen.append(request)
return Response(b'{"auth_enabled": true}' if "monitor" in request.full_url else b"<html>")
with mock.patch.object(links.urllib.request, "urlopen", side_effect=urlopen):
self.assertTrue(links._probe("https://monitor:8008"))
self.assertFalse(links._probe("https://other:8008"))
self.assertTrue(all(request.full_url.endswith("/api/auth/status") for request in seen))
self.assertTrue(all("Authorization" not in request.headers for request in seen))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,785 @@
"""Host-independent discovery fixtures; no real pct, guest, auth or registry."""
import ast
import copy
import fcntl
from functools import wraps
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from unittest.mock import Mock, patch
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
import native_app_discovery as native
import lxc_apps
def service(name='handmade', exe='/opt/handmade/server', directory='/opt/handmade', manager='systemd', script='', source=None):
path = source or ('/etc/systemd/system/' + name + '.service' if manager == 'systemd'
else '/etc/init.d/' + name)
return '\t'.join(['S', path, manager, exe, script, directory]) + '\nEND\n'
class DiscoveryFixtures(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.config = self.root / 'lxc'
self.config.mkdir()
(self.config / '101.conf').write_text('ostype: debian\n')
self.oci = self.root / 'oci'
self.oci.mkdir()
for context in (patch.object(native, 'LOCAL_CONFIG', self.config),
patch.object(native, 'OCI_ROOT', self.oci),
patch.object(native, 'UPDATE_LOCK_ROOT', self.root)):
context.start()
self.addCleanup(context.stop)
native._cache.clear()
native._locks.clear()
native._generations.clear()
self.calls = []
def runner(self, services=None, processes='END\n', footprint='FILE\n', manifest=None,
status='status: running', ports='END\n', hestia_panels='END\n'):
def run(argv, deadline):
self.calls.append(argv)
self.assertLessEqual(deadline - time.monotonic(), native.TOTAL_SECONDS)
if argv[1] == 'status':
return status
self.assertEqual(argv[:6], ['pct', 'exec', '101', '--', 'timeout', '-s'])
self.assertEqual(argv[6:9], ['KILL', '2', 'sh'])
script = argv[10]
if script == native.SERVICE_SCRIPT:
return services if services is not None else service()
if script == native.PROCESS_SCRIPT:
return processes
if script == native.PORT_SCRIPT:
return ports
if script == native.HESTIA_PANEL_SCRIPT:
return hestia_panels
if script == native.MANIFEST_FOOTPRINT_SCRIPT:
if manifest is not None:
return manifest
return 'MANIFEST\tpackage.json\n' + footprint.removeprefix('FILE\n')
if script == native.FOOTPRINT_SCRIPT:
return footprint
self.fail('Unexpected probe')
return run
def test_unknown_version_is_present_with_evidence_and_no_capabilities(self):
result = native.scan(101, self.runner())
self.assertEqual(result['status'], 'complete')
item, = result['candidates']
self.assertIsNone(item['installed_version'])
self.assertIsNone(item['tracking_suggestion'])
self.assertEqual(item['default_ports'], [])
self.assertEqual({e['kind'] for e in item['evidence']}, {'installation_file', 'systemd'})
for field in ('helper_slug', 'repo', 'update_command', 'binary_path', 'upstream_type'):
self.assertNotIn(field, item)
def test_openrc_node_script_known_version_is_manual_and_secret_free(self):
result = native.scan(101, self.runner(
service(exe='/usr/bin/node', script='/opt/handmade/server.js', manager='openrc'),
footprint='FILE\n' + json.dumps({'version': '1.2.3', 'scripts': {'start': 'SECRET'}, 'env': 'PASSWORD'})))
item, = result['candidates']
self.assertEqual(item['installed_version'], '1.2.3')
self.assertEqual(item['tracking_suggestion']['installed_via'], 'manual')
self.assertNotIn('SECRET', json.dumps(result))
self.assertNotIn('PASSWORD', json.dumps(result))
self.assertFalse(any('systemctl' in arg for call in self.calls for arg in call))
def test_inactive_service_and_packaged_application_are_not_hidden(self):
result = native.scan(101, self.runner(service(name='legit-app', exe='/usr/bin/legit-app', directory='')))
self.assertEqual(result['candidates'][0]['name'], 'legit-app')
def test_system_services_and_ambiguous_runtimes_have_zero_footprint_probes(self):
for name, exe in [('ssh', '/usr/sbin/sshd'), ('cron', '/usr/sbin/cron'),
('postfix', '/usr/lib/postfix/master'), ('rsyslog', '/usr/sbin/rsyslogd'),
('systemd-networkd', '/usr/lib/systemd/systemd-networkd'),
('ambiguous', '/usr/bin/python3')]:
self.calls.clear()
result = native.scan(101, self.runner(service(name=name, exe=exe)))
self.assertEqual(result['candidates'], [])
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
def test_common_system_daemons_and_vendor_units_have_zero_footprint_probes(self):
cases = [
('nginx', '/usr/sbin/nginx', 'openrc', None),
('apache2', '/usr/sbin/apache2', 'systemd', None),
('redis-server', '/usr/bin/redis-server', 'systemd', None),
('mariadb', '/usr/sbin/mariadbd', 'systemd', None),
('postgresql', '/usr/lib/postgresql/16/bin/postgres', 'systemd', None),
('php8.3-fpm', '/usr/sbin/php-fpm8.3', 'systemd', None),
('vendor-daemon', '/usr/bin/vendor-daemon', 'systemd',
'/usr/lib/systemd/system/vendor-daemon.service'),
]
for name, exe, manager, source in cases:
with self.subTest(name=name):
self.calls.clear()
result = native.scan(101, self.runner(
service(name=name, exe=exe, manager=manager, source=source)))
self.assertEqual(result['candidates'], [])
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
def test_alpine_openrc_infrastructure_has_zero_footprint_probes(self):
# Actual Alpine 3.24.1 CT217 init metadata: klogd, user and runsvdir.
# The runsvdir definition can exist without /usr/bin/runsvdir installed;
# rejecting it must precede probing, not turn the scan into partial.
for name, executable in (
('klogd', '/sbin/klogd'),
('user', '/usr/libexec/rc/bin/openrc-user'),
('runsvdir', '/usr/bin/runsvdir'),
('renamed-system-service', '/usr/libexec/rc/bin/openrc-user'),
):
with self.subTest(name=name):
self.calls.clear()
result = native.scan(101, self.runner(
service(name=name, exe=executable, directory='', manager='openrc')))
self.assertEqual(result['status'], 'complete')
self.assertEqual(result['candidates'], [])
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
def test_helper_services_and_templates_have_zero_footprint_probes(self):
cases = [
('netpulse-stable-update', '/bin/bash', '/opt/netpulse', '/opt/netpulse/stable-apply.sh'),
('rent-tool-backup', '/bin/bash', '/home/app/rent-tool', '/home/app/rent-tool/scripts/backup-db.sh'),
('keynest-update', '/bin/bash', '/opt/keynest', '/opt/keynest/keynest-update.sh'),
('cortendesk-bootstrap-worker', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/bootstrap-worker.sh'),
('cortendesk-source-worker', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/source-worker.sh'),
('cortendesk-managed-password@', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/password-bridge.sh'),
('miminails-watch', '/usr/local/bin/miminails-watch', '/usr/local/bin', ''),
]
for name, exe, directory, script in cases:
with self.subTest(name=name):
self.calls.clear()
result = native.scan(101, self.runner(service(
name=name, exe=exe, directory=directory, script=script)))
self.assertEqual(result['candidates'], [])
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
def test_hestia_panel_is_recognized_only_from_its_exact_runtime_and_port(self):
hestia = '/usr/local/hestia/nginx/sbin/hestia-nginx'
panel = f'H\t391\t{hestia}\t/\t8083\nEND\n'
result = native.scan(101, self.runner(
services='END\n', processes='LIMIT\n', ports='END\n', hestia_panels=panel))
item, = result['candidates']
self.assertEqual(item['name'], 'Hestia')
self.assertEqual(item['installation_path'], hestia)
self.assertEqual(item['listening_ports'], [8083])
self.assertEqual(item['default_ports'], [8083])
self.assertEqual(item['scheme'], 'https')
self.assertIn({'kind': 'working_directory', 'value': '/'}, item['evidence'])
self.assertIn('metadata_limit', result['issues'])
unrelated = native.candidate(101, {
'name': 'other-panel', 'path': '/opt/other-panel/server', 'root': '/opt/other-panel',
'process': '/opt/other-panel/server', 'listening_ports': [8083],
}, 'FILE\n')
self.assertEqual(unrelated['default_ports'], [])
self.assertNotIn('scheme', unrelated)
self.calls.clear()
result = native.scan(101, self.runner(
services='END\n', processes='LIMIT\n', ports='END\n'))
self.assertEqual(result['candidates'], [])
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
with self.assertRaises(native.ProbeError):
native.parse_records(f'H\t391\t{hestia}\t/\t8443\nEND\n', 'H')
def test_node_and_npm_wrappers_use_only_bounded_manifest_roots(self):
cases = [
('keynest', '/usr/bin/node', '/opt/keynest'),
('rent-tool', '/usr/local/bin/npm', '/home/app/rent-tool'),
]
for name, exe, directory in cases:
with self.subTest(name=name):
self.calls.clear()
result = native.scan(101, self.runner(
service(name=name, exe=exe, directory=directory),
footprint='FILE\n{"version":"1.2.3"}'))
item, = result['candidates']
self.assertEqual(item['name'], name)
self.assertEqual(item['installation_path'], directory + '/package.json')
self.assertEqual(item['installed_version'], '1.2.3')
footprint_calls = [call for call in self.calls if native.MANIFEST_FOOTPRINT_SCRIPT in call]
self.assertEqual(len(footprint_calls), 1)
if directory.startswith('/home/'):
parent = '/home/app'
else:
parent = '/opt'
self.assertEqual(footprint_calls[0][-3:], [directory, directory, parent])
self.calls.clear()
result = native.scan(101, self.runner(service(
name='ambiguous-home-runtime', exe='/usr/bin/node', directory='/home/app')))
self.assertEqual(result['candidates'], [])
self.assertFalse(any(native.MANIFEST_FOOTPRINT_SCRIPT in call for call in self.calls))
def test_node_release_layout_uses_only_server_manifest_under_shared_root(self):
self.calls.clear()
result = native.scan(101, self.runner(
service(name='keynest', exe='/opt/keynest/node/bin/node', directory='/opt/keynest/current'),
manifest='MANIFEST\tserver/package.json\n{"version":"1.5.63"}'))
item, = result['candidates']
self.assertEqual(item['name'], 'keynest')
self.assertEqual(item['installation_path'], '/opt/keynest/current/server/package.json')
self.assertEqual(item['installed_version'], '1.5.63')
manifest_calls = [call for call in self.calls if native.MANIFEST_FOOTPRINT_SCRIPT in call]
self.assertEqual(len(manifest_calls), 1)
self.assertEqual(manifest_calls[0][-3:],
['/opt/keynest/current', '/opt/keynest', '/opt'])
def test_node_release_layout_rejects_manifest_outside_operator_root(self):
self.calls.clear()
result = native.scan(101, self.runner(
service(name='outside-root', exe='/opt/outside/node/bin/node', directory='/opt/outside/current'),
manifest='OUTSIDE\n'))
self.assertEqual(result['candidates'], [])
self.assertIn('unsafe_manifest_root', result['issues'])
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
def test_actual_manifest_probe_accepts_release_and_rejects_symlink_escape(self):
parent = self.root / 'opt'
anchor = parent / 'keynest'
release = anchor / 'releases' / 'v1.5.63'
manifest = release / 'server' / 'package.json'
manifest.parent.mkdir(parents=True)
manifest.write_text('{"version":"1.5.63"}')
current = anchor / 'current'
current.symlink_to(release)
output = native.bounded_run(
['sh', '-c', native.MANIFEST_FOOTPRINT_SCRIPT, 'fixture',
str(current), str(anchor), str(parent)], time.monotonic() + 3)
self.assertEqual(output, 'MANIFEST\tserver/package.json\n{"version":"1.5.63"}')
outside = self.root / 'outside' / 'package.json'
outside.parent.mkdir()
outside.write_text('{"version":"not-readable"}')
manifest.unlink()
manifest.symlink_to(outside)
output = native.bounded_run(
['sh', '-c', native.MANIFEST_FOOTPRINT_SCRIPT, 'fixture',
str(current), str(anchor), str(parent)], time.monotonic() + 3)
self.assertEqual(output, 'OUTSIDE\n')
self.assertNotIn('not-readable', output)
def test_node_wrapper_with_no_fixed_manifest_is_not_an_error_or_candidate(self):
self.calls.clear()
result = native.scan(101, self.runner(
service(name='missing-manifest', exe='/opt/missing/node/bin/node', directory='/opt/missing/current'),
manifest='MISSING\n'))
self.assertEqual(result['status'], 'complete')
self.assertEqual(result['candidates'], [])
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
def test_helper_process_fallback_has_zero_footprint_probes(self):
self.calls.clear()
result = native.scan(101, self.runner(
services='END\n', processes='P\t123\t/opt/cortendesk/run-worker\t/opt/cortendesk\nEND\n'))
self.assertEqual(result['candidates'], [])
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
def test_missing_remote_qemu_stopped_busy_oci_guards_precede_exec(self):
scenarios = ['missing', 'stopped', 'busy', 'oci', 'invalid-oci', 'unmanaged']
for scenario in scenarios:
with self.subTest(scenario=scenario):
(self.config / '101.conf').write_text('ostype: debian\n')
if scenario == 'missing':
(self.config / '101.conf').unlink()
if scenario == 'busy':
(self.config / '101.conf').write_text('lock: backup\n')
if scenario == 'unmanaged':
(self.config / '101.conf').write_text('ostype: unmanaged\n')
if 'oci' in scenario:
(self.oci / '101').mkdir(exist_ok=True)
(self.oci / '101' / 'oci-compose.json').write_text('broken')
self.calls.clear()
with self.assertRaises(native.ProbeError):
native.scan(101, self.runner(status='status: stopped' if scenario == 'stopped' else 'status: running'))
self.assertFalse(any(call[1] == 'exec' for call in self.calls))
if (self.oci / '101').exists():
(self.oci / '101' / 'oci-compose.json').unlink()
(self.oci / '101').rmdir()
def test_failure_and_partial_output_are_not_successful_empty_results(self):
for text in ('', 'malformed\nEND\n', 'S\tpartial', 'LIMIT\n'):
result = native.scan(101, self.runner(services=text))
self.assertEqual(result['status'], 'partial')
run = self.runner()
def failed(argv, deadline):
if argv[1] == 'exec':
raise native.ProbeError('read_failed')
return run(argv, deadline)
self.assertEqual(native.scan(101, failed)['status'], 'partial')
def test_optional_invalid_version_metadata_keeps_proven_candidate(self):
result = native.scan(101, self.runner(footprint='FILE\n{broken'))
self.assertEqual(result['status'], 'partial')
item, = result['candidates']
self.assertIsNone(item['installed_version'])
self.assertIsNone(item['tracking_suggestion'])
def test_working_directory_manifest_does_not_version_an_unrelated_executable(self):
result = native.scan(101, self.runner(service(exe='/opt/other/server'),
footprint='FILE\n{"version":"1.2.3"}'))
self.assertIsNone(result['candidates'][0]['installed_version'])
def test_path_injection_and_port_only_data_do_not_become_candidates(self):
for text in (service(exe='/opt/app/$(id)'), service(exe='/opt/../server'),
service(name='app;id'), service(exe='/bin/sh')):
self.assertEqual(native.scan(101, self.runner(text))['candidates'], [])
with self.assertRaises(native.ProbeError):
native.parse_records('PORT\t8080\nEND\n', 'P')
def test_same_process_deduplicates_but_two_apps_using_node_do_not_merge(self):
services = service(exe='/usr/bin/node', script='/opt/handmade/server.js').removesuffix('END\n')
services += service(name='second', exe='/usr/bin/node', script='/opt/second/server.js', directory='/opt/second')
result = native.scan(101, self.runner(services))
self.assertEqual(len(result['candidates']), 2)
result = native.scan(101, self.runner(processes='P\t123\t/opt/handmade/server\t/opt/handmade\nEND\n'))
self.assertEqual(len(result['candidates']), 1)
other = native.candidate(102, native.parse_records(service(), 'S')[0][0], 'FILE\n')
self.assertNotEqual(other['discovery_id'], result['candidates'][0]['discovery_id'])
def test_record_and_candidate_caps(self):
too_many = 'P\t123\t/opt/a/app\t/opt/a\n' * 257 + 'END\n'
with self.assertRaises(native.ProbeError):
native.parse_records(too_many, 'P')
services = ''.join(service(name=f'app{i}', exe=f'/opt/app{i}/server', directory=f'/opt/app{i}').removesuffix('END\n') for i in range(51)) + 'END\n'
result = native.scan(101, self.runner(services))
self.assertEqual(len(result['candidates']), 50)
self.assertIn('candidate_limit', result['issues'])
def test_cached_ttl_never_runs_scan_and_error_preserves_last_known(self):
complete = native.scan(101, self.runner())
with patch.object(native, 'scan', return_value=complete) as scan:
native.discover(101)
for age, stale in ((299.9, False), (300, True), (300.1, True)):
with patch.object(native.time, 'time', return_value=complete['scanned_at'] + age):
self.assertEqual(native.cached(101)['stale'], stale)
self.assertEqual(scan.call_count, 1)
with patch.object(native, 'scan', side_effect=native.ProbeError('timeout')):
result = native.discover(101)
self.assertEqual(result['candidates'], complete['candidates'])
self.assertEqual(native.cached(101)['status'], 'error')
self.assertTrue(native.cached(101)['stale'])
partial = {**complete, 'status': 'partial', 'candidates': [], 'issues': ['read_failed']}
with patch.object(native, 'scan', return_value=partial):
self.assertEqual(native.discover(101)['candidates'], complete['candidates'])
with patch.object(native, 'scan', return_value={**complete, 'candidates': []}):
self.assertEqual(native.discover(101)['candidates'], [])
def test_lifecycle_during_first_or_cached_scan_cannot_publish_fresh_result(self):
complete = native.scan(101, self.runner())
for prior in (None, complete):
with self.subTest(cached=prior is not None):
native._cache.clear()
if prior:
native._cache[101] = copy.deepcopy(prior)
entered, release = threading.Event(), threading.Event()
results = []
def delayed(vmid):
entered.set()
if not release.wait(3):
raise AssertionError('scan release timed out')
return copy.deepcopy(complete)
with patch.object(native, 'scan', side_effect=delayed):
worker = threading.Thread(target=lambda: results.append(native.discover(101)))
worker.start()
try:
self.assertTrue(entered.wait(3))
native.mark_stale(101)
finally:
release.set()
worker.join(3)
self.assertFalse(worker.is_alive())
self.assertEqual(len(results), 1)
self.assertTrue(results[0]['stale'])
self.assertIn('guest_changed', results[0]['issues'])
self.assertEqual(results[0]['candidates'], prior['candidates'] if prior else [])
self.assertTrue(native.cached(101)['stale'])
with patch.object(native, 'scan', return_value=copy.deepcopy(complete)):
self.assertFalse(native.discover(101)['stale'])
with patch.object(native, 'scan', side_effect=lambda vmid: (
native.mark_stale(102), copy.deepcopy(complete))[1]):
self.assertFalse(native.discover(101)['stale'])
def test_scan_excludes_later_updater_and_releases_lock_after_success_or_failure(self):
path = self.root / 'proxmenux-lxc-update-101.lock'
for fail in (False, True):
with self.subTest(fail=fail):
runner = self.runner()
probes = []
def checked(argv, deadline):
if argv[1] == 'exec':
attempt = subprocess.run(['flock', '-n', str(path), 'true'])
self.assertEqual(attempt.returncode, 1)
probes.append(argv)
if fail:
raise native.ProbeError('timeout')
return runner(argv, deadline)
native.scan(101, checked)
self.assertTrue(probes)
self.assertEqual(subprocess.run(['flock', '-n', str(path), 'true']).returncode, 0)
self.assertEqual(path.read_bytes(), b'')
def test_pending_control_reservation_blocks_scan_without_touching_it(self):
path = self.root / 'proxmenux-lxc-update-101.lock'
original = b'{"task":"pending-control"}'
path.write_bytes(original)
with self.assertRaisesRegex(native.ProbeError, 'guest_busy'):
native.scan(101, self.runner())
self.assertFalse(any(call[1] == 'exec' for call in self.calls))
self.assertEqual(path.read_bytes(), original)
def test_guest_stop_during_scan_never_returns_complete(self):
runner = self.runner()
statuses = iter(['status: running', 'status: stopped'])
def stopped(argv, deadline):
return next(statuses) if argv[1] == 'status' else runner(argv, deadline)
with self.assertRaisesRegex(native.ProbeError, 'guest_stopped'):
native.scan(101, stopped)
self.assertTrue(all(call[1] == 'exec' for call in self.calls))
def test_scan_reservation_closes_race_after_guard_and_releases_after_exception(self):
import lxc_check_job
path = self.root / 'proxmenux-lxc-update-101.lock'
runner = self.runner()
handles = []
def starts_update(argv, deadline):
if argv[1] == 'status':
handles.append(lxc_check_job.acquire(101))
return runner(argv, deadline)
with patch.object(lxc_check_job, 'LOCK_DIR', self.root):
try:
with self.assertRaisesRegex(native.ProbeError, 'guest_busy'):
native.scan(101, starts_update)
self.assertFalse(any(call[1] == 'exec' for call in self.calls))
finally:
for handle in handles:
handle.close()
def crash(argv, deadline):
if argv[1] == 'exec':
with self.assertRaises(BlockingIOError):
lxc_check_job.acquire(101)
# Another CT must remain available.
lxc_check_job.acquire(102).close()
raise RuntimeError('unexpected probe failure')
return runner(argv, deadline)
with self.assertRaisesRegex(RuntimeError, 'unexpected'):
native.scan(101, crash)
lxc_check_job.acquire(101).close()
self.assertEqual(path.read_bytes(), b'')
def test_failed_scan_releases_discovery_lock_and_keeps_prior_evidence(self):
complete = native.scan(101, self.runner())
native._cache[101] = copy.deepcopy(complete)
with patch.object(native, 'scan', side_effect=RuntimeError('unexpected')):
with self.assertRaises(RuntimeError):
native.discover(101)
self.assertFalse(native._locks[101].locked())
self.assertEqual(native.cached(101)['candidates'], complete['candidates'])
def test_invalid_lock_inode_and_unreadable_config_fail_before_any_probe(self):
import os
path = self.root / 'proxmenux-lxc-update-101.lock'
target = self.root / 'unrelated'
target.write_bytes(b'keep')
for kind in ('symlink', 'fifo', 'directory'):
with self.subTest(kind=kind):
if kind == 'symlink':
path.symlink_to(target)
elif kind == 'fifo':
os.mkfifo(path)
else:
path.mkdir()
try:
with self.assertRaisesRegex(native.ProbeError, 'lock_unavailable'):
native.scan(101, self.runner())
self.assertEqual(self.calls, [])
self.assertEqual(target.read_bytes(), b'keep')
finally:
path.rmdir() if kind == 'directory' else path.unlink()
with patch.object(Path, 'open', side_effect=PermissionError):
with self.assertRaisesRegex(native.ProbeError, 'config_unavailable'):
native.scan(101, self.runner())
self.assertEqual(self.calls, [])
def test_same_ct_busy_and_different_ct_cache_isolation(self):
lock = threading.Lock()
lock.acquire()
native._locks[101] = lock
with patch.object(native, 'scan') as scanner:
self.assertEqual(native.discover(101)['status'], 'busy')
scanner.assert_not_called()
self.assertEqual(native.cached(102)['status'], 'not_scanned')
def test_filter_registered_curated_and_docker_children_without_mutation(self):
result = native.scan(101, self.runner())
original = copy.deepcopy(result)
for suggestions, apps in (({}, [{'discovery_id': result['candidates'][0]['discovery_id']}]),
({'extras': [{'name': 'Handmade'}]}, []),
({'docker_workloads': [{'name': 'Handmade'}]}, []),
({'helper_slug': 'handmade', 'name_suggestion': 'Handmade'}, [])):
self.assertEqual(native.filter_candidates(result, suggestions, apps)['candidates'], [])
self.assertEqual(result, original)
def test_actual_save_first_check_and_recheck_never_probe_or_configure_updater(self):
with patch.object(lxc_apps, '_APPS_DIR', str(self.root / 'apps')), \
patch.object(lxc_apps, '_pct_exec', side_effect=AssertionError('forbidden guest execution')) as execute, \
patch.object(lxc_apps, 'fetch_latest_upstream_details', side_effect=AssertionError('forbidden upstream')) as upstream:
for version in (None, '1.2.3'):
item = native.candidate(101, native.parse_records(service(), 'S')[0][0],
'FILE\n' + (json.dumps({'version': version}) if version else ''))
payload = {'name': item['name'], 'discovery_id': item['discovery_id'],
'update_method': 'none', **(item['tracking_suggestion'] or {})}
ok, sidecar = lxc_apps.add_app(101, payload)
self.assertTrue(ok, sidecar)
app = sidecar['apps'][-1]
self.assertEqual(app['state']['installed_version'], version)
self.assertIsNone(app['state']['update_available'])
self.assertEqual(app['update_method'], 'none')
for field in ('helper_slug', 'update_command', 'repo', 'upstream_type'):
self.assertNotIn(field, app)
lxc_apps.check_app(101, app['id'], force=True)
self.assertIsNone(lxc_apps.get_schedule(101))
self.assertIsNone(lxc_apps.get_bulk_update(101))
execute.assert_not_called()
upstream.assert_not_called()
def test_failed_save_has_no_first_check(self):
with patch.object(lxc_apps, '_write_sidecar', return_value=False), \
patch.object(lxc_apps, '_read_sidecar', return_value=None), \
patch.object(lxc_apps, 'check_app') as check:
self.assertFalse(lxc_apps.add_app(101, {'name': 'handmade'})[0])
check.assert_not_called()
def test_active_update_lock_blocks_all_guest_probes_without_writing(self):
path = self.root / 'proxmenux-lxc-update-101.lock'
path.write_bytes(b'unchanged')
with path.open('rb') as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
with self.assertRaisesRegex(native.ProbeError, 'guest_busy'):
native.scan(101, self.runner())
self.assertEqual(path.read_bytes(), b'unchanged')
self.assertEqual(self.calls, [])
def test_actual_service_parser_reads_data_never_executes_or_sources_it(self):
for manager in ('systemd', 'openrc'):
directory = self.root / manager
directory.mkdir()
marker = self.root / ('forbidden-' + manager)
unit = directory / ('handmade.service' if manager == 'systemd' else 'handmade')
content = ('[Service]\nExecStart=/usr/bin/node /opt/handmade/server.js --password SECRET\nWorkingDirectory=/opt/handmade\n'
if manager == 'systemd' else 'command="/usr/bin/node"\ncommand_args="/opt/handmade/server.js --password SECRET"\ndirectory="/opt/handmade"\n')
unit.write_text(content + '\ntouch ' + str(marker) + '\n')
script = native.SERVICE_SCRIPT.replace('/etc/systemd/system /usr/lib/systemd/system /lib/systemd/system /etc/init.d', str(directory))
# Substitute only fixture paths, not the collector/parser behavior.
if manager == 'openrc':
script = script.replace('/etc/init.d:*)', str(directory) + ':*)')
output = native.bounded_run(['sh', '-c', script], time.monotonic() + 3)
record, = native.parse_records(output, 'S')[0]
self.assertEqual(record['path'], '/opt/handmade/server.js')
self.assertNotIn('SECRET', output)
self.assertFalse(marker.exists())
def test_merged_usr_units_are_counted_once(self):
usr = self.root / 'usr-units'
usr.mkdir()
(usr / 'handmade.service').write_text('[Service]\nExecStart=/opt/handmade/server\n')
lib = self.root / 'lib-units'
lib.symlink_to(usr)
check = '[ "$dir" = /lib/systemd/system ] && [ /lib/systemd/system -ef /usr/lib/systemd/system ]'
self.assertIn(check, native.SERVICE_SCRIPT)
script = (native.SERVICE_SCRIPT
.replace('/etc/systemd/system /usr/lib/systemd/system /lib/systemd/system /etc/init.d', f'{usr} {lib}')
.replace(check, f'[ "$dir" = {lib} ] && [ {lib} -ef {usr} ]'))
output = native.bounded_run(['sh', '-c', script], time.monotonic() + 3)
self.assertEqual(output.count('handmade.service'), 1)
def test_a_guest_without_ss_or_netstat_has_no_listener_evidence(self):
bin_dir = self.root / 'no-tools'
bin_dir.mkdir()
(bin_dir / 'awk').symlink_to('/usr/bin/awk')
output = native.bounded_run(['sh', '-c', 'PATH=' + str(bin_dir) + '\n' + native.PORT_SCRIPT, 'fixture', '256'],
time.monotonic() + 3)
self.assertEqual(native.parse_records(output, 'L'), ([], False, 0))
def test_actual_process_parser_skips_docker_and_never_reads_environ_cmdline(self):
proc = self.root / 'proc'
proc.mkdir()
for pid, group in ((1, '0::/\n'), (2, '0::/docker/child\n')):
directory = proc / str(pid)
directory.mkdir()
(directory / 'cgroup').write_text(group)
(directory / 'exe').symlink_to('/opt/handmade/server')
(directory / 'cwd').symlink_to('/opt/handmade')
(directory / 'cmdline').write_text('SECRET')
(directory / 'environ').write_text('PASSWORD')
script = native.PROCESS_SCRIPT.replace('/proc/[0-9]*', str(proc) + '/[0-9]*')
output = native.bounded_run(['sh', '-c', script, 'fixture', '256'], time.monotonic() + 3)
self.assertEqual(output, 'P\t1\t/opt/handmade/server\t/opt/handmade\nEND\n')
self.assertNotIn('"$dir/cmdline"', native.PROCESS_SCRIPT)
self.assertNotIn('"$dir/environ"', native.PROCESS_SCRIPT)
self.assertNotIn('SECRET', output)
def test_ports_require_exact_process_ownership_and_do_not_infer_http(self):
process = 'P\t123\t/opt/handmade/server\t/opt/handmade\nEND\n'
result = native.scan(101, self.runner(processes=process, ports='L\t123\t8080\nL\t999\t9000\nEND\n'))
item, = result['candidates']
self.assertEqual(item['listening_ports'], [8080])
self.assertEqual(item['default_ports'], [], 'TCP listener does not prove HTTP')
result = native.scan(101, self.runner(ports='L\t123\t8080\nEND\n'))
self.assertEqual(result['candidates'][0]['listening_ports'], [])
def test_shared_runtime_ports_are_assigned_only_by_working_directory(self):
services = service(exe='/usr/bin/node', script='/opt/handmade/server.js').removesuffix('END\n')
services += service(name='second', exe='/usr/bin/node', script='/opt/second/server.js', directory='/opt/second')
process = 'P\t123\t/usr/bin/node\t/opt/second\nEND\n'
result = native.scan(101, self.runner(services, processes=process, ports='L\t123\t8080\nEND\n'))
self.assertEqual(result['candidates'][0]['listening_ports'], [])
self.assertEqual(result['candidates'][1]['listening_ports'], [8080])
def test_ss_and_alpine_netstat_parser_are_bounded_and_secret_free(self):
for tool, line in (('ss', 'LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:(("SECRET",pid=123,fd=7))'),
('netstat', 'tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN 123/SECRET')):
bin_dir = self.root / tool
bin_dir.mkdir()
executable = bin_dir / tool
executable.write_text('#!/bin/sh\nprintf \'%s\\n\' \'' + line + '\'\n')
executable.chmod(0o755)
(bin_dir / 'awk').symlink_to('/usr/bin/awk')
(bin_dir / 'timeout').symlink_to('/usr/bin/timeout')
script = 'PATH=' + str(bin_dir) + '\n' + native.PORT_SCRIPT
output = native.bounded_run(['sh', '-c', script, 'fixture', '256'], time.monotonic() + 3)
self.assertEqual(output, 'L\t123\t8080\nEND\n')
self.assertNotIn('SECRET', output)
executable.write_text('#!/bin/sh\nexit 1\n')
output = native.bounded_run(['sh', '-c', script, 'fixture', '256'], time.monotonic() + 3)
with self.assertRaises(native.ProbeError):
native.parse_records(output, 'L')
class BoundedRunner(unittest.TestCase):
def test_output_limit_is_enforced_during_read(self):
with self.assertRaisesRegex(native.ProbeError, 'output_limit'):
native.bounded_run([sys.executable, '-c', 'import os; os.write(2,b"x"*1000000)'], time.monotonic() + 3)
def test_pipe_holding_child_does_not_bypass_timeout(self):
start = time.monotonic()
with self.assertRaisesRegex(native.ProbeError, 'timeout'):
native.bounded_run([sys.executable, '-c', 'import os,time; os.fork(); time.sleep(10)'], start + .15)
self.assertLess(time.monotonic() - start, .8)
def test_failures_never_reveal_stderr_and_deadline_blocks_spawn(self):
with self.assertRaisesRegex(native.ProbeError, '^read_failed$'):
native.bounded_run([sys.executable, '-c', 'import sys; sys.stderr.write("SECRET"); sys.exit(1)'], time.monotonic() + 3)
with patch.object(native.subprocess, 'Popen') as launch:
with self.assertRaisesRegex(native.ProbeError, 'deadline'):
native.bounded_run(['forbidden'], time.monotonic() - 1)
launch.assert_not_called()
class Routes(unittest.TestCase):
"""Execute actual Flask route bodies; fixture auth, no server startup jobs."""
def setUp(self):
from flask import Flask, request, jsonify, g
self.app = Flask('native-fixture')
self.authorized = True
def require_auth(fn):
@wraps(fn)
def wrapped(*args, **kwargs):
if not self.authorized:
return jsonify({'error': 'auth_required'}), 401
return fn(*args, **kwargs)
return wrapped
self.globals = {'app': self.app, 'request': request, 'jsonify': jsonify,
'require_auth': require_auth, '_vm_app_suggestions_cache': {},
'_VM_APP_SUGGESTIONS_TTL': 300,
'_vm_cache_get': lambda cache, vmid, ttl: cache.get(vmid),
'_vm_cache_put': lambda cache, vmid, value: cache.update({vmid: value}),
'_pvesh_cache': {}, '_vm_disk_cache': {}, '_vm_schedule_cache': {}, '_vm_cache_invalidate': Mock(),
'_invalidate_lxc_ip': Mock(), '_schedule_started_guest_refresh': Mock(),
'_publish_guest_modal_cache_revision': Mock()}
tree = ast.parse((SCRIPTS / 'flask_server.py').read_text())
routes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in (
'api_vm_apps_suggestions', 'api_vm_apps_native_discovery', 'api_vm_apps_add',
'_handle_guest_lifecycle')]
self.assertEqual(len(routes), 4)
exec(compile(ast.Module(body=routes, type_ignores=[]), str(SCRIPTS / 'flask_server.py'), 'exec'), self.globals)
self.client = self.app.test_client()
def test_explicit_post_only_get_and_existing_post_never_native_scan(self):
result = {'vmid': 101, 'status': 'complete', 'candidates': [], 'stale': False}
with patch.object(native, 'discover', return_value=result) as scan, \
patch.object(native, 'cached', return_value=result), \
patch.object(lxc_apps, 'get_suggestions', return_value={'extras': []}) as suggestions, \
patch.object(lxc_apps, 'load_sidecar', return_value={'apps': []}), \
patch.object(lxc_apps, '_write_sidecar') as write:
self.assertEqual(self.client.get('/api/vms/101/apps/native-discovery').status_code, 200)
self.client.get('/api/vms/101/apps/suggestions')
# OCI refresh and Find's existing catalog path retain their purpose.
self.client.post('/api/vms/101/apps/suggestions')
with self.app.test_request_context('/api/vms/101/apps/suggestions'):
from flask import g
g._internal_call = True
self.globals['_vm_app_suggestions_cache'].clear()
self.globals['api_vm_apps_suggestions'](101)
scan.assert_not_called()
self.client.post('/api/vms/101/apps/native-discovery')
scan.assert_called_once_with(101)
self.assertEqual(suggestions.call_count, 2)
write.assert_not_called()
def test_unauthorized_and_wrong_method_have_zero_scans(self):
with patch.object(native, 'discover') as scan, patch.object(lxc_apps, 'load_sidecar') as read:
self.authorized = False
self.assertEqual(self.client.post('/api/vms/101/apps/native-discovery').status_code, 401)
self.assertEqual(self.client.put('/api/vms/101/apps/native-discovery').status_code, 405)
scan.assert_not_called()
read.assert_not_called()
def test_actual_lifecycle_marks_last_known_stale_without_scanning(self):
native._cache[101] = {'vmid': 101, 'status': 'complete', 'stale': False,
'candidates': [], 'scanned_at': time.time()}
with patch.object(native, 'discover') as scan, \
patch.object(lxc_apps, 'clear_schedule_reboot_required') as clear:
for action in ('start', 'reboot', 'stop'):
self.globals['_handle_guest_lifecycle']('101', 'lxc', action)
self.assertTrue(native.cached(101)['stale'])
scan.assert_not_called()
self.assertEqual(clear.call_count, 2)
def test_actual_repeated_prewarmer_has_zero_native_scans(self):
tree = ast.parse((SCRIPTS / 'flask_server.py').read_text())
warmer = next(node for node in tree.body if isinstance(node, ast.FunctionDef)
and node.name == '_vm_modal_prewarmer_pass')
environment = dict(self.globals)
for name in ('_vm_details_cache', '_vm_backups_cache', '_vm_schedule_cache', '_vm_mounts_cache'):
environment[name] = {}
for name in ('_VM_DETAILS_TTL', '_VM_BACKUPS_TTL', '_VM_SCHEDULE_TTL', '_VM_MOUNTS_TTL'):
environment[name] = 300
for name in ('get_vm_config', 'api_vm_backups', 'api_vm_apps_schedule', 'api_lxc_mount_points'):
environment[name] = lambda vmid: None
environment.update({'get_cached_pvesh_cluster_resources_vm': lambda: [
{'vmid': 101, 'type': 'lxc', 'status': 'running'}], 'time': Mock(), 'sys': sys})
exec(compile(ast.Module(body=[warmer], type_ignores=[]), 'flask_server.py', 'exec'), environment)
with patch.object(native, 'discover') as scan, \
patch.object(lxc_apps, 'ensure_oci_registration'), \
patch.object(lxc_apps, 'load_sidecar', return_value={'apps': []}), \
patch.object(lxc_apps, 'get_suggestions', return_value={'extras': []}):
for _ in range(2):
environment['_vm_app_suggestions_cache'].clear()
self.assertEqual(environment['_vm_modal_prewarmer_pass'](), 1)
scan.assert_not_called()
if __name__ == '__main__':
unittest.main()
+289
View File
@@ -0,0 +1,289 @@
"""PBS platform, HTTPS protocol, authorization and isolated AppImage regressions."""
import ast
from datetime import datetime, timedelta, timezone
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import ipaddress
import json
from pathlib import Path
import shlex
import ssl
import subprocess
import sys
import tempfile
import threading
from types import SimpleNamespace
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from flask import Flask
import jwt
import auth_manager
import pbs_manager as pbs
from flask_pbs_routes import pbs_bp
class PlatformTests(unittest.TestCase):
def test_supported_platforms_and_partial_dpkg_failure(self):
module = pbs.detect_platform.__globals__
with tempfile.TemporaryDirectory() as directory:
release = Path(directory) / "os-release"
for package, version, suite, product in (
("pve-manager", "8.4.15", "bookworm", "pve"),
("pve-manager", "9.2.1", "trixie", "pve"),
("proxmox-backup-server", "3.4.0-1", "bookworm", "pbs"),
("proxmox-backup-server", "4.2.5-1", "trixie", "pbs"),
):
release.write_text(f'VERSION_CODENAME="{suite}"\n')
output = f"{package}\tinstall ok installed\t{version}\n"
with self.subTest(product=product, version=version), \
mock.patch.dict(module, {"OS_RELEASE": release}), \
mock.patch.object(subprocess, "run", return_value=SimpleNamespace(stdout=output, returncode=1)):
detected = pbs.detect_platform()
self.assertEqual(detected["product"], product)
self.assertEqual(detected["version"], version)
self.assertTrue(detected["supported"])
def test_client_only_and_removed_server_are_not_pbs(self):
for output in ("proxmox-backup-client\tinstall ok installed\t4.2.5\n",
"proxmox-backup-server\tdeinstall ok config-files\t4.2.5\n", ""):
with self.subTest(output=output), mock.patch.object(subprocess, "run", return_value=SimpleNamespace(stdout=output)):
self.assertEqual(pbs.detect_platform()["product"], "unknown")
def test_pbs_utilities_preserve_sources_and_never_call_pve_policy(self):
helper = ROOT / "scripts/global/repository-functions.sh"
for supported in (True, False):
harness = f'''
source {shlex.quote(str(helper))}
pmx_platform_field() {{ if [[ "$1" == product ]]; then echo pbs; else echo {str(supported).lower()}; fi; }}
msg_info() {{ :; }}
msg_error() {{ :; }}
translate() {{ printf '%s' "$1"; }}
pveversion() {{ echo 'FORBIDDEN: pveversion'; return 90; }}
repository_policy() {{ echo 'FORBIDDEN: repository mutation'; return 90; }}
apt-get() {{ echo 'FORBIDDEN: apt'; return 90; }}
ensure_repositories
'''
result = subprocess.run(["bash"], input=harness, text=True, capture_output=True)
self.assertNotIn("FORBIDDEN", result.stdout + result.stderr)
self.assertEqual(result.returncode, 0 if supported else 1)
class PBSManagerTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
patch = mock.patch.multiple(auth_manager, CONFIG_DIR=self.root, AUTH_CONFIG_FILE=self.root / "auth.json")
patch.start(); self.addCleanup(patch.stop)
auth_manager.save_auth_config({"enabled": True, "username": "admin", "password_hash": "configured",
"jwt_secret": "s" * 48, "api_tokens": [], "revoked_tokens": []})
auth_manager._invalidate_revoked_cache()
pbs._status_cache.clear()
self.server = {"name": "Backup", "url": "https://pbs.test:8007", "token_id": "audit@pbs!monitor",
"token_secret": "fixture-token-secret", "fingerprint": "a" * 64}
app = Flask(__name__); app.register_blueprint(pbs_bp)
self.client = app.test_client()
self.headers = {"Authorization": "Bearer " + auth_manager.generate_token("admin")}
def test_save_redacts_secret_and_probes_authenticated_endpoint(self):
with mock.patch.object(pbs, "_remote_get", return_value=[]) as request:
identifier = pbs.save_server(self.server)
request.assert_called_once()
self.assertEqual(request.call_args.args[1], "/status/datastore-usage")
self.assertEqual((self.root / "pbs_servers.json").stat().st_mode & 0o777, 0o600)
public = pbs.public_servers()
self.assertEqual(public[0]["id"], identifier)
self.assertNotIn("fixture-token-secret", json.dumps(public))
self.assertTrue(public[0]["has_token"])
with mock.patch.object(pbs, "_remote_get", return_value=[]):
pbs.save_server({"id": identifier, "name": "Renamed"})
self.assertEqual(pbs._load_servers()[0]["token_secret"], "fixture-token-secret")
pbs.delete_server(identifier)
self.assertEqual(pbs.public_servers(), [])
def test_failed_credentials_or_invalid_config_do_not_replace_saved_connection(self):
with mock.patch.object(pbs, "_remote_get", return_value=[]):
identifier = pbs.save_server(self.server)
original = (self.root / "pbs_servers.json").read_bytes()
with mock.patch.object(pbs, "_remote_get", side_effect=pbs.PBSError("Rejected token")):
with self.assertRaises(pbs.PBSError):
pbs.save_server({**self.server, "id": identifier, "token_secret": "wrong"})
for changes in ({"url": "http://pbs.test"}, {"url": "https://user:pass@pbs.test"},
{"fingerprint": "bad-pin"}, {"token_id": "not-a-token"},
{"token_secret": "line\nbreak"}):
with self.subTest(changes=changes), self.assertRaises(pbs.PBSError):
pbs.save_server({**self.server, **changes, "id": identifier})
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), original)
def test_remote_partial_failure_is_unknown_instead_of_zero(self):
def remote(server, endpoint):
if endpoint == "/version": return {"version": "4.2.5"}
if endpoint == "/status/datastore-usage": return [{"store": "backup", "total": 100, "used": 30, "avail": 70}]
if endpoint == "/admin/verify": raise pbs.PBSError("Permission denied")
return []
with mock.patch.object(pbs, "_remote_get", side_effect=remote) as request:
identifier = pbs.save_server(self.server)
result = pbs.get_status(identifier)
count = request.call_count
self.assertEqual(pbs.get_status(identifier), result)
self.assertEqual(request.call_count, count)
self.assertEqual(result["datastores"][0]["name"], "backup")
self.assertIsNone(result["verify"])
self.assertIn("verify", result["errors"])
def test_unmounted_removable_datastore_has_no_fabricated_capacity(self):
with mock.patch.object(pbs, "_cli", return_value=[{"name": "removable", "path": "/not-mounted", "backing-device": "uuid"}]), \
mock.patch.object(pbs.os.path, "ismount", return_value=False), \
mock.patch.object(pbs.shutil, "disk_usage") as usage:
result = pbs._local_datastores()
usage.assert_not_called()
self.assertIsNone(result[0]["total"])
self.assertIn("error", result[0])
def test_local_status_reads_pbs_commands_and_filesystem_capacity(self):
def run(command, **kwargs):
self.assertNotIn("shell", kwargs)
self.assertEqual(command[0], "proxmox-backup-manager")
self.assertEqual(command[-2:], ["--output-format", "json"])
data = [{"name": "backup", "path": str(self.root)}] if command[1:3] == ["datastore", "list"] else []
return SimpleNamespace(stdout=json.dumps(data), returncode=0)
with mock.patch.object(pbs, "get_platform", return_value={"product": "pbs", "version": "4.2.5"}), \
mock.patch.object(pbs, "_local_services", return_value={key: "active" for key in pbs.PBS_SERVICES}), \
mock.patch.object(pbs.subprocess, "run", side_effect=run), \
mock.patch.object(pbs.shutil, "disk_usage", return_value=SimpleNamespace(total=100, used=30, free=70)):
result = pbs.get_status("local")
self.assertEqual(result["version"], "4.2.5")
self.assertEqual(result["errors"], {})
self.assertEqual(result["datastores"][0]["used"], 30)
self.assertEqual(set(result["services"]), set(pbs.PBS_SERVICES))
def test_connection_api_requires_full_admin_and_does_not_return_secret(self):
self.assertEqual(self.client.get("/api/pbs/servers").status_code, 401)
with mock.patch.object(pbs, "_remote_get", return_value=[]):
response = self.client.post("/api/pbs/servers", headers=self.headers, json=self.server)
self.assertEqual(response.status_code, 200)
self.assertNotIn("fixture-token-secret", response.text)
payload = jwt.decode(self.headers["Authorization"].split()[1], "s" * 48,
algorithms=["HS256"], audience=auth_manager.JWT_AUDIENCE)
payload["scope"] = "read_only"
read_only = {"Authorization": "Bearer " + jwt.encode(payload, "s" * 48, algorithm="HS256")}
self.assertEqual(self.client.post("/api/pbs/servers", headers=read_only, json=self.server).status_code, 403)
self.assertEqual(self.client.get("/api/pbs/servers", headers=read_only).status_code, 200)
auth_manager.save_auth_config({"enabled": False})
self.assertEqual(self.client.post("/api/pbs/servers", json=self.server).status_code, 409)
def test_health_uses_pbs_services_and_skips_pve_only_checks(self):
source = ast.parse((ROOT / "AppImage/scripts/health_monitor.py").read_text())
names = ("_check_pve_services", "_check_vms_cts_optimized", "_check_vms_cts_with_persistence",
"_check_proxmox_storages", "_check_proxmox_storage", "_check_lxc_disk_usage",
"_check_vm_disk_usage", "_check_lxc_mount_capacity", "_check_pve_storage_capacity")
functions = [node for node in ast.walk(source) if isinstance(node, ast.FunctionDef) and node.name in names]
persistence = mock.Mock(); persistence.is_error_active.return_value = False
run = mock.Mock(return_value=SimpleNamespace(returncode=0, stdout="active\n"))
namespace = {"Dict": dict, "Any": object, "Optional": __import__('typing').Optional,
"PBS_SERVICES": pbs.PBS_SERVICES, "health_persistence": persistence,
"subprocess": SimpleNamespace(run=run), "os": __import__('os')}
exec(compile(ast.Module(body=functions, type_ignores=[]), '<health-pbs-test>', 'exec'), namespace)
instance = SimpleNamespace(platform_type="pbs", PVE_SERVICES=["pveproxy", "pvedaemon", "pvestatd", "pve-cluster"])
services = namespace["_check_pve_services"](instance)
self.assertEqual(services["status"], "OK")
self.assertEqual([call.args[0][2] for call in run.call_args_list], list(pbs.PBS_SERVICES))
for name in names[1:]:
result = namespace[name](instance)
self.assertTrue(result is None or result.get("not_applicable"))
def test_appimage_copies_and_imports_pbs_dependencies_outside_source_tree(self):
builder = (ROOT / "AppImage/scripts/build_appimage.sh").read_text()
modules = ("pbs_manager", "flask_pbs_routes", "auth_manager", "jwt_middleware")
copies = []
for name in modules:
matches = [line for line in builder.splitlines() if line.startswith(f'cp "$SCRIPT_DIR/{name}.py"')]
self.assertEqual(len(matches), 1, name)
copies.extend(matches)
copies.extend(line for line in builder.splitlines() if line.startswith('cp "$APPIMAGE_ROOT/../scripts/global/proxmox_platform.py"'))
self.assertEqual(len(copies), len(modules) + 1)
app = self.root / 'AppDir'; (app / 'usr/bin').mkdir(parents=True)
q = shlex.quote
shell = f'APP_DIR={q(str(app))}\nSCRIPT_DIR={q(str(ROOT / "AppImage/scripts"))}\nAPPIMAGE_ROOT={q(str(ROOT / "AppImage"))}\n' + '\n'.join(copies)
subprocess.run(['bash', '-e', '-c', shell], check=True)
code = f'import sys; sys.path.insert(0, {str(app / "usr/bin")!r}); import pbs_manager, flask_pbs_routes, proxmox_platform'
subprocess.run([sys.executable, '-I', '-B', '-c', code], cwd=self.root, check=True, capture_output=True)
class TLSTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary = tempfile.TemporaryDirectory()
root = Path(cls.temporary.name)
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
certificate = (x509.CertificateBuilder().subject_name(subject).issuer_name(subject).public_key(key.public_key())
.serial_number(x509.random_serial_number()).not_valid_before(datetime.now(timezone.utc) - timedelta(minutes=1))
.not_valid_after(datetime.now(timezone.utc) + timedelta(hours=1))
.add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), critical=False)
.sign(key, hashes.SHA256()))
cert_path, key_path = root / 'server.pem', root / 'server.key'
cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
cls.fingerprint = hashlib.sha256(certificate.public_bytes(serialization.Encoding.DER)).hexdigest()
cls.requests = []
cls.response_status = 200
class Handler(BaseHTTPRequestHandler):
def handle(self):
try:
super().handle()
except ConnectionResetError:
pass # A rejected TLS pin closes before any HTTP request.
def do_GET(self):
cls.requests.append((self.path, self.headers.get("Authorization")))
body = json.dumps({"data": []}).encode()
self.send_response(cls.response_status)
self.send_header('Content-Length', str(len(body)))
self.end_headers(); self.wfile.write(body)
def log_message(self, *args): pass
cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); context.load_cert_chain(cert_path, key_path)
cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True)
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True); cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown(); cls.server.server_close(); cls.thread.join(timeout=5); cls.temporary.cleanup()
def setUp(self):
self.requests.clear(); type(self).response_status = 200
self.connection = {"url": f'https://127.0.0.1:{self.server.server_port}',
"fingerprint": self.fingerprint, "token_id": "audit@pbs!monitor", "token_secret": "fake-secret"}
def test_real_tls_matching_pin_sends_correct_pbs_authorization(self):
self.assertEqual(pbs._remote_get(self.connection, '/status/datastore-usage'), [])
self.assertEqual(self.requests, [('/api2/json/status/datastore-usage', 'PBSAPIToken=audit@pbs!monitor:fake-secret')])
def test_bad_pin_and_untrusted_ca_send_no_http_credentials(self):
for fingerprint in ('0' * 64, ''):
with self.subTest(fingerprint=fingerprint), self.assertRaises(pbs.PBSError):
pbs._remote_get({**self.connection, "fingerprint": fingerprint}, '/status/datastore-usage')
self.assertEqual(self.requests, [])
def test_bad_credentials_and_redirects_are_rejected(self):
for status in (401, 403, 302):
type(self).response_status = status
with self.subTest(status=status), self.assertRaises(pbs.PBSError):
pbs._remote_get(self.connection, '/status/datastore-usage')
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,67 @@
"""Remote mount checks carry the key their dismissal is saved under."""
import sys
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
_health_persistence_module = ModuleType("health_persistence")
_health_persistence_module.health_persistence = SimpleNamespace(cleanup_old_errors=lambda: None)
_health_persistence_module.disk_base_name = lambda name: str(name).replace("/dev/", "")
sys.modules.setdefault("health_persistence", _health_persistence_module)
sys.modules.setdefault("psutil", ModuleType("psutil"))
sys.modules.setdefault("flask_server", SimpleNamespace(
get_proxmox_node_name=lambda: "fixture",
get_cached_pvesh_cluster_resources_vm=lambda: [],
get_cached_vm_disk=lambda _vmid: None,
))
import health_monitor # noqa: E402
class _Persistence:
def __init__(self):
self.recorded = []
def record_error(self, **kwargs):
self.recorded.append(kwargs)
def get_active_errors(self, *args, **kwargs):
return []
def clear_error(self, key):
pass
class RemoteMountDismissal(unittest.TestCase):
def check(self, host, lxc=()):
persistence = _Persistence()
mounts = SimpleNamespace(scan_remote_mounts=lambda force: list(host),
scan_lxc_mounts=lambda force: list(lxc))
with patch.object(health_monitor, "MOUNT_MONITOR_AVAILABLE", True), \
patch.object(health_monitor, "mount_monitor", mounts, create=True), \
patch.object(health_monitor, "health_persistence", persistence):
result = health_monitor.HealthMonitor._check_remote_mounts(SimpleNamespace())
return result, persistence
def test_stale_and_read_only_mounts_carry_their_saved_key(self):
result, persistence = self.check(
[{"target": "/mnt/pve/nas", "status": "stale", "fstype": "nfs4"},
{"target": "/mnt/share", "status": "ok", "fstype": "cifs"}],
[{"target": "/media", "status": "readonly", "fstype": "nfs4", "lxc_id": "120", "lxc_name": "app"}])
checks = result["checks"]
self.assertEqual(checks["/mnt/pve/nas"]["error_key"], "mount_stale_/mnt/pve/nas")
self.assertEqual(checks["CT 120 (app) /media"]["error_key"], "mount_readonly_ct120:/media")
self.assertTrue(checks["/mnt/pve/nas"]["dismissable"])
self.assertTrue(checks["CT 120 (app) /media"]["dismissable"])
self.assertNotIn("error_key", checks["/mnt/share"])
self.assertEqual({entry["error_key"] for entry in persistence.recorded},
{"mount_stale_/mnt/pve/nas", "mount_readonly_ct120:/media"})
if __name__ == "__main__":
unittest.main()
+69
View File
@@ -0,0 +1,69 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const source = fs.readFileSync(path.join(__dirname, '../components/proxmox-dashboard.tsx'), 'utf8')
const tree = ts.createSourceFile('dashboard.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const wrapper = tree.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'ProxmoxDashboard')
assert.ok(wrapper, 'platform gate must remain separate from PVE hooks/prewarmers')
const code = ts.transpileModule(ts.createPrinter().printNode(ts.EmitHint.Unspecified, wrapper, tree), {
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
}).outputText
const PBS = () => {}, PVE = () => {}
for (const [data, error, expected] of [
[{ product: 'pbs' }, null, PBS], [{ product: 'pve' }, null, PVE],
[undefined, new Error('older API'), PVE], [undefined, null, 'p'],
]) {
const sandbox = {
exports: {}, PBSDashboard: PBS, PVEDashboard: PVE,
useT: () => key => key, useSWR: () => ({ data, error }),
require: () => ({ jsx: (type, props) => ({ type, props }) }),
}
vm.runInNewContext(code, sandbox)
assert.equal(sandbox.exports.ProxmoxDashboard().type, expected)
}
console.log('PASS: PBS dashboard routing and loading gate do not mount PVE prewarmers')
// The PVE Backup tab keeps native guest retention alongside jobs when adding
// its PBS connection view. Exercise the shipped JSX for all three selections.
const backupSource = fs.readFileSync(path.join(__dirname, '../components/backup-tab.tsx'), 'utf8')
const backupTree = ts.createSourceFile('backup-tab.tsx', backupSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const backupTab = backupTree.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'BackupTab')
assert.ok(backupTab, 'Backup tab must keep a platform-specific view switch')
const backupCode = ts.transpileModule(ts.createPrinter().printNode(ts.EmitHint.Unspecified, backupTab, backupTree), {
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
}).outputText
const HostBackup = () => {}, VmBackupJobs = () => {}, BackupStorageRetention = () => {}, PBSPanel = () => {}
for (const [view, expected] of [
['host', [HostBackup]], ['guests', [VmBackupJobs, BackupStorageRetention]], ['pbs', [PBSPanel]],
]) {
const selected = []
const sandbox = {
exports: {}, HostBackup, VmBackupJobs, BackupStorageRetention, PBSPanel, DatabaseBackup: () => {},
useT: () => key => key, useState: () => [view, value => selected.push(value)],
require: () => ({ jsx: (type, props) => ({ type, props }), jsxs: (type, props) => ({ type, props }), Fragment: Symbol('Fragment') }),
}
vm.runInNewContext(backupCode, sandbox)
const visible = [], mountedHosts = [], buttons = []
const components = new Set([HostBackup, VmBackupJobs, BackupStorageRetention, PBSPanel])
function walk(node, hidden = false) {
if (Array.isArray(node)) { node.forEach(item => walk(item, hidden)); return }
if (!node || typeof node !== 'object') return
hidden ||= node.props?.className === 'hidden'
if (node.type === HostBackup) mountedHosts.push(node)
if (components.has(node.type) && !hidden) visible.push(node.type)
if (node.type === 'button') buttons.push(node)
walk(node.props?.children, hidden)
}
walk(sandbox.exports.BackupTab())
assert.deepEqual(visible, expected, `visible backup panels for ${view}`)
assert.equal(mountedHosts.length, 1, 'host backup progress must stay mounted across view changes')
assert.equal(buttons.length, 3)
assert.equal(buttons.filter(button => button.props['aria-pressed']).length, 1)
const pbsButton = buttons.find(button => button.props.children === 'pbs.title')
assert.ok(pbsButton, 'PBS navigation button must exist')
pbsButton.props.onClick()
assert.deepEqual(selected, ['pbs'])
}
console.log('PASS: host progress stays mounted, guest jobs and retention stay together, and PBS navigation remains separate')