mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge pull request #508 from Vaso73/i18n/slovak-monitor-backup
i18n: complete Slovak node, PBS and host backup messages
This commit is contained in:
@@ -13,6 +13,9 @@ import sys
|
||||
import tempfile
|
||||
|
||||
PYTHON_FILES = (
|
||||
"AppImage/scripts/tests/test_pbs_i18n.py",
|
||||
"AppImage/scripts/tests/test_monitor_links_i18n.py",
|
||||
"AppImage/scripts/tests/test_monitor_links.py",
|
||||
"AppImage/scripts/tests/test_local_app_icon_isolation.py",
|
||||
"AppImage/scripts/tests/test_local_app_icons.py",
|
||||
"AppImage/scripts/tests/test_docker_app_discovery.py",
|
||||
@@ -28,6 +31,9 @@ PYTHON_FILES = (
|
||||
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
|
||||
)
|
||||
NODE_FILES = (
|
||||
"tests/test_host_backup_messages.cjs",
|
||||
"tests/test_pbs_i18n.cjs",
|
||||
"tests/test_monitor_links_i18n.cjs",
|
||||
"tests/lxc_updates/test_local_app_icons_ui.cjs",
|
||||
"tests/lxc_updates/test_docker_app_discovery_ui.cjs",
|
||||
"tests/lxc_updates/test_native_app_discovery_ui.cjs",
|
||||
|
||||
@@ -16,6 +16,7 @@ on:
|
||||
- 'AppImage/scripts/tests/test_local_app_icons.py'
|
||||
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
|
||||
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
|
||||
- 'AppImage/app/**'
|
||||
- 'AppImage/components/**'
|
||||
@@ -41,6 +42,7 @@ on:
|
||||
- 'AppImage/scripts/tests/test_local_app_icons.py'
|
||||
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
|
||||
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
|
||||
- 'AppImage/app/**'
|
||||
- 'AppImage/components/**'
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
- '.github/workflows/test-pbs.yml'
|
||||
- 'AppImage/scripts/*pbs*.py'
|
||||
- 'AppImage/scripts/tests/test_pbs.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/auth_manager.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
@@ -51,6 +52,7 @@ jobs:
|
||||
- name: Platform, real TLS, APIs, health and AppImage packaging
|
||||
run: |
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs_i18n.py -v
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
|
||||
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
|
||||
bash -n "$script"
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { RestoreProgressCard } from "./restore-progress-card"
|
||||
import { fetchApi, getApiUrl } from "../lib/api-config"
|
||||
import { hostBackupErrorText } from "../lib/host-backup-messages"
|
||||
import { fetchTerminalTicket } from "../lib/terminal-ws"
|
||||
import { formatStorage, formatBytes, cancelButtonClass } from "../lib/utils"
|
||||
import { getStorageUsageColor } from "../lib/storage-usage-color"
|
||||
@@ -525,7 +526,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
|
||||
await mutateInfo()
|
||||
closeUpload()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setErr(hostBackupErrorText(e, t))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -542,7 +543,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
|
||||
await mutateInfo()
|
||||
closeDelete()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setErr(hostBackupErrorText(e, t))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -765,7 +766,7 @@ function KeyfileActionsBar({
|
||||
setPass1("")
|
||||
setPass2("")
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setErr(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -956,7 +957,7 @@ export function HostBackup() {
|
||||
mutateJobs()
|
||||
setJobToDelete(null)
|
||||
} catch (e) {
|
||||
setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${e instanceof Error ? e.message : String(e)}` }))
|
||||
setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${hostBackupErrorText(e, t)}` }))
|
||||
} finally {
|
||||
setBusyJobId(null)
|
||||
}
|
||||
@@ -1334,7 +1335,7 @@ export function HostBackup() {
|
||||
<div className="font-medium">{t("backup.archives.remoteQueryWarning")}</div>
|
||||
{remoteArchivesResp.errors.map((e, i) => (
|
||||
<div key={i} className="font-mono break-all">
|
||||
{e.backend}/{e.repo_name}: {e.error}
|
||||
{e.backend}/{e.repo_name}: {hostBackupErrorText(e.error, t)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1761,7 +1762,7 @@ function InspectModal({
|
||||
setImportFile(null)
|
||||
setImportPath("")
|
||||
} catch (e) {
|
||||
setImportError(e instanceof Error ? e.message : String(e))
|
||||
setImportError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
@@ -1812,7 +1813,7 @@ function InspectModal({
|
||||
: undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
setRestoreError(e instanceof Error ? e.message : String(e))
|
||||
setRestoreError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setRestorePreparing(false)
|
||||
}
|
||||
@@ -1841,7 +1842,7 @@ function InspectModal({
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
} catch (e) {
|
||||
setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
// The download itself runs in the browser's network stack;
|
||||
// we just initiated it. Clear the spinner immediately.
|
||||
@@ -1914,7 +1915,7 @@ function InspectModal({
|
||||
document.body.removeChild(a)
|
||||
setExportTask(null)
|
||||
} catch (e) {
|
||||
setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
@@ -1962,7 +1963,7 @@ function InspectModal({
|
||||
setShowDeleteArchiveConfirm(false)
|
||||
if (onDeleted) onDeleted(); else onClose()
|
||||
} catch (e) {
|
||||
setError(t("backup.errors.deleteFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setError(t("backup.errors.deleteFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
setDeletingArchive(false)
|
||||
}
|
||||
@@ -1989,7 +1990,7 @@ function InspectModal({
|
||||
) as NonNullable<typeof instructions>
|
||||
setInstructions(res)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setFetchingInstructions(false)
|
||||
}
|
||||
@@ -2021,7 +2022,7 @@ function InspectModal({
|
||||
)
|
||||
setReport(res)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("backup.errors.preflightFailed"))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
@@ -2164,7 +2165,7 @@ function InspectModal({
|
||||
<span className="text-muted-foreground">— {exportTask.message}</span>
|
||||
</div>
|
||||
{exportTask.state === "failed" && exportTask.error && (
|
||||
<div className="text-red-500 mt-1">{exportTask.error}</div>
|
||||
<div className="text-red-500 mt-1">{hostBackupErrorText(exportTask.error, t)}</div>
|
||||
)}
|
||||
{exportTask.state === "completed" && exportTask.size_bytes > 0 && (
|
||||
<div className="text-emerald-400">{t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}</div>
|
||||
@@ -2706,7 +2707,7 @@ function PreflightReportView({ report }: { report: PreflightReport }) {
|
||||
<Icon className={`h-3.5 w-3.5 ${color} flex-shrink-0 mt-0.5`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={`font-mono ${color}`}>{c.id}</span>
|
||||
<span className="text-muted-foreground ml-2">{c.message}</span>
|
||||
<span className="text-muted-foreground ml-2">{hostBackupErrorText(c.message, t)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -3326,9 +3327,9 @@ function CreateJobDialog({
|
||||
} catch (e) {
|
||||
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
|
||||
const detail = err.body?.tool_output
|
||||
? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: err.message
|
||||
setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
|
||||
? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: hostBackupErrorText(err, t)
|
||||
setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) }))
|
||||
setPbsImportBusy(false)
|
||||
setSubmitting(false)
|
||||
return
|
||||
@@ -3391,7 +3392,7 @@ function CreateJobDialog({
|
||||
})
|
||||
onCreated()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -3764,7 +3765,7 @@ function CreateJobDialog({
|
||||
</>
|
||||
) : (
|
||||
<div className="text-red-500">
|
||||
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {calendarPreview.error}
|
||||
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {hostBackupErrorText(calendarPreview.error, t)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -4696,9 +4697,9 @@ function ManualBackupDialog({
|
||||
} catch (e) {
|
||||
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
|
||||
const detail = err.body?.tool_output
|
||||
? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: err.message
|
||||
setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
|
||||
? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: hostBackupErrorText(err, t)
|
||||
setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) }))
|
||||
setPbsImportBusy(false)
|
||||
setSubmitting(false)
|
||||
return
|
||||
@@ -4738,7 +4739,7 @@ function ManualBackupDialog({
|
||||
)
|
||||
onLaunched(resp.job_id)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -5542,7 +5543,7 @@ function DestinationsSection({
|
||||
await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -5554,7 +5555,7 @@ function DestinationsSection({
|
||||
await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -5571,7 +5572,7 @@ function DestinationsSection({
|
||||
)
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -5599,7 +5600,7 @@ function DestinationsSection({
|
||||
})
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -6042,7 +6043,7 @@ function DestinationRow({
|
||||
|
||||
{capacity?.error && (
|
||||
<p className="mt-3 text-[11px] text-muted-foreground italic">
|
||||
{t("backup.destinations.capacityUnavailable", { error: capacity.error })}
|
||||
{t("backup.destinations.capacityUnavailable", { error: hostBackupErrorText(capacity.error, t) })}
|
||||
</p>
|
||||
)}
|
||||
{!capacity && (
|
||||
@@ -6341,7 +6342,7 @@ function AddDestinationDialog({
|
||||
)
|
||||
setGeneratedKey(resp)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setGeneratingKey(false)
|
||||
}
|
||||
@@ -6405,7 +6406,7 @@ function AddDestinationDialog({
|
||||
}
|
||||
onSaved(savedRepo)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -6756,7 +6757,7 @@ function UsbPicker({
|
||||
mutate()
|
||||
if (resp?.mountpoint) onPick(resp.mountpoint)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -6784,7 +6785,7 @@ function UsbPicker({
|
||||
setFormatTyped("")
|
||||
if (resp?.mountpoint) onPick(resp.mountpoint)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7001,7 +7002,7 @@ function ExtraPathsSection() {
|
||||
setNewPath("")
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -7014,7 +7015,7 @@ function ExtraPathsSection() {
|
||||
await fetchApi(`/api/host-backups/extra-paths?path=${encodeURIComponent(p)}`, { method: "DELETE" })
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyPath(null)
|
||||
}
|
||||
@@ -7180,7 +7181,7 @@ function UsbDrivesSection() {
|
||||
})
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7198,7 +7199,7 @@ function UsbDrivesSection() {
|
||||
})
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7222,7 +7223,7 @@ function UsbDrivesSection() {
|
||||
setFormatTarget(null)
|
||||
setFormatTyped("")
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7508,7 +7509,7 @@ function JobDetailModal({
|
||||
setRunning(false)
|
||||
setRunBaseline(null)
|
||||
setRunBaselineLogPath(null)
|
||||
setActionError(t("backup.errors.runFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setActionError(t("backup.errors.runFailed", { error: hostBackupErrorText(e, t) }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7522,7 +7523,7 @@ function JobDetailModal({
|
||||
await refetch()
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setActionError(t("backup.errors.toggleFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setActionError(t("backup.errors.toggleFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
setBusy("")
|
||||
}
|
||||
@@ -8155,7 +8156,7 @@ function PbsKeyfileRecoveryDialog({
|
||||
})
|
||||
onRecovered()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -8342,7 +8343,7 @@ function ArchiveContentsModal({
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((r: any) => setData(r))
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.catch((e) => setError(hostBackupErrorText(e, t)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [open, source, repo_name, snapshot, path])
|
||||
|
||||
@@ -8571,7 +8572,7 @@ function PlanSummary({ plan }: { plan: any }) {
|
||||
<div className="text-[10px] uppercase tracking-wider text-red-400 mb-1">{t("backup.plan.blockers")}</div>
|
||||
<ul className="space-y-0.5 ml-4 list-disc">
|
||||
{blockers.map((b, i) => (
|
||||
<li key={i} className="text-red-400">{typeof b === "string" ? b : (b.message || JSON.stringify(b))}</li>
|
||||
<li key={i} className="text-red-400">{hostBackupErrorText(typeof b === "string" ? b : (b.message || JSON.stringify(b)), t)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -8581,7 +8582,7 @@ function PlanSummary({ plan }: { plan: any }) {
|
||||
<div className="text-[10px] uppercase tracking-wider text-amber-400 mb-1">{t("backup.plan.warnings")}</div>
|
||||
<ul className="space-y-0.5 ml-4 list-disc">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={i} className="text-amber-400">{typeof w === "string" ? w : (w.message || JSON.stringify(w))}</li>
|
||||
<li key={i} className="text-amber-400">{hostBackupErrorText(typeof w === "string" ? w : (w.message || JSON.stringify(w)), t)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -8611,7 +8612,7 @@ function StorageSection({ storage }: { storage: any }) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<code className="font-mono">{e.name || e.storage || `entry ${i}`}</code>
|
||||
{e.type && <span className="text-muted-foreground ml-2">{e.type}</span>}
|
||||
{e.message && <div className="text-muted-foreground text-[11px]">{e.message}</div>}
|
||||
{e.message && <div className="text-muted-foreground text-[11px]">{hostBackupErrorText(e.message, t)}</div>}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { monitorLinkError } from "../lib/monitor-link-errors"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface MonitorLink {
|
||||
@@ -185,8 +186,9 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
await refreshCache("/api/monitor-links")
|
||||
setOfferOrigin(false)
|
||||
} catch (err) {
|
||||
await refreshCache("/api/monitor-links")
|
||||
setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
|
||||
setAddError(monitorLinkError(err, t))
|
||||
try { await refreshCache("/api/monitor-links") }
|
||||
catch { /* SWR shows the load error; keep the localized mutation error. */ }
|
||||
} finally { setAdding(false) }
|
||||
}
|
||||
const notNow = () => {
|
||||
@@ -260,7 +262,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
</DropdownMenu>
|
||||
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
|
||||
{origin && <Dialog open={offerOrigin} onOpenChange={open => { if (!open) notNow() }}>
|
||||
<DialogContent className="max-w-md" onClick={event => event.stopPropagation()}>
|
||||
<DialogContent className="grid-cols-1 max-w-md" onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.knownTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.knownBody", { name: origin.name })}</DialogDescription>
|
||||
@@ -290,7 +292,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
|
||||
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 { data, mutate, error: loadError, isLoading } = 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 [editing, setEditing] = useState<string | null>(null)
|
||||
@@ -304,14 +306,14 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
await mutate()
|
||||
await refreshCache("/api/monitor-links")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
|
||||
setError(monitorLinkError(err, t))
|
||||
} 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 bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
|
||||
@@ -320,7 +322,7 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
|
||||
<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="grid grid-cols-1 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 })} />
|
||||
@@ -350,6 +352,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">{t("monitorLinks.checking")}</p>}
|
||||
{loadError && <p role="alert" className="text-sm text-amber-500">{t("monitorLinks.loadFailed")}</p>}
|
||||
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||
{data?.links.map(link => {
|
||||
const own = link.manual || link.custom_url
|
||||
@@ -378,8 +382,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.thisNode")}</span>
|
||||
</div>
|
||||
: isEditing
|
||||
? <div className="flex gap-2">
|
||||
<Input value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
|
||||
? <div className="grid grid-cols-2 gap-2 sm:flex">
|
||||
<Input className="col-span-2 min-w-0 sm:flex-1" value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
|
||||
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
|
||||
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
|
||||
<Button size="sm" className="h-10 bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100"
|
||||
|
||||
@@ -4,7 +4,8 @@ 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"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
|
||||
export interface PBSDatastore {
|
||||
name: string
|
||||
@@ -13,6 +14,7 @@ export interface PBSDatastore {
|
||||
used?: number | null
|
||||
avail?: number | null
|
||||
error?: string
|
||||
error_code?: string
|
||||
"backend-type"?: string
|
||||
"estimated-full-date"?: number
|
||||
"gc-status"?: { upid?: string | null; "removed-bytes"?: number; "pending-bytes"?: number }
|
||||
@@ -42,7 +44,7 @@ export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatast
|
||||
<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>
|
||||
<PBSProblemMessage className="text-xs text-amber-500" problem={{ code: store.error_code }} raw={store.error} fallback="pbs.capacityUnavailable" />
|
||||
</div>
|
||||
}
|
||||
const tone = usageTone(percent)
|
||||
@@ -62,10 +64,11 @@ export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatast
|
||||
// Same layout as the "Proxmox storage" cards of the PVE Monitor.
|
||||
export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const percent = datastorePercent(store)
|
||||
const tone = percent == null ? null : usageTone(percent)
|
||||
const fullDate = store["estimated-full-date"]
|
||||
const type = store["backend-type"] || "datastore"
|
||||
const type = store["backend-type"] || t("pbs.datastoreType")
|
||||
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">
|
||||
@@ -99,13 +102,13 @@ export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{store.error || t("storage.capacityNotReported")}</p>
|
||||
<PBSProblemMessage className="text-sm text-muted-foreground" problem={{ code: store.error_code }} raw={store.error} fallback="pbs.capacityUnavailable" />
|
||||
)}
|
||||
{(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 className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString(language) : t("pbs.estimatedFullUnknown")}</span>
|
||||
</span>}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,10 @@ import { Label } from "./ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
import { pbsDiagnostic, pbsResultText, pbsServiceState, pbsTaskType, formatPBSDuration } from "../lib/pbs-messages"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
|
||||
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
|
||||
interface Detected { name: string; url: string; fingerprint: string }
|
||||
@@ -26,6 +28,7 @@ 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>;
|
||||
error_details?: Record<string, { code: string; params?: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "", username: "", password: "" }
|
||||
@@ -46,9 +49,10 @@ const TASK_ICONS: Record<string, typeof Activity> = {
|
||||
termproxy: Terminal, dircreate: Database, "create-datastore": Database,
|
||||
}
|
||||
|
||||
// PBS reports "OK", "WARNINGS: n" or the error text; a task without one is still running.
|
||||
// PBS reports "unknown", "OK", "WARNINGS: n" or an error; no state is still running.
|
||||
function taskState(task: Task): keyof typeof badgeTone {
|
||||
if (!task.status) return task.endtime ? "muted" : "running"
|
||||
if (task.status === "unknown") return "muted"
|
||||
if (task.status === "OK") return "ok"
|
||||
if (task.status.startsWith("WARNINGS")) return "warn"
|
||||
return "error"
|
||||
@@ -56,27 +60,19 @@ function taskState(task: Task): keyof typeof badgeTone {
|
||||
|
||||
// PBS reports "OK", "WARNINGS: n" or the error text of the last run.
|
||||
function jobState(state?: string): keyof typeof badgeTone {
|
||||
if (!state) return "muted"
|
||||
if (!state || state === "unknown") return "muted"
|
||||
if (state.toLowerCase() === "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. Its tabs show one `view` each.
|
||||
export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: boolean; view?: "all" | "datastores" | "tasks" } = {}) {
|
||||
const showStores = view !== "tasks"
|
||||
const showTasks = view !== "datastores"
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
|
||||
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
|
||||
const [selected, setSelected] = useState("")
|
||||
@@ -85,7 +81,8 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
const [method, setMethod] = useState<Method>("password")
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [errorFallback, setErrorFallback] = useState("pbs.saveFailed")
|
||||
const { data, error: statusError, isLoading, mutate } = useSWR<PBSStatus>(
|
||||
selected ? `/api/pbs/status?server=${encodeURIComponent(selected)}` : null,
|
||||
url => fetchApi<PBSStatus>(url), { refreshInterval: 30000, revalidateOnFocus: false })
|
||||
@@ -102,7 +99,9 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setErrorFallback("pbs.saveFailed")
|
||||
setBusy(true); setError(""); setMessage("")
|
||||
let saved = false
|
||||
try {
|
||||
// A password is sent once to create a read-only token; only the token is kept.
|
||||
const { username, password, ...connection } = form
|
||||
@@ -113,22 +112,26 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
: await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
|
||||
...connection, id: form.id || undefined, token_secret: form.token_secret || undefined,
|
||||
}) })
|
||||
saved = true
|
||||
await reloadConnections()
|
||||
setSelected(result.id)
|
||||
setForm(emptyForm)
|
||||
setManage(false)
|
||||
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
|
||||
} catch (err) { setError(saved ? { body: { code: "pbs_saved_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
if (!form.id || !window.confirm(t("pbs.removeConfirm"))) return
|
||||
setErrorFallback("pbs.removeFailed")
|
||||
setBusy(true); setError(""); setMessage("")
|
||||
let removed = false
|
||||
try {
|
||||
await fetchApi(`/api/pbs/servers/${encodeURIComponent(form.id)}`, { method: "DELETE" })
|
||||
removed = true
|
||||
setForm(emptyForm)
|
||||
await reloadConnections()
|
||||
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
|
||||
} catch (err) { setError(removed ? { body: { code: "pbs_removed_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const now = Date.now() / 1000
|
||||
@@ -150,8 +153,9 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
<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>}
|
||||
{(connectionError || statusError) && <div role="alert" className="text-sm text-red-500">
|
||||
<PBSProblemMessage problem={connectionError || statusError} raw={pbsDiagnostic(connectionError || statusError)} />
|
||||
</div>}
|
||||
{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>}
|
||||
{!localOnly && !manage && (connections?.detected?.length || 0) > 0 && <Card className="bg-card border-border">
|
||||
@@ -171,7 +175,7 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{!localOnly && <Dialog open={manage} onOpenChange={open => { if (!busy) setManage(open) }}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("pbs.manage")}</DialogTitle>
|
||||
<DialogDescription>{method === "password" && !form.id ? t("pbs.passwordHelp") : t("pbs.permissionsHelp")}</DialogDescription>
|
||||
@@ -201,19 +205,26 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{(["name", "url", ...(method === "password" && !form.id ? ["username", "password"] : ["token_id", "token_secret"]), "fingerprint"] as const).map(field => {
|
||||
const secret = field === "token_secret" || field === "password"
|
||||
return <div key={field} className="space-y-2">
|
||||
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
|
||||
const help = field === "url" ? "pbs.urlHelp" : field === "username" ? "pbs.usernameHelp"
|
||||
: field === "token_id" ? "pbs.tokenIdHelp" : field === "token_secret"
|
||||
? form.id ? "pbs.tokenSecretKeepHelp" : "pbs.tokenSecretHelp" : undefined
|
||||
return <div key={field} className="min-w-0 space-y-2">
|
||||
<Label htmlFor={`pbs-${field}`} className="block leading-5">{t(`pbs.${field}`)}</Label>
|
||||
<Input id={`pbs-${field}`} disabled={busy} type={secret ? "password" : field === "url" ? "url" : "text"}
|
||||
aria-describedby={help ? `pbs-${field}-help` : undefined}
|
||||
autoComplete={secret ? "new-password" : "off"} placeholder={field === "username" ? "root@pam" : undefined}
|
||||
value={form[field as keyof typeof form]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
|
||||
{help && <p id={`pbs-${field}-help`} className="text-xs text-muted-foreground">{t(help)}</p>}
|
||||
</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>}
|
||||
{!!error && <div role="alert" className="text-sm text-red-500">
|
||||
<PBSProblemMessage problem={error} raw={pbsDiagnostic(error)} fallback={errorFallback} />
|
||||
</div>}
|
||||
{message && <p role="status" className="text-sm text-green-500">{message}</p>}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{form.id && <Button variant="outline" disabled={busy} className="mr-auto text-red-500 hover:text-red-500" onClick={remove}>
|
||||
@@ -230,14 +241,17 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
</Dialog>}
|
||||
{data && <>
|
||||
{!localOnly && <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString() })}</span>
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString(language) })}</span>
|
||||
{Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active")
|
||||
? <Badge variant="outline" className={badgeTone.ok}>{t("pbs.servicesActive")}</Badge>
|
||||
: Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) =>
|
||||
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {state}</Badge>)}
|
||||
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {pbsServiceState(state, t)}</Badge>)}
|
||||
</div>}
|
||||
{Object.entries(data.errors).filter(([section]) => section === "datastores" ? showStores : showTasks)
|
||||
.map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
|
||||
.map(([section, detail]) => <div key={section} role="alert" className="text-sm text-amber-500">
|
||||
<p className="font-medium">{t(`pbs.${["datastores", "tasks", "sync", "verify", "prune"].includes(section) ? section : "title"}`)}</p>
|
||||
<PBSProblemMessage problem={data.error_details?.[section]} raw={detail} />
|
||||
</div>)}
|
||||
|
||||
{showStores && <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>
|
||||
@@ -268,14 +282,14 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
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>
|
||||
<span className="text-foreground" title={row.worker_type}>{pbsTaskType(row.worker_type, t)}</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>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString(language) : "—"}</td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatPBSDuration(end - row.starttime, t) : "—"}</td>
|
||||
<td className="py-2">
|
||||
<PBSResultBadge status={row.status} running={state === "running"} tone={badgeTone[state]} />
|
||||
</td>
|
||||
</tr>
|
||||
})}</tbody>
|
||||
</table></div>}
|
||||
@@ -299,7 +313,7 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
<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>
|
||||
<td className="py-2"><PBSResultBadge status={row["last-run-state"]} tone={badgeTone[jobState(row["last-run-state"])]} /></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
})()}
|
||||
@@ -309,3 +323,15 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function PBSResultBadge({ status, running = false, tone }: { status?: string; running?: boolean; tone: string }) {
|
||||
const t = useT()
|
||||
const badge = <Badge variant="outline" className={`${tone} max-w-[16rem] truncate`}>
|
||||
{running ? t("pbs.running") : pbsResultText(status, t)}
|
||||
</Badge>
|
||||
if (running || !status || status === "unknown" || /^ok$/i.test(status) || /^WARNINGS:\s*\d+\s*$/i.test(status)) return badge
|
||||
return <details className="max-w-xs">
|
||||
<summary className="cursor-pointer" aria-label={t("pbs.technicalDetails")}>{badge}</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words text-xs text-muted-foreground">{status}</pre>
|
||||
</details>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { pbsErrorText, pbsIsKnownError } from "../lib/pbs-messages"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// Known application messages are already explained in the active language.
|
||||
// Preserve unrecognized external diagnostics as escaped technical details.
|
||||
export function PBSProblemMessage({ problem, raw, fallback, className = "" }: {
|
||||
problem?: unknown; raw?: string; fallback?: string; className?: string
|
||||
}) {
|
||||
const t = useT()
|
||||
return <div className={`min-w-0 space-y-1 ${className}`}>
|
||||
<p>{pbsErrorText(problem, t, fallback)}</p>
|
||||
{raw && !pbsIsKnownError(problem) && <details className="text-xs">
|
||||
<summary className="cursor-pointer">{t("pbs.technicalDetails")}</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words font-mono">{raw}</pre>
|
||||
</details>}
|
||||
</div>
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { Progress } from "./ui/progress"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight, Database } from "lucide-react"
|
||||
import { DatastoreUsageRow, type PBSDatastore } from "./pbs-datastore-usage"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
import { pbsDiagnostic } from "../lib/pbs-messages"
|
||||
import { NodeMetricsCharts } from "./node-metrics-charts"
|
||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||
import { TemperatureDetailModal } from "./temperature-detail-modal"
|
||||
@@ -187,14 +189,20 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
|
||||
return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes"
|
||||
}
|
||||
|
||||
interface PBSOverviewStatus {
|
||||
datastores: PBSDatastore[] | null
|
||||
errors?: Record<string, string>
|
||||
error_details?: Record<string, { code: string }>
|
||||
}
|
||||
|
||||
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 }>(
|
||||
const { data: pbsStatus, error: pbsError } = useSWR<PBSOverviewStatus>(
|
||||
isPbs ? "/api/pbs/status?server=local" : null,
|
||||
(url: string) => fetchApi<{ datastores: PBSDatastore[] | null }>(url),
|
||||
(url: string) => fetchApi<PBSOverviewStatus>(url),
|
||||
{ refreshInterval: 60000, revalidateOnFocus: false },
|
||||
)
|
||||
const datastores = pbsStatus?.datastores ?? null
|
||||
@@ -545,13 +553,15 @@ export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" }
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!datastores && !pbsError ? (
|
||||
{!pbsStatus && !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>
|
||||
<div role="alert" className="text-sm text-amber-500">
|
||||
<PBSProblemMessage problem={pbsError || pbsStatus?.error_details?.datastores} raw={pbsDiagnostic(pbsError) || pbsStatus?.errors?.datastores} />
|
||||
</div>
|
||||
) : datastores.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>
|
||||
) : (
|
||||
@@ -762,8 +772,12 @@ export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" }
|
||||
{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>
|
||||
{!pbsStatus && !pbsError ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.loading")}</div>
|
||||
) : !datastores ? (
|
||||
<div role="alert" className="py-4 text-amber-500 text-sm">
|
||||
<PBSProblemMessage problem={pbsError || pbsStatus?.error_details?.datastores} raw={pbsDiagnostic(pbsError) || pbsStatus?.errors?.datastores} />
|
||||
</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} />)}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Legacy HostBackup API messages are an English wire contract shared with CLI callers.
|
||||
// Match complete, source-verified messages only; never derive a translation key from input.
|
||||
// This presentation-only adapter keeps unknown tool diagnostics and parameter text intact.
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const fixed = new Map<string, string>([
|
||||
["path must be absolute", "backup.apiErrors.absolutePath"],
|
||||
["this path is already configured", "backup.apiErrors.pathConfigured"],
|
||||
["path is required", "backup.apiErrors.pathRequired"],
|
||||
["path query parameter is required", "backup.apiErrors.pathQuery"],
|
||||
["path not found", "backup.apiErrors.pathNotFound"],
|
||||
["invalid job id", "backup.apiErrors.invalidJobId"],
|
||||
["job not found", "backup.apiErrors.jobNotFound"],
|
||||
["runner script not installed", "backup.apiErrors.runnerMissing"],
|
||||
["no restore state to dismiss", "backup.apiErrors.noRestore"],
|
||||
["invalid file name", "backup.apiErrors.invalidFile"],
|
||||
["not found", "backup.apiErrors.notFound"],
|
||||
["log path outside allowed directory", "backup.apiErrors.logOutside"],
|
||||
["targets must be a list", "backup.apiErrors.targetsList"],
|
||||
["empty expression", "backup.apiErrors.emptyExpression"],
|
||||
["invalid id (use letters, digits, _ or -)", "backup.apiErrors.invalidId"],
|
||||
["backend must be pbs, local or borg", "backup.apiErrors.backend"],
|
||||
["backend must be pbs or borg", "backup.apiErrors.remoteBackend"],
|
||||
["backend must be 'pbs' or 'borg'", "backup.apiErrors.remoteBackendQuoted"],
|
||||
["profile_mode must be default or custom", "backup.apiErrors.profileMode"],
|
||||
["borg cannot be attached (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttached"],
|
||||
["borg is not a valid backend for attached jobs (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttachedLong"],
|
||||
["pve_parent_job_id and pve_storage are required for attached", "backup.apiErrors.parentRequired"],
|
||||
["paths must be a list of absolute paths", "backup.apiErrors.absolutePaths"],
|
||||
["custom profile needs at least one path", "backup.apiErrors.customPath"],
|
||||
["could not resolve default profile paths", "backup.apiErrors.defaultPaths"],
|
||||
["retention must be an object", "backup.apiErrors.retentionObject"],
|
||||
["pbs_repository is required", "backup.apiErrors.pbsRepository"],
|
||||
["pbs_encrypt_mode must be 'none', 'new' or 'existing'", "backup.apiErrors.pbsEncryption"],
|
||||
["failed to create PBS encryption keyfile", "backup.apiErrors.pbsKeyFailed"],
|
||||
["failed to create PBS encryption keyfile (is proxmox-backup-client installed?)", "backup.apiErrors.pbsClientMissing"],
|
||||
["borg_repo is required", "backup.apiErrors.borgRepository"],
|
||||
["borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.borgEncryption"],
|
||||
["encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.encryption"],
|
||||
["borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)", "backup.apiErrors.borgPassphrase"],
|
||||
["passphrase is required when encryption is enabled", "backup.apiErrors.passphrase"],
|
||||
["password is required", "backup.apiErrors.password"],
|
||||
["confirm_device must match device exactly (typo / safety check)", "backup.apiErrors.deviceConfirmation"],
|
||||
["invalid device path", "backup.apiErrors.invalidDevice"],
|
||||
["absolute mountpoint required", "backup.apiErrors.absoluteMountpoint"],
|
||||
["archive layout not recognized — no rootfs/ found", "backup.apiErrors.archiveLayout"],
|
||||
["archive not found", "backup.apiErrors.archiveMissing"],
|
||||
["archive path is not a regular file", "backup.apiErrors.archiveNotFile"],
|
||||
["invalid name", "backup.apiErrors.invalidName"],
|
||||
["invalid staging_path", "backup.apiErrors.invalidStaging"],
|
||||
["job name cannot be changed (delete + recreate to rename)", "backup.apiErrors.jobRename"],
|
||||
["key_path must be absolute", "backup.apiErrors.absoluteKey"],
|
||||
["mode must be \"local\" or \"ssh\"", "backup.apiErrors.mode"],
|
||||
["name is required (letters, digits, _ or -)", "backup.apiErrors.nameRequired"],
|
||||
["no log available for this job yet", "backup.apiErrors.noLog"],
|
||||
["no manual PBS configs file", "backup.apiErrors.noPbsConfig"],
|
||||
["output file missing", "backup.apiErrors.outputMissing"],
|
||||
["parser output was not valid JSON", "backup.apiErrors.parserJson"],
|
||||
["run_restore output was not valid JSON", "backup.apiErrors.restoreJson"],
|
||||
["repo is required", "backup.apiErrors.repoRequired"],
|
||||
["repo_name and snapshot are required", "backup.apiErrors.repoSnapshot"],
|
||||
["repo_name and snapshot are required for borg", "backup.apiErrors.borgRepoSnapshot"],
|
||||
["repo_name and snapshot are required for pbs", "backup.apiErrors.pbsRepoSnapshot"],
|
||||
["repo_name is required", "backup.apiErrors.repoName"],
|
||||
["restore tooling not installed on this host", "backup.apiErrors.restoreMissing"],
|
||||
["server and datastore are required", "backup.apiErrors.serverDatastore"],
|
||||
["snapshot is required", "backup.apiErrors.snapshotRequired"],
|
||||
["source must be pbs, borg, or local", "backup.apiErrors.source"],
|
||||
["ssh_port must be an integer between 1 and 65535", "backup.apiErrors.sshPort"],
|
||||
["ssh_user, ssh_host and ssh_remote_path are required for ssh mode", "backup.apiErrors.sshRequired"],
|
||||
["target has no repository path", "backup.apiErrors.repositoryPath"],
|
||||
["task not found or expired", "backup.apiErrors.taskExpired"],
|
||||
["invalid expression", "backup.apiErrors.invalidExpression"],
|
||||
["no report emitted", "backup.apiErrors.noReport"],
|
||||
["parse_manifest exited non-zero", "backup.apiErrors.parseManifestFailed"],
|
||||
])
|
||||
|
||||
const parameterized: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[new RegExp("^job already exists: ([\\s\\S]+)$"), "backup.apiErrors.jobExists"],
|
||||
[new RegExp("^path does not exist on this host: ([\\s\\S]+)$"), "backup.apiErrors.pathMissing"],
|
||||
[new RegExp("^device does not exist: ([\\s\\S]+)$"), "backup.apiErrors.deviceMissing"],
|
||||
[new RegExp("^not a mountpoint: ([\\s\\S]+)$"), "backup.apiErrors.mountpoint"],
|
||||
[new RegExp("^local archive not found: ([\\s\\S]+)$"), "backup.apiErrors.localArchiveMissing"],
|
||||
[new RegExp("^Borg destination '([\\s\\S]+)' not found$"), "backup.apiErrors.borgDestination"],
|
||||
[new RegExp("^PBS destination '([\\s\\S]+)' not found$"), "backup.apiErrors.pbsDestination"],
|
||||
[new RegExp("^no password available for PBS repo '([\\s\\S]+)'$"), "backup.apiErrors.pbsPassword"],
|
||||
[new RegExp("^Borg repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.borgRepoMissing"],
|
||||
[new RegExp("^PBS repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.pbsRepoMissing"],
|
||||
[new RegExp("^Borg target not found: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetMissing"],
|
||||
[new RegExp("^PBS repo not found: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetMissing"],
|
||||
[new RegExp("^PVE vzdump job not found: ([\\s\\S]+)$"), "backup.apiErrors.pveJobMissing"],
|
||||
[new RegExp("^cannot persist state: ([\\s\\S]+)$"), "backup.apiErrors.statePersist"],
|
||||
[new RegExp("^cannot prepare ssh dir: ([\\s\\S]+)$"), "backup.apiErrors.sshDirectory"],
|
||||
[new RegExp("^cannot read log: ([\\s\\S]+)$"), "backup.apiErrors.logRead"],
|
||||
[new RegExp("^could not update \\.env: ([\\s\\S]+)$"), "backup.apiErrors.envUpdate"],
|
||||
[new RegExp("^failed to persist PBS config: ([\\s\\S]+)$"), "backup.apiErrors.pbsConfigWrite"],
|
||||
[new RegExp("^failed to persist: ([\\s\\S]+)$"), "backup.apiErrors.persist"],
|
||||
[new RegExp("^failed to remove archive: ([\\s\\S]+)$"), "backup.apiErrors.archiveRemove"],
|
||||
[new RegExp("^failed to start: ([\\s\\S]+)$"), "backup.apiErrors.start"],
|
||||
[new RegExp("^failed to update borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsUpdate"],
|
||||
[new RegExp("^failed to update pbs-manual-configs\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetsUpdate"],
|
||||
[new RegExp("^failed to update systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsUpdate"],
|
||||
[new RegExp("^failed to write borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsWrite"],
|
||||
[new RegExp("^failed to write job files: ([\\s\\S]+)$"), "backup.apiErrors.jobWrite"],
|
||||
[new RegExp("^failed to write local-target\\.conf: ([\\s\\S]+)$"), "backup.apiErrors.localTargetWrite"],
|
||||
[new RegExp("^failed to write systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsWrite"],
|
||||
[new RegExp("^format failed: ([\\s\\S]+)$"), "backup.apiErrors.format"],
|
||||
[new RegExp("^invalid on_calendar: ([\\s\\S]+)$"), "backup.apiErrors.calendar"],
|
||||
[new RegExp("^mount failed: ([\\s\\S]+)$"), "backup.apiErrors.mount"],
|
||||
[new RegExp("^parser invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.parserInvocation"],
|
||||
[new RegExp("^preflight invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.preflightInvocation"],
|
||||
[new RegExp("^public key not found after generation: ([\\s\\S]+)$"), "backup.apiErrors.publicKeyMissing"],
|
||||
[new RegExp("^retention\\.([\\s\\S]+) must be an integer$"), "backup.apiErrors.retentionInteger"],
|
||||
[new RegExp("^retention\\.([\\s\\S]+) must be non-negative$"), "backup.apiErrors.retentionNegative"],
|
||||
[new RegExp("^ssh-keygen failed: ([\\s\\S]+)$"), "backup.apiErrors.keygen"],
|
||||
[new RegExp("^systemctl failed: ([\\s\\S]+)$"), "backup.apiErrors.systemctl"],
|
||||
[new RegExp("^umount failed: ([\\s\\S]+)$"), "backup.apiErrors.unmount"],
|
||||
[new RegExp("^task is in state \"([\\s\\S]+)\", not completed$"), "backup.apiErrors.taskIncomplete"],
|
||||
[new RegExp("^unknown kind: ([\\s\\S]+)$"), "backup.apiErrors.unknownKind"],
|
||||
[new RegExp("^unknown mode \"([\\s\\S]+)\"$"), "backup.apiErrors.unknownMode"],
|
||||
[new RegExp("^([\\s\\S]+) is the built-in default and cannot be removed$"), "backup.apiErrors.defaultRemove"],
|
||||
[new RegExp("^([\\s\\S]+) is the built-in default; no need to add it$"), "backup.apiErrors.defaultAdd"],
|
||||
[new RegExp("^pbs_encrypt_mode='existing' but no keyfile is installed at ([\\s\\S]+) — import one first with POST /api/host-backups/pbs-encryption/import$"), "backup.apiErrors.keyImport"],
|
||||
[new RegExp("^PVE job storage mismatch \\(job uses ([\\s\\S]+), payload says ([\\s\\S]+)\\)$"), "backup.apiErrors.storageMismatch"],
|
||||
]
|
||||
|
||||
export function hostBackupErrorText(problem: unknown, t: Translate): string {
|
||||
const message = typeof problem === "string" ? problem
|
||||
: problem && typeof problem === "object" && "message" in problem && typeof problem.message === "string"
|
||||
? problem.message : ""
|
||||
if (!message) return t("backup.apiErrors.requestFailed")
|
||||
const key = fixed.get(message)
|
||||
if (key) return t(key)
|
||||
// Keep unusually large tool output verbatim, without running pattern matching over it.
|
||||
if (message.length > 16384) return message
|
||||
for (const [pattern, patternKey] of parameterized) {
|
||||
const match = pattern.exec(message)
|
||||
if (match) return t(patternKey, { value: match[1], other: match[2] ?? "" })
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const ERROR_KEYS: Record<string, string> = {
|
||||
monitor_link_address_required: "addressRequired",
|
||||
monitor_link_invalid_address: "invalidAddress",
|
||||
monitor_link_address_credentials: "addressCredentials",
|
||||
monitor_link_invalid_port: "invalidPort",
|
||||
monitor_link_invalid_request: "invalidRequest",
|
||||
monitor_link_duplicate: "duplicate",
|
||||
monitor_link_not_found: "notFound",
|
||||
monitor_link_detected_remove: "detectedRemove",
|
||||
}
|
||||
|
||||
// fetchApi preserves the structured response on Error.body. Never expose raw
|
||||
// server/network messages or treat arbitrary error codes as translation keys.
|
||||
export function monitorLinkError(error: unknown, t: Translate): string {
|
||||
const fallback = () => t("monitorLinks.saveFailed")
|
||||
if (!error || typeof error !== "object" || !("body" in error)) return fallback()
|
||||
const body = error.body
|
||||
if (!body || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return fallback()
|
||||
if (body.code === "monitor_link_limit") {
|
||||
const params = "params" in body ? body.params : null
|
||||
const count = params && typeof params === "object" && "count" in params ? params.count : null
|
||||
return typeof count === "number" && Number.isSafeInteger(count) && count > 0
|
||||
? t("monitorLinks.errors.limit", { count }) : fallback()
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code)
|
||||
? t(`monitorLinks.errors.${ERROR_KEYS[body.code]}`) : fallback()
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const ERROR_KEYS: Record<string, string> = {
|
||||
pbs_config_read: "pbs.errors.configRead", pbs_invalid_config: "pbs.errors.invalidConfig",
|
||||
pbs_invalid_field: "pbs.errors.invalidField", pbs_invalid_url: "pbs.errors.invalidUrl",
|
||||
pbs_name_required: "pbs.errors.nameRequired", pbs_token_id_format: "pbs.errors.tokenIdFormat",
|
||||
pbs_token_secret: "pbs.errors.tokenSecret", pbs_invalid_token_id: "pbs.errors.invalidTokenId",
|
||||
pbs_invalid_fingerprint: "pbs.errors.invalidFingerprint", pbs_fingerprint_mismatch: "pbs.errors.fingerprintMismatch",
|
||||
pbs_token_rejected: "pbs.errors.tokenRejected", pbs_invalid_response: "pbs.errors.invalidResponse",
|
||||
pbs_invalid_json: "pbs.errors.invalidJson", pbs_connect_failed: "pbs.errors.connectFailed",
|
||||
pbs_invalid_id: "pbs.errors.invalidId", pbs_invalid_datastores: "pbs.errors.invalidDatastores",
|
||||
pbs_username: "pbs.errors.username", pbs_password: "pbs.errors.password",
|
||||
pbs_login_rejected: "pbs.errors.loginRejected", pbs_invalid_login: "pbs.errors.invalidLogin",
|
||||
pbs_token_create_denied: "pbs.errors.tokenCreateDenied", pbs_invalid_token: "pbs.errors.invalidToken",
|
||||
pbs_audit_role_denied: "pbs.errors.auditRoleDenied", pbs_not_found: "pbs.errors.notFound",
|
||||
pbs_read_failed: "pbs.errors.readFailed", pbs_invalid_list: "pbs.errors.invalidList",
|
||||
pbs_local_unavailable: "pbs.errors.localUnavailable", pbs_auth_required: "pbs.errors.authRequired",
|
||||
pbs_save_failed: "pbs.saveFailed", pbs_remove_failed: "pbs.removeFailed",
|
||||
pbs_saved_refresh_failed: "pbs.savedRefreshFailed", pbs_removed_refresh_failed: "pbs.removedRefreshFailed",
|
||||
pbs_datastore_offline: "pbs.errors.datastoreOffline", pbs_datastore_unavailable: "pbs.errors.datastoreUnavailable",
|
||||
}
|
||||
|
||||
// Accept structured codes, never arbitrary error text as a translation key.
|
||||
function pbsErrorTranslation(error: unknown): { key: string; params?: { count: number } } | null {
|
||||
const body = error && typeof error === "object" && "body" in error ? error.body : error
|
||||
if (!body || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return null
|
||||
if (body.code === "pbs_connection_limit") {
|
||||
const params = "params" in body ? body.params : null
|
||||
const count = params && typeof params === "object" && "count" in params ? params.count : null
|
||||
return typeof count === "number" && Number.isSafeInteger(count) && count > 0
|
||||
? { key: "pbs.errors.connectionLimit", params: { count } } : null
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code) ? { key: ERROR_KEYS[body.code] } : null
|
||||
}
|
||||
|
||||
export function pbsIsKnownError(error: unknown): boolean {
|
||||
return pbsErrorTranslation(error) !== null
|
||||
}
|
||||
|
||||
export function pbsErrorText(error: unknown, t: Translate, fallback = "pbs.loadFailed"): string {
|
||||
const translated = pbsErrorTranslation(error)
|
||||
return translated ? t(translated.key, translated.params) : t(fallback)
|
||||
}
|
||||
|
||||
export function pbsDiagnostic(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== "object") return undefined
|
||||
const body = "body" in error ? error.body : null
|
||||
if (body && typeof body === "object" && "error" in body && typeof body.error === "string") return body.error
|
||||
return "message" in error && typeof error.message === "string" ? error.message : undefined
|
||||
}
|
||||
|
||||
const TASK_TYPES: Record<string, string> = {
|
||||
backup: "backup", reader: "restore", verify: "verify", verificationjob: "verify",
|
||||
verify_group: "verifyGroup", verify_snapshot: "verifySnapshot", prune: "prune", prunejob: "prune",
|
||||
garbage_collection: "garbageCollection", sync: "sync", syncjob: "sync", aptupdate: "updateCheck",
|
||||
termproxy: "terminal", dircreate: "createDatastore", "create-datastore": "createDatastore",
|
||||
}
|
||||
export function pbsTaskType(type: string | undefined, t: Translate): string {
|
||||
return type && Object.prototype.hasOwnProperty.call(TASK_TYPES, type)
|
||||
? t(`pbs.taskTypes.${TASK_TYPES[type]}`) : t("pbs.taskTypes.other", { type: type || "—" })
|
||||
}
|
||||
|
||||
export function pbsResultText(status: string | undefined, t: Translate): string {
|
||||
if (!status || status === "unknown") return t("pbs.states.unknown")
|
||||
if (status.toLowerCase() === "ok") return t("pbs.states.ok")
|
||||
const warnings = /^WARNINGS:\s*(\d+)\s*$/i.exec(status)
|
||||
if (warnings && Number.isSafeInteger(Number(warnings[1]))) return t("pbs.states.warnings", { count: Number(warnings[1]) })
|
||||
return /^warn/i.test(status) ? t("pbs.states.warning") : t("pbs.states.error")
|
||||
}
|
||||
|
||||
export function pbsServiceState(state: string, t: Translate): string {
|
||||
const known = ["active", "inactive", "failed", "activating", "deactivating", "unknown"]
|
||||
return t(`pbs.serviceStates.${known.includes(state) ? state : "unknown"}`)
|
||||
}
|
||||
|
||||
export function formatPBSDuration(seconds: number, t: Translate): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "—"
|
||||
const unit = (key: string, count: number) => t(`pbs.durationUnits.${key}`, { count })
|
||||
if (seconds < 60) return unit("seconds", Math.round(seconds))
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${unit("minutes", minutes)} ${unit("seconds", Math.round(seconds % 60))}`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return hours < 24 ? `${unit("hours", hours)} ${unit("minutes", minutes % 60)}`
|
||||
: `${unit("days", Math.floor(hours / 24))} ${unit("hours", hours % 24)}`
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
"signInWith": "Sign in with",
|
||||
"methodPassword": "User and password",
|
||||
"methodToken": "API token",
|
||||
"username": "User (user@realm)",
|
||||
"username": "User",
|
||||
"password": "Password",
|
||||
"passwordHelp": "The password is used once to create a read-only API token (Audit role) for this Monitor. Only that token is saved, never the password. Enable Monitor authentication first.",
|
||||
"cancel": "Cancel",
|
||||
@@ -27,10 +27,15 @@
|
||||
"editConnection": "Connection to edit",
|
||||
"add": "Add a new connection",
|
||||
"name": "Name",
|
||||
"url": "HTTPS URL (include :8007 for the native PBS port)",
|
||||
"token_id": "API token ID (user@realm!token)",
|
||||
"token_secret": "API token secret (leave empty to keep the stored secret)",
|
||||
"fingerprint": "SHA256 certificate fingerprint (optional)",
|
||||
"url": "HTTPS address",
|
||||
"urlHelp": "Include :8007 for the default PBS port, for example https://pbs.example:8007.",
|
||||
"usernameHelp": "Use user@realm, for example root@pam.",
|
||||
"tokenIdHelp": "Use user@realm!token, for example monitor@pbs!proxmenux.",
|
||||
"tokenSecretHelp": "Enter the secret of this API token.",
|
||||
"tokenSecretKeepHelp": "Leave empty to keep the saved token secret.",
|
||||
"token_id": "API token ID",
|
||||
"token_secret": "API token secret",
|
||||
"fingerprint": "SHA256 fingerprint (optional)",
|
||||
"tlsHelp": "Leave the fingerprint empty for normal CA and hostname verification. For a self-signed certificate, copy its SHA256 fingerprint from the PBS console or another trusted channel. The pin is checked before sending credentials.",
|
||||
"saved": "PBS connection verified and saved.",
|
||||
"saveFailed": "Could not save the PBS connection.",
|
||||
@@ -72,7 +77,55 @@
|
||||
"estimatedFull": "Estimated full",
|
||||
"estimatedFullUnknown": "Not enough data yet",
|
||||
"duration": "Duration",
|
||||
"noJobs": "No sync, verification or prune schedules are configured."
|
||||
"noJobs": "No sync, verification or prune schedules are configured.",
|
||||
"removeFailed": "Could not remove the PBS connection.",
|
||||
"savedRefreshFailed": "The PBS connection was saved, but the connection list could not be refreshed. Reload the page before trying another change.",
|
||||
"removedRefreshFailed": "The PBS connection was removed, but the connection list could not be refreshed. Reload the page.",
|
||||
"technicalDetails": "Technical details",
|
||||
"datastoreType": "Datastore",
|
||||
"errors": {
|
||||
"configRead": "Could not read the saved PBS connections. Check the configuration file and permissions.",
|
||||
"invalidConfig": "The PBS connection settings are not valid. Check the form and try again.",
|
||||
"invalidField": "A connection field is not valid or is too long.",
|
||||
"invalidUrl": "Use an HTTPS PBS address without embedded credentials, query parameters or a fragment.",
|
||||
"nameRequired": "Enter a server name of at most 128 characters.",
|
||||
"tokenIdFormat": "Enter the PBS API token ID in user@realm!token format.",
|
||||
"tokenSecret": "Enter a valid API token secret without spaces or control characters.",
|
||||
"invalidTokenId": "The API token ID must contain only ASCII characters.",
|
||||
"invalidFingerprint": "Enter the certificate's SHA256 fingerprint: 64 hexadecimal characters, optionally separated by colons.",
|
||||
"fingerprintMismatch": "The PBS certificate does not match the fingerprint. No credentials were sent.",
|
||||
"tokenRejected": "PBS rejected the API token or its permissions. Check the ACLs of both the user and the token.",
|
||||
"invalidResponse": "PBS returned an unexpected response. Redirects are not followed.",
|
||||
"invalidJson": "PBS returned an invalid JSON document.",
|
||||
"connectFailed": "Could not connect to PBS over verified HTTPS. Check the address, certificate and service.",
|
||||
"invalidId": "The PBS connection ID is not valid. Reload the connection list.",
|
||||
"invalidDatastores": "PBS returned an invalid datastore list.",
|
||||
"connectionLimit": "At most {count} PBS connections may be configured.",
|
||||
"username": "Enter the PBS user in user@realm format, for example root@pam.",
|
||||
"password": "Enter the PBS user's password.",
|
||||
"loginRejected": "PBS rejected the username or password.",
|
||||
"invalidLogin": "PBS returned an invalid sign-in response.",
|
||||
"tokenCreateDenied": "This PBS user cannot create API tokens.",
|
||||
"invalidToken": "PBS returned an invalid API token.",
|
||||
"auditRoleDenied": "This PBS user cannot grant the Audit role. Sign in as root@pam or another administrator.",
|
||||
"notFound": "The PBS connection was not found. Reload the connection list.",
|
||||
"readFailed": "Could not read PBS data. Check its service and permissions.",
|
||||
"invalidList": "PBS returned an invalid data list.",
|
||||
"localUnavailable": "PBS is not available on this host.",
|
||||
"authRequired": "Enable local authentication in Monitor before managing remote PBS connections.",
|
||||
"datastoreOffline": "The datastore is offline, in maintenance mode or its backing device is not mounted.",
|
||||
"datastoreUnavailable": "The datastore filesystem is not available."
|
||||
},
|
||||
"taskTypes": {
|
||||
"backup": "Backup", "restore": "Restore", "verify": "Verification",
|
||||
"verifyGroup": "Backup group verification", "verifySnapshot": "Snapshot verification",
|
||||
"prune": "Prune", "garbageCollection": "Garbage collection", "sync": "Synchronization",
|
||||
"updateCheck": "Check for updates", "terminal": "Terminal", "createDatastore": "Create datastore",
|
||||
"other": "Other task ({type})"
|
||||
},
|
||||
"states": { "ok": "OK", "unknown": "Unknown", "warnings": "Warnings: {count}", "warning": "Warning", "error": "Error" },
|
||||
"serviceStates": { "active": "Active", "inactive": "Inactive", "failed": "Failed", "activating": "Starting", "deactivating": "Stopping", "unknown": "Unknown" },
|
||||
"durationUnits": { "seconds": "{count} s", "minutes": "{count} min", "hours": "{count} h", "days": "{count} d" }
|
||||
},
|
||||
"app": {
|
||||
"title": "ProxMenux Monitor",
|
||||
@@ -4315,6 +4368,124 @@
|
||||
"unrecoverable": "Without the key, encrypted backups cannot be restored.",
|
||||
"usesSharedKeyfileAt": "Uses shared keyfile at"
|
||||
},
|
||||
"apiErrors": {
|
||||
"requestFailed": "The request failed.",
|
||||
"absolutePath": "path must be absolute",
|
||||
"pathConfigured": "this path is already configured",
|
||||
"pathRequired": "path is required",
|
||||
"pathQuery": "path query parameter is required",
|
||||
"pathNotFound": "path not found",
|
||||
"invalidJobId": "invalid job id",
|
||||
"jobNotFound": "job not found",
|
||||
"runnerMissing": "runner script not installed",
|
||||
"noRestore": "no restore state to dismiss",
|
||||
"invalidFile": "invalid file name",
|
||||
"notFound": "not found",
|
||||
"logOutside": "log path outside allowed directory",
|
||||
"targetsList": "targets must be a list",
|
||||
"emptyExpression": "empty expression",
|
||||
"invalidId": "invalid id (use letters, digits, _ or -)",
|
||||
"backend": "backend must be pbs, local or borg",
|
||||
"remoteBackend": "backend must be pbs or borg",
|
||||
"remoteBackendQuoted": "backend must be 'pbs' or 'borg'",
|
||||
"profileMode": "profile_mode must be default or custom",
|
||||
"borgAttached": "borg cannot be attached (PVE vzdump only writes to pbs/local storages)",
|
||||
"borgAttachedLong": "borg is not a valid backend for attached jobs (PVE vzdump only writes to pbs/local storages)",
|
||||
"parentRequired": "pve_parent_job_id and pve_storage are required for attached",
|
||||
"absolutePaths": "paths must be a list of absolute paths",
|
||||
"customPath": "custom profile needs at least one path",
|
||||
"defaultPaths": "could not resolve default profile paths",
|
||||
"retentionObject": "retention must be an object",
|
||||
"pbsRepository": "pbs_repository is required",
|
||||
"pbsEncryption": "pbs_encrypt_mode must be 'none', 'new' or 'existing'",
|
||||
"pbsKeyFailed": "failed to create PBS encryption keyfile",
|
||||
"pbsClientMissing": "failed to create PBS encryption keyfile (is proxmox-backup-client installed?)",
|
||||
"borgRepository": "borg_repo is required",
|
||||
"borgEncryption": "borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated",
|
||||
"encryption": "encrypt_mode must be one of: none, repokey, keyfile, authenticated",
|
||||
"borgPassphrase": "borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)",
|
||||
"passphrase": "passphrase is required when encryption is enabled",
|
||||
"password": "password is required",
|
||||
"deviceConfirmation": "confirm_device must match device exactly (typo / safety check)",
|
||||
"invalidDevice": "invalid device path",
|
||||
"absoluteMountpoint": "absolute mountpoint required",
|
||||
"archiveLayout": "archive layout not recognized — no rootfs/ found",
|
||||
"archiveMissing": "archive not found",
|
||||
"archiveNotFile": "archive path is not a regular file",
|
||||
"invalidName": "invalid name",
|
||||
"invalidStaging": "invalid staging_path",
|
||||
"jobRename": "job name cannot be changed (delete + recreate to rename)",
|
||||
"absoluteKey": "key_path must be absolute",
|
||||
"mode": "mode must be \"local\" or \"ssh\"",
|
||||
"nameRequired": "name is required (letters, digits, _ or -)",
|
||||
"noLog": "no log available for this job yet",
|
||||
"noPbsConfig": "no manual PBS configs file",
|
||||
"outputMissing": "output file missing",
|
||||
"parserJson": "parser output was not valid JSON",
|
||||
"restoreJson": "run_restore output was not valid JSON",
|
||||
"repoRequired": "repo is required",
|
||||
"repoSnapshot": "repo_name and snapshot are required",
|
||||
"borgRepoSnapshot": "repo_name and snapshot are required for borg",
|
||||
"pbsRepoSnapshot": "repo_name and snapshot are required for pbs",
|
||||
"repoName": "repo_name is required",
|
||||
"restoreMissing": "restore tooling not installed on this host",
|
||||
"serverDatastore": "server and datastore are required",
|
||||
"snapshotRequired": "snapshot is required",
|
||||
"source": "source must be pbs, borg, or local",
|
||||
"sshPort": "ssh_port must be an integer between 1 and 65535",
|
||||
"sshRequired": "ssh_user, ssh_host and ssh_remote_path are required for ssh mode",
|
||||
"repositoryPath": "target has no repository path",
|
||||
"taskExpired": "task not found or expired",
|
||||
"invalidExpression": "invalid expression",
|
||||
"noReport": "no report emitted",
|
||||
"parseManifestFailed": "parse_manifest exited non-zero",
|
||||
"jobExists": "job already exists: {value}",
|
||||
"pathMissing": "path does not exist on this host: {value}",
|
||||
"deviceMissing": "device does not exist: {value}",
|
||||
"mountpoint": "not a mountpoint: {value}",
|
||||
"localArchiveMissing": "local archive not found: {value}",
|
||||
"borgDestination": "Borg destination '{value}' not found",
|
||||
"pbsDestination": "PBS destination '{value}' not found",
|
||||
"pbsPassword": "no password available for PBS repo '{value}'",
|
||||
"borgRepoMissing": "Borg repo \"{value}\" not configured",
|
||||
"pbsRepoMissing": "PBS repo \"{value}\" not configured",
|
||||
"borgTargetMissing": "Borg target not found: {value}",
|
||||
"pbsTargetMissing": "PBS repo not found: {value}",
|
||||
"pveJobMissing": "PVE vzdump job not found: {value}",
|
||||
"statePersist": "cannot persist state: {value}",
|
||||
"sshDirectory": "cannot prepare ssh dir: {value}",
|
||||
"logRead": "cannot read log: {value}",
|
||||
"envUpdate": "could not update .env: {value}",
|
||||
"pbsConfigWrite": "failed to persist PBS config: {value}",
|
||||
"persist": "failed to persist: {value}",
|
||||
"archiveRemove": "failed to remove archive: {value}",
|
||||
"start": "failed to start: {value}",
|
||||
"borgTargetsUpdate": "failed to update borg-targets.txt: {value}",
|
||||
"pbsTargetsUpdate": "failed to update pbs-manual-configs.txt: {value}",
|
||||
"unitsUpdate": "failed to update systemd units: {value}",
|
||||
"borgTargetsWrite": "failed to write borg-targets.txt: {value}",
|
||||
"jobWrite": "failed to write job files: {value}",
|
||||
"localTargetWrite": "failed to write local-target.conf: {value}",
|
||||
"unitsWrite": "failed to write systemd units: {value}",
|
||||
"format": "format failed: {value}",
|
||||
"calendar": "invalid on_calendar: {value}",
|
||||
"mount": "mount failed: {value}",
|
||||
"parserInvocation": "parser invocation failed: {value}",
|
||||
"preflightInvocation": "preflight invocation failed: {value}",
|
||||
"publicKeyMissing": "public key not found after generation: {value}",
|
||||
"retentionInteger": "retention.{value} must be an integer",
|
||||
"retentionNegative": "retention.{value} must be non-negative",
|
||||
"keygen": "ssh-keygen failed: {value}",
|
||||
"systemctl": "systemctl failed: {value}",
|
||||
"unmount": "umount failed: {value}",
|
||||
"taskIncomplete": "task is in state \"{value}\", not completed",
|
||||
"unknownKind": "unknown kind: {value}",
|
||||
"unknownMode": "unknown mode \"{value}\"",
|
||||
"defaultRemove": "{value} is the built-in default and cannot be removed",
|
||||
"defaultAdd": "{value} is the built-in default; no need to add it",
|
||||
"keyImport": "No PBS keyfile is installed at {value}. Import the keyfile first.",
|
||||
"storageMismatch": "PVE job storage mismatch (job uses {value}, payload says {other})"
|
||||
},
|
||||
"errors": {
|
||||
"backendNoStagingPath": "Backend did not return a staging path.",
|
||||
"deleteFailed": "Delete failed: {error}",
|
||||
@@ -6616,6 +6787,17 @@
|
||||
"addSelected": "Add selected",
|
||||
"pbsWebInterface": "PBS web interface",
|
||||
"changeAddress": "Change address",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"errors": {
|
||||
"addressRequired": "Enter the address of the Monitor.",
|
||||
"invalidAddress": "The address must start with http:// or https:// and name a host.",
|
||||
"addressCredentials": "The address must not contain a user or password.",
|
||||
"invalidPort": "The port of the address is not valid.",
|
||||
"invalidRequest": "The request is not valid. Reload the page and try again.",
|
||||
"limit": "At most {count} nodes can be added.",
|
||||
"duplicate": "This address is already in the list.",
|
||||
"notFound": "The node was not found. Reload the list and try again.",
|
||||
"detectedRemove": "Only nodes added by hand can be removed. Detected nodes can be hidden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"notAvailable": "Nedostupné",
|
||||
"serverOffline": "Server je offline",
|
||||
"supportProject": "Podporte projekt alebo prispejte vlastnými úpravami",
|
||||
"descriptionPbs": "Proxmox Backup Server Dashboard"
|
||||
"descriptionPbs": "Prehľad systému Proxmox Backup Server"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "Nedostupné",
|
||||
@@ -4178,7 +4178,7 @@
|
||||
"unmountTitle": "Odpojiť tento cieľ",
|
||||
"whereIsBorgRepo": "Kde je Borg repozitár?",
|
||||
"backupsKeptMessage": "Existujúce zálohy sa neodstránia.",
|
||||
"localAttachHelpMessage": "Najprv pripojte jednotky USB.Ak chcete použiť cestu k úložisku PVE + /dump, nechajte pole prázdne (záložný: /var/lib/vz/dump).",
|
||||
"localAttachHelpMessage": "Najprv pripoj USB disk. Ak pole necháš prázdne, použije sa cesta úložiska PVE doplnená o /dump; náhradná cesta je /var/lib/vz/dump.",
|
||||
"sshAuthorizedKeyHelp": "Pred pridaním do ~/.ssh/authorized_keys pre {user} na vzdialenom hostiteľovi skontrolujte riadok nižšie.",
|
||||
"sshKeyHelpMessage": "Cesta súkromného kľúča na tomto hostiteľovi.Použite {action}, potom skontrolujte a pridajte zobrazený riadok verejného kľúča do ~/.ssh/authorized_keys vzdialeného používateľa.",
|
||||
"sshKeySetupHelp": "Vytvorí kľúč na zadanej lokálnej ceste, ak žiadny neexistuje;inak prečíta svoj verejný kľúč.Na vzdialenom hostiteľovi nie je nainštalovaný žiadny kľúč.",
|
||||
@@ -4222,6 +4222,124 @@
|
||||
"usesSharedKeyfileAt": "Používa spoločný súbor s kľúčom v",
|
||||
"pbsHelp": "Monitor používa {keyfile} s {option} na šifrované zálohy a obnovy PBS.Súbor kľúča je uložený v režime {mode}.Uschovajte si bezpečnú kópiu mimo tohto hostiteľa."
|
||||
},
|
||||
"apiErrors": {
|
||||
"requestFailed": "Požiadavka zlyhala.",
|
||||
"absolutePath": "Cesta musí začínať znakom /.",
|
||||
"pathConfigured": "Táto cesta už je nastavená.",
|
||||
"pathRequired": "Zadaj cestu.",
|
||||
"pathQuery": "V požiadavke chýba cesta.",
|
||||
"pathNotFound": "Cesta sa nenašla.",
|
||||
"invalidJobId": "Neplatný identifikátor úlohy.",
|
||||
"jobNotFound": "Úloha sa nenašla.",
|
||||
"runnerMissing": "Na serveri chýba skript na spustenie zálohy.",
|
||||
"noRestore": "Nie je tu žiadny záznam obnovy na zatvorenie.",
|
||||
"invalidFile": "Neplatný názov súboru.",
|
||||
"notFound": "Požadovaný záznam sa nenašiel.",
|
||||
"logOutside": "Cesta k záznamu je mimo povoleného priečinka.",
|
||||
"targetsList": "Ciele musia byť zadané ako zoznam.",
|
||||
"emptyExpression": "Zadaj časový plán.",
|
||||
"invalidId": "V identifikátore použi iba písmená, číslice, _ alebo -.",
|
||||
"backend": "Vyber cieľ PBS, miestny archív alebo Borg.",
|
||||
"remoteBackend": "Vyber cieľ PBS alebo Borg.",
|
||||
"remoteBackendQuoted": "Vyber cieľ PBS alebo Borg.",
|
||||
"profileMode": "Vyber predvolený alebo vlastný profil.",
|
||||
"borgAttached": "Borg sa nedá pripojiť k úlohe PVE; vzdump zapisuje iba na PBS alebo miestne úložisko.",
|
||||
"borgAttachedLong": "Borg sa nedá pripojiť k úlohe PVE; vzdump zapisuje iba na PBS alebo miestne úložisko.",
|
||||
"parentRequired": "Vyber nadradenú úlohu PVE aj jej úložisko.",
|
||||
"absolutePaths": "Zadaj zoznam ciest, ktoré začínajú znakom /.",
|
||||
"customPath": "Do vlastného profilu pridaj aspoň jednu cestu.",
|
||||
"defaultPaths": "Cesty predvoleného profilu sa nepodarilo zistiť.",
|
||||
"retentionObject": "Pravidlá uchovávania záloh majú neplatný formát.",
|
||||
"pbsRepository": "Zadaj repozitár PBS.",
|
||||
"pbsEncryption": "Vyber režim šifrovania PBS: bez šifrovania, nový kľúč alebo existujúci kľúč.",
|
||||
"pbsKeyFailed": "Súbor so šifrovacím kľúčom PBS sa nepodarilo vytvoriť.",
|
||||
"pbsClientMissing": "Súbor so šifrovacím kľúčom PBS sa nepodarilo vytvoriť. Skontroluj, či je nainštalovaný proxmox-backup-client.",
|
||||
"borgRepository": "Zadaj repozitár Borg.",
|
||||
"borgEncryption": "Vyber podporovaný režim šifrovania Borg: none, repokey, keyfile alebo authenticated.",
|
||||
"encryption": "Vyber podporovaný režim šifrovania: none, repokey, keyfile alebo authenticated.",
|
||||
"borgPassphrase": "Šifrovaný repozitár Borg potrebuje heslo. Ulož ho pri cieli alebo ho zadaj v požiadavke.",
|
||||
"passphrase": "Pri zapnutom šifrovaní zadaj heslo.",
|
||||
"password": "Zadaj heslo.",
|
||||
"deviceConfirmation": "Na potvrdenie zadaj presný názov zariadenia.",
|
||||
"invalidDevice": "Neplatná cesta k zariadeniu.",
|
||||
"absoluteMountpoint": "Prípojný bod musí byť cesta začínajúca znakom /.",
|
||||
"archiveLayout": "Archív nemá očakávanú štruktúru: chýba priečinok rootfs/.",
|
||||
"archiveMissing": "Archív sa nenašiel.",
|
||||
"archiveNotFile": "Cesta k archívu neoznačuje bežný súbor.",
|
||||
"invalidName": "Neplatný názov.",
|
||||
"invalidStaging": "Neplatná cesta k dočasným súborom.",
|
||||
"jobRename": "Názov existujúcej úlohy sa nedá zmeniť. Ak ju chceš premenovať, odstráň ju a vytvor novú.",
|
||||
"absoluteKey": "Cesta k súboru s kľúčom musí začínať znakom /.",
|
||||
"mode": "Vyber miestny cieľ alebo pripojenie cez SSH.",
|
||||
"nameRequired": "Zadaj názov; použi iba písmená, číslice, _ alebo -.",
|
||||
"noLog": "Pre túto úlohu zatiaľ nie je dostupný záznam.",
|
||||
"noPbsConfig": "Súbor s ručne nastavenými cieľmi PBS sa nenašiel.",
|
||||
"outputMissing": "Chýba výstupný súbor.",
|
||||
"parserJson": "Výstup spracovania archívu nemá platný formát JSON.",
|
||||
"restoreJson": "Výstup prípravy obnovy nemá platný formát JSON.",
|
||||
"repoRequired": "Zadaj repozitár.",
|
||||
"repoSnapshot": "Vyber repozitár aj zálohu.",
|
||||
"borgRepoSnapshot": "Pre Borg vyber repozitár aj zálohu.",
|
||||
"pbsRepoSnapshot": "Pre PBS vyber repozitár aj zálohu.",
|
||||
"repoName": "Zadaj názov repozitára.",
|
||||
"restoreMissing": "Na tomto serveri chýbajú nástroje na obnovu.",
|
||||
"serverDatastore": "Zadaj server aj dátové úložisko.",
|
||||
"snapshotRequired": "Vyber zálohu.",
|
||||
"source": "Vyber zdroj PBS, Borg alebo miestny archív.",
|
||||
"sshPort": "Port SSH musí byť celé číslo od 1 do 65535.",
|
||||
"sshRequired": "Pre SSH zadaj používateľa, hostiteľa a vzdialenú cestu.",
|
||||
"repositoryPath": "Cieľ nemá nastavenú cestu k repozitáru.",
|
||||
"taskExpired": "Úloha sa nenašla alebo už vypršala.",
|
||||
"invalidExpression": "Neplatný časový plán.",
|
||||
"noReport": "Kontrola nevrátila žiadnu správu.",
|
||||
"parseManifestFailed": "Spracovanie manifestu skončilo chybou.",
|
||||
"jobExists": "Úloha s názvom {value} už existuje.",
|
||||
"pathMissing": "Na tomto serveri sa nenašla cesta: {value}",
|
||||
"deviceMissing": "Zariadenie neexistuje: {value}",
|
||||
"mountpoint": "Táto cesta nie je prípojný bod: {value}",
|
||||
"localArchiveMissing": "Miestny archív sa nenašiel: {value}",
|
||||
"borgDestination": "Cieľ Borg „{value}“ sa nenašiel.",
|
||||
"pbsDestination": "Cieľ PBS „{value}“ sa nenašiel.",
|
||||
"pbsPassword": "Pre repozitár PBS „{value}“ nie je dostupné heslo.",
|
||||
"borgRepoMissing": "Repozitár Borg „{value}“ nie je nastavený.",
|
||||
"pbsRepoMissing": "Repozitár PBS „{value}“ nie je nastavený.",
|
||||
"borgTargetMissing": "Cieľ Borg sa nenašiel: {value}",
|
||||
"pbsTargetMissing": "Repozitár PBS sa nenašiel: {value}",
|
||||
"pveJobMissing": "Úloha zálohovania PVE sa nenašla: {value}",
|
||||
"statePersist": "Stav sa nepodarilo uložiť: {value}",
|
||||
"sshDirectory": "Priečinok SSH sa nepodarilo pripraviť: {value}",
|
||||
"logRead": "Záznam sa nepodarilo prečítať: {value}",
|
||||
"envUpdate": "Súbor .env sa nepodarilo aktualizovať: {value}",
|
||||
"pbsConfigWrite": "Nastavenia PBS sa nepodarilo uložiť: {value}",
|
||||
"persist": "Údaje sa nepodarilo uložiť: {value}",
|
||||
"archiveRemove": "Archív sa nepodarilo odstrániť: {value}",
|
||||
"start": "Úlohu sa nepodarilo spustiť: {value}",
|
||||
"borgTargetsUpdate": "Súbor borg-targets.txt sa nepodarilo aktualizovať: {value}",
|
||||
"pbsTargetsUpdate": "Súbor pbs-manual-configs.txt sa nepodarilo aktualizovať: {value}",
|
||||
"unitsUpdate": "Jednotky systemd sa nepodarilo aktualizovať: {value}",
|
||||
"borgTargetsWrite": "Súbor borg-targets.txt sa nepodarilo uložiť: {value}",
|
||||
"jobWrite": "Súbory úlohy sa nepodarilo uložiť: {value}",
|
||||
"localTargetWrite": "Súbor local-target.conf sa nepodarilo uložiť: {value}",
|
||||
"unitsWrite": "Jednotky systemd sa nepodarilo uložiť: {value}",
|
||||
"format": "Formátovanie zlyhalo: {value}",
|
||||
"calendar": "Neplatný časový plán: {value}",
|
||||
"mount": "Disk sa nepodarilo pripojiť: {value}",
|
||||
"parserInvocation": "Spracovanie archívu sa nepodarilo spustiť: {value}",
|
||||
"preflightInvocation": "Kontrolu pred obnovou sa nepodarilo spustiť: {value}",
|
||||
"publicKeyMissing": "Po vytvorení kľúča sa nenašiel verejný kľúč: {value}",
|
||||
"retentionInteger": "Pravidlo uchovávania „{value}“ musí byť celé číslo.",
|
||||
"retentionNegative": "Pravidlo uchovávania „{value}“ nesmie byť záporné.",
|
||||
"keygen": "Vytvorenie kľúča SSH zlyhalo: {value}",
|
||||
"systemctl": "Správa služby cez systemctl zlyhala: {value}",
|
||||
"unmount": "Disk sa nepodarilo odpojiť: {value}",
|
||||
"taskIncomplete": "Úloha ešte nie je dokončená. Jej stav: {value}",
|
||||
"unknownKind": "Neznámy typ: {value}",
|
||||
"unknownMode": "Neznámy režim: {value}",
|
||||
"defaultRemove": "Cesta {value} je predvolená a nedá sa odstrániť.",
|
||||
"defaultAdd": "Cesta {value} je predvolená; nemusíš ju pridávať.",
|
||||
"keyImport": "V {value} nie je uložený súbor s kľúčom PBS. Najprv ho importuj.",
|
||||
"storageMismatch": "Úložisko sa nezhoduje: úloha PVE používa {value}, požiadavka uvádza {other}."
|
||||
},
|
||||
"errors": {
|
||||
"backendNoStagingPath": "Cieľ nevrátil cestu k dočasným súborom.",
|
||||
"deleteFailed": "Mazanie zlyhalo: {error}",
|
||||
@@ -4807,7 +4925,7 @@
|
||||
"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.",
|
||||
"saved": "Pravidlá uchovávania záloh na úložisku boli uložené. Žiadne zálohy sa teraz neodstránili.",
|
||||
"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.",
|
||||
@@ -5641,7 +5759,7 @@
|
||||
"nextStep": {
|
||||
"missing": "Nastavte si držanie na týchto pracovných miestach, alebo na sklade, na ktorý píšu. Proxmox berie nastavenie úlohy ako prvý, potom úložisko je, potom uzol predvolený.",
|
||||
"notDeclared": "Tieto práce uchovávajú všetky kópie, ktoré robia. To je zámerná voľba pre niektoré destinácie, ale potvrďte, že cieľ má priestor na rast.",
|
||||
"onServer": "Tieto úlohy píšu na záložný server, ktorý ich prerezáva podľa vlastných pravidiel. Z tohto uzla sa nedá čítať to, čo drží, takže skontrolujte, či tam nie je."
|
||||
"onServer": "Tieto úlohy ukladajú zálohy na zálohovací server. Ten odstraňuje staré zálohy podľa vlastných pravidiel. Z tohto uzla sa nedá zistiť, ktoré zálohy uchováva; skontroluj ich priamo na zálohovacom serveri."
|
||||
},
|
||||
"explanation": "O tom, koľko výtlačkov sa uchováva a ako dlho sa uchováva. Bez nej sa cieľ naplní, kým neprestane prijímať nové kópie, a záloha, ktorú nemožno napísať, je tá, o ktorej sa dozviete v deň, keď ju potrebujete."
|
||||
},
|
||||
@@ -7299,35 +7417,40 @@
|
||||
"title": "Proxmox Backup Server",
|
||||
"server": "Server PBS",
|
||||
"chooseServer": "Vybrať server PBS",
|
||||
"local": "Miestny PBS",
|
||||
"local": "Lokálny PBS",
|
||||
"thisNode": "PBS na tomto uzle",
|
||||
"updatedAt": "Aktualizované o {time}",
|
||||
"servicesActive": "Služby sú aktívne",
|
||||
"detectedTitle": "PBS zistené v úložisku Proxmox",
|
||||
"detectedHelp": "Tieto Proxmox Backup Servery sú nastavené ako úložisko záloh. Pripojte každý z nich API tokenom len na čítanie, aby ste tu videli jeho datastory, úlohy a joby.",
|
||||
"detectedTitle": "PBS zistené v nastaveniach úložísk Proxmoxu",
|
||||
"detectedHelp": "Tieto servery Proxmox Backup Server sú nastavené ako úložiská záloh. Pripoj ich pomocou API tokenu iba na čítanie a zobrazia sa tu ich dátové úložiská, úlohy a plány údržby.",
|
||||
"connect": "Pripojiť",
|
||||
"tasksTab": "Úlohy",
|
||||
"signInWith": "Prihlásiť sa pomocou",
|
||||
"methodPassword": "Používateľ a heslo",
|
||||
"methodToken": "API token",
|
||||
"username": "Používateľ (pouzivatel@realm)",
|
||||
"username": "Používateľ",
|
||||
"password": "Heslo",
|
||||
"passwordHelp": "Heslo sa použije iba raz na vytvorenie API tokenu len na čítanie (rola Audit) pre tento Monitor. Uloží sa iba tento token, nikdy nie heslo. Najprv zapnite overovanie v Monitore.",
|
||||
"passwordHelp": "Heslo sa použije iba raz na vytvorenie API tokenu iba na čítanie s rolou Audit pre tento Monitor. Uloží sa iba token, heslo sa neukladá. Najprv zapni overovanie v Monitore.",
|
||||
"cancel": "Zrušiť",
|
||||
"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.",
|
||||
"noServer": "Pridaj pripojenie PBS a zobrazia sa jeho dátové úložiská, úlohy a plány údržby.",
|
||||
"permissionsHelp": "Pred uložením prihlasovacích údajov zapni overovanie v Monitore. Použi vyhradený API token PBS. Používateľ aj token musia mať v zozname prístupových oprávnení (ACL) rolu Audit na ceste / 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.",
|
||||
"url": "Adresa HTTPS",
|
||||
"urlHelp": "Štandardný port PBS je 8007, napríklad https://pbs.example:8007.",
|
||||
"usernameHelp": "Použi meno a overovaciu oblasť oddelené znakom @, napríklad root@pam.",
|
||||
"tokenIdHelp": "Použi formát meno@oblasť!token, napríklad monitor@pbs!proxmenux.",
|
||||
"tokenSecretHelp": "Zadaj tajný kľúč k tomuto API tokenu.",
|
||||
"tokenSecretKeepHelp": "Prázdne pole zachová uložený tajný kľúč tokenu.",
|
||||
"token_id": "ID API tokenu",
|
||||
"token_secret": "Tajný kľúč API tokenu",
|
||||
"fingerprint": "Odtlačok SHA256 (voliteľný)",
|
||||
"tlsHelp": "Pri bežnom overovaní certifikačnej autority a názvu hostiteľa nechaj odtlačok prázdny. Pri certifikáte s vlastným podpisom skopíruj odtlačok SHA256 z konzoly PBS alebo iného dôveryhodného zdroja. Kontroluje sa ešte 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…",
|
||||
@@ -7339,9 +7462,9 @@
|
||||
"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.",
|
||||
"noDatastores": "Nie sú dostupné žiadne dátové úložiská. Skontroluj ich nastavenia a oprávnenia používateľa aj tokenu.",
|
||||
"tasks": "Nedávne úlohy",
|
||||
"noTasks": "Neboli vrátené žiadne nedávne úlohy.",
|
||||
"noTasks": "PBS nevrátil žiadne nedávne úlohy.",
|
||||
"task": "Úloha",
|
||||
"started": "Spustené",
|
||||
"status": "Stav",
|
||||
@@ -7351,7 +7474,7 @@
|
||||
"schedule": "Plán",
|
||||
"sync": "Synchronizácia",
|
||||
"verify": "Overenie",
|
||||
"prune": "Prerezávanie",
|
||||
"prune": "Odstraňovanie starých záloh",
|
||||
"overview": "Prehľad",
|
||||
"logs": "Systémové záznamy",
|
||||
"terminal": "Terminál",
|
||||
@@ -7362,51 +7485,110 @@
|
||||
"memory": "Pamäť",
|
||||
"uptime": "Čas prevádzky",
|
||||
"capacityUnavailable": "Kapacita nie je k dispozícii.",
|
||||
"datastoresUnavailable": "{count} nie je k dispozícii",
|
||||
"datastoresUnavailable": "Nedostupné úložiská: {count}",
|
||||
"datastoresOnline": "Všetky dostupné",
|
||||
"moreDatastores": "+{count} ďalších dátových úložísk",
|
||||
"estimatedFull": "Odhadovaný plný",
|
||||
"estimatedFullUnknown": "Zatiaľ nie je dostatok údajov",
|
||||
"estimatedFull": "Odhadované zaplnenie",
|
||||
"estimatedFullUnknown": "Zatiaľ sa nedá odhadnúť",
|
||||
"duration": "Trvanie",
|
||||
"noJobs": "Nie sú nakonfigurované žiadne plány synchronizácie, overovania ani odstraňovania."
|
||||
"noJobs": "Nie sú nastavené žiadne plány synchronizácie, overovania ani odstraňovania starých záloh.",
|
||||
"removeFailed": "Nepodarilo sa odstrániť pripojenie PBS.",
|
||||
"savedRefreshFailed": "Pripojenie PBS sa uložilo, ale nepodarilo sa obnoviť zoznam pripojení. Pred ďalšou zmenou obnov stránku.",
|
||||
"removedRefreshFailed": "Pripojenie PBS sa odstránilo, ale nepodarilo sa obnoviť zoznam pripojení. Obnov stránku.",
|
||||
"technicalDetails": "Technické podrobnosti",
|
||||
"datastoreType": "Dátové úložisko",
|
||||
"errors": {
|
||||
"configRead": "Nepodarilo sa načítať uložené pripojenia PBS. Skontroluj konfiguračný súbor a oprávnenia.",
|
||||
"invalidConfig": "Nastavenia pripojenia PBS nie sú platné. Skontroluj formulár a skús to znova.",
|
||||
"invalidField": "Niektoré pole pripojenia nie je platné alebo je príliš dlhé.",
|
||||
"invalidUrl": "Použi adresu HTTPS bez vložených prihlasovacích údajov, parametrov za otáznikom a časti za znakom #.",
|
||||
"nameRequired": "Zadaj názov servera s najviac 128 znakmi.",
|
||||
"tokenIdFormat": "Zadaj ID API tokenu PBS vo formáte user@realm!token.",
|
||||
"tokenSecret": "Zadaj platný tajný kľúč API tokenu bez medzier a riadiacich znakov.",
|
||||
"invalidTokenId": "ID API tokenu môže obsahovať iba znaky ASCII.",
|
||||
"invalidFingerprint": "Zadaj odtlačok SHA256 certifikátu: 64 šestnástkových znakov, prípadne oddelených dvojbodkami.",
|
||||
"fingerprintMismatch": "Certifikát PBS sa nezhoduje s odtlačkom. Prihlasovacie údaje sa neodoslali.",
|
||||
"tokenRejected": "PBS odmietol API token alebo jeho oprávnenia. Skontroluj zoznam prístupových oprávnení (ACL) používateľa aj tokenu.",
|
||||
"invalidResponse": "PBS vrátil neočakávanú odpoveď. Presmerovania sa nenasledujú.",
|
||||
"invalidJson": "PBS vrátil neplatný dokument JSON.",
|
||||
"connectFailed": "Nepodarilo sa pripojiť k PBS cez overené HTTPS. Skontroluj adresu, certifikát a službu.",
|
||||
"invalidId": "ID pripojenia PBS nie je platné. Obnov zoznam pripojení.",
|
||||
"invalidDatastores": "PBS vrátil neplatný zoznam dátových úložísk.",
|
||||
"connectionLimit": "Môžeš nastaviť najviac {count} pripojení PBS.",
|
||||
"username": "Zadaj používateľa PBS vo formáte user@realm, napríklad root@pam.",
|
||||
"password": "Zadaj heslo používateľa PBS.",
|
||||
"loginRejected": "PBS odmietol používateľské meno alebo heslo.",
|
||||
"invalidLogin": "PBS vrátil neplatnú odpoveď na prihlásenie.",
|
||||
"tokenCreateDenied": "Tento používateľ PBS nemôže vytvárať API tokeny.",
|
||||
"invalidToken": "PBS vrátil neplatný API token.",
|
||||
"auditRoleDenied": "Tento používateľ PBS nemôže prideliť rolu Audit. Prihlás sa ako root@pam alebo iný správca.",
|
||||
"notFound": "Pripojenie PBS sa nenašlo. Obnov zoznam pripojení.",
|
||||
"readFailed": "Nepodarilo sa načítať údaje PBS. Skontroluj službu a oprávnenia.",
|
||||
"invalidList": "PBS vrátil neplatný zoznam údajov.",
|
||||
"localUnavailable": "PBS na tomto hostiteľovi nie je dostupný.",
|
||||
"authRequired": "Pred správou vzdialených pripojení PBS zapni lokálne overovanie v Monitore.",
|
||||
"datastoreOffline": "Dátové úložisko je odpojené, v režime údržby alebo jeho zariadenie nie je pripojené.",
|
||||
"datastoreUnavailable": "Súborový systém dátového úložiska nie je dostupný."
|
||||
},
|
||||
"taskTypes": {
|
||||
"backup": "Zálohovanie", "restore": "Obnova", "verify": "Overovanie",
|
||||
"verifyGroup": "Overovanie skupiny záloh", "verifySnapshot": "Overovanie snímky",
|
||||
"prune": "Odstraňovanie starých záloh", "garbageCollection": "Čistenie nepoužívaných dát", "sync": "Synchronizácia",
|
||||
"updateCheck": "Kontrola aktualizácií", "terminal": "Terminál", "createDatastore": "Vytvorenie dátového úložiska",
|
||||
"other": "Iná úloha ({type})"
|
||||
},
|
||||
"states": { "ok": "V poriadku", "unknown": "Neznámy", "warnings": "Upozornenia: {count}", "warning": "Upozornenie", "error": "Chyba" },
|
||||
"serviceStates": { "active": "Aktívna", "inactive": "Neaktívna", "failed": "Zlyhala", "activating": "Spúšťa sa", "deactivating": "Zastavuje sa", "unknown": "Neznámy stav" },
|
||||
"durationUnits": { "seconds": "{count} s", "minutes": "{count} min", "hours": "{count} h", "days": "{count} d" }
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "Cluster",
|
||||
"cluster": "Klaster",
|
||||
"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.",
|
||||
"empty": "Zatiaľ tu nie sú ďalšie uzly. Pridaj uzol a jeho Monitor otvoríš priamo odtiaľto.",
|
||||
"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.",
|
||||
"dialogDescription": "Uzly klastra a nastavené servery Proxmox Backup Server sa zisťujú automaticky. Do Monitora každého uzla sa prihlasuješ samostatne.",
|
||||
"addTitle": "Pridať uzol",
|
||||
"name": "Meno",
|
||||
"address": "Adresa monitora",
|
||||
"name": "Názov uzla",
|
||||
"address": "Adresa Monitora",
|
||||
"type": "Typ",
|
||||
"add": "Pridať",
|
||||
"save": "Uložiť",
|
||||
"remove": "Odstrániť",
|
||||
"visible": "Viditeľné",
|
||||
"visible": "Zobraziť",
|
||||
"autoAddress": "Automaticky",
|
||||
"detected": "Zistené",
|
||||
"added": "Pridané",
|
||||
"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.",
|
||||
"cameFromTitle": "Pridať uzol Monitora?",
|
||||
"cameFromBody": "Pridaj {name} medzi prepojené uzly tohto Monitora.",
|
||||
"notNow": "Teraz nie",
|
||||
"knownTitle": "Pridať prepojené uzly?",
|
||||
"knownBody": "{name} prepája tieto monitory. Vyberte tie, ktoré chcete pridať do tohto monitora.",
|
||||
"knownBody": "Uzol {name} má prepojené tieto uzly. Vyber tie, ktoré chceš pridať aj sem.",
|
||||
"addSelected": "Pridať vybrané",
|
||||
"pbsWebInterface": "Webové rozhranie PBS",
|
||||
"changeAddress": "Zmeniť adresu",
|
||||
"cancel": "Zrušiť"
|
||||
"cancel": "Zrušiť",
|
||||
"errors": {
|
||||
"addressRequired": "Zadaj adresu Monitora.",
|
||||
"invalidAddress": "Adresa musí začínať na http:// alebo https:// a obsahovať názov hostiteľa alebo IP adresu.",
|
||||
"addressCredentials": "Adresa nesmie obsahovať používateľské meno ani heslo.",
|
||||
"invalidPort": "Port v adrese nie je platný. Zadaj číslo od 1 do 65535.",
|
||||
"invalidRequest": "Požiadavka nie je platná. Obnov stránku a skús to znova.",
|
||||
"limit": "Môžeš pridať najviac {count} uzlov.",
|
||||
"duplicate": "Táto adresa už je v zozname.",
|
||||
"notFound": "Uzol sa nenašiel. Obnov zoznam a skús to znova.",
|
||||
"detectedRemove": "Odstrániť môžeš iba ručne pridané uzly. Automaticky zistené uzly môžeš skryť."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ from jwt_middleware import require_auth, require_admin_scope
|
||||
monitor_links_bp = Blueprint("monitor_links", __name__)
|
||||
|
||||
|
||||
def link_error(exc):
|
||||
payload = {"error": str(exc), "code": exc.code}
|
||||
if exc.params:
|
||||
payload["params"] = exc.params
|
||||
return jsonify(payload), 400
|
||||
|
||||
|
||||
@monitor_links_bp.after_request
|
||||
def no_cache(response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
@@ -27,9 +34,9 @@ def add_link():
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True, key=key)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return link_error(exc)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["PATCH"])
|
||||
@@ -40,9 +47,9 @@ def update_link(key):
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return link_error(exc)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["DELETE"])
|
||||
@@ -53,6 +60,6 @@ def delete_link(key):
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return link_error(exc)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
|
||||
|
||||
@@ -8,6 +8,10 @@ from jwt_middleware import require_auth, require_admin_scope
|
||||
pbs_bp = Blueprint("pbs", __name__)
|
||||
|
||||
|
||||
def pbs_error(exc, status):
|
||||
return jsonify(error=str(exc), **exc.problem()), status
|
||||
|
||||
|
||||
@pbs_bp.after_request
|
||||
def no_cache(response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
@@ -27,7 +31,7 @@ def servers():
|
||||
servers = pbs.public_servers()
|
||||
return jsonify(servers=servers, local_available=pbs.local_available(), detected=_detected(servers))
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 500
|
||||
return pbs_error(exc, 500)
|
||||
|
||||
|
||||
def _detected(servers):
|
||||
@@ -57,42 +61,42 @@ def _protected_configuration():
|
||||
@require_admin_scope
|
||||
def save_server():
|
||||
if not _protected_configuration():
|
||||
return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409
|
||||
return jsonify(error="Enable local authentication before saving remote PBS credentials.", code="pbs_auth_required"), 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
|
||||
return pbs_error(exc, 400)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save PBS connection."), 500
|
||||
return jsonify(error="Could not save PBS connection.", code="pbs_save_failed"), 500
|
||||
|
||||
|
||||
@pbs_bp.route("/api/pbs/servers/login", methods=["POST"])
|
||||
@require_admin_scope
|
||||
def connect_with_password():
|
||||
if not _protected_configuration():
|
||||
return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409
|
||||
return jsonify(error="Enable local authentication before saving remote PBS credentials.", code="pbs_auth_required"), 409
|
||||
try:
|
||||
identifier = pbs.connect_with_password(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
|
||||
return pbs_error(exc, 400)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save PBS connection."), 500
|
||||
return jsonify(error="Could not save PBS connection.", code="pbs_save_failed"), 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
|
||||
return jsonify(error="Enable local authentication before changing PBS connections.", code="pbs_auth_required"), 409
|
||||
try:
|
||||
pbs.delete_server(identifier)
|
||||
return jsonify(success=True)
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return pbs_error(exc, 400)
|
||||
except OSError:
|
||||
return jsonify(error="Could not remove PBS connection."), 500
|
||||
return jsonify(error="Could not remove PBS connection.", code="pbs_remove_failed"), 500
|
||||
|
||||
|
||||
@pbs_bp.route("/api/pbs/status")
|
||||
@@ -101,4 +105,4 @@ def status():
|
||||
try:
|
||||
return jsonify(pbs.get_status(request.args.get("server", "local")))
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 502
|
||||
return pbs_error(exc, 502)
|
||||
|
||||
@@ -39,7 +39,10 @@ _refresh_lock = threading.Lock()
|
||||
|
||||
|
||||
class LinkError(ValueError):
|
||||
pass
|
||||
def __init__(self, message, *, code="monitor_links_save_failed", params=None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.params = params
|
||||
|
||||
|
||||
def _load():
|
||||
@@ -63,19 +66,19 @@ def _save(data):
|
||||
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.")
|
||||
raise LinkError("Enter the address of the Monitor.", code="monitor_link_address_required")
|
||||
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.")
|
||||
raise LinkError("The address must start with http:// or https:// and name a host.", code="monitor_link_invalid_address")
|
||||
if parsed.username or parsed.password:
|
||||
raise LinkError("The address must not contain a user or password.")
|
||||
raise LinkError("The address must not contain a user or password.", code="monitor_link_address_credentials")
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise LinkError("The port of the address is not valid.") from exc
|
||||
raise LinkError("The port of the address is not valid.", code="monitor_link_invalid_port") from exc
|
||||
host = parsed.hostname
|
||||
if ":" in host:
|
||||
host = f"[{host}]"
|
||||
@@ -301,16 +304,16 @@ def start_prewarmer():
|
||||
|
||||
def add_link(payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise LinkError("Invalid request.")
|
||||
raise LinkError("Invalid request.", code="monitor_link_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.")
|
||||
raise LinkError(f"At most {MAX_MANUAL} nodes can be added.", code="monitor_link_limit", params={"count": MAX_MANUAL})
|
||||
if any(row.get("url") == url for row in data["manual"]):
|
||||
raise LinkError("This address is already in the list.")
|
||||
raise LinkError("This address is already in the list.", code="monitor_link_duplicate")
|
||||
identifier = secrets.token_hex(6)
|
||||
data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname,
|
||||
"url": url, "product": product})
|
||||
@@ -320,13 +323,13 @@ def add_link(payload):
|
||||
|
||||
def update_link(key, payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise LinkError("Invalid request.")
|
||||
raise LinkError("Invalid request.", code="monitor_link_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.")
|
||||
raise LinkError("Node not found.", code="monitor_link_not_found")
|
||||
if "name" in payload:
|
||||
row["name"] = str(payload["name"] or "").strip()[:64] or row["name"]
|
||||
if payload.get("url"):
|
||||
@@ -345,7 +348,7 @@ def update_link(key, payload):
|
||||
if not override:
|
||||
data["overrides"].pop(key, None)
|
||||
else:
|
||||
raise LinkError("Node not found.")
|
||||
raise LinkError("Node not found.", code="monitor_link_not_found")
|
||||
_save(data)
|
||||
|
||||
|
||||
@@ -354,6 +357,6 @@ def delete_link(key):
|
||||
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.")
|
||||
raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.", code="monitor_link_detected_remove")
|
||||
data["manual"] = remaining
|
||||
_save(data)
|
||||
|
||||
@@ -39,7 +39,13 @@ MAX_RESPONSE = 4 * 1024 * 1024
|
||||
|
||||
|
||||
class PBSError(ValueError):
|
||||
pass
|
||||
def __init__(self, message, *, code="pbs_unknown", params=None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.params = params
|
||||
|
||||
def problem(self):
|
||||
return {"code": self.code, **({"params": self.params} if self.params else {})}
|
||||
|
||||
|
||||
def get_platform():
|
||||
@@ -87,7 +93,7 @@ def _load_servers():
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except (OSError, ValueError) as exc:
|
||||
raise PBSError("Could not read the PBS server configuration.") from exc
|
||||
raise PBSError("Could not read the PBS server configuration.", code="pbs_config_read") from exc
|
||||
|
||||
|
||||
def _save_servers(servers):
|
||||
@@ -113,11 +119,11 @@ def public_servers():
|
||||
|
||||
def _validate_server(data, previous=None, fields=("name", "url", "token_id", "token_secret", "fingerprint")):
|
||||
if not isinstance(data, dict):
|
||||
raise PBSError("Invalid PBS server configuration.")
|
||||
raise PBSError("Invalid PBS server configuration.", code="pbs_invalid_config")
|
||||
previous = previous or {}
|
||||
row = {field: data.get(field, previous.get(field, "")) for field in fields}
|
||||
if any(not isinstance(value, str) or len(value) > 4096 for value in row.values()):
|
||||
raise PBSError("Invalid PBS connection field.")
|
||||
raise PBSError("Invalid PBS connection field.", code="pbs_invalid_field")
|
||||
row["name"] = row["name"].strip()
|
||||
row["url"] = row["url"].strip().rstrip("/")
|
||||
try:
|
||||
@@ -127,26 +133,26 @@ def _validate_server(data, previous=None, fields=("name", "url", "token_id", "to
|
||||
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
|
||||
raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.", code="pbs_invalid_url") from exc
|
||||
if not row["name"] or len(row["name"]) > 128:
|
||||
raise PBSError("A server name of at most 128 characters is required.")
|
||||
raise PBSError("A server name of at most 128 characters is required.", code="pbs_name_required")
|
||||
if "token_id" not in row:
|
||||
return _validated_fingerprint(row)
|
||||
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.")
|
||||
raise PBSError("Enter a PBS API token ID in user@realm!token format.", code="pbs_token_id_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.")
|
||||
raise PBSError("A valid API token secret is required.", code="pbs_token_secret")
|
||||
if not row["token_id"].isascii():
|
||||
raise PBSError("Invalid API token ID.")
|
||||
raise PBSError("Invalid API token ID.", code="pbs_invalid_token_id")
|
||||
return _validated_fingerprint(row)
|
||||
|
||||
|
||||
def _validated_fingerprint(row):
|
||||
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).")
|
||||
raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).", code="pbs_invalid_fingerprint")
|
||||
row["fingerprint"] = fingerprint
|
||||
return row
|
||||
|
||||
@@ -158,7 +164,8 @@ def _remote_get(server, endpoint):
|
||||
|
||||
|
||||
def _remote_request(server, method, endpoint, headers, form=None,
|
||||
rejected="PBS rejected the API token or its permissions. Check both the user and token ACLs."):
|
||||
rejected="PBS rejected the API token or its permissions. Check both the user and token ACLs.",
|
||||
*, rejected_code="pbs_token_rejected"):
|
||||
"""One request to PBS; credentials leave only after the certificate is checked."""
|
||||
parts = urlsplit(server["url"])
|
||||
fingerprint = server.get("fingerprint")
|
||||
@@ -175,7 +182,7 @@ def _remote_request(server, method, endpoint, headers, form=None,
|
||||
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.")
|
||||
raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.", code="pbs_fingerprint_mismatch")
|
||||
path = parts.path.rstrip("/") + "/api2/json" + endpoint
|
||||
body = urlencode(form) if form is not None else None
|
||||
request_headers = {"Accept": "application/json", **headers}
|
||||
@@ -185,17 +192,17 @@ def _remote_request(server, method, endpoint, headers, form=None,
|
||||
response = connection.getresponse()
|
||||
content = response.read(MAX_RESPONSE + 1)
|
||||
if response.status in (401, 403):
|
||||
raise PBSError(rejected)
|
||||
raise PBSError(rejected, code=rejected_code)
|
||||
if response.status != 200 or len(content) > MAX_RESPONSE:
|
||||
raise PBSError("PBS returned an invalid response. Redirects are not followed.")
|
||||
raise PBSError("PBS returned an invalid response. Redirects are not followed.", code="pbs_invalid_response")
|
||||
document = json.loads(content)
|
||||
if not isinstance(document, dict) or "data" not in document:
|
||||
raise PBSError("PBS returned an invalid JSON document.")
|
||||
raise PBSError("PBS returned an invalid JSON document.", code="pbs_invalid_json")
|
||||
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
|
||||
raise PBSError("Could not connect to PBS over verified HTTPS. Check the address, certificate and service.", code="pbs_connect_failed") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@@ -203,19 +210,19 @@ def _remote_request(server, method, endpoint, headers, form=None,
|
||||
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.")
|
||||
raise PBSError("Invalid PBS server ID.", code="pbs_invalid_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.")
|
||||
raise PBSError("PBS returned an invalid datastore list.", code="pbs_invalid_datastores")
|
||||
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.")
|
||||
raise PBSError("At most 32 PBS connections may be configured.", code="pbs_connection_limit", params={"count": 32})
|
||||
servers = [existing for existing in servers if existing["id"] != row["id"]]
|
||||
_save_servers([*servers, row])
|
||||
return row["id"]
|
||||
@@ -228,27 +235,27 @@ def connect_with_password(data):
|
||||
username = data.get("username") if isinstance(data, dict) else None
|
||||
password = data.get("password") if isinstance(data, dict) else None
|
||||
if not isinstance(username, str) or not re.fullmatch(r"[^\s:!@]+@[^\s:!@]+", username.strip()):
|
||||
raise PBSError("Enter the PBS user as user@realm, for example root@pam.")
|
||||
raise PBSError("Enter the PBS user as user@realm, for example root@pam.", code="pbs_username")
|
||||
if not isinstance(password, str) or not password or len(password) > 4096:
|
||||
raise PBSError("Enter the password of the PBS user.")
|
||||
raise PBSError("Enter the password of the PBS user.", code="pbs_password")
|
||||
ticket = _remote_request(endpoint, "POST", "/access/ticket", {},
|
||||
{"username": username.strip(), "password": password},
|
||||
rejected="PBS rejected the user name or password.")
|
||||
rejected="PBS rejected the user name or password.", rejected_code="pbs_login_rejected")
|
||||
if not isinstance(ticket, dict) or not ticket.get("ticket") or not ticket.get("CSRFPreventionToken"):
|
||||
raise PBSError("PBS returned an invalid sign-in response.")
|
||||
raise PBSError("PBS returned an invalid sign-in response.", code="pbs_invalid_login")
|
||||
session = {"Cookie": "PBSAuthCookie=" + quote(ticket["ticket"], safe=""),
|
||||
"CSRFPreventionToken": ticket["CSRFPreventionToken"]}
|
||||
user = str(ticket.get("username") or username.strip())
|
||||
name = "proxmenux-" + secrets.token_hex(4)
|
||||
token_path = f"/access/users/{quote(user, safe='')}/token/{name}"
|
||||
created = _remote_request(endpoint, "POST", token_path, session, {"comment": "ProxMenux Monitor, read-only"},
|
||||
rejected="This PBS user cannot create API tokens.")
|
||||
rejected="This PBS user cannot create API tokens.", rejected_code="pbs_token_create_denied")
|
||||
if not isinstance(created, dict) or not created.get("tokenid") or not created.get("value"):
|
||||
raise PBSError("PBS returned an invalid API token.")
|
||||
raise PBSError("PBS returned an invalid API token.", code="pbs_invalid_token")
|
||||
try:
|
||||
_remote_request(endpoint, "PUT", "/access/acl", session,
|
||||
{"path": "/", "role": "Audit", "auth-id": created["tokenid"], "propagate": "1"},
|
||||
rejected="This PBS user cannot grant the Audit role. Sign in with root@pam or an administrator.")
|
||||
rejected="This PBS user cannot grant the Audit role. Sign in with root@pam or an administrator.", rejected_code="pbs_audit_role_denied")
|
||||
return save_server({**endpoint, "token_id": created["tokenid"], "token_secret": created["value"]})
|
||||
except PBSError:
|
||||
try:
|
||||
@@ -263,7 +270,7 @@ def delete_server(identifier):
|
||||
servers = _load_servers()
|
||||
remaining = [row for row in servers if row["id"] != identifier]
|
||||
if len(remaining) == len(servers):
|
||||
raise PBSError("PBS server not found.")
|
||||
raise PBSError("PBS server not found.", code="pbs_not_found")
|
||||
_save_servers(remaining)
|
||||
|
||||
|
||||
@@ -276,7 +283,7 @@ def _cli(*arguments):
|
||||
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
|
||||
raise PBSError("Could not read PBS " + " ".join(arguments[:2]) + ". Check its service and permissions.", code="pbs_read_failed") from exc
|
||||
|
||||
|
||||
def _local_api(path):
|
||||
@@ -290,7 +297,7 @@ def _local_api(path):
|
||||
raise ValueError()
|
||||
return data
|
||||
except (OSError, subprocess.SubprocessError, ValueError) as exc:
|
||||
raise PBSError("Could not read PBS " + path + ". Check its service and permissions.") from exc
|
||||
raise PBSError("Could not read PBS " + path + ". Check its service and permissions.", code="pbs_read_failed") from exc
|
||||
|
||||
|
||||
def _local_datastores():
|
||||
@@ -305,12 +312,14 @@ def _local_datastores():
|
||||
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."
|
||||
row["error_code"] = "pbs_datastore_offline"
|
||||
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."
|
||||
row["error_code"] = "pbs_datastore_unavailable"
|
||||
result.append(row)
|
||||
return result
|
||||
|
||||
@@ -331,10 +340,10 @@ def get_status(identifier):
|
||||
cached = _status_cache.get(identifier)
|
||||
if cached and cached[0] > time.monotonic():
|
||||
return cached[1]
|
||||
errors = {}
|
||||
errors, error_details = {}, {}
|
||||
if identifier == "local":
|
||||
if not local_available():
|
||||
raise PBSError("The local host is not a PBS server.")
|
||||
raise PBSError("The local host is not a PBS server.", code="pbs_local_unavailable")
|
||||
name, version = get_platform().get("hostname") or socket.gethostname(), _local_version()
|
||||
services = _local_services()
|
||||
operations = {"datastores": _local_datastores,
|
||||
@@ -346,7 +355,7 @@ def get_status(identifier):
|
||||
with _lock:
|
||||
server = next((row for row in _load_servers() if row["id"] == identifier), None)
|
||||
if not server:
|
||||
raise PBSError("PBS server not found.")
|
||||
raise PBSError("PBS server not found.", code="pbs_not_found")
|
||||
metadata = _remote_get(server, "/version")
|
||||
name, version = server["name"], metadata.get("version") if isinstance(metadata, dict) else None
|
||||
services = {}
|
||||
@@ -354,18 +363,19 @@ def get_status(identifier):
|
||||
"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}
|
||||
"fetched_at": int(time.time()), "errors": errors, "error_details": error_details}
|
||||
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.")
|
||||
raise PBSError("PBS returned an invalid list.", code="pbs_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)
|
||||
error_details[key] = exc.problem()
|
||||
with _lock:
|
||||
if len(_status_cache) >= 33:
|
||||
_status_cache.clear()
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Exercise the shipped routes and persistence; localization is additive."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from flask import Flask
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
|
||||
import monitor_links as links
|
||||
|
||||
|
||||
class MonitorLinksI18nTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.path = Path(temporary.name) / "links.json"
|
||||
self.previous = {"manual": [{"id": "saved", "name": "Saved", "url": "https://saved.test", "product": "pve"}], "overrides": {}}
|
||||
self.path.write_text(json.dumps(self.previous))
|
||||
for patch in [mock.patch.object(links, "LINKS_FILE", self.path),
|
||||
mock.patch.object(links, "refresh"), mock.patch.object(links, "_probe")]:
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
auth_provider = types.SimpleNamespace(
|
||||
load_auth_config=lambda: {"enabled": True},
|
||||
verify_token=lambda token: "fixture" if token in ("admin", "reader") else None,
|
||||
verify_token_full=lambda token: ("fixture", "full_admin" if token == "admin" else "read_only")
|
||||
if token in ("admin", "reader") else (None, None))
|
||||
middleware_spec = importlib.util.spec_from_file_location("fixture_jwt_middleware", ROOT / "AppImage/scripts/jwt_middleware.py")
|
||||
middleware = importlib.util.module_from_spec(middleware_spec)
|
||||
# Execute the shipped decorators; only credential verification is faked.
|
||||
with mock.patch.dict(sys.modules, {"auth_manager": auth_provider}):
|
||||
middleware_spec.loader.exec_module(middleware)
|
||||
spec = importlib.util.spec_from_file_location("fixture_monitor_links_routes", ROOT / "AppImage/scripts/flask_monitor_links_routes.py")
|
||||
routes = importlib.util.module_from_spec(spec)
|
||||
# Import isolation keeps the real JWT module and other suites untouched.
|
||||
with mock.patch.dict(sys.modules, {"jwt_middleware": middleware}):
|
||||
spec.loader.exec_module(routes)
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(routes.monitor_links_bp)
|
||||
self.client = app.test_client()
|
||||
|
||||
def send(self, method, path="", payload=None, role="admin"):
|
||||
return self.client.open("/api/monitor-links" + path, method=method,
|
||||
json=payload, headers={"Authorization": "Bearer " + role} if role else {})
|
||||
|
||||
def rejected(self, method, path, payload, message, code, params=None):
|
||||
before = self.path.read_bytes()
|
||||
with mock.patch.object(links, "_save") as save:
|
||||
response = self.send(method, path, payload)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
expected = {"error": message, "code": code}
|
||||
if params:
|
||||
expected["params"] = params
|
||||
self.assertEqual(response.json, expected)
|
||||
self.assertEqual(response.headers["Cache-Control"], "no-store")
|
||||
save.assert_not_called()
|
||||
links.refresh.assert_not_called()
|
||||
links._probe.assert_not_called()
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
|
||||
def test_validation_errors_preserve_english_status_and_have_stable_codes_without_effects(self):
|
||||
cases = [
|
||||
("POST", "", None, "Invalid request.", "monitor_link_invalid_request"),
|
||||
("PATCH", "/manual:saved", [], "Invalid request.", "monitor_link_invalid_request"),
|
||||
("POST", "", {"url": " "}, "Enter the address of the Monitor.", "monitor_link_address_required"),
|
||||
("POST", "", {"url": "ftp://example.test"}, "The address must start with http:// or https:// and name a host.", "monitor_link_invalid_address"),
|
||||
("POST", "", {"url": "https://user:pass@example.test"}, "The address must not contain a user or password.", "monitor_link_address_credentials"),
|
||||
("PATCH", "/manual:saved", {"name": "Unsaved", "url": "https://example.test:99999"}, "The port of the address is not valid.", "monitor_link_invalid_port"),
|
||||
("PATCH", "/cluster:node", {"url": "ftp://example.test"}, "The address must start with http:// or https:// and name a host.", "monitor_link_invalid_address"),
|
||||
("POST", "", {"url": "https://saved.test/path"}, "This address is already in the list.", "monitor_link_duplicate"),
|
||||
("PATCH", "/manual:absent", {"url": "https://new.test"}, "Node not found.", "monitor_link_not_found"),
|
||||
("PATCH", "/other:node", {}, "Node not found.", "monitor_link_not_found"),
|
||||
("DELETE", "/cluster:node", None, "Only nodes added by hand can be removed; detected ones can be hidden.", "monitor_link_detected_remove"),
|
||||
]
|
||||
for case in cases:
|
||||
with self.subTest(case=case):
|
||||
self.rejected(*case)
|
||||
|
||||
def test_limit_uses_actual_count_and_preserves_previous_file(self):
|
||||
with mock.patch.object(links, "MAX_MANUAL", 1):
|
||||
self.rejected("POST", "", {"url": "https://new.test"}, "At most 1 nodes can be added.",
|
||||
"monitor_link_limit", {"count": 1})
|
||||
|
||||
def test_write_failure_is_localizable_without_leaking_filesystem_details(self):
|
||||
for method, path, payload in [("POST", "", {"url": "https://new.test"}),
|
||||
("PATCH", "/manual:saved", {"name": "Unsaved"}),
|
||||
("DELETE", "/manual:saved", None)]:
|
||||
with self.subTest(method=method), mock.patch.object(links.os, "replace", side_effect=OSError("private path details")):
|
||||
before = self.path.read_bytes()
|
||||
response = self.send(method, path, payload)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertEqual(response.json, {"error": "Could not save the node list.", "code": "monitor_links_save_failed"})
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
links.refresh.assert_not_called()
|
||||
links._probe.assert_not_called()
|
||||
|
||||
def test_successful_crud_and_detected_hide_reset_keep_existing_contract(self):
|
||||
response = self.send("POST", payload={"name": "New", "url": "new.test:8008/path", "product": "pbs"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(set(response.json), {"success", "key"})
|
||||
key = response.json["key"]
|
||||
self.assertTrue(key.startswith("manual:"))
|
||||
row = links._load()["manual"][-1]
|
||||
self.assertEqual((row["url"], row["product"]), ("https://new.test:8008", "pbs"))
|
||||
self.assertEqual(self.send("PATCH", "/" + key, {"url": "http://changed.test"}).json, {"success": True})
|
||||
self.assertEqual(links._load()["manual"][-1]["url"], "http://changed.test")
|
||||
self.assertEqual(self.send("PATCH", "/cluster:node", {"url": "https://override.test", "hidden": True}).json, {"success": True})
|
||||
self.assertEqual(self.send("PATCH", "/cluster:node", {"url": "", "hidden": False}).json, {"success": True})
|
||||
self.assertEqual(links._load()["overrides"], {"cluster:node": {"hidden": False}})
|
||||
self.assertEqual(self.send("DELETE", "/" + key).json, {"success": True})
|
||||
self.assertEqual(links._load()["manual"], self.previous["manual"])
|
||||
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(links.refresh.call_count, 5)
|
||||
links._probe.assert_not_called()
|
||||
|
||||
def test_existing_read_and_admin_decorators_still_guard_routes_before_effects(self):
|
||||
with mock.patch.object(links, "cached_links", return_value={"self": {}, "links": []}) as cached, \
|
||||
mock.patch.object(links, "_save") as save:
|
||||
for method, path in [("POST", ""), ("PATCH", "/manual:saved"), ("DELETE", "/manual:saved")]:
|
||||
for role, status in [(None, 401), ("reader", 403)]:
|
||||
self.assertEqual(self.send(method, path, {"url": "https://new.test"}, role).status_code, status)
|
||||
self.assertEqual(self.send("GET", role=None).status_code, 401)
|
||||
self.assertEqual(self.send("GET", "?all=1", role="reader").status_code, 200)
|
||||
cached.assert_called_once_with(include_hidden=True)
|
||||
save.assert_not_called()
|
||||
links.refresh.assert_not_called()
|
||||
links._probe.assert_not_called()
|
||||
self.assertEqual(json.loads(self.path.read_text()), self.previous)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -205,7 +205,7 @@ class PBSManagerTests(unittest.TestCase):
|
||||
|
||||
def test_password_sign_in_keeps_only_a_read_only_token(self):
|
||||
calls = []
|
||||
def request(server, method, endpoint, headers, form=None, rejected=""):
|
||||
def request(server, method, endpoint, headers, form=None, rejected="", **kwargs):
|
||||
calls.append((method, endpoint, form))
|
||||
if endpoint == "/access/ticket":
|
||||
return {"ticket": "PBS:root@pam:ticket", "CSRFPreventionToken": "csrf", "username": "root@pam"}
|
||||
@@ -227,7 +227,7 @@ class PBSManagerTests(unittest.TestCase):
|
||||
|
||||
def test_password_sign_in_removes_its_token_when_the_role_cannot_be_granted(self):
|
||||
calls = []
|
||||
def request(server, method, endpoint, headers, form=None, rejected=""):
|
||||
def request(server, method, endpoint, headers, form=None, rejected="", **kwargs):
|
||||
calls.append((method, endpoint))
|
||||
if endpoint == "/access/ticket":
|
||||
return {"ticket": "t", "CSRFPreventionToken": "c"}
|
||||
@@ -351,15 +351,17 @@ class TLSTests(unittest.TestCase):
|
||||
|
||||
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):
|
||||
with self.subTest(fingerprint=fingerprint), self.assertRaises(pbs.PBSError) as caught:
|
||||
pbs._remote_get({**self.connection, "fingerprint": fingerprint}, '/status/datastore-usage')
|
||||
self.assertEqual(caught.exception.code, "pbs_fingerprint_mismatch" if fingerprint else "pbs_connect_failed")
|
||||
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):
|
||||
with self.subTest(status=status), self.assertRaises(pbs.PBSError) as caught:
|
||||
pbs._remote_get(self.connection, '/status/datastore-usage')
|
||||
self.assertEqual(caught.exception.code, "pbs_token_rejected" if status in (401, 403) else "pbs_invalid_response")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""PBS localization metadata must preserve routes, security and persistence."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from flask import Flask
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
|
||||
import auth_manager
|
||||
import pbs_manager as pbs
|
||||
REAL_REMOTE_REQUEST = pbs._remote_request
|
||||
|
||||
|
||||
class PBSI18nTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory(); self.addCleanup(temp.cleanup)
|
||||
self.root = Path(temp.name)
|
||||
for patch in [mock.patch.multiple(auth_manager, CONFIG_DIR=self.root, AUTH_CONFIG_FILE=self.root / "auth.json"),
|
||||
mock.patch.object(pbs, "_remote_get", return_value=[]),
|
||||
mock.patch.object(pbs, "_remote_request"),
|
||||
mock.patch.object(pbs, "local_available", return_value=False)]:
|
||||
patch.start(); self.addCleanup(patch.stop)
|
||||
(self.root / "auth.json").write_text(json.dumps({"enabled": True, "password_hash": "fixture"}))
|
||||
self.server = {"name": "Fixture", "url": "https://pbs.invalid:8007", "token_id": "audit@pbs!monitor", "token_secret": "fake-fixture-secret", "fingerprint": ""}
|
||||
self.id = pbs.save_server(self.server)
|
||||
pbs._remote_get.reset_mock(); pbs._status_cache.clear()
|
||||
auth = types.SimpleNamespace(load_auth_config=auth_manager.load_auth_config, verify_token=lambda _: "fixture",
|
||||
verify_token_full=lambda token: ("fixture", "full_admin" if token == "admin" else "read_only"))
|
||||
spec = importlib.util.spec_from_file_location("fixture_pbs_jwt", ROOT / "AppImage/scripts/jwt_middleware.py")
|
||||
middleware = importlib.util.module_from_spec(spec)
|
||||
with mock.patch.dict(sys.modules, {"auth_manager": auth}): spec.loader.exec_module(middleware)
|
||||
spec = importlib.util.spec_from_file_location("fixture_pbs_routes", ROOT / "AppImage/scripts/flask_pbs_routes.py")
|
||||
routes = importlib.util.module_from_spec(spec)
|
||||
with mock.patch.dict(sys.modules, {"jwt_middleware": middleware}): spec.loader.exec_module(routes)
|
||||
app = Flask(__name__); app.register_blueprint(routes.pbs_bp); self.client = app.test_client()
|
||||
|
||||
def send(self, method="POST", path="/api/pbs/servers", data=None, role="admin"):
|
||||
return self.client.open(path, method=method, json=data, headers={"Authorization": "Bearer " + role} if role else {})
|
||||
|
||||
def reject(self, data, message, code, path="/api/pbs/servers"):
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
with mock.patch.object(pbs, "_save_servers") as save:
|
||||
response = self.send(path=path, data=data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json, {"error": message, "code": code})
|
||||
self.assertEqual(response.headers["Cache-Control"], "no-store")
|
||||
save.assert_not_called(); pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called()
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
|
||||
def test_invalid_input_is_localizable_before_any_remote_or_save_effect(self):
|
||||
cases = [
|
||||
(None, "Invalid PBS server configuration.", "pbs_invalid_config"),
|
||||
({**self.server, "name": []}, "Invalid PBS connection field.", "pbs_invalid_field"),
|
||||
({**self.server, "url": "http://pbs.invalid"}, "Use an HTTPS PBS URL without embedded credentials, query or fragment.", "pbs_invalid_url"),
|
||||
({**self.server, "name": ""}, "A server name of at most 128 characters is required.", "pbs_name_required"),
|
||||
({**self.server, "token_id": "bad"}, "Enter a PBS API token ID in user@realm!token format.", "pbs_token_id_format"),
|
||||
({**self.server, "token_secret": "bad\nsecret"}, "A valid API token secret is required.", "pbs_token_secret"),
|
||||
({**self.server, "token_id": "človek@pbs!monitor"}, "Invalid API token ID.", "pbs_invalid_token_id"),
|
||||
({**self.server, "fingerprint": "invalid"}, "The certificate fingerprint must be SHA256 (64 hex characters).", "pbs_invalid_fingerprint"),
|
||||
({**self.server, "id": "../../bad"}, "Invalid PBS server ID.", "pbs_invalid_id"),
|
||||
]
|
||||
for data, message, code in cases:
|
||||
with self.subTest(code=code): self.reject(data, message, code)
|
||||
for data, message, code in [
|
||||
({**self.server, "username": "missing-realm", "password": "fake"}, "Enter the PBS user as user@realm, for example root@pam.", "pbs_username"),
|
||||
({**self.server, "username": "root@pam", "password": ""}, "Enter the password of the PBS user.", "pbs_password"),
|
||||
]:
|
||||
with self.subTest(code=code): self.reject(data, message, code, "/api/pbs/servers/login")
|
||||
|
||||
def test_authentication_and_scope_guards_precede_all_mutations(self):
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
save_patch = mock.patch.object(pbs, "_save_servers")
|
||||
save = save_patch.start(); self.addCleanup(save_patch.stop)
|
||||
for path, method in [("/api/pbs/servers", "POST"), ("/api/pbs/servers/login", "POST"), ("/api/pbs/servers/" + self.id, "DELETE")]:
|
||||
for role, status in [(None, 401), ("reader", 403)]:
|
||||
self.assertEqual(self.send(method, path, self.server, role).status_code, status)
|
||||
(self.root / "auth.json").write_text(json.dumps({"enabled": False}))
|
||||
for path in ("/api/pbs/servers", "/api/pbs/servers/login"):
|
||||
response = self.send(path=path, data=self.server)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json, {"error": "Enable local authentication before saving remote PBS credentials.", "code": "pbs_auth_required"})
|
||||
response = self.send("DELETE", "/api/pbs/servers/" + self.id)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json["code"], "pbs_auth_required")
|
||||
pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called()
|
||||
save.assert_not_called()
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
|
||||
def test_corrupt_configuration_missing_server_and_local_unavailable_keep_status(self):
|
||||
response = self.send("DELETE", "/api/pbs/servers/absent")
|
||||
self.assertEqual((response.status_code, response.json), (400, {"error": "PBS server not found.", "code": "pbs_not_found"}))
|
||||
for identifier, code in [("absent", "pbs_not_found"), ("local", "pbs_local_unavailable")]:
|
||||
response = self.send("GET", "/api/pbs/status?server=" + identifier)
|
||||
self.assertEqual(response.status_code, 502); self.assertEqual(response.json["code"], code)
|
||||
(self.root / "pbs_servers.json").write_text("invalid json")
|
||||
response = self.send("GET", "/api/pbs/servers")
|
||||
self.assertEqual((response.status_code, response.json), (500, {"error": "Could not read the PBS server configuration.", "code": "pbs_config_read"}))
|
||||
pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called()
|
||||
|
||||
def test_save_delete_failures_leave_previous_bytes_and_do_not_leak_details(self):
|
||||
for path, method, data, code, message in [
|
||||
("/api/pbs/servers", "POST", {**self.server, "id": self.id}, "pbs_save_failed", "Could not save PBS connection."),
|
||||
("/api/pbs/servers/" + self.id, "DELETE", None, "pbs_remove_failed", "Could not remove PBS connection."),
|
||||
]:
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
with self.subTest(method=method), mock.patch.object(pbs.os, "replace", side_effect=OSError("private filesystem detail")):
|
||||
response = self.send(method, path, data)
|
||||
self.assertEqual((response.status_code, response.json), (500, {"error": message, "code": code}))
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
|
||||
def test_partial_status_preserves_legacy_error_null_and_cache_with_additive_metadata(self):
|
||||
def remote(server, endpoint):
|
||||
if endpoint == "/version": return {"version": "4.2.5"}
|
||||
if endpoint == "/admin/verify": raise pbs.PBSError("Fixture access denied", code="pbs_token_rejected")
|
||||
if endpoint == "/admin/prune": return {"invalid": "list"}
|
||||
return []
|
||||
with mock.patch.object(pbs, "_remote_get", side_effect=remote) as remote_call:
|
||||
result = self.send("GET", "/api/pbs/status?server=" + self.id)
|
||||
count = remote_call.call_count
|
||||
self.assertEqual(self.send("GET", "/api/pbs/status?server=" + self.id).json, result.json)
|
||||
self.assertEqual(remote_call.call_count, count)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertIsNone(result.json["verify"]); self.assertIsNone(result.json["prune"])
|
||||
self.assertEqual(result.json["sync"], [])
|
||||
self.assertEqual(result.json["errors"]["verify"], "Fixture access denied")
|
||||
self.assertEqual(result.json["error_details"]["verify"], {"code": "pbs_token_rejected"})
|
||||
self.assertEqual(result.json["error_details"]["prune"], {"code": "pbs_invalid_list"})
|
||||
|
||||
def test_local_datastore_errors_and_command_errors_keep_legacy_messages(self):
|
||||
with mock.patch.object(pbs, "_cli", return_value=[{"name": "offline", "path": "/fixture", "backing-device": "fixture"}]), \
|
||||
mock.patch.object(pbs.os.path, "ismount", return_value=False), mock.patch.object(pbs.shutil, "disk_usage") as usage:
|
||||
store = pbs._local_datastores()[0]
|
||||
self.assertEqual(store["error"], "Datastore is offline, in maintenance or has no mounted backing device.")
|
||||
self.assertEqual(store["error_code"], "pbs_datastore_offline"); usage.assert_not_called(); self.assertIsNone(store["total"])
|
||||
with mock.patch.object(pbs.subprocess, "run", side_effect=OSError("fixture")):
|
||||
for fn, args in [(pbs._cli, ("task", "list")), (pbs._local_api, ("/admin/sync",))]:
|
||||
with self.subTest(args=args), self.assertRaises(pbs.PBSError) as caught: fn(*args)
|
||||
self.assertEqual(caught.exception.code, "pbs_read_failed")
|
||||
self.assertIn("Check its service and permissions.", str(caught.exception))
|
||||
|
||||
def test_remote_failure_codes_follow_the_actual_request_and_keep_english(self):
|
||||
cases = [
|
||||
(401, b'{}', "pbs_token_rejected", "PBS rejected the API token or its permissions. Check both the user and token ACLs."),
|
||||
(302, b'{}', "pbs_invalid_response", "PBS returned an invalid response. Redirects are not followed."),
|
||||
(200, b'{}', "pbs_invalid_json", "PBS returned an invalid JSON document."),
|
||||
(200, b'invalid-json', "pbs_connect_failed", "Could not connect to PBS over verified HTTPS. Check the address, certificate and service."),
|
||||
]
|
||||
for status, body, code, message in cases:
|
||||
connection = mock.Mock(); connection.getresponse.return_value.status = status
|
||||
connection.getresponse.return_value.read.return_value = body
|
||||
with self.subTest(code=code), mock.patch.object(pbs.http.client, "HTTPSConnection", return_value=connection), \
|
||||
self.assertRaises(pbs.PBSError) as caught:
|
||||
REAL_REMOTE_REQUEST(self.server, "GET", "/version", {})
|
||||
self.assertEqual((str(caught.exception), caught.exception.problem()), (message, {"code": code}))
|
||||
connection.close.assert_called_once()
|
||||
for code in ("pbs_login_rejected", "pbs_token_create_denied", "pbs_audit_role_denied"):
|
||||
connection = mock.Mock(); connection.getresponse.return_value.status = 403
|
||||
connection.getresponse.return_value.read.return_value = b'{}'
|
||||
with self.subTest(code=code), mock.patch.object(pbs.http.client, "HTTPSConnection", return_value=connection), self.assertRaises(pbs.PBSError) as caught:
|
||||
REAL_REMOTE_REQUEST(self.server, "POST", "/access/ticket", {}, rejected="Original context", rejected_code=code)
|
||||
self.assertEqual((str(caught.exception), caught.exception.code), ("Original context", code))
|
||||
|
||||
def test_connection_limit_and_invalid_datastore_response_do_not_save(self):
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
with mock.patch.object(pbs, "_save_servers") as save, mock.patch.object(pbs, "_load_servers", return_value=[{"id": str(i)} for i in range(32)]):
|
||||
response = self.send(data=self.server)
|
||||
self.assertEqual(response.json, {"error": "At most 32 PBS connections may be configured.", "code": "pbs_connection_limit", "params": {"count": 32}})
|
||||
self.assertEqual(response.status_code, 400); save.assert_not_called()
|
||||
pbs._remote_get.return_value = {}
|
||||
response = self.send(data=self.server)
|
||||
self.assertEqual(response.json["code"], "pbs_invalid_datastores")
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
|
||||
def test_login_failures_keep_saved_state_and_cleanup_a_created_token(self):
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
login = {"name": "Fixture", "url": "https://pbs.invalid", "username": "root@pam", "password": "fake"}
|
||||
for stage, code in [("ticket", "pbs_invalid_login"), ("token", "pbs_invalid_token"), ("acl", "pbs_audit_role_denied")]:
|
||||
calls = []
|
||||
def request(server, method, endpoint, headers, form=None, rejected="", rejected_code="pbs_token_rejected"):
|
||||
calls.append((method, endpoint))
|
||||
if endpoint == "/access/ticket": return None if stage == "ticket" else {"ticket": "fake", "CSRFPreventionToken": "fake"}
|
||||
if method == "POST": return None if stage == "token" else {"tokenid": "audit@pbs!fixture", "value": "fake"}
|
||||
if method == "PUT": raise pbs.PBSError(rejected, code=rejected_code)
|
||||
pbs._remote_request.side_effect = request
|
||||
response = self.send(path="/api/pbs/servers/login", data=login)
|
||||
self.assertEqual(response.status_code, 400); self.assertEqual(response.json["code"], code)
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
self.assertEqual(sum(method == "DELETE" for method, _ in calls), 1 if stage == "acl" else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
// Exercise the shipped error adapter and actual HostBackup handlers with a fake transport.
|
||||
const assert = require('node:assert/strict'), fs = require('node:fs'), vm = require('node:vm');
|
||||
const { ts, read, compile, translator } = require('./backup_i18n_seam.cjs');
|
||||
const library = { exports: {} };
|
||||
vm.runInNewContext(compile(read('lib/host-backup-messages.ts')), library);
|
||||
const { hostBackupErrorText } = library.exports;
|
||||
// Independently read the real API's literal/f-string contracts without importing
|
||||
// Flask or running any route, host probe, script or persistence operation.
|
||||
const contracts = JSON.parse(require('node:child_process').execFileSync('python3', ['-I', '-B', '-c', `
|
||||
import ast,json,sys
|
||||
tree=ast.parse(open(sys.argv[1]).read())
|
||||
def messages(node):
|
||||
if isinstance(node,ast.Constant) and isinstance(node.value,str): return [node.value]
|
||||
if isinstance(node,ast.JoinedStr): return [''.join(part.value if isinstance(part,ast.Constant) else 'PARAMETER' for part in node.values)]
|
||||
if isinstance(node,ast.BoolOp): return [text for child in node.values for text in messages(child)]
|
||||
return []
|
||||
result=set()
|
||||
for function in tree.body:
|
||||
if isinstance(function,ast.FunctionDef) and function.name.startswith(('api_host_backups_','api_host_restore')):
|
||||
for node in ast.walk(function):
|
||||
if isinstance(node,ast.Dict):
|
||||
for key,value in zip(node.keys,node.values):
|
||||
if isinstance(key,ast.Constant) and key.value=='error': result.update(messages(value))
|
||||
print(json.dumps(sorted(result)))
|
||||
`, 'AppImage/scripts/flask_server.py'], { encoding: 'utf8' }));
|
||||
const flatten = n => !n || typeof n === 'boolean' ? [] : Array.isArray(n) ? n.flatMap(flatten)
|
||||
: typeof n === 'object' ? [n, ...flatten(n.props?.children)] : [n];
|
||||
const text = n => flatten(n).filter(n => typeof n === 'string' || typeof n === 'number').join('');
|
||||
|
||||
function fixture(locale) {
|
||||
let cursor = 0, slots = [], tree, failure = 'path must be absolute', mutations = 0;
|
||||
const calls = [], t = translator(locale);
|
||||
const jsx = (type, props) => typeof type === 'function' ? type(props) : { type, props };
|
||||
const hooks = { useState(initial) { const i = cursor++; if (!(i in slots)) slots[i] = initial; return [slots[i], value => { slots[i] = value; }]; } };
|
||||
const requireModule = name => {
|
||||
if (name === 'react') return hooks;
|
||||
if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx };
|
||||
if (name === 'swr') return { default: () => ({ data: { paths: [{ path: '/kept' }] }, mutate: () => { mutations++; } }) };
|
||||
if (name.includes('i18n/provider')) return { useT: () => t };
|
||||
if (name.endsWith('host-backup-messages')) return library.exports;
|
||||
if (name.endsWith('api-config')) return { fetchApi: async (url, options) => { calls.push({ url, ...options }); if (failure) throw new Error(failure); return { status: 'ok' }; } };
|
||||
return new Proxy({}, { get: (_, key) => key });
|
||||
};
|
||||
const source = read('components/host-backup.tsx') + '\nexports.ExtraPathsSection = ExtraPathsSection;';
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX } }).outputText,
|
||||
{ exports: module.exports, require: requireModule, Error, console });
|
||||
return { calls, t, render() { cursor = 0; tree = module.exports.ExtraPathsSection(); return tree; }, text: () => text(tree),
|
||||
input: () => flatten(tree).find(n => n.type === 'Input'),
|
||||
add: () => flatten(tree).find(n => n.type === 'Button' && text(n).includes(t('backup.actions.addPath'))),
|
||||
fail: message => { failure = message; }, mutations: () => mutations };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
assert.ok(contracts.length > 100, 'real HostBackup error contracts discovered');
|
||||
for (const message of contracts) {
|
||||
const translated = hostBackupErrorText(message, translator('sk'));
|
||||
assert.notEqual(translated, message, 'application-owned message must be localized: ' + message);
|
||||
assert.ok(!translated.includes('backup.apiErrors.'), 'every mapped key has a real Slovak value');
|
||||
assert.equal(hostBackupErrorText(message, translator('missing')), hostBackupErrorText(message, translator('en')), 'missing locale uses English');
|
||||
}
|
||||
for (const locale of ['en', 'sk', 'missing']) {
|
||||
const t = translator(locale);
|
||||
assert.equal(hostBackupErrorText(new Error('path must be absolute'), t), t('backup.apiErrors.absolutePath'));
|
||||
assert.equal(hostBackupErrorText({ message: 'this path is already configured' }, t), t('backup.apiErrors.pathConfigured'));
|
||||
assert.equal(hostBackupErrorText('job already exists: test_1', t), t('backup.apiErrors.jobExists', { value: 'test_1' }));
|
||||
const raw = 'tool failed: <img src=x onerror=unsafe()> path must be absolute';
|
||||
assert.equal(hostBackupErrorText(raw, t), raw, 'external diagnostics must not be partially matched or discarded');
|
||||
const long = 'path does not exist on this host: ' + 'x'.repeat(20000);
|
||||
assert.equal(hostBackupErrorText(long, t), long, 'large diagnostics preserved without pattern matching');
|
||||
for (const unknown of ['constructor', '__proto__', 'toString', 'future backend message']) assert.equal(hostBackupErrorText(unknown, t), unknown);
|
||||
for (const invalid of [null, undefined, {}, 23]) assert.equal(hostBackupErrorText(invalid, t), t('backup.apiErrors.requestFailed'));
|
||||
const unsafe = '<img src=x onerror=unsafe()>{backup.actions.run}';
|
||||
assert.equal(hostBackupErrorText('path does not exist on this host: ' + unsafe, t), t('backup.apiErrors.pathMissing', { value: unsafe }), 'captured paths remain literal data');
|
||||
const f = fixture(locale); f.render();
|
||||
assert.equal(f.add().props.disabled, true);
|
||||
await f.add().props.onClick(); assert.equal(f.calls.length, 0, 'blank path causes no request');
|
||||
f.input().props.onChange({ target: { value: 'relative-path' } }); f.render(); await f.add().props.onClick(); f.render();
|
||||
assert.equal(f.calls.length, 1); assert.equal(f.calls[0].url, '/api/host-backups/extra-paths');
|
||||
assert.equal(f.calls[0].method, 'POST'); assert.deepEqual(JSON.parse(f.calls[0].body), { path: 'relative-path' });
|
||||
assert.equal(f.input().props.value, 'relative-path'); assert.equal(f.mutations(), 0, 'rejected input preserves state and does not refresh as success');
|
||||
assert.ok(f.text().includes(t('backup.apiErrors.absolutePath'))); assert.ok(f.text().includes('/kept'));
|
||||
f.fail('future backend message'); await f.add().props.onClick(); f.render(); assert.ok(f.text().includes('future backend message'));
|
||||
f.fail(null); await f.add().props.onClick(); f.render(); assert.equal(f.input().props.value, ''); assert.equal(f.mutations(), 1);
|
||||
}
|
||||
// Adapter has no effect on the server's CLI/API/persistence contracts.
|
||||
const host = read('components/host-backup.tsx');
|
||||
const ast = ts.createSourceFile('host-backup.tsx', host, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX), encryptionCatches = [];
|
||||
function visit(node) { if (ts.isCatchClause(node) && node.block.getText(ast).includes('err.body?.tool_output')) encryptionCatches.push(node.block); ts.forEachChild(node, visit); }
|
||||
visit(ast); assert.equal(encryptionCatches.length, 2, 'scheduled and manual backup encryption paths both checked');
|
||||
for (const locale of ['en', 'sk', 'missing']) for (const block of encryptionCatches) {
|
||||
let displayed, effects = 0;
|
||||
const code = ts.transpileModule('function run(e) '+block.getText(ast)+'; run(error);', { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText;
|
||||
const t = translator(locale), error = Object.assign(new Error('failed to create PBS encryption keyfile'), { body: { tool_output: 'EXTERNAL TOOL DIAGNOSTIC' } });
|
||||
vm.runInNewContext(code, { error, t, hostBackupErrorText, setError: value => { displayed = value; }, setPbsImportBusy: () => {}, setSubmitting: () => {}, onCreated: () => effects++ });
|
||||
assert.ok(displayed.includes(hostBackupErrorText(error, t)) && displayed.includes('EXTERNAL TOOL DIAGNOSTIC'));
|
||||
assert.ok(!displayed.includes('{error}') && !displayed.includes('{detail}'), 'error interpolation must not leave an unresolved placeholder');
|
||||
assert.equal(effects, 0);
|
||||
}
|
||||
assert.ok(!host.includes('e instanceof Error ? e.message : String(e)'), 'all HostBackup catch consumers use the adapter');
|
||||
assert.ok(!host.includes('{e.error}') && !host.includes('{calendarPreview.error}'), 'structured error consumers use the adapter too');
|
||||
console.log('PASS: '+contracts.length+' real API contracts, HostBackup actual extra-path handlers, EN/SK/fallback, 0 calls for blank, rejected input/state, success, literal parameters and unknown diagnostics');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,133 @@
|
||||
// Run the shipped translation adapter, transport and both dialog components.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const compile = source => ts.transpileModule(source, {compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
|
||||
}}).outputText;
|
||||
const translations = Object.fromEntries(['en', 'sk'].map(locale => [locale, JSON.parse(fs.readFileSync(`AppImage/messages/${locale}/common.json`))]));
|
||||
const translate = locale => (key, params = {}) => {
|
||||
const value = key.split('.').reduce((tree, part) => tree?.[part], translations[locale]);
|
||||
assert.equal(typeof value, 'string', key);
|
||||
return value.replace(/\{(\w+)\}/g, (match, key) => params[key] ?? match);
|
||||
};
|
||||
const adapter = {exports: {}};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/monitor-link-errors.ts', 'utf8')), adapter);
|
||||
const {monitorLinkError} = adapter.exports;
|
||||
const codes = {
|
||||
monitor_link_address_required: 'addressRequired', monitor_link_invalid_address: 'invalidAddress',
|
||||
monitor_link_address_credentials: 'addressCredentials', monitor_link_invalid_port: 'invalidPort',
|
||||
monitor_link_invalid_request: 'invalidRequest', monitor_link_duplicate: 'duplicate',
|
||||
monitor_link_not_found: 'notFound', monitor_link_detected_remove: 'detectedRemove',
|
||||
};
|
||||
const failure = (code, params) => Object.assign(new Error('RAW ENGLISH SECRET DETAILS'), {body: {error: 'RAW ENGLISH SECRET DETAILS', code, params}});
|
||||
|
||||
const flatten = tree => !tree || typeof tree === 'boolean' ? [] : Array.isArray(tree) ? tree.flatMap(flatten)
|
||||
: typeof tree === 'object' ? [tree, ...flatten(tree.props?.children)] : [tree];
|
||||
const text = tree => flatten(tree).filter(n => typeof n === 'string' || typeof n === 'number').join('');
|
||||
const jsx = (type, props) => ({type, props});
|
||||
const source = compile(fs.readFileSync('AppImage/components/monitor-switcher.tsx', 'utf8') + '\nexport {ManageLinksDialog};');
|
||||
|
||||
function fixture({locale = 'sk', links = [], origin = false, loadError = false, loading = false} = {}) {
|
||||
let slots = [], cursor = 0, effects = [], tree, responseError, refreshFailure = false;
|
||||
const calls = [], refreshes = [], storage = new Map();
|
||||
const candidates = [{u: 'https://other.test', n: 'Other', p: 'pve'}];
|
||||
const carried = Buffer.from(JSON.stringify(candidates)).toString('base64url');
|
||||
const location = {origin: 'https://current.test', hostname: 'current.test', pathname: '/', hash: '',
|
||||
search: origin ? `?pmx_from=https%3A%2F%2Fsource.test&pmx_name=Source&pmx_links=${carried}` : ''};
|
||||
const react = {
|
||||
useState(initial) {const slot = cursor++; if (!(slot in slots)) slots[slot] = typeof initial === 'function' ? initial() : initial;
|
||||
return [slots[slot], value => {slots[slot] = typeof value === 'function' ? value(slots[slot]) : value;}];},
|
||||
useEffect(fn, deps) {const slot = cursor++; const old = slots[slot];
|
||||
if (!old || deps.some((dep, i) => dep !== old[i])) effects.push(fn);
|
||||
slots[slot] = deps;
|
||||
},
|
||||
};
|
||||
const exported = {};
|
||||
vm.runInNewContext(source, {exports: exported, URL, URLSearchParams, escape, unescape,
|
||||
atob: value => Buffer.from(value, 'base64').toString('binary'), btoa: value => Buffer.from(value, 'binary').toString('base64'),
|
||||
window: {location, history: {replaceState() {location.search = ''}}},
|
||||
sessionStorage: {getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value)},
|
||||
require(name) {
|
||||
if (name === 'react') return react;
|
||||
if (name === 'react/jsx-runtime') return {jsx, jsxs: jsx, Fragment: 'Fragment'};
|
||||
if (name === 'swr') return {default: () => ({data: {self: {name: 'Current', product: 'pve'}, links}, error: loadError, isLoading: loading,
|
||||
mutate: async () => refreshes.push('all')}), mutate: async key => {refreshes.push(key); if (refreshFailure) throw new Error('RAW REFRESH ERROR');}};
|
||||
if (name.includes('i18n/provider')) return {useT: () => translate(locale)};
|
||||
if (name.includes('monitor-link-errors')) return {monitorLinkError};
|
||||
if (name.includes('api-config')) return {fetchApi: async (path, options) => {calls.push({path, ...options}); if (responseError) throw responseError; return {success: true};}};
|
||||
return new Proxy({}, {get: (_, key) => key});
|
||||
},
|
||||
});
|
||||
return {
|
||||
render(component = 'ManageLinksDialog') {cursor = 0; effects = []; tree = exported[component]({open: true, onOpenChange() {}, nodeName: 'Current'}); effects.forEach(fn => fn()); return tree;},
|
||||
button(label) {const node = flatten(tree).find(n => n.type === 'Button' && (text(n) === label || n.props['aria-label'] === label)); assert.ok(node, label); return node;},
|
||||
input(id) {const node = flatten(tree).find(n => n.type === 'Input' && (n.props.id === id || n.props['aria-label'] === id)); assert.ok(node, id); return node;},
|
||||
nodes: () => flatten(tree), text: () => text(tree), fail: error => {responseError = error;}, failRefresh: () => {refreshFailure = true;}, calls, refreshes, storage,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
for (const locale of ['en', 'sk']) {
|
||||
const t = translate(locale);
|
||||
for (const [code, key] of Object.entries(codes)) assert.equal(monitorLinkError(failure(code), t), t(`monitorLinks.errors.${key}`));
|
||||
assert.equal(monitorLinkError(failure('monitor_link_limit', {count: 64}), t), t('monitorLinks.errors.limit', {count: 64}));
|
||||
for (const error of [null, undefined, 'raw', new Error('raw'), {body: null}, {body: {}},
|
||||
...['constructor', '__proto__', 'toString', 'unknown', 'monitor_links_save_failed'].map(code => failure(code)),
|
||||
...[undefined, null, '64', 0, -1, 1.5, NaN, Infinity, 2 ** 54].map(count => failure('monitor_link_limit', {count}))]) {
|
||||
assert.equal(monitorLinkError(error, t), t('monitorLinks.saveFailed'));
|
||||
}
|
||||
const f = fixture({locale, links: [{key: 'manual:saved', name: 'Saved', url: 'https://saved.test', manual: true}]});
|
||||
f.render(); f.button(t('monitorLinks.changeAddress')).props.onClick(); f.render();
|
||||
f.input(t('monitorLinks.address')).props.onChange({target: {value: 'https://unsaved.test'}}); f.render();
|
||||
f.button(t('monitorLinks.cancel')).props.onClick(); f.render();
|
||||
assert.equal(f.calls.length, 0, 'Cancel must not write');
|
||||
f.button(t('monitorLinks.changeAddress')).props.onClick(); f.render();
|
||||
assert.equal(f.input(t('monitorLinks.address')).props.value, 'https://saved.test', 'Cancel discards address draft');
|
||||
f.input(t('monitorLinks.address')).props.onChange({target: {value: 'https://changed.test'}}); f.render();
|
||||
await f.button(t('monitorLinks.save')).props.onClick(); f.render();
|
||||
assert.equal(f.calls[0].method, 'PATCH'); assert.deepEqual(JSON.parse(f.calls[0].body), {url: 'https://changed.test'});
|
||||
assert.equal(f.button(t('monitorLinks.add')).props.disabled, true, 'Blank address cannot be submitted');
|
||||
f.input('monitor-link-url').props.onChange({target: {value: 'ftp://bad.test'}}); f.render();
|
||||
f.fail(failure('monitor_link_invalid_address')); await f.button(t('monitorLinks.add')).props.onClick(); f.render();
|
||||
assert.ok(f.text().includes(t('monitorLinks.errors.invalidAddress')));
|
||||
assert.equal(f.input('monitor-link-url').props.value, 'ftp://bad.test', 'Failure retains draft');
|
||||
assert.ok(!f.text().includes('RAW ENGLISH'));
|
||||
f.fail(new Error('raw network details')); await f.button(t('monitorLinks.remove')).props.onClick(); f.render();
|
||||
assert.ok(f.text().includes(t('monitorLinks.saveFailed')));
|
||||
const failedRead = fixture({locale, loadError: new Error('raw')}); failedRead.render();
|
||||
assert.ok(failedRead.text().includes(t('monitorLinks.loadFailed')));
|
||||
const pendingRead = fixture({locale, loading: true}); pendingRead.render();
|
||||
assert.ok(pendingRead.text().includes(t('monitorLinks.checking')));
|
||||
|
||||
const declined = fixture({locale, origin: true});
|
||||
for (let n = 0; n < 3; n++) declined.render('MonitorSwitcher');
|
||||
declined.button(t('monitorLinks.notNow')).props.onClick(); declined.render('MonitorSwitcher');
|
||||
assert.equal(declined.calls.length, 0, 'Not now must not write');
|
||||
const carried = fixture({locale, origin: true});
|
||||
for (let n = 0; n < 3; n++) carried.render('MonitorSwitcher');
|
||||
const boxes = carried.nodes().filter(n => n.type === 'Checkbox'); assert.equal(boxes.length, 2);
|
||||
boxes[1].props.onCheckedChange(false); carried.render('MonitorSwitcher');
|
||||
carried.fail(failure('monitor_link_duplicate'));
|
||||
carried.failRefresh();
|
||||
await carried.button(t('monitorLinks.addSelected')).props.onClick(); carried.render('MonitorSwitcher');
|
||||
assert.equal(carried.calls.length, 1, 'Only selected origin is attempted');
|
||||
assert.equal(JSON.parse(carried.calls[0].body).url, 'https://source.test');
|
||||
assert.ok(carried.text().includes(t('monitorLinks.errors.duplicate')));
|
||||
assert.equal(carried.refreshes.length, 1, 'Failed/partially successful batch refreshes the visible list');
|
||||
}
|
||||
// Real authenticated transport preserves additive fields for the adapter.
|
||||
const api = {exports: {}, process: {env: {}}, fetch: async () => new Response(JSON.stringify({error: 'Original English error', code: 'monitor_link_duplicate'}), {status: 400, headers: {'Content-Type': 'application/json'}})};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/api-config.ts', 'utf8')), api);
|
||||
try {await api.exports.fetchApi('/api/monitor-links', {method: 'POST'}); assert.fail('expected rejection');}
|
||||
catch (error) {assert.equal(error.status, 400); assert.equal(error.message, 'Original English error'); assert.equal(monitorLinkError(error, translate('sk')), translations.sk.monitorLinks.errors.duplicate);}
|
||||
const en = translations.en.monitorLinks, sk = translations.sk.monitorLinks;
|
||||
assert.deepEqual(Object.keys(en.errors).sort(), Object.keys(sk.errors).sort());
|
||||
for (const key of Object.keys(en.errors)) {
|
||||
assert.ok(sk.errors[key]);
|
||||
assert.deepEqual(en.errors[key].match(/\{\w+\}/g), sk.errors[key].match(/\{\w+\}/g), key);
|
||||
}
|
||||
console.log('PASS: EN/SK known/unknown errors, real transport, both dialogs, Cancel/Not now zero writes, draft persistence, selected-only batch, localized loading/failure');
|
||||
}
|
||||
main().catch(error => {console.error(error); process.exitCode = 1;});
|
||||
@@ -0,0 +1,154 @@
|
||||
// Shipped PBS code with only transport, hooks and UI primitives replaced.
|
||||
const assert = require('node:assert/strict'), fs = require('node:fs'), vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const compile = source => ts.transpileModule(source, {compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX}}).outputText;
|
||||
const catalogs = Object.fromEntries(['en','sk'].map(l => [l, JSON.parse(fs.readFileSync(`AppImage/messages/${l}/common.json`))]));
|
||||
const translate = locale => (key, params = {}) => {
|
||||
const value = key.split('.').reduce((tree, part) => tree?.[part], catalogs[locale]);
|
||||
assert.equal(typeof value, 'string', key);
|
||||
return value.replace(/\{(\w+)\}/g, (m,k) => params[k] ?? m);
|
||||
};
|
||||
const library = {exports: {}};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/pbs-messages.ts','utf8')), library);
|
||||
const messages = library.exports;
|
||||
const failure = code => Object.assign(new Error('RAW DIAGNOSTIC'), {body: {error:'RAW DIAGNOSTIC',code}});
|
||||
const flatten = n => !n || typeof n === 'boolean' ? [] : Array.isArray(n) ? n.flatMap(flatten)
|
||||
: typeof n === 'object' ? n.type === 'Dialog' && !n.props.open ? [] : [n,...flatten(n.props?.children)] : [n];
|
||||
const text = n => flatten(n).filter(n => typeof n === 'string' || typeof n === 'number').join('');
|
||||
|
||||
function fixture(locale='sk', options={}) {
|
||||
let cursor=0, slots=[], effects=[], tree, apiError, reloadError;
|
||||
const calls=[], confirmations=[], reloads=[];
|
||||
const servers={local_available:false,servers:[{id:'saved',name:'Saved PBS',url:'https://saved.invalid:8007',token_id:'audit@pbs!monitor',has_token:true,fingerprint:''}],detected:[]};
|
||||
const status={name:'Fixture',version:'4.2.5',fetched_at:1700000000,services:{'proxmox-backup':'inactive'},datastores:[],tasks:[],sync:[],verify:[],prune:[],errors:{}};
|
||||
const t=translate(locale), loaded={};
|
||||
const jsx=(type,props) => typeof type === 'function' ? type(props) : ({type,props});
|
||||
const react={useState(initial){const i=cursor++;if(!(i in slots))slots[i]=typeof initial==='function'?initial():initial;return [slots[i],value=>{slots[i]=typeof value==='function'?value(slots[i]):value;}];},
|
||||
useEffect(fn,deps){const i=cursor++, old=slots[i];if(!old||deps.some((d,j)=>d!==old[j]))effects.push(fn);slots[i]=deps;}};
|
||||
function requireModule(name) {
|
||||
if(name==='react')return react;
|
||||
if(name==='react/jsx-runtime')return {jsx,jsxs:jsx,Fragment:'Fragment'};
|
||||
if(name==='swr')return {default:key=>({data:key?.startsWith('/api/pbs/status')?status:key?servers:undefined,error:options.loadError,
|
||||
isLoading:false,mutate:async()=>{reloads.push(key);if(reloadError)throw reloadError;}})};
|
||||
if(name.includes('i18n/provider'))return {useT:()=>t,useI18n:()=>({language:locale})};
|
||||
if(name.includes('pbs-messages'))return messages;
|
||||
if(name.includes('api-config'))return {fetchApi:async(path,opts)=>{calls.push({path,...opts});if(apiError)throw apiError;return {id:'new',success:true};}};
|
||||
if(name.endsWith('pbs-problem-message'))return load('AppImage/components/pbs-problem-message.tsx');
|
||||
if(name.endsWith('pbs-datastore-usage'))return load('AppImage/components/pbs-datastore-usage.tsx');
|
||||
if(name.includes('lib/utils'))return {formatStorage:n=>String(n)+' GB'};
|
||||
return new Proxy({}, {get:(_,key)=>key});
|
||||
}
|
||||
function load(path) {if(loaded[path])return loaded[path];const e={};loaded[path]=e;
|
||||
vm.runInNewContext(compile(fs.readFileSync(path,'utf8')), {exports:e,require:requireModule,Date,window:{confirm:message=>{confirmations.push(message);return options.confirm===true;}}});return e;}
|
||||
const panel=load('AppImage/components/pbs-panel.tsx').PBSPanel;
|
||||
return {t,servers,status,calls,confirmations,reloads,jsx,requireModule,
|
||||
render(props){cursor=0;effects=[];tree=panel(props);effects.forEach(fn=>fn());return tree;},
|
||||
nodes:()=>flatten(tree),text:()=>text(tree),
|
||||
button(label){const n=flatten(tree).find(n=>n.type==='Button'&&text(n)===label);assert.ok(n,label);return n;},
|
||||
input(field){const n=flatten(tree).find(n=>n.type==='Input'&&n.props.id==='pbs-'+field);assert.ok(n,field);return n;},
|
||||
select(label){const n=flatten(tree).find(n=>n.type==='Select'&&flatten(n).some(n=>n.props?.['aria-label']===label));assert.ok(n,label);return n;},
|
||||
fail:e=>{apiError=e;},failReload:e=>{reloadError=e;},load,
|
||||
};
|
||||
}
|
||||
|
||||
function overview(locale, sourcePath='AppImage/components/system-overview.tsx') {
|
||||
const source=fs.readFileSync(sourcePath,'utf8'), ast=ts.createSourceFile('overview.tsx',source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX), branches=[];
|
||||
function visit(n){if(ts.isConditionalExpression(n)&&n.condition.getText(ast)==='isPbs'&&n.whenTrue.getText(ast).includes('pbs.datastores'))branches.push(n.whenTrue.getText(ast));ts.forEachChild(n,visit);}visit(ast);
|
||||
assert.equal(branches.length,2,'overview summary and storage details both consume PBS');
|
||||
const f=fixture(locale);
|
||||
for(const branch of branches) {
|
||||
for(const state of ['partial','network','empty','loading']) {
|
||||
const pbsStatus=state==='loading'||state==='network'?undefined:{datastores:state==='empty'?[]:null,errors:{datastores:'RAW DIAGNOSTIC'},error_details:{datastores:{code:'pbs_token_rejected'}}};
|
||||
const context={isPbs:true,pbsStatus,pbsError:state==='network'?failure('pbs_connect_failed'):undefined,datastores:pbsStatus?.datastores,
|
||||
t:f.t,require:f.requireModule,pbsDiagnostic:messages.pbsDiagnostic,PBSProblemMessage:f.load('AppImage/components/pbs-problem-message.tsx').PBSProblemMessage,
|
||||
DatastoreUsageRow:f.load('AppImage/components/pbs-datastore-usage.tsx').DatastoreUsageRow};
|
||||
for(const name of ['Card','CardHeader','CardTitle','CardContent','Database','Badge'])context[name]=name;
|
||||
vm.runInNewContext(compile('exports.result=('+branch+');'),{...context,exports:context});
|
||||
const rendered=text(context.result);
|
||||
if(state==='partial') {assert.ok(rendered.includes(f.t('pbs.errors.tokenRejected')));assert.ok(!rendered.includes(f.t('pbs.loading')));assert.ok(!flatten(context.result).some(n=>n.props?.className?.includes('animate-pulse')));}
|
||||
if(state==='network')assert.ok(rendered.includes(f.t('pbs.errors.connectFailed')));
|
||||
if(state==='empty')assert.ok(rendered.includes(f.t('pbs.noDatastores')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const backend=fs.readFileSync('AppImage/scripts/pbs_manager.py','utf8')+fs.readFileSync('AppImage/scripts/flask_pbs_routes.py','utf8');
|
||||
const codes=new Set([...backend.matchAll(/(?:code|rejected_code)="(pbs_\w+)"/g)].map(m=>m[1]));
|
||||
for(const locale of ['en','sk']) {
|
||||
const t=translate(locale);
|
||||
for(const code of codes) {
|
||||
if(code==='pbs_unknown')continue;
|
||||
const err=failure(code);err.body.params={count:32};
|
||||
assert.notEqual(messages.pbsErrorText(err,t),t('pbs.loadFailed'),code);
|
||||
assert.ok(!messages.pbsErrorText(err,t).includes('RAW'));
|
||||
}
|
||||
for(const err of [null,undefined,'raw',new Error('raw'),{body:null},{code:'constructor'},{code:'__proto__'},{code:'future'},
|
||||
...[null,0,-1,1.2,'32',Infinity,NaN,2**54].map(count=>({code:'pbs_connection_limit',params:{count}}))]) {
|
||||
assert.equal(messages.pbsErrorText(err,t),t('pbs.loadFailed'));
|
||||
}
|
||||
assert.equal(messages.pbsResultText('OK',t),t('pbs.states.ok'));
|
||||
assert.equal(messages.pbsResultText('WARNINGS: 2',t),t('pbs.states.warnings',{count:2}));
|
||||
assert.equal(messages.pbsResultText(undefined,t),t('pbs.states.unknown'));
|
||||
assert.equal(messages.pbsResultText('unknown',t),t('pbs.states.unknown'));
|
||||
assert.equal(messages.pbsResultText('TASK ERROR: raw',t),t('pbs.states.error'));
|
||||
for(const n of [-1,NaN,Infinity])assert.equal(messages.formatPBSDuration(n,t),'—');
|
||||
for(const [n,label] of [[0,'0 s'],[59,'59 s'],[60,'1 min 0 s'],[3599,'59 min 59 s'],[3600,'1 h 0 min'],[86399,'23 h 59 min'],[86400,'1 d 0 h']])assert.equal(messages.formatPBSDuration(n,t),label);
|
||||
assert.equal(messages.pbsTaskType('garbage_collection',t),t('pbs.taskTypes.garbageCollection'));
|
||||
assert.equal(messages.pbsTaskType('future',t),t('pbs.taskTypes.other',{type:'future'}));
|
||||
assert.equal(messages.pbsServiceState('constructor',t),t('pbs.serviceStates.unknown'));
|
||||
assert.equal(t('pbs.url'),locale==='sk'?'Adresa HTTPS':'HTTPS address');
|
||||
const diagnosticFixture=fixture(locale),problem=diagnosticFixture.load('AppImage/components/pbs-problem-message.tsx').PBSProblemMessage;
|
||||
for(const code of codes){
|
||||
if(code==='pbs_unknown')continue;
|
||||
const err=failure(code);err.body.params={count:32};
|
||||
const rendered=problem({problem:err,raw:'RAW DIAGNOSTIC'});
|
||||
assert.ok(!text(rendered).includes('RAW DIAGNOSTIC'),'Known '+code+' must not repeat an English application message');
|
||||
assert.ok(!flatten(rendered).some(n=>n.type==='details'));
|
||||
}
|
||||
for(const code of ['future','__proto__','constructor']){
|
||||
const rendered=problem({problem:failure(code),raw:'RAW <img> DIAGNOSTIC'});
|
||||
assert.ok(text(rendered).includes('RAW <img> DIAGNOSTIC'));assert.ok(flatten(rendered).some(n=>n.type==='details'));
|
||||
}
|
||||
const unknown=fixture(locale);unknown.status.tasks=[{upid:'unknown',endtime:1700000000,status:'unknown'}];unknown.status.sync=[{id:'unknown','last-run-state':'unknown'}];
|
||||
unknown.render();unknown.render();
|
||||
const unknownBadges=unknown.nodes().filter(n=>n.type==='Badge'&&text(n)===t('pbs.states.unknown'));
|
||||
assert.equal(unknownBadges.length,2);assert.ok(unknownBadges.every(n=>!n.props.className.includes('red-')));
|
||||
unknown.status.tasks=[{upid:'warn',endtime:1700000000,status:'WARNINGS: 3'},{upid:'raw',endtime:1700000000,status:'RAW EXTERNAL TASK ERROR'}];unknown.status.sync=[];unknown.render();
|
||||
const taskDetails=unknown.nodes().filter(n=>n.type==='details');assert.equal(taskDetails.length,1);assert.ok(text(taskDetails[0]).includes('RAW EXTERNAL TASK ERROR'));
|
||||
const f=fixture(locale);f.render();f.render();f.button(t('pbs.manage')).props.onClick();f.render();
|
||||
assert.equal(f.input('url').props['aria-describedby'],'pbs-url-help');
|
||||
assert.ok(f.text().includes(t('pbs.urlHelp')));assert.ok(f.text().includes(t('pbs.usernameHelp')));
|
||||
f.input('name').props.onChange({target:{value:'Draft'}});f.render();
|
||||
f.input('password').props.onChange({target:{value:'fake-one-time-password'}});f.render();
|
||||
f.button(t('pbs.cancel')).props.onClick();f.render();assert.equal(f.calls.length,0);
|
||||
f.button(t('pbs.manage')).props.onClick();f.render();assert.equal(f.input('password').props.value,'');
|
||||
f.input('name').props.onChange({target:{value:'Draft'}});f.render();f.input('url').props.onChange({target:{value:'http://bad.invalid'}});f.render();
|
||||
f.fail(failure('pbs_invalid_url'));await f.button(t('pbs.save')).props.onClick();f.render();
|
||||
assert.ok(f.text().includes(t('pbs.errors.invalidUrl')));assert.equal(f.input('url').props.value,'http://bad.invalid');
|
||||
assert.equal(f.calls[0].path,'/api/pbs/servers/login');assert.ok(!Object.hasOwn(JSON.parse(f.calls[0].body),'token_secret'));
|
||||
f.button(t('pbs.methodToken')).props.onClick();f.render();await f.button(t('pbs.save')).props.onClick();f.render();
|
||||
assert.ok(f.text().includes(t('pbs.tokenSecretHelp')));assert.ok(!f.text().includes(t('pbs.tokenSecretKeepHelp')));
|
||||
assert.equal(f.input('token_secret').props['aria-describedby'],'pbs-token_secret-help');
|
||||
assert.equal(f.calls[1].path,'/api/pbs/servers');assert.ok(!Object.hasOwn(JSON.parse(f.calls[1].body),'password'));
|
||||
f.select(t('pbs.editConnection')).props.onValueChange('saved');f.render();await f.button(t('pbs.remove')).props.onClick();f.render();
|
||||
assert.ok(f.text().includes(t('pbs.tokenSecretKeepHelp')));assert.ok(!f.text().includes(t('pbs.tokenSecretHelp')));
|
||||
assert.deepEqual(f.confirmations,[t('pbs.removeConfirm')]);assert.equal(f.calls.length,2,'Declined remove has no DELETE');
|
||||
const remove=fixture(locale,{confirm:true});remove.render();remove.render();remove.button(t('pbs.manage')).props.onClick();remove.render();remove.select(t('pbs.editConnection')).props.onValueChange('saved');remove.render();
|
||||
await remove.button(t('pbs.remove')).props.onClick();remove.render();assert.equal(remove.calls.length,1);assert.equal(remove.calls[0].method,'DELETE');
|
||||
const failedRemove=fixture(locale,{confirm:true});failedRemove.render();failedRemove.render();failedRemove.button(t('pbs.manage')).props.onClick();failedRemove.render();failedRemove.select(t('pbs.editConnection')).props.onValueChange('saved');failedRemove.render();failedRemove.fail(new Error('network failure'));
|
||||
await failedRemove.button(t('pbs.remove')).props.onClick();failedRemove.render();assert.ok(failedRemove.text().includes(t('pbs.removeFailed')));assert.equal(failedRemove.calls.length,1);
|
||||
const failedReload=fixture(locale);failedReload.render();failedReload.render();failedReload.button(t('pbs.manage')).props.onClick();failedReload.render();failedReload.failReload(new Error('raw reload'));
|
||||
for(const [field,value] of Object.entries({name:'Valid',url:'https://fixture.invalid',username:'root@pam',password:'fake'})){failedReload.input(field).props.onChange({target:{value}});failedReload.render();}
|
||||
assert.equal(failedReload.button(t('pbs.save')).props.disabled,false);
|
||||
await failedReload.button(t('pbs.save')).props.onClick();failedReload.render();assert.equal(failedReload.calls.length,1,'No automatic mutation retry after refresh fails');assert.ok(failedReload.text().includes(t('pbs.savedRefreshFailed')));
|
||||
const removeReload=fixture(locale,{confirm:true});removeReload.render();removeReload.render();removeReload.button(t('pbs.manage')).props.onClick();removeReload.render();removeReload.select(t('pbs.editConnection')).props.onValueChange('saved');removeReload.render();removeReload.failReload(new Error('network'));
|
||||
await removeReload.button(t('pbs.remove')).props.onClick();removeReload.render();assert.equal(removeReload.calls.length,1);assert.ok(removeReload.text().includes(t('pbs.removedRefreshFailed')));
|
||||
const store=f.load('AppImage/components/pbs-datastore-usage.tsx').PBSDatastoreCard({store:{name:'Offline',total:null,used:null,error:'RAW DIAGNOSTIC',error_code:'pbs_datastore_offline'}});
|
||||
assert.ok(text(store).includes(t('pbs.errors.datastoreOffline')));assert.ok(!text(store).includes('0 GB'));
|
||||
overview(locale);
|
||||
}
|
||||
console.log('PASS: all structured PBS error codes, EN/SK/unknown diagnostics, forms/login/token/remove/cancel, no automatic retry, task/service/duration states and both real overview branches');
|
||||
}
|
||||
main().catch(error=>{console.error(error);process.exitCode=1});
|
||||
module.exports={fixture,overview};
|
||||
Reference in New Issue
Block a user