mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-05 04:12:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b41cbfbe8 | ||
|
|
17a72753bb | ||
|
|
333d03a51e | ||
|
|
14823809d3 | ||
|
|
1bef36fb20 | ||
|
|
c820685241 | ||
|
|
f59323455b | ||
|
|
20cf0a4a7f | ||
|
|
287ad44ffd | ||
|
|
ac30502b67 | ||
|
|
580fab6c78 | ||
|
|
84a3bbe84f | ||
|
|
1309654b4a | ||
|
|
2f7541040f | ||
|
|
3cc020a31b | ||
|
|
4baf7ccf7e | ||
|
|
674e9dba98 | ||
|
|
b7c5960d9a | ||
|
|
220a670b46 | ||
|
|
3c39f84290 | ||
|
|
bdab88c2e8 | ||
|
|
7cdf562efb | ||
|
|
ebfb842dc3 | ||
|
|
2c8fce3df6 | ||
|
|
b88fd353a2 | ||
|
|
55603990b9 | ||
|
|
976c6b46b0 | ||
|
|
8a94fdc66d | ||
|
|
4763897272 | ||
|
|
9946dcfd08 | ||
|
|
1262dafba8 | ||
|
|
f439757fe5 | ||
|
|
315259f5ec | ||
|
|
f8e65cc4c4 | ||
|
|
abf0f7fbf8 | ||
|
|
d13f5ed900 | ||
|
|
3fbfb5233e | ||
|
|
44a446b309 | ||
|
|
039988b506 | ||
|
|
0646f6fd96 | ||
|
|
cce714f873 | ||
|
|
eed9c3e597 | ||
|
|
71058685ef | ||
|
|
f80c3cb3f1 | ||
|
|
49b2d6d2c8 | ||
|
|
dcb94acee4 | ||
|
|
4b14dfa47e | ||
|
|
c2a6db38a8 | ||
|
|
0fe32d2842 | ||
|
|
fcfe8da765 | ||
|
|
b71dd65898 | ||
|
|
46158209f1 | ||
|
|
2302b0967b | ||
|
|
d62f8c6bfd | ||
|
|
0251f77331 | ||
|
|
7244201810 | ||
|
|
812ca1ba7d | ||
|
|
9bf0ff9848 | ||
|
|
f8605b7583 | ||
|
|
e7bb511821 | ||
|
|
bce139f9d1 | ||
|
|
c5050ae022 | ||
|
|
44d2dc7b43 | ||
|
|
7d81724e14 | ||
|
|
29cd269e08 | ||
|
|
b2b1b5e3a1 | ||
|
|
9d8a9fb378 | ||
|
|
d88c36f6ca | ||
|
|
1dac6166ab | ||
|
|
513a9359fd | ||
|
|
9f66c004a2 | ||
|
|
b5476c75fa | ||
|
|
11e030b9de | ||
|
|
c5d717c85a | ||
|
|
e73ec8e24c | ||
|
|
b577c154ee | ||
|
|
ec129b8262 | ||
|
|
698c89fd3b | ||
|
|
7bbccef81e | ||
|
|
b45799d319 | ||
|
|
17d00f2db7 | ||
|
|
58a28dd81e | ||
|
|
42b4aa2489 | ||
|
|
0beeb7a68b | ||
|
|
34b8c47415 | ||
|
|
c25f72febe | ||
|
|
f0c8e73446 | ||
|
|
157ab50f9b | ||
|
|
9eb576f8f3 | ||
|
|
f6da553aeb | ||
|
|
cf0c21eff3 | ||
|
|
99ad69e8cf | ||
|
|
452902bd9a | ||
|
|
e1fb203e46 | ||
|
|
ac2c28ad2c | ||
|
|
8481e588b2 | ||
|
|
d9944edf5e | ||
|
|
037421d762 | ||
|
|
3fe9a49ebc | ||
|
|
805003d811 | ||
|
|
bc88a34390 | ||
|
|
ef859d5ec3 | ||
|
|
55a95a2346 | ||
|
|
c81f552ed0 | ||
|
|
d2c7be9a32 | ||
|
|
bf6c580877 | ||
|
|
3dc263f9fb | ||
|
|
05e5277ae5 | ||
|
|
c2ba648ebe | ||
|
|
2bfa84d5e0 | ||
|
|
d6162af020 | ||
|
|
c4b8ad65da | ||
|
|
a056d7e0dc | ||
|
|
87bda3bbb6 | ||
|
|
c1bd4b3403 | ||
|
|
f2420d9978 | ||
|
|
2901b22be6 | ||
|
|
7b2d9a3268 | ||
|
|
9dae948a7c | ||
|
|
66a1781298 | ||
|
|
504d34a3ee | ||
|
|
3d83d9d561 | ||
|
|
5c33852a65 | ||
|
|
a2740b6873 | ||
|
|
547942cac8 | ||
|
|
8a5f7a4cb8 | ||
|
|
d2a3720ec4 | ||
|
|
732f1e8ef7 | ||
|
|
e05b4e94dc | ||
|
|
05476b99c6 | ||
|
|
06f41f5792 | ||
|
|
fb8b41b445 | ||
|
|
eeb39e6e0f | ||
|
|
fda41ae8d5 | ||
|
|
3a7e450b55 | ||
|
|
7d781d570c | ||
|
|
e581aa0121 | ||
|
|
ef53fa0827 | ||
|
|
86517860ff | ||
|
|
d62fc29581 | ||
|
|
39e7a7d2db | ||
|
|
88f1d09cb8 | ||
|
|
2109cf2508 | ||
|
|
04a028245b | ||
|
|
f10ff18abc | ||
|
|
359a5c269c | ||
|
|
2a23cc9079 | ||
|
|
7fcbc467df | ||
|
|
f2d9c9fb8e | ||
|
|
2c05c800d9 | ||
|
|
89fa4e73a5 | ||
|
|
c4a574422b | ||
|
|
e7a9e3a5b6 | ||
|
|
cbb46cb70a | ||
|
|
37189ed67e | ||
|
|
ebd41b8a46 | ||
|
|
42ca256bac | ||
|
|
3d3b1fc902 |
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"repository": "MacRimi/ProxMenux",
|
"repository": "MacRimi/ProxMenux",
|
||||||
"updatedAt": "2026-09-01T09:26:29.128Z",
|
"updatedAt": "2026-09-02T08:48:44.825Z",
|
||||||
"backfilledAt": "2026-07-22T18:09:20.834Z",
|
"backfilledAt": "2026-07-22T18:09:20.834Z",
|
||||||
"totals": {
|
"totals": {
|
||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"clones": 21669
|
"clones": 22221
|
||||||
},
|
},
|
||||||
"assets": {},
|
"assets": {},
|
||||||
"traffic": {
|
"traffic": {
|
||||||
@@ -129,6 +129,10 @@
|
|||||||
"2026-08-31": {
|
"2026-08-31": {
|
||||||
"count": 343,
|
"count": 343,
|
||||||
"uniques": 268
|
"uniques": 268
|
||||||
|
},
|
||||||
|
"2026-09-01": {
|
||||||
|
"count": 552,
|
||||||
|
"uniques": 396
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3358,7 +3362,14 @@
|
|||||||
"stars": 2925,
|
"stars": 2925,
|
||||||
"forks": 152,
|
"forks": 152,
|
||||||
"downloads": 0,
|
"downloads": 0,
|
||||||
"clones": 21669
|
"clones": 22221
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-09-02",
|
||||||
|
"stars": 2926,
|
||||||
|
"forks": 152,
|
||||||
|
"downloads": 0,
|
||||||
|
"clones": 22221
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Auto-translate missing keys in AppImage/messages/<locale>/common.json
|
||||||
|
against the English source (AppImage/messages/en/common.json).
|
||||||
|
|
||||||
|
Guardrails:
|
||||||
|
- Keys already translated in a target locale are PRESERVED. A key is
|
||||||
|
considered "already translated" when the target value is non-empty
|
||||||
|
AND differs from the English source. This protects human-curated
|
||||||
|
locales (Vaso73's sk) from being overwritten.
|
||||||
|
- `{placeholder}` tokens (next-intl style: `{vmid}`, `{appName}`, etc.)
|
||||||
|
are extracted before translation and restored afterwards, so the
|
||||||
|
interpolation contract stays intact regardless of what the
|
||||||
|
translation provider does with the surrounding text.
|
||||||
|
- `sk` IS translated by default too. Guardrail #1 protects every key
|
||||||
|
Vaso73 has curated by hand; auto-translation only fills the keys
|
||||||
|
that are still on the English fallback in sk.
|
||||||
|
|
||||||
|
Reuses the same translation providers as build_translation_cache.py so
|
||||||
|
the CI environment (googletrans pinning, AppImage provider) stays
|
||||||
|
identical.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Reuse providers + cleaner from the CLI translation script.
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from build_translation_cache import ( # noqa: E402
|
||||||
|
clean_translation,
|
||||||
|
protect_technical_terms,
|
||||||
|
restore_technical_terms,
|
||||||
|
translate_appimage,
|
||||||
|
translate_google_web,
|
||||||
|
translate_googletrans,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# sk IS included in the default. Guardrail #1 (never overwrite a key
|
||||||
|
# whose target value differs from EN) protects every string Vaso73 has
|
||||||
|
# already curated by hand — auto-translation only ever touches keys
|
||||||
|
# that are still on the English fallback in sk. Trade-off accepted:
|
||||||
|
# users on sk see a decent auto-translation for new keys instead of raw
|
||||||
|
# English while the human maintainer catches up, and Vaso73 keeps full
|
||||||
|
# ownership of the wording via follow-up PRs.
|
||||||
|
DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv")
|
||||||
|
DEFAULT_CONTEXT = "Context: Technical UI text for a Proxmox management dashboard. Translate:"
|
||||||
|
|
||||||
|
# next-intl / ICU-style placeholders: {name}, {vmid}, {count}, {app_name}.
|
||||||
|
# We deliberately do NOT match `{{ escaped }}` or nested braces — the
|
||||||
|
# codebase uses only the simple form.
|
||||||
|
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
|
||||||
|
|
||||||
|
|
||||||
|
def flatten(node: dict, prefix: str = "") -> dict[str, str]:
|
||||||
|
"""Depth-first flatten of a nested dict into ``{"a.b.c": "value"}``.
|
||||||
|
Non-string leaves are coerced to str (should not happen in messages
|
||||||
|
catalogs, but keeps the function total)."""
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for key, value in node.items():
|
||||||
|
path = f"{prefix}{key}" if not prefix else f"{prefix}.{key}"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
out.update(flatten(value, path))
|
||||||
|
elif value is None:
|
||||||
|
out[path] = ""
|
||||||
|
else:
|
||||||
|
out[path] = str(value)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def unflatten(flat: dict[str, str]) -> dict:
|
||||||
|
"""Inverse of ``flatten``: rebuild nested structure from dotted keys."""
|
||||||
|
out: dict = {}
|
||||||
|
for path, value in flat.items():
|
||||||
|
parts = path.split(".")
|
||||||
|
cursor = out
|
||||||
|
for part in parts[:-1]:
|
||||||
|
existing = cursor.get(part)
|
||||||
|
if not isinstance(existing, dict):
|
||||||
|
existing = {}
|
||||||
|
cursor[part] = existing
|
||||||
|
cursor = existing
|
||||||
|
cursor[parts[-1]] = value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def protect_placeholders(text: str) -> tuple[str, list[str]]:
|
||||||
|
"""Swap each ``{xxx}`` for an opaque token that machine translators
|
||||||
|
tend to leave alone. Order is preserved so restore_placeholders can
|
||||||
|
walk it linearly."""
|
||||||
|
placeholders: list[str] = []
|
||||||
|
|
||||||
|
def _swap(match: re.Match) -> str:
|
||||||
|
placeholders.append(match.group(0))
|
||||||
|
return f"__PMX_PH_{len(placeholders) - 1}__"
|
||||||
|
|
||||||
|
return PLACEHOLDER_RE.sub(_swap, text), placeholders
|
||||||
|
|
||||||
|
|
||||||
|
def restore_placeholders(text: str, placeholders: list[str]) -> str:
|
||||||
|
"""Reverse of ``protect_placeholders``. If the provider mangled a
|
||||||
|
token beyond recognition we leave the mangled form in place — the
|
||||||
|
fallback assignment (target = existing or EN) upstream catches
|
||||||
|
the worst case."""
|
||||||
|
for i, original in enumerate(placeholders):
|
||||||
|
text = text.replace(f"__PMX_PH_{i}__", original)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def translate_one(
|
||||||
|
text: str,
|
||||||
|
lang: str,
|
||||||
|
provider: str,
|
||||||
|
context: str,
|
||||||
|
timeout: int,
|
||||||
|
appimage_path: Path,
|
||||||
|
) -> str:
|
||||||
|
"""Dispatch to the correct provider. Reuses the same three
|
||||||
|
implementations as build_translation_cache.py so there is exactly
|
||||||
|
one place to fix if a provider changes upstream."""
|
||||||
|
if provider == "googletrans":
|
||||||
|
raw = translate_googletrans(text, lang, context)
|
||||||
|
elif provider == "google-web":
|
||||||
|
raw = translate_google_web(text, lang, context, timeout)
|
||||||
|
elif provider == "appimage":
|
||||||
|
raw = translate_appimage(text, lang, context, timeout, appimage_path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown provider: {provider}")
|
||||||
|
return clean_translation(raw) or text
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path) -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise SystemExit(f"Invalid JSON at {path}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: Path, data: dict) -> None:
|
||||||
|
"""Write with indent=2, no sort_keys — we want to keep the same
|
||||||
|
top-level ordering the maintainer uses in en/common.json so diffs
|
||||||
|
stay readable side-by-side."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(
|
||||||
|
json.dumps(data, ensure_ascii=False, indent=2, sort_keys=False) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def build_arg_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument(
|
||||||
|
"--source",
|
||||||
|
type=Path,
|
||||||
|
default=Path("AppImage/messages/en/common.json"),
|
||||||
|
help="Path to the English source catalog.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--messages-dir",
|
||||||
|
type=Path,
|
||||||
|
default=Path("AppImage/messages"),
|
||||||
|
help="Directory that contains per-locale subdirectories.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--languages",
|
||||||
|
default=",".join(DEFAULT_LANGUAGES),
|
||||||
|
help=(
|
||||||
|
"Comma-separated target locales. Includes sk by default; "
|
||||||
|
"guardrail #1 never overwrites keys whose sk value differs "
|
||||||
|
"from EN, so Vaso73's curated translations are safe."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--provider",
|
||||||
|
choices=("appimage", "googletrans", "google-web"),
|
||||||
|
default="googletrans",
|
||||||
|
help="Translation provider. Default matches build_translation_cache.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--appimage-path",
|
||||||
|
type=Path,
|
||||||
|
default=Path("/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"),
|
||||||
|
)
|
||||||
|
parser.add_argument("--context", default=DEFAULT_CONTEXT)
|
||||||
|
parser.add_argument("--timeout", type=int, default=30)
|
||||||
|
parser.add_argument("--sleep", type=float, default=0.15)
|
||||||
|
parser.add_argument(
|
||||||
|
"--refresh",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Re-translate EVERY key, ignoring existing translations. "
|
||||||
|
"Dangerous: this DOES overwrite human-curated strings. "
|
||||||
|
"Use only when you know what you are doing."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--limit",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="Only translate the first N missing keys per locale (test runs).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--save-every",
|
||||||
|
type=int,
|
||||||
|
default=50,
|
||||||
|
help="Write the locale JSON every N translated keys so a crash mid-run leaves partial progress on disk.",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = build_arg_parser().parse_args()
|
||||||
|
|
||||||
|
source = args.source.resolve()
|
||||||
|
messages_dir = args.messages_dir.resolve()
|
||||||
|
languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()]
|
||||||
|
|
||||||
|
if not source.is_file():
|
||||||
|
print(f"Source not found: {source}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not languages:
|
||||||
|
print("No destination languages selected.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
en_nested = read_json(source)
|
||||||
|
en_flat = flatten(en_nested)
|
||||||
|
print(f"Source: {source}", flush=True)
|
||||||
|
print(f"EN keys: {len(en_flat)}", flush=True)
|
||||||
|
print(f"Target locales: {', '.join(languages)}", flush=True)
|
||||||
|
print(f"Provider: {args.provider}", flush=True)
|
||||||
|
print(f"Sleep between calls: {args.sleep}s", flush=True)
|
||||||
|
|
||||||
|
total_failures: list[tuple[str, str, str]] = []
|
||||||
|
|
||||||
|
for lang in languages:
|
||||||
|
locale_path = messages_dir / lang / "common.json"
|
||||||
|
target_flat = flatten(read_json(locale_path))
|
||||||
|
|
||||||
|
# Decide what needs translating. Same rule as
|
||||||
|
# build_translation_cache.py: only touch keys whose target
|
||||||
|
# value is empty. Never overwrite an existing value — that
|
||||||
|
# covers three legitimate cases in one line:
|
||||||
|
# 1. Human-curated translations (protected trivially).
|
||||||
|
# 2. Universal tokens the maintainer left equal to EN on
|
||||||
|
# purpose (Hardware, Terminal, Normal, SMART, OK, CPU %,
|
||||||
|
# {count}h, product names, etc.). Around 120 keys in
|
||||||
|
# es/common.json — the old `existing == en_value` rule
|
||||||
|
# kept resending these to Google every run, and the
|
||||||
|
# provider sometimes mangled them (`Terminal` →
|
||||||
|
# `terminal`, `CPU %` → `% de CPU`, ...).
|
||||||
|
# 3. Auto-fills from prior runs whose output happened to
|
||||||
|
# match EN — leaving them alone is the intended
|
||||||
|
# steady-state, not a bug.
|
||||||
|
# When someone genuinely wants to redo everything, --refresh
|
||||||
|
# is still available (and is destructive by design).
|
||||||
|
missing: list[str] = []
|
||||||
|
for key, en_value in en_flat.items():
|
||||||
|
if not en_value:
|
||||||
|
continue
|
||||||
|
existing = target_flat.get(key, "")
|
||||||
|
if args.refresh or not existing:
|
||||||
|
missing.append(key)
|
||||||
|
|
||||||
|
if args.limit > 0:
|
||||||
|
missing = missing[: args.limit]
|
||||||
|
|
||||||
|
print(f"\n=== {lang}: {len(missing)} keys to translate ===", flush=True)
|
||||||
|
if not missing:
|
||||||
|
print(f" {lang}: nothing to do", flush=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
failures_for_lang: list[tuple[str, str, str]] = []
|
||||||
|
|
||||||
|
for index, key in enumerate(missing, start=1):
|
||||||
|
en_value = en_flat[key]
|
||||||
|
protected, placeholders = protect_placeholders(en_value)
|
||||||
|
protected, technical_terms = protect_technical_terms(protected)
|
||||||
|
|
||||||
|
try:
|
||||||
|
translated = translate_one(
|
||||||
|
protected,
|
||||||
|
lang,
|
||||||
|
args.provider,
|
||||||
|
args.context,
|
||||||
|
args.timeout,
|
||||||
|
args.appimage_path,
|
||||||
|
)
|
||||||
|
translated = restore_technical_terms(translated, technical_terms)
|
||||||
|
target_flat[key] = restore_placeholders(translated, placeholders)
|
||||||
|
print(
|
||||||
|
f" [{lang} {index}/{len(missing)}] {key}: "
|
||||||
|
f"{en_value[:60]!r} → {target_flat[key][:60]!r}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
# Fall back to whatever we already had (or EN) so the
|
||||||
|
# runtime fallback still kicks in for this key.
|
||||||
|
target_flat[key] = target_flat.get(key) or en_value
|
||||||
|
failures_for_lang.append((lang, key, str(exc)))
|
||||||
|
print(f" [{lang}] {key}: FAILED — {exc}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
if args.save_every > 0 and index % args.save_every == 0:
|
||||||
|
# Preserve keys already in target_flat + write partial progress.
|
||||||
|
write_json(locale_path, unflatten(target_flat))
|
||||||
|
time.sleep(args.sleep)
|
||||||
|
|
||||||
|
write_json(locale_path, unflatten(target_flat))
|
||||||
|
print(f" wrote {locale_path}", flush=True)
|
||||||
|
total_failures.extend(failures_for_lang)
|
||||||
|
|
||||||
|
if total_failures:
|
||||||
|
print(
|
||||||
|
f"\nCompleted with {len(total_failures)} translation failures "
|
||||||
|
f"(partial progress persisted, next run will retry).",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
for lang, key, error in total_failures[:20]:
|
||||||
|
print(f" - {lang}: {key} → {error}", file=sys.stderr, flush=True)
|
||||||
|
if len(total_failures) > 20:
|
||||||
|
print(f" ... and {len(total_failures) - 20} more.", file=sys.stderr, flush=True)
|
||||||
|
# Exit 0 on partial failure so the workflow's Commit + push
|
||||||
|
# step still runs and the keys that DID translate reach
|
||||||
|
# develop. The un-translated keys stay empty and the next
|
||||||
|
# workflow tick (or a manual dispatch) retries them.
|
||||||
|
# Previously we returned 2, which failed the whole run and
|
||||||
|
# discarded 36 out of 38 successful translations because
|
||||||
|
# 2 googletrans timeouts hit sv at the start of the burst.
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print("\ni18n messages generated successfully.", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -16,6 +16,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import ast
|
import ast
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -28,14 +30,77 @@ from urllib.parse import quote
|
|||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt")
|
DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt", "sk", "sv")
|
||||||
DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
|
DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
|
||||||
|
# googletrans and the public Google endpoint used by this workflow do not
|
||||||
|
# support Cloud Translation glossaries. Protect product names, package names
|
||||||
|
# and command identifiers with opaque tokens before sending text to any
|
||||||
|
# provider, then restore the exact source spelling afterwards. Keep longer
|
||||||
|
# terms first so ``gasket`` cannot consume part of ``gasket-dkms``.
|
||||||
|
PROTECTED_TECHNICAL_TERMS = (
|
||||||
|
"google/gasket-driver",
|
||||||
|
"feranick/gasket-driver",
|
||||||
|
"libedgetpu1-std",
|
||||||
|
"Proxmox VE Helper-Scripts",
|
||||||
|
"Docker Compose",
|
||||||
|
"gasket-driver",
|
||||||
|
"gasket-dkms",
|
||||||
|
"libedgetpu1",
|
||||||
|
"libedgetpu",
|
||||||
|
"Google Coral",
|
||||||
|
"Edge TPU",
|
||||||
|
"ProxMenux",
|
||||||
|
"Proxmox",
|
||||||
|
"AppImage",
|
||||||
|
"smartctl",
|
||||||
|
"systemctl",
|
||||||
|
"pveproxy",
|
||||||
|
"apt-get",
|
||||||
|
"Frigate",
|
||||||
|
"Docker",
|
||||||
|
"Coral",
|
||||||
|
"gasket",
|
||||||
|
"apex",
|
||||||
|
"lspci",
|
||||||
|
"dpkg",
|
||||||
|
"DKMS",
|
||||||
|
"QEMU",
|
||||||
|
"LXC",
|
||||||
|
"ZFS",
|
||||||
|
"SSH",
|
||||||
|
"fork",
|
||||||
|
)
|
||||||
|
TECHNICAL_TERM_RE = re.compile(
|
||||||
|
"|".join(
|
||||||
|
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
|
||||||
|
for term in sorted(PROTECTED_TECHNICAL_TERMS, key=len, reverse=True)
|
||||||
|
),
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
TRANSLATE_CALL_RE = re.compile(
|
TRANSLATE_CALL_RE = re.compile(
|
||||||
r"""translate\s+(?P<quote>["'])(?P<text>(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""",
|
r"""translate\s+(?P<quote>["'])(?P<text>(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""",
|
||||||
re.VERBOSE | re.DOTALL,
|
re.VERBOSE | re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def protect_technical_terms(text: str) -> tuple[str, list[str]]:
|
||||||
|
"""Replace glossary terms with stable tokens before translation."""
|
||||||
|
protected: list[str] = []
|
||||||
|
|
||||||
|
def _swap(match: re.Match[str]) -> str:
|
||||||
|
protected.append(match.group(0))
|
||||||
|
return f"__PMX_TERM_{len(protected) - 1}__"
|
||||||
|
|
||||||
|
return TECHNICAL_TERM_RE.sub(_swap, text), protected
|
||||||
|
|
||||||
|
|
||||||
|
def restore_technical_terms(text: str, protected: list[str]) -> str:
|
||||||
|
"""Restore glossary terms exactly as they appeared in the source."""
|
||||||
|
for index, original in enumerate(protected):
|
||||||
|
text = text.replace(f"__PMX_TERM_{index}__", original)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def iter_script_files(
|
def iter_script_files(
|
||||||
scripts_dir: Path, extra_files: Iterable[Path] = ()
|
scripts_dir: Path, extra_files: Iterable[Path] = ()
|
||||||
) -> Iterable[Path]:
|
) -> Iterable[Path]:
|
||||||
@@ -97,7 +162,10 @@ def translate_googletrans(text: str, dest_lang: str, context: str) -> str:
|
|||||||
|
|
||||||
translator = Translator()
|
translator = Translator()
|
||||||
full_text = f"{context} {text}".strip()
|
full_text = f"{context} {text}".strip()
|
||||||
return translator.translate(full_text, dest=dest_lang).text
|
result = translator.translate(full_text, dest=dest_lang)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
result = asyncio.run(result)
|
||||||
|
return result.text
|
||||||
|
|
||||||
|
|
||||||
def translate_google_web(text: str, dest_lang: str, context: str, timeout: int) -> str:
|
def translate_google_web(text: str, dest_lang: str, context: str, timeout: int) -> str:
|
||||||
@@ -163,8 +231,23 @@ def translate_appimage(
|
|||||||
|
|
||||||
def clean_translation(value: str) -> str:
|
def clean_translation(value: str) -> str:
|
||||||
separator = r"[\s\u00a0]*[::]"
|
separator = r"[\s\u00a0]*[::]"
|
||||||
translate_labels = "Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir"
|
# `Translate` pivot in every locale currently supported. Without
|
||||||
context_labels = "Context|Contexto|Contexte|Kontext|Contesto"
|
# the target-language variant here, the cleaner can't locate the
|
||||||
|
# boundary between the context prompt and the real translation,
|
||||||
|
# and the whole payload leaks through as the translated context.
|
||||||
|
# Caught on the 2026-08-14 workflow run — every sk / sv key ended
|
||||||
|
# up as "Technický text používateľského rozhrania…" or
|
||||||
|
# "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Övers"
|
||||||
|
# because Preložiť / Översätt / Översätta were missing.
|
||||||
|
translate_labels = (
|
||||||
|
"Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir"
|
||||||
|
"|Preložiť|Prelož|Preloz" # sk
|
||||||
|
"|Översätta|Översätt" # sv
|
||||||
|
)
|
||||||
|
context_labels = (
|
||||||
|
"Context|Contexto|Contexte|Kontext|Contesto"
|
||||||
|
"|Sammanhang" # sv alternate
|
||||||
|
)
|
||||||
value = re.sub(
|
value = re.sub(
|
||||||
rf"^.*?({translate_labels}){separator}",
|
rf"^.*?({translate_labels}){separator}",
|
||||||
"",
|
"",
|
||||||
@@ -194,15 +277,19 @@ def translate_text(
|
|||||||
timeout: int,
|
timeout: int,
|
||||||
appimage_path: Path,
|
appimage_path: Path,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
protected_text, protected_terms = protect_technical_terms(text)
|
||||||
if provider == "googletrans":
|
if provider == "googletrans":
|
||||||
translated = translate_googletrans(text, dest_lang, context)
|
translated = translate_googletrans(protected_text, dest_lang, context)
|
||||||
elif provider == "google-web":
|
elif provider == "google-web":
|
||||||
translated = translate_google_web(text, dest_lang, context, timeout)
|
translated = translate_google_web(protected_text, dest_lang, context, timeout)
|
||||||
elif provider == "appimage":
|
elif provider == "appimage":
|
||||||
translated = translate_appimage(text, dest_lang, context, timeout, appimage_path)
|
translated = translate_appimage(
|
||||||
|
protected_text, dest_lang, context, timeout, appimage_path
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown provider: {provider}")
|
raise ValueError(f"Unknown provider: {provider}")
|
||||||
return clean_translation(translated) or text
|
translated = restore_technical_terms(clean_translation(translated), protected_terms)
|
||||||
|
return translated or text
|
||||||
|
|
||||||
|
|
||||||
def load_language_cache(path: Path) -> dict[str, str]:
|
def load_language_cache(path: Path) -> dict[str, str]:
|
||||||
@@ -260,7 +347,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--languages",
|
"--languages",
|
||||||
default=",".join(DEFAULT_LANGUAGES),
|
default=",".join(DEFAULT_LANGUAGES),
|
||||||
help="Comma-separated destination languages. Default: es,fr,de,it,pt",
|
help="Comma-separated destination languages. Default: es,fr,de,it,pt,sk",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--provider",
|
"--provider",
|
||||||
@@ -373,12 +460,20 @@ def main() -> int:
|
|||||||
write_language_cache(output_dir / f"{lang}.json", cache)
|
write_language_cache(output_dir / f"{lang}.json", cache)
|
||||||
|
|
||||||
if failures:
|
if failures:
|
||||||
print(f"Completed with {len(failures)} translation failures.", file=sys.stderr, flush=True)
|
print(
|
||||||
|
f"Completed with {len(failures)} translation failures "
|
||||||
|
f"(partial progress persisted, next run will retry).",
|
||||||
|
file=sys.stderr, flush=True,
|
||||||
|
)
|
||||||
for text, lang, error in failures[:20]:
|
for text, lang, error in failures[:20]:
|
||||||
print(f"- {lang}: {text[:80]} -> {error}", file=sys.stderr, flush=True)
|
print(f"- {lang}: {text[:80]} -> {error}", file=sys.stderr, flush=True)
|
||||||
if len(failures) > 20:
|
if len(failures) > 20:
|
||||||
print(f"... and {len(failures) - 20} more.", file=sys.stderr, flush=True)
|
print(f"... and {len(failures) - 20} more.", file=sys.stderr, flush=True)
|
||||||
return 2
|
# Exit 0 on partial failure so the workflow's Commit + push
|
||||||
|
# step still runs. Otherwise a couple of transient googletrans
|
||||||
|
# timeouts would fail the whole workflow and discard every
|
||||||
|
# successful translation from the same batch.
|
||||||
|
return 0
|
||||||
|
|
||||||
print("Translation cache generated successfully.", flush=True)
|
print("Translation cache generated successfully.", flush=True)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -0,0 +1,853 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build missing translations for the ProxMenux documentation catalog."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
from build_translation_cache import ( # noqa: E402
|
||||||
|
clean_translation,
|
||||||
|
translate_appimage,
|
||||||
|
translate_google_web,
|
||||||
|
translate_googletrans,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv")
|
||||||
|
DEFAULT_CONTEXT = (
|
||||||
|
"Context: ProxMenux technical documentation for Proxmox VE users. "
|
||||||
|
"Preserve product names, commands, paths, variables and placeholders. Translate:"
|
||||||
|
)
|
||||||
|
|
||||||
|
TECHNICAL_TERMS = (
|
||||||
|
"Proxmox VE Helper-Scripts",
|
||||||
|
"Proxmox Backup Server",
|
||||||
|
"Proxmox Mail Gateway",
|
||||||
|
"Proxmox VE",
|
||||||
|
"ProxMenux Monitor",
|
||||||
|
"ProxMenux Scripts",
|
||||||
|
"Docker Compose",
|
||||||
|
"Docker Engine",
|
||||||
|
"Google Coral",
|
||||||
|
"Edge TPU",
|
||||||
|
"Let's Encrypt",
|
||||||
|
"Cloudflare",
|
||||||
|
"Pushover",
|
||||||
|
"Telegram",
|
||||||
|
"Discord",
|
||||||
|
"Microsoft Teams",
|
||||||
|
"GitHub",
|
||||||
|
"Gotify",
|
||||||
|
"Apprise",
|
||||||
|
"Frigate",
|
||||||
|
"Vaultwarden",
|
||||||
|
"Portainer",
|
||||||
|
"ProxMenux",
|
||||||
|
"AppImage",
|
||||||
|
"systemctl",
|
||||||
|
"journalctl",
|
||||||
|
"smartctl",
|
||||||
|
"pveproxy",
|
||||||
|
"apt-get",
|
||||||
|
"gasket-dkms",
|
||||||
|
"libedgetpu",
|
||||||
|
"QEMU",
|
||||||
|
"LXC",
|
||||||
|
"ZFS",
|
||||||
|
"Ceph",
|
||||||
|
"Docker",
|
||||||
|
"OpenAI",
|
||||||
|
"WebSocket",
|
||||||
|
"OAuth",
|
||||||
|
"DKMS",
|
||||||
|
"SSH",
|
||||||
|
"API",
|
||||||
|
)
|
||||||
|
|
||||||
|
PROTECTED_PATTERNS = (
|
||||||
|
# Keep rich-text tags visible to Google Translate. It preserves their
|
||||||
|
# structure while translating the enclosed prose, whereas replacing
|
||||||
|
# opening/closing tags with adjacent sentinels can make the provider drop
|
||||||
|
# one side of the pair. The contract is validated after translation.
|
||||||
|
re.compile(r"`[^`]+`"),
|
||||||
|
re.compile(r"https?://[^\s<>]+"),
|
||||||
|
re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}"),
|
||||||
|
re.compile(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?"),
|
||||||
|
re.compile(r"(?<![\w-])--[A-Za-z0-9][A-Za-z0-9_-]*"),
|
||||||
|
re.compile(r"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"),
|
||||||
|
re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"),
|
||||||
|
)
|
||||||
|
LITERAL_TAG_RE = re.compile(
|
||||||
|
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
TERM_RE = re.compile(
|
||||||
|
"|".join(
|
||||||
|
rf"(?<![A-Za-z0-9_]){re.escape(term)}(?![A-Za-z0-9_])"
|
||||||
|
for term in sorted(TECHNICAL_TERMS, key=len, reverse=True)
|
||||||
|
),
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
TAG_RE = re.compile(r"</?([A-Za-z][A-Za-z0-9]*)>")
|
||||||
|
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
|
||||||
|
NON_TRANSLATABLE_KEYS = {
|
||||||
|
"command",
|
||||||
|
"code",
|
||||||
|
"href",
|
||||||
|
"icon",
|
||||||
|
"id",
|
||||||
|
"path",
|
||||||
|
"route",
|
||||||
|
"slug",
|
||||||
|
"src",
|
||||||
|
"url",
|
||||||
|
}
|
||||||
|
SOURCE_STATE_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Leaf:
|
||||||
|
path: tuple[str | int, ...]
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path) -> Any:
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path: Path, value: Any) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps(value, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_leaves(node: Any, path: tuple[str | int, ...] = ()) -> list[Leaf]:
|
||||||
|
leaves: list[Leaf] = []
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for key, value in node.items():
|
||||||
|
leaves.extend(iter_leaves(value, path + (key,)))
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for index, value in enumerate(node):
|
||||||
|
leaves.extend(iter_leaves(value, path + (index,)))
|
||||||
|
elif isinstance(node, str):
|
||||||
|
leaves.append(Leaf(path, node))
|
||||||
|
return leaves
|
||||||
|
|
||||||
|
|
||||||
|
def get_at_path(node: Any, path: tuple[str | int, ...]) -> Any:
|
||||||
|
current = node
|
||||||
|
try:
|
||||||
|
for part in path:
|
||||||
|
if isinstance(part, int):
|
||||||
|
if not isinstance(current, list):
|
||||||
|
return None
|
||||||
|
current = current[part]
|
||||||
|
else:
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
return None
|
||||||
|
current = current[part]
|
||||||
|
except (IndexError, KeyError, TypeError):
|
||||||
|
return None
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def path_key(path: tuple[str | int, ...]) -> str:
|
||||||
|
return ".".join(str(part) for part in path)
|
||||||
|
|
||||||
|
|
||||||
|
def should_copy(source: str, path: tuple[str | int, ...]) -> bool:
|
||||||
|
if not source.strip() or not re.search(r"[A-Za-z]", source):
|
||||||
|
return True
|
||||||
|
last = str(path[-1]).lower() if path else ""
|
||||||
|
if last in NON_TRANSLATABLE_KEYS or any(
|
||||||
|
last.endswith(suffix)
|
||||||
|
for suffix in ("url", "href", "path", "command", "code", "icon")
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/[A-Za-z0-9_./:@%+=-]+", source):
|
||||||
|
return True
|
||||||
|
if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def leaf_token(path: tuple[str | int, ...]) -> str:
|
||||||
|
return json.dumps(path, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def source_fingerprints(source: Any) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20]
|
||||||
|
for leaf in iter_leaves(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def needs_translation(
|
||||||
|
source: str,
|
||||||
|
target: Any,
|
||||||
|
path: tuple[str | int, ...],
|
||||||
|
refresh: bool,
|
||||||
|
forced_tokens: set[str] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
if should_copy(source, path):
|
||||||
|
return False
|
||||||
|
if refresh:
|
||||||
|
return True
|
||||||
|
if forced_tokens and leaf_token(path) in forced_tokens:
|
||||||
|
return True
|
||||||
|
return not isinstance(target, str) or not target.strip() or target == source
|
||||||
|
|
||||||
|
|
||||||
|
def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]:
|
||||||
|
"""Give translatable rich-text tags opaque names during translation.
|
||||||
|
|
||||||
|
Google can remove semantic tags such as ``strong`` or ``em`` after
|
||||||
|
translating their contents. Unknown tag names are retained, so rename
|
||||||
|
non-literal tags temporarily and restore them before contract validation.
|
||||||
|
Literal ``code``, ``kbd`` and ``pre`` elements stay untouched because the
|
||||||
|
provider preserves both their markup and their contents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
names: dict[str, str] = {}
|
||||||
|
reverse: dict[str, str] = {}
|
||||||
|
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
original = match.group(1)
|
||||||
|
if original.lower() in {"code", "kbd", "pre"}:
|
||||||
|
return match.group(0)
|
||||||
|
key = original.lower()
|
||||||
|
internal = names.get(key)
|
||||||
|
if internal is None:
|
||||||
|
internal = f"pmxrich{len(names):04d}"
|
||||||
|
names[key] = internal
|
||||||
|
reverse[internal] = original
|
||||||
|
slash = "/" if match.group(0).startswith("</") else ""
|
||||||
|
return f"<{slash}{internal}>"
|
||||||
|
|
||||||
|
return TAG_RE.sub(replace, text), reverse
|
||||||
|
|
||||||
|
|
||||||
|
def restore_rich_tags(text: str, mapping: dict[str, str]) -> str:
|
||||||
|
for internal, original in mapping.items():
|
||||||
|
text = re.sub(
|
||||||
|
rf"<(/?){re.escape(internal)}>",
|
||||||
|
lambda match: f"<{match.group(1)}{original}>",
|
||||||
|
text,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def protect_text(text: str) -> tuple[str, dict[str, str]]:
|
||||||
|
# Google already keeps both the markup and the contents of literal rich-
|
||||||
|
# text blocks. Replacing a filename or path inside one of these blocks can
|
||||||
|
# leave the sentinel as its only child, which the provider may discard.
|
||||||
|
literal_ranges = [(match.start(), match.end()) for match in LITERAL_TAG_RE.finditer(text)]
|
||||||
|
|
||||||
|
def overlaps_literal(start: int, end: int) -> bool:
|
||||||
|
return any(start < literal_end and end > literal_start for literal_start, literal_end in literal_ranges)
|
||||||
|
|
||||||
|
candidates: list[tuple[int, int]] = []
|
||||||
|
for pattern in PROTECTED_PATTERNS:
|
||||||
|
candidates.extend(
|
||||||
|
(match.start(), match.end())
|
||||||
|
for match in pattern.finditer(text)
|
||||||
|
if not overlaps_literal(match.start(), match.end())
|
||||||
|
)
|
||||||
|
candidates.extend(
|
||||||
|
(match.start(), match.end())
|
||||||
|
for match in TERM_RE.finditer(text)
|
||||||
|
if not overlaps_literal(match.start(), match.end())
|
||||||
|
)
|
||||||
|
candidates.sort(key=lambda item: (item[0], -(item[1] - item[0])))
|
||||||
|
|
||||||
|
selected: list[tuple[int, int]] = []
|
||||||
|
cursor = -1
|
||||||
|
for start, end in candidates:
|
||||||
|
if start >= cursor:
|
||||||
|
selected.append((start, end))
|
||||||
|
cursor = end
|
||||||
|
|
||||||
|
mapping: dict[str, str] = {}
|
||||||
|
chunks: list[str] = []
|
||||||
|
cursor = 0
|
||||||
|
for index, (start, end) in enumerate(selected):
|
||||||
|
# Google Translate can drop underscore-delimited sentinels when they
|
||||||
|
# sit directly beside inline markup (for example an opening <em>
|
||||||
|
# token followed by translated prose). Triple brackets remain opaque
|
||||||
|
# in that position and preserve the complete rich-text contract.
|
||||||
|
token = f"[[[PMXDOC{index:04d}]]]"
|
||||||
|
chunks.append(text[cursor:start])
|
||||||
|
chunks.append(token)
|
||||||
|
mapping[token] = text[start:end]
|
||||||
|
cursor = end
|
||||||
|
chunks.append(text[cursor:])
|
||||||
|
return "".join(chunks), mapping
|
||||||
|
|
||||||
|
|
||||||
|
def restore_text(text: str, mapping: dict[str, str]) -> str:
|
||||||
|
missing = [token for token in mapping if token not in text]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"translation provider changed protected token {missing[0]}")
|
||||||
|
for token, original in mapping.items():
|
||||||
|
text = text.replace(token, original)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def validate_contract(source: str, target: str) -> None:
|
||||||
|
if sorted(TAG_RE.findall(source)) != sorted(TAG_RE.findall(target)):
|
||||||
|
raise ValueError("rich-text tag contract changed")
|
||||||
|
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
|
||||||
|
raise ValueError("placeholder contract changed")
|
||||||
|
|
||||||
|
|
||||||
|
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
|
||||||
|
def translate(text: str, language: str) -> str:
|
||||||
|
if args.provider == "googletrans":
|
||||||
|
raw = translate_googletrans(text, language, args.context)
|
||||||
|
elif args.provider == "google-web":
|
||||||
|
raw = translate_google_web(text, language, args.context, args.timeout)
|
||||||
|
else:
|
||||||
|
raw = translate_appimage(
|
||||||
|
text,
|
||||||
|
language,
|
||||||
|
args.context,
|
||||||
|
args.timeout,
|
||||||
|
args.appimage_path,
|
||||||
|
)
|
||||||
|
return clean_translation(raw).strip()
|
||||||
|
|
||||||
|
return translate
|
||||||
|
|
||||||
|
|
||||||
|
def translate_with_retry(
|
||||||
|
source: str,
|
||||||
|
language: str,
|
||||||
|
translate: Callable[[str, str], str],
|
||||||
|
retries: int,
|
||||||
|
delay: float,
|
||||||
|
) -> str:
|
||||||
|
rich_text, rich_mapping = protect_rich_tags(source)
|
||||||
|
protected, mapping = protect_text(rich_text)
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(retries + 1):
|
||||||
|
try:
|
||||||
|
translated = translate(protected, language)
|
||||||
|
if not translated:
|
||||||
|
raise ValueError("translation provider returned an empty value")
|
||||||
|
translated = restore_text(translated, mapping)
|
||||||
|
translated = restore_rich_tags(translated, rich_mapping)
|
||||||
|
validate_contract(source, translated)
|
||||||
|
return translated
|
||||||
|
except Exception as exc: # network/provider errors are retried together
|
||||||
|
last_error = exc
|
||||||
|
if attempt < retries:
|
||||||
|
time.sleep(delay * (attempt + 1))
|
||||||
|
raise RuntimeError(str(last_error)) from last_error
|
||||||
|
|
||||||
|
|
||||||
|
def merge_tree(
|
||||||
|
source: Any,
|
||||||
|
target: Any,
|
||||||
|
translations: dict[tuple[str | int, ...], str],
|
||||||
|
path: tuple[str | int, ...] = (),
|
||||||
|
) -> Any:
|
||||||
|
if isinstance(source, dict):
|
||||||
|
target_dict = target if isinstance(target, dict) else {}
|
||||||
|
return {
|
||||||
|
key: merge_tree(value, target_dict.get(key), translations, path + (key,))
|
||||||
|
for key, value in source.items()
|
||||||
|
}
|
||||||
|
if isinstance(source, list):
|
||||||
|
target_list = target if isinstance(target, list) else []
|
||||||
|
return [
|
||||||
|
merge_tree(
|
||||||
|
value,
|
||||||
|
target_list[index] if index < len(target_list) else None,
|
||||||
|
translations,
|
||||||
|
path + (index,),
|
||||||
|
)
|
||||||
|
for index, value in enumerate(source)
|
||||||
|
]
|
||||||
|
if isinstance(source, str):
|
||||||
|
if path in translations:
|
||||||
|
return translations[path]
|
||||||
|
if should_copy(source, path):
|
||||||
|
return source
|
||||||
|
if isinstance(target, str) and target.strip():
|
||||||
|
return target
|
||||||
|
return source
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
def source_files(source_root: Path, section: str) -> list[Path]:
|
||||||
|
scope = (source_root / section).resolve()
|
||||||
|
root = source_root.resolve()
|
||||||
|
if scope != root and root not in scope.parents:
|
||||||
|
raise ValueError("section must stay inside the English messages directory")
|
||||||
|
if scope.is_file():
|
||||||
|
if scope.suffix != ".json":
|
||||||
|
raise ValueError("section file must be JSON")
|
||||||
|
return [scope]
|
||||||
|
if not scope.is_dir():
|
||||||
|
raise ValueError(f"section does not exist: {scope}")
|
||||||
|
return sorted(
|
||||||
|
path
|
||||||
|
for path in scope.rglob("*.json")
|
||||||
|
if not any(part.startswith(".") for part in path.relative_to(root).parts)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_memory(source_root: Path, messages_root: Path, language: str) -> dict[str, str]:
|
||||||
|
memory: dict[str, str] = {}
|
||||||
|
conflicts: set[str] = set()
|
||||||
|
for source_path in sorted(source_root.rglob("*.json")):
|
||||||
|
if any(part.startswith(".") for part in source_path.relative_to(source_root).parts):
|
||||||
|
continue
|
||||||
|
target_path = messages_root / language / source_path.relative_to(source_root)
|
||||||
|
source = read_json(source_path)
|
||||||
|
target = read_json(target_path)
|
||||||
|
if target is None:
|
||||||
|
continue
|
||||||
|
for leaf in iter_leaves(source):
|
||||||
|
translated = get_at_path(target, leaf.path)
|
||||||
|
if not isinstance(translated, str) or not translated.strip() or translated == leaf.source:
|
||||||
|
continue
|
||||||
|
previous = memory.get(leaf.source)
|
||||||
|
if previous is not None and previous != translated:
|
||||||
|
conflicts.add(leaf.source)
|
||||||
|
else:
|
||||||
|
memory[leaf.source] = translated
|
||||||
|
for source in conflicts:
|
||||||
|
memory.pop(source, None)
|
||||||
|
return memory
|
||||||
|
|
||||||
|
|
||||||
|
def pending_leaves(
|
||||||
|
source: Any,
|
||||||
|
target: Any,
|
||||||
|
refresh: bool,
|
||||||
|
forced_tokens: set[str] | None = None,
|
||||||
|
) -> list[Leaf]:
|
||||||
|
return [
|
||||||
|
leaf
|
||||||
|
for leaf in iter_leaves(source)
|
||||||
|
if needs_translation(
|
||||||
|
leaf.source,
|
||||||
|
get_at_path(target, leaf.path),
|
||||||
|
leaf.path,
|
||||||
|
refresh,
|
||||||
|
forced_tokens,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def schema_matches(source: Any, target: Any) -> bool:
|
||||||
|
if type(source) is not type(target):
|
||||||
|
return False
|
||||||
|
if isinstance(source, dict):
|
||||||
|
return list(source) == list(target) and all(
|
||||||
|
schema_matches(source[key], target[key]) for key in source
|
||||||
|
)
|
||||||
|
if isinstance(source, list):
|
||||||
|
return len(source) == len(target) and all(
|
||||||
|
schema_matches(left, right) for left, right in zip(source, target)
|
||||||
|
)
|
||||||
|
if isinstance(source, str):
|
||||||
|
# Localized strings intentionally differ from the English source.
|
||||||
|
return True
|
||||||
|
# Booleans, numbers and null values are structural data and must stay in
|
||||||
|
# sync with the English catalog instead of retaining an obsolete value.
|
||||||
|
return source == target
|
||||||
|
|
||||||
|
|
||||||
|
def empty_source_state() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"version": SOURCE_STATE_VERSION,
|
||||||
|
"initialized": False,
|
||||||
|
"source": {},
|
||||||
|
"pending": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_source_state(path: Path) -> dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
return empty_source_state()
|
||||||
|
try:
|
||||||
|
value = read_json(path)
|
||||||
|
except ValueError:
|
||||||
|
return empty_source_state()
|
||||||
|
if not isinstance(value, dict) or value.get("version") != SOURCE_STATE_VERSION:
|
||||||
|
return empty_source_state()
|
||||||
|
if not isinstance(value.get("source"), dict) or not isinstance(value.get("pending"), dict):
|
||||||
|
return empty_source_state()
|
||||||
|
value.setdefault("initialized", True)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def state_pending_tokens(state: dict[str, Any], language: str, relative: str) -> set[str]:
|
||||||
|
language_state = state.setdefault("pending", {}).setdefault(language, {})
|
||||||
|
values = language_state.get(relative, [])
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return set()
|
||||||
|
return {str(value) for value in values}
|
||||||
|
|
||||||
|
|
||||||
|
def set_state_pending_tokens(
|
||||||
|
state: dict[str, Any], language: str, relative: str, tokens: set[str]
|
||||||
|
) -> None:
|
||||||
|
language_state = state.setdefault("pending", {}).setdefault(language, {})
|
||||||
|
if tokens:
|
||||||
|
language_state[relative] = sorted(tokens)
|
||||||
|
else:
|
||||||
|
language_state.pop(relative, None)
|
||||||
|
if not language_state:
|
||||||
|
state["pending"].pop(language, None)
|
||||||
|
|
||||||
|
|
||||||
|
def translate_file(
|
||||||
|
source: Any,
|
||||||
|
target: Any,
|
||||||
|
leaves: list[Leaf],
|
||||||
|
language: str,
|
||||||
|
memory: dict[str, str],
|
||||||
|
translate: Callable[[str, str], str],
|
||||||
|
args: argparse.Namespace,
|
||||||
|
) -> tuple[Any | None, list[str], int]:
|
||||||
|
resolved: dict[tuple[str | int, ...], str] = {}
|
||||||
|
failures: list[str] = []
|
||||||
|
jobs: dict[str, list[tuple[str | int, ...]]] = {}
|
||||||
|
|
||||||
|
for leaf in leaves:
|
||||||
|
if not args.refresh and leaf.source in memory:
|
||||||
|
resolved[leaf.path] = memory[leaf.source]
|
||||||
|
continue
|
||||||
|
jobs.setdefault(leaf.source, []).append(leaf.path)
|
||||||
|
|
||||||
|
if jobs:
|
||||||
|
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||||
|
futures = {
|
||||||
|
executor.submit(
|
||||||
|
translate_with_retry,
|
||||||
|
text,
|
||||||
|
language,
|
||||||
|
translate,
|
||||||
|
args.retries,
|
||||||
|
args.retry_delay,
|
||||||
|
): text
|
||||||
|
for text in jobs
|
||||||
|
}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
text = futures[future]
|
||||||
|
try:
|
||||||
|
translated = future.result()
|
||||||
|
memory[text] = translated
|
||||||
|
for path in jobs[text]:
|
||||||
|
resolved[path] = translated
|
||||||
|
except Exception as exc:
|
||||||
|
failures.append(f"{text[:90]}: {exc}")
|
||||||
|
if args.sleep:
|
||||||
|
time.sleep(args.sleep)
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
return None, failures, len(jobs)
|
||||||
|
return merge_tree(source, target, resolved), [], len(jobs)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--source-dir", type=Path, default=Path("web/messages/en"))
|
||||||
|
parser.add_argument("--messages-dir", type=Path, default=Path("web/messages"))
|
||||||
|
parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES))
|
||||||
|
parser.add_argument(
|
||||||
|
"--section",
|
||||||
|
default=".",
|
||||||
|
help="Relative file or directory below the English messages directory.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--provider",
|
||||||
|
choices=("google-web", "googletrans", "appimage"),
|
||||||
|
default="googletrans",
|
||||||
|
)
|
||||||
|
parser.add_argument("--appimage-path", type=Path, default=Path("ProxMenux-Monitor.AppImage"))
|
||||||
|
parser.add_argument(
|
||||||
|
"--source-state",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Source fingerprint state used to detect changed English strings. "
|
||||||
|
"Defaults to <messages-dir>/.docs-i18n-source-state.json."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("--context", default=DEFAULT_CONTEXT)
|
||||||
|
parser.add_argument("--timeout", type=int, default=30)
|
||||||
|
parser.add_argument("--workers", type=int, default=4)
|
||||||
|
parser.add_argument("--retries", type=int, default=3)
|
||||||
|
parser.add_argument("--retry-delay", type=float, default=2.0)
|
||||||
|
parser.add_argument("--sleep", type=float, default=0.0)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-files",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="Maximum pending files per locale; zero processes all files.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument("--check", action="store_true")
|
||||||
|
parser.add_argument(
|
||||||
|
"--refresh",
|
||||||
|
action="store_true",
|
||||||
|
help="Overwrite existing translations in the selected scope.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--strict", action="store_true")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
if args.workers < 1 or args.retries < 0 or args.max_files < 0:
|
||||||
|
print("workers must be positive; retries and max-files cannot be negative", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
source_root = args.source_dir.resolve()
|
||||||
|
messages_root = args.messages_dir.resolve()
|
||||||
|
source_state_path = (
|
||||||
|
args.source_state.resolve()
|
||||||
|
if args.source_state is not None
|
||||||
|
else messages_root / ".docs-i18n-source-state.json"
|
||||||
|
)
|
||||||
|
languages = [item.strip() for item in args.languages.split(",") if item.strip()]
|
||||||
|
try:
|
||||||
|
files = source_files(source_root, args.section)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(exc, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if not languages:
|
||||||
|
print("No target languages selected.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if args.refresh:
|
||||||
|
print("WARNING: --refresh overwrites existing translations in the selected scope.")
|
||||||
|
|
||||||
|
translate = provider_function(args)
|
||||||
|
state = load_source_state(source_state_path)
|
||||||
|
state_was_initialized = bool(state.get("initialized"))
|
||||||
|
previous_source_state = state.setdefault("source", {})
|
||||||
|
|
||||||
|
file_info: dict[
|
||||||
|
str, tuple[Path, Any, dict[str, str], set[str], set[str]]
|
||||||
|
] = {}
|
||||||
|
selected_relatives: set[str] = set()
|
||||||
|
for source_path in files:
|
||||||
|
relative = source_path.relative_to(source_root).as_posix()
|
||||||
|
selected_relatives.add(relative)
|
||||||
|
source = read_json(source_path)
|
||||||
|
current_fingerprints = source_fingerprints(source)
|
||||||
|
translatable_tokens = {
|
||||||
|
leaf_token(leaf.path)
|
||||||
|
for leaf in iter_leaves(source)
|
||||||
|
if not should_copy(leaf.source, leaf.path)
|
||||||
|
}
|
||||||
|
previous_fingerprints = previous_source_state.get(relative, {})
|
||||||
|
if not isinstance(previous_fingerprints, dict):
|
||||||
|
previous_fingerprints = {}
|
||||||
|
if state_was_initialized:
|
||||||
|
changed_tokens = {
|
||||||
|
token
|
||||||
|
for token, fingerprint in current_fingerprints.items()
|
||||||
|
if token in translatable_tokens
|
||||||
|
# Existing English leaves whose text changed must be sent to
|
||||||
|
# the provider even when the target still contains the old,
|
||||||
|
# non-empty translation. New leaves are handled by the usual
|
||||||
|
# missing-target detection, preserving a translation supplied
|
||||||
|
# manually in the same commit.
|
||||||
|
and token in previous_fingerprints
|
||||||
|
and previous_fingerprints[token] != fingerprint
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# The first run establishes the baseline without replacing
|
||||||
|
# existing human/Codex translations. Missing target values are
|
||||||
|
# still discovered separately for every locale below.
|
||||||
|
changed_tokens = set()
|
||||||
|
file_info[relative] = (
|
||||||
|
source_path,
|
||||||
|
source,
|
||||||
|
current_fingerprints,
|
||||||
|
changed_tokens,
|
||||||
|
translatable_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Materialize every locale's pending queue before advancing the shared
|
||||||
|
# English baseline. If the runner stops halfway through, unprocessed
|
||||||
|
# locales retain the exact changed leaf tokens for the next run.
|
||||||
|
for language in languages:
|
||||||
|
for relative, (
|
||||||
|
_,
|
||||||
|
source,
|
||||||
|
_,
|
||||||
|
changed_tokens,
|
||||||
|
translatable_tokens,
|
||||||
|
) in file_info.items():
|
||||||
|
target_path = messages_root / language / relative
|
||||||
|
target = read_json(target_path)
|
||||||
|
pending_tokens = (
|
||||||
|
state_pending_tokens(state, language, relative)
|
||||||
|
& translatable_tokens
|
||||||
|
)
|
||||||
|
pending_tokens.update(changed_tokens)
|
||||||
|
pending_tokens.update(
|
||||||
|
leaf_token(leaf.path)
|
||||||
|
for leaf in pending_leaves(source, target, args.refresh)
|
||||||
|
)
|
||||||
|
set_state_pending_tokens(state, language, relative, pending_tokens)
|
||||||
|
|
||||||
|
for relative, (_, _, current_fingerprints, _, _) in file_info.items():
|
||||||
|
previous_source_state[relative] = current_fingerprints
|
||||||
|
state["initialized"] = True
|
||||||
|
|
||||||
|
# A complete run also mirrors deletion of an English catalog. Partial
|
||||||
|
# --section runs deliberately leave unrelated paths untouched.
|
||||||
|
if args.section in (".", ""):
|
||||||
|
removed_files = set(previous_source_state) - selected_relatives
|
||||||
|
for relative in sorted(removed_files):
|
||||||
|
for language in languages:
|
||||||
|
target_path = messages_root / language / relative
|
||||||
|
if target_path.exists() and not (args.check or args.dry_run):
|
||||||
|
target_path.unlink()
|
||||||
|
print(f"[{language}] removed obsolete catalog {target_path}")
|
||||||
|
set_state_pending_tokens(state, language, relative, set())
|
||||||
|
previous_source_state.pop(relative, None)
|
||||||
|
|
||||||
|
if not (args.check or args.dry_run):
|
||||||
|
write_json(source_state_path, state)
|
||||||
|
|
||||||
|
total_failures = 0
|
||||||
|
total_written = 0
|
||||||
|
print(f"English files: {len(files)} | locales: {', '.join(languages)}")
|
||||||
|
|
||||||
|
for language in languages:
|
||||||
|
memory = collect_memory(source_root, messages_root, language)
|
||||||
|
# A target value paired with a newly changed English source is the old
|
||||||
|
# translation, not valid translation memory for the new sentence.
|
||||||
|
# Remove every queued source text before provider reuse; successful
|
||||||
|
# translations repopulate memory normally for later files.
|
||||||
|
queued_source_texts: set[str] = set()
|
||||||
|
for relative_key, (_, source, _, _, _) in file_info.items():
|
||||||
|
queued_tokens = state_pending_tokens(state, language, relative_key)
|
||||||
|
queued_source_texts.update(
|
||||||
|
leaf.source
|
||||||
|
for leaf in iter_leaves(source)
|
||||||
|
if leaf_token(leaf.path) in queued_tokens
|
||||||
|
)
|
||||||
|
for source_text in queued_source_texts:
|
||||||
|
memory.pop(source_text, None)
|
||||||
|
pending: list[
|
||||||
|
tuple[Path, Path, Any, Any, list[Leaf], set[str], bool]
|
||||||
|
] = []
|
||||||
|
total_strings = 0
|
||||||
|
missing_strings = 0
|
||||||
|
|
||||||
|
for source_path in files:
|
||||||
|
relative = source_path.relative_to(source_root)
|
||||||
|
relative_key = relative.as_posix()
|
||||||
|
target_path = messages_root / language / relative
|
||||||
|
source = file_info[relative_key][1]
|
||||||
|
target = read_json(target_path)
|
||||||
|
queued_tokens = state_pending_tokens(state, language, relative_key)
|
||||||
|
leaves = pending_leaves(source, target, args.refresh, queued_tokens)
|
||||||
|
total_strings += len(iter_leaves(source))
|
||||||
|
missing_strings += len(leaves)
|
||||||
|
schema_changed = not schema_matches(source, target)
|
||||||
|
if leaves or schema_changed:
|
||||||
|
pending.append(
|
||||||
|
(
|
||||||
|
source_path,
|
||||||
|
target_path,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
leaves,
|
||||||
|
queued_tokens,
|
||||||
|
schema_changed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[{language}] {missing_strings}/{total_strings} strings pending "
|
||||||
|
f"across {len(pending)} files; reusable translations: {len(memory)}"
|
||||||
|
)
|
||||||
|
if args.check or args.dry_run:
|
||||||
|
continue
|
||||||
|
if args.max_files:
|
||||||
|
pending = pending[: args.max_files]
|
||||||
|
|
||||||
|
for index, (
|
||||||
|
source_path,
|
||||||
|
target_path,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
leaves,
|
||||||
|
queued_tokens,
|
||||||
|
schema_changed,
|
||||||
|
) in enumerate(pending, 1):
|
||||||
|
relative = source_path.relative_to(source_root)
|
||||||
|
relative_key = relative.as_posix()
|
||||||
|
suffix = " + schema sync" if schema_changed else ""
|
||||||
|
print(
|
||||||
|
f"[{language} {index}/{len(pending)}] {relative} "
|
||||||
|
f"({len(leaves)} strings{suffix})",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
built, failures, calls = translate_file(
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
leaves,
|
||||||
|
language,
|
||||||
|
memory,
|
||||||
|
translate,
|
||||||
|
args,
|
||||||
|
)
|
||||||
|
if failures:
|
||||||
|
total_failures += len(failures)
|
||||||
|
set_state_pending_tokens(state, language, relative_key, queued_tokens)
|
||||||
|
write_json(source_state_path, state)
|
||||||
|
print(
|
||||||
|
f" skipped atomically after {len(failures)} failures "
|
||||||
|
f"({calls} calls)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
for failure in failures[:5]:
|
||||||
|
print(f" - {failure}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
write_json(target_path, built)
|
||||||
|
set_state_pending_tokens(state, language, relative_key, set())
|
||||||
|
write_json(source_state_path, state)
|
||||||
|
total_written += 1
|
||||||
|
print(f" wrote {target_path} ({calls} provider calls)", flush=True)
|
||||||
|
|
||||||
|
if args.check or args.dry_run:
|
||||||
|
return 0
|
||||||
|
print(f"Completed: {total_written} files written; {total_failures} failed strings.")
|
||||||
|
return 1 if args.strict and total_failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,221 @@
|
|||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).parents[1] / "build_web_docs_i18n.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("build_web_docs_i18n", SCRIPT)
|
||||||
|
MODULE = importlib.util.module_from_spec(SPEC)
|
||||||
|
assert SPEC and SPEC.loader
|
||||||
|
sys.modules[SPEC.name] = MODULE
|
||||||
|
SPEC.loader.exec_module(MODULE)
|
||||||
|
|
||||||
|
|
||||||
|
class WebDocsI18nTests(unittest.TestCase):
|
||||||
|
def run_generator(self, root, provider):
|
||||||
|
source_root = root / "messages" / "en"
|
||||||
|
messages_root = root / "messages"
|
||||||
|
state_path = messages_root / ".docs-i18n-source-state.json"
|
||||||
|
argv = [
|
||||||
|
str(SCRIPT),
|
||||||
|
"--source-dir",
|
||||||
|
str(source_root),
|
||||||
|
"--messages-dir",
|
||||||
|
str(messages_root),
|
||||||
|
"--languages",
|
||||||
|
"de",
|
||||||
|
"--source-state",
|
||||||
|
str(state_path),
|
||||||
|
"--workers",
|
||||||
|
"1",
|
||||||
|
]
|
||||||
|
with mock.patch.object(sys, "argv", argv), mock.patch.object(
|
||||||
|
MODULE, "provider_function", return_value=provider
|
||||||
|
):
|
||||||
|
return MODULE.main()
|
||||||
|
|
||||||
|
def test_protected_contract_round_trip(self):
|
||||||
|
source = (
|
||||||
|
"Run <code>systemctl restart pveproxy</code> in Proxmox VE, "
|
||||||
|
"then open <link>Settings</link> at {host}."
|
||||||
|
)
|
||||||
|
protected, mapping = MODULE.protect_text(source)
|
||||||
|
self.assertIn("<code>systemctl restart pveproxy</code>", protected)
|
||||||
|
self.assertNotIn("Proxmox VE", protected)
|
||||||
|
self.assertIn("<code>", protected)
|
||||||
|
self.assertIn("</code>", protected)
|
||||||
|
self.assertIn("<link>", protected)
|
||||||
|
self.assertIn("</link>", protected)
|
||||||
|
self.assertNotIn("<code>", mapping.values())
|
||||||
|
self.assertNotIn("</code>", mapping.values())
|
||||||
|
self.assertNotIn("systemctl", mapping.values())
|
||||||
|
self.assertEqual(MODULE.restore_text(protected, mapping), source)
|
||||||
|
|
||||||
|
def test_human_translation_is_not_pending(self):
|
||||||
|
source = {"title": "Updates", "url": "https://example.com"}
|
||||||
|
target = {"title": "Actualizaciones", "url": "https://example.com"}
|
||||||
|
self.assertEqual(MODULE.pending_leaves(source, target, refresh=False), [])
|
||||||
|
|
||||||
|
def test_changed_source_text_is_pending_even_with_existing_translation(self):
|
||||||
|
source = {"title": "Updated installation guidance"}
|
||||||
|
target = {"title": "Vorherige Installationsanleitung"}
|
||||||
|
token = MODULE.leaf_token(("title",))
|
||||||
|
leaves = MODULE.pending_leaves(
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
refresh=False,
|
||||||
|
forced_tokens={token},
|
||||||
|
)
|
||||||
|
self.assertEqual([leaf.path for leaf in leaves], [("title",)])
|
||||||
|
|
||||||
|
def test_source_fingerprints_change_per_leaf(self):
|
||||||
|
before = MODULE.source_fingerprints({"title": "One", "body": "Same"})
|
||||||
|
after = MODULE.source_fingerprints({"title": "Two", "body": "Same"})
|
||||||
|
self.assertNotEqual(
|
||||||
|
before[MODULE.leaf_token(("title",))],
|
||||||
|
after[MODULE.leaf_token(("title",))],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
before[MODULE.leaf_token(("body",))],
|
||||||
|
after[MODULE.leaf_token(("body",))],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_schema_mismatch_detects_removed_keys(self):
|
||||||
|
self.assertFalse(
|
||||||
|
MODULE.schema_matches(
|
||||||
|
{"title": "Title"},
|
||||||
|
{"title": "Título", "removed": "Old"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_schema_mismatch_detects_changed_structural_values(self):
|
||||||
|
self.assertFalse(
|
||||||
|
MODULE.schema_matches(
|
||||||
|
{"enabled": True, "retries": 2},
|
||||||
|
{"enabled": False, "retries": 2},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_schema_allows_localized_string_values(self):
|
||||||
|
self.assertTrue(
|
||||||
|
MODULE.schema_matches(
|
||||||
|
{"title": "Updates"},
|
||||||
|
{"title": "Aktualisierungen"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_translatable_rich_tags_use_opaque_names(self):
|
||||||
|
source = (
|
||||||
|
"Keep <strong>this</strong>, <em>that</em> and "
|
||||||
|
"<code>systemctl restart pveproxy</code>."
|
||||||
|
)
|
||||||
|
protected, mapping = MODULE.protect_rich_tags(source)
|
||||||
|
self.assertIn("<pmxrich0000>this</pmxrich0000>", protected)
|
||||||
|
self.assertIn("<pmxrich0001>that</pmxrich0001>", protected)
|
||||||
|
self.assertIn("<code>systemctl restart pveproxy</code>", protected)
|
||||||
|
self.assertEqual(MODULE.restore_rich_tags(protected, mapping), source)
|
||||||
|
|
||||||
|
def test_source_schema_drives_output(self):
|
||||||
|
source = {"title": "Title", "items": ["One", "Two"]}
|
||||||
|
target = {
|
||||||
|
"title": "Título",
|
||||||
|
"items": ["Uno", "Dos", "Obsoleto"],
|
||||||
|
"removed": "Old",
|
||||||
|
}
|
||||||
|
translated = {
|
||||||
|
("items", 0): "Uno",
|
||||||
|
("items", 1): "Dos",
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
MODULE.merge_tree(source, target, translated),
|
||||||
|
{"title": "Título", "items": ["Uno", "Dos"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_translation_memory_ignores_conflicts(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
source_root = root / "messages" / "en"
|
||||||
|
target_root = root / "messages" / "es"
|
||||||
|
source_root.mkdir(parents=True)
|
||||||
|
target_root.mkdir(parents=True)
|
||||||
|
(source_root / "one.json").write_text(
|
||||||
|
json.dumps({"label": "Settings"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
(target_root / "one.json").write_text(
|
||||||
|
json.dumps({"label": "Ajustes"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
(source_root / "two.json").write_text(
|
||||||
|
json.dumps({"label": "Settings"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
(target_root / "two.json").write_text(
|
||||||
|
json.dumps({"label": "Configuración"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
memory = MODULE.collect_memory(source_root, root / "messages", "es")
|
||||||
|
self.assertNotIn("Settings", memory)
|
||||||
|
|
||||||
|
def test_source_state_retranslates_an_existing_changed_leaf(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
source_path = root / "messages" / "en" / "page.json"
|
||||||
|
target_path = root / "messages" / "de" / "page.json"
|
||||||
|
source_path.parent.mkdir(parents=True)
|
||||||
|
target_path.parent.mkdir(parents=True)
|
||||||
|
source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8")
|
||||||
|
target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.run_generator(root, lambda *_: self.fail("provider called during baseline")),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
self.assertEqual(read_json_file(target_path), {"title": "Ursprünglich"})
|
||||||
|
|
||||||
|
source_path.write_text(json.dumps({"title": "Changed"}), encoding="utf-8")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def provider(text, language):
|
||||||
|
calls.append((text, language))
|
||||||
|
return "Geändert"
|
||||||
|
|
||||||
|
self.assertEqual(self.run_generator(root, provider), 0)
|
||||||
|
self.assertEqual(calls, [("Changed", "de")])
|
||||||
|
self.assertEqual(read_json_file(target_path), {"title": "Geändert"})
|
||||||
|
|
||||||
|
def test_new_leaf_preserves_translation_supplied_with_source_change(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
source_path = root / "messages" / "en" / "page.json"
|
||||||
|
target_path = root / "messages" / "de" / "page.json"
|
||||||
|
source_path.parent.mkdir(parents=True)
|
||||||
|
target_path.parent.mkdir(parents=True)
|
||||||
|
source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8")
|
||||||
|
target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8")
|
||||||
|
self.assertEqual(self.run_generator(root, lambda *_: "unused"), 0)
|
||||||
|
|
||||||
|
source_path.write_text(
|
||||||
|
json.dumps({"title": "Original", "new": "New text"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
target_path.write_text(
|
||||||
|
json.dumps({"title": "Ursprünglich", "new": "Neuer Text"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.run_generator(root, lambda *_: self.fail("manual translation overwritten")),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
read_json_file(target_path),
|
||||||
|
{"title": "Ursprünglich", "new": "Neuer Text"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json_file(path):
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
name: Build i18n messages
|
||||||
|
|
||||||
|
# Auto-translate missing keys in AppImage/messages/<locale>/common.json
|
||||||
|
# against the English source whenever the source changes.
|
||||||
|
#
|
||||||
|
# The Monitor's i18n layer (AppImage/lib/i18n/provider.tsx) does its own
|
||||||
|
# runtime fallback (locale → en → key), so this workflow doesn't break
|
||||||
|
# anything if it misses a key: it just eliminates the visible-English
|
||||||
|
# blocks in non-en locales.
|
||||||
|
#
|
||||||
|
# Guardrails baked into build_i18n_messages.py:
|
||||||
|
# - Never overwrites a key whose target value differs from EN (i.e.
|
||||||
|
# already translated by a human). This is what makes it safe to
|
||||||
|
# include sk in the default set: Vaso73's curated strings are
|
||||||
|
# protected end-to-end; auto only fills keys still on the EN
|
||||||
|
# fallback.
|
||||||
|
# - `{placeholder}` tokens are protected end-to-end.
|
||||||
|
#
|
||||||
|
# Triggers:
|
||||||
|
# - push to develop touching AppImage/messages/en/common.json
|
||||||
|
# - manual via workflow_dispatch
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
paths:
|
||||||
|
# Only en/ triggers the run — it's the source of truth. Other
|
||||||
|
# locales are destinations, and the bot auto-commits them at
|
||||||
|
# the end of every run; if they were in the trigger too, each
|
||||||
|
# auto-commit would fire another (empty) run.
|
||||||
|
# Manual edits to a curated locale that empty a key for the
|
||||||
|
# workflow to refill are the exception — dispatch this
|
||||||
|
# workflow manually from Actions in that case, or piggy-back
|
||||||
|
# a trivial en/ change onto the commit.
|
||||||
|
- 'AppImage/messages/en/common.json'
|
||||||
|
- '.github/scripts/build_i18n_messages.py'
|
||||||
|
- '.github/workflows/build-i18n-messages.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
refresh:
|
||||||
|
description: 'Re-translate every key (overwrites human translations!)'
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
languages:
|
||||||
|
description: 'Comma-separated locales. Default: es,de,fr,it,pt,sk,sv (guardrail protects Vaso73 sk).'
|
||||||
|
default: 'es,de,fr,it,pt,sk,sv'
|
||||||
|
|
||||||
|
# Prevent two runs from racing on the same branch and fighting over the
|
||||||
|
# auto-commit. cancel-in-progress:false because a full first-run may take
|
||||||
|
# ~30 min for the initial bootstrap and interrupting mid-flight would
|
||||||
|
# waste the calls already made.
|
||||||
|
concurrency:
|
||||||
|
group: build-i18n-messages-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
translate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write # auto-commit AppImage/messages/*/common.json to develop
|
||||||
|
# First-run bootstrap of ~5 locales × 3.8k keys can take a while at
|
||||||
|
# 0.15s/call with rate-limit backoffs. 90 min headroom.
|
||||||
|
timeout-minutes: 90
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout develop
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: develop
|
||||||
|
# Full history so the auto-commit doesn't drift when another
|
||||||
|
# push landed between trigger and this job start.
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install googletrans
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
# Same pinning as build-translation-cache.yml so the two
|
||||||
|
# workflows share behavior. Bump both in lockstep.
|
||||||
|
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
|
||||||
|
|
||||||
|
- name: Translate missing keys
|
||||||
|
run: |
|
||||||
|
REFRESH_FLAG=""
|
||||||
|
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
|
||||||
|
REFRESH_FLAG="--refresh"
|
||||||
|
fi
|
||||||
|
LANGS="${{ github.event.inputs.languages }}"
|
||||||
|
# Keep this fallback in sync with the workflow_dispatch input
|
||||||
|
# default above AND with DEFAULT_LANGUAGES in the Python script —
|
||||||
|
# `push` triggers hit this branch (inputs are empty on push).
|
||||||
|
LANGS="${LANGS:-es,de,fr,it,pt,sk,sv}"
|
||||||
|
python .github/scripts/build_i18n_messages.py \
|
||||||
|
--source AppImage/messages/en/common.json \
|
||||||
|
--messages-dir AppImage/messages \
|
||||||
|
--languages "$LANGS" \
|
||||||
|
--provider googletrans \
|
||||||
|
$REFRESH_FLAG
|
||||||
|
|
||||||
|
- name: Commit + push if changed
|
||||||
|
run: |
|
||||||
|
if git diff --quiet -- AppImage/messages/; then
|
||||||
|
echo "No translation changes — skipping commit."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git config user.name "ProxMenuxBot"
|
||||||
|
git config user.email "bot@proxmenux.local"
|
||||||
|
git add AppImage/messages/
|
||||||
|
git commit -m "chore(i18n): auto-fill missing translations in messages/{locale}/common.json
|
||||||
|
|
||||||
|
Source: ${GITHUB_SHA::7}
|
||||||
|
Triggered by: ${{ github.event_name }}"
|
||||||
|
# Rebase-and-retry against develop: between the initial
|
||||||
|
# checkout and this push another workflow (e.g.
|
||||||
|
# build-translation-cache auto-commit) or a manual push can
|
||||||
|
# land on develop first, and a naked push fails with
|
||||||
|
# non-fast-forward — losing the freshly generated translation
|
||||||
|
# commit. Rebasing the local i18n commit on top of the newer
|
||||||
|
# HEAD is safe: paths don't overlap with cache/script
|
||||||
|
# workflows, and the same-branch collisions between two i18n
|
||||||
|
# runs are already blocked by the concurrency group above.
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
git fetch origin develop
|
||||||
|
if git rebase origin/develop && git push origin develop; then
|
||||||
|
echo "push succeeded on attempt ${attempt}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "push attempt ${attempt} failed, retrying..."
|
||||||
|
git rebase --abort 2>/dev/null || true
|
||||||
|
sleep $(( attempt * 3 ))
|
||||||
|
done
|
||||||
|
echo "push failed after 5 attempts"
|
||||||
|
exit 1
|
||||||
@@ -96,4 +96,23 @@ jobs:
|
|||||||
|
|
||||||
Source: ${GITHUB_SHA::7}
|
Source: ${GITHUB_SHA::7}
|
||||||
Triggered by: ${{ github.event_name }}"
|
Triggered by: ${{ github.event_name }}"
|
||||||
git push origin develop
|
# Rebase-and-retry against develop: between the initial
|
||||||
|
# checkout and this push another workflow (e.g.
|
||||||
|
# build-i18n-messages auto-commit) or a manual push can land
|
||||||
|
# on develop first, and a naked push fails with
|
||||||
|
# non-fast-forward — losing the freshly built cache commit.
|
||||||
|
# Paths don't overlap with the other workflows, and
|
||||||
|
# same-branch collisions between two cache runs are already
|
||||||
|
# blocked by the concurrency group.
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
git fetch origin develop
|
||||||
|
if git rebase origin/develop && git push origin develop; then
|
||||||
|
echo "push succeeded on attempt ${attempt}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "push attempt ${attempt} failed, retrying..."
|
||||||
|
git rebase --abort 2>/dev/null || true
|
||||||
|
sleep $(( attempt * 3 ))
|
||||||
|
done
|
||||||
|
echo "push failed after 5 attempts"
|
||||||
|
exit 1
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
name: Build web documentation translations
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
paths:
|
||||||
|
- 'web/messages/en/*.json'
|
||||||
|
- 'web/messages/en/**/*.json'
|
||||||
|
- '.github/scripts/build_web_docs_i18n.py'
|
||||||
|
- '.github/scripts/build_translation_cache.py'
|
||||||
|
- '.github/workflows/build-web-docs-i18n.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
languages:
|
||||||
|
description: 'Comma-separated locales'
|
||||||
|
default: 'es,de,fr,it,pt,sk,sv'
|
||||||
|
section:
|
||||||
|
description: 'File or directory below web/messages/en'
|
||||||
|
default: '.'
|
||||||
|
max_files:
|
||||||
|
description: 'Maximum pending files per locale; 0 means all'
|
||||||
|
default: '0'
|
||||||
|
refresh:
|
||||||
|
description: 'Overwrite existing translations in the selected scope'
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
dry_run:
|
||||||
|
description: 'Report pending coverage without writing files'
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-web-docs-i18n-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
translate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
timeout-minutes: 120
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout develop
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: develop
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install translation provider
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
|
||||||
|
|
||||||
|
- name: Build missing translations
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
LANGUAGES="${{ github.event.inputs.languages }}"
|
||||||
|
LANGUAGES="${LANGUAGES:-es,de,fr,it,pt,sk,sv}"
|
||||||
|
SECTION="${{ github.event.inputs.section }}"
|
||||||
|
SECTION="${SECTION:-.}"
|
||||||
|
MAX_FILES="${{ github.event.inputs.max_files }}"
|
||||||
|
MAX_FILES="${MAX_FILES:-0}"
|
||||||
|
|
||||||
|
EXTRA_ARGS=()
|
||||||
|
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
|
||||||
|
EXTRA_ARGS+=(--refresh)
|
||||||
|
fi
|
||||||
|
if [[ "${{ github.event.inputs.dry_run }}" == "true" ]]; then
|
||||||
|
EXTRA_ARGS+=(--dry-run)
|
||||||
|
fi
|
||||||
|
|
||||||
|
python .github/scripts/build_web_docs_i18n.py \
|
||||||
|
--source-dir web/messages/en \
|
||||||
|
--messages-dir web/messages \
|
||||||
|
--languages "$LANGUAGES" \
|
||||||
|
--section "$SECTION" \
|
||||||
|
--max-files "$MAX_FILES" \
|
||||||
|
--provider googletrans \
|
||||||
|
--workers 4 \
|
||||||
|
"${EXTRA_ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Validate catalogs
|
||||||
|
run: |
|
||||||
|
python .github/scripts/build_web_docs_i18n.py \
|
||||||
|
--source-dir web/messages/en \
|
||||||
|
--messages-dir web/messages \
|
||||||
|
--languages "${{ github.event.inputs.languages || 'es,de,fr,it,pt,sk,sv' }}" \
|
||||||
|
--check
|
||||||
|
|
||||||
|
- name: Commit and push changes
|
||||||
|
if: ${{ github.event.inputs.dry_run != 'true' }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if git diff --quiet -- web/messages/; then
|
||||||
|
echo "No documentation translations changed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git config user.name "ProxMenuxBot"
|
||||||
|
git config user.email "bot@proxmenux.local"
|
||||||
|
git add web/messages/
|
||||||
|
git commit -m "docs(i18n): update documentation translations"
|
||||||
|
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
git fetch origin develop
|
||||||
|
if git rebase origin/develop && git push origin develop; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git rebase --abort 2>/dev/null || true
|
||||||
|
sleep $((attempt * 3))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
name: Update App Tracking Hints
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Manual trigger from the Actions UI
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Re-merge whenever the generator, workflow, or the maintainer-
|
||||||
|
# curated runtime overrides change. `runtime_verified_overrides.json`
|
||||||
|
# is the file to edit when a real LXC reveals a canonical path the
|
||||||
|
# community-scripts helper doesn't ship (legacy /app/package.json,
|
||||||
|
# /opt/vaultwarden/bin/vaultwarden, etc.) — the generator folds it
|
||||||
|
# into the operational catalog every run.
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- ".github/scripts/generate_app_tracking_catalog.py"
|
||||||
|
- ".github/workflows/update-app-tracking-hints.yml"
|
||||||
|
- "json/runtime_verified_overrides.json"
|
||||||
|
|
||||||
|
# Regen every 6h — picks up new community-scripts LXC apps and
|
||||||
|
# detector-relevant script edits without needing a manual trigger.
|
||||||
|
schedule:
|
||||||
|
- cron: "0 */6 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-hints:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: ⬇️ Checkout the repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: 🐍 Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: ⚙️ Generate app_tracking_hints.generated.json (intermediate)
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# The generator writes 4 files; only `.generated.json` is
|
||||||
|
# consumed downstream by the merge step. The v2 catalog and
|
||||||
|
# per-app audit are useful for local review but not kept in
|
||||||
|
# the repo — written under /tmp so they never appear as
|
||||||
|
# dirty files here.
|
||||||
|
#
|
||||||
|
# `--runtime-overrides` folds real-CT evidence into the
|
||||||
|
# operational hints (canonical paths, cross-method fallbacks
|
||||||
|
# per app) so the runtime doesn't get fed helper-marker
|
||||||
|
# false-positives. Modern helper markers are included as their
|
||||||
|
# official version contract; runtime keeps them distinguishable from
|
||||||
|
# canonical package/binary/manual detectors for legacy compatibility.
|
||||||
|
run: |
|
||||||
|
python .github/scripts/generate_app_tracking_catalog.py \
|
||||||
|
--helpers-cache json/helpers_cache.json \
|
||||||
|
--existing json/app_tracking_hints.json \
|
||||||
|
--runtime-overrides json/runtime_verified_overrides.json \
|
||||||
|
--include-helper-markers \
|
||||||
|
--output json/app_tracking_hints.generated.json \
|
||||||
|
--v2-output /tmp/app_tracking_catalog.v2.json \
|
||||||
|
--audit-output /tmp/app_tracking_hints.audit.json
|
||||||
|
|
||||||
|
- name: 🧬 Smart-merge generated into app_tracking_hints.json
|
||||||
|
# Single source of truth: `app_tracking_hints.json` is the ONE
|
||||||
|
# file. It contains 3 kinds of entries:
|
||||||
|
# 1. Auto-verified from community-scripts (the generator
|
||||||
|
# manages every "generator-owned" field on these).
|
||||||
|
# 2. User-edited additions to those entries — extra fields
|
||||||
|
# the generator doesn't touch (default_ports,
|
||||||
|
# file_fallbacks, custom logo overrides…).
|
||||||
|
# 3. User-only entries the generator can't verify (Docker,
|
||||||
|
# AdGuard, Pi-hole, WireGuard, …) — left alone.
|
||||||
|
# Merge rule: for slugs the generator produces, refresh only
|
||||||
|
# the whitelisted fields; preserve everything else. For slugs
|
||||||
|
# NOT in the generator's output, keep the existing entry
|
||||||
|
# untouched.
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
GEN = Path("json/app_tracking_hints.generated.json")
|
||||||
|
OUT = Path("json/app_tracking_hints.json")
|
||||||
|
|
||||||
|
# Fields owned by the generator — refreshed on every run.
|
||||||
|
# These are all populated deterministically by the generator
|
||||||
|
# (the audit script folds `runtime_verified_overrides.json`
|
||||||
|
# in as it runs), so a local hand-edit for a generator-known
|
||||||
|
# slug would get overwritten on the next tick. To add a new
|
||||||
|
# canonical path or a cross-method fallback for a slug the
|
||||||
|
# generator already knows, edit `runtime_verified_overrides
|
||||||
|
# .json` — that file IS the maintainer-controlled input.
|
||||||
|
#
|
||||||
|
# For user-only slugs (Docker, WireGuard, Pi-hole and any
|
||||||
|
# other entry not in the generator's output) EVERY field is
|
||||||
|
# preserved verbatim by the merge below — the whitelist only
|
||||||
|
# governs generator-covered slugs.
|
||||||
|
GENERATOR_FIELDS = {
|
||||||
|
"installed_via", "package", "file_path", "file_regex",
|
||||||
|
"binary_path", "binary_args", "python_path", "distribution",
|
||||||
|
"container_name", "label", "command_argv", "installed_version",
|
||||||
|
"repo", "github_source", "tag_regex", "installed_regex",
|
||||||
|
# Upstream source discriminator + per-type fields
|
||||||
|
# (http_json + docker_hub). Kept in the whitelist so a
|
||||||
|
# curated entry in runtime_verified_overrides.json can
|
||||||
|
# supply them and the smart merge won't drop them on the
|
||||||
|
# next regeneration.
|
||||||
|
"upstream_type", "upstream_url", "upstream_json_path",
|
||||||
|
"docker_image",
|
||||||
|
"logo", "website",
|
||||||
|
"default_ports", "file_fallbacks", "alt_detectors",
|
||||||
|
}
|
||||||
|
|
||||||
|
generated = json.loads(GEN.read_text(encoding="utf-8"))
|
||||||
|
existing = {}
|
||||||
|
if OUT.is_file():
|
||||||
|
try:
|
||||||
|
existing = json.loads(OUT.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(existing, dict):
|
||||||
|
existing = {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
existing = {}
|
||||||
|
|
||||||
|
merged = {}
|
||||||
|
for slug, gen_entry in generated.items():
|
||||||
|
base = dict(existing.get(slug) or {})
|
||||||
|
# Refresh generator-owned fields (add/update).
|
||||||
|
for k, v in gen_entry.items():
|
||||||
|
if k in GENERATOR_FIELDS:
|
||||||
|
base[k] = v
|
||||||
|
# Drop generator-owned fields that the generator no
|
||||||
|
# longer emits for this slug (e.g. path renamed away).
|
||||||
|
for k in list(base):
|
||||||
|
if k in GENERATOR_FIELDS and k not in gen_entry:
|
||||||
|
del base[k]
|
||||||
|
merged[slug] = base
|
||||||
|
# Preserve user-only entries the generator can't verify.
|
||||||
|
for slug, entry in existing.items():
|
||||||
|
if slug not in generated and isinstance(entry, dict):
|
||||||
|
merged[slug] = dict(entry)
|
||||||
|
|
||||||
|
OUT.write_text(json.dumps(merged, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
added = sorted(set(generated) - set(existing))
|
||||||
|
removed = sorted(set(existing) - set(generated) - {s for s, e in existing.items() if not (
|
||||||
|
set(e.keys()) - GENERATOR_FIELDS
|
||||||
|
)})
|
||||||
|
print(f"merged: {len(merged)} entries "
|
||||||
|
f"(generated={len(generated)}, existing={len(existing)})")
|
||||||
|
if added:
|
||||||
|
print(f" new from generator: {len(added)}")
|
||||||
|
# Clean up the intermediate file so it doesn't get committed.
|
||||||
|
GEN.unlink()
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: 📤 Commit + push if changed
|
||||||
|
run: |
|
||||||
|
git config user.name "ProxMenuxBot"
|
||||||
|
git config user.email "bot@proxmenux.local"
|
||||||
|
git add json/app_tracking_hints.json
|
||||||
|
git diff --cached --quiet || git commit -m "Update app tracking hints"
|
||||||
|
git push
|
||||||
Binary file not shown.
@@ -1 +1 @@
|
|||||||
d3aedf08d50d332161a3c1f7f58f4f546af46ee2b7a4bd193ff6b2669b7cd5e8 ProxMenux-1.2.4.AppImage
|
6449a4e1126f428765d0bb9459342a079d2bc1d4c372b676d579f1c48a2d7d4a ProxMenux-1.2.5.AppImage
|
||||||
|
|||||||
@@ -201,6 +201,20 @@ After setting up your password, you can enable 2FA using any TOTP authenticator
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### Embedding in Trusted Iframes
|
||||||
|
|
||||||
|
By default, ProxMenux Monitor blocks embedding in iframes with `frame-ancestors 'none'` and `X-Frame-Options: DENY`.
|
||||||
|
|
||||||
|
If you run a trusted local portal or monitoring page and need to embed the Monitor, set `PROXMENUX_ALLOWED_FRAME_ANCESTORS` to the exact parent origins that may frame it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PROXMENUX_ALLOWED_FRAME_ANCESTORS="https://portal.example.com http://raspberrypi.local:8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
Only exact `http://` or `https://` origins are accepted. Paths, wildcards, broad schemes, credentials, and malformed values are ignored. When this setting is present, the Monitor sends a matching CSP `frame-ancestors` allowlist and omits `X-Frame-Options`, because that legacy header cannot express multiple allowed parents.
|
||||||
|
|
||||||
|
`ALLOWED_FRAME_ANCESTORS` is also accepted as a compatibility alias when `PROXMENUX_ALLOWED_FRAME_ANCESTORS` is not set.
|
||||||
|
|
||||||
### Security Best Practices for API Tokens
|
### Security Best Practices for API Tokens
|
||||||
|
|
||||||
**IMPORTANT**: Never hardcode your API tokens directly in configuration files or scripts. Instead, use environment variables or secrets management.
|
**IMPORTANT**: Never hardcode your API tokens directly in configuration files or scripts. Instead, use environment variables or secrets management.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { GeistMono } from "geist/font/mono"
|
|||||||
import { ThemeProvider } from "../components/theme-provider"
|
import { ThemeProvider } from "../components/theme-provider"
|
||||||
import { PwaRegister } from "../components/pwa-register"
|
import { PwaRegister } from "../components/pwa-register"
|
||||||
import { PwaInstallPrompt } from "../components/pwa-install-prompt"
|
import { PwaInstallPrompt } from "../components/pwa-install-prompt"
|
||||||
|
import { I18nProvider } from "../lib/i18n/provider"
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
|
||||||
@@ -43,13 +44,15 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-background text-foreground`}>
|
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-background text-foreground`}>
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Suspense fallback={null}>
|
||||||
|
<I18nProvider>
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
||||||
{children}
|
{children}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
<PwaInstallPrompt />
|
||||||
|
</I18nProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<PwaRegister />
|
<PwaRegister />
|
||||||
<PwaInstallPrompt />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { ProxmoxDashboard } from "../components/proxmox-dashboard"
|
|||||||
import { Login } from "../components/login"
|
import { Login } from "../components/login"
|
||||||
import { AuthSetup } from "../components/auth-setup"
|
import { AuthSetup } from "../components/auth-setup"
|
||||||
import { getApiUrl } from "../lib/api-config"
|
import { getApiUrl } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const t = useT()
|
||||||
const [authStatus, setAuthStatus] = useState<{
|
const [authStatus, setAuthStatus] = useState<{
|
||||||
loading: boolean
|
loading: boolean
|
||||||
authEnabled: boolean
|
authEnabled: boolean
|
||||||
@@ -113,8 +115,8 @@ export default function Home() {
|
|||||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-foreground">Loading...</div>
|
<div className="text-sm font-medium text-foreground">{t("app.loading")}</div>
|
||||||
<p className="text-xs text-muted-foreground">Connecting to ProxMenux Monitor</p>
|
<p className="text-xs text-muted-foreground">{t("app.connecting")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||||
import { APP_VERSION } from "./release-notes-modal"
|
import { APP_VERSION } from "./release-notes-modal"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
// Issue #191: a dedicated About tab. Centralises project metadata
|
// Issue #191: a dedicated About tab. Centralises project metadata
|
||||||
// (version, license, author) and every external link the project
|
// (version, license, author) and every external link the project
|
||||||
@@ -22,8 +23,8 @@ import { APP_VERSION } from "./release-notes-modal"
|
|||||||
// without re-cluttering the dashboard footer.
|
// without re-cluttering the dashboard footer.
|
||||||
|
|
||||||
interface LinkRow {
|
interface LinkRow {
|
||||||
label: string
|
labelKey: string
|
||||||
description: string
|
descriptionKey: string
|
||||||
href: string
|
href: string
|
||||||
Icon: React.ComponentType<{ className?: string }>
|
Icon: React.ComponentType<{ className?: string }>
|
||||||
accent?: keyof typeof ACCENT_CLASSES
|
accent?: keyof typeof ACCENT_CLASSES
|
||||||
@@ -42,29 +43,29 @@ const ACCENT_CLASSES = {
|
|||||||
|
|
||||||
const PROJECT_LINKS: LinkRow[] = [
|
const PROJECT_LINKS: LinkRow[] = [
|
||||||
{
|
{
|
||||||
label: "GitHub repository",
|
labelKey: "about.links.repository.label",
|
||||||
description: "Source code, releases and issue tracker.",
|
descriptionKey: "about.links.repository.description",
|
||||||
href: "https://github.com/MacRimi/ProxMenux",
|
href: "https://github.com/MacRimi/ProxMenux",
|
||||||
Icon: Github,
|
Icon: Github,
|
||||||
accent: "gray",
|
accent: "gray",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Documentation",
|
labelKey: "about.links.documentation.label",
|
||||||
description: "Full user guide for ProxMenux and the Monitor.",
|
descriptionKey: "about.links.documentation.description",
|
||||||
href: "https://proxmenux.com",
|
href: "https://proxmenux.com",
|
||||||
Icon: BookOpen,
|
Icon: BookOpen,
|
||||||
accent: "blue",
|
accent: "blue",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Discussions",
|
labelKey: "about.links.discussions.label",
|
||||||
description: "Ask questions, share custom AI prompts, swap ideas.",
|
descriptionKey: "about.links.discussions.description",
|
||||||
href: "https://github.com/MacRimi/ProxMenux/discussions",
|
href: "https://github.com/MacRimi/ProxMenux/discussions",
|
||||||
Icon: MessageSquare,
|
Icon: MessageSquare,
|
||||||
accent: "purple",
|
accent: "purple",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Report a bug or request a feature",
|
labelKey: "about.links.issues.label",
|
||||||
description: "Open an issue on GitHub — bugs, ideas, regressions.",
|
descriptionKey: "about.links.issues.description",
|
||||||
href: "https://github.com/MacRimi/ProxMenux/issues",
|
href: "https://github.com/MacRimi/ProxMenux/issues",
|
||||||
Icon: Bug,
|
Icon: Bug,
|
||||||
accent: "red",
|
accent: "red",
|
||||||
@@ -73,8 +74,8 @@ const PROJECT_LINKS: LinkRow[] = [
|
|||||||
|
|
||||||
const SUPPORT_LINKS: LinkRow[] = [
|
const SUPPORT_LINKS: LinkRow[] = [
|
||||||
{
|
{
|
||||||
label: "Support the project on Ko-fi",
|
labelKey: "about.links.support.label",
|
||||||
description: "ProxMenux is free and open source. Donations cover hosting and dev time.",
|
descriptionKey: "about.links.support.description",
|
||||||
href: "https://ko-fi.com/macrimi",
|
href: "https://ko-fi.com/macrimi",
|
||||||
Icon: Heart,
|
Icon: Heart,
|
||||||
accent: "pink",
|
accent: "pink",
|
||||||
@@ -82,6 +83,7 @@ const SUPPORT_LINKS: LinkRow[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
function LinkCard({ row }: { row: LinkRow }) {
|
function LinkCard({ row }: { row: LinkRow }) {
|
||||||
|
const t = useT()
|
||||||
const accentClass = ACCENT_CLASSES[row.accent ?? "blue"]
|
const accentClass = ACCENT_CLASSES[row.accent ?? "blue"]
|
||||||
// Style mirrors the PCI Devices cards in the Hardware tab: subtle
|
// Style mirrors the PCI Devices cards in the Hardware tab: subtle
|
||||||
// translucent background by default, slightly lighter on hover, no
|
// translucent background by default, slightly lighter on hover, no
|
||||||
@@ -101,16 +103,17 @@ function LinkCard({ row }: { row: LinkRow }) {
|
|||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||||
{row.label}
|
{t(row.labelKey)}
|
||||||
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{row.description}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{t(row.descriptionKey)}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function About() {
|
export function About() {
|
||||||
|
const t = useT()
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
{/* Hero — logo, name, version, one-line description. */}
|
{/* Hero — logo, name, version, one-line description. */}
|
||||||
@@ -120,7 +123,7 @@ export function About() {
|
|||||||
<div className="relative w-24 h-24 md:w-28 md:h-28 flex-shrink-0">
|
<div className="relative w-24 h-24 md:w-28 md:h-28 flex-shrink-0">
|
||||||
<Image
|
<Image
|
||||||
src="/images/proxmenux-logo.png"
|
src="/images/proxmenux-logo.png"
|
||||||
alt="ProxMenux logo"
|
alt={t("about.logoAlt")}
|
||||||
fill
|
fill
|
||||||
priority
|
priority
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
@@ -131,9 +134,7 @@ export function About() {
|
|||||||
ProxMenux Monitor
|
ProxMenux Monitor
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
A web dashboard and management layer for Proxmox VE — health monitoring,
|
{t("about.heroDescription")}
|
||||||
notifications, terminal, optimization tracker and more, packaged as a single
|
|
||||||
AppImage.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mt-3">
|
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mt-3">
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/30 px-2.5 py-1 text-xs font-mono">
|
<span className="inline-flex items-center gap-1.5 rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/30 px-2.5 py-1 text-xs font-mono">
|
||||||
@@ -151,7 +152,7 @@ export function About() {
|
|||||||
const href = isPrerelease
|
const href = isPrerelease
|
||||||
? "https://github.com/MacRimi/ProxMenux/releases"
|
? "https://github.com/MacRimi/ProxMenux/releases"
|
||||||
: "https://proxmenux.com/en/changelog"
|
: "https://proxmenux.com/en/changelog"
|
||||||
const label = isPrerelease ? "Release notes" : "Changelog"
|
const label = isPrerelease ? t("about.releaseNotes") : t("about.changelog")
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
@@ -175,9 +176,9 @@ export function About() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Github className="h-4 w-4 text-muted-foreground" />
|
<Github className="h-4 w-4 text-muted-foreground" />
|
||||||
Project
|
{t("about.project.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Repository, documentation and community channels.</CardDescription>
|
<CardDescription>{t("about.project.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
@@ -195,11 +196,10 @@ export function About() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Heart className="h-4 w-4 text-pink-500" />
|
<Heart className="h-4 w-4 text-pink-500" />
|
||||||
Support & License
|
{t("about.support.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
ProxMenux is free and open source under the GPL-3.0 license. If it's useful to
|
{t("about.support.description")}
|
||||||
you, a one-off contribution helps keep it that way.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -218,11 +218,11 @@ export function About() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||||
GPL-3.0 license
|
{t("about.license.label")}
|
||||||
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||||
Free software — see the LICENSE file for the full text.
|
{t("about.license.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,727 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import useSWR from "swr"
|
||||||
|
import { ArrowUpCircle, Check, ExternalLink, Pencil, Plus, Search } from "lucide-react"
|
||||||
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
import { ThemeAwareLogo } from "./lxc-app-panel"
|
||||||
|
import { CustomLinkEditor, type CustomLink, type GuestOption } from "./custom-link-editor"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
|
||||||
|
|
||||||
|
// ─── Local subset of /api/vms shape ─────────────────────────────
|
||||||
|
// Kept narrow on purpose — this component only needs what feeds a
|
||||||
|
// launcher card. Full VMData / LxcAppWatch types live in
|
||||||
|
// virtual-machines.tsx.
|
||||||
|
|
||||||
|
interface AppPort {
|
||||||
|
port: number
|
||||||
|
description?: string
|
||||||
|
scheme?: "http" | "https"
|
||||||
|
web_path?: string
|
||||||
|
logo_url?: string | null
|
||||||
|
category?: string
|
||||||
|
custom_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppWatch {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
logo_url?: string | null
|
||||||
|
ports?: AppPort[]
|
||||||
|
installed_version: string | null
|
||||||
|
latest_version: string | null
|
||||||
|
update_available: boolean | null
|
||||||
|
managed_oci_app_id?: string | null
|
||||||
|
helper_slug?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DockerImageUpdate {
|
||||||
|
reference: string
|
||||||
|
display_name?: string | null
|
||||||
|
used_by?: string[]
|
||||||
|
update_available: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the docker_inventory image whose lifecycle matches a given
|
||||||
|
// Web Link. The port's `description` is a user-typed label (e.g.
|
||||||
|
// "Paperless") so exact match on `used_by` (real container names
|
||||||
|
// like "paperless-webserver-1") almost never hits. Fall back through:
|
||||||
|
// 1. exact match in `used_by`
|
||||||
|
// 2. case-insensitive substring either way in `used_by`
|
||||||
|
// 3. substring in `display_name`
|
||||||
|
// 4. substring in `reference` (the full image path)
|
||||||
|
// Returns undefined when nothing matches — the caller treats that as
|
||||||
|
// "no upstream update signal for this port".
|
||||||
|
function findDockerImageForPort(
|
||||||
|
port: AppPort,
|
||||||
|
images: DockerImageUpdate[],
|
||||||
|
): DockerImageUpdate | undefined {
|
||||||
|
const desc = (port.description || "").trim().toLowerCase()
|
||||||
|
if (!desc || !images.length) return undefined
|
||||||
|
const exact = images.find((i) =>
|
||||||
|
(i.used_by || []).some((c) => c.toLowerCase() === desc),
|
||||||
|
)
|
||||||
|
if (exact) return exact
|
||||||
|
const inclUsedBy = images.find((i) =>
|
||||||
|
(i.used_by || []).some((c) => {
|
||||||
|
const cl = c.toLowerCase()
|
||||||
|
return cl.includes(desc) || desc.includes(cl)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (inclUsedBy) return inclUsedBy
|
||||||
|
const byDisplay = images.find((i) => {
|
||||||
|
const d = (i.display_name || "").toLowerCase()
|
||||||
|
return !!d && (d.includes(desc) || desc.includes(d))
|
||||||
|
})
|
||||||
|
if (byDisplay) return byDisplay
|
||||||
|
return images.find((i) => (i.reference || "").toLowerCase().includes(desc))
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VM {
|
||||||
|
vmid: number
|
||||||
|
name: string
|
||||||
|
ip?: string
|
||||||
|
type: string
|
||||||
|
app_watches?: AppWatch[]
|
||||||
|
docker_inventory?: { images?: DockerImageUpdate[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LaunchLink {
|
||||||
|
key: string
|
||||||
|
// Present for LXC-registered apps and for custom links with a
|
||||||
|
// guest binding. Absent when the link is an unbound custom entry
|
||||||
|
// (e.g. an external service).
|
||||||
|
vmid: number | null
|
||||||
|
guestType: "lxc" | "qemu" | null
|
||||||
|
ctName: string
|
||||||
|
appName: string
|
||||||
|
logoUrl: string | null
|
||||||
|
weblink: string
|
||||||
|
category: string
|
||||||
|
updateAvailable: boolean
|
||||||
|
// Set for user-defined custom links so the card can offer edit
|
||||||
|
// and delete actions in edit mode.
|
||||||
|
isCustom: boolean
|
||||||
|
customId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Same URL construction as the Web Link row in the App tab.
|
||||||
|
// Duplicated (small) on purpose — buildWebUrl in lxc-app-panel.tsx
|
||||||
|
// is scoped to that module, and copying keeps this component free of
|
||||||
|
// hidden cross-file dependencies. A per-port `custom_url` overrides
|
||||||
|
// the ip:port composition entirely — used for apps served behind a
|
||||||
|
// reverse-proxy domain.
|
||||||
|
function buildWebUrl(ip: string | undefined, port: AppPort): string | null {
|
||||||
|
const custom = (port.custom_url || "").trim()
|
||||||
|
if (custom) return custom
|
||||||
|
const raw = (ip || "").trim().split("/")[0]
|
||||||
|
if (!raw || raw === "DHCP" || !port?.port) return null
|
||||||
|
const host = raw.includes(":") && !raw.startsWith("[") ? `[${raw}]` : raw
|
||||||
|
const scheme = port.scheme || ([443, 8443, 9443].includes(port.port) ? "https" : "http")
|
||||||
|
const path = port.web_path ? `/${port.web_path.replace(/^\/+/, "")}` : ""
|
||||||
|
return `${scheme}://${host}:${port.port}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortMode = "name" | "ct" | "category"
|
||||||
|
const SORT_STORAGE_KEY = "proxmenux-apps-sort"
|
||||||
|
const ALL_CATEGORIES = "__all__"
|
||||||
|
|
||||||
|
const fetcher = async (url: string) => fetchApi(url)
|
||||||
|
|
||||||
|
// ─── Component ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function AppsDashboard() {
|
||||||
|
const t = useT()
|
||||||
|
const { data: vms } = useSWR<VM[]>("/api/vms", fetcher, { refreshInterval: 5000, revalidateOnFocus: false })
|
||||||
|
// Custom links persisted in /etc/proxmenux/custom_links.json. Small
|
||||||
|
// and rarely changes, so we don't poll on an interval — mutate() is
|
||||||
|
// called explicitly after create / update / delete.
|
||||||
|
const { data: customLinks, mutate: mutateCustomLinks } = useSWR<CustomLink[]>(
|
||||||
|
"/api/apps/custom-links", fetcher, { revalidateOnFocus: false },
|
||||||
|
)
|
||||||
|
// Category presets for the "+ Add link" modal.
|
||||||
|
const { data: categoryPresets } = useSWR<string[]>(
|
||||||
|
"/api/apps/categories", fetcher, { revalidateOnFocus: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
const isLightTheme = useIsLightTheme()
|
||||||
|
|
||||||
|
// Flatten VMs → LaunchLinks. One card per (app × port with weblink)
|
||||||
|
// for LXC-registered apps, plus one card per user-defined custom
|
||||||
|
// link. A custom link with a binding resolves its ctName from the
|
||||||
|
// matching guest in `vms` so renames stay in sync automatically.
|
||||||
|
const links = useMemo<LaunchLink[]>(() => {
|
||||||
|
const out: LaunchLink[] = []
|
||||||
|
const vmsList = Array.isArray(vms) ? vms : []
|
||||||
|
for (const vm of vmsList) {
|
||||||
|
const apps = vm.app_watches || []
|
||||||
|
if (!apps.length) continue
|
||||||
|
for (const app of apps) {
|
||||||
|
// Skip the synthetic entry ProxMenux inserts for managed
|
||||||
|
// OCI apps (Secure Gateway) — it has no user-assigned
|
||||||
|
// Web Link and doesn't belong in a launcher.
|
||||||
|
if (app.managed_oci_app_id) continue
|
||||||
|
// `app.update_available` refers to the app itself. For a
|
||||||
|
// Docker registration that app is the Docker engine, and its
|
||||||
|
// ports are containers running INSIDE Docker (Portainer,
|
||||||
|
// Frigate…) — each with an independent image update
|
||||||
|
// lifecycle in `vm.docker_inventory.images[]`. Propagating
|
||||||
|
// the engine-level flag to every container card would falsely
|
||||||
|
// mark Portainer/Frigate as updatable when only the engine
|
||||||
|
// needs bumping; missing the per-image flag would hide real
|
||||||
|
// Portainer/Frigate updates that ARE tracked in the App tab
|
||||||
|
// and fire notifications. Resolution: for each Docker port,
|
||||||
|
// find the image entry whose `used_by` includes the port's
|
||||||
|
// container name (== `port.description`) and use THAT image's
|
||||||
|
// update_available. Engine update stays out of the port cards
|
||||||
|
// — it belongs in the Updates tab.
|
||||||
|
const isDockerApp = app.helper_slug === "docker"
|
||||||
|
const dockerImages = vm.docker_inventory?.images || []
|
||||||
|
for (const port of app.ports || []) {
|
||||||
|
const url = buildWebUrl(vm.ip, port)
|
||||||
|
if (!url) continue
|
||||||
|
let updateAvailable = false
|
||||||
|
if (isDockerApp) {
|
||||||
|
const img = findDockerImageForPort(port, dockerImages)
|
||||||
|
updateAvailable = img?.update_available === true
|
||||||
|
} else {
|
||||||
|
updateAvailable = app.update_available === true
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
key: `lxc-${vm.vmid}-${app.id}-${port.port}`,
|
||||||
|
vmid: vm.vmid,
|
||||||
|
guestType: "lxc",
|
||||||
|
ctName: vm.name,
|
||||||
|
appName: (port.description || app.name || vm.name || "").trim(),
|
||||||
|
logoUrl: port.logo_url || app.logo_url || null,
|
||||||
|
weblink: url,
|
||||||
|
category: (port.category || "").trim(),
|
||||||
|
updateAvailable,
|
||||||
|
isCustom: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Merge user-defined custom links. Their `binding` decides how the
|
||||||
|
// CT/VM reference renders and where clicking it navigates.
|
||||||
|
const guestByVmid = new Map<number, { name: string; type: string }>()
|
||||||
|
for (const vm of vmsList) guestByVmid.set(vm.vmid, { name: vm.name, type: vm.type })
|
||||||
|
for (const link of customLinks || []) {
|
||||||
|
let ctName = ""
|
||||||
|
let vmid: number | null = null
|
||||||
|
let guestType: "lxc" | "qemu" | null = null
|
||||||
|
if (link.binding) {
|
||||||
|
const guest = guestByVmid.get(link.binding.vmid)
|
||||||
|
vmid = link.binding.vmid
|
||||||
|
guestType = link.binding.guest_type
|
||||||
|
ctName = guest?.name || ""
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
key: `custom-${link.id}`,
|
||||||
|
vmid,
|
||||||
|
guestType,
|
||||||
|
ctName,
|
||||||
|
appName: link.name,
|
||||||
|
logoUrl: link.logo_url || null,
|
||||||
|
weblink: link.url,
|
||||||
|
category: (link.category || "").trim(),
|
||||||
|
updateAvailable: false,
|
||||||
|
isCustom: true,
|
||||||
|
customId: link.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [vms, customLinks])
|
||||||
|
|
||||||
|
// Category list for the filter dropdown — built from the data so
|
||||||
|
// it always reflects reality (presets and custom-entered names).
|
||||||
|
const categoryCounts = useMemo(() => {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
for (const l of links) {
|
||||||
|
const key = l.category || t("apps.uncategorized")
|
||||||
|
map.set(key, (map.get(key) || 0) + 1)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [links, t])
|
||||||
|
const sortedCategoryEntries = useMemo(
|
||||||
|
() => Array.from(categoryCounts.entries()).sort((a, b) => a[0].localeCompare(b[0])),
|
||||||
|
[categoryCounts],
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Controls state ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [currentCat, setCurrentCat] = useState<string>(ALL_CATEGORIES)
|
||||||
|
const [sortMode, setSortMode] = useState<SortMode>("name")
|
||||||
|
const [searchExpanded, setSearchExpanded] = useState(false)
|
||||||
|
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
// Restore sort from localStorage on mount.
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(SORT_STORAGE_KEY)
|
||||||
|
if (saved === "name" || saved === "ct" || saved === "category") {
|
||||||
|
setSortMode(saved)
|
||||||
|
}
|
||||||
|
} catch (_) { /* private mode / storage disabled — silent */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Persist sort choice — only this one preference survives reload;
|
||||||
|
// category filter and search reset each visit so the dashboard
|
||||||
|
// always opens showing every app.
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem(SORT_STORAGE_KEY, sortMode) } catch (_) {}
|
||||||
|
}, [sortMode])
|
||||||
|
|
||||||
|
// Filter category resets if the user removes/renames the currently
|
||||||
|
// selected one and it disappears from the list.
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentCat === ALL_CATEGORIES) return
|
||||||
|
if (!categoryCounts.has(currentCat)) setCurrentCat(ALL_CATEGORIES)
|
||||||
|
}, [currentCat, categoryCounts])
|
||||||
|
|
||||||
|
// ─── Custom link editor state ──────────────────────────────────
|
||||||
|
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
|
const [editingLink, setEditingLink] = useState<CustomLink | null>(null)
|
||||||
|
const [editMode, setEditMode] = useState(false)
|
||||||
|
|
||||||
|
// Guest list feeds the binding dropdown in the editor modal.
|
||||||
|
const guestOptions = useMemo<GuestOption[]>(() => {
|
||||||
|
if (!Array.isArray(vms)) return []
|
||||||
|
return vms
|
||||||
|
.filter((v) => v.type === "lxc" || v.type === "qemu")
|
||||||
|
.map((v) => ({
|
||||||
|
vmid: v.vmid,
|
||||||
|
name: v.name,
|
||||||
|
type: v.type as "lxc" | "qemu",
|
||||||
|
}))
|
||||||
|
}, [vms])
|
||||||
|
|
||||||
|
const openNewLink = () => {
|
||||||
|
setEditingLink(null)
|
||||||
|
setEditorOpen(true)
|
||||||
|
}
|
||||||
|
const openEditForLink = (customId: string) => {
|
||||||
|
const found = (customLinks || []).find((l) => l.id === customId)
|
||||||
|
if (!found) return
|
||||||
|
setEditingLink(found)
|
||||||
|
setEditorOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Filter + sort ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
const uncatKey = t("apps.uncategorized")
|
||||||
|
let filtered = links
|
||||||
|
if (currentCat !== ALL_CATEGORIES) {
|
||||||
|
filtered = filtered.filter((l) => (l.category || uncatKey) === currentCat)
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
filtered = filtered.filter((l) =>
|
||||||
|
l.appName.toLowerCase().includes(q) ||
|
||||||
|
(l.ctName || "").toLowerCase().includes(q) ||
|
||||||
|
(l.vmid != null && String(l.vmid).includes(q)) ||
|
||||||
|
(l.category || "").toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const sorted = [...filtered]
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
if (sortMode === "name") return a.appName.localeCompare(b.appName)
|
||||||
|
if (sortMode === "ct") {
|
||||||
|
// Unbound custom links have no vmid; sort them after every
|
||||||
|
// bound entry, ordered alphabetically by app name.
|
||||||
|
if (a.vmid == null && b.vmid == null) return a.appName.localeCompare(b.appName)
|
||||||
|
if (a.vmid == null) return 1
|
||||||
|
if (b.vmid == null) return -1
|
||||||
|
return (a.vmid - b.vmid) || a.appName.localeCompare(b.appName)
|
||||||
|
}
|
||||||
|
// category — grouped alphabetically, then by app name inside
|
||||||
|
const catA = a.category || uncatKey
|
||||||
|
const catB = b.category || uncatKey
|
||||||
|
const c = catA.localeCompare(catB)
|
||||||
|
return c !== 0 ? c : a.appName.localeCompare(b.appName)
|
||||||
|
})
|
||||||
|
return sorted
|
||||||
|
}, [links, query, currentCat, sortMode, t])
|
||||||
|
|
||||||
|
// ─── Empty state ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const hasAnyData = links.length > 0 || (customLinks && customLinks.length > 0)
|
||||||
|
if (vms && !hasAnyData) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="text-center text-muted-foreground py-16">
|
||||||
|
<div className="text-sm">{t("apps.emptyTitle")}</div>
|
||||||
|
<div className="text-xs mt-1 opacity-80">{t("apps.emptyHint")}</div>
|
||||||
|
<Button
|
||||||
|
onClick={openNewLink}
|
||||||
|
variant="outline"
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
|
{t("apps.customLinkAdd")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CustomLinkEditor
|
||||||
|
open={editorOpen}
|
||||||
|
onOpenChange={setEditorOpen}
|
||||||
|
editing={editingLink}
|
||||||
|
guests={guestOptions}
|
||||||
|
categoryPresets={categoryPresets || []}
|
||||||
|
onSaved={() => mutateCustomLinks()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Render ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const countLabel = shown.length === 1
|
||||||
|
? t("apps.countOne")
|
||||||
|
: t("apps.countMany", { n: shown.length })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Search — icon-only until tapped on narrow screens */}
|
||||||
|
<div className={`relative ${searchExpanded ? "flex-1 min-w-full sm:min-w-0 sm:flex-none sm:w-72" : "sm:flex-1 sm:min-w-40 sm:max-w-xs"}`}>
|
||||||
|
{!searchExpanded && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchExpanded(true)
|
||||||
|
requestAnimationFrame(() => searchInputRef.current?.focus())
|
||||||
|
}}
|
||||||
|
className="sm:hidden inline-flex items-center justify-center w-9 h-9 rounded-md border border-border bg-card text-muted-foreground hover:text-foreground hover:border-border/80 transition-colors"
|
||||||
|
aria-label={t("apps.searchAriaLabel")}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className={`relative ${searchExpanded ? "block" : "hidden sm:block"}`}>
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onBlur={() => { if (!query.trim()) setSearchExpanded(false) }}
|
||||||
|
placeholder={t("apps.searchPlaceholder")}
|
||||||
|
aria-label={t("apps.searchAriaLabel")}
|
||||||
|
className="w-full h-9 pl-8 pr-3 text-sm bg-card border border-border rounded-md text-foreground placeholder:text-muted-foreground focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category filter */}
|
||||||
|
<select
|
||||||
|
value={currentCat}
|
||||||
|
onChange={(e) => setCurrentCat(e.target.value)}
|
||||||
|
aria-label={t("apps.filterAriaLabel")}
|
||||||
|
className="h-9 pl-3 pr-8 text-sm bg-card border border-border rounded-md text-foreground appearance-none cursor-pointer max-w-[9.5rem] sm:max-w-none truncate focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring bg-no-repeat bg-[right_0.6rem_center]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>\")",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={ALL_CATEGORIES}>{t("apps.filterAll")}</option>
|
||||||
|
{sortedCategoryEntries.map(([cat, n]) => (
|
||||||
|
<option key={cat} value={cat}>{`${cat} · ${n}`}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<select
|
||||||
|
value={sortMode}
|
||||||
|
onChange={(e) => setSortMode(e.target.value as SortMode)}
|
||||||
|
aria-label={t("apps.sortAriaLabel")}
|
||||||
|
className="h-9 pl-3 pr-8 text-sm bg-card border border-border rounded-md text-foreground appearance-none cursor-pointer max-w-[8rem] sm:max-w-none truncate focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring bg-no-repeat bg-[right_0.6rem_center]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>\")",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="name">{t("apps.sortName")}</option>
|
||||||
|
<option value="ct">{t("apps.sortId")}</option>
|
||||||
|
<option value="category">{t("apps.sortCategory")}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Count — desktop only. Mobile gives the horizontal room to
|
||||||
|
the + button instead so everything stays on one line. */}
|
||||||
|
<span className="ml-auto hidden sm:inline-flex items-center h-9 px-3 text-xs text-muted-foreground rounded-md bg-card border border-border font-mono tabular-nums">
|
||||||
|
{countLabel}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* + Add custom link. Icon-only on mobile (mirrors the search
|
||||||
|
icon-toggle pattern) so the toolbar fits in one line even
|
||||||
|
in the narrowest viewport. On desktop shows label + icon. */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={openNewLink}
|
||||||
|
className="sm:ml-2 ml-auto h-9 px-2.5 sm:px-3 flex-shrink-0"
|
||||||
|
aria-label={t("apps.customLinkAdd")}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 sm:mr-1.5" />
|
||||||
|
<span className="hidden sm:inline">{t("apps.customLinkAdd")}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Edit mode toggle — only shown when at least one custom link
|
||||||
|
exists, since it's the only card type that carries per-card
|
||||||
|
edit/delete actions. LXC-registered apps are edited in the
|
||||||
|
LXC App tab of their guest modal. */}
|
||||||
|
{(customLinks && customLinks.length > 0) && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEditMode((v) => !v)}
|
||||||
|
className={`h-9 px-2.5 sm:px-3 flex-shrink-0 ${editMode ? "border-blue-500/60 text-blue-400" : ""}`}
|
||||||
|
aria-pressed={editMode}
|
||||||
|
aria-label={t("apps.editModeToggle")}
|
||||||
|
>
|
||||||
|
{editMode ? <Check className="h-4 w-4 sm:mr-1.5" /> : <Pencil className="h-4 w-4 sm:mr-1.5" />}
|
||||||
|
<span className="hidden sm:inline">{editMode ? t("apps.editModeDone") : t("apps.editModeToggle")}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid — grouped headers when sorted by category */}
|
||||||
|
<CardsGrid
|
||||||
|
links={shown}
|
||||||
|
grouped={sortMode === "category"}
|
||||||
|
uncategorizedLabel={t("apps.uncategorized")}
|
||||||
|
openLabel={t("apps.openAriaLabel")}
|
||||||
|
isLightTheme={isLightTheme}
|
||||||
|
editMode={editMode}
|
||||||
|
onEditCustom={openEditForLink}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomLinkEditor
|
||||||
|
open={editorOpen}
|
||||||
|
onOpenChange={setEditorOpen}
|
||||||
|
editing={editingLink}
|
||||||
|
guests={guestOptions}
|
||||||
|
categoryPresets={categoryPresets || []}
|
||||||
|
onSaved={() => mutateCustomLinks()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cards grid + card ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function CardsGrid({
|
||||||
|
links,
|
||||||
|
grouped,
|
||||||
|
uncategorizedLabel,
|
||||||
|
openLabel,
|
||||||
|
isLightTheme,
|
||||||
|
editMode,
|
||||||
|
onEditCustom,
|
||||||
|
}: {
|
||||||
|
links: LaunchLink[]
|
||||||
|
grouped: boolean
|
||||||
|
uncategorizedLabel: string
|
||||||
|
openLabel: string
|
||||||
|
isLightTheme: boolean
|
||||||
|
editMode: boolean
|
||||||
|
onEditCustom: (customId: string) => void
|
||||||
|
}) {
|
||||||
|
if (!links.length) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
|
<div className="col-span-full text-center text-muted-foreground text-sm py-8">
|
||||||
|
{/* No results after filter/search */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grouped) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
|
{links.map((link) => (
|
||||||
|
<AppCard key={link.key} link={link} openLabel={openLabel} isLightTheme={isLightTheme} editMode={editMode} onEditCustom={onEditCustom} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by category, insert header rows spanning the full grid width.
|
||||||
|
const groups: Array<[string, LaunchLink[]]> = []
|
||||||
|
let currentCat: string | null = null
|
||||||
|
let bucket: LaunchLink[] = []
|
||||||
|
for (const link of links) {
|
||||||
|
const cat = link.category || uncategorizedLabel
|
||||||
|
if (cat !== currentCat) {
|
||||||
|
if (bucket.length) groups.push([currentCat!, bucket])
|
||||||
|
currentCat = cat
|
||||||
|
bucket = []
|
||||||
|
}
|
||||||
|
bucket.push(link)
|
||||||
|
}
|
||||||
|
if (bucket.length) groups.push([currentCat!, bucket])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
|
{groups.map(([cat, items]) => (
|
||||||
|
<div key={`grp-${cat}`} className="contents">
|
||||||
|
<h3 className="col-span-full uppercase text-xs tracking-wider text-muted-foreground font-semibold pt-3 pb-1.5 border-b border-border/60 flex items-center gap-2">
|
||||||
|
<span>{cat}</span>
|
||||||
|
<span className="font-mono tabular-nums text-[10px] px-1.5 py-0.5 rounded bg-card border border-border/60 font-normal">{items.length}</span>
|
||||||
|
</h3>
|
||||||
|
{items.map((link) => (
|
||||||
|
<AppCard key={link.key} link={link} openLabel={openLabel} isLightTheme={isLightTheme} editMode={editMode} onEditCustom={onEditCustom} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hueForCategory / categoryChipStyle / readIsLightTheme moved to
|
||||||
|
// lib/category-color.ts so the LXC App tab can render the same chip.
|
||||||
|
|
||||||
|
// Dispatch the pair of events that jumps from the Apps dashboard to
|
||||||
|
// the VMs modal on the App tab for a given CT. Two events by design:
|
||||||
|
// `changeTab` switches the outer tab (dashboard-level) and
|
||||||
|
// `openLxcAppModal` tells VirtualMachines which guest to open and on
|
||||||
|
// which inner tab to land. Both fire in the same tick.
|
||||||
|
function openLxcModalOnAppTab(vmid: number) {
|
||||||
|
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
|
||||||
|
window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same pattern for a QEMU guest: land on the modal's Status tab
|
||||||
|
// (QEMU guests don't have the App tab). Used by custom links whose
|
||||||
|
// binding is a VM instead of an LXC.
|
||||||
|
function openVmModalOnStatusTab(vmid: number) {
|
||||||
|
window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } }))
|
||||||
|
window.dispatchEvent(new CustomEvent("openVmStatusModal", { detail: { vmid } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppCard({
|
||||||
|
link,
|
||||||
|
openLabel,
|
||||||
|
isLightTheme,
|
||||||
|
editMode,
|
||||||
|
onEditCustom,
|
||||||
|
}: {
|
||||||
|
link: LaunchLink
|
||||||
|
openLabel: string
|
||||||
|
isLightTheme: boolean
|
||||||
|
editMode: boolean
|
||||||
|
onEditCustom: (customId: string) => void
|
||||||
|
}) {
|
||||||
|
const t = useT()
|
||||||
|
// Navigate to the bound guest's modal on the appropriate inner tab:
|
||||||
|
// LXC → App tab (where the weblink was registered), VM → Status tab
|
||||||
|
// (VMs don't have an App tab). Unbound custom links have no CT ref
|
||||||
|
// to click, so this handler is only wired when `link.vmid` exists.
|
||||||
|
const goToBoundGuest = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (link.vmid == null) return
|
||||||
|
if (link.guestType === "qemu") {
|
||||||
|
openVmModalOnStatusTab(link.vmid)
|
||||||
|
} else {
|
||||||
|
openLxcModalOnAppTab(link.vmid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToEditor = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (link.customId) onEditCustom(link.customId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const guestPrefix = link.guestType === "qemu" ? "VM" : "CT"
|
||||||
|
const hasBinding = link.vmid != null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={link.weblink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={openLabel.replace("{name}", link.appName)}
|
||||||
|
className="group relative flex flex-col gap-2 p-3.5 bg-card border border-border rounded-xl no-underline text-foreground hover:bg-white/5 hover:border-border/80 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:translate-y-px"
|
||||||
|
>
|
||||||
|
{/* Head: logo + name (+ update icon) */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-14 h-14 rounded-md bg-muted/40 grid place-items-center flex-shrink-0 overflow-hidden">
|
||||||
|
{link.logoUrl ? (
|
||||||
|
<ThemeAwareLogo src={link.logoUrl} className="w-9 h-9 object-contain" />
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground uppercase">{link.appName.slice(0, 2)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-base font-semibold text-foreground truncate leading-tight">{link.appName}</div>
|
||||||
|
</div>
|
||||||
|
{/* Edit mode on a custom card takes over the update-icon slot
|
||||||
|
with a proper edit button — custom links never carry the
|
||||||
|
update signal, so nothing is displaced. Falls back to the
|
||||||
|
update icon in every other case. */}
|
||||||
|
{editMode && link.isCustom ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goToEditor}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToEditor(e) }}
|
||||||
|
className="h-8 w-8 rounded-md border border-border bg-background hover:bg-muted flex items-center justify-center flex-shrink-0 self-start text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
aria-label={t("apps.customLinkEditAria", { name: link.appName })}
|
||||||
|
title={t("actions.edit")}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : link.updateAvailable && (
|
||||||
|
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0 self-start mt-0.5" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Foot: weblink + CT ref + category chip */}
|
||||||
|
<div className="flex flex-col gap-1.5 mt-auto pt-2 border-t border-dashed border-border/60">
|
||||||
|
<div className="flex items-center gap-1.5 text-blue-400 text-sm font-mono truncate">
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0 opacity-80" />
|
||||||
|
<span className="truncate">{link.weblink}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||||
|
{/* Guest ref → click opens that guest's modal (LXC → App
|
||||||
|
tab, VM → Status tab). stopPropagation keeps the outer
|
||||||
|
anchor from firing at the same time. Unbound custom
|
||||||
|
links: in edit mode show the edit button here, otherwise
|
||||||
|
show nothing. */}
|
||||||
|
{hasBinding && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goToBoundGuest}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") goToBoundGuest(e) }}
|
||||||
|
className="inline-flex items-center gap-1.5 min-w-0 rounded hover:text-blue-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer"
|
||||||
|
aria-label={t("apps.openGuestAriaLabel", {
|
||||||
|
name: link.ctName || String(link.vmid),
|
||||||
|
type: guestPrefix,
|
||||||
|
id: link.vmid!,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span className="font-mono px-1.5 py-0.5 rounded bg-muted/40 text-muted-foreground/90 flex-shrink-0">{link.vmid}</span>
|
||||||
|
<span className="truncate min-w-0">{link.ctName || `${guestPrefix} ${link.vmid}`}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{link.category && (
|
||||||
|
<span
|
||||||
|
style={categoryChipStyle(link.category, isLightTheme)}
|
||||||
|
className="ml-auto px-1.5 py-0.5 border rounded text-[10px] font-medium flex-shrink-0 truncate max-w-[45%]"
|
||||||
|
title={link.category}
|
||||||
|
>
|
||||||
|
{link.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,12 +7,14 @@ import { Input } from "./ui/input"
|
|||||||
import { Label } from "./ui/label"
|
import { Label } from "./ui/label"
|
||||||
import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react"
|
import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react"
|
||||||
import { getApiUrl } from "../lib/api-config"
|
import { getApiUrl } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface AuthSetupProps {
|
interface AuthSetupProps {
|
||||||
onComplete: () => void
|
onComplete: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthSetup({ onComplete }: AuthSetupProps) {
|
export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||||
|
const t = useT()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [step, setStep] = useState<"choice" | "setup">("choice")
|
const [step, setStep] = useState<"choice" | "setup">("choice")
|
||||||
const [username, setUsername] = useState("")
|
const [username, setUsername] = useState("")
|
||||||
@@ -74,7 +76,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || "Failed to skip authentication")
|
throw new Error(data.error || t("authSetup.skipFailed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.auth_declined) {
|
if (data.auth_declined) {
|
||||||
@@ -86,7 +88,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
onComplete()
|
onComplete()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Auth skip error:", err)
|
console.error("Auth skip error:", err)
|
||||||
setError(err instanceof Error ? err.message : "Failed to save preference")
|
setError(err instanceof Error ? err.message : t("authSetup.savePreferenceFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -108,17 +110,17 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
setError("Please fill in all fields")
|
setError(t("authSetup.fillFields"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError("Passwords do not match")
|
setError(t("authSetup.passwordMismatch"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 6) {
|
||||||
setError("Password must be at least 6 characters")
|
setError(t("authSetup.passwordTooShort"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +139,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || "Failed to setup authentication")
|
throw new Error(data.error || t("authSetup.setupFailed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
@@ -204,7 +206,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
onComplete()
|
onComplete()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Auth setup error:", err)
|
console.error("Auth setup error:", err)
|
||||||
setError(err instanceof Error ? err.message : "Failed to setup authentication")
|
setError(err instanceof Error ? err.message : t("authSetup.setupFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -214,7 +216,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
<DialogTitle className="sr-only">
|
<DialogTitle className="sr-only">
|
||||||
{step === "choice" ? "Setup Dashboard Protection" : "Create Password"}
|
{step === "choice" ? t("authSetup.choiceTitle") : t("authSetup.passwordTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
{step === "choice" ? (
|
{step === "choice" ? (
|
||||||
<div className="space-y-6 py-2">
|
<div className="space-y-6 py-2">
|
||||||
@@ -222,16 +224,16 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
|
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
|
||||||
<Shield className="h-8 w-8 text-blue-500" />
|
<Shield className="h-8 w-8 text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold">Protect Your Dashboard?</h2>
|
<h2 className="text-2xl font-bold">{t("authSetup.protectTitle")}</h2>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
|
{t("authSetup.protectDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
|
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
|
||||||
<Lock className="h-4 w-4 mr-2" />
|
<Lock className="h-4 w-4 mr-2" />
|
||||||
Yes, Setup Password
|
{t("authSetup.setupPassword")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSkipAuth}
|
onClick={handleSkipAuth}
|
||||||
@@ -240,11 +242,11 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
size="lg"
|
size="lg"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
No, Continue Without Protection
|
{t("authSetup.skipProtection")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-center text-muted-foreground">You can always enable this later in Settings</p>
|
<p className="text-xs text-center text-muted-foreground">{t("authSetup.enableLater")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6 py-2">
|
<div className="space-y-6 py-2">
|
||||||
@@ -252,8 +254,8 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
|
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
|
||||||
<Lock className="h-8 w-8 text-blue-500" />
|
<Lock className="h-8 w-8 text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold">Setup Authentication</h2>
|
<h2 className="text-2xl font-bold">{t("authSetup.setupTitle")}</h2>
|
||||||
<p className="text-muted-foreground text-sm">Create a username and password to protect your dashboard</p>
|
<p className="text-muted-foreground text-sm">{t("authSetup.setupDescription")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -266,14 +268,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username" className="text-sm">
|
<Label htmlFor="username" className="text-sm">
|
||||||
Username
|
{t("authSetup.username")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter username"
|
placeholder={t("authSetup.usernamePlaceholder")}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className="pl-10 text-base"
|
className="pl-10 text-base"
|
||||||
@@ -285,14 +287,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password" className="text-sm">
|
<Label htmlFor="password" className="text-sm">
|
||||||
Password
|
{t("authSetup.password")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder="Enter password"
|
placeholder={t("authSetup.passwordPlaceholder")}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="pl-10 text-base"
|
className="pl-10 text-base"
|
||||||
@@ -312,14 +314,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirm-password" className="text-sm">
|
<Label htmlFor="confirm-password" className="text-sm">
|
||||||
Confirm Password
|
{t("authSetup.confirmPassword")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="confirm-password"
|
id="confirm-password"
|
||||||
type={showConfirmPassword ? "text" : "password"}
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
placeholder="Confirm password"
|
placeholder={t("authSetup.confirmPasswordPlaceholder")}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
className="pl-10 text-base"
|
className="pl-10 text-base"
|
||||||
@@ -345,19 +347,19 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
setup endpoint returns the JWT. */}
|
setup endpoint returns the JWT. */}
|
||||||
<div className="pt-3 border-t border-border/60 space-y-4">
|
<div className="pt-3 border-t border-border/60 space-y-4">
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||||
Profile · optional
|
{t("authSetup.profileOptional")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="display-name" className="text-sm">
|
<Label htmlFor="display-name" className="text-sm">
|
||||||
Display name
|
{t("authSetup.displayName")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="display-name"
|
id="display-name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Shown above the username in the menu"
|
placeholder={t("authSetup.displayNamePlaceholder")}
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
maxLength={64}
|
maxLength={64}
|
||||||
@@ -366,12 +368,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Leave empty to render the username itself. Up to 64 characters.
|
{t("authSetup.displayNameHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm">Avatar</Label>
|
<Label className="text-sm">{t("authSetup.avatar")}</Label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{avatarPreviewUrl ? (
|
{avatarPreviewUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@@ -407,7 +409,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
>
|
>
|
||||||
<Upload className="h-3 w-3 mr-1.5" />
|
<Upload className="h-3 w-3 mr-1.5" />
|
||||||
{avatarFile ? "Change" : "Choose image"}
|
{avatarFile ? t("authSetup.change") : t("authSetup.chooseImage")}
|
||||||
</Button>
|
</Button>
|
||||||
{avatarFile && (
|
{avatarFile && (
|
||||||
<Button
|
<Button
|
||||||
@@ -419,12 +421,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
className="h-7 text-xs text-red-500 hover:text-red-500 hover:bg-red-500/10"
|
className="h-7 text-xs text-red-500 hover:text-red-500 hover:bg-red-500/10"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3 mr-1.5" />
|
<Trash2 className="h-3 w-3 mr-1.5" />
|
||||||
Clear
|
{t("authSetup.clear")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results.
|
{t("authSetup.avatarHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -434,10 +436,10 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||||
{loading ? "Setting up..." : "Setup Authentication"}
|
{loading ? t("authSetup.settingUp") : t("authSetup.setupAuthentication")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
|
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
|
||||||
Back
|
{t("authSetup.back")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "./ui/dropdown-menu"
|
} from "./ui/dropdown-menu"
|
||||||
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface AuthStatus {
|
interface AuthStatus {
|
||||||
auth_enabled?: boolean
|
auth_enabled?: boolean
|
||||||
@@ -57,6 +58,8 @@ interface AvatarMenuProps {
|
|||||||
* proper /api/auth/logout that revokes the JWT server-side too.
|
* proper /api/auth/logout that revokes the JWT server-side too.
|
||||||
*/
|
*/
|
||||||
export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) {
|
export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
// IMPORTANT — all hooks must run unconditionally on every render. The
|
// IMPORTANT — all hooks must run unconditionally on every render. The
|
||||||
// previous version short-circuited with `if (!auth_enabled) return null`
|
// previous version short-circuited with `if (!auth_enabled) return null`
|
||||||
// BEFORE the avatar blob hooks, so the hook count changed between
|
// BEFORE the avatar blob hooks, so the hook count changed between
|
||||||
@@ -201,7 +204,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="rounded-full hover:ring-2 hover:ring-cyan-500/30 transition-all relative z-50 focus:outline-none focus-visible:outline-none active:outline-none data-[state=open]:outline-none data-[state=open]:ring-0 select-none"
|
className="rounded-full hover:ring-2 hover:ring-cyan-500/30 transition-all relative z-50 focus:outline-none focus-visible:outline-none active:outline-none data-[state=open]:outline-none data-[state=open]:ring-0 select-none"
|
||||||
aria-label="Open user menu"
|
aria-label={t("actions.openUserMenu")}
|
||||||
// WebKit ignores `outline` for the tap-highlight overlay
|
// WebKit ignores `outline` for the tap-highlight overlay
|
||||||
// shown on iOS / Android Chrome after a touch. That overlay
|
// shown on iOS / Android Chrome after a touch. That overlay
|
||||||
// was the white border that lingered on the avatar after
|
// was the white border that lingered on the avatar after
|
||||||
@@ -248,7 +251,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
|||||||
<div className="text-xs text-muted-foreground truncate">{username}</div>
|
<div className="text-xs text-muted-foreground truncate">{username}</div>
|
||||||
)}
|
)}
|
||||||
{!profile?.display_name && (
|
{!profile?.display_name && (
|
||||||
<div className="text-xs text-muted-foreground truncate">Signed in</div>
|
<div className="text-xs text-muted-foreground truncate">{t("account.signedIn")}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,13 +260,13 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
|||||||
{onOpenProfile && (
|
{onOpenProfile && (
|
||||||
<DropdownMenuItem onClick={onOpenProfile}>
|
<DropdownMenuItem onClick={onOpenProfile}>
|
||||||
<User className="h-4 w-4 mr-2" />
|
<User className="h-4 w-4 mr-2" />
|
||||||
View profile
|
{t("account.viewProfile")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{onOpenSecurity && (
|
{onOpenSecurity && (
|
||||||
<DropdownMenuItem onClick={onOpenSecurity}>
|
<DropdownMenuItem onClick={onOpenSecurity}>
|
||||||
<Shield className="h-4 w-4 mr-2" />
|
<Shield className="h-4 w-4 mr-2" />
|
||||||
Security
|
{t("account.security")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />}
|
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />}
|
||||||
@@ -272,7 +275,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
|||||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4 mr-2" />
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
Sign out
|
{t("account.signOut")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Trash2 } from "lucide-react"
|
||||||
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { Input } from "./ui/input"
|
||||||
|
import { Label } from "./ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||||
|
|
||||||
|
// Minimal shape we need from the /api/vms poll. Kept narrow so this
|
||||||
|
// component stays independent of the fuller VMData type used in
|
||||||
|
// virtual-machines.tsx.
|
||||||
|
export interface GuestOption {
|
||||||
|
vmid: number
|
||||||
|
name: string
|
||||||
|
type: "lxc" | "qemu"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomLink {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
logo_url: string
|
||||||
|
category: string
|
||||||
|
binding: { vmid: number; guest_type: "lxc" | "qemu" } | null
|
||||||
|
created_at?: number
|
||||||
|
updated_at?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftCustomLink {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
logo_url: string
|
||||||
|
category: string
|
||||||
|
bindingKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNBOUND_KEY = "__none__"
|
||||||
|
|
||||||
|
function buildKey(binding: CustomLink["binding"]): string {
|
||||||
|
if (!binding) return UNBOUND_KEY
|
||||||
|
return `${binding.guest_type}:${binding.vmid}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKey(key: string): CustomLink["binding"] {
|
||||||
|
if (!key || key === UNBOUND_KEY) return null
|
||||||
|
const [type, vmid] = key.split(":")
|
||||||
|
if (type !== "lxc" && type !== "qemu") return null
|
||||||
|
const n = Number(vmid)
|
||||||
|
if (!Number.isFinite(n)) return null
|
||||||
|
return { guest_type: type, vmid: n }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomLinkEditor({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
editing,
|
||||||
|
guests,
|
||||||
|
categoryPresets,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (v: boolean) => void
|
||||||
|
/** null = create; existing link = edit */
|
||||||
|
editing: CustomLink | null
|
||||||
|
/** VMs + LXCs from /api/vms so the user can bind a link to a guest */
|
||||||
|
guests: GuestOption[]
|
||||||
|
/** Populated from /api/apps/categories */
|
||||||
|
categoryPresets: string[]
|
||||||
|
/** Called on successful save/delete so the parent can refresh */
|
||||||
|
onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const t = useT()
|
||||||
|
const [draft, setDraft] = useState<DraftCustomLink>({
|
||||||
|
name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY,
|
||||||
|
})
|
||||||
|
const [customCategoryMode, setCustomCategoryMode] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Reset the draft whenever the modal opens with a new target.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setError(null)
|
||||||
|
if (editing) {
|
||||||
|
setDraft({
|
||||||
|
name: editing.name,
|
||||||
|
url: editing.url,
|
||||||
|
logo_url: editing.logo_url || "",
|
||||||
|
category: editing.category || "",
|
||||||
|
bindingKey: buildKey(editing.binding),
|
||||||
|
})
|
||||||
|
setCustomCategoryMode(
|
||||||
|
!!editing.category && !categoryPresets.includes(editing.category),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setDraft({ name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY })
|
||||||
|
setCustomCategoryMode(false)
|
||||||
|
}
|
||||||
|
}, [open, editing, categoryPresets])
|
||||||
|
|
||||||
|
const canSave = draft.name.trim() && draft.url.trim() && !saving
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setError(null)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: draft.name.trim(),
|
||||||
|
url: draft.url.trim(),
|
||||||
|
logo_url: draft.logo_url.trim(),
|
||||||
|
category: draft.category.trim(),
|
||||||
|
binding: parseKey(draft.bindingKey),
|
||||||
|
}
|
||||||
|
if (editing) {
|
||||||
|
await fetchApi(`/api/apps/custom-links/${editing.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await fetchApi("/api/apps/custom-links", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSaved()
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch (e: any) {
|
||||||
|
setError((e && e.message) || t("apps.customLinkSaveError"))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!editing) return
|
||||||
|
setError(null)
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await fetchApi(`/api/apps/custom-links/${editing.id}`, { method: "DELETE" })
|
||||||
|
onSaved()
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch (e: any) {
|
||||||
|
setError((e && e.message) || t("apps.customLinkDeleteError"))
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort guests by vmid so the dropdown is easy to scan
|
||||||
|
const sortedGuests = [...guests].sort((a, b) => a.vmid - b.vmid)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[520px] bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editing ? t("apps.customLinkEditTitle") : t("apps.customLinkNewTitle")}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-3 py-2">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="cl-name" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkName")}</Label>
|
||||||
|
<Input
|
||||||
|
id="cl-name"
|
||||||
|
autoFocus
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||||
|
placeholder={t("apps.customLinkNamePlaceholder")}
|
||||||
|
maxLength={80}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="cl-url" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkUrl")}</Label>
|
||||||
|
<Input
|
||||||
|
id="cl-url"
|
||||||
|
type="url"
|
||||||
|
value={draft.url}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, url: e.target.value }))}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
maxLength={512}
|
||||||
|
className="text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label htmlFor="cl-logo" className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkLogo")}</Label>
|
||||||
|
<Input
|
||||||
|
id="cl-logo"
|
||||||
|
type="url"
|
||||||
|
value={draft.logo_url}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, logo_url: e.target.value }))}
|
||||||
|
placeholder={t("apps.customLinkLogoPlaceholder")}
|
||||||
|
maxLength={512}
|
||||||
|
className="text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkCategory")}</Label>
|
||||||
|
{customCategoryMode ? (
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={draft.category}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, category: e.target.value }))}
|
||||||
|
placeholder={t("vmLxc.appEditor.portCategoryCustomPlaceholder")}
|
||||||
|
maxLength={60}
|
||||||
|
className="text-sm"
|
||||||
|
onBlur={() => { if (!draft.category.trim()) setCustomCategoryMode(false) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={draft.category || "__none__"}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v === "__add__") {
|
||||||
|
setCustomCategoryMode(true)
|
||||||
|
setDraft((d) => ({ ...d, category: "" }))
|
||||||
|
} else if (v === "__none__") {
|
||||||
|
setDraft((d) => ({ ...d, category: "" }))
|
||||||
|
} else {
|
||||||
|
setDraft((d) => ({ ...d, category: v }))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="text-sm h-9">
|
||||||
|
<SelectValue placeholder={t("vmLxc.appEditor.portCategoryPlaceholder")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">{t("vmLxc.appEditor.portCategoryNone")}</SelectItem>
|
||||||
|
{categoryPresets.map((c) => (
|
||||||
|
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="__add__">{t("vmLxc.appEditor.portCategoryAddNew")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">{t("apps.customLinkBinding")}</Label>
|
||||||
|
<Select
|
||||||
|
value={draft.bindingKey}
|
||||||
|
onValueChange={(v) => setDraft((d) => ({ ...d, bindingKey: v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="text-sm h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={UNBOUND_KEY}>{t("apps.customLinkBindingNone")}</SelectItem>
|
||||||
|
{sortedGuests.map((g) => (
|
||||||
|
<SelectItem key={`${g.type}:${g.vmid}`} value={`${g.type}:${g.vmid}`}>
|
||||||
|
{g.type === "qemu" ? "VM" : "CT"} {g.vmid} · {g.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||||
|
{t("apps.customLinkBindingHelp")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:justify-between">
|
||||||
|
{editing ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting || saving}
|
||||||
|
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1.5" />
|
||||||
|
{t("apps.customLinkDelete")}
|
||||||
|
</Button>
|
||||||
|
) : <div />}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||||
|
{t("apps.customLinkCancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
className="bg-blue-500 hover:bg-blue-600 !text-white"
|
||||||
|
>
|
||||||
|
{editing ? t("apps.customLinkSave") : t("apps.customLinkCreate")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Badge } from "./ui/badge"
|
|||||||
import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts"
|
import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
import { useDiskTempThresholds } from "@/lib/health-thresholds"
|
import { useDiskTempThresholds } from "@/lib/health-thresholds"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
interface TempPoint {
|
interface TempPoint {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
@@ -24,11 +25,11 @@ interface DiskTemperatureCardProps {
|
|||||||
// Disk-temperature thresholds come from the user-configurable backend
|
// Disk-temperature thresholds come from the user-configurable backend
|
||||||
// (lib/health-thresholds.ts). The classifier here takes the resolved
|
// (lib/health-thresholds.ts). The classifier here takes the resolved
|
||||||
// pair so the consumer can read it from the hook once per render.
|
// pair so the consumer can read it from the hook once per render.
|
||||||
function statusFor(temp: number, t: { warn: number; hot: number }) {
|
function statusFor(temp: number, thresholds: { warn: number; hot: number }) {
|
||||||
if (temp <= 0) return { label: "N/A", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
|
if (temp <= 0) return { labelKey: "common.notAvailable", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
|
||||||
if (temp >= t.hot) return { label: "Hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" }
|
if (temp >= thresholds.hot) return { labelKey: "details.temperature.status.hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" }
|
||||||
if (temp >= t.warn) return { label: "Warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
|
if (temp >= thresholds.warn) return { labelKey: "details.temperature.status.warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
|
||||||
return { label: "Normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
|
return { labelKey: "details.temperature.status.normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const MiniTooltip = ({ active, payload }: any) => {
|
const MiniTooltip = ({ active, payload }: any) => {
|
||||||
@@ -55,6 +56,7 @@ export function DiskTemperatureCard({
|
|||||||
diskType,
|
diskType,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
}: DiskTemperatureCardProps) {
|
}: DiskTemperatureCardProps) {
|
||||||
|
const t = useT()
|
||||||
const [data, setData] = useState<TempPoint[]>([])
|
const [data, setData] = useState<TempPoint[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const cancelled = useRef(false)
|
const cancelled = useRef(false)
|
||||||
@@ -98,7 +100,7 @@ export function DiskTemperatureCard({
|
|||||||
})()
|
})()
|
||||||
const status = statusFor(liveTemperature, dt)
|
const status = statusFor(liveTemperature, dt)
|
||||||
const lineColor = status.color
|
const lineColor = status.color
|
||||||
const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : "N/A"
|
const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : t("common.notAvailable")
|
||||||
const samples = data.length
|
const samples = data.length
|
||||||
|
|
||||||
const interactive = !!onOpenDetail
|
const interactive = !!onOpenDetail
|
||||||
@@ -112,11 +114,11 @@ export function DiskTemperatureCard({
|
|||||||
"w-full text-left border border-white/10 rounded-lg p-3 bg-white/[0.02]",
|
"w-full text-left border border-white/10 rounded-lg p-3 bg-white/[0.02]",
|
||||||
interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "",
|
interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
title={interactive ? "Open temperature history" : undefined}
|
title={interactive ? t("details.temperature.openHistory") : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3 mb-1.5">
|
<div className="flex items-start justify-between gap-3 mb-1.5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Temperature</p>
|
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">{t("details.temperature.diskTitle")}</p>
|
||||||
<p className="text-xl font-bold leading-tight mt-0.5" style={{ color: lineColor }}>
|
<p className="text-xl font-bold leading-tight mt-0.5" style={{ color: lineColor }}>
|
||||||
{tempDisplay}
|
{tempDisplay}
|
||||||
</p>
|
</p>
|
||||||
@@ -124,7 +126,7 @@ export function DiskTemperatureCard({
|
|||||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||||
<Thermometer className="h-3.5 w-3.5" style={{ color: lineColor }} />
|
<Thermometer className="h-3.5 w-3.5" style={{ color: lineColor }} />
|
||||||
<Badge variant="outline" className={`${status.className} text-[10px] px-2 py-0`}>
|
<Badge variant="outline" className={`${status.className} text-[10px] px-2 py-0`}>
|
||||||
{status.label}
|
{t(status.labelKey)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +136,7 @@ export function DiskTemperatureCard({
|
|||||||
<div className="h-full w-full animate-pulse bg-white/[0.03] rounded" />
|
<div className="h-full w-full animate-pulse bg-white/[0.03] rounded" />
|
||||||
) : samples < 2 ? (
|
) : samples < 2 ? (
|
||||||
<div className="h-full flex items-center justify-center text-[10px] text-muted-foreground">
|
<div className="h-full flex items-center justify-center text-[10px] text-muted-foreground">
|
||||||
Collecting samples — chart populates after ~2 minutes
|
{t("details.temperature.collectingSamples")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
|||||||
import { useIsMobile } from "../hooks/use-mobile"
|
import { useIsMobile } from "../hooks/use-mobile"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
import { useDiskTempThresholds, type DiskTempThreshold } from "@/lib/health-thresholds"
|
import { useDiskTempThresholds, type DiskTempThreshold } from "@/lib/health-thresholds"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
const TIMEFRAME_OPTIONS = [
|
const TIMEFRAME_OPTIONS = [
|
||||||
{ value: "hour", label: "1 Hour" },
|
{ value: "hour", labelKey: "details.temperature.timeframes.hour" },
|
||||||
{ value: "day", label: "24 Hours" },
|
{ value: "day", labelKey: "details.temperature.timeframes.day" },
|
||||||
{ value: "week", label: "7 Days" },
|
{ value: "week", labelKey: "details.temperature.timeframes.week" },
|
||||||
{ value: "month", label: "30 Days" },
|
{ value: "month", labelKey: "details.temperature.timeframes.month" },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface TempHistoryPoint {
|
interface TempHistoryPoint {
|
||||||
@@ -69,10 +70,10 @@ function colorFor(temp: number, t: DiskTempThreshold): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusInfoFor(temp: number, t: DiskTempThreshold) {
|
function statusInfoFor(temp: number, t: DiskTempThreshold) {
|
||||||
if (temp <= 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
if (temp <= 0) return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||||
if (temp >= t.hot) return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
if (temp >= t.hot) return { color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||||
if (temp >= t.warn) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
if (temp >= t.warn) return { color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||||
return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
return { color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiskTemperatureDetailModal({
|
export function DiskTemperatureDetailModal({
|
||||||
@@ -83,6 +84,7 @@ export function DiskTemperatureDetailModal({
|
|||||||
liveTemperature,
|
liveTemperature,
|
||||||
diskType,
|
diskType,
|
||||||
}: DiskTemperatureDetailModalProps) {
|
}: DiskTemperatureDetailModalProps) {
|
||||||
|
const t = useT()
|
||||||
const [timeframe, setTimeframe] = useState("day")
|
const [timeframe, setTimeframe] = useState("day")
|
||||||
const [data, setData] = useState<TempHistoryPoint[]>([])
|
const [data, setData] = useState<TempHistoryPoint[]>([])
|
||||||
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
|
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
|
||||||
@@ -168,7 +170,7 @@ export function DiskTemperatureDetailModal({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TIMEFRAME_OPTIONS.map((opt) => (
|
{TIMEFRAME_OPTIONS.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{t(opt.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -181,24 +183,24 @@ export function DiskTemperatureDetailModal({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
|
||||||
<div className={`rounded-lg p-3 text-center border ${currentStatus.color}`}>
|
<div className={`rounded-lg p-3 text-center border ${currentStatus.color}`}>
|
||||||
<div className="text-xs opacity-80 mb-1">Current</div>
|
<div className="text-xs opacity-80 mb-1">{t("details.temperature.current")}</div>
|
||||||
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : "N/A"}</div>
|
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : t("common.notAvailable")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||||
<TrendingDown className="h-3 w-3" /> Min
|
<TrendingDown className="h-3 w-3" /> {t("details.temperature.min")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
|
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||||
<Minus className="h-3 w-3" /> Avg
|
<Minus className="h-3 w-3" /> {t("details.temperature.avg")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
|
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||||
<TrendingUp className="h-3 w-3" /> Max
|
<TrendingUp className="h-3 w-3" /> {t("details.temperature.max")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
|
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,8 +218,8 @@ export function DiskTemperatureDetailModal({
|
|||||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
<p>No temperature data yet for this disk</p>
|
<p>{t("details.temperature.noData")}</p>
|
||||||
<p className="text-sm mt-1">Samples are collected every 60 seconds</p>
|
<p className="text-sm mt-1">{t("details.temperature.sampleInterval")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -250,7 +252,7 @@ export function DiskTemperatureDetailModal({
|
|||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
name="Temperature"
|
name={t("details.temperature.seriesName")}
|
||||||
stroke={chartColor}
|
stroke={chartColor}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill={`url(#diskTempGradient-${diskName})`}
|
fill={`url(#diskTempGradient-${diskName})`}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface SriovInfo {
|
interface SriovInfo {
|
||||||
role: "vf" | "pf-active" | "pf-idle"
|
role: "vf" | "pf-active" | "pf-idle"
|
||||||
@@ -26,6 +27,8 @@ export function GpuSwitchModeIndicator({
|
|||||||
className,
|
className,
|
||||||
sriovInfo,
|
sriovInfo,
|
||||||
}: GpuSwitchModeIndicatorProps) {
|
}: GpuSwitchModeIndicatorProps) {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
// SR-IOV is a non-editable hardware state. Pending toggles don't apply here.
|
// SR-IOV is a non-editable hardware state. Pending toggles don't apply here.
|
||||||
const displayMode = mode === "sriov" ? "sriov" : (pendingMode ?? mode)
|
const displayMode = mode === "sriov" ? "sriov" : (pendingMode ?? mode)
|
||||||
const isLxcActive = displayMode === "lxc"
|
const isLxcActive = displayMode === "lxc"
|
||||||
@@ -69,9 +72,11 @@ export function GpuSwitchModeIndicator({
|
|||||||
// exactly how many VFs are active; for a VF we show its parent PF.
|
// exactly how many VFs are active; for a VF we show its parent PF.
|
||||||
const sriovBadgeText = (() => {
|
const sriovBadgeText = (() => {
|
||||||
if (!isSriovActive) return ""
|
if (!isSriovActive) return ""
|
||||||
if (sriovInfo?.role === "vf") return "SR-IOV VF"
|
if (sriovInfo?.role === "vf") return t("hardware.gpuSwitch.sriovVf")
|
||||||
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) return `SR-IOV ×${sriovInfo.vfCount}`
|
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) {
|
||||||
return "SR-IOV"
|
return t("hardware.gpuSwitch.sriovCount", { count: sriovInfo.vfCount })
|
||||||
|
}
|
||||||
|
return t("hardware.gpuSwitch.sriov")
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -124,7 +129,7 @@ export function GpuSwitchModeIndicator({
|
|||||||
className="text-[14px] font-bold transition-all duration-300"
|
className="text-[14px] font-bold transition-all duration-300"
|
||||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||||
>
|
>
|
||||||
GPU
|
{t("hardware.gpuSwitch.gpu")}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
@@ -268,7 +273,7 @@ export function GpuSwitchModeIndicator({
|
|||||||
)}
|
)}
|
||||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||||
>
|
>
|
||||||
LXC
|
{t("hardware.gpuSwitch.lxc")}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
{isSriovActive && (
|
{isSriovActive && (
|
||||||
@@ -279,7 +284,7 @@ export function GpuSwitchModeIndicator({
|
|||||||
className="text-[9px] font-medium"
|
className="text-[9px] font-medium"
|
||||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||||
>
|
>
|
||||||
LXC
|
{t("hardware.gpuSwitch.lxc")}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -332,7 +337,7 @@ export function GpuSwitchModeIndicator({
|
|||||||
)}
|
)}
|
||||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||||
>
|
>
|
||||||
VM
|
{t("hardware.gpuSwitch.vm")}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
{isSriovActive && (
|
{isSriovActive && (
|
||||||
@@ -343,7 +348,7 @@ export function GpuSwitchModeIndicator({
|
|||||||
className="text-[9px] font-medium"
|
className="text-[9px] font-medium"
|
||||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||||
>
|
>
|
||||||
VM
|
{t("hardware.gpuSwitch.vm")}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
@@ -363,34 +368,47 @@ export function GpuSwitchModeIndicator({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSriovActive
|
{isSriovActive
|
||||||
? "SR-IOV active"
|
? t("hardware.gpuSwitch.sriovActive")
|
||||||
: isLxcActive
|
: isLxcActive
|
||||||
? "Ready for LXC containers"
|
? t("hardware.gpuSwitch.readyForLxc")
|
||||||
: isVmActive
|
: isVmActive
|
||||||
? "Ready for VM passthrough"
|
? t("hardware.gpuSwitch.readyForVm")
|
||||||
: "Mode unknown"}
|
: t("hardware.gpuSwitch.modeUnknown")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{isSriovActive
|
{isSriovActive
|
||||||
? "Virtual Functions managed externally"
|
? t("hardware.gpuSwitch.virtualFunctionsExternal")
|
||||||
: isLxcActive
|
: isLxcActive
|
||||||
? "Native driver active"
|
? t("hardware.gpuSwitch.nativeDriverActive")
|
||||||
: isVmActive
|
: isVmActive
|
||||||
? "VFIO-PCI driver active"
|
? t("hardware.gpuSwitch.vfioDriverActive")
|
||||||
: "No driver detected"}
|
: t("hardware.gpuSwitch.noDriverDetected")}
|
||||||
</span>
|
</span>
|
||||||
{isSriovActive && sriovInfo && (
|
{isSriovActive && sriovInfo && (
|
||||||
<span className="text-xs font-mono text-teal-600/80 dark:text-teal-400/80">
|
<span className="text-xs font-mono text-teal-600/80 dark:text-teal-400/80">
|
||||||
{sriovInfo.role === "vf"
|
{sriovInfo.role === "vf"
|
||||||
? `Virtual Function${sriovInfo.physfn ? ` · parent PF ${sriovInfo.physfn}` : ""}`
|
? t(
|
||||||
|
sriovInfo.physfn
|
||||||
|
? "hardware.gpuSwitch.virtualFunctionWithParent"
|
||||||
|
: "hardware.gpuSwitch.virtualFunction",
|
||||||
|
{ parent: sriovInfo.physfn || "" },
|
||||||
|
)
|
||||||
: sriovInfo.vfCount !== undefined
|
: sriovInfo.vfCount !== undefined
|
||||||
? `1 PF + ${sriovInfo.vfCount} VF${sriovInfo.vfCount === 1 ? "" : "s"}${sriovInfo.totalvfs ? ` / ${sriovInfo.totalvfs} max` : ""}`
|
? t(
|
||||||
|
sriovInfo.totalvfs
|
||||||
|
? "hardware.gpuSwitch.physicalFunctionWithMax"
|
||||||
|
: "hardware.gpuSwitch.physicalFunction",
|
||||||
|
{
|
||||||
|
count: sriovInfo.vfCount,
|
||||||
|
max: sriovInfo.totalvfs || "",
|
||||||
|
},
|
||||||
|
)
|
||||||
: null}
|
: null}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{hasChanged && (
|
{hasChanged && (
|
||||||
<span className="text-sm text-amber-500 font-medium animate-pulse">
|
<span className="text-sm text-amber-500 font-medium animate-pulse">
|
||||||
Change pending...
|
{t("hardware.gpuSwitch.changePending")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+409
-310
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Shield,
|
Shield,
|
||||||
Download,
|
ArrowUpCircle,
|
||||||
X,
|
X,
|
||||||
Clock,
|
Clock,
|
||||||
BellOff,
|
BellOff,
|
||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
interface CategoryCheck {
|
interface CategoryCheck {
|
||||||
status: string
|
status: string
|
||||||
@@ -104,19 +105,20 @@ interface HealthStatusModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ key: "cpu", category: "temperature", label: "CPU Usage & Temperature", Icon: Cpu },
|
{ key: "cpu", category: "temperature", Icon: Cpu },
|
||||||
{ key: "memory", category: "memory", label: "Memory & Swap", Icon: MemoryStick },
|
{ key: "memory", category: "memory", Icon: MemoryStick },
|
||||||
{ key: "storage", category: "storage", label: "Storage Mounts & Space", Icon: HardDrive },
|
{ key: "storage", category: "storage", Icon: HardDrive },
|
||||||
{ key: "disks", category: "disks", label: "Disk I/O & Errors", Icon: Disc },
|
{ key: "disks", category: "disks", Icon: Disc },
|
||||||
{ key: "network", category: "network", label: "Network Interfaces", Icon: Network },
|
{ key: "network", category: "network", Icon: Network },
|
||||||
{ key: "vms", category: "vms", label: "VMs & Containers", Icon: Box },
|
{ key: "vms", category: "vms", Icon: Box },
|
||||||
{ key: "services", category: "pve_services", label: "PVE Services", Icon: Settings },
|
{ key: "services", category: "pve_services", Icon: Settings },
|
||||||
{ key: "logs", category: "logs", label: "System Logs", Icon: FileText },
|
{ key: "logs", category: "logs", Icon: FileText },
|
||||||
{ key: "updates", category: "updates", label: "System Updates", Icon: RefreshCw },
|
{ key: "updates", category: "updates", Icon: RefreshCw },
|
||||||
{ key: "security", category: "security", label: "Security & Certificates", Icon: Shield },
|
{ key: "security", category: "security", Icon: Shield },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
|
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
|
||||||
|
const t = useT()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
|
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
|
||||||
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
||||||
@@ -146,7 +148,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Fallback to legacy endpoint
|
// Fallback to legacy endpoint
|
||||||
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
|
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
|
||||||
if (!legacyResponse.ok) throw new Error("Failed to fetch health details")
|
if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
|
||||||
const data = await legacyResponse.json()
|
const data = await legacyResponse.json()
|
||||||
setHealthData(data)
|
setHealthData(data)
|
||||||
setDismissedItems([])
|
setDismissedItems([])
|
||||||
@@ -203,11 +205,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
})
|
})
|
||||||
window.dispatchEvent(event)
|
window.dispatchEvent(event)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Unknown error")
|
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [getApiUrl])
|
}, [getApiUrl, t])
|
||||||
|
|
||||||
// Tick counter to force re-render every 30s so "X minutes ago" stays current
|
// Tick counter to force re-render every 30s so "X minutes ago" stays current
|
||||||
const [, setTick] = useState(0)
|
const [, setTick] = useState(0)
|
||||||
@@ -277,21 +279,96 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
const statusUpper = status?.toUpperCase()
|
const s = status?.toUpperCase()
|
||||||
switch (statusUpper) {
|
const label =
|
||||||
case "OK":
|
s === "OK" ? t("healthStatus.status.ok") :
|
||||||
return <Badge className="bg-green-500 text-white hover:bg-green-500">OK</Badge>
|
s === "INFO" ? t("healthStatus.status.info") :
|
||||||
case "INFO":
|
s === "WARNING" ? t("healthStatus.status.warning") :
|
||||||
return <Badge className="bg-blue-500 text-white hover:bg-blue-500">Info</Badge>
|
s === "CRITICAL" ? t("healthStatus.status.critical") :
|
||||||
case "WARNING":
|
t("healthStatus.status.unknown")
|
||||||
return <Badge className="bg-yellow-500 text-white hover:bg-yellow-500">Warning</Badge>
|
return <Badge variant="outline" className={getOutlineBadgeStyle(status)}>{label}</Badge>
|
||||||
case "CRITICAL":
|
|
||||||
return <Badge className="bg-red-500 text-white hover:bg-red-500">Critical</Badge>
|
|
||||||
case "UNKNOWN":
|
|
||||||
return <Badge className="bg-amber-500 text-white hover:bg-amber-500">UNKNOWN</Badge>
|
|
||||||
default:
|
|
||||||
return <Badge>Unknown</Badge>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatStatus = (status: string) => {
|
||||||
|
const key = status?.toLowerCase()
|
||||||
|
return ["ok", "info", "warning", "critical", "unknown"].includes(key)
|
||||||
|
? t(`healthStatus.status.${key}`)
|
||||||
|
: status
|
||||||
|
}
|
||||||
|
|
||||||
|
const translateHealthText = (value?: string): string => {
|
||||||
|
if (!value) return ""
|
||||||
|
const exact: Record<string, string> = {
|
||||||
|
"All systems operational": t("healthStatus.details.allOperational"),
|
||||||
|
"Normal": t("healthStatus.details.normal"),
|
||||||
|
"No I/O errors in dmesg": t("healthStatus.details.noIoErrors"),
|
||||||
|
"Mounted read-write, space OK": t("healthStatus.details.rootFilesystemOk"),
|
||||||
|
"No SMART warnings in journal": t("healthStatus.details.noSmartWarnings"),
|
||||||
|
"No critical errors": t("healthStatus.details.noCriticalErrors"),
|
||||||
|
"No cascading errors": t("healthStatus.details.noCascadingErrors"),
|
||||||
|
"No error spikes": t("healthStatus.details.noErrorSpikes"),
|
||||||
|
"No persistent patterns": t("healthStatus.details.noPersistentPatterns"),
|
||||||
|
"Certificate valid": t("healthStatus.details.certificateValid"),
|
||||||
|
"Cluster detected (corosync.conf present)": t("healthStatus.details.clusterDetected"),
|
||||||
|
"Active": t("healthStatus.details.active"),
|
||||||
|
"UP": t("healthStatus.details.up"),
|
||||||
|
"Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"),
|
||||||
|
"Proxmox VE is up to date": t("healthStatus.details.proxmoxUpToDate"),
|
||||||
|
"No security updates pending": t("healthStatus.details.noSecurityUpdates"),
|
||||||
|
"No container startup errors": t("healthStatus.details.noContainerErrors"),
|
||||||
|
"No OOM events detected": t("healthStatus.details.noOomEvents"),
|
||||||
|
"No QMP timeouts detected": t("healthStatus.details.noQmpTimeouts"),
|
||||||
|
"No VM startup failures": t("healthStatus.details.noVmFailures"),
|
||||||
|
"Dismissed by user": t("healthStatus.details.dismissedByUser"),
|
||||||
|
}
|
||||||
|
if (exact[value]) return exact[value]
|
||||||
|
|
||||||
|
let match = value.match(/^Latency ([\d.]+)ms to gateway$/)
|
||||||
|
if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] })
|
||||||
|
match = value.match(/^(\d+) failed login attempts in 24h$/)
|
||||||
|
if (match) return t("healthStatus.details.failedLogins", { count: match[1] })
|
||||||
|
match = value.match(/^(\d+) IP\(s\) currently banned by Fail2Ban \(jails: (.+)\)$/)
|
||||||
|
if (match) return t("healthStatus.details.fail2banBannedIps", { count: match[1], jails: match[2] })
|
||||||
|
match = value.match(/^Uptime (\d+) days?$/)
|
||||||
|
if (match) return t("healthStatus.details.uptimeDays", { count: match[1] })
|
||||||
|
match = value.match(/^(\d+) package\(s\) pending$/)
|
||||||
|
if (match) return t("healthStatus.details.pendingPackages", { count: match[1] })
|
||||||
|
match = value.match(/^Last updated (\d+) day\(s\) ago$/)
|
||||||
|
if (match) return t("healthStatus.details.updatedDaysAgo", { count: match[1] })
|
||||||
|
match = value.match(/^Storage: \d+ Proxmox storages unavailable: (.+) \(startup\)$/)
|
||||||
|
if (match) return t("healthStatus.details.startupStoragesChecking", { storages: match[1] })
|
||||||
|
match = value.match(/^Storage: (.+) not yet available \(startup\)$/)
|
||||||
|
if (match) return t("healthStatus.details.startupStorageChecking", { storage: match[1] })
|
||||||
|
match = value.match(/^(.+) not yet available \(startup\)$/)
|
||||||
|
if (match) return t("healthStatus.details.startupStorageChecking", { storage: match[1] })
|
||||||
|
match = value.match(/^\[Startup\] Storage '(.+)' is configured but not found on the server\. \(checking\.\.\.\)$/)
|
||||||
|
if (match) return t("healthStatus.details.startupStorageNotFound", { storage: match[1] })
|
||||||
|
match = value.match(/^\[Startup\] Storage '(.+)' is not available \(connection error or backend issue\)\. \(checking\.\.\.\)$/)
|
||||||
|
if (match) return t("healthStatus.details.startupStorageUnavailable", { storage: match[1] })
|
||||||
|
match = value.match(/^\[Startup\] Storage '(.+)' has status: (.+)\. \(checking\.\.\.\)$/)
|
||||||
|
if (match) return t("healthStatus.details.startupStorageStatus", { storage: match[1], status: match[2] })
|
||||||
|
match = value.match(/^(.+) storage available$/)
|
||||||
|
if (match) return t("healthStatus.details.storageAvailable", { type: match[1] })
|
||||||
|
match = value.match(/^(.+) mount reachable$/)
|
||||||
|
if (match) return t("healthStatus.details.mountReachable", { type: match[1] })
|
||||||
|
match = value.match(/^rootfs ([\d.]+)% used \((.+)\)$/)
|
||||||
|
if (match) return t("healthStatus.details.rootfsUsed", { percent: match[1], size: match[2] })
|
||||||
|
match = value.match(/^(\d+) running CT\(s\) within safe rootfs usage$/)
|
||||||
|
if (match) return t("healthStatus.details.runningCtsSafe", { count: match[1] })
|
||||||
|
match = value.match(/^(\d+) PVE block storage\(s\) within safe usage$/)
|
||||||
|
if (match) return t("healthStatus.details.pveStorageSafe", { count: match[1] })
|
||||||
|
match = value.match(/^(\d+) remote mount\(s\) healthy$/)
|
||||||
|
if (match) return t("healthStatus.details.remoteMountsHealthy", { count: match[1] })
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (hours: number) => {
|
||||||
|
if (hours === -1) return t("healthStatus.permanent")
|
||||||
|
if (hours >= 8760) return t("healthStatus.duration.years", { count: Math.floor(hours / 8760) })
|
||||||
|
if (hours >= 720) return t("healthStatus.duration.months", { count: Math.floor(hours / 720) })
|
||||||
|
if (hours >= 168) return t("healthStatus.duration.weeks", { count: Math.floor(hours / 168) })
|
||||||
|
if (hours >= 24) return t("healthStatus.duration.days", { count: Math.floor(hours / 24) })
|
||||||
|
return t("healthStatus.duration.hours", { count: Math.round(hours) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get categories that have dismissed items (to show as INFO)
|
// Get categories that have dismissed items (to show as INFO)
|
||||||
@@ -444,11 +521,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diffMs = now.getTime() - checkTime.getTime()
|
const diffMs = now.getTime() - checkTime.getTime()
|
||||||
const diffMin = Math.floor(diffMs / 60000)
|
const diffMin = Math.floor(diffMs / 60000)
|
||||||
if (diffMin < 1) return "just now"
|
if (diffMin < 1) return t("healthStatus.time.justNow")
|
||||||
if (diffMin === 1) return "1 minute ago"
|
if (diffMin === 1) return t("healthStatus.time.oneMinuteAgo")
|
||||||
if (diffMin < 60) return `${diffMin} minutes ago`
|
if (diffMin < 60) return t("healthStatus.time.minutesAgo", { count: diffMin })
|
||||||
const diffHours = Math.floor(diffMin / 60)
|
const diffHours = Math.floor(diffMin / 60)
|
||||||
return `${diffHours}h ${diffMin % 60}m ago`
|
return t("healthStatus.time.hoursMinutesAgo", { hours: diffHours, minutes: diffMin % 60 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCategoryRowStyle = (status: string) => {
|
const getCategoryRowStyle = (status: string) => {
|
||||||
@@ -471,49 +548,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatCheckLabel = (key: string): string => {
|
const formatCheckLabel = (key: string): string => {
|
||||||
const labels: Record<string, string> = {
|
const knownKeys = new Set([
|
||||||
// CPU
|
"cpu_usage", "cpu_temperature", "ram_usage", "swap_usage", "root_filesystem",
|
||||||
cpu_usage: "CPU Usage",
|
"smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity",
|
||||||
cpu_temperature: "Temperature",
|
"qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode",
|
||||||
// Memory
|
"log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors",
|
||||||
ram_usage: "RAM Usage",
|
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime",
|
||||||
swap_usage: "Swap Usage",
|
"certificates", "login_attempts", "fail2ban", "proxmox_storages",
|
||||||
// Disk I/O
|
])
|
||||||
root_filesystem: "Root Filesystem",
|
if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`)
|
||||||
smart_health: "SMART Health",
|
|
||||||
io_errors: "I/O Errors",
|
|
||||||
zfs_pools: "ZFS Pools",
|
|
||||||
lvm_volumes: "LVM Volumes",
|
|
||||||
lvm_check: "LVM Status",
|
|
||||||
// Network
|
|
||||||
connectivity: "Connectivity",
|
|
||||||
// VMs & CTs
|
|
||||||
qmp_communication: "QMP Communication",
|
|
||||||
container_startup: "Container Startup",
|
|
||||||
vm_startup: "VM Startup",
|
|
||||||
oom_killer: "OOM Killer",
|
|
||||||
// Services
|
|
||||||
cluster_mode: "Cluster Mode",
|
|
||||||
// Logs (prefixed with log_)
|
|
||||||
log_error_cascade: "Error Cascade",
|
|
||||||
log_error_spike: "Error Spike",
|
|
||||||
log_persistent_errors: "Persistent Errors",
|
|
||||||
log_critical_errors: "Critical Errors",
|
|
||||||
// Updates
|
|
||||||
pve_version: "Proxmox VE Version",
|
|
||||||
security_updates: "Security Updates",
|
|
||||||
system_age: "System Age",
|
|
||||||
pending_updates: "Pending Updates",
|
|
||||||
kernel_pve: "Kernel / PVE",
|
|
||||||
// Security
|
|
||||||
uptime: "Uptime",
|
|
||||||
certificates: "Certificates",
|
|
||||||
login_attempts: "Login Attempts",
|
|
||||||
fail2ban: "Fail2Ban",
|
|
||||||
// Storage (Proxmox)
|
|
||||||
proxmox_storages: "Proxmox Storages",
|
|
||||||
}
|
|
||||||
if (labels[key]) return labels[key]
|
|
||||||
// Convert snake_case or camelCase to Title Case
|
// Convert snake_case or camelCase to Title Case
|
||||||
return key
|
return key
|
||||||
.replace(/_/g, " ")
|
.replace(/_/g, " ")
|
||||||
@@ -543,15 +586,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<div className="flex items-start gap-1.5 sm:gap-2 min-w-0 flex-1">
|
<div className="flex items-start gap-1.5 sm:gap-2 min-w-0 flex-1">
|
||||||
<span className="mt-0.5 shrink-0">{getStatusIcon(checkData.dismissed ? "INFO" : checkData.status, "sm")}</span>
|
<span className="mt-0.5 shrink-0">{getStatusIcon(checkData.dismissed ? "INFO" : checkData.status, "sm")}</span>
|
||||||
<span className="font-medium shrink-0">{formatCheckLabel(checkKey)}</span>
|
<span className="font-medium shrink-0">{formatCheckLabel(checkKey)}</span>
|
||||||
<span className="text-muted-foreground break-words whitespace-pre-wrap min-w-0">{checkData.detail}</span>
|
<span className="text-muted-foreground break-words whitespace-pre-wrap min-w-0">{translateHealthText(checkData.detail)}</span>
|
||||||
{checkData.dismissed && (
|
{checkData.dismissed && (
|
||||||
checkData.permanent ? (
|
checkData.permanent ? (
|
||||||
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-amber-400 border-amber-400/40">
|
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-amber-400 border-amber-400/40">
|
||||||
Permanent
|
{t("healthStatus.permanent")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-blue-400 border-blue-400/30">
|
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-blue-400 border-blue-400/30">
|
||||||
Dismissed
|
{t("healthStatus.dismissed")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -563,6 +606,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
handleAcknowledge(checkData.error_key || checkKey, hours)
|
handleAcknowledge(checkData.error_key || checkKey, hours)
|
||||||
}
|
}
|
||||||
busy={dismissingKey === (checkData.error_key || checkKey)}
|
busy={dismissingKey === (checkData.error_key || checkKey)}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -582,12 +626,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<DialogTitle className="flex items-center gap-2 flex-1 min-w-0">
|
<DialogTitle className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<Activity className="h-5 w-5 sm:h-6 sm:w-6 shrink-0" />
|
<Activity className="h-5 w-5 sm:h-6 sm:w-6 shrink-0" />
|
||||||
<span className="truncate text-base sm:text-lg">System Health Status</span>
|
<span className="truncate text-base sm:text-lg">{t("healthStatus.title")}</span>
|
||||||
{healthData && <div className="shrink-0">{getStatusBadge(healthData.overall)}</div>}
|
{healthData && <div className="shrink-0">{getStatusBadge(healthData.overall)}</div>}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</div>
|
</div>
|
||||||
<DialogDescription className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:text-sm">
|
<DialogDescription className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:text-sm">
|
||||||
<span>Detailed health checks for all system components</span>
|
<span>{t("healthStatus.description")}</span>
|
||||||
{getTimeSinceCheck() && (
|
{getTimeSinceCheck() && (
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
@@ -605,7 +649,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200">
|
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200">
|
||||||
<p className="font-medium">Error loading health status</p>
|
<p className="font-medium">{t("healthStatus.errors.loading")}</p>
|
||||||
<p className="text-sm">{error}</p>
|
<p className="text-sm">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -616,47 +660,47 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<div className={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}>
|
<div className={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg sm:text-2xl font-bold">{stats.total}</div>
|
<div className="text-lg sm:text-2xl font-bold">{stats.total}</div>
|
||||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Total</div>
|
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.total")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg sm:text-2xl font-bold text-green-500">{stats.healthy}</div>
|
<div className="text-lg sm:text-2xl font-bold text-green-500">{stats.healthy}</div>
|
||||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Healthy</div>
|
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.healthy")}</div>
|
||||||
</div>
|
</div>
|
||||||
{stats.info > 0 && (
|
{stats.info > 0 && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg sm:text-2xl font-bold text-blue-500">{stats.info}</div>
|
<div className="text-lg sm:text-2xl font-bold text-blue-500">{stats.info}</div>
|
||||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Info</div>
|
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.info")}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg sm:text-2xl font-bold text-yellow-500">{stats.warnings}</div>
|
<div className="text-lg sm:text-2xl font-bold text-yellow-500">{stats.warnings}</div>
|
||||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Warn</div>
|
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.warning")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg sm:text-2xl font-bold text-red-500">{stats.critical}</div>
|
<div className="text-lg sm:text-2xl font-bold text-red-500">{stats.critical}</div>
|
||||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Critical</div>
|
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.critical")}</div>
|
||||||
</div>
|
</div>
|
||||||
{stats.unknown > 0 && (
|
{stats.unknown > 0 && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg sm:text-2xl font-bold text-amber-400">{stats.unknown}</div>
|
<div className="text-lg sm:text-2xl font-bold text-amber-400">{stats.unknown}</div>
|
||||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Unknown</div>
|
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.unknown")}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{healthData.summary && healthData.summary !== "All systems operational" && (
|
{healthData.summary && healthData.summary !== "All systems operational" && (
|
||||||
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
|
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
|
||||||
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{healthData.summary}</p>
|
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthText(healthData.summary)}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Category List */}
|
{/* Category List */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{CATEGORIES.map(({ key, label, Icon }) => {
|
{CATEGORIES.map(({ key, Icon }) => {
|
||||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||||
const originalStatus = categoryData?.status || "UNKNOWN"
|
const originalStatus = categoryData?.status || "UNKNOWN"
|
||||||
const status = getEffectiveStatus(key, originalStatus)
|
const status = getEffectiveStatus(key, originalStatus)
|
||||||
const reason = categoryData?.reason
|
const reason = translateHealthText(categoryData?.reason)
|
||||||
const checks = categoryData?.checks
|
const checks = categoryData?.checks
|
||||||
const isExpanded = expandedCategories.has(key)
|
const isExpanded = expandedCategories.has(key)
|
||||||
const hasChecks = checks && Object.keys(checks).length > 0
|
const hasChecks = checks && Object.keys(checks).length > 0
|
||||||
@@ -677,7 +721,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||||
<p className="font-medium text-xs sm:text-sm truncate">{label}</p>
|
<p className="font-medium text-xs sm:text-sm truncate">{t(`healthStatus.categories.${key}`)}</p>
|
||||||
{hasChecks && (
|
{hasChecks && (
|
||||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||||
({Object.values(checks).filter(c => c.installed !== false).length})
|
({Object.values(checks).filter(c => c.installed !== false).length})
|
||||||
@@ -690,7 +734,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||||
<Badge variant="outline" className={`text-[10px] sm:text-xs px-1.5 sm:px-2.5 ${getOutlineBadgeStyle(status)}`}>
|
<Badge variant="outline" className={`text-[10px] sm:text-xs px-1.5 sm:px-2.5 ${getOutlineBadgeStyle(status)}`}>
|
||||||
{status}
|
{formatStatus(status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground transition-transform duration-200 ${
|
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground transition-transform duration-200 ${
|
||||||
@@ -713,6 +757,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
handleAcknowledge(`category_${key}_unknown`, hours)
|
handleAcknowledge(`category_${key}_unknown`, hours)
|
||||||
}
|
}
|
||||||
busy={dismissingKey === `category_${key}_unknown`}
|
busy={dismissingKey === `category_${key}_unknown`}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -722,7 +767,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-3 py-2">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground px-3 py-2">
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||||
No issues detected
|
{t("healthStatus.noIssues")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Only offer "Update Now" when the category is not
|
{/* Only offer "Update Now" when the category is not
|
||||||
@@ -737,8 +782,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
onClick={() => setShowUpdateTerminal(true)}
|
onClick={() => setShowUpdateTerminal(true)}
|
||||||
className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
|
className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4 mr-1.5" />
|
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
||||||
Update Now
|
{t("healthStatus.updateNow")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -758,12 +803,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground pt-2">
|
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground pt-2">
|
||||||
<BellOff className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
<BellOff className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
Dismissed Items ({filteredDismissed.length})
|
{t("healthStatus.dismissedItems", { count: filteredDismissed.length })}
|
||||||
</div>
|
</div>
|
||||||
{filteredDismissed.map((item) => {
|
{filteredDismissed.map((item) => {
|
||||||
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
||||||
const CatIcon = catMeta?.Icon || BellOff
|
const CatIcon = catMeta?.Icon || BellOff
|
||||||
const catLabel = catMeta?.label || item.category
|
const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category
|
||||||
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
|
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -778,34 +823,28 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<div className="flex items-start justify-between gap-2 mb-1">
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
<p className="font-medium text-xs sm:text-sm text-muted-foreground truncate">{catLabel}</p>
|
<p className="font-medium text-xs sm:text-sm text-muted-foreground truncate">{catLabel}</p>
|
||||||
<p className="text-[10px] sm:text-xs text-muted-foreground/70 break-words line-clamp-2">{item.reason}</p>
|
<p className="text-[10px] sm:text-xs text-muted-foreground/70 break-words line-clamp-2">{translateHealthText(item.reason)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
{isPermanent ? (
|
{isPermanent ? (
|
||||||
<Badge variant="outline" className="text-[9px] sm:text-xs border-amber-500/50 text-amber-500/70 bg-transparent whitespace-nowrap">
|
<Badge variant="outline" className="text-[9px] sm:text-xs border-amber-500/50 text-amber-500/70 bg-transparent whitespace-nowrap">
|
||||||
Permanent
|
{t("healthStatus.permanent")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-[9px] sm:text-xs border-blue-500/50 text-blue-500/70 bg-transparent whitespace-nowrap">
|
<Badge variant="outline" className="text-[9px] sm:text-xs border-blue-500/50 text-blue-500/70 bg-transparent whitespace-nowrap">
|
||||||
Dismissed
|
{t("healthStatus.dismissed")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge variant="outline" className={`text-[9px] sm:text-xs whitespace-nowrap ${getOutlineBadgeStyle(item.severity)}`}>
|
<Badge variant="outline" className={`text-[9px] sm:text-xs whitespace-nowrap ${getOutlineBadgeStyle(item.severity)}`}>
|
||||||
was {item.severity}
|
{t("healthStatus.wasStatus", { status: formatStatus(item.severity) })}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] sm:text-xs text-muted-foreground flex items-center gap-1">
|
<p className="text-[10px] sm:text-xs text-muted-foreground flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
{isPermanent
|
{isPermanent
|
||||||
? "Permanently suppressed"
|
? t("healthStatus.permanentlySuppressed")
|
||||||
: `Suppressed for ${
|
: t("healthStatus.suppressedForMore", { duration: formatDuration(item.suppression_remaining_hours) })
|
||||||
item.suppression_remaining_hours < 24
|
|
||||||
? `${Math.round(item.suppression_remaining_hours)}h`
|
|
||||||
: item.suppression_remaining_hours < 720
|
|
||||||
? `${Math.round(item.suppression_remaining_hours / 24)} days`
|
|
||||||
: `${Math.round(item.suppression_remaining_hours / 720)} month(s)`
|
|
||||||
} more`
|
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -821,30 +860,20 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground">
|
||||||
<Settings2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
<Settings2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
Custom Suppression Settings
|
{t("healthStatus.customSuppressionSettings")}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-2.5 sm:p-3">
|
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-2.5 sm:p-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{customSuppressions.map((cs) => {
|
{customSuppressions.map((cs) => {
|
||||||
const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category || c.label === cs.label)
|
const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category)
|
||||||
const CatIcon = catMeta?.Icon || Settings2
|
const CatIcon = catMeta?.Icon || Settings2
|
||||||
const durationLabel = cs.hours === -1
|
const durationLabel = formatDuration(cs.hours)
|
||||||
? "Permanent"
|
|
||||||
: cs.hours >= 8760
|
|
||||||
? `${Math.floor(cs.hours / 8760)} year(s)`
|
|
||||||
: cs.hours >= 720
|
|
||||||
? `${Math.floor(cs.hours / 720)} month(s)`
|
|
||||||
: cs.hours >= 168
|
|
||||||
? `${Math.floor(cs.hours / 168)} week(s)`
|
|
||||||
: cs.hours >= 72
|
|
||||||
? `${Math.floor(cs.hours / 24)} days`
|
|
||||||
: `${cs.hours}h`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cs.key} className="flex items-center justify-between gap-2">
|
<div key={cs.key} className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
|
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
|
||||||
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{cs.label}</span>
|
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
|
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
|
||||||
{durationLabel}
|
{durationLabel}
|
||||||
@@ -854,7 +883,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground/60 mt-2 pt-1.5 border-t border-blue-500/10">
|
<p className="text-[10px] text-muted-foreground/60 mt-2 pt-1.5 border-t border-blue-500/10">
|
||||||
Alerts in these categories are auto-suppressed when detected.
|
{t("healthStatus.autoSuppressedHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -862,7 +891,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
|
|
||||||
{healthData.timestamp && (
|
{healthData.timestamp && (
|
||||||
<div className="text-xs text-muted-foreground text-center pt-2">
|
<div className="text-xs text-muted-foreground text-center pt-2">
|
||||||
Last updated: {new Date(healthData.timestamp).toLocaleString()}
|
{t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -882,8 +911,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
params={{
|
params={{
|
||||||
EXECUTION_MODE: "web",
|
EXECUTION_MODE: "web",
|
||||||
}}
|
}}
|
||||||
title="Proxmox System Update"
|
title={t("healthStatus.updateTerminalTitle")}
|
||||||
description="Runs apt-get update + dist-upgrade and post-update cleanup on the host."
|
description={t("healthStatus.updateTerminalDescription")}
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
@@ -896,9 +925,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
function DismissDropdown({
|
function DismissDropdown({
|
||||||
onSelect,
|
onSelect,
|
||||||
busy,
|
busy,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
onSelect: (suppressionHours: number) => void
|
onSelect: (suppressionHours: number) => void
|
||||||
busy: boolean
|
busy: boolean
|
||||||
|
t: ReturnType<typeof useT>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -915,27 +946,27 @@ function DismissDropdown({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<X className="h-3 w-3 sm:mr-0.5" />
|
<X className="h-3 w-3 sm:mr-0.5" />
|
||||||
<span className="hidden sm:inline">Dismiss</span>
|
<span className="hidden sm:inline">{t("healthStatus.dismiss")}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
|
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
|
||||||
<DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
<DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
Silence this alert for
|
{t("healthStatus.silenceFor")}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onSelect={() => onSelect(24)} className="text-xs">
|
<DropdownMenuItem onSelect={() => onSelect(24)} className="text-xs">
|
||||||
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> 24 hours
|
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> {t("healthStatus.duration.24hours")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => onSelect(168)} className="text-xs">
|
<DropdownMenuItem onSelect={() => onSelect(168)} className="text-xs">
|
||||||
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> 7 days
|
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> {t("healthStatus.duration.7days")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => onSelect(-1)}
|
onSelect={() => onSelect(-1)}
|
||||||
className="text-xs text-red-500 focus:text-red-500 focus:bg-red-500/10"
|
className="text-xs text-red-500 focus:text-red-500 focus:bg-red-500/10"
|
||||||
>
|
>
|
||||||
<BellOff className="h-3 w-3 mr-2" /> Permanently
|
<BellOff className="h-3 w-3 mr-2" /> {t("healthStatus.permanently")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Waves,
|
Waves,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { getApiUrl, getAuthToken } from "../lib/api-config"
|
import { getApiUrl, getAuthToken } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
// Local fetch wrapper that *preserves* the JSON body on non-2xx
|
// Local fetch wrapper that *preserves* the JSON body on non-2xx
|
||||||
// responses so we can surface backend validation messages
|
// responses so we can surface backend validation messages
|
||||||
@@ -80,7 +81,7 @@ interface ThresholdLeaf {
|
|||||||
|
|
||||||
interface ThresholdsTree {
|
interface ThresholdsTree {
|
||||||
cpu: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
cpu: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||||
memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_critical: ThresholdLeaf }
|
memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_high: ThresholdLeaf; available_min: ThresholdLeaf }
|
||||||
host_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
host_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||||
lxc_rootfs: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
lxc_rootfs: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||||
cpu_temperature: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
cpu_temperature: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||||
@@ -149,7 +150,8 @@ const SECTIONS: SectionDef[] = [
|
|||||||
fields: [
|
fields: [
|
||||||
{ path: ["memory", "warning"], label: "Memory warning" },
|
{ path: ["memory", "warning"], label: "Memory warning" },
|
||||||
{ path: ["memory", "critical"], label: "Memory critical" },
|
{ path: ["memory", "critical"], label: "Memory critical" },
|
||||||
{ path: ["memory", "swap_critical"], label: "Swap critical" },
|
{ path: ["memory", "swap_high"], label: "Swap high" },
|
||||||
|
{ path: ["memory", "available_min"], label: "Memory available minimum" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// ── Heat ────────────────────────────────────────────────────────
|
// ── Heat ────────────────────────────────────────────────────────
|
||||||
@@ -282,6 +284,11 @@ function computeVisualRange(
|
|||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function HealthThresholds() {
|
export function HealthThresholds() {
|
||||||
|
const t = useT()
|
||||||
|
const tFallback = (key: string, fallback: string) => {
|
||||||
|
const translated = t(key)
|
||||||
|
return translated === key ? fallback : translated
|
||||||
|
}
|
||||||
const [tree, setTree] = useState<ThresholdsTree | null>(null)
|
const [tree, setTree] = useState<ThresholdsTree | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [editMode, setEditMode] = useState(false)
|
const [editMode, setEditMode] = useState(false)
|
||||||
@@ -299,7 +306,7 @@ export function HealthThresholds() {
|
|||||||
)
|
)
|
||||||
if (res?.success && res.thresholds) setTree(res.thresholds)
|
if (res?.success && res.thresholds) setTree(res.thresholds)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load thresholds")
|
setError(err instanceof Error ? err.message : t("settings.healthThresholds.loadFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -322,7 +329,7 @@ export function HealthThresholds() {
|
|||||||
if (trimmed === "") continue
|
if (trimmed === "") continue
|
||||||
const num = Number(trimmed)
|
const num = Number(trimmed)
|
||||||
if (!isFinite(num)) {
|
if (!isFinite(num)) {
|
||||||
setError(`Invalid value for ${key}: must be a number`)
|
setError(t("settings.healthThresholds.invalidValue", { key }))
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// Walk into payload mirroring the path
|
// Walk into payload mirroring the path
|
||||||
@@ -362,7 +369,7 @@ export function HealthThresholds() {
|
|||||||
{ method: "PUT", body: JSON.stringify(payload) },
|
{ method: "PUT", body: JSON.stringify(payload) },
|
||||||
)
|
)
|
||||||
if (!data.success || !data.thresholds) {
|
if (!data.success || !data.thresholds) {
|
||||||
setError(data.message || "Save failed")
|
setError(data.message || t("status.saveFailed"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTree(data.thresholds)
|
setTree(data.thresholds)
|
||||||
@@ -371,14 +378,16 @@ export function HealthThresholds() {
|
|||||||
setSavedFlash(true)
|
setSavedFlash(true)
|
||||||
setTimeout(() => setSavedFlash(false), 2000)
|
setTimeout(() => setSavedFlash(false), 2000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Network error while saving")
|
setError(err instanceof Error ? err.message : t("status.networkErrorWhileSaving"))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResetSection = async (sectionId: string) => {
|
const handleResetSection = async (sectionId: string) => {
|
||||||
if (!confirm(`Reset all "${SECTIONS.find((s) => s.id === sectionId)?.title}" thresholds to recommended values?`))
|
const section = SECTIONS.find((s) => s.id === sectionId)
|
||||||
|
const sectionTitle = section ? tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title) : sectionId
|
||||||
|
if (!confirm(t("settings.healthThresholds.resetSectionConfirm", { section: sectionTitle })))
|
||||||
return
|
return
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
|
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
|
||||||
@@ -386,7 +395,7 @@ export function HealthThresholds() {
|
|||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
)
|
)
|
||||||
if (!data.success || !data.thresholds) {
|
if (!data.success || !data.thresholds) {
|
||||||
setError(data.message || "Reset failed")
|
setError(data.message || t("settings.healthThresholds.resetFailed"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTree(data.thresholds)
|
setTree(data.thresholds)
|
||||||
@@ -400,25 +409,25 @@ export function HealthThresholds() {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Network error while resetting")
|
setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResetAll = async () => {
|
const handleResetAll = async () => {
|
||||||
if (!confirm("Reset ALL thresholds to recommended values? This affects every section.")) return
|
if (!confirm(t("settings.healthThresholds.resetAllConfirm"))) return
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
|
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
|
||||||
"/api/health/thresholds/reset",
|
"/api/health/thresholds/reset",
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
)
|
)
|
||||||
if (!data.success || !data.thresholds) {
|
if (!data.success || !data.thresholds) {
|
||||||
setError(data.message || "Reset failed")
|
setError(data.message || t("settings.healthThresholds.resetFailed"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTree(data.thresholds)
|
setTree(data.thresholds)
|
||||||
setPending({})
|
setPending({})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Network error while resetting")
|
setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,7 +450,7 @@ export function HealthThresholds() {
|
|||||||
const isCustomised = leaf.customised && !(key in pending)
|
const isCustomised = leaf.customised && !(key in pending)
|
||||||
const customisedClass = "border-blue-500 bg-blue-500/10 focus-visible:border-blue-500"
|
const customisedClass = "border-blue-500 bg-blue-500/10 focus-visible:border-blue-500"
|
||||||
const fieldClass = isCustomised ? customisedClass : severityClass
|
const fieldClass = isCustomised ? customisedClass : severityClass
|
||||||
const recommendedTooltip = `Recommended: ${leaf.recommended}${leaf.unit}`
|
const recommendedTooltip = `${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${leaf.unit}`
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex items-center justify-between gap-2 py-1.5 px-1">
|
<div key={key} className="flex items-center justify-between gap-2 py-1.5 px-1">
|
||||||
<span className="text-xs sm:text-sm text-foreground/90 min-w-0">
|
<span className="text-xs sm:text-sm text-foreground/90 min-w-0">
|
||||||
@@ -524,12 +533,12 @@ export function HealthThresholds() {
|
|||||||
value={val}
|
value={val}
|
||||||
onChange={(e) => setPending((p) => ({ ...p, [key]: e.target.value }))}
|
onChange={(e) => setPending((p) => ({ ...p, [key]: e.target.value }))}
|
||||||
className={`absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background ${handleClass}`}
|
className={`absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background ${handleClass}`}
|
||||||
title={`Recommended: ${leaf.recommended}${unit}`}
|
title={`${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${unit}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
|
<div className="grid grid-cols-2 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
<span>OK < {val}{unit}</span>
|
<span>{t("settings.healthThresholds.ok")} < {val}{unit}</span>
|
||||||
<span className="text-right">{severity === "critical" ? "CRIT" : "WARN"} > {val}{unit}</span>
|
<span className="text-right">{severity === "critical" ? t("settings.healthThresholds.crit") : t("settings.healthThresholds.warn")} > {val}{unit}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -641,7 +650,7 @@ export function HealthThresholds() {
|
|||||||
value={wVal}
|
value={wVal}
|
||||||
onChange={(e) => setVal(wKey, Number(e.target.value), cVal, true)}
|
onChange={(e) => setVal(wKey, Number(e.target.value), cVal, true)}
|
||||||
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-amber-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-amber-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
|
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-amber-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-amber-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
|
||||||
title={`Warning (recommended: ${wLeaf.recommended}${unit})`}
|
title={`${t("settings.healthThresholds.warning")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${wLeaf.recommended}${unit})`}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -652,7 +661,7 @@ export function HealthThresholds() {
|
|||||||
value={cVal}
|
value={cVal}
|
||||||
onChange={(e) => setVal(cKey, Number(e.target.value), wVal, false)}
|
onChange={(e) => setVal(cKey, Number(e.target.value), wVal, false)}
|
||||||
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-red-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
|
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-red-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background"
|
||||||
title={`Critical (recommended: ${cLeaf.recommended}${unit})`}
|
title={`${t("settings.healthThresholds.critical")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${cLeaf.recommended}${unit})`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -660,9 +669,9 @@ export function HealthThresholds() {
|
|||||||
"warn" starts and ends without having to read the handles. */}
|
"warn" starts and ends without having to read the handles. */}
|
||||||
{!options?.hideLabels && (
|
{!options?.hideLabels && (
|
||||||
<div className="grid grid-cols-3 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
|
<div className="grid grid-cols-3 gap-2 mt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
<span>OK < {wVal}{unit}</span>
|
<span>{t("settings.healthThresholds.ok")} < {wVal}{unit}</span>
|
||||||
<span className="text-center">WARN {wVal}–{cVal}{unit}</span>
|
<span className="text-center">{t("settings.healthThresholds.warn")} {wVal}–{cVal}{unit}</span>
|
||||||
<span className="text-right">CRIT > {cVal}{unit}</span>
|
<span className="text-right">{t("settings.healthThresholds.crit")} > {cVal}{unit}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -675,14 +684,14 @@ export function HealthThresholds() {
|
|||||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<SlidersHorizontal className="h-5 w-5 text-amber-500" />
|
<SlidersHorizontal className="h-5 w-5 text-amber-500" />
|
||||||
<CardTitle>Health Monitor Thresholds</CardTitle>
|
<CardTitle>{t("settings.healthThresholds.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{savedFlash && (
|
{savedFlash && (
|
||||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
Saved
|
{t("status.saved")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
@@ -692,7 +701,7 @@ export function HealthThresholds() {
|
|||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||||
@@ -704,7 +713,7 @@ export function HealthThresholds() {
|
|||||||
) : (
|
) : (
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
Save
|
{t("actions.save")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -712,17 +721,17 @@ export function HealthThresholds() {
|
|||||||
<button
|
<button
|
||||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground flex items-center gap-1.5"
|
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground flex items-center gap-1.5"
|
||||||
onClick={handleResetAll}
|
onClick={handleResetAll}
|
||||||
title="Reset every threshold to its recommended value"
|
title={t("settings.healthThresholds.resetAllTitle")}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3 w-3" />
|
<RotateCcw className="h-3 w-3" />
|
||||||
Reset all
|
{t("actions.resetAll")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
>
|
>
|
||||||
<Settings2 className="h-3 w-3" />
|
<Settings2 className="h-3 w-3" />
|
||||||
Edit
|
{t("actions.edit")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -730,19 +739,23 @@ export function HealthThresholds() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
The Health Monitor and notifications fire when these thresholds are crossed.
|
{t("settings.healthThresholds.description")}
|
||||||
Drag the amber handle to set the warning level and the red handle to set the
|
|
||||||
critical level. Values that differ from the recommended default appear in blue —
|
|
||||||
hover a handle to see the recommendation, or use Reset to restore it.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
{/* Intentional exception to the global "edit mode contrast"
|
||||||
|
rule: the numeric inputs already carry semantic colored
|
||||||
|
backgrounds (red critical, amber warning, blue customised)
|
||||||
|
which are meaningful and more graphical than a plain form.
|
||||||
|
A sunken selector would either erase those tints or force
|
||||||
|
us to `!important` every one — cleaner to keep this card
|
||||||
|
untouched in edit mode. */}
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : !tree ? (
|
) : !tree ? (
|
||||||
<div className="text-sm text-muted-foreground">Failed to load thresholds.</div>
|
<div className="text-sm text-muted-foreground">{t("settings.healthThresholds.loadFailed")}</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -767,13 +780,13 @@ export function HealthThresholds() {
|
|||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
<h4 className="text-sm font-medium">{section.title}</h4>
|
<h4 className="text-sm font-medium">{tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title)}</h4>
|
||||||
</div>
|
</div>
|
||||||
{editMode && (
|
{editMode && (
|
||||||
<button
|
<button
|
||||||
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors flex items-center justify-center"
|
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors flex items-center justify-center"
|
||||||
onClick={() => handleResetSection(section.id)}
|
onClick={() => handleResetSection(section.id)}
|
||||||
title="Reset this section to recommended"
|
title={t("settings.healthThresholds.resetSectionTitle")}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3 w-3" />
|
<RotateCcw className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -781,7 +794,7 @@ export function HealthThresholds() {
|
|||||||
</div>
|
</div>
|
||||||
{section.description && (
|
{section.description && (
|
||||||
<p className="text-[11px] text-muted-foreground mb-1.5 leading-snug">
|
<p className="text-[11px] text-muted-foreground mb-1.5 leading-snug">
|
||||||
{section.description}
|
{tFallback(`settings.healthThresholds.sections.${section.id}.description`, section.description)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
@@ -800,20 +813,36 @@ export function HealthThresholds() {
|
|||||||
))
|
))
|
||||||
) : section.id === "memory" ? (
|
) : section.id === "memory" ? (
|
||||||
// Memory & Swap is special: warn/crit pair for
|
// Memory & Swap is special: warn/crit pair for
|
||||||
// RAM, plus a single Swap threshold that has no
|
// RAM, plus the swap-pressure pair. Swap
|
||||||
// companion (it's a "critical only" metric).
|
// CRITICAL fires only when both conditions hold
|
||||||
// Both use sliders so the section reads as one
|
// — swap file above `swap_high` AND available
|
||||||
// visual language end to end.
|
// RAM below `available_min`. Rendering the two
|
||||||
|
// sliders one under the other under a shared
|
||||||
|
// header reads as "the two knobs of one signal".
|
||||||
<>
|
<>
|
||||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
|
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
|
||||||
RAM
|
{t("settings.healthThresholds.ram")}
|
||||||
</div>
|
</div>
|
||||||
{renderThresholdRange(["memory"])}
|
{renderThresholdRange(["memory"])}
|
||||||
<div className="border-t border-border/40">
|
<div className="border-t border-border/40 pt-1.5 mt-1.5">
|
||||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1 pt-1.5">
|
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
|
||||||
Swap (critical only)
|
{t("settings.healthThresholds.swapPressure")}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground px-1 pt-1 pb-1 leading-snug">
|
||||||
|
{t("settings.healthThresholds.swapPressureHint")}
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/80 px-1 pt-1">
|
||||||
|
{t("settings.healthThresholds.swapHighLabel")}
|
||||||
|
</div>
|
||||||
|
{renderSingleThresholdSlider(["memory", "swap_high"], "critical")}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground/80 px-1">
|
||||||
|
{t("settings.healthThresholds.availableMinLabel")}
|
||||||
|
</div>
|
||||||
|
{renderSingleThresholdSlider(["memory", "available_min"], "warning")}
|
||||||
</div>
|
</div>
|
||||||
{renderSingleThresholdSlider(["memory", "swap_critical"], "critical")}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : section.fields.length === 2 &&
|
) : section.fields.length === 2 &&
|
||||||
|
|||||||
+867
-786
File diff suppressed because it is too large
Load Diff
@@ -9,19 +9,22 @@ import { Activity, TrendingDown, TrendingUp, Minus, RefreshCw, Wifi, FileText, S
|
|||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from "recharts"
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from "recharts"
|
||||||
import { useIsMobile } from "../hooks/use-mobile"
|
import { useIsMobile } from "../hooks/use-mobile"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
|
type TFunction = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
const TIMEFRAME_OPTIONS = [
|
const TIMEFRAME_OPTIONS = [
|
||||||
{ value: "hour", label: "1 Hour" },
|
{ value: "hour", labelKey: "network.latency.timeframes.hour" },
|
||||||
{ value: "6hour", label: "6 Hours" },
|
{ value: "6hour", labelKey: "network.latency.timeframes.sixHours" },
|
||||||
{ value: "day", label: "24 Hours" },
|
{ value: "day", labelKey: "network.latency.timeframes.day" },
|
||||||
{ value: "3day", label: "3 Days" },
|
{ value: "3day", labelKey: "network.latency.timeframes.threeDays" },
|
||||||
{ value: "week", label: "7 Days" },
|
{ value: "week", labelKey: "network.latency.timeframes.week" },
|
||||||
]
|
]
|
||||||
|
|
||||||
const TARGET_OPTIONS = [
|
const TARGET_OPTIONS = [
|
||||||
{ value: "gateway", label: "Gateway (Router)", shortLabel: "Gateway", realtime: false },
|
{ value: "gateway", labelKey: "network.latency.targets.gateway", shortLabelKey: "network.latency.targets.gatewayShort", realtime: false },
|
||||||
{ value: "cloudflare", label: "Cloudflare (1.1.1.1)", shortLabel: "Cloudflare", realtime: true },
|
{ value: "cloudflare", labelKey: "network.latency.targets.cloudflare", shortLabelKey: "network.latency.targets.cloudflareShort", realtime: true },
|
||||||
{ value: "google", label: "Google DNS (8.8.8.8)", shortLabel: "Google DNS", realtime: true },
|
{ value: "google", labelKey: "network.latency.targets.google", shortLabelKey: "network.latency.targets.googleShort", realtime: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Realtime test configuration
|
// Realtime test configuration
|
||||||
@@ -60,7 +63,22 @@ interface LatencyDetailModalProps {
|
|||||||
currentLatency?: number
|
currentLatency?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
const getLatencyTimeframeLabel = (value: string, t: TFunction): string =>
|
||||||
|
TIMEFRAME_OPTIONS.find((option) => option.value === value)
|
||||||
|
? t(TIMEFRAME_OPTIONS.find((option) => option.value === value)!.labelKey)
|
||||||
|
: value
|
||||||
|
|
||||||
|
const getLatencyTargetLabel = (value: string, t: TFunction): string =>
|
||||||
|
TARGET_OPTIONS.find((option) => option.value === value)
|
||||||
|
? t(TARGET_OPTIONS.find((option) => option.value === value)!.labelKey)
|
||||||
|
: value
|
||||||
|
|
||||||
|
const getLatencyTargetShortLabel = (value: string, t: TFunction): string =>
|
||||||
|
TARGET_OPTIONS.find((option) => option.value === value)
|
||||||
|
? t(TARGET_OPTIONS.find((option) => option.value === value)!.shortLabelKey)
|
||||||
|
: value
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label, t }: any) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
const entry = payload[0]
|
const entry = payload[0]
|
||||||
const data = entry?.payload
|
const data = entry?.payload
|
||||||
@@ -76,17 +94,17 @@ const CustomTooltip = ({ active, payload, label }: any) => {
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-green-500" />
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-green-500" />
|
||||||
<span className="text-xs text-gray-300 min-w-[60px]">Min:</span>
|
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.min")}:</span>
|
||||||
<span className="text-sm font-semibold text-green-400">{data.min} ms</span>
|
<span className="text-sm font-semibold text-green-400">{data.min} ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
|
||||||
<span className="text-xs text-gray-300 min-w-[60px]">Avg:</span>
|
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.avg")}:</span>
|
||||||
<span className="text-sm font-semibold text-white">{data.value} ms</span>
|
<span className="text-sm font-semibold text-white">{data.value} ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-red-500" />
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-red-500" />
|
||||||
<span className="text-xs text-gray-300 min-w-[60px]">Max:</span>
|
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.max")}:</span>
|
||||||
<span className="text-sm font-semibold text-red-400">{data.max} ms</span>
|
<span className="text-sm font-semibold text-red-400">{data.max} ms</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -94,14 +112,14 @@ const CustomTooltip = ({ active, payload, label }: any) => {
|
|||||||
// Simple latency display for single data points
|
// Simple latency display for single data points
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
|
||||||
<span className="text-xs text-gray-300 min-w-[60px]">Latency:</span>
|
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.latency")}:</span>
|
||||||
<span className="text-sm font-semibold text-white">{entry.value} ms</span>
|
<span className="text-sm font-semibold text-white">{entry.value} ms</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{packetLoss !== undefined && packetLoss > 0 && (
|
{packetLoss !== undefined && packetLoss > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-orange-500" />
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-orange-500" />
|
||||||
<span className="text-xs text-gray-300 min-w-[60px]">Pkt Loss:</span>
|
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.packetLossShort")}:</span>
|
||||||
<span className="text-sm font-semibold text-orange-400">{packetLoss}%</span>
|
<span className="text-sm font-semibold text-orange-400">{packetLoss}%</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -118,20 +136,38 @@ const getStatusColor = (latency: number) => {
|
|||||||
return "#22c55e"
|
return "#22c55e"
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusInfo = (latency: number | null) => {
|
const getStatusInfo = (latency: number | null, t: TFunction) => {
|
||||||
if (latency === null || latency === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
if (latency === null || latency === 0) return { status: t("common.notAvailable"), color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||||
if (latency < 50) return { status: "Excellent", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
if (latency < 50) return { status: t("network.latency.status.excellent"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||||
if (latency < 100) return { status: "Good", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
if (latency < 100) return { status: t("network.latency.status.good"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||||
if (latency < 200) return { status: "Fair", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
if (latency < 200) return { status: t("network.latency.status.fair"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||||
return { status: "Poor", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
return { status: t("network.latency.status.poor"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusText = (latency: number | null): string => {
|
const getStatusKey = (latency: number | null): "na" | "excellent" | "good" | "fair" | "poor" => {
|
||||||
if (latency === null || latency === 0) return "N/A"
|
if (latency === null || latency === 0) return "na"
|
||||||
if (latency < 50) return "Excellent"
|
if (latency < 50) return "excellent"
|
||||||
if (latency < 100) return "Good"
|
if (latency < 100) return "good"
|
||||||
if (latency < 200) return "Fair"
|
if (latency < 200) return "fair"
|
||||||
return "Poor"
|
return "poor"
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (latency: number | null, t: TFunction): string => {
|
||||||
|
const key = getStatusKey(latency)
|
||||||
|
return key === "na" ? t("common.notAvailable") : t(`network.latency.status.${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatReportDuration = (seconds: number | undefined, t: TFunction, compact = false): string => {
|
||||||
|
if (!seconds || seconds <= 0) {
|
||||||
|
return compact ? t("network.latency.report.realTime") : t("network.latency.report.testPeriod")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seconds < 60) {
|
||||||
|
return t(compact ? "network.latency.report.secondsShort" : "network.latency.report.seconds", { count: seconds })
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.max(1, Math.round(seconds / 60))
|
||||||
|
return t(compact ? "network.latency.report.minutesShort" : "network.latency.report.minutes", { count: minutes })
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReportData {
|
interface ReportData {
|
||||||
@@ -145,9 +181,10 @@ interface ReportData {
|
|||||||
testDuration?: number
|
testDuration?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateLatencyReport = (report: ReportData) => {
|
const generateLatencyReport = (report: ReportData, t: TFunction) => {
|
||||||
const now = new Date().toLocaleString()
|
const now = new Date().toLocaleString()
|
||||||
const logoUrl = `${window.location.origin}/images/proxmenux-logo.png`
|
const logoUrl = `${window.location.origin}/images/proxmenux-logo.png`
|
||||||
|
const htmlLang = document.documentElement.lang || "en"
|
||||||
|
|
||||||
// Calculate stats for realtime results - all values are individual ping measurements in latency_avg
|
// Calculate stats for realtime results - all values are individual ping measurements in latency_avg
|
||||||
const validRealtimeValues = report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
|
const validRealtimeValues = report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
|
||||||
@@ -160,29 +197,57 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
} : null
|
} : null
|
||||||
|
|
||||||
const statusText = report.isRealtime
|
const statusText = report.isRealtime
|
||||||
? getStatusText(realtimeStats?.current ?? null)
|
? getStatusText(realtimeStats?.current ?? null, t)
|
||||||
: getStatusText(report.stats.current)
|
: getStatusText(report.stats.current, t)
|
||||||
|
|
||||||
// Colors matching Lynis report
|
// Colors matching Lynis report
|
||||||
const statusColorMap: Record<string, string> = {
|
const statusColorMap: Record<string, string> = {
|
||||||
"Excellent": "#16a34a",
|
excellent: "#16a34a",
|
||||||
"Good": "#16a34a",
|
good: "#16a34a",
|
||||||
"Fair": "#ca8a04",
|
fair: "#ca8a04",
|
||||||
"Poor": "#dc2626",
|
poor: "#dc2626",
|
||||||
"N/A": "#64748b"
|
na: "#64748b",
|
||||||
}
|
}
|
||||||
const statusColor = statusColorMap[statusText] || "#64748b"
|
const statusKey = report.isRealtime
|
||||||
|
? getStatusKey(realtimeStats?.current ?? null)
|
||||||
|
: getStatusKey(report.stats.current)
|
||||||
|
const statusColor = statusColorMap[statusKey] || "#64748b"
|
||||||
|
|
||||||
const timeframeLabel = TIMEFRAME_OPTIONS.find(t => t.value === report.timeframe)?.label || report.timeframe
|
const timeframeLabel = getLatencyTimeframeLabel(report.timeframe, t)
|
||||||
|
const reportId = `PMXL-${Date.now().toString(36).toUpperCase()}`
|
||||||
|
const notAvailable = t("common.notAvailable")
|
||||||
|
const modeLabel = report.isRealtime
|
||||||
|
? t("network.latency.report.realTimeTest")
|
||||||
|
: t("network.latency.report.historicalAnalysis")
|
||||||
|
const realtimeDurationText = formatReportDuration(report.testDuration, t)
|
||||||
|
const realtimePacketLossText =
|
||||||
|
realtimeStats && realtimeStats.avgPacketLoss > 0
|
||||||
|
? `<span style="color:#dc2626">${t("network.latency.report.averagePacketLoss", {
|
||||||
|
value: realtimeStats.avgPacketLoss.toFixed(1),
|
||||||
|
})}</span>`
|
||||||
|
: `<span style="color:#16a34a">${t("network.latency.report.noPacketLoss")}</span>`
|
||||||
|
const testPeriodValue = report.isRealtime
|
||||||
|
? formatReportDuration(report.testDuration, t, true)
|
||||||
|
: timeframeLabel
|
||||||
|
const targetIpLabel =
|
||||||
|
report.target === "gateway"
|
||||||
|
? t("network.latency.report.defaultGateway")
|
||||||
|
: report.target === "cloudflare"
|
||||||
|
? "1.1.1.1"
|
||||||
|
: "8.8.8.8"
|
||||||
|
const detailSectionNumber =
|
||||||
|
(report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0)
|
||||||
|
? "6"
|
||||||
|
: "5"
|
||||||
|
|
||||||
// Build test results table for realtime mode - each row is now an individual ping measurement
|
// Build test results table for realtime mode - each row is now an individual ping measurement
|
||||||
const realtimeTableRows = report.realtimeResults.map((r, i) => `
|
const realtimeTableRows = report.realtimeResults.map((r, i) => `
|
||||||
<tr${r.packet_loss > 0 ? ' class="warn"' : ''}>
|
<tr${r.packet_loss > 0 ? ' class="warn"' : ''}>
|
||||||
<td>${i + 1}</td>
|
<td>${i + 1}</td>
|
||||||
<td>${new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</td>
|
<td>${new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</td>
|
||||||
<td style="font-weight:600;color:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}">${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : 'Failed'}</td>
|
<td style="font-weight:600;color:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}">${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : t("network.latency.report.failed")}</td>
|
||||||
<td${r.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${r.packet_loss}%</td>
|
<td${r.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${r.packet_loss}%</td>
|
||||||
<td><span class="f-tag" style="background:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}15;color:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}">${getStatusText(r.latency_avg)}</span></td>
|
<td><span class="f-tag" style="background:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}15;color:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}">${getStatusText(r.latency_avg, t)}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')
|
`).join('')
|
||||||
|
|
||||||
@@ -199,9 +264,9 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
<tr${d.packet_loss && d.packet_loss > 0 ? ' class="warn"' : ''}>
|
<tr${d.packet_loss && d.packet_loss > 0 ? ' class="warn"' : ''}>
|
||||||
<td>${i + 1}</td>
|
<td>${i + 1}</td>
|
||||||
<td>${new Date(d.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
|
<td>${new Date(d.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
|
||||||
<td style="font-weight:600;color:${statusColorMap[getStatusText(d.value)] || '#64748b'}">${d.value !== null ? d.value.toFixed(1) + ' ms' : 'Failed'}</td>
|
<td style="font-weight:600;color:${statusColorMap[getStatusKey(d.value)] || '#64748b'}">${d.value !== null ? d.value.toFixed(1) + ' ms' : t("network.latency.report.failed")}</td>
|
||||||
<td${d.packet_loss && d.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${d.packet_loss?.toFixed(1) ?? 0}%</td>
|
<td${d.packet_loss && d.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${d.packet_loss?.toFixed(1) ?? 0}%</td>
|
||||||
<td><span class="f-tag" style="background:${statusColorMap[getStatusText(d.value)] || '#64748b'}15;color:${statusColorMap[getStatusText(d.value)] || '#64748b'}">${getStatusText(d.value)}</span></td>
|
<td><span class="f-tag" style="background:${statusColorMap[getStatusKey(d.value)] || '#64748b'}15;color:${statusColorMap[getStatusKey(d.value)] || '#64748b'}">${getStatusText(d.value, t)}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')
|
`).join('')
|
||||||
|
|
||||||
@@ -210,7 +275,7 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
? report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
|
? report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
|
||||||
: report.data.map(d => d.value || 0)
|
: report.data.map(d => d.value || 0)
|
||||||
|
|
||||||
let chartSvg = '<p style="text-align:center;color:#64748b;padding:20px;">Not enough data points for chart</p>'
|
let chartSvg = `<p style="text-align:center;color:#64748b;padding:20px;">${t("network.latency.report.notEnoughData")}</p>`
|
||||||
if (chartData.length >= 2) {
|
if (chartData.length >= 2) {
|
||||||
const rawMin = Math.min(...chartData)
|
const rawMin = Math.min(...chartData)
|
||||||
const rawMax = Math.max(...chartData)
|
const rawMax = Math.max(...chartData)
|
||||||
@@ -253,17 +318,17 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
<text x="${padding - 5}" y="${height - padding + 4}" font-size="9" fill="#64748b" text-anchor="end">${Math.round(minVal)}ms</text>
|
<text x="${padding - 5}" y="${height - padding + 4}" font-size="9" fill="#64748b" text-anchor="end">${Math.round(minVal)}ms</text>
|
||||||
<polygon points="${areaPoints}" fill="url(#areaGrad)"/>
|
<polygon points="${areaPoints}" fill="url(#areaGrad)"/>
|
||||||
<polyline points="${points}" fill="none" stroke="#3b82f6" stroke-width="2"/>
|
<polyline points="${points}" fill="none" stroke="#3b82f6" stroke-width="2"/>
|
||||||
<text x="${width / 2}" y="${height - 5}" font-size="9" fill="#64748b" text-anchor="middle">${chartData.length} samples</text>
|
<text x="${width / 2}" y="${height - 5}" font-size="9" fill="#64748b" text-anchor="middle">${t("network.latency.report.samples", { count: chartData.length })}</text>
|
||||||
</svg>
|
</svg>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = `<!DOCTYPE html>
|
const html = `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="${htmlLang}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Network Latency Report - ${report.targetLabel}</title>
|
<title>${t("network.latency.report.title")} - ${report.targetLabel}</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; }
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; }
|
||||||
@@ -463,11 +528,11 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
<div class="top-bar no-print">
|
<div class="top-bar no-print">
|
||||||
<div class="top-bar-left">
|
<div class="top-bar-left">
|
||||||
<div>
|
<div>
|
||||||
<div class="top-bar-title">ProxMenux Network Latency Report</div>
|
<div class="top-bar-title">${t("network.latency.report.topBarTitle")}</div>
|
||||||
<div class="top-bar-subtitle">Review the report, then print or save as PDF</div>
|
<div class="top-bar-subtitle">${t("network.latency.report.topBarSubtitle")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="window.print()">Print / Save as PDF</button>
|
<button onclick="window.print()">${t("network.latency.report.printSavePdf")}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -475,21 +540,21 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
<div class="rpt-header-left">
|
<div class="rpt-header-left">
|
||||||
<img src="${logoUrl}" alt="ProxMenux" onerror="this.style.display='none'" />
|
<img src="${logoUrl}" alt="ProxMenux" onerror="this.style.display='none'" />
|
||||||
<div>
|
<div>
|
||||||
<h1>Network Latency Report</h1>
|
<h1>${t("network.latency.report.title")}</h1>
|
||||||
<p>ProxMenux Monitor - Network Performance Analysis</p>
|
<p>${t("network.latency.report.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rpt-header-right">
|
<div class="rpt-header-right">
|
||||||
<div><strong>Date:</strong> ${now}</div>
|
<div><strong>${t("network.latency.report.date")}:</strong> ${now}</div>
|
||||||
<div><strong>Target:</strong> ${report.targetLabel}</div>
|
<div><strong>${t("network.latency.report.target")}:</strong> ${report.targetLabel}</div>
|
||||||
<div><strong>Mode:</strong> ${report.isRealtime ? 'Real-time Test' : 'Historical Analysis'}</div>
|
<div><strong>${t("network.latency.report.mode")}:</strong> ${modeLabel}</div>
|
||||||
<div class="rid">ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
|
<div class="rid">ID: ${reportId}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 1. Executive Summary -->
|
<!-- 1. Executive Summary -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">1. Executive Summary</div>
|
<div class="section-title">1. ${t("network.latency.report.executiveSummary")}</div>
|
||||||
<div class="exec-box">
|
<div class="exec-box">
|
||||||
<div class="latency-gauge">
|
<div class="latency-gauge">
|
||||||
<svg viewBox="0 0 120 90" width="160" height="120">
|
<svg viewBox="0 0 120 90" width="160" height="120">
|
||||||
@@ -508,35 +573,41 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
<text x="98" y="87" font-size="7" fill="#64748b">300+</text>
|
<text x="98" y="87" font-size="7" fill="#64748b">300+</text>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="gauge-value" style="color:${statusColor};">
|
<div class="gauge-value" style="color:${statusColor};">
|
||||||
<span class="gauge-num">${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? 'N/A') : report.stats.avg}</span>
|
<span class="gauge-num">${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? notAvailable) : report.stats.avg}</span>
|
||||||
<span class="gauge-unit">ms</span>
|
<span class="gauge-unit">ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gauge-status" style="color:${statusColor};">${statusText}</div>
|
<div class="gauge-status" style="color:${statusColor};">${statusText}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="exec-text">
|
<div class="exec-text">
|
||||||
<h3>Network Latency Assessment${report.isRealtime ? ' (Real-time)' : ''}</h3>
|
<h3>${t("network.latency.report.assessmentTitle")}${report.isRealtime ? ` (${t("network.latency.report.realTime")})` : ""}</h3>
|
||||||
<p>
|
<p>
|
||||||
${report.isRealtime
|
${report.isRealtime
|
||||||
? `Real-time latency test to <strong>${report.targetLabel}</strong> with <strong>${report.realtimeResults.length} samples</strong> collected over ${report.testDuration ? Math.round(report.testDuration / 60) + ' minute(s)' : 'the test period'}.
|
? `${t("network.latency.report.realtimeSummary", {
|
||||||
Average latency: <strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? 'N/A'} ms</strong>.
|
target: `<strong>${report.targetLabel}</strong>`,
|
||||||
${realtimeStats && realtimeStats.avgPacketLoss > 0 ? `<span style="color:#dc2626">Average packet loss: ${realtimeStats.avgPacketLoss.toFixed(1)}%.</span>` : '<span style="color:#16a34a">No packet loss detected.</span>'}`
|
count: `<strong>${report.realtimeResults.length}</strong>`,
|
||||||
: `Historical latency analysis to <strong>Gateway</strong> over <strong>${timeframeLabel.toLowerCase()}</strong>.
|
duration: realtimeDurationText,
|
||||||
<strong>${report.data.length} samples</strong> analyzed.
|
avg: `<strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? notAvailable} ms</strong>`,
|
||||||
Average latency: <strong style="color:${statusColor}">${report.stats.avg} ms</strong>.`
|
})} ${realtimePacketLossText}`
|
||||||
|
: `${t("network.latency.report.historicalSummary", {
|
||||||
|
target: t("network.latency.targets.gatewayShort"),
|
||||||
|
timeframe: timeframeLabel.toLowerCase(),
|
||||||
|
count: report.data.length,
|
||||||
|
avg: report.stats.avg,
|
||||||
|
})}`
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<div class="latency-range">
|
<div class="latency-range">
|
||||||
<div class="range-item">
|
<div class="range-item">
|
||||||
<span class="range-label">Minimum</span>
|
<span class="range-label">${t("network.labels.minimum")}</span>
|
||||||
<span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min} ms</span>
|
<span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min} ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="range-item">
|
<div class="range-item">
|
||||||
<span class="range-label">Average</span>
|
<span class="range-label">${t("network.labels.average")}</span>
|
||||||
<span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg} ms</span>
|
<span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg} ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="range-item">
|
<div class="range-item">
|
||||||
<span class="range-label">Maximum</span>
|
<span class="range-label">${t("network.labels.maximum")}</span>
|
||||||
<span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max} ms</span>
|
<span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max} ms</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -545,42 +616,40 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
|
|
||||||
<!-- 2. Statistics -->
|
<!-- 2. Statistics -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">2. Latency Statistics</div>
|
<div class="section-title">2. ${t("network.latency.report.latencyStatistics")}</div>
|
||||||
<div class="grid-4">
|
<div class="grid-4">
|
||||||
<div class="card card-c">
|
<div class="card card-c">
|
||||||
<div class="card-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? 'N/A') : report.stats.current}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
<div class="card-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? notAvailable) : report.stats.current}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||||
<div class="card-label">Current</div>
|
<div class="card-label">${t("network.labels.current")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card card-c">
|
<div class="card card-c">
|
||||||
<div class="card-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
<div class="card-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||||
<div class="card-label">Minimum</div>
|
<div class="card-label">${t("network.labels.minimum")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card card-c">
|
<div class="card card-c">
|
||||||
<div class="card-value">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
<div class="card-value">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||||
<div class="card-label">Average</div>
|
<div class="card-label">${t("network.labels.average")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card card-c">
|
<div class="card card-c">
|
||||||
<div class="card-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
<div class="card-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||||
<div class="card-label">Maximum</div>
|
<div class="card-label">${t("network.labels.maximum")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-3">
|
<div class="grid-3">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Sample Count</div>
|
<div class="card-label">${t("network.latency.report.sampleCount")}</div>
|
||||||
<div class="card-value">${report.isRealtime ? report.realtimeResults.length : report.data.length}</div>
|
<div class="card-value">${report.isRealtime ? report.realtimeResults.length : report.data.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Packet Loss (Avg)</div>
|
<div class="card-label">${t("network.latency.report.packetLossAvg")}</div>
|
||||||
<div class="card-value" style="color:${(report.isRealtime ? (realtimeStats?.avgPacketLoss ?? 0) : parseFloat(historyStats?.avgPacketLoss ?? '0')) > 0 ? '#dc2626' : '#16a34a'};">
|
<div class="card-value" style="color:${(report.isRealtime ? (realtimeStats?.avgPacketLoss ?? 0) : parseFloat(historyStats?.avgPacketLoss ?? '0')) > 0 ? '#dc2626' : '#16a34a'};">
|
||||||
${report.isRealtime ? (realtimeStats?.avgPacketLoss?.toFixed(1) ?? '0') : (historyStats?.avgPacketLoss ?? '0')}%
|
${report.isRealtime ? (realtimeStats?.avgPacketLoss?.toFixed(1) ?? '0') : (historyStats?.avgPacketLoss ?? '0')}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Test Period</div>
|
<div class="card-label">${t("network.latency.report.testPeriodLabel")}</div>
|
||||||
<div class="card-value" style="font-size:11px;">
|
<div class="card-value" style="font-size:11px;">
|
||||||
${report.isRealtime
|
${testPeriodValue}
|
||||||
? (report.testDuration ? Math.round(report.testDuration / 60) + ' min' : 'Real-time')
|
|
||||||
: timeframeLabel}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -588,7 +657,7 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
|
|
||||||
<!-- 3. Latency Graph (always section 3) -->
|
<!-- 3. Latency Graph (always section 3) -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">3. Latency Graph</div>
|
<div class="section-title">3. ${t("network.latency.report.latencyGraph")}</div>
|
||||||
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;">
|
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;">
|
||||||
${chartSvg}
|
${chartSvg}
|
||||||
</div>
|
</div>
|
||||||
@@ -596,37 +665,37 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
|
|
||||||
<!-- 4. Performance Thresholds (always section 4) -->
|
<!-- 4. Performance Thresholds (always section 4) -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">4. Performance Thresholds</div>
|
<div class="section-title">4. ${t("network.latency.report.performanceThresholds")}</div>
|
||||||
<div class="threshold-item">
|
<div class="threshold-item">
|
||||||
<div class="threshold-dot" style="background:#16a34a;"></div>
|
<div class="threshold-dot" style="background:#16a34a;"></div>
|
||||||
<p><strong>Excellent (< 50ms):</strong> Optimal for real-time applications, gaming, and video calls.</p>
|
<p><strong>${t("network.latency.status.excellent")} (< 50ms):</strong> ${t("network.latency.report.thresholdExcellent")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="threshold-item">
|
<div class="threshold-item">
|
||||||
<div class="threshold-dot" style="background:#16a34a;"></div>
|
<div class="threshold-dot" style="background:#16a34a;"></div>
|
||||||
<p><strong>Good (50-100ms):</strong> Acceptable for most applications with minimal impact.</p>
|
<p><strong>${t("network.latency.status.good")} (50-100ms):</strong> ${t("network.latency.report.thresholdGood")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="threshold-item">
|
<div class="threshold-item">
|
||||||
<div class="threshold-dot" style="background:#ca8a04;"></div>
|
<div class="threshold-dot" style="background:#ca8a04;"></div>
|
||||||
<p><strong>Fair (100-200ms):</strong> Noticeable delay. May affect VoIP and interactive applications.</p>
|
<p><strong>${t("network.latency.status.fair")} (100-200ms):</strong> ${t("network.latency.report.thresholdFair")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="threshold-item">
|
<div class="threshold-item">
|
||||||
<div class="threshold-dot" style="background:#dc2626;"></div>
|
<div class="threshold-dot" style="background:#dc2626;"></div>
|
||||||
<p><strong>Poor (> 200ms):</strong> Significant latency. Investigation recommended.</p>
|
<p><strong>${t("network.latency.status.poor")} (> 200ms):</strong> ${t("network.latency.report.thresholdPoor")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${report.isRealtime && report.realtimeResults.length > 0 ? `
|
${report.isRealtime && report.realtimeResults.length > 0 ? `
|
||||||
<!-- 5. Detailed Test Results (for Cloudflare / Google DNS) -->
|
<!-- 5. Detailed Test Results (for Cloudflare / Google DNS) -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">5. Detailed Test Results</div>
|
<div class="section-title">5. ${t("network.latency.report.detailedTestResults")}</div>
|
||||||
<table class="chk-tbl">
|
<table class="chk-tbl">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
<th>Time</th>
|
<th>${t("network.labels.time")}</th>
|
||||||
<th>Latency</th>
|
<th>${t("network.labels.latency")}</th>
|
||||||
<th>Packet Loss</th>
|
<th>${t("network.labels.packetLoss")}</th>
|
||||||
<th>Status</th>
|
<th>${t("network.labels.status")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -639,15 +708,15 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
${!report.isRealtime && report.data.length > 0 ? `
|
${!report.isRealtime && report.data.length > 0 ? `
|
||||||
<!-- 5. Detailed History (for Gateway) -->
|
<!-- 5. Detailed History (for Gateway) -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">5. Latency History (Last ${Math.min(20, report.data.length)} Records)</div>
|
<div class="section-title">5. ${t("network.latency.report.latencyHistory", { count: Math.min(20, report.data.length) })}</div>
|
||||||
<table class="chk-tbl">
|
<table class="chk-tbl">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
<th>Time</th>
|
<th>${t("network.labels.time")}</th>
|
||||||
<th>Latency</th>
|
<th>${t("network.labels.latency")}</th>
|
||||||
<th>Packet Loss</th>
|
<th>${t("network.labels.packetLoss")}</th>
|
||||||
<th>Status</th>
|
<th>${t("network.labels.status")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -659,41 +728,41 @@ ${!report.isRealtime && report.data.length > 0 ? `
|
|||||||
|
|
||||||
<!-- Methodology -->
|
<!-- Methodology -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">${(report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0) ? '6' : '5'}. Methodology</div>
|
<div class="section-title">${detailSectionNumber}. ${t("network.latency.report.methodology")}</div>
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Test Method</div>
|
<div class="card-label">${t("network.latency.report.testMethod")}</div>
|
||||||
<div class="card-value" style="font-size:12px;">ICMP Echo Request (Ping)</div>
|
<div class="card-value" style="font-size:12px;">${t("network.latency.report.icmpEchoRequest")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Samples per Test</div>
|
<div class="card-label">${t("network.latency.report.samplesPerTest")}</div>
|
||||||
<div class="card-value" style="font-size:12px;">3 consecutive pings</div>
|
<div class="card-value" style="font-size:12px;">${t("network.latency.report.threeConsecutivePings")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Target</div>
|
<div class="card-label">${t("network.latency.report.target")}</div>
|
||||||
<div class="card-value" style="font-size:12px;">${report.targetLabel}</div>
|
<div class="card-value" style="font-size:12px;">${report.targetLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Target IP</div>
|
<div class="card-label">${t("network.latency.report.targetIp")}</div>
|
||||||
<div class="card-value" style="font-size:12px;">${report.target === 'gateway' ? 'Default Gateway' : report.target === 'cloudflare' ? '1.1.1.1' : '8.8.8.8'}</div>
|
<div class="card-value" style="font-size:12px;">${targetIpLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<h4>Performance Assessment</h4>
|
<h4>${t("network.latency.report.performanceAssessment")}</h4>
|
||||||
<p>${
|
<p>${
|
||||||
statusText === 'Excellent' ? 'Network latency is excellent. No action required.' :
|
statusKey === 'excellent' ? t("network.latency.report.assessmentExcellent") :
|
||||||
statusText === 'Good' ? 'Network latency is within acceptable parameters.' :
|
statusKey === 'good' ? t("network.latency.report.assessmentGood") :
|
||||||
statusText === 'Fair' ? 'Network latency is elevated. Consider investigating network congestion or routing issues.' :
|
statusKey === 'fair' ? t("network.latency.report.assessmentFair") :
|
||||||
statusText === 'Poor' ? 'Network latency is critically high. Immediate investigation recommended.' :
|
statusKey === 'poor' ? t("network.latency.report.assessmentPoor") :
|
||||||
'Unable to determine network status.'
|
t("network.latency.report.assessmentUnknown")
|
||||||
}</p>
|
}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="rpt-footer">
|
<div class="rpt-footer">
|
||||||
<div>ProxMenux Monitor - Network Performance Report</div>
|
<div>${t("network.latency.report.footerTitle")}</div>
|
||||||
<div>Generated: ${now} | Report ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
|
<div>${t("network.latency.report.generated")}: ${now} | ${t("network.latency.report.reportId")}: ${reportId}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
@@ -706,6 +775,7 @@ ${!report.isRealtime && report.data.length > 0 ? `
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) {
|
export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) {
|
||||||
|
const t = useT()
|
||||||
const [timeframe, setTimeframe] = useState("hour")
|
const [timeframe, setTimeframe] = useState("hour")
|
||||||
const [target, setTarget] = useState("gateway")
|
const [target, setTarget] = useState("gateway")
|
||||||
const [data, setData] = useState<LatencyHistoryPoint[]>([])
|
const [data, setData] = useState<LatencyHistoryPoint[]>([])
|
||||||
@@ -882,7 +952,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
avg: Math.round((realtimeStats?.avg ?? 0) * 10) / 10,
|
avg: Math.round((realtimeStats?.avg ?? 0) * 10) / 10,
|
||||||
} : stats
|
} : stats
|
||||||
|
|
||||||
const statusInfo = getStatusInfo(displayStats.current)
|
const statusInfo = getStatusInfo(displayStats.current, t)
|
||||||
|
|
||||||
// Calculate test duration for report based on first and last result timestamps
|
// Calculate test duration for report based on first and last result timestamps
|
||||||
const testDuration = realtimeResults.length >= 2
|
const testDuration = realtimeResults.length >= 2
|
||||||
@@ -897,20 +967,20 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||||
<Wifi className="h-5 w-5 text-blue-500" />
|
<Wifi className="h-5 w-5 text-blue-500" />
|
||||||
Network Latency
|
{t("network.cards.latency")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex items-center gap-2 mt-1 flex-nowrap">
|
<div className="flex items-center gap-2 mt-1 flex-nowrap">
|
||||||
<Select value={target} onValueChange={setTarget}>
|
<Select value={target} onValueChange={setTarget}>
|
||||||
<SelectTrigger className="w-[140px] sm:w-[180px] h-8 text-xs shrink-0">
|
<SelectTrigger className="w-[140px] sm:w-[180px] h-8 text-xs shrink-0">
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{TARGET_OPTIONS.find(t => t.value === target)?.shortLabel || target}
|
{getLatencyTargetShortLabel(target, t)}
|
||||||
</span>
|
</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TARGET_OPTIONS.map(opt => (
|
{TARGET_OPTIONS.map(opt => (
|
||||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||||
{opt.label}
|
{t(opt.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -923,7 +993,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TIMEFRAME_OPTIONS.map(opt => (
|
{TIMEFRAME_OPTIONS.map(opt => (
|
||||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||||
{opt.label}
|
{t(opt.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -938,7 +1008,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
className="gap-1.5 text-red-500 border-red-500/30 hover:bg-red-500/10 shrink-0 h-8 px-3"
|
className="gap-1.5 text-red-500 border-red-500/30 hover:bg-red-500/10 shrink-0 h-8 px-3"
|
||||||
>
|
>
|
||||||
<Square className="h-3 w-3 fill-current" />
|
<Square className="h-3 w-3 fill-current" />
|
||||||
Stop
|
{t("network.latency.actions.stop")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -948,7 +1018,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
className="gap-1.5 shrink-0 h-8 px-3"
|
className="gap-1.5 shrink-0 h-8 px-3"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3 w-3" />
|
<RefreshCw className="h-3 w-3" />
|
||||||
Test Again
|
{t("network.latency.actions.testAgain")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -957,19 +1027,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => generateLatencyReport({
|
onClick={() => generateLatencyReport({
|
||||||
target,
|
target,
|
||||||
targetLabel: TARGET_OPTIONS.find(t => t.value === target)?.label || target,
|
targetLabel: getLatencyTargetLabel(target, t),
|
||||||
isRealtime,
|
isRealtime,
|
||||||
stats,
|
stats,
|
||||||
realtimeResults,
|
realtimeResults,
|
||||||
data,
|
data,
|
||||||
timeframe,
|
timeframe,
|
||||||
testDuration: isRealtime ? testDuration : undefined,
|
testDuration: isRealtime ? testDuration : undefined,
|
||||||
})}
|
}, t)}
|
||||||
disabled={isRealtime ? realtimeResults.length === 0 : data.length === 0}
|
disabled={isRealtime ? realtimeResults.length === 0 : data.length === 0}
|
||||||
className="gap-1.5 shrink-0 h-8 px-3"
|
className="gap-1.5 shrink-0 h-8 px-3"
|
||||||
>
|
>
|
||||||
<FileText className="h-3.5 w-3.5" />
|
<FileText className="h-3.5 w-3.5" />
|
||||||
Report
|
{t("network.latency.actions.report")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -977,8 +1047,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
{isRealtime && realtimeTesting && (
|
{isRealtime && realtimeTesting && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
|
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
|
||||||
<span>Testing... {Math.round(testProgress)}%</span>
|
<span>{t("network.latency.testingProgress", { percent: Math.round(testProgress) })}</span>
|
||||||
<span>{Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100)))}s remaining</span>
|
<span>{t("network.latency.secondsRemaining", { seconds: Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100))) })}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -992,7 +1062,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
{/* Stats Cards - Compact single row */}
|
{/* Stats Cards - Compact single row */}
|
||||||
<div className="flex items-center justify-between gap-1 mb-2 py-2 px-1 bg-muted/20 rounded-lg">
|
<div className="flex items-center justify-between gap-1 mb-2 py-2 px-1 bg-muted/20 rounded-lg">
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
<span className="text-[10px] text-muted-foreground">Current</span>
|
<span className="text-[10px] text-muted-foreground">{t("network.labels.current")}</span>
|
||||||
<span className="text-base font-bold" style={{ color: getStatusColor(displayStats.current || 0) }}>
|
<span className="text-base font-bold" style={{ color: getStatusColor(displayStats.current || 0) }}>
|
||||||
{displayStats.current || '-'}
|
{displayStats.current || '-'}
|
||||||
</span>
|
</span>
|
||||||
@@ -1000,19 +1070,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
<TrendingDown className="h-3 w-3 text-green-500 shrink-0" />
|
<TrendingDown className="h-3 w-3 text-green-500 shrink-0" />
|
||||||
<span className="text-[10px] text-muted-foreground">Min</span>
|
<span className="text-[10px] text-muted-foreground">{t("network.labels.min")}</span>
|
||||||
<span className="text-base font-bold text-green-500">{displayStats.min || '-'}</span>
|
<span className="text-base font-bold text-green-500">{displayStats.min || '-'}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">ms</span>
|
<span className="text-[10px] text-muted-foreground">ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
<Minus className="h-3 w-3 shrink-0" />
|
<Minus className="h-3 w-3 shrink-0" />
|
||||||
<span className="text-[10px] text-muted-foreground">Avg</span>
|
<span className="text-[10px] text-muted-foreground">{t("network.labels.avg")}</span>
|
||||||
<span className="text-base font-bold">{displayStats.avg || '-'}</span>
|
<span className="text-base font-bold">{displayStats.avg || '-'}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">ms</span>
|
<span className="text-[10px] text-muted-foreground">ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
<TrendingUp className="h-3 w-3 text-red-500 shrink-0" />
|
<TrendingUp className="h-3 w-3 text-red-500 shrink-0" />
|
||||||
<span className="text-[10px] text-muted-foreground">Max</span>
|
<span className="text-[10px] text-muted-foreground">{t("network.labels.max")}</span>
|
||||||
<span className="text-base font-bold text-red-500">{displayStats.max || '-'}</span>
|
<span className="text-base font-bold text-red-500">{displayStats.max || '-'}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">ms</span>
|
<span className="text-[10px] text-muted-foreground">ms</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1025,8 +1095,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
</Badge>
|
</Badge>
|
||||||
{isRealtime && (
|
{isRealtime && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{realtimeResults.length} sample{realtimeResults.length !== 1 ? 's' : ''} collected
|
{t("network.latency.samplesCollected", { count: realtimeResults.length })}
|
||||||
{realtimeStats?.packetLoss ? ` | ${realtimeStats.packetLoss}% packet loss` : ''}
|
{realtimeStats?.packetLoss ? ` | ${t("network.latency.packetLossValue", { value: realtimeStats.packetLoss })}` : ""}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1058,7 +1128,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
domain={['dataMin - 1', 'dataMax + 2']}
|
domain={['dataMin - 1', 'dataMax + 2']}
|
||||||
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
|
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip t={t} />} />
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
@@ -1075,7 +1145,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||||
<Activity className="h-12 w-12 mb-3 opacity-30" />
|
<Activity className="h-12 w-12 mb-3 opacity-30" />
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{realtimeTesting ? 'Collecting data...' : 'No data yet. Click "Test Again" to start.'}
|
{realtimeTesting ? t("network.latency.collectingData") : t("network.latency.noRealtimeData")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1107,7 +1177,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
domain={['dataMin - 1', 'dataMax + 2']}
|
domain={['dataMin - 1', 'dataMax + 2']}
|
||||||
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
|
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip t={t} />} />
|
||||||
{/* For longer timeframes (6h+), show max values to preserve spikes.
|
{/* For longer timeframes (6h+), show max values to preserve spikes.
|
||||||
For 1 hour view, show avg values since there's no downsampling */}
|
For 1 hour view, show avg values since there's no downsampling */}
|
||||||
<Area
|
<Area
|
||||||
@@ -1123,8 +1193,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
) : (
|
) : (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||||
<Activity className="h-12 w-12 mb-3 opacity-30" />
|
<Activity className="h-12 w-12 mb-3 opacity-30" />
|
||||||
<p className="text-sm">No latency data available for this period</p>
|
<p className="text-sm">{t("network.latency.noDataForPeriod")}</p>
|
||||||
<p className="text-xs mt-1">Data is collected every 60 seconds</p>
|
<p className="text-xs mt-1">{t("network.latency.collectionInterval")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1133,8 +1203,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
{isRealtime && (
|
{isRealtime && (
|
||||||
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
|
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
|
||||||
<p className="text-xs text-blue-400">
|
<p className="text-xs text-blue-400">
|
||||||
<strong>Real-time Mode:</strong> Tests run for 2 minutes with readings every 5 seconds.
|
<strong>{t("network.latency.realTimeMode")}:</strong> {t("network.latency.realTimeModeDescription")}
|
||||||
Click "Test Again" to add more samples. All data is included in the report.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect, useRef } from "react"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { Input } from "./ui/input"
|
import { Input } from "./ui/input"
|
||||||
import { Label } from "./ui/label"
|
import { Label } from "./ui/label"
|
||||||
import { Checkbox } from "./ui/checkbox"
|
import { Checkbox } from "./ui/checkbox"
|
||||||
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
|
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
|
||||||
import { getApiUrl } from "../lib/api-config"
|
import { getApiUrl } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
|
||||||
interface LoginProps {
|
interface LoginProps {
|
||||||
@@ -16,6 +17,7 @@ interface LoginProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Login({ onLogin }: LoginProps) {
|
export function Login({ onLogin }: LoginProps) {
|
||||||
|
const t = useT()
|
||||||
const [username, setUsername] = useState("")
|
const [username, setUsername] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [totpCode, setTotpCode] = useState("")
|
const [totpCode, setTotpCode] = useState("")
|
||||||
@@ -24,6 +26,7 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const lastAutoSubmittedTotp = useRef("")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// The Login screen is, by construction, the recovery path from any
|
// The Login screen is, by construction, the recovery path from any
|
||||||
@@ -51,17 +54,18 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const submitLogin = async (totpOverride?: string) => {
|
||||||
e.preventDefault()
|
|
||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
|
const token = totpOverride ?? totpCode
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
setError("Please enter username and password")
|
setError(t("login.missingCredentials"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requiresTotp && !totpCode) {
|
if (requiresTotp && !token) {
|
||||||
setError("Please enter your 2FA code")
|
setError(t("login.missingTotp"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,20 +78,26 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
totp_token: totpCode || undefined, // Include 2FA code if provided
|
totp_token: token || undefined, // Include 2FA code if provided
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (data.requires_totp) {
|
if (response.ok && data.requires_totp) {
|
||||||
setRequiresTotp(true)
|
setRequiresTotp(true)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.message || "Login failed")
|
if (response.status === 429) {
|
||||||
|
throw new Error(t("login.tooManyAttempts"))
|
||||||
|
}
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error(data.requires_totp ? t("login.invalidTotp") : t("login.invalidCredentials"))
|
||||||
|
}
|
||||||
|
throw new Error(t("login.loginFailed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem("proxmenux-auth-token", data.token)
|
localStorage.setItem("proxmenux-auth-token", data.token)
|
||||||
@@ -107,12 +117,28 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
|
|
||||||
onLogin()
|
onLogin()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Login failed")
|
setError(err instanceof Error ? err.message : t("login.loginFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
await submitLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requiresTotp || loading || !/^\d{6}$/.test(totpCode)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (lastAutoSubmittedTotp.current === totpCode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastAutoSubmittedTotp.current = totpCode
|
||||||
|
void submitLogin(totpCode)
|
||||||
|
}, [requiresTotp, totpCode, loading])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
@@ -139,8 +165,8 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">ProxMenux Monitor</h1>
|
<h1 className="text-3xl font-bold">{t("app.title")}</h1>
|
||||||
<p className="text-muted-foreground mt-2">Sign in to access your dashboard</p>
|
<p className="text-muted-foreground mt-2">{t("login.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -157,14 +183,14 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="login-username" className="text-sm">
|
<Label htmlFor="login-username" className="text-sm">
|
||||||
Username
|
{t("login.username")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="login-username"
|
id="login-username"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter your username"
|
placeholder={t("login.usernamePlaceholder")}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className="pl-10 text-base"
|
className="pl-10 text-base"
|
||||||
@@ -176,14 +202,14 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="login-password" className="text-sm">
|
<Label htmlFor="login-password" className="text-sm">
|
||||||
Password
|
{t("login.password")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="login-password"
|
id="login-password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder="Enter your password"
|
placeholder={t("login.passwordPlaceholder")}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="pl-10 pr-10 text-base"
|
className="pl-10 pr-10 text-base"
|
||||||
@@ -214,7 +240,7 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
|
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
|
||||||
Remember me
|
{t("login.rememberMe")}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -223,29 +249,29 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
|
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
|
||||||
<Shield className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
<Shield className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-blue-500">Two-Factor Authentication</p>
|
<p className="text-sm font-medium text-blue-500">{t("login.twoFactorTitle")}</p>
|
||||||
<p className="text-xs text-blue-500 mt-1">Enter the 6-digit code from your authentication app</p>
|
<p className="text-xs text-blue-500 mt-1">{t("login.twoFactorDescription")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="totp-code" className="text-sm">
|
<Label htmlFor="totp-code" className="text-sm">
|
||||||
Authentication Code
|
{t("login.authenticationCode")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="totp-code"
|
id="totp-code"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="000000"
|
placeholder="000000"
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
onChange={(e) => setTotpCode(e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, "").slice(0, 9))}
|
||||||
className="text-center text-lg tracking-widest font-mono text-base"
|
className="text-center text-lg tracking-widest font-mono text-base"
|
||||||
maxLength={6}
|
maxLength={9}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
You can also use a backup code (format: XXXX-XXXX)
|
{t("login.backupCodeHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -260,18 +286,18 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Back to login
|
{t("login.backToLogin")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||||
{loading ? "Signing in..." : requiresTotp ? "Verify Code" : "Sign In"}
|
{loading ? t("login.signingIn") : requiresTotp ? t("login.verifyCode") : t("login.signIn")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.4</p>
|
<p className="text-center text-sm text-muted-foreground">{t("login.version")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useState, useEffect, useRef, useCallback } from "react"
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react"
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +37,7 @@ import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTit
|
|||||||
import "xterm/css/xterm.css"
|
import "xterm/css/xterm.css"
|
||||||
import { API_PORT, fetchApi } from "@/lib/api-config"
|
import { API_PORT, fetchApi } from "@/lib/api-config"
|
||||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
interface LxcTerminalModalProps {
|
interface LxcTerminalModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -51,33 +52,35 @@ interface CheatSheetResult {
|
|||||||
examples: string[]
|
examples: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxmoxCommands = [
|
const LXC_COMMANDS = [
|
||||||
{ cmd: "ls -la", desc: "List all files with details" },
|
{ cmd: "ls -la", descKey: "listFiles" },
|
||||||
{ cmd: "cd /path/to/dir", desc: "Change directory" },
|
{ cmd: "cd /path/to/dir", descKey: "changeDirectory" },
|
||||||
{ cmd: "cat filename", desc: "Display file contents" },
|
{ cmd: "cat filename", descKey: "displayFile" },
|
||||||
{ cmd: "grep 'pattern' file", desc: "Search for pattern in file" },
|
{ cmd: "grep 'pattern' file", descKey: "searchPattern" },
|
||||||
{ cmd: "find . -name 'file'", desc: "Find files by name" },
|
{ cmd: "find . -name 'file'", descKey: "findFiles" },
|
||||||
{ cmd: "df -h", desc: "Show disk usage" },
|
{ cmd: "df -h", descKey: "diskUsage" },
|
||||||
{ cmd: "du -sh *", desc: "Show directory sizes" },
|
{ cmd: "du -sh *", descKey: "directorySizes" },
|
||||||
{ cmd: "free -h", desc: "Show memory usage" },
|
{ cmd: "free -h", descKey: "memoryUsage" },
|
||||||
{ cmd: "top", desc: "Show running processes" },
|
{ cmd: "top", descKey: "runningProcesses" },
|
||||||
{ cmd: "ps aux | grep process", desc: "Find running process" },
|
{ cmd: "ps aux | grep process", descKey: "findProcess" },
|
||||||
{ cmd: "systemctl status service", desc: "Check service status" },
|
{ cmd: "systemctl status service", descKey: "serviceStatus" },
|
||||||
{ cmd: "systemctl restart service", desc: "Restart a service" },
|
{ cmd: "systemctl restart service", descKey: "restartService" },
|
||||||
{ cmd: "apt update && apt upgrade", desc: "Update packages" },
|
{ cmd: "apt update && apt upgrade", descKey: "updatePackages" },
|
||||||
{ cmd: "apt install package", desc: "Install package" },
|
{ cmd: "apt install package", descKey: "installPackage" },
|
||||||
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file" },
|
{ cmd: "tail -f /var/log/syslog", descKey: "followLog" },
|
||||||
{ cmd: "chmod 755 file", desc: "Change file permissions" },
|
{ cmd: "chmod 755 file", descKey: "changePermissions" },
|
||||||
{ cmd: "chown user:group file", desc: "Change file owner" },
|
{ cmd: "chown user:group file", descKey: "changeOwner" },
|
||||||
{ cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" },
|
{ cmd: "tar -xzf file.tar.gz", descKey: "extractArchive" },
|
||||||
{ cmd: "docker ps", desc: "List running containers" },
|
{ cmd: "docker ps", descKey: "listContainers" },
|
||||||
{ cmd: "docker images", desc: "List Docker images" },
|
{ cmd: "docker images", descKey: "listImages" },
|
||||||
{ cmd: "ip addr show", desc: "Show IP addresses" },
|
{ cmd: "ip addr show", descKey: "showIpAddresses" },
|
||||||
{ cmd: "ping host", desc: "Test network connectivity" },
|
{ cmd: "ping host", descKey: "testConnectivity" },
|
||||||
{ cmd: "curl -I url", desc: "Get HTTP headers" },
|
{ cmd: "curl -I url", descKey: "httpHeaders" },
|
||||||
{ cmd: "history", desc: "Show command history" },
|
{ cmd: "history", descKey: "commandHistory" },
|
||||||
{ cmd: "clear", desc: "Clear terminal screen" },
|
{ cmd: "clear", descKey: "clearScreen" },
|
||||||
]
|
] as const
|
||||||
|
|
||||||
|
type LocalCommand = { cmd: string; desc: string }
|
||||||
|
|
||||||
function getWebSocketUrl(): string {
|
function getWebSocketUrl(): string {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
@@ -101,6 +104,7 @@ export function LxcTerminalModal({
|
|||||||
vmid,
|
vmid,
|
||||||
vmName,
|
vmName,
|
||||||
}: LxcTerminalModalProps) {
|
}: LxcTerminalModalProps) {
|
||||||
|
const t = useT()
|
||||||
const termRef = useRef<any>(null)
|
const termRef = useRef<any>(null)
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const fitAddonRef = useRef<any>(null)
|
const fitAddonRef = useRef<any>(null)
|
||||||
@@ -121,12 +125,18 @@ export function LxcTerminalModal({
|
|||||||
// Search state
|
// Search state
|
||||||
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands)
|
const localCommands = useMemo<LocalCommand[]>(
|
||||||
|
() => LXC_COMMANDS.map((item) => ({ cmd: item.cmd, desc: t(`lxcTerminal.commands.${item.descKey}`) })),
|
||||||
|
[t],
|
||||||
|
)
|
||||||
|
const [filteredCommands, setFilteredCommands] = useState<LocalCommand[]>([])
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
||||||
const [useOnline, setUseOnline] = useState(true)
|
const [useOnline, setUseOnline] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFilteredCommands(localCommands)
|
||||||
|
}, [localCommands])
|
||||||
|
|
||||||
// Detect mobile/tablet
|
// Detect mobile/tablet
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -278,7 +288,7 @@ export function LxcTerminalModal({
|
|||||||
// through Number without losing fidelity.
|
// through Number without losing fidelity.
|
||||||
const id = Number(vmid)
|
const id = Number(vmid)
|
||||||
if (!Number.isInteger(id) || id <= 0 || id >= 1_000_000) {
|
if (!Number.isInteger(id) || id <= 0 || id >= 1_000_000) {
|
||||||
term.writeln('\r\n\x1b[31m[ERROR] Invalid VMID — refusing to execute pct enter\x1b[0m')
|
term.writeln(`\r\n\x1b[31m[ERROR] ${t("lxcTerminal.errors.invalidVmid")}\x1b[0m`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ws.send(`pct enter ${id}\r`)
|
ws.send(`pct enter ${id}\r`)
|
||||||
@@ -287,7 +297,7 @@ export function LxcTerminalModal({
|
|||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
setConnectionStatus("offline")
|
setConnectionStatus("offline")
|
||||||
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
|
term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.websocketError")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
@@ -295,7 +305,7 @@ export function LxcTerminalModal({
|
|||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current)
|
clearInterval(pingIntervalRef.current)
|
||||||
}
|
}
|
||||||
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
|
term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
|
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
@@ -395,7 +405,7 @@ export function LxcTerminalModal({
|
|||||||
termRef.current.dispose()
|
termRef.current.dispose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen, vmid])
|
}, [isOpen, vmid, t])
|
||||||
|
|
||||||
// Resize handling
|
// Resize handling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -478,7 +488,7 @@ export function LxcTerminalModal({
|
|||||||
const searchCheatSh = async (query: string) => {
|
const searchCheatSh = async (query: string) => {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
setFilteredCommands(proxmoxCommands)
|
setFilteredCommands(localCommands)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +501,7 @@ export function LxcTerminalModal({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!data.success || !data.examples || data.examples.length === 0) {
|
if (!data.success || !data.examples || data.examples.length === 0) {
|
||||||
throw new Error("No examples found")
|
throw new Error(t("terminal.noExamplesFound"))
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
|
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
|
||||||
@@ -503,7 +513,7 @@ export function LxcTerminalModal({
|
|||||||
setUseOnline(true)
|
setUseOnline(true)
|
||||||
setSearchResults(formattedResults)
|
setSearchResults(formattedResults)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const filtered = proxmoxCommands.filter(
|
const filtered = localCommands.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
item.desc.toLowerCase().includes(query.toLowerCase()),
|
item.desc.toLowerCase().includes(query.toLowerCase()),
|
||||||
@@ -521,12 +531,12 @@ export function LxcTerminalModal({
|
|||||||
searchCheatSh(searchQuery)
|
searchCheatSh(searchQuery)
|
||||||
} else {
|
} else {
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
setFilteredCommands(proxmoxCommands)
|
setFilteredCommands(localCommands)
|
||||||
}
|
}
|
||||||
}, 800)
|
}, 800)
|
||||||
|
|
||||||
return () => clearTimeout(debounce)
|
return () => clearTimeout(debounce)
|
||||||
}, [searchQuery])
|
}, [searchQuery, localCommands, t])
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
if (termRef.current) {
|
if (termRef.current) {
|
||||||
@@ -565,7 +575,7 @@ export function LxcTerminalModal({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800">
|
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800">
|
||||||
<DialogTitle className="text-sm font-medium text-white">
|
<DialogTitle className="text-sm font-medium text-white">
|
||||||
Terminal: {vmName} (ID: {vmid})
|
{t("lxcTerminal.title", { name: vmName, id: vmid })}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -576,7 +586,7 @@ export function LxcTerminalModal({
|
|||||||
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
|
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Search</span>
|
<span className="hidden sm:inline">{t("terminal.search")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
@@ -586,7 +596,7 @@ export function LxcTerminalModal({
|
|||||||
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
|
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Clear</span>
|
<span className="hidden sm:inline">{t("terminal.clear")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -673,29 +683,29 @@ export function LxcTerminalModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onSelect={() => sendKey("\x03")}>
|
<DropdownMenuItem onSelect={() => sendKey("\x03")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||||
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
|
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||||
<span className="text-muted-foreground text-xs">Exit (nano)</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => sendKey("\x12")}>
|
<DropdownMenuItem onSelect={() => sendKey("\x12")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
||||||
<span className="text-muted-foreground text-xs">Search history</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
||||||
<Copy className="h-3.5 w-3.5 mr-2" />
|
<Copy className="h-3.5 w-3.5 mr-2" />
|
||||||
<span className="text-xs">Copy selection</span>
|
<span className="text-xs">{t("scriptTerminal.copySelection")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
||||||
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
||||||
<span className="text-xs">Paste</span>
|
<span className="text-xs">{t("scriptTerminal.paste")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -716,7 +726,7 @@ export function LxcTerminalModal({
|
|||||||
: "bg-red-500"
|
: "bg-red-500"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-zinc-400 capitalize">{connectionStatus}</span>
|
<span className="text-xs text-zinc-400">{t(`scriptTerminal.${connectionStatus}`)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -725,7 +735,7 @@ export function LxcTerminalModal({
|
|||||||
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Close</span>
|
<span className="hidden sm:inline">{t("actions.close")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -734,22 +744,22 @@ export function LxcTerminalModal({
|
|||||||
<SearchDialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
<SearchDialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
||||||
<SearchDialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
<SearchDialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
|
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
|
||||||
<SearchDialogTitle className="text-xl font-semibold">Search Commands</SearchDialogTitle>
|
<SearchDialogTitle className="text-xl font-semibold">{t("terminal.searchCommands")}</SearchDialogTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
|
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
|
||||||
title={useOnline ? "Online - Using cheat.sh API" : "Offline - Using local commands"}
|
title={useOnline ? t("terminal.onlineSource") : t("terminal.offlineSource")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogDescription className="sr-only">Search for Linux commands</DialogDescription>
|
<DialogDescription className="sr-only">{t("terminal.searchDescription")}</DialogDescription>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search commands... (e.g., tar, docker, systemctl)"
|
placeholder={t("terminal.searchPlaceholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
|
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
|
||||||
@@ -763,7 +773,7 @@ export function LxcTerminalModal({
|
|||||||
{isSearching && (
|
{isSearching && (
|
||||||
<div className="text-center py-4 text-zinc-400">
|
<div className="text-center py-4 text-zinc-400">
|
||||||
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
|
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
|
||||||
<p className="text-sm">Searching cheat.sh...</p>
|
<p className="text-sm">{t("terminal.searchingCheatSh")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -790,7 +800,7 @@ export function LxcTerminalModal({
|
|||||||
<div className="text-center py-2">
|
<div className="text-center py-2">
|
||||||
<p className="text-xs text-zinc-500">
|
<p className="text-xs text-zinc-500">
|
||||||
<Lightbulb className="inline-block w-3 h-3 mr-1" />
|
<Lightbulb className="inline-block w-3 h-3 mr-1" />
|
||||||
Powered by cheat.sh
|
{t("terminal.poweredByCheatSh")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -816,13 +826,13 @@ export function LxcTerminalModal({
|
|||||||
className="shrink-0 h-7 px-2 text-xs"
|
className="shrink-0 h-7 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<Send className="h-3 w-3 mr-1" />
|
<Send className="h-3 w-3 mr-1" />
|
||||||
Send
|
{t("terminal.send")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : !isSearching && !searchQuery && !useOnline ? (
|
) : !isSearching && !searchQuery && !useOnline ? (
|
||||||
proxmoxCommands.map((item, index) => (
|
localCommands.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => sendToTerminal(item.cmd)}
|
onClick={() => sendToTerminal(item.cmd)}
|
||||||
@@ -843,7 +853,7 @@ export function LxcTerminalModal({
|
|||||||
className="shrink-0 h-7 px-2 text-xs"
|
className="shrink-0 h-7 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<Send className="h-3 w-3 mr-1" />
|
<Send className="h-3 w-3 mr-1" />
|
||||||
Send
|
{t("terminal.send")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -854,17 +864,17 @@ export function LxcTerminalModal({
|
|||||||
<>
|
<>
|
||||||
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
|
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-zinc-400 font-medium">{"No results found for \""}{searchQuery}{"\""}</p>
|
<p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
|
||||||
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
|
<p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-zinc-400 font-medium mb-2">Search for any command</p>
|
<p className="text-zinc-400 font-medium mb-2">{t("terminal.searchAnyCommand")}</p>
|
||||||
<div className="text-sm text-zinc-500 space-y-1">
|
<div className="text-sm text-zinc-500 space-y-1">
|
||||||
<p>Try searching for:</p>
|
<p>{t("terminal.trySearchingFor")}</p>
|
||||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||||
{["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => (
|
{["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => (
|
||||||
<code
|
<code
|
||||||
@@ -881,7 +891,7 @@ export function LxcTerminalModal({
|
|||||||
{useOnline && (
|
{useOnline && (
|
||||||
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
|
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
|
||||||
<Lightbulb className="w-3 h-3" />
|
<Lightbulb className="w-3 h-3" />
|
||||||
<span>Powered by cheat.sh</span>
|
<span>{t("terminal.poweredByCheatSh")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -890,13 +900,11 @@ export function LxcTerminalModal({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
|
{useOnline && searchResults.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="pt-2 border-t border-zinc-800 text-xs text-zinc-500 text-right">
|
||||||
<Lightbulb className="w-3 h-3" />
|
<span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>
|
||||||
<span>Tip: Search for any Linux command</span>
|
|
||||||
</div>
|
|
||||||
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SearchDialogContent>
|
</SearchDialogContent>
|
||||||
</SearchDialog>
|
</SearchDialog>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Boxes, Info, Loader2, Settings2, CheckCircle2 } from "lucide-react"
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||||
import { Badge } from "./ui/badge"
|
import { Badge } from "./ui/badge"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface DetectionResponse {
|
interface DetectionResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -14,6 +15,7 @@ interface DetectionResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LxcUpdateDetection() {
|
export function LxcUpdateDetection() {
|
||||||
|
const t = useT()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [enabled, setEnabled] = useState<boolean>(true)
|
const [enabled, setEnabled] = useState<boolean>(true)
|
||||||
@@ -32,11 +34,11 @@ export function LxcUpdateDetection() {
|
|||||||
setEnabled(data.enabled)
|
setEnabled(data.enabled)
|
||||||
setPending(data.enabled)
|
setPending(data.enabled)
|
||||||
} else {
|
} else {
|
||||||
setError(data.message || "Failed to load setting")
|
setError(data.message || t("settings.lxcUpdateDetection.loadFailed"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
if (!cancelled) setError(String(e))
|
if (!cancelled) setError(t("settings.lxcUpdateDetection.loadFailed"))
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false)
|
if (!cancelled) setLoading(false)
|
||||||
@@ -77,7 +79,7 @@ export function LxcUpdateDetection() {
|
|||||||
body: JSON.stringify({ enabled: pending }),
|
body: JSON.stringify({ enabled: pending }),
|
||||||
})
|
})
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
setError(data.message || "Failed to save setting")
|
setError(data.message || t("settings.lxcUpdateDetection.saveFailed"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setEnabled(pending)
|
setEnabled(pending)
|
||||||
@@ -95,7 +97,7 @@ export function LxcUpdateDetection() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e))
|
setError(t("settings.lxcUpdateDetection.saveFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -111,14 +113,14 @@ export function LxcUpdateDetection() {
|
|||||||
breakpoint thanks to `items-center` + leading-tight title. */}
|
breakpoint thanks to `items-center` + leading-tight title. */}
|
||||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||||
<Boxes className="h-5 w-5 text-purple-500 shrink-0" />
|
<Boxes className="h-5 w-5 text-purple-500 shrink-0" />
|
||||||
<CardTitle className="leading-tight">LXC Update Detection</CardTitle>
|
<CardTitle className="leading-tight">{t("settings.lxcUpdateDetection.title")}</CardTitle>
|
||||||
{enabled ? (
|
{enabled ? (
|
||||||
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
|
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
|
||||||
Active
|
{t("status.active")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground">
|
<Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground">
|
||||||
Disabled
|
{t("status.disabled")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +128,7 @@ export function LxcUpdateDetection() {
|
|||||||
{saved && (
|
{saved && (
|
||||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
Saved
|
{t("status.saved")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{error && !editMode && (
|
{error && !editMode && (
|
||||||
@@ -134,7 +136,7 @@ export function LxcUpdateDetection() {
|
|||||||
className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate"
|
className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate"
|
||||||
title={error}
|
title={error}
|
||||||
>
|
>
|
||||||
Save failed: {error}
|
{t("status.saveFailed")}: {error}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
@@ -144,7 +146,7 @@ export function LxcUpdateDetection() {
|
|||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||||
@@ -152,7 +154,7 @@ export function LxcUpdateDetection() {
|
|||||||
disabled={saving || !hasChanges}
|
disabled={saving || !hasChanges}
|
||||||
>
|
>
|
||||||
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||||
Save
|
{t("actions.save")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -162,20 +164,25 @@ export function LxcUpdateDetection() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<Settings2 className="h-3 w-3" />
|
<Settings2 className="h-3 w-3" />
|
||||||
Edit
|
{t("actions.edit")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Periodically check running Debian/Ubuntu/Alpine LXC containers for pending package updates
|
{t("settings.lxcUpdateDetection.descriptionStart")}{" "}
|
||||||
(<code>apt list --upgradable</code> / <code>apk list -u</code>) and surface them on the dashboard. The
|
(<code>apt list --upgradable</code> / <code>apk list -u</code>)
|
||||||
corresponding notification toggle in <strong>Notifications → Services</strong> appears only while detection
|
{t("settings.lxcUpdateDetection.descriptionEnd")}
|
||||||
is enabled.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-5">
|
<CardContent
|
||||||
|
className={`space-y-5${
|
||||||
|
editMode
|
||||||
|
? " bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{/* ── Enable/Disable ── single-line label + toggle. The description
|
{/* ── Enable/Disable ── single-line label + toggle. The description
|
||||||
paragraph was removed because the CardDescription above already
|
paragraph was removed because the CardDescription above already
|
||||||
covers the behaviour; on mobile that second paragraph forced
|
covers the behaviour; on mobile that second paragraph forced
|
||||||
@@ -185,7 +192,7 @@ export function LxcUpdateDetection() {
|
|||||||
<Boxes
|
<Boxes
|
||||||
className={`h-4 w-4 shrink-0 ${pending ? "text-purple-500" : "text-muted-foreground"}`}
|
className={`h-4 w-4 shrink-0 ${pending ? "text-purple-500" : "text-muted-foreground"}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium truncate">Enable LXC update detection</span>
|
<span className="text-sm font-medium truncate">{t("settings.lxcUpdateDetection.enableLabel")}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
|
className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
|
||||||
@@ -195,7 +202,7 @@ export function LxcUpdateDetection() {
|
|||||||
disabled={!editMode || saving}
|
disabled={!editMode || saving}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={pending}
|
aria-checked={pending}
|
||||||
aria-label="Enable LXC update detection"
|
aria-label={t("settings.lxcUpdateDetection.enableLabel")}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||||
@@ -209,8 +216,7 @@ export function LxcUpdateDetection() {
|
|||||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 border border-border">
|
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 border border-border">
|
||||||
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
||||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||||
{lastPurged} LXC entries removed from the registry. Re-enabling detection will repopulate them on the
|
{t("settings.lxcUpdateDetection.purgedMessage", { count: lastPurged })}
|
||||||
next scan cycle.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { ArrowLeft, Loader2 } from "lucide-react"
|
import { ArrowLeft, Loader2 } from "lucide-react"
|
||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
|
import { useI18n } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
interface MetricsViewProps {
|
interface MetricsViewProps {
|
||||||
vmid: number
|
vmid: number
|
||||||
@@ -15,12 +16,12 @@ interface MetricsViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TIMEFRAME_OPTIONS = [
|
const TIMEFRAME_OPTIONS = [
|
||||||
{ value: "hour", label: "1 Hour" },
|
{ value: "hour", labelKey: "vmMetrics.timeframes.hour" },
|
||||||
{ value: "day", label: "24 Hours" },
|
{ value: "day", labelKey: "vmMetrics.timeframes.day" },
|
||||||
{ value: "week", label: "7 Days" },
|
{ value: "week", labelKey: "vmMetrics.timeframes.week" },
|
||||||
{ value: "month", label: "30 Days" },
|
{ value: "month", labelKey: "vmMetrics.timeframes.month" },
|
||||||
{ value: "year", label: "1 Year" },
|
{ value: "year", labelKey: "vmMetrics.timeframes.year" },
|
||||||
]
|
] as const
|
||||||
|
|
||||||
const CustomCPUTooltip = ({ active, payload, label }: any) => {
|
const CustomCPUTooltip = ({ active, payload, label }: any) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
@@ -103,6 +104,7 @@ const CustomNetworkTooltip = ({ active, payload, label }: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps) {
|
export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps) {
|
||||||
|
const { language, t } = useI18n()
|
||||||
const [timeframe, setTimeframe] = useState("week")
|
const [timeframe, setTimeframe] = useState("week")
|
||||||
const [data, setData] = useState<any[]>([])
|
const [data, setData] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -112,7 +114,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMetrics()
|
fetchMetrics()
|
||||||
}, [vmid, timeframe])
|
}, [vmid, timeframe, language])
|
||||||
|
|
||||||
const fetchMetrics = async () => {
|
const fetchMetrics = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -126,19 +128,19 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
let timeLabel = ""
|
let timeLabel = ""
|
||||||
|
|
||||||
if (timeframe === "hour") {
|
if (timeframe === "hour") {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
})
|
})
|
||||||
} else if (timeframe === "day") {
|
} else if (timeframe === "day") {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
})
|
})
|
||||||
} else if (timeframe === "week") {
|
} else if (timeframe === "week") {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@@ -146,12 +148,12 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
hour12: false,
|
hour12: false,
|
||||||
})
|
})
|
||||||
} else if (timeframe === "month") {
|
} else if (timeframe === "month") {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
month: "short",
|
month: "short",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
})
|
})
|
||||||
@@ -173,7 +175,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
|
|
||||||
setData(transformedData)
|
setData(transformedData)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "Error loading metrics")
|
setError(err.message || t("vmMetrics.errors.loading"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -203,7 +205,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[400px]">
|
<div className="flex items-center justify-center h-[400px]">
|
||||||
<p className="text-muted-foreground">No data available</p>
|
<p className="text-muted-foreground">{t("vmMetrics.noData")}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -214,7 +216,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* CPU Chart */}
|
{/* CPU Chart */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">CPU Usage</h3>
|
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.cpu")}</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||||
@@ -244,7 +246,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#3b82f6"
|
fill="#3b82f6"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="CPU %"
|
name={t("vmMetrics.series.cpu")}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -252,7 +254,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
|
|
||||||
{/* Memory Chart */}
|
{/* Memory Chart */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">Memory Usage</h3>
|
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.memory")}</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||||
@@ -282,7 +284,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
fill="#10b981"
|
fill="#10b981"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Memory GB"
|
name={t("vmMetrics.series.memoryGb")}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -290,7 +292,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
|
|
||||||
{/* Disk I/O Chart */}
|
{/* Disk I/O Chart */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">Disk I/O</h3>
|
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.diskIo")}</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||||
@@ -321,7 +323,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
fill="#10b981"
|
fill="#10b981"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Read"
|
name={t("vmMetrics.series.read")}
|
||||||
hide={hiddenDiskLines.includes("diskread")}
|
hide={hiddenDiskLines.includes("diskread")}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
@@ -331,7 +333,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
fill="#3b82f6"
|
fill="#3b82f6"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Write"
|
name={t("vmMetrics.series.write")}
|
||||||
hide={hiddenDiskLines.includes("diskwrite")}
|
hide={hiddenDiskLines.includes("diskwrite")}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
@@ -340,7 +342,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
|
|
||||||
{/* Network I/O Chart */}
|
{/* Network I/O Chart */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">Network I/O</h3>
|
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.networkIo")}</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||||
@@ -371,7 +373,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
fill="#10b981"
|
fill="#10b981"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Download"
|
name={t("vmMetrics.series.download")}
|
||||||
hide={hiddenNetworkLines.includes("netin")}
|
hide={hiddenNetworkLines.includes("netin")}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
@@ -381,7 +383,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
fill="#3b82f6"
|
fill="#3b82f6"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Upload"
|
name={t("vmMetrics.series.upload")}
|
||||||
hide={hiddenNetworkLines.includes("netout")}
|
hide={hiddenNetworkLines.includes("netout")}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
@@ -461,9 +463,9 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">Metrics - {vmName}</h2>
|
<h2 className="text-xl font-semibold">{t("vmMetrics.title", { name: vmName })}</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
VMID: {vmid} • Type: {vmType.toUpperCase()}
|
VMID: {vmid} • {t("vmMetrics.type")}: {vmType.toUpperCase()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -474,7 +476,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TIMEFRAME_OPTIONS.map((option) => (
|
{TIMEFRAME_OPTIONS.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{t(option.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from "react"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||||
|
import {
|
||||||
|
Boxes,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
DatabaseBackup,
|
||||||
|
GripVertical,
|
||||||
|
Grid3x3,
|
||||||
|
LayoutDashboard,
|
||||||
|
Layers,
|
||||||
|
RotateCcw,
|
||||||
|
Server,
|
||||||
|
Settings2,
|
||||||
|
Terminal,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
import {
|
||||||
|
DEFAULT_TAB_ORDER,
|
||||||
|
useTabOrder,
|
||||||
|
type TabId,
|
||||||
|
} from "../lib/tab-order"
|
||||||
|
|
||||||
|
// Long-press activation on touch — matches the delay platform UIs
|
||||||
|
// use so the user can still scroll a page that happens to start on a
|
||||||
|
// tab handle.
|
||||||
|
const TOUCH_ACTIVATION_MS = 250
|
||||||
|
const TOUCH_TOLERANCE_PX = 5
|
||||||
|
|
||||||
|
type TabMeta = {
|
||||||
|
id: TabId
|
||||||
|
Icon: React.ComponentType<{ className?: string }>
|
||||||
|
labelKey: string
|
||||||
|
hasDropdown: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const META: Record<TabId, TabMeta> = {
|
||||||
|
overview: { id: "overview", Icon: LayoutDashboard, labelKey: "navigation.overview", hasDropdown: false },
|
||||||
|
apps: { id: "apps", Icon: Grid3x3, labelKey: "navigation.apps", hasDropdown: false },
|
||||||
|
vms: { id: "vms", Icon: Boxes, labelKey: "navigation.virtualMachines", hasDropdown: false },
|
||||||
|
node: { id: "node", Icon: Server, labelKey: "navigation.node", hasDropdown: true },
|
||||||
|
backup: { id: "backup", Icon: DatabaseBackup, labelKey: "navigation.backup", hasDropdown: false },
|
||||||
|
terminal: { id: "terminal", Icon: Terminal, labelKey: "navigation.terminal", hasDropdown: false },
|
||||||
|
admin: { id: "admin", Icon: Settings2, labelKey: "navigation.admin", hasDropdown: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortable list built on Pointer Events. Mouse activates on move
|
||||||
|
// (2px threshold to survive accidental clicks); touch activates on
|
||||||
|
// long-press after 250ms unless the finger moves past 5px, which is
|
||||||
|
// treated as a scroll intent and the drag is cancelled.
|
||||||
|
export function NavTabOrderCard() {
|
||||||
|
const t = useT()
|
||||||
|
const { order: savedOrder, setOrder, reset, isCustom } = useTabOrder()
|
||||||
|
const [editMode, setEditMode] = useState(false)
|
||||||
|
const [draft, setDraft] = useState<TabId[]>(savedOrder)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editMode) setDraft(savedOrder)
|
||||||
|
}, [savedOrder, editMode])
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setDraft(savedOrder)
|
||||||
|
setEditMode(false)
|
||||||
|
}
|
||||||
|
const handleSave = () => {
|
||||||
|
setOrder(draft)
|
||||||
|
setEditMode(false)
|
||||||
|
setSaved(true)
|
||||||
|
window.setTimeout(() => setSaved(false), 2000)
|
||||||
|
}
|
||||||
|
const handleReset = () => {
|
||||||
|
setDraft([...DEFAULT_TAB_ORDER])
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftIsChanged =
|
||||||
|
draft.length !== savedOrder.length ||
|
||||||
|
draft.some((id, i) => id !== savedOrder[i])
|
||||||
|
const draftIsCustom =
|
||||||
|
draft.length !== DEFAULT_TAB_ORDER.length ||
|
||||||
|
draft.some((id, i) => id !== DEFAULT_TAB_ORDER[i])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="h-5 w-5 text-blue-500" />
|
||||||
|
<CardTitle>{t("settings.navOrder.title")}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{saved && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
{t("status.saved")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{editMode ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
{t("actions.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!draftIsChanged}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
{t("actions.save")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||||
|
onClick={() => setEditMode(true)}
|
||||||
|
>
|
||||||
|
<Settings2 className="h-3 w-3" />
|
||||||
|
{t("actions.edit")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardDescription>{t("settings.navOrder.description")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent
|
||||||
|
className={
|
||||||
|
editMode
|
||||||
|
? "bg-accent"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SortableList
|
||||||
|
items={editMode ? draft : savedOrder}
|
||||||
|
onReorder={setDraft}
|
||||||
|
editable={editMode}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
{editMode && (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5 text-muted-foreground disabled:opacity-50 disabled:pointer-events-none"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!draftIsCustom}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
{t("settings.navOrder.reset")}
|
||||||
|
</button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{t("settings.navOrder.hint")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!editMode && isCustom && (
|
||||||
|
<div className="mt-3 text-[11px] text-muted-foreground">
|
||||||
|
{t("settings.navOrder.customActive")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// SortableList
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
fromIdx: number
|
||||||
|
pointerY: number
|
||||||
|
offsetY: number
|
||||||
|
itemHeight: number
|
||||||
|
} | null
|
||||||
|
|
||||||
|
function SortableList({
|
||||||
|
items,
|
||||||
|
onReorder,
|
||||||
|
editable,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
items: TabId[]
|
||||||
|
onReorder: (next: TabId[]) => void
|
||||||
|
editable: boolean
|
||||||
|
t: (k: string) => string
|
||||||
|
}) {
|
||||||
|
const [drag, setDrag] = useState<DragState>(null)
|
||||||
|
const [hoverIdx, setHoverIdx] = useState<number | null>(null)
|
||||||
|
const listRef = useRef<HTMLUListElement | null>(null)
|
||||||
|
|
||||||
|
const commit = (from: number, to: number) => {
|
||||||
|
if (from === to || to < 0 || to >= items.length) return
|
||||||
|
const next = items.slice()
|
||||||
|
const [moved] = next.splice(from, 1)
|
||||||
|
next.splice(to, 0, moved)
|
||||||
|
onReorder(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragMove = (clientY: number) => {
|
||||||
|
if (!drag || !listRef.current) return
|
||||||
|
const rows = Array.from(listRef.current.querySelectorAll<HTMLLIElement>("li[data-row]"))
|
||||||
|
let target = drag.fromIdx
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const rect = rows[i].getBoundingClientRect()
|
||||||
|
const mid = rect.top + rect.height / 2
|
||||||
|
if (clientY < mid) { target = i; break }
|
||||||
|
target = i
|
||||||
|
}
|
||||||
|
setHoverIdx(target)
|
||||||
|
setDrag((d) => (d ? { ...d, pointerY: clientY } : d))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
if (drag && hoverIdx !== null) commit(drag.fromIdx, hoverIdx)
|
||||||
|
setDrag(null)
|
||||||
|
setHoverIdx(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
className="flex flex-col gap-1.5 select-none"
|
||||||
|
onPointerMove={(e) => { if (drag) handleDragMove(e.clientY) }}
|
||||||
|
onPointerUp={handleDragEnd}
|
||||||
|
onPointerCancel={handleDragEnd}
|
||||||
|
>
|
||||||
|
{items.map((id, idx) => {
|
||||||
|
const isDragging = drag?.fromIdx === idx
|
||||||
|
const meta = META[id]
|
||||||
|
return (
|
||||||
|
<SortableRow
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
idx={idx}
|
||||||
|
meta={meta}
|
||||||
|
label={t(meta.labelKey)}
|
||||||
|
editable={editable}
|
||||||
|
isDragging={!!isDragging}
|
||||||
|
hoverIdx={hoverIdx}
|
||||||
|
drag={drag}
|
||||||
|
onDragStart={(fromIdx, pointerY, offsetY, itemHeight) => {
|
||||||
|
setDrag({ fromIdx, pointerY, offsetY, itemHeight })
|
||||||
|
setHoverIdx(fromIdx)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableRow({
|
||||||
|
id,
|
||||||
|
idx,
|
||||||
|
meta,
|
||||||
|
label,
|
||||||
|
editable,
|
||||||
|
isDragging,
|
||||||
|
hoverIdx,
|
||||||
|
drag,
|
||||||
|
onDragStart,
|
||||||
|
}: {
|
||||||
|
id: TabId
|
||||||
|
idx: number
|
||||||
|
meta: TabMeta
|
||||||
|
label: string
|
||||||
|
editable: boolean
|
||||||
|
isDragging: boolean
|
||||||
|
hoverIdx: number | null
|
||||||
|
drag: DragState
|
||||||
|
onDragStart: (fromIdx: number, pointerY: number, offsetY: number, itemHeight: number) => void
|
||||||
|
}) {
|
||||||
|
const rowRef = useRef<HTMLLIElement | null>(null)
|
||||||
|
const longPressTimer = useRef<number | null>(null)
|
||||||
|
const pointerStart = useRef<{ x: number; y: number } | null>(null)
|
||||||
|
const activatedRef = useRef(false)
|
||||||
|
|
||||||
|
const cancelLongPress = () => {
|
||||||
|
if (longPressTimer.current !== null) {
|
||||||
|
window.clearTimeout(longPressTimer.current)
|
||||||
|
longPressTimer.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const beginDrag = (clientY: number) => {
|
||||||
|
if (!rowRef.current) return
|
||||||
|
const rect = rowRef.current.getBoundingClientRect()
|
||||||
|
activatedRef.current = true
|
||||||
|
onDragStart(idx, clientY, clientY - rect.top, rect.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent<HTMLLIElement>) => {
|
||||||
|
if (!editable) return
|
||||||
|
if (e.button !== undefined && e.button !== 0) return
|
||||||
|
pointerStart.current = { x: e.clientX, y: e.clientY }
|
||||||
|
activatedRef.current = false
|
||||||
|
if (e.pointerType === "touch") {
|
||||||
|
longPressTimer.current = window.setTimeout(() => {
|
||||||
|
longPressTimer.current = null
|
||||||
|
beginDrag(e.clientY)
|
||||||
|
}, TOUCH_ACTIVATION_MS)
|
||||||
|
} else {
|
||||||
|
// Mouse/pen: activate immediately on press.
|
||||||
|
beginDrag(e.clientY)
|
||||||
|
}
|
||||||
|
;(e.currentTarget as HTMLLIElement).setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<HTMLLIElement>) => {
|
||||||
|
if (!editable) return
|
||||||
|
if (!activatedRef.current && pointerStart.current) {
|
||||||
|
const dx = e.clientX - pointerStart.current.x
|
||||||
|
const dy = e.clientY - pointerStart.current.y
|
||||||
|
if (Math.hypot(dx, dy) > TOUCH_TOLERANCE_PX) {
|
||||||
|
// Movement before activation → scroll intent on touch. Cancel
|
||||||
|
// the pending long-press so the page can scroll normally.
|
||||||
|
cancelLongPress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerUp = () => {
|
||||||
|
cancelLongPress()
|
||||||
|
pointerStart.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple drag visual: ghost the row being dragged, show a blue
|
||||||
|
// drop-line above or below the row the pointer is currently over.
|
||||||
|
// No item swap animation — commit on release.
|
||||||
|
const isTarget = drag && hoverIdx === idx && drag.fromIdx !== idx
|
||||||
|
const dropAbove = isTarget && drag!.fromIdx > idx
|
||||||
|
const dropBelow = isTarget && drag!.fromIdx < idx
|
||||||
|
const RowIcon = meta.Icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
ref={rowRef}
|
||||||
|
data-row
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
className={[
|
||||||
|
"relative rounded-md border transition-colors",
|
||||||
|
editable
|
||||||
|
? "bg-background border-border cursor-grab active:cursor-grabbing touch-none"
|
||||||
|
: "bg-card border-border/60",
|
||||||
|
isDragging ? "opacity-40" : "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{dropAbove && <div className="absolute -top-[3px] left-2 right-2 h-[2px] bg-blue-500 rounded-full pointer-events-none" />}
|
||||||
|
{dropBelow && <div className="absolute -bottom-[3px] left-2 right-2 h-[2px] bg-blue-500 rounded-full pointer-events-none" />}
|
||||||
|
<div className="flex items-center gap-3 p-2.5">
|
||||||
|
<GripVertical
|
||||||
|
className={
|
||||||
|
"h-4 w-4 flex-shrink-0 " +
|
||||||
|
(editable ? "text-muted-foreground" : "text-muted-foreground/40")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<RowIcon className="h-4 w-4 flex-shrink-0 text-blue-500" />
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">{label}</span>
|
||||||
|
{meta.hasDropdown && (
|
||||||
|
<ChevronDown className="h-3 w-3 text-muted-foreground/70" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ import { Wifi, Zap } from 'lucide-react'
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
|
type TFunction = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
interface NetworkCardProps {
|
interface NetworkCardProps {
|
||||||
interface_: {
|
interface_: {
|
||||||
@@ -32,43 +35,51 @@ interface NetworkCardProps {
|
|||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const getInterfaceTypeBadge = (type: string) => {
|
const getInterfaceTypeBadge = (type: string, t: TFunction) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "physical":
|
case "physical":
|
||||||
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: "Physical" }
|
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: t("network.interfaceTypes.physical") }
|
||||||
case "bridge":
|
case "bridge":
|
||||||
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: "Bridge" }
|
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: t("network.interfaceTypes.bridge") }
|
||||||
case "bond":
|
case "bond":
|
||||||
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "Bond" }
|
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: t("network.interfaceTypes.bond") }
|
||||||
case "vlan":
|
case "vlan":
|
||||||
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "VLAN" }
|
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: t("network.interfaceTypes.vlan") }
|
||||||
case "vm_lxc":
|
case "vm_lxc":
|
||||||
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
|
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
|
||||||
case "virtual":
|
case "virtual":
|
||||||
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
|
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
|
||||||
default:
|
default:
|
||||||
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
|
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getVMTypeBadge = (vmType: string | undefined) => {
|
const getVMTypeBadge = (vmType: string | undefined, t: TFunction) => {
|
||||||
if (vmType === "lxc") {
|
if (vmType === "lxc") {
|
||||||
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
|
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
|
||||||
} else if (vmType === "vm") {
|
} else if (vmType === "vm") {
|
||||||
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" }
|
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" }
|
||||||
}
|
}
|
||||||
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
|
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatSpeed = (speed: number): string => {
|
const formatInterfaceStatus = (status: string | undefined, t: TFunction): string => {
|
||||||
if (speed === 0) return "N/A"
|
const normalized = (status || "").toLowerCase()
|
||||||
|
if (normalized === "up") return t("network.status.up")
|
||||||
|
if (normalized === "down") return t("network.status.down")
|
||||||
|
return status || t("common.unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSpeed = (speed: number, unavailable = "N/A"): string => {
|
||||||
|
if (speed === 0) return unavailable
|
||||||
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
|
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
|
||||||
return `${speed} Mbps`
|
return `${speed} Mbps`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps) {
|
export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps) {
|
||||||
const typeBadge = getInterfaceTypeBadge(interface_.type)
|
const t = useT()
|
||||||
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type) : null
|
const typeBadge = getInterfaceTypeBadge(interface_.type, t)
|
||||||
|
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type, t) : null
|
||||||
|
|
||||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(getNetworkUnit())
|
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(getNetworkUnit())
|
||||||
|
|
||||||
@@ -125,17 +136,17 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
|
|||||||
const getTimeframeLabel = () => {
|
const getTimeframeLabel = () => {
|
||||||
switch (timeframe) {
|
switch (timeframe) {
|
||||||
case "hour":
|
case "hour":
|
||||||
return "Last Hour"
|
return t("network.timeframes.last.hour")
|
||||||
case "day":
|
case "day":
|
||||||
return "Last 24 Hours"
|
return t("network.timeframes.last.day")
|
||||||
case "week":
|
case "week":
|
||||||
return "Last 7 Days"
|
return t("network.timeframes.last.week")
|
||||||
case "month":
|
case "month":
|
||||||
return "Last 30 Days"
|
return t("network.timeframes.last.month")
|
||||||
case "year":
|
case "year":
|
||||||
return "Last Year"
|
return t("network.timeframes.last.year")
|
||||||
default:
|
default:
|
||||||
return "Last 24 Hours"
|
return t("network.timeframes.last.day")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +185,7 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
|
|||||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{interface_.status.toUpperCase()}
|
{formatInterfaceStatus(interface_.status, t)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -182,22 +193,22 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
{interface_.type === "vm_lxc" ? "VMID" : "IP Address"}
|
{interface_.type === "vm_lxc" ? "VMID" : t("network.labels.ipAddress")}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-foreground font-mono text-sm truncate">
|
<div className="font-medium text-foreground font-mono text-sm truncate">
|
||||||
{interface_.type === "vm_lxc"
|
{interface_.type === "vm_lxc"
|
||||||
? (interface_.vmid ?? "N/A")
|
? (interface_.vmid ?? t("common.notAvailable"))
|
||||||
: interface_.addresses.length > 0
|
: interface_.addresses.length > 0
|
||||||
? interface_.addresses[0].ip
|
? interface_.addresses[0].ip
|
||||||
: "N/A"}
|
: t("common.notAvailable")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground text-xs">Speed</div>
|
<div className="text-muted-foreground text-xs">{t("network.labels.speed")}</div>
|
||||||
<div className="font-medium text-foreground flex items-center gap-1 text-xs">
|
<div className="font-medium text-foreground flex items-center gap-1 text-xs">
|
||||||
<Zap className="h-3 w-3" />
|
<Zap className="h-3 w-3" />
|
||||||
{formatSpeed(interface_.speed)}
|
{formatSpeed(interface_.speed, t("common.notAvailable"))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||||
import { Activity } from "lucide-react"
|
import { Activity } from "lucide-react"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
import { getNetworkUnit, type NetworkUnit } from "../lib/format-network"
|
||||||
|
|
||||||
// One animated comet-trail pulse. Returned as DATA from the layout
|
// One animated comet-trail pulse. Returned as DATA from the layout
|
||||||
// renderers instead of an SVG string so the parent component can
|
// renderers instead of an SVG string so the parent component can
|
||||||
@@ -25,6 +27,12 @@ type PulseData = {
|
|||||||
rate?: number
|
rate?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FlowLabels = {
|
||||||
|
down: string
|
||||||
|
active: string
|
||||||
|
standby: string
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Public types — match the /api/network shape ────────────
|
// ─── Public types — match the /api/network shape ────────────
|
||||||
type NIC = {
|
type NIC = {
|
||||||
id: string
|
id: string
|
||||||
@@ -126,17 +134,26 @@ function resolveBonds(data: NetworkFlowData): {
|
|||||||
// Sub-label under a NIC. In active-backup the role is the useful bit
|
// Sub-label under a NIC. In active-backup the role is the useful bit
|
||||||
// (which cable is actually carrying traffic right now); in every other
|
// (which cable is actually carrying traffic right now); in every other
|
||||||
// mode all slaves transmit, so the link speed stays.
|
// mode all slaves transmit, so the link speed stays.
|
||||||
function nicSubLabel(n: NIC): string {
|
function nicSubLabel(n: NIC, labels: FlowLabels): string {
|
||||||
if (n.status === "down") return "down"
|
if (n.status === "down") return labels.down
|
||||||
const role = n.role === "standby" || n.role === "active" ? n.role : ""
|
const role = n.role === "standby" || n.role === "active" ? n.role : ""
|
||||||
if (!role) return n.link
|
if (!role) return n.link
|
||||||
// A NIC that doesn't report a negotiated speed renders its link as
|
// A NIC that doesn't report a negotiated speed renders its link as
|
||||||
// "—"; pairing that with the role would read as "— · active".
|
// "—"; pairing that with the role would read as "— · active".
|
||||||
if (!n.link || n.link === "—") return role
|
const roleLabel = role === "active" ? labels.active : labels.standby
|
||||||
return `${n.link} · ${role}`
|
if (!n.link || n.link === "—") return roleLabel
|
||||||
|
return `${n.link} · ${roleLabel}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmt(v: number): string {
|
function fmt(v: number, unit: NetworkUnit = "Bytes"): string {
|
||||||
|
if (unit === "Bits") {
|
||||||
|
// MB/s (base 2) → bits/s, then decimal SI prefixes (Kb/Mb use ×1000, networking convention).
|
||||||
|
const bps = (v || 0) * 1024 * 1024 * 8
|
||||||
|
if (!bps) return "0 b/s"
|
||||||
|
if (bps < 1000) return `${Math.round(bps)} b/s`
|
||||||
|
if (bps < 1_000_000) return `${(bps / 1000).toFixed(0)} Kb/s`
|
||||||
|
return `${(bps / 1_000_000).toFixed(1)} Mb/s`
|
||||||
|
}
|
||||||
if (!v) return "0 B/s"
|
if (!v) return "0 B/s"
|
||||||
// Below 1 KB/s show B/s — the previous "—" hid real-but-low traffic.
|
// Below 1 KB/s show B/s — the previous "—" hid real-but-low traffic.
|
||||||
if (v < 0.001) return `${Math.round(v * 1024 * 1024)} B/s`
|
if (v < 0.001) return `${Math.round(v * 1024 * 1024)} B/s`
|
||||||
@@ -214,7 +231,7 @@ function curvedTap(cx: number, busY: number, targetY: number, r = 14): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Renderer: returns full SVG markup string for a given width ──
|
// ─── Renderer: returns full SVG markup string for a given width ──
|
||||||
function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; pulses: PulseData[]; height: number } {
|
function renderHorizontal(data: NetworkFlowData, W: number, labels: FlowLabels, unit: NetworkUnit): { svg: string; pulses: PulseData[]; height: number } {
|
||||||
const top = activeConsumers(data.consumers)
|
const top = activeConsumers(data.consumers)
|
||||||
const bridges = visibleBridges(data.bridges, top)
|
const bridges = visibleBridges(data.bridges, top)
|
||||||
const host = data.consumers.find((c) => c.kind === "host")
|
const host = data.consumers.find((c) => c.kind === "host")
|
||||||
@@ -343,7 +360,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
|
|||||||
<circle class="nf-circle" cx="${nicX}" cy="${y}" r="${radNic}" stroke="${stroke}" />
|
<circle class="nf-circle" cx="${nicX}" cy="${y}" r="${radNic}" stroke="${stroke}" />
|
||||||
${svgIcon("nic", nicX, y, 18, stroke)}
|
${svgIcon("nic", nicX, y, 18, stroke)}
|
||||||
<text class="nf-label" x="${nicX}" y="${y + radNic + 14}">${n.id}</text>
|
<text class="nf-label" x="${nicX}" y="${y + radNic + 14}">${n.id}</text>
|
||||||
<text class="nf-sub" x="${nicX}" y="${y + radNic + 26}">${nicSubLabel(n)}</text>
|
<text class="nf-sub" x="${nicX}" y="${y + radNic + 26}">${nicSubLabel(n, labels)}</text>
|
||||||
</g>`)
|
</g>`)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -376,7 +393,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
|
|||||||
${svgIcon("bond", bondX, y, 16, stroke)}
|
${svgIcon("bond", bondX, y, 16, stroke)}
|
||||||
<text class="nf-label" x="${bondX}" y="${y + radBond + 14}">${b.id}</text>
|
<text class="nf-label" x="${bondX}" y="${y + radBond + 14}">${b.id}</text>
|
||||||
<text class="nf-sub" x="${bondX}" y="${y + radBond + 26}">${b.mode || "bond"}</text>
|
<text class="nf-sub" x="${bondX}" y="${y + radBond + 26}">${b.mode || "bond"}</text>
|
||||||
<text class="nf-sub" x="${bondX}" y="${y + radBond + 38}">${fmt(b.rx + b.tx)}</text>
|
<text class="nf-sub" x="${bondX}" y="${y + radBond + 38}">${fmt(b.rx + b.tx, unit)}</text>
|
||||||
</g>`)
|
</g>`)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -384,7 +401,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
|
|||||||
<circle class="nf-circle" cx="${hostX}" cy="${hostY}" r="${radHost}" stroke="${COLORS.host}" stroke-width="2.5" />
|
<circle class="nf-circle" cx="${hostX}" cy="${hostY}" r="${radHost}" stroke="${COLORS.host}" stroke-width="2.5" />
|
||||||
${svgIcon("host", hostX, hostY, 24, COLORS.host)}
|
${svgIcon("host", hostX, hostY, 24, COLORS.host)}
|
||||||
<text class="nf-label" x="${hostX}" y="${hostY + radHost + 14}" font-weight="600">PROXMOX</text>
|
<text class="nf-label" x="${hostX}" y="${hostY + radHost + 14}" font-weight="600">PROXMOX</text>
|
||||||
<text class="nf-sub" x="${hostX}" y="${hostY + radHost + 26}">${fmt((host?.rx || 0) + (host?.tx || 0))}</text>
|
<text class="nf-sub" x="${hostX}" y="${hostY + radHost + 26}">${fmt((host?.rx || 0) + (host?.tx || 0), unit)}</text>
|
||||||
</g>`)
|
</g>`)
|
||||||
|
|
||||||
// Logarithmic mapping rate (MB/s) → pulse animation duration (s),
|
// Logarithmic mapping rate (MB/s) → pulse animation duration (s),
|
||||||
@@ -432,7 +449,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
|
|||||||
<circle class="nf-circle" cx="${bridgesX}" cy="${sec.bridgeY}" r="${radBridge}" stroke="${COLORS.bridge}" />
|
<circle class="nf-circle" cx="${bridgesX}" cy="${sec.bridgeY}" r="${radBridge}" stroke="${COLORS.bridge}" />
|
||||||
${svgIcon("bridge", bridgesX, sec.bridgeY, 16, COLORS.bridge)}
|
${svgIcon("bridge", bridgesX, sec.bridgeY, 16, COLORS.bridge)}
|
||||||
<text class="nf-label" x="${bridgesX}" y="${sec.bridgeY + radBridge + 14}">${sec.b.id}</text>
|
<text class="nf-label" x="${bridgesX}" y="${sec.bridgeY + radBridge + 14}">${sec.b.id}</text>
|
||||||
<text class="nf-sub" x="${bridgesX}" y="${sec.bridgeY + radBridge + 26}">${fmt(bridgeRate)}</text>
|
<text class="nf-sub" x="${bridgesX}" y="${sec.bridgeY + radBridge + 26}">${fmt(bridgeRate, unit)}</text>
|
||||||
</g>`)
|
</g>`)
|
||||||
|
|
||||||
// Trunk lines are STATIC only — every per-guest path will travel
|
// Trunk lines are STATIC only — every per-guest path will travel
|
||||||
@@ -467,7 +484,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
|
|||||||
<circle class="nf-circle" cx="${cx}" cy="${circleY}" r="${radGuest}" stroke="${stroke}" />
|
<circle class="nf-circle" cx="${cx}" cy="${circleY}" r="${radGuest}" stroke="${stroke}" />
|
||||||
${svgIcon(g.kind, cx, circleY, 14, stroke)}
|
${svgIcon(g.kind, cx, circleY, 14, stroke)}
|
||||||
<text class="nf-label" x="${cx}" y="${labelY}">${g.label}</text>
|
<text class="nf-label" x="${cx}" y="${labelY}">${g.label}</text>
|
||||||
<text class="nf-sub" x="${cx}" y="${subY}">${fmt(g.rx + g.tx)}</text>
|
<text class="nf-sub" x="${cx}" y="${subY}">${fmt(g.rx + g.tx, unit)}</text>
|
||||||
</g>`)
|
</g>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,7 +608,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
|
|||||||
// own sub-trunk lives at SUB_TRUNK_X and fans out to its guests in
|
// own sub-trunk lives at SUB_TRUNK_X and fans out to its guests in
|
||||||
// an arc (some above, some below the bridge.cy). All elbows use Q
|
// an arc (some above, some below the bridge.cy). All elbows use Q
|
||||||
// curves; no sharp 90° corners anywhere.
|
// curves; no sharp 90° corners anywhere.
|
||||||
function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData[]; height: number; viewBox: string } {
|
function renderVertical(data: NetworkFlowData, labels: FlowLabels, unit: NetworkUnit): { svg: string; pulses: PulseData[]; height: number; viewBox: string } {
|
||||||
// Smaller W → SVG scales up on the mobile screen, nodes look bigger.
|
// Smaller W → SVG scales up on the mobile screen, nodes look bigger.
|
||||||
// All four x-columns evenly spaced so curve→target distances are
|
// All four x-columns evenly spaced so curve→target distances are
|
||||||
// homogeneous (host→bridge, bridge→spine, spine→guest all ~60 px).
|
// homogeneous (host→bridge, bridge→spine, spine→guest all ~60 px).
|
||||||
@@ -768,7 +785,7 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
|
|||||||
<circle class="nf-circle" cx="${cx}" cy="${cy}" r="${r}" stroke="${color}" />
|
<circle class="nf-circle" cx="${cx}" cy="${cy}" r="${r}" stroke="${color}" />
|
||||||
${svgIcon("nic", cx, cy, 13, color)}
|
${svgIcon("nic", cx, cy, 13, color)}
|
||||||
<text class="nf-label" x="${cx}" y="${cy + r + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${n.id}</text>
|
<text class="nf-label" x="${cx}" y="${cy + r + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${n.id}</text>
|
||||||
<text class="nf-sub" x="${cx}" y="${cy + r + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:11px">${nicSubLabel(n)}</text>
|
<text class="nf-sub" x="${cx}" y="${cy + r + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:11px">${nicSubLabel(n, labels)}</text>
|
||||||
</g>`)
|
</g>`)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -786,7 +803,7 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
|
|||||||
${svgIcon("bond", cx, cy, 13, color)}
|
${svgIcon("bond", cx, cy, 13, color)}
|
||||||
<text class="nf-label" x="${cx}" y="${cy + r + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${b.id}</text>
|
<text class="nf-label" x="${cx}" y="${cy + r + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${b.id}</text>
|
||||||
<text class="nf-sub" x="${cx}" y="${cy + r + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:11px">${b.mode || "bond"}</text>
|
<text class="nf-sub" x="${cx}" y="${cy + r + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:11px">${b.mode || "bond"}</text>
|
||||||
<text class="nf-sub" x="${cx}" y="${cy + r + BOND_TEXT_H}" text-anchor="middle" style="font-size:11px">${fmt(rate)}</text>
|
<text class="nf-sub" x="${cx}" y="${cy + r + BOND_TEXT_H}" text-anchor="middle" style="font-size:11px">${fmt(rate, unit)}</text>
|
||||||
</g>`)
|
</g>`)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -795,7 +812,7 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
|
|||||||
<circle class="nf-circle" cx="${HOST_X}" cy="${hostY}" r="${RAD_HOST}" stroke="${COLORS.host}" stroke-width="2.5" />
|
<circle class="nf-circle" cx="${HOST_X}" cy="${hostY}" r="${RAD_HOST}" stroke="${COLORS.host}" stroke-width="2.5" />
|
||||||
${svgIcon("host", HOST_X, hostY, 20, COLORS.host)}
|
${svgIcon("host", HOST_X, hostY, 20, COLORS.host)}
|
||||||
<text class="nf-label" x="${HOST_X}" y="${hostY + RAD_HOST + LABEL_OFFSET_Y}" text-anchor="middle" font-weight="700" style="font-size:13px">PROXMOX</text>
|
<text class="nf-label" x="${HOST_X}" y="${hostY + RAD_HOST + LABEL_OFFSET_Y}" text-anchor="middle" font-weight="700" style="font-size:13px">PROXMOX</text>
|
||||||
<text class="nf-sub" x="${HOST_X}" y="${hostY + RAD_HOST + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${fmt((host?.rx || 0) + (host?.tx || 0))}</text>
|
<text class="nf-sub" x="${HOST_X}" y="${hostY + RAD_HOST + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${fmt((host?.rx || 0) + (host?.tx || 0), unit)}</text>
|
||||||
</g>`)
|
</g>`)
|
||||||
|
|
||||||
// ─── 3. Bridges + guests, ARC layout around each bridge ──
|
// ─── 3. Bridges + guests, ARC layout around each bridge ──
|
||||||
@@ -862,7 +879,7 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
|
|||||||
<circle class="nf-circle" cx="${cx}" cy="${cy}" r="${r}" stroke="${COLORS.bridge}" />
|
<circle class="nf-circle" cx="${cx}" cy="${cy}" r="${r}" stroke="${COLORS.bridge}" />
|
||||||
${svgIcon("bridge", cx, cy, 13, COLORS.bridge)}
|
${svgIcon("bridge", cx, cy, 13, COLORS.bridge)}
|
||||||
<text class="nf-label" x="${cx}" y="${cy + r + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${b.id}</text>
|
<text class="nf-label" x="${cx}" y="${cy + r + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${b.id}</text>
|
||||||
<text class="nf-sub" x="${cx}" y="${cy + r + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${fmt(rate)}</text>
|
<text class="nf-sub" x="${cx}" y="${cy + r + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${fmt(rate, unit)}</text>
|
||||||
</g>`)
|
</g>`)
|
||||||
|
|
||||||
if (guests.length === 0) return
|
if (guests.length === 0) return
|
||||||
@@ -905,7 +922,7 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
|
|||||||
<circle class="nf-circle" cx="${gCx}" cy="${gCy}" r="${gR}" stroke="${stroke}" />
|
<circle class="nf-circle" cx="${gCx}" cy="${gCy}" r="${gR}" stroke="${stroke}" />
|
||||||
${svgIcon(g.kind, gCx, gCy, 13, stroke)}
|
${svgIcon(g.kind, gCx, gCy, 13, stroke)}
|
||||||
<text class="nf-label" x="${gCx}" y="${gCy + gR + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${g.label}</text>
|
<text class="nf-label" x="${gCx}" y="${gCy + gR + LABEL_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${g.label}</text>
|
||||||
<text class="nf-sub" x="${gCx}" y="${gCy + gR + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${fmt(gRate)}</text>
|
<text class="nf-sub" x="${gCx}" y="${gCy + gR + SUB_OFFSET_Y}" text-anchor="middle" style="font-size:12.5px">${fmt(gRate, unit)}</text>
|
||||||
</g>`)
|
</g>`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -936,9 +953,19 @@ export function NetworkFlow({
|
|||||||
// opens the per-interface details modal.
|
// opens the per-interface details modal.
|
||||||
onNodeClick?: (name: string, kind: "nic" | "host" | "bond" | "bridge" | "lxc" | "vm") => void
|
onNodeClick?: (name: string, kind: "nic" | "host" | "bond" | "bridge" | "lxc" | "vm") => void
|
||||||
}) {
|
}) {
|
||||||
|
const t = useT()
|
||||||
|
const labels = useMemo<FlowLabels>(
|
||||||
|
() => ({
|
||||||
|
down: t("network.status.down"),
|
||||||
|
active: t("network.roles.active"),
|
||||||
|
standby: t("network.roles.standby"),
|
||||||
|
}),
|
||||||
|
[t],
|
||||||
|
)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const [width, setWidth] = useState(1320)
|
const [width, setWidth] = useState(1320)
|
||||||
const [mode, setMode] = useState<"desktop" | "tablet" | "mobile">("desktop")
|
const [mode, setMode] = useState<"desktop" | "tablet" | "mobile">("desktop")
|
||||||
|
const [networkUnit, setNetworkUnit] = useState<NetworkUnit>("Bytes")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const update = () => {
|
const update = () => {
|
||||||
@@ -953,6 +980,16 @@ export function NetworkFlow({
|
|||||||
return () => window.removeEventListener("resize", update)
|
return () => window.removeEventListener("resize", update)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNetworkUnit(getNetworkUnit())
|
||||||
|
const onChange = (e: Event) => {
|
||||||
|
const detail = (e as CustomEvent).detail as NetworkUnit | undefined
|
||||||
|
setNetworkUnit(detail ?? getNetworkUnit())
|
||||||
|
}
|
||||||
|
window.addEventListener("networkUnitChanged", onChange)
|
||||||
|
return () => window.removeEventListener("networkUnitChanged", onChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Stable memo key: the SVG is regenerated ONLY when something
|
// Stable memo key: the SVG is regenerated ONLY when something
|
||||||
// structurally relevant changes (mode, width, who's online,
|
// structurally relevant changes (mode, width, who's online,
|
||||||
// who's linked where, or a rate crossed into a different speed
|
// who's linked where, or a rate crossed into a different speed
|
||||||
@@ -980,26 +1017,26 @@ export function NetworkFlow({
|
|||||||
`${c.id}:${c.bridge}:${c.kind}:${c.offline ? 1 : 0}:${bucket(c.rx)}:${bucket(c.tx)}:${sig(c.rx)}:${sig(c.tx)}`
|
`${c.id}:${c.bridge}:${c.kind}:${c.offline ? 1 : 0}:${bucket(c.rx)}:${bucket(c.tx)}:${sig(c.rx)}:${sig(c.tx)}`
|
||||||
).join("|")
|
).join("|")
|
||||||
const bridges = data.bridges.map((b) => `${b.id}:${b.parent || ""}`).join("|")
|
const bridges = data.bridges.map((b) => `${b.id}:${b.parent || ""}`).join("|")
|
||||||
return `${mode}|${width}|${nics}||${bonds}||${guests}||${bridges}`
|
return `${mode}|${width}|${networkUnit}|${nics}||${bonds}||${guests}||${bridges}`
|
||||||
}, [data, mode, width])
|
}, [data, mode, width, networkUnit])
|
||||||
|
|
||||||
const { svgContent, pulses, viewBox, height } = useMemo(() => {
|
const { svgContent, pulses, viewBox, height } = useMemo(() => {
|
||||||
if (mode === "mobile") {
|
if (mode === "mobile") {
|
||||||
const out = renderVertical(data)
|
const out = renderVertical(data, labels, networkUnit)
|
||||||
return { svgContent: out.svg, pulses: out.pulses, viewBox: out.viewBox, height: out.height }
|
return { svgContent: out.svg, pulses: out.pulses, viewBox: out.viewBox, height: out.height }
|
||||||
}
|
}
|
||||||
const W = mode === "tablet" ? 1100 : 1320
|
const W = mode === "tablet" ? 1100 : 1320
|
||||||
const out = renderHorizontal(data, W)
|
const out = renderHorizontal(data, W, labels, networkUnit)
|
||||||
return { svgContent: out.svg, pulses: out.pulses, viewBox: `0 0 ${W} ${out.height}`, height: out.height }
|
return { svgContent: out.svg, pulses: out.pulses, viewBox: `0 0 ${W} ${out.height}`, height: out.height }
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [memoKey])
|
}, [memoKey, labels])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center text-base">
|
<CardTitle className="text-foreground flex items-center text-base">
|
||||||
<Activity className="h-5 w-5 mr-2" />
|
<Activity className="h-5 w-5 mr-2" />
|
||||||
Network Flow (PoC)
|
{t("network.flow.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import { fetchApi } from "../lib/api-config"
|
|||||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||||
import { LatencyDetailModal } from "./latency-detail-modal"
|
import { LatencyDetailModal } from "./latency-detail-modal"
|
||||||
import { AreaChart, Area, LineChart, Line, ResponsiveContainer, YAxis } from "recharts"
|
import { AreaChart, Area, LineChart, Line, ResponsiveContainer, YAxis } from "recharts"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
|
type TFunction = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
interface NetworkData {
|
interface NetworkData {
|
||||||
interfaces: NetworkInterface[]
|
interfaces: NetworkInterface[]
|
||||||
@@ -141,24 +144,57 @@ function getInterfaceIcon(iface: NetworkInterface): React.ComponentType<{ classN
|
|||||||
|
|
||||||
// Match the dark blue badge tone the Storage card uses for the disk
|
// Match the dark blue badge tone the Storage card uses for the disk
|
||||||
// type chip, but mapped to the actual interface class.
|
// type chip, but mapped to the actual interface class.
|
||||||
function getInterfaceTypeChip(type: string) {
|
function getInterfaceTypeLabel(type: string, t: TFunction) {
|
||||||
switch ((type || "").toLowerCase()) {
|
switch ((type || "").toLowerCase()) {
|
||||||
case "physical":
|
case "physical":
|
||||||
return { className: "bg-blue-500/10 text-blue-400 border-blue-500/20", label: "Physical" }
|
return t("network.interfaceTypes.physical")
|
||||||
case "bridge":
|
case "bridge":
|
||||||
return { className: "bg-green-500/10 text-green-400 border-green-500/20", label: "Bridge" }
|
return t("network.interfaceTypes.bridge")
|
||||||
case "bond":
|
case "bond":
|
||||||
return { className: "bg-purple-500/10 text-purple-400 border-purple-500/20", label: "Bond" }
|
return t("network.interfaceTypes.bond")
|
||||||
case "vlan":
|
case "vlan":
|
||||||
return { className: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20", label: "VLAN" }
|
return t("network.interfaceTypes.vlan")
|
||||||
case "vm_lxc":
|
case "vm_lxc":
|
||||||
case "virtual":
|
case "virtual":
|
||||||
return { className: "bg-orange-500/10 text-orange-400 border-orange-500/20", label: "Virtual" }
|
return t("network.interfaceTypes.virtual")
|
||||||
default:
|
default:
|
||||||
return { className: "bg-gray-500/10 text-gray-400 border-gray-500/20", label: type || "Unknown" }
|
return type || t("common.unknown")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInterfaceTypeChip(type: string, t: TFunction) {
|
||||||
|
switch ((type || "").toLowerCase()) {
|
||||||
|
case "physical":
|
||||||
|
return { className: "bg-blue-500/10 text-blue-400 border-blue-500/20", label: getInterfaceTypeLabel(type, t) }
|
||||||
|
case "bridge":
|
||||||
|
return { className: "bg-green-500/10 text-green-400 border-green-500/20", label: getInterfaceTypeLabel(type, t) }
|
||||||
|
case "bond":
|
||||||
|
return { className: "bg-purple-500/10 text-purple-400 border-purple-500/20", label: getInterfaceTypeLabel(type, t) }
|
||||||
|
case "vlan":
|
||||||
|
return { className: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20", label: getInterfaceTypeLabel(type, t) }
|
||||||
|
case "vm_lxc":
|
||||||
|
case "virtual":
|
||||||
|
return { className: "bg-orange-500/10 text-orange-400 border-orange-500/20", label: getInterfaceTypeLabel(type, t) }
|
||||||
|
default:
|
||||||
|
return { className: "bg-gray-500/10 text-gray-400 border-gray-500/20", label: type || t("common.unknown") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatInterfaceStatus = (status: string | undefined, t: TFunction): string => {
|
||||||
|
const normalized = (status || "").toLowerCase()
|
||||||
|
if (normalized === "up") return t("network.status.up")
|
||||||
|
if (normalized === "down") return t("network.status.down")
|
||||||
|
return status || t("common.unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuplex = (duplex: string | undefined, t: TFunction): string => {
|
||||||
|
const normalized = (duplex || "").toLowerCase()
|
||||||
|
if (normalized === "full") return t("network.duplex.full")
|
||||||
|
if (normalized === "half") return t("network.duplex.half")
|
||||||
|
if (!duplex || normalized === "unknown") return t("common.unknown")
|
||||||
|
return duplex
|
||||||
|
}
|
||||||
|
|
||||||
// Per-interface card matching the Storage page's "Physical Disks"
|
// Per-interface card matching the Storage page's "Physical Disks"
|
||||||
// pattern: 2-line header (identity / live state), horizontal divider,
|
// pattern: 2-line header (identity / live state), horizontal divider,
|
||||||
// vertical key→value stat block, footer with serial + arrow CTA.
|
// vertical key→value stat block, footer with serial + arrow CTA.
|
||||||
@@ -166,18 +202,19 @@ function getInterfaceTypeChip(type: string) {
|
|||||||
function renderPhysicalInterfaceCardV2(
|
function renderPhysicalInterfaceCardV2(
|
||||||
iface: NetworkInterface,
|
iface: NetworkInterface,
|
||||||
onOpen: (iface: NetworkInterface) => void,
|
onOpen: (iface: NetworkInterface) => void,
|
||||||
|
t: TFunction,
|
||||||
) {
|
) {
|
||||||
const Icon = getInterfaceIcon(iface)
|
const Icon = getInterfaceIcon(iface)
|
||||||
const chip = getInterfaceTypeChip(iface.type)
|
const chip = getInterfaceTypeChip(iface.type, t)
|
||||||
const isUp = (iface.status || "").toLowerCase() === "up"
|
const isUp = (iface.status || "").toLowerCase() === "up"
|
||||||
const firstAddr = iface.addresses?.[0]?.ip || ""
|
const firstAddr = iface.addresses?.[0]?.ip || ""
|
||||||
const extraAddrs = Math.max(0, (iface.addresses?.length || 0) - 1)
|
const extraAddrs = Math.max(0, (iface.addresses?.length || 0) - 1)
|
||||||
const speedStr = formatSpeed(iface.speed)
|
const speedStr = formatSpeed(iface.speed, t("common.notAvailable"))
|
||||||
// Hardware max in Mbps from ethtool. Show only when it's different
|
// Hardware max in Mbps from ethtool. Show only when it's different
|
||||||
// from the negotiated speed (avoids "1 Gbps (max 1 Gbps)" noise).
|
// from the negotiated speed (avoids "1 Gbps (max 1 Gbps)" noise).
|
||||||
const maxSpeedStr =
|
const maxSpeedStr =
|
||||||
iface.max_speed && iface.max_speed !== iface.speed
|
iface.max_speed && iface.max_speed !== iface.speed
|
||||||
? formatSpeed(iface.max_speed)
|
? formatSpeed(iface.max_speed, t("common.notAvailable"))
|
||||||
: ""
|
: ""
|
||||||
const bridgesUsing = iface.used_by_bridges || []
|
const bridgesUsing = iface.used_by_bridges || []
|
||||||
const errIn = iface.errors_in ?? 0
|
const errIn = iface.errors_in ?? 0
|
||||||
@@ -206,7 +243,7 @@ function renderPhysicalInterfaceCardV2(
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<NetStatusDot tone={isUp ? "ok" : "fail"} />
|
<NetStatusDot tone={isUp ? "ok" : "fail"} />
|
||||||
{iface.status || "?"}
|
{formatInterfaceStatus(iface.status, t)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -217,11 +254,11 @@ function renderPhysicalInterfaceCardV2(
|
|||||||
{speedStr}
|
{speedStr}
|
||||||
{maxSpeedStr && (
|
{maxSpeedStr && (
|
||||||
<span className="text-[11px] text-muted-foreground/70">
|
<span className="text-[11px] text-muted-foreground/70">
|
||||||
· max {maxSpeedStr}
|
· {t("network.labels.maxSpeed", { speed: maxSpeedStr })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="capitalize">{iface.duplex || "—"}</span>
|
<span>{formatDuplex(iface.duplex, t)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Separator. */}
|
{/* Separator. */}
|
||||||
@@ -246,7 +283,7 @@ function renderPhysicalInterfaceCardV2(
|
|||||||
{bridgesUsing.length > 0 && (
|
{bridgesUsing.length > 0 && (
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground shrink-0">
|
<span className="text-[11px] uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
Bridge
|
{t("network.interfaceTypes.bridge")}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-right truncate font-mono text-xs text-cyan-400">
|
<span className="font-medium text-right truncate font-mono text-xs text-cyan-400">
|
||||||
{bridgesUsing.map((b) => `→ ${b}`).join(" ")}
|
{bridgesUsing.map((b) => `→ ${b}`).join(" ")}
|
||||||
@@ -260,7 +297,7 @@ function renderPhysicalInterfaceCardV2(
|
|||||||
has no previous sample to compute against. */}
|
has no previous sample to compute against. */}
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground flex items-center gap-1">
|
<span className="text-[11px] uppercase tracking-wider text-muted-foreground flex items-center gap-1">
|
||||||
<ArrowDown className="h-3 w-3 text-green-500" /> Received
|
<ArrowDown className="h-3 w-3 text-green-500" /> {t("network.labels.received")}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-green-500 tabular-nums">
|
<span className="font-medium text-green-500 tabular-nums">
|
||||||
{iface.rx_Bps !== undefined ? formatRate(iface.rx_Bps) : "—"}
|
{iface.rx_Bps !== undefined ? formatRate(iface.rx_Bps) : "—"}
|
||||||
@@ -268,7 +305,7 @@ function renderPhysicalInterfaceCardV2(
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground flex items-center gap-1">
|
<span className="text-[11px] uppercase tracking-wider text-muted-foreground flex items-center gap-1">
|
||||||
<ArrowUp className="h-3 w-3 text-blue-400" /> Sent
|
<ArrowUp className="h-3 w-3 text-blue-400" /> {t("network.labels.sent")}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-blue-400 tabular-nums">
|
<span className="font-medium text-blue-400 tabular-nums">
|
||||||
{iface.tx_Bps !== undefined ? formatRate(iface.tx_Bps) : "—"}
|
{iface.tx_Bps !== undefined ? formatRate(iface.tx_Bps) : "—"}
|
||||||
@@ -278,7 +315,7 @@ function renderPhysicalInterfaceCardV2(
|
|||||||
<>
|
<>
|
||||||
{totalErrors > 0 && (
|
{totalErrors > 0 && (
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">Errors</span>
|
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">{t("network.labels.errors")}</span>
|
||||||
<span
|
<span
|
||||||
className={`font-medium flex items-center gap-1.5 ${
|
className={`font-medium flex items-center gap-1.5 ${
|
||||||
netCounterTone(totalErrors) === "ok"
|
netCounterTone(totalErrors) === "ok"
|
||||||
@@ -295,7 +332,7 @@ function renderPhysicalInterfaceCardV2(
|
|||||||
)}
|
)}
|
||||||
{totalDrops > 0 && (
|
{totalDrops > 0 && (
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">Drops</span>
|
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">{t("network.labels.drops")}</span>
|
||||||
<span
|
<span
|
||||||
className={`font-medium flex items-center gap-1.5 ${
|
className={`font-medium flex items-center gap-1.5 ${
|
||||||
netCounterTone(totalDrops) === "ok"
|
netCounterTone(totalDrops) === "ok"
|
||||||
@@ -325,7 +362,7 @@ function renderPhysicalInterfaceCardV2(
|
|||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className="text-blue-400 hover:text-blue-300 transition-colors text-base leading-none shrink-0"
|
className="text-blue-400 hover:text-blue-300 transition-colors text-base leading-none shrink-0"
|
||||||
aria-label="View details"
|
aria-label={t("network.actions.viewDetails")}
|
||||||
>
|
>
|
||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
@@ -335,32 +372,32 @@ function renderPhysicalInterfaceCardV2(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const getInterfaceTypeBadge = (type: string) => {
|
const getInterfaceTypeBadge = (type: string, t: TFunction) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "physical":
|
case "physical":
|
||||||
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: "Physical" }
|
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: t("network.interfaceTypes.physical") }
|
||||||
case "bridge":
|
case "bridge":
|
||||||
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: "Bridge" }
|
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: t("network.interfaceTypes.bridge") }
|
||||||
case "bond":
|
case "bond":
|
||||||
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "Bond" }
|
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: t("network.interfaceTypes.bond") }
|
||||||
case "vlan":
|
case "vlan":
|
||||||
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "VLAN" }
|
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: t("network.interfaceTypes.vlan") }
|
||||||
case "vm_lxc":
|
case "vm_lxc":
|
||||||
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
|
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
|
||||||
case "virtual":
|
case "virtual":
|
||||||
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
|
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
|
||||||
default:
|
default:
|
||||||
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
|
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getVMTypeBadge = (vmType: string | undefined) => {
|
const getVMTypeBadge = (vmType: string | undefined, t: TFunction) => {
|
||||||
if (vmType === "lxc") {
|
if (vmType === "lxc") {
|
||||||
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
|
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
|
||||||
} else if (vmType === "vm") {
|
} else if (vmType === "vm") {
|
||||||
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" }
|
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" }
|
||||||
}
|
}
|
||||||
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
|
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format bytes/sec into the canonical network unit ladder.
|
// Format bytes/sec into the canonical network unit ladder.
|
||||||
@@ -396,8 +433,8 @@ const formatStorage = (bytes: number): string => {
|
|||||||
return `${value.toFixed(decimals)} ${sizes[i]}`
|
return `${value.toFixed(decimals)} ${sizes[i]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatSpeed = (speed: number): string => {
|
const formatSpeed = (speed: number, unavailable = "N/A"): string => {
|
||||||
if (speed === 0) return "N/A"
|
if (speed === 0) return unavailable
|
||||||
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
|
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
|
||||||
return `${speed} Mbps`
|
return `${speed} Mbps`
|
||||||
}
|
}
|
||||||
@@ -408,6 +445,7 @@ const fetcher = async (url: string): Promise<NetworkData> => {
|
|||||||
|
|
||||||
|
|
||||||
export function NetworkMetrics() {
|
export function NetworkMetrics() {
|
||||||
|
const t = useT()
|
||||||
const {
|
const {
|
||||||
data: networkData,
|
data: networkData,
|
||||||
error,
|
error,
|
||||||
@@ -469,8 +507,8 @@ export function NetworkMetrics() {
|
|||||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-foreground">Loading network data...</div>
|
<div className="text-sm font-medium text-foreground">{t("network.loading.title")}</div>
|
||||||
<p className="text-xs text-muted-foreground">Scanning interfaces, bridges and traffic</p>
|
<p className="text-xs text-muted-foreground">{t("network.loading.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -483,10 +521,10 @@ export function NetworkMetrics() {
|
|||||||
<div className="flex items-center gap-3 text-red-600">
|
<div className="flex items-center gap-3 text-red-600">
|
||||||
<AlertCircle className="h-6 w-6" />
|
<AlertCircle className="h-6 w-6" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-lg mb-1">Flask Server Not Available</div>
|
<div className="font-semibold text-lg mb-1">{t("network.errors.serverUnavailableTitle")}</div>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{error?.message ||
|
{error?.message ||
|
||||||
"Unable to connect to the Flask server. Please ensure the server is running and try again."}
|
t("network.errors.serverUnavailableDescription")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -514,14 +552,14 @@ export function NetworkMetrics() {
|
|||||||
const avgPacketLoss = ((packetLossIn + packetLossOut) / 2).toFixed(2)
|
const avgPacketLoss = ((packetLossIn + packetLossOut) / 2).toFixed(2)
|
||||||
|
|
||||||
// Determine health status
|
// Determine health status
|
||||||
let healthStatus = "Healthy"
|
let healthStatusKey = "network.status.healthy"
|
||||||
let healthColor = "bg-green-500/10 text-green-500 border-green-500/20"
|
let healthColor = "bg-green-500/10 text-green-500 border-green-500/20"
|
||||||
|
|
||||||
if (Number.parseFloat(avgPacketLoss) > 5 || totalErrors > 1000) {
|
if (Number.parseFloat(avgPacketLoss) > 5 || totalErrors > 1000) {
|
||||||
healthStatus = "Critical"
|
healthStatusKey = "network.status.critical"
|
||||||
healthColor = "bg-red-500/10 text-red-500 border-red-500/20"
|
healthColor = "bg-red-500/10 text-red-500 border-red-500/20"
|
||||||
} else if (Number.parseFloat(avgPacketLoss) >= 1 || totalErrors >= 100) {
|
} else if (Number.parseFloat(avgPacketLoss) >= 1 || totalErrors >= 100) {
|
||||||
healthStatus = "Warning"
|
healthStatusKey = "network.status.warning"
|
||||||
healthColor = "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
healthColor = "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,24 +583,24 @@ export function NetworkMetrics() {
|
|||||||
const topTraffic = (top.bytes_recv || 0) + (top.bytes_sent || 0)
|
const topTraffic = (top.bytes_recv || 0) + (top.bytes_sent || 0)
|
||||||
return ifaceTraffic > topTraffic ? iface : top
|
return ifaceTraffic > topTraffic ? iface : top
|
||||||
}, vmLxcInterfaces[0])
|
}, vmLxcInterfaces[0])
|
||||||
: { name: "No VM/LXC", type: "unknown", bytes_recv: 0, bytes_sent: 0, vm_name: "N/A" }
|
: { name: t("network.empty.noVmLxc"), type: "unknown", bytes_recv: 0, bytes_sent: 0, vm_name: t("common.notAvailable") }
|
||||||
|
|
||||||
const topInterfaceTraffic = (topInterface.bytes_recv || 0) + (topInterface.bytes_sent || 0)
|
const topInterfaceTraffic = (topInterface.bytes_recv || 0) + (topInterface.bytes_sent || 0)
|
||||||
|
|
||||||
const getTimeframeLabel = () => {
|
const getTimeframeLabel = () => {
|
||||||
switch (timeframe) {
|
switch (timeframe) {
|
||||||
case "hour":
|
case "hour":
|
||||||
return "1 Hour"
|
return t("network.timeframes.hour")
|
||||||
case "day":
|
case "day":
|
||||||
return "24 Hours"
|
return t("network.timeframes.day")
|
||||||
case "week":
|
case "week":
|
||||||
return "7 Days"
|
return t("network.timeframes.week")
|
||||||
case "month":
|
case "month":
|
||||||
return "30 Days"
|
return t("network.timeframes.month")
|
||||||
case "year":
|
case "year":
|
||||||
return "1 Year"
|
return t("network.timeframes.year")
|
||||||
default:
|
default:
|
||||||
return "24 Hours"
|
return t("network.timeframes.day")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,25 +609,42 @@ export function NetworkMetrics() {
|
|||||||
const getTimeframeShortLabel = () => {
|
const getTimeframeShortLabel = () => {
|
||||||
switch (timeframe) {
|
switch (timeframe) {
|
||||||
case "hour":
|
case "hour":
|
||||||
return "Past 1 h"
|
return t("network.timeframes.short.hour")
|
||||||
case "day":
|
case "day":
|
||||||
return "Past 24 h"
|
return t("network.timeframes.short.day")
|
||||||
case "week":
|
case "week":
|
||||||
return "Past 7 d"
|
return t("network.timeframes.short.week")
|
||||||
case "month":
|
case "month":
|
||||||
return "Past 30 d"
|
return t("network.timeframes.short.month")
|
||||||
case "year":
|
case "year":
|
||||||
return "Past 1 y"
|
return t("network.timeframes.short.year")
|
||||||
default:
|
default:
|
||||||
return "Past 24 h"
|
return t("network.timeframes.short.day")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hostname = networkData.hostname || "N/A"
|
const getLastTimeframeLabel = (value: "hour" | "day" | "week" | "month" | "year") => {
|
||||||
const domain = networkData.domain || "N/A"
|
switch (value) {
|
||||||
|
case "hour":
|
||||||
|
return t("network.timeframes.last.hour")
|
||||||
|
case "day":
|
||||||
|
return t("network.timeframes.last.day")
|
||||||
|
case "week":
|
||||||
|
return t("network.timeframes.last.week")
|
||||||
|
case "month":
|
||||||
|
return t("network.timeframes.last.month")
|
||||||
|
case "year":
|
||||||
|
return t("network.timeframes.last.year")
|
||||||
|
default:
|
||||||
|
return t("network.timeframes.last.day")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = networkData.hostname || t("common.notAvailable")
|
||||||
|
const domain = networkData.domain || t("common.notAvailable")
|
||||||
const dnsServers = networkData.dns_servers || []
|
const dnsServers = networkData.dns_servers || []
|
||||||
const primaryDNS = dnsServers[0] || "N/A"
|
const primaryDNS = dnsServers[0] || t("common.notAvailable")
|
||||||
const secondaryDNS = dnsServers[1] || "N/A"
|
const secondaryDNS = dnsServers[1] || t("common.notAvailable")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -606,7 +661,7 @@ export function NetworkMetrics() {
|
|||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<div className="flex flex-col gap-0.5 min-w-0">
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Network Traffic</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("network.cards.traffic")}</CardTitle>
|
||||||
<span className="text-[10px] text-muted-foreground/70 font-normal">{getTimeframeShortLabel()}</span>
|
<span className="text-[10px] text-muted-foreground/70 font-normal">{getTimeframeShortLabel()}</span>
|
||||||
</div>
|
</div>
|
||||||
<Activity className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
<Activity className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
@@ -615,13 +670,13 @@ export function NetworkMetrics() {
|
|||||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
<span className="text-green-500">↓</span> Down
|
<span className="text-green-500">↓</span> {t("network.labels.down")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xl lg:text-2xl font-bold leading-tight text-green-500">{trafficInFormatted}</div>
|
<div className="text-xl lg:text-2xl font-bold leading-tight text-green-500">{trafficInFormatted}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||||
<span className="text-blue-500">↑</span> Up
|
<span className="text-blue-500">↑</span> {t("network.labels.up")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xl lg:text-2xl font-bold leading-tight text-blue-500">{trafficOutFormatted}</div>
|
<div className="text-xl lg:text-2xl font-bold leading-tight text-blue-500">{trafficOutFormatted}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -631,8 +686,8 @@ export function NetworkMetrics() {
|
|||||||
<div style={{ width: `${upPct}%`, background: '#3b82f6' }}></div>
|
<div style={{ width: `${upPct}%`, background: '#3b82f6' }}></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex justify-between text-xs text-muted-foreground">
|
<div className="mt-2 flex justify-between text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>Down {Math.round(downPct)}%</span>
|
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>{t("network.labels.down")} {Math.round(downPct)}%</span>
|
||||||
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-blue-500"></span>Up {Math.round(upPct)}%</span>
|
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-blue-500"></span>{t("network.labels.up")} {Math.round(upPct)}%</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -642,7 +697,7 @@ export function NetworkMetrics() {
|
|||||||
{/* ── Active Interfaces (preview restyle v2: revertido al original con title uppercase) ── */}
|
{/* ── Active Interfaces (preview restyle v2: revertido al original con title uppercase) ── */}
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Active Interfaces</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("network.cards.activeInterfaces")}</CardTitle>
|
||||||
<Network className="h-4 w-4 text-muted-foreground" />
|
<Network className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -651,14 +706,16 @@ export function NetworkMetrics() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
<div className="flex flex-wrap items-center gap-2 mt-2">
|
||||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
|
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
|
||||||
Physical: {networkData.physical_active_count ?? 0}/{networkData.physical_total_count ?? 0}
|
{t("network.interfaceTypes.physical")}: {networkData.physical_active_count ?? 0}/{networkData.physical_total_count ?? 0}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
Bridges: {networkData.bridge_active_count ?? 0}/{networkData.bridge_total_count ?? 0}
|
{t("network.interfaceTypes.bridges")}: {networkData.bridge_active_count ?? 0}/{networkData.bridge_total_count ?? 0}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
{(networkData.physical_total_count ?? 0) + (networkData.bridge_total_count ?? 0)} total interfaces
|
{t("network.summary.totalInterfaces", {
|
||||||
|
count: (networkData.physical_total_count ?? 0) + (networkData.bridge_total_count ?? 0),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -666,8 +723,8 @@ export function NetworkMetrics() {
|
|||||||
{/* ── Network Status (preview restyle: packet-loss highlight + 2x2 grid) ── */}
|
{/* ── Network Status (preview restyle: packet-loss highlight + 2x2 grid) ── */}
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Network Status</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("network.cards.status")}</CardTitle>
|
||||||
<Badge variant="outline" className={`${healthColor}`}>{healthStatus === 'Healthy' ? '✓ ' : ''}{healthStatus}</Badge>
|
<Badge variant="outline" className={`${healthColor}`}>{healthStatusKey === "network.status.healthy" ? "✓ " : ""}{t(healthStatusKey)}</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -680,13 +737,13 @@ export function NetworkMetrics() {
|
|||||||
return (
|
return (
|
||||||
<div className={`mb-3 text-xl lg:text-2xl font-bold ${lossColor} leading-none`}>
|
<div className={`mb-3 text-xl lg:text-2xl font-bold ${lossColor} leading-none`}>
|
||||||
{avgPacketLoss}<span className="text-sm font-normal text-muted-foreground">% </span>
|
{avgPacketLoss}<span className="text-sm font-normal text-muted-foreground">% </span>
|
||||||
<span className="text-sm font-normal text-muted-foreground">Packet Loss</span>
|
<span className="text-sm font-normal text-muted-foreground">{t("network.labels.packetLoss")}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3 pt-3 border-t border-border/50 text-sm">
|
<div className="grid grid-cols-2 gap-x-3 gap-y-3 pt-3 border-t border-border/50 text-sm">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-muted-foreground">Hostname:</div>
|
<div className="text-muted-foreground">{t("network.labels.hostname")}:</div>
|
||||||
<div className="font-medium font-mono truncate">{hostname}</div>
|
<div className="font-medium font-mono truncate">{hostname}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -694,12 +751,12 @@ export function NetworkMetrics() {
|
|||||||
<div className="font-medium font-mono truncate">{primaryDNS}</div>
|
<div className="font-medium font-mono truncate">{primaryDNS}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-muted-foreground">Errors:</div>
|
<div className="text-muted-foreground">{t("network.labels.errors")}:</div>
|
||||||
<div className="font-medium font-mono">{totalErrors}</div>
|
<div className="font-medium font-mono">{totalErrors}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-muted-foreground">Domain:</div>
|
<div className="text-muted-foreground">{t("network.labels.domain")}:</div>
|
||||||
<div className="font-medium font-mono truncate">{networkData.domain || '—'}</div>
|
<div className="font-medium font-mono truncate">{domain}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -711,7 +768,7 @@ export function NetworkMetrics() {
|
|||||||
onClick={() => setLatencyModalOpen(true)}
|
onClick={() => setLatencyModalOpen(true)}
|
||||||
>
|
>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Network Latency</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("network.cards.latency")}</CardTitle>
|
||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
<Timer className="h-4 w-4" />
|
<Timer className="h-4 w-4" />
|
||||||
<ChevronRight className="h-4 w-4 opacity-60" />
|
<ChevronRight className="h-4 w-4 opacity-60" />
|
||||||
@@ -734,9 +791,9 @@ export function NetworkMetrics() {
|
|||||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(latencyData?.stats?.current ?? 0) < 50 ? "Excellent" :
|
{(latencyData?.stats?.current ?? 0) < 50 ? t("network.latency.status.excellent") :
|
||||||
(latencyData?.stats?.current ?? 0) < 100 ? "Good" :
|
(latencyData?.stats?.current ?? 0) < 100 ? t("network.latency.status.good") :
|
||||||
(latencyData?.stats?.current ?? 0) < 200 ? "Fair" : "Poor"}
|
(latencyData?.stats?.current ?? 0) < 200 ? t("network.latency.status.fair") : t("network.latency.status.poor")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{/* Sparkline */}
|
{/* Sparkline */}
|
||||||
@@ -765,7 +822,7 @@ export function NetworkMetrics() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Avg: {latencyData?.stats?.avg ?? 0}ms | Max: {latencyData?.stats?.max ?? 0}ms
|
{t("network.labels.avg")}: {latencyData?.stats?.avg ?? 0}ms | {t("network.labels.max")}: {latencyData?.stats?.max ?? 0}ms
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -778,11 +835,11 @@ export function NetworkMetrics() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="hour">1 Hour</SelectItem>
|
<SelectItem value="hour">{t("network.timeframes.hour")}</SelectItem>
|
||||||
<SelectItem value="day">24 Hours</SelectItem>
|
<SelectItem value="day">{t("network.timeframes.day")}</SelectItem>
|
||||||
<SelectItem value="week">7 Days</SelectItem>
|
<SelectItem value="week">{t("network.timeframes.week")}</SelectItem>
|
||||||
<SelectItem value="month">30 Days</SelectItem>
|
<SelectItem value="month">{t("network.timeframes.month")}</SelectItem>
|
||||||
<SelectItem value="year">1 Year</SelectItem>
|
<SelectItem value="year">{t("network.timeframes.year")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -792,7 +849,7 @@ export function NetworkMetrics() {
|
|||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-foreground flex items-center">
|
||||||
<Activity className="h-5 w-5 mr-2" />
|
<Activity className="h-5 w-5 mr-2" />
|
||||||
Network Traffic
|
{t("network.cards.traffic")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -904,9 +961,12 @@ export function NetworkMetrics() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-foreground flex items-center">
|
||||||
<Router className="h-5 w-5 mr-2" />
|
<Router className="h-5 w-5 mr-2" />
|
||||||
Physical Interfaces
|
{t("network.sections.physicalInterfaces")}
|
||||||
<Badge variant="outline" className="ml-3 bg-blue-500/10 text-blue-500 border-blue-500/20">
|
<Badge variant="outline" className="ml-3 bg-blue-500/10 text-blue-500 border-blue-500/20">
|
||||||
{networkData.physical_active_count ?? 0}/{networkData.physical_total_count ?? 0} Active
|
{t("network.summary.activeCount", {
|
||||||
|
active: networkData.physical_active_count ?? 0,
|
||||||
|
total: networkData.physical_total_count ?? 0,
|
||||||
|
})}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -916,7 +976,7 @@ export function NetworkMetrics() {
|
|||||||
long interface names won't push others off-screen. */}
|
long interface names won't push others off-screen. */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{networkData.physical_interfaces.map((iface) =>
|
{networkData.physical_interfaces.map((iface) =>
|
||||||
renderPhysicalInterfaceCardV2(iface, setSelectedInterface),
|
renderPhysicalInterfaceCardV2(iface, setSelectedInterface, t),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -927,16 +987,19 @@ export function NetworkMetrics() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-foreground flex items-center">
|
||||||
<Network className="h-5 w-5 mr-2" />
|
<Network className="h-5 w-5 mr-2" />
|
||||||
Bridge Interfaces
|
{t("network.sections.bridgeInterfaces")}
|
||||||
<Badge variant="outline" className="ml-3 bg-green-500/10 text-green-500 border-green-500/20">
|
<Badge variant="outline" className="ml-3 bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
{networkData.bridge_active_count ?? 0}/{networkData.bridge_total_count ?? 0} Active
|
{t("network.summary.activeCount", {
|
||||||
|
active: networkData.bridge_active_count ?? 0,
|
||||||
|
total: networkData.bridge_total_count ?? 0,
|
||||||
|
})}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{networkData.bridge_interfaces.map((interface_, index) => {
|
{networkData.bridge_interfaces.map((interface_, index) => {
|
||||||
const typeBadge = getInterfaceTypeBadge(interface_.type)
|
const typeBadge = getInterfaceTypeBadge(interface_.type, t)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -971,30 +1034,30 @@ export function NetworkMetrics() {
|
|||||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{interface_.status.toUpperCase()}
|
{formatInterfaceStatus(interface_.status, t)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Second row: Details - Responsive layout */}
|
{/* Second row: Details - Responsive layout */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground text-xs">IP Address</div>
|
<div className="text-muted-foreground text-xs">{t("network.labels.ipAddress")}</div>
|
||||||
<div className="font-medium text-foreground font-mono text-sm truncate">
|
<div className="font-medium text-foreground font-mono text-sm truncate">
|
||||||
{interface_.addresses.length > 0 ? interface_.addresses[0].ip : "N/A"}
|
{interface_.addresses.length > 0 ? interface_.addresses[0].ip : t("common.notAvailable")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground text-xs">Speed</div>
|
<div className="text-muted-foreground text-xs">{t("network.labels.speed")}</div>
|
||||||
<div className="font-medium text-foreground flex items-center gap-1">
|
<div className="font-medium text-foreground flex items-center gap-1">
|
||||||
<Zap className="h-3 w-3" />
|
<Zap className="h-3 w-3" />
|
||||||
{formatSpeed(interface_.speed)}
|
{formatSpeed(interface_.speed, t("common.notAvailable"))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground text-xs">Duplex</div>
|
<div className="text-muted-foreground text-xs">{t("network.labels.duplex")}</div>
|
||||||
<div className="font-medium text-foreground text-xs capitalize">{interface_.duplex}</div>
|
<div className="font-medium text-foreground text-xs">{formatDuplex(interface_.duplex, t)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -1025,16 +1088,19 @@ export function NetworkMetrics() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-foreground flex items-center">
|
||||||
<Network className="h-5 w-5 mr-2" />
|
<Network className="h-5 w-5 mr-2" />
|
||||||
VM & LXC Network Interfaces
|
{t("network.sections.vmLxcInterfaces")}
|
||||||
<Badge variant="outline" className="ml-3 bg-orange-500/10 text-orange-500 border-orange-500/20">
|
<Badge variant="outline" className="ml-3 bg-orange-500/10 text-orange-500 border-orange-500/20">
|
||||||
{networkData.vm_lxc_active_count ?? 0} / {networkData.vm_lxc_total_count ?? 0} Active
|
{t("network.summary.activeCount", {
|
||||||
|
active: networkData.vm_lxc_active_count ?? 0,
|
||||||
|
total: networkData.vm_lxc_total_count ?? 0,
|
||||||
|
})}
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{vmLxcInterfaces.map((interface_, index) => {
|
{vmLxcInterfaces.map((interface_, index) => {
|
||||||
const vmTypeBadge = getVMTypeBadge(interface_.vm_type)
|
const vmTypeBadge = getVMTypeBadge(interface_.vm_type, t)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1062,7 +1128,7 @@ export function NetworkMetrics() {
|
|||||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{interface_.status.toUpperCase()}
|
{formatInterfaceStatus(interface_.status, t)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1070,20 +1136,20 @@ export function NetworkMetrics() {
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">VMID</div>
|
<div className="text-sm text-muted-foreground">VMID</div>
|
||||||
<div className="font-medium">{interface_.vmid ?? "N/A"}</div>
|
<div className="font-medium">{interface_.vmid ?? t("common.notAvailable")}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Speed</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.speed")}</div>
|
||||||
<div className="font-medium text-foreground flex items-center gap-1">
|
<div className="font-medium text-foreground flex items-center gap-1">
|
||||||
<Zap className="h-3 w-3" />
|
<Zap className="h-3 w-3" />
|
||||||
{formatSpeed(interface_.speed)}
|
{formatSpeed(interface_.speed, t("common.notAvailable"))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Duplex</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.duplex")}</div>
|
||||||
<div className="font-medium text-foreground text-xs capitalize">{interface_.duplex}</div>
|
<div className="font-medium text-foreground text-xs">{formatDuplex(interface_.duplex, t)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -1114,10 +1180,10 @@ export function NetworkMetrics() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Router className="h-5 w-5" />
|
<Router className="h-5 w-5" />
|
||||||
{selectedInterface?.name} - Interface Details
|
{selectedInterface?.name} - {t("network.interfaceDetails.title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
View detailed information and network traffic statistics for this interface
|
{t("network.interfaceDetails.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
{selectedInterface?.status.toLowerCase() === "up" && selectedInterface?.vm_type !== "vm" && (
|
{selectedInterface?.status.toLowerCase() === "up" && selectedInterface?.vm_type !== "vm" && (
|
||||||
<div className="flex justify-end pt-2">
|
<div className="flex justify-end pt-2">
|
||||||
@@ -1126,11 +1192,11 @@ export function NetworkMetrics() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="hour">1 Hour</SelectItem>
|
<SelectItem value="hour">{t("network.timeframes.hour")}</SelectItem>
|
||||||
<SelectItem value="day">24 Hours</SelectItem>
|
<SelectItem value="day">{t("network.timeframes.day")}</SelectItem>
|
||||||
<SelectItem value="week">7 Days</SelectItem>
|
<SelectItem value="week">{t("network.timeframes.week")}</SelectItem>
|
||||||
<SelectItem value="month">30 Days</SelectItem>
|
<SelectItem value="month">{t("network.timeframes.month")}</SelectItem>
|
||||||
<SelectItem value="year">1 Year</SelectItem>
|
<SelectItem value="year">{t("network.timeframes.year")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1156,21 +1222,21 @@ export function NetworkMetrics() {
|
|||||||
<>
|
<>
|
||||||
{/* Basic Information */}
|
{/* Basic Information */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Basic Information</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground mb-3">{t("network.interfaceDetails.basicInformation")}</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Interface Name</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.interfaceName")}</div>
|
||||||
<div className="font-medium">{displayInterface.name}</div>
|
<div className="font-medium">{displayInterface.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Type</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.type")}</div>
|
||||||
<Badge variant="outline" className={getInterfaceTypeBadge(displayInterface.type).color}>
|
<Badge variant="outline" className={getInterfaceTypeBadge(displayInterface.type, t).color}>
|
||||||
{getInterfaceTypeBadge(displayInterface.type).label}
|
{getInterfaceTypeBadge(displayInterface.type, t).label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{displayInterface.type === "bridge" && displayInterface.bridge_physical_interface && (
|
{displayInterface.type === "bridge" && displayInterface.bridge_physical_interface && (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<div className="text-sm text-muted-foreground">Physical Interface</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.physicalInterface")}</div>
|
||||||
<div className="font-medium text-blue-500 text-lg break-all">
|
<div className="font-medium text-blue-500 text-lg break-all">
|
||||||
{displayInterface.bridge_physical_interface}
|
{displayInterface.bridge_physical_interface}
|
||||||
</div>
|
</div>
|
||||||
@@ -1180,7 +1246,7 @@ export function NetworkMetrics() {
|
|||||||
there never matched. */}
|
there never matched. */}
|
||||||
{displayInterface.bridge_bond_slaves && displayInterface.bridge_bond_slaves.length > 0 && (
|
{displayInterface.bridge_bond_slaves && displayInterface.bridge_bond_slaves.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="text-sm text-muted-foreground mb-2">Bond Members</div>
|
<div className="text-sm text-muted-foreground mb-2">{t("network.labels.bondMembers")}</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{displayInterface.bridge_bond_slaves.map((slave, idx) => (
|
{displayInterface.bridge_bond_slaves.map((slave, idx) => (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -1198,19 +1264,19 @@ export function NetworkMetrics() {
|
|||||||
)}
|
)}
|
||||||
{displayInterface.type === "vm_lxc" && displayInterface.vm_name && (
|
{displayInterface.type === "vm_lxc" && displayInterface.vm_name && (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<div className="text-sm text-muted-foreground">VM/LXC Name</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.vmLxcName")}</div>
|
||||||
<div className="font-medium text-orange-500 text-lg flex items-center gap-2">
|
<div className="font-medium text-orange-500 text-lg flex items-center gap-2">
|
||||||
{displayInterface.vm_name}
|
{displayInterface.vm_name}
|
||||||
{displayInterface.vm_type && (
|
{displayInterface.vm_type && (
|
||||||
<Badge variant="outline" className={getVMTypeBadge(displayInterface.vm_type).color}>
|
<Badge variant="outline" className={getVMTypeBadge(displayInterface.vm_type, t).color}>
|
||||||
{getVMTypeBadge(displayInterface.vm_type).label}
|
{getVMTypeBadge(displayInterface.vm_type, t).label}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Status</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.status")}</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={
|
className={
|
||||||
@@ -1219,16 +1285,16 @@ export function NetworkMetrics() {
|
|||||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{displayInterface.status.toUpperCase()}
|
{formatInterfaceStatus(displayInterface.status, t)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Speed</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.speed")}</div>
|
||||||
<div className="font-medium">{formatSpeed(displayInterface.speed)}</div>
|
<div className="font-medium">{formatSpeed(displayInterface.speed, t("common.notAvailable"))}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Duplex</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.duplex")}</div>
|
||||||
<div className="font-medium capitalize">{displayInterface.duplex}</div>
|
<div className="font-medium">{formatDuplex(displayInterface.duplex, t)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">MTU</div>
|
<div className="text-sm text-muted-foreground">MTU</div>
|
||||||
@@ -1236,7 +1302,7 @@ export function NetworkMetrics() {
|
|||||||
</div>
|
</div>
|
||||||
{displayInterface.mac_address && (
|
{displayInterface.mac_address && (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<div className="text-sm text-muted-foreground">MAC Address</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.macAddress")}</div>
|
||||||
<div className="font-medium font-mono">{displayInterface.mac_address}</div>
|
<div className="font-medium font-mono">{displayInterface.mac_address}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1246,13 +1312,13 @@ export function NetworkMetrics() {
|
|||||||
{/* IP Addresses */}
|
{/* IP Addresses */}
|
||||||
{displayInterface.addresses.length > 0 && (
|
{displayInterface.addresses.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">IP Addresses</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground mb-3">{t("network.interfaceDetails.ipAddresses")}</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{displayInterface.addresses.map((addr, idx) => (
|
{displayInterface.addresses.map((addr, idx) => (
|
||||||
<div key={idx} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
<div key={idx} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium font-mono">{addr.ip}</div>
|
<div className="font-medium font-mono">{addr.ip}</div>
|
||||||
<div className="text-sm text-muted-foreground">Netmask: {addr.netmask}</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.netmask")}: {addr.netmask}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1264,23 +1330,15 @@ export function NetworkMetrics() {
|
|||||||
{displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type !== "vm" ? (
|
{displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type !== "vm" ? (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-4">
|
<h3 className="text-sm font-semibold text-muted-foreground mb-4">
|
||||||
Network Traffic Statistics (
|
{t("network.interfaceDetails.trafficStatistics", {
|
||||||
{modalTimeframe === "hour"
|
timeframe: getLastTimeframeLabel(modalTimeframe),
|
||||||
? "Last Hour"
|
})}
|
||||||
: modalTimeframe === "day"
|
|
||||||
? "Last 24 Hours"
|
|
||||||
: modalTimeframe === "week"
|
|
||||||
? "Last 7 Days"
|
|
||||||
: modalTimeframe === "month"
|
|
||||||
? "Last 30 Days"
|
|
||||||
: "Last Year"}
|
|
||||||
)
|
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{networkUnit === "Bits" ? "Bits Received" : "Bytes Received"}
|
{networkUnit === "Bits" ? t("network.labels.bitsReceived") : t("network.labels.bytesReceived")}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-green-500 text-lg">
|
<div className="font-medium text-green-500 text-lg">
|
||||||
{formatNetworkTraffic(
|
{formatNetworkTraffic(
|
||||||
@@ -1292,7 +1350,7 @@ export function NetworkMetrics() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{networkUnit === "Bits" ? "Bits Sent" : "Bytes Sent"}
|
{networkUnit === "Bits" ? t("network.labels.bitsSent") : t("network.labels.bytesSent")}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-blue-500 text-lg">
|
<div className="font-medium text-blue-500 text-lg">
|
||||||
{formatNetworkTraffic(
|
{formatNetworkTraffic(
|
||||||
@@ -1316,31 +1374,31 @@ export function NetworkMetrics() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border">
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Packets Received</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.packetsReceived")}</div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{displayInterface.packets_recv?.toLocaleString() || "N/A"}
|
{displayInterface.packets_recv?.toLocaleString() || t("common.notAvailable")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Packets Sent</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.packetsSent")}</div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{displayInterface.packets_sent?.toLocaleString() || "N/A"}
|
{displayInterface.packets_sent?.toLocaleString() || t("common.notAvailable")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Errors In</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.errorsIn")}</div>
|
||||||
<div className="font-medium text-red-500">{displayInterface.errors_in || 0}</div>
|
<div className="font-medium text-red-500">{displayInterface.errors_in || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Errors Out</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.errorsOut")}</div>
|
||||||
<div className="font-medium text-red-500">{displayInterface.errors_out || 0}</div>
|
<div className="font-medium text-red-500">{displayInterface.errors_out || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Drops In</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.dropsIn")}</div>
|
||||||
<div className="font-medium text-yellow-500">{displayInterface.drops_in || 0}</div>
|
<div className="font-medium text-yellow-500">{displayInterface.drops_in || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Drops Out</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.dropsOut")}</div>
|
||||||
<div className="font-medium text-yellow-500">{displayInterface.drops_out || 0}</div>
|
<div className="font-medium text-yellow-500">{displayInterface.drops_out || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1348,11 +1406,11 @@ export function NetworkMetrics() {
|
|||||||
</div>
|
</div>
|
||||||
) : displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type === "vm" ? (
|
) : displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type === "vm" ? (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-4">Traffic since last boot</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground mb-4">{t("network.interfaceDetails.trafficSinceBoot")}</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{networkUnit === "Bits" ? "Bits Received" : "Bytes Received"}
|
{networkUnit === "Bits" ? t("network.labels.bitsReceived") : t("network.labels.bytesReceived")}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-green-500 text-lg">
|
<div className="font-medium text-green-500 text-lg">
|
||||||
{formatNetworkTraffic(displayInterface.bytes_recv || 0, networkUnit)}
|
{formatNetworkTraffic(displayInterface.bytes_recv || 0, networkUnit)}
|
||||||
@@ -1360,38 +1418,38 @@ export function NetworkMetrics() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{networkUnit === "Bits" ? "Bits Sent" : "Bytes Sent"}
|
{networkUnit === "Bits" ? t("network.labels.bitsSent") : t("network.labels.bytesSent")}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-blue-500 text-lg">
|
<div className="font-medium text-blue-500 text-lg">
|
||||||
{formatNetworkTraffic(displayInterface.bytes_sent || 0, networkUnit)}
|
{formatNetworkTraffic(displayInterface.bytes_sent || 0, networkUnit)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Packets Received</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.packetsReceived")}</div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{displayInterface.packets_recv?.toLocaleString() || "N/A"}
|
{displayInterface.packets_recv?.toLocaleString() || t("common.notAvailable")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Packets Sent</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.packetsSent")}</div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{displayInterface.packets_sent?.toLocaleString() || "N/A"}
|
{displayInterface.packets_sent?.toLocaleString() || t("common.notAvailable")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Errors In</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.errorsIn")}</div>
|
||||||
<div className="font-medium text-red-500">{displayInterface.errors_in || 0}</div>
|
<div className="font-medium text-red-500">{displayInterface.errors_in || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Errors Out</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.errorsOut")}</div>
|
||||||
<div className="font-medium text-red-500">{displayInterface.errors_out || 0}</div>
|
<div className="font-medium text-red-500">{displayInterface.errors_out || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Drops In</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.dropsIn")}</div>
|
||||||
<div className="font-medium text-yellow-500">{displayInterface.drops_in || 0}</div>
|
<div className="font-medium text-yellow-500">{displayInterface.drops_in || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Drops Out</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.dropsOut")}</div>
|
||||||
<div className="font-medium text-yellow-500">{displayInterface.drops_out || 0}</div>
|
<div className="font-medium text-yellow-500">{displayInterface.drops_out || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1399,9 +1457,9 @@ export function NetworkMetrics() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="bg-muted/30 rounded-lg p-6 text-center">
|
<div className="bg-muted/30 rounded-lg p-6 text-center">
|
||||||
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-2">Interface Inactive</h3>
|
<h3 className="text-lg font-semibold text-foreground mb-2">{t("network.interfaceDetails.inactiveTitle")}</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This interface is currently down. Network traffic statistics are not available.
|
{t("network.interfaceDetails.inactiveDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1409,12 +1467,12 @@ export function NetworkMetrics() {
|
|||||||
{/* Bond Information */}
|
{/* Bond Information */}
|
||||||
{displayInterface.type === "bond" && displayInterface.bond_slaves && (
|
{displayInterface.type === "bond" && displayInterface.bond_slaves && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Bond Configuration</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground mb-3">{t("network.interfaceDetails.bondConfiguration")}</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">Bonding Mode</div>
|
<div className="text-sm text-muted-foreground">{t("network.labels.bondingMode")}</div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{displayInterface.bond_mode || "Unknown"}
|
{displayInterface.bond_mode || t("common.unknown")}
|
||||||
{displayInterface.bond_mode_detail &&
|
{displayInterface.bond_mode_detail &&
|
||||||
displayInterface.bond_mode_detail !== displayInterface.bond_mode && (
|
displayInterface.bond_mode_detail !== displayInterface.bond_mode && (
|
||||||
<span className="text-muted-foreground font-normal">
|
<span className="text-muted-foreground font-normal">
|
||||||
@@ -1427,13 +1485,13 @@ export function NetworkMetrics() {
|
|||||||
{displayInterface.bond_active_slave && (
|
{displayInterface.bond_active_slave && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{displayInterface.bond_supports_failover ? "Active Slave" : "Primary Slave"}
|
{displayInterface.bond_supports_failover ? t("network.labels.activeSlave") : t("network.labels.primarySlave")}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium">{displayInterface.bond_active_slave}</div>
|
<div className="font-medium">{displayInterface.bond_active_slave}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground mb-2">Slave Interfaces</div>
|
<div className="text-sm text-muted-foreground mb-2">{t("network.labels.slaveInterfaces")}</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{displayInterface.bond_slaves.map((slave, idx) => {
|
{displayInterface.bond_slaves.map((slave, idx) => {
|
||||||
// Only active-backup has a real standby. In every
|
// Only active-backup has a real standby. In every
|
||||||
@@ -1456,7 +1514,7 @@ export function NetworkMetrics() {
|
|||||||
return (
|
return (
|
||||||
<Badge key={idx} variant="outline" className={tone}>
|
<Badge key={idx} variant="outline" className={tone}>
|
||||||
{slave}
|
{slave}
|
||||||
{role && <span className="ml-1 opacity-70">· {role}</span>}
|
{role && <span className="ml-1 opacity-70">· {t(`network.roles.${role}`)}</span>}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -1469,9 +1527,9 @@ export function NetworkMetrics() {
|
|||||||
{/* Bridge Information */}
|
{/* Bridge Information */}
|
||||||
{displayInterface.type === "bridge" && displayInterface.bridge_members && (
|
{displayInterface.type === "bridge" && displayInterface.bridge_members && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Bridge Configuration</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground mb-3">{t("network.interfaceDetails.bridgeConfiguration")}</h3>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground mb-2">Virtual Member Interfaces</div>
|
<div className="text-sm text-muted-foreground mb-2">{t("network.labels.virtualMemberInterfaces")}</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{displayInterface.bridge_members.length > 0 ? (
|
{displayInterface.bridge_members.length > 0 ? (
|
||||||
displayInterface.bridge_members
|
displayInterface.bridge_members
|
||||||
@@ -1494,7 +1552,7 @@ export function NetworkMetrics() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted-foreground">No virtual members</div>
|
<div className="text-sm text-muted-foreground">{t("network.empty.noVirtualMembers")}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
|||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
import { getNetworkUnit } from "../lib/format-network"
|
import { getNetworkUnit } from "../lib/format-network"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface NetworkMetricsData {
|
interface NetworkMetricsData {
|
||||||
time: string
|
time: string
|
||||||
@@ -50,6 +51,7 @@ export function NetworkTrafficChart({
|
|||||||
refreshInterval = 60000,
|
refreshInterval = 60000,
|
||||||
networkUnit: networkUnitProp, // Rename prop to avoid conflict
|
networkUnit: networkUnitProp, // Rename prop to avoid conflict
|
||||||
}: NetworkTrafficChartProps) {
|
}: NetworkTrafficChartProps) {
|
||||||
|
const t = useT()
|
||||||
const [data, setData] = useState<NetworkMetricsData[]>([])
|
const [data, setData] = useState<NetworkMetricsData[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -114,7 +116,7 @@ export function NetworkTrafficChart({
|
|||||||
const result = await fetchApi<any>(apiPath)
|
const result = await fetchApi<any>(apiPath)
|
||||||
|
|
||||||
if (!result.data || !Array.isArray(result.data)) {
|
if (!result.data || !Array.isArray(result.data)) {
|
||||||
throw new Error("Invalid data format received from server")
|
throw new Error(t("network.chart.invalidDataFormat"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.data.length === 0) {
|
if (result.data.length === 0) {
|
||||||
@@ -207,7 +209,7 @@ export function NetworkTrafficChart({
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Error fetching network metrics:", err)
|
console.error("Error fetching network metrics:", err)
|
||||||
setError(err.message || "Error loading metrics")
|
setError(err.message || t("network.chart.loadError"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -255,7 +257,7 @@ export function NetworkTrafficChart({
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-[300px] gap-2">
|
<div className="flex flex-col items-center justify-center h-[300px] gap-2">
|
||||||
<p className="text-muted-foreground text-sm">Network metrics not available yet</p>
|
<p className="text-muted-foreground text-sm">{t("overview.networkMetricsUnavailable")}</p>
|
||||||
<p className="text-xs text-red-500">{error}</p>
|
<p className="text-xs text-red-500">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -264,7 +266,7 @@ export function NetworkTrafficChart({
|
|||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[300px]">
|
<div className="flex items-center justify-center h-[300px]">
|
||||||
<p className="text-muted-foreground text-sm">No network metrics available</p>
|
<p className="text-muted-foreground text-sm">{t("overview.noNetworkMetrics")}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -295,7 +297,7 @@ export function NetworkTrafficChart({
|
|||||||
}}
|
}}
|
||||||
domain={[0, "auto"]}
|
domain={[0, "auto"]}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} /> // Pass networkUnit to tooltip
|
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} />
|
||||||
<Legend verticalAlign="top" height={36} content={renderLegend} />
|
<Legend verticalAlign="top" height={36} content={renderLegend} />
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
@@ -304,7 +306,7 @@ export function NetworkTrafficChart({
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#10b981"
|
fill="#10b981"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="Received"
|
name={t("overview.receivedShort")}
|
||||||
hide={!visibleLines.netIn}
|
hide={!visibleLines.netIn}
|
||||||
isAnimationActive={true}
|
isAnimationActive={true}
|
||||||
animationDuration={300}
|
animationDuration={300}
|
||||||
@@ -317,7 +319,7 @@ export function NetworkTrafficChart({
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#3b82f6"
|
fill="#3b82f6"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="Sent"
|
name={t("overview.sentShort")}
|
||||||
hide={!visibleLines.netOut}
|
hide={!visibleLines.netOut}
|
||||||
isAnimationActive={true}
|
isAnimationActive={true}
|
||||||
animationDuration={300}
|
animationDuration={300}
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
|||||||
import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
|
import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
|
||||||
import { useIsMobile } from "../hooks/use-mobile"
|
import { useIsMobile } from "../hooks/use-mobile"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
|
import { useI18n } from "../lib/i18n/provider"
|
||||||
|
|
||||||
const TIMEFRAME_OPTIONS = [
|
const TIMEFRAME_OPTIONS = [
|
||||||
{ value: "hour", label: "1 Hour" },
|
{ value: "hour", labelKey: "overview.timeframes.hour" },
|
||||||
{ value: "day", label: "24 Hours" },
|
{ value: "day", labelKey: "overview.timeframes.day" },
|
||||||
{ value: "week", label: "7 Days" },
|
{ value: "week", labelKey: "overview.timeframes.week" },
|
||||||
{ value: "month", label: "30 Days" },
|
{ value: "month", labelKey: "overview.timeframes.month" },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface NodeMetricsData {
|
interface NodeMetricsData {
|
||||||
@@ -90,9 +91,11 @@ type PeriodStat = { avg: number; max: number; min: number } | null
|
|||||||
function ChartStatsHeader({
|
function ChartStatsHeader({
|
||||||
stats,
|
stats,
|
||||||
suffix = "",
|
suffix = "",
|
||||||
|
labels,
|
||||||
}: {
|
}: {
|
||||||
stats: PeriodStat
|
stats: PeriodStat
|
||||||
suffix?: string
|
suffix?: string
|
||||||
|
labels: { avg: string; max: string; min: string }
|
||||||
}) {
|
}) {
|
||||||
if (!stats) return null
|
if (!stats) return null
|
||||||
const fmt = (n: number) => (n >= 100 ? n.toFixed(0) : n.toFixed(1))
|
const fmt = (n: number) => (n >= 100 ? n.toFixed(0) : n.toFixed(1))
|
||||||
@@ -100,15 +103,15 @@ function ChartStatsHeader({
|
|||||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm tabular-nums">
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm tabular-nums">
|
||||||
<span>
|
<span>
|
||||||
<span className="font-semibold text-foreground">{fmt(stats.avg)}{suffix}</span>
|
<span className="font-semibold text-foreground">{fmt(stats.avg)}{suffix}</span>
|
||||||
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">avg</span>
|
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.avg}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="font-semibold text-foreground">{fmt(stats.max)}{suffix}</span>
|
<span className="font-semibold text-foreground">{fmt(stats.max)}{suffix}</span>
|
||||||
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">max</span>
|
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.max}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span className="font-semibold text-foreground">{fmt(stats.min)}{suffix}</span>
|
<span className="font-semibold text-foreground">{fmt(stats.min)}{suffix}</span>
|
||||||
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">min</span>
|
<span className="ml-1 text-xs uppercase tracking-wide text-muted-foreground">{labels.min}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -116,6 +119,7 @@ function ChartStatsHeader({
|
|||||||
|
|
||||||
|
|
||||||
export function NodeMetricsCharts() {
|
export function NodeMetricsCharts() {
|
||||||
|
const { language, t } = useI18n()
|
||||||
const [timeframe, setTimeframe] = useState("day")
|
const [timeframe, setTimeframe] = useState("day")
|
||||||
const [data, setData] = useState<NodeMetricsData[]>([])
|
const [data, setData] = useState<NodeMetricsData[]>([])
|
||||||
// period_stats from the backend — computed over the raw RRD points
|
// period_stats from the backend — computed over the raw RRD points
|
||||||
@@ -141,7 +145,7 @@ export function NodeMetricsCharts() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMetrics()
|
fetchMetrics()
|
||||||
}, [timeframe])
|
}, [timeframe, language])
|
||||||
|
|
||||||
const fetchMetrics = async () => {
|
const fetchMetrics = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -153,7 +157,7 @@ export function NodeMetricsCharts() {
|
|||||||
|
|
||||||
if (!result.data || !Array.isArray(result.data)) {
|
if (!result.data || !Array.isArray(result.data)) {
|
||||||
console.error("Invalid data format - data is not an array:", result)
|
console.error("Invalid data format - data is not an array:", result)
|
||||||
throw new Error("Invalid data format received from server")
|
throw new Error(t("overview.invalidMetricsData"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.data.length === 0) {
|
if (result.data.length === 0) {
|
||||||
@@ -171,26 +175,26 @@ export function NodeMetricsCharts() {
|
|||||||
let timeLabel = ""
|
let timeLabel = ""
|
||||||
|
|
||||||
if (timeframe === "hour") {
|
if (timeframe === "hour") {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
})
|
})
|
||||||
} else if (timeframe === "day") {
|
} else if (timeframe === "day") {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
})
|
})
|
||||||
} else if (timeframe === "week") {
|
} else if (timeframe === "week") {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})
|
})
|
||||||
@@ -224,7 +228,7 @@ export function NodeMetricsCharts() {
|
|||||||
// the user sees actionable text instead of a bare "503".
|
// the user sees actionable text instead of a bare "503".
|
||||||
const body = err?.body
|
const body = err?.body
|
||||||
setError({
|
setError({
|
||||||
headline: body?.error || err?.message || "Error loading metrics",
|
headline: body?.error || err?.message || t("overview.metricsLoadError"),
|
||||||
details: body?.details,
|
details: body?.details,
|
||||||
suggestion: body?.suggestion,
|
suggestion: body?.suggestion,
|
||||||
})
|
})
|
||||||
@@ -311,7 +315,7 @@ export function NodeMetricsCharts() {
|
|||||||
{error.suggestion && (
|
{error.suggestion && (
|
||||||
<div className="w-full mt-2">
|
<div className="w-full mt-2">
|
||||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
|
||||||
Suggested fix on the Proxmox host
|
{t("overview.suggestedFix")}
|
||||||
</p>
|
</p>
|
||||||
<code className="block text-xs bg-background/60 border border-border rounded px-2 py-1.5 font-mono break-all">
|
<code className="block text-xs bg-background/60 border border-border rounded px-2 py-1.5 font-mono break-all">
|
||||||
{error.suggestion}
|
{error.suggestion}
|
||||||
@@ -336,14 +340,14 @@ export function NodeMetricsCharts() {
|
|||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-center h-[300px]">
|
<div className="flex items-center justify-center h-[300px]">
|
||||||
<p className="text-muted-foreground text-sm">No metrics data available</p>
|
<p className="text-muted-foreground text-sm">{t("overview.noMetricsData")}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-center h-[300px]">
|
<div className="flex items-center justify-center h-[300px]">
|
||||||
<p className="text-muted-foreground text-sm">No metrics data available</p>
|
<p className="text-muted-foreground text-sm">{t("overview.noMetricsData")}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -363,7 +367,7 @@ export function NodeMetricsCharts() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TIMEFRAME_OPTIONS.map((option) => (
|
{TIMEFRAME_OPTIONS.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{t(option.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -378,9 +382,17 @@ export function NodeMetricsCharts() {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-foreground flex items-center">
|
||||||
<TrendingUp className="h-5 w-5 mr-2" />
|
<TrendingUp className="h-5 w-5 mr-2" />
|
||||||
CPU Usage & Load Average
|
{t("overview.cpuUsageLoadAverage")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<ChartStatsHeader stats={periodStats.cpu ?? null} suffix="%" />
|
<ChartStatsHeader
|
||||||
|
stats={periodStats.cpu ?? null}
|
||||||
|
suffix="%"
|
||||||
|
labels={{
|
||||||
|
avg: t("overview.stats.avg"),
|
||||||
|
max: t("overview.stats.max"),
|
||||||
|
min: t("overview.stats.min"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0 md:px-6">
|
<CardContent className="px-0 md:px-6">
|
||||||
@@ -414,7 +426,7 @@ export function NodeMetricsCharts() {
|
|||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||||
label={
|
label={
|
||||||
isMobile ? undefined : { value: "Load", angle: 90, position: "insideRight", fill: "currentColor" }
|
isMobile ? undefined : { value: t("overview.loadAxis"), angle: 90, position: "insideRight", fill: "currentColor" }
|
||||||
}
|
}
|
||||||
domain={[0, "dataMax"]}
|
domain={[0, "dataMax"]}
|
||||||
/>
|
/>
|
||||||
@@ -428,7 +440,7 @@ export function NodeMetricsCharts() {
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#3b82f6"
|
fill="#3b82f6"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="CPU %"
|
name={t("overview.cpuPercent")}
|
||||||
hide={!visibleLines.cpu.cpu}
|
hide={!visibleLines.cpu.cpu}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
@@ -439,7 +451,7 @@ export function NodeMetricsCharts() {
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#10b981"
|
fill="#10b981"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="Load Avg"
|
name={t("overview.loadAverage")}
|
||||||
hide={!visibleLines.cpu.load}
|
hide={!visibleLines.cpu.load}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
@@ -453,9 +465,17 @@ export function NodeMetricsCharts() {
|
|||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-foreground flex items-center">
|
||||||
<MemoryStick className="h-5 w-5 mr-2" />
|
<MemoryStick className="h-5 w-5 mr-2" />
|
||||||
Memory Usage
|
{t("overview.memoryUsage")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<ChartStatsHeader stats={periodStats.memory_used ?? null} suffix=" GB" />
|
<ChartStatsHeader
|
||||||
|
stats={periodStats.memory_used ?? null}
|
||||||
|
suffix=" GB"
|
||||||
|
labels={{
|
||||||
|
avg: t("overview.stats.avg"),
|
||||||
|
max: t("overview.stats.max"),
|
||||||
|
min: t("overview.stats.min"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0 pr-2 md:px-6">
|
<CardContent className="px-0 pr-2 md:px-6">
|
||||||
@@ -490,7 +510,7 @@ export function NodeMetricsCharts() {
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#3b82f6"
|
fill="#3b82f6"
|
||||||
fillOpacity={0.1}
|
fillOpacity={0.1}
|
||||||
name="Total"
|
name={t("overview.total")}
|
||||||
hide={!visibleLines.memory.memoryTotal}
|
hide={!visibleLines.memory.memoryTotal}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
@@ -500,7 +520,7 @@ export function NodeMetricsCharts() {
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#10b981"
|
fill="#10b981"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="Used"
|
name={t("overview.used")}
|
||||||
hide={!visibleLines.memory.memoryUsed}
|
hide={!visibleLines.memory.memoryUsed}
|
||||||
/>
|
/>
|
||||||
{/* Only show ZFS ARC if there's data */}
|
{/* Only show ZFS ARC if there's data */}
|
||||||
@@ -525,7 +545,7 @@ export function NodeMetricsCharts() {
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#06b6d4"
|
fill="#06b6d4"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="Free"
|
name={t("overview.free")}
|
||||||
hide={!visibleLines.memory.memoryFree}
|
hide={!visibleLines.memory.memoryFree}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,11 +20,12 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { Checkbox } from "./ui/checkbox"
|
import { Checkbox } from "./ui/checkbox"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface OnboardingSlide {
|
interface OnboardingSlide {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
titleKey: string
|
||||||
description: string
|
descriptionKey: string
|
||||||
image?: string
|
image?: string
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
gradient: string
|
gradient: string
|
||||||
@@ -33,77 +34,70 @@ interface OnboardingSlide {
|
|||||||
const slides: OnboardingSlide[] = [
|
const slides: OnboardingSlide[] = [
|
||||||
{
|
{
|
||||||
id: 0,
|
id: 0,
|
||||||
title: "Welcome to ProxMenux Monitor!",
|
titleKey: "onboarding.slides.welcome.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.welcome.description",
|
||||||
"Your new monitoring tool for Proxmox. Discover all the features that will help you manage and supervise your infrastructure efficiently.",
|
|
||||||
icon: <Sparkles className="h-16 w-16" />,
|
icon: <Sparkles className="h-16 w-16" />,
|
||||||
gradient: "from-blue-500 via-purple-500 to-pink-500",
|
gradient: "from-blue-500 via-purple-500 to-pink-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
title: "System Overview",
|
titleKey: "onboarding.slides.overview.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.overview.description",
|
||||||
"Monitor your server's status in real-time: CPU, memory, temperature, system load and more. Everything in an intuitive and easy-to-understand dashboard.",
|
|
||||||
image: "/images/onboarding/imagen1.png",
|
image: "/images/onboarding/imagen1.png",
|
||||||
icon: <LayoutDashboard className="h-12 w-12" />,
|
icon: <LayoutDashboard className="h-12 w-12" />,
|
||||||
gradient: "from-blue-500 to-cyan-500",
|
gradient: "from-blue-500 to-cyan-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
title: "Storage Management",
|
titleKey: "onboarding.slides.storage.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.storage.description",
|
||||||
"Visualize the status of all your disks and volumes. Detailed information on capacity, usage, SMART health, temperature and performance of each storage device.",
|
|
||||||
image: "/images/onboarding/imagen2.png",
|
image: "/images/onboarding/imagen2.png",
|
||||||
icon: <HardDrive className="h-12 w-12" />,
|
icon: <HardDrive className="h-12 w-12" />,
|
||||||
gradient: "from-cyan-500 to-teal-500",
|
gradient: "from-cyan-500 to-teal-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
title: "Network Metrics",
|
titleKey: "onboarding.slides.network.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.network.description",
|
||||||
"Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.",
|
|
||||||
image: "/images/onboarding/imagen3.png",
|
image: "/images/onboarding/imagen3.png",
|
||||||
icon: <Network className="h-12 w-12" />,
|
icon: <Network className="h-12 w-12" />,
|
||||||
gradient: "from-teal-500 to-green-500",
|
gradient: "from-teal-500 to-green-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
title: "Virtual Machines & Containers",
|
titleKey: "onboarding.slides.virtualMachines.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.virtualMachines.description",
|
||||||
"Manage all your VMs and LXC containers from one place. Status, allocated resources, current usage and quick controls for each virtual machine.",
|
|
||||||
image: "/images/onboarding/imagen4.png",
|
image: "/images/onboarding/imagen4.png",
|
||||||
icon: <Box className="h-12 w-12" />,
|
icon: <Box className="h-12 w-12" />,
|
||||||
gradient: "from-green-500 to-emerald-500",
|
gradient: "from-green-500 to-emerald-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
title: "Hardware Information",
|
titleKey: "onboarding.slides.hardware.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.hardware.description",
|
||||||
"Complete details of your server hardware: CPU, RAM, GPU, disks, network, UPS and more. Technical specifications, models, serial numbers and status of each component.",
|
|
||||||
image: "/images/onboarding/imagen5.png",
|
image: "/images/onboarding/imagen5.png",
|
||||||
icon: <Cpu className="h-12 w-12" />,
|
icon: <Cpu className="h-12 w-12" />,
|
||||||
gradient: "from-emerald-500 to-blue-500",
|
gradient: "from-emerald-500 to-blue-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
title: "System Logs",
|
titleKey: "onboarding.slides.logs.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.logs.description",
|
||||||
"Access system logs in real-time. Filter by event type, search for specific errors and keep complete track of your server activity. Download the displayed logs for further analysis.",
|
|
||||||
image: "/images/onboarding/imagen6.png",
|
image: "/images/onboarding/imagen6.png",
|
||||||
icon: <FileText className="h-12 w-12" />,
|
icon: <FileText className="h-12 w-12" />,
|
||||||
gradient: "from-blue-500 to-indigo-500",
|
gradient: "from-blue-500 to-indigo-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 7,
|
||||||
title: "Ready for the Future!",
|
titleKey: "onboarding.slides.future.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.future.description",
|
||||||
"ProxMenux Monitor is prepared to receive updates and improvements that will be added gradually, improving the user experience and being able to execute ProxMenux functions from the web panel.",
|
|
||||||
icon: <Rocket className="h-16 w-16" />,
|
icon: <Rocket className="h-16 w-16" />,
|
||||||
gradient: "from-indigo-500 via-purple-500 to-pink-500",
|
gradient: "from-indigo-500 via-purple-500 to-pink-500",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function OnboardingCarousel() {
|
export function OnboardingCarousel() {
|
||||||
|
const t = useT()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [currentSlide, setCurrentSlide] = useState(0)
|
const [currentSlide, setCurrentSlide] = useState(0)
|
||||||
const [direction, setDirection] = useState<"next" | "prev">("next")
|
const [direction, setDirection] = useState<"next" | "prev">("next")
|
||||||
@@ -155,11 +149,13 @@ export function OnboardingCarousel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const slide = slides[currentSlide]
|
const slide = slides[currentSlide]
|
||||||
|
const slideTitle = t(slide.titleKey)
|
||||||
|
const slideDescription = t(slide.descriptionKey)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent">
|
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent">
|
||||||
<DialogTitle className="sr-only">ProxMenux Onboarding</DialogTitle>
|
<DialogTitle className="sr-only">{t("onboarding.dialogTitle")}</DialogTitle>
|
||||||
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
|
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -181,7 +177,7 @@ export function OnboardingCarousel() {
|
|||||||
<div className="relative w-full h-36 md:h-48 flex items-center justify-center px-4">
|
<div className="relative w-full h-36 md:h-48 flex items-center justify-center px-4">
|
||||||
<Image
|
<Image
|
||||||
src={slide.image || "/placeholder.svg"}
|
src={slide.image || "/placeholder.svg"}
|
||||||
alt={slide.title}
|
alt={slideTitle}
|
||||||
width={600}
|
width={600}
|
||||||
height={400}
|
height={400}
|
||||||
className="rounded-lg shadow-2xl object-cover max-h-36 md:max-h-48"
|
className="rounded-lg shadow-2xl object-cover max-h-36 md:max-h-48"
|
||||||
@@ -207,9 +203,9 @@ export function OnboardingCarousel() {
|
|||||||
|
|
||||||
<div className="p-4 md:p-8 space-y-3 md:space-y-6 max-h-[60vh] md:max-h-none overflow-y-auto">
|
<div className="p-4 md:p-8 space-y-3 md:space-y-6 max-h-[60vh] md:max-h-none overflow-y-auto">
|
||||||
<div className="space-y-2 md:space-y-3">
|
<div className="space-y-2 md:space-y-3">
|
||||||
<h2 className="text-xl md:text-3xl font-bold text-foreground text-balance">{slide.title}</h2>
|
<h2 className="text-xl md:text-3xl font-bold text-foreground text-balance">{slideTitle}</h2>
|
||||||
<p className="text-sm md:text-lg text-muted-foreground leading-relaxed text-pretty">
|
<p className="text-sm md:text-lg text-muted-foreground leading-relaxed text-pretty">
|
||||||
{slide.description}
|
{slideDescription}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -223,7 +219,7 @@ export function OnboardingCarousel() {
|
|||||||
? "w-8 h-2.5 bg-blue-500 shadow-lg shadow-blue-500/50"
|
? "w-8 h-2.5 bg-blue-500 shadow-lg shadow-blue-500/50"
|
||||||
: "w-2.5 h-2.5 bg-muted-foreground/60 hover:bg-muted-foreground/80 border border-muted-foreground/40"
|
: "w-2.5 h-2.5 bg-muted-foreground/60 hover:bg-muted-foreground/80 border border-muted-foreground/40"
|
||||||
}`}
|
}`}
|
||||||
aria-label={`Go to slide ${index + 1}`}
|
aria-label={t("onboarding.goToSlide", { number: index + 1 })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -236,7 +232,7 @@ export function OnboardingCarousel() {
|
|||||||
className="gap-2 w-full sm:w-auto text-sm"
|
className="gap-2 w-full sm:w-auto text-sm"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
Previous
|
{t("onboarding.previous")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex gap-2 w-full sm:w-auto">
|
<div className="flex gap-2 w-full sm:w-auto">
|
||||||
@@ -247,13 +243,13 @@ export function OnboardingCarousel() {
|
|||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="flex-1 sm:flex-none bg-transparent text-sm"
|
className="flex-1 sm:flex-none bg-transparent text-sm"
|
||||||
>
|
>
|
||||||
Skip
|
{t("onboarding.skip")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none text-sm"
|
className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none text-sm"
|
||||||
>
|
>
|
||||||
Next
|
{t("onboarding.next")}
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -262,7 +258,7 @@ export function OnboardingCarousel() {
|
|||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 w-full sm:w-auto text-sm"
|
className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 w-full sm:w-auto text-sm"
|
||||||
>
|
>
|
||||||
Get Started!
|
{t("onboarding.getStarted")}
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -279,7 +275,7 @@ export function OnboardingCarousel() {
|
|||||||
htmlFor="dont-show-again"
|
htmlFor="dont-show-again"
|
||||||
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
|
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
|
||||||
>
|
>
|
||||||
Don't show this again
|
{t("onboarding.dontShowAgain")}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ScrollArea } from "./ui/scroll-area"
|
|||||||
import { Cpu, MemoryStick, Search } from "lucide-react"
|
import { Cpu, MemoryStick, Search } from "lucide-react"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
import { ProcessInfoModal } from "./process-info-modal"
|
import { ProcessInfoModal } from "./process-info-modal"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
interface ProcessInfo {
|
interface ProcessInfo {
|
||||||
pid: number
|
pid: number
|
||||||
@@ -61,6 +62,7 @@ const formatRss = (kb: number): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailModalProps) {
|
export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailModalProps) {
|
||||||
|
const t = useT()
|
||||||
const [data, setData] = useState<ProcessesResponse | null>(null)
|
const [data, setData] = useState<ProcessesResponse | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -74,7 +76,7 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
|
|||||||
const res = await fetchApi<ProcessesResponse>(`/api/processes?sort=${sort}&limit=${FETCH_LIMIT}`)
|
const res = await fetchApi<ProcessesResponse>(`/api/processes?sort=${sort}&limit=${FETCH_LIMIT}`)
|
||||||
setData(res)
|
setData(res)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || "Failed to fetch processes")
|
setError(e?.message || t("details.processes.loadFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) setLoading(false)
|
if (!silent) setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -110,11 +112,11 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
|
|||||||
const filtered = filter ? allMatches : allMatches.slice(0, DISPLAY_LIMIT)
|
const filtered = filter ? allMatches : allMatches.slice(0, DISPLAY_LIMIT)
|
||||||
|
|
||||||
const Icon = sort === "cpu" ? Cpu : MemoryStick
|
const Icon = sort === "cpu" ? Cpu : MemoryStick
|
||||||
const title = sort === "cpu" ? "Top processes by CPU" : "Top processes by Memory"
|
const title = sort === "cpu" ? t("details.processes.topByCpu") : t("details.processes.topByMemory")
|
||||||
const description =
|
const description =
|
||||||
sort === "cpu"
|
sort === "cpu"
|
||||||
? "Current CPU usage per process, as a fraction of the host's total CPU — same scale as the CPU Usage card above. Refreshes every 3 s while open."
|
? t("details.processes.cpuDescription")
|
||||||
: "Current resident memory per process. Refreshes every 3 s while open."
|
: t("details.processes.memoryDescription")
|
||||||
|
|
||||||
// Accent palette matched to the Overview cards: CPU Usage donut uses
|
// Accent palette matched to the Overview cards: CPU Usage donut uses
|
||||||
// blue (#3b82f6), Memory cached uses rgba(99,102,241,0.55) — we keep
|
// blue (#3b82f6), Memory cached uses rgba(99,102,241,0.55) — we keep
|
||||||
@@ -160,7 +162,7 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
|
|||||||
<div className="relative mb-2">
|
<div className="relative mb-2">
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Filter by command line, user or PID..."
|
placeholder={t("details.processes.filterPlaceholder")}
|
||||||
value={filter}
|
value={filter}
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
className="pl-8 h-8 text-sm"
|
className="pl-8 h-8 text-sm"
|
||||||
@@ -176,16 +178,18 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
|
|||||||
<div
|
<div
|
||||||
className={`grid items-center gap-x-3 sm:gap-x-6 px-3 py-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground border-b border-border bg-card sticky top-0 z-10 ${gridCols}`}
|
className={`grid items-center gap-x-3 sm:gap-x-6 px-3 py-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground border-b border-border bg-card sticky top-0 z-10 ${gridCols}`}
|
||||||
>
|
>
|
||||||
<div className="hidden sm:block">PID</div>
|
<div className="hidden sm:block">{t("details.processes.pid")}</div>
|
||||||
<div className="hidden sm:block truncate">User</div>
|
<div className="hidden sm:block truncate">{t("details.processes.user")}</div>
|
||||||
<div>Command</div>
|
<div>{t("details.processes.command")}</div>
|
||||||
<div className={`text-right ${sort === "cpu" ? accent.text : ""}`}>CPU %</div>
|
<div className={`text-right ${sort === "cpu" ? accent.text : ""}`}>{t("details.processes.cpuPercent")}</div>
|
||||||
<div className={`text-right ${sort === "mem" ? accent.text : ""}`}>{sort === "mem" ? "Memory" : "Mem %"}</div>
|
<div className={`text-right ${sort === "mem" ? accent.text : ""}`}>
|
||||||
|
{sort === "mem" ? t("details.processes.memory") : t("details.processes.memPercent")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 && !loading ? (
|
{filtered.length === 0 && !loading ? (
|
||||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||||
No processes match the filter
|
{t("details.processes.noMatches")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((p) => {
|
filtered.map((p) => {
|
||||||
@@ -228,8 +232,8 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
|
|||||||
where avg and now match within sampler
|
where avg and now match within sampler
|
||||||
noise. */}
|
noise. */}
|
||||||
{typeof p.cpu_avg === "number" && p.cpu_avg >= 0.5 && p.cpu_avg > p.cpu * 1.5 && (
|
{typeof p.cpu_avg === "number" && p.cpu_avg >= 0.5 && p.cpu_avg > p.cpu * 1.5 && (
|
||||||
<span className="font-mono text-[10px] text-amber-400" title="Average CPU% across this process's lifetime — useful for finding long-running idle baselines">
|
<span className="font-mono text-[10px] text-amber-400" title={t("details.processes.lifetimeAverageTitle")}>
|
||||||
avg {p.cpu_avg.toFixed(1)}
|
{t("details.processes.averageShort")} {p.cpu_avg.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
|
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
|
||||||
@@ -261,9 +265,15 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
|
|||||||
|
|
||||||
{data?.captured_at && (
|
{data?.captured_at && (
|
||||||
<div className="text-[10px] text-muted-foreground text-right mt-1">
|
<div className="text-[10px] text-muted-foreground text-right mt-1">
|
||||||
Captured {new Date(data.captured_at * 1000).toLocaleTimeString()} · {filter
|
{t("details.processes.captured", { time: new Date(data.captured_at * 1000).toLocaleTimeString() })} · {filter
|
||||||
? `${allMatches.length} match${allMatches.length === 1 ? '' : 'es'} of ${data.processes.length} processes`
|
? t(allMatches.length === 1 ? "details.processes.matchCount" : "details.processes.matchesCount", {
|
||||||
: `Top ${filtered.length} of ${data.processes.length} processes`}
|
count: allMatches.length,
|
||||||
|
total: data.processes.length,
|
||||||
|
})
|
||||||
|
: t("details.processes.topCount", {
|
||||||
|
shown: filtered.length,
|
||||||
|
total: data.processes.length,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
|||||||
import { ScrollArea } from "./ui/scroll-area"
|
import { ScrollArea } from "./ui/scroll-area"
|
||||||
import { Activity, FileText, HardDrive, Clock, Info } from "lucide-react"
|
import { Activity, FileText, HardDrive, Clock, Info } from "lucide-react"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
|
import { useI18n } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface ProcessDetail {
|
interface ProcessDetail {
|
||||||
pid: number
|
pid: number
|
||||||
@@ -59,22 +60,24 @@ const formatBytes = (b: number | null | undefined): string => {
|
|||||||
// Linux process states from /proc/<pid>/status. The first char of `State:`
|
// Linux process states from /proc/<pid>/status. The first char of `State:`
|
||||||
// is the canonical letter — the rest of the field is a human label like
|
// is the canonical letter — the rest of the field is a human label like
|
||||||
// "(running)". We expand the bare letter to something readable.
|
// "(running)". We expand the bare letter to something readable.
|
||||||
const stateLabel = (state: string): string => {
|
const stateLabel = (state: string, t: (key: string) => string): string => {
|
||||||
const letter = (state || "").trim().charAt(0).toUpperCase()
|
const rawLetter = (state || "").trim().charAt(0)
|
||||||
|
const letter = rawLetter.toUpperCase()
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
R: "Running",
|
R: "running",
|
||||||
S: "Sleeping",
|
S: "sleeping",
|
||||||
D: "Disk wait",
|
D: "diskWait",
|
||||||
Z: "Zombie",
|
Z: "zombie",
|
||||||
T: "Stopped",
|
T: rawLetter === "t" ? "tracingStop" : "stopped",
|
||||||
t: "Tracing stop",
|
X: "dead",
|
||||||
X: "Dead",
|
I: "idle",
|
||||||
I: "Idle",
|
|
||||||
}
|
}
|
||||||
return map[letter] || state || "—"
|
const key = map[letter]
|
||||||
|
return key ? t(`details.processInfo.states.${key}`) : state || "—"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps) {
|
export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps) {
|
||||||
|
const { language, t } = useI18n()
|
||||||
const [data, setData] = useState<ProcessDetail | null>(null)
|
const [data, setData] = useState<ProcessDetail | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -105,7 +108,7 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
|
|||||||
setExited(true)
|
setExited(true)
|
||||||
stopPolling()
|
stopPolling()
|
||||||
} else {
|
} else {
|
||||||
setError(e?.message || "Failed to fetch process")
|
setError(t("details.processInfo.fetchFailed"))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) setLoading(false)
|
if (!silent) setLoading(false)
|
||||||
@@ -136,15 +139,13 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
|
|||||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
style={{ background: accent.dot }}
|
style={{ background: accent.dot }}
|
||||||
/>
|
/>
|
||||||
<span className="truncate font-mono text-base">{data?.comm || "Process"}</span>
|
<span className="truncate font-mono text-base">{data?.comm || t("details.processInfo.titleFallback")}</span>
|
||||||
<span className="text-xs text-muted-foreground font-mono flex-shrink-0">PID {pid}</span>
|
<span className="text-xs text-muted-foreground font-mono flex-shrink-0">PID {pid}</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-xs">
|
<DialogDescription className="text-xs">
|
||||||
{exited ? (
|
{exited
|
||||||
<>Last snapshot from <span className="font-mono">/proc/{pid}</span> before the process finished.</>
|
? t("details.processInfo.descriptionExited", { pid: pid ?? "" })
|
||||||
) : (
|
: t("details.processInfo.descriptionLive", { pid: pid ?? "", seconds: REFRESH_MS / 1000 })}
|
||||||
<>Live snapshot from <span className="font-mono">/proc/{pid}</span>. Auto-refreshes every {REFRESH_MS / 1000} s while open.</>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -154,9 +155,9 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
|
|||||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md border border-amber-500/30 bg-amber-500/10 text-xs text-amber-300">
|
<div className="flex items-start gap-2 px-3 py-2 rounded-md border border-amber-500/30 bg-amber-500/10 text-xs text-amber-300">
|
||||||
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-amber-200">This process has finished</div>
|
<div className="font-medium text-amber-200">{t("details.processInfo.finishedTitle")}</div>
|
||||||
<div className="text-amber-300/80 mt-0.5">
|
<div className="text-amber-300/80 mt-0.5">
|
||||||
It was likely a short-lived helper (a script, a <span className="font-mono">pct exec</span>, or a one-shot command) that completed while the modal was open. The data below is the last snapshot captured before it exited — not a stale or broken read.
|
{t("details.processInfo.finishedDescription")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,44 +167,44 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
|
|||||||
<div className="text-sm text-red-500 py-4">{error}</div>
|
<div className="text-sm text-red-500 py-4">{error}</div>
|
||||||
) : !data ? (
|
) : !data ? (
|
||||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||||
{loading ? "Loading…" : "—"}
|
{loading ? t("details.processInfo.loading") : "—"}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className={`max-h-[480px] pr-2 ${exited ? "opacity-75" : ""}`}>
|
<ScrollArea className={`max-h-[480px] pr-2 ${exited ? "opacity-75" : ""}`}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Overview */}
|
{/* Overview */}
|
||||||
<Section icon={<Activity className="h-4 w-4 text-blue-400" />} title="Overview">
|
<Section icon={<Activity className="h-4 w-4 text-blue-400" />} title={t("details.processInfo.sections.overview")}>
|
||||||
<Row label="State" value={exited ? "Exited" : stateLabel(data.state)} />
|
<Row label={t("details.processInfo.labels.state")} value={exited ? t("details.processInfo.states.exited") : stateLabel(data.state, t)} />
|
||||||
<Row label="Parent" value={data.parent_name ? `${data.parent_name} (PID ${data.ppid})` : `PID ${data.ppid}`} mono />
|
<Row label={t("details.processInfo.labels.parent")} value={data.parent_name ? `${data.parent_name} (PID ${data.ppid})` : `PID ${data.ppid}`} mono />
|
||||||
<Row label="Threads" value={String(data.threads)} mono />
|
<Row label={t("details.processInfo.labels.threads")} value={String(data.threads)} mono />
|
||||||
<Row label="Open FDs" value={data.fd_count != null ? String(data.fd_count) : "—"} mono />
|
<Row label={t("details.processInfo.labels.openFds")} value={data.fd_count != null ? String(data.fd_count) : "—"} mono />
|
||||||
<Row label="User" value={`${data.user} (${data.uid})`} mono />
|
<Row label={t("details.processInfo.labels.user")} value={`${data.user} (${data.uid})`} mono />
|
||||||
<Row label="Group" value={`${data.group} (${data.gid})`} mono />
|
<Row label={t("details.processInfo.labels.group")} value={`${data.group} (${data.gid})`} mono />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Resources */}
|
{/* Resources */}
|
||||||
<Section icon={<HardDrive className="h-4 w-4 text-amber-400" />} title="Resources">
|
<Section icon={<HardDrive className="h-4 w-4 text-amber-400" />} title={t("details.processInfo.sections.resources")}>
|
||||||
<Row label="CPU" value={`${data.cpu.toFixed(1)} %`} mono valueClass={accent.text} />
|
<Row label={t("details.processInfo.labels.cpu")} value={`${data.cpu.toFixed(1)} %`} mono valueClass={accent.text} />
|
||||||
<Row label="Memory" value={`${data.mem.toFixed(1)} %`} mono valueClass={accent.text} />
|
<Row label={t("details.processInfo.labels.memory")} value={`${data.mem.toFixed(1)} %`} mono valueClass={accent.text} />
|
||||||
<Row label="Resident (RSS)" value={formatKb(data.vm_rss_kb)} mono />
|
<Row label={t("details.processInfo.labels.residentRss")} value={formatKb(data.vm_rss_kb)} mono />
|
||||||
<Row label="Virtual size" value={formatKb(data.vm_size_kb)} mono />
|
<Row label={t("details.processInfo.labels.virtualSize")} value={formatKb(data.vm_size_kb)} mono />
|
||||||
<Row label="Swap" value={formatKb(data.vm_swap_kb)} mono />
|
<Row label={t("details.processInfo.labels.swap")} value={formatKb(data.vm_swap_kb)} mono />
|
||||||
<Row label="I/O read" value={formatBytes(data.io_read_bytes)} mono />
|
<Row label={t("details.processInfo.labels.ioRead")} value={formatBytes(data.io_read_bytes)} mono />
|
||||||
<Row label="I/O write" value={formatBytes(data.io_write_bytes)} mono />
|
<Row label={t("details.processInfo.labels.ioWrite")} value={formatBytes(data.io_write_bytes)} mono />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Command */}
|
{/* Command */}
|
||||||
<Section icon={<FileText className="h-4 w-4 text-purple-400" />} title="Command">
|
<Section icon={<FileText className="h-4 w-4 text-purple-400" />} title={t("details.processInfo.sections.command")}>
|
||||||
<Row label="Name" value={data.comm} mono />
|
<Row label={t("details.processInfo.labels.name")} value={data.comm} mono />
|
||||||
<Row label="Command line" value={data.cmdline || data.comm} mono wrap />
|
<Row label={t("details.processInfo.labels.commandLine")} value={data.cmdline || data.comm} mono wrap />
|
||||||
<Row label="Executable" value={data.exe || "—"} mono wrap />
|
<Row label={t("details.processInfo.labels.executable")} value={data.exe || "—"} mono wrap />
|
||||||
<Row label="Working dir" value={data.cwd || "—"} mono wrap />
|
<Row label={t("details.processInfo.labels.workingDir")} value={data.cwd || "—"} mono wrap />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Times */}
|
{/* Times */}
|
||||||
<Section icon={<Clock className="h-4 w-4 text-emerald-400" />} title="Lifetime">
|
<Section icon={<Clock className="h-4 w-4 text-emerald-400" />} title={t("details.processInfo.sections.lifetime")}>
|
||||||
<Row label="Started" value={data.start_time || "—"} mono />
|
<Row label={t("details.processInfo.labels.started")} value={data.start_time || "—"} mono />
|
||||||
<Row label="Running for" value={data.elapsed || "—"} mono />
|
<Row label={t("details.processInfo.labels.runningFor")} value={data.elapsed || "—"} mono />
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@@ -211,7 +212,8 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
|
|||||||
|
|
||||||
{data?.captured_at && (
|
{data?.captured_at && (
|
||||||
<div className="text-[10px] text-muted-foreground text-right mt-1">
|
<div className="text-[10px] text-muted-foreground text-right mt-1">
|
||||||
{exited ? "Last seen" : "Captured"} {new Date(data.captured_at * 1000).toLocaleTimeString()}
|
{exited ? t("details.processInfo.lastSeen") : t("details.processInfo.captured")}{" "}
|
||||||
|
{new Date(data.captured_at * 1000).toLocaleTimeString(language)}
|
||||||
{error ? ` · ${error}` : ""}
|
{error ? ` · ${error}` : ""}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Button } from "./ui/button"
|
|||||||
import { Input } from "./ui/input"
|
import { Input } from "./ui/input"
|
||||||
import { Label } from "./ui/label"
|
import { Label } from "./ui/label"
|
||||||
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface ProfileData {
|
interface ProfileData {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -51,6 +52,7 @@ interface ProfileProps {
|
|||||||
* the operator hits Edit to start typing.
|
* the operator hits Edit to start typing.
|
||||||
*/
|
*/
|
||||||
export function Profile({ onOpenSecurity }: ProfileProps) {
|
export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||||
|
const t = useT()
|
||||||
const [profile, setProfile] = useState<ProfileData | null>(null)
|
const [profile, setProfile] = useState<ProfileData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -146,7 +148,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
body: JSON.stringify({ display_name: displayDraft }),
|
body: JSON.stringify({ display_name: displayDraft }),
|
||||||
})
|
})
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
setError(data.message || "Failed to save display name")
|
setError(data.message || t("profilePage.errors.saveDisplayNameFailed"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setProfile(data)
|
setProfile(data)
|
||||||
@@ -182,7 +184,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
})
|
})
|
||||||
const data: ProfileData = await r.json().catch(() => ({ success: false }))
|
const data: ProfileData = await r.json().catch(() => ({ success: false }))
|
||||||
if (!r.ok || !data.success) {
|
if (!r.ok || !data.success) {
|
||||||
setAvatarError(data.message || `Upload failed (${r.status})`)
|
setAvatarError(data.message || t("profilePage.errors.uploadFailed", { status: r.status }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setProfile(data)
|
setProfile(data)
|
||||||
@@ -212,7 +214,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
})
|
})
|
||||||
const data: ProfileData = await r.json().catch(() => ({ success: false }))
|
const data: ProfileData = await r.json().catch(() => ({ success: false }))
|
||||||
if (!r.ok || !data.success) {
|
if (!r.ok || !data.success) {
|
||||||
setAvatarError(data.message || `Delete failed (${r.status})`)
|
setAvatarError(data.message || t("profilePage.errors.deleteFailed", { status: r.status }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setProfile(data)
|
setProfile(data)
|
||||||
@@ -232,7 +234,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-8 flex items-center justify-center text-muted-foreground">
|
<CardContent className="p-8 flex items-center justify-center text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
Loading profile…
|
{t("profilePage.loading")}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,7 +249,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
<div className="flex items-start gap-2 text-red-500">
|
<div className="flex items-start gap-2 text-red-500">
|
||||||
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
|
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Failed to load profile</div>
|
<div className="font-medium">{t("profilePage.loadFailed")}</div>
|
||||||
<div className="text-xs text-muted-foreground mt-1 break-all">{error}</div>
|
<div className="text-xs text-muted-foreground mt-1 break-all">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,13 +270,13 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserIcon className="h-5 w-5 text-cyan-500" />
|
<UserIcon className="h-5 w-5 text-cyan-500" />
|
||||||
<CardTitle>User Profile</CardTitle>
|
<CardTitle>{t("profilePage.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{savedDisplay && (
|
{savedDisplay && (
|
||||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
Saved
|
{t("status.saved")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{displayEditMode ? (
|
{displayEditMode ? (
|
||||||
@@ -286,7 +288,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
disabled={savingDisplay}
|
disabled={savingDisplay}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -299,7 +301,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
) : (
|
) : (
|
||||||
<CheckCircle2 className="h-3 w-3 mr-1.5" />
|
<CheckCircle2 className="h-3 w-3 mr-1.5" />
|
||||||
)}
|
)}
|
||||||
Save
|
{t("actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -310,14 +312,13 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
>
|
>
|
||||||
<Settings2 className="h-3 w-3 mr-1.5" />
|
<Settings2 className="h-3 w-3 mr-1.5" />
|
||||||
Edit
|
{t("actions.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Personal details rendered in the header avatar menu. None of this is required —
|
{t("profilePage.description")}
|
||||||
the username already covers identity. Display name and avatar are decorative.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
@@ -327,7 +328,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
image they uploaded. `object-cover` keeps the aspect
|
image they uploaded. `object-cover` keeps the aspect
|
||||||
ratio and crops to fit the circle. */}
|
ratio and crops to fit the circle. */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm">Avatar</Label>
|
<Label className="text-sm">{t("profilePage.avatar.label")}</Label>
|
||||||
<div className="flex flex-col sm:flex-row items-start gap-6 mt-3">
|
<div className="flex flex-col sm:flex-row items-start gap-6 mt-3">
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
{avatarBlobUrl ? (
|
{avatarBlobUrl ? (
|
||||||
@@ -367,7 +368,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
className="justify-start"
|
className="justify-start"
|
||||||
>
|
>
|
||||||
<Upload className="h-3.5 w-3.5 mr-2" />
|
<Upload className="h-3.5 w-3.5 mr-2" />
|
||||||
{profile?.has_avatar ? "Replace avatar" : "Upload avatar"}
|
{profile?.has_avatar ? t("profilePage.avatar.replace") : t("profilePage.avatar.upload")}
|
||||||
</Button>
|
</Button>
|
||||||
{profile?.has_avatar && (
|
{profile?.has_avatar && (
|
||||||
<Button
|
<Button
|
||||||
@@ -378,12 +379,11 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
className="justify-start text-red-500 hover:text-red-500 hover:bg-red-500/10"
|
className="justify-start text-red-500 hover:text-red-500 hover:bg-red-500/10"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||||
Remove avatar
|
{t("profilePage.avatar.remove")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<p className="text-[11px] text-muted-foreground leading-relaxed max-w-xs">
|
<p className="text-[11px] text-muted-foreground leading-relaxed max-w-xs">
|
||||||
PNG, JPEG, WebP or GIF. Up to 2 MB. The image isn't resized —
|
{t("profilePage.avatar.hint")}
|
||||||
render it square or pre-crop for best results in the header.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,7 +397,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
|
|
||||||
{/* ─── Username (read-only) ─── */}
|
{/* ─── Username (read-only) ─── */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm" htmlFor="profile-username">Username</Label>
|
<Label className="text-sm" htmlFor="profile-username">{t("profilePage.username.label")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="profile-username"
|
id="profile-username"
|
||||||
value={profile?.username || ""}
|
value={profile?.username || ""}
|
||||||
@@ -405,28 +405,26 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
|
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground mt-1">
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
The login name. To change it, disable authentication and reconfigure from
|
{t("profilePage.username.help")}
|
||||||
Security.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── Display name (Edit controls live in the card header) ─── */}
|
{/* ─── Display name (Edit controls live in the card header) ─── */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm" htmlFor="profile-display">
|
<Label className="text-sm" htmlFor="profile-display">
|
||||||
Display name <span className="text-muted-foreground font-normal">(optional)</span>
|
{t("profilePage.displayName.label")} <span className="text-muted-foreground font-normal">{t("profilePage.displayName.optional")}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="profile-display"
|
id="profile-display"
|
||||||
value={displayDraft}
|
value={displayDraft}
|
||||||
onChange={(e) => setDisplayDraft(e.target.value)}
|
onChange={(e) => setDisplayDraft(e.target.value)}
|
||||||
placeholder={profile?.username || "Display name"}
|
placeholder={profile?.username || t("profilePage.displayName.placeholder")}
|
||||||
maxLength={64}
|
maxLength={64}
|
||||||
disabled={!displayEditMode || savingDisplay}
|
disabled={!displayEditMode || savingDisplay}
|
||||||
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
|
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground mt-1">
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
Shown above the username inside the avatar menu. Leave empty to show the
|
{t("profilePage.displayName.help")}
|
||||||
username itself. Up to 64 characters.
|
|
||||||
</p>
|
</p>
|
||||||
{error && displayEditMode && (
|
{error && displayEditMode && (
|
||||||
<div className="mt-2 text-xs text-red-500 flex items-start gap-1.5">
|
<div className="mt-2 text-xs text-red-500 flex items-start gap-1.5">
|
||||||
@@ -443,21 +441,21 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Shield className="h-5 w-5 text-orange-500" />
|
<Shield className="h-5 w-5 text-orange-500" />
|
||||||
<CardTitle>Account security</CardTitle>
|
<CardTitle>{t("profilePage.accountSecurity.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Password, two-factor authentication and API tokens live in the Security panel.
|
{t("profilePage.accountSecurity.description")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{onOpenSecurity ? (
|
{onOpenSecurity ? (
|
||||||
<Button variant="outline" onClick={onOpenSecurity}>
|
<Button variant="outline" onClick={onOpenSecurity}>
|
||||||
<Lock className="h-4 w-4 mr-2" />
|
<Lock className="h-4 w-4 mr-2" />
|
||||||
Open Security settings
|
{t("profilePage.accountSecurity.openSecurity")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Open the Security tab from the navigation.
|
{t("profilePage.accountSecurity.fallback")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback } from "react"
|
import React, { useState, useEffect, useMemo, useCallback } from "react"
|
||||||
|
import useSWR from "swr"
|
||||||
import { Badge } from "./ui/badge"
|
import { Badge } from "./ui/badge"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"
|
||||||
@@ -8,6 +9,7 @@ import { SystemOverview } from "./system-overview"
|
|||||||
import { StorageOverview } from "./storage-overview"
|
import { StorageOverview } from "./storage-overview"
|
||||||
import { NetworkMetrics } from "./network-metrics"
|
import { NetworkMetrics } from "./network-metrics"
|
||||||
import { VirtualMachines } from "./virtual-machines"
|
import { VirtualMachines } from "./virtual-machines"
|
||||||
|
import { AppsDashboard } from "./apps-dashboard"
|
||||||
import Hardware from "./hardware"
|
import Hardware from "./hardware"
|
||||||
import { SystemLogs } from "./system-logs"
|
import { SystemLogs } from "./system-logs"
|
||||||
import { Settings } from "./settings"
|
import { Settings } from "./settings"
|
||||||
@@ -32,6 +34,7 @@ import {
|
|||||||
HardDrive,
|
HardDrive,
|
||||||
NetworkIcon,
|
NetworkIcon,
|
||||||
Boxes,
|
Boxes,
|
||||||
|
Grid3x3,
|
||||||
Cpu,
|
Cpu,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
@@ -51,6 +54,9 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "./ui/dropdown-menu"
|
} from "./ui/dropdown-menu"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
import { APP_VERSION } from "../lib/version"
|
||||||
|
import { useTabOrder, firstActualTab, type TabId } from "../lib/tab-order"
|
||||||
|
|
||||||
interface SystemStatus {
|
interface SystemStatus {
|
||||||
status: "healthy" | "warning" | "critical"
|
status: "healthy" | "warning" | "critical"
|
||||||
@@ -79,7 +85,19 @@ interface FlaskSystemInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefetch on dashboard mount: SWR caches by key across all
|
||||||
|
// `useSWR` calls, so firing these here means the Apps tab finds the
|
||||||
|
// data already resolved when it opens. Without this, the tab pays a
|
||||||
|
// visible roundtrip on first render because VirtualMachines has been
|
||||||
|
// warming /api/vms since page load but nobody was warming the custom
|
||||||
|
// links endpoint.
|
||||||
|
const _dashboardPrefetchFetcher = (url: string) => fetchApi(url)
|
||||||
|
|
||||||
export function ProxmoxDashboard() {
|
export function ProxmoxDashboard() {
|
||||||
|
const t = useT()
|
||||||
|
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||||
|
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||||
|
const { order: tabOrder } = useTabOrder()
|
||||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
|
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
|
||||||
status: "healthy",
|
status: "healthy",
|
||||||
uptime: "Loading...",
|
uptime: "Loading...",
|
||||||
@@ -92,12 +110,21 @@ export function ProxmoxDashboard() {
|
|||||||
const [componentKey, setComponentKey] = useState(0)
|
const [componentKey, setComponentKey] = useState(0)
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState("overview")
|
const [activeTab, setActiveTab] = useState("overview")
|
||||||
|
// On first mount, land on whatever the user's custom order says is
|
||||||
|
// the leading tab. localStorage isn't available during SSR so this
|
||||||
|
// runs post-hydration; the tiny flash is acceptable and matches the
|
||||||
|
// pattern next-themes uses for the same reason.
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveTab(firstActualTab())
|
||||||
|
}, [])
|
||||||
const [infoCount, setInfoCount] = useState(0)
|
const [infoCount, setInfoCount] = useState(0)
|
||||||
const [updateAvailable, setUpdateAvailable] = useState(false)
|
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||||
const [showNavigation, setShowNavigation] = useState(true)
|
const [showNavigation, setShowNavigation] = useState(true)
|
||||||
const [lastScrollY, setLastScrollY] = useState(0)
|
const [lastScrollY, setLastScrollY] = useState(0)
|
||||||
const [showHealthModal, setShowHealthModal] = useState(false)
|
const [showHealthModal, setShowHealthModal] = useState(false)
|
||||||
const { showReleaseNotes, setShowReleaseNotes } = useVersionCheck()
|
const { showReleaseNotes, setShowReleaseNotes } = useVersionCheck()
|
||||||
|
const displayServerName = systemStatus.serverName === "Loading..." ? t("app.loading") : systemStatus.serverName
|
||||||
|
const displayUptime = systemStatus.uptime === "Loading..." ? t("app.loading") : systemStatus.uptime || t("app.notAvailable")
|
||||||
|
|
||||||
// Category keys for health info count calculation
|
// Category keys for health info count calculation
|
||||||
const HEALTH_CATEGORY_KEYS = [
|
const HEALTH_CATEGORY_KEYS = [
|
||||||
@@ -168,7 +195,7 @@ export function ProxmoxDashboard() {
|
|||||||
const data: FlaskSystemInfo = await fetchApi("/api/system-info")
|
const data: FlaskSystemInfo = await fetchApi("/api/system-info")
|
||||||
|
|
||||||
const uptimeValue =
|
const uptimeValue =
|
||||||
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : "N/A"
|
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : t("app.notAvailable")
|
||||||
|
|
||||||
const backendStatus = data.health?.status?.toUpperCase() || "OK"
|
const backendStatus = data.health?.status?.toUpperCase() || "OK"
|
||||||
let healthStatus: "healthy" | "warning" | "critical"
|
let healthStatus: "healthy" | "warning" | "critical"
|
||||||
@@ -185,8 +212,8 @@ export function ProxmoxDashboard() {
|
|||||||
status: healthStatus,
|
status: healthStatus,
|
||||||
uptime: uptimeValue,
|
uptime: uptimeValue,
|
||||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||||
serverName: data.hostname || "Unknown",
|
serverName: data.hostname || t("app.unknown"),
|
||||||
nodeId: data.node_id || "Unknown",
|
nodeId: data.node_id || t("app.unknown"),
|
||||||
})
|
})
|
||||||
setIsServerConnected(true)
|
setIsServerConnected(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -196,13 +223,13 @@ export function ProxmoxDashboard() {
|
|||||||
setSystemStatus((prev) => ({
|
setSystemStatus((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
status: "critical",
|
status: "critical",
|
||||||
serverName: "Server Offline",
|
serverName: t("app.serverOffline"),
|
||||||
nodeId: "Server Offline",
|
nodeId: t("app.serverOffline"),
|
||||||
uptime: "N/A",
|
uptime: t("app.notAvailable"),
|
||||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Siempre fetch inicial
|
// Siempre fetch inicial
|
||||||
@@ -294,13 +321,13 @@ export function ProxmoxDashboard() {
|
|||||||
if (
|
if (
|
||||||
systemStatus.serverName &&
|
systemStatus.serverName &&
|
||||||
systemStatus.serverName !== "Loading..." &&
|
systemStatus.serverName !== "Loading..." &&
|
||||||
systemStatus.serverName !== "Server Offline"
|
systemStatus.serverName !== t("app.serverOffline")
|
||||||
) {
|
) {
|
||||||
document.title = `${systemStatus.serverName} - ProxMenux Monitor`
|
document.title = `${systemStatus.serverName} - ProxMenux Monitor`
|
||||||
} else {
|
} else {
|
||||||
document.title = "ProxMenux Monitor"
|
document.title = "ProxMenux Monitor"
|
||||||
}
|
}
|
||||||
}, [systemStatus.serverName])
|
}, [systemStatus.serverName, t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -362,19 +389,20 @@ export function ProxmoxDashboard() {
|
|||||||
|
|
||||||
const getActiveTabLabel = () => {
|
const getActiveTabLabel = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case "overview": return "Overview"
|
case "overview": return t("navigation.overview")
|
||||||
case "vms": return "VMs & LXCs"
|
case "apps": return t("navigation.apps")
|
||||||
case "storage": return "Storage"
|
case "vms": return t("navigation.virtualMachines")
|
||||||
case "network": return "Network"
|
case "storage": return t("navigation.storage")
|
||||||
case "hardware": return "Hardware"
|
case "network": return t("navigation.network")
|
||||||
case "backup": return "Backup"
|
case "hardware": return t("navigation.hardware")
|
||||||
case "terminal": return "Terminal"
|
case "backup": return t("navigation.backup")
|
||||||
case "logs": return "System Logs"
|
case "terminal": return t("navigation.terminal")
|
||||||
case "security": return "Security"
|
case "logs": return t("navigation.systemLogs")
|
||||||
case "settings": return "Settings"
|
case "security": return t("navigation.security")
|
||||||
case "about": return "About"
|
case "settings": return t("navigation.settings")
|
||||||
case "profile": return "Profile"
|
case "about": return t("navigation.about")
|
||||||
default: return "Navigation Menu"
|
case "profile": return t("navigation.profile")
|
||||||
|
default: return t("navigation.menu")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,13 +416,13 @@ export function ProxmoxDashboard() {
|
|||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
<div className="flex items-center space-x-2 text-red-500 mb-2">
|
<div className="flex items-center space-x-2 text-red-500 mb-2">
|
||||||
<XCircle className="h-5 w-5" />
|
<XCircle className="h-5 w-5" />
|
||||||
<span className="font-medium">ProxMenux Server Connection Failed</span>
|
<span className="font-medium">{t("status.connectionFailed")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-red-500/80 space-y-1 ml-7">
|
<div className="text-sm text-red-500/80 space-y-1 ml-7">
|
||||||
<p>• Check that the monitor.service is running correctly.</p>
|
<p>• {t("status.checkService")}</p>
|
||||||
<p>• The ProxMenux server should start automatically on port 8008</p>
|
<p>• {t("status.serverPort")}</p>
|
||||||
<p>
|
<p>
|
||||||
• Try accessing:{" "}
|
• {t("status.tryAccessing")}{" "}
|
||||||
<a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline">
|
<a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline">
|
||||||
{getApiUrl("/api/health")}
|
{getApiUrl("/api/health")}
|
||||||
</a>
|
</a>
|
||||||
@@ -433,11 +461,11 @@ export function ProxmoxDashboard() {
|
|||||||
<Server className="h-8 w-8 md:h-6 md:w-6 text-primary absolute fallback-icon hidden" />
|
<Server className="h-8 w-8 md:h-6 md:w-6 text-primary absolute fallback-icon hidden" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">ProxMenux Monitor</h1>
|
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">{t("app.title")}</h1>
|
||||||
<p className="text-xs md:text-sm text-muted-foreground">Proxmox System Dashboard</p>
|
<p className="text-xs md:text-sm text-muted-foreground">{t("app.description")}</p>
|
||||||
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||||
<Server className="h-3 w-3" />
|
<Server className="h-3 w-3" />
|
||||||
<span className="truncate">Node: {systemStatus.serverName}</span>
|
<span className="truncate">{t("status.node", { node: displayServerName })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -447,14 +475,14 @@ export function ProxmoxDashboard() {
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Server className="h-4 w-4 text-muted-foreground" />
|
<Server className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="font-medium text-foreground">Node: {systemStatus.serverName}</div>
|
<div className="font-medium text-foreground">{t("status.node", { node: displayServerName })}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className={statusColor}>
|
<Badge variant="outline" className={statusColor}>
|
||||||
{statusIcon}
|
{statusIcon}
|
||||||
<span className="ml-1 capitalize">{systemStatus.status}</span>
|
<span className="ml-1">{t(`status.${systemStatus.status}`)}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
{systemStatus.status === "healthy" && infoCount > 0 && (
|
{systemStatus.status === "healthy" && infoCount > 0 && (
|
||||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
|
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
|
||||||
@@ -465,7 +493,7 @@ export function ProxmoxDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
Uptime: {systemStatus.uptime || "N/A"}
|
{t("status.uptime", { uptime: displayUptime })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -479,7 +507,7 @@ export function ProxmoxDashboard() {
|
|||||||
className="border-border/50 bg-transparent hover:bg-secondary"
|
className="border-border/50 bg-transparent hover:bg-secondary"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||||
Refresh
|
{t("actions.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -513,7 +541,7 @@ export function ProxmoxDashboard() {
|
|||||||
}}
|
}}
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
className="h-8 w-8 p-0 border-border/50 bg-transparent hover:bg-secondary"
|
className="h-8 w-8 p-0 border-border/50 bg-transparent hover:bg-secondary"
|
||||||
aria-label="Refresh"
|
aria-label={t("actions.refresh")}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -541,7 +569,7 @@ export function ProxmoxDashboard() {
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
|
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
|
||||||
{statusIcon}
|
{statusIcon}
|
||||||
<span className="ml-1 capitalize">{systemStatus.status}</span>
|
<span className="ml-1">{t(`status.${systemStatus.status}`)}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
{systemStatus.status === "healthy" && infoCount > 0 && (
|
{systemStatus.status === "healthy" && infoCount > 0 && (
|
||||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs px-2">
|
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs px-2">
|
||||||
@@ -551,7 +579,7 @@ export function ProxmoxDashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
Uptime: {systemStatus.uptime || "N/A"}
|
{t("status.uptime", { uptime: displayUptime })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -583,15 +611,15 @@ export function ProxmoxDashboard() {
|
|||||||
// crumb shows where you are, the chevron tells you the
|
// crumb shows where you are, the chevron tells you the
|
||||||
// siblings are one click away.
|
// siblings are one click away.
|
||||||
const NODE_ITEMS = [
|
const NODE_ITEMS = [
|
||||||
{ value: "storage", label: "Storage", Icon: HardDrive, default: false },
|
{ value: "storage", label: t("navigation.storage"), Icon: HardDrive, default: false },
|
||||||
{ value: "network", label: "Network", Icon: NetworkIcon, default: false },
|
{ value: "network", label: t("navigation.network"), Icon: NetworkIcon, default: false },
|
||||||
{ value: "hardware", label: "Hardware", Icon: Cpu, default: false },
|
{ value: "hardware", label: t("navigation.hardware"), Icon: Cpu, default: false },
|
||||||
]
|
]
|
||||||
const ADMIN_ITEMS = [
|
const ADMIN_ITEMS = [
|
||||||
{ value: "logs", label: "System Logs", Icon: ScrollText, default: false },
|
{ value: "logs", label: t("navigation.systemLogs"), Icon: ScrollText, default: false },
|
||||||
{ value: "security", label: "Security", Icon: ShieldCheck, default: false },
|
{ value: "security", label: t("navigation.security"), Icon: ShieldCheck, default: false },
|
||||||
{ value: "settings", label: "Settings", Icon: SettingsIcon, default: false },
|
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
|
||||||
{ value: "about", label: "About", Icon: Info, default: false },
|
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
|
||||||
]
|
]
|
||||||
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
|
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
|
||||||
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
|
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
|
||||||
@@ -600,9 +628,9 @@ export function ProxmoxDashboard() {
|
|||||||
// The trigger label + icon shown on the bar. When a child
|
// The trigger label + icon shown on the bar. When a child
|
||||||
// is active we surface IT; otherwise the group default.
|
// is active we surface IT; otherwise the group default.
|
||||||
const NodeTriggerIcon = activeNodeItem ? activeNodeItem.Icon : Server
|
const NodeTriggerIcon = activeNodeItem ? activeNodeItem.Icon : Server
|
||||||
const NodeTriggerLabel = activeNodeItem ? activeNodeItem.label : "Node"
|
const NodeTriggerLabel = activeNodeItem ? activeNodeItem.label : t("navigation.node")
|
||||||
const AdminTriggerIcon = activeAdminItem ? activeAdminItem.Icon : Settings2
|
const AdminTriggerIcon = activeAdminItem ? activeAdminItem.Icon : Settings2
|
||||||
const AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : "Admin"
|
const AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : t("navigation.admin")
|
||||||
// Dropdown trigger styling: parity with TabsTrigger so the
|
// Dropdown trigger styling: parity with TabsTrigger so the
|
||||||
// parent visibly carries the "I'm the selected section"
|
// parent visibly carries the "I'm the selected section"
|
||||||
// signal when any of its children is the active tab —
|
// signal when any of its children is the active tab —
|
||||||
@@ -616,75 +644,62 @@ export function ProxmoxDashboard() {
|
|||||||
: "text-muted-foreground hover:text-foreground rounded-sm"
|
: "text-muted-foreground hover:text-foreground rounded-sm"
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
// Data-driven TabsList: iterate over the user's saved
|
||||||
|
// top-level order. Each slot is either a direct tab or a
|
||||||
|
// dropdown group (Node/Admin). The internal items of a
|
||||||
|
// dropdown are never reordered by the user — a grouped
|
||||||
|
// slot moves as a unit.
|
||||||
|
const renderDirect = (
|
||||||
|
value: string,
|
||||||
|
Icon: React.ComponentType<{ className?: string }>,
|
||||||
|
label: string,
|
||||||
|
) => (
|
||||||
|
<TabsTrigger key={value} value={value} className={triggerActiveClass}>
|
||||||
|
<Icon className="mr-2 h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</TabsTrigger>
|
||||||
|
)
|
||||||
|
const renderDropdown = (
|
||||||
|
key: string,
|
||||||
|
items: { value: string; label: string; Icon: React.ComponentType<{ className?: string }> }[],
|
||||||
|
active: boolean,
|
||||||
|
TriggerIcon: React.ComponentType<{ className?: string }>,
|
||||||
|
triggerLabel: string,
|
||||||
|
) => (
|
||||||
|
<DropdownMenu key={key}>
|
||||||
|
<DropdownMenuTrigger className={dropdownBtnClass(active)}>
|
||||||
|
<TriggerIcon className="mr-2 h-4 w-4" />
|
||||||
|
{triggerLabel}
|
||||||
|
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" className="min-w-[180px]">
|
||||||
|
{items.map(({ value, label, Icon }) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={value}
|
||||||
|
onClick={() => setActiveTab(value)}
|
||||||
|
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
|
||||||
|
>
|
||||||
|
<Icon className="mr-2 h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
const renderTop = (id: TabId) => {
|
||||||
|
switch (id) {
|
||||||
|
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
|
||||||
|
case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps"))
|
||||||
|
case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines"))
|
||||||
|
case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup"))
|
||||||
|
case "terminal": return renderDirect("terminal", Terminal, t("navigation.terminal"))
|
||||||
|
case "node": return renderDropdown("node", NODE_ITEMS, isNodeActive, NodeTriggerIcon, NodeTriggerLabel)
|
||||||
|
case "admin": return renderDropdown("admin", ADMIN_ITEMS, isAdminActive, AdminTriggerIcon, AdminTriggerLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<TabsList className="hidden lg:grid w-full grid-cols-6 bg-card border border-border">
|
<TabsList className="hidden lg:grid w-full grid-cols-7 bg-card border border-border">
|
||||||
{/* Direct: Overview */}
|
{tabOrder.map(renderTop)}
|
||||||
<TabsTrigger value="overview" className={triggerActiveClass}>
|
|
||||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
|
||||||
Overview
|
|
||||||
</TabsTrigger>
|
|
||||||
|
|
||||||
{/* Direct: VMs & LXCs — first-class because Proxmox IS
|
|
||||||
a hypervisor; workloads belong at top level. */}
|
|
||||||
<TabsTrigger value="vms" className={triggerActiveClass}>
|
|
||||||
<Boxes className="mr-2 h-4 w-4" />
|
|
||||||
VMs & LXCs
|
|
||||||
</TabsTrigger>
|
|
||||||
|
|
||||||
{/* Dropdown: Node (Storage / Network / Hardware) */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger className={dropdownBtnClass(isNodeActive)}>
|
|
||||||
<NodeTriggerIcon className="mr-2 h-4 w-4" />
|
|
||||||
{NodeTriggerLabel}
|
|
||||||
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="center" className="min-w-[180px]">
|
|
||||||
{NODE_ITEMS.map(({ value, label, Icon }) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={value}
|
|
||||||
onClick={() => setActiveTab(value)}
|
|
||||||
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
|
|
||||||
>
|
|
||||||
<Icon className="mr-2 h-4 w-4" />
|
|
||||||
{label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
{/* Direct: Backup (today: Host Backup only). When VM/LXC
|
|
||||||
backup ships this becomes a dropdown. */}
|
|
||||||
<TabsTrigger value="backup" className={triggerActiveClass}>
|
|
||||||
<DatabaseBackup className="mr-2 h-4 w-4" />
|
|
||||||
Backup
|
|
||||||
</TabsTrigger>
|
|
||||||
|
|
||||||
{/* Direct: Terminal */}
|
|
||||||
<TabsTrigger value="terminal" className={triggerActiveClass}>
|
|
||||||
<Terminal className="mr-2 h-4 w-4" />
|
|
||||||
Terminal
|
|
||||||
</TabsTrigger>
|
|
||||||
|
|
||||||
{/* Dropdown: Admin (System Logs / Security / Settings / About) */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger className={dropdownBtnClass(isAdminActive)}>
|
|
||||||
<AdminTriggerIcon className="mr-2 h-4 w-4" />
|
|
||||||
{AdminTriggerLabel}
|
|
||||||
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="center" className="min-w-[180px]">
|
|
||||||
{ADMIN_ITEMS.map(({ value, label, Icon }) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={value}
|
|
||||||
onClick={() => setActiveTab(value)}
|
|
||||||
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
|
|
||||||
>
|
|
||||||
<Icon className="mr-2 h-4 w-4" />
|
|
||||||
{label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
@@ -719,56 +734,52 @@ export function ProxmoxDashboard() {
|
|||||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||||
: ""
|
: ""
|
||||||
}`
|
}`
|
||||||
// Mobile sheet is a flat list (no section headers).
|
// Mobile sheet honours the same user-defined
|
||||||
// The desktop layout uses dropdowns to express the
|
// top-level order as the desktop TabsList. Grouped
|
||||||
// Node/Admin grouping; here we just enumerate items
|
// slots (Node/Admin) expand their child items
|
||||||
// in the same visual order.
|
// inline right after their position — the group
|
||||||
|
// still moves as a unit, but children stay grouped.
|
||||||
|
const btn = (
|
||||||
|
value: string,
|
||||||
|
Icon: React.ComponentType<{ className?: string }>,
|
||||||
|
label: string,
|
||||||
|
) => (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => select(value)}
|
||||||
|
className={itemClass(activeTab === value)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 mt-4">
|
<div className="flex flex-col gap-1 mt-4">
|
||||||
<Button variant="ghost" onClick={() => select("overview")} className={itemClass(activeTab === "overview")}>
|
{tabOrder.map((id): React.ReactNode => {
|
||||||
<LayoutDashboard className="h-5 w-5" />
|
switch (id) {
|
||||||
<span>Overview</span>
|
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
|
||||||
</Button>
|
case "apps": return btn("apps", Grid3x3, t("navigation.apps"))
|
||||||
<Button variant="ghost" onClick={() => select("vms")} className={itemClass(activeTab === "vms")}>
|
case "vms": return btn("vms", Boxes, t("navigation.virtualMachines"))
|
||||||
<Boxes className="h-5 w-5" />
|
case "backup": return btn("backup", DatabaseBackup, t("navigation.backup"))
|
||||||
<span>VMs & LXCs</span>
|
case "terminal": return btn("terminal", Terminal, t("navigation.terminal"))
|
||||||
</Button>
|
case "node": return (
|
||||||
<Button variant="ghost" onClick={() => select("storage")} className={itemClass(activeTab === "storage")}>
|
<React.Fragment key="node">
|
||||||
<HardDrive className="h-5 w-5" />
|
{btn("storage", HardDrive, t("navigation.storage"))}
|
||||||
<span>Storage</span>
|
{btn("network", NetworkIcon, t("navigation.network"))}
|
||||||
</Button>
|
{btn("hardware", Cpu, t("navigation.hardware"))}
|
||||||
<Button variant="ghost" onClick={() => select("network")} className={itemClass(activeTab === "network")}>
|
</React.Fragment>
|
||||||
<NetworkIcon className="h-5 w-5" />
|
)
|
||||||
<span>Network</span>
|
case "admin": return (
|
||||||
</Button>
|
<React.Fragment key="admin">
|
||||||
<Button variant="ghost" onClick={() => select("hardware")} className={itemClass(activeTab === "hardware")}>
|
{btn("logs", ScrollText, t("navigation.systemLogs"))}
|
||||||
<Cpu className="h-5 w-5" />
|
{btn("security", ShieldCheck, t("navigation.security"))}
|
||||||
<span>Hardware</span>
|
{btn("settings", SettingsIcon, t("navigation.settings"))}
|
||||||
</Button>
|
{btn("about", Info, t("navigation.about"))}
|
||||||
<Button variant="ghost" onClick={() => select("backup")} className={itemClass(activeTab === "backup")}>
|
</React.Fragment>
|
||||||
<DatabaseBackup className="h-5 w-5" />
|
)
|
||||||
<span>Backup</span>
|
}
|
||||||
</Button>
|
})}
|
||||||
<Button variant="ghost" onClick={() => select("terminal")} className={itemClass(activeTab === "terminal")}>
|
|
||||||
<Terminal className="h-5 w-5" />
|
|
||||||
<span>Terminal</span>
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" onClick={() => select("logs")} className={itemClass(activeTab === "logs")}>
|
|
||||||
<ScrollText className="h-5 w-5" />
|
|
||||||
<span>System Logs</span>
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" onClick={() => select("security")} className={itemClass(activeTab === "security")}>
|
|
||||||
<ShieldCheck className="h-5 w-5" />
|
|
||||||
<span>Security</span>
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" onClick={() => select("settings")} className={itemClass(activeTab === "settings")}>
|
|
||||||
<SettingsIcon className="h-5 w-5" />
|
|
||||||
<span>Settings</span>
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" onClick={() => select("about")} className={itemClass(activeTab === "about")}>
|
|
||||||
<Info className="h-5 w-5" />
|
|
||||||
<span>About</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
@@ -779,11 +790,30 @@ export function ProxmoxDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container mx-auto px-4 md:px-6 py-4 md:py-6">
|
<div className="container mx-auto px-4 md:px-6 py-4 md:py-6">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4 md:space-y-6">
|
{/* No `space-y-*` here: only one TabsContent is visible at a
|
||||||
<TabsContent value="overview" className="space-y-4 md:space-y-6 mt-0">
|
time, but Overview stays force-mounted (hidden) as the
|
||||||
|
first child, so every OTHER active tab used to inherit an
|
||||||
|
extra top margin from the space-y utility — pushing the
|
||||||
|
page content further from the nav than on Overview.
|
||||||
|
Vertical spacing INSIDE each tab lives on its own
|
||||||
|
TabsContent's `space-y-*`. */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
|
||||||
|
{/* forceMount so SystemOverview mounts at dashboard load and
|
||||||
|
never gets torn down when the user visits another tab.
|
||||||
|
Without this, every return to Overview re-fires ~7 fetches
|
||||||
|
(system, vms, storage, proxmox-storage, network, node
|
||||||
|
metrics, network chart) and the user waited for the
|
||||||
|
cascade to complete each time. With forceMount, the
|
||||||
|
5 s / 59 s refresh intervals keep the data fresh in the
|
||||||
|
background — reopening the tab is instant. */}
|
||||||
|
<TabsContent value="overview" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
|
||||||
<SystemOverview key={`overview-${componentKey}`} />
|
<SystemOverview key={`overview-${componentKey}`} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0">
|
||||||
|
<AppsDashboard key={`apps-${componentKey}`} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="storage" className="space-y-4 md:space-y-6 mt-0">
|
<TabsContent value="storage" className="space-y-4 md:space-y-6 mt-0">
|
||||||
<StorageOverview key={`storage-${componentKey}`} />
|
<StorageOverview key={`storage-${componentKey}`} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -792,7 +822,13 @@ export function ProxmoxDashboard() {
|
|||||||
<NetworkMetrics key={`network-${componentKey}`} />
|
<NetworkMetrics key={`network-${componentKey}`} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="vms" className="space-y-4 md:space-y-6 mt-0">
|
{/* forceMount so the modal-data prefetcher (inside VirtualMachines)
|
||||||
|
starts warming caches from the moment the dashboard loads,
|
||||||
|
not the first time the user clicks the VMs tab. Kept
|
||||||
|
visually hidden with data-attribute selector when the tab
|
||||||
|
is inactive — mount cost is ~zero (no polling loop that
|
||||||
|
other components run). */}
|
||||||
|
<TabsContent value="vms" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
|
||||||
<VirtualMachines key={`vms-${componentKey}`} />
|
<VirtualMachines key={`vms-${componentKey}`} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -836,7 +872,7 @@ export function ProxmoxDashboard() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
||||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.4</p>
|
<p className="font-medium mb-2">ProxMenux Monitor v{APP_VERSION}</p>
|
||||||
<p>
|
<p>
|
||||||
<a
|
<a
|
||||||
href="https://ko-fi.com/macrimi"
|
href="https://ko-fi.com/macrimi"
|
||||||
@@ -844,7 +880,7 @@ export function ProxmoxDashboard() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-500 hover:text-blue-600 hover:underline transition-colors"
|
className="text-blue-500 hover:text-blue-600 hover:underline transition-colors"
|
||||||
>
|
>
|
||||||
Support and contribute to the project
|
{t("app.supportProject")}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { Plus, Share, X } from "lucide-react"
|
import { Plus, Share, X } from "lucide-react"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
// ==========================================================
|
// ==========================================================
|
||||||
// PwaInstallPrompt
|
// PwaInstallPrompt
|
||||||
@@ -58,6 +59,7 @@ function isIOS(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PwaInstallPrompt() {
|
export function PwaInstallPrompt() {
|
||||||
|
const t = useT()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [platform, setPlatform] = useState<"ios" | "android" | null>(null)
|
const [platform, setPlatform] = useState<"ios" | "android" | null>(null)
|
||||||
|
|
||||||
@@ -128,13 +130,30 @@ export function PwaInstallPrompt() {
|
|||||||
>
|
>
|
||||||
<div className="relative px-5 pt-5">
|
<div className="relative px-5 pt-5">
|
||||||
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-border" aria-hidden="true" />
|
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-border" aria-hidden="true" />
|
||||||
|
{/* Hardened close button — the X wasn't reliably closing
|
||||||
|
the sheet on iOS/Android. Fixes:
|
||||||
|
* `stopPropagation` so the click never bubbles up to
|
||||||
|
the backdrop handler (which was seeing the target
|
||||||
|
and might have been running its own logic against
|
||||||
|
the same tap on some mobile browsers).
|
||||||
|
* `z-10` puts it above any sibling absolute layers
|
||||||
|
(drag handle, headings) in case one silently ate
|
||||||
|
the tap.
|
||||||
|
* 40 × 40 hit area — comfortable Apple/Google minimum
|
||||||
|
for a touch target; the 32 × 32 we had was fine on
|
||||||
|
desktop but easy to miss with a thumb.
|
||||||
|
* `onPointerDown` as a secondary handler covers the
|
||||||
|
iOS Safari case where a fast tap on a nested
|
||||||
|
`<button>` inside a `role="dialog"` can lose the
|
||||||
|
click event to the parent overlay. */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClose}
|
onClick={(e) => { e.stopPropagation(); handleClose() }}
|
||||||
aria-label="Close"
|
onPointerDown={(e) => { e.stopPropagation() }}
|
||||||
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground hover:bg-muted transition-colors"
|
aria-label={t("actions.close")}
|
||||||
|
className="absolute right-2 top-2 z-10 flex h-10 w-10 items-center justify-center rounded-full text-muted-foreground hover:bg-muted active:bg-muted transition-colors touch-manipulation"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mb-4 flex items-start gap-3.5">
|
<div className="mb-4 flex items-start gap-3.5">
|
||||||
@@ -143,12 +162,12 @@ export function PwaInstallPrompt() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 id="pwa-install-title" className="text-[17px] font-bold leading-tight tracking-tight text-foreground">
|
<h3 id="pwa-install-title" className="text-[17px] font-bold leading-tight tracking-tight text-foreground">
|
||||||
Install ProxMenux Monitor
|
{t("pwaInstall.title")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-[13px] leading-snug text-muted-foreground">
|
<p className="mt-1 text-[13px] leading-snug text-muted-foreground">
|
||||||
{platform === "ios"
|
{platform === "ios"
|
||||||
? "Add the Monitor to your home screen for quick access."
|
? t("pwaInstall.iosDescription")
|
||||||
: "Add the Monitor as an app to launch it like a native application."}
|
: t("pwaInstall.androidDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,12 +179,12 @@ export function PwaInstallPrompt() {
|
|||||||
1
|
1
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Tap the{" "}
|
{t("pwaInstall.ios.stepShareBefore")}{" "}
|
||||||
<span className="inline-flex items-center gap-1 font-semibold text-primary">
|
<span className="inline-flex items-center gap-1 font-semibold text-primary">
|
||||||
<Share className="h-4 w-4" aria-hidden="true" />
|
<Share className="h-4 w-4" aria-hidden="true" />
|
||||||
Share
|
{t("pwaInstall.ios.share")}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
button in the bottom bar
|
{t("pwaInstall.ios.stepShareAfter")}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-3 rounded-xl bg-primary/10 px-3.5 py-3 text-[13.5px] leading-tight">
|
<li className="flex items-center gap-3 rounded-xl bg-primary/10 px-3.5 py-3 text-[13.5px] leading-tight">
|
||||||
@@ -173,10 +192,10 @@ export function PwaInstallPrompt() {
|
|||||||
2
|
2
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Choose{" "}
|
{t("pwaInstall.ios.stepChooseBefore")}{" "}
|
||||||
<span className="inline-flex items-center gap-1 font-semibold text-primary">
|
<span className="inline-flex items-center gap-1 font-semibold text-primary">
|
||||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
Add to Home Screen
|
{t("pwaInstall.addToHomeScreen")}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -185,15 +204,15 @@ export function PwaInstallPrompt() {
|
|||||||
3
|
3
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Confirm by tapping <b>Add</b> in the top-right
|
{t("pwaInstall.ios.stepConfirmBefore")} <b>{t("pwaInstall.ios.add")}</b> {t("pwaInstall.ios.stepConfirmAfter")}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
) : (
|
) : (
|
||||||
<div className="mb-4 rounded-lg border border-border bg-muted/50 px-3.5 py-3 text-[13px] leading-relaxed text-muted-foreground">
|
<div className="mb-4 rounded-lg border border-border bg-muted/50 px-3.5 py-3 text-[13px] leading-relaxed text-muted-foreground">
|
||||||
Open the browser menu <b className="text-foreground">⋮</b> →{" "}
|
{t("pwaInstall.android.stepOpenMenu")} <b className="text-foreground">⋮</b> →{" "}
|
||||||
<b className="text-foreground">Add to Home Screen</b> → confirm by tapping{" "}
|
<b className="text-foreground">{t("pwaInstall.addToHomeScreen")}</b> → {t("pwaInstall.android.stepConfirm")}{" "}
|
||||||
<b className="text-foreground">Install</b>.
|
<b className="text-foreground">{t("pwaInstall.android.install")}</b>.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -203,14 +222,14 @@ export function PwaInstallPrompt() {
|
|||||||
onClick={handleNotNow}
|
onClick={handleNotNow}
|
||||||
className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-muted-foreground hover:bg-muted transition-colors"
|
className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-muted-foreground hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
Not now
|
{t("pwaInstall.notNow")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNeverAgain}
|
onClick={handleNeverAgain}
|
||||||
className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-amber-700 dark:text-amber-500 hover:bg-muted transition-colors"
|
className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-amber-700 dark:text-amber-500 hover:bg-muted transition-colors"
|
||||||
>
|
>
|
||||||
Don't show again
|
{t("pwaInstall.neverAgain")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||||
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup } from "lucide-react"
|
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone, Languages } from "lucide-react"
|
||||||
import { Checkbox } from "./ui/checkbox"
|
import { Checkbox } from "./ui/checkbox"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
const APP_VERSION = "1.2.4" // Sync with AppImage/package.json
|
import { APP_VERSION } from "../lib/version"
|
||||||
|
|
||||||
interface ReleaseNote {
|
interface ReleaseNote {
|
||||||
date: string
|
date: string
|
||||||
@@ -18,6 +18,57 @@ interface ReleaseNote {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CHANGELOG: Record<string, ReleaseNote> = {
|
export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||||
|
"1.2.5": {
|
||||||
|
date: "September 1, 2026",
|
||||||
|
changes: {
|
||||||
|
added: [
|
||||||
|
"New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, sort by name/id/category, and one-click deep-links back to the guest modal (LXC cards land on App, VM cards on Status).",
|
||||||
|
"Application detection catalog with over 380 tracked workloads, generated live from the community-scripts source across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
|
||||||
|
"App tab inside every VM & LXC modal — register the apps installed in a container, capture their weblinks, and get notifications when a new upstream version ships. Cold-start Docker detection now correctly promotes Docker as the parent workload before the daemon finishes booting.",
|
||||||
|
"Reworked LXC Updates tab — apply OS packages and registered-app updates from a single button, schedule a recurring auto-update job, and cover Docker end to end (Engine + per-image on the same 24-hour rolling cycle as OS packages, with a 'Check now' action for on-demand digest comparison).",
|
||||||
|
"The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Thanks to @vaso73 for building the i18n scaffolding.",
|
||||||
|
"Navigation order — a new Settings card lets each user reorder the seven top-level tabs by drag and drop. Grouped slots (Node, Admin) move as a single unit; the first slot in the saved order becomes the tab the Monitor opens on. Mouse and touch supported.",
|
||||||
|
"Custom Web Links — persistent sidecar at /etc/proxmenux/custom_links.json for URLs that don't live inside a registered LXC app. Editor takes name, URL, optional logo, category and optional binding to a specific VM or LXC.",
|
||||||
|
"NVIDIA — multi-GPU passthrough by exact BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs. Kernel + branch + GPU-aware version picker (#298).",
|
||||||
|
"Pushover joins Telegram, Gotify, Discord, Email and Apprise as a native notification channel — user/API key, device and sound selectors, priority 0 for regular messages, optional priority 1 for CRITICAL events. Suggested by @benginx (#308).",
|
||||||
|
"Host backup/restore continuity — ProxMenux configuration and independent backups survive a full Proxmox restore (#317, reported by @tropicaljoe).",
|
||||||
|
"Hardware temperature sensor identity — storage temperatures now identify the physical drive (NVMe namespace, model, serial) instead of the generic hwmon label. HDD/SSD classification follows the block device rotational flag (#315, suggested by @Dark-Witcher).",
|
||||||
|
"Post-install — precise rollback for every registered flow and Debian 13 readiness.",
|
||||||
|
"First-time visitors on Android and iOS Safari now see an in-app install prompt for adding the Monitor to their home screen as a PWA.",
|
||||||
|
],
|
||||||
|
changed: [
|
||||||
|
"Memory & Swap health check now signals real memory pressure — CRITICAL fires only when swap file is nearly full AND available RAM is genuinely tight (both editable in Settings → Health thresholds). The old swap-only signal was noisy on hosts where Linux proactively swaps out inactive pages.",
|
||||||
|
"Faster page loads and smoother navigation across the dashboard. Overview opens instantly and the VMs & LXCs page never flashes 'Loading…' between guest modals again.",
|
||||||
|
"Long backup jobs no longer time out. VM and LXC backups launched from the Monitor now run in the background until they naturally finish, so a 30-minute PBS backup completes the same as a 10-second local one.",
|
||||||
|
"VMs running the QEMU Guest Agent now report real used / total disk figures on the dashboard, instead of the '0 GB' that PVE returns for guest-managed filesystems.",
|
||||||
|
"App tab cache stays warm across every action. Successful add / edit / check / dismiss / delete operations write the returned sidecar directly into both backend and shared frontend caches; post-update scans revalidate in the background while the last valid content stays visible.",
|
||||||
|
"Docker cards on the Apps dashboard resolve their update state per image, not per Docker Engine. The purple update arrow only lights up when that specific image has an upstream update.",
|
||||||
|
"ZFS ARC sizing under memory pressure and OOM diagnostics reworked (credit: LeidenSpain).",
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
"Fix #309 — Proxmox storage availability: false critical alerts for iSCSI storages with maxdisk=0.",
|
||||||
|
"HID USB device class no longer reads as 'escondido' (past tense of 'to hide') in Spanish, German, French, Italian and Portuguese — the acronym is now preserved.",
|
||||||
|
"The Memory & Swap health card is dismissable like every other category (RAM usage and Swap usage sub-checks carry the dismissable flag).",
|
||||||
|
"Regenerated Proxmox VE Helper-Scripts updaters remain available after an app update (both historical wrappers and the current generated entrypoint are recognised).",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"1.2.4.1-beta": {
|
||||||
|
date: "August 17, 2026",
|
||||||
|
changes: {
|
||||||
|
added: [
|
||||||
|
"The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.",
|
||||||
|
"New App tab inside the VM & LXC modal — especially for LXCs. Register the apps installed in a container, capture their weblinks, and get notifications when a new upstream version ships.",
|
||||||
|
"Reworked Updates tab for LXCs: apply OS packages and registered-app updates from a single button, and schedule a recurring auto-update job that checks the container's OS and its tracked app on every run.",
|
||||||
|
"First-time visitors on Android and iOS Safari now see an in-app install prompt with clear steps for adding the Monitor to their home screen as a PWA.",
|
||||||
|
],
|
||||||
|
changed: [
|
||||||
|
"Faster page loads and smoother navigation across the dashboard. Overview opens instantly and the VMs & LXCs page never flashes 'Loading…' between guest modals again.",
|
||||||
|
"Long backup jobs no longer time out. VM and LXC backups launched from the Monitor now run in the background until they naturally finish, so a 30-minute PBS backup completes the same as a 10-second local one.",
|
||||||
|
"VMs running the QEMU Guest Agent (qemu-guest-agent) now report real used / total disk figures on the dashboard, instead of the '0 GB' that PVE returns for guest-managed filesystems.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
"1.2.3": {
|
"1.2.3": {
|
||||||
date: "July 15, 2026",
|
date: "July 15, 2026",
|
||||||
changes: {
|
changes: {
|
||||||
@@ -230,23 +281,78 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each feature carries an i18n key so translations live in the
|
||||||
|
// common.json catalogs and the modal renders in the user's chosen
|
||||||
|
// locale. `text` is the English source of truth — it's what the
|
||||||
|
// build-i18n-messages workflow feeds to Google Translate for locales
|
||||||
|
// that haven't been curated by hand.
|
||||||
const CURRENT_VERSION_FEATURES = [
|
const CURRENT_VERSION_FEATURES = [
|
||||||
{
|
{
|
||||||
icon: <RefreshCw className="h-5 w-5" />,
|
icon: <Sparkles className="h-5 w-5" />,
|
||||||
text: "One-click host update from the Health Monitor — new Update Now button in the System Updates section runs the Proxmox update flow in an in-dashboard terminal, without leaving the browser.",
|
key: "releaseNotes.currentFeatures.appsDashboard",
|
||||||
|
text: "New top-level Apps dashboard — a single launcher for every Web Link across the node. LXC-registered apps and user-defined Custom Web Links share the same grid with category badges, search, and one-click deep-links back to the guest modal.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Sparkles className="h-5 w-5" />,
|
icon: <Cpu className="h-5 w-5" />,
|
||||||
text: "In-app Install prompt for mobile — first-time visitors on Android and iOS Safari now see a bottom-sheet with clear steps for adding the Monitor to their home screen as a PWA.",
|
key: "releaseNotes.currentFeatures.lxcAppsUpdates",
|
||||||
|
text: "App tab inside every LXC modal registers installed apps, captures weblinks and tracks upstream versions. Reworked Updates tab applies OS packages and app updates from a single button; Docker Engine and per-image tracking follow the same 24-hour cycle, with a 'Check now' action on demand.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Zap className="h-5 w-5" />,
|
||||||
|
key: "releaseNotes.currentFeatures.appCatalog",
|
||||||
|
text: "New application detection catalog with over 380 tracked workloads, generated live from community-scripts across seven detector methods (file, binary, dpkg, apk, Python, Docker exec, Docker label). Primary and fallback detectors cover both new and historical LXC layouts.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Languages className="h-5 w-5" />,
|
||||||
|
key: "releaseNotes.currentFeatures.multilingual",
|
||||||
|
text: "The Monitor now speaks 8 languages: English, Spanish, German, French, Italian, Portuguese, Swedish and Slovak. Huge thanks to @vaso73 for building the i18n scaffolding that made this possible.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Server className="h-5 w-5" />,
|
||||||
|
key: "releaseNotes.currentFeatures.nvidiaMultiGpu",
|
||||||
|
text: "NVIDIA driver lifecycle moves to per-BDF ownership so a multi-GPU host can pass one card to a VM and keep the other operational on the host or in LXCs, plus a kernel + branch + GPU-aware version picker (#298).",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Turn any "@handle" mention inside a release-notes string into a
|
||||||
|
// link to that GitHub profile. Applied to every feature bullet so a
|
||||||
|
// contributor shout-out reads as a real link without needing rich
|
||||||
|
// i18n formatting. Only matches `@` followed by a valid GitHub
|
||||||
|
// username (letters/digits/hyphen, no consecutive hyphens, 1-39
|
||||||
|
// chars) so unrelated punctuation stays untouched.
|
||||||
|
function linkifyGithubMentions(text: string): (string | JSX.Element)[] {
|
||||||
|
const parts: (string | JSX.Element)[] = []
|
||||||
|
const re = /@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)/g
|
||||||
|
let cursor = 0
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
let idx = 0
|
||||||
|
while ((m = re.exec(text)) !== null) {
|
||||||
|
if (m.index > cursor) parts.push(text.slice(cursor, m.index))
|
||||||
|
const handle = m[1]
|
||||||
|
parts.push(
|
||||||
|
<a
|
||||||
|
key={`gh-${idx++}`}
|
||||||
|
href={`https://github.com/${handle}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-orange-500 hover:text-orange-400 underline underline-offset-2"
|
||||||
|
>
|
||||||
|
@{handle}
|
||||||
|
</a>,
|
||||||
|
)
|
||||||
|
cursor = m.index + m[0].length
|
||||||
|
}
|
||||||
|
if (cursor < text.length) parts.push(text.slice(cursor))
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
interface ReleaseNotesModalProps {
|
interface ReleaseNotesModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||||
|
const t = useT()
|
||||||
const [dontShowAgain, setDontShowAgain] = useState(false)
|
const [dontShowAgain, setDontShowAgain] = useState(false)
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -259,7 +365,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-2xl max-h-[85vh] p-0 gap-0 border-0 bg-transparent">
|
<DialogContent className="max-w-2xl max-h-[85vh] p-0 gap-0 border-0 bg-transparent">
|
||||||
<DialogTitle className="sr-only">Release Notes - Version {APP_VERSION}</DialogTitle>
|
<DialogTitle className="sr-only">{t("releaseNotes.dialogTitle", { version: APP_VERSION })}</DialogTitle>
|
||||||
<div className="relative bg-card rounded-lg shadow-2xl h-full flex flex-col max-h-[85vh]">
|
<div className="relative bg-card rounded-lg shadow-2xl h-full flex flex-col max-h-[85vh]">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -285,10 +391,10 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
|||||||
<div className="flex-1 overflow-y-auto p-6 md:p-8 space-y-4 md:space-y-6 min-h-0">
|
<div className="flex-1 overflow-y-auto p-6 md:p-8 space-y-4 md:space-y-6 min-h-0">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-xl md:text-2xl font-bold text-foreground text-balance">
|
<h2 className="text-xl md:text-2xl font-bold text-foreground text-balance">
|
||||||
What's New in Version {APP_VERSION}
|
{t("releaseNotes.title", { version: APP_VERSION })}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
We've added exciting new features and improvements to make ProxMenux Monitor even better!
|
{t("releaseNotes.intro")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -299,7 +405,9 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
|||||||
className="flex items-start gap-2 md:gap-3 p-3 rounded-lg bg-muted/50 border border-border/50 hover:bg-muted/70 transition-colors"
|
className="flex items-start gap-2 md:gap-3 p-3 rounded-lg bg-muted/50 border border-border/50 hover:bg-muted/70 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="text-orange-500 mt-0.5 flex-shrink-0">{feature.icon}</div>
|
<div className="text-orange-500 mt-0.5 flex-shrink-0">{feature.icon}</div>
|
||||||
<p className="text-xs md:text-sm text-foreground leading-relaxed">{feature.text}</p>
|
<p className="text-xs md:text-sm text-foreground leading-relaxed">
|
||||||
|
{linkifyGithubMentions(t(feature.key))}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -312,7 +420,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
|||||||
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600"
|
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4 mr-2" />
|
<Sparkles className="h-4 w-4 mr-2" />
|
||||||
Got it!
|
{t("releaseNotes.gotIt")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
@@ -325,7 +433,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
|||||||
htmlFor="dont-show-version-again"
|
htmlFor="dont-show-version-again"
|
||||||
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
|
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
|
||||||
>
|
>
|
||||||
Don't show again for this version
|
{t("releaseNotes.dontShowAgain")}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
// ── Shape contracts with the backend ──────────────────────────
|
// ── Shape contracts with the backend ──────────────────────────
|
||||||
|
|
||||||
@@ -121,15 +122,17 @@ const formatIso = (iso: string | null | undefined) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatRelative = (iso: string) => {
|
type Translator = ReturnType<typeof useT>
|
||||||
|
|
||||||
|
const formatRelative = (iso: string, t: Translator) => {
|
||||||
try {
|
try {
|
||||||
const then = new Date(iso).getTime()
|
const then = new Date(iso).getTime()
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const diff = Math.max(0, Math.round((now - then) / 1000))
|
const diff = Math.max(0, Math.round((now - then) / 1000))
|
||||||
if (diff < 60) return `${diff}s ago`
|
if (diff < 60) return t("restoreProgress.time.secondsAgo", { count: diff })
|
||||||
if (diff < 3600) return `${Math.round(diff / 60)}m ago`
|
if (diff < 3600) return t("restoreProgress.time.minutesAgo", { count: Math.round(diff / 60) })
|
||||||
if (diff < 86400) return `${Math.round(diff / 3600)}h ago`
|
if (diff < 86400) return t("restoreProgress.time.hoursAgo", { count: Math.round(diff / 3600) })
|
||||||
return `${Math.round(diff / 86400)}d ago`
|
return t("restoreProgress.time.daysAgo", { count: Math.round(diff / 86400) })
|
||||||
} catch {
|
} catch {
|
||||||
return iso
|
return iso
|
||||||
}
|
}
|
||||||
@@ -140,40 +143,41 @@ const formatRelative = (iso: string) => {
|
|||||||
// "estimating time…". After the run is terminal, "—". The output is
|
// "estimating time…". After the run is terminal, "—". The output is
|
||||||
// a full phrase so the caller doesn't have to add suffix words that
|
// a full phrase so the caller doesn't have to add suffix words that
|
||||||
// only make sense on some branches.
|
// only make sense on some branches.
|
||||||
const computeEta = (state: RestoreState): string => {
|
const computeEta = (state: RestoreState, t: Translator): string => {
|
||||||
if (state.status !== "running") return "—"
|
if (state.status !== "running") return "—"
|
||||||
if (!state.steps_done || state.steps_done <= 0) return "estimating time…"
|
if (!state.steps_done || state.steps_done <= 0) return t("restoreProgress.time.estimating")
|
||||||
const elapsedSec = Math.max(1, Math.round((Date.now() - new Date(state.started_at).getTime()) / 1000))
|
const elapsedSec = Math.max(1, Math.round((Date.now() - new Date(state.started_at).getTime()) / 1000))
|
||||||
const perStep = elapsedSec / state.steps_done
|
const perStep = elapsedSec / state.steps_done
|
||||||
const remaining = Math.max(0, state.steps_total - state.steps_done)
|
const remaining = Math.max(0, state.steps_total - state.steps_done)
|
||||||
const eta = Math.round(perStep * remaining)
|
const eta = Math.round(perStep * remaining)
|
||||||
if (eta < 60) return `~${eta}s left`
|
if (eta < 60) return t("restoreProgress.time.secondsLeft", { count: eta })
|
||||||
if (eta < 3600) return `~${Math.round(eta / 60)}m left`
|
if (eta < 3600) return t("restoreProgress.time.minutesLeft", { count: Math.round(eta / 60) })
|
||||||
return `~${Math.round(eta / 3600)}h left`
|
return t("restoreProgress.time.hoursLeft", { count: Math.round(eta / 3600) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Small building blocks ─────────────────────────────────────
|
// ── Small building blocks ─────────────────────────────────────
|
||||||
|
|
||||||
const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
|
const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
|
||||||
|
const t = useT()
|
||||||
if (status === "running")
|
if (status === "running")
|
||||||
return (
|
return (
|
||||||
<Badge className="bg-blue-500/10 border-blue-500/40 text-blue-300 gap-1">
|
<Badge className="bg-blue-500/10 border-blue-500/40 text-blue-300 gap-1">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
Restore in progress
|
{t("restoreProgress.status.running")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
if (status === "complete")
|
if (status === "complete")
|
||||||
return (
|
return (
|
||||||
<Badge className="bg-emerald-500/10 border-emerald-500/40 text-emerald-400 gap-1">
|
<Badge className="bg-emerald-500/10 border-emerald-500/40 text-emerald-400 gap-1">
|
||||||
<CheckCircle2 className="h-3 w-3" />
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
Restore complete
|
{t("restoreProgress.status.complete")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
if (status === "failed")
|
if (status === "failed")
|
||||||
return (
|
return (
|
||||||
<Badge className="bg-red-500/10 border-red-500/40 text-red-400 gap-1">
|
<Badge className="bg-red-500/10 border-red-500/40 text-red-400 gap-1">
|
||||||
<XCircle className="h-3 w-3" />
|
<XCircle className="h-3 w-3" />
|
||||||
Restore failed
|
{t("restoreProgress.status.failed")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
return <Badge variant="outline">{status}</Badge>
|
return <Badge variant="outline">{status}</Badge>
|
||||||
@@ -190,6 +194,7 @@ const ComponentStatusIcon: React.FC<{ status: string }> = ({ status }) => {
|
|||||||
// ── Log viewer ────────────────────────────────────────────────
|
// ── Log viewer ────────────────────────────────────────────────
|
||||||
|
|
||||||
const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ path, historyOnly }) => {
|
const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ path, historyOnly }) => {
|
||||||
|
const t = useT()
|
||||||
const [filter, setFilter] = useState<"all" | "issues">("all")
|
const [filter, setFilter] = useState<"all" | "issues">("all")
|
||||||
const swrKey = path
|
const swrKey = path
|
||||||
? `/api/host-backups/restore/log?filter=${filter}&tail=600${historyOnly ? `&path=${encodeURIComponent(path)}` : ""}`
|
? `/api/host-backups/restore/log?filter=${filter}&tail=600${historyOnly ? `&path=${encodeURIComponent(path)}` : ""}`
|
||||||
@@ -205,7 +210,7 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
|
|||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
<FileText className="h-3.5 w-3.5" />
|
<FileText className="h-3.5 w-3.5" />
|
||||||
{path ?? "no log yet"}
|
{path ?? t("restoreProgress.log.noLog")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
@@ -215,7 +220,7 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
|
|||||||
onClick={() => setFilter("all")}
|
onClick={() => setFilter("all")}
|
||||||
>
|
>
|
||||||
<ArrowDownAZ className="h-3 w-3 mr-1" />
|
<ArrowDownAZ className="h-3 w-3 mr-1" />
|
||||||
Full
|
{t("restoreProgress.log.full")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -224,13 +229,13 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
|
|||||||
onClick={() => setFilter("issues")}
|
onClick={() => setFilter("issues")}
|
||||||
>
|
>
|
||||||
<Filter className="h-3 w-3 mr-1" />
|
<Filter className="h-3 w-3 mr-1" />
|
||||||
Issues only
|
{t("restoreProgress.log.issuesOnly")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="h-72 rounded-md border border-border bg-black/40">
|
<ScrollArea className="h-72 rounded-md border border-border bg-black/40">
|
||||||
<pre className="p-3 text-xs text-muted-foreground whitespace-pre-wrap font-mono leading-relaxed">
|
<pre className="p-3 text-xs text-muted-foreground whitespace-pre-wrap font-mono leading-relaxed">
|
||||||
{isLoading ? "Loading…" : (data?.lines?.join("\n") || "(no output)")}
|
{isLoading ? t("app.loading") : (data?.lines?.join("\n") || t("restoreProgress.log.noOutput"))}
|
||||||
</pre>
|
</pre>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,13 +245,14 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
|
|||||||
// ── Rollback delta widget ─────────────────────────────────────
|
// ── Rollback delta widget ─────────────────────────────────────
|
||||||
|
|
||||||
const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta }) => {
|
const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta }) => {
|
||||||
|
const t = useT()
|
||||||
const vms = delta?.vms_to_remove ?? []
|
const vms = delta?.vms_to_remove ?? []
|
||||||
const lxcs = delta?.lxcs_to_remove ?? []
|
const lxcs = delta?.lxcs_to_remove ?? []
|
||||||
const comps = delta?.components_to_uninstall ?? []
|
const comps = delta?.components_to_uninstall ?? []
|
||||||
if (!vms.length && !lxcs.length && !comps.length) {
|
if (!vms.length && !lxcs.length && !comps.length) {
|
||||||
return (
|
return (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
No entries exist on this host that weren't in the restored backup.
|
{t("restoreProgress.rollback.empty")}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -264,7 +270,7 @@ const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta
|
|||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<details className="text-xs">
|
<details className="text-xs">
|
||||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||||
Show manual cleanup commands
|
{t("restoreProgress.rollback.showCleanup")}
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-1 p-2 rounded-md bg-black/40 text-xs text-muted-foreground font-mono">
|
<pre className="mt-1 p-2 rounded-md bg-black/40 text-xs text-muted-foreground font-mono">
|
||||||
{items.map(cmd).join("\n")}
|
{items.map(cmd).join("\n")}
|
||||||
@@ -277,22 +283,22 @@ const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
These entries exist on this host but were NOT in the restored backup. Review before removing.
|
{t("restoreProgress.rollback.description")}
|
||||||
</div>
|
</div>
|
||||||
<Row
|
<Row
|
||||||
label="VMs created after the backup"
|
label={t("restoreProgress.rollback.vms")}
|
||||||
items={vms}
|
items={vms}
|
||||||
cmd={(id) => `qm stop ${id} 2>/dev/null; qm destroy ${id} --purge`}
|
cmd={(id) => `qm stop ${id} 2>/dev/null; qm destroy ${id} --purge`}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label="LXCs created after the backup"
|
label={t("restoreProgress.rollback.lxcs")}
|
||||||
items={lxcs}
|
items={lxcs}
|
||||||
cmd={(id) => `pct stop ${id} 2>/dev/null; pct destroy ${id} --purge`}
|
cmd={(id) => `pct stop ${id} 2>/dev/null; pct destroy ${id} --purge`}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label="Components installed after the backup"
|
label={t("restoreProgress.rollback.components")}
|
||||||
items={comps}
|
items={comps}
|
||||||
cmd={(name) => `# uninstall ${name} manually via ProxMenux → Hardware & GPU`}
|
cmd={(name) => t("restoreProgress.rollback.uninstallComponentCommand", { name })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -306,6 +312,7 @@ const RestoreDetailModal: React.FC<{
|
|||||||
state: RestoreState
|
state: RestoreState
|
||||||
historyMode?: boolean
|
historyMode?: boolean
|
||||||
}> = ({ open, onClose, state, historyMode }) => {
|
}> = ({ open, onClose, state, historyMode }) => {
|
||||||
|
const t = useT()
|
||||||
const progressPct = state.steps_total > 0 ? Math.round((state.steps_done / state.steps_total) * 100) : 0
|
const progressPct = state.steps_total > 0 ? Math.round((state.steps_done / state.steps_total) * 100) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -314,12 +321,12 @@ const RestoreDetailModal: React.FC<{
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<RotateCcw className="h-5 w-5 text-blue-500" />
|
<RotateCcw className="h-5 w-5 text-blue-500" />
|
||||||
Post-restore progress
|
{t("restoreProgress.title")}
|
||||||
<StatusBadge status={state.status} />
|
<StatusBadge status={state.status} />
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Started {formatIso(state.started_at)}
|
{t("restoreProgress.startedAt", { time: formatIso(state.started_at) })}
|
||||||
{state.finished_at ? ` · finished ${formatIso(state.finished_at)}` : ""}
|
{state.finished_at ? ` · ${t("restoreProgress.finishedAt", { time: formatIso(state.finished_at) })}` : ""}
|
||||||
{state.summary?.duration ? ` · ${state.summary.duration}` : ""}
|
{state.summary?.duration ? ` · ${state.summary.duration}` : ""}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -329,8 +336,8 @@ const RestoreDetailModal: React.FC<{
|
|||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
<span>{state.current_step || "—"}</span>
|
<span>{state.current_step || "—"}</span>
|
||||||
<span>
|
<span>
|
||||||
{state.steps_done}/{state.steps_total} steps
|
{t("restoreProgress.steps", { done: state.steps_done, total: state.steps_total })}
|
||||||
{state.status === "running" && ` · ${computeEta(state)}`}
|
{state.status === "running" && ` · ${computeEta(state, t)}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
@@ -347,7 +354,7 @@ const RestoreDetailModal: React.FC<{
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-medium flex items-center gap-2">
|
<div className="text-sm font-medium flex items-center gap-2">
|
||||||
<Cpu className="h-4 w-4" />
|
<Cpu className="h-4 w-4" />
|
||||||
Components
|
{t("restoreProgress.sections.components")}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{state.components.map((c) => (
|
{state.components.map((c) => (
|
||||||
@@ -358,8 +365,8 @@ const RestoreDetailModal: React.FC<{
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ComponentStatusIcon status={c.status} />
|
<ComponentStatusIcon status={c.status} />
|
||||||
<span className="font-medium">{formatComponent(c.name)}</span>
|
<span className="font-medium">{formatComponent(c.name)}</span>
|
||||||
<span className="text-muted-foreground">{c.status}</span>
|
<span className="text-muted-foreground">{t(`restoreProgress.componentStatus.${c.status}`)}</span>
|
||||||
{c.exit_code && <span className="text-red-400">exit {c.exit_code}</span>}
|
{c.exit_code && <span className="text-red-400">{t("restoreProgress.exitCode", { code: c.exit_code })}</span>}
|
||||||
</div>
|
</div>
|
||||||
{c.log && <span className="text-muted-foreground font-mono">{c.log}</span>}
|
{c.log && <span className="text-muted-foreground font-mono">{c.log}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -372,7 +379,7 @@ const RestoreDetailModal: React.FC<{
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-medium flex items-center gap-2 text-amber-400">
|
<div className="text-sm font-medium flex items-center gap-2 text-amber-400">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
Boot sanity warnings
|
{t("restoreProgress.sections.bootWarnings")}
|
||||||
</div>
|
</div>
|
||||||
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-1">
|
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-1">
|
||||||
{state.sanity_warnings.map((w) => (
|
{state.sanity_warnings.map((w) => (
|
||||||
@@ -385,19 +392,19 @@ const RestoreDetailModal: React.FC<{
|
|||||||
{state.data_pools_import && <DataPoolsBlock section={state.data_pools_import} />}
|
{state.data_pools_import && <DataPoolsBlock section={state.data_pools_import} />}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-medium">Rollback delta</div>
|
<div className="text-sm font-medium">{t("restoreProgress.sections.rollbackDelta")}</div>
|
||||||
<RollbackDelta delta={state.rollback_delta} />
|
<RollbackDelta delta={state.rollback_delta} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-medium">Log</div>
|
<div className="text-sm font-medium">{t("restoreProgress.sections.log")}</div>
|
||||||
<LogViewer path={state.log_path} historyOnly={historyMode} />
|
<LogViewer path={state.log_path} historyOnly={historyMode} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
Close
|
{t("actions.close")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -408,6 +415,7 @@ const RestoreDetailModal: React.FC<{
|
|||||||
// Rendered inside RestoreDetailModal — one row per outcome category
|
// Rendered inside RestoreDetailModal — one row per outcome category
|
||||||
// (imported / forced / partial skip / missing skip / failed).
|
// (imported / forced / partial skip / missing skip / failed).
|
||||||
const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) => {
|
const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) => {
|
||||||
|
const t = useT()
|
||||||
const total =
|
const total =
|
||||||
section.ok.length +
|
section.ok.length +
|
||||||
section.forced.length +
|
section.forced.length +
|
||||||
@@ -451,37 +459,37 @@ const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) =>
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-medium flex items-center gap-2">
|
<div className="text-sm font-medium flex items-center gap-2">
|
||||||
<Cpu className="h-4 w-4" />
|
<Cpu className="h-4 w-4" />
|
||||||
ZFS data pools — auto-import
|
{t("restoreProgress.dataPools.title")}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Row label="Imported" tone="ok" items={section.ok} />
|
<Row label={t("restoreProgress.dataPools.imported")} tone="ok" items={section.ok} />
|
||||||
<Row
|
<Row
|
||||||
label="Imported (forced, foreign hostid)"
|
label={t("restoreProgress.dataPools.importedForced")}
|
||||||
tone="info"
|
tone="info"
|
||||||
items={section.forced}
|
items={section.forced}
|
||||||
help="New hostid grabbed onto the pool label — next boot imports clean."
|
help={t("restoreProgress.dataPools.importedForcedHelp")}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label="Skipped (some disks missing)"
|
label={t("restoreProgress.dataPools.skippedPartial")}
|
||||||
tone="warn"
|
tone="warn"
|
||||||
items={section.partial}
|
items={section.partial}
|
||||||
help="Some vdev disks weren't found by /dev/disk/by-id. Pool NOT imported to avoid a degraded auto-import. Fix the disks or import manually with zpool import."
|
help={t("restoreProgress.dataPools.skippedPartialHelp")}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label="Skipped (no disks present)"
|
label={t("restoreProgress.dataPools.skippedMissing")}
|
||||||
tone="warn"
|
tone="warn"
|
||||||
items={section.missing}
|
items={section.missing}
|
||||||
help="None of the pool's disks are on this host. Move the disks over or import from a different host."
|
help={t("restoreProgress.dataPools.skippedMissingHelp")}
|
||||||
/>
|
/>
|
||||||
<Row
|
<Row
|
||||||
label="Import failed"
|
label={t("restoreProgress.dataPools.importFailed")}
|
||||||
tone="error"
|
tone="error"
|
||||||
items={section.failed}
|
items={section.failed}
|
||||||
help="ZFS rejected the import even with -f. Inspect with `zpool import` and the log below."
|
help={t("restoreProgress.dataPools.importFailedHelp")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{section.log_path && (
|
{section.log_path && (
|
||||||
<div className="text-xs text-muted-foreground font-mono">Log: {section.log_path}</div>
|
<div className="text-xs text-muted-foreground font-mono">{t("restoreProgress.dataPools.logPath", { path: section.log_path })}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -490,6 +498,7 @@ const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) =>
|
|||||||
// ── History browser modal ─────────────────────────────────────
|
// ── History browser modal ─────────────────────────────────────
|
||||||
|
|
||||||
const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||||
|
const t = useT()
|
||||||
const { data } = useSWR<{ entries: HistoryEntry[] }>(open ? "/api/host-backups/restore/history" : null, fetcher)
|
const { data } = useSWR<{ entries: HistoryEntry[] }>(open ? "/api/host-backups/restore/history" : null, fetcher)
|
||||||
const [detailFile, setDetailFile] = useState<string | null>(null)
|
const [detailFile, setDetailFile] = useState<string | null>(null)
|
||||||
const { data: detailResp } = useSWR<{ state: RestoreState }>(
|
const { data: detailResp } = useSWR<{ state: RestoreState }>(
|
||||||
@@ -504,17 +513,17 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<History className="h-5 w-5" />
|
<History className="h-5 w-5" />
|
||||||
Past restores
|
{t("restoreProgress.history.title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Restores archived by the post-boot dispatcher. The latest 20 are kept.
|
{t("restoreProgress.history.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{(data?.entries ?? []).length === 0 ? (
|
{(data?.entries ?? []).length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-6 text-center">No past restores recorded.</div>
|
<div className="text-sm text-muted-foreground py-6 text-center">{t("restoreProgress.history.empty")}</div>
|
||||||
) : (
|
) : (
|
||||||
(data?.entries ?? []).map((e) => (
|
(data?.entries ?? []).map((e) => (
|
||||||
<button
|
<button
|
||||||
@@ -538,7 +547,7 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
Close
|
{t("actions.close")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -559,6 +568,7 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
|||||||
// ── Main inline card ──────────────────────────────────────────
|
// ── Main inline card ──────────────────────────────────────────
|
||||||
|
|
||||||
export const RestoreProgressCard: React.FC = () => {
|
export const RestoreProgressCard: React.FC = () => {
|
||||||
|
const t = useT()
|
||||||
const { data, mutate } = useSWR<{ state: RestoreState | null }>(
|
const { data, mutate } = useSWR<{ state: RestoreState | null }>(
|
||||||
"/api/host-backups/restore/status",
|
"/api/host-backups/restore/status",
|
||||||
fetcher,
|
fetcher,
|
||||||
@@ -597,7 +607,7 @@ export const RestoreProgressCard: React.FC = () => {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setHistoryOpen(true)}>
|
<Button variant="ghost" size="sm" onClick={() => setHistoryOpen(true)}>
|
||||||
<History className="h-3.5 w-3.5 mr-1" />
|
<History className="h-3.5 w-3.5 mr-1" />
|
||||||
Past restores
|
{t("restoreProgress.history.title")}
|
||||||
</Button>
|
</Button>
|
||||||
<RestoreHistoryModal open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
<RestoreHistoryModal open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -625,12 +635,12 @@ export const RestoreProgressCard: React.FC = () => {
|
|||||||
<RotateCcw
|
<RotateCcw
|
||||||
className={`h-5 w-5 ${state.status === "running" ? "text-blue-500 animate-spin" : "text-blue-500"}`}
|
className={`h-5 w-5 ${state.status === "running" ? "text-blue-500 animate-spin" : "text-blue-500"}`}
|
||||||
/>
|
/>
|
||||||
Post-restore progress
|
{t("restoreProgress.title")}
|
||||||
<StatusBadge status={state.status} />
|
<StatusBadge status={state.status} />
|
||||||
{hasWarnings && (
|
{hasWarnings && (
|
||||||
<Badge variant="outline" className="text-amber-400 border-amber-500/40 bg-amber-500/10 gap-1">
|
<Badge variant="outline" className="text-amber-400 border-amber-500/40 bg-amber-500/10 gap-1">
|
||||||
<AlertTriangle className="h-3 w-3" />
|
<AlertTriangle className="h-3 w-3" />
|
||||||
{state.sanity_warnings.length} boot warning{state.sanity_warnings.length === 1 ? "" : "s"}
|
{t("restoreProgress.badges.bootWarnings", { count: state.sanity_warnings.length })}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{poolCount > 0 && (
|
{poolCount > 0 && (
|
||||||
@@ -643,22 +653,22 @@ export const RestoreProgressCard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Cpu className="h-3 w-3" />
|
<Cpu className="h-3 w-3" />
|
||||||
{poolCount} ZFS pool{poolCount === 1 ? "" : "s"}
|
{t("restoreProgress.badges.zfsPools", { count: poolCount })}
|
||||||
{poolWarnings > 0 && ` · ${poolWarnings} need attention`}
|
{poolWarnings > 0 && ` · ${t("restoreProgress.badges.needAttention", { count: poolWarnings })}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={() => setDetailOpen(true)}>
|
<Button size="sm" variant="outline" onClick={() => setDetailOpen(true)}>
|
||||||
Details
|
{t("restoreProgress.actions.details")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setHistoryOpen(true)}>
|
<Button size="sm" variant="ghost" onClick={() => setHistoryOpen(true)}>
|
||||||
<History className="h-3.5 w-3.5 mr-1" />
|
<History className="h-3.5 w-3.5 mr-1" />
|
||||||
History
|
{t("restoreProgress.actions.history")}
|
||||||
</Button>
|
</Button>
|
||||||
{state.status !== "running" && (
|
{state.status !== "running" && (
|
||||||
<Button size="sm" onClick={dismiss} disabled={dismissing}>
|
<Button size="sm" onClick={dismiss} disabled={dismissing}>
|
||||||
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Dismiss"}
|
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t("restoreProgress.actions.dismiss")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -668,11 +678,11 @@ export const RestoreProgressCard: React.FC = () => {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{state.current_step || "—"} · started {formatRelative(state.started_at)}
|
{state.current_step || "—"} · {t("restoreProgress.startedRelative", { time: formatRelative(state.started_at, t) })}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{state.steps_done}/{state.steps_total} steps
|
{t("restoreProgress.steps", { done: state.steps_done, total: state.steps_total })}
|
||||||
{state.status === "running" && ` · ${computeEta(state)}`}
|
{state.status === "running" && ` · ${computeEta(state, t)}`}
|
||||||
{state.summary?.duration && state.status !== "running" && ` · ${state.summary.duration}`}
|
{state.summary?.duration && state.status !== "running" && ` · ${state.summary.duration}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -684,19 +694,19 @@ export const RestoreProgressCard: React.FC = () => {
|
|||||||
{state.summary && (
|
{state.summary && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
|
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
|
||||||
<div className="text-muted-foreground">Guests</div>
|
<div className="text-muted-foreground">{t("restoreProgress.summary.guests")}</div>
|
||||||
<div className="font-medium">{state.summary.guests}</div>
|
<div className="font-medium">{state.summary.guests}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
|
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
|
||||||
<div className="text-muted-foreground">Bind-mount stubs</div>
|
<div className="text-muted-foreground">{t("restoreProgress.summary.bindMountStubs")}</div>
|
||||||
<div className="font-medium">{state.summary.stubs}</div>
|
<div className="font-medium">{state.summary.stubs}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
|
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
|
||||||
<div className="text-muted-foreground">Stale nodes cleaned</div>
|
<div className="text-muted-foreground">{t("restoreProgress.summary.staleNodesCleaned")}</div>
|
||||||
<div className="font-medium">{state.summary.stale_nodes}</div>
|
<div className="font-medium">{state.summary.stale_nodes}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
|
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
|
||||||
<div className="text-muted-foreground">Components</div>
|
<div className="text-muted-foreground">{t("restoreProgress.summary.components")}</div>
|
||||||
<div className="font-medium">{state.summary.components}</div>
|
<div className="font-medium">{state.summary.components}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
import "xterm/css/xterm.css"
|
import "xterm/css/xterm.css"
|
||||||
import { API_PORT } from "@/lib/api-config"
|
import { API_PORT } from "@/lib/api-config"
|
||||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface WebInteraction {
|
interface WebInteraction {
|
||||||
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
|
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
|
||||||
@@ -49,12 +50,14 @@ interface ScriptTerminalModalProps {
|
|||||||
description: string
|
description: string
|
||||||
scriptName?: string
|
scriptName?: string
|
||||||
params?: Record<string, string>
|
params?: Record<string, string>
|
||||||
|
completedSuccessfullyMessage?: string
|
||||||
|
completedWithErrorMessage?: (exitCode: number) => string
|
||||||
// Optional callback fired when the script's WebSocket closes
|
// Optional callback fired when the script's WebSocket closes
|
||||||
// (script_runner sends an exit code and then closes). Lets the
|
// (script_runner sends an exit code and then closes). Lets the
|
||||||
// parent auto-dismiss the modal — used by host-backup's Restore
|
// parent auto-dismiss the modal — used by host-backup's Restore
|
||||||
// flow so "Press Enter to close" in the bash script actually
|
// flow so "Press Enter to close" in the bash script actually
|
||||||
// closes the modal without an extra click. Other callers ignore.
|
// closes the modal without an extra click. Other callers ignore.
|
||||||
onComplete?: () => void
|
onComplete?: (exitCode?: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScriptTerminalModal({
|
export function ScriptTerminalModal({
|
||||||
@@ -64,8 +67,11 @@ export function ScriptTerminalModal({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
params = { EXECUTION_MODE: "web" },
|
params = { EXECUTION_MODE: "web" },
|
||||||
|
completedSuccessfullyMessage,
|
||||||
|
completedWithErrorMessage,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: ScriptTerminalModalProps) {
|
}: ScriptTerminalModalProps) {
|
||||||
|
const t = useT()
|
||||||
const termRef = useRef<any>(null)
|
const termRef = useRef<any>(null)
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
// Mirrors `isOpen` for use inside async closures (initializeTerminal)
|
// Mirrors `isOpen` for use inside async closures (initializeTerminal)
|
||||||
@@ -83,6 +89,7 @@ export function ScriptTerminalModal({
|
|||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
const reconnectAttemptsRef = useRef(0)
|
const reconnectAttemptsRef = useRef(0)
|
||||||
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const completionReceivedRef = useRef(false)
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
const [isTablet, setIsTablet] = useState(false)
|
const [isTablet, setIsTablet] = useState(false)
|
||||||
|
|
||||||
@@ -94,6 +101,14 @@ export function ScriptTerminalModal({
|
|||||||
const resizeBarRef = useRef<HTMLDivElement>(null)
|
const resizeBarRef = useRef<HTMLDivElement>(null)
|
||||||
const modalHeightRef = useRef(600)
|
const modalHeightRef = useRef(600)
|
||||||
|
|
||||||
|
const getCompletionMessage = useCallback(
|
||||||
|
(exitCode: number) =>
|
||||||
|
exitCode === 0
|
||||||
|
? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully"))
|
||||||
|
: (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })),
|
||||||
|
[completedSuccessfullyMessage, completedWithErrorMessage, t],
|
||||||
|
)
|
||||||
|
|
||||||
const terminalContainerRef = useRef<HTMLDivElement>(null)
|
const terminalContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const paramsRef = useRef(params)
|
const paramsRef = useRef(params)
|
||||||
|
|
||||||
@@ -104,7 +119,7 @@ export function ScriptTerminalModal({
|
|||||||
|
|
||||||
// Same trick for onComplete — we want the latest callback inside
|
// Same trick for onComplete — we want the latest callback inside
|
||||||
// the ws.onclose handler without re-running the connection effect.
|
// the ws.onclose handler without re-running the connection effect.
|
||||||
const onCompleteRef = useRef<(() => void) | undefined>(undefined)
|
const onCompleteRef = useRef<((exitCode?: number) => void) | undefined>(undefined)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onCompleteRef.current = onComplete
|
onCompleteRef.current = onComplete
|
||||||
}, [onComplete])
|
}, [onComplete])
|
||||||
@@ -166,6 +181,23 @@ const initMessage = {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The PTY worker always emits this final line. Treat it as a
|
||||||
|
// completion fallback because some WebSocket servers tear down the
|
||||||
|
// connection before the following structured message is flushed.
|
||||||
|
const exitMatch = typeof event.data === "string"
|
||||||
|
? event.data.match(/\[Script exited with code (-?\d+)\]/)
|
||||||
|
: null
|
||||||
|
if (exitMatch) {
|
||||||
|
const exitCode = Number(exitMatch[1])
|
||||||
|
termRef.current?.write(event.data)
|
||||||
|
completionReceivedRef.current = true
|
||||||
|
setIsComplete(true)
|
||||||
|
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||||
|
onCompleteRef.current?.(exitCode)
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
if (msg.type === "web_interaction" && msg.interaction) {
|
if (msg.type === "web_interaction" && msg.interaction) {
|
||||||
@@ -187,6 +219,15 @@ const initMessage = {
|
|||||||
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (msg.type === "script_complete") {
|
||||||
|
const exitCode = Number(msg.exit_code ?? 1)
|
||||||
|
completionReceivedRef.current = true
|
||||||
|
setIsComplete(true)
|
||||||
|
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||||
|
onCompleteRef.current?.(exitCode)
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||||
|
return
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
termRef.current?.write(event.data)
|
termRef.current?.write(event.data)
|
||||||
setIsWaitingNextInteraction(false)
|
setIsWaitingNextInteraction(false)
|
||||||
@@ -197,6 +238,9 @@ const initMessage = {
|
|||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
setConnectionStatus("offline")
|
setConnectionStatus("offline")
|
||||||
|
if (!completionReceivedRef.current) {
|
||||||
|
termRef.current?.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
@@ -205,16 +249,19 @@ const initMessage = {
|
|||||||
clearInterval(keepAliveIntervalRef.current)
|
clearInterval(keepAliveIntervalRef.current)
|
||||||
keepAliveIntervalRef.current = null
|
keepAliveIntervalRef.current = null
|
||||||
}
|
}
|
||||||
|
if (completionReceivedRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!isComplete && reconnectAttemptsRef.current < 3) {
|
if (!isComplete && reconnectAttemptsRef.current < 3) {
|
||||||
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
|
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
|
||||||
} else {
|
} else {
|
||||||
setIsComplete(true)
|
setIsComplete(true)
|
||||||
onCompleteRef.current?.()
|
onCompleteRef.current?.(-1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}, [isOpen, isComplete, scriptPath])
|
}, [isOpen, isComplete, scriptPath, getCompletionMessage, t])
|
||||||
|
|
||||||
const sendKey = useCallback((key: string) => {
|
const sendKey = useCallback((key: string) => {
|
||||||
if (!termRef.current) return
|
if (!termRef.current) return
|
||||||
@@ -351,6 +398,23 @@ const initMessage = {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// See the reconnect handler above. The exit line is guaranteed to be
|
||||||
|
// sent with the PTY output and is therefore a robust fallback when a
|
||||||
|
// final JSON frame is lost during server-side socket teardown.
|
||||||
|
const exitMatch = typeof event.data === "string"
|
||||||
|
? event.data.match(/\[Script exited with code (-?\d+)\]/)
|
||||||
|
: null
|
||||||
|
if (exitMatch) {
|
||||||
|
const exitCode = Number(exitMatch[1])
|
||||||
|
term.write(event.data)
|
||||||
|
completionReceivedRef.current = true
|
||||||
|
setIsComplete(true)
|
||||||
|
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||||
|
onCompleteRef.current?.(exitCode)
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data)
|
const msg = JSON.parse(event.data)
|
||||||
|
|
||||||
@@ -374,6 +438,15 @@ const initMessage = {
|
|||||||
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (msg.type === "script_complete") {
|
||||||
|
const exitCode = Number(msg.exit_code ?? 1)
|
||||||
|
completionReceivedRef.current = true
|
||||||
|
setIsComplete(true)
|
||||||
|
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||||
|
onCompleteRef.current?.(exitCode)
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||||
|
return
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Not JSON, es output normal de terminal
|
// Not JSON, es output normal de terminal
|
||||||
}
|
}
|
||||||
@@ -388,21 +461,25 @@ const initMessage = {
|
|||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
setConnectionStatus("offline")
|
setConnectionStatus("offline")
|
||||||
term.writeln("\x1b[31mWebSocket error occurred\x1b[0m")
|
if (!completionReceivedRef.current) {
|
||||||
|
term.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
setConnectionStatus("offline")
|
setConnectionStatus("offline")
|
||||||
term.writeln("\x1b[33mConnection closed\x1b[0m")
|
if (!completionReceivedRef.current) {
|
||||||
|
term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`)
|
||||||
|
}
|
||||||
|
|
||||||
if (keepAliveIntervalRef.current) {
|
if (keepAliveIntervalRef.current) {
|
||||||
clearInterval(keepAliveIntervalRef.current)
|
clearInterval(keepAliveIntervalRef.current)
|
||||||
keepAliveIntervalRef.current = null
|
keepAliveIntervalRef.current = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isComplete) {
|
if (!completionReceivedRef.current && !isComplete) {
|
||||||
setIsComplete(true)
|
setIsComplete(true)
|
||||||
onCompleteRef.current?.()
|
onCompleteRef.current?.(-1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +566,7 @@ const initMessage = {
|
|||||||
|
|
||||||
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
|
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
|
||||||
reconnectAttemptsRef.current = 0
|
reconnectAttemptsRef.current = 0
|
||||||
|
completionReceivedRef.current = false
|
||||||
setIsComplete(false)
|
setIsComplete(false)
|
||||||
setInteractionInput("")
|
setInteractionInput("")
|
||||||
setCurrentInteraction(null)
|
setCurrentInteraction(null)
|
||||||
@@ -712,7 +790,7 @@ const initMessage = {
|
|||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
<p className="text-sm text-muted-foreground">Processing...</p>
|
<p className="text-sm text-muted-foreground">{t("scriptTerminal.processing")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -835,29 +913,29 @@ const initMessage = {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onSelect={() => sendCommand("\x03")}>
|
<DropdownMenuItem onSelect={() => sendCommand("\x03")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||||
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => sendCommand("\x18")}>
|
<DropdownMenuItem onSelect={() => sendCommand("\x18")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||||
<span className="text-muted-foreground text-xs">Exit (nano)</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => sendCommand("\x12")}>
|
<DropdownMenuItem onSelect={() => sendCommand("\x12")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
||||||
<span className="text-muted-foreground text-xs">Search history</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
||||||
<Copy className="h-3.5 w-3.5 mr-2" />
|
<Copy className="h-3.5 w-3.5 mr-2" />
|
||||||
<span className="text-xs">Copy selection</span>
|
<span className="text-xs">{t("scriptTerminal.copySelection")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
||||||
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
||||||
<span className="text-xs">Paste</span>
|
<span className="text-xs">{t("scriptTerminal.paste")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -877,18 +955,18 @@ const initMessage = {
|
|||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
connectionStatus === "online"
|
connectionStatus === "online"
|
||||||
? "Connected"
|
? t("scriptTerminal.connected")
|
||||||
: connectionStatus === "connecting"
|
: connectionStatus === "connecting"
|
||||||
? "Connecting"
|
? t("scriptTerminal.connecting")
|
||||||
: "Disconnected"
|
: t("scriptTerminal.disconnected")
|
||||||
}
|
}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{connectionStatus === "online"
|
{connectionStatus === "online"
|
||||||
? "Online"
|
? t("scriptTerminal.online")
|
||||||
: connectionStatus === "connecting"
|
: connectionStatus === "connecting"
|
||||||
? "Connecting..."
|
? t("scriptTerminal.connectingStatus")
|
||||||
: "Offline"}
|
: t("scriptTerminal.offline")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -897,7 +975,7 @@ const initMessage = {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||||
>
|
>
|
||||||
Close
|
{t("actions.close")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -933,14 +1011,14 @@ const initMessage = {
|
|||||||
onClick={() => handleInteractionResponse("yes")}
|
onClick={() => handleInteractionResponse("yes")}
|
||||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white transition-all duration-150"
|
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white transition-all duration-150"
|
||||||
>
|
>
|
||||||
Yes
|
{t("scriptTerminal.yes")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleInteractionResponse("cancel")}
|
onClick={() => handleInteractionResponse("cancel")}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -963,14 +1041,14 @@ const initMessage = {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
className="w-full hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && (
|
{(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Your input:</Label>
|
<Label>{t("scriptTerminal.yourInput")}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={interactionInput}
|
value={interactionInput}
|
||||||
onChange={(e) => setInteractionInput(e.target.value)}
|
onChange={(e) => setInteractionInput(e.target.value)}
|
||||||
@@ -987,14 +1065,14 @@ const initMessage = {
|
|||||||
onClick={() => handleInteractionResponse(interactionInput)}
|
onClick={() => handleInteractionResponse(interactionInput)}
|
||||||
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
||||||
>
|
>
|
||||||
Submit
|
{t("scriptTerminal.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleInteractionResponse("cancel")}
|
onClick={() => handleInteractionResponse("cancel")}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1006,14 +1084,14 @@ const initMessage = {
|
|||||||
onClick={() => handleInteractionResponse("ok")}
|
onClick={() => handleInteractionResponse("ok")}
|
||||||
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
||||||
>
|
>
|
||||||
OK
|
{t("scriptTerminal.ok")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleInteractionResponse("cancel")}
|
onClick={() => handleInteractionResponse("cancel")}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
ArrowUpCircle,
|
ArrowUpCircle,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface NetworkInfo {
|
interface NetworkInfo {
|
||||||
interface: string
|
interface: string
|
||||||
@@ -77,6 +78,20 @@ interface WizardStep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SecureGatewaySetup() {
|
export function SecureGatewaySetup() {
|
||||||
|
const t = useT()
|
||||||
|
const sg = (key: string, params?: Record<string, string | number>) => t(`securityPage.secureGateway.${key}`, params)
|
||||||
|
const maybeSg = (key: string, fallback?: string) => {
|
||||||
|
const fullKey = `securityPage.secureGateway.${key}`
|
||||||
|
const value = t(fullKey)
|
||||||
|
return value === fullKey ? fallback || "" : value
|
||||||
|
}
|
||||||
|
const fieldText = (fieldName: string, part: string, fallback?: string) =>
|
||||||
|
maybeSg(`schema.${fieldName}.${part}`, fallback)
|
||||||
|
const optionText = (fieldName: string, value: string, part: string, fallback?: string) =>
|
||||||
|
maybeSg(`schema.${fieldName}.options.${value}.${part}`, fallback)
|
||||||
|
const stepText = (step: WizardStep, part: "title" | "description") =>
|
||||||
|
maybeSg(`steps.${step.id}.${part}`, step[part])
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [runtimeAvailable, setRuntimeAvailable] = useState(false)
|
const [runtimeAvailable, setRuntimeAvailable] = useState(false)
|
||||||
@@ -207,7 +222,7 @@ export function SecureGatewaySetup() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load data:", err)
|
console.error("Failed to load data:", err)
|
||||||
setLoadError(err instanceof Error ? err.message : "Failed to load wizard data")
|
setLoadError(err instanceof Error ? err.message : sg("errors.loadWizardFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -265,7 +280,7 @@ export function SecureGatewaySetup() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
if (res?.success) {
|
if (res?.success) {
|
||||||
setUpdateResultMsg(res.message || "Update applied")
|
setUpdateResultMsg(res.message || sg("messages.updateApplied"))
|
||||||
// Re-probe with force=true so the panel flips back to "No
|
// Re-probe with force=true so the panel flips back to "No
|
||||||
// updates available" immediately, bypassing the 24h server
|
// updates available" immediately, bypassing the 24h server
|
||||||
// cache which may still hold the pre-apply "available" entry.
|
// cache which may still hold the pre-apply "available" entry.
|
||||||
@@ -274,10 +289,10 @@ export function SecureGatewaySetup() {
|
|||||||
// refresh that too so the action buttons render the right state.
|
// refresh that too so the action buttons render the right state.
|
||||||
await loadStatus()
|
await loadStatus()
|
||||||
} else {
|
} else {
|
||||||
setUpdateError(res?.message || "Update failed")
|
setUpdateError(res?.message || sg("errors.updateFailed"))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setUpdateError(err instanceof Error ? err.message : "Network error during update")
|
setUpdateError(err instanceof Error ? err.message : sg("errors.networkUpdateFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setUpdateApplying(false)
|
setUpdateApplying(false)
|
||||||
}
|
}
|
||||||
@@ -293,7 +308,7 @@ export function SecureGatewaySetup() {
|
|||||||
if (deploying) return
|
if (deploying) return
|
||||||
setDeploying(true)
|
setDeploying(true)
|
||||||
setDeployError("")
|
setDeployError("")
|
||||||
setDeployProgress("Preparing deployment...")
|
setDeployProgress(sg("messages.preparingDeployment"))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
@@ -302,7 +317,7 @@ export function SecureGatewaySetup() {
|
|||||||
for (const fieldName of step.fields) {
|
for (const fieldName of step.fields) {
|
||||||
const field = configSchema?.[fieldName]
|
const field = configSchema?.[fieldName]
|
||||||
if (field?.required && !config[fieldName]) {
|
if (field?.required && !config[fieldName]) {
|
||||||
setDeployError(`${field.label} is required`)
|
setDeployError(sg("errors.fieldRequired", { field: fieldText(fieldName, "label", field.label) }))
|
||||||
setDeploying(false)
|
setDeploying(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -326,7 +341,7 @@ export function SecureGatewaySetup() {
|
|||||||
}
|
}
|
||||||
// For "custom", the user has already selected networks manually
|
// For "custom", the user has already selected networks manually
|
||||||
|
|
||||||
setDeployProgress("Creating LXC container...")
|
setDeployProgress(sg("messages.creatingLxc"))
|
||||||
|
|
||||||
const result = await fetchApi("/api/oci/deploy", {
|
const result = await fetchApi("/api/oci/deploy", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -338,16 +353,16 @@ export function SecureGatewaySetup() {
|
|||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
// Make runtime errors more user-friendly
|
// Make runtime errors more user-friendly
|
||||||
let errorMsg = result.message || "Deployment failed"
|
let errorMsg = result.message || sg("errors.deploymentFailed")
|
||||||
if (errorMsg.includes("9.1") || errorMsg.includes("OCI") || errorMsg.includes("not supported")) {
|
if (errorMsg.includes("9.1") || errorMsg.includes("OCI") || errorMsg.includes("not supported")) {
|
||||||
errorMsg = "OCI containers require Proxmox VE 9.1 or later. Please upgrade your Proxmox installation to use this feature."
|
errorMsg = sg("errors.ociRequiresPve")
|
||||||
}
|
}
|
||||||
setDeployError(errorMsg)
|
setDeployError(errorMsg)
|
||||||
setDeploying(false)
|
setDeploying(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeployProgress("Gateway deployed successfully!")
|
setDeployProgress(sg("messages.gatewayDeployed"))
|
||||||
|
|
||||||
// Wipe the Tailscale auth_key from React state so it's no longer
|
// Wipe the Tailscale auth_key from React state so it's no longer
|
||||||
// reachable from a future XSS / state-inspection. The key only needs
|
// reachable from a future XSS / state-inspection. The key only needs
|
||||||
@@ -376,7 +391,7 @@ export function SecureGatewaySetup() {
|
|||||||
}, 2000)
|
}, 2000)
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setDeployError(err.message || "Deployment failed")
|
setDeployError(err.message || sg("errors.deploymentFailed"))
|
||||||
setDeploying(false)
|
setDeploying(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,7 +415,7 @@ export function SecureGatewaySetup() {
|
|||||||
|
|
||||||
const handleUpdateAuthKey = async () => {
|
const handleUpdateAuthKey = async () => {
|
||||||
if (!newAuthKey.trim()) {
|
if (!newAuthKey.trim()) {
|
||||||
setUpdateAuthKeyError("Auth Key is required")
|
setUpdateAuthKeyError(sg("errors.authKeyRequired"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +432,7 @@ export function SecureGatewaySetup() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setUpdateAuthKeyError(result.message || "Failed to update auth key")
|
setUpdateAuthKeyError(result.message || sg("errors.updateAuthKeyFailed"))
|
||||||
setUpdateAuthKeyLoading(false)
|
setUpdateAuthKeyLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -427,7 +442,7 @@ export function SecureGatewaySetup() {
|
|||||||
setNewAuthKey("")
|
setNewAuthKey("")
|
||||||
await loadStatus()
|
await loadStatus()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setUpdateAuthKeyError(err.message || "Failed to update auth key")
|
setUpdateAuthKeyError(err.message || sg("errors.updateAuthKeyFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setUpdateAuthKeyLoading(false)
|
setUpdateAuthKeyLoading(false)
|
||||||
}
|
}
|
||||||
@@ -456,10 +471,10 @@ export function SecureGatewaySetup() {
|
|||||||
try {
|
try {
|
||||||
const result = await fetchApi("/api/oci/installed/secure-gateway/logs?lines=100")
|
const result = await fetchApi("/api/oci/installed/secure-gateway/logs?lines=100")
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setLogs(result.logs || "No logs available")
|
setLogs(result.logs || sg("logs.empty"))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLogs("Failed to load logs")
|
setLogs(sg("logs.failed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLogsLoading(false)
|
setLogsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -476,16 +491,16 @@ export function SecureGatewaySetup() {
|
|||||||
// date-only string. Used in the Updates panel — the user wants to know
|
// date-only string. Used in the Updates panel — the user wants to know
|
||||||
// "how stale is this number" without seeing the raw 2026-05-09T10:23Z.
|
// "how stale is this number" without seeing the raw 2026-05-09T10:23Z.
|
||||||
const formatLastChecked = (iso?: string): string => {
|
const formatLastChecked = (iso?: string): string => {
|
||||||
if (!iso) return "never"
|
if (!iso) return sg("values.never")
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
if (isNaN(d.getTime())) return "unknown"
|
if (isNaN(d.getTime())) return t("common.unknown")
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const ageMs = now - d.getTime()
|
const ageMs = now - d.getTime()
|
||||||
const sameDay = new Date(now).toDateString() === d.toDateString()
|
const sameDay = new Date(now).toDateString() === d.toDateString()
|
||||||
const yesterday = new Date(now - 86_400_000).toDateString() === d.toDateString()
|
const yesterday = new Date(now - 86_400_000).toDateString() === d.toDateString()
|
||||||
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
if (sameDay) return time
|
if (sameDay) return time
|
||||||
if (yesterday) return `yesterday ${time}`
|
if (yesterday) return sg("values.yesterdayAt", { time })
|
||||||
if (ageMs < 7 * 86_400_000) {
|
if (ageMs < 7 * 86_400_000) {
|
||||||
return d.toLocaleDateString([], { weekday: "short" }) + " " + time
|
return d.toLocaleDateString([], { weekday: "short" }) + " " + time
|
||||||
}
|
}
|
||||||
@@ -495,6 +510,11 @@ export function SecureGatewaySetup() {
|
|||||||
const renderField = (fieldName: string) => {
|
const renderField = (fieldName: string) => {
|
||||||
const field = configSchema?.[fieldName]
|
const field = configSchema?.[fieldName]
|
||||||
if (!field) return null
|
if (!field) return null
|
||||||
|
const translatedLabel = fieldText(fieldName, "label", field.label)
|
||||||
|
const translatedDescription = fieldText(fieldName, "description", field.description)
|
||||||
|
const translatedPlaceholder = fieldText(fieldName, "placeholder", field.placeholder)
|
||||||
|
const translatedWarning = fieldText(fieldName, "warning", field.warning)
|
||||||
|
const translatedHelpText = fieldText(fieldName, "helpText", field.help_text)
|
||||||
|
|
||||||
// Check depends_on
|
// Check depends_on
|
||||||
if (field.depends_on) {
|
if (field.depends_on) {
|
||||||
@@ -511,7 +531,7 @@ export function SecureGatewaySetup() {
|
|||||||
return (
|
return (
|
||||||
<div key={fieldName} className="space-y-2">
|
<div key={fieldName} className="space-y-2">
|
||||||
<Label htmlFor={fieldName} className="text-sm font-medium">
|
<Label htmlFor={fieldName} className="text-sm font-medium">
|
||||||
{field.label}
|
{translatedLabel}
|
||||||
{field.required && <span className="text-red-500 ml-1">*</span>}
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -520,7 +540,7 @@ export function SecureGatewaySetup() {
|
|||||||
type={isVisible ? "text" : "password"}
|
type={isVisible ? "text" : "password"}
|
||||||
value={config[fieldName] || ""}
|
value={config[fieldName] || ""}
|
||||||
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
|
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
|
||||||
placeholder={field.placeholder}
|
placeholder={translatedPlaceholder}
|
||||||
className="pr-10 bg-background border-border"
|
className="pr-10 bg-background border-border"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -536,7 +556,7 @@ export function SecureGatewaySetup() {
|
|||||||
{isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
|
||||||
{field.help_url && (
|
{field.help_url && (
|
||||||
<a
|
<a
|
||||||
href={field.help_url}
|
href={field.help_url}
|
||||||
@@ -544,7 +564,7 @@ export function SecureGatewaySetup() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1"
|
className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{field.help_text || "Learn more"} <ExternalLink className="h-3 w-3" />
|
{translatedHelpText || sg("learnMore")} <ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -554,7 +574,7 @@ export function SecureGatewaySetup() {
|
|||||||
return (
|
return (
|
||||||
<div key={fieldName} className="space-y-2">
|
<div key={fieldName} className="space-y-2">
|
||||||
<Label htmlFor={fieldName} className="text-sm font-medium">
|
<Label htmlFor={fieldName} className="text-sm font-medium">
|
||||||
{field.label}
|
{translatedLabel}
|
||||||
{field.required && <span className="text-red-500 ml-1">*</span>}
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -562,10 +582,10 @@ export function SecureGatewaySetup() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={config[fieldName] || ""}
|
value={config[fieldName] || ""}
|
||||||
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
|
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
|
||||||
placeholder={field.placeholder}
|
placeholder={translatedPlaceholder}
|
||||||
className="bg-background border-border"
|
className="bg-background border-border"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -596,7 +616,7 @@ export function SecureGatewaySetup() {
|
|||||||
return (
|
return (
|
||||||
<div key={fieldName} className="space-y-3">
|
<div key={fieldName} className="space-y-3">
|
||||||
<Label className="text-sm font-medium">
|
<Label className="text-sm font-medium">
|
||||||
{field.label}
|
{translatedLabel}
|
||||||
{field.required && <span className="text-red-500 ml-1">*</span>}
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -619,15 +639,15 @@ export function SecureGatewaySetup() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium text-sm">{opt.label}</p>
|
<p className="font-medium text-sm">{optionText(fieldName, opt.value, "label", opt.label)}</p>
|
||||||
{opt.description && (
|
{opt.description && (
|
||||||
<p className="text-xs text-muted-foreground">{opt.description}</p>
|
<p className="text-xs text-muted-foreground">{optionText(fieldName, opt.value, "description", opt.description)}</p>
|
||||||
)}
|
)}
|
||||||
{/* Show selected network for proxmox_network */}
|
{/* Show selected network for proxmox_network */}
|
||||||
{fieldName === "access_mode" && opt.value === "proxmox_network" && config[fieldName] === "proxmox_network" && (
|
{fieldName === "access_mode" && opt.value === "proxmox_network" && config[fieldName] === "proxmox_network" && (
|
||||||
<p className="text-xs text-cyan-400 mt-1 flex items-center gap-1">
|
<p className="text-xs text-cyan-400 mt-1 flex items-center gap-1">
|
||||||
<Network className="h-3 w-3" />
|
<Network className="h-3 w-3" />
|
||||||
{networks.find((n) => n.recommended)?.subnet || networks[0]?.subnet || "No network detected"}
|
{networks.find((n) => n.recommended)?.subnet || networks[0]?.subnet || sg("noNetworkDetected")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -642,13 +662,13 @@ export function SecureGatewaySetup() {
|
|||||||
return (
|
return (
|
||||||
<div key={fieldName} className="space-y-3">
|
<div key={fieldName} className="space-y-3">
|
||||||
<Label className="text-sm font-medium">
|
<Label className="text-sm font-medium">
|
||||||
{field.label}
|
{translatedLabel}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
|
||||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||||
{networks.length === 0 ? (
|
{networks.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground p-3 bg-muted/30 rounded">
|
<p className="text-sm text-muted-foreground p-3 bg-muted/30 rounded">
|
||||||
No networks detected
|
{sg("noNetworksDetected")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
networks.map((net) => {
|
networks.map((net) => {
|
||||||
@@ -676,7 +696,7 @@ export function SecureGatewaySetup() {
|
|||||||
<span className="font-mono text-sm">{net.subnet}</span>
|
<span className="font-mono text-sm">{net.subnet}</span>
|
||||||
{net.recommended && (
|
{net.recommended && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
|
||||||
Recommended
|
{sg("recommended")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -705,12 +725,12 @@ export function SecureGatewaySetup() {
|
|||||||
>
|
>
|
||||||
<Checkbox checked={config[fieldName] || false} className="pointer-events-none mt-0.5" />
|
<Checkbox checked={config[fieldName] || false} className="pointer-events-none mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">{field.label}</p>
|
<p className="font-medium text-sm">{translatedLabel}</p>
|
||||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
|
||||||
{field.warning && config[fieldName] && (
|
{field.warning && config[fieldName] && (
|
||||||
<p className="text-xs text-cyan-400 mt-2 flex items-start gap-1.5 bg-cyan-500/10 p-2 rounded">
|
<p className="text-xs text-cyan-400 mt-2 flex items-start gap-1.5 bg-cyan-500/10 p-2 rounded">
|
||||||
<Info className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
<Info className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||||
{field.warning}
|
{translatedWarning}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -736,40 +756,40 @@ export function SecureGatewaySetup() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<h3 className="text-lg font-semibold">Secure Remote Access</h3>
|
<h3 className="text-lg font-semibold">{sg("wizard.introTitle")}</h3>
|
||||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||||
Deploy a VPN gateway using Tailscale for secure, zero-trust access to your Proxmox infrastructure without opening ports.
|
{sg("wizard.introDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||||
<h4 className="text-sm font-medium">What you{"'"}ll get:</h4>
|
<h4 className="text-sm font-medium">{sg("wizard.whatYouGet")}</h4>
|
||||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
Access ProxMenux Monitor from anywhere
|
{sg("wizard.benefitMonitorAnywhere")}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
Secure access to Proxmox web UI
|
{sg("wizard.benefitProxmoxUi")}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
Optionally expose VMs and LXC containers
|
{sg("wizard.benefitVmLxc")}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
End-to-end encryption
|
{sg("wizard.benefitEncryption")}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
No port forwarding required
|
{sg("wizard.benefitNoPorts")}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-3">
|
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-3">
|
||||||
<p className="text-xs text-cyan-400 flex items-start gap-2">
|
<p className="text-xs text-cyan-400 flex items-start gap-2">
|
||||||
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||||
You{"'"}ll need a free Tailscale account. If you don{"'"}t have one, you can create it at{" "}
|
{sg("wizard.tailscaleAccountBefore")}{" "}
|
||||||
<a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="underline hover:text-cyan-300">
|
<a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="underline hover:text-cyan-300">
|
||||||
tailscale.com
|
tailscale.com
|
||||||
</a>
|
</a>
|
||||||
@@ -783,17 +803,17 @@ export function SecureGatewaySetup() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<h3 className="text-lg font-semibold">Review & Deploy</h3>
|
<h3 className="text-lg font-semibold">{sg("wizard.reviewDeploy")}</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Review your configuration before deploying the gateway.
|
{sg("wizard.reviewDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Storage selector */}
|
{/* Storage selector */}
|
||||||
{storages.length > 1 && (
|
{storages.length > 1 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-sm font-medium">Storage Location</Label>
|
<Label className="text-sm font-medium">{sg("wizard.storageLocation")}</Label>
|
||||||
<p className="text-xs text-muted-foreground">Select where to create the container disk.</p>
|
<p className="text-xs text-muted-foreground">{sg("wizard.storageDescription")}</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{storages.filter(s => s.active && s.enabled).map((storage) => (
|
{storages.filter(s => s.active && s.enabled).map((storage) => (
|
||||||
<div
|
<div
|
||||||
@@ -819,12 +839,12 @@ export function SecureGatewaySetup() {
|
|||||||
<span className="text-xs text-muted-foreground">({storage.type})</span>
|
<span className="text-xs text-muted-foreground">({storage.type})</span>
|
||||||
{storage.recommended && (
|
{storage.recommended && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
|
||||||
Recommended
|
{sg("recommended")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{(storage.avail / 1024 / 1024 / 1024).toFixed(1)} GB available
|
{sg("wizard.gbAvailable", { amount: (storage.avail / 1024 / 1024 / 1024).toFixed(1) })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -835,41 +855,41 @@ export function SecureGatewaySetup() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||||
<h4 className="text-sm font-medium">Configuration Summary</h4>
|
<h4 className="text-sm font-medium">{sg("wizard.configurationSummary")}</h4>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Hostname:</span>
|
<span className="text-muted-foreground">{sg("wizard.hostname")}:</span>
|
||||||
<span className="font-mono">{config.hostname || "proxmox-gateway"}</span>
|
<span className="font-mono">{config.hostname || "proxmox-gateway"}</span>
|
||||||
</div>
|
</div>
|
||||||
{storages.length > 1 && (
|
{storages.length > 1 && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Storage:</span>
|
<span className="text-muted-foreground">{sg("wizard.storage")}:</span>
|
||||||
<span className="font-mono">{config.storage || storages[0]?.name}</span>
|
<span className="font-mono">{config.storage || storages[0]?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Access Mode:</span>
|
<span className="text-muted-foreground">{sg("wizard.accessMode")}:</span>
|
||||||
<span>{config.access_mode === "host_only" ? "Host Only" : config.access_mode === "proxmox_network" ? "Proxmox Network" : "Custom Networks"}</span>
|
<span>{config.access_mode === "host_only" ? sg("wizard.accessModes.hostOnly") : config.access_mode === "proxmox_network" ? sg("wizard.accessModes.proxmoxNetwork") : sg("wizard.accessModes.customNetworks")}</span>
|
||||||
</div>
|
</div>
|
||||||
{config.access_mode === "host_only" && hostIp && (
|
{config.access_mode === "host_only" && hostIp && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Host Access:</span>
|
<span className="text-muted-foreground">{sg("wizard.hostAccess")}:</span>
|
||||||
<span className="text-right font-mono text-xs">{hostIp}/32</span>
|
<span className="text-right font-mono text-xs">{hostIp}/32</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(config.access_mode === "proxmox_network" || config.access_mode === "custom") && config.advertise_routes?.length > 0 && (
|
{(config.access_mode === "proxmox_network" || config.access_mode === "custom") && config.advertise_routes?.length > 0 && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Networks:</span>
|
<span className="text-muted-foreground">{sg("wizard.networks")}:</span>
|
||||||
<span className="text-right font-mono text-xs">{config.advertise_routes.join(", ")}</span>
|
<span className="text-right font-mono text-xs">{config.advertise_routes.join(", ")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Exit Node:</span>
|
<span className="text-muted-foreground">{sg("wizard.exitNode")}:</span>
|
||||||
<span>{config.exit_node ? "Yes" : "No"}</span>
|
<span>{config.exit_node ? sg("values.yes") : sg("values.no")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Accept Routes:</span>
|
<span className="text-muted-foreground">{sg("wizard.acceptRoutes")}:</span>
|
||||||
<span>{config.accept_routes ? "Yes" : "No"}</span>
|
<span>{config.accept_routes ? sg("values.yes") : sg("values.no")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -880,12 +900,12 @@ export function SecureGatewaySetup() {
|
|||||||
<p className="text-xs text-cyan-400 flex items-start gap-2">
|
<p className="text-xs text-cyan-400 flex items-start gap-2">
|
||||||
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||||
<span>
|
<span>
|
||||||
<strong>Important:</strong> After deployment, you must approve the subnet route in Tailscale Admin for remote access to work.
|
<strong>{sg("wizard.important")}:</strong> {sg("wizard.approvalRequired")}
|
||||||
{config.exit_node && <span> You{"'"}ll also need to approve the exit node.</span>}
|
{config.exit_node && <span> {sg("wizard.exitNodeApprovalRequired")}</span>}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground ml-6">
|
<p className="text-xs text-muted-foreground ml-6">
|
||||||
We{"'"}ll show you exactly what to do after the gateway is deployed.
|
{sg("wizard.showAfterDeploy")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -915,8 +935,8 @@ export function SecureGatewaySetup() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<h3 className="text-lg font-semibold">{step.title}</h3>
|
<h3 className="text-lg font-semibold">{stepText(step, "title")}</h3>
|
||||||
<p className="text-sm text-muted-foreground">{step.description}</p>
|
<p className="text-sm text-muted-foreground">{stepText(step, "description")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{step.fields?.map((fieldName) => renderField(fieldName))}
|
{step.fields?.map((fieldName) => renderField(fieldName))}
|
||||||
@@ -932,7 +952,7 @@ export function SecureGatewaySetup() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
<CardTitle className="text-base">{sg("title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -953,14 +973,14 @@ export function SecureGatewaySetup() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
<CardTitle className="text-base">{sg("title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3 py-2">
|
<div className="space-y-3 py-2">
|
||||||
<p className="text-sm text-red-500">Could not load setup data: {loadError}</p>
|
<p className="text-sm text-red-500">{sg("errors.couldNotLoadSetupData")} {loadError}</p>
|
||||||
<Button size="sm" variant="outline" onClick={() => loadInitialData()}>
|
<Button size="sm" variant="outline" onClick={() => loadInitialData()}>
|
||||||
Retry
|
{sg("retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -981,7 +1001,7 @@ export function SecureGatewaySetup() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
<CardTitle className="text-base">{sg("title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${
|
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
isRunning ? "bg-green-500/10 text-green-500" :
|
isRunning ? "bg-green-500/10 text-green-500" :
|
||||||
@@ -991,16 +1011,16 @@ export function SecureGatewaySetup() {
|
|||||||
{isRunning ? <Wifi className="h-3 w-3" /> :
|
{isRunning ? <Wifi className="h-3 w-3" /> :
|
||||||
isStopped ? <Square className="h-3 w-3" /> :
|
isStopped ? <Square className="h-3 w-3" /> :
|
||||||
<XCircle className="h-3 w-3" />}
|
<XCircle className="h-3 w-3" />}
|
||||||
{isRunning ? "Connected" : isStopped ? "Stopped" : "Error"}
|
{isRunning ? sg("status.connected") : isStopped ? sg("status.stopped") : sg("status.error")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>Tailscale VPN Gateway</CardDescription>
|
<CardDescription>{sg("installed.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Status info */}
|
{/* Status info */}
|
||||||
{isRunning && appStatus.uptime_seconds > 0 && (
|
{isRunning && appStatus.uptime_seconds > 0 && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Uptime: {formatUptime(appStatus.uptime_seconds)}
|
{sg("installed.uptime")}: {formatUptime(appStatus.uptime_seconds)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1018,7 +1038,7 @@ export function SecureGatewaySetup() {
|
|||||||
) : (
|
) : (
|
||||||
<Play className="h-4 w-4 mr-1" />
|
<Play className="h-4 w-4 mr-1" />
|
||||||
)}
|
)}
|
||||||
Start
|
{sg("actions.start")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isRunning && (
|
{isRunning && (
|
||||||
@@ -1034,7 +1054,7 @@ export function SecureGatewaySetup() {
|
|||||||
) : (
|
) : (
|
||||||
<Square className="h-4 w-4 mr-1" />
|
<Square className="h-4 w-4 mr-1" />
|
||||||
)}
|
)}
|
||||||
Stop
|
{sg("actions.stop")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1047,7 +1067,7 @@ export function SecureGatewaySetup() {
|
|||||||
) : (
|
) : (
|
||||||
<RotateCw className="h-4 w-4 mr-1" />
|
<RotateCw className="h-4 w-4 mr-1" />
|
||||||
)}
|
)}
|
||||||
Restart
|
{sg("actions.restart")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1060,7 +1080,7 @@ export function SecureGatewaySetup() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4 mr-1" />
|
<FileText className="h-4 w-4 mr-1" />
|
||||||
Logs
|
{sg("actions.logs")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1070,7 +1090,7 @@ export function SecureGatewaySetup() {
|
|||||||
disabled={actionLoading !== null}
|
disabled={actionLoading !== null}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
Remove
|
{sg("actions.remove")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1083,9 +1103,9 @@ export function SecureGatewaySetup() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Last checked: {formatLastChecked(updateInfo.last_checked_iso)} ·{" "}
|
{sg("updates.lastChecked")}: {formatLastChecked(updateInfo.last_checked_iso)} ·{" "}
|
||||||
<span className="text-purple-400 font-medium">
|
<span className="text-purple-400 font-medium">
|
||||||
Tailscale v{updateInfo.latest_version} available
|
{sg("updates.tailscaleAvailable", { version: updateInfo.latest_version || "" })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1101,24 +1121,23 @@ export function SecureGatewaySetup() {
|
|||||||
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
||||||
)}
|
)}
|
||||||
{updateApplying
|
{updateApplying
|
||||||
? "Updating…"
|
? sg("updates.updating")
|
||||||
: `Update to v${updateInfo.latest_version}`}
|
: sg("updates.updateToVersion", { version: updateInfo.latest_version || "" })}
|
||||||
</Button>
|
</Button>
|
||||||
{updateInfo.packages && updateInfo.packages.length > 1 && (
|
{updateInfo.packages && updateInfo.packages.length > 1 && (
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
+{updateInfo.packages.length - 1} other package
|
{sg("updates.otherPackagesPending", { count: updateInfo.packages.length - 1 })}
|
||||||
{updateInfo.packages.length > 2 ? "s" : ""} pending in the container
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Last checked: {formatLastChecked(updateInfo.last_checked_iso)}
|
{sg("updates.lastChecked")}: {formatLastChecked(updateInfo.last_checked_iso)}
|
||||||
{updateInfo.current_version
|
{updateInfo.current_version
|
||||||
? ` · Tailscale v${updateInfo.current_version}`
|
? ` · Tailscale v${updateInfo.current_version}`
|
||||||
: ""}
|
: ""}
|
||||||
{" · "}
|
{" · "}
|
||||||
<span className="text-green-500/80">No updates available</span>
|
<span className="text-green-500/80">{sg("updates.noneAvailable")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{updateError && (
|
{updateError && (
|
||||||
@@ -1146,7 +1165,7 @@ export function SecureGatewaySetup() {
|
|||||||
className="text-xs h-7 px-2"
|
className="text-xs h-7 px-2"
|
||||||
>
|
>
|
||||||
<Key className="h-3 w-3 mr-1" />
|
<Key className="h-3 w-3 mr-1" />
|
||||||
Update Auth Key
|
{sg("authKey.update")}
|
||||||
</Button>
|
</Button>
|
||||||
<a
|
<a
|
||||||
href="https://login.tailscale.com/admin/machines"
|
href="https://login.tailscale.com/admin/machines"
|
||||||
@@ -1154,7 +1173,7 @@ export function SecureGatewaySetup() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1"
|
className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
Open Tailscale Admin <ExternalLink className="h-3 w-3" />
|
{sg("tailscale.openAdmin")} <ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -1164,8 +1183,8 @@ export function SecureGatewaySetup() {
|
|||||||
<Dialog open={showLogs} onOpenChange={setShowLogs}>
|
<Dialog open={showLogs} onOpenChange={setShowLogs}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Secure Gateway Logs</DialogTitle>
|
<DialogTitle>{sg("logs.title")}</DialogTitle>
|
||||||
<DialogDescription>Recent container logs</DialogDescription>
|
<DialogDescription>{sg("logs.description")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="bg-black/50 rounded-lg p-4 max-h-96 overflow-auto">
|
<div className="bg-black/50 rounded-lg p-4 max-h-96 overflow-auto">
|
||||||
{logsLoading ? (
|
{logsLoading ? (
|
||||||
@@ -1174,14 +1193,14 @@ export function SecureGatewaySetup() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<pre className="text-xs font-mono text-green-400 whitespace-pre-wrap">
|
<pre className="text-xs font-mono text-green-400 whitespace-pre-wrap">
|
||||||
{logs || "No logs available"}
|
{logs || sg("logs.empty")}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button variant="outline" size="sm" onClick={loadLogs}>
|
<Button variant="outline" size="sm" onClick={loadLogs}>
|
||||||
<RotateCw className="h-4 w-4 mr-1" />
|
<RotateCw className="h-4 w-4 mr-1" />
|
||||||
Refresh
|
{t("actions.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -1191,14 +1210,14 @@ export function SecureGatewaySetup() {
|
|||||||
<Dialog open={showRemoveConfirm} onOpenChange={setShowRemoveConfirm}>
|
<Dialog open={showRemoveConfirm} onOpenChange={setShowRemoveConfirm}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Remove Secure Gateway?</DialogTitle>
|
<DialogTitle>{sg("remove.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This will stop and remove the gateway container. Your Tailscale state will be preserved for re-deployment.
|
{sg("remove.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => setShowRemoveConfirm(false)}>
|
<Button variant="outline" onClick={() => setShowRemoveConfirm(false)}>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -1210,7 +1229,7 @@ export function SecureGatewaySetup() {
|
|||||||
) : (
|
) : (
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
)}
|
)}
|
||||||
Remove
|
{sg("actions.remove")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -1228,16 +1247,16 @@ export function SecureGatewaySetup() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Key className="h-5 w-5 text-cyan-500" />
|
<Key className="h-5 w-5 text-cyan-500" />
|
||||||
Update Auth Key
|
{sg("authKey.update")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Enter a new Tailscale auth key to re-authenticate the gateway. This is useful if your previous key has expired.
|
{sg("authKey.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">New Auth Key</label>
|
<label className="text-sm font-medium">{sg("authKey.newKey")}</label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={newAuthKey}
|
value={newAuthKey}
|
||||||
@@ -1246,14 +1265,14 @@ export function SecureGatewaySetup() {
|
|||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Generate a new key at{" "}
|
{sg("authKey.generateAt")}{" "}
|
||||||
<a
|
<a
|
||||||
href="https://login.tailscale.com/admin/settings/keys"
|
href="https://login.tailscale.com/admin/settings/keys"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-cyan-500 hover:text-cyan-400 underline"
|
className="text-cyan-500 hover:text-cyan-400 underline"
|
||||||
>
|
>
|
||||||
Tailscale Admin > Settings > Keys
|
{sg("authKey.adminKeys")}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1267,7 +1286,7 @@ export function SecureGatewaySetup() {
|
|||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="outline" onClick={() => setShowUpdateAuthKey(false)}>
|
<Button variant="outline" onClick={() => setShowUpdateAuthKey(false)}>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpdateAuthKey}
|
onClick={handleUpdateAuthKey}
|
||||||
@@ -1279,7 +1298,7 @@ export function SecureGatewaySetup() {
|
|||||||
) : (
|
) : (
|
||||||
<Key className="h-4 w-4 mr-2" />
|
<Key className="h-4 w-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
Update Key
|
{sg("authKey.updateKey")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -1291,10 +1310,10 @@ export function SecureGatewaySetup() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
Gateway Deployed Successfully
|
{sg("postDeploy.title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
One more step to complete the setup
|
{sg("postDeploy.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -1302,17 +1321,17 @@ export function SecureGatewaySetup() {
|
|||||||
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-4">
|
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-4">
|
||||||
<p className="text-sm font-medium text-cyan-400 flex items-center gap-2 mb-2">
|
<p className="text-sm font-medium text-cyan-400 flex items-center gap-2 mb-2">
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
Next Step: Approve in Tailscale Admin
|
{sg("postDeploy.nextStep")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
You need to approve the following settings in your Tailscale admin console for them to take effect:
|
{sg("postDeploy.approveDescription")}
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
{deployedConfig.advertise_routes?.length > 0 && (
|
{deployedConfig.advertise_routes?.length > 0 && (
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<Network className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
|
<Network className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Subnet Routes:</span>
|
<span className="font-medium">{sg("postDeploy.subnetRoutes")}:</span>
|
||||||
<span className="text-muted-foreground ml-1">
|
<span className="text-muted-foreground ml-1">
|
||||||
{deployedConfig.advertise_routes.join(", ")}
|
{deployedConfig.advertise_routes.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
@@ -1323,9 +1342,9 @@ export function SecureGatewaySetup() {
|
|||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<Globe className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
|
<Globe className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Exit Node:</span>
|
<span className="font-medium">{sg("postDeploy.exitNode")}:</span>
|
||||||
<span className="text-muted-foreground ml-1">
|
<span className="text-muted-foreground ml-1">
|
||||||
Route all internet traffic
|
{sg("postDeploy.routeAllTraffic")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -1334,30 +1353,30 @@ export function SecureGatewaySetup() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
|
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
|
||||||
<p className="text-sm font-medium">How to approve:</p>
|
<p className="text-sm font-medium">{sg("postDeploy.howToApprove")}</p>
|
||||||
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||||
<li>Click the button below to open Tailscale Admin</li>
|
<li>{sg("postDeploy.stepOpenAdmin")}</li>
|
||||||
<li>Find <span className="font-mono text-cyan-400">{deployedConfig.hostname || "proxmox-gateway"}</span> in the machines list</li>
|
<li>{sg("postDeploy.stepFindBefore")} <span className="font-mono text-cyan-400">{deployedConfig.hostname || "proxmox-gateway"}</span> {sg("postDeploy.stepFindAfter")}</li>
|
||||||
<li>Click on it to open machine details</li>
|
<li>{sg("postDeploy.stepOpenDetails")}</li>
|
||||||
<li>In the <strong>Subnets</strong> section, click <strong>Edit</strong> and enable the route</li>
|
<li>{sg("postDeploy.stepSubnetsBefore")} <strong>Subnets</strong> {sg("postDeploy.stepSubnetsMiddle")} <strong>Edit</strong> {sg("postDeploy.stepSubnetsAfter")}</li>
|
||||||
{deployedConfig.exit_node && (
|
{deployedConfig.exit_node && (
|
||||||
<li>In <strong>Routing Settings</strong>, enable <strong>Exit Node</strong></li>
|
<li>{sg("postDeploy.stepRoutingBefore")} <strong>Routing Settings</strong>, {sg("postDeploy.stepRoutingMiddle")} <strong>Exit Node</strong></li>
|
||||||
)}
|
)}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3">
|
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3">
|
||||||
<p className="text-xs text-green-400">
|
<p className="text-xs text-green-400">
|
||||||
Once approved, you can access your Proxmox host at{" "}
|
{sg("postDeploy.accessAfterApproval")}{" "}
|
||||||
<span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8006</span> (Proxmox UI) or{" "}
|
<span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8006</span> (Proxmox UI) {sg("postDeploy.or")}{" "}
|
||||||
<span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8008</span> (ProxMenux Monitor) from any device with Tailscale.
|
<span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8008</span> (ProxMenux Monitor) {sg("postDeploy.fromAnyDevice")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="outline" onClick={() => setShowPostDeployInfo(false)}>
|
<Button variant="outline" onClick={() => setShowPostDeployInfo(false)}>
|
||||||
I{"'"}ll do it later
|
{sg("postDeploy.doLater")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1366,7 +1385,7 @@ export function SecureGatewaySetup() {
|
|||||||
}}
|
}}
|
||||||
className="bg-cyan-600 hover:bg-cyan-700"
|
className="bg-cyan-600 hover:bg-cyan-700"
|
||||||
>
|
>
|
||||||
Open Tailscale Admin
|
{sg("tailscale.openAdmin")}
|
||||||
<ExternalLink className="h-4 w-4 ml-2" />
|
<ExternalLink className="h-4 w-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1383,13 +1402,13 @@ export function SecureGatewaySetup() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
<CardTitle className="text-base">{sg("title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>VPN access without opening ports</CardDescription>
|
<CardDescription>{sg("notInstalled.subtitle")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Deploy a Tailscale VPN gateway for secure remote access to your Proxmox infrastructure. No port forwarding required.
|
{sg("notInstalled.description")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -1397,7 +1416,7 @@ export function SecureGatewaySetup() {
|
|||||||
className="bg-cyan-600 hover:bg-cyan-700"
|
className="bg-cyan-600 hover:bg-cyan-700"
|
||||||
>
|
>
|
||||||
<ShieldCheck className="h-4 w-4 mr-2" />
|
<ShieldCheck className="h-4 w-4 mr-2" />
|
||||||
Deploy Secure Gateway
|
{sg("notInstalled.deploy")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1418,7 +1437,7 @@ export function SecureGatewaySetup() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
Secure Gateway Setup
|
{sg("wizard.setupTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -1465,7 +1484,7 @@ export function SecureGatewaySetup() {
|
|||||||
}}
|
}}
|
||||||
disabled={currentStep === 0 || deploying}
|
disabled={currentStep === 0 || deploying}
|
||||||
>
|
>
|
||||||
Back
|
{sg("actions.back")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{currentStep < wizardSteps.length - 1 ? (
|
{currentStep < wizardSteps.length - 1 ? (
|
||||||
@@ -1480,7 +1499,7 @@ export function SecureGatewaySetup() {
|
|||||||
}}
|
}}
|
||||||
className="bg-cyan-600 hover:bg-cyan-700"
|
className="bg-cyan-600 hover:bg-cyan-700"
|
||||||
>
|
>
|
||||||
Continue
|
{sg("actions.continue")}
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -1492,12 +1511,12 @@ export function SecureGatewaySetup() {
|
|||||||
{deploying ? (
|
{deploying ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
Deploying...
|
{sg("actions.deploying")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Play className="h-4 w-4 mr-2" />
|
<Play className="h-4 w-4 mr-2" />
|
||||||
Deploy Gateway
|
{sg("actions.deployGateway")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+933
-576
File diff suppressed because it is too large
Load Diff
+467
-147
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
|
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
||||||
@@ -14,6 +15,8 @@ const menuItems = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const Sidebar = ({ currentPath, setOpen }) => {
|
const Sidebar = ({ currentPath, setOpen }) => {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
const handleNavigation = (tabName: string) => {
|
const handleNavigation = (tabName: string) => {
|
||||||
// Dispatch custom event to change tab in dashboard
|
// Dispatch custom event to change tab in dashboard
|
||||||
const event = new CustomEvent("changeTab", { detail: { tab: tabName } })
|
const event = new CustomEvent("changeTab", { detail: { tab: tabName } })
|
||||||
@@ -32,7 +35,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="h-5 w-5" />
|
<LayoutDashboard className="h-5 w-5" />
|
||||||
<span>Overview</span>
|
<span>{t("navigation.overview")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -44,7 +47,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<HardDrive className="h-5 w-5" />
|
<HardDrive className="h-5 w-5" />
|
||||||
<span>Storage</span>
|
<span>{t("navigation.storage")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -56,7 +59,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Network className="h-5 w-5" />
|
<Network className="h-5 w-5" />
|
||||||
<span>Network</span>
|
<span>{t("navigation.network")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -68,7 +71,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Server className="h-5 w-5" />
|
<Server className="h-5 w-5" />
|
||||||
<span>VMs & LXCs</span>
|
<span>{t("navigation.virtualMachines")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -80,7 +83,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Cpu className="h-5 w-5" />
|
<Cpu className="h-5 w-5" />
|
||||||
<span>Hardware</span>
|
<span>{t("navigation.hardware")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -92,7 +95,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
<span>System Logs</span>
|
<span>{t("navigation.systemLogs")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -104,7 +107,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Terminal className="h-5 w-5" />
|
<Terminal className="h-5 w-5" />
|
||||||
<span>Terminal</span>
|
<span>{t("navigation.terminal")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -116,7 +119,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<SettingsIcon className="h-5 w-5" />
|
<SettingsIcon className="h-5 w-5" />
|
||||||
<span>Settings</span>
|
<span>{t("navigation.settings")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+130
-122
@@ -29,6 +29,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useState, useEffect, useMemo } from "react"
|
import { useState, useEffect, useMemo } from "react"
|
||||||
import { API_PORT, fetchApi, getApiUrl, getAuthToken } from "@/lib/api-config"
|
import { API_PORT, fetchApi, getApiUrl, getAuthToken } from "@/lib/api-config"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
interface Backup {
|
interface Backup {
|
||||||
volid: string
|
volid: string
|
||||||
@@ -88,6 +89,7 @@ interface CombinedLogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SystemLogs() {
|
export function SystemLogs() {
|
||||||
|
const t = useT()
|
||||||
const [logs, setLogs] = useState<SystemLog[]>([])
|
const [logs, setLogs] = useState<SystemLog[]>([])
|
||||||
const [backups, setBackups] = useState<Backup[]>([])
|
const [backups, setBackups] = useState<Backup[]>([])
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
@@ -150,7 +152,7 @@ export function SystemLogs() {
|
|||||||
setLogsCounts(countsRes)
|
setLogsCounts(countsRes)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setError("Failed to connect to server")
|
setError(t("systemLogs.errors.connectFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false)
|
if (!cancelled) setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -177,7 +179,7 @@ export function SystemLogs() {
|
|||||||
const data = await fetchApi<{ logs?: SystemLog[] } | SystemLog[]>(apiUrl)
|
const data = await fetchApi<{ logs?: SystemLog[] } | SystemLog[]>(apiUrl)
|
||||||
return Array.isArray(data) ? data : data.logs || []
|
return Array.isArray(data) ? data : data.logs || []
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to load logs. Please try again.")
|
setError(t("systemLogs.errors.loadFailed"))
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,26 +205,26 @@ export function SystemLogs() {
|
|||||||
|
|
||||||
// Generate log content
|
// Generate log content
|
||||||
const logContent = [
|
const logContent = [
|
||||||
`Proxmox System Logs & Events Export`,
|
t("systemLogs.export.title"),
|
||||||
`Generated: ${new Date().toISOString()}`,
|
`${t("systemLogs.fields.generated")}: ${new Date().toISOString()}`,
|
||||||
`Total Entries: ${filteredCombinedLogs.length.toLocaleString()}`,
|
`${t("systemLogs.cards.totalEntries")}: ${filteredCombinedLogs.length.toLocaleString()}`,
|
||||||
``,
|
``,
|
||||||
`Filters Applied:`,
|
`${t("systemLogs.export.filtersApplied")}:`,
|
||||||
`- Date Range: ${dateFilter === "custom" ? `${customDays} days ago` : `${dateFilter} day(s) ago`}`,
|
`- ${t("systemLogs.filters.dateRange")}: ${t("systemLogs.filters.daysAgo", { count: dateFilter === "custom" ? customDays : dateFilter })}`,
|
||||||
`- Level: ${levelFilter === "all" ? "All Levels" : levelFilter}`,
|
`- ${t("systemLogs.fields.level")}: ${levelFilter === "all" ? t("systemLogs.filters.allLevels") : levelLabel(levelFilter)}`,
|
||||||
`- Service: ${serviceFilter === "all" ? "All Services" : serviceFilter}`,
|
`- ${t("systemLogs.fields.service")}: ${serviceFilter === "all" ? t("systemLogs.filters.allServices") : serviceFilter}`,
|
||||||
`- Search: ${searchTerm || "None"}`,
|
`- ${t("systemLogs.fields.search")}: ${searchTerm || t("systemLogs.fields.none")}`,
|
||||||
``,
|
``,
|
||||||
`${"=".repeat(80)}`,
|
`${"=".repeat(80)}`,
|
||||||
``,
|
``,
|
||||||
...filteredCombinedLogs.map((log) => {
|
...filteredCombinedLogs.map((log) => {
|
||||||
const lines = [
|
const lines = [
|
||||||
`[${log.timestamp}] ${log.level.toUpperCase()} - ${log.service}${log.isEvent ? " [EVENT]" : ""}`,
|
`[${log.timestamp}] ${levelLabel(log.level)} - ${log.service}${log.isEvent ? ` [${t("systemLogs.badges.event")}]` : ""}`,
|
||||||
`Message: ${log.message}`,
|
`${t("systemLogs.fields.message")}: ${log.message}`,
|
||||||
`Source: ${log.source}`,
|
`${t("systemLogs.fields.source")}: ${log.source}`,
|
||||||
]
|
]
|
||||||
if (log.pid) lines.push(`PID: ${log.pid}`)
|
if (log.pid) lines.push(`PID: ${log.pid}`)
|
||||||
if (log.hostname) lines.push(`Hostname: ${log.hostname}`)
|
if (log.hostname) lines.push(`${t("systemLogs.fields.hostname")}: ${log.hostname}`)
|
||||||
lines.push(`${"-".repeat(80)}`)
|
lines.push(`${"-".repeat(80)}`)
|
||||||
return lines.join("\n")
|
return lines.join("\n")
|
||||||
}),
|
}),
|
||||||
@@ -273,13 +275,13 @@ export function SystemLogs() {
|
|||||||
// Download the complete task log
|
// Download the complete task log
|
||||||
const blob = new Blob(
|
const blob = new Blob(
|
||||||
[
|
[
|
||||||
`Proxmox Task Log\n`,
|
`${t("systemLogs.download.taskLog")}\n`,
|
||||||
`================\n\n`,
|
`================\n\n`,
|
||||||
`UPID: ${upid}\n`,
|
`UPID: ${upid}\n`,
|
||||||
`Timestamp: ${notification.timestamp}\n`,
|
`${t("systemLogs.fields.timestamp")}: ${notification.timestamp}\n`,
|
||||||
`Service: ${notification.service}\n`,
|
`${t("systemLogs.fields.service")}: ${notification.service}\n`,
|
||||||
`Source: ${notification.source}\n\n`,
|
`${t("systemLogs.fields.source")}: ${notification.source}\n\n`,
|
||||||
`Complete Task Log:\n`,
|
`${t("systemLogs.download.completeTaskLog")}:\n`,
|
||||||
`${"-".repeat(80)}\n`,
|
`${"-".repeat(80)}\n`,
|
||||||
`${taskLog}\n`,
|
`${taskLog}\n`,
|
||||||
],
|
],
|
||||||
@@ -303,13 +305,13 @@ export function SystemLogs() {
|
|||||||
// If no UPID or failed to fetch task log, download the notification message
|
// If no UPID or failed to fetch task log, download the notification message
|
||||||
const blob = new Blob(
|
const blob = new Blob(
|
||||||
[
|
[
|
||||||
`Notification Details\n`,
|
`${t("systemLogs.modals.notificationTitle")}\n`,
|
||||||
`==================\n\n`,
|
`==================\n\n`,
|
||||||
`Timestamp: ${notification.timestamp}\n`,
|
`${t("systemLogs.fields.timestamp")}: ${notification.timestamp}\n`,
|
||||||
`Type: ${notification.type}\n`,
|
`${t("systemLogs.fields.type")}: ${notification.type}\n`,
|
||||||
`Service: ${notification.service}\n`,
|
`${t("systemLogs.fields.service")}: ${notification.service}\n`,
|
||||||
`Source: ${notification.source}\n\n`,
|
`${t("systemLogs.fields.source")}: ${notification.source}\n\n`,
|
||||||
`Complete Message:\n`,
|
`${t("systemLogs.download.completeMessage")}:\n`,
|
||||||
`${notification.message}\n`,
|
`${notification.message}\n`,
|
||||||
],
|
],
|
||||||
{ type: "text/plain" },
|
{ type: "text/plain" },
|
||||||
@@ -342,7 +344,7 @@ export function SystemLogs() {
|
|||||||
level: event.level,
|
level: event.level,
|
||||||
service: event.type,
|
service: event.type,
|
||||||
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
|
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
|
||||||
source: `Node: ${event.node} • User: ${event.user}`,
|
source: `${t("systemLogs.fields.node")}: ${event.node} • ${t("systemLogs.fields.user")}: ${event.user}`,
|
||||||
isEvent: true,
|
isEvent: true,
|
||||||
eventData: event,
|
eventData: event,
|
||||||
sortTimestamp: new Date(event.starttime).getTime(),
|
sortTimestamp: new Date(event.starttime).getTime(),
|
||||||
@@ -392,6 +394,12 @@ export function SystemLogs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const levelLabel = (level: string) => {
|
||||||
|
const key = `systemLogs.levels.${safeToLowerCase(level)}`
|
||||||
|
const translated = t(key)
|
||||||
|
return translated === key ? String(level).toUpperCase() : translated
|
||||||
|
}
|
||||||
|
|
||||||
const getLevelIcon = (level: string) => {
|
const getLevelIcon = (level: string) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case "error":
|
case "error":
|
||||||
@@ -551,15 +559,15 @@ export function SystemLogs() {
|
|||||||
const getSectionLabel = (section: string) => {
|
const getSectionLabel = (section: string) => {
|
||||||
switch (section) {
|
switch (section) {
|
||||||
case "logs":
|
case "logs":
|
||||||
return "Logs"
|
return t("systemLogs.tabs.logs")
|
||||||
case "events":
|
case "events":
|
||||||
return "Events"
|
return t("systemLogs.tabs.events")
|
||||||
case "backups":
|
case "backups":
|
||||||
return "Backups"
|
return t("systemLogs.tabs.backups")
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return "Notifications"
|
return t("systemLogs.tabs.notifications")
|
||||||
default:
|
default:
|
||||||
return "Logs"
|
return t("systemLogs.tabs.logs")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,8 +578,8 @@ export function SystemLogs() {
|
|||||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-foreground">Loading logs...</div>
|
<div className="text-sm font-medium text-foreground">{t("systemLogs.loading.title")}</div>
|
||||||
<p className="text-xs text-muted-foreground">Fetching system logs and events</p>
|
<p className="text-xs text-muted-foreground">{t("systemLogs.loading.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -585,7 +593,7 @@ export function SystemLogs() {
|
|||||||
<div className="h-10 w-10 rounded-full border-2 border-muted"></div>
|
<div className="h-10 w-10 rounded-full border-2 border-muted"></div>
|
||||||
<div className="absolute inset-0 h-10 w-10 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
<div className="absolute inset-0 h-10 w-10 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-foreground">Loading logs...</div>
|
<div className="text-sm font-medium text-foreground">{t("systemLogs.loading.title")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -594,42 +602,42 @@ export function SystemLogs() {
|
|||||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4 xl:gap-6">
|
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4 xl:gap-6">
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Entries</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.totalEntries")}</CardTitle>
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-foreground">
|
<div className="text-2xl font-bold text-foreground">
|
||||||
{(logsCounts?.total ?? 0).toLocaleString("fr-FR")}
|
{(logsCounts?.total ?? 0).toLocaleString("fr-FR")}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">In selected range</p>
|
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.selectedRange")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Errors</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.errors")}</CardTitle>
|
||||||
<XCircle className="h-4 w-4 text-red-500" />
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-red-500">{(logsCounts?.errors ?? 0).toLocaleString("fr-FR")}</div>
|
<div className="text-2xl font-bold text-red-500">{(logsCounts?.errors ?? 0).toLocaleString("fr-FR")}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">Requires attention</p>
|
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.requiresAttention")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Warnings</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.warnings")}</CardTitle>
|
||||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-yellow-500">{(logsCounts?.warnings ?? 0).toLocaleString("fr-FR")}</div>
|
<div className="text-2xl font-bold text-yellow-500">{(logsCounts?.warnings ?? 0).toLocaleString("fr-FR")}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">Monitor closely</p>
|
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.monitorClosely")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Backups</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.backups")}</CardTitle>
|
||||||
<Database className="h-4 w-4 text-blue-500" />
|
<Database className="h-4 w-4 text-blue-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -645,11 +653,11 @@ export function SystemLogs() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-foreground flex items-center">
|
||||||
<Activity className="h-5 w-5 mr-2" />
|
<Activity className="h-5 w-5 mr-2" />
|
||||||
System Logs & Events
|
{t("systemLogs.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button variant="outline" size="sm" onClick={refreshData} disabled={loading}>
|
<Button variant="outline" size="sm" onClick={refreshData} disabled={loading}>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||||
Refresh
|
{t("actions.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -658,18 +666,18 @@ export function SystemLogs() {
|
|||||||
<TabsList className="hidden md:grid w-full grid-cols-3">
|
<TabsList className="hidden md:grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="logs" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
|
<TabsTrigger value="logs" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
|
||||||
<Terminal className="h-4 w-4 mr-2" />
|
<Terminal className="h-4 w-4 mr-2" />
|
||||||
Logs
|
{t("systemLogs.tabs.logs")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="backups" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
|
<TabsTrigger value="backups" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
|
||||||
<Database className="h-4 w-4 mr-2" />
|
<Database className="h-4 w-4 mr-2" />
|
||||||
Backups
|
{t("systemLogs.tabs.backups")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="notifications"
|
value="notifications"
|
||||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white"
|
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white"
|
||||||
>
|
>
|
||||||
<Bell className="h-4 w-4 mr-2" />
|
<Bell className="h-4 w-4 mr-2" />
|
||||||
Notifications
|
{t("systemLogs.tabs.notifications")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -691,7 +699,7 @@ export function SystemLogs() {
|
|||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" className="w-[280px]">
|
<SheetContent side="left" className="w-[280px]">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>Sections</SheetTitle>
|
<SheetTitle>{t("systemLogs.sections")}</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="mt-6 space-y-2">
|
<div className="mt-6 space-y-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -707,7 +715,7 @@ export function SystemLogs() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Terminal className="h-4 w-4" />
|
<Terminal className="h-4 w-4" />
|
||||||
Logs
|
{t("systemLogs.tabs.logs")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -722,7 +730,7 @@ export function SystemLogs() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
Backups
|
{t("systemLogs.tabs.backups")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -737,7 +745,7 @@ export function SystemLogs() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
Notifications
|
{t("systemLogs.tabs.notifications")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -751,7 +759,7 @@ export function SystemLogs() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search logs & events..."
|
placeholder={t("systemLogs.filters.searchPlaceholder")}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-10 bg-background border-border"
|
className="pl-10 bg-background border-border"
|
||||||
@@ -761,22 +769,22 @@ export function SystemLogs() {
|
|||||||
|
|
||||||
<Select value={dateFilter} onValueChange={setDateFilter}>
|
<Select value={dateFilter} onValueChange={setDateFilter}>
|
||||||
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
||||||
<SelectValue placeholder="Time range" />
|
<SelectValue placeholder={t("systemLogs.filters.timeRange")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="1">1 day ago</SelectItem>
|
<SelectItem value="1">{t("systemLogs.filters.oneDay")}</SelectItem>
|
||||||
<SelectItem value="3">3 days ago</SelectItem>
|
<SelectItem value="3">{t("systemLogs.filters.threeDays")}</SelectItem>
|
||||||
<SelectItem value="7">1 week ago</SelectItem>
|
<SelectItem value="7">{t("systemLogs.filters.oneWeek")}</SelectItem>
|
||||||
<SelectItem value="14">2 weeks ago</SelectItem>
|
<SelectItem value="14">{t("systemLogs.filters.twoWeeks")}</SelectItem>
|
||||||
<SelectItem value="30">1 month ago</SelectItem>
|
<SelectItem value="30">{t("systemLogs.filters.oneMonth")}</SelectItem>
|
||||||
<SelectItem value="custom">Custom days</SelectItem>
|
<SelectItem value="custom">{t("systemLogs.filters.customDays")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{dateFilter === "custom" && (
|
{dateFilter === "custom" && (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Days ago"
|
placeholder={t("systemLogs.filters.daysAgoPlaceholder")}
|
||||||
value={customDays}
|
value={customDays}
|
||||||
onChange={(e) => setCustomDays(e.target.value)}
|
onChange={(e) => setCustomDays(e.target.value)}
|
||||||
className="w-full sm:w-[120px] bg-background border-border"
|
className="w-full sm:w-[120px] bg-background border-border"
|
||||||
@@ -786,23 +794,23 @@ export function SystemLogs() {
|
|||||||
|
|
||||||
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||||
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
||||||
<SelectValue placeholder="Filter by level" />
|
<SelectValue placeholder={t("systemLogs.filters.byLevel")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Levels</SelectItem>
|
<SelectItem value="all">{t("systemLogs.filters.allLevels")}</SelectItem>
|
||||||
<SelectItem value="error">Error</SelectItem>
|
<SelectItem value="error">{t("systemLogs.levels.error")}</SelectItem>
|
||||||
<SelectItem value="warning">Warning</SelectItem>
|
<SelectItem value="warning">{t("systemLogs.levels.warning")}</SelectItem>
|
||||||
<SelectItem value="info">Info</SelectItem>
|
<SelectItem value="info">{t("systemLogs.levels.info")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={serviceFilter} onValueChange={setServiceFilter}>
|
<Select value={serviceFilter} onValueChange={setServiceFilter}>
|
||||||
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
||||||
<SelectValue placeholder="Filter by service" />
|
<SelectValue placeholder={t("systemLogs.filters.byService")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem key="service-all" value="all">
|
<SelectItem key="service-all" value="all">
|
||||||
All Services
|
{t("systemLogs.filters.allServices")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{uniqueServices.map((service) => (
|
{uniqueServices.map((service) => (
|
||||||
<SelectItem key={`service-${service}`} value={service}>
|
<SelectItem key={`service-${service}`} value={service}>
|
||||||
@@ -814,7 +822,7 @@ export function SystemLogs() {
|
|||||||
|
|
||||||
<Button variant="outline" className="border-border bg-transparent" onClick={handleDownloadLogs}>
|
<Button variant="outline" className="border-border bg-transparent" onClick={handleDownloadLogs}>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
Export Logs
|
{t("systemLogs.export.button")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -844,12 +852,12 @@ export function SystemLogs() {
|
|||||||
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
||||||
<Badge variant="outline" className={getLevelColor(log.level)}>
|
<Badge variant="outline" className={getLevelColor(log.level)}>
|
||||||
{getLevelIcon(log.level)}
|
{getLevelIcon(log.level)}
|
||||||
{log.level.toUpperCase()}
|
{levelLabel(log.level)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{log.eventData && (
|
{log.eventData && (
|
||||||
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
|
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
|
||||||
<Activity className="h-3 w-3 mr-1" />
|
<Activity className="h-3 w-3 mr-1" />
|
||||||
EVENT
|
{t("systemLogs.badges.event")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -866,9 +874,9 @@ export function SystemLogs() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground truncate overflow-hidden">
|
<div className="text-xs text-muted-foreground truncate overflow-hidden">
|
||||||
{log.source}
|
{log.source}
|
||||||
{log.unit && log.unit !== log.service && ` • Unit: ${log.unit}`}
|
{log.unit && log.unit !== log.service && ` • ${t("systemLogs.fields.unit")}: ${log.unit}`}
|
||||||
{log.pid && ` • PID: ${log.pid}`}
|
{log.pid && ` • PID: ${log.pid}`}
|
||||||
{log.hostname && ` • Host: ${log.hostname}`}
|
{log.hostname && ` • ${t("systemLogs.fields.host")}: ${log.hostname}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -878,7 +886,7 @@ export function SystemLogs() {
|
|||||||
{displayedLogs.length === 0 && (
|
{displayedLogs.length === 0 && (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
<p>No logs found matching your criteria</p>
|
<p>{t("systemLogs.empty.logs")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -890,7 +898,7 @@ export function SystemLogs() {
|
|||||||
className="border-border"
|
className="border-border"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Load More ({filteredCombinedLogs.length - displayedLogsCount} remaining)
|
{t("systemLogs.loadMore", { count: filteredCombinedLogs.length - displayedLogsCount })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -906,19 +914,19 @@ export function SystemLogs() {
|
|||||||
<Card className="bg-card/50 border-border">
|
<Card className="bg-card/50 border-border">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-2xl font-bold text-cyan-500">{backupStats.qemu}</div>
|
<div className="text-2xl font-bold text-cyan-500">{backupStats.qemu}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">VM Backups</p>
|
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.vm")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-card/50 border-border">
|
<Card className="bg-card/50 border-border">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-2xl font-bold text-orange-500">{backupStats.lxc}</div>
|
<div className="text-2xl font-bold text-orange-500">{backupStats.lxc}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">LXC Backups</p>
|
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.lxc")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-card/50 border-border hidden md:block">
|
<Card className="bg-card/50 border-border hidden md:block">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div>
|
<div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Total Size</p>
|
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.totalSize")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -927,7 +935,7 @@ export function SystemLogs() {
|
|||||||
<Card className="bg-card/50 border-border md:hidden">
|
<Card className="bg-card/50 border-border md:hidden">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div>
|
<div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Total Size</p>
|
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.totalSize")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -967,7 +975,7 @@ export function SystemLogs() {
|
|||||||
{backup.size_human}
|
{backup.size_human}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground mb-1 truncate">Storage: {backup.storage}</div>
|
<div className="text-xs text-muted-foreground mb-1 truncate">{t("systemLogs.fields.storage")}: {backup.storage}</div>
|
||||||
<div className="text-xs text-muted-foreground flex items-center">
|
<div className="text-xs text-muted-foreground flex items-center">
|
||||||
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
|
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
|
||||||
<span className="truncate">{backup.created}</span>
|
<span className="truncate">{backup.created}</span>
|
||||||
@@ -980,7 +988,7 @@ export function SystemLogs() {
|
|||||||
{backups.length === 0 && (
|
{backups.length === 0 && (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<Database className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
<Database className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
<p>No backups found</p>
|
<p>{t("systemLogs.empty.backups")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1006,12 +1014,12 @@ export function SystemLogs() {
|
|||||||
>
|
>
|
||||||
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
||||||
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
|
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
|
||||||
{(notification.type || "unknown").toUpperCase()}
|
{notification.type ? levelLabel(notification.type) : t("app.unknown")}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
|
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
|
||||||
{notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />}
|
{notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />}
|
||||||
{notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />}
|
{notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />}
|
||||||
{(notification.source || "unknown").toUpperCase()}
|
{notification.source ? notification.source.toUpperCase() : t("app.unknown")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1026,7 +1034,7 @@ export function SystemLogs() {
|
|||||||
{notification.message}
|
{notification.message}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground break-words overflow-hidden">
|
<div className="text-xs text-muted-foreground break-words overflow-hidden">
|
||||||
Service: {notification.service} • Source: {notification.source}
|
{t("systemLogs.fields.service")}: {notification.service} • {t("systemLogs.fields.source")}: {notification.source}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1036,7 +1044,7 @@ export function SystemLogs() {
|
|||||||
{notifications.length === 0 && (
|
{notifications.length === 0 && (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<Bell className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
<Bell className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
<p>No notifications found</p>
|
<p>{t("systemLogs.empty.notifications")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1051,55 +1059,55 @@ export function SystemLogs() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
Log Details
|
{t("systemLogs.modals.logTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>Complete information about this log entry</DialogDescription>
|
<DialogDescription>{t("systemLogs.modals.logDescription")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{selectedLog && (
|
{selectedLog && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Level</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.level")}</div>
|
||||||
<Badge variant="outline" className={getLevelColor(selectedLog.level)}>
|
<Badge variant="outline" className={getLevelColor(selectedLog.level)}>
|
||||||
{getLevelIcon(selectedLog.level)}
|
{getLevelIcon(selectedLog.level)}
|
||||||
{selectedLog.level.toUpperCase()}
|
{levelLabel(selectedLog.level)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Service</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.service")}</div>
|
||||||
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.service}</div>
|
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.service}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Timestamp</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.timestamp")}</div>
|
||||||
<div className="text-sm text-foreground font-mono break-all overflow-hidden">
|
<div className="text-sm text-foreground font-mono break-all overflow-hidden">
|
||||||
{selectedLog.timestamp}
|
{selectedLog.timestamp}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Source</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.source")}</div>
|
||||||
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.source}</div>
|
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.source}</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedLog.unit && (
|
{selectedLog.unit && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Systemd Unit</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.systemdUnit")}</div>
|
||||||
<div className="text-sm text-foreground font-mono break-all overflow-hidden">{selectedLog.unit}</div>
|
<div className="text-sm text-foreground font-mono break-all overflow-hidden">{selectedLog.unit}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedLog.pid && (
|
{selectedLog.pid && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Process ID</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.processId")}</div>
|
||||||
<div className="text-sm text-foreground font-mono">{selectedLog.pid}</div>
|
<div className="text-sm text-foreground font-mono">{selectedLog.pid}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedLog.hostname && (
|
{selectedLog.hostname && (
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Hostname</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.hostname")}</div>
|
||||||
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.hostname}</div>
|
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.hostname}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-2">Message</div>
|
<div className="text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.message")}</div>
|
||||||
<div className="p-4 rounded-lg bg-muted/50 border border-border overflow-hidden">
|
<div className="p-4 rounded-lg bg-muted/50 border border-border overflow-hidden">
|
||||||
<pre className="text-sm text-foreground whitespace-pre-wrap break-all overflow-hidden">
|
<pre className="text-sm text-foreground whitespace-pre-wrap break-all overflow-hidden">
|
||||||
{selectedLog.message}
|
{selectedLog.message}
|
||||||
@@ -1116,37 +1124,37 @@ export function SystemLogs() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Activity className="h-5 w-5" />
|
<Activity className="h-5 w-5" />
|
||||||
Event Details
|
{t("systemLogs.modals.eventTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>Complete information about this event</DialogDescription>
|
<DialogDescription>{t("systemLogs.modals.eventDescription")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{selectedEvent && (
|
{selectedEvent && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Badge variant="outline" className={getLevelColor(selectedEvent.level)}>
|
<Badge variant="outline" className={getLevelColor(selectedEvent.level)}>
|
||||||
{getLevelIcon(selectedEvent.level)}
|
{getLevelIcon(selectedEvent.level)}
|
||||||
{selectedEvent.level.toUpperCase()}
|
{levelLabel(selectedEvent.level)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
|
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
|
||||||
<Activity className="h-3 w-3 mr-1" />
|
<Activity className="h-3 w-3 mr-1" />
|
||||||
EVENT
|
{t("systemLogs.badges.event")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Message</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.message")}</div>
|
||||||
<div className="text-sm text-foreground break-words">{selectedEvent.status}</div>
|
<div className="text-sm text-foreground break-words">{selectedEvent.status}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Type</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.type")}</div>
|
||||||
<div className="text-sm text-foreground break-words">{selectedEvent.type}</div>
|
<div className="text-sm text-foreground break-words">{selectedEvent.type}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Node</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.node")}</div>
|
||||||
<div className="text-sm text-foreground">{selectedEvent.node}</div>
|
<div className="text-sm text-foreground">{selectedEvent.node}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">User</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.user")}</div>
|
||||||
<div className="text-sm text-foreground break-words">{selectedEvent.user}</div>
|
<div className="text-sm text-foreground break-words">{selectedEvent.user}</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedEvent.vmid && (
|
{selectedEvent.vmid && (
|
||||||
@@ -1156,15 +1164,15 @@ export function SystemLogs() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Duration</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.duration")}</div>
|
||||||
<div className="text-sm text-foreground">{selectedEvent.duration}</div>
|
<div className="text-sm text-foreground">{selectedEvent.duration}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Start Time</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.startTime")}</div>
|
||||||
<div className="text-sm text-foreground break-words">{selectedEvent.starttime}</div>
|
<div className="text-sm text-foreground break-words">{selectedEvent.starttime}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">End Time</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.endTime")}</div>
|
||||||
<div className="text-sm text-foreground break-words">{selectedEvent.endtime}</div>
|
<div className="text-sm text-foreground break-words">{selectedEvent.endtime}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1186,31 +1194,31 @@ export function SystemLogs() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Database className="h-5 w-5" />
|
<Database className="h-5 w-5" />
|
||||||
Backup Details
|
{t("systemLogs.modals.backupTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>Complete information about this backup</DialogDescription>
|
<DialogDescription>{t("systemLogs.modals.backupDescription")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{selectedBackup && (
|
{selectedBackup && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Type</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.type")}</div>
|
||||||
<Badge variant="outline" className={getBackupTypeColor(selectedBackup.volid)}>
|
<Badge variant="outline" className={getBackupTypeColor(selectedBackup.volid)}>
|
||||||
{getBackupTypeLabel(selectedBackup.volid)}
|
{getBackupTypeLabel(selectedBackup.volid)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Storage Type</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.storageType")}</div>
|
||||||
<Badge variant="outline" className={getBackupStorageColor(selectedBackup.volid)}>
|
<Badge variant="outline" className={getBackupStorageColor(selectedBackup.volid)}>
|
||||||
{getBackupStorageLabel(selectedBackup.volid)}
|
{getBackupStorageLabel(selectedBackup.volid)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Storage</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.storage")}</div>
|
||||||
<div className="text-sm text-foreground break-words">{selectedBackup.storage}</div>
|
<div className="text-sm text-foreground break-words">{selectedBackup.storage}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Size</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.size")}</div>
|
||||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
{selectedBackup.size_human}
|
{selectedBackup.size_human}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -1222,12 +1230,12 @@ export function SystemLogs() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Created</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.created")}</div>
|
||||||
<div className="text-sm text-foreground break-words">{selectedBackup.created}</div>
|
<div className="text-sm text-foreground break-words">{selectedBackup.created}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-2">Volume ID</div>
|
<div className="text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.volumeId")}</div>
|
||||||
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
||||||
<pre className="text-sm text-foreground font-mono whitespace-pre-wrap break-all">
|
<pre className="text-sm text-foreground font-mono whitespace-pre-wrap break-all">
|
||||||
{selectedBackup.volid}
|
{selectedBackup.volid}
|
||||||
@@ -1244,38 +1252,38 @@ export function SystemLogs() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg pr-8">
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg pr-8">
|
||||||
<Bell className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
|
<Bell className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
|
||||||
<span className="truncate">Notification Details</span>
|
<span className="truncate">{t("systemLogs.modals.notificationTitle")}</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
Complete information about this notification
|
{t("systemLogs.modals.notificationDescription")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{selectedNotification && (
|
{selectedNotification && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Type</div>
|
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.type")}</div>
|
||||||
<Badge variant="outline" className={`${getNotificationTypeColor(selectedNotification.type)} text-xs`}>
|
<Badge variant="outline" className={`${getNotificationTypeColor(selectedNotification.type)} text-xs`}>
|
||||||
{(selectedNotification.type || "unknown").toUpperCase()}
|
{selectedNotification.type ? levelLabel(selectedNotification.type) : t("app.unknown")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Timestamp</div>
|
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.timestamp")}</div>
|
||||||
<div className="text-xs sm:text-sm text-foreground font-mono break-all">
|
<div className="text-xs sm:text-sm text-foreground font-mono break-all">
|
||||||
{selectedNotification.timestamp}
|
{selectedNotification.timestamp}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Service</div>
|
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.service")}</div>
|
||||||
<div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.service}</div>
|
<div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.service}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Source</div>
|
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.source")}</div>
|
||||||
<div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.source}</div>
|
<div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.source}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-2">Message</div>
|
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.message")}</div>
|
||||||
<div className="p-3 sm:p-4 rounded-lg bg-muted/50 border border-border max-h-[180px] sm:max-h-[300px] overflow-y-auto">
|
<div className="p-3 sm:p-4 rounded-lg bg-muted/50 border border-border max-h-[180px] sm:max-h-[300px] overflow-y-auto">
|
||||||
<pre className="text-xs sm:text-sm text-foreground whitespace-pre-wrap break-all font-mono">
|
<pre className="text-xs sm:text-sm text-foreground whitespace-pre-wrap break-all font-mono">
|
||||||
{selectedNotification.message}
|
{selectedNotification.message}
|
||||||
@@ -1289,7 +1297,7 @@ export function SystemLogs() {
|
|||||||
className="border-border w-full sm:w-auto text-xs sm:text-sm h-9 sm:h-10"
|
className="border-border w-full sm:w-auto text-xs sm:text-sm h-9 sm:h-10"
|
||||||
>
|
>
|
||||||
<Download className="h-3 w-3 sm:h-4 sm:w-4 mr-2" />
|
<Download className="h-3 w-3 sm:h-4 sm:w-4 mr-2" />
|
||||||
<span className="truncate">Download Complete Message</span>
|
<span className="truncate">{t("systemLogs.download.completeMessageButton")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
|||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||||
import { formatStorage } from "../lib/utils"
|
import { formatStorage } from "../lib/utils"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
import { Area, AreaChart, ResponsiveContainer } from "recharts"
|
import { Area, AreaChart, ResponsiveContainer } from "recharts"
|
||||||
|
|
||||||
interface TempDataPoint {
|
interface TempDataPoint {
|
||||||
@@ -171,6 +172,7 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SystemOverview() {
|
export function SystemOverview() {
|
||||||
|
const t = useT()
|
||||||
const [systemData, setSystemData] = useState<SystemData | null>(null)
|
const [systemData, setSystemData] = useState<SystemData | null>(null)
|
||||||
const [vmData, setVmData] = useState<VMData[]>([])
|
const [vmData, setVmData] = useState<VMData[]>([])
|
||||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||||
@@ -205,7 +207,7 @@ export function SystemOverview() {
|
|||||||
setHasAttemptedLoad(true)
|
setHasAttemptedLoad(true)
|
||||||
|
|
||||||
if (!systemResult) {
|
if (!systemResult) {
|
||||||
setError("Flask server not available. Please ensure the server is running.")
|
setError(t("overview.errors.serverUnavailableDescription"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +263,7 @@ export function SystemOverview() {
|
|||||||
clearInterval(networkInterval)
|
clearInterval(networkInterval)
|
||||||
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
|
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
if (!hasAttemptedLoad || loadingStates.system) {
|
if (!hasAttemptedLoad || loadingStates.system) {
|
||||||
return (
|
return (
|
||||||
@@ -270,8 +272,8 @@ export function SystemOverview() {
|
|||||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-foreground">Loading system overview...</div>
|
<div className="text-sm font-medium text-foreground">{t("overview.loadingTitle")}</div>
|
||||||
<p className="text-xs text-muted-foreground">Fetching system status and metrics</p>
|
<p className="text-xs text-muted-foreground">{t("overview.loadingDescription")}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -284,9 +286,9 @@ export function SystemOverview() {
|
|||||||
<div className="flex items-center gap-3 text-red-600">
|
<div className="flex items-center gap-3 text-red-600">
|
||||||
<AlertCircle className="h-6 w-6" />
|
<AlertCircle className="h-6 w-6" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-lg mb-1">Flask Server Not Available</div>
|
<div className="font-semibold text-lg mb-1">{t("overview.errors.serverUnavailableTitle")}</div>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{error || "Unable to connect to the Flask server. Please ensure the server is running and try again."}
|
{error || t("overview.errors.serverUnavailableDescription")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,14 +307,14 @@ export function SystemOverview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getTemperatureStatus = (temp: number) => {
|
const getTemperatureStatus = (temp: number) => {
|
||||||
if (temp === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
if (temp === 0) return { status: t("app.notAvailable"), color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||||
if (temp < 60) return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
if (temp < 60) return { status: t("status.normal"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||||
if (temp < 75) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
if (temp < 75) return { status: t("status.warm"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||||
return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
return { status: t("status.hot"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatUptime = (seconds: number) => {
|
const formatUptime = (seconds: number) => {
|
||||||
if (!seconds || seconds === 0) return "Stopped"
|
if (!seconds || seconds === 0) return t("status.stopped")
|
||||||
const days = Math.floor(seconds / 86400)
|
const days = Math.floor(seconds / 86400)
|
||||||
const hours = Math.floor((seconds % 86400) / 3600)
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
const minutes = Math.floor((seconds % 3600) / 60)
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
@@ -322,6 +324,19 @@ export function SystemOverview() {
|
|||||||
return `${minutes}m`
|
return `${minutes}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatSystemUptime = (uptime: string) => {
|
||||||
|
const trimmed = uptime?.trim()
|
||||||
|
if (!trimmed) return t("app.unknown")
|
||||||
|
|
||||||
|
const dayMatch = trimmed.match(/^(\d+)\s+days?,\s*(.+)$/)
|
||||||
|
if (!dayMatch) return trimmed
|
||||||
|
|
||||||
|
const days = Number(dayMatch[1])
|
||||||
|
const dayKey = days === 1 ? "dayOne" : days >= 2 && days <= 4 ? "dayFew" : "dayMany"
|
||||||
|
|
||||||
|
return `${t(`overview.uptimeDuration.${dayKey}`, { count: days })}, ${dayMatch[2]}`
|
||||||
|
}
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
return (bytes / 1024 ** 3).toFixed(2)
|
return (bytes / 1024 ** 3).toFixed(2)
|
||||||
}
|
}
|
||||||
@@ -346,40 +361,14 @@ export function SystemOverview() {
|
|||||||
|
|
||||||
const getLoadStatus = (load: number, cores: number) => {
|
const getLoadStatus = (load: number, cores: number) => {
|
||||||
if (load < cores) {
|
if (load < cores) {
|
||||||
return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
return { status: t("status.normal"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||||
} else if (load < cores * 1.5) {
|
} else if (load < cores * 1.5) {
|
||||||
return { status: "Moderate", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
return { status: t("status.moderate"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||||
} else {
|
} else {
|
||||||
return { status: "High", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
return { status: t("status.high"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemAlerts = []
|
|
||||||
if (systemData.available_updates && systemData.available_updates > 0) {
|
|
||||||
systemAlerts.push({
|
|
||||||
type: "warning",
|
|
||||||
message: `${systemData.available_updates} updates available`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (vmStats.stopped > 0) {
|
|
||||||
systemAlerts.push({
|
|
||||||
type: "info",
|
|
||||||
message: `${vmStats.stopped} VM${vmStats.stopped > 1 ? "s" : ""} stopped`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (systemData.temperature > 75) {
|
|
||||||
systemAlerts.push({
|
|
||||||
type: "warning",
|
|
||||||
message: "High temperature detected",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (localStorage && localStorage.percent > 90) {
|
|
||||||
systemAlerts.push({
|
|
||||||
type: "warning",
|
|
||||||
message: "System storage almost full",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadStatus = getLoadStatus(systemData.load_average[0], systemData.cpu_cores || 8)
|
const loadStatus = getLoadStatus(systemData.load_average[0], systemData.cpu_cores || 8)
|
||||||
|
|
||||||
const getTimeframeLabel = (timeframe: string): string => {
|
const getTimeframeLabel = (timeframe: string): string => {
|
||||||
@@ -406,10 +395,10 @@ export function SystemOverview() {
|
|||||||
<Card
|
<Card
|
||||||
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
|
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
|
||||||
onClick={() => setCpuProcModalOpen(true)}
|
onClick={() => setCpuProcModalOpen(true)}
|
||||||
title="View top processes by CPU"
|
title={t("overview.topProcessesCpu")}
|
||||||
>
|
>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">CPU Usage</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.cpuUsage")}</CardTitle>
|
||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
<Cpu className="h-4 w-4" />
|
<Cpu className="h-4 w-4" />
|
||||||
<ChevronRight className="h-4 w-4 opacity-60" />
|
<ChevronRight className="h-4 w-4 opacity-60" />
|
||||||
@@ -427,7 +416,7 @@ export function SystemOverview() {
|
|||||||
<div className="flex-1 space-y-2 min-w-0">
|
<div className="flex-1 space-y-2 min-w-0">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">User</span>
|
<span className="text-muted-foreground">{t("overview.user")}</span>
|
||||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_user !== undefined ? `${Math.round(systemData.cpu_user)}%` : '—'}</span>
|
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_user !== undefined ? `${Math.round(systemData.cpu_user)}%` : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
@@ -436,7 +425,7 @@ export function SystemOverview() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">System</span>
|
<span className="text-muted-foreground">{t("overview.system")}</span>
|
||||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_system !== undefined ? `${Math.round(systemData.cpu_system)}%` : '—'}</span>
|
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_system !== undefined ? `${Math.round(systemData.cpu_system)}%` : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
@@ -444,7 +433,7 @@ export function SystemOverview() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Cores</span>
|
<span className="text-muted-foreground">{t("overview.cores")}</span>
|
||||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_cores ?? '—'}{systemData.cpu_threads ? `/${systemData.cpu_threads}` : ''}</span>
|
<span className="font-medium font-mono whitespace-nowrap">{systemData.cpu_cores ?? '—'}{systemData.cpu_threads ? `/${systemData.cpu_threads}` : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -456,10 +445,10 @@ export function SystemOverview() {
|
|||||||
<Card
|
<Card
|
||||||
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
|
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
|
||||||
onClick={() => setMemProcModalOpen(true)}
|
onClick={() => setMemProcModalOpen(true)}
|
||||||
title="View top processes by memory"
|
title={t("overview.topProcessesMemory")}
|
||||||
>
|
>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Memory</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.memory")}</CardTitle>
|
||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
<MemoryStick className="h-4 w-4" />
|
<MemoryStick className="h-4 w-4" />
|
||||||
<ChevronRight className="h-4 w-4 opacity-60" />
|
<ChevronRight className="h-4 w-4 opacity-60" />
|
||||||
@@ -477,7 +466,7 @@ export function SystemOverview() {
|
|||||||
<div className="flex-1 space-y-2 min-w-0">
|
<div className="flex-1 space-y-2 min-w-0">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Used</span>
|
<span className="text-muted-foreground">{t("overview.used")}</span>
|
||||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_used.toFixed(1)}</span>
|
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_used.toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
@@ -486,7 +475,7 @@ export function SystemOverview() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Cached</span>
|
<span className="text-muted-foreground">{t("overview.cached")}</span>
|
||||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_cached !== undefined ? systemData.memory_cached.toFixed(1) : '—'}</span>
|
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_cached !== undefined ? systemData.memory_cached.toFixed(1) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
@@ -494,7 +483,7 @@ export function SystemOverview() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Total</span>
|
<span className="text-muted-foreground">{t("overview.total")}</span>
|
||||||
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_total.toFixed(0)} GB</span>
|
<span className="font-medium font-mono whitespace-nowrap">{systemData.memory_total.toFixed(0)} GB</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -505,7 +494,7 @@ export function SystemOverview() {
|
|||||||
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
|
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Active VM & LXC</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.activeVmLxc")}</CardTitle>
|
||||||
<Server className="h-4 w-4 text-muted-foreground" />
|
<Server className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -521,13 +510,19 @@ export function SystemOverview() {
|
|||||||
<span className="text-4xl font-bold leading-none text-foreground">{vmStats.running}</span>
|
<span className="text-4xl font-bold leading-none text-foreground">{vmStats.running}</span>
|
||||||
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {vmStats.vms + vmStats.lxc}</span>
|
<span className="text-lg font-medium ml-1 text-muted-foreground">/ {vmStats.vms + vmStats.lxc}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">{vmStats.running} running</Badge>
|
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
|
{t("overview.runningCount", { count: vmStats.running })}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-1 flex-wrap">
|
<div className="mt-3 flex gap-1 flex-wrap">
|
||||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">{vmStats.vms} VMs</Badge>
|
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
|
{t("overview.vmsCount", { count: vmStats.vms })}
|
||||||
|
</Badge>
|
||||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">{vmStats.lxc} LXC</Badge>
|
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">{vmStats.lxc} LXC</Badge>
|
||||||
{vmStats.stopped > 0 && (
|
{vmStats.stopped > 0 && (
|
||||||
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">{vmStats.stopped} stopped</Badge>
|
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">
|
||||||
|
{t("overview.stoppedCount", { count: vmStats.stopped })}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -540,7 +535,7 @@ export function SystemOverview() {
|
|||||||
onClick={() => systemData.temperature > 0 && setTempModalOpen(true)}
|
onClick={() => systemData.temperature > 0 && setTempModalOpen(true)}
|
||||||
>
|
>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Temperature</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.temperature")}</CardTitle>
|
||||||
<div className="flex items-center gap-1 text-muted-foreground">
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
<Thermometer className="h-4 w-4" />
|
<Thermometer className="h-4 w-4" />
|
||||||
{systemData.temperature > 0 && (
|
{systemData.temperature > 0 && (
|
||||||
@@ -551,7 +546,7 @@ export function SystemOverview() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xl lg:text-2xl font-bold text-foreground">
|
<span className="text-xl lg:text-2xl font-bold text-foreground">
|
||||||
{systemData.temperature === 0 ? "N/A" : `${Math.round(systemData.temperature * 10) / 10}°C`}
|
{systemData.temperature === 0 ? t("app.notAvailable") : `${Math.round(systemData.temperature * 10) / 10}°C`}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="outline" className={`${tempStatus.color}`}>
|
<Badge variant="outline" className={`${tempStatus.color}`}>
|
||||||
{tempStatus.status}
|
{tempStatus.status}
|
||||||
@@ -581,7 +576,7 @@ export function SystemOverview() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
{systemData.temperature === 0 ? "No sensor available" : "Collecting data..."}
|
{systemData.temperature === 0 ? t("overview.noSensorAvailable") : t("overview.collectingData")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -613,7 +608,7 @@ export function SystemOverview() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-foreground flex items-center">
|
||||||
<HardDrive className="h-5 w-5 mr-2" />
|
<HardDrive className="h-5 w-5 mr-2" />
|
||||||
Storage Overview
|
{t("overview.storageOverview")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -634,7 +629,7 @@ export function SystemOverview() {
|
|||||||
return totalCapacity > 0 ? (
|
return totalCapacity > 0 ? (
|
||||||
<div className="space-y-2 pb-4 border-b-2 border-border">
|
<div className="space-y-2 pb-4 border-b-2 border-border">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm font-medium text-foreground">Total Node Capacity:</span>
|
<span className="text-sm font-medium text-foreground">{t("overview.totalNodeCapacity")}</span>
|
||||||
<span className="text-lg font-bold text-foreground">
|
<span className="text-lg font-bold text-foreground">
|
||||||
{formatStorage(totalCapacity)}
|
{formatStorage(totalCapacity)}
|
||||||
</span>
|
</span>
|
||||||
@@ -646,13 +641,13 @@ export function SystemOverview() {
|
|||||||
<div className="flex justify-between items-center mt-1">
|
<div className="flex justify-between items-center mt-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Used:{" "}
|
{t("overview.used")}:{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{formatStorage(totalUsed)}
|
{formatStorage(totalUsed)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Free:{" "}
|
{t("overview.free")}:{" "}
|
||||||
<span className="font-semibold text-green-500">
|
<span className="font-semibold text-green-500">
|
||||||
{formatStorage(totalAvailable)}
|
{formatStorage(totalAvailable)}
|
||||||
</span>
|
</span>
|
||||||
@@ -666,28 +661,28 @@ export function SystemOverview() {
|
|||||||
|
|
||||||
<div className="space-y-2 pb-3 border-b border-border">
|
<div className="space-y-2 pb-3 border-b border-border">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-muted-foreground">Total Capacity:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.totalCapacity")}</span>
|
||||||
<span className="text-lg font-semibold text-foreground">{storageData.total} TB</span>
|
<span className="text-lg font-semibold text-foreground">{storageData.total} TB</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-muted-foreground">Physical Disks:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.physicalDisks")}</span>
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-semibold text-foreground">
|
||||||
{storageData.disk_count} disk{storageData.disk_count !== 1 ? "s" : ""}
|
{storageData.disk_count} {storageData.disk_count === 1 ? t("overview.diskSingular") : t("overview.diskPlural")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{vmLxcStorages && vmLxcStorages.length > 0 ? (
|
{vmLxcStorages && vmLxcStorages.length > 0 ? (
|
||||||
<div className="space-y-2 pb-3 border-b border-border">
|
<div className="space-y-2 pb-3 border-b border-border">
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-2">VM/LXC Storage</div>
|
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-xs text-muted-foreground">Used:</span>
|
<span className="text-xs text-muted-foreground">{t("overview.used")}:</span>
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-semibold text-foreground">
|
||||||
{formatStorage(vmLxcStorageUsed)}
|
{formatStorage(vmLxcStorageUsed)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-xs text-muted-foreground">Available:</span>
|
<span className="text-xs text-muted-foreground">{t("overview.available")}:</span>
|
||||||
<span className="text-sm font-semibold text-green-500">
|
<span className="text-sm font-semibold text-green-500">
|
||||||
{formatStorage(vmLxcStorageAvailable)}
|
{formatStorage(vmLxcStorageAvailable)}
|
||||||
</span>
|
</span>
|
||||||
@@ -702,28 +697,28 @@ export function SystemOverview() {
|
|||||||
</div>
|
</div>
|
||||||
{vmLxcStorages.length > 1 && (
|
{vmLxcStorages.length > 1 && (
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
{vmLxcStorages.length} storage volume{vmLxcStorages.length > 1 ? "s" : ""}
|
{vmLxcStorages.length} {vmLxcStorages.length === 1 ? t("overview.storageVolumeSingular") : t("overview.storageVolumePlural")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 pb-3 border-b border-border">
|
<div className="space-y-2 pb-3 border-b border-border">
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-2">VM/LXC Storage</div>
|
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
|
||||||
<div className="text-center py-4 text-muted-foreground text-sm">No VM/LXC storage configured</div>
|
<div className="text-center py-4 text-muted-foreground text-sm">{t("overview.noVmLxcStorage")}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{localStorage && (
|
{localStorage && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-2">Local Storage (System)</div>
|
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.localStorageSystem")}</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-xs text-muted-foreground">Used:</span>
|
<span className="text-xs text-muted-foreground">{t("overview.used")}:</span>
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-semibold text-foreground">
|
||||||
{formatStorage(localStorage.used)}
|
{formatStorage(localStorage.used)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-xs text-muted-foreground">Available:</span>
|
<span className="text-xs text-muted-foreground">{t("overview.available")}:</span>
|
||||||
<span className="text-sm font-semibold text-green-500">
|
<span className="text-sm font-semibold text-green-500">
|
||||||
{formatStorage(localStorage.available)}
|
{formatStorage(localStorage.available)}
|
||||||
</span>
|
</span>
|
||||||
@@ -740,7 +735,7 @@ export function SystemOverview() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-muted-foreground">Storage data not available</div>
|
<div className="text-center py-8 text-muted-foreground">{t("overview.storageDataUnavailable")}</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -750,18 +745,18 @@ export function SystemOverview() {
|
|||||||
<CardTitle className="text-foreground flex items-center justify-between">
|
<CardTitle className="text-foreground flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Network className="h-5 w-5 mr-2" />
|
<Network className="h-5 w-5 mr-2" />
|
||||||
Network Overview
|
{t("overview.networkOverview")}
|
||||||
</div>
|
</div>
|
||||||
<Select value={networkTimeframe} onValueChange={setNetworkTimeframe}>
|
<Select value={networkTimeframe} onValueChange={setNetworkTimeframe}>
|
||||||
<SelectTrigger className="w-28 h-8 text-xs">
|
<SelectTrigger className="w-28 h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="hour">1 Hour</SelectItem>
|
<SelectItem value="hour">{t("overview.timeframes.hour")}</SelectItem>
|
||||||
<SelectItem value="day">24 Hours</SelectItem>
|
<SelectItem value="day">{t("overview.timeframes.day")}</SelectItem>
|
||||||
<SelectItem value="week">7 Days</SelectItem>
|
<SelectItem value="week">{t("overview.timeframes.week")}</SelectItem>
|
||||||
<SelectItem value="month">30 Days</SelectItem>
|
<SelectItem value="month">{t("overview.timeframes.month")}</SelectItem>
|
||||||
<SelectItem value="year">1 Year</SelectItem>
|
<SelectItem value="year">{t("overview.timeframes.year")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -776,7 +771,7 @@ export function SystemOverview() {
|
|||||||
) : networkData ? (
|
) : networkData ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||||
<span className="text-sm text-muted-foreground">Active Interfaces:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.activeInterfaces")}</span>
|
||||||
<span className="text-lg font-semibold text-foreground">
|
<span className="text-lg font-semibold text-foreground">
|
||||||
{(networkData.physical_active_count || 0) + (networkData.bridge_active_count || 0)}
|
{(networkData.physical_active_count || 0) + (networkData.bridge_active_count || 0)}
|
||||||
</span>
|
</span>
|
||||||
@@ -818,7 +813,7 @@ export function SystemOverview() {
|
|||||||
|
|
||||||
<div className="pt-2 border-t border-border space-y-2">
|
<div className="pt-2 border-t border-border space-y-2">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-muted-foreground">Received:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.received")}</span>
|
||||||
<span className="text-lg font-semibold text-green-500 flex items-center gap-1">
|
<span className="text-lg font-semibold text-green-500 flex items-center gap-1">
|
||||||
↓{" "}
|
↓{" "}
|
||||||
{networkUnit === "Bytes"
|
{networkUnit === "Bytes"
|
||||||
@@ -828,7 +823,7 @@ export function SystemOverview() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-muted-foreground">Sent:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.sent")}</span>
|
||||||
<span className="text-lg font-semibold text-blue-500 flex items-center gap-1">
|
<span className="text-lg font-semibold text-blue-500 flex items-center gap-1">
|
||||||
↑{" "}
|
↑{" "}
|
||||||
{networkUnit === "Bytes"
|
{networkUnit === "Bytes"
|
||||||
@@ -848,7 +843,7 @@ export function SystemOverview() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-muted-foreground">Network data not available</div>
|
<div className="text-center py-8 text-muted-foreground">{t("overview.networkDataUnavailable")}</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -859,27 +854,27 @@ export function SystemOverview() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-foreground flex items-center">
|
||||||
<Server className="h-5 w-5 mr-2" />
|
<Server className="h-5 w-5 mr-2" />
|
||||||
System Information
|
{t("overview.systemInformation")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Uptime:</span>
|
<span className="text-muted-foreground">{t("overview.uptime")}</span>
|
||||||
<span className="text-foreground">{systemData.uptime}</span>
|
<span className="text-foreground">{formatSystemUptime(systemData.uptime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Proxmox Version:</span>
|
<span className="text-muted-foreground">{t("overview.proxmoxVersion")}</span>
|
||||||
<span className="text-foreground">{systemData.proxmox_version || "N/A"}</span>
|
<span className="text-foreground">{systemData.proxmox_version || "N/A"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Kernel:</span>
|
<span className="text-muted-foreground">{t("overview.kernel")}</span>
|
||||||
<span className="text-foreground font-mono text-sm">{systemData.kernel_version || "Linux"}</span>
|
<span className="text-foreground font-mono text-sm">{systemData.kernel_version || "Linux"}</span>
|
||||||
</div>
|
</div>
|
||||||
{systemData.available_updates !== undefined && systemData.available_updates > 0 && (
|
{systemData.available_updates !== undefined && systemData.available_updates > 0 && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Available Updates:</span>
|
<span className="text-muted-foreground">{t("overview.availableUpdates")}</span>
|
||||||
<Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">
|
<Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">
|
||||||
{systemData.available_updates} packages
|
{systemData.available_updates} {t("overview.packages")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -890,13 +885,13 @@ export function SystemOverview() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-foreground flex items-center">
|
||||||
<Zap className="h-5 w-5 mr-2" />
|
<Zap className="h-5 w-5 mr-2" />
|
||||||
System Overview
|
{t("overview.systemOverview")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm text-muted-foreground">Load Average (1m):</span>
|
<span className="text-sm text-muted-foreground">{t("overview.loadAverage1m")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-lg font-semibold text-foreground font-mono">
|
<span className="text-lg font-semibold text-foreground font-mono">
|
||||||
@@ -909,17 +904,17 @@ export function SystemOverview() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||||
<span className="text-sm text-muted-foreground">CPU Threads:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.cpuThreads")}</span>
|
||||||
<span className="text-lg font-semibold text-foreground">{systemData.cpu_threads || "N/A"}</span>
|
<span className="text-lg font-semibold text-foreground">{systemData.cpu_threads || "N/A"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||||
<span className="text-sm text-muted-foreground">Physical Disks:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.physicalDisks")}</span>
|
||||||
<span className="text-lg font-semibold text-foreground">{storageData?.disk_count || "N/A"}</span>
|
<span className="text-lg font-semibold text-foreground">{storageData?.disk_count || "N/A"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-muted-foreground">Network Interfaces:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.networkInterfaces")}</span>
|
||||||
<span className="text-lg font-semibold text-foreground">
|
<span className="text-lg font-semibold text-foreground">
|
||||||
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || "N/A"}
|
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || "N/A"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import { Thermometer, TrendingDown, TrendingUp, Minus } from "lucide-react"
|
|||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||||
import { useIsMobile } from "../hooks/use-mobile"
|
import { useIsMobile } from "../hooks/use-mobile"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
const TIMEFRAME_OPTIONS = [
|
const TIMEFRAME_OPTIONS = [
|
||||||
{ value: "hour", label: "1 Hour" },
|
{ value: "hour", labelKey: "overview.timeframes.hour" },
|
||||||
{ value: "day", label: "24 Hours" },
|
{ value: "day", labelKey: "overview.timeframes.day" },
|
||||||
{ value: "week", label: "7 Days" },
|
{ value: "week", labelKey: "overview.timeframes.week" },
|
||||||
{ value: "month", label: "30 Days" },
|
{ value: "month", labelKey: "overview.timeframes.month" },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface TempHistoryPoint {
|
interface TempHistoryPoint {
|
||||||
@@ -70,6 +71,7 @@ const getStatusInfo = (temp: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }: TemperatureDetailModalProps) {
|
export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }: TemperatureDetailModalProps) {
|
||||||
|
const t = useT()
|
||||||
// Default to 24 h — matches the disk temperature modal and is the
|
// Default to 24 h — matches the disk temperature modal and is the
|
||||||
// useful timeframe for spotting trends; the 1-h view rarely tells
|
// useful timeframe for spotting trends; the 1-h view rarely tells
|
||||||
// you anything that the live reading doesn't already show.
|
// you anything that the live reading doesn't already show.
|
||||||
@@ -138,7 +140,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
|||||||
<div className="flex items-center justify-between pr-6">
|
<div className="flex items-center justify-between pr-6">
|
||||||
<DialogTitle className="text-foreground flex items-center gap-2">
|
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||||
<Thermometer className="h-5 w-5" />
|
<Thermometer className="h-5 w-5" />
|
||||||
CPU Temperature
|
{t("details.temperature.title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<Select value={timeframe} onValueChange={setTimeframe}>
|
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||||
<SelectTrigger className="w-[130px] bg-card border-border">
|
<SelectTrigger className="w-[130px] bg-card border-border">
|
||||||
@@ -147,7 +149,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TIMEFRAME_OPTIONS.map((opt) => (
|
{TIMEFRAME_OPTIONS.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{t(opt.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -158,24 +160,24 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
|||||||
{/* Stats bar */}
|
{/* Stats bar */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
|
||||||
<div className={`rounded-lg p-3 text-center ${currentStatus.color}`}>
|
<div className={`rounded-lg p-3 text-center ${currentStatus.color}`}>
|
||||||
<div className="text-xs opacity-80 mb-1">Current</div>
|
<div className="text-xs opacity-80 mb-1">{t("details.temperature.current")}</div>
|
||||||
<div className="text-lg font-bold">{currentTemp}°C</div>
|
<div className="text-lg font-bold">{currentTemp}°C</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||||
<TrendingDown className="h-3 w-3" /> Min
|
<TrendingDown className="h-3 w-3" /> {t("details.temperature.min")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
|
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||||
<Minus className="h-3 w-3" /> Avg
|
<Minus className="h-3 w-3" /> {t("details.temperature.avg")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
|
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||||
<TrendingUp className="h-3 w-3" /> Max
|
<TrendingUp className="h-3 w-3" /> {t("details.temperature.max")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
|
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,8 +196,8 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
|||||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
<p>No temperature data available for this period</p>
|
<p>{t("details.temperature.noData")}</p>
|
||||||
<p className="text-sm mt-1">Data is collected every 60 seconds</p>
|
<p className="text-sm mt-1">{t("details.temperature.collectionHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -228,7 +230,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
|||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
name="Temperature"
|
name={t("details.temperature.seriesName")}
|
||||||
stroke={chartColor}
|
stroke={chartColor}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="url(#tempGradient)"
|
fill="url(#tempGradient)"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||||
import type { CheatSheetResult } from "@/lib/cheat-sheet-result" // Declare CheatSheetResult here
|
import type { CheatSheetResult } from "@/lib/cheat-sheet-result" // Declare CheatSheetResult here
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
type TerminalPanelProps = {
|
type TerminalPanelProps = {
|
||||||
websocketUrl?: string
|
websocketUrl?: string
|
||||||
@@ -78,74 +79,30 @@ function getApiUrl(endpoint?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const proxmoxCommands = [
|
const proxmoxCommands = [
|
||||||
{ cmd: "pvesh get /nodes", desc: "List all Proxmox nodes" },
|
"pvesh get /nodes", "pvesh get /nodes/{node}/qemu", "pvesh get /nodes/{node}/lxc",
|
||||||
{ cmd: "pvesh get /nodes/{node}/qemu", desc: "List VMs on a node" },
|
"pvesh get /nodes/{node}/storage", "pvesh get /nodes/{node}/network", "qm list",
|
||||||
{ cmd: "pvesh get /nodes/{node}/lxc", desc: "List LXC containers on a node" },
|
"qm start <vmid>", "qm stop <vmid>", "qm shutdown <vmid>", "qm status <vmid>",
|
||||||
{ cmd: "pvesh get /nodes/{node}/storage", desc: "List storage on a node" },
|
"qm config <vmid>", "qm snapshot <vmid> <snapname>", "pct list", "pct start <vmid>",
|
||||||
{ cmd: "pvesh get /nodes/{node}/network", desc: "List network interfaces" },
|
"pct stop <vmid>", "pct enter <vmid>", "pct config <vmid>", "pvesm status",
|
||||||
{ cmd: "qm list", desc: "List all QEMU/KVM virtual machines" },
|
"pvesm list <storage>", "pveperf", "pveversion", "systemctl status pve-cluster",
|
||||||
{ cmd: "qm start <vmid>", desc: "Start a virtual machine" },
|
"pvecm status", "pvecm nodes", "zpool status", "zpool list", "zfs list", "ls -la",
|
||||||
{ cmd: "qm stop <vmid>", desc: "Stop a virtual machine" },
|
"cd /path/to/dir", "mkdir dirname", "rm -rf dirname", "cp source dest", "mv source dest",
|
||||||
{ cmd: "qm shutdown <vmid>", desc: "Shutdown a virtual machine gracefully" },
|
"cat filename", "grep 'pattern' file", "find . -name 'file'", "chmod 755 file",
|
||||||
{ cmd: "qm status <vmid>", desc: "Show VM status" },
|
"chown user:group file", "tar -xzf file.tar.gz", "tar -czf archive.tar.gz dir/", "df -h",
|
||||||
{ cmd: "qm config <vmid>", desc: "Show VM configuration" },
|
"du -sh *", "free -h", "top", "ps aux | grep process", "kill -9 PID",
|
||||||
{ cmd: "qm snapshot <vmid> <snapname>", desc: "Create VM snapshot" },
|
"systemctl status service", "systemctl start service", "systemctl stop service",
|
||||||
{ cmd: "pct list", desc: "List all LXC containers" },
|
"systemctl restart service", "apt update && apt upgrade", "apt install package",
|
||||||
{ cmd: "pct start <vmid>", desc: "Start LXC container" },
|
"apt remove package", "docker ps", "docker images", "docker exec -it container bash",
|
||||||
{ cmd: "pct stop <vmid>", desc: "Stop LXC container" },
|
"ip addr show", "ping host", "curl -I url", "wget url", "ssh user@host",
|
||||||
{ cmd: "pct enter <vmid>", desc: "Enter LXC container console" },
|
"scp file user@host:/path", "tail -f /var/log/syslog", "history", "clear",
|
||||||
{ cmd: "pct config <vmid>", desc: "Show container configuration" },
|
|
||||||
{ cmd: "pvesm status", desc: "Show storage status" },
|
|
||||||
{ cmd: "pvesm list <storage>", desc: "List storage content" },
|
|
||||||
{ cmd: "pveperf", desc: "Test Proxmox system performance" },
|
|
||||||
{ cmd: "pveversion", desc: "Show Proxmox VE version" },
|
|
||||||
{ cmd: "systemctl status pve-cluster", desc: "Check cluster status" },
|
|
||||||
{ cmd: "pvecm status", desc: "Show cluster status" },
|
|
||||||
{ cmd: "pvecm nodes", desc: "List cluster nodes" },
|
|
||||||
{ cmd: "zpool status", desc: "Show ZFS pool status" },
|
|
||||||
{ cmd: "zpool list", desc: "List all ZFS pools" },
|
|
||||||
{ cmd: "zfs list", desc: "List all ZFS datasets" },
|
|
||||||
{ cmd: "ls -la", desc: "List all files with details" },
|
|
||||||
{ cmd: "cd /path/to/dir", desc: "Change directory" },
|
|
||||||
{ cmd: "mkdir dirname", desc: "Create new directory" },
|
|
||||||
{ cmd: "rm -rf dirname", desc: "Remove directory recursively" },
|
|
||||||
{ cmd: "cp source dest", desc: "Copy files or directories" },
|
|
||||||
{ cmd: "mv source dest", desc: "Move or rename files" },
|
|
||||||
{ cmd: "cat filename", desc: "Display file contents" },
|
|
||||||
{ cmd: "grep 'pattern' file", desc: "Search for pattern in file" },
|
|
||||||
{ cmd: "find . -name 'file'", desc: "Find files by name" },
|
|
||||||
{ cmd: "chmod 755 file", desc: "Change file permissions" },
|
|
||||||
{ cmd: "chown user:group file", desc: "Change file owner" },
|
|
||||||
{ cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" },
|
|
||||||
{ cmd: "tar -czf archive.tar.gz dir/", desc: "Create tar.gz archive" },
|
|
||||||
{ cmd: "df -h", desc: "Show disk usage" },
|
|
||||||
{ cmd: "du -sh *", desc: "Show directory sizes" },
|
|
||||||
{ cmd: "free -h", desc: "Show memory usage" },
|
|
||||||
{ cmd: "top", desc: "Show running processes" },
|
|
||||||
{ cmd: "ps aux | grep process", desc: "Find running process" },
|
|
||||||
{ cmd: "kill -9 PID", desc: "Force kill process" },
|
|
||||||
{ cmd: "systemctl status service", desc: "Check service status" },
|
|
||||||
{ cmd: "systemctl start service", desc: "Start a service" },
|
|
||||||
{ cmd: "systemctl stop service", desc: "Stop a service" },
|
|
||||||
{ cmd: "systemctl restart service", desc: "Restart a service" },
|
|
||||||
{ cmd: "apt update && apt upgrade", desc: "Update Debian/Ubuntu packages" },
|
|
||||||
{ cmd: "apt install package", desc: "Install package on Debian/Ubuntu" },
|
|
||||||
{ cmd: "apt remove package", desc: "Remove package" },
|
|
||||||
{ cmd: "docker ps", desc: "List running containers" },
|
|
||||||
{ cmd: "docker images", desc: "List Docker images" },
|
|
||||||
{ cmd: "docker exec -it container bash", desc: "Enter container shell" },
|
|
||||||
{ cmd: "ip addr show", desc: "Show IP addresses" },
|
|
||||||
{ cmd: "ping host", desc: "Test network connectivity" },
|
|
||||||
{ cmd: "curl -I url", desc: "Get HTTP headers" },
|
|
||||||
{ cmd: "wget url", desc: "Download file from URL" },
|
|
||||||
{ cmd: "ssh user@host", desc: "Connect via SSH" },
|
|
||||||
{ cmd: "scp file user@host:/path", desc: "Copy file via SSH" },
|
|
||||||
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file in real-time" },
|
|
||||||
{ cmd: "history", desc: "Show command history" },
|
|
||||||
{ cmd: "clear", desc: "Clear terminal screen" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onClose }) => {
|
export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onClose }) => {
|
||||||
|
const t = useT()
|
||||||
|
const localizedCommands = proxmoxCommands.map((cmd, index) => ({
|
||||||
|
cmd,
|
||||||
|
desc: t(`terminal.commandDescriptions.${index}`),
|
||||||
|
}))
|
||||||
const [terminals, setTerminals] = useState<TerminalInstance[]>([])
|
const [terminals, setTerminals] = useState<TerminalInstance[]>([])
|
||||||
const [activeTerminalId, setActiveTerminalId] = useState<string>("")
|
const [activeTerminalId, setActiveTerminalId] = useState<string>("")
|
||||||
const [layout, setLayout] = useState<"single" | "grid">("grid")
|
const [layout, setLayout] = useState<"single" | "grid">("grid")
|
||||||
@@ -154,7 +111,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
const [terminalHeight, setTerminalHeight] = useState<number>(500) // altura por defecto en px
|
const [terminalHeight, setTerminalHeight] = useState<number>(500) // altura por defecto en px
|
||||||
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands)
|
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(localizedCommands)
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
||||||
const [useOnline, setUseOnline] = useState(true)
|
const [useOnline, setUseOnline] = useState(true)
|
||||||
@@ -272,7 +229,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
const searchCheatSh = async (query: string) => {
|
const searchCheatSh = async (query: string) => {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
setFilteredCommands(proxmoxCommands)
|
setFilteredCommands(localizedCommands)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +244,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!data.success || !data.examples || data.examples.length === 0) {
|
if (!data.success || !data.examples || data.examples.length === 0) {
|
||||||
throw new Error("No examples found")
|
throw new Error(t("terminal.noExamplesFound"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -300,7 +257,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
setUseOnline(true)
|
setUseOnline(true)
|
||||||
setSearchResults(formattedResults)
|
setSearchResults(formattedResults)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const filtered = proxmoxCommands.filter(
|
const filtered = localizedCommands.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
item.desc.toLowerCase().includes(query.toLowerCase()),
|
item.desc.toLowerCase().includes(query.toLowerCase()),
|
||||||
@@ -318,12 +275,12 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
searchCheatSh(searchQuery)
|
searchCheatSh(searchQuery)
|
||||||
} else {
|
} else {
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
setFilteredCommands(proxmoxCommands)
|
setFilteredCommands(localizedCommands)
|
||||||
}
|
}
|
||||||
}, 800)
|
}, 800)
|
||||||
|
|
||||||
return () => clearTimeout(debounce)
|
return () => clearTimeout(debounce)
|
||||||
}, [searchQuery])
|
}, [searchQuery, t])
|
||||||
|
|
||||||
// Function to reconnect a terminal when connection is lost
|
// Function to reconnect a terminal when connection is lost
|
||||||
// This is called when page visibility changes (user returns from another app)
|
// This is called when page visibility changes (user returns from another app)
|
||||||
@@ -332,7 +289,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
if (!terminal || !terminal.term) return
|
if (!terminal || !terminal.term) return
|
||||||
|
|
||||||
// Show reconnecting message
|
// Show reconnecting message
|
||||||
terminal.term.writeln('\r\n\x1b[33m[INFO] Reconnecting...\x1b[0m')
|
terminal.term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.reconnecting")}\x1b[0m`)
|
||||||
|
|
||||||
const wsUrl = websocketUrl || getWebSocketUrl()
|
const wsUrl = websocketUrl || getWebSocketUrl()
|
||||||
// Append the single-use auth ticket so the backend handshake can validate.
|
// Append the single-use auth ticket so the backend handshake can validate.
|
||||||
@@ -358,7 +315,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
setTerminals((prev) =>
|
setTerminals((prev) =>
|
||||||
prev.map((t) => (t.id === terminalId ? { ...t, isConnected: true, ws, pingInterval } : t))
|
prev.map((t) => (t.id === terminalId ? { ...t, isConnected: true, ws, pingInterval } : t))
|
||||||
)
|
)
|
||||||
terminal.term.writeln('\r\n\x1b[32m[INFO] Reconnected successfully\x1b[0m')
|
terminal.term.writeln(`\r\n\x1b[32m[INFO] ${t("terminal.reconnected")}\x1b[0m`)
|
||||||
|
|
||||||
// Sync terminal size
|
// Sync terminal size
|
||||||
if (terminal.fitAddon) {
|
if (terminal.fitAddon) {
|
||||||
@@ -384,7 +341,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
terminal.term.writeln('\r\n\x1b[31m[ERROR] Reconnection failed\x1b[0m')
|
terminal.term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.reconnectionFailed")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
@@ -397,7 +354,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
}
|
}
|
||||||
return t
|
return t
|
||||||
}))
|
}))
|
||||||
terminal.term.writeln('\r\n\x1b[33m[INFO] Connection closed\x1b[0m')
|
terminal.term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.term.onData((data: string) => {
|
terminal.term.onData((data: string) => {
|
||||||
@@ -415,7 +372,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: newId,
|
id: newId,
|
||||||
title: `Terminal ${prev.length + 1}`,
|
title: t("terminal.terminalTitle", { number: prev.length + 1 }),
|
||||||
term: null,
|
term: null,
|
||||||
ws: null,
|
ws: null,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
@@ -570,8 +527,8 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
if (ws.readyState !== WebSocket.OPEN) {
|
if (ws.readyState !== WebSocket.OPEN) {
|
||||||
connectionTimedOut = true
|
connectionTimedOut = true
|
||||||
ws.close()
|
ws.close()
|
||||||
term.writeln('\x1b[31m[ERROR] Connection timeout. Please check your network and try again.\x1b[0m')
|
term.writeln(`\x1b[31m[ERROR] ${t("terminal.connectionTimeout")}\x1b[0m`)
|
||||||
term.writeln('\x1b[33m[TIP] If using VPN, ensure the connection is stable.\x1b[0m')
|
term.writeln(`\x1b[33m[TIP] ${t("terminal.vpnTip")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
}, connectionTimeout)
|
}, connectionTimeout)
|
||||||
|
|
||||||
@@ -636,7 +593,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
}))
|
}))
|
||||||
// Only show error if not already shown by timeout
|
// Only show error if not already shown by timeout
|
||||||
if (!connectionTimedOut) {
|
if (!connectionTimedOut) {
|
||||||
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
|
term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.websocketError")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,7 +610,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
}))
|
}))
|
||||||
// Only show close message if not already shown by timeout
|
// Only show close message if not already shown by timeout
|
||||||
if (!connectionTimedOut) {
|
if (!connectionTimedOut) {
|
||||||
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
|
term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,9 +773,9 @@ const handleClose = () => {
|
|||||||
<Activity className="h-5 w-5 text-blue-500" />
|
<Activity className="h-5 w-5 text-blue-500" />
|
||||||
<div
|
<div
|
||||||
className={`w-2 h-2 rounded-full ${activeTerminal?.isConnected ? "bg-green-500" : "bg-red-500"}`}
|
className={`w-2 h-2 rounded-full ${activeTerminal?.isConnected ? "bg-green-500" : "bg-red-500"}`}
|
||||||
title={activeTerminal?.isConnected ? "Connected" : "Disconnected"}
|
title={activeTerminal?.isConnected ? t("terminal.connected") : t("terminal.disconnected")}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-xs text-zinc-500">{terminals.length} / 4 terminals</span>
|
<span className="text-xs text-zinc-500">{t("terminal.terminalCount", { count: terminals.length })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -829,7 +786,7 @@ const handleClose = () => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={`h-8 px-2 ${layout === "single" ? "bg-blue-500/20 border-blue-500" : ""}`}
|
className={`h-8 px-2 ${layout === "single" ? "bg-blue-500/20 border-blue-500" : ""}`}
|
||||||
title="Vista apilada (filas)"
|
title={t("terminal.stackedLayout")}
|
||||||
>
|
>
|
||||||
<AlignJustify className="h-4 w-4" />
|
<AlignJustify className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -838,7 +795,7 @@ const handleClose = () => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={`h-8 px-2 ${layout === "grid" ? "bg-blue-500/20 border-blue-500" : ""}`}
|
className={`h-8 px-2 ${layout === "grid" ? "bg-blue-500/20 border-blue-500" : ""}`}
|
||||||
title="Vista cuadrícula 2x2"
|
title={t("terminal.gridLayout")}
|
||||||
>
|
>
|
||||||
<Grid2X2 className="h-4 w-4" />
|
<Grid2X2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -852,7 +809,7 @@ const handleClose = () => {
|
|||||||
className="h-8 gap-2 bg-green-600/20 hover:bg-green-600/30 border-green-600/50 text-green-400 disabled:opacity-50"
|
className="h-8 gap-2 bg-green-600/20 hover:bg-green-600/30 border-green-600/50 text-green-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">New</span>
|
<span className="hidden sm:inline">{t("terminal.new")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setSearchModalOpen(true)}
|
onClick={() => setSearchModalOpen(true)}
|
||||||
@@ -862,7 +819,7 @@ const handleClose = () => {
|
|||||||
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
|
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Search</span>
|
<span className="hidden sm:inline">{t("terminal.search")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
@@ -872,7 +829,7 @@ const handleClose = () => {
|
|||||||
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
|
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Clear</span>
|
<span className="hidden sm:inline">{t("terminal.clear")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
@@ -881,7 +838,7 @@ const handleClose = () => {
|
|||||||
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Close</span>
|
<span className="hidden sm:inline">{t("actions.close")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1075,29 +1032,29 @@ const handleClose = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onSelect={() => sendSequence("\x03")}>
|
<DropdownMenuItem onSelect={() => sendSequence("\x03")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||||
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => sendSequence("\x18")}>
|
<DropdownMenuItem onSelect={() => sendSequence("\x18")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||||
<span className="text-muted-foreground text-xs">Exit (nano)</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => sendSequence("\x12")}>
|
<DropdownMenuItem onSelect={() => sendSequence("\x12")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
||||||
<span className="text-muted-foreground text-xs">Search history</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
||||||
<Copy className="h-3.5 w-3.5 mr-2" />
|
<Copy className="h-3.5 w-3.5 mr-2" />
|
||||||
<span className="text-xs">Copy selection</span>
|
<span className="text-xs">{t("scriptTerminal.copySelection")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
||||||
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
||||||
<span className="text-xs">Paste</span>
|
<span className="text-xs">{t("scriptTerminal.paste")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -1107,22 +1064,22 @@ const handleClose = () => {
|
|||||||
<Dialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
<Dialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
||||||
<DialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
<DialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
|
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
|
||||||
<DialogTitle className="text-xl font-semibold">Search Commands</DialogTitle>
|
<DialogTitle className="text-xl font-semibold">{t("terminal.searchCommands")}</DialogTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
|
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
|
||||||
title={useOnline ? "Online - Using cheat.sh API" : "Offline - Using local commands"}
|
title={useOnline ? t("terminal.onlineSource") : t("terminal.offlineSource")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogDescription className="sr-only">Search for Linux and Proxmox commands</DialogDescription>
|
<DialogDescription className="sr-only">{t("terminal.searchDescription")}</DialogDescription>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search commands... (e.g., tar, docker, qm, systemctl)"
|
placeholder={t("terminal.searchPlaceholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
|
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
|
||||||
@@ -1136,7 +1093,7 @@ const handleClose = () => {
|
|||||||
{isSearching && (
|
{isSearching && (
|
||||||
<div className="text-center py-4 text-zinc-400">
|
<div className="text-center py-4 text-zinc-400">
|
||||||
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
|
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
|
||||||
<p className="text-sm">Searching cheat.sh...</p>
|
<p className="text-sm">{t("terminal.searchingCheatSh")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1164,7 +1121,7 @@ const handleClose = () => {
|
|||||||
<div className="text-center py-2">
|
<div className="text-center py-2">
|
||||||
<p className="text-xs text-zinc-500">
|
<p className="text-xs text-zinc-500">
|
||||||
<Lightbulb className="inline-block w-3 h-3 mr-1" />
|
<Lightbulb className="inline-block w-3 h-3 mr-1" />
|
||||||
Powered by cheat.sh
|
{t("terminal.poweredByCheatSh")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -1190,13 +1147,13 @@ const handleClose = () => {
|
|||||||
className="shrink-0 h-7 px-2 text-xs"
|
className="shrink-0 h-7 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<Send className="h-3 w-3 mr-1" />
|
<Send className="h-3 w-3 mr-1" />
|
||||||
Send
|
{t("terminal.send")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : !isSearching && !searchQuery && !useOnline ? (
|
) : !isSearching && !searchQuery && !useOnline ? (
|
||||||
proxmoxCommands.map((item, index) => (
|
localizedCommands.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => sendToActiveTerminal(item.cmd)}
|
onClick={() => sendToActiveTerminal(item.cmd)}
|
||||||
@@ -1217,7 +1174,7 @@ const handleClose = () => {
|
|||||||
className="shrink-0 h-7 px-2 text-xs"
|
className="shrink-0 h-7 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<Send className="h-3 w-3 mr-1" />
|
<Send className="h-3 w-3 mr-1" />
|
||||||
Send
|
{t("terminal.send")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1228,17 +1185,17 @@ const handleClose = () => {
|
|||||||
<>
|
<>
|
||||||
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
|
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-zinc-400 font-medium">No results found for "{searchQuery}"</p>
|
<p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
|
||||||
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
|
<p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-zinc-400 font-medium mb-2">Search for any command</p>
|
<p className="text-zinc-400 font-medium mb-2">{t("terminal.searchAnyCommand")}</p>
|
||||||
<div className="text-sm text-zinc-500 space-y-1">
|
<div className="text-sm text-zinc-500 space-y-1">
|
||||||
<p>Try searching for:</p>
|
<p>{t("terminal.trySearchingFor")}</p>
|
||||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||||
{["tar", "grep", "docker", "qm", "systemctl"].map((cmd) => (
|
{["tar", "grep", "docker", "qm", "systemctl"].map((cmd) => (
|
||||||
<code
|
<code
|
||||||
@@ -1255,7 +1212,7 @@ const handleClose = () => {
|
|||||||
{useOnline && (
|
{useOnline && (
|
||||||
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
|
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
|
||||||
<Lightbulb className="w-3 h-3" />
|
<Lightbulb className="w-3 h-3" />
|
||||||
<span>Powered by cheat.sh</span>
|
<span>{t("terminal.poweredByCheatSh")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -1264,13 +1221,11 @@ const handleClose = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
|
{useOnline && searchResults.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="pt-2 border-t border-zinc-800 text-xs text-zinc-500 text-right">
|
||||||
<Lightbulb className="w-3 h-3" />
|
<span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>
|
||||||
<span>Tip: Search for any Linux command or Proxmox commands (qm, pct, zpool)</span>
|
|
||||||
</div>
|
|
||||||
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { useTheme } from "next-themes"
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
|
const t = useT()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ export function ThemeToggle() {
|
|||||||
return (
|
return (
|
||||||
<Button variant="outline" size="sm" className="border-border bg-transparent w-9 h-9">
|
<Button variant="outline" size="sm" className="border-border bg-transparent w-9 h-9">
|
||||||
<Sun className="h-4 w-4" />
|
<Sun className="h-4 w-4" />
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">{t("actions.toggleTheme")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -31,7 +33,7 @@ export function ThemeToggle() {
|
|||||||
<Button variant="outline" size="sm" onClick={handleThemeToggle} className="border-border bg-transparent w-9 h-9">
|
<Button variant="outline" size="sm" onClick={handleThemeToggle} className="border-border bg-transparent w-9 h-9">
|
||||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">{t("actions.toggleTheme")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Input } from "./ui/input"
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||||
import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react"
|
import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react"
|
||||||
import { getApiUrl } from "../lib/api-config"
|
import { getApiUrl } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface TwoFactorSetupProps {
|
interface TwoFactorSetupProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -14,6 +15,8 @@ interface TwoFactorSetupProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps) {
|
export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps) {
|
||||||
|
const t = useT()
|
||||||
|
const tf = (key: string) => t(`securityPage.twoFactorSetup.${key}`)
|
||||||
const [step, setStep] = useState(1)
|
const [step, setStep] = useState(1)
|
||||||
const [qrCode, setQrCode] = useState("")
|
const [qrCode, setQrCode] = useState("")
|
||||||
const [secret, setSecret] = useState("")
|
const [secret, setSecret] = useState("")
|
||||||
@@ -41,7 +44,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.message || "Failed to setup 2FA")
|
throw new Error(data.message || tf("setupFailed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
setQrCode(data.qr_code)
|
setQrCode(data.qr_code)
|
||||||
@@ -49,7 +52,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
setBackupCodes(data.backup_codes)
|
setBackupCodes(data.backup_codes)
|
||||||
setStep(2)
|
setStep(2)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to setup 2FA")
|
setError(err instanceof Error ? err.message : tf("setupFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -57,7 +60,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
|
|
||||||
const handleVerify = async () => {
|
const handleVerify = async () => {
|
||||||
if (!verificationCode || verificationCode.length !== 6) {
|
if (!verificationCode || verificationCode.length !== 6) {
|
||||||
setError("Please enter a 6-digit code")
|
setError(tf("enterSixDigitCode"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,12 +81,12 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.message || "Invalid verification code")
|
throw new Error(data.message || tf("invalidCode"))
|
||||||
}
|
}
|
||||||
|
|
||||||
setStep(3)
|
setStep(3)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Verification failed")
|
setError(err instanceof Error ? err.message : tf("verificationFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -141,7 +144,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
// both the Clipboard API and execCommand may be locked down.
|
// both the Clipboard API and execCommand may be locked down.
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
try {
|
try {
|
||||||
window.prompt("Copy this value:", text)
|
window.prompt(tf("copyPrompt"), text)
|
||||||
ok = true
|
ok = true
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -183,9 +186,9 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Shield className="h-5 w-5 text-blue-500" />
|
<Shield className="h-5 w-5 text-blue-500" />
|
||||||
Setup Two-Factor Authentication
|
{tf("title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>Add an extra layer of security to your account</DialogDescription>
|
<DialogDescription>{tf("description")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -199,22 +202,21 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
|
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
|
||||||
<p className="text-sm text-blue-500">
|
<p className="text-sm text-blue-500">
|
||||||
Two-factor authentication (2FA) adds an extra layer of security by requiring a code from your
|
{tf("intro")}
|
||||||
authentication app in addition to your password.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium">You will need:</h4>
|
<h4 className="font-medium">{tf("youWillNeed")}</h4>
|
||||||
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
||||||
<li>An authentication app (Google Authenticator, Authy, etc.)</li>
|
<li>{tf("needApp")}</li>
|
||||||
<li>Scan a QR code or enter a key manually</li>
|
<li>{tf("needQrOrKey")}</li>
|
||||||
<li>Store backup codes securely</li>
|
<li>{tf("needBackupCodes")}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleSetupStart} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
<Button onClick={handleSetupStart} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||||
{loading ? "Starting..." : "Start Setup"}
|
{loading ? tf("starting") : tf("startSetup")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -222,24 +224,24 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium">1. Scan the QR code</h4>
|
<h4 className="font-medium">{tf("scanTitle")}</h4>
|
||||||
<p className="text-sm text-muted-foreground">Open your authentication app and scan this QR code</p>
|
<p className="text-sm text-muted-foreground">{tf("scanDescription")}</p>
|
||||||
{qrCode && (
|
{qrCode && (
|
||||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||||
<img src={qrCode || "/placeholder.svg"} alt="QR Code" width={200} height={200} className="rounded" />
|
<img src={qrCode || "/placeholder.svg"} alt={tf("qrCodeAlt")} width={200} height={200} className="rounded" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium">Or enter the key manually:</h4>
|
<h4 className="font-medium">{tf("manualKey")}</h4>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input value={secret} readOnly className="font-mono text-sm" />
|
<Input value={secret} readOnly className="font-mono text-sm" />
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => copyToClipboard(secret, "secret")}
|
onClick={() => copyToClipboard(secret, "secret")}
|
||||||
title="Copy key"
|
title={tf("copyKey")}
|
||||||
>
|
>
|
||||||
{copiedSecret ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
{copiedSecret ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -247,8 +249,8 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium">2. Enter the verification code</h4>
|
<h4 className="font-medium">{tf("verifyTitle")}</h4>
|
||||||
<p className="text-sm text-muted-foreground">Enter the 6-digit code that appears in your app</p>
|
<p className="text-sm text-muted-foreground">{tf("verifyDescription")}</p>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="000000"
|
placeholder="000000"
|
||||||
@@ -262,10 +264,10 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={handleVerify} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
<Button onClick={handleVerify} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||||
{loading ? "Verifying..." : "Verify and Enable"}
|
{loading ? tf("verifying") : tf("verifyAndEnable")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
|
<Button onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,30 +278,29 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4 flex items-start gap-2">
|
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4 flex items-start gap-2">
|
||||||
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-green-500">2FA Enabled Successfully</p>
|
<p className="font-medium text-green-500">{tf("enabledTitle")}</p>
|
||||||
<p className="text-sm text-green-500 mt-1">
|
<p className="text-sm text-green-500 mt-1">
|
||||||
Your account is now protected with two-factor authentication
|
{tf("enabledDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium text-orange-500">Important: Save your backup codes</h4>
|
<h4 className="font-medium text-orange-500">{tf("saveCodesTitle")}</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
These codes will allow you to access your account if you lose access to your authentication app. Store
|
{tf("saveCodesDescription")}
|
||||||
them in a safe place.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
|
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="text-sm font-medium">Backup Codes</span>
|
<span className="text-sm font-medium">{tf("backupCodes")}</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => copyToClipboard(backupCodes.join("\n"), "codes")}>
|
<Button variant="outline" size="sm" onClick={() => copyToClipboard(backupCodes.join("\n"), "codes")}>
|
||||||
{copiedCodes ? (
|
{copiedCodes ? (
|
||||||
<Check className="h-4 w-4 text-green-500 mr-2" />
|
<Check className="h-4 w-4 text-green-500 mr-2" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="h-4 w-4 mr-2" />
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
Copy All
|
{tf("copyAll")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -313,7 +314,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
|
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
|
||||||
Finish
|
{tf("finish")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as React from "react"
|
|||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
import { X } from "lucide-react"
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root
|
const Dialog = DialogPrimitive.Root
|
||||||
@@ -34,7 +35,10 @@ const DialogContent = React.forwardRef<
|
|||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
hideClose?: boolean
|
hideClose?: boolean
|
||||||
}
|
}
|
||||||
>(({ className, children, hideClose, ...props }, ref) => (
|
>(({ className, children, hideClose, ...props }, ref) => {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
@@ -50,12 +54,13 @@ const DialogContent = React.forwardRef<
|
|||||||
{!hideClose && (
|
{!hideClose && (
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">{t("actions.close")}</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
))
|
)
|
||||||
|
})
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
|
|||||||
// 1px blue ring + matching border so a focused input now sits at the
|
// 1px blue ring + matching border so a focused input now sits at the
|
||||||
// same visual weight as the colored card selectors used elsewhere
|
// same visual weight as the colored card selectors used elsewhere
|
||||||
// (Backend picker, etc.).
|
// (Backend picker, etc.).
|
||||||
"flex h-10 w-full rounded-lg border border-input bg-background px-4 py-2 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50",
|
"flex h-10 w-full rounded-lg border border-input bg-background px-4 py-2 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground placeholder:opacity-40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
|
|||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { X } from "lucide-react"
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Sheet = DialogPrimitive.Root
|
const Sheet = DialogPrimitive.Root
|
||||||
@@ -54,18 +55,22 @@ interface SheetContentProps
|
|||||||
VariantProps<typeof sheetVariants> {}
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
const SheetContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, SheetContentProps>(
|
const SheetContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, SheetContentProps>(
|
||||||
({ side = "right", className, children, ...props }, ref) => (
|
({ side = "right", className, children, ...props }, ref) => {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay />
|
||||||
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">{t("actions.close")}</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
),
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
SheetContent.displayName = DialogPrimitive.Content.displayName
|
SheetContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground placeholder:opacity-40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,78 +1,126 @@
|
|||||||
{
|
{
|
||||||
"_description": "Verified AI models for ProxMenux notifications. Only models listed here will be shown to users. Models are tested to work with the chat/completions API format.",
|
"_description": "Verified AI models for ProxMenux notifications. Only models listed here will be shown to users. Models are tested to work with the chat/completions API format.",
|
||||||
"_updated": "2026-07-14",
|
"_updated": "2026-09-02",
|
||||||
"_verifier": "Refreshed with tools/ai-models-verifier (private). Re-run before each ProxMenux release to keep the list current. The verifier and ProxMenux share the same reasoning/thinking-model handlers so their verdicts stay aligned with runtime behaviour.",
|
"_verifier": "Refreshed with tools/ai-models-verifier (private). Re-run before each ProxMenux release to keep the list current. The verifier and ProxMenux share the same reasoning/thinking-model handlers so their verdicts stay aligned with runtime behaviour.",
|
||||||
|
|
||||||
"groq": {
|
"groq": {
|
||||||
"models": [
|
"models": [
|
||||||
"llama-3.3-70b-versatile",
|
"allam-2-7b",
|
||||||
"llama-3.1-8b-instant",
|
"qwen/qwen3.8-27b",
|
||||||
"meta-llama/llama-4-scout-17b-16e-instruct",
|
"openai/gpt-oss-20b",
|
||||||
"openai/gpt-oss-120b",
|
"groq/compound-mini"
|
||||||
"openai/gpt-oss-20b"
|
|
||||||
],
|
],
|
||||||
"recommended": "llama-3.3-70b-versatile",
|
"recommended": "openai/gpt-oss-20b",
|
||||||
"_note": "Verified functionally 2026-07-14 with the Groq API (15 models discovered, 9 passed). Legacy llama-3.1-70b-versatile / llama3-70b-8192 / llama3-8b-8192 / mixtral-8x7b-32768 / gemma2-9b-it removed (retired upstream). llama-4-scout added (current-gen Llama 4, 0.47s). openai/gpt-oss-120b / gpt-oss-20b confirmed. Passing but excluded: allam-2-7b (Arabic-focused), qwen/qwen3-32b (Chinese-first, unreliable Spanish output), openai/gpt-oss-safeguard-20b (safety-classifier variant), groq/compound-mini (agentic system, wrong fit for notification translation)."
|
"_note": "Verified 2026-09-02 with the Groq API. 4 of 12 tested pass. `llama-3.3-70b-versatile` no longer served by Groq. Recommended kept as `openai/gpt-oss-20b` (english-capable) — `allam-2-7b` is fastest but Arabic-focused; add manually if serving Arabic notifications."
|
||||||
},
|
},
|
||||||
|
|
||||||
"gemini": {
|
"gemini": {
|
||||||
"models": [
|
"models": [
|
||||||
"gemini-flash-lite-latest",
|
|
||||||
"gemini-2.5-flash-lite",
|
"gemini-2.5-flash-lite",
|
||||||
"gemini-2.5-flash",
|
|
||||||
"gemini-3.1-flash-lite",
|
"gemini-3.1-flash-lite",
|
||||||
|
"gemini-3.5-flash-lite",
|
||||||
|
"gemini-3.1-flash-lite-preview",
|
||||||
|
"gemini-flash-lite-latest",
|
||||||
|
"gemini-2.5-flash",
|
||||||
"gemini-3-flash-preview",
|
"gemini-3-flash-preview",
|
||||||
"gemini-3.5-flash"
|
"gemini-3.5-flash"
|
||||||
],
|
],
|
||||||
"recommended": "gemini-2.5-flash-lite",
|
"recommended": "gemini-2.5-flash-lite",
|
||||||
"_note": "Verified 2026-07-13. gemini-flash-lite-latest now passes consistently (1.6s) and is fastest, but gemini-2.5-flash-lite remains recommended because 'latest' aliases can drift over time. gemini-3.1-flash-lite is the stable successor to 3-flash-preview. Pro variants continue to reject thinkingBudget=0 and are overkill for notification translation.",
|
"_deprecated": [
|
||||||
"_deprecated": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-flash", "gemini-1.0-pro", "gemini-pro"]
|
"gemini-2.0-flash",
|
||||||
|
"gemini-2.0-flash-lite",
|
||||||
|
"gemini-1.5-flash",
|
||||||
|
"gemini-1.0-pro",
|
||||||
|
"gemini-pro"
|
||||||
|
],
|
||||||
|
"_note": "Verified 2026-09-02. gemini-flash-lite-latest passes consistently but gemini-2.5-flash-lite remains recommended because 'latest' aliases can drift. gemini-3.1-flash-lite is the stable successor to 3-flash-preview. Pro variants continue to reject thinkingBudget=0 and are overkill for notification translation."
|
||||||
},
|
},
|
||||||
|
|
||||||
"openai": {
|
"openai": {
|
||||||
"models": [
|
"models": [
|
||||||
|
"gpt-4.1-nano-2025-04-14",
|
||||||
"gpt-4.1-nano",
|
"gpt-4.1-nano",
|
||||||
"gpt-4.1-mini",
|
"gpt-4.1-2025-04-14",
|
||||||
"gpt-4o-mini",
|
|
||||||
"gpt-4.1",
|
"gpt-4.1",
|
||||||
|
"gpt-4o-2024-11-20",
|
||||||
|
"gpt-4o-2024-05-13",
|
||||||
|
"gpt-4o-mini",
|
||||||
|
"gpt-3.5-turbo-16k",
|
||||||
|
"gpt-4o-mini-2024-07-18",
|
||||||
|
"gpt-5-nano",
|
||||||
|
"gpt-3.5-turbo-1106",
|
||||||
|
"gpt-3.5-turbo-0125",
|
||||||
|
"gpt-5-nano-2025-08-07",
|
||||||
|
"gpt-4.1-mini",
|
||||||
|
"gpt-4.1-mini-2025-04-14",
|
||||||
|
"gpt-3.5-turbo",
|
||||||
"gpt-4o",
|
"gpt-4o",
|
||||||
"gpt-5-chat-latest",
|
"gpt-4o-2024-08-06",
|
||||||
"gpt-5-nano"
|
"gpt-4",
|
||||||
|
"gpt-4-turbo",
|
||||||
|
"gpt-4-turbo-2024-04-09",
|
||||||
|
"gpt-4-0613"
|
||||||
],
|
],
|
||||||
"recommended": "gpt-4.1-nano",
|
"recommended": "gpt-4.1-nano",
|
||||||
"_note": "Verified 2026-07-13. gpt-5.4-nano / gpt-5.4-mini removed (HTTP 400 — provider params rejected). gpt-5-nano added (2.0s, current-gen fast). Reasoning models (o-series, gpt-5/5.1/5.2 non-chat variants) are supported by openai_provider.py via max_completion_tokens + reasoning_effort=minimal, but not listed here: their latency is higher and they do not improve translation quality for notifications. Add specific reasoning IDs to this list only if a user explicitly wants them."
|
"_note": "Verified 2026-09-02 with the OpenAI API — 22 of 74 tested pass. Includes stable aliases (gpt-4.1-nano, gpt-4o-mini, ...), dated snapshots for pinning (gpt-4o-2024-11-20, gpt-4.1-nano-2025-04-14) and legacy families (gpt-3.5-turbo, gpt-4) for cost-optimised use cases. Recommended kept as `gpt-4.1-nano` (stable alias, 1.39s)."
|
||||||
},
|
},
|
||||||
|
|
||||||
"anthropic": {
|
"anthropic": {
|
||||||
"models": [
|
"models": [
|
||||||
"claude-haiku-4-5",
|
"claude-haiku-4-5-20251001",
|
||||||
"claude-sonnet-5",
|
|
||||||
"claude-opus-4-8",
|
"claude-opus-4-8",
|
||||||
|
"claude-sonnet-4-5-20250929",
|
||||||
|
"claude-opus-4-5-20251101",
|
||||||
|
"claude-opus-4-7",
|
||||||
"claude-sonnet-4-6",
|
"claude-sonnet-4-6",
|
||||||
"claude-opus-4-6",
|
"claude-fable-5",
|
||||||
"claude-fable-5"
|
"claude-fable-5-1",
|
||||||
|
"claude-opus-4-6"
|
||||||
],
|
],
|
||||||
"recommended": "claude-haiku-4-5",
|
"recommended": "claude-haiku-4-5-20251001",
|
||||||
"_note": "Verified 2026-07-13 with all 10 discovered models passing after aligning the verifier with anthropic_provider.py (temperature omitted — newest generations reject it with 'temperature is deprecated for this model'). Legacy claude-3-5-haiku-latest / claude-3-5-sonnet-latest / claude-3-opus-latest removed (deprecated upstream, not in the Models API). haiku-4-5 is the sweet spot for notification translation (3.6s, $1/$5 per MTok); sonnet-5 for slightly richer output (3.1s, $3/$15); opus-4-8 / fable-5 for demanding cases."
|
"_note": "Verified 2026-09-02 — 9 of 11 tested pass. The `claude-haiku-4-5` alias no longer resolves upstream; the working snapshot is `claude-haiku-4-5-20251001` — used as recommended (2.89s). Full family surfaced: haiku/sonnet/opus/fable across generations 4.5/4.6/4.7/4.8/5."
|
||||||
},
|
},
|
||||||
|
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
"models": [
|
"models": [
|
||||||
"meta-llama/llama-3.3-70b-instruct",
|
|
||||||
"meta-llama/llama-3.1-70b-instruct",
|
|
||||||
"meta-llama/llama-3.1-8b-instruct",
|
|
||||||
"meta-llama/llama-4-scout",
|
|
||||||
"anthropic/claude-haiku-4.5",
|
|
||||||
"anthropic/claude-sonnet-4.6",
|
|
||||||
"google/gemini-2.5-flash-lite",
|
"google/gemini-2.5-flash-lite",
|
||||||
|
"google/gemini-3.5-flash-lite",
|
||||||
|
"amazon/nova-pro-v1",
|
||||||
|
"mistralai/mistral-medium-3",
|
||||||
"google/gemini-2.5-flash",
|
"google/gemini-2.5-flash",
|
||||||
|
"amazon/nova-lite-v1",
|
||||||
|
"openai/gpt-5.4-mini",
|
||||||
"openai/gpt-4o-mini",
|
"openai/gpt-4o-mini",
|
||||||
"mistralai/mistral-small-3.2-24b-instruct"
|
"openai/gpt-4.1",
|
||||||
|
"perplexity/sonar",
|
||||||
|
"meta-llama/llama-4-scout",
|
||||||
|
"x-ai/grok-4.20",
|
||||||
|
"google/gemini-3-flash-preview",
|
||||||
|
"meta-llama/llama-4-maverick",
|
||||||
|
"perplexity/sonar-pro",
|
||||||
|
"openai/gpt-4.1-nano",
|
||||||
|
"anthropic/claude-haiku-4.5",
|
||||||
|
"mistralai/mistral-large-2512",
|
||||||
|
"meta-llama/llama-3.1-70b-instruct",
|
||||||
|
"qwen/qwen3-235b-a22b-2507",
|
||||||
|
"moonshotai/kimi-k2",
|
||||||
|
"anthropic/claude-opus-4.8",
|
||||||
|
"meta-llama/llama-3.3-70b-instruct",
|
||||||
|
"openai/gpt-5.1",
|
||||||
|
"anthropic/claude-sonnet-5",
|
||||||
|
"mistralai/mistral-small-3.2-24b-instruct",
|
||||||
|
"cohere/command-a",
|
||||||
|
"x-ai/grok-4.3",
|
||||||
|
"anthropic/claude-sonnet-4.6",
|
||||||
|
"deepseek/deepseek-v3.2",
|
||||||
|
"openai/gpt-oss-120b",
|
||||||
|
"meta-llama/llama-3.1-8b-instruct",
|
||||||
|
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
||||||
|
"nvidia/nemotron-3-super-120b-a12b:free",
|
||||||
|
"google/gemma-4-26b-a4b-it:free",
|
||||||
|
"nvidia/nemotron-nano-12b-v2-vl:free",
|
||||||
|
"nvidia/nemotron-3-nano-30b-a3b:free",
|
||||||
|
"poolside/laguna-s-2.1:free",
|
||||||
|
"openai/gpt-oss-20b:free"
|
||||||
],
|
],
|
||||||
"recommended": "meta-llama/llama-3.3-70b-instruct",
|
"recommended": "meta-llama/llama-3.3-70b-instruct",
|
||||||
"_note": "Verified functionally 2026-07-14 with the OpenRouter API — all 10 curated candidates pass the Spanish-translation notification test. Fastest: llama-4-scout (0.51s), gemini-2.5-flash-lite (1.14s), gemini-2.5-flash (1.94s), llama-3.3-70b-instruct (2.29s), claude-haiku-4.5 (2.71s). Legacy anthropic/claude-3.5-* / google/gemini-flash-1.5 / mistralai/mistral-7b-instruct / mixtral-8x7b-instruct removed (GONE from catalog). Modern replacements added: llama-4-scout (Meta's current gen — dramatically fastest), claude-haiku-4.5 / claude-sonnet-4.6, gemini-2.5-flash / flash-lite, mistral-small-3.2-24b. recommended kept as llama-3.3-70b for capability/latency balance; llama-4-scout is a faster alternative worth considering as recommended after a broader release."
|
"_note": "Curated manually from the 249+ models OpenRouter serves — the auto-verifier passes ~200 today but only a subset are practical for notification translation. Includes the 11 previously-curated paid models plus 22 new mainstream additions (gpt-5.1, claude-opus-4.8, sonnet-5, grok-4.3/4.20, kimi-k2, deepseek-v3.2, nova, cohere-a, sonar, ...) and 6 `:free` variants re-added manually — the free tier is intentionally supported per user request even though today's rate limits blocked some of them from passing the test."
|
||||||
},
|
},
|
||||||
|
|
||||||
"ollama": {
|
"ollama": {
|
||||||
"_note": "Ollama models are local, we don't filter them. User manages their own models.",
|
"_note": "Ollama models are local, we don't filter them. User manages their own models.",
|
||||||
"models": [],
|
"models": [],
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Deterministic OKLCH colouring for category badges. Shared between
|
||||||
|
// the Apps dashboard and the LXC App tab so both surfaces show the
|
||||||
|
// exact same colour for a given category name.
|
||||||
|
//
|
||||||
|
// Hue exclusions
|
||||||
|
// --------------
|
||||||
|
// Two bands are skipped because their meaning is already reserved by
|
||||||
|
// the rest of the Monitor and a chip in those hues on the same view
|
||||||
|
// would be visually confusing:
|
||||||
|
// * 260–319° purple/violet — "update available" (ArrowUpCircle)
|
||||||
|
// * 340–19° red — error / danger signal
|
||||||
|
// Green and yellow ARE used elsewhere for health status, but only as
|
||||||
|
// tiny dots in other views — a chip in those hues on an app card
|
||||||
|
// carries no false meaning, so they stay in the allowed range.
|
||||||
|
//
|
||||||
|
// Allowed ranges after the exclusions:
|
||||||
|
// [20, 260) ∪ [320, 340) = 240° + 20° = 260° of usable hues.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
export function hueForCategory(text: string): number {
|
||||||
|
let hash = 5381
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0
|
||||||
|
}
|
||||||
|
const raw = Math.abs(hash) % 260
|
||||||
|
if (raw < 240) return 20 + raw // 0-239 → 20-259 (orange..blue)
|
||||||
|
return 320 + (raw - 240) // 240-259 → 320-339 (pink/magenta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OKLCH is perceptually uniform — L=0.80 looks equally bright for a
|
||||||
|
// blue and a yellow. HSL fails this because eyes weight green/yellow
|
||||||
|
// more, so the same L% renders visually darker for blues.
|
||||||
|
export function categoryChipStyle(text: string, isLight: boolean): {
|
||||||
|
backgroundColor: string
|
||||||
|
color: string
|
||||||
|
borderColor: string
|
||||||
|
} {
|
||||||
|
const h = hueForCategory(text)
|
||||||
|
if (isLight) {
|
||||||
|
return {
|
||||||
|
backgroundColor: `oklch(0.55 0.20 ${h} / 0.14)`,
|
||||||
|
color: `oklch(0.42 0.19 ${h})`,
|
||||||
|
borderColor: `oklch(0.55 0.20 ${h} / 0.5)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
backgroundColor: `oklch(0.60 0.16 ${h} / 0.18)`,
|
||||||
|
color: `oklch(0.80 0.16 ${h})`,
|
||||||
|
borderColor: `oklch(0.60 0.16 ${h} / 0.55)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the effective theme from next-themes' hooks on <html>:
|
||||||
|
// `class="dark|light"` (Tailwind class strategy) or `data-theme`.
|
||||||
|
// Falls back to the OS setting when the user hasn't chosen one.
|
||||||
|
export function readIsLightTheme(): boolean {
|
||||||
|
if (typeof window === "undefined" || typeof document === "undefined") return false
|
||||||
|
const el = document.documentElement
|
||||||
|
if (el.classList.contains("dark")) return false
|
||||||
|
if (el.classList.contains("light")) return true
|
||||||
|
const attr = el.getAttribute("data-theme")
|
||||||
|
if (attr === "light") return true
|
||||||
|
if (attr === "dark") return false
|
||||||
|
return window.matchMedia("(prefers-color-scheme: light)").matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// React hook — recomputes when the user toggles theme or the OS pref
|
||||||
|
// flips. Watches <html>'s attributes (data-theme + class) and the
|
||||||
|
// system media query. Used by any component that renders category
|
||||||
|
// chips so they stay legible after a theme change.
|
||||||
|
export function useIsLightTheme(): boolean {
|
||||||
|
const [isLight, setIsLight] = useState<boolean>(false)
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setIsLight(readIsLightTheme())
|
||||||
|
update()
|
||||||
|
const mq = window.matchMedia("(prefers-color-scheme: light)")
|
||||||
|
const observer = new MutationObserver(update)
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["data-theme", "class"],
|
||||||
|
})
|
||||||
|
mq.addEventListener("change", update)
|
||||||
|
return () => { observer.disconnect(); mq.removeEventListener("change", update) }
|
||||||
|
}, [])
|
||||||
|
return isLight
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export const LANGUAGE_STORAGE_KEY = "proxmenux-ui-language"
|
||||||
|
export const DEFAULT_LANGUAGE = "en"
|
||||||
|
|
||||||
|
export type LanguageCode = "en" | "es" | "fr" | "de" | "it" | "pt" | "sk" | "sv"
|
||||||
|
|
||||||
|
export type LanguageStatus = "complete" | "partial" | "needs-translation"
|
||||||
|
|
||||||
|
export interface SupportedLanguage {
|
||||||
|
code: LanguageCode
|
||||||
|
englishName: string
|
||||||
|
nativeName: string
|
||||||
|
status: LanguageStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUPPORTED_LANGUAGES: SupportedLanguage[] = [
|
||||||
|
{ code: "en", englishName: "English", nativeName: "English", status: "complete" },
|
||||||
|
{ code: "de", englishName: "German", nativeName: "Deutsch", status: "complete" },
|
||||||
|
{ code: "es", englishName: "Spanish", nativeName: "Español", status: "complete" },
|
||||||
|
{ code: "fr", englishName: "French", nativeName: "Français", status: "complete" },
|
||||||
|
{ code: "it", englishName: "Italian", nativeName: "Italiano", status: "complete" },
|
||||||
|
{ code: "pt", englishName: "Portuguese", nativeName: "Português", status: "complete" },
|
||||||
|
{ code: "sk", englishName: "Slovak", nativeName: "Slovenčina", status: "complete" },
|
||||||
|
{ code: "sv", englishName: "Swedish", nativeName: "Svenska", status: "complete" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function isSupportedLanguage(value: string | null | undefined): value is LanguageCode {
|
||||||
|
return SUPPORTED_LANGUAGES.some((language) => language.code === value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectBrowserLanguage(): LanguageCode {
|
||||||
|
if (typeof navigator === "undefined") return DEFAULT_LANGUAGE
|
||||||
|
|
||||||
|
const candidates = [navigator.language, ...(navigator.languages || [])]
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const code = candidate?.split("-")[0]?.toLowerCase()
|
||||||
|
if (isSupportedLanguage(code)) return code
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_LANGUAGE
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
|
||||||
|
import enMessages from "../../messages/en/common.json"
|
||||||
|
import deMessages from "../../messages/de/common.json"
|
||||||
|
import esMessages from "../../messages/es/common.json"
|
||||||
|
import frMessages from "../../messages/fr/common.json"
|
||||||
|
import itMessages from "../../messages/it/common.json"
|
||||||
|
import ptMessages from "../../messages/pt/common.json"
|
||||||
|
import skMessages from "../../messages/sk/common.json"
|
||||||
|
import svMessages from "../../messages/sv/common.json"
|
||||||
|
import {
|
||||||
|
DEFAULT_LANGUAGE,
|
||||||
|
LANGUAGE_STORAGE_KEY,
|
||||||
|
type LanguageCode,
|
||||||
|
SUPPORTED_LANGUAGES,
|
||||||
|
detectBrowserLanguage,
|
||||||
|
isSupportedLanguage,
|
||||||
|
} from "./languages"
|
||||||
|
|
||||||
|
type MessageTree = Record<string, unknown>
|
||||||
|
type TranslationParams = Record<string, string | number>
|
||||||
|
|
||||||
|
const MESSAGE_CATALOG: Record<LanguageCode, MessageTree> = {
|
||||||
|
en: enMessages as MessageTree,
|
||||||
|
de: deMessages as MessageTree,
|
||||||
|
es: esMessages as MessageTree,
|
||||||
|
fr: frMessages as MessageTree,
|
||||||
|
it: itMessages as MessageTree,
|
||||||
|
pt: ptMessages as MessageTree,
|
||||||
|
sk: skMessages as MessageTree,
|
||||||
|
sv: svMessages as MessageTree,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface I18nContextValue {
|
||||||
|
language: LanguageCode
|
||||||
|
setLanguage: (language: LanguageCode) => void
|
||||||
|
t: (key: string, params?: TranslationParams) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue | null>(null)
|
||||||
|
|
||||||
|
function getInitialLanguage(): LanguageCode {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_LANGUAGE
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY)
|
||||||
|
if (isSupportedLanguage(stored)) return stored
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable in private browsing.
|
||||||
|
}
|
||||||
|
|
||||||
|
return detectBrowserLanguage()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessage(messages: MessageTree, key: string): string | undefined {
|
||||||
|
const value = key.split(".").reduce<unknown>((cursor, segment) => {
|
||||||
|
if (!cursor || typeof cursor !== "object") return undefined
|
||||||
|
return (cursor as Record<string, unknown>)[segment]
|
||||||
|
}, messages)
|
||||||
|
|
||||||
|
return typeof value === "string" ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(template: string, params?: TranslationParams): string {
|
||||||
|
if (!params) return template
|
||||||
|
|
||||||
|
return template.replace(/\{(\w+)\}/g, (match, name) => {
|
||||||
|
const value = params[name]
|
||||||
|
return value === undefined ? match : String(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [language, setLanguageState] = useState<LanguageCode>(DEFAULT_LANGUAGE)
|
||||||
|
const [isHydrated, setIsHydrated] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLanguageState(getInitialLanguage())
|
||||||
|
setIsHydrated(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isHydrated) return
|
||||||
|
|
||||||
|
document.documentElement.lang = language
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language)
|
||||||
|
} catch {
|
||||||
|
// Best-effort; the in-memory language still works for this session.
|
||||||
|
}
|
||||||
|
}, [isHydrated, language])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key === LANGUAGE_STORAGE_KEY && isSupportedLanguage(event.newValue)) {
|
||||||
|
setLanguageState(event.newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("storage", onStorage)
|
||||||
|
return () => window.removeEventListener("storage", onStorage)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setLanguage = useCallback((nextLanguage: LanguageCode) => {
|
||||||
|
setLanguageState(nextLanguage)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const t = useCallback(
|
||||||
|
(key: string, params?: TranslationParams) => {
|
||||||
|
const localized = getMessage(MESSAGE_CATALOG[language], key)
|
||||||
|
const fallback = getMessage(MESSAGE_CATALOG.en, key)
|
||||||
|
return interpolate(localized ?? fallback ?? key, params)
|
||||||
|
},
|
||||||
|
[language],
|
||||||
|
)
|
||||||
|
|
||||||
|
const value = useMemo<I18nContextValue>(() => ({ language, setLanguage, t }), [language, setLanguage, t])
|
||||||
|
|
||||||
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n() {
|
||||||
|
const context = useContext(I18nContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useI18n must be used within I18nProvider")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useT() {
|
||||||
|
return useI18n().t
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SUPPORTED_LANGUAGES }
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Shared cross-component cache for the LXC App-tab payload
|
||||||
|
// (registered sidecar + cached detection suggestions). Used by both
|
||||||
|
// virtual-machines.tsx (which prefetches on modal open and on hover)
|
||||||
|
// and lxc-app-panel.tsx (which reads the cache first and only fetches
|
||||||
|
// if empty). The in-flight promise map dedups concurrent requests: if
|
||||||
|
// the parent already fired a prefetch, the panel awaits the SAME
|
||||||
|
// promise instead of duplicating the request against the backend —
|
||||||
|
// no more racing fetches on tab switch during a slow first visit.
|
||||||
|
|
||||||
|
import { fetchApi } from "./api-config"
|
||||||
|
|
||||||
|
export type LxcAppsBundle = {
|
||||||
|
sidecar: any
|
||||||
|
suggestions: any | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataCache = new Map<number, LxcAppsBundle>()
|
||||||
|
const inFlight = new Map<number, Promise<LxcAppsBundle | null>>()
|
||||||
|
const cacheRevision = new Map<number, number>()
|
||||||
|
|
||||||
|
export function getLxcAppsCached(vmid: number): LxcAppsBundle | undefined {
|
||||||
|
return dataCache.get(vmid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write-through after a successful App/Updates mutation. The API returns the
|
||||||
|
// complete sidecar, so evicting this entry would throw away newer data and
|
||||||
|
// make the App tab flash "Loading applications..." on its next mount. Keep
|
||||||
|
// the already-fetched suggestions unless the caller explicitly replaces them.
|
||||||
|
export function setLxcAppsCached(
|
||||||
|
vmid: number,
|
||||||
|
sidecar: any,
|
||||||
|
suggestions?: any | null,
|
||||||
|
): LxcAppsBundle {
|
||||||
|
cacheRevision.set(vmid, (cacheRevision.get(vmid) || 0) + 1)
|
||||||
|
const current = dataCache.get(vmid)
|
||||||
|
const bundle: LxcAppsBundle = {
|
||||||
|
sidecar,
|
||||||
|
suggestions: suggestions === undefined
|
||||||
|
? (current?.suggestions ?? null)
|
||||||
|
: suggestions,
|
||||||
|
}
|
||||||
|
dataCache.set(vmid, bundle)
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchLxcApps(vmid: number): Promise<LxcAppsBundle | null> {
|
||||||
|
const existing = inFlight.get(vmid)
|
||||||
|
if (existing) return existing
|
||||||
|
const startedRevision = cacheRevision.get(vmid) || 0
|
||||||
|
const p = Promise.all([
|
||||||
|
fetchApi(`/api/vms/${vmid}/apps`).catch(() => null) as Promise<any>,
|
||||||
|
fetchApi(`/api/vms/${vmid}/apps/suggestions`).catch(() => null) as Promise<any>,
|
||||||
|
])
|
||||||
|
.then(([sc, sug]) => {
|
||||||
|
if (!sc) return null
|
||||||
|
const bundle: LxcAppsBundle = { sidecar: sc, suggestions: sug }
|
||||||
|
// A successful write may have completed while these GETs were in
|
||||||
|
// flight. Never let that older response overwrite the mutation result.
|
||||||
|
if ((cacheRevision.get(vmid) || 0) !== startedRevision) {
|
||||||
|
return dataCache.get(vmid) ?? null
|
||||||
|
}
|
||||||
|
dataCache.set(vmid, bundle)
|
||||||
|
return bundle
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inFlight.delete(vmid)
|
||||||
|
})
|
||||||
|
inFlight.set(vmid, p)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateLxcApps(vmid: number): void {
|
||||||
|
cacheRevision.set(vmid, (cacheRevision.get(vmid) || 0) + 1)
|
||||||
|
dataCache.delete(vmid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the cache from the bulk modal-cache endpoint. Both registered
|
||||||
|
// apps and startup detection suggestions are already in memory, so
|
||||||
|
// opening the App tab never starts a discovery scan.
|
||||||
|
export function seedLxcAppsCache(
|
||||||
|
vmid: number,
|
||||||
|
sidecar: any,
|
||||||
|
suggestions?: any | null,
|
||||||
|
): void {
|
||||||
|
if (!sidecar) return
|
||||||
|
const existing = dataCache.get(vmid)
|
||||||
|
if (existing) return // per-panel fetch already ran, don't overwrite
|
||||||
|
dataCache.set(vmid, { sidecar, suggestions: suggestions ?? null })
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Proxmox VE tag color scheme — 1:1 port of the algorithm in
|
||||||
|
// proxmoxlib.js (`Proxmox.Utils.stringToRGB` +
|
||||||
|
// `Proxmox.Utils.getTextContrastClass`). Same input → same color
|
||||||
|
// as the PVE web UI, so tags render identically in both places.
|
||||||
|
|
||||||
|
export type TagColor = {
|
||||||
|
bg: string // css `background-color`
|
||||||
|
fg: string // css `color` — auto-picked for contrast (SAPC)
|
||||||
|
border: string // css `border-color`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbatim port of stringToRGB from proxmoxlib.js. The `+ 'prox'`
|
||||||
|
// suffix, the `<< 5` hash, and the `alpha=0.7 / bg=255` blend
|
||||||
|
// keep the output in the [76.5, 255] range per channel — that's
|
||||||
|
// why every PVE tag is a "washed" bright color instead of a raw
|
||||||
|
// hash-hue.
|
||||||
|
function stringToRGB(input: string): [number, number, number] {
|
||||||
|
let hash = 0
|
||||||
|
if (!input) return [255, 255, 255]
|
||||||
|
const source = input + "prox"
|
||||||
|
for (let i = 0; i < source.length; i++) {
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
hash = source.charCodeAt(i) + ((hash << 5) - hash)
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
hash = hash & hash
|
||||||
|
}
|
||||||
|
const alpha = 0.7
|
||||||
|
const bg = 255
|
||||||
|
return [
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
(hash & 255) * alpha + bg * (1 - alpha),
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
((hash >> 8) & 255) * alpha + bg * (1 - alpha),
|
||||||
|
// eslint-disable-next-line no-bitwise
|
||||||
|
((hash >> 16) & 255) * alpha + bg * (1 - alpha),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAPC-based light/dark text picker — verbatim port of
|
||||||
|
// getTextContrastClass. Same tag → same text color as PVE.
|
||||||
|
function getTextContrastClass(rgb: [number, number, number]): "light" | "dark" {
|
||||||
|
const blkThrs = 0.022
|
||||||
|
const blkClmp = 1.414
|
||||||
|
const r = (rgb[0] / 255) ** 2.4
|
||||||
|
const g = (rgb[1] / 255) ** 2.4
|
||||||
|
const b = (rgb[2] / 255) ** 2.4
|
||||||
|
let bg = r * 0.2126729 + g * 0.7151522 + b * 0.072175
|
||||||
|
bg = bg > blkThrs ? bg : bg + (blkThrs - bg) ** blkClmp
|
||||||
|
const contrastLight = bg ** 0.65 - 1
|
||||||
|
const contrastDark = bg ** 0.56 - 0.046134502
|
||||||
|
return Math.abs(contrastLight) >= Math.abs(contrastDark) ? "light" : "dark"
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToCss(rgb: [number, number, number]): string {
|
||||||
|
return `rgb(${Math.round(rgb[0])}, ${Math.round(rgb[1])}, ${Math.round(rgb[2])})`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagToColor(tag: string): TagColor {
|
||||||
|
const rgb = stringToRGB(tag)
|
||||||
|
const bg = rgbToCss(rgb)
|
||||||
|
const fg = getTextContrastClass(rgb) === "light" ? "#ffffff" : "#000000"
|
||||||
|
return { bg, fg, border: bg }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split a PVE tags string into an array. PVE separators are ';' and
|
||||||
|
// ',' (both accepted); whitespace around tokens is stripped and
|
||||||
|
// empty tokens dropped.
|
||||||
|
export function parseTags(raw: string | null | undefined): string[] {
|
||||||
|
if (!raw) return []
|
||||||
|
return raw
|
||||||
|
.split(/[;,]/)
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join back into the canonical PVE format (';' separator).
|
||||||
|
export function stringifyTags(tags: string[]): string {
|
||||||
|
return tags.map((t) => t.trim()).filter(Boolean).join(";")
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react"
|
||||||
|
|
||||||
|
// Persistent top-level tab order for the Monitor dashboard.
|
||||||
|
//
|
||||||
|
// Only the seven top-level slots are user-orderable; the internal
|
||||||
|
// items of the Node and Admin dropdowns keep their canonical order —
|
||||||
|
// grouped items move as a single unit.
|
||||||
|
|
||||||
|
export type TabId = "overview" | "apps" | "vms" | "node" | "backup" | "terminal" | "admin"
|
||||||
|
|
||||||
|
export const DEFAULT_TAB_ORDER: TabId[] = [
|
||||||
|
"overview",
|
||||||
|
"apps",
|
||||||
|
"vms",
|
||||||
|
"node",
|
||||||
|
"backup",
|
||||||
|
"terminal",
|
||||||
|
"admin",
|
||||||
|
]
|
||||||
|
|
||||||
|
const STORAGE_KEY = "proxmenux-nav-order"
|
||||||
|
const CHANGE_EVENT = "proxmenux-nav-order-changed"
|
||||||
|
|
||||||
|
function isTabId(v: unknown): v is TabId {
|
||||||
|
return typeof v === "string" && (DEFAULT_TAB_ORDER as string[]).includes(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read + normalise: unknown ids are dropped, missing ones are
|
||||||
|
// appended in their default position so a future release adding a
|
||||||
|
// new tab still surfaces it for users with a stored order.
|
||||||
|
export function readTabOrder(): TabId[] {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_TAB_ORDER
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return DEFAULT_TAB_ORDER
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(parsed)) return DEFAULT_TAB_ORDER
|
||||||
|
const seen = new Set<TabId>()
|
||||||
|
const clean: TabId[] = []
|
||||||
|
for (const item of parsed) {
|
||||||
|
if (isTabId(item) && !seen.has(item)) {
|
||||||
|
clean.push(item)
|
||||||
|
seen.add(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of DEFAULT_TAB_ORDER) {
|
||||||
|
if (!seen.has(id)) clean.push(id)
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_TAB_ORDER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeTabOrder(order: TabId[]): void {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(order))
|
||||||
|
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||||
|
} catch {
|
||||||
|
// Storage full / disabled — the in-memory state still updates.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map a top-level slot id to the concrete `activeTab` value the
|
||||||
|
// Tabs component uses. Direct tabs pass through; grouped slots
|
||||||
|
// (Node/Admin) resolve to the first child in the dropdown so the
|
||||||
|
// dashboard lands on a real tab, not a group header.
|
||||||
|
const GROUP_FIRST_CHILD: Record<TabId, string> = {
|
||||||
|
overview: "overview",
|
||||||
|
apps: "apps",
|
||||||
|
vms: "vms",
|
||||||
|
node: "storage",
|
||||||
|
backup: "backup",
|
||||||
|
terminal: "terminal",
|
||||||
|
admin: "logs",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstActualTab(order: TabId[] = readTabOrder()): string {
|
||||||
|
const head = order[0]
|
||||||
|
return (head && GROUP_FIRST_CHILD[head]) || "overview"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetTabOrder(): void {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY)
|
||||||
|
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook that keeps every consumer in sync. Firing a custom event on
|
||||||
|
// write means the Settings card and the top navigation update in the
|
||||||
|
// same tick without prop-drilling.
|
||||||
|
export function useTabOrder(): {
|
||||||
|
order: TabId[]
|
||||||
|
setOrder: (next: TabId[]) => void
|
||||||
|
reset: () => void
|
||||||
|
isCustom: boolean
|
||||||
|
} {
|
||||||
|
const [order, setOrderState] = useState<TabId[]>(DEFAULT_TAB_ORDER)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOrderState(readTabOrder())
|
||||||
|
const onChange = () => setOrderState(readTabOrder())
|
||||||
|
window.addEventListener(CHANGE_EVENT, onChange)
|
||||||
|
window.addEventListener("storage", (e) => {
|
||||||
|
if (e.key === STORAGE_KEY) onChange()
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(CHANGE_EVENT, onChange)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setOrder = useCallback((next: TabId[]) => {
|
||||||
|
writeTabOrder(next)
|
||||||
|
setOrderState(next)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
resetTabOrder()
|
||||||
|
setOrderState(DEFAULT_TAB_ORDER)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isCustom =
|
||||||
|
order.length !== DEFAULT_TAB_ORDER.length ||
|
||||||
|
order.some((id, idx) => id !== DEFAULT_TAB_ORDER[idx])
|
||||||
|
|
||||||
|
return { order, setOrder, reset, isCustom }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Single source of truth for the app version displayed inside the
|
||||||
|
// Monitor. Every component that renders the version (dashboard footer,
|
||||||
|
// SMART report footer, release-notes modal…) imports from here so a
|
||||||
|
// version bump only has to be applied once per file lane:
|
||||||
|
//
|
||||||
|
// 1. AppImage/lib/version.ts ← this file
|
||||||
|
// 2. AppImage/package.json ← npm/Next.js metadata
|
||||||
|
// 3. beta_version.txt ← bash pipeline (build_appimage.sh)
|
||||||
|
//
|
||||||
|
// Keep the three in sync on every bump.
|
||||||
|
export const APP_VERSION = "1.2.5"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Monitor dashboard translations
|
||||||
|
|
||||||
|
The ProxMenux Monitor dashboard uses a small client-side i18n layer.
|
||||||
|
|
||||||
|
- English (`en`) is the source language and the runtime fallback.
|
||||||
|
- Eight locales are shipped and fully populated end-to-end: `en`, `de`, `es`, `fr`, `it`, `pt`, `sk`, `sv`. Non-English catalogs started from an automated bootstrap and are being polished as native speakers pass through them.
|
||||||
|
|
||||||
|
To improve a translation, edit the values in your locale's `common.json` — keep placeholders such as `{uptime}`, `{vmid}` or `{count}` unchanged, and don't translate brand or product names (`ProxMenux Monitor`, `Proxmox Backup Server`, `Secure Gateway`, `Tailscale`, etc.). Missing keys fall back to English at runtime, so a partial refresh is always safe to merge.
|
||||||
|
|
||||||
|
To add a new locale, see [§11 → Adding a new locale](../../CONTRIBUTING.md#adding-a-new-locale) in the Contributing Guide.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ProxMenux-Monitor",
|
"name": "ProxMenux-Monitor",
|
||||||
"version": "1.2.2.2-beta",
|
"version": "1.2.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ProxMenux-Monitor",
|
"name": "ProxMenux-Monitor",
|
||||||
"version": "1.2.2.2-beta",
|
"version": "1.2.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-accordion": "1.2.2",
|
"@radix-ui/react-accordion": "1.2.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ProxMenux-Monitor",
|
"name": "ProxMenux-Monitor",
|
||||||
"version": "1.2.4",
|
"version": "1.2.5",
|
||||||
"description": "Proxmox System Monitoring Dashboard",
|
"description": "Proxmox System Monitoring Dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ class OpenAIProvider(AIProvider):
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Custom OpenAI-compatible endpoints often expose opaque aliases whose
|
||||||
|
# upstream capabilities are known only to the proxy. Do not infer
|
||||||
|
# sampling or reasoning parameters from those aliases; let the proxy
|
||||||
|
# apply model-specific defaults.
|
||||||
|
if self.base_url:
|
||||||
|
payload['max_tokens'] = max_tokens
|
||||||
# Reasoning models (o1/o3/o4/gpt-5*, excluding *-chat-latest) use a
|
# Reasoning models (o1/o3/o4/gpt-5*, excluding *-chat-latest) use a
|
||||||
# different parameter contract: max_completion_tokens instead of
|
# different parameter contract: max_completion_tokens instead of
|
||||||
# max_tokens, and no temperature field. Sending the classic chat
|
# max_tokens, and no temperature field. Sending the classic chat
|
||||||
@@ -196,7 +202,7 @@ class OpenAIProvider(AIProvider):
|
|||||||
# exactly what this pipeline wants. OpenAI documents 'minimal',
|
# exactly what this pipeline wants. OpenAI documents 'minimal',
|
||||||
# 'low', 'medium', 'high' — 'minimal' is the right setting for a
|
# 'low', 'medium', 'high' — 'minimal' is the right setting for a
|
||||||
# straightforward translate+explain task.
|
# straightforward translate+explain task.
|
||||||
if self._is_reasoning_model(self.model):
|
elif self._is_reasoning_model(self.model):
|
||||||
payload['max_completion_tokens'] = max_tokens
|
payload['max_completion_tokens'] = max_tokens
|
||||||
payload['reasoning_effort'] = 'minimal'
|
payload['reasoning_effort'] = 'minimal'
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -653,12 +653,13 @@ def setup_auth(username, password):
|
|||||||
Set up authentication with username and password
|
Set up authentication with username and password
|
||||||
Returns (success: bool, message: str)
|
Returns (success: bool, message: str)
|
||||||
"""
|
"""
|
||||||
# Refuse if auth has already been configured. Without this guard an
|
# Refuse if real credentials already exist. Without this guard an
|
||||||
# unauthenticated POST to /api/auth/setup would let an attacker overwrite
|
# unauthenticated POST to /api/auth/setup would let an attacker overwrite
|
||||||
# the existing admin credentials and take over the account. See audit
|
# the existing admin credentials and take over the account. A declined
|
||||||
# Tier 1 #4.
|
# setup is marked configured but deliberately has no credentials, so it
|
||||||
|
# must remain possible to finish setup later. See audit Tier 1 #4.
|
||||||
existing = load_auth_config()
|
existing = load_auth_config()
|
||||||
if existing.get("configured", False):
|
if existing.get("username") and existing.get("password_hash"):
|
||||||
return False, "Authentication is already configured"
|
return False, "Authentication is already configured"
|
||||||
|
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
@@ -668,7 +669,7 @@ def setup_auth(username, password):
|
|||||||
if pw_err:
|
if pw_err:
|
||||||
return False, pw_err
|
return False, pw_err
|
||||||
|
|
||||||
config = {
|
existing.update({
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"username": username,
|
"username": username,
|
||||||
"password_hash": hash_password(password),
|
"password_hash": hash_password(password),
|
||||||
@@ -677,9 +678,9 @@ def setup_auth(username, password):
|
|||||||
"totp_enabled": False,
|
"totp_enabled": False,
|
||||||
"totp_secret": None,
|
"totp_secret": None,
|
||||||
"backup_codes": []
|
"backup_codes": []
|
||||||
}
|
})
|
||||||
|
|
||||||
if save_auth_config(config):
|
if save_auth_config(existing):
|
||||||
return True, "Authentication configured successfully"
|
return True, "Authentication configured successfully"
|
||||||
else:
|
else:
|
||||||
return False, "Failed to save authentication configuration"
|
return False, "Failed to save authentication configuration"
|
||||||
@@ -1053,6 +1054,15 @@ PROXMOX_KEY_PATH = "/etc/pve/local/pve-ssl.key"
|
|||||||
PROXMOX_CUSTOM_CERT_PATH = "/etc/pve/local/pveproxy-ssl.pem"
|
PROXMOX_CUSTOM_CERT_PATH = "/etc/pve/local/pveproxy-ssl.pem"
|
||||||
PROXMOX_CUSTOM_KEY_PATH = "/etc/pve/local/pveproxy-ssl.key"
|
PROXMOX_CUSTOM_KEY_PATH = "/etc/pve/local/pveproxy-ssl.key"
|
||||||
|
|
||||||
|
_SSL_RUNTIME_LOCK = threading.RLock()
|
||||||
|
_SSL_RUNTIME_REFRESH_LOCK = threading.Lock()
|
||||||
|
_SSL_RUNTIME_CONTEXT = None
|
||||||
|
_SSL_RUNTIME_FINGERPRINT = ""
|
||||||
|
_SSL_RUNTIME_CERT_PATH = ""
|
||||||
|
_SSL_RUNTIME_KEY_PATH = ""
|
||||||
|
_SSL_RUNTIME_SOURCE = "none"
|
||||||
|
_SSL_RUNTIME_LAST_REFRESH_ERROR = ""
|
||||||
|
|
||||||
|
|
||||||
def load_ssl_config():
|
def load_ssl_config():
|
||||||
"""Load SSL configuration from file"""
|
"""Load SSL configuration from file"""
|
||||||
@@ -1093,6 +1103,15 @@ def save_ssl_config(config):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_proxmox_certificate_paths():
|
||||||
|
"""Return the certificate pair currently preferred by Proxmox."""
|
||||||
|
if os.path.isfile(PROXMOX_CUSTOM_CERT_PATH) and os.path.isfile(PROXMOX_CUSTOM_KEY_PATH):
|
||||||
|
return PROXMOX_CUSTOM_CERT_PATH, PROXMOX_CUSTOM_KEY_PATH
|
||||||
|
if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
|
||||||
|
return PROXMOX_CERT_PATH, PROXMOX_KEY_PATH
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
def detect_proxmox_certificates():
|
def detect_proxmox_certificates():
|
||||||
"""
|
"""
|
||||||
Detect available Proxmox certificates.
|
Detect available Proxmox certificates.
|
||||||
@@ -1110,11 +1129,10 @@ def detect_proxmox_certificates():
|
|||||||
"cert_info": None
|
"cert_info": None
|
||||||
}
|
}
|
||||||
|
|
||||||
if os.path.isfile(PROXMOX_CUSTOM_CERT_PATH) and os.path.isfile(PROXMOX_CUSTOM_KEY_PATH):
|
cert_path, key_path = _detect_proxmox_certificate_paths()
|
||||||
result["proxmox_cert"] = PROXMOX_CUSTOM_CERT_PATH
|
if cert_path and key_path:
|
||||||
result["proxmox_key"] = PROXMOX_CUSTOM_KEY_PATH
|
result["proxmox_cert"] = cert_path
|
||||||
result["proxmox_available"] = True
|
result["proxmox_key"] = key_path
|
||||||
elif os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
|
|
||||||
result["proxmox_available"] = True
|
result["proxmox_available"] = True
|
||||||
|
|
||||||
if result["proxmox_available"]:
|
if result["proxmox_available"]:
|
||||||
@@ -1174,26 +1192,181 @@ def validate_certificate_files(cert_path, key_path):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"Error reading certificate files: {str(e)}"
|
return False, f"Error reading certificate files: {str(e)}"
|
||||||
|
|
||||||
# Verify cert and key match
|
# Parse the complete chain and verify that the private key matches it.
|
||||||
try:
|
try:
|
||||||
import subprocess
|
import ssl
|
||||||
cert_mod = subprocess.run(
|
test_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
["openssl", "x509", "-noout", "-modulus", "-in", cert_path],
|
test_context.load_cert_chain(cert_path, key_path)
|
||||||
capture_output=True, text=True, timeout=5
|
except Exception as e:
|
||||||
)
|
return False, f"Certificate or private key is invalid: {str(e)}"
|
||||||
key_mod = subprocess.run(
|
|
||||||
["openssl", "rsa", "-noout", "-modulus", "-in", key_path],
|
|
||||||
capture_output=True, text=True, timeout=5
|
|
||||||
)
|
|
||||||
if cert_mod.returncode == 0 and key_mod.returncode == 0:
|
|
||||||
if cert_mod.stdout.strip() != key_mod.stdout.strip():
|
|
||||||
return False, "Certificate and key do not match"
|
|
||||||
except Exception:
|
|
||||||
pass # Non-critical, proceed anyway
|
|
||||||
|
|
||||||
return True, "Certificate files are valid"
|
return True, "Certificate files are valid"
|
||||||
|
|
||||||
|
|
||||||
|
def _certificate_pair_fingerprint(cert_path, key_path):
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
for path in (cert_path, key_path):
|
||||||
|
with open(path, "rb") as source:
|
||||||
|
for chunk in iter(lambda: source.read(65536), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_server_ssl_context(cert_path, key_path):
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
context.load_cert_chain(cert_path, key_path)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def _record_ssl_refresh_error(error):
|
||||||
|
"""Log one warning per distinct automatic-refresh failure."""
|
||||||
|
global _SSL_RUNTIME_LAST_REFRESH_ERROR
|
||||||
|
|
||||||
|
message = str(error)
|
||||||
|
with _SSL_RUNTIME_LOCK:
|
||||||
|
if message == _SSL_RUNTIME_LAST_REFRESH_ERROR:
|
||||||
|
return
|
||||||
|
_SSL_RUNTIME_LAST_REFRESH_ERROR = message
|
||||||
|
print(
|
||||||
|
"[ProxMenux] Proxmox TLS certificate refresh skipped; "
|
||||||
|
f"the active certificate remains unchanged: {message}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_active_proxmox_certificate_paths(cert_path, key_path):
|
||||||
|
"""Keep the selected Proxmox pair in sync for the next service start."""
|
||||||
|
config = load_ssl_config()
|
||||||
|
if not config.get("enabled") or config.get("source") != "proxmox":
|
||||||
|
return
|
||||||
|
if config.get("cert_path") == cert_path and config.get("key_path") == key_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
updated_config = dict(config)
|
||||||
|
updated_config["cert_path"] = cert_path
|
||||||
|
updated_config["key_path"] = key_path
|
||||||
|
if not save_ssl_config(updated_config):
|
||||||
|
print(
|
||||||
|
"[ProxMenux] Warning: the renewed Proxmox certificate is active, "
|
||||||
|
"but its paths could not be saved for the next service start",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_proxmox_ssl_context_for_handshake():
|
||||||
|
"""Activate a renewed Proxmox pair just before a TLS handshake.
|
||||||
|
|
||||||
|
This deliberately has no timer and does not depend on inotify (pmxcfs can
|
||||||
|
update /etc/pve without emitting a local event). The small PEM pair is
|
||||||
|
inspected only when a client starts a new TLS connection. Any missing,
|
||||||
|
partial or mismatched pair leaves the already-active context untouched.
|
||||||
|
"""
|
||||||
|
global _SSL_RUNTIME_LAST_REFRESH_ERROR
|
||||||
|
|
||||||
|
with _SSL_RUNTIME_LOCK:
|
||||||
|
if _SSL_RUNTIME_SOURCE != "proxmox" or _SSL_RUNTIME_CONTEXT is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Several browser connections can arrive together. Only one of them may
|
||||||
|
# validate/swap a newly written pair; the others reuse its result.
|
||||||
|
with _SSL_RUNTIME_REFRESH_LOCK:
|
||||||
|
with _SSL_RUNTIME_LOCK:
|
||||||
|
if _SSL_RUNTIME_SOURCE != "proxmox" or _SSL_RUNTIME_CONTEXT is None:
|
||||||
|
return False
|
||||||
|
active_fingerprint = _SSL_RUNTIME_FINGERPRINT
|
||||||
|
active_cert_path = _SSL_RUNTIME_CERT_PATH
|
||||||
|
active_key_path = _SSL_RUNTIME_KEY_PATH
|
||||||
|
|
||||||
|
cert_path, key_path = _detect_proxmox_certificate_paths()
|
||||||
|
if not cert_path or not key_path:
|
||||||
|
raise RuntimeError("No complete Proxmox certificate/key pair was detected")
|
||||||
|
|
||||||
|
candidate_fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
|
||||||
|
paths_changed = cert_path != active_cert_path or key_path != active_key_path
|
||||||
|
if candidate_fingerprint == active_fingerprint and not paths_changed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# reload_server_ssl_context builds and validates the replacement first
|
||||||
|
# and checks that neither PEM changed while it was being loaded. The
|
||||||
|
# global context is swapped only after all of those checks succeed.
|
||||||
|
changed = reload_server_ssl_context(cert_path, key_path)
|
||||||
|
_persist_active_proxmox_certificate_paths(cert_path, key_path)
|
||||||
|
with _SSL_RUNTIME_LOCK:
|
||||||
|
_SSL_RUNTIME_LAST_REFRESH_ERROR = ""
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
print(
|
||||||
|
f"[ProxMenux] Renewed Proxmox TLS certificate activated from {cert_path}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def create_reloadable_ssl_context(cert_path, key_path):
|
||||||
|
"""Create the stable server context used by automatic and manual reloads."""
|
||||||
|
global _SSL_RUNTIME_CONTEXT
|
||||||
|
global _SSL_RUNTIME_FINGERPRINT
|
||||||
|
global _SSL_RUNTIME_CERT_PATH
|
||||||
|
global _SSL_RUNTIME_KEY_PATH
|
||||||
|
global _SSL_RUNTIME_SOURCE
|
||||||
|
global _SSL_RUNTIME_LAST_REFRESH_ERROR
|
||||||
|
|
||||||
|
context = _build_server_ssl_context(cert_path, key_path)
|
||||||
|
fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
|
||||||
|
config = load_ssl_config()
|
||||||
|
source = config.get("source", "none") if config.get("enabled") else "none"
|
||||||
|
|
||||||
|
def _select_active_context(ssl_socket, _server_name, _initial_context):
|
||||||
|
try:
|
||||||
|
_refresh_proxmox_ssl_context_for_handshake()
|
||||||
|
except Exception as error:
|
||||||
|
# Never fail a client handshake because Proxmox is between the
|
||||||
|
# certificate and key writes. The previously validated context
|
||||||
|
# remains authoritative until a later connection can load both.
|
||||||
|
_record_ssl_refresh_error(error)
|
||||||
|
with _SSL_RUNTIME_LOCK:
|
||||||
|
active_context = _SSL_RUNTIME_CONTEXT
|
||||||
|
if active_context is not None and ssl_socket.context is not active_context:
|
||||||
|
ssl_socket.context = active_context
|
||||||
|
|
||||||
|
context.sni_callback = _select_active_context
|
||||||
|
with _SSL_RUNTIME_LOCK:
|
||||||
|
_SSL_RUNTIME_CONTEXT = context
|
||||||
|
_SSL_RUNTIME_FINGERPRINT = fingerprint
|
||||||
|
_SSL_RUNTIME_CERT_PATH = cert_path
|
||||||
|
_SSL_RUNTIME_KEY_PATH = key_path
|
||||||
|
_SSL_RUNTIME_SOURCE = source
|
||||||
|
_SSL_RUNTIME_LAST_REFRESH_ERROR = ""
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def reload_server_ssl_context(cert_path, key_path):
|
||||||
|
"""Validate and activate a new certificate for subsequent TLS handshakes."""
|
||||||
|
global _SSL_RUNTIME_CONTEXT
|
||||||
|
global _SSL_RUNTIME_FINGERPRINT
|
||||||
|
global _SSL_RUNTIME_CERT_PATH
|
||||||
|
global _SSL_RUNTIME_KEY_PATH
|
||||||
|
|
||||||
|
before_fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
|
||||||
|
replacement = _build_server_ssl_context(cert_path, key_path)
|
||||||
|
after_fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
|
||||||
|
if before_fingerprint != after_fingerprint:
|
||||||
|
raise RuntimeError("Certificate files changed while they were being loaded")
|
||||||
|
|
||||||
|
with _SSL_RUNTIME_LOCK:
|
||||||
|
if _SSL_RUNTIME_CONTEXT is None:
|
||||||
|
raise RuntimeError("The HTTPS runtime is not initialized")
|
||||||
|
changed = after_fingerprint != _SSL_RUNTIME_FINGERPRINT
|
||||||
|
if changed:
|
||||||
|
_SSL_RUNTIME_CONTEXT = replacement
|
||||||
|
_SSL_RUNTIME_FINGERPRINT = after_fingerprint
|
||||||
|
_SSL_RUNTIME_CERT_PATH = cert_path
|
||||||
|
_SSL_RUNTIME_KEY_PATH = key_path
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
def configure_ssl(cert_path, key_path, source="custom"):
|
def configure_ssl(cert_path, key_path, source="custom"):
|
||||||
"""
|
"""
|
||||||
Configure SSL with given certificate and key paths.
|
Configure SSL with given certificate and key paths.
|
||||||
|
|||||||
Regular → Executable
+9
@@ -124,10 +124,19 @@ cp "$SCRIPT_DIR/post_install_versions.py" "$APP_DIR/usr/bin/" 2>/dev/null || ech
|
|||||||
cp "$SCRIPT_DIR/mount_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ mount_monitor.py not found"
|
cp "$SCRIPT_DIR/mount_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ mount_monitor.py not found"
|
||||||
cp "$SCRIPT_DIR/lxc_mount_points.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_mount_points.py not found"
|
cp "$SCRIPT_DIR/lxc_mount_points.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_mount_points.py not found"
|
||||||
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
|
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
|
||||||
|
cp "$SCRIPT_DIR/smartctl_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ smartctl_resolver.py not found"
|
||||||
cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_thresholds.py not found"
|
cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_thresholds.py not found"
|
||||||
cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found"
|
cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found"
|
||||||
|
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
|
||||||
|
cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found"
|
||||||
|
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
|
||||||
|
chmod +x "$APP_DIR/usr/bin/recreate_docker_container.py" 2>/dev/null || true
|
||||||
|
cp "$SCRIPT_DIR/update_docker_engine.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ update_docker_engine.py not found"
|
||||||
|
chmod +x "$APP_DIR/usr/bin/update_docker_engine.py" 2>/dev/null || true
|
||||||
|
cp "$APPIMAGE_ROOT/../json/app_tracking_hints.json" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ app_tracking_hints.json not found"
|
||||||
cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_terminal_routes.py not found"
|
cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_terminal_routes.py not found"
|
||||||
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
|
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
|
||||||
|
cp "$SCRIPT_DIR/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ temperature_sensor_resolver.py not found"
|
||||||
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
|
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
|
||||||
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
|
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
|
||||||
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
|
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""User-defined web links surfaced in the Apps dashboard alongside
|
||||||
|
LXC-registered apps. Kept in a single sidecar
|
||||||
|
(/etc/proxmenux/custom_links.json) because the collection is small,
|
||||||
|
global, and never bound to a specific guest by ProxMenux itself.
|
||||||
|
|
||||||
|
Schema of each entry
|
||||||
|
--------------------
|
||||||
|
{
|
||||||
|
"id": "<uuid4>",
|
||||||
|
"name": "<display name>", # required
|
||||||
|
"url": "<http(s) URL>", # required
|
||||||
|
"logo_url": "<http(s) URL or ''>", # optional
|
||||||
|
"category": "<free text or ''>", # optional
|
||||||
|
"binding": { # optional; null when unbound
|
||||||
|
"vmid": <int>,
|
||||||
|
"guest_type": "lxc" | "qemu"
|
||||||
|
},
|
||||||
|
"created_at": <unix ts>,
|
||||||
|
"updated_at": <unix ts>
|
||||||
|
}
|
||||||
|
|
||||||
|
Design notes
|
||||||
|
------------
|
||||||
|
* One file (not per-VM). Volume is small; unbound links have no natural
|
||||||
|
home; global lookups are O(N) with N tiny.
|
||||||
|
* All writes go through `save_all` which does the classic
|
||||||
|
write-temp+rename dance so a crash mid-save can't corrupt the file.
|
||||||
|
* Validation is strict at the boundary — the frontend can send whatever;
|
||||||
|
the backend refuses anything malformed. Fields that survive are
|
||||||
|
exactly the schema above; unknown keys are dropped silently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
_CUSTOM_LINKS_PATH = "/etc/proxmenux/custom_links.json"
|
||||||
|
_lock = threading.RLock()
|
||||||
|
|
||||||
|
# In-memory copy of the full list. Populated on first read (or by
|
||||||
|
# `warmup()` at Monitor startup) and refreshed only when a write goes
|
||||||
|
# through this module. The sidecar file is our source of truth; the
|
||||||
|
# cache exists so `/api/apps/custom-links` doesn't hit disk on every
|
||||||
|
# request. Reads always return a fresh copy so callers can't mutate
|
||||||
|
# the cached state by accident.
|
||||||
|
_cached_entries: Optional[list[dict]] = None
|
||||||
|
|
||||||
|
# Same character set / max length as the LXC-app editor uses so users
|
||||||
|
# don't have to learn two different rulesets.
|
||||||
|
_NAME_RE = re.compile(r"^[\w\s._+\-()/:,&]{1,80}$", re.UNICODE)
|
||||||
|
_URL_RE = re.compile(r"^https?://[\w\-._~:/?#\[\]@!$&'()*+,;=%]{1,510}$")
|
||||||
|
_CATEGORY_RE = re.compile(r"^[\w\s&/,.\-*+()]{1,60}$", re.UNICODE)
|
||||||
|
_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||||
|
_GUEST_TYPES = frozenset({"lxc", "qemu"})
|
||||||
|
|
||||||
|
|
||||||
|
def _err(msg: str) -> tuple[bool, str]:
|
||||||
|
return False, msg
|
||||||
|
|
||||||
|
|
||||||
|
# ── Persistence ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _read_from_disk() -> list[dict]:
|
||||||
|
"""Actually parse the sidecar file. A missing/empty/corrupt file
|
||||||
|
returns [] — we never let bad JSON take down the whole Apps
|
||||||
|
dashboard, the user's other data is fine."""
|
||||||
|
try:
|
||||||
|
with open(_CUSTOM_LINKS_PATH, encoding="utf-8") as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
except (FileNotFoundError, PermissionError):
|
||||||
|
return []
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
return [entry for entry in raw if isinstance(entry, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def load_all() -> list[dict]:
|
||||||
|
"""Return the current list of custom links from the in-memory
|
||||||
|
cache. First call after a Monitor restart pays one disk read
|
||||||
|
(~1 ms); every subsequent call is a memory op. Writes go through
|
||||||
|
`save_all` which also refreshes the cache, so callers never see
|
||||||
|
stale data.
|
||||||
|
"""
|
||||||
|
global _cached_entries
|
||||||
|
with _lock:
|
||||||
|
if _cached_entries is None:
|
||||||
|
_cached_entries = _read_from_disk()
|
||||||
|
return [dict(entry) for entry in _cached_entries]
|
||||||
|
|
||||||
|
|
||||||
|
def warmup() -> int:
|
||||||
|
"""Force the cache to populate now. Invoked from Monitor startup
|
||||||
|
so the very first `/api/apps/custom-links` request is served
|
||||||
|
straight from memory. Returns the entry count for the log line."""
|
||||||
|
global _cached_entries
|
||||||
|
with _lock:
|
||||||
|
_cached_entries = _read_from_disk()
|
||||||
|
return len(_cached_entries)
|
||||||
|
|
||||||
|
|
||||||
|
def save_all(entries: list[dict]) -> None:
|
||||||
|
"""Persist the full list. Write-temp+rename so a crash cannot
|
||||||
|
leave a half-written JSON on disk. Also refreshes the in-memory
|
||||||
|
cache so the next `load_all` returns the new state without a
|
||||||
|
disk read. Caller must have validated every entry — this
|
||||||
|
function trusts its input and writes verbatim.
|
||||||
|
"""
|
||||||
|
global _cached_entries
|
||||||
|
directory = os.path.dirname(_CUSTOM_LINKS_PATH)
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
payload = json.dumps(entries, ensure_ascii=False, indent=2)
|
||||||
|
with _lock:
|
||||||
|
tmp = f"{_CUSTOM_LINKS_PATH}.tmp.{os.getpid()}"
|
||||||
|
try:
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
f.write(payload)
|
||||||
|
f.write("\n")
|
||||||
|
os.replace(tmp, _CUSTOM_LINKS_PATH)
|
||||||
|
_cached_entries = [dict(e) for e in entries]
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.remove(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Validation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _validate_binding(raw: Any) -> tuple[bool, Any]:
|
||||||
|
"""Accepts either null (unbound) or {vmid, guest_type}. Coerces
|
||||||
|
vmid to int and guest_type to one of the allowed literals."""
|
||||||
|
if raw in (None, "", {}):
|
||||||
|
return True, None
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return _err("binding must be an object with {vmid, guest_type}")
|
||||||
|
vmid_raw = raw.get("vmid")
|
||||||
|
try:
|
||||||
|
vmid = int(vmid_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _err("binding.vmid must be an integer")
|
||||||
|
if not (0 < vmid <= 999_999_999):
|
||||||
|
return _err("binding.vmid out of range")
|
||||||
|
guest_type = (raw.get("guest_type") or "").strip().lower()
|
||||||
|
if guest_type not in _GUEST_TYPES:
|
||||||
|
return _err("binding.guest_type must be 'lxc' or 'qemu'")
|
||||||
|
return True, {"vmid": vmid, "guest_type": guest_type}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_entry(raw: Any, existing_id: Optional[str] = None) -> tuple[bool, Any]:
|
||||||
|
"""Validate a single link payload from the API layer. Returns
|
||||||
|
(True, sanitised_dict) or (False, error_string). Fields absent in
|
||||||
|
the input default to safe values; unknown keys are ignored."""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return _err("payload must be a JSON object")
|
||||||
|
|
||||||
|
name = (raw.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return _err("name is required")
|
||||||
|
if not _NAME_RE.match(name):
|
||||||
|
return _err("name contains invalid characters or exceeds 80 chars")
|
||||||
|
|
||||||
|
url = (raw.get("url") or "").strip()
|
||||||
|
if not url:
|
||||||
|
return _err("url is required")
|
||||||
|
if not _URL_RE.match(url):
|
||||||
|
return _err("url must be an http(s) URL (max 512 chars)")
|
||||||
|
|
||||||
|
logo_url = (raw.get("logo_url") or "").strip()
|
||||||
|
if logo_url and not _URL_RE.match(logo_url):
|
||||||
|
return _err("logo_url must be an http(s) URL (max 512 chars)")
|
||||||
|
|
||||||
|
category = (raw.get("category") or "").strip()
|
||||||
|
if category and not _CATEGORY_RE.match(category):
|
||||||
|
return _err("category contains invalid characters or exceeds 60 chars")
|
||||||
|
|
||||||
|
ok, binding = _validate_binding(raw.get("binding"))
|
||||||
|
if not ok:
|
||||||
|
return _err(binding)
|
||||||
|
|
||||||
|
entry_id = existing_id or raw.get("id") or str(uuid.uuid4())
|
||||||
|
if not _UUID_RE.match(entry_id):
|
||||||
|
entry_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
return True, {
|
||||||
|
"id": entry_id,
|
||||||
|
"name": name,
|
||||||
|
"url": url,
|
||||||
|
"logo_url": logo_url,
|
||||||
|
"category": category,
|
||||||
|
"binding": binding,
|
||||||
|
"created_at": int(raw.get("created_at") or now),
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── CRUD helpers used by the Flask endpoints ───────────────────────
|
||||||
|
|
||||||
|
def create(payload: dict) -> tuple[bool, Any]:
|
||||||
|
"""Add a new link. Assigns a fresh UUID and appends to the file."""
|
||||||
|
ok, entry = validate_entry(payload)
|
||||||
|
if not ok:
|
||||||
|
return False, entry
|
||||||
|
with _lock:
|
||||||
|
current = load_all()
|
||||||
|
current.append(entry)
|
||||||
|
save_all(current)
|
||||||
|
return True, entry
|
||||||
|
|
||||||
|
|
||||||
|
def update(link_id: str, payload: dict) -> tuple[bool, Any]:
|
||||||
|
"""Replace one link by id. 404 if the id is unknown."""
|
||||||
|
if not _UUID_RE.match(link_id or ""):
|
||||||
|
return _err("invalid link id")
|
||||||
|
with _lock:
|
||||||
|
current = load_all()
|
||||||
|
for i, existing in enumerate(current):
|
||||||
|
if existing.get("id") == link_id:
|
||||||
|
merged = dict(existing)
|
||||||
|
merged.update(payload)
|
||||||
|
merged["id"] = link_id # id is immutable
|
||||||
|
merged["created_at"] = existing.get("created_at")
|
||||||
|
ok, entry = validate_entry(merged, existing_id=link_id)
|
||||||
|
if not ok:
|
||||||
|
return False, entry
|
||||||
|
current[i] = entry
|
||||||
|
save_all(current)
|
||||||
|
return True, entry
|
||||||
|
return _err("link not found")
|
||||||
|
|
||||||
|
|
||||||
|
def delete(link_id: str) -> tuple[bool, Any]:
|
||||||
|
"""Remove one link by id. Idempotent — deleting an unknown id
|
||||||
|
returns success so the UI doesn't have to distinguish."""
|
||||||
|
if not _UUID_RE.match(link_id or ""):
|
||||||
|
return _err("invalid link id")
|
||||||
|
with _lock:
|
||||||
|
current = load_all()
|
||||||
|
remaining = [e for e in current if e.get("id") != link_id]
|
||||||
|
if len(remaining) != len(current):
|
||||||
|
save_all(remaining)
|
||||||
|
return True, {"deleted": link_id}
|
||||||
|
|
||||||
|
|
||||||
|
def purge_binding_for_vmid(vmid: int) -> int:
|
||||||
|
"""Clear the `binding` on every link that pointed to a guest that
|
||||||
|
no longer exists. Called from the guest lifecycle hook when a VM
|
||||||
|
or CT is destroyed so the dashboard never surfaces a dead ID.
|
||||||
|
Returns the number of links updated (0 or more)."""
|
||||||
|
try:
|
||||||
|
target = int(vmid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
changed = 0
|
||||||
|
with _lock:
|
||||||
|
current = load_all()
|
||||||
|
for entry in current:
|
||||||
|
binding = entry.get("binding") or {}
|
||||||
|
if isinstance(binding, dict) and binding.get("vmid") == target:
|
||||||
|
entry["binding"] = None
|
||||||
|
entry["updated_at"] = int(time.time())
|
||||||
|
changed += 1
|
||||||
|
if changed:
|
||||||
|
save_all(current)
|
||||||
|
return changed
|
||||||
@@ -12,7 +12,7 @@ don't add another background thread.
|
|||||||
|
|
||||||
Performance — three caches keep the steady-state cost flat on big JBODs:
|
Performance — three caches keep the steady-state cost flat on big JBODs:
|
||||||
|
|
||||||
* ``_disk_list_cache`` — lsblk + USB filter, refreshed every 5 min.
|
* ``_disk_list_cache`` — physical disk inventory, refreshed every 5 min.
|
||||||
* ``_disk_probe_cache`` — remembers which ``smartctl -d <type>``
|
* ``_disk_probe_cache`` — remembers which ``smartctl -d <type>``
|
||||||
variant works for each disk so we skip
|
variant works for each disk so we skip
|
||||||
the 4-attempt fallback chain.
|
the 4-attempt fallback chain.
|
||||||
@@ -36,6 +36,8 @@ import time
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from smartctl_resolver import smartctl_probe_types, smartctl_result_is_standby
|
||||||
|
|
||||||
# Use the same DB the CPU temperature pipeline writes to so we share
|
# Use the same DB the CPU temperature pipeline writes to so we share
|
||||||
# the WAL file and the periodic vacuum that flask_server already runs.
|
# the WAL file and the periodic vacuum that flask_server already runs.
|
||||||
_DB_DIR = "/usr/local/share/proxmenux"
|
_DB_DIR = "/usr/local/share/proxmenux"
|
||||||
@@ -55,7 +57,7 @@ _SMARTCTL_TIMEOUT = 5
|
|||||||
# On a 24-disk host the naive sampler can spend several seconds per minute
|
# On a 24-disk host the naive sampler can spend several seconds per minute
|
||||||
# just iterating smartctl. Three caches keep the steady-state cost flat:
|
# just iterating smartctl. Three caches keep the steady-state cost flat:
|
||||||
#
|
#
|
||||||
# _disk_list_cache — the (lsblk + USB filter) result. Disks don't
|
# _disk_list_cache — the physical disk inventory. Disks don't
|
||||||
# appear/disappear between samples, so we only
|
# appear/disappear between samples, so we only
|
||||||
# re-enumerate every _DISK_LIST_TTL seconds.
|
# re-enumerate every _DISK_LIST_TTL seconds.
|
||||||
#
|
#
|
||||||
@@ -81,7 +83,7 @@ _MAX_WORKERS = 16 # cap concurrency for huge JBODs
|
|||||||
|
|
||||||
_cache_lock = threading.Lock()
|
_cache_lock = threading.Lock()
|
||||||
_disk_list_cache: Optional[tuple[float, list[str]]] = None
|
_disk_list_cache: Optional[tuple[float, list[str]]] = None
|
||||||
# Maps disk_name -> probe key: 'auto' | 'nvme' | 'ata' | 'sat'.
|
# Maps disk_name -> the working smartctl device type.
|
||||||
# Only successful probes get cached.
|
# Only successful probes get cached.
|
||||||
_disk_probe_cache: dict[str, str] = {}
|
_disk_probe_cache: dict[str, str] = {}
|
||||||
# Maps disk_name -> consecutive_failures count (cleared on success).
|
# Maps disk_name -> consecutive_failures count (cleared on success).
|
||||||
@@ -176,26 +178,8 @@ def init_disk_temperature_db() -> bool:
|
|||||||
# Disk enumeration + temperature read
|
# Disk enumeration + temperature read
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Match the modal's filter: USB drives are excluded. The hardware tab
|
|
||||||
# already hides them in the per-disk list and the user's cluster
|
|
||||||
# storage doesn't run on USB-attached disks anyway. Including them
|
|
||||||
# would clutter the history table for thumbdrives plugged in once
|
|
||||||
# during a recovery session.
|
|
||||||
def _is_usb_disk(disk_name: str) -> bool:
|
|
||||||
"""Return True for disks attached over USB. Mirrors the heuristic
|
|
||||||
in `get_disk_connection_type` in flask_server — checks the realpath
|
|
||||||
of /sys/block/<name> for `usb` in the bus chain."""
|
|
||||||
try:
|
|
||||||
link = os.path.realpath(f"/sys/block/{disk_name}")
|
|
||||||
return "/usb" in link
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _enumerate_target_disks() -> list[str]:
|
def _enumerate_target_disks() -> list[str]:
|
||||||
"""Run ``lsblk`` + USB filter. The expensive part is the realpath
|
"""Enumerate physical disks for temperature sampling."""
|
||||||
walks in ``_is_usb_disk``; both are short-lived but we still amortise
|
|
||||||
them via the disk-list cache so they only run every few minutes."""
|
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
@@ -214,8 +198,6 @@ def _enumerate_target_disks() -> list[str]:
|
|||||||
# Skip virtual/loop devices that lsblk still reports as type=disk.
|
# Skip virtual/loop devices that lsblk still reports as type=disk.
|
||||||
if name.startswith("loop") or name.startswith("zd"):
|
if name.startswith("loop") or name.startswith("zd"):
|
||||||
continue
|
continue
|
||||||
if _is_usb_disk(name):
|
|
||||||
continue
|
|
||||||
out.append(name)
|
out.append(name)
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
pass
|
pass
|
||||||
@@ -237,22 +219,6 @@ def _list_target_disks() -> list[str]:
|
|||||||
return fresh
|
return fresh
|
||||||
|
|
||||||
|
|
||||||
def _is_disk_usb(disk_name: str) -> bool:
|
|
||||||
"""True if the disk sits behind a USB bus, checked via the resolved
|
|
||||||
sysfs device path. USB-NVMe bridges (ASMedia, JMicron, Realtek) and
|
|
||||||
plain USB-HDDs both report `/sys/block/<disk>/removable = 0`, so the
|
|
||||||
older removable-flag heuristic missed them and the temperature
|
|
||||||
poller never tried the snt* driver variants that are the only way
|
|
||||||
to reach the NVMe controller behind those bridges."""
|
|
||||||
try:
|
|
||||||
base = disk_name[5:] if disk_name.startswith('/dev/') else disk_name
|
|
||||||
real = os.path.realpath(f'/sys/block/{base}')
|
|
||||||
return any(seg.startswith('usb') and (len(seg) == 3 or seg[3:].isdigit())
|
|
||||||
for seg in real.split('/'))
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _smartctl_cmd_for(disk_name: str, probe: str) -> list[str]:
|
def _smartctl_cmd_for(disk_name: str, probe: str) -> list[str]:
|
||||||
"""Build the smartctl invocation for a given probe key.
|
"""Build the smartctl invocation for a given probe key.
|
||||||
|
|
||||||
@@ -293,7 +259,7 @@ def _try_probe(disk_name: str, probe: str) -> Optional[float]:
|
|||||||
# the backoff and stop polling that drive forever) — surface it
|
# the backoff and stop polling that drive forever) — surface it
|
||||||
# as the dedicated _STANDBY sentinel so the caller skips the
|
# as the dedicated _STANDBY sentinel so the caller skips the
|
||||||
# update cleanly.
|
# update cleanly.
|
||||||
if proc.returncode == 2:
|
if smartctl_result_is_standby(proc.returncode, proc.stdout, proc.stderr):
|
||||||
return _STANDBY # type: ignore[return-value]
|
return _STANDBY # type: ignore[return-value]
|
||||||
# smartctl returns non-zero on warnings (bit 0x40 etc.) even when
|
# smartctl returns non-zero on warnings (bit 0x40 etc.) even when
|
||||||
# JSON is fully populated. Don't gate on returncode — parse the
|
# JSON is fully populated. Don't gate on returncode — parse the
|
||||||
@@ -369,15 +335,9 @@ def _read_temperature(disk_name: str) -> Optional[float]:
|
|||||||
return temp
|
return temp
|
||||||
# Cached probe stopped working — fall through and re-detect.
|
# Cached probe stopped working — fall through and re-detect.
|
||||||
|
|
||||||
# Slow path: try every probe and remember the first one that works.
|
# Slow path: try the transport-specific order and remember the first
|
||||||
# For USB-attached disks we prepend the three snt* driver variants —
|
# probe that returns an actual temperature.
|
||||||
# USB-NVMe bridges (ASMedia / JMicron / Realtek) don't answer the
|
probes = smartctl_probe_types(disk_name)
|
||||||
# plain probes with real SMART; only snt* passes through to the NVMe
|
|
||||||
# controller so temperature actually comes back. Non-USB disks skip
|
|
||||||
# them, so this adds zero overhead on internal drives.
|
|
||||||
probes: tuple[str, ...] = ("auto", "nvme", "ata", "sat")
|
|
||||||
if _is_disk_usb(disk_name):
|
|
||||||
probes = ("sntasmedia", "sntjmicron", "sntrealtek") + probes
|
|
||||||
for probe in probes:
|
for probe in probes:
|
||||||
if probe == cached_probe:
|
if probe == cached_probe:
|
||||||
continue # already tried above
|
continue # already tried above
|
||||||
|
|||||||
@@ -249,6 +249,78 @@ def ssl_disable():
|
|||||||
return jsonify({"success": False, "message": str(e)}), 500
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/api/ssl/reload', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def ssl_reload():
|
||||||
|
"""Reload the configured certificate without restarting the Monitor."""
|
||||||
|
config = auth_manager.load_ssl_config()
|
||||||
|
if not config.get("enabled"):
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"code": "ssl_not_enabled",
|
||||||
|
"message": "HTTPS is not enabled",
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
source = config.get("source", "custom")
|
||||||
|
cert_info = None
|
||||||
|
if source == "proxmox":
|
||||||
|
detection = auth_manager.detect_proxmox_certificates()
|
||||||
|
if not detection.get("proxmox_available"):
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"code": "certificate_unavailable",
|
||||||
|
"message": "No Proxmox certificate was detected",
|
||||||
|
}), 404
|
||||||
|
cert_path = detection.get("proxmox_cert", "")
|
||||||
|
key_path = detection.get("proxmox_key", "")
|
||||||
|
cert_info = detection.get("cert_info")
|
||||||
|
else:
|
||||||
|
cert_path = config.get("cert_path", "")
|
||||||
|
key_path = config.get("key_path", "")
|
||||||
|
|
||||||
|
valid, validation_message = auth_manager.validate_certificate_files(cert_path, key_path)
|
||||||
|
if not valid:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"code": "certificate_invalid",
|
||||||
|
"message": validation_message,
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
paths_changed = (
|
||||||
|
cert_path != config.get("cert_path", "") or
|
||||||
|
key_path != config.get("key_path", "")
|
||||||
|
)
|
||||||
|
if paths_changed:
|
||||||
|
updated_config = dict(config)
|
||||||
|
updated_config["cert_path"] = cert_path
|
||||||
|
updated_config["key_path"] = key_path
|
||||||
|
if not auth_manager.save_ssl_config(updated_config):
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"code": "config_save_failed",
|
||||||
|
"message": "Failed to save the renewed certificate paths",
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
try:
|
||||||
|
changed = auth_manager.reload_server_ssl_context(cert_path, key_path)
|
||||||
|
except Exception as e:
|
||||||
|
if paths_changed:
|
||||||
|
auth_manager.save_ssl_config(config)
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"code": "runtime_reload_failed",
|
||||||
|
"message": str(e),
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"changed": changed,
|
||||||
|
"cert_path": cert_path,
|
||||||
|
"key_path": key_path,
|
||||||
|
"cert_info": cert_info,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def _refresh_pve_webhook_for_ssl_change():
|
def _refresh_pve_webhook_for_ssl_change():
|
||||||
"""Helper used by both `ssl_configure` and `ssl_disable`.
|
"""Helper used by both `ssl_configure` and `ssl_disable`.
|
||||||
|
|
||||||
|
|||||||
@@ -454,6 +454,7 @@ def get_remote_storages():
|
|||||||
'used': storage.get('used', 0),
|
'used': storage.get('used', 0),
|
||||||
'available': storage.get('available', 0),
|
'available': storage.get('available', 0),
|
||||||
'percent': storage.get('percent', 0),
|
'percent': storage.get('percent', 0),
|
||||||
|
'capacity_known': storage.get('capacity_known', storage.get('total', 0) > 0),
|
||||||
'exclude_health': exclusion.get('exclude_health', 0) == 1,
|
'exclude_health': exclusion.get('exclude_health', 0) == 1,
|
||||||
'exclude_notifications': exclusion.get('exclude_notifications', 0) == 1,
|
'exclude_notifications': exclusion.get('exclude_notifications', 0) == 1,
|
||||||
'excluded_at': exclusion.get('excluded_at'),
|
'excluded_at': exclusion.get('excluded_at'),
|
||||||
|
|||||||
@@ -808,12 +808,21 @@ def send_notification():
|
|||||||
if not _validate_severity(severity):
|
if not _validate_severity(severity):
|
||||||
return _bad_request('Invalid severity')
|
return _bad_request('Invalid severity')
|
||||||
|
|
||||||
|
# Accept `title`/`message` either at the root of the payload
|
||||||
|
# or nested under `data` — the public docs show the nested
|
||||||
|
# form (`data.message`) as the primary example, so falling
|
||||||
|
# back to it prevents "empty title/message" custom events
|
||||||
|
# (issue #297).
|
||||||
|
payload_body = data.get('data') if isinstance(data.get('data'), dict) else {}
|
||||||
|
title = data.get('title') or payload_body.get('title') or ''
|
||||||
|
message = data.get('message') or payload_body.get('message') or ''
|
||||||
|
|
||||||
result = notification_manager.send_notification(
|
result = notification_manager.send_notification(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
severity=severity,
|
severity=severity,
|
||||||
title=data.get('title', ''),
|
title=title,
|
||||||
message=data.get('message', ''),
|
message=message,
|
||||||
data=data.get('data', {}),
|
data=payload_body,
|
||||||
source='api'
|
source='api'
|
||||||
)
|
)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
@@ -1212,9 +1221,13 @@ def setup_pve_webhook_core() -> dict:
|
|||||||
# `could not decode UTF8 string from base64, key 'X-Webhook-Secret' (500)`
|
# `could not decode UTF8 string from base64, key 'X-Webhook-Secret' (500)`
|
||||||
# whenever `token_urlsafe` produced `-` or `_` chars (GH #198).
|
# whenever `token_urlsafe` produced `-` or `_` chars (GH #198).
|
||||||
secret_b64 = base64.b64encode(secret.encode()).decode()
|
secret_b64 = base64.b64encode(secret.encode()).decode()
|
||||||
|
# PVE parses /etc/pve/*.cfg TAB-strict. The endpoint_block above
|
||||||
|
# indents with `\t`; the priv_block MUST too, or PVE silently
|
||||||
|
# ignores the `secret` line and never sends `X-Webhook-Secret`,
|
||||||
|
# so every remote delivery lands as 401 invalid_secret. GH #294.
|
||||||
priv_block = (
|
priv_block = (
|
||||||
f"webhook: {_PVE_ENDPOINT_ID}\n"
|
f"webhook: {_PVE_ENDPOINT_ID}\n"
|
||||||
f" secret name=X-Webhook-Secret,value={secret_b64}\n"
|
f"\tsecret name=X-Webhook-Secret,value={secret_b64}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
if priv_text is not None:
|
if priv_text is not None:
|
||||||
@@ -1234,8 +1247,13 @@ def setup_pve_webhook_core() -> dict:
|
|||||||
result['error'] = f'Permission denied writing {_PVE_PRIV_CFG}'
|
result['error'] = f'Permission denied writing {_PVE_PRIV_CFG}'
|
||||||
result['fallback_commands'] = _build_webhook_fallback()
|
result['fallback_commands'] = _build_webhook_fallback()
|
||||||
return result
|
return result
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
# Silently swallowing this here would report configured:True
|
||||||
|
# while PVE has no valid secret — exactly the failure mode of
|
||||||
|
# GH #294. Surface it so the caller can flag the setup.
|
||||||
|
result['error'] = f'Failed writing {_PVE_PRIV_CFG}: {e}'
|
||||||
|
result['fallback_commands'] = _build_webhook_fallback()
|
||||||
|
return result
|
||||||
|
|
||||||
result['configured'] = True
|
result['configured'] = True
|
||||||
result['secret'] = secret
|
result['secret'] = secret
|
||||||
@@ -1489,19 +1507,37 @@ def proxmox_webhook():
|
|||||||
if not hmac.compare_digest(configured_secret, request_secret):
|
if not hmac.compare_digest(configured_secret, request_secret):
|
||||||
return _reject(401, 'invalid_secret', 401)
|
return _reject(401, 'invalid_secret', 401)
|
||||||
|
|
||||||
# Layer 3: Anti-replay timestamp
|
# Layer 3: Anti-replay timestamp.
|
||||||
|
# PVE's webhook notification target can only send a static secret
|
||||||
|
# header + a Handlebars-templated body; it cannot inject a custom
|
||||||
|
# dynamic header, so `X-ProxMenux-Timestamp` never arrives from a
|
||||||
|
# PVE-origin delivery (GH #294). Our own PVE endpoint template
|
||||||
|
# already embeds `"timestamp":"{{ timestamp }}"` (Unix epoch), so
|
||||||
|
# accept the body value as a fallback when the header is absent.
|
||||||
|
# The replay cache in Layer 4 still binds every accepted request
|
||||||
|
# to (timestamp, raw_body), so this widens the source of the
|
||||||
|
# timestamp without weakening the anti-replay guarantee.
|
||||||
|
raw_body = request.get_data(as_text=True) or ''
|
||||||
ts_header = request.headers.get('X-ProxMenux-Timestamp', '')
|
ts_header = request.headers.get('X-ProxMenux-Timestamp', '')
|
||||||
if not ts_header:
|
ts_value = None
|
||||||
return _reject(401, 'missing_timestamp', 401)
|
if ts_header:
|
||||||
try:
|
try:
|
||||||
ts_value = int(ts_header)
|
ts_value = int(ts_header)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return _reject(401, 'invalid_timestamp', 401)
|
return _reject(401, 'invalid_timestamp', 401)
|
||||||
|
elif raw_body:
|
||||||
|
try:
|
||||||
|
body_ts = json.loads(raw_body).get('timestamp')
|
||||||
|
if body_ts is not None:
|
||||||
|
ts_value = int(str(body_ts).strip())
|
||||||
|
except (ValueError, TypeError, json.JSONDecodeError, AttributeError):
|
||||||
|
ts_value = None
|
||||||
|
if ts_value is None:
|
||||||
|
return _reject(401, 'missing_timestamp', 401)
|
||||||
if abs(time.time() - ts_value) > _TIMESTAMP_MAX_DRIFT:
|
if abs(time.time() - ts_value) > _TIMESTAMP_MAX_DRIFT:
|
||||||
return _reject(401, 'timestamp_expired', 401)
|
return _reject(401, 'timestamp_expired', 401)
|
||||||
|
|
||||||
# Layer 4: Replay cache
|
# Layer 4: Replay cache
|
||||||
raw_body = request.get_data(as_text=True) or ''
|
|
||||||
signature = hashlib.sha256(f"{ts_value}:{raw_body}".encode(errors='replace')).hexdigest()
|
signature = hashlib.sha256(f"{ts_value}:{raw_body}".encode(errors='replace')).hexdigest()
|
||||||
if _replay_cache.check_and_record(signature):
|
if _replay_cache.check_and_record(signature):
|
||||||
return _reject(409, 'replay_detected', 409)
|
return _reject(409, 'replay_detected', 409)
|
||||||
@@ -1548,11 +1584,15 @@ def proxmox_webhook():
|
|||||||
return _reject(400, 'missing_title', 400)
|
return _reject(400, 'missing_title', 400)
|
||||||
if not isinstance(message, str):
|
if not isinstance(message, str):
|
||||||
message = str(message) if message is not None else ''
|
message = str(message) if message is not None else ''
|
||||||
# Bound runaway sizes — webhooks shouldn't exceed a few KB of text.
|
# Keep the full webhook body for downstream parsers. PVE vzdump
|
||||||
|
# reports can legitimately exceed Telegram's 4096-character delivery
|
||||||
|
# limit when a job covers many VM/CT guests. Truncating here can cut a
|
||||||
|
# table row in half before notification_templates._parse_vzdump_message
|
||||||
|
# sees it, which makes a successful backup look like a failed one.
|
||||||
|
# Channel-specific senders (for example TelegramChannel._split_message)
|
||||||
|
# are responsible for splitting the final formatted notification.
|
||||||
if len(title) > 256:
|
if len(title) > 256:
|
||||||
payload['title'] = title[:256]
|
payload['title'] = title[:256]
|
||||||
if len(message) > 4096:
|
|
||||||
payload['message'] = message[:4096]
|
|
||||||
# Severity normalisation: accept the canonical set, default to 'info'.
|
# Severity normalisation: accept the canonical set, default to 'info'.
|
||||||
sev = (payload.get('severity') or '').lower()
|
sev = (payload.get('severity') or '').lower()
|
||||||
if sev not in {'info', 'warning', 'critical', 'error', 'notice'}:
|
if sev not in {'info', 'warning', 'critical', 'error', 'notice'}:
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ TOOL_METADATA = {
|
|||||||
'kernel_panic': {'name': 'Kernel Panic Configuration', 'function': 'configure_kernel_panic', 'version': '1.0'},
|
'kernel_panic': {'name': 'Kernel Panic Configuration', 'function': 'configure_kernel_panic', 'version': '1.0'},
|
||||||
'apt_ipv4': {'name': 'APT IPv4 Force', 'function': 'force_apt_ipv4', 'version': '1.0'},
|
'apt_ipv4': {'name': 'APT IPv4 Force', 'function': 'force_apt_ipv4', 'version': '1.0'},
|
||||||
'kexec': {'name': 'kexec for quick reboots', 'function': 'enable_kexec', 'version': '1.0'},
|
'kexec': {'name': 'kexec for quick reboots', 'function': 'enable_kexec', 'version': '1.0'},
|
||||||
|
'rpc': {'name': 'RPC / rpcbind Disable', 'function': 'disable_rpc', 'version': '1.0'},
|
||||||
|
'motd': {'name': 'Custom MOTD Banner', 'function': 'setup_motd', 'version': '1.0'},
|
||||||
|
'system_utils': {'name': 'System Utilities', 'function': 'install_system_utils', 'version': '1.0'},
|
||||||
'network_optimization': {'name': 'Network Optimizations', 'function': 'apply_network_optimizations', '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'},
|
'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.0'},
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ ProxMenux Security Routes
|
|||||||
Flask blueprint for firewall management and security tool detection.
|
Flask blueprint for firewall management and security tool detection.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
from jwt_middleware import require_auth
|
from jwt_middleware import require_auth
|
||||||
|
|
||||||
@@ -234,6 +236,80 @@ def fail2ban_jail_config():
|
|||||||
return jsonify({"success": False, "message": str(e)}), 500
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@security_bp.route('/api/security/fail2ban/trusted-networks', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def fail2ban_trusted_networks():
|
||||||
|
"""List global IP/CIDR addresses excluded from all Fail2Ban jails."""
|
||||||
|
if not security_manager:
|
||||||
|
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||||
|
try:
|
||||||
|
detected_ip = request.remote_addr
|
||||||
|
try:
|
||||||
|
parsed_ip = ipaddress.ip_address(detected_ip) if detected_ip else None
|
||||||
|
if isinstance(parsed_ip, ipaddress.IPv6Address) and parsed_ip.ipv4_mapped:
|
||||||
|
parsed_ip = parsed_ip.ipv4_mapped
|
||||||
|
if not parsed_ip or parsed_ip.is_loopback:
|
||||||
|
detected_ip = None
|
||||||
|
else:
|
||||||
|
detected_ip = str(parsed_ip)
|
||||||
|
except ValueError:
|
||||||
|
detected_ip = None
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"entries": security_manager.get_fail2ban_trusted_networks(),
|
||||||
|
"detected_ip": detected_ip,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@security_bp.route('/api/security/fail2ban/trusted-networks', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def fail2ban_add_trusted_network():
|
||||||
|
"""Add one global Fail2Ban IP/CIDR exclusion."""
|
||||||
|
if not security_manager:
|
||||||
|
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
success, message, value = security_manager.add_fail2ban_trusted_network(data.get("value", ""))
|
||||||
|
status = 200 if success else 400
|
||||||
|
return jsonify({"success": success, "message": message, "value": value}), status
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@security_bp.route('/api/security/fail2ban/trusted-networks', methods=['DELETE'])
|
||||||
|
@require_auth
|
||||||
|
def fail2ban_remove_trusted_network():
|
||||||
|
"""Remove one user-managed global Fail2Ban IP/CIDR exclusion."""
|
||||||
|
if not security_manager:
|
||||||
|
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
success, message = security_manager.remove_fail2ban_trusted_network(data.get("value", ""))
|
||||||
|
status = 200 if success else 400
|
||||||
|
return jsonify({"success": success, "message": message}), status
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@security_bp.route('/api/security/fail2ban/trusted-networks', methods=['PUT'])
|
||||||
|
@require_auth
|
||||||
|
def fail2ban_update_trusted_network():
|
||||||
|
"""Replace one user-managed global Fail2Ban IP/CIDR exclusion."""
|
||||||
|
if not security_manager:
|
||||||
|
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
success, message, value = security_manager.update_fail2ban_trusted_network(
|
||||||
|
data.get("old_value", ""), data.get("new_value", "")
|
||||||
|
)
|
||||||
|
status = 200 if success else 400
|
||||||
|
return jsonify({"success": success, "message": message, "value": value}), status
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "message": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@security_bp.route('/api/security/fail2ban/apply-jails', methods=['POST'])
|
@security_bp.route('/api/security/fail2ban/apply-jails', methods=['POST'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def fail2ban_apply_jails():
|
def fail2ban_apply_jails():
|
||||||
|
|||||||
+3755
-239
File diff suppressed because it is too large
Load Diff
@@ -108,6 +108,32 @@ sock = Sock()
|
|||||||
# Active terminal sessions
|
# Active terminal sessions
|
||||||
active_sessions = {}
|
active_sessions = {}
|
||||||
|
|
||||||
|
_script_completion_hook = None
|
||||||
|
_script_completion_hook_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def set_script_completion_hook(callback):
|
||||||
|
"""Register the backend hook invoked after a streamed script exits."""
|
||||||
|
global _script_completion_hook
|
||||||
|
with _script_completion_hook_lock:
|
||||||
|
_script_completion_hook = callback
|
||||||
|
|
||||||
|
|
||||||
|
def _run_script_completion_hook(script_path, params, exit_code, duration_seconds):
|
||||||
|
with _script_completion_hook_lock:
|
||||||
|
callback = _script_completion_hook
|
||||||
|
if callback is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
callback(
|
||||||
|
script_path=script_path,
|
||||||
|
params=dict(params or {}),
|
||||||
|
exit_code=int(exit_code),
|
||||||
|
duration_seconds=max(0, int(duration_seconds)),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[ProxMenux] script completion hook failed: {exc}", flush=True)
|
||||||
|
|
||||||
@terminal_bp.route('/api/terminal/health', methods=['GET'])
|
@terminal_bp.route('/api/terminal/health', methods=['GET'])
|
||||||
def terminal_health():
|
def terminal_health():
|
||||||
"""Health check for terminal service"""
|
"""Health check for terminal service"""
|
||||||
@@ -470,6 +496,7 @@ def script_websocket(ws, session_id):
|
|||||||
env['PYTHONUNBUFFERED'] = '1'
|
env['PYTHONUNBUFFERED'] = '1'
|
||||||
env['TERM'] = 'xterm-256color'
|
env['TERM'] = 'xterm-256color'
|
||||||
|
|
||||||
|
script_started_at = time.monotonic()
|
||||||
script_process = subprocess.Popen(
|
script_process = subprocess.Popen(
|
||||||
['/bin/bash', script_path],
|
['/bin/bash', script_path],
|
||||||
stdin=slave_fd,
|
stdin=slave_fd,
|
||||||
@@ -479,6 +506,15 @@ def script_websocket(ws, session_id):
|
|||||||
env=env
|
env=env
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The child inherited the slave side of the PTY. Keeping the parent's
|
||||||
|
# duplicate open can prevent the reader from seeing EOF after the script
|
||||||
|
# exits, which in turn hides the final script_complete message.
|
||||||
|
try:
|
||||||
|
os.close(slave_fd)
|
||||||
|
slave_fd = None
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Set non-blocking mode for master_fd
|
# Set non-blocking mode for master_fd
|
||||||
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
||||||
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||||
@@ -571,8 +607,27 @@ def script_websocket(ws, session_id):
|
|||||||
script_process.wait()
|
script_process.wait()
|
||||||
exit_code = script_process.returncode if script_process.returncode is not None else 0
|
exit_code = script_process.returncode if script_process.returncode is not None else 0
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=_run_script_completion_hook,
|
||||||
|
args=(
|
||||||
|
script_path,
|
||||||
|
params,
|
||||||
|
exit_code,
|
||||||
|
time.monotonic() - script_started_at,
|
||||||
|
),
|
||||||
|
daemon=True,
|
||||||
|
name=f'script-complete-{session_id}',
|
||||||
|
).start()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ws.send(f'\r\n[Script exited with code {exit_code}]\r\n')
|
ws.send(f'\r\n[Script exited with code {exit_code}]\r\n')
|
||||||
|
# Send an explicit terminal result before the connection is
|
||||||
|
# closed. The browser previously saw the worker disappear as a
|
||||||
|
# generic WebSocket failure even when the script exited with 0.
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'type': 'script_complete',
|
||||||
|
'exit_code': exit_code,
|
||||||
|
}))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -581,10 +636,16 @@ def script_websocket(ws, session_id):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
data = ws.receive(timeout=None)
|
data = ws.receive(timeout=0.25)
|
||||||
|
|
||||||
if data is None:
|
if data is None:
|
||||||
|
if script_process.poll() is not None:
|
||||||
|
# The output worker owns the final PTY drain and emits
|
||||||
|
# both `[Script exited with code N]` and
|
||||||
|
# `script_complete`. Wait briefly for it before cleanup.
|
||||||
|
output_thread.join(timeout=2.0)
|
||||||
break
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = json.loads(data)
|
msg = json.loads(data)
|
||||||
@@ -625,6 +686,14 @@ def script_websocket(ws, session_id):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if script_process.poll() is not None:
|
if script_process.poll() is not None:
|
||||||
|
# The output worker owns the final PTY drain and emits both
|
||||||
|
# `[Script exited with code N]` and `script_complete`. A
|
||||||
|
# resize/ping arriving just after process exit used to make
|
||||||
|
# this receive loop enter cleanup immediately, closing the
|
||||||
|
# socket before those final frames were sent. Wait briefly
|
||||||
|
# for the worker so a normal script exit is delivered before
|
||||||
|
# teardown.
|
||||||
|
output_thread.join(timeout=2.0)
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -644,6 +713,7 @@ def script_websocket(ws, session_id):
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if slave_fd is not None:
|
||||||
try:
|
try:
|
||||||
os.close(slave_fd)
|
os.close(slave_fd)
|
||||||
except:
|
except:
|
||||||
|
|||||||
+278
-135
@@ -19,6 +19,12 @@ from collections import defaultdict
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from health_persistence import health_persistence, disk_base_name
|
from health_persistence import health_persistence, disk_base_name
|
||||||
|
from proxmox_known_errors import analyze_oom_event, format_oom_diagnosis
|
||||||
|
from smartctl_resolver import (
|
||||||
|
is_usb_disk as resolver_is_usb_disk,
|
||||||
|
probe_smartctl_json,
|
||||||
|
smart_json_has_telemetry,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from proxmox_storage_monitor import proxmox_storage_monitor
|
from proxmox_storage_monitor import proxmox_storage_monitor
|
||||||
@@ -100,14 +106,6 @@ def _fmt_entity_and_summary(items, singular: str, plural: str, limit: int = _NAM
|
|||||||
return title_entity, reason
|
return title_entity, reason
|
||||||
|
|
||||||
|
|
||||||
# USB-NVMe bridges (ASMedia, JMicron, Realtek) answer plain smartctl with
|
|
||||||
# the *bridge* identity — model shows as "ASMT 2462 NVME" and there is no
|
|
||||||
# temperature. Only `-d snt*` passes through to the actual NVMe controller
|
|
||||||
# behind the bridge. For removable disks we try the snt* variants first
|
|
||||||
# so both identity and health reflect the drive, not the enclosure.
|
|
||||||
_USB_NVME_DRIVERS = ('sntasmedia', 'sntjmicron', 'sntrealtek')
|
|
||||||
|
|
||||||
|
|
||||||
def _disk_base_for_sysfs(name: str) -> str:
|
def _disk_base_for_sysfs(name: str) -> str:
|
||||||
"""Normalize `/dev/sda` / `sda` to just `sda` for `/sys/block/<name>` lookups."""
|
"""Normalize `/dev/sda` / `sda` to just `sda` for `/sys/block/<name>` lookups."""
|
||||||
if name.startswith('/dev/'):
|
if name.startswith('/dev/'):
|
||||||
@@ -142,10 +140,13 @@ def _hdd_in_standby(disk_name: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
parked = False
|
parked = False
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
result = probe_smartctl_json(
|
||||||
['smartctl', '-n', 'standby', '-i', f'/dev/{base}'],
|
base,
|
||||||
capture_output=True, text=True, timeout=5)
|
('-n', 'standby', '-i', '-j'),
|
||||||
parked = r.returncode == 2
|
timeout=5,
|
||||||
|
require_telemetry=False,
|
||||||
|
)
|
||||||
|
parked = bool(result.get('standby'))
|
||||||
except Exception:
|
except Exception:
|
||||||
parked = False
|
parked = False
|
||||||
_standby_cache[base] = (now, parked)
|
_standby_cache[base] = (now, parked)
|
||||||
@@ -195,19 +196,8 @@ def _is_disk_removable(disk_name: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _is_disk_usb(disk_name: str) -> bool:
|
def _is_disk_usb(disk_name: str) -> bool:
|
||||||
"""True if the disk sits behind a USB bus. Reads the resolved sysfs
|
"""True when sysfs places the disk behind a USB bus."""
|
||||||
device path — reliable for USB-NVMe bridges and USB-attached HDDs
|
return resolver_is_usb_disk(disk_name)
|
||||||
that report `removable=0` even though they ARE USB (so the older
|
|
||||||
`_is_disk_removable` heuristic skipped snt* driver probes and left
|
|
||||||
NVMe-behind-a-bridge disks with the bridge's own chatter cached
|
|
||||||
forever)."""
|
|
||||||
try:
|
|
||||||
base = _disk_base_for_sysfs(disk_name)
|
|
||||||
real = os.path.realpath(f'/sys/block/{base}')
|
|
||||||
return any(seg.startswith('usb') and (len(seg) == 3 or seg[3:].isdigit())
|
|
||||||
for seg in real.split('/'))
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
class HealthMonitor:
|
class HealthMonitor:
|
||||||
"""
|
"""
|
||||||
@@ -229,7 +219,12 @@ class HealthMonitor:
|
|||||||
MEMORY_CRITICAL = 95
|
MEMORY_CRITICAL = 95
|
||||||
MEMORY_DURATION = 300 # 5 minutes sustained (aligned with CPU)
|
MEMORY_DURATION = 300 # 5 minutes sustained (aligned with CPU)
|
||||||
SWAP_WARNING_DURATION = 300
|
SWAP_WARNING_DURATION = 300
|
||||||
SWAP_CRITICAL_PERCENT = 5
|
# Swap CRITICAL now requires BOTH: swap file nearly full AND RAM
|
||||||
|
# genuinely tight. Alerting on just one of them fired constantly on
|
||||||
|
# healthy Proxmox hosts where the kernel proactively swaps out
|
||||||
|
# inactive pages while RAM remains plentifully available.
|
||||||
|
SWAP_HIGH_PERCENT = 80 # % of swap file in use
|
||||||
|
AVAILABLE_MIN_PERCENT = 15 # % of RAM that must stay available
|
||||||
SWAP_CRITICAL_DURATION = 120
|
SWAP_CRITICAL_DURATION = 120
|
||||||
|
|
||||||
# Storage Thresholds
|
# Storage Thresholds
|
||||||
@@ -445,7 +440,8 @@ class HealthMonitor:
|
|||||||
(("cpu", "critical"), "CPU_CRITICAL"),
|
(("cpu", "critical"), "CPU_CRITICAL"),
|
||||||
(("memory", "warning"), "MEMORY_WARNING"),
|
(("memory", "warning"), "MEMORY_WARNING"),
|
||||||
(("memory", "critical"), "MEMORY_CRITICAL"),
|
(("memory", "critical"), "MEMORY_CRITICAL"),
|
||||||
(("memory", "swap_critical"), "SWAP_CRITICAL_PERCENT"),
|
(("memory", "swap_high"), "SWAP_HIGH_PERCENT"),
|
||||||
|
(("memory", "available_min"), "AVAILABLE_MIN_PERCENT"),
|
||||||
(("host_storage", "warning"), "STORAGE_WARNING"),
|
(("host_storage", "warning"), "STORAGE_WARNING"),
|
||||||
(("host_storage", "critical"), "STORAGE_CRITICAL"),
|
(("host_storage", "critical"), "STORAGE_CRITICAL"),
|
||||||
(("cpu_temperature", "warning"), "TEMP_WARNING"),
|
(("cpu_temperature", "warning"), "TEMP_WARNING"),
|
||||||
@@ -635,12 +631,12 @@ class HealthMonitor:
|
|||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
mem_percent = memory.percent
|
mem_percent = memory.percent
|
||||||
swap_percent = swap.percent if swap.total > 0 else 0
|
swap_percent = swap.percent if swap.total > 0 else 0
|
||||||
swap_vs_ram = (swap.used / memory.total * 100) if memory.total > 0 else 0
|
available_percent = (memory.available / memory.total * 100) if memory.total > 0 else 100
|
||||||
state_key = 'memory_usage'
|
state_key = 'memory_usage'
|
||||||
self.state_history[state_key].append({
|
self.state_history[state_key].append({
|
||||||
'mem_percent': mem_percent,
|
'mem_percent': mem_percent,
|
||||||
'swap_percent': swap_percent,
|
'swap_percent': swap_percent,
|
||||||
'swap_vs_ram': swap_vs_ram,
|
'available_percent': available_percent,
|
||||||
'time': current_time
|
'time': current_time
|
||||||
})
|
})
|
||||||
# Prune entries older than 10 minutes
|
# Prune entries older than 10 minutes
|
||||||
@@ -964,6 +960,20 @@ class HealthMonitor:
|
|||||||
elif lxc_disk_result.get('status') == 'WARNING':
|
elif lxc_disk_result.get('status') == 'WARNING':
|
||||||
warning_issues.append(lxc_disk_result.get('reason', 'LXC rootfs filling up'))
|
warning_issues.append(lxc_disk_result.get('reason', 'LXC rootfs filling up'))
|
||||||
|
|
||||||
|
# QEMU VM filesystem usage via guest agent — mirrors the LXC
|
||||||
|
# rootfs check but reads from the guest agent because pvesh
|
||||||
|
# reports disk=0 for most QEMU storage backends. VMs without
|
||||||
|
# a responsive agent are silently skipped (no signal ≠ OK).
|
||||||
|
_t = time.time()
|
||||||
|
vm_disk_result = self._check_vm_disk_usage()
|
||||||
|
_perf_log("vm_disk_usage", (time.time() - _t) * 1000)
|
||||||
|
if vm_disk_result:
|
||||||
|
details['vm_disk'] = vm_disk_result
|
||||||
|
if vm_disk_result.get('status') == 'CRITICAL':
|
||||||
|
critical_issues.append(vm_disk_result.get('reason', 'VM filesystems near full'))
|
||||||
|
elif vm_disk_result.get('status') == 'WARNING':
|
||||||
|
warning_issues.append(vm_disk_result.get('reason', 'VM filesystems filling up'))
|
||||||
|
|
||||||
# Phase 3 capacity checks added on top of the existing storage
|
# Phase 3 capacity checks added on top of the existing storage
|
||||||
# ones. Each is independently configurable via Settings →
|
# ones. Each is independently configurable via Settings →
|
||||||
# Health Thresholds; defaults are 85/95 to align with the host
|
# Health Thresholds; defaults are 85/95 to align with the host
|
||||||
@@ -1601,7 +1611,13 @@ class HealthMonitor:
|
|||||||
def _check_memory_comprehensive(self) -> Dict[str, Any]:
|
def _check_memory_comprehensive(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Check memory including RAM and swap with realistic thresholds.
|
Check memory including RAM and swap with realistic thresholds.
|
||||||
Only alerts on truly problematic memory situations.
|
|
||||||
|
Swap CRITICAL requires the memory-pressure AND-clause: swap is
|
||||||
|
called out only when the swap file is nearly full AND RAM is
|
||||||
|
genuinely tight (available memory below the configured floor).
|
||||||
|
Alerting on swap size alone fires constantly on healthy hosts
|
||||||
|
where Linux proactively swaps out inactive pages — the user
|
||||||
|
can't act on that signal and it drowns real pressure events.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
memory = psutil.virtual_memory()
|
memory = psutil.virtual_memory()
|
||||||
@@ -1610,13 +1626,13 @@ class HealthMonitor:
|
|||||||
|
|
||||||
mem_percent = memory.percent
|
mem_percent = memory.percent
|
||||||
swap_percent = swap.percent if swap.total > 0 else 0
|
swap_percent = swap.percent if swap.total > 0 else 0
|
||||||
swap_vs_ram = (swap.used / memory.total * 100) if memory.total > 0 else 0
|
available_percent = (memory.available / memory.total * 100) if memory.total > 0 else 100
|
||||||
|
|
||||||
state_key = 'memory_usage'
|
state_key = 'memory_usage'
|
||||||
self.state_history[state_key].append({
|
self.state_history[state_key].append({
|
||||||
'mem_percent': mem_percent,
|
'mem_percent': mem_percent,
|
||||||
'swap_percent': swap_percent,
|
'swap_percent': swap_percent,
|
||||||
'swap_vs_ram': swap_vs_ram,
|
'available_percent': available_percent,
|
||||||
'time': current_time
|
'time': current_time
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1637,10 +1653,15 @@ class HealthMonitor:
|
|||||||
current_time - entry['time'] <= self.MEMORY_DURATION
|
current_time - entry['time'] <= self.MEMORY_DURATION
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Swap CRITICAL requires BOTH conditions sustained. Older
|
||||||
|
# samples predating the new `available_percent` field are
|
||||||
|
# skipped rather than defaulted to a passing value so the
|
||||||
|
# transition period never manufactures a false positive.
|
||||||
swap_critical = sum(
|
swap_critical = sum(
|
||||||
1 for entry in self.state_history[state_key]
|
1 for entry in self.state_history[state_key]
|
||||||
if entry['swap_vs_ram'] > 20 and
|
if entry['swap_percent'] > self.SWAP_HIGH_PERCENT
|
||||||
current_time - entry['time'] <= self.SWAP_CRITICAL_DURATION
|
and entry.get('available_percent', 100) < self.AVAILABLE_MIN_PERCENT
|
||||||
|
and current_time - entry['time'] <= self.SWAP_CRITICAL_DURATION
|
||||||
)
|
)
|
||||||
|
|
||||||
# Require sustained high usage across most of the 300s window.
|
# Require sustained high usage across most of the 300s window.
|
||||||
@@ -1659,7 +1680,8 @@ class HealthMonitor:
|
|||||||
reason = f'RAM >90% sustained for {actual_duration}s'
|
reason = f'RAM >90% sustained for {actual_duration}s'
|
||||||
elif swap_critical >= 2:
|
elif swap_critical >= 2:
|
||||||
status = 'CRITICAL'
|
status = 'CRITICAL'
|
||||||
reason = f'Swap >20% of RAM ({swap_vs_ram:.1f}%)'
|
reason = (f'Memory pressure: swap {swap_percent:.0f}% used '
|
||||||
|
f'and only {available_percent:.0f}% RAM available')
|
||||||
elif mem_warning_count >= MEM_WARNING_MIN_SAMPLES:
|
elif mem_warning_count >= MEM_WARNING_MIN_SAMPLES:
|
||||||
oldest = min(s['time'] for s in mem_warning_samples)
|
oldest = min(s['time'] for s in mem_warning_samples)
|
||||||
actual_duration = int(current_time - oldest)
|
actual_duration = int(current_time - oldest)
|
||||||
@@ -1687,11 +1709,16 @@ class HealthMonitor:
|
|||||||
'checks': {
|
'checks': {
|
||||||
'ram_usage': {
|
'ram_usage': {
|
||||||
'status': ram_status,
|
'status': ram_status,
|
||||||
'detail': 'High RAM usage sustained' if ram_status != 'OK' else 'Normal'
|
'detail': 'High RAM usage sustained' if ram_status != 'OK' else 'Normal',
|
||||||
|
'dismissable': True,
|
||||||
},
|
},
|
||||||
'swap_usage': {
|
'swap_usage': {
|
||||||
'status': swap_status,
|
'status': swap_status,
|
||||||
'detail': 'Excessive swap usage' if swap_status != 'OK' else ('Normal' if swap.total > 0 else 'No swap configured')
|
'detail': (
|
||||||
|
'Swap nearly full with RAM tight' if swap_status != 'OK'
|
||||||
|
else ('Normal' if swap.total > 0 else 'No swap configured')
|
||||||
|
),
|
||||||
|
'dismissable': True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2043,21 +2070,9 @@ class HealthMonitor:
|
|||||||
is_usb = tran == 'USB'
|
is_usb = tran == 'USB'
|
||||||
is_nvme = disk_name.startswith('nvme')
|
is_nvme = disk_name.startswith('nvme')
|
||||||
|
|
||||||
# Get serial from smartctl
|
identity = self._get_disk_identity(disk_name)
|
||||||
serial = ''
|
serial = identity.get('serial', '')
|
||||||
model = ''
|
model = identity.get('model', '')
|
||||||
try:
|
|
||||||
smart_result = subprocess.run(
|
|
||||||
['smartctl', '-i', '-j', f'/dev/{disk_name}'],
|
|
||||||
capture_output=True, text=True, timeout=5
|
|
||||||
)
|
|
||||||
if smart_result.returncode in (0, 4): # 4 = SMART not available but info OK
|
|
||||||
import json
|
|
||||||
smart_data = json.loads(smart_result.stdout)
|
|
||||||
serial = smart_data.get('serial_number', '')
|
|
||||||
model = smart_data.get('model_name', '') or smart_data.get('model_family', '')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
physical_disks[disk_name] = {
|
physical_disks[disk_name] = {
|
||||||
'serial': serial,
|
'serial': serial,
|
||||||
@@ -2568,35 +2583,15 @@ class HealthMonitor:
|
|||||||
try:
|
try:
|
||||||
dev_path = f'/dev/{disk_name}' if not disk_name.startswith('/') else disk_name
|
dev_path = f'/dev/{disk_name}' if not disk_name.startswith('/') else disk_name
|
||||||
|
|
||||||
# USB-attached disks may sit behind an NVMe bridge: try the
|
probe = probe_smartctl_json(
|
||||||
# snt* driver variants first so identity reflects the drive
|
dev_path,
|
||||||
# (Samsung 990 PRO) rather than the enclosure (ASMT 2462 NVME).
|
('-i', '-j'),
|
||||||
# If all snt* fail, fall through to the plain call — that's
|
timeout=5,
|
||||||
# still correct for USB-SATA sticks and non-USB devices.
|
require_telemetry=False,
|
||||||
# USB detection is by sysfs path (`_is_disk_usb`) rather than
|
)
|
||||||
# the `removable` flag, since USB-NVMe and USB-HDD both report
|
data = probe.get('data', {})
|
||||||
# `removable=0` even though they ARE USB.
|
result['serial'] = data.get('serial_number', '')
|
||||||
attempts = []
|
result['model'] = data.get('model_name', '') or data.get('model_family', '')
|
||||||
if _is_disk_usb(disk_name) or _is_disk_removable(disk_name):
|
|
||||||
for drv in _USB_NVME_DRIVERS:
|
|
||||||
attempts.append(['smartctl', '-i', '-j', '-d', drv, dev_path])
|
|
||||||
attempts.append(['smartctl', '-i', '-j', dev_path])
|
|
||||||
|
|
||||||
import json as _json
|
|
||||||
for cmd in attempts:
|
|
||||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
|
||||||
if proc.returncode not in (0, 4):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
data = _json.loads(proc.stdout)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
serial = data.get('serial_number', '')
|
|
||||||
model = data.get('model_name', '') or data.get('model_family', '')
|
|
||||||
if serial or model:
|
|
||||||
result['serial'] = serial
|
|
||||||
result['model'] = model
|
|
||||||
break
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -2629,49 +2624,24 @@ class HealthMonitor:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
dev_path = f'/dev/{disk_name}' if not disk_name.startswith('/') else disk_name
|
dev_path = f'/dev/{disk_name}' if not disk_name.startswith('/') else disk_name
|
||||||
# `-n standby` skips the command (exit code 2, no disk I/O)
|
probe = probe_smartctl_json(
|
||||||
# when the drive is parked, preventing the health poller
|
dev_path,
|
||||||
# from spinning up HDDs that hdparm / hd-idle just put to
|
('-n', 'standby', '-a', '-j'),
|
||||||
# sleep — issue #232. The "UNKNOWN" branch below correctly
|
timeout=5,
|
||||||
# keeps the previous cached result alive on exit code 2.
|
require_telemetry=True,
|
||||||
#
|
)
|
||||||
# USB-attached disks may sit behind an NVMe bridge: try snt*
|
if probe.get('standby'):
|
||||||
# drivers first so health reflects the actual NVMe controller.
|
return cached['result'] if cached else 'UNKNOWN'
|
||||||
# A bridge that fakes "PASSED" while the drive behind it is
|
|
||||||
# failing is exactly the false-negative we want to avoid.
|
|
||||||
# USB detection uses the sysfs path so USB-NVMe bridges (which
|
|
||||||
# report removable=0) are caught too.
|
|
||||||
attempts = []
|
|
||||||
if _is_disk_usb(disk_name) or _is_disk_removable(disk_name):
|
|
||||||
for drv in _USB_NVME_DRIVERS:
|
|
||||||
attempts.append(['smartctl', '-n', 'standby', '--health', '-j', '-d', drv, dev_path])
|
|
||||||
attempts.append(['smartctl', '-n', 'standby', '--health', '-j', dev_path])
|
|
||||||
|
|
||||||
import json as _json
|
data = probe.get('data', {})
|
||||||
smart_result = None
|
passed = data.get('smart_status', {}).get('passed')
|
||||||
for cmd in attempts:
|
if _is_disk_usb(disk_name) and not smart_json_has_telemetry(data):
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
smart_result = 'UNKNOWN'
|
||||||
if result.returncode == 2:
|
elif passed is True:
|
||||||
# Drive in standby — reuse the previous health state
|
|
||||||
# if we have one, otherwise report UNKNOWN. Either way,
|
|
||||||
# don't refresh the cache TTL so we retry on the next
|
|
||||||
# cycle (a drive can come out of standby at any time).
|
|
||||||
if cached:
|
|
||||||
return cached['result']
|
|
||||||
return 'UNKNOWN'
|
|
||||||
try:
|
|
||||||
data = _json.loads(result.stdout)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
passed = data.get('smart_status', {}).get('passed', None)
|
|
||||||
if passed is True:
|
|
||||||
smart_result = 'PASSED'
|
smart_result = 'PASSED'
|
||||||
break
|
elif passed is False:
|
||||||
if passed is False:
|
|
||||||
smart_result = 'FAILED'
|
smart_result = 'FAILED'
|
||||||
break
|
else:
|
||||||
# No opinion yet — next attempt (fallthrough to plain).
|
|
||||||
if smart_result is None:
|
|
||||||
smart_result = 'UNKNOWN'
|
smart_result = 'UNKNOWN'
|
||||||
|
|
||||||
# Cache the result with the device fingerprint for hot-swap invalidation
|
# Cache the result with the device fingerprint for hot-swap invalidation
|
||||||
@@ -4081,10 +4051,21 @@ class HealthMonitor:
|
|||||||
return reason
|
return reason
|
||||||
|
|
||||||
# Out of memory
|
# Out of memory
|
||||||
if 'out of memory' in line_lower or 'oom_kill' in line_lower:
|
if any(token in line_lower for token in (
|
||||||
m = re.search(r'Killed process\s+\d+\s+\(([^)]+)\)', line)
|
'out of memory', 'oom_kill', 'oom-kill', 'invoked oom-killer', 'oom_reaper'
|
||||||
process = m.group(1) if m else 'unknown'
|
)):
|
||||||
return f'Out of memory - system killed process "{process}" to free RAM'
|
victim = re.search(r'Killed process\s+\d+\s+\(([^)]+)\)', line, re.IGNORECASE)
|
||||||
|
if victim:
|
||||||
|
return f'Memory pressure - kernel killed process "{victim.group(1)}"'
|
||||||
|
|
||||||
|
invoker = re.search(r'(?:kernel:\s*)?([^\s:]+)\s+invoked oom-killer', line, re.IGNORECASE)
|
||||||
|
if invoker:
|
||||||
|
return (
|
||||||
|
f'Memory pressure triggered the OOM killer while "{invoker.group(1)}" '
|
||||||
|
'requested memory; this process is not necessarily the main consumer'
|
||||||
|
)
|
||||||
|
|
||||||
|
return 'Memory pressure triggered the OOM killer; inspect the complete kernel OOM block'
|
||||||
|
|
||||||
# Kernel panic
|
# Kernel panic
|
||||||
if 'kernel panic' in line_lower:
|
if 'kernel panic' in line_lower:
|
||||||
@@ -4206,6 +4187,9 @@ class HealthMonitor:
|
|||||||
if result_recent.returncode == 0:
|
if result_recent.returncode == 0:
|
||||||
recent_lines = result_recent.stdout.strip().split('\n')
|
recent_lines = result_recent.stdout.strip().split('\n')
|
||||||
previous_lines = result_previous.stdout.strip().split('\n') if result_previous.returncode == 0 else []
|
previous_lines = result_previous.stdout.strip().split('\n') if result_previous.returncode == 0 else []
|
||||||
|
recent_oom_analysis = analyze_oom_event(result_recent.stdout)
|
||||||
|
recent_oom_reason = format_oom_diagnosis(recent_oom_analysis)
|
||||||
|
processed_oom_patterns = set()
|
||||||
|
|
||||||
recent_patterns = defaultdict(int)
|
recent_patterns = defaultdict(int)
|
||||||
previous_patterns = defaultdict(int)
|
previous_patterns = defaultdict(int)
|
||||||
@@ -4226,6 +4210,21 @@ class HealthMonitor:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Normalize to a pattern for grouping
|
# Normalize to a pattern for grouping
|
||||||
|
is_oom_line = any(token in line.lower() for token in (
|
||||||
|
'out of memory', 'oom_kill', 'oom-kill',
|
||||||
|
'invoked oom-killer', 'oom_reaper'
|
||||||
|
))
|
||||||
|
if is_oom_line and recent_oom_analysis:
|
||||||
|
scope = recent_oom_analysis.get('scope') or 'unknown'
|
||||||
|
scope_id = recent_oom_analysis.get('ctid') \
|
||||||
|
or recent_oom_analysis.get('cgroup_path') \
|
||||||
|
or 'unknown'
|
||||||
|
victim = recent_oom_analysis.get('victim_process') or 'unknown'
|
||||||
|
pattern = f'oom_event_{scope}_{scope_id}_{victim}'
|
||||||
|
if pattern in processed_oom_patterns:
|
||||||
|
continue
|
||||||
|
processed_oom_patterns.add(pattern)
|
||||||
|
else:
|
||||||
pattern = self._normalize_log_pattern(line)
|
pattern = self._normalize_log_pattern(line)
|
||||||
|
|
||||||
if severity == 'CRITICAL':
|
if severity == 'CRITICAL':
|
||||||
@@ -4267,6 +4266,9 @@ class HealthMonitor:
|
|||||||
if severity == 'CRITICAL':
|
if severity == 'CRITICAL':
|
||||||
critical_errors_found[pattern] = line
|
critical_errors_found[pattern] = line
|
||||||
# Build a human-readable reason from the raw log line
|
# Build a human-readable reason from the raw log line
|
||||||
|
if is_oom_line and recent_oom_reason:
|
||||||
|
enriched_reason = recent_oom_reason
|
||||||
|
else:
|
||||||
enriched_reason = self._enrich_critical_log_reason(line)
|
enriched_reason = self._enrich_critical_log_reason(line)
|
||||||
|
|
||||||
# Append SMART context to the reason if we checked it
|
# Append SMART context to the reason if we checked it
|
||||||
@@ -4284,9 +4286,13 @@ class HealthMonitor:
|
|||||||
category='logs',
|
category='logs',
|
||||||
severity=severity,
|
severity=severity,
|
||||||
reason=enriched_reason,
|
reason=enriched_reason,
|
||||||
details={'pattern': pattern, 'raw_line': line[:200],
|
details={
|
||||||
|
'pattern': pattern,
|
||||||
|
'raw_line': line[:200],
|
||||||
'smart_status': smart_status_for_log,
|
'smart_status': smart_status_for_log,
|
||||||
'dismissable': True}
|
'oom_analysis': recent_oom_analysis if is_oom_line else None,
|
||||||
|
'dismissable': True,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cross-reference: filesystem errors also belong in the disks category
|
# Cross-reference: filesystem errors also belong in the disks category
|
||||||
@@ -4334,14 +4340,8 @@ class HealthMonitor:
|
|||||||
try:
|
try:
|
||||||
obs_serial = None
|
obs_serial = None
|
||||||
try:
|
try:
|
||||||
sm = subprocess.run(
|
identity = self._get_disk_identity(base_device)
|
||||||
['smartctl', '-i', f'/dev/{base_device}'],
|
obs_serial = identity.get('serial') or None
|
||||||
capture_output=True, text=True, timeout=3)
|
|
||||||
if sm.returncode in (0, 4):
|
|
||||||
for sline in sm.stdout.split('\n'):
|
|
||||||
if 'Serial Number' in sline or 'Serial number' in sline:
|
|
||||||
obs_serial = sline.split(':')[-1].strip()
|
|
||||||
break
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
health_persistence.record_disk_observation(
|
health_persistence.record_disk_observation(
|
||||||
@@ -6262,6 +6262,149 @@ class HealthMonitor:
|
|||||||
'checks': checks,
|
'checks': checks,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _check_vm_disk_usage(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""QEMU VM filesystem usage via the guest agent.
|
||||||
|
|
||||||
|
Sibling of ``_check_lxc_disk_usage`` that closes the analogous
|
||||||
|
gap for VMs: ``pvesh cluster resources`` reports ``disk=0`` for
|
||||||
|
most QEMU storage backends (PVE can't see inside the guest),
|
||||||
|
so this check asks the guest agent directly for every running
|
||||||
|
QEMU VM and emits WARNING at 85% / CRITICAL at 95% — same
|
||||||
|
defaults as the LXC counterpart. The aggregated total includes
|
||||||
|
every persistent filesystem the guest reports as backed by a
|
||||||
|
block device, PCI-passthrough drives included: the metric is
|
||||||
|
"how full is the guest", not "how full is the virtual disk
|
||||||
|
PVE knows about", which is intentionally more useful for
|
||||||
|
appliances like TrueNAS or a Synology VM.
|
||||||
|
|
||||||
|
VMs whose agent is absent, unreachable, times out, or reports
|
||||||
|
no usable data are skipped — no false OK, no false alert.
|
||||||
|
Reads the pre-computed cache maintained by the daemon refresher
|
||||||
|
in ``flask_server``; never spawns a subprocess on the check
|
||||||
|
path, so a slow / dead guest agent can't stretch the health
|
||||||
|
cycle.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import flask_server # deferred — avoids circular import
|
||||||
|
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[HealthMonitor] VM disk check failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Cheap short-circuit: no running QEMU VMs on this node.
|
||||||
|
if not any(
|
||||||
|
r.get('type') in ('qemu', 'vm') and r.get('status') == 'running'
|
||||||
|
for r in resources
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
WARN_PCT, CRIT_PCT = self._read_capacity_thresholds('vm_disk', fb_warn=85, fb_crit=95)
|
||||||
|
|
||||||
|
checks: Dict[str, Dict[str, Any]] = {}
|
||||||
|
critical_vms: list[str] = []
|
||||||
|
warning_vms: list[str] = []
|
||||||
|
emitted_keys: set[str] = set()
|
||||||
|
|
||||||
|
for r in resources:
|
||||||
|
if r.get('type') not in ('qemu', 'vm'):
|
||||||
|
continue
|
||||||
|
if r.get('status') != 'running':
|
||||||
|
continue
|
||||||
|
|
||||||
|
vmid = r.get('vmid')
|
||||||
|
if vmid is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
computed = flask_server.get_cached_vm_disk(vmid)
|
||||||
|
except Exception:
|
||||||
|
computed = None
|
||||||
|
|
||||||
|
if computed is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
used, total = computed
|
||||||
|
if total <= 0:
|
||||||
|
continue
|
||||||
|
pct = (used / total) * 100
|
||||||
|
vmid_str = str(vmid)
|
||||||
|
name = r.get('name', '') or ''
|
||||||
|
label = f'VM {vmid_str}' + (f' ({name})' if name else '')
|
||||||
|
|
||||||
|
entry: Dict[str, Any] = {
|
||||||
|
'detail': f'guest filesystems {pct:.1f}% used ({used // (1024**2)} MB / {total // (1024**2)} MB)',
|
||||||
|
'usage_percent': round(pct, 1),
|
||||||
|
'disk_bytes': used,
|
||||||
|
'maxdisk_bytes': total,
|
||||||
|
'vmid': vmid_str,
|
||||||
|
'name': name,
|
||||||
|
}
|
||||||
|
error_key = f'vm_disk_{vmid_str}'
|
||||||
|
|
||||||
|
if pct >= CRIT_PCT:
|
||||||
|
entry['status'] = 'CRITICAL'
|
||||||
|
entry['error_key'] = error_key
|
||||||
|
entry['dismissable'] = True
|
||||||
|
checks[label] = entry
|
||||||
|
critical_vms.append(label)
|
||||||
|
emitted_keys.add(error_key)
|
||||||
|
health_persistence.record_error(
|
||||||
|
error_key=error_key,
|
||||||
|
category='storage',
|
||||||
|
severity='CRITICAL',
|
||||||
|
reason=f'{label} filesystems at {pct:.1f}% ({used // (1024**2)} MB / {total // (1024**2)} MB)',
|
||||||
|
details=entry,
|
||||||
|
)
|
||||||
|
elif pct >= WARN_PCT:
|
||||||
|
entry['status'] = 'WARNING'
|
||||||
|
entry['error_key'] = error_key
|
||||||
|
entry['dismissable'] = True
|
||||||
|
checks[label] = entry
|
||||||
|
warning_vms.append(label)
|
||||||
|
emitted_keys.add(error_key)
|
||||||
|
health_persistence.record_error(
|
||||||
|
error_key=error_key,
|
||||||
|
category='storage',
|
||||||
|
severity='WARNING',
|
||||||
|
reason=f'{label} filesystems at {pct:.1f}% ({used // (1024**2)} MB / {total // (1024**2)} MB)',
|
||||||
|
details=entry,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
entry['status'] = 'OK'
|
||||||
|
checks[label] = entry
|
||||||
|
|
||||||
|
# Clear stale VM disk errors (VM stopped, agent lost, freed up).
|
||||||
|
for err in (health_persistence.get_active_errors() or []):
|
||||||
|
ek = err.get('error_key', '')
|
||||||
|
if not ek.startswith('vm_disk_'):
|
||||||
|
continue
|
||||||
|
if ek not in emitted_keys:
|
||||||
|
health_persistence.clear_error(ek)
|
||||||
|
|
||||||
|
if not checks:
|
||||||
|
return None
|
||||||
|
if critical_vms:
|
||||||
|
entity, _ = _fmt_entity_and_summary(critical_vms, 'x', 'x')
|
||||||
|
return {
|
||||||
|
'status': 'CRITICAL',
|
||||||
|
'reason': f'{len(critical_vms)} VM(s) at >{CRIT_PCT}% filesystems: {_fmt_name_list(critical_vms)}',
|
||||||
|
'entity': entity,
|
||||||
|
'checks': checks,
|
||||||
|
}
|
||||||
|
if warning_vms:
|
||||||
|
entity, _ = _fmt_entity_and_summary(warning_vms, 'x', 'x')
|
||||||
|
return {
|
||||||
|
'status': 'WARNING',
|
||||||
|
'reason': f'{len(warning_vms)} VM(s) at >{WARN_PCT}% filesystems: {_fmt_name_list(warning_vms)}',
|
||||||
|
'entity': entity,
|
||||||
|
'checks': checks,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'status': 'OK',
|
||||||
|
'reason': f'{len(checks)} running VM(s) within safe filesystem usage',
|
||||||
|
'checks': checks,
|
||||||
|
}
|
||||||
|
|
||||||
# ─── Phase 3 capacity checks ─────────────────────────────────────────────
|
# ─── Phase 3 capacity checks ─────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# Three sibling methods that all share the same shape:
|
# Three sibling methods that all share the same shape:
|
||||||
|
|||||||
@@ -924,6 +924,12 @@ class HealthPersistence:
|
|||||||
for cat, prefix in [('updates', 'security_updates'), ('updates', 'system_age'),
|
for cat, prefix in [('updates', 'security_updates'), ('updates', 'system_age'),
|
||||||
('updates', 'pending_updates'), ('updates', 'kernel_pve'),
|
('updates', 'pending_updates'), ('updates', 'kernel_pve'),
|
||||||
('security', 'security_'),
|
('security', 'security_'),
|
||||||
|
# `vm_disk_<vmid>` is a storage-category key that WOULD otherwise
|
||||||
|
# match the `vm_` prefix below and end up mis-tagged under `vms`;
|
||||||
|
# putting the storage-specific override first keeps the Dismiss
|
||||||
|
# flow honest (invalidates the storage cache, groups with the
|
||||||
|
# other capacity events).
|
||||||
|
('storage', 'vm_disk_'),
|
||||||
('pve_services', 'pve_service_'), ('vms', 'vmct_'), ('vms', 'vm_'), ('vms', 'ct_'),
|
('pve_services', 'pve_service_'), ('vms', 'vmct_'), ('vms', 'vm_'), ('vms', 'ct_'),
|
||||||
# ── Storage keys — HealthMonitor emits these under `storage` category
|
# ── Storage keys — HealthMonitor emits these under `storage` category
|
||||||
# but they used to fall through to 'general' here because no prefix
|
# but they used to fall through to 'general' here because no prefix
|
||||||
|
|||||||
@@ -65,7 +65,12 @@ DEFAULTS: dict[str, Any] = {
|
|||||||
"memory": {
|
"memory": {
|
||||||
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
|
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
|
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
"swap_critical": {"value": 5, "unit": "%", "min": 1, "max": 100, "step": 1},
|
# Swap CRITICAL requires BOTH to hold: swap_high AND
|
||||||
|
# available_min. Alerting on swap alone was too noisy on
|
||||||
|
# Proxmox hosts where Linux proactively swaps inactive pages
|
||||||
|
# while RAM stays plentifully available.
|
||||||
|
"swap_high": {"value": 80, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
|
"available_min": {"value": 15, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
},
|
},
|
||||||
"host_storage": {
|
"host_storage": {
|
||||||
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
|
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
@@ -75,6 +80,16 @@ DEFAULTS: dict[str, Any] = {
|
|||||||
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
|
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
|
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
},
|
},
|
||||||
|
"vm_disk": {
|
||||||
|
# Aggregate guest-filesystem usage for running QEMU VMs, read
|
||||||
|
# from the guest agent (mirrors `lxc_rootfs` for VMs). Includes
|
||||||
|
# every persistent filesystem the guest reports on a block
|
||||||
|
# device, so PCI-passthrough drives and add-on storage count
|
||||||
|
# towards the threshold — the metric is "how full is the
|
||||||
|
# guest", not "how full is the disk PVE knows about".
|
||||||
|
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
|
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
|
},
|
||||||
"cpu_temperature": {
|
"cpu_temperature": {
|
||||||
"warning": {"value": 80, "unit": "°C", "min": 30, "max": 120, "step": 1},
|
"warning": {"value": 80, "unit": "°C", "min": 30, "max": 120, "step": 1},
|
||||||
"critical": {"value": 90, "unit": "°C", "min": 30, "max": 120, "step": 1},
|
"critical": {"value": 90, "unit": "°C", "min": 30, "max": 120, "step": 1},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Provides decorator to protect Flask routes with JWT authentication
|
|||||||
Automatically checks auth status and validates tokens
|
Automatically checks auth status and validates tokens
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import request, jsonify
|
from flask import request, jsonify, g
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from auth_manager import load_auth_config, verify_token, verify_token_full
|
from auth_manager import load_auth_config, verify_token, verify_token_full
|
||||||
|
|
||||||
@@ -26,6 +26,17 @@ def require_auth(f):
|
|||||||
"""
|
"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
|
# Internal calls (background prewarmers, in-process cache
|
||||||
|
# refresh) bypass auth. Set `g._internal_call = True` inside
|
||||||
|
# an `app.test_request_context()` block before invoking a
|
||||||
|
# decorated handler — the flag lives only for that context so
|
||||||
|
# a real HTTP request can never accidentally inherit it.
|
||||||
|
try:
|
||||||
|
if getattr(g, '_internal_call', False):
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # No request context yet — treat as normal auth flow.
|
||||||
|
|
||||||
# Check if authentication is enabled
|
# Check if authentication is enabled
|
||||||
config = load_auth_config()
|
config = load_auth_config()
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user