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/scripts/tests/test_oci_wording.py b/.github/scripts/tests/test_oci_wording.py index bae032fa..c38e3671 100644 --- a/.github/scripts/tests/test_oci_wording.py +++ b/.github/scripts/tests/test_oci_wording.py @@ -17,13 +17,15 @@ MENU = ROOT / 'oci/src/proxmenux_oci/management.py' CHOICE = 'Recover the previous installation' BEFORE = 'The operation stopped halfway. Choose "{choice}" for this container in the OCI management menu.' AFTER = 'The recovery did not complete. Review the log and choose "{choice}" again for this container in the OCI management menu.' +REGISTRY_ADVICE = ('Check the image reference and registry access. If the registry is unavailable, ' + 'try again later. For a private image, sign in from Registry accounts in the OCI menu.') NEW_KEYS = ( 'The Compose file describes several services:', 'Only one service at a time can be installed this way.', 'No docker run command was given', 'Image selection was cancelled', 'Could not inspect the image in its registry:', - 'Check the image reference and registry access. If the registry is unavailable, try again later. A private registry needs credentials, which are not supported yet.', + REGISTRY_ADVICE, BEFORE, AFTER, ) @@ -117,16 +119,16 @@ class OciWordingTests(TestCase): self.assertEqual(report('example:latest'), (True, 'The image is in its registry: amd64')) self.assertEqual(run.call_count, 1) - def test_failed_registry_inspection_has_neutral_advice(self): + def test_failed_registry_inspection_points_to_registry_accounts(self): ui = SimpleNamespace(message=Mock()) fn = function(CUSTOM, 'explore', read_definition=lambda _: ('definition', 'source'), ignored_settings=lambda _: [], template_from_compose=lambda _: { 'container_contract': {'image': {'reference': 'example:latest'}}}, - describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED')) + describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'), + _registry_refused=lambda _: False) fn(ui) - self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n' - 'Check the image reference and registry access. If the registry is unavailable, ' - 'try again later. A private registry needs credentials, which are not supported yet.') + self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n' + REGISTRY_ADVICE) + self.assertNotIn('not supported yet', ui.message.call_args.args[0]) def test_recovery_hint_quotes_translated_choice_in_all_locales(self): # Pin the real translated management choice, not an English AST constant alone. 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-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/.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/.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/.github/workflows/test-guest-backups.yml b/.github/workflows/test-guest-backups.yml new file mode 100644 index 00000000..60da0353 --- /dev/null +++ b/.github/workflows/test-guest-backups.yml @@ -0,0 +1,54 @@ +name: Guest backup archives + +on: + pull_request: + paths: + - '.github/workflows/test-guest-backups.yml' + - 'AppImage/scripts/guest_backup_archives.py' + - 'AppImage/scripts/flask_server.py' + - 'AppImage/scripts/jwt_middleware.py' + - 'AppImage/scripts/build_appimage.sh' + - 'AppImage/components/guest-backup-list.tsx' + - 'AppImage/components/virtual-machines.tsx' + - 'AppImage/lib/guest-backup-archives.ts' + - 'AppImage/messages/**' + - 'tests/test_guest_backup_archives.*' + push: + branches: [main, develop] + paths: + - 'AppImage/scripts/guest_backup_archives.py' + - 'AppImage/scripts/flask_server.py' + - 'AppImage/scripts/jwt_middleware.py' + - 'AppImage/components/guest-backup-list.tsx' + - 'AppImage/components/virtual-machines.tsx' + - 'AppImage/lib/guest-backup-archives.ts' + - 'tests/test_guest_backup_archives.*' + workflow_dispatch: + +permissions: + contents: read + +jobs: + guest-backups: + 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 deletion, protection, authorization and cache invalidation + run: python -B -m unittest discover -s tests -p test_guest_backup_archives.py -v + - name: Test frontend deletion and completion handling + env: + NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules + run: node tests/test_guest_backup_archives.cjs 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/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml new file mode 100644 index 00000000..9612c0d3 --- /dev/null +++ b/.github/workflows/test-oci-registries.yml @@ -0,0 +1,34 @@ +name: Offline OCI registry tests + +on: + pull_request: + paths: + - 'oci/**' + - '.github/workflows/test-oci-registries.yml' + push: + branches: [main, develop] + paths: + - 'oci/**' + - '.github/workflows/test-oci-registries.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + registry-accounts: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Install OCI test dependencies + run: python3 -m pip install -r oci/requirements.txt + - name: Run offline registry account tests + env: + PYTHONDONTWRITEBYTECODE: '1' + run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v diff --git a/AppImage/components/apprise-service-help.tsx b/AppImage/components/apprise-service-help.tsx new file mode 100644 index 00000000..de9d132b --- /dev/null +++ b/AppImage/components/apprise-service-help.tsx @@ -0,0 +1,32 @@ +"use client" + +import { ExternalLink } from "lucide-react" +import { useT } from "../lib/i18n/provider" + +// These are examples, never defaults: opening help must not replace a saved +// destination or put placeholder credentials into the notification settings. +export function AppriseServiceHelp() { + const t = useT() + return
+ {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/backup-storage-retention.tsx b/AppImage/components/backup-storage-retention.tsx new file mode 100644 index 00000000..4d15c6a5 --- /dev/null +++ b/AppImage/components/backup-storage-retention.tsx @@ -0,0 +1,105 @@ +"use client" + +import { useState } from "react" +import useSWR from "swr" +import { HardDrive, Loader2, Pencil, RefreshCw } from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" +import { Badge } from "./ui/badge" +import { Button } from "./ui/button" +import { Checkbox } from "./ui/checkbox" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog" +import { Input } from "./ui/input" +import { Label } from "./ui/label" +import { fetchApi } from "../lib/api-config" +import { buildRetention, parseRetention, RETENTION } from "../lib/backup-retention" +import { useT } from "../lib/i18n/provider" + +interface StorageRetention { id: string; type: string; prune_backups: string; digest: string } +const fetcher = (url: string) => fetchApi<{ storages: StorageRetention[] }>(url) + +export function BackupStorageRetention() { + const t = useT() + const { data, error, mutate, isValidating } = useSWR("/api/vm-backup-storages", fetcher) + const [editing, setEditing] = useState(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("") + + function edit(storage: StorageRetention) { + const policy = parseRetention(storage.prune_backups) + setFields(policy.fields) + setKeepAll(policy.keepAll) + setUseDefault(!storage.prune_backups) + setSaveError("") + setMessage("") + setEditing(storage) + } + + async function save() { + if (!editing || busy) return + const prune = useDefault ? "" : buildRetention(fields, keepAll) + if (!useDefault && !prune) return setSaveError(t("backup.storageRetention.choosePolicy")) + setBusy(true) + setSaveError("") + try { + await fetchApi(`/api/vm-backup-storages/${encodeURIComponent(editing.id)}`, { + method: "PUT", body: JSON.stringify({ digest: editing.digest, prune_backups: prune }), + }) + setMessage(t("backup.storageRetention.saved")) + setEditing(null) + await mutate() + } catch (error) { + setSaveError(`${t("backup.storageRetention.saveFailed")} ${error instanceof Error ? error.message : ""}`) + } finally { setBusy(false) } + } + + return + + {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/guest-backup-list.tsx b/AppImage/components/guest-backup-list.tsx new file mode 100644 index 00000000..3f6878f7 --- /dev/null +++ b/AppImage/components/guest-backup-list.tsx @@ -0,0 +1,135 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { Archive, Clock, Loader2, LockKeyhole, RefreshCw, Trash2 } from "lucide-react" +import { Badge } from "./ui/badge" +import { Button } from "./ui/button" +import { Checkbox } from "./ui/checkbox" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog" +import { useT } from "@/lib/i18n/provider" +import { deleteGuestBackups, type GuestBackup } from "../lib/guest-backup-archives" + +export function GuestBackupList({ vmid, backups, loading, onRefresh, storageColor }: { + vmid: number + backups: GuestBackup[] + loading: boolean + onRefresh: () => Promise + storageColor?: (storage: string) => { bg: string; text: string; border: string } +}) { + const t = useT() + const [selected, setSelected] = useState([]) + const [confirm, setConfirm] = useState(false) + const [busy, setBusy] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const [message, setMessage] = useState("") + const [error, setError] = useState("") + const mounted = useRef(true) + useEffect(() => { + mounted.current = true + return () => { mounted.current = false } + }, []) + // A fresh list can remove a selection or reveal a newly protected archive. + useEffect(() => { + setSelected(previous => previous.filter(volid => backups.some(backup => backup.volid === volid && !backup.protected))) + }, [backups]) + + const chosen = backups.filter(backup => selected.includes(backup.volid) && !backup.protected) + const selectable = backups.filter(backup => !backup.protected) + const disabled = loading || busy || refreshing + const storageClass = (storage: string) => { + const color = storageColor?.(storage) + return color ? `${color.bg} ${color.text} ${color.border}` : "" + } + + async function refresh() { + setRefreshing(true) + setError("") + try { await onRefresh() } catch { if (mounted.current) setError(t("vmLxc.backups.refreshFailed")) } + finally { if (mounted.current) setRefreshing(false) } + } + + async function remove() { + if (!chosen.length || busy) return + setBusy(true) + setError("") + setMessage("") + try { + const results = await deleteGuestBackups(vmid, chosen) + if (!mounted.current) return + const deleted = results.filter(result => result.status === "deleted").length + const unconfirmed = results.length - deleted + setMessage(t("vmLxc.backups.deletedCount", { count: deleted })) + if (unconfirmed) setError(t("vmLxc.backups.unconfirmedCount", { count: unconfirmed })) + setSelected([]) + } catch (error) { + if (mounted.current) setError(`${t("vmLxc.backups.deleteFailed")} ${error instanceof Error ? error.message : ""}`) + } finally { + // A timeout can hide a successful native task. Always bypass both caches. + try { await onRefresh() } catch { if (mounted.current) setError(t("vmLxc.backups.refreshFailed")) } + if (mounted.current) { setBusy(false); setConfirm(false) } + } + } + + return <> +
+
+ {t("vmLxc.backups.available")} + {backups.length} +
+
+ + +
+
+ {message &&

{message}

} + {error &&

{error}

} + {loading ?
+ {t("vmLxc.backups.loading")} +
: backups.length === 0 ?
+ {t("vmLxc.backups.empty")} + {t("vmLxc.backups.emptyHint")} +
: <> + +
+ {backups.map(backup =>