From 113659c0e3d5aedd7ec2f40fc65bfe14ab9c08c7 Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:32:05 +0200 Subject: [PATCH 01/14] Expose native storage retention for VM and CT backups --- .../tests/test_command_descriptions.py | 4 + .../tests/test_vm_backup_jobs_wiring.py | 2 +- .../test-backup-storage-retention.yml | 50 ++++++++++ .../components/backup-storage-retention.tsx | 99 +++++++++++++++++++ AppImage/components/backup-tab.tsx | 3 +- AppImage/components/vm-backup-jobs.tsx | 19 +--- AppImage/lib/backup-retention.ts | 18 ++++ AppImage/messages/en/common.json | 17 ++++ AppImage/scripts/flask_vm_backup_routes.py | 53 ++++++++++ tests/test_backup_storage_retention.cjs | 27 +++++ tests/test_vm_backup_jobs_api.py | 83 +++++++++++++++- 11 files changed, 353 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/test-backup-storage-retention.yml create mode 100644 AppImage/components/backup-storage-retention.tsx create mode 100644 AppImage/lib/backup-retention.ts create mode 100644 tests/test_backup_storage_retention.cjs diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index ec35c3b2..7633c782 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -200,6 +200,10 @@ class CommandDescriptionsTests(unittest.TestCase): if lang != "en": messages = json.loads(path.read_text()) english = catalog("en") + # Storage-retention labels await the translation bot. Seed + # only those new leaves in this steady-state fixture. + for key, value in english["backup"]["storageRetention"].items(): + messages["backup"].setdefault("storageRetention", {}).setdefault(key, value) for section, key in (("encryption", "pbsHelp"), ("keyfileActions", "pveKeyDescription")): messages["backup"][section].setdefault(key, english["backup"][section][key]) diff --git a/.github/scripts/tests/test_vm_backup_jobs_wiring.py b/.github/scripts/tests/test_vm_backup_jobs_wiring.py index a8ecf4f5..930c82e3 100644 --- a/.github/scripts/tests/test_vm_backup_jobs_wiring.py +++ b/.github/scripts/tests/test_vm_backup_jobs_wiring.py @@ -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})') diff --git a/.github/workflows/test-backup-storage-retention.yml b/.github/workflows/test-backup-storage-retention.yml new file mode 100644 index 00000000..321a227e --- /dev/null +++ b/.github/workflows/test-backup-storage-retention.yml @@ -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 diff --git a/AppImage/components/backup-storage-retention.tsx b/AppImage/components/backup-storage-retention.tsx new file mode 100644 index 00000000..f27ae046 --- /dev/null +++ b/AppImage/components/backup-storage-retention.tsx @@ -0,0 +1,99 @@ +"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(null) + const [fields, setFields] = useState>({}) + const [keepAll, setKeepAll] = 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 || !storage.prune_backups) + setSaveError("") + setMessage("") + setEditing(storage) + } + + async function save() { + if (!editing || busy) return + const prune = buildRetention(fields, keepAll) + if (!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 + + {t("backup.storageRetention.title")} + + + +

{t("backup.storageRetention.description")}

+ {message &&

{message}

} + {error ?

{t("backup.storageRetention.loadFailed")}

: !data ? + : !data.storages.length ? +

{t("backup.storageRetention.empty")}

: + data.storages.map(storage =>
+ {storage.id}{storage.type} + {storage.prune_backups || t("backup.storageRetention.default")} + +
)} +
+ { if (!open && !busy) setEditing(null) }}> + + {t("backup.storageRetention.editTitle", { storage: editing?.id ?? "" })} + {t("backup.storageRetention.effect")} + +
+ {RETENTION.map(key =>
+ + setFields(previous => ({ ...previous, [key]: event.target.value }))} /> +
)} +
+ {saveError &&

{saveError}

} + + +
+
+
+} diff --git a/AppImage/components/backup-tab.tsx b/AppImage/components/backup-tab.tsx index ae62e19c..ffa5f82d 100644 --- a/AppImage/components/backup-tab.tsx +++ b/AppImage/components/backup-tab.tsx @@ -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() {
- {view === "guests" && } + {view === "guests" && <>} ) } diff --git a/AppImage/components/vm-backup-jobs.tsx b/AppImage/components/vm-backup-jobs.tsx index b4054300..972c187d 100644 --- a/AppImage/components/vm-backup-jobs.tsx +++ b/AppImage/components/vm-backup-jobs.tsx @@ -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 = {} - 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, 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) diff --git a/AppImage/lib/backup-retention.ts b/AppImage/lib/backup-retention.ts new file mode 100644 index 00000000..7caa7686 --- /dev/null +++ b/AppImage/lib/backup-retention.ts @@ -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 = {} + 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, 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" : "" +} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index ca7e536d..f0d208f8 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -3938,6 +3938,23 @@ } }, "backup": { + "storageRetention": { + "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", diff --git a/AppImage/scripts/flask_vm_backup_routes.py b/AppImage/scripts/flask_vm_backup_routes.py index be83d8f9..e1259a2c 100644 --- a/AppImage/scripts/flask_vm_backup_routes.py +++ b/AppImage/scripts/flask_vm_backup_routes.py @@ -16,6 +16,9 @@ Endpoints, all under /api/vm-backup-jobs: DELETE / delete a job (its backups are kept) POST //toggle enable / disable POST //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,56 @@ 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/", 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") + if not isinstance(prune, str) or not PRUNE_RE.fullmatch(prune): + return _err("Invalid retention policy") + pairs = [part.split("=", 1) for part in prune.split(",")] + 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 + not any(int(value) > 0 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) + ok, out = _pvesh(["set", f"/storage/{storage_id}", "--digest", digest, "--prune-backups", prune]) + 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(): diff --git a/tests/test_backup_storage_retention.cjs b/tests/test_backup_storage_retention.cjs new file mode 100644 index 00000000..218be9fd --- /dev/null +++ b/tests/test_backup_storage_retention.cjs @@ -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('')); +console.log('PASS: shared native retention properties, keep-all/count round trips, explicit empty policy, translations and digest wiring'); diff --git a/tests/test_vm_backup_jobs_api.py b/tests/test_vm_backup_jobs_api.py index cd7a2fda..c9f7b12b 100644 --- a/tests/test_vm_backup_jobs_api.py +++ b/tests/test_vm_backup_jobs_api.py @@ -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,59 @@ 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_invalid_storage_retention_is_rejected_before_commands(self): + invalid = [None, [], {}, {"digest": "a" * 40, "prune_backups": []}] + for prune in ("", "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", From ee6b3bb232886019422c84c1efeb9283cb15e0b2 Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:39:43 +0200 Subject: [PATCH 02/14] Clarify Slack and Matrix setup and preserve Apprise recovery severity --- .../tests/test_command_descriptions.py | 5 + .github/workflows/test-apprise-services.yml | 50 +++++++ AppImage/components/apprise-service-help.tsx | 31 ++++ AppImage/components/notification-settings.tsx | 2 + AppImage/messages/en/common.json | 6 + AppImage/scripts/notification_channels.py | 2 +- tests/test_apprise_service_help.cjs | 30 ++++ tests/test_apprise_services.py | 137 ++++++++++++++++++ 8 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test-apprise-services.yml create mode 100644 AppImage/components/apprise-service-help.tsx create mode 100644 tests/test_apprise_service_help.cjs create mode 100644 tests/test_apprise_services.py diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index ec35c3b2..019ccb0c 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -200,6 +200,11 @@ class CommandDescriptionsTests(unittest.TestCase): if lang != "en": messages = json.loads(path.read_text()) english = catalog("en") + # Provider help ships in English before the translation bot. + for key in ("appriseServiceHelp", "appriseSlackSteps", "appriseSlackDocs", + "appriseMatrixSteps", "appriseMatrixDocs", "appriseServiceFinish"): + messages["settings"]["notifications"]["ui"].setdefault( + key, english["settings"]["notifications"]["ui"][key]) for section, key in (("encryption", "pbsHelp"), ("keyfileActions", "pveKeyDescription")): messages["backup"][section].setdefault(key, english["backup"][section][key]) diff --git a/.github/workflows/test-apprise-services.yml b/.github/workflows/test-apprise-services.yml new file mode 100644 index 00000000..59df1fd0 --- /dev/null +++ b/.github/workflows/test-apprise-services.yml @@ -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 diff --git a/AppImage/components/apprise-service-help.tsx b/AppImage/components/apprise-service-help.tsx new file mode 100644 index 00000000..db6eae71 --- /dev/null +++ b/AppImage/components/apprise-service-help.tsx @@ -0,0 +1,31 @@ +"use client" + +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
+ {t("settings.notifications.ui.appriseServiceHelp")} +
+
+

Slack

+

{t("settings.notifications.ui.appriseSlackSteps")}

+ https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C + + {t("settings.notifications.ui.appriseSlackDocs")} + +
+
+

Matrix

+

{t("settings.notifications.ui.appriseMatrixSteps")}

+ matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com + + {t("settings.notifications.ui.appriseMatrixDocs")} + +
+
+

{t("settings.notifications.ui.appriseServiceFinish")}

+
+} diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index ac9733f1..e84590ee 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -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. */} +
diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index ca7e536d..157093e4 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -2094,6 +2094,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", diff --git a/AppImage/scripts/notification_channels.py b/AppImage/scripts/notification_channels.py index c2bff230..ba9aaa4d 100644 --- a/AppImage/scripts/notification_channels.py +++ b/AppImage/scripts/notification_channels.py @@ -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 diff --git a/tests/test_apprise_service_help.cjs b/tests/test_apprise_service_help.cjs new file mode 100644 index 00000000..d2f0d131 --- /dev/null +++ b/tests/test_apprise_service_help.cjs @@ -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('', settings.indexOf('', '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'); diff --git a/tests/test_apprise_services.py b/tests/test_apprise_services.py new file mode 100644 index 00000000..fffb158e --- /dev/null +++ b/tests/test_apprise_services.py @@ -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() From c2ef5a54a6ca598dba548e335a6950d1f08b0a98 Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:57:14 +0200 Subject: [PATCH 03/14] fix: update Figurine and select architecture-specific binaries --- .github/workflows/test-figurine.yml | 73 +++++++ AppImage/scripts/flask_proxmenux_routes.py | 2 +- .../post_install/customizable_post_install.sh | 18 +- tests/test_figurine_installation.py | 204 ++++++++++++++++++ .../docs/post-install/optional/page.tsx | 28 ++- .../en/docs/post-install/optional.json | 4 +- .../es/docs/post-install/optional.json | 4 +- 7 files changed, 314 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/test-figurine.yml create mode 100644 tests/test_figurine_installation.py diff --git a/.github/workflows/test-figurine.yml b/.github/workflows/test-figurine.yml new file mode 100644 index 00000000..b379ce4b --- /dev/null +++ b/.github/workflows/test-figurine.yml @@ -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 diff --git a/AppImage/scripts/flask_proxmenux_routes.py b/AppImage/scripts/flask_proxmenux_routes.py index 48da2fbe..a5fa8266 100644 --- a/AppImage/scripts/flask_proxmenux_routes.py +++ b/AppImage/scripts/flask_proxmenux_routes.py @@ -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'}, diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh index 49ed0f3c..0f0f635d 100644 --- a/scripts/post_install/customizable_post_install.sh +++ b/scripts/post_install/customizable_post_install.sh @@ -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" diff --git a/tests/test_figurine_installation.py b/tests/test_figurine_installation.py new file mode 100644 index 00000000..2e7e7b36 --- /dev/null +++ b/tests/test_figurine_installation.py @@ -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() diff --git a/web/app/[locale]/docs/post-install/optional/page.tsx b/web/app/[locale]/docs/post-install/optional/page.tsx index 258b0b4e..5f1bc657 100644 --- a/web/app/[locale]/docs/post-install/optional/page.tsx +++ b/web/app/[locale]/docs/post-install/optional/page.tsx @@ -312,25 +312,31 @@ EOF

{t("figurine.automates")}

/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" +) `} /> diff --git a/web/messages/en/docs/post-install/optional.json b/web/messages/en/docs/post-install/optional.json index a7221f42..dc3a1d04 100644 --- a/web/messages/en/docs/post-install/optional.json +++ b/web/messages/en/docs/post-install/optional.json @@ -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:", diff --git a/web/messages/es/docs/post-install/optional.json b/web/messages/es/docs/post-install/optional.json index 0882f1ac..a2ab138a 100644 --- a/web/messages/es/docs/post-install/optional.json +++ b/web/messages/es/docs/post-install/optional.json @@ -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:", From 87720f14f18b581012807296f44af54a0f751abe Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:15:49 +0200 Subject: [PATCH 04/14] Make i18n steady-state tests handle pending translations --- .../tests/test_command_descriptions.py | 224 ++++++------------ .github/workflows/test-i18n.yml | 6 +- CONTRIBUTING.md | 15 +- 3 files changed, 90 insertions(+), 155 deletions(-) diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index ec35c3b2..f3b48675 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -80,165 +80,74 @@ class CommandDescriptionsTests(unittest.TestCase): self.assertEqual(catalog("sv")["terminal"]["commandDescriptions"][index], FIXTURE["locales"]["sv"][index]) - def test_workflow_default_catalogs_rerun_without_provider_or_writes(self): + def assert_completed_catalogs_are_stable(self, *, future_messages=None): workflow = (ROOT / ".github/workflows/build-i18n-messages.yml").read_text() languages = re.search(r'LANGS="\$\{LANGS:-(.*?)\}"', workflow).group(1) self.assertEqual(languages.split(","), list(MODULE.DEFAULT_LANGUAGES)) + locales = ["en", *languages.split(",")] + shipped = {ROOT / f"AppImage/messages/{lang}/common.json": catalog(lang) for lang in locales} + original_bytes = {path: path.read_bytes() for path in shipped} + + def strings(node, path=()): + # Independent traversal: every existing nonempty string, including + # explicit English and obsolete human keys, must survive completion. + if isinstance(node, str) and node: + yield path, node + elif isinstance(node, dict): + for key, value in node.items(): + yield from strings(value, (*path, key)) + elif isinstance(node, list): + for index, value in enumerate(node): + yield from strings(value, (*path, index)) + with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - for lang in ["en", *languages.split(",")]: - path = root / lang / "common.json" - path.parent.mkdir() - shutil.copyfile(ROOT / f"AppImage/messages/{lang}/common.json", path) - # This steady-state fixture assumes generation already finished. - # Seed only intentional message additions; keep the - # recovered command arrays and the repo catalogs exact. Runtime - # missing-key fallback is covered by the JSX seam tests and by - # test_storage_messages.cjs and test_health_text.cjs. - if lang != "en": - temporary = json.loads(path.read_text()) - for section, key in ( - ("archives", "emptyMessage"), - ("archives", "formerJob"), - ("archives", "formerJobTitle"), - ("archives", "hiddenOtherNodes"), - ("archives", "scopeAllNodes"), - ("archives", "scopeAriaLabel"), - ("archives", "scopeShowAll"), - ("archives", "scopeThisNode"), - ("destinations", "backupsKeptMessage"), - ("destinations", "localAttachHelpMessage"), - ): - temporary["backup"][section].setdefault( - key, catalog("en")["backup"][section][key]) - temporary["storage"].setdefault( - "savedSmartData", catalog("en")["storage"]["savedSmartData"]) - report = temporary["storage"]["smartReport"] - source_report = catalog("en")["storage"]["smartReport"] - for key in ("passedAssessment", "noReallocatedSectorsReported", - "passedMeaningTitle", "passedMeaning"): - report.setdefault(key, source_report[key]) - for key in ("passedTitle", "passedText"): - report["recommendations"].setdefault( - key, source_report["recommendations"][key]) - # New health-popup messages deliberately use runtime English - # fallback until each locale is translated. Model generator - # steady state only in this disposable copy, as for thermal keys. - for key in ( - 'certificateExpired', - 'certificateExpiresDays', - 'certificateInconclusive', - 'cpuCheckFailed', - 'cpuHighSustained', - 'cpuSustained', - 'fail2banCheckUnavailable', - 'fail2banInactive', - 'failedLoginsWithinThreshold', - 'kernelPackagesMore', - 'kernelPveUpdates', - 'loginCheckUnavailable', - 'memoryCheckFailed', - 'memoryPressure', - 'noFailedLogins', - 'noSwapConfigured', - 'noTemperatureSensor', - 'nonRunningKernelUpdates', - 'pendingPackageUpdates', - 'pendingSecurityUpdates', - 'pendingSecurityUpdatesDays', - 'pendingSecurityUpdatesUnpatched', - 'pveVersionAvailable', - 'ramHighSustained', - 'ramSustained', - 'runningKernelUpdate', - 'securityCheckUnavailable', - 'sensorHighSamplesSpan', - 'swapRamPressure', - 'systemNotUpdatedMonths', - 'systemNotUpdatedYear', - 'temperatureElevated', - 'unknownDetail', - 'updatesAptError', - 'updatesAptTimeout', - 'updatesCheckUnavailable', - 'uptimeKernelAdvice', - 'uptimeUnavailable', - ): - temporary["healthStatus"]["details"].setdefault( - key, catalog("en")["healthStatus"]["details"][key]) - # New thermal messages also await automatic translation. - # Seed only these intentional additions in the temporary copy. - for key in ( - "temperatureNoDisks", "temperatureNormal", "temperatureHighCount", - "temperatureCriticalCount", "temperatureUnavailable", - "temperatureIncomplete", "temperatureUnavailableCount", - ): - temporary["storage"].setdefault(key, catalog("en")["storage"][key]) - # Every shipped locale carries the temperature alert fallback - # keys; the seed only guards a catalog that lacks them. The - # runtime fallback is tested separately. - source_fallback = catalog("en")["runtime"]["notifications"]["fallback"] - fallback = temporary["runtime"]["notifications"]["fallback"] - for key in ("temperatureAlertTitle", "temperatureAlertBody", - "recordedReason", "recordedDetails"): - fallback.setdefault(key, source_fallback[key]) - # Slovak remains the exact upstream catalog; model the - # pending outcome-key generator additions in disposable - # copies rather than modifying its curated values. - if lang == 'sk': - local = temporary['runtime']['notifications'] - source = catalog('en')['runtime']['notifications'] - for key, value in source['backup'].items(): - local.setdefault('backup', {}).setdefault(key, value) - local['channels']['email']['status'].setdefault( - 'unconfirmed', source['channels']['email']['status']['unconfirmed']) - local['channels']['email']['status'].setdefault( - 'completed_with_warnings', source['channels']['email']['status']['completed_with_warnings']) - path.write_text(json.dumps(temporary, ensure_ascii=False)) - # Model steady state after the bot fills these intentional new - # messages; keep repository locales and all other leaves intact. - if lang != "en": - messages = json.loads(path.read_text()) - english = catalog("en") - for section, key in (("encryption", "pbsHelp"), - ("keyfileActions", "pveKeyDescription")): - messages["backup"][section].setdefault(key, english["backup"][section][key]) - for key in ("sshUserHelpMessage", "sshKeyHelpMessage", "sshKeySetupTitle", - "sshKeySetupHelp", "sshAuthorizedKeyHelp"): - messages["backup"]["destinations"].setdefault( - key, english["backup"]["destinations"][key]) - messages["backup"]["actions"].setdefault( - "prepareSshKey", english["backup"]["actions"]["prepareSshKey"]) - # New LXC observation labels intentionally ship only EN/SK. - # Model the bot's future output in temporary catalogs, just - # like the other pending keys above; never rewrite locales. - messages["scriptTerminal"].setdefault("endOfInput", english["scriptTerminal"]["endOfInput"]) - for key in ("noShell", "enterFailed"): - messages["lxcTerminal"]["errors"].setdefault(key, english["lxcTerminal"]["errors"][key]) - # The notification history panel texts await the bot as well. - for key, value in english["settings"]["notifications"]["history"].items(): - messages["settings"]["notifications"].setdefault("history", {}).setdefault(key, value) - for key in ("checkNow", "checkReadOnlyHelp", "checkCooldown", - "checkStale", "checkStaleHelp", "checkBeforeUpdate", - "startBeforeCheck", "notCheckedYet", - "checkStillRunning", "refreshCheckHelp", - "checkInProgress", "checkSystemPackages", "runCustomCommand", - "installedVersionUnverified", "versionStatusUnknown"): - messages["vmLxc"]["updates"].setdefault( - key, english["vmLxc"]["updates"][key]) - for key in ("startPending", "shutdownPending", "rebootPending", "stopPending"): - messages["vmLxc"]["actions"].setdefault( - key, english["vmLxc"]["actions"][key]) - for key in ("osDockerScope", "dockerImageMethod", "unverifiedTargetsHelp"): - messages["vmLxc"]["bulkUpdate"].setdefault( - key, english["vmLxc"]["bulkUpdate"][key]) - messages["vmLxc"]["errors"].setdefault( - "controlNotConfirmed", english["vmLxc"]["errors"]["controlNotConfirmed"]) - path.write_text(json.dumps(messages, ensure_ascii=False)) - before = {p: p.read_bytes() for p in root.glob("*/common.json")} + root = Path(directory).resolve() + for path in shipped: + destination = root / path.parent.name / "common.json" + destination.parent.mkdir() + shutil.copyfile(path, destination) + if future_messages is not None: + source = json.loads((root / "en/common.json").read_text()) + source["futureFeatureFixture"] = future_messages + (root / "en/common.json").write_text(json.dumps(source)) + argv = [str(SCRIPT), "--source", str(root / "en/common.json"), "--messages-dir", str(root), "--languages", languages, "--sleep", "0"] output = io.StringIO() + # Establish steady state with the real generator and a deterministic + # offline provider. Translation happens only in disposable catalogs; + # adding an EN-only feature never needs a hand-maintained seed list. + with mock.patch.object(sys, "argv", argv), \ + mock.patch.object(MODULE, "translate_one", side_effect=lambda text, lang, *_: f"TEST {lang} {text}") as seed, \ + mock.patch.object(MODULE, "write_json", wraps=MODULE.write_json) as initial_writer, \ + redirect_stdout(output), redirect_stderr(output): + self.assertEqual(MODULE.main(), 0, output.getvalue()) + self.assertNotRegex(output.getvalue(), r"(?m)^Completed with [0-9]+ translation failures") + self.assertTrue(all(call.args[0].is_relative_to(root) for call in initial_writer.call_args_list)) + for path, original in shipped.items(): + completed = json.loads((root / path.parent.name / "common.json").read_text()) + for keys, value in strings(original): + actual = completed + for key in keys: + actual = actual[key] + self.assertEqual(actual, value, f"{path.parent.name}: {keys}") + # The recovered arrays remain exact, including frozen English. + if path.parent.name != "en": + self.assertEqual(completed["terminal"]["commandDescriptions"], + FIXTURE["locales"].get(path.parent.name, + original["terminal"]["commandDescriptions"])) + if future_messages is not None and path.parent.name != "en": + self.assertEqual(completed["futureFeatureFixture"], { + "title": f"TEST {path.parent.name} Future feature {{count}}", + "items": [f"TEST {path.parent.name} First item", {"label": f"TEST {path.parent.name} Second item"}], + "enabled": False, "count": 2, "empty": [], + }) + if future_messages is not None: + self.assertGreaterEqual(seed.call_count, 3 * len(languages.split(","))) + before = {p: p.read_bytes() for p in root.glob("*/common.json")} + output = io.StringIO() + # Keep the original strict assertion: once completed, any provider + # call or catalog write is a regression. No production locale edits. with mock.patch.object(sys, "argv", argv), \ mock.patch.object(MODULE, "translate_one", side_effect=AssertionError("provider forbidden")) as provider, \ mock.patch.object(MODULE, "write_json", side_effect=AssertionError("writes forbidden")) as writer, \ @@ -250,6 +159,17 @@ class CommandDescriptionsTests(unittest.TestCase): self.assertEqual(before, {p: p.read_bytes() for p in before}) for lang in languages.split(","): self.assertIn(f"{lang}: nothing to translate", output.getvalue()) + self.assertEqual(original_bytes, {path: path.read_bytes() for path in shipped}) + + def test_workflow_default_catalogs_rerun_without_provider_or_writes(self): + self.assert_completed_catalogs_are_stable() + + def test_new_english_feature_preserves_curated_values_and_reruns_without_writes(self): + self.assert_completed_catalogs_are_stable(future_messages={ + "title": "Future feature {count}", + "items": ["First item", {"label": "Second item"}], + "enabled": False, "count": 2, "empty": [], + }) def test_actual_consumer_lookup_null_fallback_and_explicit_english(self): node = require_node() diff --git a/.github/workflows/test-i18n.yml b/.github/workflows/test-i18n.yml index 3a2fc5f0..2b3e4685 100644 --- a/.github/workflows/test-i18n.yml +++ b/.github/workflows/test-i18n.yml @@ -1,4 +1,4 @@ -name: Offline i18n tests +name: Offline script regressions on: pull_request: @@ -42,6 +42,8 @@ permissions: contents: read jobs: + # Preserve this check ID for existing branch protection rules. This suite + # includes menu, download and scheduler regressions as well as i18n tests. offline-i18n: runs-on: ubuntu-latest timeout-minutes: 5 @@ -61,7 +63,7 @@ jobs: test -x /usr/bin/jq - name: Install OCI wording test dependency run: python3 -m pip install 'PyYAML>=6,<7' - - name: Run complete offline script test suite + - name: Run offline i18n and script regressions env: PYTHONDONTWRITEBYTECODE: '1' run: python3 -m unittest discover -s .github/scripts/tests -v diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aab13a74..5e76a028 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -818,7 +818,7 @@ Note that the dev server has no Flask backend on the same host by default: the a ### Testing -#### Offline i18n script tests +#### Offline script regressions (including i18n) From the repository root, run the complete script suite: @@ -836,6 +836,14 @@ It does not launch React or require `npm install`, `pip install`, a Proxmox host API keys, or a translation provider. Generator tests use isolated temporary catalogs and fake/forbidden providers; repository catalogs are not regenerated. +The command-description recovery regression first completes disposable copies +of the shipped catalogs with the real generator and a deterministic offline +provider, then requires a second run to make zero provider calls and zero +writes. It checks that all existing nonempty translations and the frozen +command-description arrays survive unchanged. A synthetic EN-only feature tests +new nested strings, arrays, placeholders and non-text values. New message keys +do not need to be added to a test-specific translation seed list. + Scheduler fixtures discover every shipped `lang/*.json` cache and run the actual `translate` lookup at both empty-job consumers for PBS and local archive backends. They accept English fallback for missing/empty entries; complete translations are @@ -859,6 +867,11 @@ than silently skipping the consumer assertions. `.github/workflows/test-i18n.yml provisions Python and Node and runs this same command on relevant pull requests and main/develop pushes (or manually). It is separate from the translation publication workflow and has read-only repository permissions. +Its Actions name is **Offline script regressions** because it also covers menus, +downloads, scheduler behavior and other scripts. The `offline-i18n` check ID is +retained for compatibility with existing branch protection rules; a failure of +that check is not necessarily a translation failure. Read its failing test name +and traceback before changing catalogs or translation-provider settings. #### Qualified offline `tests/` CI lanes From b035373efb1fcb6f6f2f2a8aee8c8b66e7b645dd Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:00:33 +0200 Subject: [PATCH 05/14] Complete translations for all supported languages --- lang/de.json | 3 ++- lang/es.json | 3 ++- lang/fr.json | 3 ++- lang/it.json | 3 ++- lang/pt.json | 3 ++- lang/sk.json | 3 ++- lang/sv.json | 3 ++- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/lang/de.json b/lang/de.json index 7a074cc3..dddbee8f 100644 --- a/lang/de.json +++ b/lang/de.json @@ -8064,5 +8064,6 @@ "⚠ Disk data will NOT be erased.": "⚠ Festplattendaten werden NICHT gelöscht.", "⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Die Festplatte wird ausgehängt und aus /etc/fstab entfernt.", "⚠ The /etc/fstab entry will be removed.": "⚠ Der /etc/fstab-Eintrag wird entfernt.", - "⚠ The disk will be unmounted.": "⚠ Die Festplatte wird ausgehängt." + "⚠ The disk will be unmounted.": "⚠ Die Festplatte wird ausgehängt.", + "Unsupported architecture for Figurine:": "Nicht unterstützte Architektur für Figurine:" } diff --git a/lang/es.json b/lang/es.json index 153a0133..18e2b56c 100644 --- a/lang/es.json +++ b/lang/es.json @@ -8036,5 +8036,6 @@ "⚠ Disk data will NOT be erased.": "⚠ Los datos del disco NO se borrarán.", "⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ El disco se desmontará y se eliminará de /etc/fstab.", "⚠ The /etc/fstab entry will be removed.": "⚠ Se eliminará la entrada /etc/fstab.", - "⚠ The disk will be unmounted.": "⚠ El disco se desmontará." + "⚠ The disk will be unmounted.": "⚠ El disco se desmontará.", + "Unsupported architecture for Figurine:": "Arquitectura no compatible con Figurine:" } diff --git a/lang/fr.json b/lang/fr.json index 0147e0ab..615dcae3 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -8063,5 +8063,6 @@ "⚠ Disk data will NOT be erased.": "⚠ Les données du disque ne seront PAS effacées.", "⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Le disque sera démonté et supprimé de /etc/fstab.", "⚠ The /etc/fstab entry will be removed.": "⚠ L'entrée /etc/fstab sera supprimée.", - "⚠ The disk will be unmounted.": "⚠ Le disque sera démonté." + "⚠ The disk will be unmounted.": "⚠ Le disque sera démonté.", + "Unsupported architecture for Figurine:": "Architecture non prise en charge pour Figurine :" } diff --git a/lang/it.json b/lang/it.json index 7f80fc8e..2a3a4237 100644 --- a/lang/it.json +++ b/lang/it.json @@ -8064,5 +8064,6 @@ "⚠ Disk data will NOT be erased.": "⚠ I dati del disco NON verranno cancellati.", "⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Il disco verrà smontato e rimosso da /etc/fstab.", "⚠ The /etc/fstab entry will be removed.": "⚠ La voce /etc/fstab verrà rimossa.", - "⚠ The disk will be unmounted.": "⚠ Il disco verrà smontato." + "⚠ The disk will be unmounted.": "⚠ Il disco verrà smontato.", + "Unsupported architecture for Figurine:": "Architettura non supportata da Figurine:" } diff --git a/lang/pt.json b/lang/pt.json index f066ced5..55ba5600 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -8064,5 +8064,6 @@ "⚠ Disk data will NOT be erased.": "⚠ Os dados do disco NÃO serão apagados.", "⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ O disco será desmontado e removido de /etc/fstab.", "⚠ The /etc/fstab entry will be removed.": "⚠ A entrada /etc/fstab será removida.", - "⚠ The disk will be unmounted.": "⚠ O disco será desmontado." + "⚠ The disk will be unmounted.": "⚠ O disco será desmontado.", + "Unsupported architecture for Figurine:": "Arquitetura não suportada pelo Figurine:" } diff --git a/lang/sk.json b/lang/sk.json index 0bfa4645..63e05cbd 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -8065,5 +8065,6 @@ "⚠ Disk data will NOT be erased.": "⚠ Dáta na disku sa NEVYMAŽÚ.", "⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disk sa odpojí a odstráni z /etc/fstab.", "⚠ The /etc/fstab entry will be removed.": "⚠ Záznam v /etc/fstab bude odstránený.", - "⚠ The disk will be unmounted.": "⚠ Disk bude odpojený." + "⚠ The disk will be unmounted.": "⚠ Disk bude odpojený.", + "Unsupported architecture for Figurine:": "Nepodporovaná architektúra pre Figurine:" } diff --git a/lang/sv.json b/lang/sv.json index 197444db..6bf573ca 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -8064,5 +8064,6 @@ "⚠ Disk data will NOT be erased.": "⚠ Diskdata kommer INTE att raderas.", "⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disken kommer att avmonteras och tas bort från /etc/fstab.", "⚠ The /etc/fstab entry will be removed.": "⚠ /etc/fstab-posten kommer att tas bort.", - "⚠ The disk will be unmounted.": "⚠ Disken kommer att avmonteras." + "⚠ The disk will be unmounted.": "⚠ Disken kommer att avmonteras.", + "Unsupported architecture for Figurine:": "Arkitekturen stöds inte av Figurine:" } From 8772b286da35c175f0602620f60ac04c8d4f7b1c Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:00:38 +0200 Subject: [PATCH 06/14] Complete translations for all supported languages --- AppImage/messages/de/common.json | 8 +++++++- AppImage/messages/es/common.json | 8 +++++++- AppImage/messages/fr/common.json | 8 +++++++- AppImage/messages/it/common.json | 8 +++++++- AppImage/messages/pt/common.json | 8 +++++++- AppImage/messages/sk/common.json | 8 +++++++- AppImage/messages/sv/common.json | 8 +++++++- 7 files changed, 49 insertions(+), 7 deletions(-) diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 4093529a..b726a4bc 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -2108,7 +2108,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", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 166bf6a5..9e939506 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -2108,7 +2108,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", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 80582c89..80c11bdc 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -2108,7 +2108,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", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 27a5a76b..1b486c31 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -2108,7 +2108,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", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index 2c688625..44e2aafd 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -2108,7 +2108,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", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index dc5b7b80..fe0316ee 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -2107,7 +2107,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", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index a40378c9..83be229d 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -2108,7 +2108,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", From 766cfa0779d4811ce6dcbed17a8a46256707648d Mon Sep 17 00:00:00 2001 From: Jonatan <190036+jcastro@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:00:43 +0200 Subject: [PATCH 07/14] Complete translations for all supported languages --- AppImage/messages/de/common.json | 17 +++++++++++++++++ AppImage/messages/es/common.json | 17 +++++++++++++++++ AppImage/messages/fr/common.json | 17 +++++++++++++++++ AppImage/messages/it/common.json | 17 +++++++++++++++++ AppImage/messages/pt/common.json | 17 +++++++++++++++++ AppImage/messages/sk/common.json | 17 +++++++++++++++++ AppImage/messages/sv/common.json | 17 +++++++++++++++++ 7 files changed, 119 insertions(+) diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 4093529a..a61edcad 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -4753,6 +4753,23 @@ "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" } }, "healthStatus": { diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 166bf6a5..0fdcc14e 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -4753,6 +4753,23 @@ "thursday": "Jueves", "tuesday": "Martes", "wednesday": "Miércoles" + }, + "storageRetention": { + "title": "Retención del almacenamiento", + "keepAll": "Conservar todas las copias de seguridad", + "description": "Retención de copias de seguridad predeterminada guardada en Proxmox. Las tareas de copia con una política explícita tienen prioridad sobre este valor.", + "refresh": "Actualizar", + "saved": "Retención del almacenamiento guardada. No se ha ejecutado ninguna purga.", + "choosePolicy": "Elige Conservar todas 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 se han encontrado almacenamientos de copias de seguridad.", + "default": "Valor predeterminado de Proxmox", + "edit": "Editar", + "editTitle": "Retención de {storage}", + "effect": "Se aplica a las futuras copias de seguridad que hereden esta política de almacenamiento. Guardar no elimina las copias existentes. Las copias protegidas mantienen su protección.", + "cancel": "Cancelar", + "save": "Guardar retención" } }, "healthStatus": { diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 80582c89..4526b086 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -4753,6 +4753,23 @@ "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" } }, "healthStatus": { diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 27a5a76b..63119e33 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -4753,6 +4753,23 @@ "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" } }, "healthStatus": { diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index 2c688625..7b1a1252 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -4753,6 +4753,23 @@ "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" } }, "healthStatus": { diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index dc5b7b80..85f80571 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -4752,6 +4752,23 @@ "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" } }, "healthStatus": { diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index a40378c9..ff79b6d0 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -4753,6 +4753,23 @@ "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" } }, "healthStatus": { From 9558bd3609966826ae3d1d1bee114a6687a42cbc Mon Sep 17 00:00:00 2001 From: MacRimi Date: Fri, 16 Oct 2026 23:04:02 +0200 Subject: [PATCH 08/14] mend the translations of the stopped scheduled update messages --- AppImage/messages/de/common.json | 2 +- AppImage/messages/es/common.json | 4 ++-- AppImage/messages/fr/common.json | 4 ++-- AppImage/messages/it/common.json | 6 +++--- AppImage/messages/pt/common.json | 4 ++-- AppImage/messages/sv/common.json | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 647d973f..3be22f46 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -1304,7 +1304,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": { diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index dc33bf2b..eb237425 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -1304,8 +1304,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", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index f5fec259..948e2b71 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -1304,8 +1304,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é", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 209ef136..1ac4f370 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -1303,9 +1303,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", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index f29d5ab5..eee97846 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -1304,8 +1304,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", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index 2ace06ed..3214fe6f 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -1303,8 +1303,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": { From 5661891d839d2e2eb2171c32ddec87548acc757d Mon Sep 17 00:00:00 2001 From: ProxMenuxBot Date: Fri, 16 Oct 2026 21:18:59 +0000 Subject: [PATCH 09/14] chore(lang): auto-rebuild translation cache Source: c9caea0 Triggered by: push --- lang/de.json | 4 ++-- lang/es.json | 4 ++-- lang/fr.json | 4 ++-- lang/it.json | 4 ++-- lang/pt.json | 4 ++-- lang/sk.json | 4 ++-- lang/sv.json | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lang/de.json b/lang/de.json index 4ad4dbe1..1de7d160 100644 --- a/lang/de.json +++ b/lang/de.json @@ -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:", @@ -8154,6 +8155,5 @@ "⚠ Disk data will NOT be erased.": "⚠ Festplattendaten werden NICHT gelöscht.", "⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Die Festplatte wird ausgehängt und aus /etc/fstab entfernt.", "⚠ The /etc/fstab entry will be removed.": "⚠ Der /etc/fstab-Eintrag wird entfernt.", - "⚠ The disk will be unmounted.": "⚠ Die Festplatte wird ausgehängt.", - "Unsupported architecture for Figurine:": "Nicht unterstützte Architektur für Figurine:" + "⚠ The disk will be unmounted.": "⚠ Die Festplatte wird ausgehängt." } diff --git a/lang/es.json b/lang/es.json index 3afca031..3b6b5468 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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:", @@ -8126,6 +8127,5 @@ "⚠ Disk data will NOT be erased.": "⚠ Los datos del disco NO se borrarán.", "⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ El disco se desmontará y se eliminará de /etc/fstab.", "⚠ The /etc/fstab entry will be removed.": "⚠ Se eliminará la entrada /etc/fstab.", - "⚠ The disk will be unmounted.": "⚠ El disco se desmontará.", - "Unsupported architecture for Figurine:": "Arquitectura no compatible con Figurine:" + "⚠ The disk will be unmounted.": "⚠ El disco se desmontará." } diff --git a/lang/fr.json b/lang/fr.json index d187b56f..ed455b6f 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -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:", @@ -8153,6 +8154,5 @@ "⚠ Disk data will NOT be erased.": "⚠ Les données du disque ne seront PAS effacées.", "⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Le disque sera démonté et supprimé de /etc/fstab.", "⚠ The /etc/fstab entry will be removed.": "⚠ L'entrée /etc/fstab sera supprimée.", - "⚠ The disk will be unmounted.": "⚠ Le disque sera démonté.", - "Unsupported architecture for Figurine:": "Architecture non prise en charge pour Figurine :" + "⚠ The disk will be unmounted.": "⚠ Le disque sera démonté." } diff --git a/lang/it.json b/lang/it.json index 001b0f4e..039251b0 100644 --- a/lang/it.json +++ b/lang/it.json @@ -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:", @@ -8154,6 +8155,5 @@ "⚠ Disk data will NOT be erased.": "⚠ I dati del disco NON verranno cancellati.", "⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Il disco verrà smontato e rimosso da /etc/fstab.", "⚠ The /etc/fstab entry will be removed.": "⚠ La voce /etc/fstab verrà rimossa.", - "⚠ The disk will be unmounted.": "⚠ Il disco verrà smontato.", - "Unsupported architecture for Figurine:": "Architettura non supportata da Figurine:" + "⚠ The disk will be unmounted.": "⚠ Il disco verrà smontato." } diff --git a/lang/pt.json b/lang/pt.json index bb6befac..cc9bb7c9 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -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:", @@ -8154,6 +8155,5 @@ "⚠ Disk data will NOT be erased.": "⚠ Os dados do disco NÃO serão apagados.", "⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ O disco será desmontado e removido de /etc/fstab.", "⚠ The /etc/fstab entry will be removed.": "⚠ A entrada /etc/fstab será removida.", - "⚠ The disk will be unmounted.": "⚠ O disco será desmontado.", - "Unsupported architecture for Figurine:": "Arquitetura não suportada pelo Figurine:" + "⚠ The disk will be unmounted.": "⚠ O disco será desmontado." } diff --git a/lang/sk.json b/lang/sk.json index deda17f5..6c81d06a 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -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:", @@ -8155,6 +8156,5 @@ "⚠ Disk data will NOT be erased.": "⚠ Dáta na disku sa NEVYMAŽÚ.", "⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disk sa odpojí a odstráni z /etc/fstab.", "⚠ The /etc/fstab entry will be removed.": "⚠ Záznam v /etc/fstab bude odstránený.", - "⚠ The disk will be unmounted.": "⚠ Disk bude odpojený.", - "Unsupported architecture for Figurine:": "Nepodporovaná architektúra pre Figurine:" + "⚠ The disk will be unmounted.": "⚠ Disk bude odpojený." } diff --git a/lang/sv.json b/lang/sv.json index 2c79f92f..4c16fdf2 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -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:", @@ -8154,6 +8155,5 @@ "⚠ Disk data will NOT be erased.": "⚠ Diskdata kommer INTE att raderas.", "⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disken kommer att avmonteras och tas bort från /etc/fstab.", "⚠ The /etc/fstab entry will be removed.": "⚠ /etc/fstab-posten kommer att tas bort.", - "⚠ The disk will be unmounted.": "⚠ Disken kommer att avmonteras.", - "Unsupported architecture for Figurine:": "Arkitekturen stöds inte av Figurine:" + "⚠ The disk will be unmounted.": "⚠ Disken kommer att avmonteras." } From b75d373f31c3e7b315fba44b485c0ade21770378 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Fri, 16 Oct 2026 23:22:50 +0200 Subject: [PATCH 10/14] remove invisible characters from translations --- AppImage/messages/es/common.json | 6 +++--- AppImage/messages/pt/common.json | 24 ++++++++++++------------ lang/de.json | 4 ++-- lang/es.json | 4 ++-- lang/fr.json | 6 +++--- lang/pt.json | 4 ++-- lang/sk.json | 2 +- lang/sv.json | 6 +++--- 8 files changed, 28 insertions(+), 28 deletions(-) diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index eb237425..1d4f4b7f 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -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.", @@ -3566,8 +3566,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": { diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index eee97846..af1683b0 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -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", @@ -1771,7 +1771,7 @@ }, "healthMonitor": { "title": "Monitor de Saúde", - "description": "Configure por quanto tempo os alertas dispensados ​​permanecem ocultos para cada categoria. As alterações se aplicam a alertas ignorados existentes e futuros.", + "description": "Configure por quanto tempo os alertas dispensados permanecem ocultos para cada categoria. As alterações se aplicam a alertas ignorados existentes e futuros.", "category": "Categoria", "suppressionDuration": "Duração da Supressão", "hours": "Horas", @@ -1781,7 +1781,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", @@ -3567,7 +3567,7 @@ "hour": "Passada 1h", "day": "Passadas 24 horas", "week": "Passado 7 dias", - "month": "Passados ​​30 dias", + "month": "Passados 30 dias", "year": "Último 1 ano" }, "last": { @@ -4019,7 +4019,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.", @@ -4547,7 +4547,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", @@ -4912,7 +4912,7 @@ }, "noIssues": "Nenhum problema detectado", "updateNow": "Atualizar agora", - "dismissedItems": "Itens dispensados ​​({count})", + "dismissedItems": "Itens dispensados ({count})", "permanent": "Permanente", "dismissed": "Dispensado", "wasStatus": "era {status}", diff --git a/lang/de.json b/lang/de.json index 1de7d160..656785b5 100644 --- a/lang/de.json +++ b/lang/de.json @@ -7969,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", diff --git a/lang/es.json b/lang/es.json index 3b6b5468..cbed3c8f 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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:", diff --git a/lang/fr.json b/lang/fr.json index ed455b6f..d3d78253 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -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.", diff --git a/lang/pt.json b/lang/pt.json index cc9bb7c9..2b583f7f 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -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", diff --git a/lang/sk.json b/lang/sk.json index 6c81d06a..249bfb53 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -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.", diff --git a/lang/sv.json b/lang/sv.json index 4c16fdf2..5bdb4f9b 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -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.", From 9a94045c33d262bd2f645014596dada6dfab8373 Mon Sep 17 00:00:00 2001 From: MacRimi <123239993+MacRimi@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:25:01 +0200 Subject: [PATCH 11/14] Show the external link icon on the Apprise service help links --- AppImage/components/apprise-service-help.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/AppImage/components/apprise-service-help.tsx b/AppImage/components/apprise-service-help.tsx index db6eae71..de9d132b 100644 --- a/AppImage/components/apprise-service-help.tsx +++ b/AppImage/components/apprise-service-help.tsx @@ -1,5 +1,6 @@ "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 @@ -13,16 +14,16 @@ export function AppriseServiceHelp() {

Slack

{t("settings.notifications.ui.appriseSlackSteps")}

https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C - - {t("settings.notifications.ui.appriseSlackDocs")} + + {t("settings.notifications.ui.appriseSlackDocs")}

Matrix

{t("settings.notifications.ui.appriseMatrixSteps")}

matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com - - {t("settings.notifications.ui.appriseMatrixDocs")} + + {t("settings.notifications.ui.appriseMatrixDocs")}
From f336eb65f06e49369eb0f46416d3692f25abc064 Mon Sep 17 00:00:00 2001 From: MacRimi <123239993+MacRimi@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:34:21 +0200 Subject: [PATCH 12/14] Let a storage return to the Proxmox default retention and match the job editor labels - An empty policy removes the storage setting with --delete prune-backups, and a storage without a policy opens with that choice - Retention fields use the Keep last, Keep daily... labels of the backup job editor - A retention count of zero is rejected before calling Proxmox - Spanish uses backups for the retention messages --- .../components/backup-storage-retention.tsx | 18 ++++++++++++------ AppImage/messages/en/common.json | 1 + AppImage/messages/es/common.json | 15 ++++++++------- AppImage/scripts/flask_vm_backup_routes.py | 10 ++++++---- tests/test_vm_backup_jobs_api.py | 6 +++++- 5 files changed, 32 insertions(+), 18 deletions(-) diff --git a/AppImage/components/backup-storage-retention.tsx b/AppImage/components/backup-storage-retention.tsx index f27ae046..4d15c6a5 100644 --- a/AppImage/components/backup-storage-retention.tsx +++ b/AppImage/components/backup-storage-retention.tsx @@ -23,6 +23,7 @@ export function BackupStorageRetention() { const [editing, setEditing] = useState(null) const [fields, setFields] = useState>({}) const [keepAll, setKeepAll] = useState(false) + const [useDefault, setUseDefault] = useState(false) const [busy, setBusy] = useState(false) const [message, setMessage] = useState("") const [saveError, setSaveError] = useState("") @@ -30,7 +31,8 @@ export function BackupStorageRetention() { function edit(storage: StorageRetention) { const policy = parseRetention(storage.prune_backups) setFields(policy.fields) - setKeepAll(policy.keepAll || !storage.prune_backups) + setKeepAll(policy.keepAll) + setUseDefault(!storage.prune_backups) setSaveError("") setMessage("") setEditing(storage) @@ -38,8 +40,8 @@ export function BackupStorageRetention() { async function save() { if (!editing || busy) return - const prune = buildRetention(fields, keepAll) - if (!prune) return setSaveError(t("backup.storageRetention.choosePolicy")) + const prune = useDefault ? "" : buildRetention(fields, keepAll) + if (!useDefault && !prune) return setSaveError(t("backup.storageRetention.choosePolicy")) setBusy(true) setSaveError("") try { @@ -79,14 +81,18 @@ export function BackupStorageRetention() { {t("backup.storageRetention.editTitle", { storage: editing?.id ?? "" })} {t("backup.storageRetention.effect")} -