mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts: # AppImage/messages/es/common.json
This commit is contained in:
@@ -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,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" : ""
|
||||
}
|
||||
@@ -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' }
|
||||
}))
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "", ""
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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'},
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user