mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge remote-tracking branch 'origin/develop' into HEAD
This commit is contained in:
@@ -15,7 +15,7 @@ class VmBackupJobsWiring(TestCase):
|
||||
source = ROUTES.read_text()
|
||||
routes = re.findall(r'@vm_backup_bp\.route\("([^"]+)", methods=\["(\w+)"\]\)\n@(\w+)\ndef (\w+)', source)
|
||||
self.assertEqual(len(routes), source.count('@vm_backup_bp.route('))
|
||||
self.assertEqual(len(routes), 8)
|
||||
self.assertEqual(len(routes), 10)
|
||||
for path, method, guard, name in routes:
|
||||
expected = 'require_auth' if method == 'GET' else 'require_admin_scope'
|
||||
self.assertEqual(guard, expected, f'{method} {path} ({name})')
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Apprise Slack and Matrix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-apprise-services.yml'
|
||||
- 'AppImage/scripts/notification_channels.py'
|
||||
- 'AppImage/components/apprise-service-help.tsx'
|
||||
- 'AppImage/components/notification-settings.tsx'
|
||||
- 'tests/test_apprise_services.py'
|
||||
- 'tests/test_apprise_service_help.cjs'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/notification_channels.py'
|
||||
- 'AppImage/components/apprise-service-help.tsx'
|
||||
- 'tests/test_apprise_services.py'
|
||||
- 'tests/test_apprise_service_help.cjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
apprise-services:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
apprise: ['1.7.0', '2.0.1']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Install fixture dependencies
|
||||
run: |
|
||||
python -m pip install 'apprise==${{ matrix.apprise }}'
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
- name: Test real provider plugins with mocked HTTP
|
||||
run: python -B -m unittest discover -s tests -p test_apprise_services.py -v
|
||||
- name: Render provider help and locale fallback
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||
run: node tests/test_apprise_service_help.cjs
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Backup storage retention
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-backup-storage-retention.yml'
|
||||
- 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||
- 'AppImage/components/backup-storage-retention.tsx'
|
||||
- 'AppImage/components/backup-tab.tsx'
|
||||
- 'AppImage/components/vm-backup-jobs.tsx'
|
||||
- 'AppImage/lib/backup-retention.ts'
|
||||
- 'tests/test_vm_backup_jobs_api.py'
|
||||
- 'tests/test_backup_storage_retention.cjs'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||
- 'AppImage/components/backup-storage-retention.tsx'
|
||||
- 'AppImage/lib/backup-retention.ts'
|
||||
- 'tests/test_vm_backup_jobs_api.py'
|
||||
- 'tests/test_backup_storage_retention.cjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
storage-retention:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Install fixture dependencies
|
||||
run: |
|
||||
python -m pip install 'Flask==3.1.2'
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
- name: Test native job and storage configuration APIs
|
||||
run: python -B -m unittest discover -s tests -p test_vm_backup_jobs_api.py -v
|
||||
- name: Test shared retention properties
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||
run: node tests/test_backup_storage_retention.cjs
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Figurine installation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-figurine.yml'
|
||||
- 'tests/test_figurine_installation.py'
|
||||
- 'scripts/post_install/customizable_post_install.sh'
|
||||
- 'AppImage/scripts/post_install_versions.py'
|
||||
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- '.github/workflows/test-figurine.yml'
|
||||
- 'tests/test_figurine_installation.py'
|
||||
- 'scripts/post_install/customizable_post_install.sh'
|
||||
- 'AppImage/scripts/post_install_versions.py'
|
||||
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
installer-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Check shell syntax and isolated installer regressions
|
||||
run: |
|
||||
bash -n scripts/post_install/customizable_post_install.sh
|
||||
python3 -B -m unittest discover -s tests -p test_figurine_installation.py -v
|
||||
- name: Install ARM user-mode emulation for release smoke tests
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends qemu-user
|
||||
- name: Verify and run the pinned AMD64, ARM64 and ARMv7 release binaries
|
||||
env:
|
||||
FIGURINE_VERSION: '2.1.1'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scratch="$(mktemp -d)"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
while read -r arch sha; do
|
||||
archive="figurine_linux_${arch}_v${FIGURINE_VERSION}.tar.gz"
|
||||
target="$scratch/$arch"
|
||||
mkdir -p "$target"
|
||||
curl --fail --location --retry 3 --output "$target/$archive" \
|
||||
"https://github.com/arsham/figurine/releases/download/v${FIGURINE_VERSION}/$archive"
|
||||
printf '%s %s\n' "$sha" "$target/$archive" | sha256sum --check
|
||||
tar -xf "$target/$archive" -C "$target"
|
||||
runner=()
|
||||
case "$arch" in
|
||||
arm64) runner=(qemu-aarch64) ;;
|
||||
arm) runner=(qemu-arm) ;;
|
||||
esac
|
||||
"${runner[@]}" "$target/deploy/figurine" --version | tee "$target/version"
|
||||
grep -F "figurine version v$FIGURINE_VERSION" "$target/version"
|
||||
"${runner[@]}" "$target/deploy/figurine" -f "3d.flf" "ProxMenux-test" > "$target/banner"
|
||||
test -s "$target/banner"
|
||||
printf 'PASS: %s version and login banner\n' "$arch"
|
||||
done <<'ASSETS'
|
||||
amd64 f2b1c076942c94009d133df0f304a234a0994bf590b40f2927668324d578306a
|
||||
arm64 611550ddb31a75437be3f8587bcdd029d0d890f4d820b7f494a2c6615fecba10
|
||||
arm 429e66bded486d49f730c4a6559e728cd1e9a163a0f56a85867e2cbec010171d
|
||||
ASSETS
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// These are examples, never defaults: opening help must not replace a saved
|
||||
// destination or put placeholder credentials into the notification settings.
|
||||
export function AppriseServiceHelp() {
|
||||
const t = useT()
|
||||
return <details className="rounded-md border border-border bg-muted/20 p-2 text-xs">
|
||||
<summary className="cursor-pointer font-medium">{t("settings.notifications.ui.appriseServiceHelp")}</summary>
|
||||
<div className="grid gap-3 pt-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<h4 className="font-medium">Slack</h4>
|
||||
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseSlackSteps")}</p>
|
||||
<code className="block break-all rounded bg-background p-2 text-[10px]">https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C</code>
|
||||
<a href="https://appriseit.com/services/slack/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
|
||||
{t("settings.notifications.ui.appriseSlackDocs")} <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<h4 className="font-medium">Matrix</h4>
|
||||
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseMatrixSteps")}</p>
|
||||
<code className="block break-all rounded bg-background p-2 text-[10px]">matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com</code>
|
||||
<a href="https://appriseit.com/services/matrix/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
|
||||
{t("settings.notifications.ui.appriseMatrixDocs")} <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-muted-foreground">{t("settings.notifications.ui.appriseServiceFinish")}</p>
|
||||
</details>
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import { HardDrive, Loader2, Pencil, RefreshCw } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { buildRetention, parseRetention, RETENTION } from "../lib/backup-retention"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface StorageRetention { id: string; type: string; prune_backups: string; digest: string }
|
||||
const fetcher = (url: string) => fetchApi<{ storages: StorageRetention[] }>(url)
|
||||
|
||||
export function BackupStorageRetention() {
|
||||
const t = useT()
|
||||
const { data, error, mutate, isValidating } = useSWR("/api/vm-backup-storages", fetcher)
|
||||
const [editing, setEditing] = useState<StorageRetention | null>(null)
|
||||
const [fields, setFields] = useState<Record<string, string>>({})
|
||||
const [keepAll, setKeepAll] = useState(false)
|
||||
const [useDefault, setUseDefault] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [saveError, setSaveError] = useState("")
|
||||
|
||||
function edit(storage: StorageRetention) {
|
||||
const policy = parseRetention(storage.prune_backups)
|
||||
setFields(policy.fields)
|
||||
setKeepAll(policy.keepAll)
|
||||
setUseDefault(!storage.prune_backups)
|
||||
setSaveError("")
|
||||
setMessage("")
|
||||
setEditing(storage)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editing || busy) return
|
||||
const prune = useDefault ? "" : buildRetention(fields, keepAll)
|
||||
if (!useDefault && !prune) return setSaveError(t("backup.storageRetention.choosePolicy"))
|
||||
setBusy(true)
|
||||
setSaveError("")
|
||||
try {
|
||||
await fetchApi(`/api/vm-backup-storages/${encodeURIComponent(editing.id)}`, {
|
||||
method: "PUT", body: JSON.stringify({ digest: editing.digest, prune_backups: prune }),
|
||||
})
|
||||
setMessage(t("backup.storageRetention.saved"))
|
||||
setEditing(null)
|
||||
await mutate()
|
||||
} catch (error) {
|
||||
setSaveError(`${t("backup.storageRetention.saveFailed")} ${error instanceof Error ? error.message : ""}`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <Card className="border-border bg-card/50">
|
||||
<CardHeader className="pb-3 flex-row items-center justify-between gap-2">
|
||||
<CardTitle className="text-base flex items-center gap-2"><HardDrive className="h-4 w-4" />{t("backup.storageRetention.title")}</CardTitle>
|
||||
<Button variant="ghost" size="sm" disabled={isValidating} onClick={() => { void mutate().catch(() => {}) }}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isValidating ? "animate-spin" : ""}`} />{t("backup.storageRetention.refresh")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t("backup.storageRetention.description")}</p>
|
||||
{message && <p role="status" className="text-xs text-emerald-500">{message}</p>}
|
||||
{error ? <p role="alert" className="text-xs text-destructive">{t("backup.storageRetention.loadFailed")}</p> : !data ?
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> : !data.storages.length ?
|
||||
<p className="text-xs text-muted-foreground">{t("backup.storageRetention.empty")}</p> :
|
||||
data.storages.map(storage => <div key={storage.id} className="flex flex-wrap gap-2 items-center border rounded-lg p-3">
|
||||
<span className="font-medium text-sm">{storage.id}</span><Badge variant="outline">{storage.type}</Badge>
|
||||
<span className="text-xs font-mono text-muted-foreground break-all">{storage.prune_backups || t("backup.storageRetention.default")}</span>
|
||||
<Button variant="outline" size="sm" className="ml-auto gap-1" onClick={() => edit(storage)} disabled={!storage.digest || busy}>
|
||||
<Pencil className="h-3 w-3" />{t("backup.storageRetention.edit")}
|
||||
</Button>
|
||||
</div>)}
|
||||
</CardContent>
|
||||
<Dialog open={!!editing} onOpenChange={open => { if (!open && !busy) setEditing(null) }}>
|
||||
<DialogContent className="bg-accent [&_input]:bg-background">
|
||||
<DialogHeader><DialogTitle>{t("backup.storageRetention.editTitle", { storage: editing?.id ?? "" })}</DialogTitle>
|
||||
<DialogDescription>{t("backup.storageRetention.effect")}</DialogDescription></DialogHeader>
|
||||
<Label className="flex items-center gap-2"><Checkbox checked={useDefault} disabled={busy} onCheckedChange={checked => {
|
||||
setUseDefault(checked === true)
|
||||
if (checked) { setKeepAll(false); setFields({}) }
|
||||
}} />{t("backup.storageRetention.useDefault")}</Label>
|
||||
<Label className="flex items-center gap-2"><Checkbox checked={keepAll} disabled={useDefault || busy} onCheckedChange={checked => {
|
||||
setKeepAll(checked === true)
|
||||
if (checked) setFields({})
|
||||
}} />{t("backup.storageRetention.keepAll")}</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{RETENTION.map(key => <div key={key} className="space-y-1">
|
||||
<Label htmlFor={`storage-retention-${key}`}>{t(`backup.retention.keep${key[0].toUpperCase()}${key.slice(1)}`)}</Label>
|
||||
<Input id={`storage-retention-${key}`} type="number" min={1} max={99999} step={1} disabled={useDefault || keepAll || busy}
|
||||
value={fields[key] ?? ""} onChange={event => setFields(previous => ({ ...previous, [key]: event.target.value }))} />
|
||||
</div>)}
|
||||
</div>
|
||||
{saveError && <p role="alert" className="text-xs text-destructive break-words">{saveError}</p>}
|
||||
<DialogFooter><Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>{t("backup.storageRetention.cancel")}</Button>
|
||||
<Button disabled={busy} onClick={save}>{busy && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}{t("backup.storageRetention.save")}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useState } from "react"
|
||||
import { DatabaseBackup } from "lucide-react"
|
||||
import { HostBackup } from "./host-backup"
|
||||
import { VmBackupJobs } from "./vm-backup-jobs"
|
||||
import { BackupStorageRetention } from "./backup-storage-retention"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
type BackupView = "host" | "guests"
|
||||
@@ -51,7 +52,7 @@ export function BackupTab() {
|
||||
<div className={view === "host" ? "" : "hidden"}>
|
||||
<HostBackup />
|
||||
</div>
|
||||
{view === "guests" && <VmBackupJobs />}
|
||||
{view === "guests" && <><VmBackupJobs /><BackupStorageRetention /></>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { AppriseServiceHelp } from "./apprise-service-help"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
@@ -2061,6 +2062,7 @@ export function NotificationSettings() {
|
||||
Enable toggle + Test button pattern as the other
|
||||
channels. */}
|
||||
<TabsContent value="apprise" className="space-y-3 pt-2">
|
||||
<AppriseServiceHelp />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs font-medium">{t("settings.notifications.ui.enableApprise")}</Label>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { cancelButtonClass } from "../lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { RETENTION, parseRetention, buildRetention } from "../lib/backup-retention"
|
||||
|
||||
interface VmBackupJob {
|
||||
id: string
|
||||
@@ -70,24 +71,6 @@ const EMPTY_FORM: FormState = {
|
||||
mode: "snapshot", compress: "zstd", retention: {}, keepAll: false, notes: "{{guestname}}", attached: [],
|
||||
}
|
||||
|
||||
// The limits a job can keep, in the order Proxmox lists them.
|
||||
const RETENTION = ["last", "hourly", "daily", "weekly", "monthly", "yearly"] as const
|
||||
|
||||
const parseRetention = (prune: string) => {
|
||||
const fields: Record<string, string> = {}
|
||||
let keepAll = false
|
||||
for (const part of prune.split(",")) {
|
||||
const [key, value] = part.trim().split("=")
|
||||
if (key === "keep-all") keepAll = value === "1"
|
||||
else if (key?.startsWith("keep-") && Number(value) > 0) fields[key.slice(5)] = String(Number(value))
|
||||
}
|
||||
return { fields, keepAll }
|
||||
}
|
||||
|
||||
const buildRetention = (fields: Record<string, string>, keepAll: boolean) => {
|
||||
const parts = RETENTION.filter((key) => Number(fields[key]) > 0).map((key) => `keep-${key}=${Number(fields[key])}`)
|
||||
return parts.length ? parts.join(",") : keepAll ? "keep-all=1" : ""
|
||||
}
|
||||
|
||||
const isOn = (value: unknown) => value === 1 || value === true || value === "1"
|
||||
const jobEnabled = (job: VmBackupJob) => job.enabled === undefined || isOn(job.enabled)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Native Proxmox retention properties, shared by storage and job editors.
|
||||
export const RETENTION = ["last", "hourly", "daily", "weekly", "monthly", "yearly"] as const
|
||||
|
||||
export function parseRetention(prune: string) {
|
||||
const fields: Record<string, string> = {}
|
||||
let keepAll = false
|
||||
for (const part of prune.split(",")) {
|
||||
const [key, value] = part.trim().split("=")
|
||||
if (key === "keep-all") keepAll = value === "1"
|
||||
else if (key?.startsWith("keep-") && Number(value) > 0) fields[key.slice(5)] = String(Number(value))
|
||||
}
|
||||
return { fields, keepAll }
|
||||
}
|
||||
|
||||
export function buildRetention(fields: Record<string, string>, keepAll: boolean) {
|
||||
const parts = RETENTION.filter(key => Number(fields[key]) > 0).map(key => `keep-${key}=${Number(fields[key])}`)
|
||||
return parts.length ? parts.join(",") : keepAll ? "keep-all=1" : ""
|
||||
}
|
||||
@@ -1318,7 +1318,7 @@
|
||||
"Samstag"
|
||||
],
|
||||
"runSkippedStatus": "Lauf übersprungen",
|
||||
"reasonStoppedContainer": "Container ist gestoppt;Das geplante Update wurde übersprungen.",
|
||||
"reasonStoppedContainer": "Container ist gestoppt; das geplante Update wurde übersprungen.",
|
||||
"reasonStoppedContainerHistorical": "Der Container wurde zum geplanten Aktualisierungszeitpunkt gestoppt."
|
||||
},
|
||||
"cronChip": {
|
||||
@@ -2125,7 +2125,13 @@
|
||||
"inactiveFooter": "Aktivieren Sie Benachrichtigungen, um System-, Gesundheits- und Sicherheitswarnungen über die konfigurierten Kanäle zu erhalten.",
|
||||
"notificationLanguage": "Benachrichtigungssprache",
|
||||
"selectNotificationLanguage": "Benachrichtigungssprache auswählen",
|
||||
"notificationLanguageHint": "Wird für deterministische Laufzeitbenachrichtigungen verwendet, auch wenn KI deaktiviert ist."
|
||||
"notificationLanguageHint": "Wird für deterministische Laufzeitbenachrichtigungen verwendet, auch wenn KI deaktiviert ist.",
|
||||
"appriseServiceHelp": "Slack und Matrix einrichten",
|
||||
"appriseSlackSteps": "Erstellen Sie in Ihrer Slack-App einen eingehenden Webhook für den Zielkanal und fügen Sie dessen vollständige HTTPS-URL in das Feld Apprise-URL ein. Eine Umwandlung ist nicht erforderlich.",
|
||||
"appriseSlackDocs": "Slack-URL-Formate und Einrichtung",
|
||||
"appriseMatrixSteps": "Verwenden Sie ein Benachrichtigungskonto, das in den Raum schreiben darf. Ersetzen Sie den Beispielbenutzer, das Passwort, den Homeserver und die vollständige Raum-ID; kodieren Sie Sonderzeichen in Zugangsdaten für URLs. Das Schema matrixs verwendet HTTPS. Informationen zur Unterstützung von Tokens, SSO und verschlüsselten Räumen in Ihrer installierten Apprise-Version finden Sie in der Dokumentation.",
|
||||
"appriseMatrixDocs": "Matrix-URL-Formate und Einrichtung",
|
||||
"appriseServiceFinish": "Aktivieren Sie Apprise, geben Sie Ihre URL ein, wählen Sie Benachrichtigungskategorien oder einzelne Ereignisse aus, speichern Sie und verwenden Sie anschließend Test senden. Diese Beispiele ändern Ihre gespeicherte URL nicht. Dieser Apprise-Kanal hat ein Ziel; verwenden Sie die Apprise-API für mehrere Dienste gleichzeitig."
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI-Verbesserung",
|
||||
@@ -4770,6 +4776,24 @@
|
||||
"thursday": "Donnerstag",
|
||||
"tuesday": "Dienstag",
|
||||
"wednesday": "Mittwoch"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Aufbewahrung im Speicher",
|
||||
"keepAll": "Alle Sicherungen behalten",
|
||||
"description": "In Proxmox gespeicherte Standardaufbewahrung für Sicherungen. Sicherungsaufträge mit einer eigenen Richtlinie überschreiben diesen Standard.",
|
||||
"refresh": "Aktualisieren",
|
||||
"saved": "Aufbewahrungsrichtlinie des Speichers gespeichert. Es wurde keine Bereinigung ausgeführt.",
|
||||
"choosePolicy": "Wählen Sie Alle behalten oder mindestens eine Aufbewahrungsanzahl größer als null.",
|
||||
"saveFailed": "Aufbewahrungsrichtlinie des Speichers konnte nicht gespeichert werden. Aktualisieren Sie die Liste, falls die Konfiguration geändert wurde.",
|
||||
"loadFailed": "Aufbewahrungsrichtlinie des Speichers konnte nicht geladen werden.",
|
||||
"empty": "Keine Sicherungsspeicher gefunden.",
|
||||
"default": "Proxmox-Standard",
|
||||
"edit": "Bearbeiten",
|
||||
"editTitle": "Aufbewahrung für {storage}",
|
||||
"effect": "Gilt für zukünftige Sicherungsläufe, die diese Speicherrichtlinie übernehmen. Das Speichern löscht keine vorhandenen Sicherungen. Geschützte Sicherungen bleiben geschützt.",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Aufbewahrung speichern",
|
||||
"useDefault": "Proxmox-Standard verwenden (keine Richtlinie für diesen Speicher)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
|
||||
@@ -2111,6 +2111,12 @@
|
||||
"pushoverCredentialsHint": "Create an application in Pushover and copy its API token and your user or group key.",
|
||||
"enableApprise": "Enable Apprise",
|
||||
"urlFormats": "+URL formats",
|
||||
"appriseServiceHelp": "Slack and Matrix setup",
|
||||
"appriseSlackSteps": "Create an Incoming Webhook in your Slack app for the target channel, then paste its complete HTTPS URL into Apprise URL. No conversion is needed.",
|
||||
"appriseSlackDocs": "Slack URL formats and setup",
|
||||
"appriseMatrixSteps": "Use a notification account that can post to the room. Replace the example user, password, homeserver and full room ID; URL-encode special characters in credentials. The matrixs scheme uses HTTPS. See the documentation for token, SSO and encrypted-room support in your installed Apprise version.",
|
||||
"appriseMatrixDocs": "Matrix URL formats and setup",
|
||||
"appriseServiceFinish": "Enable Apprise, enter your URL, choose the notification categories or individual events, save, then use Send Test. These examples do not change your saved URL. This Apprise channel has one destination; use Apprise API for several services at once.",
|
||||
"appriseUrl": "Apprise URL",
|
||||
"showUrl": "Show URL",
|
||||
"hideUrl": "Hide URL",
|
||||
@@ -3955,6 +3961,24 @@
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"storageRetention": {
|
||||
"useDefault": "Use the Proxmox default (no policy on this storage)",
|
||||
"title": "Storage retention",
|
||||
"keepAll": "Keep all backups",
|
||||
"description": "Default backup retention stored in Proxmox. Backup jobs with an explicit policy override this default.",
|
||||
"refresh": "Refresh",
|
||||
"saved": "Storage retention saved. No pruning was run.",
|
||||
"choosePolicy": "Choose Keep all or at least one positive retention count.",
|
||||
"saveFailed": "Could not save storage retention. Refresh the list if the configuration changed.",
|
||||
"loadFailed": "Could not load storage retention.",
|
||||
"empty": "No backup storages found.",
|
||||
"default": "Proxmox default",
|
||||
"edit": "Edit",
|
||||
"editTitle": "Retention for {storage}",
|
||||
"effect": "Applies to future backup runs that inherit this storage policy. Saving does not delete existing backups. Protected backups remain protected.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save retention"
|
||||
},
|
||||
"actions": {
|
||||
"addAnother": "Add another",
|
||||
"addPath": "Add path",
|
||||
|
||||
@@ -607,7 +607,7 @@
|
||||
"controllerBusyTime": "Minutos totales que el controlador pasó procesando comandos de E/S. Los valores altos indican una carga de trabajo pesada y sostenida.",
|
||||
"errorLogEntries": "Número de entradas en el registro de información de errores. A menudo incluye artefactos benignos de autoevaluación; verifique con Errores de Medios.",
|
||||
"warningTempTime": "Total de minutos transcurridos por encima del umbral de temperatura de advertencia. Provoca una limitación del rendimiento. El cero es ideal.",
|
||||
"criticalTempTime": "Minutos totales pasados por encima del umbral de temperatura crítica. La unidad puede apagarse para evitar daños. Siempre debe ser cero.",
|
||||
"criticalTempTime": "Minutos totales pasados por encima del umbral de temperatura crítica. La unidad puede apagarse para evitar daños. Siempre debe ser cero.",
|
||||
"warningCompositeTemperatureTime": "Minutos totales que la temperatura compuesta superó el umbral de advertencia.",
|
||||
"criticalCompositeTemperatureTime": "Minutos totales la temperatura compuesta superó el umbral crítico. Debe ser cero.",
|
||||
"thermalManagementT1TransCount": "Número de veces que la unidad entró en aceleración térmica ligera (T1). Indica problemas de enfriamiento.",
|
||||
@@ -1318,8 +1318,8 @@
|
||||
"sábado"
|
||||
],
|
||||
"runSkippedStatus": "ejecución omitida",
|
||||
"reasonStoppedContainer": "el contenedor está detenido;Se omitió la actualización programada.",
|
||||
"reasonStoppedContainerHistorical": "el contenedor se detuvo a la hora de actualización programada."
|
||||
"reasonStoppedContainer": "El contenedor está parado; se ha omitido la actualización programada.",
|
||||
"reasonStoppedContainerHistorical": "El contenedor estaba parado a la hora de la actualización programada."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron del host detectado",
|
||||
@@ -2125,7 +2125,13 @@
|
||||
"inactiveFooter": "Habilite notificaciones para recibir alertas del sistema, de salud y de seguridad a través de los canales configurados.",
|
||||
"notificationLanguage": "Idioma de notificación",
|
||||
"selectNotificationLanguage": "Seleccione el idioma de notificación",
|
||||
"notificationLanguageHint": "Se utiliza para notificaciones deterministas en tiempo de ejecución incluso cuando la IA está deshabilitada."
|
||||
"notificationLanguageHint": "Se utiliza para notificaciones deterministas en tiempo de ejecución incluso cuando la IA está deshabilitada.",
|
||||
"appriseServiceHelp": "Configuración de Slack y Matrix",
|
||||
"appriseSlackSteps": "Crea un webhook entrante en tu aplicación de Slack para el canal de destino y pega su URL HTTPS completa en URL de Apprise. No hace falta convertirla.",
|
||||
"appriseSlackDocs": "Formatos de URL y configuración de Slack",
|
||||
"appriseMatrixSteps": "Utiliza una cuenta de notificaciones que pueda publicar en la sala. Sustituye el usuario, la contraseña, el servidor de origen y el ID completo de la sala del ejemplo; codifica para URL los caracteres especiales de las credenciales. El esquema matrixs utiliza HTTPS. Consulta en la documentación la compatibilidad con tokens, SSO y salas cifradas de tu versión de Apprise.",
|
||||
"appriseMatrixDocs": "Formatos de URL y configuración de Matrix",
|
||||
"appriseServiceFinish": "Activa Apprise, introduce tu URL, elige las categorías de notificación o los eventos individuales, guarda y utiliza Enviar prueba. Estos ejemplos no cambian tu URL guardada. Este canal de Apprise tiene un único destino; utiliza la API de Apprise para varios servicios a la vez."
|
||||
},
|
||||
"ai": {
|
||||
"title": "mejora de la IA",
|
||||
@@ -3580,8 +3586,8 @@
|
||||
"short": {
|
||||
"hour": "Pasada 1 hora",
|
||||
"day": "Pasadas 24h",
|
||||
"week": "Pasados los 7 días",
|
||||
"month": "Pasados los 30 días",
|
||||
"week": "Pasados los 7 días",
|
||||
"month": "Pasados los 30 días",
|
||||
"year": "Último 1 año"
|
||||
},
|
||||
"last": {
|
||||
@@ -4770,6 +4776,24 @@
|
||||
"thursday": "Jueves",
|
||||
"tuesday": "Martes",
|
||||
"wednesday": "Miércoles"
|
||||
},
|
||||
"storageRetention": {
|
||||
"useDefault": "Usar el valor por defecto de Proxmox (sin política en este almacenamiento)",
|
||||
"title": "Retención del almacenamiento",
|
||||
"keepAll": "Conservar todos los backups",
|
||||
"description": "Retención por defecto de los backups, guardada en Proxmox. Las tareas de backup con su propia política tienen prioridad sobre ella.",
|
||||
"refresh": "Actualizar",
|
||||
"saved": "Retención guardada. No se ha purgado ningún backup.",
|
||||
"choosePolicy": "Elige Conservar todos o al menos un número de retención mayor que cero.",
|
||||
"saveFailed": "No se pudo guardar la retención del almacenamiento. Actualiza la lista si ha cambiado la configuración.",
|
||||
"loadFailed": "No se pudo cargar la retención del almacenamiento.",
|
||||
"empty": "No hay almacenamientos de backups.",
|
||||
"default": "Valor por defecto de Proxmox",
|
||||
"edit": "Editar",
|
||||
"editTitle": "Retención de {storage}",
|
||||
"effect": "Se aplica a los próximos backups que hereden la política de este almacenamiento. Guardar no elimina los backups existentes, y los protegidos siguen protegidos.",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar retención"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
|
||||
@@ -1318,8 +1318,8 @@
|
||||
"Samedi"
|
||||
],
|
||||
"runSkippedStatus": "exécution ignorée",
|
||||
"reasonStoppedContainer": "le conteneur est arrêté ;la mise à jour programmée a été ignorée.",
|
||||
"reasonStoppedContainerHistorical": "le conteneur a été arrêté à l'heure de mise à jour prévue."
|
||||
"reasonStoppedContainer": "Le conteneur est arrêté ; la mise à jour programmée a été ignorée.",
|
||||
"reasonStoppedContainerHistorical": "Le conteneur était arrêté à l'heure de la mise à jour programmée."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron hôte détecté",
|
||||
@@ -2125,7 +2125,13 @@
|
||||
"inactiveFooter": "Activez les notifications pour recevoir des alertes système, de santé et de sécurité via les canaux configurés.",
|
||||
"notificationLanguage": "Langue de notification",
|
||||
"selectNotificationLanguage": "Sélectionnez la langue de notification",
|
||||
"notificationLanguageHint": "Utilisé pour les notifications d'exécution déterministes, même lorsque l'IA est désactivée."
|
||||
"notificationLanguageHint": "Utilisé pour les notifications d'exécution déterministes, même lorsque l'IA est désactivée.",
|
||||
"appriseServiceHelp": "Configuration de Slack et Matrix",
|
||||
"appriseSlackSteps": "Créez un webhook entrant dans votre application Slack pour le canal cible, puis collez son URL HTTPS complète dans le champ URL Apprise. Aucune conversion n'est nécessaire.",
|
||||
"appriseSlackDocs": "Formats d'URL et configuration de Slack",
|
||||
"appriseMatrixSteps": "Utilisez un compte de notification autorisé à publier dans le salon. Remplacez l'utilisateur, le mot de passe, le serveur d'origine et l'identifiant complet du salon de l'exemple ; encodez les caractères spéciaux des identifiants pour les URL. Le schéma matrixs utilise HTTPS. Consultez la documentation pour la prise en charge des jetons, du SSO et des salons chiffrés dans votre version d'Apprise.",
|
||||
"appriseMatrixDocs": "Formats d'URL et configuration de Matrix",
|
||||
"appriseServiceFinish": "Activez Apprise, saisissez votre URL, choisissez les catégories de notification ou les événements individuels, enregistrez, puis utilisez Envoyer un test. Ces exemples ne modifient pas votre URL enregistrée. Ce canal Apprise possède une destination ; utilisez l'API Apprise pour plusieurs services à la fois."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Amélioration de l'IA",
|
||||
@@ -4770,6 +4776,24 @@
|
||||
"thursday": "Jeudi",
|
||||
"tuesday": "Mardi",
|
||||
"wednesday": "Mercredi"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Rétention du stockage",
|
||||
"keepAll": "Conserver toutes les sauvegardes",
|
||||
"description": "Rétention par défaut des sauvegardes enregistrée dans Proxmox. Les tâches de sauvegarde avec une politique explicite remplacent cette valeur par défaut.",
|
||||
"refresh": "Actualiser",
|
||||
"saved": "Rétention du stockage enregistrée. Aucune purge n'a été exécutée.",
|
||||
"choosePolicy": "Choisissez Tout conserver ou au moins un nombre de rétention supérieur à zéro.",
|
||||
"saveFailed": "Impossible d'enregistrer la rétention du stockage. Actualisez la liste si la configuration a changé.",
|
||||
"loadFailed": "Impossible de charger la rétention du stockage.",
|
||||
"empty": "Aucun stockage de sauvegarde trouvé.",
|
||||
"default": "Valeur par défaut de Proxmox",
|
||||
"edit": "Modifier",
|
||||
"editTitle": "Rétention de {storage}",
|
||||
"effect": "S'applique aux futures sauvegardes qui héritent de cette politique de stockage. L'enregistrement ne supprime pas les sauvegardes existantes. Les sauvegardes protégées restent protégées.",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer la rétention",
|
||||
"useDefault": "Utiliser la valeur par défaut de Proxmox (aucune politique sur ce stockage)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
|
||||
@@ -1317,9 +1317,9 @@
|
||||
"Venerdì",
|
||||
"Sabato"
|
||||
],
|
||||
"runSkippedStatus": "corsa saltata",
|
||||
"reasonStoppedContainer": "il contenitore è fermo;l'aggiornamento pianificato è stato saltato.",
|
||||
"reasonStoppedContainerHistorical": "il contenitore è stato arrestato all'ora di aggiornamento pianificata."
|
||||
"runSkippedStatus": "esecuzione saltata",
|
||||
"reasonStoppedContainer": "Il contenitore è fermo; l'aggiornamento pianificato è stato saltato.",
|
||||
"reasonStoppedContainerHistorical": "Il contenitore era fermo all'ora dell'aggiornamento pianificato."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron host rilevato",
|
||||
@@ -2125,7 +2125,13 @@
|
||||
"inactiveFooter": "Abilita le notifiche per ricevere avvisi di sistema, integrità e sicurezza attraverso i canali configurati.",
|
||||
"notificationLanguage": "Lingua notifiche",
|
||||
"selectNotificationLanguage": "Seleziona lingua notifiche",
|
||||
"notificationLanguageHint": "Usata per le notifiche runtime deterministiche anche quando l’IA è disabilitata."
|
||||
"notificationLanguageHint": "Usata per le notifiche runtime deterministiche anche quando l’IA è disabilitata.",
|
||||
"appriseServiceHelp": "Configurazione di Slack e Matrix",
|
||||
"appriseSlackSteps": "Crea un webhook in ingresso nell'app Slack per il canale di destinazione, quindi incolla il suo URL HTTPS completo nel campo URL di Apprise. Non è necessaria alcuna conversione.",
|
||||
"appriseSlackDocs": "Formati URL e configurazione di Slack",
|
||||
"appriseMatrixSteps": "Usa un account di notifica autorizzato a pubblicare nella stanza. Sostituisci utente, password, homeserver e ID completo della stanza dell'esempio; codifica per URL i caratteri speciali nelle credenziali. Lo schema matrixs usa HTTPS. Consulta la documentazione per il supporto di token, SSO e stanze cifrate nella versione di Apprise installata.",
|
||||
"appriseMatrixDocs": "Formati URL e configurazione di Matrix",
|
||||
"appriseServiceFinish": "Abilita Apprise, inserisci il tuo URL, scegli le categorie di notifica o i singoli eventi, salva e poi usa Invia test. Questi esempi non modificano l'URL salvato. Questo canale Apprise ha una sola destinazione; usa l'API Apprise per più servizi contemporaneamente."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Miglioramento con IA",
|
||||
@@ -4770,6 +4776,24 @@
|
||||
"thursday": "Giovedì",
|
||||
"tuesday": "Martedì",
|
||||
"wednesday": "Mercoledì"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Conservazione dell'archiviazione",
|
||||
"keepAll": "Mantieni tutti i backup",
|
||||
"description": "Conservazione predefinita dei backup salvata in Proxmox. Le attività di backup con una politica esplicita hanno la precedenza su questo valore.",
|
||||
"refresh": "Aggiorna",
|
||||
"saved": "Politica di conservazione dell'archiviazione salvata. Non è stata eseguita alcuna pulizia.",
|
||||
"choosePolicy": "Scegli Mantieni tutti oppure almeno un numero di conservazione maggiore di zero.",
|
||||
"saveFailed": "Impossibile salvare la politica di conservazione dell'archiviazione. Aggiorna l'elenco se la configurazione è cambiata.",
|
||||
"loadFailed": "Impossibile caricare la politica di conservazione dell'archiviazione.",
|
||||
"empty": "Nessuno spazio di archiviazione dei backup trovato.",
|
||||
"default": "Valore predefinito di Proxmox",
|
||||
"edit": "Modifica",
|
||||
"editTitle": "Conservazione per {storage}",
|
||||
"effect": "Si applica alle future esecuzioni dei backup che ereditano questa politica di archiviazione. Il salvataggio non elimina i backup esistenti. I backup protetti rimangono protetti.",
|
||||
"cancel": "Annulla",
|
||||
"save": "Salva conservazione",
|
||||
"useDefault": "Usa l'impostazione predefinita di Proxmox (nessuna policy su questo storage)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
|
||||
@@ -353,7 +353,7 @@
|
||||
"downloadJson": "Baixar JSON",
|
||||
"delete": "Excluir",
|
||||
"confirmDelete": "Excluir este registro de teste?",
|
||||
"note": "Os resultados dos testes são armazenados localmente e usados para gerar relatórios SMART detalhados."
|
||||
"note": "Os resultados dos testes são armazenados localmente e usados para gerar relatórios SMART detalhados."
|
||||
},
|
||||
"smartReport": {
|
||||
"title": "Relatório de saúde SMART",
|
||||
@@ -595,8 +595,8 @@
|
||||
"availableSpare": "Porcentagem de blocos NAND sobressalentes restantes para substituição de blocos defeituosos. Um alerta é acionado abaixo do limite.",
|
||||
"availableSpareThreshold": "Mínimo definido pelo fabricante para peças sobressalentes disponíveis. Abaixo disso, a unidade sinaliza um aviso crítico.",
|
||||
"percentageUsed": "Impulsione a estimativa da resistência consumida com base nos ciclos de gravação reais versus nominais. 100% significa que o TBW avaliado foi atingido; a unidade pode continuar funcionando além disso.",
|
||||
"mediaErrors": "Erros de leitura/gravação irrecuperáveis no flash NAND. Qualquer valor diferente de zero indica dano celular permanente. Contagem crescente significa substituição em breve.",
|
||||
"mediaIntegrityErrors": "Erros irrecuperáveis detectados pelo controlador. Diferente de zero significa risco de corrupção de dados.",
|
||||
"mediaErrors": "Erros de leitura/gravação irrecuperáveis no flash NAND. Qualquer valor diferente de zero indica dano celular permanente. Contagem crescente significa substituição em breve.",
|
||||
"mediaIntegrityErrors": "Erros irrecuperáveis detectados pelo controlador. Diferente de zero significa risco de corrupção de dados.",
|
||||
"unsafeShutdowns": "Perdas de energia sem descarga ou desligamento adequado. Contagens muito altas correm o risco de corrupção de metadados e problemas de firmware.",
|
||||
"nvmePowerCycles": "Ciclos totais de ligar/desligar. A ciclagem frequente aumenta o desgaste do conector e do capacitor.",
|
||||
"nvmePowerOnHours": "Total de horas acumuladas em que o inversor esteve ligado desde a fabricação.",
|
||||
@@ -624,7 +624,7 @@
|
||||
"reallocatedSectorCount": "Setores defeituosos substituídos por setores sobressalentes do pool de reserva. Uma contagem crescente significa degradação da unidade.",
|
||||
"retiredBlockCount": "Blocos NAND retirados devido a desgaste ou falha (SSD). Semelhante à contagem de setores realocados para HDDs.",
|
||||
"reallocatedEventCount": "Número de operações de remapeamento realizadas. Cada evento significa que um setor defeituoso foi substituído.",
|
||||
"currentPendingSector": "Setores instáveis esperando para serem remapeados na próxima gravação. Eles podem ser resolvidos ou serem realocados permanentemente.",
|
||||
"currentPendingSector": "Setores instáveis esperando para serem remapeados na próxima gravação. Eles podem ser resolvidos ou serem realocados permanentemente.",
|
||||
"pendingSectors": "Setores esperando para serem remapeados. Eles podem ser resolvidos ou realocados.",
|
||||
"offlineUncorrectable": "Setores que falharam durante a verificação offline e não puderam ser corrigidos. Indica possível perda de dados.",
|
||||
"offlineUncorrectableSectorCount": "Setores incorrigíveis encontrados durante a verificação em segundo plano. Os dados sobre estes setores são perdidos.",
|
||||
@@ -667,7 +667,7 @@
|
||||
"endToEndError": "Corrupção de dados detectada entre o cache do controlador e a interface do host. Deve ser sempre zero.",
|
||||
"endToEndErrorDetectionCount": "Número de erros de paridade no caminho de dados. Diferente de zero indica problemas no controlador.",
|
||||
"wearLevelingCount": "Média de ciclos de apagamento por bloco NAND (SSD). VALOR mais baixo significa mais desgaste consumido.",
|
||||
"wearRangeDelta": "Diferença entre blocos mais usados e menos usados (SSD). Valores altos indicam desgaste irregular.",
|
||||
"wearRangeDelta": "Diferença entre blocos mais usados e menos usados (SSD). Valores altos indicam desgaste irregular.",
|
||||
"mediaWearoutIndicator": "Estimativa de vida útil restante do SSD Intel. Começa em 100 e diminui para 0 conforme a resistência é consumida.",
|
||||
"ssdLifeLeft": "Porcentagem estimada de vida útil restante do SSD com base no desgaste da NAND.",
|
||||
"percentLifetimeRemain": "Porcentagem estimada de vida útil restante. 100 significa novo; 0 significa fim da vida útil nominal.",
|
||||
@@ -744,7 +744,7 @@
|
||||
"nvmeWarningTitle": "Aviso crítico NVMe ativo (0x{value})",
|
||||
"nvmeWarningText": "O controlador NVMe gerou um alerta. Faça backup dos dados imediatamente e investigue mais a fundo.",
|
||||
"nvmeMediaTitle": "Erros de mídia NVMe detectados ({count})",
|
||||
"nvmeMediaText": "Erros irrecuperáveis de flash NAND indicam danos físicos. Faça backup dos dados e planeje a substituição.",
|
||||
"nvmeMediaText": "Erros irrecuperáveis de flash NAND indicam danos físicos. Faça backup dos dados e planeje a substituição.",
|
||||
"unsafeTitle": "Contagem alta de desligamentos inseguros ({count})",
|
||||
"unsafeText": "Perdas frequentes de energia aumentam o risco de corrupção do firmware. Garanta energia estável ou use um UPS.",
|
||||
"seagateTitle": "Seagate Raw_Read_Error_Rate - Comportamento normal",
|
||||
@@ -1318,8 +1318,8 @@
|
||||
"Sábado"
|
||||
],
|
||||
"runSkippedStatus": "execução ignorada",
|
||||
"reasonStoppedContainer": "Container está parado;a atualização agendada foi ignorada.",
|
||||
"reasonStoppedContainerHistorical": "o contêiner foi interrompido no horário de atualização agendado."
|
||||
"reasonStoppedContainer": "O contêiner está parado; a atualização agendada foi ignorada.",
|
||||
"reasonStoppedContainerHistorical": "O contêiner estava parado no horário da atualização agendada."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron do host detectado",
|
||||
@@ -1785,7 +1785,7 @@
|
||||
},
|
||||
"healthMonitor": {
|
||||
"title": "Monitor de Saúde",
|
||||
"description": "Configure por quanto tempo os alertas dispensados permanecem ocultos para cada categoria. As alterações se aplicam a alertas ignorados existentes e futuros.",
|
||||
"description": "Configure por quanto tempo os alertas dispensados permanecem ocultos para cada categoria. As alterações se aplicam a alertas ignorados existentes e futuros.",
|
||||
"category": "Categoria",
|
||||
"suppressionDuration": "Duração da Supressão",
|
||||
"hours": "Horas",
|
||||
@@ -1795,7 +1795,7 @@
|
||||
"footerNote": "Essas configurações se aplicam quando você descarta um aviso do Health Monitor. Alertas críticos de temperatura da CPU sempre aparecem para proteger seu hardware.",
|
||||
"activeSuppressions": "Supressões Ativas",
|
||||
"activeSuppressionsDescription": "Alertas que você ocultou do Health Monitor. As demissões permanentes só podem ser revertidas aqui. A edição requer o Health Monitor",
|
||||
"noActiveSuppressions": "Sem supressões ativas. Os alertas dispensados do Health Monitor aparecerão aqui.",
|
||||
"noActiveSuppressions": "Sem supressões ativas. Os alertas dispensados do Health Monitor aparecerão aqui.",
|
||||
"permanent": "Permanente",
|
||||
"daysRemaining": "{count}d restantes",
|
||||
"hoursRemaining": "{count}h restantes",
|
||||
@@ -2125,7 +2125,13 @@
|
||||
"inactiveFooter": "Habilite notificações para receber alertas de sistema, integridade e segurança por meio dos canais configurados.",
|
||||
"notificationLanguage": "Idioma de notificação",
|
||||
"selectNotificationLanguage": "Selecione o idioma de notificação",
|
||||
"notificationLanguageHint": "Usado para notificações determinísticas de tempo de execução, mesmo quando a IA está desabilitada."
|
||||
"notificationLanguageHint": "Usado para notificações determinísticas de tempo de execução, mesmo quando a IA está desabilitada.",
|
||||
"appriseServiceHelp": "Configuração do Slack e do Matrix",
|
||||
"appriseSlackSteps": "Crie um webhook de entrada na sua aplicação Slack para o canal de destino e cole o seu URL HTTPS completo no campo URL do Apprise. Não é necessária qualquer conversão.",
|
||||
"appriseSlackDocs": "Formatos de URL e configuração do Slack",
|
||||
"appriseMatrixSteps": "Utilize uma conta de notificações que possa publicar na sala. Substitua o utilizador, a palavra-passe, o homeserver e o ID completo da sala do exemplo; codifique os caracteres especiais das credenciais para URL. O esquema matrixs usa HTTPS. Consulte a documentação sobre o suporte de tokens, SSO e salas cifradas na versão do Apprise instalada.",
|
||||
"appriseMatrixDocs": "Formatos de URL e configuração do Matrix",
|
||||
"appriseServiceFinish": "Ative o Apprise, introduza o seu URL, escolha as categorias de notificação ou os eventos individuais, guarde e utilize Enviar teste. Estes exemplos não alteram o URL guardado. Este canal do Apprise tem um destino; utilize a API do Apprise para vários serviços em simultâneo."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Aprimoramento de IA",
|
||||
@@ -3581,7 +3587,7 @@
|
||||
"hour": "Passada 1h",
|
||||
"day": "Passadas 24 horas",
|
||||
"week": "Passado 7 dias",
|
||||
"month": "Passados 30 dias",
|
||||
"month": "Passados 30 dias",
|
||||
"year": "Último 1 ano"
|
||||
},
|
||||
"last": {
|
||||
@@ -4033,7 +4039,7 @@
|
||||
},
|
||||
"backends": {
|
||||
"borgDescription": "Repositório Borg para backups de host desduplicados.",
|
||||
"borgDescriptionTimerOnly": "Os destinos Borg são usados pelos temporizadores do Monitor, não pelos trabalhos PVE vzdump.",
|
||||
"borgDescriptionTimerOnly": "Os destinos Borg são usados pelos temporizadores do Monitor, não pelos trabalhos PVE vzdump.",
|
||||
"borgShortDescription": "Repositório Borg desduplicado",
|
||||
"local": "Local",
|
||||
"localDescription": "Pasta neste host ou em uma unidade USB montada.",
|
||||
@@ -4561,7 +4567,7 @@
|
||||
"customPickDescriptionMiddle": "através do",
|
||||
"destructiveTitle": "A restauração completa REMOVERÁ o seguinte (criado após o backup)",
|
||||
"noPathsMatch": "Nenhum caminho corresponde ao filtro.",
|
||||
"noRestorablePaths": "Este backup não contém caminhos restauráveis — apenas a restauração completa é significativa aqui.",
|
||||
"noRestorablePaths": "Este backup não contém caminhos restauráveis — apenas a restauração completa é significativa aqui.",
|
||||
"olderThanTargetKernel": "é mais antigo que o kernel alvo",
|
||||
"operatorConfigDescription": "As seguintes configurações criadas pelo operador serão mescladas no destino automaticamente, sem alterar os padrões vinculados ao kernel. É executado na restauração completa e personalizada.",
|
||||
"operatorConfigTitle": "Configuração do operador reaplicada por meio de mesclagem independente de kernel",
|
||||
@@ -4770,6 +4776,24 @@
|
||||
"thursday": "Quinta-feira",
|
||||
"tuesday": "Terça-feira",
|
||||
"wednesday": "Quarta-feira"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Retenção do armazenamento",
|
||||
"keepAll": "Manter todas as cópias de segurança",
|
||||
"description": "Retenção predefinida de cópias de segurança guardada no Proxmox. As tarefas de cópia com uma política explícita substituem este valor predefinido.",
|
||||
"refresh": "Atualizar",
|
||||
"saved": "Retenção do armazenamento guardada. Não foi executada nenhuma limpeza.",
|
||||
"choosePolicy": "Escolha Manter todas ou pelo menos uma quantidade de retenção superior a zero.",
|
||||
"saveFailed": "Não foi possível guardar a retenção do armazenamento. Atualize a lista se a configuração tiver mudado.",
|
||||
"loadFailed": "Não foi possível carregar a retenção do armazenamento.",
|
||||
"empty": "Não foram encontrados armazenamentos de cópias de segurança.",
|
||||
"default": "Predefinição do Proxmox",
|
||||
"edit": "Editar",
|
||||
"editTitle": "Retenção para {storage}",
|
||||
"effect": "Aplica-se às futuras cópias de segurança que herdem esta política de armazenamento. Guardar não elimina as cópias existentes. As cópias protegidas permanecem protegidas.",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar retenção",
|
||||
"useDefault": "Use o padrão Proxmox (nenhuma política neste armazenamento)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
@@ -4926,7 +4950,7 @@
|
||||
},
|
||||
"noIssues": "Nenhum problema detectado",
|
||||
"updateNow": "Atualizar agora",
|
||||
"dismissedItems": "Itens dispensados ({count})",
|
||||
"dismissedItems": "Itens dispensados ({count})",
|
||||
"permanent": "Permanente",
|
||||
"dismissed": "Dispensado",
|
||||
"wasStatus": "era {status}",
|
||||
|
||||
@@ -2124,7 +2124,13 @@
|
||||
"inactiveFooter": "Zapnite notifikácie a dostávajte upozornenia o systéme, stave a bezpečnosti cez nastavené kanály.",
|
||||
"notificationLanguage": "Jazyk notifikácií",
|
||||
"selectNotificationLanguage": "Vyberte jazyk notifikácií",
|
||||
"notificationLanguageHint": "Používa sa pre spoľahlivé runtime notifikácie aj vtedy, keď je AI vypnutá."
|
||||
"notificationLanguageHint": "Používa sa pre spoľahlivé runtime notifikácie aj vtedy, keď je AI vypnutá.",
|
||||
"appriseServiceHelp": "Nastavenie Slack a Matrix",
|
||||
"appriseSlackSteps": "V aplikácii Slack vytvorte prichádzajúci webhook pre cieľový kanál a vložte jeho úplnú HTTPS URL do poľa URL Apprise. Nie je potrebný žiadny prevod.",
|
||||
"appriseSlackDocs": "Formáty URL a nastavenie Slack",
|
||||
"appriseMatrixSteps": "Použite účet pre oznámenia, ktorý môže odosielať správy do miestnosti. Nahraďte vzorového používateľa, heslo, domovský server a úplné ID miestnosti; špeciálne znaky v prihlasovacích údajoch zakódujte pre URL. Schéma matrixs používa HTTPS. Podporu tokenov, SSO a šifrovaných miestností v nainštalovanej verzii Apprise nájdete v dokumentácii.",
|
||||
"appriseMatrixDocs": "Formáty URL a nastavenie Matrix",
|
||||
"appriseServiceFinish": "Povoľte Apprise, zadajte svoju URL, vyberte kategórie oznámení alebo jednotlivé udalosti, uložte a potom použite Odoslať test. Tieto príklady nemenia uloženú URL. Tento kanál Apprise má jeden cieľ; pre viac služieb naraz použite API Apprise."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI vylepšenie",
|
||||
@@ -4769,6 +4775,24 @@
|
||||
"thursday": "štvrtok",
|
||||
"tuesday": "utorok",
|
||||
"wednesday": "streda"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Uchovávanie záloh úložiska",
|
||||
"keepAll": "Ponechať všetky zálohy",
|
||||
"description": "Predvolená politika uchovávania záloh uložená v Proxmoxe. Zálohovacie úlohy s vlastnou politikou majú pred týmto predvoleným nastavením prednosť.",
|
||||
"refresh": "Obnoviť",
|
||||
"saved": "Politika uchovávania záloh úložiska bola uložená. Prerezávanie sa nevykonalo.",
|
||||
"choosePolicy": "Vyberte Ponechať všetky alebo aspoň jeden počet uchovávaných záloh väčší ako nula.",
|
||||
"saveFailed": "Nepodarilo sa uložiť politiku uchovávania záloh úložiska. Ak sa konfigurácia zmenila, obnovte zoznam.",
|
||||
"loadFailed": "Nepodarilo sa načítať politiku uchovávania záloh úložiska.",
|
||||
"empty": "Nenašli sa žiadne úložiská záloh.",
|
||||
"default": "Predvolené nastavenie Proxmoxu",
|
||||
"edit": "Upraviť",
|
||||
"editTitle": "Uchovávanie záloh pre {storage}",
|
||||
"effect": "Platí pre budúce zálohovania, ktoré preberajú túto politiku úložiska. Uložením sa existujúce zálohy neodstránia. Chránené zálohy zostanú chránené.",
|
||||
"cancel": "Zrušiť",
|
||||
"save": "Uložiť uchovávanie záloh",
|
||||
"useDefault": "Použite predvolené nastavenie Proxmox (žiadne pravidlá pre toto úložisko)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
|
||||
@@ -1317,8 +1317,8 @@
|
||||
"fredag",
|
||||
"lördag"
|
||||
],
|
||||
"runSkippedStatus": "kör hoppade över",
|
||||
"reasonStoppedContainer": "Behållaren är stoppad;schemalagd uppdatering hoppades över.",
|
||||
"runSkippedStatus": "körning överhoppad",
|
||||
"reasonStoppedContainer": "Behållaren är stoppad; den schemalagda uppdateringen hoppades över.",
|
||||
"reasonStoppedContainerHistorical": "Behållaren stoppades vid den schemalagda uppdateringstiden."
|
||||
},
|
||||
"cronChip": {
|
||||
@@ -2125,7 +2125,13 @@
|
||||
"inactiveFooter": "Aktivera aviseringar för att ta emot system-, hälso- och säkerhetsvarningar via de konfigurerade kanalerna.",
|
||||
"notificationLanguage": "Aviseringsspråk",
|
||||
"selectNotificationLanguage": "Välj språk för meddelanden",
|
||||
"notificationLanguageHint": "Används för deterministiska körtidsmeddelanden även när AI är inaktiverat."
|
||||
"notificationLanguageHint": "Används för deterministiska körtidsmeddelanden även när AI är inaktiverat.",
|
||||
"appriseServiceHelp": "Konfiguration av Slack och Matrix",
|
||||
"appriseSlackSteps": "Skapa en inkommande webhook i din Slack-app för målkanalen och klistra sedan in dess fullständiga HTTPS-URL i fältet Apprise-URL. Ingen omvandling behövs.",
|
||||
"appriseSlackDocs": "URL-format och konfiguration för Slack",
|
||||
"appriseMatrixSteps": "Använd ett aviseringskonto som får skicka meddelanden till rummet. Ersätt exemplets användare, lösenord, hemserver och fullständiga rums-ID; URL-koda specialtecken i inloggningsuppgifterna. Schemat matrixs använder HTTPS. Se dokumentationen för stöd för token, SSO och krypterade rum i din installerade Apprise-version.",
|
||||
"appriseMatrixDocs": "URL-format och konfiguration för Matrix",
|
||||
"appriseServiceFinish": "Aktivera Apprise, ange din URL, välj aviseringskategorier eller enskilda händelser, spara och använd sedan Skicka test. Dessa exempel ändrar inte din sparade URL. Denna Apprise-kanal har ett mål; använd Apprise-API för flera tjänster samtidigt."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI-förbättring",
|
||||
@@ -4770,6 +4776,24 @@
|
||||
"thursday": "torsdag",
|
||||
"tuesday": "tisdag",
|
||||
"wednesday": "onsdag"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Lagringens bevarandepolicy",
|
||||
"keepAll": "Behåll alla säkerhetskopior",
|
||||
"description": "Standardpolicy för bevarande av säkerhetskopior, sparad i Proxmox. Säkerhetskopieringsjobb med en uttrycklig policy åsidosätter denna standard.",
|
||||
"refresh": "Uppdatera",
|
||||
"saved": "Lagringens bevarandepolicy har sparats. Ingen gallring har körts.",
|
||||
"choosePolicy": "Välj Behåll alla eller minst ett bevarandeantal som är större än noll.",
|
||||
"saveFailed": "Det gick inte att spara lagringens bevarandepolicy. Uppdatera listan om konfigurationen har ändrats.",
|
||||
"loadFailed": "Det gick inte att läsa in lagringens bevarandepolicy.",
|
||||
"empty": "Ingen lagring för säkerhetskopior hittades.",
|
||||
"default": "Proxmox standard",
|
||||
"edit": "Redigera",
|
||||
"editTitle": "Bevarande för {storage}",
|
||||
"effect": "Gäller framtida säkerhetskopieringar som ärver denna lagringspolicy. Att spara tar inte bort befintliga säkerhetskopior. Skyddade säkerhetskopior förblir skyddade.",
|
||||
"cancel": "Avbryt",
|
||||
"save": "Spara bevarandepolicy",
|
||||
"useDefault": "Använd Proxmox-standard (ingen policy för denna lagring)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
|
||||
@@ -35,7 +35,7 @@ TOOL_METADATA = {
|
||||
'system_utils': {'name': 'System Utilities', 'function': 'install_system_utils', 'version': '1.0'},
|
||||
'network_optimization': {'name': 'Network Optimizations', 'function': 'apply_network_optimizations', 'version': '1.0'},
|
||||
'bashrc_custom': {'name': 'Bashrc Customization', 'function': 'customize_bashrc', 'version': '1.0'},
|
||||
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'},
|
||||
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.2'},
|
||||
'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', 'version': '1.0'},
|
||||
'log2ram': {'name': 'Log2ram (SSD Protection)', 'function': 'configure_log2ram', 'version': '1.0'},
|
||||
'zfs_autotrim': {'name': 'ZFS Autotrim', 'function': 'enable_zfs_autotrim', 'version': '1.0'},
|
||||
|
||||
@@ -16,6 +16,9 @@ Endpoints, all under /api/vm-backup-jobs:
|
||||
DELETE /<id> delete a job (its backups are kept)
|
||||
POST /<id>/toggle enable / disable
|
||||
POST /<id>/run run it now, as a Proxmox task on each node
|
||||
|
||||
Storage retention is read/edited under /api/vm-backup-storages. Saving a
|
||||
policy only changes the native storage configuration; it never runs pruning.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -229,6 +232,58 @@ def options():
|
||||
})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-storages", methods=["GET"])
|
||||
@require_auth
|
||||
def backup_storage_retention():
|
||||
ok, storages = _pvesh_json(["get", "/storage"])
|
||||
if not ok or not isinstance(storages, list):
|
||||
return _err(storages if not ok else "Invalid storage inventory", 502)
|
||||
return jsonify({"storages": [
|
||||
{"id": storage["storage"], "type": storage.get("type", ""),
|
||||
"prune_backups": _flat(storage.get("prune-backups") or ""),
|
||||
"digest": storage.get("digest", "")}
|
||||
for storage in storages
|
||||
if isinstance(storage, dict) and storage.get("storage")
|
||||
and "backup" in (storage.get("content") or "").split(",")
|
||||
]})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-storages/<storage_id>", methods=["PUT"])
|
||||
@require_admin_scope
|
||||
def update_backup_storage_retention(storage_id):
|
||||
if not STORAGE_RE.fullmatch(storage_id):
|
||||
return _err("Invalid storage")
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return _err("A retention policy and current configuration digest are required")
|
||||
digest, prune = data.get("digest"), data.get("prune_backups")
|
||||
if not isinstance(digest, str) or not re.fullmatch(r"[a-fA-F0-9]{40}", digest):
|
||||
return _err("Refresh the storage list before editing its retention")
|
||||
# An empty policy removes the storage setting, so the Proxmox default applies.
|
||||
if not isinstance(prune, str) or (prune and not PRUNE_RE.fullmatch(prune)):
|
||||
return _err("Invalid retention policy")
|
||||
pairs = [part.split("=", 1) for part in prune.split(",")] if prune else []
|
||||
keys = [key for key, _ in pairs]
|
||||
if (len(keys) != len(set(keys)) or
|
||||
("keep-all" in keys and (len(keys) != 1 or pairs[0][1] != "1")) or
|
||||
any(int(value) < 1 for _, value in pairs)):
|
||||
return _err("Choose keep-all=1 or one or more positive retention counts")
|
||||
# Check that this is still a backup storage, then let Proxmox check the
|
||||
# digest under its storage lock. Other storage settings are never passed.
|
||||
ok, storage = _pvesh_json(["get", f"/storage/{storage_id}"])
|
||||
if not ok:
|
||||
return _err(storage, 502)
|
||||
if not isinstance(storage, dict) or "backup" not in (storage.get("content") or "").split(","):
|
||||
return _err("This storage does not accept backups", 409)
|
||||
if storage.get("digest") != digest:
|
||||
return _err("Storage configuration changed; refresh before saving", 409)
|
||||
change = ["--prune-backups", prune] if prune else ["--delete", "prune-backups"]
|
||||
ok, out = _pvesh(["set", f"/storage/{storage_id}", "--digest", digest, *change])
|
||||
if not ok:
|
||||
return _err(out, 409)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-jobs", methods=["POST"])
|
||||
@require_admin_scope
|
||||
def create_job():
|
||||
|
||||
@@ -1564,7 +1564,7 @@ class AppriseChannel(NotificationChannel):
|
||||
return apprise_mod.NotifyType.FAILURE
|
||||
if sev == 'WARNING':
|
||||
return apprise_mod.NotifyType.WARNING
|
||||
if sev == 'SUCCESS':
|
||||
if sev in ('SUCCESS', 'OK'):
|
||||
return apprise_mod.NotifyType.SUCCESS
|
||||
return apprise_mod.NotifyType.INFO
|
||||
|
||||
|
||||
+3
-2
@@ -7079,6 +7079,7 @@
|
||||
"Unsupported OCI-LXC AppArmor profile:": "Nicht unterstütztes OCI-LXC AppArmor-Profil:",
|
||||
"Unsupported OCI-LXC seccomp profile:": "Nicht unterstütztes OCI-LXC seccomp Profil:",
|
||||
"Unsupported Terminal": "Nicht unterstütztes Terminal",
|
||||
"Unsupported architecture for Figurine:": "Nicht unterstützte Architektur für Figurine:",
|
||||
"Unsupported architecture:": "Nicht unterstützte Architektur:",
|
||||
"Unsupported backup compression": "Nicht unterstützte Backup-Komprimierung",
|
||||
"Unsupported credential pattern:": "Nicht unterstütztes credential-Muster:",
|
||||
@@ -7968,8 +7969,8 @@
|
||||
"qBittorrent: invalid login response or missing session cookie": "qBittorrent: ungültige Anmeldeantwort oder fehlendes Sitzungscookie",
|
||||
"raw USB disk — no filesystem (will be FORMATTED)": "Raw-USB-Festplatte – kein Dateisystem (wird FORMATTIERT)",
|
||||
"read-only": "Read-Only",
|
||||
"reboot-quick alias added": "reboot-quick-Alias hinzugefügt",
|
||||
"reboot-quick alias is already configured": "reboot-quick Alias ist bereits konfiguriert",
|
||||
"reboot-quick alias added": "reboot-quick-Alias hinzugefügt",
|
||||
"reboot-quick alias is already configured": "reboot-quick Alias ist bereits konfiguriert",
|
||||
"recognition": "Erkennung",
|
||||
"recognition only": "Nur Erkennung",
|
||||
"recommended": "empfohlen",
|
||||
|
||||
+3
-2
@@ -328,7 +328,7 @@
|
||||
"After this LXC → VM switch, reboot the host so the new binding state is applied cleanly.": "Después de este cambio LXC → VM, reinicie el host para que el nuevo estado de enlace se aplique limpiamente.",
|
||||
"Airsonic Advanced web interface": "Interfaz web de Airsonic Advanced",
|
||||
"Airsonic-advanced is a free, web-based media streamer, providing ubiquitious access to your music. Use it to share your music with friends, or to listen to your own music while at work. You can stream to multiple players simultaneously, for instance to one player in your kitchen and another in your living room.": "Airsonic-advanced es un servidor de streaming multimedia gratuito y basado en web que da acceso a tu música desde cualquier lugar. Úsalo para compartir tu música con amigos o para escuchar la tuya en el trabajo. Puede transmitir a varios reproductores a la vez, por ejemplo a uno en la cocina y a otro en el salón.",
|
||||
"Aliases added to .bashrc": "Alias agregados a .bashrc",
|
||||
"Aliases added to .bashrc": "Alias agregados a .bashrc",
|
||||
"Alist Sync web interface": "Interfaz web de Alist Sync",
|
||||
"All": "Todos",
|
||||
"All Available Scripts": "Todos los scripts disponibles",
|
||||
@@ -2415,7 +2415,7 @@
|
||||
"Fastfetch will start automatically in the console": "Fastfetch se iniciará automáticamente en la consola",
|
||||
"Ferdium is a desktop app that helps you organize how you use your favourite apps by combining them into one application.": "Ferdium es una aplicación de escritorio que te ayuda a organizar el uso de tus aplicaciones favoritas reuniéndolas en una sola.",
|
||||
"Fetching NVIDIA driver versions supported by your GPU...": "Obteniendo versiones del controlador NVIDIA compatibles con su GPU...",
|
||||
"Figurine installation and configuration completed successfully.": "La instalación y configuración deFigurine se completó con éxito.",
|
||||
"Figurine installation and configuration completed successfully.": "La instalación y configuración de Figurine se completó correctamente.",
|
||||
"Figurine is not installed.": "Figurine no está instalado.",
|
||||
"Figurine removed from system": "Figurine desinstalado del sistema",
|
||||
"File bind mounts do not support spaces:": "Los bind mounts de archivo no admiten espacios:",
|
||||
@@ -7054,6 +7054,7 @@
|
||||
"Unsupported OCI-LXC AppArmor profile:": "Perfil OCI-LXC sin soporte:",
|
||||
"Unsupported OCI-LXC seccomp profile:": "Perfil sin soporte OCI-LXC seccomp:",
|
||||
"Unsupported Terminal": "Terminal no compatible",
|
||||
"Unsupported architecture for Figurine:": "Arquitectura no compatible con Figurine:",
|
||||
"Unsupported architecture:": "Arquitectura sin soporte:",
|
||||
"Unsupported backup compression": "Compresión de backup sin soporte",
|
||||
"Unsupported credential pattern:": "Patrón credential sin soporte:",
|
||||
|
||||
+4
-3
@@ -330,7 +330,7 @@
|
||||
"After this LXC → VM switch, reboot the host so the new binding state is applied cleanly.": "Après ce commutateur LXC → VM, redémarrez l'hôte afin que le nouvel état de liaison soit appliqué proprement.",
|
||||
"Airsonic Advanced web interface": "Interface web Airsonic Advanced",
|
||||
"Airsonic-advanced is a free, web-based media streamer, providing ubiquitious access to your music. Use it to share your music with friends, or to listen to your own music while at work. You can stream to multiple players simultaneously, for instance to one player in your kitchen and another in your living room.": "Airsonic-Advanced est un flux multimédia gratuit, basé sur le web, offrant un accès ubiquitious à votre musique. Utilisez-le pour partager votre musique avec des amis, ou pour écouter votre propre musique au travail. Vous pouvez diffuser simultanément vers plusieurs joueurs, par exemple vers un joueur dans votre cuisine et un autre dans votre salon.",
|
||||
"Aliases added to .bashrc": "Alias ajoutés à .bashrc",
|
||||
"Aliases added to .bashrc": "Alias ajoutés à .bashrc",
|
||||
"Alist Sync web interface": "Alist Interface web Sync",
|
||||
"All": "Tous",
|
||||
"All Available Scripts": "Tous les scripts disponibles",
|
||||
@@ -2802,7 +2802,7 @@
|
||||
"How do you want to add it?": "Comment veux-tu l’ajouter ?",
|
||||
"How do you want to authenticate this backup target?": "Comment souhaitez-vous authentifier cette cible de sauvegarde ?",
|
||||
"How do you want to select the": "Comment voulez-vous sélectionner le",
|
||||
"How do you want to select the HOST folder to mount?": "Comment souhaitez-vous sélectionner le dossier HOST à monter ?",
|
||||
"How do you want to select the HOST folder to mount?": "Comment souhaitez-vous sélectionner le dossier HOST à monter ?",
|
||||
"How do you want to select the NFS server?": "Comment voulez-vous sélectionner le serveur NFS ?",
|
||||
"How do you want to select the Samba server?": "Comment voulez-vous sélectionner le serveur Samba ?",
|
||||
"How do you want to select the folder to export?": "Comment voulez-vous sélectionner le dossier à exporter ?",
|
||||
@@ -4334,7 +4334,7 @@
|
||||
"Not all shared directories were verified": "Tous les répertoires partagés n'ont pas été vérifiés",
|
||||
"Not an OVH server, skipping RTM installation": "Pas un serveur OVH, sautant l'installation RTM",
|
||||
"Not currently mounted": "Non monté actuellement",
|
||||
"Not currently mounted — skipping umount.": "Non monté actuellement – ignorer umount.",
|
||||
"Not currently mounted — skipping umount.": "Non monté actuellement – ignorer umount.",
|
||||
"Not declared": "Non déclaré",
|
||||
"Not enough free space for the backup": "Pas assez d'espace libre pour la sauvegarde",
|
||||
"Not enough free space on {storage}. Free some space or select another storage.": "Pas assez d’espace libre sur {storage}. Libérez de l'espace ou sélectionnez un autre stockage.",
|
||||
@@ -7078,6 +7078,7 @@
|
||||
"Unsupported OCI-LXC AppArmor profile:": "Profil OCI-LXC AppArmor non soutenu :",
|
||||
"Unsupported OCI-LXC seccomp profile:": "Profil de seccomp OCI-LXC non pris en charge:",
|
||||
"Unsupported Terminal": "Terminal non pris en charge",
|
||||
"Unsupported architecture for Figurine:": "Architecture non prise en charge pour Figurine :",
|
||||
"Unsupported architecture:": "Architecture non supportée :",
|
||||
"Unsupported backup compression": "compression de sauvegarde non prise en charge",
|
||||
"Unsupported credential pattern:": "Modèle credential non pris en charge:",
|
||||
|
||||
@@ -7079,6 +7079,7 @@
|
||||
"Unsupported OCI-LXC AppArmor profile:": "Profilo OCI-LXC AppArmor non supportato:",
|
||||
"Unsupported OCI-LXC seccomp profile:": "Profilo seccomp OCI-LXC non supportato:",
|
||||
"Unsupported Terminal": "Terminale non supportato",
|
||||
"Unsupported architecture for Figurine:": "Architettura non supportata da Figurine:",
|
||||
"Unsupported architecture:": "Architettura non supportata:",
|
||||
"Unsupported backup compression": "Compressione backup non supportata",
|
||||
"Unsupported credential pattern:": "Schema credenziali non supportato:",
|
||||
|
||||
+3
-2
@@ -4215,7 +4215,7 @@
|
||||
"No format-safe disks are available.": "Nenhum disco de formato seguro está disponível.",
|
||||
"No free ProxMenux private /24 network is available": "Nenhuma rede privada ProxMenux livre 24 está disponível",
|
||||
"No gasket DKMS registrations remain.": "Não restam registos DKMS de gasket.",
|
||||
"No group creation required — uses world-writable sticky bit permissions.": "Não é necessária a criação de grupos — usa permissões de sticky bit graváveis mundialmente.",
|
||||
"No group creation required — uses world-writable sticky bit permissions.": "Não é necessária a criação de grupos — usa permissões de sticky bit graváveis mundialmente.",
|
||||
"No guest selected.": "Nenhum convidado selecionado.",
|
||||
"No host VFIO reconfiguration expected": "Nenhuma reconfiguração VFIO do host é esperada",
|
||||
"No host VFIO/native binding changes were required.": "Nenhuma alteração de ligação VFIO/nativa do host foi necessária.",
|
||||
@@ -4432,7 +4432,7 @@
|
||||
"Only an installed standalone OCI instance can adopt external changes": "Somente uma instância OCI autônoma instalada pode adotar alterações externas",
|
||||
"Only backed-up Proxmox volumes can be adopted": "Somente volumes Proxmox com backup podem ser adotados",
|
||||
"Only convert to privileged if absolutely necessary for your use case.": "Converta para privilegiado apenas se for absolutamente necessário para o seu caso de uso.",
|
||||
"Only fully free disks are shown (not system-used and not referenced by VM/LXC).": "Somente discos totalmente livres são mostrados (não usados pelo sistema e não referenciados pelo VM/LXC).",
|
||||
"Only fully free disks are shown (not system-used and not referenced by VM/LXC).": "Somente discos totalmente livres são mostrados (não usados pelo sistema e não referenciados pelo VM/LXC).",
|
||||
"Only if using enterprise subscription": "Somente se estiver usando assinatura corporativa",
|
||||
"Only if using no-subscription repository": "Somente se estiver usando repositório sem assinatura",
|
||||
"Only needed if you mounted the filesystem in step 6b": "Necessário apenas se você montou o sistema de arquivos na etapa 6b",
|
||||
@@ -7079,6 +7079,7 @@
|
||||
"Unsupported OCI-LXC AppArmor profile:": "Perfil do AppArmor OCI-LXC não suportado:",
|
||||
"Unsupported OCI-LXC seccomp profile:": "Perfil OCI-LXC seccomp não suportado:",
|
||||
"Unsupported Terminal": "Terminal não suportado",
|
||||
"Unsupported architecture for Figurine:": "Arquitetura não suportada pelo Figurine:",
|
||||
"Unsupported architecture:": "Arquitetura não suportada:",
|
||||
"Unsupported backup compression": "Compressão de backup não suportada",
|
||||
"Unsupported credential pattern:": "Padrão credential não suportado:",
|
||||
|
||||
+2
-1
@@ -2668,7 +2668,7 @@
|
||||
"HAOS One is a community image that runs Docker inside the container. The LXC stays unprivileged, but the inner AppArmor profiles may not be available. The first start downloads Home Assistant Core and its add-ons. If the check fails, the CT and /mnt/data are kept for diagnosis.": "HAOS One je obraz spoločenstva, ktorý prevádzkuje Docker vnútri kontajnera. LXC zostáva neprivilegovaný, ale vnútorné profily AppArmor nemusí byť k dispozícii. Prvý štart stiahne Home Assistant jadro a jeho doplnky. Ak kontrola zlyhá, CT a / mnt/ dáta sa uchovávajú na určenie diagnózy.",
|
||||
"HAOS One profile declined": "Profil HAOS One klesol",
|
||||
"HAOS One requires an unprivileged unmanaged LXC with nesting and keyctl, 2 cores, 2048 MB RAM, rootfs of at least 12 GB and /mnt/data of at least 16 GB on a container volume included in backups": "HAOS One requires an unprivileged unmanaged LXC with nestering and keyctl, 2 cores, 2048 MB RAM, rootfs najmenej 12 GB a /mnt/data najmenej 16 GB on a container volume included in backions",
|
||||
"HTML5 Network Speed Test Server.": "HTML5 Network Speed Test Server.",
|
||||
"HTML5 Network Speed Test Server.": "HTML5 Network Speed Test Server.",
|
||||
"HTTP service check without a saved URL": "Kontrola služby HTTP bez uloženého URL",
|
||||
"Habridge emulates Philips Hue API to other home automation gateways such as an Amazon Echo/Dot Gen 1 (gen 2 has issues discovering ha-bridge) or other systems that support Philips Hue. The Bridge handles basic commands such as On, Off and brightness commands of the hue protocol. This bridge can control most devices that have a distinct API.": "Habridge napodobňuje Philipsa Spoločnosť Amazon tvrdí, že spoločnosť LuxOpCo by mala mať prístup k webovým stránkam spoločnosti LuxOpCo. Most riadi základné príkazy, ako je On, Off a jas príkazy protokolu odtieňov. Tento most môže ovládať väčšinu zariadení, ktoré majú odlišné API.",
|
||||
"HandBrake is an open-source tool, built by volunteers, for converting video from nearly any format to a selection of modern, widely supported codecs.": "HandBrake je open-source nástroj, postavený dobrovoľníkmi, pre konverziu video z takmer akéhokoľvek formátu na výber moderných, široko podporovaných kodekov.",
|
||||
@@ -7080,6 +7080,7 @@
|
||||
"Unsupported OCI-LXC AppArmor profile:": "Nepodporovaný profil OCI-LXC AppArmor:",
|
||||
"Unsupported OCI-LXC seccomp profile:": "Nepodporovaný profil OCI-LXC seccomp:",
|
||||
"Unsupported Terminal": "Nepodporovaný terminál",
|
||||
"Unsupported architecture for Figurine:": "Nepodporovaná architektúra pre Figurine:",
|
||||
"Unsupported architecture:": "Nepodporovaná architektúra:",
|
||||
"Unsupported backup compression": "Nepodporovaná kompresia zálohy",
|
||||
"Unsupported credential pattern:": "Nepodporovaný credential vzor:",
|
||||
|
||||
+4
-3
@@ -330,7 +330,7 @@
|
||||
"After this LXC → VM switch, reboot the host so the new binding state is applied cleanly.": "Efter denna LXC → VM-switch, starta om värden så att det nya bindningsläget tillämpas korrekt.",
|
||||
"Airsonic Advanced web interface": "Airsonic Advanced webbgränssnitt",
|
||||
"Airsonic-advanced is a free, web-based media streamer, providing ubiquitious access to your music. Use it to share your music with friends, or to listen to your own music while at work. You can stream to multiple players simultaneously, for instance to one player in your kitchen and another in your living room.": "Airsonic-advanced är en gratis, webbaserad media streamer, som ger ubiquitious tillgång till din musik. Använd den för att dela din musik med vänner, eller för att lyssna på din egen musik på jobbet. Du kan strömma till flera spelare samtidigt, till exempel till en spelare i ditt kök och en annan i ditt vardagsrum.",
|
||||
"Aliases added to .bashrc": "Alias har lagts till i .bashrc",
|
||||
"Aliases added to .bashrc": "Alias har lagts till i .bashrc",
|
||||
"Alist Sync web interface": "Alist Sync webbgränssnitt",
|
||||
"All": "Alla",
|
||||
"All Available Scripts": "Alla tillgängliga skript",
|
||||
@@ -2143,7 +2143,7 @@
|
||||
"Etherpad admin page": "Etherpad admin sida",
|
||||
"Every 12 hours": "Var 12:e timme",
|
||||
"Every 3 hours": "Var 3:e timme",
|
||||
"Every 6 hours": "Var 6:e timme",
|
||||
"Every 6 hours": "Var 6:e timme",
|
||||
"Every fail2ban jail ships disabled. Enable the ones you need in /config/fail2ban/jail.local, taking the ready-made jails in /config/fail2ban/jail.d/ as reference, then restart the container.": "Varje fail2ban fängslade fartyg. Möjliggöra de du behöver i /config / fail2ban /jail.local, ta de färdiga fängelserna i /config / fail2ban /jail.d / som referens, sedan starta om behållaren.",
|
||||
"Every hour": "Varje timme",
|
||||
"Every member of the stack is back to its previous installation.": "Varje medlem av stacken är tillbaka till sin tidigare installation.",
|
||||
@@ -2668,7 +2668,7 @@
|
||||
"HAOS One is a community image that runs Docker inside the container. The LXC stays unprivileged, but the inner AppArmor profiles may not be available. The first start downloads Home Assistant Core and its add-ons. If the check fails, the CT and /mnt/data are kept for diagnosis.": "HAOS One är en gemenskapsbild som kör Docker inuti behållaren. LXC är oprivilegierad, men de inre AppArmor-profilerna kanske inte är tillgängliga. Den första starten hämtar Home Assistant Core och dess tillägg. Om kontrollen misslyckas, CT och /mnt / data hålls för diagnos.",
|
||||
"HAOS One profile declined": "HAOS One-profilen minskade",
|
||||
"HAOS One requires an unprivileged unmanaged LXC with nesting and keyctl, 2 cores, 2048 MB RAM, rootfs of at least 12 GB and /mnt/data of at least 16 GB on a container volume included in backups": "HAOS One requires en oprivilegierad ohanterad LXC med häckning och keyctl, 2 kärnor, 2048 MB RAM, rötter på minst 12 GB och /mnt / data på minst 16 GB på en behållarvolym som ingår i backups",
|
||||
"HTML5 Network Speed Test Server.": "HTML5 Network Speed Test Server.",
|
||||
"HTML5 Network Speed Test Server.": "HTML5 Network Speed Test Server.",
|
||||
"HTTP service check without a saved URL": "HTTP service check utan sparad URL",
|
||||
"Habridge emulates Philips Hue API to other home automation gateways such as an Amazon Echo/Dot Gen 1 (gen 2 has issues discovering ha-bridge) or other systems that support Philips Hue. The Bridge handles basic commands such as On, Off and brightness commands of the hue protocol. This bridge can control most devices that have a distinct API.": "Habridge emulerar Philips Hue API till andra hemautomatiseringsgateways som Amazon Echo / Dot Gen 1 (gen 2 har problem med att upptäcka ha-bro) eller andra system som stöder Philips Hue. Bron hanterar grundläggande kommandon som On, Off och ljusstyrka kommandon av nyansprotokollet. Denna bro kan styra de flesta enheter som har ett tydligt API.",
|
||||
"HandBrake is an open-source tool, built by volunteers, for converting video from nearly any format to a selection of modern, widely supported codecs.": "HandBrake är ett open-source verktyg, byggt av volontärer, för att konvertera video från nästan alla format till ett urval av moderna, allmänt stödda codecs.",
|
||||
@@ -7079,6 +7079,7 @@
|
||||
"Unsupported OCI-LXC AppArmor profile:": "Ostödd OCI-LXC AppArmor-profil:",
|
||||
"Unsupported OCI-LXC seccomp profile:": "Unsupported OCI-LXC seccomp profil:",
|
||||
"Unsupported Terminal": "Terminal som inte stöds",
|
||||
"Unsupported architecture for Figurine:": "Arkitekturen stöds inte av Figurine:",
|
||||
"Unsupported architecture:": "Ostödd arkitektur:",
|
||||
"Unsupported backup compression": "Unsupported backup komprimering",
|
||||
"Unsupported credential pattern:": "Unsupported credential mönster:",
|
||||
|
||||
@@ -3034,15 +3034,27 @@ register_tool "fastfetch" true "$FUNC_VERSION"
|
||||
|
||||
|
||||
configure_figurine() {
|
||||
local FUNC_VERSION="1.1"
|
||||
local FUNC_VERSION="1.2"
|
||||
pmx_journal_context "configure_figurine" "$FUNC_VERSION"
|
||||
# description: Install Figurine (ASCII-art hostname banner) and wire it into the SSH login flow.
|
||||
msg_info2 "$(translate "Installing and configuring Figurine...")"
|
||||
# `FIGURINE_VERSION` env var allows pinning to a specific release;
|
||||
# default tracks the last tested upstream tag. Audit Tier 6 —
|
||||
# recursos remotos sin pinning de versión.
|
||||
local version="${FIGURINE_VERSION:-2.0.0}"
|
||||
local file="figurine_linux_amd64_v${version}.tar.gz"
|
||||
local version="${FIGURINE_VERSION:-2.1.1}"
|
||||
# Prefer the Debian userspace ABI; the upstream 32-bit ARM build requires ARMv7.
|
||||
local arch
|
||||
arch="$(dpkg --print-architecture 2>/dev/null || uname -m)"
|
||||
case "$arch" in
|
||||
amd64|x86_64) arch="amd64" ;;
|
||||
arm64|aarch64) arch="arm64" ;;
|
||||
armhf|armv7l|armv8l) arch="arm" ;;
|
||||
*)
|
||||
msg_error "$(translate "Unsupported architecture for Figurine:") $arch"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
local file="figurine_linux_${arch}_v${version}.tar.gz"
|
||||
local url="https://github.com/arsham/figurine/releases/download/v${version}/${file}"
|
||||
local temp_dir; temp_dir=$(mktemp -d)
|
||||
local install_dir="/usr/local/bin"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Render the real help component; only its translation provider is replaced.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const React = require('react');
|
||||
const { renderToStaticMarkup } = require('react-dom/server');
|
||||
const source = fs.readFileSync('AppImage/components/apprise-service-help.tsx', 'utf8');
|
||||
const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX } }).outputText;
|
||||
const english = JSON.parse(fs.readFileSync('AppImage/messages/en/common.json'));
|
||||
function resolve(catalog, key) { return key.split('.').reduce((value, part) => value?.[part], catalog); }
|
||||
for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv', 'missing']) {
|
||||
const catalog = locale === 'missing' ? {} : JSON.parse(fs.readFileSync(`AppImage/messages/${locale}/common.json`));
|
||||
const context = { exports: {}, require: name => name === '../lib/i18n/provider' ? {
|
||||
useT: () => key => resolve(catalog, key) ?? resolve(english, key),
|
||||
} : require(name) };
|
||||
vm.runInNewContext(js, context);
|
||||
const html = renderToStaticMarkup(React.createElement(context.exports.AppriseServiceHelp));
|
||||
assert.ok(html.includes('https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C'));
|
||||
assert.ok(html.includes('matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com'));
|
||||
assert.ok(html.includes('https://appriseit.com/services/slack/'));
|
||||
assert.ok(html.includes('https://appriseit.com/services/matrix/'));
|
||||
assert.ok(!html.includes('settings.notifications.'), `${locale}: no unresolved labels`);
|
||||
}
|
||||
for (const [, key] of source.matchAll(/t\("([^"]+)"\)/g)) assert.ok(resolve(english, key), key);
|
||||
assert.ok(!source.includes('onChange') && !source.includes('fetch(') && !source.includes('updateChannel'));
|
||||
const settings = fs.readFileSync('AppImage/components/notification-settings.tsx', 'utf8');
|
||||
const section = settings.slice(settings.indexOf('<TabsContent value="apprise"'), settings.indexOf('</TabsContent>', settings.indexOf('<TabsContent value="apprise"')));
|
||||
for (const part of ['<AppriseServiceHelp />', 'renderChannelCategories("apprise")', 'handleTest("apprise")']) assert.ok(section.includes(part));
|
||||
console.log('PASS: real provider-help JSX, shipped locales and English fallback, no secret mutation, native category/test controls retained');
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Real Apprise Slack/Matrix plugins with the HTTP boundary replaced.
|
||||
|
||||
No provider account or network is used. These contracts run against both
|
||||
the minimum bundled Apprise version and the current tested version.
|
||||
"""
|
||||
import gc
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import apprise
|
||||
import requests
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "AppImage/scripts"))
|
||||
from notification_channels import create_channel
|
||||
|
||||
SLACK = "https://hooks.slack.com/services/T00000000/B00000000/fixturesecret1234567890"
|
||||
MATRIX = "matrixs://notify:fixture-password@matrix.example.test/!room:matrix.example.test"
|
||||
|
||||
|
||||
class AppriseServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.calls = []
|
||||
self.status = 200
|
||||
self.transport = patch.object(requests.Session, "request", side_effect=self.http)
|
||||
self.transport.start()
|
||||
self.addCleanup(self.transport.stop)
|
||||
self.sleep = patch("notification_channels.time.sleep")
|
||||
self.sleep.start()
|
||||
self.addCleanup(self.sleep.stop)
|
||||
self.addCleanup(gc.collect)
|
||||
|
||||
def http(self, method, url, **kwargs):
|
||||
self.calls.append((method, url, kwargs))
|
||||
response = requests.Response()
|
||||
response.status_code = self.status
|
||||
response.url = url
|
||||
response.reason = "OK" if self.status == 200 else "Rejected"
|
||||
response._content_consumed = True
|
||||
host, path = urlparse(url).hostname, urlparse(url).path
|
||||
if host == "hooks.slack.com":
|
||||
self.assertTrue(path.startswith("/services/"))
|
||||
response._content = b"ok" if self.status == 200 else b"invalid_auth"
|
||||
else:
|
||||
self.assertEqual(host, "matrix.example.test", "no unexpected destinations")
|
||||
if self.status != 200:
|
||||
payload = {"errcode": "M_FORBIDDEN", "error": "Invalid credentials"}
|
||||
elif path == "/.well-known/matrix/client":
|
||||
payload = {"m.homeserver": {"base_url": "https://" + host}}
|
||||
elif path.endswith("/login"):
|
||||
payload = {"access_token": "fixture-token", "home_server": host, "user_id": "@notify:" + host}
|
||||
elif path.endswith("/joined_rooms"):
|
||||
payload = {"joined_rooms": ["!room:" + host]}
|
||||
elif "/join/" in path:
|
||||
payload = {"room_id": "!room:" + host}
|
||||
elif "/send/" in path:
|
||||
payload = {"event_id": "$fixture"}
|
||||
elif path.endswith("/logout"):
|
||||
payload = {}
|
||||
elif path.endswith("/versions"):
|
||||
payload = {"versions": ["v1.1"]}
|
||||
else:
|
||||
raise AssertionError("Unhandled Matrix request: " + path)
|
||||
response._content = json.dumps(payload).encode()
|
||||
return response
|
||||
|
||||
def send(self, url):
|
||||
channel = create_channel("apprise", {"url": url})
|
||||
self.assertIsNotNone(channel)
|
||||
return channel.send("ProxMenux probe", "Controlled message", "WARNING")
|
||||
|
||||
def test_slack_accepts_the_native_webhook_without_conversion(self):
|
||||
result = self.send(SLACK)
|
||||
self.assertTrue(result["success"], result)
|
||||
self.assertTrue(any(url == SLACK for _, url, _ in self.calls))
|
||||
self.assertIn("Controlled message", str(self.calls))
|
||||
|
||||
def test_slack_apprise_scheme_uses_the_same_transport(self):
|
||||
result = self.send(SLACK.replace("https://hooks.slack.com/services/", "slack://"))
|
||||
self.assertTrue(result["success"], result)
|
||||
self.assertTrue(any(url == SLACK for _, url, _ in self.calls))
|
||||
|
||||
def test_matrix_authenticates_and_delivers_to_the_full_room_id_over_https(self):
|
||||
result = self.send(MATRIX)
|
||||
self.assertTrue(result["success"], result)
|
||||
sends = [(url, kwargs) for _, url, kwargs in self.calls if "/send/" in url]
|
||||
self.assertTrue(sends, self.calls)
|
||||
self.assertTrue(all(url.startswith("https://matrix.example.test/") for url, _ in sends))
|
||||
self.assertTrue(all(kwargs.get("verify", True) for _, kwargs in sends))
|
||||
self.assertIn("Controlled message", str(sends))
|
||||
self.assertTrue(any(url.endswith("/login") for _, url, _ in self.calls))
|
||||
|
||||
def test_test_button_contract_uses_the_same_provider(self):
|
||||
ok, error = create_channel("apprise", {"url": SLACK}).test()
|
||||
self.assertTrue(ok, error)
|
||||
self.assertTrue(self.calls)
|
||||
|
||||
def test_rejected_slack_webhook_is_not_reported_as_delivered(self):
|
||||
self.status = 403
|
||||
result = self.send(SLACK)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(result["error"])
|
||||
|
||||
def test_invalid_matrix_credentials_are_not_reported_as_delivered(self):
|
||||
self.status = 403
|
||||
result = self.send(MATRIX)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(any("/send/" in url for _, url, _ in self.calls))
|
||||
|
||||
def test_invalid_urls_and_missing_library_fail_before_any_request(self):
|
||||
channel = create_channel("apprise", {"url": "not-a-url"})
|
||||
self.assertFalse(channel.send("Probe", "Body")["success"])
|
||||
channel = create_channel("apprise", {"url": SLACK})
|
||||
with patch.object(channel, "_load_apprise", return_value=None):
|
||||
self.assertFalse(channel.send("Probe", "Body")["success"])
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_connection_failure_never_claims_delivery(self):
|
||||
for url in (SLACK, MATRIX):
|
||||
with patch.object(requests.Session, "request", side_effect=requests.ConnectionError("fixture connection failure")):
|
||||
result = self.send(url)
|
||||
self.assertFalse(result["success"])
|
||||
self.assertTrue(result["error"])
|
||||
|
||||
def test_severity_mapping_keeps_native_notification_semantics(self):
|
||||
channel = create_channel("apprise", {"url": SLACK})
|
||||
for severity, expected in (("CRITICAL", apprise.NotifyType.FAILURE), ("WARNING", apprise.NotifyType.WARNING),
|
||||
("INFO", apprise.NotifyType.INFO), ("OK", apprise.NotifyType.SUCCESS),
|
||||
("SUCCESS", apprise.NotifyType.SUCCESS)):
|
||||
self.assertEqual(channel._severity_to_notify_type(apprise, severity), expected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,27 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const source = fs.readFileSync('AppImage/lib/backup-retention.ts', 'utf8');
|
||||
const context = { exports: {} };
|
||||
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, context);
|
||||
const { parseRetention, buildRetention } = context.exports;
|
||||
for (const policy of ['keep-all=1', 'keep-last=3', 'keep-last=3,keep-weekly=2', 'keep-hourly=24,keep-daily=7,keep-monthly=12,keep-yearly=2']) {
|
||||
const { fields, keepAll } = parseRetention(policy);
|
||||
assert.equal(buildRetention(fields, keepAll), policy);
|
||||
}
|
||||
assert.equal(buildRetention({}, false), '', 'empty policy remains explicit');
|
||||
assert.equal(buildRetention({}, true), 'keep-all=1');
|
||||
assert.equal(buildRetention({ daily: '3', hourly: '0', last: '' }, false), 'keep-daily=3');
|
||||
assert.equal(buildRetention({ last: '3' }, true), 'keep-last=3', 'existing job editor precedence preserved');
|
||||
const component = fs.readFileSync('AppImage/components/backup-storage-retention.tsx', 'utf8');
|
||||
const english = JSON.parse(fs.readFileSync('AppImage/messages/en/common.json')).backup.storageRetention;
|
||||
for (const [, key] of component.matchAll(/t\("backup\.storageRetention\.([A-Za-z]+)"/g)) {
|
||||
assert.ok(typeof english[key] === 'string' && english[key], `translation: ${key}`);
|
||||
}
|
||||
assert.ok(component.includes('digest: editing.digest'));
|
||||
assert.ok(component.includes('method: "PUT"'));
|
||||
assert.ok(!component.includes('/prune'));
|
||||
assert.ok(fs.readFileSync('AppImage/components/vm-backup-jobs.tsx', 'utf8').includes('from "../lib/backup-retention"'));
|
||||
assert.ok(fs.readFileSync('AppImage/components/backup-tab.tsx', 'utf8').includes('<BackupStorageRetention />'));
|
||||
console.log('PASS: shared native retention properties, keep-all/count round trips, explicit empty policy, translations and digest wiring');
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Exercise the real Figurine installer with isolated files and no network/root access."""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "scripts/post_install/customizable_post_install.sh"
|
||||
FUNCTION = re.search(
|
||||
r"^configure_figurine\(\) \{\n.*?^\}", SCRIPT.read_text(), re.M | re.S
|
||||
).group(0)
|
||||
VERSION = re.search(r'FIGURINE_VERSION:-([^}]+)', FUNCTION).group(1)
|
||||
FUNCTION_VERSION = re.search(r'local FUNC_VERSION="([^"]+)"', FUNCTION).group(1)
|
||||
|
||||
|
||||
class FigurineInstallation(unittest.TestCase):
|
||||
def run_installer(self, arch="amd64", *, kernel="x86_64", dpkg_exit=0,
|
||||
release=None, download_exit=0, package="valid", existing=True):
|
||||
with tempfile.TemporaryDirectory(prefix="figurine-test-") as directory:
|
||||
root = Path(directory)
|
||||
binary_dir = root / "bin"
|
||||
profile_dir = root / "profile.d"
|
||||
binary_dir.mkdir()
|
||||
profile_dir.mkdir()
|
||||
(root / "tmp").mkdir()
|
||||
binary = binary_dir / "figurine"
|
||||
profile = profile_dir / "figurine.sh"
|
||||
bashrc = root / "bashrc"
|
||||
if existing:
|
||||
binary.write_text("previous binary\n")
|
||||
binary.chmod(0o755)
|
||||
profile.write_text("previous profile\n")
|
||||
bashrc.write_text("# previous bashrc\n")
|
||||
archive = root / "fixture.tar.gz"
|
||||
payload = b"#!/bin/sh\nprintf 'fixture Figurine\\n'\n"
|
||||
if package == "corrupt":
|
||||
archive.write_bytes(b"invalid tarball")
|
||||
else:
|
||||
with tarfile.open(archive, "w:gz") as tar:
|
||||
name = "deploy/figurine" if package == "valid" else "README"
|
||||
member = tarfile.TarInfo(name)
|
||||
member.size = len(payload)
|
||||
tar.addfile(member, io.BytesIO(payload))
|
||||
|
||||
# Relocate every installer filesystem boundary, including its login script.
|
||||
function = (FUNCTION.replace("/usr/local/bin", str(binary_dir))
|
||||
.replace("/etc/profile.d", str(profile_dir))
|
||||
.replace("/root/.bashrc", str(bashrc)))
|
||||
harness = r'''
|
||||
translate() { printf '%s' "$1"; }
|
||||
msg_info2() { :; }
|
||||
msg_info() { :; }
|
||||
msg_ok() { :; }
|
||||
msg_success() { :; }
|
||||
msg_error() { printf '%s\n' "$*" >&2; }
|
||||
pmx_journal_context() { :; }
|
||||
pmx_write_file() { cat > "$1"; }
|
||||
pmx_append_file() { cat >> "$1"; }
|
||||
pmx_edit_file() { :; }
|
||||
chown() { :; }
|
||||
dpkg() {
|
||||
[[ "$TEST_DPKG_EXIT" == 0 ]] || return "$TEST_DPKG_EXIT"
|
||||
printf '%s\n' "$TEST_ARCH"
|
||||
}
|
||||
uname() { printf '%s\n' "$TEST_KERNEL"; }
|
||||
wget() {
|
||||
[[ "$1" == -qO ]] || return 90
|
||||
printf '%s\n' "$3" >> "$TEST_ROOT/downloads"
|
||||
[[ "$TEST_DOWNLOAD_EXIT" == 0 ]] || return "$TEST_DOWNLOAD_EXIT"
|
||||
cp "$TEST_ROOT/fixture.tar.gz" "$2"
|
||||
}
|
||||
register_tool() { printf '%s\n' "$*" > "$TEST_ROOT/registered"; }
|
||||
'''
|
||||
harness += function + "\nconfigure_figurine\nexit \"$?\"\n"
|
||||
environment = {
|
||||
"PATH": f"{binary_dir}:/usr/bin:/bin",
|
||||
"HOME": str(root),
|
||||
"TMPDIR": str(root / "tmp"),
|
||||
"LC_ALL": "C",
|
||||
"TEST_ROOT": str(root),
|
||||
"TEST_ARCH": arch,
|
||||
"TEST_DPKG_EXIT": str(dpkg_exit),
|
||||
"TEST_KERNEL": kernel,
|
||||
"TEST_DOWNLOAD_EXIT": str(download_exit),
|
||||
}
|
||||
if release is not None:
|
||||
environment["FIGURINE_VERSION"] = release
|
||||
result = subprocess.run(
|
||||
["/bin/bash", "--noprofile", "--norc"], input=harness,
|
||||
text=True, capture_output=True, env=environment, timeout=10,
|
||||
)
|
||||
return {
|
||||
"result": result,
|
||||
"binary": binary.read_bytes(),
|
||||
"mode": binary.stat().st_mode & 0o777,
|
||||
"profile": profile.read_text(),
|
||||
"bashrc": bashrc.read_text(),
|
||||
"downloads": (root / "downloads").read_text() if (root / "downloads").exists() else "",
|
||||
"registered": (root / "registered").read_text() if (root / "registered").exists() else "",
|
||||
"payload": payload,
|
||||
"binary_path": str(binary),
|
||||
}
|
||||
|
||||
def test_installs_release_matching_debian_userspace(self):
|
||||
for arch, asset in (("amd64", "amd64"), ("arm64", "arm64"), ("armhf", "arm")):
|
||||
with self.subTest(arch=arch):
|
||||
state = self.run_installer(arch)
|
||||
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
|
||||
self.assertEqual(state["downloads"],
|
||||
f"https://github.com/arsham/figurine/releases/download/v{VERSION}/"
|
||||
f"figurine_linux_{asset}_v{VERSION}.tar.gz\n")
|
||||
self.assertEqual(state["binary"], state["payload"])
|
||||
self.assertEqual(state["mode"], 0o755)
|
||||
self.assertIn(f'{state["binary_path"]} -f "3d.flf" $(hostname)', state["profile"])
|
||||
self.assertEqual(state["registered"], f"figurine true {FUNCTION_VERSION}\n")
|
||||
|
||||
def test_falls_back_to_kernel_when_dpkg_is_unavailable(self):
|
||||
for kernel, asset in (("x86_64", "amd64"), ("aarch64", "arm64"),
|
||||
("armv7l", "arm"), ("armv8l", "arm")):
|
||||
with self.subTest(kernel=kernel):
|
||||
state = self.run_installer("", kernel=kernel, dpkg_exit=127)
|
||||
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
|
||||
self.assertIn(f"figurine_linux_{asset}_v{VERSION}.tar.gz", state["downloads"])
|
||||
|
||||
def test_userspace_takes_precedence_over_kernel(self):
|
||||
state = self.run_installer("armhf", kernel="aarch64")
|
||||
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
|
||||
self.assertIn(f"figurine_linux_arm_v{VERSION}.tar.gz", state["downloads"])
|
||||
|
||||
def test_fresh_install_creates_binary_banner_and_aliases(self):
|
||||
state = self.run_installer(existing=False)
|
||||
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
|
||||
self.assertEqual(state["binary"], state["payload"])
|
||||
self.assertIn(' -f "3d.flf" $(hostname)', state["profile"])
|
||||
self.assertIn("shopt -s expand_aliases", state["bashrc"])
|
||||
self.assertIn("alias aptup='apt update && apt dist-upgrade'", state["bashrc"])
|
||||
self.assertEqual(state["registered"], f"figurine true {FUNCTION_VERSION}\n")
|
||||
|
||||
def test_unsupported_architecture_fails_before_changes_or_download(self):
|
||||
for arch in ("i386", "riscv64", "armel", "armv6l", "ppc64el", ""):
|
||||
with self.subTest(arch=arch):
|
||||
state = self.run_installer(arch)
|
||||
self.assertNotEqual(state["result"].returncode, 0)
|
||||
self.assertIn("Unsupported architecture for Figurine:", state["result"].stderr)
|
||||
self.assertEqual(state["downloads"], "")
|
||||
self.assert_preserved(state)
|
||||
self.assertEqual(state["bashrc"], "# previous bashrc\n")
|
||||
|
||||
def assert_preserved(self, state):
|
||||
self.assertEqual(state["binary"], b"previous binary\n")
|
||||
self.assertEqual(state["profile"], "previous profile\n")
|
||||
self.assertEqual(state["registered"], "")
|
||||
|
||||
def test_download_failure_preserves_previous_installation(self):
|
||||
state = self.run_installer(download_exit=8)
|
||||
self.assertNotEqual(state["result"].returncode, 0)
|
||||
self.assertIn("Failed to download Figurine", state["result"].stderr)
|
||||
self.assert_preserved(state)
|
||||
|
||||
def test_invalid_packages_preserve_previous_installation(self):
|
||||
for package, error in (("corrupt", "Failed to extract package"),
|
||||
("missing", "Binary not found in extracted content.")):
|
||||
with self.subTest(package=package):
|
||||
state = self.run_installer(package=package)
|
||||
self.assertNotEqual(state["result"].returncode, 0)
|
||||
self.assertIn(error, state["result"].stderr)
|
||||
self.assert_preserved(state)
|
||||
|
||||
def test_release_override_is_preserved(self):
|
||||
state = self.run_installer("arm64", release="2.1.0")
|
||||
self.assertEqual(state["result"].returncode, 0, state["result"].stderr)
|
||||
self.assertIn("/v2.1.0/figurine_linux_arm64_v2.1.0.tar.gz", state["downloads"])
|
||||
|
||||
def test_existing_installation_is_offered_the_update_once(self):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"post_install_versions", ROOT / "AppImage/scripts/post_install_versions.py")
|
||||
versions = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(versions)
|
||||
metadata = versions.parse_post_install_script(SCRIPT)
|
||||
installed = {"figurine": {"installed": True, "version": "1.1", "source": "custom"}}
|
||||
updates = versions._detect_updates({}, metadata, installed)
|
||||
self.assertEqual(len(updates), 1)
|
||||
self.assertEqual(updates[0]["available_version"], FUNCTION_VERSION)
|
||||
self.assertEqual(updates[0]["function"], "configure_figurine")
|
||||
installed["figurine"]["version"] = FUNCTION_VERSION
|
||||
self.assertEqual(versions._detect_updates({}, metadata, installed), [])
|
||||
|
||||
tree = ast.parse((ROOT / "AppImage/scripts/flask_proxmenux_routes.py").read_text())
|
||||
fallback = next(ast.literal_eval(node.value) for node in tree.body
|
||||
if isinstance(node, ast.Assign)
|
||||
and any(isinstance(t, ast.Name) and t.id == "TOOL_METADATA"
|
||||
for t in node.targets))
|
||||
self.assertEqual(fallback["figurine"]["version"], FUNCTION_VERSION)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,5 +1,6 @@
|
||||
"""VM/CT backup jobs API: what reaches pvesh, with pvesh itself replaced."""
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
@@ -23,6 +24,34 @@ GUESTS = [{"vmid": 100, "name": "web", "type": "qemu", "node": "pve1"},
|
||||
{"vmid": 200, "name": "other", "type": "qemu", "node": "pve2"}]
|
||||
|
||||
|
||||
@unittest.skipIf(Flask is None, "Flask runtime required")
|
||||
class StorageRetentionAuthorizationTests(unittest.TestCase):
|
||||
def test_real_authentication_guard_blocks_read_only_writes_before_pvesh(self):
|
||||
scripts = Path(__file__).resolve().parents[1] / "AppImage/scripts"
|
||||
auth = types.ModuleType("auth_manager")
|
||||
auth.load_auth_config = lambda: {"enabled": True}
|
||||
auth.verify_token = lambda token: "tester" if token in ("admin", "reader") else None
|
||||
auth.verify_token_full = lambda token: ("tester", "full_admin" if token == "admin" else "read_only")
|
||||
spec = importlib.util.spec_from_file_location("storage_retention_middleware", scripts / "jwt_middleware.py")
|
||||
middleware = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, auth_manager=auth):
|
||||
spec.loader.exec_module(middleware)
|
||||
spec = importlib.util.spec_from_file_location("storage_retention_auth_routes", scripts / "flask_vm_backup_routes.py")
|
||||
routes = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, jwt_middleware=middleware):
|
||||
spec.loader.exec_module(routes)
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(routes.vm_backup_bp)
|
||||
client = app.test_client()
|
||||
data = {"digest": "a" * 40, "prune_backups": "keep-last=3"}
|
||||
with patch.object(routes, "_pvesh") as run:
|
||||
self.assertEqual(client.get("/api/vm-backup-storages").status_code, 401)
|
||||
self.assertEqual(client.put("/api/vm-backup-storages/local", json=data).status_code, 401)
|
||||
self.assertEqual(client.put("/api/vm-backup-storages/local", json=data,
|
||||
headers={"Authorization": "Bearer reader"}).status_code, 403)
|
||||
run.assert_not_called()
|
||||
|
||||
|
||||
@unittest.skipIf(Flask is None, "Flask runtime required")
|
||||
class VmBackupJobsApiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -73,9 +102,63 @@ class VmBackupJobsApiTests(unittest.TestCase):
|
||||
|
||||
def test_reading_needs_a_session_and_changing_needs_an_administrator(self):
|
||||
guards = dict((name, kind) for kind, name in self.guards)
|
||||
self.assertEqual({name for name, kind in guards.items() if kind == "auth"}, {"list_jobs", "options", "get_job"})
|
||||
self.assertEqual({name for name, kind in guards.items() if kind == "auth"}, {"list_jobs", "options", "get_job", "backup_storage_retention"})
|
||||
self.assertEqual({name for name, kind in guards.items() if kind == "admin"},
|
||||
{"create_job", "update_job", "delete_job", "toggle_job", "run_job"})
|
||||
{"create_job", "update_job", "delete_job", "toggle_job", "run_job", "update_backup_storage_retention"})
|
||||
|
||||
def test_storage_retention_lists_only_backup_storages_and_no_secrets(self):
|
||||
with patch.object(self.routes, "_pvesh_json", return_value=(True, [
|
||||
{"storage": "local", "type": "dir", "content": "backup,iso", "digest": "a" * 40,
|
||||
"prune-backups": {"keep-last": 3}, "password": "private"},
|
||||
{"storage": "images", "type": "dir", "content": "images", "digest": "a" * 40},
|
||||
])):
|
||||
answer = self.client.get("/api/vm-backup-storages").get_json()
|
||||
self.assertEqual(answer, {"storages": [{"id": "local", "type": "dir", "digest": "a" * 40, "prune_backups": "keep-last=3"}]})
|
||||
|
||||
def storage_policy(self, prune="keep-last=3,keep-weekly=2", digest="a" * 40, current=None):
|
||||
if current is None:
|
||||
current = {"storage": "local", "content": "backup,iso", "digest": "a" * 40}
|
||||
with patch.object(self.routes, "_pvesh_json", return_value=(True, current)), self.pvesh():
|
||||
return self.client.put("/api/vm-backup-storages/local", json={"digest": digest, "prune_backups": prune})
|
||||
|
||||
def test_saving_native_storage_retention_never_runs_pruning_or_changes_other_properties(self):
|
||||
response = self.storage_policy()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.changes(), [["set", "/storage/local", "--digest", "a" * 40,
|
||||
"--prune-backups", "keep-last=3,keep-weekly=2"]])
|
||||
self.calls.clear()
|
||||
self.assertEqual(self.storage_policy("keep-all=1").status_code, 200)
|
||||
self.assertEqual(self.changes()[0][-1], "keep-all=1")
|
||||
|
||||
def test_an_empty_policy_returns_the_storage_to_the_proxmox_default(self):
|
||||
self.assertEqual(self.storage_policy("").status_code, 200)
|
||||
self.assertEqual(self.changes(), [["set", "/storage/local", "--digest", "a" * 40, "--delete", "prune-backups"]])
|
||||
|
||||
def test_invalid_storage_retention_is_rejected_before_commands(self):
|
||||
invalid = [None, [], {}, {"digest": "a" * 40, "prune_backups": []}]
|
||||
for prune in ("keep-last=3,keep-daily=0", "keep-last=-1", "keep-all=0", "keep-all=2", "keep-last=0", "keep-last=1,keep-last=2",
|
||||
"keep-all=1,keep-last=2", "keep-last=2 --delete path", "keep-last=1\n", "keep-other=1"):
|
||||
invalid.append({"digest": "a" * 40, "prune_backups": prune})
|
||||
invalid += [{"digest": "invalid", "prune_backups": "keep-last=1"}]
|
||||
with patch.object(self.routes, "_pvesh") as run:
|
||||
for data in invalid:
|
||||
with self.subTest(data=data):
|
||||
self.assertEqual(self.client.put("/api/vm-backup-storages/local", json=data).status_code, 400)
|
||||
self.assertEqual(self.client.put("/api/vm-backup-storages/-bad", json=invalid[-1]).status_code, 400)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_stale_storage_configuration_or_non_backup_storage_is_not_overwritten(self):
|
||||
for current in ({"content": "backup", "digest": "b" * 40},
|
||||
{"content": "images", "digest": "a" * 40}):
|
||||
self.assertEqual(self.storage_policy(current=current).status_code, 409)
|
||||
self.assertEqual(self.changes(), [])
|
||||
|
||||
def test_native_storage_update_failure_is_visible(self):
|
||||
with patch.object(self.routes, "_pvesh_json", return_value=(True, {"content": "backup", "digest": "a" * 40})), \
|
||||
patch.object(self.routes, "_pvesh", return_value=(False, "configuration changed under lock")):
|
||||
response = self.client.put("/api/vm-backup-storages/local", json={"digest": "a" * 40, "prune_backups": "keep-last=3"})
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertIn("changed", response.get_json()["error"])
|
||||
|
||||
def test_the_list_gives_text_properties_and_the_host_backups_that_hang_from_a_job(self):
|
||||
with self.pvesh(), patch.object(self.routes, "_host_backups_attached",
|
||||
|
||||
@@ -312,25 +312,31 @@ EOF
|
||||
<p className="text-lg mb-2">{t("figurine.automates")}</p>
|
||||
<CopyableCode
|
||||
code={`
|
||||
# Check for previous installation and remove if found
|
||||
if command -v figurine &> /dev/null; then
|
||||
rm -f "/usr/local/bin/figurine"
|
||||
fi
|
||||
|
||||
# Download and install Figurine
|
||||
version="2.0.0"
|
||||
file="figurine_linux_amd64_v\${version}.tar.gz"
|
||||
(
|
||||
set -e
|
||||
version="\${FIGURINE_VERSION:-2.1.1}"
|
||||
arch="$(dpkg --print-architecture 2>/dev/null || uname -m)"
|
||||
case "$arch" in
|
||||
amd64|x86_64) arch="amd64" ;;
|
||||
arm64|aarch64) arch="arm64" ;;
|
||||
armhf|armv7l|armv8l) arch="arm" ;;
|
||||
*) echo "Unsupported architecture for Figurine: $arch" >&2; exit 1 ;;
|
||||
esac
|
||||
file="figurine_linux_\${arch}_v\${version}.tar.gz"
|
||||
url="https://github.com/arsham/figurine/releases/download/v\${version}/\${file}"
|
||||
wget -qO "/tmp/\${file}" "\${url}"
|
||||
tar -xf "/tmp/\${file}" -C "/tmp"
|
||||
mv "/tmp/deploy/figurine" "/usr/local/bin/figurine"
|
||||
chmod +x "/usr/local/bin/figurine"
|
||||
temp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$temp_dir"' EXIT
|
||||
wget -qO "\${temp_dir}/\${file}" "\${url}"
|
||||
tar -xf "\${temp_dir}/\${file}" -C "$temp_dir"
|
||||
install -m 0755 "\${temp_dir}/deploy/figurine" "/usr/local/bin/figurine"
|
||||
|
||||
# Create welcome message script
|
||||
cat << 'EOF' > "/etc/profile.d/figurine.sh"
|
||||
/usr/local/bin/figurine -f "3d.flf" $(hostname)
|
||||
EOF
|
||||
chmod +x "/etc/profile.d/figurine.sh"
|
||||
)
|
||||
`}
|
||||
/>
|
||||
|
||||
|
||||
@@ -119,9 +119,9 @@
|
||||
"intro": "This option installs and configures Figurine, a tool that creates stylish ASCII text banners for your terminal, displaying your hostname in a visually appealing 3D format.",
|
||||
"doesLabel": "What it does:",
|
||||
"doesItems": [
|
||||
"Downloads and installs Figurine v2.0.0 from GitHub",
|
||||
"Downloads and installs Figurine v2.1.1 from GitHub for the host architecture (AMD64, ARM64 or ARMv7)",
|
||||
"Creates a welcome message that displays your hostname in 3D ASCII art when you log in",
|
||||
"Automatically removes any previous Figurine installation if present",
|
||||
"Updates an existing Figurine binary after download and extraction succeed",
|
||||
"Sets up the welcome message to run automatically at login"
|
||||
],
|
||||
"practicalLabel": "Practical Use:",
|
||||
|
||||
@@ -119,9 +119,9 @@
|
||||
"intro": "Esta opción instala y configura Figurine, una herramienta que crea banners de texto ASCII con estilo para tu terminal, mostrando tu hostname en un formato 3D atractivo visualmente.",
|
||||
"doesLabel": "Qué hace:",
|
||||
"doesItems": [
|
||||
"Descarga e instala Figurine v2.0.0 desde GitHub",
|
||||
"Descarga e instala Figurine v2.1.1 desde GitHub para la arquitectura del host (AMD64, ARM64 o ARMv7)",
|
||||
"Crea un mensaje de bienvenida que muestra tu hostname en ASCII art 3D al hacer login",
|
||||
"Elimina automáticamente cualquier instalación previa de Figurine si la hay",
|
||||
"Actualiza el binario existente de Figurine tras descargar y extraer el paquete correctamente",
|
||||
"Configura el mensaje de bienvenida para que se ejecute automáticamente al hacer login"
|
||||
],
|
||||
"practicalLabel": "Uso práctico:",
|
||||
|
||||
Reference in New Issue
Block a user