i18n: refine Slovak linked nodes and localize validation errors

This commit is contained in:
VAIO73
2026-10-20 06:46:15 +00:00
parent 65f47ec646
commit 9ffd154a2a
9 changed files with 379 additions and 40 deletions
+13 -9
View File
@@ -14,6 +14,7 @@ import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { fetchApi } from "../lib/api-config"
import { monitorLinkError } from "../lib/monitor-link-errors"
import { useT } from "../lib/i18n/provider"
interface MonitorLink {
@@ -185,8 +186,9 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
await refreshCache("/api/monitor-links")
setOfferOrigin(false)
} catch (err) {
await refreshCache("/api/monitor-links")
setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
setAddError(monitorLinkError(err, t))
try { await refreshCache("/api/monitor-links") }
catch { /* SWR shows the load error; keep the localized mutation error. */ }
} finally { setAdding(false) }
}
const notNow = () => {
@@ -260,7 +262,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
</DropdownMenu>
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
{origin && <Dialog open={offerOrigin} onOpenChange={open => { if (!open) notNow() }}>
<DialogContent className="max-w-md" onClick={event => event.stopPropagation()}>
<DialogContent className="grid-cols-1 max-w-md" onClick={event => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>{t("monitorLinks.knownTitle")}</DialogTitle>
<DialogDescription>{t("monitorLinks.knownBody", { name: origin.name })}</DialogDescription>
@@ -290,7 +292,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const t = useT()
const { data, mutate } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
const { data, mutate, error: loadError, isLoading } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
const [form, setForm] = useState({ name: "", url: "", product: "pve" })
const [addresses, setAddresses] = useState<Record<string, string>>({})
const [editing, setEditing] = useState<string | null>(null)
@@ -304,14 +306,14 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
await mutate()
await refreshCache("/api/monitor-links")
} catch (err) {
setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
setError(monitorLinkError(err, t))
} finally { setBusy(false) }
}
const send = (path: string, method: string, body?: unknown) =>
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
return <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
onClick={event => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
@@ -320,7 +322,7 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
<div className="space-y-3 rounded-lg border border-border p-4">
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
<div className="grid gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
<div className="space-y-1.5">
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label>
<Input id="monitor-link-name" value={form.name} disabled={busy} onChange={event => setForm({ ...form, name: event.target.value })} />
@@ -350,6 +352,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
<div className="space-y-3">
{isLoading && <p className="text-sm text-muted-foreground">{t("monitorLinks.checking")}</p>}
{loadError && <p role="alert" className="text-sm text-amber-500">{t("monitorLinks.loadFailed")}</p>}
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
{data?.links.map(link => {
const own = link.manual || link.custom_url
@@ -378,8 +382,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.thisNode")}</span>
</div>
: isEditing
? <div className="flex gap-2">
<Input value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
? <div className="grid grid-cols-2 gap-2 sm:flex">
<Input className="col-span-2 min-w-0 sm:flex-1" value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
<Button size="sm" className="h-10 bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100"
+29
View File
@@ -0,0 +1,29 @@
type Translate = (key: string, params?: Record<string, string | number>) => string
const ERROR_KEYS: Record<string, string> = {
monitor_link_address_required: "addressRequired",
monitor_link_invalid_address: "invalidAddress",
monitor_link_address_credentials: "addressCredentials",
monitor_link_invalid_port: "invalidPort",
monitor_link_invalid_request: "invalidRequest",
monitor_link_duplicate: "duplicate",
monitor_link_not_found: "notFound",
monitor_link_detected_remove: "detectedRemove",
}
// fetchApi preserves the structured response on Error.body. Never expose raw
// server/network messages or treat arbitrary error codes as translation keys.
export function monitorLinkError(error: unknown, t: Translate): string {
const fallback = () => t("monitorLinks.saveFailed")
if (!error || typeof error !== "object" || !("body" in error)) return fallback()
const body = error.body
if (!body || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return fallback()
if (body.code === "monitor_link_limit") {
const params = "params" in body ? body.params : null
const count = params && typeof params === "object" && "count" in params ? params.count : null
return typeof count === "number" && Number.isSafeInteger(count) && count > 0
? t("monitorLinks.errors.limit", { count }) : fallback()
}
return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code)
? t(`monitorLinks.errors.${ERROR_KEYS[body.code]}`) : fallback()
}
+12 -1
View File
@@ -6616,6 +6616,17 @@
"addSelected": "Add selected",
"pbsWebInterface": "PBS web interface",
"changeAddress": "Change address",
"cancel": "Cancel"
"cancel": "Cancel",
"errors": {
"addressRequired": "Enter the address of the Monitor.",
"invalidAddress": "The address must start with http:// or https:// and name a host.",
"addressCredentials": "The address must not contain a user or password.",
"invalidPort": "The port of the address is not valid.",
"invalidRequest": "The request is not valid. Reload the page and try again.",
"limit": "At most {count} nodes can be added.",
"duplicate": "This address is already in the list.",
"notFound": "The node was not found. Reload the list and try again.",
"detectedRemove": "Only nodes added by hand can be removed. Detected nodes can be hidden."
}
}
}
+23 -12
View File
@@ -7372,41 +7372,52 @@
},
"monitorLinks": {
"groups": {
"cluster": "Cluster",
"cluster": "Klaster",
"standalone": "Samostatné uzly",
"pbs": "Proxmox Backup Server"
},
"thisNode": "Tento uzol",
"checking": "Kontrolujú sa uzly…",
"loadFailed": "Nepodarilo sa načítať zoznam uzlov.",
"empty": "Zatiaľ žiadne ďalšie uzly. Pridajte jeden, aby ste odtiaľto otvorili jeho Monitor.",
"empty": "Zatiaľ tu nie sú ďalšie uzly. Pridaj uzol a jeho Monitor otvoríš priamo odtiaľto.",
"noResponse": "Žiadna odpoveď",
"pbsInterface": "rozhranie PBS",
"openNewTab": "Otvoriť na novej karte",
"manage": "Pridať alebo spravovať uzly…",
"dialogTitle": "Prepojené uzly",
"dialogDescription": "Uzly klastra a nakonfigurované Proxmox Backup Server sa zisťujú automaticky. Každý Monitor má vlastné prihlásenie.",
"dialogDescription": "Uzly klastra a nastavené servery Proxmox Backup Server sa zisťujú automaticky. Do Monitora každého uzla sa prihlasuješ samostatne.",
"addTitle": "Pridať uzol",
"name": "Meno",
"address": "Adresa monitora",
"name": "Názov uzla",
"address": "Adresa Monitora",
"type": "Typ",
"add": "Pridať",
"save": "Uložiť",
"remove": "Odstrániť",
"visible": "Viditeľné",
"visible": "Zobraziť",
"autoAddress": "Automaticky",
"detected": "Zistené",
"added": "Pridané",
"detected": "Zistený",
"added": "Pridaný",
"saveFailed": "Nepodarilo sa uložiť zoznam uzlov.",
"backTo": "Späť na {name}",
"cameFromTitle": "Pridať uzol monitora?",
"cameFromBody": "Pridajte {name} do prepojených uzlov tohto monitora.",
"cameFromTitle": "Pridať uzol Monitora?",
"cameFromBody": "Pridaj {name} medzi prepojené uzly tohto Monitora.",
"notNow": "Teraz nie",
"knownTitle": "Pridať prepojené uzly?",
"knownBody": "{name} prepája tieto monitory. Vyberte tie, ktoré chcete pridať do tohto monitora.",
"knownBody": "Uzol {name} má prepojené tieto uzly. Vyber tie, ktoré chceš pridať aj sem.",
"addSelected": "Pridať vybrané",
"pbsWebInterface": "Webové rozhranie PBS",
"changeAddress": "Zmeniť adresu",
"cancel": "Zrušiť"
"cancel": "Zrušiť",
"errors": {
"addressRequired": "Zadaj adresu Monitora.",
"invalidAddress": "Adresa musí začínať na http:// alebo https:// a obsahovať názov hostiteľa alebo IP adresu.",
"addressCredentials": "Adresa nesmie obsahovať používateľské meno ani heslo.",
"invalidPort": "Port v adrese nie je platný. Zadaj číslo od 1 do 65535.",
"invalidRequest": "Požiadavka nie je platná. Obnov stránku a skús to znova.",
"limit": "Môžeš pridať najviac {count} uzlov.",
"duplicate": "Táto adresa už je v zozname.",
"notFound": "Uzol sa nenašiel. Obnov zoznam a skús to znova.",
"detectedRemove": "Odstrániť môžeš iba ručne pridané uzly. Automaticky zistené uzly môžeš skryť."
}
}
}
+13 -6
View File
@@ -7,6 +7,13 @@ from jwt_middleware import require_auth, require_admin_scope
monitor_links_bp = Blueprint("monitor_links", __name__)
def link_error(exc):
payload = {"error": str(exc), "code": exc.code}
if exc.params:
payload["params"] = exc.params
return jsonify(payload), 400
@monitor_links_bp.after_request
def no_cache(response):
response.headers["Cache-Control"] = "no-store"
@@ -27,9 +34,9 @@ def add_link():
monitor_links.refresh()
return jsonify(success=True, key=key)
except monitor_links.LinkError as exc:
return jsonify(error=str(exc)), 400
return link_error(exc)
except OSError:
return jsonify(error="Could not save the node list."), 500
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["PATCH"])
@@ -40,9 +47,9 @@ def update_link(key):
monitor_links.refresh()
return jsonify(success=True)
except monitor_links.LinkError as exc:
return jsonify(error=str(exc)), 400
return link_error(exc)
except OSError:
return jsonify(error="Could not save the node list."), 500
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["DELETE"])
@@ -53,6 +60,6 @@ def delete_link(key):
monitor_links.refresh()
return jsonify(success=True)
except monitor_links.LinkError as exc:
return jsonify(error=str(exc)), 400
return link_error(exc)
except OSError:
return jsonify(error="Could not save the node list."), 500
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
+15 -12
View File
@@ -39,7 +39,10 @@ _refresh_lock = threading.Lock()
class LinkError(ValueError):
pass
def __init__(self, message, *, code="monitor_links_save_failed", params=None):
super().__init__(message)
self.code = code
self.params = params
def _load():
@@ -63,19 +66,19 @@ def _save(data):
def normalize_url(value):
"""Return the origin of an http(s) address, or raise LinkError."""
if not isinstance(value, str) or not value.strip():
raise LinkError("Enter the address of the Monitor.")
raise LinkError("Enter the address of the Monitor.", code="monitor_link_address_required")
text = value.strip()
if "://" not in text:
text = "https://" + text
parsed = urllib.parse.urlsplit(text)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise LinkError("The address must start with http:// or https:// and name a host.")
raise LinkError("The address must start with http:// or https:// and name a host.", code="monitor_link_invalid_address")
if parsed.username or parsed.password:
raise LinkError("The address must not contain a user or password.")
raise LinkError("The address must not contain a user or password.", code="monitor_link_address_credentials")
try:
port = parsed.port
except ValueError as exc:
raise LinkError("The port of the address is not valid.") from exc
raise LinkError("The port of the address is not valid.", code="monitor_link_invalid_port") from exc
host = parsed.hostname
if ":" in host:
host = f"[{host}]"
@@ -301,16 +304,16 @@ def start_prewarmer():
def add_link(payload):
if not isinstance(payload, dict):
raise LinkError("Invalid request.")
raise LinkError("Invalid request.", code="monitor_link_invalid_request")
name = str(payload.get("name") or "").strip()[:64]
url = normalize_url(payload.get("url"))
product = payload.get("product") if payload.get("product") in PRODUCTS else "pve"
with _lock:
data = _load()
if len(data["manual"]) >= MAX_MANUAL:
raise LinkError(f"At most {MAX_MANUAL} nodes can be added.")
raise LinkError(f"At most {MAX_MANUAL} nodes can be added.", code="monitor_link_limit", params={"count": MAX_MANUAL})
if any(row.get("url") == url for row in data["manual"]):
raise LinkError("This address is already in the list.")
raise LinkError("This address is already in the list.", code="monitor_link_duplicate")
identifier = secrets.token_hex(6)
data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname,
"url": url, "product": product})
@@ -320,13 +323,13 @@ def add_link(payload):
def update_link(key, payload):
if not isinstance(payload, dict):
raise LinkError("Invalid request.")
raise LinkError("Invalid request.", code="monitor_link_invalid_request")
with _lock:
data = _load()
if key.startswith("manual:"):
row = next((r for r in data["manual"] if f"manual:{r.get('id')}" == key), None)
if row is None:
raise LinkError("Node not found.")
raise LinkError("Node not found.", code="monitor_link_not_found")
if "name" in payload:
row["name"] = str(payload["name"] or "").strip()[:64] or row["name"]
if payload.get("url"):
@@ -345,7 +348,7 @@ def update_link(key, payload):
if not override:
data["overrides"].pop(key, None)
else:
raise LinkError("Node not found.")
raise LinkError("Node not found.", code="monitor_link_not_found")
_save(data)
@@ -354,6 +357,6 @@ def delete_link(key):
data = _load()
remaining = [r for r in data["manual"] if f"manual:{r.get('id')}" != key]
if len(remaining) == len(data["manual"]):
raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.")
raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.", code="monitor_link_detected_remove")
data["manual"] = remaining
_save(data)
@@ -0,0 +1,138 @@
"""Exercise the shipped routes and persistence; localization is additive."""
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest import mock
from flask import Flask
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
import monitor_links as links
class MonitorLinksI18nTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.path = Path(temporary.name) / "links.json"
self.previous = {"manual": [{"id": "saved", "name": "Saved", "url": "https://saved.test", "product": "pve"}], "overrides": {}}
self.path.write_text(json.dumps(self.previous))
for patch in [mock.patch.object(links, "LINKS_FILE", self.path),
mock.patch.object(links, "refresh"), mock.patch.object(links, "_probe")]:
patch.start()
self.addCleanup(patch.stop)
auth_provider = types.SimpleNamespace(
load_auth_config=lambda: {"enabled": True},
verify_token=lambda token: "fixture" if token in ("admin", "reader") else None,
verify_token_full=lambda token: ("fixture", "full_admin" if token == "admin" else "read_only")
if token in ("admin", "reader") else (None, None))
middleware_spec = importlib.util.spec_from_file_location("fixture_jwt_middleware", ROOT / "AppImage/scripts/jwt_middleware.py")
middleware = importlib.util.module_from_spec(middleware_spec)
# Execute the shipped decorators; only credential verification is faked.
with mock.patch.dict(sys.modules, {"auth_manager": auth_provider}):
middleware_spec.loader.exec_module(middleware)
spec = importlib.util.spec_from_file_location("fixture_monitor_links_routes", ROOT / "AppImage/scripts/flask_monitor_links_routes.py")
routes = importlib.util.module_from_spec(spec)
# Import isolation keeps the real JWT module and other suites untouched.
with mock.patch.dict(sys.modules, {"jwt_middleware": middleware}):
spec.loader.exec_module(routes)
app = Flask(__name__)
app.register_blueprint(routes.monitor_links_bp)
self.client = app.test_client()
def send(self, method, path="", payload=None, role="admin"):
return self.client.open("/api/monitor-links" + path, method=method,
json=payload, headers={"Authorization": "Bearer " + role} if role else {})
def rejected(self, method, path, payload, message, code, params=None):
before = self.path.read_bytes()
with mock.patch.object(links, "_save") as save:
response = self.send(method, path, payload)
self.assertEqual(response.status_code, 400)
expected = {"error": message, "code": code}
if params:
expected["params"] = params
self.assertEqual(response.json, expected)
self.assertEqual(response.headers["Cache-Control"], "no-store")
save.assert_not_called()
links.refresh.assert_not_called()
links._probe.assert_not_called()
self.assertEqual(self.path.read_bytes(), before)
def test_validation_errors_preserve_english_status_and_have_stable_codes_without_effects(self):
cases = [
("POST", "", None, "Invalid request.", "monitor_link_invalid_request"),
("PATCH", "/manual:saved", [], "Invalid request.", "monitor_link_invalid_request"),
("POST", "", {"url": " "}, "Enter the address of the Monitor.", "monitor_link_address_required"),
("POST", "", {"url": "ftp://example.test"}, "The address must start with http:// or https:// and name a host.", "monitor_link_invalid_address"),
("POST", "", {"url": "https://user:pass@example.test"}, "The address must not contain a user or password.", "monitor_link_address_credentials"),
("PATCH", "/manual:saved", {"name": "Unsaved", "url": "https://example.test:99999"}, "The port of the address is not valid.", "monitor_link_invalid_port"),
("PATCH", "/cluster:node", {"url": "ftp://example.test"}, "The address must start with http:// or https:// and name a host.", "monitor_link_invalid_address"),
("POST", "", {"url": "https://saved.test/path"}, "This address is already in the list.", "monitor_link_duplicate"),
("PATCH", "/manual:absent", {"url": "https://new.test"}, "Node not found.", "monitor_link_not_found"),
("PATCH", "/other:node", {}, "Node not found.", "monitor_link_not_found"),
("DELETE", "/cluster:node", None, "Only nodes added by hand can be removed; detected ones can be hidden.", "monitor_link_detected_remove"),
]
for case in cases:
with self.subTest(case=case):
self.rejected(*case)
def test_limit_uses_actual_count_and_preserves_previous_file(self):
with mock.patch.object(links, "MAX_MANUAL", 1):
self.rejected("POST", "", {"url": "https://new.test"}, "At most 1 nodes can be added.",
"monitor_link_limit", {"count": 1})
def test_write_failure_is_localizable_without_leaking_filesystem_details(self):
for method, path, payload in [("POST", "", {"url": "https://new.test"}),
("PATCH", "/manual:saved", {"name": "Unsaved"}),
("DELETE", "/manual:saved", None)]:
with self.subTest(method=method), mock.patch.object(links.os, "replace", side_effect=OSError("private path details")):
before = self.path.read_bytes()
response = self.send(method, path, payload)
self.assertEqual(response.status_code, 500)
self.assertEqual(response.json, {"error": "Could not save the node list.", "code": "monitor_links_save_failed"})
self.assertEqual(self.path.read_bytes(), before)
links.refresh.assert_not_called()
links._probe.assert_not_called()
def test_successful_crud_and_detected_hide_reset_keep_existing_contract(self):
response = self.send("POST", payload={"name": "New", "url": "new.test:8008/path", "product": "pbs"})
self.assertEqual(response.status_code, 200)
self.assertEqual(set(response.json), {"success", "key"})
key = response.json["key"]
self.assertTrue(key.startswith("manual:"))
row = links._load()["manual"][-1]
self.assertEqual((row["url"], row["product"]), ("https://new.test:8008", "pbs"))
self.assertEqual(self.send("PATCH", "/" + key, {"url": "http://changed.test"}).json, {"success": True})
self.assertEqual(links._load()["manual"][-1]["url"], "http://changed.test")
self.assertEqual(self.send("PATCH", "/cluster:node", {"url": "https://override.test", "hidden": True}).json, {"success": True})
self.assertEqual(self.send("PATCH", "/cluster:node", {"url": "", "hidden": False}).json, {"success": True})
self.assertEqual(links._load()["overrides"], {"cluster:node": {"hidden": False}})
self.assertEqual(self.send("DELETE", "/" + key).json, {"success": True})
self.assertEqual(links._load()["manual"], self.previous["manual"])
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
self.assertEqual(links.refresh.call_count, 5)
links._probe.assert_not_called()
def test_existing_read_and_admin_decorators_still_guard_routes_before_effects(self):
with mock.patch.object(links, "cached_links", return_value={"self": {}, "links": []}) as cached, \
mock.patch.object(links, "_save") as save:
for method, path in [("POST", ""), ("PATCH", "/manual:saved"), ("DELETE", "/manual:saved")]:
for role, status in [(None, 401), ("reader", 403)]:
self.assertEqual(self.send(method, path, {"url": "https://new.test"}, role).status_code, status)
self.assertEqual(self.send("GET", role=None).status_code, 401)
self.assertEqual(self.send("GET", "?all=1", role="reader").status_code, 200)
cached.assert_called_once_with(include_hidden=True)
save.assert_not_called()
links.refresh.assert_not_called()
links._probe.assert_not_called()
self.assertEqual(json.loads(self.path.read_text()), self.previous)
if __name__ == "__main__":
unittest.main()