mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-09 09:16:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -0,0 +1,323 @@
|
||||
#!/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,
|
||||
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.
|
||||
# - refresh=True → every EN key
|
||||
# - refresh=False → only keys where target is empty OR equals EN
|
||||
# (i.e. "not yet translated by a human")
|
||||
missing: list[str] = []
|
||||
for key, en_value in en_flat.items():
|
||||
if not en_value:
|
||||
continue
|
||||
existing = target_flat.get(key, "")
|
||||
if args.refresh:
|
||||
missing.append(key)
|
||||
elif not existing or existing == en_value:
|
||||
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)
|
||||
|
||||
try:
|
||||
translated = translate_one(
|
||||
protected,
|
||||
lang,
|
||||
args.provider,
|
||||
args.context,
|
||||
args.timeout,
|
||||
args.appimage_path,
|
||||
)
|
||||
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.",
|
||||
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)
|
||||
return 2
|
||||
|
||||
print("\ni18n messages generated successfully.", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -28,7 +28,7 @@ from urllib.parse import quote
|
||||
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:"
|
||||
TRANSLATE_CALL_RE = re.compile(
|
||||
r"""translate\s+(?P<quote>["'])(?P<text>(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""",
|
||||
@@ -260,7 +260,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument(
|
||||
"--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(
|
||||
"--provider",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
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:
|
||||
- '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 }}"
|
||||
LANGS="${LANGS:-es,de,fr,it,pt}"
|
||||
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 }}"
|
||||
git push origin develop
|
||||
@@ -0,0 +1,160 @@
|
||||
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.
|
||||
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 \
|
||||
--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", "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
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
5dcad163093e68c6d7742c6724fb7df8f3d5bdd5542462f3120d2a00ad7bf699 ProxMenux-1.2.4.AppImage
|
||||
@@ -5,6 +5,7 @@ import { GeistMono } from "geist/font/mono"
|
||||
import { ThemeProvider } from "../components/theme-provider"
|
||||
import { PwaRegister } from "../components/pwa-register"
|
||||
import { PwaInstallPrompt } from "../components/pwa-install-prompt"
|
||||
import { I18nProvider } from "../lib/i18n/provider"
|
||||
import { Suspense } from "react"
|
||||
import "./globals.css"
|
||||
|
||||
@@ -43,13 +44,15 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-background text-foreground`}>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
<PwaInstallPrompt />
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
<PwaRegister />
|
||||
<PwaInstallPrompt />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -5,8 +5,10 @@ import { ProxmoxDashboard } from "../components/proxmox-dashboard"
|
||||
import { Login } from "../components/login"
|
||||
import { AuthSetup } from "../components/auth-setup"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
export default function Home() {
|
||||
const t = useT()
|
||||
const [authStatus, setAuthStatus] = useState<{
|
||||
loading: 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="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading...</div>
|
||||
<p className="text-xs text-muted-foreground">Connecting to ProxMenux Monitor</p>
|
||||
<div className="text-sm font-medium text-foreground">{t("app.loading")}</div>
|
||||
<p className="text-xs text-muted-foreground">{t("app.connecting")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { APP_VERSION } from "./release-notes-modal"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// Issue #191: a dedicated About tab. Centralises project metadata
|
||||
// (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.
|
||||
|
||||
interface LinkRow {
|
||||
label: string
|
||||
description: string
|
||||
labelKey: string
|
||||
descriptionKey: string
|
||||
href: string
|
||||
Icon: React.ComponentType<{ className?: string }>
|
||||
accent?: keyof typeof ACCENT_CLASSES
|
||||
@@ -42,29 +43,29 @@ const ACCENT_CLASSES = {
|
||||
|
||||
const PROJECT_LINKS: LinkRow[] = [
|
||||
{
|
||||
label: "GitHub repository",
|
||||
description: "Source code, releases and issue tracker.",
|
||||
labelKey: "about.links.repository.label",
|
||||
descriptionKey: "about.links.repository.description",
|
||||
href: "https://github.com/MacRimi/ProxMenux",
|
||||
Icon: Github,
|
||||
accent: "gray",
|
||||
},
|
||||
{
|
||||
label: "Documentation",
|
||||
description: "Full user guide for ProxMenux and the Monitor.",
|
||||
labelKey: "about.links.documentation.label",
|
||||
descriptionKey: "about.links.documentation.description",
|
||||
href: "https://proxmenux.com",
|
||||
Icon: BookOpen,
|
||||
accent: "blue",
|
||||
},
|
||||
{
|
||||
label: "Discussions",
|
||||
description: "Ask questions, share custom AI prompts, swap ideas.",
|
||||
labelKey: "about.links.discussions.label",
|
||||
descriptionKey: "about.links.discussions.description",
|
||||
href: "https://github.com/MacRimi/ProxMenux/discussions",
|
||||
Icon: MessageSquare,
|
||||
accent: "purple",
|
||||
},
|
||||
{
|
||||
label: "Report a bug or request a feature",
|
||||
description: "Open an issue on GitHub — bugs, ideas, regressions.",
|
||||
labelKey: "about.links.issues.label",
|
||||
descriptionKey: "about.links.issues.description",
|
||||
href: "https://github.com/MacRimi/ProxMenux/issues",
|
||||
Icon: Bug,
|
||||
accent: "red",
|
||||
@@ -73,8 +74,8 @@ const PROJECT_LINKS: LinkRow[] = [
|
||||
|
||||
const SUPPORT_LINKS: LinkRow[] = [
|
||||
{
|
||||
label: "Support the project on Ko-fi",
|
||||
description: "ProxMenux is free and open source. Donations cover hosting and dev time.",
|
||||
labelKey: "about.links.support.label",
|
||||
descriptionKey: "about.links.support.description",
|
||||
href: "https://ko-fi.com/macrimi",
|
||||
Icon: Heart,
|
||||
accent: "pink",
|
||||
@@ -82,6 +83,7 @@ const SUPPORT_LINKS: LinkRow[] = [
|
||||
]
|
||||
|
||||
function LinkCard({ row }: { row: LinkRow }) {
|
||||
const t = useT()
|
||||
const accentClass = ACCENT_CLASSES[row.accent ?? "blue"]
|
||||
// Style mirrors the PCI Devices cards in the Hardware tab: subtle
|
||||
// translucent background by default, slightly lighter on hover, no
|
||||
@@ -101,16 +103,17 @@ function LinkCard({ row }: { row: LinkRow }) {
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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" />
|
||||
</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>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function About() {
|
||||
const t = useT()
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* 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">
|
||||
<Image
|
||||
src="/images/proxmenux-logo.png"
|
||||
alt="ProxMenux logo"
|
||||
alt={t("about.logoAlt")}
|
||||
fill
|
||||
priority
|
||||
className="object-contain"
|
||||
@@ -131,9 +134,7 @@ export function About() {
|
||||
ProxMenux Monitor
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A web dashboard and management layer for Proxmox VE — health monitoring,
|
||||
notifications, terminal, optimization tracker and more, packaged as a single
|
||||
AppImage.
|
||||
{t("about.heroDescription")}
|
||||
</p>
|
||||
<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">
|
||||
@@ -151,7 +152,7 @@ export function About() {
|
||||
const href = isPrerelease
|
||||
? "https://github.com/MacRimi/ProxMenux/releases"
|
||||
: "https://proxmenux.com/en/changelog"
|
||||
const label = isPrerelease ? "Release notes" : "Changelog"
|
||||
const label = isPrerelease ? t("about.releaseNotes") : t("about.changelog")
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
@@ -175,9 +176,9 @@ export function About() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Github className="h-4 w-4 text-muted-foreground" />
|
||||
Project
|
||||
{t("about.project.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>Repository, documentation and community channels.</CardDescription>
|
||||
<CardDescription>{t("about.project.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
@@ -195,11 +196,10 @@ export function About() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Heart className="h-4 w-4 text-pink-500" />
|
||||
Support & License
|
||||
{t("about.support.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
ProxMenux is free and open source under the GPL-3.0 license. If it's useful to
|
||||
you, a one-off contribution helps keep it that way.
|
||||
{t("about.support.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -218,11 +218,11 @@ export function About() {
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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" />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -7,12 +7,14 @@ import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface AuthSetupProps {
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const t = useT()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState<"choice" | "setup">("choice")
|
||||
const [username, setUsername] = useState("")
|
||||
@@ -74,7 +76,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to skip authentication")
|
||||
throw new Error(data.error || t("authSetup.skipFailed"))
|
||||
}
|
||||
|
||||
if (data.auth_declined) {
|
||||
@@ -86,7 +88,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
onComplete()
|
||||
} catch (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 {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -108,17 +110,17 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
setError("")
|
||||
|
||||
if (!username || !password) {
|
||||
setError("Please fill in all fields")
|
||||
setError(t("authSetup.fillFields"))
|
||||
return
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match")
|
||||
setError(t("authSetup.passwordMismatch"))
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters")
|
||||
setError(t("authSetup.passwordTooShort"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -137,7 +139,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to setup authentication")
|
||||
throw new Error(data.error || t("authSetup.setupFailed"))
|
||||
}
|
||||
|
||||
if (data.token) {
|
||||
@@ -204,7 +206,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
onComplete()
|
||||
} catch (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 {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -214,7 +216,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogTitle className="sr-only">
|
||||
{step === "choice" ? "Setup Dashboard Protection" : "Create Password"}
|
||||
{step === "choice" ? t("authSetup.choiceTitle") : t("authSetup.passwordTitle")}
|
||||
</DialogTitle>
|
||||
{step === "choice" ? (
|
||||
<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">
|
||||
<Shield className="h-8 w-8 text-blue-500" />
|
||||
</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">
|
||||
Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
|
||||
{t("authSetup.protectDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
|
||||
<Lock className="h-4 w-4 mr-2" />
|
||||
Yes, Setup Password
|
||||
{t("authSetup.setupPassword")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSkipAuth}
|
||||
@@ -240,11 +242,11 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
>
|
||||
No, Continue Without Protection
|
||||
{t("authSetup.skipProtection")}
|
||||
</Button>
|
||||
</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 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">
|
||||
<Lock className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">Setup Authentication</h2>
|
||||
<p className="text-muted-foreground text-sm">Create a username and password to protect your dashboard</p>
|
||||
<h2 className="text-2xl font-bold">{t("authSetup.setupTitle")}</h2>
|
||||
<p className="text-muted-foreground text-sm">{t("authSetup.setupDescription")}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -266,14 +268,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-sm">
|
||||
Username
|
||||
{t("authSetup.username")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="Enter username"
|
||||
placeholder={t("authSetup.usernamePlaceholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
@@ -285,14 +287,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm">
|
||||
Password
|
||||
{t("authSetup.password")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter password"
|
||||
placeholder={t("authSetup.passwordPlaceholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
@@ -312,14 +314,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password" className="text-sm">
|
||||
Confirm Password
|
||||
{t("authSetup.confirmPassword")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirm password"
|
||||
placeholder={t("authSetup.confirmPasswordPlaceholder")}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
@@ -345,19 +347,19 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
setup endpoint returns the JWT. */}
|
||||
<div className="pt-3 border-t border-border/60 space-y-4">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||
Profile · optional
|
||||
{t("authSetup.profileOptional")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="display-name" className="text-sm">
|
||||
Display name
|
||||
{t("authSetup.displayName")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="display-name"
|
||||
type="text"
|
||||
placeholder="Shown above the username in the menu"
|
||||
placeholder={t("authSetup.displayNamePlaceholder")}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
maxLength={64}
|
||||
@@ -366,12 +368,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Leave empty to render the username itself. Up to 64 characters.
|
||||
{t("authSetup.displayNameHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{avatarPreviewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -407,7 +409,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Upload className="h-3 w-3 mr-1.5" />
|
||||
{avatarFile ? "Change" : "Choose image"}
|
||||
{avatarFile ? t("authSetup.change") : t("authSetup.chooseImage")}
|
||||
</Button>
|
||||
{avatarFile && (
|
||||
<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"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1.5" />
|
||||
Clear
|
||||
{t("authSetup.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -434,10 +436,10 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<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 onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
|
||||
Back
|
||||
{t("authSetup.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu"
|
||||
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface AuthStatus {
|
||||
auth_enabled?: boolean
|
||||
@@ -57,6 +58,8 @@ interface AvatarMenuProps {
|
||||
* proper /api/auth/logout that revokes the JWT server-side too.
|
||||
*/
|
||||
export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) {
|
||||
const t = useT()
|
||||
|
||||
// IMPORTANT — all hooks must run unconditionally on every render. The
|
||||
// previous version short-circuited with `if (!auth_enabled) return null`
|
||||
// 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>
|
||||
<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"
|
||||
aria-label="Open user menu"
|
||||
aria-label={t("actions.openUserMenu")}
|
||||
// WebKit ignores `outline` for the tap-highlight overlay
|
||||
// shown on iOS / Android Chrome after a touch. That overlay
|
||||
// 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>
|
||||
)}
|
||||
{!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>
|
||||
@@ -257,13 +260,13 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
||||
{onOpenProfile && (
|
||||
<DropdownMenuItem onClick={onOpenProfile}>
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
View profile
|
||||
{t("account.viewProfile")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onOpenSecurity && (
|
||||
<DropdownMenuItem onClick={onOpenSecurity}>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Security
|
||||
{t("account.security")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(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"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Sign out
|
||||
{t("account.signOut")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Badge } from "./ui/badge"
|
||||
import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { useDiskTempThresholds } from "@/lib/health-thresholds"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
interface TempPoint {
|
||||
timestamp: number
|
||||
@@ -24,11 +25,11 @@ interface DiskTemperatureCardProps {
|
||||
// Disk-temperature thresholds come from the user-configurable backend
|
||||
// (lib/health-thresholds.ts). The classifier here takes the resolved
|
||||
// pair so the consumer can read it from the hook once per render.
|
||||
function statusFor(temp: number, t: { 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 >= t.hot) return { label: "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" }
|
||||
return { label: "Normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
|
||||
function statusFor(temp: number, thresholds: { warn: number; hot: number }) {
|
||||
if (temp <= 0) return { labelKey: "common.notAvailable", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
|
||||
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 >= thresholds.warn) return { labelKey: "details.temperature.status.warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
|
||||
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) => {
|
||||
@@ -55,6 +56,7 @@ export function DiskTemperatureCard({
|
||||
diskType,
|
||||
onOpenDetail,
|
||||
}: DiskTemperatureCardProps) {
|
||||
const t = useT()
|
||||
const [data, setData] = useState<TempPoint[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const cancelled = useRef(false)
|
||||
@@ -98,7 +100,7 @@ export function DiskTemperatureCard({
|
||||
})()
|
||||
const status = statusFor(liveTemperature, dt)
|
||||
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 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]",
|
||||
interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "",
|
||||
].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="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 }}>
|
||||
{tempDisplay}
|
||||
</p>
|
||||
@@ -124,7 +126,7 @@ export function DiskTemperatureCard({
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<Thermometer className="h-3.5 w-3.5" style={{ color: lineColor }} />
|
||||
<Badge variant="outline" className={`${status.className} text-[10px] px-2 py-0`}>
|
||||
{status.label}
|
||||
{t(status.labelKey)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,7 +136,7 @@ export function DiskTemperatureCard({
|
||||
<div className="h-full w-full animate-pulse bg-white/[0.03] rounded" />
|
||||
) : samples < 2 ? (
|
||||
<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>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
@@ -8,12 +8,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
||||
import { useIsMobile } from "../hooks/use-mobile"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { useDiskTempThresholds, type DiskTempThreshold } from "@/lib/health-thresholds"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: "hour", label: "1 Hour" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "month", label: "30 Days" },
|
||||
{ value: "hour", labelKey: "details.temperature.timeframes.hour" },
|
||||
{ value: "day", labelKey: "details.temperature.timeframes.day" },
|
||||
{ value: "week", labelKey: "details.temperature.timeframes.week" },
|
||||
{ value: "month", labelKey: "details.temperature.timeframes.month" },
|
||||
]
|
||||
|
||||
interface TempHistoryPoint {
|
||||
@@ -69,10 +70,10 @@ function colorFor(temp: number, t: DiskTempThreshold): string {
|
||||
}
|
||||
|
||||
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 >= t.hot) return { status: "Hot", 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" }
|
||||
return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
if (temp <= 0) return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||
if (temp >= t.hot) return { color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||
if (temp >= t.warn) return { color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||
return { color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
}
|
||||
|
||||
export function DiskTemperatureDetailModal({
|
||||
@@ -83,6 +84,7 @@ export function DiskTemperatureDetailModal({
|
||||
liveTemperature,
|
||||
diskType,
|
||||
}: DiskTemperatureDetailModalProps) {
|
||||
const t = useT()
|
||||
const [timeframe, setTimeframe] = useState("day")
|
||||
const [data, setData] = useState<TempHistoryPoint[]>([])
|
||||
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
|
||||
@@ -168,7 +170,7 @@ export function DiskTemperatureDetailModal({
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{t(opt.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</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={`rounded-lg p-3 text-center border ${currentStatus.color}`}>
|
||||
<div className="text-xs opacity-80 mb-1">Current</div>
|
||||
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : "N/A"}</div>
|
||||
<div className="text-xs opacity-80 mb-1">{t("details.temperature.current")}</div>
|
||||
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : t("common.notAvailable")}</div>
|
||||
</div>
|
||||
<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">
|
||||
<TrendingDown className="h-3 w-3" /> Min
|
||||
<TrendingDown className="h-3 w-3" /> {t("details.temperature.min")}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
|
||||
</div>
|
||||
<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">
|
||||
<Minus className="h-3 w-3" /> Avg
|
||||
<Minus className="h-3 w-3" /> {t("details.temperature.avg")}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
|
||||
</div>
|
||||
<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">
|
||||
<TrendingUp className="h-3 w-3" /> Max
|
||||
<TrendingUp className="h-3 w-3" /> {t("details.temperature.max")}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
|
||||
</div>
|
||||
@@ -216,8 +218,8 @@ export function DiskTemperatureDetailModal({
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No temperature data yet for this disk</p>
|
||||
<p className="text-sm mt-1">Samples are collected every 60 seconds</p>
|
||||
<p>{t("details.temperature.noData")}</p>
|
||||
<p className="text-sm mt-1">{t("details.temperature.sampleInterval")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -250,7 +252,7 @@ export function DiskTemperatureDetailModal({
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
name="Temperature"
|
||||
name={t("details.temperature.seriesName")}
|
||||
stroke={chartColor}
|
||||
strokeWidth={2}
|
||||
fill={`url(#diskTempGradient-${diskName})`}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface SriovInfo {
|
||||
role: "vf" | "pf-active" | "pf-idle"
|
||||
@@ -26,6 +27,8 @@ export function GpuSwitchModeIndicator({
|
||||
className,
|
||||
sriovInfo,
|
||||
}: GpuSwitchModeIndicatorProps) {
|
||||
const t = useT()
|
||||
|
||||
// SR-IOV is a non-editable hardware state. Pending toggles don't apply here.
|
||||
const displayMode = mode === "sriov" ? "sriov" : (pendingMode ?? mode)
|
||||
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.
|
||||
const sriovBadgeText = (() => {
|
||||
if (!isSriovActive) return ""
|
||||
if (sriovInfo?.role === "vf") return "SR-IOV VF"
|
||||
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) return `SR-IOV ×${sriovInfo.vfCount}`
|
||||
return "SR-IOV"
|
||||
if (sriovInfo?.role === "vf") return t("hardware.gpuSwitch.sriovVf")
|
||||
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) {
|
||||
return t("hardware.gpuSwitch.sriovCount", { count: sriovInfo.vfCount })
|
||||
}
|
||||
return t("hardware.gpuSwitch.sriov")
|
||||
})()
|
||||
|
||||
return (
|
||||
@@ -124,7 +129,7 @@ export function GpuSwitchModeIndicator({
|
||||
className="text-[14px] font-bold transition-all duration-300"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
GPU
|
||||
{t("hardware.gpuSwitch.gpu")}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
@@ -268,7 +273,7 @@ export function GpuSwitchModeIndicator({
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
LXC
|
||||
{t("hardware.gpuSwitch.lxc")}
|
||||
</text>
|
||||
)}
|
||||
{isSriovActive && (
|
||||
@@ -279,7 +284,7 @@ export function GpuSwitchModeIndicator({
|
||||
className="text-[9px] font-medium"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
LXC
|
||||
{t("hardware.gpuSwitch.lxc")}
|
||||
</text>
|
||||
)}
|
||||
|
||||
@@ -332,7 +337,7 @@ export function GpuSwitchModeIndicator({
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
VM
|
||||
{t("hardware.gpuSwitch.vm")}
|
||||
</text>
|
||||
)}
|
||||
{isSriovActive && (
|
||||
@@ -343,7 +348,7 @@ export function GpuSwitchModeIndicator({
|
||||
className="text-[9px] font-medium"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
VM
|
||||
{t("hardware.gpuSwitch.vm")}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
@@ -363,34 +368,47 @@ export function GpuSwitchModeIndicator({
|
||||
)}
|
||||
>
|
||||
{isSriovActive
|
||||
? "SR-IOV active"
|
||||
? t("hardware.gpuSwitch.sriovActive")
|
||||
: isLxcActive
|
||||
? "Ready for LXC containers"
|
||||
? t("hardware.gpuSwitch.readyForLxc")
|
||||
: isVmActive
|
||||
? "Ready for VM passthrough"
|
||||
: "Mode unknown"}
|
||||
? t("hardware.gpuSwitch.readyForVm")
|
||||
: t("hardware.gpuSwitch.modeUnknown")}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isSriovActive
|
||||
? "Virtual Functions managed externally"
|
||||
? t("hardware.gpuSwitch.virtualFunctionsExternal")
|
||||
: isLxcActive
|
||||
? "Native driver active"
|
||||
? t("hardware.gpuSwitch.nativeDriverActive")
|
||||
: isVmActive
|
||||
? "VFIO-PCI driver active"
|
||||
: "No driver detected"}
|
||||
? t("hardware.gpuSwitch.vfioDriverActive")
|
||||
: t("hardware.gpuSwitch.noDriverDetected")}
|
||||
</span>
|
||||
{isSriovActive && sriovInfo && (
|
||||
<span className="text-xs font-mono text-teal-600/80 dark:text-teal-400/80">
|
||||
{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
|
||||
? `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}
|
||||
</span>
|
||||
)}
|
||||
{hasChanged && (
|
||||
<span className="text-sm text-amber-500 font-medium animate-pulse">
|
||||
Change pending...
|
||||
{t("hardware.gpuSwitch.changePending")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+326
-263
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ import {
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Download,
|
||||
ArrowUpCircle,
|
||||
X,
|
||||
Clock,
|
||||
BellOff,
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
HelpCircle,
|
||||
} from "lucide-react"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
interface CategoryCheck {
|
||||
status: string
|
||||
@@ -104,19 +105,20 @@ interface HealthStatusModalProps {
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: "cpu", category: "temperature", label: "CPU Usage & Temperature", Icon: Cpu },
|
||||
{ key: "memory", category: "memory", label: "Memory & Swap", Icon: MemoryStick },
|
||||
{ key: "storage", category: "storage", label: "Storage Mounts & Space", Icon: HardDrive },
|
||||
{ key: "disks", category: "disks", label: "Disk I/O & Errors", Icon: Disc },
|
||||
{ key: "network", category: "network", label: "Network Interfaces", Icon: Network },
|
||||
{ key: "vms", category: "vms", label: "VMs & Containers", Icon: Box },
|
||||
{ key: "services", category: "pve_services", label: "PVE Services", Icon: Settings },
|
||||
{ key: "logs", category: "logs", label: "System Logs", Icon: FileText },
|
||||
{ key: "updates", category: "updates", label: "System Updates", Icon: RefreshCw },
|
||||
{ key: "security", category: "security", label: "Security & Certificates", Icon: Shield },
|
||||
{ key: "cpu", category: "temperature", Icon: Cpu },
|
||||
{ key: "memory", category: "memory", Icon: MemoryStick },
|
||||
{ key: "storage", category: "storage", Icon: HardDrive },
|
||||
{ key: "disks", category: "disks", Icon: Disc },
|
||||
{ key: "network", category: "network", Icon: Network },
|
||||
{ key: "vms", category: "vms", Icon: Box },
|
||||
{ key: "services", category: "pve_services", Icon: Settings },
|
||||
{ key: "logs", category: "logs", Icon: FileText },
|
||||
{ key: "updates", category: "updates", Icon: RefreshCw },
|
||||
{ key: "security", category: "security", Icon: Shield },
|
||||
]
|
||||
|
||||
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
|
||||
const t = useT()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
|
||||
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
||||
@@ -146,7 +148,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
if (!response.ok) {
|
||||
// Fallback to legacy endpoint
|
||||
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()
|
||||
setHealthData(data)
|
||||
setDismissedItems([])
|
||||
@@ -203,11 +205,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error")
|
||||
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [getApiUrl])
|
||||
}, [getApiUrl, t])
|
||||
|
||||
// Tick counter to force re-render every 30s so "X minutes ago" stays current
|
||||
const [, setTick] = useState(0)
|
||||
@@ -280,20 +282,90 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
const statusUpper = status?.toUpperCase()
|
||||
switch (statusUpper) {
|
||||
case "OK":
|
||||
return <Badge className="bg-green-500 text-white hover:bg-green-500">OK</Badge>
|
||||
return <Badge className="bg-green-500 text-white hover:bg-green-500">{t("healthStatus.status.ok")}</Badge>
|
||||
case "INFO":
|
||||
return <Badge className="bg-blue-500 text-white hover:bg-blue-500">Info</Badge>
|
||||
return <Badge className="bg-blue-500 text-white hover:bg-blue-500">{t("healthStatus.status.info")}</Badge>
|
||||
case "WARNING":
|
||||
return <Badge className="bg-yellow-500 text-white hover:bg-yellow-500">Warning</Badge>
|
||||
return <Badge className="bg-yellow-500 text-white hover:bg-yellow-500">{t("healthStatus.status.warning")}</Badge>
|
||||
case "CRITICAL":
|
||||
return <Badge className="bg-red-500 text-white hover:bg-red-500">Critical</Badge>
|
||||
return <Badge className="bg-red-500 text-white hover:bg-red-500">{t("healthStatus.status.critical")}</Badge>
|
||||
case "UNKNOWN":
|
||||
return <Badge className="bg-amber-500 text-white hover:bg-amber-500">UNKNOWN</Badge>
|
||||
return <Badge className="bg-amber-500 text-white hover:bg-amber-500">{t("healthStatus.status.unknown")}</Badge>
|
||||
default:
|
||||
return <Badge>Unknown</Badge>
|
||||
return <Badge>{t("healthStatus.status.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 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)
|
||||
const getCategoriesWithDismissed = () => {
|
||||
const customCats = new Set(customSuppressions.map(cs => cs.category))
|
||||
@@ -444,11 +516,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - checkTime.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return "just now"
|
||||
if (diffMin === 1) return "1 minute ago"
|
||||
if (diffMin < 60) return `${diffMin} minutes ago`
|
||||
if (diffMin < 1) return t("healthStatus.time.justNow")
|
||||
if (diffMin === 1) return t("healthStatus.time.oneMinuteAgo")
|
||||
if (diffMin < 60) return t("healthStatus.time.minutesAgo", { count: diffMin })
|
||||
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) => {
|
||||
@@ -471,49 +543,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
}
|
||||
|
||||
const formatCheckLabel = (key: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
// CPU
|
||||
cpu_usage: "CPU Usage",
|
||||
cpu_temperature: "Temperature",
|
||||
// Memory
|
||||
ram_usage: "RAM Usage",
|
||||
swap_usage: "Swap Usage",
|
||||
// Disk I/O
|
||||
root_filesystem: "Root Filesystem",
|
||||
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]
|
||||
const knownKeys = new Set([
|
||||
"cpu_usage", "cpu_temperature", "ram_usage", "swap_usage", "root_filesystem",
|
||||
"smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity",
|
||||
"qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode",
|
||||
"log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors",
|
||||
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime",
|
||||
"certificates", "login_attempts", "fail2ban", "proxmox_storages",
|
||||
])
|
||||
if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`)
|
||||
// Convert snake_case or camelCase to Title Case
|
||||
return key
|
||||
.replace(/_/g, " ")
|
||||
@@ -543,15 +581,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<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="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.permanent ? (
|
||||
<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 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>
|
||||
)
|
||||
)}
|
||||
@@ -563,6 +601,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
handleAcknowledge(checkData.error_key || checkKey, hours)
|
||||
}
|
||||
busy={dismissingKey === (checkData.error_key || checkKey)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -582,12 +621,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<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" />
|
||||
<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>}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<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() && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
@@ -605,7 +644,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
|
||||
{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">
|
||||
<p className="font-medium">Error loading health status</p>
|
||||
<p className="font-medium">{t("healthStatus.errors.loading")}</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -616,47 +655,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="text-center">
|
||||
<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 className="text-center">
|
||||
<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>
|
||||
{stats.info > 0 && (
|
||||
<div className="text-center">
|
||||
<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 className="text-center">
|
||||
<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 className="text-center">
|
||||
<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>
|
||||
{stats.unknown > 0 && (
|
||||
<div className="text-center">
|
||||
<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>
|
||||
|
||||
{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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Category List */}
|
||||
<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 originalStatus = categoryData?.status || "UNKNOWN"
|
||||
const status = getEffectiveStatus(key, originalStatus)
|
||||
const reason = categoryData?.reason
|
||||
const reason = translateHealthText(categoryData?.reason)
|
||||
const checks = categoryData?.checks
|
||||
const isExpanded = expandedCategories.has(key)
|
||||
const hasChecks = checks && Object.keys(checks).length > 0
|
||||
@@ -677,7 +716,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<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 && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
({Object.values(checks).filter(c => c.installed !== false).length})
|
||||
@@ -690,7 +729,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
</div>
|
||||
<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)}`}>
|
||||
{status}
|
||||
{formatStatus(status)}
|
||||
</Badge>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground transition-transform duration-200 ${
|
||||
@@ -713,6 +752,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
handleAcknowledge(`category_${key}_unknown`, hours)
|
||||
}
|
||||
busy={dismissingKey === `category_${key}_unknown`}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -722,7 +762,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
) : (
|
||||
<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" />
|
||||
No issues detected
|
||||
{t("healthStatus.noIssues")}
|
||||
</div>
|
||||
)}
|
||||
{/* Only offer "Update Now" when the category is not
|
||||
@@ -737,8 +777,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
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"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1.5" />
|
||||
Update Now
|
||||
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
||||
{t("healthStatus.updateNow")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -758,12 +798,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<div className="space-y-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" />
|
||||
Dismissed Items ({filteredDismissed.length})
|
||||
{t("healthStatus.dismissedItems", { count: filteredDismissed.length })}
|
||||
</div>
|
||||
{filteredDismissed.map((item) => {
|
||||
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
||||
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
|
||||
|
||||
return (
|
||||
@@ -778,34 +818,28 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<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="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 className="flex items-center gap-1.5 shrink-0">
|
||||
{isPermanent ? (
|
||||
<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 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 variant="outline" className={`text-[9px] sm:text-xs whitespace-nowrap ${getOutlineBadgeStyle(item.severity)}`}>
|
||||
was {item.severity}
|
||||
{t("healthStatus.wasStatus", { status: formatStatus(item.severity) })}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{isPermanent
|
||||
? "Permanently suppressed"
|
||||
: `Suppressed for ${
|
||||
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`
|
||||
? t("healthStatus.permanentlySuppressed")
|
||||
: t("healthStatus.suppressedForMore", { duration: formatDuration(item.suppression_remaining_hours) })
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@@ -821,30 +855,20 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<div className="space-y-2 pt-2">
|
||||
<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" />
|
||||
Custom Suppression Settings
|
||||
{t("healthStatus.customSuppressionSettings")}
|
||||
</div>
|
||||
<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">
|
||||
{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 durationLabel = cs.hours === -1
|
||||
? "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`
|
||||
const durationLabel = formatDuration(cs.hours)
|
||||
|
||||
return (
|
||||
<div key={cs.key} className="flex items-center justify-between gap-2">
|
||||
<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" />
|
||||
<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>
|
||||
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
|
||||
{durationLabel}
|
||||
@@ -854,7 +878,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
})}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -862,7 +886,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
|
||||
{healthData.timestamp && (
|
||||
<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>
|
||||
@@ -882,8 +906,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
params={{
|
||||
EXECUTION_MODE: "web",
|
||||
}}
|
||||
title="Proxmox System Update"
|
||||
description="Runs apt-get update + dist-upgrade and post-update cleanup on the host."
|
||||
title={t("healthStatus.updateTerminalTitle")}
|
||||
description={t("healthStatus.updateTerminalDescription")}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -896,9 +920,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
function DismissDropdown({
|
||||
onSelect,
|
||||
busy,
|
||||
t,
|
||||
}: {
|
||||
onSelect: (suppressionHours: number) => void
|
||||
busy: boolean
|
||||
t: ReturnType<typeof useT>
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -915,27 +941,27 @@ function DismissDropdown({
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Silence this alert for
|
||||
{t("healthStatus.silenceFor")}
|
||||
</DropdownMenuLabel>
|
||||
<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 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>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onSelect(-1)}
|
||||
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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Waves,
|
||||
} from "lucide-react"
|
||||
import { getApiUrl, getAuthToken } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// Local fetch wrapper that *preserves* the JSON body on non-2xx
|
||||
// responses so we can surface backend validation messages
|
||||
@@ -282,6 +283,11 @@ function computeVisualRange(
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
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 [loading, setLoading] = useState(true)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
@@ -299,7 +305,7 @@ export function HealthThresholds() {
|
||||
)
|
||||
if (res?.success && res.thresholds) setTree(res.thresholds)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load thresholds")
|
||||
setError(err instanceof Error ? err.message : t("settings.healthThresholds.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -322,7 +328,7 @@ export function HealthThresholds() {
|
||||
if (trimmed === "") continue
|
||||
const num = Number(trimmed)
|
||||
if (!isFinite(num)) {
|
||||
setError(`Invalid value for ${key}: must be a number`)
|
||||
setError(t("settings.healthThresholds.invalidValue", { key }))
|
||||
return null
|
||||
}
|
||||
// Walk into payload mirroring the path
|
||||
@@ -362,7 +368,7 @@ export function HealthThresholds() {
|
||||
{ method: "PUT", body: JSON.stringify(payload) },
|
||||
)
|
||||
if (!data.success || !data.thresholds) {
|
||||
setError(data.message || "Save failed")
|
||||
setError(data.message || t("status.saveFailed"))
|
||||
return
|
||||
}
|
||||
setTree(data.thresholds)
|
||||
@@ -371,14 +377,16 @@ export function HealthThresholds() {
|
||||
setSavedFlash(true)
|
||||
setTimeout(() => setSavedFlash(false), 2000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Network error while saving")
|
||||
setError(err instanceof Error ? err.message : t("status.networkErrorWhileSaving"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
try {
|
||||
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
|
||||
@@ -386,7 +394,7 @@ export function HealthThresholds() {
|
||||
{ method: "POST" },
|
||||
)
|
||||
if (!data.success || !data.thresholds) {
|
||||
setError(data.message || "Reset failed")
|
||||
setError(data.message || t("settings.healthThresholds.resetFailed"))
|
||||
return
|
||||
}
|
||||
setTree(data.thresholds)
|
||||
@@ -400,25 +408,25 @@ export function HealthThresholds() {
|
||||
return next
|
||||
})
|
||||
} 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 () => {
|
||||
if (!confirm("Reset ALL thresholds to recommended values? This affects every section.")) return
|
||||
if (!confirm(t("settings.healthThresholds.resetAllConfirm"))) return
|
||||
try {
|
||||
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
|
||||
"/api/health/thresholds/reset",
|
||||
{ method: "POST" },
|
||||
)
|
||||
if (!data.success || !data.thresholds) {
|
||||
setError(data.message || "Reset failed")
|
||||
setError(data.message || t("settings.healthThresholds.resetFailed"))
|
||||
return
|
||||
}
|
||||
setTree(data.thresholds)
|
||||
setPending({})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Network error while resetting")
|
||||
setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,7 +449,7 @@ export function HealthThresholds() {
|
||||
const isCustomised = leaf.customised && !(key in pending)
|
||||
const customisedClass = "border-blue-500 bg-blue-500/10 focus-visible:border-blue-500"
|
||||
const fieldClass = isCustomised ? customisedClass : severityClass
|
||||
const recommendedTooltip = `Recommended: ${leaf.recommended}${leaf.unit}`
|
||||
const recommendedTooltip = `${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${leaf.unit}`
|
||||
return (
|
||||
<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">
|
||||
@@ -524,12 +532,12 @@ export function HealthThresholds() {
|
||||
value={val}
|
||||
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}`}
|
||||
title={`Recommended: ${leaf.recommended}${unit}`}
|
||||
title={`${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${unit}`}
|
||||
/>
|
||||
</div>
|
||||
<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 className="text-right">{severity === "critical" ? "CRIT" : "WARN"} > {val}{unit}</span>
|
||||
<span>{t("settings.healthThresholds.ok")} < {val}{unit}</span>
|
||||
<span className="text-right">{severity === "critical" ? t("settings.healthThresholds.crit") : t("settings.healthThresholds.warn")} > {val}{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -641,7 +649,7 @@ export function HealthThresholds() {
|
||||
value={wVal}
|
||||
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"
|
||||
title={`Warning (recommended: ${wLeaf.recommended}${unit})`}
|
||||
title={`${t("settings.healthThresholds.warning")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${wLeaf.recommended}${unit})`}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
@@ -652,7 +660,7 @@ export function HealthThresholds() {
|
||||
value={cVal}
|
||||
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"
|
||||
title={`Critical (recommended: ${cLeaf.recommended}${unit})`}
|
||||
title={`${t("settings.healthThresholds.critical")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${cLeaf.recommended}${unit})`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -660,9 +668,9 @@ export function HealthThresholds() {
|
||||
"warn" starts and ends without having to read the handles. */}
|
||||
{!options?.hideLabels && (
|
||||
<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 className="text-center">WARN {wVal}–{cVal}{unit}</span>
|
||||
<span className="text-right">CRIT > {cVal}{unit}</span>
|
||||
<span>{t("settings.healthThresholds.ok")} < {wVal}{unit}</span>
|
||||
<span className="text-center">{t("settings.healthThresholds.warn")} {wVal}–{cVal}{unit}</span>
|
||||
<span className="text-right">{t("settings.healthThresholds.crit")} > {cVal}{unit}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -675,14 +683,14 @@ export function HealthThresholds() {
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<SlidersHorizontal className="h-5 w-5 text-amber-500" />
|
||||
<CardTitle>Health Monitor Thresholds</CardTitle>
|
||||
<CardTitle>{t("settings.healthThresholds.title")}</CardTitle>
|
||||
</div>
|
||||
{!loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
{savedFlash && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Saved
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{editMode ? (
|
||||
@@ -692,7 +700,7 @@ export function HealthThresholds() {
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
{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"
|
||||
@@ -704,7 +712,7 @@ export function HealthThresholds() {
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
Save
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -712,17 +720,17 @@ export function HealthThresholds() {
|
||||
<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"
|
||||
onClick={handleResetAll}
|
||||
title="Reset every threshold to its recommended value"
|
||||
title={t("settings.healthThresholds.resetAllTitle")}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset all
|
||||
{t("actions.resetAll")}
|
||||
</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={handleEdit}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
Edit
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -730,10 +738,7 @@ export function HealthThresholds() {
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
The Health Monitor and notifications fire when these thresholds are crossed.
|
||||
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.
|
||||
{t("settings.healthThresholds.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -742,7 +747,7 @@ export function HealthThresholds() {
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !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>
|
||||
{error && (
|
||||
@@ -767,13 +772,13 @@ export function HealthThresholds() {
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-2 min-w-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>
|
||||
{editMode && (
|
||||
<button
|
||||
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)}
|
||||
title="Reset this section to recommended"
|
||||
title={t("settings.healthThresholds.resetSectionTitle")}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -781,7 +786,7 @@ export function HealthThresholds() {
|
||||
</div>
|
||||
{section.description && (
|
||||
<p className="text-[11px] text-muted-foreground mb-1.5 leading-snug">
|
||||
{section.description}
|
||||
{tFallback(`settings.healthThresholds.sections.${section.id}.description`, section.description)}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
@@ -806,12 +811,12 @@ export function HealthThresholds() {
|
||||
// visual language end to end.
|
||||
<>
|
||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
|
||||
RAM
|
||||
{t("settings.healthThresholds.ram")}
|
||||
</div>
|
||||
{renderThresholdRange(["memory"])}
|
||||
<div className="border-t border-border/40">
|
||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1 pt-1.5">
|
||||
Swap (critical only)
|
||||
{t("settings.healthThresholds.swapCriticalOnly")}
|
||||
</div>
|
||||
{renderSingleThresholdSlider(["memory", "swap_critical"], "critical")}
|
||||
</div>
|
||||
|
||||
+820
-757
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 { useIsMobile } from "../hooks/use-mobile"
|
||||
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 = [
|
||||
{ value: "hour", label: "1 Hour" },
|
||||
{ value: "6hour", label: "6 Hours" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "3day", label: "3 Days" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "hour", labelKey: "network.latency.timeframes.hour" },
|
||||
{ value: "6hour", labelKey: "network.latency.timeframes.sixHours" },
|
||||
{ value: "day", labelKey: "network.latency.timeframes.day" },
|
||||
{ value: "3day", labelKey: "network.latency.timeframes.threeDays" },
|
||||
{ value: "week", labelKey: "network.latency.timeframes.week" },
|
||||
]
|
||||
|
||||
const TARGET_OPTIONS = [
|
||||
{ value: "gateway", label: "Gateway (Router)", shortLabel: "Gateway", realtime: false },
|
||||
{ value: "cloudflare", label: "Cloudflare (1.1.1.1)", shortLabel: "Cloudflare", realtime: true },
|
||||
{ value: "google", label: "Google DNS (8.8.8.8)", shortLabel: "Google DNS", realtime: true },
|
||||
{ value: "gateway", labelKey: "network.latency.targets.gateway", shortLabelKey: "network.latency.targets.gatewayShort", realtime: false },
|
||||
{ value: "cloudflare", labelKey: "network.latency.targets.cloudflare", shortLabelKey: "network.latency.targets.cloudflareShort", realtime: true },
|
||||
{ value: "google", labelKey: "network.latency.targets.google", shortLabelKey: "network.latency.targets.googleShort", realtime: true },
|
||||
]
|
||||
|
||||
// Realtime test configuration
|
||||
@@ -60,7 +63,22 @@ interface LatencyDetailModalProps {
|
||||
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) {
|
||||
const entry = payload[0]
|
||||
const data = entry?.payload
|
||||
@@ -76,17 +94,17 @@ const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
@@ -94,14 +112,14 @@ const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
// Simple latency display for single data points
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{packetLoss !== undefined && packetLoss > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -118,20 +136,38 @@ const getStatusColor = (latency: number) => {
|
||||
return "#22c55e"
|
||||
}
|
||||
|
||||
const getStatusInfo = (latency: number | null) => {
|
||||
if (latency === null || latency === 0) return { status: "N/A", 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 < 100) return { 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" }
|
||||
return { status: "Poor", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||
const getStatusInfo = (latency: number | null, t: TFunction) => {
|
||||
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: t("network.latency.status.excellent"), 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: t("network.latency.status.fair"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-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 => {
|
||||
if (latency === null || latency === 0) return "N/A"
|
||||
if (latency < 50) return "Excellent"
|
||||
if (latency < 100) return "Good"
|
||||
if (latency < 200) return "Fair"
|
||||
return "Poor"
|
||||
const getStatusKey = (latency: number | null): "na" | "excellent" | "good" | "fair" | "poor" => {
|
||||
if (latency === null || latency === 0) return "na"
|
||||
if (latency < 50) return "excellent"
|
||||
if (latency < 100) return "good"
|
||||
if (latency < 200) return "fair"
|
||||
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 {
|
||||
@@ -145,9 +181,10 @@ interface ReportData {
|
||||
testDuration?: number
|
||||
}
|
||||
|
||||
const generateLatencyReport = (report: ReportData) => {
|
||||
const generateLatencyReport = (report: ReportData, t: TFunction) => {
|
||||
const now = new Date().toLocaleString()
|
||||
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
|
||||
const validRealtimeValues = report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
|
||||
@@ -160,29 +197,57 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
} : null
|
||||
|
||||
const statusText = report.isRealtime
|
||||
? getStatusText(realtimeStats?.current ?? null)
|
||||
: getStatusText(report.stats.current)
|
||||
? getStatusText(realtimeStats?.current ?? null, t)
|
||||
: getStatusText(report.stats.current, t)
|
||||
|
||||
// Colors matching Lynis report
|
||||
const statusColorMap: Record<string, string> = {
|
||||
"Excellent": "#16a34a",
|
||||
"Good": "#16a34a",
|
||||
"Fair": "#ca8a04",
|
||||
"Poor": "#dc2626",
|
||||
"N/A": "#64748b"
|
||||
excellent: "#16a34a",
|
||||
good: "#16a34a",
|
||||
fair: "#ca8a04",
|
||||
poor: "#dc2626",
|
||||
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
|
||||
const realtimeTableRows = report.realtimeResults.map((r, i) => `
|
||||
<tr${r.packet_loss > 0 ? ' class="warn"' : ''}>
|
||||
<td>${i + 1}</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><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>
|
||||
`).join('')
|
||||
|
||||
@@ -199,9 +264,9 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
<tr${d.packet_loss && d.packet_loss > 0 ? ' class="warn"' : ''}>
|
||||
<td>${i + 1}</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><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>
|
||||
`).join('')
|
||||
|
||||
@@ -210,7 +275,7 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
? report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
|
||||
: 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) {
|
||||
const rawMin = Math.min(...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>
|
||||
<polygon points="${areaPoints}" fill="url(#areaGrad)"/>
|
||||
<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>
|
||||
`
|
||||
}
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="${htmlLang}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
* { 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; }
|
||||
@@ -463,11 +528,11 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
<div class="top-bar no-print">
|
||||
<div class="top-bar-left">
|
||||
<div>
|
||||
<div class="top-bar-title">ProxMenux Network Latency Report</div>
|
||||
<div class="top-bar-subtitle">Review the report, then print or save as PDF</div>
|
||||
<div class="top-bar-title">${t("network.latency.report.topBarTitle")}</div>
|
||||
<div class="top-bar-subtitle">${t("network.latency.report.topBarSubtitle")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="window.print()">Print / Save as PDF</button>
|
||||
<button onclick="window.print()">${t("network.latency.report.printSavePdf")}</button>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
@@ -475,21 +540,21 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
<div class="rpt-header-left">
|
||||
<img src="${logoUrl}" alt="ProxMenux" onerror="this.style.display='none'" />
|
||||
<div>
|
||||
<h1>Network Latency Report</h1>
|
||||
<p>ProxMenux Monitor - Network Performance Analysis</p>
|
||||
<h1>${t("network.latency.report.title")}</h1>
|
||||
<p>${t("network.latency.report.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rpt-header-right">
|
||||
<div><strong>Date:</strong> ${now}</div>
|
||||
<div><strong>Target:</strong> ${report.targetLabel}</div>
|
||||
<div><strong>Mode:</strong> ${report.isRealtime ? 'Real-time Test' : 'Historical Analysis'}</div>
|
||||
<div class="rid">ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
|
||||
<div><strong>${t("network.latency.report.date")}:</strong> ${now}</div>
|
||||
<div><strong>${t("network.latency.report.target")}:</strong> ${report.targetLabel}</div>
|
||||
<div><strong>${t("network.latency.report.mode")}:</strong> ${modeLabel}</div>
|
||||
<div class="rid">ID: ${reportId}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. Executive Summary -->
|
||||
<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="latency-gauge">
|
||||
<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>
|
||||
</svg>
|
||||
<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>
|
||||
</div>
|
||||
<div class="gauge-status" style="color:${statusColor};">${statusText}</div>
|
||||
</div>
|
||||
<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>
|
||||
${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'}.
|
||||
Average latency: <strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? 'N/A'} ms</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>'}`
|
||||
: `Historical latency analysis to <strong>Gateway</strong> over <strong>${timeframeLabel.toLowerCase()}</strong>.
|
||||
<strong>${report.data.length} samples</strong> analyzed.
|
||||
Average latency: <strong style="color:${statusColor}">${report.stats.avg} ms</strong>.`
|
||||
? `${t("network.latency.report.realtimeSummary", {
|
||||
target: `<strong>${report.targetLabel}</strong>`,
|
||||
count: `<strong>${report.realtimeResults.length}</strong>`,
|
||||
duration: realtimeDurationText,
|
||||
avg: `<strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? notAvailable} 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>
|
||||
<div class="latency-range">
|
||||
<div class="range-item">
|
||||
<span class="range-label">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-label">${t("network.labels.minimum")}</span>
|
||||
<span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min} ms</span>
|
||||
</div>
|
||||
<div class="range-item">
|
||||
<span class="range-label">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-label">${t("network.labels.average")}</span>
|
||||
<span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg} ms</span>
|
||||
</div>
|
||||
<div class="range-item">
|
||||
<span class="range-label">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-label">${t("network.labels.maximum")}</span>
|
||||
<span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max} ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -545,42 +616,40 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
|
||||
<!-- 2. Statistics -->
|
||||
<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="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-label">Current</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">${t("network.labels.current")}</div>
|
||||
</div>
|
||||
<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-label">Minimum</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">${t("network.labels.minimum")}</div>
|
||||
</div>
|
||||
<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-label">Average</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">${t("network.labels.average")}</div>
|
||||
</div>
|
||||
<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-label">Maximum</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">${t("network.labels.maximum")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-3">
|
||||
<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>
|
||||
<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'};">
|
||||
${report.isRealtime ? (realtimeStats?.avgPacketLoss?.toFixed(1) ?? '0') : (historyStats?.avgPacketLoss ?? '0')}%
|
||||
</div>
|
||||
</div>
|
||||
<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;">
|
||||
${report.isRealtime
|
||||
? (report.testDuration ? Math.round(report.testDuration / 60) + ' min' : 'Real-time')
|
||||
: timeframeLabel}
|
||||
${testPeriodValue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -588,7 +657,7 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
|
||||
<!-- 3. Latency Graph (always section 3) -->
|
||||
<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;">
|
||||
${chartSvg}
|
||||
</div>
|
||||
@@ -596,37 +665,37 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
|
||||
<!-- 4. Performance Thresholds (always section 4) -->
|
||||
<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-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 class="threshold-item">
|
||||
<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 class="threshold-item">
|
||||
<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 class="threshold-item">
|
||||
<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>
|
||||
|
||||
${report.isRealtime && report.realtimeResults.length > 0 ? `
|
||||
<!-- 5. Detailed Test Results (for Cloudflare / Google DNS) -->
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Time</th>
|
||||
<th>Latency</th>
|
||||
<th>Packet Loss</th>
|
||||
<th>Status</th>
|
||||
<th>${t("network.labels.time")}</th>
|
||||
<th>${t("network.labels.latency")}</th>
|
||||
<th>${t("network.labels.packetLoss")}</th>
|
||||
<th>${t("network.labels.status")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -639,15 +708,15 @@ const generateLatencyReport = (report: ReportData) => {
|
||||
${!report.isRealtime && report.data.length > 0 ? `
|
||||
<!-- 5. Detailed History (for Gateway) -->
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Time</th>
|
||||
<th>Latency</th>
|
||||
<th>Packet Loss</th>
|
||||
<th>Status</th>
|
||||
<th>${t("network.labels.time")}</th>
|
||||
<th>${t("network.labels.latency")}</th>
|
||||
<th>${t("network.labels.packetLoss")}</th>
|
||||
<th>${t("network.labels.status")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -659,41 +728,41 @@ ${!report.isRealtime && report.data.length > 0 ? `
|
||||
|
||||
<!-- Methodology -->
|
||||
<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="card">
|
||||
<div class="card-label">Test Method</div>
|
||||
<div class="card-value" style="font-size:12px;">ICMP Echo Request (Ping)</div>
|
||||
<div class="card-label">${t("network.latency.report.testMethod")}</div>
|
||||
<div class="card-value" style="font-size:12px;">${t("network.latency.report.icmpEchoRequest")}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Samples per Test</div>
|
||||
<div class="card-value" style="font-size:12px;">3 consecutive pings</div>
|
||||
<div class="card-label">${t("network.latency.report.samplesPerTest")}</div>
|
||||
<div class="card-value" style="font-size:12px;">${t("network.latency.report.threeConsecutivePings")}</div>
|
||||
</div>
|
||||
<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>
|
||||
<div class="card">
|
||||
<div class="card-label">Target IP</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-label">${t("network.latency.report.targetIp")}</div>
|
||||
<div class="card-value" style="font-size:12px;">${targetIpLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<h4>Performance Assessment</h4>
|
||||
<h4>${t("network.latency.report.performanceAssessment")}</h4>
|
||||
<p>${
|
||||
statusText === 'Excellent' ? 'Network latency is excellent. No action required.' :
|
||||
statusText === 'Good' ? 'Network latency is within acceptable parameters.' :
|
||||
statusText === 'Fair' ? 'Network latency is elevated. Consider investigating network congestion or routing issues.' :
|
||||
statusText === 'Poor' ? 'Network latency is critically high. Immediate investigation recommended.' :
|
||||
'Unable to determine network status.'
|
||||
statusKey === 'excellent' ? t("network.latency.report.assessmentExcellent") :
|
||||
statusKey === 'good' ? t("network.latency.report.assessmentGood") :
|
||||
statusKey === 'fair' ? t("network.latency.report.assessmentFair") :
|
||||
statusKey === 'poor' ? t("network.latency.report.assessmentPoor") :
|
||||
t("network.latency.report.assessmentUnknown")
|
||||
}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="rpt-footer">
|
||||
<div>ProxMenux Monitor - Network Performance Report</div>
|
||||
<div>Generated: ${now} | Report ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
|
||||
<div>${t("network.latency.report.footerTitle")}</div>
|
||||
<div>${t("network.latency.report.generated")}: ${now} | ${t("network.latency.report.reportId")}: ${reportId}</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
@@ -706,6 +775,7 @@ ${!report.isRealtime && report.data.length > 0 ? `
|
||||
}
|
||||
|
||||
export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) {
|
||||
const t = useT()
|
||||
const [timeframe, setTimeframe] = useState("hour")
|
||||
const [target, setTarget] = useState("gateway")
|
||||
const [data, setData] = useState<LatencyHistoryPoint[]>([])
|
||||
@@ -882,7 +952,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
avg: Math.round((realtimeStats?.avg ?? 0) * 10) / 10,
|
||||
} : stats
|
||||
|
||||
const statusInfo = getStatusInfo(displayStats.current)
|
||||
const statusInfo = getStatusInfo(displayStats.current, t)
|
||||
|
||||
// Calculate test duration for report based on first and last result timestamps
|
||||
const testDuration = realtimeResults.length >= 2
|
||||
@@ -897,20 +967,20 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<Wifi className="h-5 w-5 text-blue-500" />
|
||||
Network Latency
|
||||
{t("network.cards.latency")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-2 mt-1 flex-nowrap">
|
||||
<Select value={target} onValueChange={setTarget}>
|
||||
<SelectTrigger className="w-[140px] sm:w-[180px] h-8 text-xs shrink-0">
|
||||
<span className="truncate">
|
||||
{TARGET_OPTIONS.find(t => t.value === target)?.shortLabel || target}
|
||||
{getLatencyTargetShortLabel(target, t)}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TARGET_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
{t(opt.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -923,7 +993,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
{t(opt.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</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"
|
||||
>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
Stop
|
||||
{t("network.latency.actions.stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -948,7 +1018,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
className="gap-1.5 shrink-0 h-8 px-3"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Test Again
|
||||
{t("network.latency.actions.testAgain")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
@@ -957,19 +1027,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
size="sm"
|
||||
onClick={() => generateLatencyReport({
|
||||
target,
|
||||
targetLabel: TARGET_OPTIONS.find(t => t.value === target)?.label || target,
|
||||
targetLabel: getLatencyTargetLabel(target, t),
|
||||
isRealtime,
|
||||
stats,
|
||||
realtimeResults,
|
||||
data,
|
||||
timeframe,
|
||||
testDuration: isRealtime ? testDuration : undefined,
|
||||
})}
|
||||
}, t)}
|
||||
disabled={isRealtime ? realtimeResults.length === 0 : data.length === 0}
|
||||
className="gap-1.5 shrink-0 h-8 px-3"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
Report
|
||||
{t("network.latency.actions.report")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -977,8 +1047,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
{isRealtime && realtimeTesting && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
|
||||
<span>Testing... {Math.round(testProgress)}%</span>
|
||||
<span>{Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100)))}s remaining</span>
|
||||
<span>{t("network.latency.testingProgress", { percent: Math.round(testProgress) })}</span>
|
||||
<span>{t("network.latency.secondsRemaining", { seconds: Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100))) })}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -992,7 +1062,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
{/* 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 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) }}>
|
||||
{displayStats.current || '-'}
|
||||
</span>
|
||||
@@ -1000,19 +1070,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
</div>
|
||||
<div className="flex items-center gap-1 min-w-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-[10px] text-muted-foreground">ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 min-w-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-[10px] text-muted-foreground">ms</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 min-w-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-[10px] text-muted-foreground">ms</span>
|
||||
</div>
|
||||
@@ -1025,8 +1095,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
</Badge>
|
||||
{isRealtime && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{realtimeResults.length} sample{realtimeResults.length !== 1 ? 's' : ''} collected
|
||||
{realtimeStats?.packetLoss ? ` | ${realtimeStats.packetLoss}% packet loss` : ''}
|
||||
{t("network.latency.samplesCollected", { count: realtimeResults.length })}
|
||||
{realtimeStats?.packetLoss ? ` | ${t("network.latency.packetLossValue", { value: realtimeStats.packetLoss })}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1058,7 +1128,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
domain={['dataMin - 1', 'dataMax + 2']}
|
||||
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Tooltip content={<CustomTooltip t={t} />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
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">
|
||||
<Activity className="h-12 w-12 mb-3 opacity-30" />
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
@@ -1107,7 +1177,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
domain={['dataMin - 1', 'dataMax + 2']}
|
||||
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 1 hour view, show avg values since there's no downsampling */}
|
||||
<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">
|
||||
<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-xs mt-1">Data is collected every 60 seconds</p>
|
||||
<p className="text-sm">{t("network.latency.noDataForPeriod")}</p>
|
||||
<p className="text-xs mt-1">{t("network.latency.collectionInterval")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1133,8 +1203,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
||||
{isRealtime && (
|
||||
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
|
||||
<p className="text-xs text-blue-400">
|
||||
<strong>Real-time Mode:</strong> Tests run for 2 minutes with readings every 5 seconds.
|
||||
Click "Test Again" to add more samples. All data is included in the report.
|
||||
<strong>{t("network.latency.realTimeMode")}:</strong> {t("network.latency.realTimeModeDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Label } from "./ui/label"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import Image from "next/image"
|
||||
|
||||
interface LoginProps {
|
||||
@@ -16,6 +17,7 @@ interface LoginProps {
|
||||
}
|
||||
|
||||
export function Login({ onLogin }: LoginProps) {
|
||||
const t = useT()
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [totpCode, setTotpCode] = useState("")
|
||||
@@ -56,12 +58,12 @@ export function Login({ onLogin }: LoginProps) {
|
||||
setError("")
|
||||
|
||||
if (!username || !password) {
|
||||
setError("Please enter username and password")
|
||||
setError(t("login.missingCredentials"))
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresTotp && !totpCode) {
|
||||
setError("Please enter your 2FA code")
|
||||
setError(t("login.missingTotp"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,14 +82,20 @@ export function Login({ onLogin }: LoginProps) {
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.requires_totp) {
|
||||
if (response.ok && data.requires_totp) {
|
||||
setRequiresTotp(true)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -107,7 +115,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
|
||||
onLogin()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Login failed")
|
||||
setError(err instanceof Error ? err.message : t("login.loginFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -139,8 +147,8 @@ export function Login({ onLogin }: LoginProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">ProxMenux Monitor</h1>
|
||||
<p className="text-muted-foreground mt-2">Sign in to access your dashboard</p>
|
||||
<h1 className="text-3xl font-bold">{t("app.title")}</h1>
|
||||
<p className="text-muted-foreground mt-2">{t("login.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -157,14 +165,14 @@ export function Login({ onLogin }: LoginProps) {
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-username" className="text-sm">
|
||||
Username
|
||||
{t("login.username")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="login-username"
|
||||
type="text"
|
||||
placeholder="Enter your username"
|
||||
placeholder={t("login.usernamePlaceholder")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
@@ -176,14 +184,14 @@ export function Login({ onLogin }: LoginProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-password" className="text-sm">
|
||||
Password
|
||||
{t("login.password")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="login-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
placeholder={t("login.passwordPlaceholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 pr-10 text-base"
|
||||
@@ -214,7 +222,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
|
||||
Remember me
|
||||
{t("login.rememberMe")}
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
@@ -223,14 +231,14 @@ 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">
|
||||
<Shield className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-500">Two-Factor Authentication</p>
|
||||
<p className="text-xs text-blue-500 mt-1">Enter the 6-digit code from your authentication app</p>
|
||||
<p className="text-sm font-medium text-blue-500">{t("login.twoFactorTitle")}</p>
|
||||
<p className="text-xs text-blue-500 mt-1">{t("login.twoFactorDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="totp-code" className="text-sm">
|
||||
Authentication Code
|
||||
{t("login.authenticationCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="totp-code"
|
||||
@@ -245,7 +253,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
You can also use a backup code (format: XXXX-XXXX)
|
||||
{t("login.backupCodeHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -260,18 +268,18 @@ export function Login({ onLogin }: LoginProps) {
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Back to login
|
||||
{t("login.backToLogin")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</form>
|
||||
</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>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
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 { Button } from "@/components/ui/button"
|
||||
import {
|
||||
@@ -37,6 +37,7 @@ import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTit
|
||||
import "xterm/css/xterm.css"
|
||||
import { API_PORT, fetchApi } from "@/lib/api-config"
|
||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
interface LxcTerminalModalProps {
|
||||
open: boolean
|
||||
@@ -51,33 +52,35 @@ interface CheatSheetResult {
|
||||
examples: string[]
|
||||
}
|
||||
|
||||
const proxmoxCommands = [
|
||||
{ cmd: "ls -la", desc: "List all files with details" },
|
||||
{ cmd: "cd /path/to/dir", desc: "Change directory" },
|
||||
{ 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: "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: "systemctl status service", desc: "Check service status" },
|
||||
{ cmd: "systemctl restart service", desc: "Restart a service" },
|
||||
{ cmd: "apt update && apt upgrade", desc: "Update packages" },
|
||||
{ cmd: "apt install package", desc: "Install package" },
|
||||
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file" },
|
||||
{ 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: "docker ps", desc: "List running containers" },
|
||||
{ cmd: "docker images", desc: "List Docker images" },
|
||||
{ cmd: "ip addr show", desc: "Show IP addresses" },
|
||||
{ cmd: "ping host", desc: "Test network connectivity" },
|
||||
{ cmd: "curl -I url", desc: "Get HTTP headers" },
|
||||
{ cmd: "history", desc: "Show command history" },
|
||||
{ cmd: "clear", desc: "Clear terminal screen" },
|
||||
]
|
||||
const LXC_COMMANDS = [
|
||||
{ cmd: "ls -la", descKey: "listFiles" },
|
||||
{ cmd: "cd /path/to/dir", descKey: "changeDirectory" },
|
||||
{ cmd: "cat filename", descKey: "displayFile" },
|
||||
{ cmd: "grep 'pattern' file", descKey: "searchPattern" },
|
||||
{ cmd: "find . -name 'file'", descKey: "findFiles" },
|
||||
{ cmd: "df -h", descKey: "diskUsage" },
|
||||
{ cmd: "du -sh *", descKey: "directorySizes" },
|
||||
{ cmd: "free -h", descKey: "memoryUsage" },
|
||||
{ cmd: "top", descKey: "runningProcesses" },
|
||||
{ cmd: "ps aux | grep process", descKey: "findProcess" },
|
||||
{ cmd: "systemctl status service", descKey: "serviceStatus" },
|
||||
{ cmd: "systemctl restart service", descKey: "restartService" },
|
||||
{ cmd: "apt update && apt upgrade", descKey: "updatePackages" },
|
||||
{ cmd: "apt install package", descKey: "installPackage" },
|
||||
{ cmd: "tail -f /var/log/syslog", descKey: "followLog" },
|
||||
{ cmd: "chmod 755 file", descKey: "changePermissions" },
|
||||
{ cmd: "chown user:group file", descKey: "changeOwner" },
|
||||
{ cmd: "tar -xzf file.tar.gz", descKey: "extractArchive" },
|
||||
{ cmd: "docker ps", descKey: "listContainers" },
|
||||
{ cmd: "docker images", descKey: "listImages" },
|
||||
{ cmd: "ip addr show", descKey: "showIpAddresses" },
|
||||
{ cmd: "ping host", descKey: "testConnectivity" },
|
||||
{ cmd: "curl -I url", descKey: "httpHeaders" },
|
||||
{ cmd: "history", descKey: "commandHistory" },
|
||||
{ cmd: "clear", descKey: "clearScreen" },
|
||||
] as const
|
||||
|
||||
type LocalCommand = { cmd: string; desc: string }
|
||||
|
||||
function getWebSocketUrl(): string {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -101,6 +104,7 @@ export function LxcTerminalModal({
|
||||
vmid,
|
||||
vmName,
|
||||
}: LxcTerminalModalProps) {
|
||||
const t = useT()
|
||||
const termRef = useRef<any>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const fitAddonRef = useRef<any>(null)
|
||||
@@ -121,12 +125,18 @@ export function LxcTerminalModal({
|
||||
// Search state
|
||||
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
||||
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 [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
||||
const [useOnline, setUseOnline] = useState(true)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredCommands(localCommands)
|
||||
}, [localCommands])
|
||||
|
||||
// Detect mobile/tablet
|
||||
useEffect(() => {
|
||||
@@ -278,7 +288,7 @@ export function LxcTerminalModal({
|
||||
// through Number without losing fidelity.
|
||||
const id = Number(vmid)
|
||||
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
|
||||
}
|
||||
ws.send(`pct enter ${id}\r`)
|
||||
@@ -287,7 +297,7 @@ export function LxcTerminalModal({
|
||||
|
||||
ws.onerror = () => {
|
||||
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 = () => {
|
||||
@@ -295,7 +305,7 @@ export function LxcTerminalModal({
|
||||
if (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) => {
|
||||
@@ -395,7 +405,7 @@ export function LxcTerminalModal({
|
||||
termRef.current.dispose()
|
||||
}
|
||||
}
|
||||
}, [isOpen, vmid])
|
||||
}, [isOpen, vmid, t])
|
||||
|
||||
// Resize handling
|
||||
useEffect(() => {
|
||||
@@ -478,7 +488,7 @@ export function LxcTerminalModal({
|
||||
const searchCheatSh = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([])
|
||||
setFilteredCommands(proxmoxCommands)
|
||||
setFilteredCommands(localCommands)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -491,7 +501,7 @@ export function LxcTerminalModal({
|
||||
})
|
||||
|
||||
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) => ({
|
||||
@@ -503,7 +513,7 @@ export function LxcTerminalModal({
|
||||
setUseOnline(true)
|
||||
setSearchResults(formattedResults)
|
||||
} catch (error) {
|
||||
const filtered = proxmoxCommands.filter(
|
||||
const filtered = localCommands.filter(
|
||||
(item) =>
|
||||
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
||||
item.desc.toLowerCase().includes(query.toLowerCase()),
|
||||
@@ -521,12 +531,12 @@ export function LxcTerminalModal({
|
||||
searchCheatSh(searchQuery)
|
||||
} else {
|
||||
setSearchResults([])
|
||||
setFilteredCommands(proxmoxCommands)
|
||||
setFilteredCommands(localCommands)
|
||||
}
|
||||
}, 800)
|
||||
|
||||
return () => clearTimeout(debounce)
|
||||
}, [searchQuery])
|
||||
}, [searchQuery, localCommands, t])
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (termRef.current) {
|
||||
@@ -565,7 +575,7 @@ export function LxcTerminalModal({
|
||||
{/* Header */}
|
||||
<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">
|
||||
Terminal: {vmName} (ID: {vmid})
|
||||
{t("lxcTerminal.title", { name: vmName, id: vmid })}
|
||||
</DialogTitle>
|
||||
<div className="flex gap-2">
|
||||
<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"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Search</span>
|
||||
<span className="hidden sm:inline">{t("terminal.search")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Clear</span>
|
||||
<span className="hidden sm:inline">{t("terminal.clear")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -673,29 +683,29 @@ export function LxcTerminalModal({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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 />
|
||||
<DropdownMenuItem onSelect={() => sendKey("\x03")}>
|
||||
<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 onSelect={() => sendKey("\x18")}>
|
||||
<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 onSelect={() => sendKey("\x12")}>
|
||||
<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>
|
||||
<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() }}>
|
||||
<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 onSelect={() => { void handlePaste() }}>
|
||||
<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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -716,7 +726,7 @@ export function LxcTerminalModal({
|
||||
: "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>
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Close</span>
|
||||
<span className="hidden sm:inline">{t("actions.close")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -734,22 +744,22 @@ export function LxcTerminalModal({
|
||||
<SearchDialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
||||
<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">
|
||||
<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={`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>
|
||||
</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="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Search commands... (e.g., tar, docker, systemctl)"
|
||||
placeholder={t("terminal.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
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"
|
||||
@@ -763,7 +773,7 @@ export function LxcTerminalModal({
|
||||
{isSearching && (
|
||||
<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" />
|
||||
<p className="text-sm">Searching cheat.sh...</p>
|
||||
<p className="text-sm">{t("terminal.searchingCheatSh")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -790,7 +800,7 @@ export function LxcTerminalModal({
|
||||
<div className="text-center py-2">
|
||||
<p className="text-xs text-zinc-500">
|
||||
<Lightbulb className="inline-block w-3 h-3 mr-1" />
|
||||
Powered by cheat.sh
|
||||
{t("terminal.poweredByCheatSh")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -816,13 +826,13 @@ export function LxcTerminalModal({
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Send
|
||||
{t("terminal.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : !isSearching && !searchQuery && !useOnline ? (
|
||||
proxmoxCommands.map((item, index) => (
|
||||
localCommands.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => sendToTerminal(item.cmd)}
|
||||
@@ -843,7 +853,7 @@ export function LxcTerminalModal({
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Send
|
||||
{t("terminal.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -854,17 +864,17 @@ export function LxcTerminalModal({
|
||||
<>
|
||||
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||
<div>
|
||||
<p className="text-zinc-400 font-medium">{"No results found for \""}{searchQuery}{"\""}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
|
||||
<p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||
<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">
|
||||
<p>Try searching for:</p>
|
||||
<p>{t("terminal.trySearchingFor")}</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||
{["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => (
|
||||
<code
|
||||
@@ -881,7 +891,7 @@ export function LxcTerminalModal({
|
||||
{useOnline && (
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
|
||||
<Lightbulb className="w-3 h-3" />
|
||||
<span>Powered by cheat.sh</span>
|
||||
<span>{t("terminal.poweredByCheatSh")}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -893,9 +903,9 @@ export function LxcTerminalModal({
|
||||
<div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="w-3 h-3" />
|
||||
<span>Tip: Search for any Linux command</span>
|
||||
<span>{t("terminal.searchTip")}</span>
|
||||
</div>
|
||||
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
|
||||
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</SearchDialogContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Boxes, Info, Loader2, Settings2, CheckCircle2 } from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface DetectionResponse {
|
||||
success: boolean
|
||||
@@ -14,6 +15,7 @@ interface DetectionResponse {
|
||||
}
|
||||
|
||||
export function LxcUpdateDetection() {
|
||||
const t = useT()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [enabled, setEnabled] = useState<boolean>(true)
|
||||
@@ -32,11 +34,11 @@ export function LxcUpdateDetection() {
|
||||
setEnabled(data.enabled)
|
||||
setPending(data.enabled)
|
||||
} else {
|
||||
setError(data.message || "Failed to load setting")
|
||||
setError(data.message || t("settings.lxcUpdateDetection.loadFailed"))
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
if (!cancelled) setError(String(e))
|
||||
if (!cancelled) setError(t("settings.lxcUpdateDetection.loadFailed"))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
@@ -77,7 +79,7 @@ export function LxcUpdateDetection() {
|
||||
body: JSON.stringify({ enabled: pending }),
|
||||
})
|
||||
if (!data.success) {
|
||||
setError(data.message || "Failed to save setting")
|
||||
setError(data.message || t("settings.lxcUpdateDetection.saveFailed"))
|
||||
return
|
||||
}
|
||||
setEnabled(pending)
|
||||
@@ -95,7 +97,7 @@ export function LxcUpdateDetection() {
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
setError(t("settings.lxcUpdateDetection.saveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -111,14 +113,14 @@ export function LxcUpdateDetection() {
|
||||
breakpoint thanks to `items-center` + leading-tight title. */}
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-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 ? (
|
||||
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
|
||||
Active
|
||||
{t("status.active")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground">
|
||||
Disabled
|
||||
{t("status.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -126,7 +128,7 @@ export function LxcUpdateDetection() {
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Saved
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{error && !editMode && (
|
||||
@@ -134,7 +136,7 @@ export function LxcUpdateDetection() {
|
||||
className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate"
|
||||
title={error}
|
||||
>
|
||||
Save failed: {error}
|
||||
{t("status.saveFailed")}: {error}
|
||||
</span>
|
||||
)}
|
||||
{editMode ? (
|
||||
@@ -144,7 +146,7 @@ export function LxcUpdateDetection() {
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
{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"
|
||||
@@ -152,7 +154,7 @@ export function LxcUpdateDetection() {
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||
Save
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -162,16 +164,15 @@ export function LxcUpdateDetection() {
|
||||
disabled={loading}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
Edit
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Periodically check running Debian/Ubuntu/Alpine LXC containers for pending package updates
|
||||
(<code>apt list --upgradable</code> / <code>apk list -u</code>) and surface them on the dashboard. The
|
||||
corresponding notification toggle in <strong>Notifications → Services</strong> appears only while detection
|
||||
is enabled.
|
||||
{t("settings.lxcUpdateDetection.descriptionStart")}{" "}
|
||||
(<code>apt list --upgradable</code> / <code>apk list -u</code>)
|
||||
{t("settings.lxcUpdateDetection.descriptionEnd")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -185,7 +186,7 @@ export function LxcUpdateDetection() {
|
||||
<Boxes
|
||||
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>
|
||||
<button
|
||||
className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
|
||||
@@ -195,7 +196,7 @@ export function LxcUpdateDetection() {
|
||||
disabled={!editMode || saving}
|
||||
role="switch"
|
||||
aria-checked={pending}
|
||||
aria-label="Enable LXC update detection"
|
||||
aria-label={t("settings.lxcUpdateDetection.enableLabel")}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
@@ -209,8 +210,7 @@ export function LxcUpdateDetection() {
|
||||
<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" />
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{lastPurged} LXC entries removed from the registry. Re-enabling detection will repopulate them on the
|
||||
next scan cycle.
|
||||
{t("settings.lxcUpdateDetection.purgedMessage", { count: lastPurged })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { ArrowLeft, Loader2 } from "lucide-react"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { useI18n } from "@/lib/i18n/provider"
|
||||
|
||||
interface MetricsViewProps {
|
||||
vmid: number
|
||||
@@ -15,12 +16,12 @@ interface MetricsViewProps {
|
||||
}
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: "hour", label: "1 Hour" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "month", label: "30 Days" },
|
||||
{ value: "year", label: "1 Year" },
|
||||
]
|
||||
{ value: "hour", labelKey: "vmMetrics.timeframes.hour" },
|
||||
{ value: "day", labelKey: "vmMetrics.timeframes.day" },
|
||||
{ value: "week", labelKey: "vmMetrics.timeframes.week" },
|
||||
{ value: "month", labelKey: "vmMetrics.timeframes.month" },
|
||||
{ value: "year", labelKey: "vmMetrics.timeframes.year" },
|
||||
] as const
|
||||
|
||||
const CustomCPUTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
@@ -103,6 +104,7 @@ const CustomNetworkTooltip = ({ active, payload, label }: any) => {
|
||||
}
|
||||
|
||||
export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps) {
|
||||
const { language, t } = useI18n()
|
||||
const [timeframe, setTimeframe] = useState("week")
|
||||
const [data, setData] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -112,7 +114,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics()
|
||||
}, [vmid, timeframe])
|
||||
}, [vmid, timeframe, language])
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
setLoading(true)
|
||||
@@ -126,19 +128,19 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
let timeLabel = ""
|
||||
|
||||
if (timeframe === "hour") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "day") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "week") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
@@ -146,12 +148,12 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "month") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
} else {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
@@ -173,7 +175,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
|
||||
setData(transformedData)
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error loading metrics")
|
||||
setError(err.message || t("vmMetrics.errors.loading"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -203,7 +205,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -214,7 +216,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
<div className="space-y-8">
|
||||
{/* CPU Chart */}
|
||||
<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}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
@@ -244,7 +246,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
name="CPU %"
|
||||
name={t("vmMetrics.series.cpu")}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -252,7 +254,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
|
||||
{/* Memory Chart */}
|
||||
<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}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
@@ -282,7 +284,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Memory GB"
|
||||
name={t("vmMetrics.series.memoryGb")}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -290,7 +292,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
|
||||
{/* Disk I/O Chart */}
|
||||
<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}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
@@ -321,7 +323,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Read"
|
||||
name={t("vmMetrics.series.read")}
|
||||
hide={hiddenDiskLines.includes("diskread")}
|
||||
/>
|
||||
<Area
|
||||
@@ -331,7 +333,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Write"
|
||||
name={t("vmMetrics.series.write")}
|
||||
hide={hiddenDiskLines.includes("diskwrite")}
|
||||
/>
|
||||
</AreaChart>
|
||||
@@ -340,7 +342,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
|
||||
{/* Network I/O Chart */}
|
||||
<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}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
@@ -371,7 +373,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Download"
|
||||
name={t("vmMetrics.series.download")}
|
||||
hide={hiddenNetworkLines.includes("netin")}
|
||||
/>
|
||||
<Area
|
||||
@@ -381,7 +383,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Upload"
|
||||
name={t("vmMetrics.series.upload")}
|
||||
hide={hiddenNetworkLines.includes("netout")}
|
||||
/>
|
||||
</AreaChart>
|
||||
@@ -461,9 +463,9 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<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">
|
||||
VMID: {vmid} • Type: {vmType.toUpperCase()}
|
||||
VMID: {vmid} • {t("vmMetrics.type")}: {vmType.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -474,7 +476,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -6,6 +6,9 @@ import { Wifi, Zap } from 'lucide-react'
|
||||
import { useState, useEffect } from "react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
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_: {
|
||||
@@ -32,43 +35,51 @@ interface NetworkCardProps {
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const getInterfaceTypeBadge = (type: string) => {
|
||||
const getInterfaceTypeBadge = (type: string, t: TFunction) => {
|
||||
switch (type) {
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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:
|
||||
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") {
|
||||
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
|
||||
} else if (vmType === "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 => {
|
||||
if (speed === 0) return "N/A"
|
||||
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 formatSpeed = (speed: number, unavailable = "N/A"): string => {
|
||||
if (speed === 0) return unavailable
|
||||
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
|
||||
return `${speed} Mbps`
|
||||
}
|
||||
|
||||
export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps) {
|
||||
const typeBadge = getInterfaceTypeBadge(interface_.type)
|
||||
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type) : null
|
||||
const t = useT()
|
||||
const typeBadge = getInterfaceTypeBadge(interface_.type, t)
|
||||
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type, t) : null
|
||||
|
||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(getNetworkUnit())
|
||||
|
||||
@@ -125,17 +136,17 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
|
||||
const getTimeframeLabel = () => {
|
||||
switch (timeframe) {
|
||||
case "hour":
|
||||
return "Last Hour"
|
||||
return t("network.timeframes.last.hour")
|
||||
case "day":
|
||||
return "Last 24 Hours"
|
||||
return t("network.timeframes.last.day")
|
||||
case "week":
|
||||
return "Last 7 Days"
|
||||
return t("network.timeframes.last.week")
|
||||
case "month":
|
||||
return "Last 30 Days"
|
||||
return t("network.timeframes.last.month")
|
||||
case "year":
|
||||
return "Last Year"
|
||||
return t("network.timeframes.last.year")
|
||||
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"
|
||||
}
|
||||
>
|
||||
{interface_.status.toUpperCase()}
|
||||
{formatInterfaceStatus(interface_.status, t)}
|
||||
</Badge>
|
||||
</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>
|
||||
<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 className="font-medium text-foreground font-mono text-sm truncate">
|
||||
{interface_.type === "vm_lxc"
|
||||
? (interface_.vmid ?? "N/A")
|
||||
? (interface_.vmid ?? t("common.notAvailable"))
|
||||
: interface_.addresses.length > 0
|
||||
? interface_.addresses[0].ip
|
||||
: "N/A"}
|
||||
: t("common.notAvailable")}
|
||||
</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">
|
||||
<Zap className="h-3 w-3" />
|
||||
{formatSpeed(interface_.speed)}
|
||||
{formatSpeed(interface_.speed, t("common.notAvailable"))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Activity } from "lucide-react"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// One animated comet-trail pulse. Returned as DATA from the layout
|
||||
// renderers instead of an SVG string so the parent component can
|
||||
@@ -25,6 +26,12 @@ type PulseData = {
|
||||
rate?: number
|
||||
}
|
||||
|
||||
type FlowLabels = {
|
||||
down: string
|
||||
active: string
|
||||
standby: string
|
||||
}
|
||||
|
||||
// ─── Public types — match the /api/network shape ────────────
|
||||
type NIC = {
|
||||
id: string
|
||||
@@ -126,14 +133,15 @@ function resolveBonds(data: NetworkFlowData): {
|
||||
// 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
|
||||
// mode all slaves transmit, so the link speed stays.
|
||||
function nicSubLabel(n: NIC): string {
|
||||
if (n.status === "down") return "down"
|
||||
function nicSubLabel(n: NIC, labels: FlowLabels): string {
|
||||
if (n.status === "down") return labels.down
|
||||
const role = n.role === "standby" || n.role === "active" ? n.role : ""
|
||||
if (!role) return n.link
|
||||
// A NIC that doesn't report a negotiated speed renders its link as
|
||||
// "—"; pairing that with the role would read as "— · active".
|
||||
if (!n.link || n.link === "—") return role
|
||||
return `${n.link} · ${role}`
|
||||
const roleLabel = role === "active" ? labels.active : labels.standby
|
||||
if (!n.link || n.link === "—") return roleLabel
|
||||
return `${n.link} · ${roleLabel}`
|
||||
}
|
||||
|
||||
function fmt(v: number): string {
|
||||
@@ -214,7 +222,7 @@ function curvedTap(cx: number, busY: number, targetY: number, r = 14): string {
|
||||
}
|
||||
|
||||
// ─── 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): { svg: string; pulses: PulseData[]; height: number } {
|
||||
const top = activeConsumers(data.consumers)
|
||||
const bridges = visibleBridges(data.bridges, top)
|
||||
const host = data.consumers.find((c) => c.kind === "host")
|
||||
@@ -343,7 +351,7 @@ function renderHorizontal(data: NetworkFlowData, W: number): { svg: string; puls
|
||||
<circle class="nf-circle" cx="${nicX}" cy="${y}" r="${radNic}" stroke="${stroke}" />
|
||||
${svgIcon("nic", nicX, y, 18, stroke)}
|
||||
<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>`)
|
||||
})
|
||||
|
||||
@@ -591,7 +599,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
|
||||
// an arc (some above, some below the bridge.cy). All elbows use Q
|
||||
// curves; no sharp 90° corners anywhere.
|
||||
function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData[]; height: number; viewBox: string } {
|
||||
function renderVertical(data: NetworkFlowData, labels: FlowLabels): { svg: string; pulses: PulseData[]; height: number; viewBox: string } {
|
||||
// Smaller W → SVG scales up on the mobile screen, nodes look bigger.
|
||||
// All four x-columns evenly spaced so curve→target distances are
|
||||
// homogeneous (host→bridge, bridge→spine, spine→guest all ~60 px).
|
||||
@@ -768,7 +776,7 @@ function renderVertical(data: NetworkFlowData): { svg: string; pulses: PulseData
|
||||
<circle class="nf-circle" cx="${cx}" cy="${cy}" r="${r}" stroke="${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-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>`)
|
||||
})
|
||||
|
||||
@@ -936,6 +944,15 @@ export function NetworkFlow({
|
||||
// opens the per-interface details modal.
|
||||
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 [width, setWidth] = useState(1320)
|
||||
const [mode, setMode] = useState<"desktop" | "tablet" | "mobile">("desktop")
|
||||
@@ -985,21 +1002,21 @@ export function NetworkFlow({
|
||||
|
||||
const { svgContent, pulses, viewBox, height } = useMemo(() => {
|
||||
if (mode === "mobile") {
|
||||
const out = renderVertical(data)
|
||||
const out = renderVertical(data, labels)
|
||||
return { svgContent: out.svg, pulses: out.pulses, viewBox: out.viewBox, height: out.height }
|
||||
}
|
||||
const W = mode === "tablet" ? 1100 : 1320
|
||||
const out = renderHorizontal(data, W)
|
||||
const out = renderHorizontal(data, W, labels)
|
||||
return { svgContent: out.svg, pulses: out.pulses, viewBox: `0 0 ${W} ${out.height}`, height: out.height }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [memoKey])
|
||||
}, [memoKey, labels])
|
||||
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center text-base">
|
||||
<Activity className="h-5 w-5 mr-2" />
|
||||
Network Flow (PoC)
|
||||
{t("network.flow.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -13,6 +13,9 @@ import { fetchApi } from "../lib/api-config"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
import { LatencyDetailModal } from "./latency-detail-modal"
|
||||
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 {
|
||||
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
|
||||
// type chip, but mapped to the actual interface class.
|
||||
function getInterfaceTypeChip(type: string) {
|
||||
function getInterfaceTypeLabel(type: string, t: TFunction) {
|
||||
switch ((type || "").toLowerCase()) {
|
||||
case "physical":
|
||||
return { className: "bg-blue-500/10 text-blue-400 border-blue-500/20", label: "Physical" }
|
||||
return t("network.interfaceTypes.physical")
|
||||
case "bridge":
|
||||
return { className: "bg-green-500/10 text-green-400 border-green-500/20", label: "Bridge" }
|
||||
return t("network.interfaceTypes.bridge")
|
||||
case "bond":
|
||||
return { className: "bg-purple-500/10 text-purple-400 border-purple-500/20", label: "Bond" }
|
||||
return t("network.interfaceTypes.bond")
|
||||
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 "virtual":
|
||||
return { className: "bg-orange-500/10 text-orange-400 border-orange-500/20", label: "Virtual" }
|
||||
return t("network.interfaceTypes.virtual")
|
||||
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"
|
||||
// pattern: 2-line header (identity / live state), horizontal divider,
|
||||
// vertical key→value stat block, footer with serial + arrow CTA.
|
||||
@@ -166,18 +202,19 @@ function getInterfaceTypeChip(type: string) {
|
||||
function renderPhysicalInterfaceCardV2(
|
||||
iface: NetworkInterface,
|
||||
onOpen: (iface: NetworkInterface) => void,
|
||||
t: TFunction,
|
||||
) {
|
||||
const Icon = getInterfaceIcon(iface)
|
||||
const chip = getInterfaceTypeChip(iface.type)
|
||||
const chip = getInterfaceTypeChip(iface.type, t)
|
||||
const isUp = (iface.status || "").toLowerCase() === "up"
|
||||
const firstAddr = iface.addresses?.[0]?.ip || ""
|
||||
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
|
||||
// from the negotiated speed (avoids "1 Gbps (max 1 Gbps)" noise).
|
||||
const maxSpeedStr =
|
||||
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 errIn = iface.errors_in ?? 0
|
||||
@@ -206,7 +243,7 @@ function renderPhysicalInterfaceCardV2(
|
||||
}`}
|
||||
>
|
||||
<NetStatusDot tone={isUp ? "ok" : "fail"} />
|
||||
{iface.status || "?"}
|
||||
{formatInterfaceStatus(iface.status, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -217,11 +254,11 @@ function renderPhysicalInterfaceCardV2(
|
||||
{speedStr}
|
||||
{maxSpeedStr && (
|
||||
<span className="text-[11px] text-muted-foreground/70">
|
||||
· max {maxSpeedStr}
|
||||
· {t("network.labels.maxSpeed", { speed: maxSpeedStr })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="capitalize">{iface.duplex || "—"}</span>
|
||||
<span>{formatDuplex(iface.duplex, t)}</span>
|
||||
</div>
|
||||
|
||||
{/* Separator. */}
|
||||
@@ -246,7 +283,7 @@ function renderPhysicalInterfaceCardV2(
|
||||
{bridgesUsing.length > 0 && (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
Bridge
|
||||
{t("network.interfaceTypes.bridge")}
|
||||
</span>
|
||||
<span className="font-medium text-right truncate font-mono text-xs text-cyan-400">
|
||||
{bridgesUsing.map((b) => `→ ${b}`).join(" ")}
|
||||
@@ -260,7 +297,7 @@ function renderPhysicalInterfaceCardV2(
|
||||
has no previous sample to compute against. */}
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<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 className="font-medium text-green-500 tabular-nums">
|
||||
{iface.rx_Bps !== undefined ? formatRate(iface.rx_Bps) : "—"}
|
||||
@@ -268,7 +305,7 @@ function renderPhysicalInterfaceCardV2(
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<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 className="font-medium text-blue-400 tabular-nums">
|
||||
{iface.tx_Bps !== undefined ? formatRate(iface.tx_Bps) : "—"}
|
||||
@@ -278,7 +315,7 @@ function renderPhysicalInterfaceCardV2(
|
||||
<>
|
||||
{totalErrors > 0 && (
|
||||
<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
|
||||
className={`font-medium flex items-center gap-1.5 ${
|
||||
netCounterTone(totalErrors) === "ok"
|
||||
@@ -295,7 +332,7 @@ function renderPhysicalInterfaceCardV2(
|
||||
)}
|
||||
{totalDrops > 0 && (
|
||||
<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
|
||||
className={`font-medium flex items-center gap-1.5 ${
|
||||
netCounterTone(totalDrops) === "ok"
|
||||
@@ -325,7 +362,7 @@ function renderPhysicalInterfaceCardV2(
|
||||
)}
|
||||
<span
|
||||
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>
|
||||
@@ -335,32 +372,32 @@ function renderPhysicalInterfaceCardV2(
|
||||
}
|
||||
|
||||
|
||||
const getInterfaceTypeBadge = (type: string) => {
|
||||
const getInterfaceTypeBadge = (type: string, t: TFunction) => {
|
||||
switch (type) {
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
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:
|
||||
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") {
|
||||
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
|
||||
} else if (vmType === "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.
|
||||
@@ -396,8 +433,8 @@ const formatStorage = (bytes: number): string => {
|
||||
return `${value.toFixed(decimals)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
const formatSpeed = (speed: number): string => {
|
||||
if (speed === 0) return "N/A"
|
||||
const formatSpeed = (speed: number, unavailable = "N/A"): string => {
|
||||
if (speed === 0) return unavailable
|
||||
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
|
||||
return `${speed} Mbps`
|
||||
}
|
||||
@@ -408,6 +445,7 @@ const fetcher = async (url: string): Promise<NetworkData> => {
|
||||
|
||||
|
||||
export function NetworkMetrics() {
|
||||
const t = useT()
|
||||
const {
|
||||
data: networkData,
|
||||
error,
|
||||
@@ -469,8 +507,8 @@ export function NetworkMetrics() {
|
||||
<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>
|
||||
<div className="text-sm font-medium text-foreground">Loading network data...</div>
|
||||
<p className="text-xs text-muted-foreground">Scanning interfaces, bridges and traffic</p>
|
||||
<div className="text-sm font-medium text-foreground">{t("network.loading.title")}</div>
|
||||
<p className="text-xs text-muted-foreground">{t("network.loading.description")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -483,10 +521,10 @@ export function NetworkMetrics() {
|
||||
<div className="flex items-center gap-3 text-red-600">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
<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">
|
||||
{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>
|
||||
@@ -514,14 +552,14 @@ export function NetworkMetrics() {
|
||||
const avgPacketLoss = ((packetLossIn + packetLossOut) / 2).toFixed(2)
|
||||
|
||||
// Determine health status
|
||||
let healthStatus = "Healthy"
|
||||
let healthStatusKey = "network.status.healthy"
|
||||
let healthColor = "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
|
||||
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"
|
||||
} 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"
|
||||
}
|
||||
|
||||
@@ -545,24 +583,24 @@ export function NetworkMetrics() {
|
||||
const topTraffic = (top.bytes_recv || 0) + (top.bytes_sent || 0)
|
||||
return ifaceTraffic > topTraffic ? iface : top
|
||||
}, 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 getTimeframeLabel = () => {
|
||||
switch (timeframe) {
|
||||
case "hour":
|
||||
return "1 Hour"
|
||||
return t("network.timeframes.hour")
|
||||
case "day":
|
||||
return "24 Hours"
|
||||
return t("network.timeframes.day")
|
||||
case "week":
|
||||
return "7 Days"
|
||||
return t("network.timeframes.week")
|
||||
case "month":
|
||||
return "30 Days"
|
||||
return t("network.timeframes.month")
|
||||
case "year":
|
||||
return "1 Year"
|
||||
return t("network.timeframes.year")
|
||||
default:
|
||||
return "24 Hours"
|
||||
return t("network.timeframes.day")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,25 +609,42 @@ export function NetworkMetrics() {
|
||||
const getTimeframeShortLabel = () => {
|
||||
switch (timeframe) {
|
||||
case "hour":
|
||||
return "Past 1 h"
|
||||
return t("network.timeframes.short.hour")
|
||||
case "day":
|
||||
return "Past 24 h"
|
||||
return t("network.timeframes.short.day")
|
||||
case "week":
|
||||
return "Past 7 d"
|
||||
return t("network.timeframes.short.week")
|
||||
case "month":
|
||||
return "Past 30 d"
|
||||
return t("network.timeframes.short.month")
|
||||
case "year":
|
||||
return "Past 1 y"
|
||||
return t("network.timeframes.short.year")
|
||||
default:
|
||||
return "Past 24 h"
|
||||
return t("network.timeframes.short.day")
|
||||
}
|
||||
}
|
||||
|
||||
const hostname = networkData.hostname || "N/A"
|
||||
const domain = networkData.domain || "N/A"
|
||||
const getLastTimeframeLabel = (value: "hour" | "day" | "week" | "month" | "year") => {
|
||||
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 primaryDNS = dnsServers[0] || "N/A"
|
||||
const secondaryDNS = dnsServers[1] || "N/A"
|
||||
const primaryDNS = dnsServers[0] || t("common.notAvailable")
|
||||
const secondaryDNS = dnsServers[1] || t("common.notAvailable")
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -606,7 +661,7 @@ export function NetworkMetrics() {
|
||||
<Card className="bg-card border-border">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<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 className="text-xl lg:text-2xl font-bold leading-tight text-green-500">{trafficInFormatted}</div>
|
||||
</div>
|
||||
<div>
|
||||
<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 className="text-xl lg:text-2xl font-bold leading-tight text-blue-500">{trafficOutFormatted}</div>
|
||||
</div>
|
||||
@@ -631,8 +686,8 @@ export function NetworkMetrics() {
|
||||
<div style={{ width: `${upPct}%`, background: '#3b82f6' }}></div>
|
||||
</div>
|
||||
<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-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-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>{t("network.labels.up")} {Math.round(upPct)}%</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -642,7 +697,7 @@ export function NetworkMetrics() {
|
||||
{/* ── Active Interfaces (preview restyle v2: revertido al original con title uppercase) ── */}
|
||||
<Card className="bg-card border-border">
|
||||
<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" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -651,14 +706,16 @@ export function NetworkMetrics() {
|
||||
</div>
|
||||
<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">
|
||||
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 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>
|
||||
</div>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -666,8 +723,8 @@ export function NetworkMetrics() {
|
||||
{/* ── Network Status (preview restyle: packet-loss highlight + 2x2 grid) ── */}
|
||||
<Card className="bg-card border-border">
|
||||
<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>
|
||||
<Badge variant="outline" className={`${healthColor}`}>{healthStatus === 'Healthy' ? '✓ ' : ''}{healthStatus}</Badge>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("network.cards.status")}</CardTitle>
|
||||
<Badge variant="outline" className={`${healthColor}`}>{healthStatusKey === "network.status.healthy" ? "✓ " : ""}{t(healthStatusKey)}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
@@ -680,13 +737,13 @@ export function NetworkMetrics() {
|
||||
return (
|
||||
<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>
|
||||
<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 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="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>
|
||||
<div className="min-w-0">
|
||||
@@ -694,12 +751,12 @@ export function NetworkMetrics() {
|
||||
<div className="font-medium font-mono truncate">{primaryDNS}</div>
|
||||
</div>
|
||||
<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>
|
||||
<div className="min-w-0">
|
||||
<div className="text-muted-foreground">Domain:</div>
|
||||
<div className="font-medium font-mono truncate">{networkData.domain || '—'}</div>
|
||||
<div className="text-muted-foreground">{t("network.labels.domain")}:</div>
|
||||
<div className="font-medium font-mono truncate">{domain}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -711,7 +768,7 @@ export function NetworkMetrics() {
|
||||
onClick={() => setLatencyModalOpen(true)}
|
||||
>
|
||||
<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">
|
||||
<Timer className="h-4 w-4" />
|
||||
<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"
|
||||
}
|
||||
>
|
||||
{(latencyData?.stats?.current ?? 0) < 50 ? "Excellent" :
|
||||
(latencyData?.stats?.current ?? 0) < 100 ? "Good" :
|
||||
(latencyData?.stats?.current ?? 0) < 200 ? "Fair" : "Poor"}
|
||||
{(latencyData?.stats?.current ?? 0) < 50 ? t("network.latency.status.excellent") :
|
||||
(latencyData?.stats?.current ?? 0) < 100 ? t("network.latency.status.good") :
|
||||
(latencyData?.stats?.current ?? 0) < 200 ? t("network.latency.status.fair") : t("network.latency.status.poor")}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Sparkline */}
|
||||
@@ -765,7 +822,7 @@ export function NetworkMetrics() {
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -778,11 +835,11 @@ export function NetworkMetrics() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hour">1 Hour</SelectItem>
|
||||
<SelectItem value="day">24 Hours</SelectItem>
|
||||
<SelectItem value="week">7 Days</SelectItem>
|
||||
<SelectItem value="month">30 Days</SelectItem>
|
||||
<SelectItem value="year">1 Year</SelectItem>
|
||||
<SelectItem value="hour">{t("network.timeframes.hour")}</SelectItem>
|
||||
<SelectItem value="day">{t("network.timeframes.day")}</SelectItem>
|
||||
<SelectItem value="week">{t("network.timeframes.week")}</SelectItem>
|
||||
<SelectItem value="month">{t("network.timeframes.month")}</SelectItem>
|
||||
<SelectItem value="year">{t("network.timeframes.year")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -792,7 +849,7 @@ export function NetworkMetrics() {
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Activity className="h-5 w-5 mr-2" />
|
||||
Network Traffic
|
||||
{t("network.cards.traffic")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -904,9 +961,12 @@ export function NetworkMetrics() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<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">
|
||||
{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>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -916,7 +976,7 @@ export function NetworkMetrics() {
|
||||
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">
|
||||
{networkData.physical_interfaces.map((iface) =>
|
||||
renderPhysicalInterfaceCardV2(iface, setSelectedInterface),
|
||||
renderPhysicalInterfaceCardV2(iface, setSelectedInterface, t),
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -927,16 +987,19 @@ export function NetworkMetrics() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<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">
|
||||
{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>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{networkData.bridge_interfaces.map((interface_, index) => {
|
||||
const typeBadge = getInterfaceTypeBadge(interface_.type)
|
||||
const typeBadge = getInterfaceTypeBadge(interface_.type, t)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -971,30 +1034,30 @@ export function NetworkMetrics() {
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
}
|
||||
>
|
||||
{interface_.status.toUpperCase()}
|
||||
{formatInterfaceStatus(interface_.status, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Second row: Details - Responsive layout */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<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">
|
||||
{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 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">
|
||||
<Zap className="h-3 w-3" />
|
||||
{formatSpeed(interface_.speed)}
|
||||
{formatSpeed(interface_.speed, t("common.notAvailable"))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Duplex</div>
|
||||
<div className="font-medium text-foreground text-xs capitalize">{interface_.duplex}</div>
|
||||
<div className="text-muted-foreground text-xs">{t("network.labels.duplex")}</div>
|
||||
<div className="font-medium text-foreground text-xs">{formatDuplex(interface_.duplex, t)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1025,16 +1088,19 @@ export function NetworkMetrics() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<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">
|
||||
{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>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{vmLxcInterfaces.map((interface_, index) => {
|
||||
const vmTypeBadge = getVMTypeBadge(interface_.vm_type)
|
||||
const vmTypeBadge = getVMTypeBadge(interface_.vm_type, t)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1062,7 +1128,7 @@ export function NetworkMetrics() {
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
}
|
||||
>
|
||||
{interface_.status.toUpperCase()}
|
||||
{formatInterfaceStatus(interface_.status, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -1070,20 +1136,20 @@ export function NetworkMetrics() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<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 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">
|
||||
<Zap className="h-3 w-3" />
|
||||
{formatSpeed(interface_.speed)}
|
||||
{formatSpeed(interface_.speed, t("common.notAvailable"))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Duplex</div>
|
||||
<div className="font-medium text-foreground text-xs capitalize">{interface_.duplex}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("network.labels.duplex")}</div>
|
||||
<div className="font-medium text-foreground text-xs">{formatDuplex(interface_.duplex, t)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1114,10 +1180,10 @@ export function NetworkMetrics() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Router className="h-5 w-5" />
|
||||
{selectedInterface?.name} - Interface Details
|
||||
{selectedInterface?.name} - {t("network.interfaceDetails.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
View detailed information and network traffic statistics for this interface
|
||||
{t("network.interfaceDetails.description")}
|
||||
</DialogDescription>
|
||||
{selectedInterface?.status.toLowerCase() === "up" && selectedInterface?.vm_type !== "vm" && (
|
||||
<div className="flex justify-end pt-2">
|
||||
@@ -1126,11 +1192,11 @@ export function NetworkMetrics() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hour">1 Hour</SelectItem>
|
||||
<SelectItem value="day">24 Hours</SelectItem>
|
||||
<SelectItem value="week">7 Days</SelectItem>
|
||||
<SelectItem value="month">30 Days</SelectItem>
|
||||
<SelectItem value="year">1 Year</SelectItem>
|
||||
<SelectItem value="hour">{t("network.timeframes.hour")}</SelectItem>
|
||||
<SelectItem value="day">{t("network.timeframes.day")}</SelectItem>
|
||||
<SelectItem value="week">{t("network.timeframes.week")}</SelectItem>
|
||||
<SelectItem value="month">{t("network.timeframes.month")}</SelectItem>
|
||||
<SelectItem value="year">{t("network.timeframes.year")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1156,21 +1222,21 @@ export function NetworkMetrics() {
|
||||
<>
|
||||
{/* Basic Information */}
|
||||
<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>
|
||||
<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>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Type</div>
|
||||
<Badge variant="outline" className={getInterfaceTypeBadge(displayInterface.type).color}>
|
||||
{getInterfaceTypeBadge(displayInterface.type).label}
|
||||
<div className="text-sm text-muted-foreground">{t("network.labels.type")}</div>
|
||||
<Badge variant="outline" className={getInterfaceTypeBadge(displayInterface.type, t).color}>
|
||||
{getInterfaceTypeBadge(displayInterface.type, t).label}
|
||||
</Badge>
|
||||
</div>
|
||||
{displayInterface.type === "bridge" && displayInterface.bridge_physical_interface && (
|
||||
<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">
|
||||
{displayInterface.bridge_physical_interface}
|
||||
</div>
|
||||
@@ -1180,7 +1246,7 @@ export function NetworkMetrics() {
|
||||
there never matched. */}
|
||||
{displayInterface.bridge_bond_slaves && displayInterface.bridge_bond_slaves.length > 0 && (
|
||||
<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">
|
||||
{displayInterface.bridge_bond_slaves.map((slave, idx) => (
|
||||
<Badge
|
||||
@@ -1198,19 +1264,19 @@ export function NetworkMetrics() {
|
||||
)}
|
||||
{displayInterface.type === "vm_lxc" && displayInterface.vm_name && (
|
||||
<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">
|
||||
{displayInterface.vm_name}
|
||||
{displayInterface.vm_type && (
|
||||
<Badge variant="outline" className={getVMTypeBadge(displayInterface.vm_type).color}>
|
||||
{getVMTypeBadge(displayInterface.vm_type).label}
|
||||
<Badge variant="outline" className={getVMTypeBadge(displayInterface.vm_type, t).color}>
|
||||
{getVMTypeBadge(displayInterface.vm_type, t).label}
|
||||
</Badge>
|
||||
)}
|
||||
</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
|
||||
variant="outline"
|
||||
className={
|
||||
@@ -1219,16 +1285,16 @@ export function NetworkMetrics() {
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
}
|
||||
>
|
||||
{displayInterface.status.toUpperCase()}
|
||||
{formatInterfaceStatus(displayInterface.status, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Speed</div>
|
||||
<div className="font-medium">{formatSpeed(displayInterface.speed)}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("network.labels.speed")}</div>
|
||||
<div className="font-medium">{formatSpeed(displayInterface.speed, t("common.notAvailable"))}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Duplex</div>
|
||||
<div className="font-medium capitalize">{displayInterface.duplex}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("network.labels.duplex")}</div>
|
||||
<div className="font-medium">{formatDuplex(displayInterface.duplex, t)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">MTU</div>
|
||||
@@ -1236,7 +1302,7 @@ export function NetworkMetrics() {
|
||||
</div>
|
||||
{displayInterface.mac_address && (
|
||||
<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>
|
||||
)}
|
||||
@@ -1246,13 +1312,13 @@ export function NetworkMetrics() {
|
||||
{/* IP Addresses */}
|
||||
{displayInterface.addresses.length > 0 && (
|
||||
<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">
|
||||
{displayInterface.addresses.map((addr, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
||||
<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>
|
||||
))}
|
||||
@@ -1264,23 +1330,15 @@ export function NetworkMetrics() {
|
||||
{displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type !== "vm" ? (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-4">
|
||||
Network Traffic Statistics (
|
||||
{modalTimeframe === "hour"
|
||||
? "Last Hour"
|
||||
: modalTimeframe === "day"
|
||||
? "Last 24 Hours"
|
||||
: modalTimeframe === "week"
|
||||
? "Last 7 Days"
|
||||
: modalTimeframe === "month"
|
||||
? "Last 30 Days"
|
||||
: "Last Year"}
|
||||
)
|
||||
{t("network.interfaceDetails.trafficStatistics", {
|
||||
timeframe: getLastTimeframeLabel(modalTimeframe),
|
||||
})}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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 className="font-medium text-green-500 text-lg">
|
||||
{formatNetworkTraffic(
|
||||
@@ -1292,7 +1350,7 @@ export function NetworkMetrics() {
|
||||
</div>
|
||||
<div>
|
||||
<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 className="font-medium text-blue-500 text-lg">
|
||||
{formatNetworkTraffic(
|
||||
@@ -1316,31 +1374,31 @@ export function NetworkMetrics() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border">
|
||||
<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">
|
||||
{displayInterface.packets_recv?.toLocaleString() || "N/A"}
|
||||
{displayInterface.packets_recv?.toLocaleString() || t("common.notAvailable")}
|
||||
</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">
|
||||
{displayInterface.packets_sent?.toLocaleString() || "N/A"}
|
||||
{displayInterface.packets_sent?.toLocaleString() || t("common.notAvailable")}
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -1348,11 +1406,11 @@ export function NetworkMetrics() {
|
||||
</div>
|
||||
) : displayInterface.status.toLowerCase() === "up" && displayInterface.vm_type === "vm" ? (
|
||||
<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>
|
||||
<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 className="font-medium text-green-500 text-lg">
|
||||
{formatNetworkTraffic(displayInterface.bytes_recv || 0, networkUnit)}
|
||||
@@ -1360,38 +1418,38 @@ export function NetworkMetrics() {
|
||||
</div>
|
||||
<div>
|
||||
<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 className="font-medium text-blue-500 text-lg">
|
||||
{formatNetworkTraffic(displayInterface.bytes_sent || 0, networkUnit)}
|
||||
</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">
|
||||
{displayInterface.packets_recv?.toLocaleString() || "N/A"}
|
||||
{displayInterface.packets_recv?.toLocaleString() || t("common.notAvailable")}
|
||||
</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">
|
||||
{displayInterface.packets_sent?.toLocaleString() || "N/A"}
|
||||
{displayInterface.packets_sent?.toLocaleString() || t("common.notAvailable")}
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -1399,9 +1457,9 @@ export function NetworkMetrics() {
|
||||
) : (
|
||||
<div className="bg-muted/30 rounded-lg p-6 text-center">
|
||||
<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">
|
||||
This interface is currently down. Network traffic statistics are not available.
|
||||
{t("network.interfaceDetails.inactiveDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1409,12 +1467,12 @@ export function NetworkMetrics() {
|
||||
{/* Bond Information */}
|
||||
{displayInterface.type === "bond" && displayInterface.bond_slaves && (
|
||||
<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>
|
||||
<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">
|
||||
{displayInterface.bond_mode || "Unknown"}
|
||||
{displayInterface.bond_mode || t("common.unknown")}
|
||||
{displayInterface.bond_mode_detail &&
|
||||
displayInterface.bond_mode_detail !== displayInterface.bond_mode && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
@@ -1427,13 +1485,13 @@ export function NetworkMetrics() {
|
||||
{displayInterface.bond_active_slave && (
|
||||
<div>
|
||||
<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 className="font-medium">{displayInterface.bond_active_slave}</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">
|
||||
{displayInterface.bond_slaves.map((slave, idx) => {
|
||||
// Only active-backup has a real standby. In every
|
||||
@@ -1456,7 +1514,7 @@ export function NetworkMetrics() {
|
||||
return (
|
||||
<Badge key={idx} variant="outline" className={tone}>
|
||||
{slave}
|
||||
{role && <span className="ml-1 opacity-70">· {role}</span>}
|
||||
{role && <span className="ml-1 opacity-70">· {t(`network.roles.${role}`)}</span>}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
@@ -1469,9 +1527,9 @@ export function NetworkMetrics() {
|
||||
{/* Bridge Information */}
|
||||
{displayInterface.type === "bridge" && displayInterface.bridge_members && (
|
||||
<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 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">
|
||||
{displayInterface.bridge_members.length > 0 ? (
|
||||
displayInterface.bridge_members
|
||||
@@ -1494,7 +1552,7 @@ export function NetworkMetrics() {
|
||||
</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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { getNetworkUnit } from "../lib/format-network"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface NetworkMetricsData {
|
||||
time: string
|
||||
@@ -50,6 +51,7 @@ export function NetworkTrafficChart({
|
||||
refreshInterval = 60000,
|
||||
networkUnit: networkUnitProp, // Rename prop to avoid conflict
|
||||
}: NetworkTrafficChartProps) {
|
||||
const t = useT()
|
||||
const [data, setData] = useState<NetworkMetricsData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -114,7 +116,7 @@ export function NetworkTrafficChart({
|
||||
const result = await fetchApi<any>(apiPath)
|
||||
|
||||
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) {
|
||||
@@ -207,7 +209,7 @@ export function NetworkTrafficChart({
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Error fetching network metrics:", err)
|
||||
setError(err.message || "Error loading metrics")
|
||||
setError(err.message || t("network.chart.loadError"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -255,7 +257,7 @@ export function NetworkTrafficChart({
|
||||
if (error) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
@@ -264,7 +266,7 @@ export function NetworkTrafficChart({
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -295,7 +297,7 @@ export function NetworkTrafficChart({
|
||||
}}
|
||||
domain={[0, "auto"]}
|
||||
/>
|
||||
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} /> // Pass networkUnit to tooltip
|
||||
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} />
|
||||
<Legend verticalAlign="top" height={36} content={renderLegend} />
|
||||
<Area
|
||||
type="monotone"
|
||||
@@ -304,7 +306,7 @@ export function NetworkTrafficChart({
|
||||
strokeWidth={2}
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
name="Received"
|
||||
name={t("overview.receivedShort")}
|
||||
hide={!visibleLines.netIn}
|
||||
isAnimationActive={true}
|
||||
animationDuration={300}
|
||||
@@ -317,7 +319,7 @@ export function NetworkTrafficChart({
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
name="Sent"
|
||||
name={t("overview.sentShort")}
|
||||
hide={!visibleLines.netOut}
|
||||
isAnimationActive={true}
|
||||
animationDuration={300}
|
||||
|
||||
@@ -7,12 +7,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
||||
import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
|
||||
import { useIsMobile } from "../hooks/use-mobile"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { useI18n } from "../lib/i18n/provider"
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: "hour", label: "1 Hour" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "month", label: "30 Days" },
|
||||
{ value: "hour", labelKey: "overview.timeframes.hour" },
|
||||
{ value: "day", labelKey: "overview.timeframes.day" },
|
||||
{ value: "week", labelKey: "overview.timeframes.week" },
|
||||
{ value: "month", labelKey: "overview.timeframes.month" },
|
||||
]
|
||||
|
||||
interface NodeMetricsData {
|
||||
@@ -90,9 +91,11 @@ type PeriodStat = { avg: number; max: number; min: number } | null
|
||||
function ChartStatsHeader({
|
||||
stats,
|
||||
suffix = "",
|
||||
labels,
|
||||
}: {
|
||||
stats: PeriodStat
|
||||
suffix?: string
|
||||
labels: { avg: string; max: string; min: string }
|
||||
}) {
|
||||
if (!stats) return null
|
||||
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">
|
||||
<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 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 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>
|
||||
</div>
|
||||
)
|
||||
@@ -116,6 +119,7 @@ function ChartStatsHeader({
|
||||
|
||||
|
||||
export function NodeMetricsCharts() {
|
||||
const { language, t } = useI18n()
|
||||
const [timeframe, setTimeframe] = useState("day")
|
||||
const [data, setData] = useState<NodeMetricsData[]>([])
|
||||
// period_stats from the backend — computed over the raw RRD points
|
||||
@@ -141,7 +145,7 @@ export function NodeMetricsCharts() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics()
|
||||
}, [timeframe])
|
||||
}, [timeframe, language])
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
setLoading(true)
|
||||
@@ -153,7 +157,7 @@ export function NodeMetricsCharts() {
|
||||
|
||||
if (!result.data || !Array.isArray(result.data)) {
|
||||
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) {
|
||||
@@ -171,26 +175,26 @@ export function NodeMetricsCharts() {
|
||||
let timeLabel = ""
|
||||
|
||||
if (timeframe === "hour") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "day") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "week") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
timeLabel = date.toLocaleString(language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
@@ -224,7 +228,7 @@ export function NodeMetricsCharts() {
|
||||
// the user sees actionable text instead of a bare "503".
|
||||
const body = err?.body
|
||||
setError({
|
||||
headline: body?.error || err?.message || "Error loading metrics",
|
||||
headline: body?.error || err?.message || t("overview.metricsLoadError"),
|
||||
details: body?.details,
|
||||
suggestion: body?.suggestion,
|
||||
})
|
||||
@@ -311,7 +315,7 @@ export function NodeMetricsCharts() {
|
||||
{error.suggestion && (
|
||||
<div className="w-full mt-2">
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
|
||||
Suggested fix on the Proxmox host
|
||||
{t("overview.suggestedFix")}
|
||||
</p>
|
||||
<code className="block text-xs bg-background/60 border border-border rounded px-2 py-1.5 font-mono break-all">
|
||||
{error.suggestion}
|
||||
@@ -336,14 +340,14 @@ export function NodeMetricsCharts() {
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -363,7 +367,7 @@ export function NodeMetricsCharts() {
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -378,9 +382,17 @@ export function NodeMetricsCharts() {
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<TrendingUp className="h-5 w-5 mr-2" />
|
||||
CPU Usage & Load Average
|
||||
{t("overview.cpuUsageLoadAverage")}
|
||||
</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>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 md:px-6">
|
||||
@@ -414,7 +426,7 @@ export function NodeMetricsCharts() {
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
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"]}
|
||||
/>
|
||||
@@ -428,7 +440,7 @@ export function NodeMetricsCharts() {
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
name="CPU %"
|
||||
name={t("overview.cpuPercent")}
|
||||
hide={!visibleLines.cpu.cpu}
|
||||
/>
|
||||
<Area
|
||||
@@ -439,7 +451,7 @@ export function NodeMetricsCharts() {
|
||||
strokeWidth={2}
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
name="Load Avg"
|
||||
name={t("overview.loadAverage")}
|
||||
hide={!visibleLines.cpu.load}
|
||||
/>
|
||||
</AreaChart>
|
||||
@@ -453,9 +465,17 @@ export function NodeMetricsCharts() {
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<MemoryStick className="h-5 w-5 mr-2" />
|
||||
Memory Usage
|
||||
{t("overview.memoryUsage")}
|
||||
</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>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pr-2 md:px-6">
|
||||
@@ -490,7 +510,7 @@ export function NodeMetricsCharts() {
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.1}
|
||||
name="Total"
|
||||
name={t("overview.total")}
|
||||
hide={!visibleLines.memory.memoryTotal}
|
||||
/>
|
||||
<Area
|
||||
@@ -500,7 +520,7 @@ export function NodeMetricsCharts() {
|
||||
strokeWidth={2}
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
name="Used"
|
||||
name={t("overview.used")}
|
||||
hide={!visibleLines.memory.memoryUsed}
|
||||
/>
|
||||
{/* Only show ZFS ARC if there's data */}
|
||||
@@ -525,7 +545,7 @@ export function NodeMetricsCharts() {
|
||||
strokeWidth={2}
|
||||
fill="#06b6d4"
|
||||
fillOpacity={0.3}
|
||||
name="Free"
|
||||
name={t("overview.free")}
|
||||
hide={!visibleLines.memory.memoryFree}
|
||||
/>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,11 +20,12 @@ import {
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface OnboardingSlide {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
titleKey: string
|
||||
descriptionKey: string
|
||||
image?: string
|
||||
icon: React.ReactNode
|
||||
gradient: string
|
||||
@@ -33,77 +34,70 @@ interface OnboardingSlide {
|
||||
const slides: OnboardingSlide[] = [
|
||||
{
|
||||
id: 0,
|
||||
title: "Welcome to ProxMenux Monitor!",
|
||||
description:
|
||||
"Your new monitoring tool for Proxmox. Discover all the features that will help you manage and supervise your infrastructure efficiently.",
|
||||
titleKey: "onboarding.slides.welcome.title",
|
||||
descriptionKey: "onboarding.slides.welcome.description",
|
||||
icon: <Sparkles className="h-16 w-16" />,
|
||||
gradient: "from-blue-500 via-purple-500 to-pink-500",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: "System 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.",
|
||||
titleKey: "onboarding.slides.overview.title",
|
||||
descriptionKey: "onboarding.slides.overview.description",
|
||||
image: "/images/onboarding/imagen1.png",
|
||||
icon: <LayoutDashboard className="h-12 w-12" />,
|
||||
gradient: "from-blue-500 to-cyan-500",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Storage Management",
|
||||
description:
|
||||
"Visualize the status of all your disks and volumes. Detailed information on capacity, usage, SMART health, temperature and performance of each storage device.",
|
||||
titleKey: "onboarding.slides.storage.title",
|
||||
descriptionKey: "onboarding.slides.storage.description",
|
||||
image: "/images/onboarding/imagen2.png",
|
||||
icon: <HardDrive className="h-12 w-12" />,
|
||||
gradient: "from-cyan-500 to-teal-500",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Network Metrics",
|
||||
description:
|
||||
"Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.",
|
||||
titleKey: "onboarding.slides.network.title",
|
||||
descriptionKey: "onboarding.slides.network.description",
|
||||
image: "/images/onboarding/imagen3.png",
|
||||
icon: <Network className="h-12 w-12" />,
|
||||
gradient: "from-teal-500 to-green-500",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Virtual Machines & Containers",
|
||||
description:
|
||||
"Manage all your VMs and LXC containers from one place. Status, allocated resources, current usage and quick controls for each virtual machine.",
|
||||
titleKey: "onboarding.slides.virtualMachines.title",
|
||||
descriptionKey: "onboarding.slides.virtualMachines.description",
|
||||
image: "/images/onboarding/imagen4.png",
|
||||
icon: <Box className="h-12 w-12" />,
|
||||
gradient: "from-green-500 to-emerald-500",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Hardware Information",
|
||||
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.",
|
||||
titleKey: "onboarding.slides.hardware.title",
|
||||
descriptionKey: "onboarding.slides.hardware.description",
|
||||
image: "/images/onboarding/imagen5.png",
|
||||
icon: <Cpu className="h-12 w-12" />,
|
||||
gradient: "from-emerald-500 to-blue-500",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "System 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.",
|
||||
titleKey: "onboarding.slides.logs.title",
|
||||
descriptionKey: "onboarding.slides.logs.description",
|
||||
image: "/images/onboarding/imagen6.png",
|
||||
icon: <FileText className="h-12 w-12" />,
|
||||
gradient: "from-blue-500 to-indigo-500",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "Ready for the 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.",
|
||||
titleKey: "onboarding.slides.future.title",
|
||||
descriptionKey: "onboarding.slides.future.description",
|
||||
icon: <Rocket className="h-16 w-16" />,
|
||||
gradient: "from-indigo-500 via-purple-500 to-pink-500",
|
||||
},
|
||||
]
|
||||
|
||||
export function OnboardingCarousel() {
|
||||
const t = useT()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [currentSlide, setCurrentSlide] = useState(0)
|
||||
const [direction, setDirection] = useState<"next" | "prev">("next")
|
||||
@@ -155,11 +149,13 @@ export function OnboardingCarousel() {
|
||||
}
|
||||
|
||||
const slide = slides[currentSlide]
|
||||
const slideTitle = t(slide.titleKey)
|
||||
const slideDescription = t(slide.descriptionKey)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<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">
|
||||
<Button
|
||||
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">
|
||||
<Image
|
||||
src={slide.image || "/placeholder.svg"}
|
||||
alt={slide.title}
|
||||
alt={slideTitle}
|
||||
width={600}
|
||||
height={400}
|
||||
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="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">
|
||||
{slide.description}
|
||||
{slideDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -223,7 +219,7 @@ export function OnboardingCarousel() {
|
||||
? "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"
|
||||
}`}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
aria-label={t("onboarding.goToSlide", { number: index + 1 })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -236,7 +232,7 @@ export function OnboardingCarousel() {
|
||||
className="gap-2 w-full sm:w-auto text-sm"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
{t("onboarding.previous")}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
@@ -247,13 +243,13 @@ export function OnboardingCarousel() {
|
||||
onClick={handleSkip}
|
||||
className="flex-1 sm:flex-none bg-transparent text-sm"
|
||||
>
|
||||
Skip
|
||||
{t("onboarding.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
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" />
|
||||
</Button>
|
||||
</>
|
||||
@@ -262,7 +258,7 @@ export function OnboardingCarousel() {
|
||||
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"
|
||||
>
|
||||
Get Started!
|
||||
{t("onboarding.getStarted")}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -279,7 +275,7 @@ export function OnboardingCarousel() {
|
||||
htmlFor="dont-show-again"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ScrollArea } from "./ui/scroll-area"
|
||||
import { Cpu, MemoryStick, Search } from "lucide-react"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { ProcessInfoModal } from "./process-info-modal"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
interface ProcessInfo {
|
||||
pid: number
|
||||
@@ -61,6 +62,7 @@ const formatRss = (kb: number): string => {
|
||||
}
|
||||
|
||||
export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailModalProps) {
|
||||
const t = useT()
|
||||
const [data, setData] = useState<ProcessesResponse | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
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}`)
|
||||
setData(res)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to fetch processes")
|
||||
setError(e?.message || t("details.processes.loadFailed"))
|
||||
} finally {
|
||||
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 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 =
|
||||
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."
|
||||
: "Current resident memory per process. Refreshes every 3 s while open."
|
||||
? t("details.processes.cpuDescription")
|
||||
: t("details.processes.memoryDescription")
|
||||
|
||||
// Accent palette matched to the Overview cards: CPU Usage donut uses
|
||||
// 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">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter by command line, user or PID..."
|
||||
placeholder={t("details.processes.filterPlaceholder")}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="pl-8 h-8 text-sm"
|
||||
@@ -176,16 +178,18 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
|
||||
<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}`}
|
||||
>
|
||||
<div className="hidden sm:block">PID</div>
|
||||
<div className="hidden sm:block truncate">User</div>
|
||||
<div>Command</div>
|
||||
<div className={`text-right ${sort === "cpu" ? accent.text : ""}`}>CPU %</div>
|
||||
<div className={`text-right ${sort === "mem" ? accent.text : ""}`}>{sort === "mem" ? "Memory" : "Mem %"}</div>
|
||||
<div className="hidden sm:block">{t("details.processes.pid")}</div>
|
||||
<div className="hidden sm:block truncate">{t("details.processes.user")}</div>
|
||||
<div>{t("details.processes.command")}</div>
|
||||
<div className={`text-right ${sort === "cpu" ? accent.text : ""}`}>{t("details.processes.cpuPercent")}</div>
|
||||
<div className={`text-right ${sort === "mem" ? accent.text : ""}`}>
|
||||
{sort === "mem" ? t("details.processes.memory") : t("details.processes.memPercent")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && !loading ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
No processes match the filter
|
||||
{t("details.processes.noMatches")}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((p) => {
|
||||
@@ -228,8 +232,8 @@ export function ProcessDetailModal({ open, onOpenChange, sort }: ProcessDetailMo
|
||||
where avg and now match within sampler
|
||||
noise. */}
|
||||
{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">
|
||||
avg {p.cpu_avg.toFixed(1)}
|
||||
<span className="font-mono text-[10px] text-amber-400" title={t("details.processes.lifetimeAverageTitle")}>
|
||||
{t("details.processes.averageShort")} {p.cpu_avg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
<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 && (
|
||||
<div className="text-[10px] text-muted-foreground text-right mt-1">
|
||||
Captured {new Date(data.captured_at * 1000).toLocaleTimeString()} · {filter
|
||||
? `${allMatches.length} match${allMatches.length === 1 ? '' : 'es'} of ${data.processes.length} processes`
|
||||
: `Top ${filtered.length} of ${data.processes.length} processes`}
|
||||
{t("details.processes.captured", { time: new Date(data.captured_at * 1000).toLocaleTimeString() })} · {filter
|
||||
? t(allMatches.length === 1 ? "details.processes.matchCount" : "details.processes.matchesCount", {
|
||||
count: allMatches.length,
|
||||
total: data.processes.length,
|
||||
})
|
||||
: t("details.processes.topCount", {
|
||||
shown: filtered.length,
|
||||
total: data.processes.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
||||
import { ScrollArea } from "./ui/scroll-area"
|
||||
import { Activity, FileText, HardDrive, Clock, Info } from "lucide-react"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { useI18n } from "../lib/i18n/provider"
|
||||
|
||||
interface ProcessDetail {
|
||||
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:`
|
||||
// is the canonical letter — the rest of the field is a human label like
|
||||
// "(running)". We expand the bare letter to something readable.
|
||||
const stateLabel = (state: string): string => {
|
||||
const letter = (state || "").trim().charAt(0).toUpperCase()
|
||||
const stateLabel = (state: string, t: (key: string) => string): string => {
|
||||
const rawLetter = (state || "").trim().charAt(0)
|
||||
const letter = rawLetter.toUpperCase()
|
||||
const map: Record<string, string> = {
|
||||
R: "Running",
|
||||
S: "Sleeping",
|
||||
D: "Disk wait",
|
||||
Z: "Zombie",
|
||||
T: "Stopped",
|
||||
t: "Tracing stop",
|
||||
X: "Dead",
|
||||
I: "Idle",
|
||||
R: "running",
|
||||
S: "sleeping",
|
||||
D: "diskWait",
|
||||
Z: "zombie",
|
||||
T: rawLetter === "t" ? "tracingStop" : "stopped",
|
||||
X: "dead",
|
||||
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) {
|
||||
const { language, t } = useI18n()
|
||||
const [data, setData] = useState<ProcessDetail | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -105,7 +108,7 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
|
||||
setExited(true)
|
||||
stopPolling()
|
||||
} else {
|
||||
setError(e?.message || "Failed to fetch process")
|
||||
setError(t("details.processInfo.fetchFailed"))
|
||||
}
|
||||
} finally {
|
||||
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"
|
||||
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>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{exited ? (
|
||||
<>Last snapshot from <span className="font-mono">/proc/{pid}</span> before the process finished.</>
|
||||
) : (
|
||||
<>Live snapshot from <span className="font-mono">/proc/{pid}</span>. Auto-refreshes every {REFRESH_MS / 1000} s while open.</>
|
||||
)}
|
||||
{exited
|
||||
? t("details.processInfo.descriptionExited", { pid: pid ?? "" })
|
||||
: t("details.processInfo.descriptionLive", { pid: pid ?? "", seconds: REFRESH_MS / 1000 })}
|
||||
</DialogDescription>
|
||||
</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">
|
||||
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<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">
|
||||
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>
|
||||
@@ -166,44 +167,44 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
|
||||
<div className="text-sm text-red-500 py-4">{error}</div>
|
||||
) : !data ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
{loading ? "Loading…" : "—"}
|
||||
{loading ? t("details.processInfo.loading") : "—"}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className={`max-h-[480px] pr-2 ${exited ? "opacity-75" : ""}`}>
|
||||
<div className="space-y-4">
|
||||
{/* Overview */}
|
||||
<Section icon={<Activity className="h-4 w-4 text-blue-400" />} title="Overview">
|
||||
<Row label="State" value={exited ? "Exited" : stateLabel(data.state)} />
|
||||
<Row label="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="Open FDs" value={data.fd_count != null ? String(data.fd_count) : "—"} mono />
|
||||
<Row label="User" value={`${data.user} (${data.uid})`} mono />
|
||||
<Row label="Group" value={`${data.group} (${data.gid})`} mono />
|
||||
<Section icon={<Activity className="h-4 w-4 text-blue-400" />} title={t("details.processInfo.sections.overview")}>
|
||||
<Row label={t("details.processInfo.labels.state")} value={exited ? t("details.processInfo.states.exited") : stateLabel(data.state, t)} />
|
||||
<Row label={t("details.processInfo.labels.parent")} value={data.parent_name ? `${data.parent_name} (PID ${data.ppid})` : `PID ${data.ppid}`} mono />
|
||||
<Row label={t("details.processInfo.labels.threads")} value={String(data.threads)} mono />
|
||||
<Row label={t("details.processInfo.labels.openFds")} value={data.fd_count != null ? String(data.fd_count) : "—"} mono />
|
||||
<Row label={t("details.processInfo.labels.user")} value={`${data.user} (${data.uid})`} mono />
|
||||
<Row label={t("details.processInfo.labels.group")} value={`${data.group} (${data.gid})`} mono />
|
||||
</Section>
|
||||
|
||||
{/* Resources */}
|
||||
<Section icon={<HardDrive className="h-4 w-4 text-amber-400" />} title="Resources">
|
||||
<Row label="CPU" value={`${data.cpu.toFixed(1)} %`} mono valueClass={accent.text} />
|
||||
<Row label="Memory" value={`${data.mem.toFixed(1)} %`} mono valueClass={accent.text} />
|
||||
<Row label="Resident (RSS)" value={formatKb(data.vm_rss_kb)} mono />
|
||||
<Row label="Virtual size" value={formatKb(data.vm_size_kb)} mono />
|
||||
<Row label="Swap" value={formatKb(data.vm_swap_kb)} mono />
|
||||
<Row label="I/O read" value={formatBytes(data.io_read_bytes)} mono />
|
||||
<Row label="I/O write" value={formatBytes(data.io_write_bytes)} mono />
|
||||
<Section icon={<HardDrive className="h-4 w-4 text-amber-400" />} title={t("details.processInfo.sections.resources")}>
|
||||
<Row label={t("details.processInfo.labels.cpu")} value={`${data.cpu.toFixed(1)} %`} mono valueClass={accent.text} />
|
||||
<Row label={t("details.processInfo.labels.memory")} value={`${data.mem.toFixed(1)} %`} mono valueClass={accent.text} />
|
||||
<Row label={t("details.processInfo.labels.residentRss")} value={formatKb(data.vm_rss_kb)} mono />
|
||||
<Row label={t("details.processInfo.labels.virtualSize")} value={formatKb(data.vm_size_kb)} mono />
|
||||
<Row label={t("details.processInfo.labels.swap")} value={formatKb(data.vm_swap_kb)} mono />
|
||||
<Row label={t("details.processInfo.labels.ioRead")} value={formatBytes(data.io_read_bytes)} mono />
|
||||
<Row label={t("details.processInfo.labels.ioWrite")} value={formatBytes(data.io_write_bytes)} mono />
|
||||
</Section>
|
||||
|
||||
{/* Command */}
|
||||
<Section icon={<FileText className="h-4 w-4 text-purple-400" />} title="Command">
|
||||
<Row label="Name" value={data.comm} mono />
|
||||
<Row label="Command line" value={data.cmdline || data.comm} mono wrap />
|
||||
<Row label="Executable" value={data.exe || "—"} mono wrap />
|
||||
<Row label="Working dir" value={data.cwd || "—"} mono wrap />
|
||||
<Section icon={<FileText className="h-4 w-4 text-purple-400" />} title={t("details.processInfo.sections.command")}>
|
||||
<Row label={t("details.processInfo.labels.name")} value={data.comm} mono />
|
||||
<Row label={t("details.processInfo.labels.commandLine")} value={data.cmdline || data.comm} mono wrap />
|
||||
<Row label={t("details.processInfo.labels.executable")} value={data.exe || "—"} mono wrap />
|
||||
<Row label={t("details.processInfo.labels.workingDir")} value={data.cwd || "—"} mono wrap />
|
||||
</Section>
|
||||
|
||||
{/* Times */}
|
||||
<Section icon={<Clock className="h-4 w-4 text-emerald-400" />} title="Lifetime">
|
||||
<Row label="Started" value={data.start_time || "—"} mono />
|
||||
<Row label="Running for" value={data.elapsed || "—"} mono />
|
||||
<Section icon={<Clock className="h-4 w-4 text-emerald-400" />} title={t("details.processInfo.sections.lifetime")}>
|
||||
<Row label={t("details.processInfo.labels.started")} value={data.start_time || "—"} mono />
|
||||
<Row label={t("details.processInfo.labels.runningFor")} value={data.elapsed || "—"} mono />
|
||||
</Section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -211,7 +212,8 @@ export function ProcessInfoModal({ pid, accent, onClose }: ProcessInfoModalProps
|
||||
|
||||
{data?.captured_at && (
|
||||
<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}` : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface ProfileData {
|
||||
success: boolean
|
||||
@@ -51,6 +52,7 @@ interface ProfileProps {
|
||||
* the operator hits Edit to start typing.
|
||||
*/
|
||||
export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||
const t = useT()
|
||||
const [profile, setProfile] = useState<ProfileData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -146,7 +148,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||
body: JSON.stringify({ display_name: displayDraft }),
|
||||
})
|
||||
if (!data.success) {
|
||||
setError(data.message || "Failed to save display name")
|
||||
setError(data.message || t("profilePage.errors.saveDisplayNameFailed"))
|
||||
return
|
||||
}
|
||||
setProfile(data)
|
||||
@@ -182,7 +184,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||
})
|
||||
const data: ProfileData = await r.json().catch(() => ({ success: false }))
|
||||
if (!r.ok || !data.success) {
|
||||
setAvatarError(data.message || `Upload failed (${r.status})`)
|
||||
setAvatarError(data.message || t("profilePage.errors.uploadFailed", { status: r.status }))
|
||||
return
|
||||
}
|
||||
setProfile(data)
|
||||
@@ -212,7 +214,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||
})
|
||||
const data: ProfileData = await r.json().catch(() => ({ success: false }))
|
||||
if (!r.ok || !data.success) {
|
||||
setAvatarError(data.message || `Delete failed (${r.status})`)
|
||||
setAvatarError(data.message || t("profilePage.errors.deleteFailed", { status: r.status }))
|
||||
return
|
||||
}
|
||||
setProfile(data)
|
||||
@@ -232,7 +234,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||
<Card>
|
||||
<CardContent className="p-8 flex items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading profile…
|
||||
{t("profilePage.loading")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -247,7 +249,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||
<div className="flex items-start gap-2 text-red-500">
|
||||
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
|
||||
<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>
|
||||
</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 gap-2">
|
||||
<UserIcon className="h-5 w-5 text-cyan-500" />
|
||||
<CardTitle>User Profile</CardTitle>
|
||||
<CardTitle>{t("profilePage.title")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{savedDisplay && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Saved
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{displayEditMode ? (
|
||||
@@ -286,7 +288,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||
disabled={savingDisplay}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -299,7 +301,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||
) : (
|
||||
<CheckCircle2 className="h-3 w-3 mr-1.5" />
|
||||
)}
|
||||
Save
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -310,14 +312,13 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Settings2 className="h-3 w-3 mr-1.5" />
|
||||
Edit
|
||||
{t("actions.edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Personal details rendered in the header avatar menu. None of this is required —
|
||||
the username already covers identity. Display name and avatar are decorative.
|
||||
{t("profilePage.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -327,7 +328,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||
image they uploaded. `object-cover` keeps the aspect
|
||||
ratio and crops to fit the circle. */}
|
||||
<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="relative shrink-0">
|
||||
{avatarBlobUrl ? (
|
||||
@@ -367,7 +368,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||
className="justify-start"
|
||||
>
|
||||
<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>
|
||||
{profile?.has_avatar && (
|
||||
<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"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||
Remove avatar
|
||||
{t("profilePage.avatar.remove")}
|
||||
</Button>
|
||||
)}
|
||||
<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 —
|
||||
render it square or pre-crop for best results in the header.
|
||||
{t("profilePage.avatar.hint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,7 +397,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||
|
||||
{/* ─── Username (read-only) ─── */}
|
||||
<div>
|
||||
<Label className="text-sm" htmlFor="profile-username">Username</Label>
|
||||
<Label className="text-sm" htmlFor="profile-username">{t("profilePage.username.label")}</Label>
|
||||
<Input
|
||||
id="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"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
The login name. To change it, disable authentication and reconfigure from
|
||||
Security.
|
||||
{t("profilePage.username.help")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ─── Display name (Edit controls live in the card header) ─── */}
|
||||
<div>
|
||||
<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>
|
||||
<Input
|
||||
id="profile-display"
|
||||
value={displayDraft}
|
||||
onChange={(e) => setDisplayDraft(e.target.value)}
|
||||
placeholder={profile?.username || "Display name"}
|
||||
placeholder={profile?.username || t("profilePage.displayName.placeholder")}
|
||||
maxLength={64}
|
||||
disabled={!displayEditMode || savingDisplay}
|
||||
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Shown above the username inside the avatar menu. Leave empty to show the
|
||||
username itself. Up to 64 characters.
|
||||
{t("profilePage.displayName.help")}
|
||||
</p>
|
||||
{error && displayEditMode && (
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-orange-500" />
|
||||
<CardTitle>Account security</CardTitle>
|
||||
<CardTitle>{t("profilePage.accountSecurity.title")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Password, two-factor authentication and API tokens live in the Security panel.
|
||||
{t("profilePage.accountSecurity.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{onOpenSecurity ? (
|
||||
<Button variant="outline" onClick={onOpenSecurity}>
|
||||
<Lock className="h-4 w-4 mr-2" />
|
||||
Open Security settings
|
||||
{t("profilePage.accountSecurity.openSecurity")}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Open the Security tab from the navigation.
|
||||
{t("profilePage.accountSecurity.fallback")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface SystemStatus {
|
||||
status: "healthy" | "warning" | "critical"
|
||||
@@ -80,6 +81,7 @@ interface FlaskSystemInfo {
|
||||
}
|
||||
|
||||
export function ProxmoxDashboard() {
|
||||
const t = useT()
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
|
||||
status: "healthy",
|
||||
uptime: "Loading...",
|
||||
@@ -98,6 +100,8 @@ export function ProxmoxDashboard() {
|
||||
const [lastScrollY, setLastScrollY] = useState(0)
|
||||
const [showHealthModal, setShowHealthModal] = useState(false)
|
||||
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
|
||||
const HEALTH_CATEGORY_KEYS = [
|
||||
@@ -168,7 +172,7 @@ export function ProxmoxDashboard() {
|
||||
const data: FlaskSystemInfo = await fetchApi("/api/system-info")
|
||||
|
||||
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"
|
||||
let healthStatus: "healthy" | "warning" | "critical"
|
||||
@@ -185,8 +189,8 @@ export function ProxmoxDashboard() {
|
||||
status: healthStatus,
|
||||
uptime: uptimeValue,
|
||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
serverName: data.hostname || "Unknown",
|
||||
nodeId: data.node_id || "Unknown",
|
||||
serverName: data.hostname || t("app.unknown"),
|
||||
nodeId: data.node_id || t("app.unknown"),
|
||||
})
|
||||
setIsServerConnected(true)
|
||||
} catch (error) {
|
||||
@@ -196,13 +200,13 @@ export function ProxmoxDashboard() {
|
||||
setSystemStatus((prev) => ({
|
||||
...prev,
|
||||
status: "critical",
|
||||
serverName: "Server Offline",
|
||||
nodeId: "Server Offline",
|
||||
uptime: "N/A",
|
||||
serverName: t("app.serverOffline"),
|
||||
nodeId: t("app.serverOffline"),
|
||||
uptime: t("app.notAvailable"),
|
||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
// Siempre fetch inicial
|
||||
@@ -294,13 +298,13 @@ export function ProxmoxDashboard() {
|
||||
if (
|
||||
systemStatus.serverName &&
|
||||
systemStatus.serverName !== "Loading..." &&
|
||||
systemStatus.serverName !== "Server Offline"
|
||||
systemStatus.serverName !== t("app.serverOffline")
|
||||
) {
|
||||
document.title = `${systemStatus.serverName} - ProxMenux Monitor`
|
||||
} else {
|
||||
document.title = "ProxMenux Monitor"
|
||||
}
|
||||
}, [systemStatus.serverName])
|
||||
}, [systemStatus.serverName, t])
|
||||
|
||||
useEffect(() => {
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -362,19 +366,19 @@ export function ProxmoxDashboard() {
|
||||
|
||||
const getActiveTabLabel = () => {
|
||||
switch (activeTab) {
|
||||
case "overview": return "Overview"
|
||||
case "vms": return "VMs & LXCs"
|
||||
case "storage": return "Storage"
|
||||
case "network": return "Network"
|
||||
case "hardware": return "Hardware"
|
||||
case "backup": return "Backup"
|
||||
case "terminal": return "Terminal"
|
||||
case "logs": return "System Logs"
|
||||
case "security": return "Security"
|
||||
case "settings": return "Settings"
|
||||
case "about": return "About"
|
||||
case "profile": return "Profile"
|
||||
default: return "Navigation Menu"
|
||||
case "overview": return t("navigation.overview")
|
||||
case "vms": return t("navigation.virtualMachines")
|
||||
case "storage": return t("navigation.storage")
|
||||
case "network": return t("navigation.network")
|
||||
case "hardware": return t("navigation.hardware")
|
||||
case "backup": return t("navigation.backup")
|
||||
case "terminal": return t("navigation.terminal")
|
||||
case "logs": return t("navigation.systemLogs")
|
||||
case "security": return t("navigation.security")
|
||||
case "settings": return t("navigation.settings")
|
||||
case "about": return t("navigation.about")
|
||||
case "profile": return t("navigation.profile")
|
||||
default: return t("navigation.menu")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,13 +392,13 @@ export function ProxmoxDashboard() {
|
||||
<div className="container mx-auto">
|
||||
<div className="flex items-center space-x-2 text-red-500 mb-2">
|
||||
<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 className="text-sm text-red-500/80 space-y-1 ml-7">
|
||||
<p>• Check that the monitor.service is running correctly.</p>
|
||||
<p>• The ProxMenux server should start automatically on port 8008</p>
|
||||
<p>• {t("status.checkService")}</p>
|
||||
<p>• {t("status.serverPort")}</p>
|
||||
<p>
|
||||
• Try accessing:{" "}
|
||||
• {t("status.tryAccessing")}{" "}
|
||||
<a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
{getApiUrl("/api/health")}
|
||||
</a>
|
||||
@@ -433,11 +437,11 @@ export function ProxmoxDashboard() {
|
||||
<Server className="h-8 w-8 md:h-6 md:w-6 text-primary absolute fallback-icon hidden" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">ProxMenux Monitor</h1>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">Proxmox System Dashboard</p>
|
||||
<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">{t("app.description")}</p>
|
||||
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<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>
|
||||
@@ -447,14 +451,14 @@ export function ProxmoxDashboard() {
|
||||
<div className="flex items-center space-x-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<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 className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={statusColor}>
|
||||
{statusIcon}
|
||||
<span className="ml-1 capitalize">{systemStatus.status}</span>
|
||||
<span className="ml-1">{t(`status.${systemStatus.status}`)}</span>
|
||||
</Badge>
|
||||
{systemStatus.status === "healthy" && infoCount > 0 && (
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
|
||||
@@ -465,7 +469,7 @@ export function ProxmoxDashboard() {
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Uptime: {systemStatus.uptime || "N/A"}
|
||||
{t("status.uptime", { uptime: displayUptime })}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -479,7 +483,7 @@ export function ProxmoxDashboard() {
|
||||
className="border-border/50 bg-transparent hover:bg-secondary"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
{t("actions.refresh")}
|
||||
</Button>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
@@ -513,7 +517,7 @@ export function ProxmoxDashboard() {
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
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" : ""}`} />
|
||||
</Button>
|
||||
@@ -541,7 +545,7 @@ export function ProxmoxDashboard() {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
|
||||
{statusIcon}
|
||||
<span className="ml-1 capitalize">{systemStatus.status}</span>
|
||||
<span className="ml-1">{t(`status.${systemStatus.status}`)}</span>
|
||||
</Badge>
|
||||
{systemStatus.status === "healthy" && infoCount > 0 && (
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs px-2">
|
||||
@@ -551,7 +555,7 @@ export function ProxmoxDashboard() {
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Uptime: {systemStatus.uptime || "N/A"}
|
||||
{t("status.uptime", { uptime: displayUptime })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -583,15 +587,15 @@ export function ProxmoxDashboard() {
|
||||
// crumb shows where you are, the chevron tells you the
|
||||
// siblings are one click away.
|
||||
const NODE_ITEMS = [
|
||||
{ value: "storage", label: "Storage", Icon: HardDrive, default: false },
|
||||
{ value: "network", label: "Network", Icon: NetworkIcon, default: false },
|
||||
{ value: "hardware", label: "Hardware", Icon: Cpu, default: false },
|
||||
{ value: "storage", label: t("navigation.storage"), Icon: HardDrive, default: false },
|
||||
{ value: "network", label: t("navigation.network"), Icon: NetworkIcon, default: false },
|
||||
{ value: "hardware", label: t("navigation.hardware"), Icon: Cpu, default: false },
|
||||
]
|
||||
const ADMIN_ITEMS = [
|
||||
{ value: "logs", label: "System Logs", Icon: ScrollText, default: false },
|
||||
{ value: "security", label: "Security", Icon: ShieldCheck, default: false },
|
||||
{ value: "settings", label: "Settings", Icon: SettingsIcon, default: false },
|
||||
{ value: "about", label: "About", Icon: Info, default: false },
|
||||
{ value: "logs", label: t("navigation.systemLogs"), Icon: ScrollText, default: false },
|
||||
{ value: "security", label: t("navigation.security"), Icon: ShieldCheck, default: false },
|
||||
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
|
||||
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
|
||||
]
|
||||
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
|
||||
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
|
||||
@@ -600,9 +604,9 @@ export function ProxmoxDashboard() {
|
||||
// The trigger label + icon shown on the bar. When a child
|
||||
// is active we surface IT; otherwise the group default.
|
||||
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 AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : "Admin"
|
||||
const AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : t("navigation.admin")
|
||||
// Dropdown trigger styling: parity with TabsTrigger so the
|
||||
// parent visibly carries the "I'm the selected section"
|
||||
// signal when any of its children is the active tab —
|
||||
@@ -621,14 +625,14 @@ export function ProxmoxDashboard() {
|
||||
{/* Direct: Overview */}
|
||||
<TabsTrigger value="overview" className={triggerActiveClass}>
|
||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||
Overview
|
||||
{t("navigation.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
|
||||
{t("navigation.virtualMachines")}
|
||||
</TabsTrigger>
|
||||
|
||||
{/* Dropdown: Node (Storage / Network / Hardware) */}
|
||||
@@ -656,13 +660,13 @@ export function ProxmoxDashboard() {
|
||||
backup ships this becomes a dropdown. */}
|
||||
<TabsTrigger value="backup" className={triggerActiveClass}>
|
||||
<DatabaseBackup className="mr-2 h-4 w-4" />
|
||||
Backup
|
||||
{t("navigation.backup")}
|
||||
</TabsTrigger>
|
||||
|
||||
{/* Direct: Terminal */}
|
||||
<TabsTrigger value="terminal" className={triggerActiveClass}>
|
||||
<Terminal className="mr-2 h-4 w-4" />
|
||||
Terminal
|
||||
{t("navigation.terminal")}
|
||||
</TabsTrigger>
|
||||
|
||||
{/* Dropdown: Admin (System Logs / Security / Settings / About) */}
|
||||
@@ -727,47 +731,47 @@ export function ProxmoxDashboard() {
|
||||
<div className="flex flex-col gap-1 mt-4">
|
||||
<Button variant="ghost" onClick={() => select("overview")} className={itemClass(activeTab === "overview")}>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
<span>Overview</span>
|
||||
<span>{t("navigation.overview")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("vms")} className={itemClass(activeTab === "vms")}>
|
||||
<Boxes className="h-5 w-5" />
|
||||
<span>VMs & LXCs</span>
|
||||
<span>{t("navigation.virtualMachines")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("storage")} className={itemClass(activeTab === "storage")}>
|
||||
<HardDrive className="h-5 w-5" />
|
||||
<span>Storage</span>
|
||||
<span>{t("navigation.storage")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("network")} className={itemClass(activeTab === "network")}>
|
||||
<NetworkIcon className="h-5 w-5" />
|
||||
<span>Network</span>
|
||||
<span>{t("navigation.network")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("hardware")} className={itemClass(activeTab === "hardware")}>
|
||||
<Cpu className="h-5 w-5" />
|
||||
<span>Hardware</span>
|
||||
<span>{t("navigation.hardware")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("backup")} className={itemClass(activeTab === "backup")}>
|
||||
<DatabaseBackup className="h-5 w-5" />
|
||||
<span>Backup</span>
|
||||
<span>{t("navigation.backup")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("terminal")} className={itemClass(activeTab === "terminal")}>
|
||||
<Terminal className="h-5 w-5" />
|
||||
<span>Terminal</span>
|
||||
<span>{t("navigation.terminal")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("logs")} className={itemClass(activeTab === "logs")}>
|
||||
<ScrollText className="h-5 w-5" />
|
||||
<span>System Logs</span>
|
||||
<span>{t("navigation.systemLogs")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("security")} className={itemClass(activeTab === "security")}>
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
<span>Security</span>
|
||||
<span>{t("navigation.security")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("settings")} className={itemClass(activeTab === "settings")}>
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
<span>Settings</span>
|
||||
<span>{t("navigation.settings")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => select("about")} className={itemClass(activeTab === "about")}>
|
||||
<Info className="h-5 w-5" />
|
||||
<span>About</span>
|
||||
<span>{t("navigation.about")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -836,7 +840,7 @@ export function ProxmoxDashboard() {
|
||||
</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">
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.4</p>
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.4.1-beta</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://ko-fi.com/macrimi"
|
||||
@@ -844,7 +848,7 @@ export function ProxmoxDashboard() {
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 hover:underline transition-colors"
|
||||
>
|
||||
Support and contribute to the project
|
||||
{t("app.supportProject")}
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Plus, Share, X } from "lucide-react"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// ==========================================================
|
||||
// PwaInstallPrompt
|
||||
@@ -58,6 +59,7 @@ function isIOS(): boolean {
|
||||
}
|
||||
|
||||
export function PwaInstallPrompt() {
|
||||
const t = useT()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [platform, setPlatform] = useState<"ios" | "android" | null>(null)
|
||||
|
||||
@@ -131,7 +133,7 @@ export function PwaInstallPrompt() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
aria-label={t("actions.close")}
|
||||
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"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -143,12 +145,12 @@ export function PwaInstallPrompt() {
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 id="pwa-install-title" className="text-[17px] font-bold leading-tight tracking-tight text-foreground">
|
||||
Install ProxMenux Monitor
|
||||
{t("pwaInstall.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-[13px] leading-snug text-muted-foreground">
|
||||
{platform === "ios"
|
||||
? "Add the Monitor to your home screen for quick access."
|
||||
: "Add the Monitor as an app to launch it like a native application."}
|
||||
? t("pwaInstall.iosDescription")
|
||||
: t("pwaInstall.androidDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,12 +162,12 @@ export function PwaInstallPrompt() {
|
||||
1
|
||||
</span>
|
||||
<span>
|
||||
Tap the{" "}
|
||||
{t("pwaInstall.ios.stepShareBefore")}{" "}
|
||||
<span className="inline-flex items-center gap-1 font-semibold text-primary">
|
||||
<Share className="h-4 w-4" aria-hidden="true" />
|
||||
Share
|
||||
{t("pwaInstall.ios.share")}
|
||||
</span>{" "}
|
||||
button in the bottom bar
|
||||
{t("pwaInstall.ios.stepShareAfter")}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 rounded-xl bg-primary/10 px-3.5 py-3 text-[13.5px] leading-tight">
|
||||
@@ -173,10 +175,10 @@ export function PwaInstallPrompt() {
|
||||
2
|
||||
</span>
|
||||
<span>
|
||||
Choose{" "}
|
||||
{t("pwaInstall.ios.stepChooseBefore")}{" "}
|
||||
<span className="inline-flex items-center gap-1 font-semibold text-primary">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
Add to Home Screen
|
||||
{t("pwaInstall.addToHomeScreen")}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
@@ -185,15 +187,15 @@ export function PwaInstallPrompt() {
|
||||
3
|
||||
</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>
|
||||
</li>
|
||||
</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">
|
||||
Open the browser menu <b className="text-foreground">⋮</b> →{" "}
|
||||
<b className="text-foreground">Add to Home Screen</b> → confirm by tapping{" "}
|
||||
<b className="text-foreground">Install</b>.
|
||||
{t("pwaInstall.android.stepOpenMenu")} <b className="text-foreground">⋮</b> →{" "}
|
||||
<b className="text-foreground">{t("pwaInstall.addToHomeScreen")}</b> → {t("pwaInstall.android.stepConfirm")}{" "}
|
||||
<b className="text-foreground">{t("pwaInstall.android.install")}</b>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -203,14 +205,14 @@ export function PwaInstallPrompt() {
|
||||
onClick={handleNotNow}
|
||||
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
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Don't show again
|
||||
{t("pwaInstall.neverAgain")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,9 @@ import { Button } from "./ui/button"
|
||||
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 { Checkbox } from "./ui/checkbox"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
const APP_VERSION = "1.2.4" // Sync with AppImage/package.json
|
||||
const APP_VERSION = "1.2.4.1-beta" // Sync with AppImage/package.json
|
||||
|
||||
interface ReleaseNote {
|
||||
date: string
|
||||
@@ -247,6 +248,7 @@ interface ReleaseNotesModalProps {
|
||||
}
|
||||
|
||||
export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||
const t = useT()
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false)
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -259,7 +261,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<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]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -285,10 +287,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="space-y-2">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -299,7 +301,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"
|
||||
>
|
||||
<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">
|
||||
{t(index === 0 ? "releaseNotes.currentFeatures.hostUpdate" : "releaseNotes.currentFeatures.mobileInstall")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -312,7 +316,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"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Got it!
|
||||
{t("releaseNotes.gotIt")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
@@ -325,7 +329,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||
htmlFor="dont-show-version-again"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
Filter,
|
||||
} from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// ── 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 {
|
||||
const then = new Date(iso).getTime()
|
||||
const now = Date.now()
|
||||
const diff = Math.max(0, Math.round((now - then) / 1000))
|
||||
if (diff < 60) return `${diff}s ago`
|
||||
if (diff < 3600) return `${Math.round(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.round(diff / 3600)}h ago`
|
||||
return `${Math.round(diff / 86400)}d ago`
|
||||
if (diff < 60) return t("restoreProgress.time.secondsAgo", { count: diff })
|
||||
if (diff < 3600) return t("restoreProgress.time.minutesAgo", { count: Math.round(diff / 60) })
|
||||
if (diff < 86400) return t("restoreProgress.time.hoursAgo", { count: Math.round(diff / 3600) })
|
||||
return t("restoreProgress.time.daysAgo", { count: Math.round(diff / 86400) })
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
@@ -140,40 +143,41 @@ const formatRelative = (iso: string) => {
|
||||
// "estimating time…". After the run is terminal, "—". The output is
|
||||
// a full phrase so the caller doesn't have to add suffix words that
|
||||
// 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.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 perStep = elapsedSec / state.steps_done
|
||||
const remaining = Math.max(0, state.steps_total - state.steps_done)
|
||||
const eta = Math.round(perStep * remaining)
|
||||
if (eta < 60) return `~${eta}s left`
|
||||
if (eta < 3600) return `~${Math.round(eta / 60)}m left`
|
||||
return `~${Math.round(eta / 3600)}h left`
|
||||
if (eta < 60) return t("restoreProgress.time.secondsLeft", { count: eta })
|
||||
if (eta < 3600) return t("restoreProgress.time.minutesLeft", { count: Math.round(eta / 60) })
|
||||
return t("restoreProgress.time.hoursLeft", { count: Math.round(eta / 3600) })
|
||||
}
|
||||
|
||||
// ── Small building blocks ─────────────────────────────────────
|
||||
|
||||
const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
|
||||
const t = useT()
|
||||
if (status === "running")
|
||||
return (
|
||||
<Badge className="bg-blue-500/10 border-blue-500/40 text-blue-300 gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Restore in progress
|
||||
{t("restoreProgress.status.running")}
|
||||
</Badge>
|
||||
)
|
||||
if (status === "complete")
|
||||
return (
|
||||
<Badge className="bg-emerald-500/10 border-emerald-500/40 text-emerald-400 gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Restore complete
|
||||
{t("restoreProgress.status.complete")}
|
||||
</Badge>
|
||||
)
|
||||
if (status === "failed")
|
||||
return (
|
||||
<Badge className="bg-red-500/10 border-red-500/40 text-red-400 gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Restore failed
|
||||
{t("restoreProgress.status.failed")}
|
||||
</Badge>
|
||||
)
|
||||
return <Badge variant="outline">{status}</Badge>
|
||||
@@ -190,6 +194,7 @@ const ComponentStatusIcon: React.FC<{ status: string }> = ({ status }) => {
|
||||
// ── Log viewer ────────────────────────────────────────────────
|
||||
|
||||
const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ path, historyOnly }) => {
|
||||
const t = useT()
|
||||
const [filter, setFilter] = useState<"all" | "issues">("all")
|
||||
const swrKey = 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 gap-1 text-muted-foreground">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
{path ?? "no log yet"}
|
||||
{path ?? t("restoreProgress.log.noLog")}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
@@ -215,7 +220,7 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
<ArrowDownAZ className="h-3 w-3 mr-1" />
|
||||
Full
|
||||
{t("restoreProgress.log.full")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -224,13 +229,13 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
|
||||
onClick={() => setFilter("issues")}
|
||||
>
|
||||
<Filter className="h-3 w-3 mr-1" />
|
||||
Issues only
|
||||
{t("restoreProgress.log.issuesOnly")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{isLoading ? "Loading…" : (data?.lines?.join("\n") || "(no output)")}
|
||||
{isLoading ? t("app.loading") : (data?.lines?.join("\n") || t("restoreProgress.log.noOutput"))}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
@@ -240,13 +245,14 @@ const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ p
|
||||
// ── Rollback delta widget ─────────────────────────────────────
|
||||
|
||||
const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta }) => {
|
||||
const t = useT()
|
||||
const vms = delta?.vms_to_remove ?? []
|
||||
const lxcs = delta?.lxcs_to_remove ?? []
|
||||
const comps = delta?.components_to_uninstall ?? []
|
||||
if (!vms.length && !lxcs.length && !comps.length) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -264,7 +270,7 @@ const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta
|
||||
{items.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
Show manual cleanup commands
|
||||
{t("restoreProgress.rollback.showCleanup")}
|
||||
</summary>
|
||||
<pre className="mt-1 p-2 rounded-md bg-black/40 text-xs text-muted-foreground font-mono">
|
||||
{items.map(cmd).join("\n")}
|
||||
@@ -277,22 +283,22 @@ const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
<Row
|
||||
label="VMs created after the backup"
|
||||
label={t("restoreProgress.rollback.vms")}
|
||||
items={vms}
|
||||
cmd={(id) => `qm stop ${id} 2>/dev/null; qm destroy ${id} --purge`}
|
||||
/>
|
||||
<Row
|
||||
label="LXCs created after the backup"
|
||||
label={t("restoreProgress.rollback.lxcs")}
|
||||
items={lxcs}
|
||||
cmd={(id) => `pct stop ${id} 2>/dev/null; pct destroy ${id} --purge`}
|
||||
/>
|
||||
<Row
|
||||
label="Components installed after the backup"
|
||||
label={t("restoreProgress.rollback.components")}
|
||||
items={comps}
|
||||
cmd={(name) => `# uninstall ${name} manually via ProxMenux → Hardware & GPU`}
|
||||
cmd={(name) => t("restoreProgress.rollback.uninstallComponentCommand", { name })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -306,6 +312,7 @@ const RestoreDetailModal: React.FC<{
|
||||
state: RestoreState
|
||||
historyMode?: boolean
|
||||
}> = ({ open, onClose, state, historyMode }) => {
|
||||
const t = useT()
|
||||
const progressPct = state.steps_total > 0 ? Math.round((state.steps_done / state.steps_total) * 100) : 0
|
||||
|
||||
return (
|
||||
@@ -314,12 +321,12 @@ const RestoreDetailModal: React.FC<{
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RotateCcw className="h-5 w-5 text-blue-500" />
|
||||
Post-restore progress
|
||||
{t("restoreProgress.title")}
|
||||
<StatusBadge status={state.status} />
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Started {formatIso(state.started_at)}
|
||||
{state.finished_at ? ` · finished ${formatIso(state.finished_at)}` : ""}
|
||||
{t("restoreProgress.startedAt", { time: formatIso(state.started_at) })}
|
||||
{state.finished_at ? ` · ${t("restoreProgress.finishedAt", { time: formatIso(state.finished_at) })}` : ""}
|
||||
{state.summary?.duration ? ` · ${state.summary.duration}` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -329,8 +336,8 @@ const RestoreDetailModal: React.FC<{
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{state.current_step || "—"}</span>
|
||||
<span>
|
||||
{state.steps_done}/{state.steps_total} steps
|
||||
{state.status === "running" && ` · ${computeEta(state)}`}
|
||||
{t("restoreProgress.steps", { done: state.steps_done, total: state.steps_total })}
|
||||
{state.status === "running" && ` · ${computeEta(state, t)}`}
|
||||
</span>
|
||||
</div>
|
||||
<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="text-sm font-medium flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
Components
|
||||
{t("restoreProgress.sections.components")}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{state.components.map((c) => (
|
||||
@@ -358,8 +365,8 @@ const RestoreDetailModal: React.FC<{
|
||||
<div className="flex items-center gap-2">
|
||||
<ComponentStatusIcon status={c.status} />
|
||||
<span className="font-medium">{formatComponent(c.name)}</span>
|
||||
<span className="text-muted-foreground">{c.status}</span>
|
||||
{c.exit_code && <span className="text-red-400">exit {c.exit_code}</span>}
|
||||
<span className="text-muted-foreground">{t(`restoreProgress.componentStatus.${c.status}`)}</span>
|
||||
{c.exit_code && <span className="text-red-400">{t("restoreProgress.exitCode", { code: c.exit_code })}</span>}
|
||||
</div>
|
||||
{c.log && <span className="text-muted-foreground font-mono">{c.log}</span>}
|
||||
</div>
|
||||
@@ -372,7 +379,7 @@ const RestoreDetailModal: React.FC<{
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium flex items-center gap-2 text-amber-400">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Boot sanity warnings
|
||||
{t("restoreProgress.sections.bootWarnings")}
|
||||
</div>
|
||||
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-1">
|
||||
{state.sanity_warnings.map((w) => (
|
||||
@@ -385,19 +392,19 @@ const RestoreDetailModal: React.FC<{
|
||||
{state.data_pools_import && <DataPoolsBlock section={state.data_pools_import} />}
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
{t("actions.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -408,6 +415,7 @@ const RestoreDetailModal: React.FC<{
|
||||
// Rendered inside RestoreDetailModal — one row per outcome category
|
||||
// (imported / forced / partial skip / missing skip / failed).
|
||||
const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) => {
|
||||
const t = useT()
|
||||
const total =
|
||||
section.ok.length +
|
||||
section.forced.length +
|
||||
@@ -448,40 +456,40 @@ const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) =>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
ZFS data pools — auto-import
|
||||
{t("restoreProgress.dataPools.title")}
|
||||
</div>
|
||||
<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
|
||||
label="Imported (forced, foreign hostid)"
|
||||
label={t("restoreProgress.dataPools.importedForced")}
|
||||
tone="info"
|
||||
items={section.forced}
|
||||
help="New hostid grabbed onto the pool label — next boot imports clean."
|
||||
help={t("restoreProgress.dataPools.importedForcedHelp")}
|
||||
/>
|
||||
<Row
|
||||
label="Skipped (some disks missing)"
|
||||
label={t("restoreProgress.dataPools.skippedPartial")}
|
||||
tone="warn"
|
||||
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
|
||||
label="Skipped (no disks present)"
|
||||
label={t("restoreProgress.dataPools.skippedMissing")}
|
||||
tone="warn"
|
||||
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
|
||||
label="Import failed"
|
||||
label={t("restoreProgress.dataPools.importFailed")}
|
||||
tone="error"
|
||||
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>
|
||||
{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>
|
||||
)
|
||||
@@ -490,6 +498,7 @@ const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) =>
|
||||
// ── History browser modal ─────────────────────────────────────
|
||||
|
||||
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 [detailFile, setDetailFile] = useState<string | null>(null)
|
||||
const { data: detailResp } = useSWR<{ state: RestoreState }>(
|
||||
@@ -504,17 +513,17 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
Past restores
|
||||
{t("restoreProgress.history.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Restores archived by the post-boot dispatcher. The latest 20 are kept.
|
||||
{t("restoreProgress.history.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-96">
|
||||
<div className="space-y-1.5">
|
||||
{(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) => (
|
||||
<button
|
||||
@@ -538,7 +547,7 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
{t("actions.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -559,6 +568,7 @@ const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
// ── Main inline card ──────────────────────────────────────────
|
||||
|
||||
export const RestoreProgressCard: React.FC = () => {
|
||||
const t = useT()
|
||||
const { data, mutate } = useSWR<{ state: RestoreState | null }>(
|
||||
"/api/host-backups/restore/status",
|
||||
fetcher,
|
||||
@@ -597,7 +607,7 @@ export const RestoreProgressCard: React.FC = () => {
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={() => setHistoryOpen(true)}>
|
||||
<History className="h-3.5 w-3.5 mr-1" />
|
||||
Past restores
|
||||
{t("restoreProgress.history.title")}
|
||||
</Button>
|
||||
<RestoreHistoryModal open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||
</div>
|
||||
@@ -625,12 +635,12 @@ export const RestoreProgressCard: React.FC = () => {
|
||||
<RotateCcw
|
||||
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} />
|
||||
{hasWarnings && (
|
||||
<Badge variant="outline" className="text-amber-400 border-amber-500/40 bg-amber-500/10 gap-1">
|
||||
<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>
|
||||
)}
|
||||
{poolCount > 0 && (
|
||||
@@ -643,22 +653,22 @@ export const RestoreProgressCard: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<Cpu className="h-3 w-3" />
|
||||
{poolCount} ZFS pool{poolCount === 1 ? "" : "s"}
|
||||
{poolWarnings > 0 && ` · ${poolWarnings} need attention`}
|
||||
{t("restoreProgress.badges.zfsPools", { count: poolCount })}
|
||||
{poolWarnings > 0 && ` · ${t("restoreProgress.badges.needAttention", { count: poolWarnings })}`}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setDetailOpen(true)}>
|
||||
Details
|
||||
{t("restoreProgress.actions.details")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setHistoryOpen(true)}>
|
||||
<History className="h-3.5 w-3.5 mr-1" />
|
||||
History
|
||||
{t("restoreProgress.actions.history")}
|
||||
</Button>
|
||||
{state.status !== "running" && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -668,11 +678,11 @@ export const RestoreProgressCard: React.FC = () => {
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<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>
|
||||
{state.steps_done}/{state.steps_total} steps
|
||||
{state.status === "running" && ` · ${computeEta(state)}`}
|
||||
{t("restoreProgress.steps", { done: state.steps_done, total: state.steps_total })}
|
||||
{state.status === "running" && ` · ${computeEta(state, t)}`}
|
||||
{state.summary?.duration && state.status !== "running" && ` · ${state.summary.duration}`}
|
||||
</span>
|
||||
</div>
|
||||
@@ -684,19 +694,19 @@ export const RestoreProgressCard: React.FC = () => {
|
||||
{state.summary && (
|
||||
<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="text-muted-foreground">Guests</div>
|
||||
<div className="text-muted-foreground">{t("restoreProgress.summary.guests")}</div>
|
||||
<div className="font-medium">{state.summary.guests}</div>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import "xterm/css/xterm.css"
|
||||
import { API_PORT } from "@/lib/api-config"
|
||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface WebInteraction {
|
||||
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
|
||||
@@ -49,12 +50,14 @@ interface ScriptTerminalModalProps {
|
||||
description: string
|
||||
scriptName?: string
|
||||
params?: Record<string, string>
|
||||
completedSuccessfullyMessage?: string
|
||||
completedWithErrorMessage?: (exitCode: number) => string
|
||||
// Optional callback fired when the script's WebSocket closes
|
||||
// (script_runner sends an exit code and then closes). Lets the
|
||||
// parent auto-dismiss the modal — used by host-backup's Restore
|
||||
// flow so "Press Enter to close" in the bash script actually
|
||||
// closes the modal without an extra click. Other callers ignore.
|
||||
onComplete?: () => void
|
||||
onComplete?: (exitCode?: number) => void
|
||||
}
|
||||
|
||||
export function ScriptTerminalModal({
|
||||
@@ -64,8 +67,11 @@ export function ScriptTerminalModal({
|
||||
title,
|
||||
description,
|
||||
params = { EXECUTION_MODE: "web" },
|
||||
completedSuccessfullyMessage,
|
||||
completedWithErrorMessage,
|
||||
onComplete,
|
||||
}: ScriptTerminalModalProps) {
|
||||
const t = useT()
|
||||
const termRef = useRef<any>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
// Mirrors `isOpen` for use inside async closures (initializeTerminal)
|
||||
@@ -83,6 +89,7 @@ export function ScriptTerminalModal({
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const completionReceivedRef = useRef(false)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isTablet, setIsTablet] = useState(false)
|
||||
|
||||
@@ -94,6 +101,14 @@ export function ScriptTerminalModal({
|
||||
const resizeBarRef = useRef<HTMLDivElement>(null)
|
||||
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 paramsRef = useRef(params)
|
||||
|
||||
@@ -104,7 +119,7 @@ export function ScriptTerminalModal({
|
||||
|
||||
// Same trick for onComplete — we want the latest callback inside
|
||||
// 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(() => {
|
||||
onCompleteRef.current = onComplete
|
||||
}, [onComplete])
|
||||
@@ -165,6 +180,23 @@ const initMessage = {
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
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 {
|
||||
const msg = JSON.parse(event.data)
|
||||
@@ -187,6 +219,15 @@ const initMessage = {
|
||||
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
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 {}
|
||||
termRef.current?.write(event.data)
|
||||
setIsWaitingNextInteraction(false)
|
||||
@@ -197,6 +238,9 @@ const initMessage = {
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnectionStatus("offline")
|
||||
if (!completionReceivedRef.current) {
|
||||
termRef.current?.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
@@ -205,16 +249,19 @@ const initMessage = {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
keepAliveIntervalRef.current = null
|
||||
}
|
||||
if (completionReceivedRef.current) {
|
||||
return
|
||||
}
|
||||
if (!isComplete && reconnectAttemptsRef.current < 3) {
|
||||
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
|
||||
} else {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.()
|
||||
onCompleteRef.current?.(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, [isOpen, isComplete, scriptPath])
|
||||
}, [isOpen, isComplete, scriptPath, getCompletionMessage, t])
|
||||
|
||||
const sendKey = useCallback((key: string) => {
|
||||
if (!termRef.current) return
|
||||
@@ -350,6 +397,23 @@ const initMessage = {
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
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 {
|
||||
const msg = JSON.parse(event.data)
|
||||
@@ -374,6 +438,15 @@ const initMessage = {
|
||||
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
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 {
|
||||
// Not JSON, es output normal de terminal
|
||||
}
|
||||
@@ -388,21 +461,25 @@ const initMessage = {
|
||||
|
||||
ws.onerror = (error) => {
|
||||
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) => {
|
||||
setConnectionStatus("offline")
|
||||
term.writeln("\x1b[33mConnection closed\x1b[0m")
|
||||
if (!completionReceivedRef.current) {
|
||||
term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`)
|
||||
}
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
keepAliveIntervalRef.current = null
|
||||
}
|
||||
|
||||
if (!isComplete) {
|
||||
if (!completionReceivedRef.current && !isComplete) {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.()
|
||||
onCompleteRef.current?.(-1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,6 +566,7 @@ const initMessage = {
|
||||
|
||||
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
|
||||
reconnectAttemptsRef.current = 0
|
||||
completionReceivedRef.current = false
|
||||
setIsComplete(false)
|
||||
setInteractionInput("")
|
||||
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="flex flex-col items-center gap-3">
|
||||
<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>
|
||||
)}
|
||||
@@ -835,29 +913,29 @@ const initMessage = {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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 />
|
||||
<DropdownMenuItem onSelect={() => sendCommand("\x03")}>
|
||||
<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 onSelect={() => sendCommand("\x18")}>
|
||||
<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 onSelect={() => sendCommand("\x12")}>
|
||||
<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>
|
||||
<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() }}>
|
||||
<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 onSelect={() => { void handlePaste() }}>
|
||||
<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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -877,18 +955,18 @@ const initMessage = {
|
||||
}`}
|
||||
title={
|
||||
connectionStatus === "online"
|
||||
? "Connected"
|
||||
? t("scriptTerminal.connected")
|
||||
: connectionStatus === "connecting"
|
||||
? "Connecting"
|
||||
: "Disconnected"
|
||||
? t("scriptTerminal.connecting")
|
||||
: t("scriptTerminal.disconnected")
|
||||
}
|
||||
></div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{connectionStatus === "online"
|
||||
? "Online"
|
||||
? t("scriptTerminal.online")
|
||||
: connectionStatus === "connecting"
|
||||
? "Connecting..."
|
||||
: "Offline"}
|
||||
? t("scriptTerminal.connectingStatus")
|
||||
: t("scriptTerminal.offline")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -897,7 +975,7 @@ const initMessage = {
|
||||
variant="outline"
|
||||
className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||
>
|
||||
Close
|
||||
{t("actions.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -933,14 +1011,14 @@ const initMessage = {
|
||||
onClick={() => handleInteractionResponse("yes")}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white transition-all duration-150"
|
||||
>
|
||||
Yes
|
||||
{t("scriptTerminal.yes")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -963,14 +1041,14 @@ const initMessage = {
|
||||
variant="outline"
|
||||
className="w-full hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && (
|
||||
<div className="space-y-2">
|
||||
<Label>Your input:</Label>
|
||||
<Label>{t("scriptTerminal.yourInput")}</Label>
|
||||
<Input
|
||||
value={interactionInput}
|
||||
onChange={(e) => setInteractionInput(e.target.value)}
|
||||
@@ -987,14 +1065,14 @@ const initMessage = {
|
||||
onClick={() => handleInteractionResponse(interactionInput)}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
||||
>
|
||||
Submit
|
||||
{t("scriptTerminal.submit")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1006,14 +1084,14 @@ const initMessage = {
|
||||
onClick={() => handleInteractionResponse("ok")}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
||||
>
|
||||
OK
|
||||
{t("scriptTerminal.ok")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ArrowUpCircle,
|
||||
} from "lucide-react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface NetworkInfo {
|
||||
interface: string
|
||||
@@ -77,6 +78,20 @@ interface WizardStep {
|
||||
}
|
||||
|
||||
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
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [runtimeAvailable, setRuntimeAvailable] = useState(false)
|
||||
@@ -207,7 +222,7 @@ export function SecureGatewaySetup() {
|
||||
}
|
||||
} catch (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 {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -265,7 +280,7 @@ export function SecureGatewaySetup() {
|
||||
method: "POST",
|
||||
})
|
||||
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
|
||||
// updates available" immediately, bypassing the 24h server
|
||||
// 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.
|
||||
await loadStatus()
|
||||
} else {
|
||||
setUpdateError(res?.message || "Update failed")
|
||||
setUpdateError(res?.message || sg("errors.updateFailed"))
|
||||
}
|
||||
} catch (err) {
|
||||
setUpdateError(err instanceof Error ? err.message : "Network error during update")
|
||||
setUpdateError(err instanceof Error ? err.message : sg("errors.networkUpdateFailed"))
|
||||
} finally {
|
||||
setUpdateApplying(false)
|
||||
}
|
||||
@@ -293,7 +308,7 @@ export function SecureGatewaySetup() {
|
||||
if (deploying) return
|
||||
setDeploying(true)
|
||||
setDeployError("")
|
||||
setDeployProgress("Preparing deployment...")
|
||||
setDeployProgress(sg("messages.preparingDeployment"))
|
||||
|
||||
try {
|
||||
// Validate required fields
|
||||
@@ -302,7 +317,7 @@ export function SecureGatewaySetup() {
|
||||
for (const fieldName of step.fields) {
|
||||
const field = configSchema?.[fieldName]
|
||||
if (field?.required && !config[fieldName]) {
|
||||
setDeployError(`${field.label} is required`)
|
||||
setDeployError(sg("errors.fieldRequired", { field: fieldText(fieldName, "label", field.label) }))
|
||||
setDeploying(false)
|
||||
return
|
||||
}
|
||||
@@ -326,7 +341,7 @@ export function SecureGatewaySetup() {
|
||||
}
|
||||
// For "custom", the user has already selected networks manually
|
||||
|
||||
setDeployProgress("Creating LXC container...")
|
||||
setDeployProgress(sg("messages.creatingLxc"))
|
||||
|
||||
const result = await fetchApi("/api/oci/deploy", {
|
||||
method: "POST",
|
||||
@@ -338,16 +353,16 @@ export function SecureGatewaySetup() {
|
||||
|
||||
if (!result.success) {
|
||||
// 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")) {
|
||||
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)
|
||||
setDeploying(false)
|
||||
return
|
||||
}
|
||||
|
||||
setDeployProgress("Gateway deployed successfully!")
|
||||
setDeployProgress(sg("messages.gatewayDeployed"))
|
||||
|
||||
// Wipe the Tailscale auth_key from React state so it's no longer
|
||||
// reachable from a future XSS / state-inspection. The key only needs
|
||||
@@ -376,7 +391,7 @@ export function SecureGatewaySetup() {
|
||||
}, 2000)
|
||||
|
||||
} catch (err: any) {
|
||||
setDeployError(err.message || "Deployment failed")
|
||||
setDeployError(err.message || sg("errors.deploymentFailed"))
|
||||
setDeploying(false)
|
||||
}
|
||||
}
|
||||
@@ -400,7 +415,7 @@ export function SecureGatewaySetup() {
|
||||
|
||||
const handleUpdateAuthKey = async () => {
|
||||
if (!newAuthKey.trim()) {
|
||||
setUpdateAuthKeyError("Auth Key is required")
|
||||
setUpdateAuthKeyError(sg("errors.authKeyRequired"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -417,7 +432,7 @@ export function SecureGatewaySetup() {
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
setUpdateAuthKeyError(result.message || "Failed to update auth key")
|
||||
setUpdateAuthKeyError(result.message || sg("errors.updateAuthKeyFailed"))
|
||||
setUpdateAuthKeyLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -427,7 +442,7 @@ export function SecureGatewaySetup() {
|
||||
setNewAuthKey("")
|
||||
await loadStatus()
|
||||
} catch (err: any) {
|
||||
setUpdateAuthKeyError(err.message || "Failed to update auth key")
|
||||
setUpdateAuthKeyError(err.message || sg("errors.updateAuthKeyFailed"))
|
||||
} finally {
|
||||
setUpdateAuthKeyLoading(false)
|
||||
}
|
||||
@@ -456,10 +471,10 @@ export function SecureGatewaySetup() {
|
||||
try {
|
||||
const result = await fetchApi("/api/oci/installed/secure-gateway/logs?lines=100")
|
||||
if (result.success) {
|
||||
setLogs(result.logs || "No logs available")
|
||||
setLogs(result.logs || sg("logs.empty"))
|
||||
}
|
||||
} catch (err) {
|
||||
setLogs("Failed to load logs")
|
||||
setLogs(sg("logs.failed"))
|
||||
} finally {
|
||||
setLogsLoading(false)
|
||||
}
|
||||
@@ -476,16 +491,16 @@ export function SecureGatewaySetup() {
|
||||
// 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.
|
||||
const formatLastChecked = (iso?: string): string => {
|
||||
if (!iso) return "never"
|
||||
if (!iso) return sg("values.never")
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return "unknown"
|
||||
if (isNaN(d.getTime())) return t("common.unknown")
|
||||
const now = Date.now()
|
||||
const ageMs = now - d.getTime()
|
||||
const sameDay = new Date(now).toDateString() === d.toDateString()
|
||||
const yesterday = new Date(now - 86_400_000).toDateString() === d.toDateString()
|
||||
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
if (sameDay) return time
|
||||
if (yesterday) return `yesterday ${time}`
|
||||
if (yesterday) return sg("values.yesterdayAt", { time })
|
||||
if (ageMs < 7 * 86_400_000) {
|
||||
return d.toLocaleDateString([], { weekday: "short" }) + " " + time
|
||||
}
|
||||
@@ -495,6 +510,11 @@ export function SecureGatewaySetup() {
|
||||
const renderField = (fieldName: string) => {
|
||||
const field = configSchema?.[fieldName]
|
||||
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
|
||||
if (field.depends_on) {
|
||||
@@ -511,7 +531,7 @@ export function SecureGatewaySetup() {
|
||||
return (
|
||||
<div key={fieldName} className="space-y-2">
|
||||
<Label htmlFor={fieldName} className="text-sm font-medium">
|
||||
{field.label}
|
||||
{translatedLabel}
|
||||
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
@@ -520,7 +540,7 @@ export function SecureGatewaySetup() {
|
||||
type={isVisible ? "text" : "password"}
|
||||
value={config[fieldName] || ""}
|
||||
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
|
||||
placeholder={field.placeholder}
|
||||
placeholder={translatedPlaceholder}
|
||||
className="pr-10 bg-background border-border"
|
||||
/>
|
||||
<button
|
||||
@@ -536,7 +556,7 @@ export function SecureGatewaySetup() {
|
||||
{isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
|
||||
{field.help_url && (
|
||||
<a
|
||||
href={field.help_url}
|
||||
@@ -544,7 +564,7 @@ export function SecureGatewaySetup() {
|
||||
rel="noopener noreferrer"
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
@@ -554,7 +574,7 @@ export function SecureGatewaySetup() {
|
||||
return (
|
||||
<div key={fieldName} className="space-y-2">
|
||||
<Label htmlFor={fieldName} className="text-sm font-medium">
|
||||
{field.label}
|
||||
{translatedLabel}
|
||||
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
@@ -562,10 +582,10 @@ export function SecureGatewaySetup() {
|
||||
type="text"
|
||||
value={config[fieldName] || ""}
|
||||
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
|
||||
placeholder={field.placeholder}
|
||||
placeholder={translatedPlaceholder}
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -596,7 +616,7 @@ export function SecureGatewaySetup() {
|
||||
return (
|
||||
<div key={fieldName} className="space-y-3">
|
||||
<Label className="text-sm font-medium">
|
||||
{field.label}
|
||||
{translatedLabel}
|
||||
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
@@ -619,15 +639,15 @@ export function SecureGatewaySetup() {
|
||||
)}
|
||||
</div>
|
||||
<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 && (
|
||||
<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 */}
|
||||
{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">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -642,13 +662,13 @@ export function SecureGatewaySetup() {
|
||||
return (
|
||||
<div key={fieldName} className="space-y-3">
|
||||
<Label className="text-sm font-medium">
|
||||
{field.label}
|
||||
{translatedLabel}
|
||||
</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">
|
||||
{networks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground p-3 bg-muted/30 rounded">
|
||||
No networks detected
|
||||
{sg("noNetworksDetected")}
|
||||
</p>
|
||||
) : (
|
||||
networks.map((net) => {
|
||||
@@ -676,7 +696,7 @@ export function SecureGatewaySetup() {
|
||||
<span className="font-mono text-sm">{net.subnet}</span>
|
||||
{net.recommended && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
|
||||
Recommended
|
||||
{sg("recommended")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -705,12 +725,12 @@ export function SecureGatewaySetup() {
|
||||
>
|
||||
<Checkbox checked={config[fieldName] || false} className="pointer-events-none mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">{field.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
<p className="font-medium text-sm">{translatedLabel}</p>
|
||||
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
|
||||
{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">
|
||||
<Info className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
{field.warning}
|
||||
{translatedWarning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -736,40 +756,40 @@ export function SecureGatewaySetup() {
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
Deploy a VPN gateway using Tailscale for secure, zero-trust access to your Proxmox infrastructure without opening ports.
|
||||
{sg("wizard.introDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
Access ProxMenux Monitor from anywhere
|
||||
{sg("wizard.benefitMonitorAnywhere")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
Secure access to Proxmox web UI
|
||||
{sg("wizard.benefitProxmoxUi")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
Optionally expose VMs and LXC containers
|
||||
{sg("wizard.benefitVmLxc")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
End-to-end encryption
|
||||
{sg("wizard.benefitEncryption")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
No port forwarding required
|
||||
{sg("wizard.benefitNoPorts")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
tailscale.com
|
||||
</a>
|
||||
@@ -783,17 +803,17 @@ export function SecureGatewaySetup() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
Review your configuration before deploying the gateway.
|
||||
{sg("wizard.reviewDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Storage selector */}
|
||||
{storages.length > 1 && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Storage Location</Label>
|
||||
<p className="text-xs text-muted-foreground">Select where to create the container disk.</p>
|
||||
<Label className="text-sm font-medium">{sg("wizard.storageLocation")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{sg("wizard.storageDescription")}</p>
|
||||
<div className="space-y-2">
|
||||
{storages.filter(s => s.active && s.enabled).map((storage) => (
|
||||
<div
|
||||
@@ -819,12 +839,12 @@ export function SecureGatewaySetup() {
|
||||
<span className="text-xs text-muted-foreground">({storage.type})</span>
|
||||
{storage.recommended && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
|
||||
Recommended
|
||||
{sg("recommended")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -835,41 +855,41 @@ export function SecureGatewaySetup() {
|
||||
)}
|
||||
|
||||
<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="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>
|
||||
</div>
|
||||
{storages.length > 1 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Access Mode:</span>
|
||||
<span>{config.access_mode === "host_only" ? "Host Only" : config.access_mode === "proxmox_network" ? "Proxmox Network" : "Custom Networks"}</span>
|
||||
<span className="text-muted-foreground">{sg("wizard.accessMode")}:</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>
|
||||
{config.access_mode === "host_only" && hostIp && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{(config.access_mode === "proxmox_network" || config.access_mode === "custom") && config.advertise_routes?.length > 0 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Exit Node:</span>
|
||||
<span>{config.exit_node ? "Yes" : "No"}</span>
|
||||
<span className="text-muted-foreground">{sg("wizard.exitNode")}:</span>
|
||||
<span>{config.exit_node ? sg("values.yes") : sg("values.no")}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Accept Routes:</span>
|
||||
<span>{config.accept_routes ? "Yes" : "No"}</span>
|
||||
<span className="text-muted-foreground">{sg("wizard.acceptRoutes")}:</span>
|
||||
<span>{config.accept_routes ? sg("values.yes") : sg("values.no")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -880,12 +900,12 @@ export function SecureGatewaySetup() {
|
||||
<p className="text-xs text-cyan-400 flex items-start gap-2">
|
||||
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
<strong>Important:</strong> After deployment, you must approve the subnet route in Tailscale Admin for remote access to work.
|
||||
{config.exit_node && <span> You{"'"}ll also need to approve the exit node.</span>}
|
||||
<strong>{sg("wizard.important")}:</strong> {sg("wizard.approvalRequired")}
|
||||
{config.exit_node && <span> {sg("wizard.exitNodeApprovalRequired")}</span>}
|
||||
</span>
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -915,8 +935,8 @@ export function SecureGatewaySetup() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-semibold">{step.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{step.description}</p>
|
||||
<h3 className="text-lg font-semibold">{stepText(step, "title")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{stepText(step, "description")}</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{step.fields?.map((fieldName) => renderField(fieldName))}
|
||||
@@ -932,7 +952,7 @@ export function SecureGatewaySetup() {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
||||
<CardTitle className="text-base">{sg("title")}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -953,14 +973,14 @@ export function SecureGatewaySetup() {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
||||
<CardTitle className="text-base">{sg("title")}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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()}>
|
||||
Retry
|
||||
{sg("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -981,7 +1001,7 @@ export function SecureGatewaySetup() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 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" :
|
||||
@@ -991,16 +1011,16 @@ export function SecureGatewaySetup() {
|
||||
{isRunning ? <Wifi className="h-3 w-3" /> :
|
||||
isStopped ? <Square 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>
|
||||
<CardDescription>Tailscale VPN Gateway</CardDescription>
|
||||
<CardDescription>{sg("installed.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Status info */}
|
||||
{isRunning && appStatus.uptime_seconds > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Uptime: {formatUptime(appStatus.uptime_seconds)}
|
||||
{sg("installed.uptime")}: {formatUptime(appStatus.uptime_seconds)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1018,7 +1038,7 @@ export function SecureGatewaySetup() {
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Start
|
||||
{sg("actions.start")}
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
@@ -1034,7 +1054,7 @@ export function SecureGatewaySetup() {
|
||||
) : (
|
||||
<Square className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Stop
|
||||
{sg("actions.stop")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1047,7 +1067,7 @@ export function SecureGatewaySetup() {
|
||||
) : (
|
||||
<RotateCw className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Restart
|
||||
{sg("actions.restart")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -1060,7 +1080,7 @@ export function SecureGatewaySetup() {
|
||||
}}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
Logs
|
||||
{sg("actions.logs")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1070,7 +1090,7 @@ export function SecureGatewaySetup() {
|
||||
disabled={actionLoading !== null}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Remove
|
||||
{sg("actions.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1083,9 +1103,9 @@ export function SecureGatewaySetup() {
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<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">
|
||||
Tailscale v{updateInfo.latest_version} available
|
||||
{sg("updates.tailscaleAvailable", { version: updateInfo.latest_version || "" })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1101,24 +1121,23 @@ export function SecureGatewaySetup() {
|
||||
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
||||
)}
|
||||
{updateApplying
|
||||
? "Updating…"
|
||||
: `Update to v${updateInfo.latest_version}`}
|
||||
? sg("updates.updating")
|
||||
: sg("updates.updateToVersion", { version: updateInfo.latest_version || "" })}
|
||||
</Button>
|
||||
{updateInfo.packages && updateInfo.packages.length > 1 && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
+{updateInfo.packages.length - 1} other package
|
||||
{updateInfo.packages.length > 2 ? "s" : ""} pending in the container
|
||||
{sg("updates.otherPackagesPending", { count: updateInfo.packages.length - 1 })}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<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
|
||||
? ` · 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>
|
||||
)}
|
||||
{updateError && (
|
||||
@@ -1146,7 +1165,7 @@ export function SecureGatewaySetup() {
|
||||
className="text-xs h-7 px-2"
|
||||
>
|
||||
<Key className="h-3 w-3 mr-1" />
|
||||
Update Auth Key
|
||||
{sg("authKey.update")}
|
||||
</Button>
|
||||
<a
|
||||
href="https://login.tailscale.com/admin/machines"
|
||||
@@ -1154,7 +1173,7 @@ export function SecureGatewaySetup() {
|
||||
rel="noopener noreferrer"
|
||||
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>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -1164,8 +1183,8 @@ export function SecureGatewaySetup() {
|
||||
<Dialog open={showLogs} onOpenChange={setShowLogs}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Secure Gateway Logs</DialogTitle>
|
||||
<DialogDescription>Recent container logs</DialogDescription>
|
||||
<DialogTitle>{sg("logs.title")}</DialogTitle>
|
||||
<DialogDescription>{sg("logs.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="bg-black/50 rounded-lg p-4 max-h-96 overflow-auto">
|
||||
{logsLoading ? (
|
||||
@@ -1174,14 +1193,14 @@ export function SecureGatewaySetup() {
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-xs font-mono text-green-400 whitespace-pre-wrap">
|
||||
{logs || "No logs available"}
|
||||
{logs || sg("logs.empty")}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={loadLogs}>
|
||||
<RotateCw className="h-4 w-4 mr-1" />
|
||||
Refresh
|
||||
{t("actions.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -1191,14 +1210,14 @@ export function SecureGatewaySetup() {
|
||||
<Dialog open={showRemoveConfirm} onOpenChange={setShowRemoveConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Secure Gateway?</DialogTitle>
|
||||
<DialogTitle>{sg("remove.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will stop and remove the gateway container. Your Tailscale state will be preserved for re-deployment.
|
||||
{sg("remove.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setShowRemoveConfirm(false)}>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -1210,7 +1229,7 @@ export function SecureGatewaySetup() {
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Remove
|
||||
{sg("actions.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -1228,16 +1247,16 @@ export function SecureGatewaySetup() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-cyan-500" />
|
||||
Update Auth Key
|
||||
{sg("authKey.update")}
|
||||
</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
type="password"
|
||||
value={newAuthKey}
|
||||
@@ -1246,14 +1265,14 @@ export function SecureGatewaySetup() {
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Generate a new key at{" "}
|
||||
{sg("authKey.generateAt")}{" "}
|
||||
<a
|
||||
href="https://login.tailscale.com/admin/settings/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-cyan-500 hover:text-cyan-400 underline"
|
||||
>
|
||||
Tailscale Admin > Settings > Keys
|
||||
{sg("authKey.adminKeys")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1267,7 +1286,7 @@ export function SecureGatewaySetup() {
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setShowUpdateAuthKey(false)}>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdateAuthKey}
|
||||
@@ -1279,7 +1298,7 @@ export function SecureGatewaySetup() {
|
||||
) : (
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Update Key
|
||||
{sg("authKey.updateKey")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -1291,10 +1310,10 @@ export function SecureGatewaySetup() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
Gateway Deployed Successfully
|
||||
{sg("postDeploy.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
One more step to complete the setup
|
||||
{sg("postDeploy.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1302,17 +1321,17 @@ export function SecureGatewaySetup() {
|
||||
<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">
|
||||
<Info className="h-4 w-4" />
|
||||
Next Step: Approve in Tailscale Admin
|
||||
{sg("postDeploy.nextStep")}
|
||||
</p>
|
||||
<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>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{deployedConfig.advertise_routes?.length > 0 && (
|
||||
<li className="flex items-start gap-2">
|
||||
<Network className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">Subnet Routes:</span>
|
||||
<span className="font-medium">{sg("postDeploy.subnetRoutes")}:</span>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
{deployedConfig.advertise_routes.join(", ")}
|
||||
</span>
|
||||
@@ -1323,9 +1342,9 @@ export function SecureGatewaySetup() {
|
||||
<li className="flex items-start gap-2">
|
||||
<Globe className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">Exit Node:</span>
|
||||
<span className="font-medium">{sg("postDeploy.exitNode")}:</span>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
Route all internet traffic
|
||||
{sg("postDeploy.routeAllTraffic")}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
@@ -1334,30 +1353,30 @@ export function SecureGatewaySetup() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<li>Click the button below to open Tailscale Admin</li>
|
||||
<li>Find <span className="font-mono text-cyan-400">{deployedConfig.hostname || "proxmox-gateway"}</span> in the machines list</li>
|
||||
<li>Click on it to open machine details</li>
|
||||
<li>In the <strong>Subnets</strong> section, click <strong>Edit</strong> and enable the route</li>
|
||||
<li>{sg("postDeploy.stepOpenAdmin")}</li>
|
||||
<li>{sg("postDeploy.stepFindBefore")} <span className="font-mono text-cyan-400">{deployedConfig.hostname || "proxmox-gateway"}</span> {sg("postDeploy.stepFindAfter")}</li>
|
||||
<li>{sg("postDeploy.stepOpenDetails")}</li>
|
||||
<li>{sg("postDeploy.stepSubnetsBefore")} <strong>Subnets</strong> {sg("postDeploy.stepSubnetsMiddle")} <strong>Edit</strong> {sg("postDeploy.stepSubnetsAfter")}</li>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3">
|
||||
<p className="text-xs text-green-400">
|
||||
Once approved, you can access your Proxmox host at{" "}
|
||||
<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}:8008</span> (ProxMenux Monitor) from any device with Tailscale.
|
||||
{sg("postDeploy.accessAfterApproval")}{" "}
|
||||
<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) {sg("postDeploy.fromAnyDevice")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setShowPostDeployInfo(false)}>
|
||||
I{"'"}ll do it later
|
||||
{sg("postDeploy.doLater")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -1366,7 +1385,7 @@ export function SecureGatewaySetup() {
|
||||
}}
|
||||
className="bg-cyan-600 hover:bg-cyan-700"
|
||||
>
|
||||
Open Tailscale Admin
|
||||
{sg("tailscale.openAdmin")}
|
||||
<ExternalLink className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1383,13 +1402,13 @@ export function SecureGatewaySetup() {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
||||
<CardTitle className="text-base">{sg("title")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>VPN access without opening ports</CardDescription>
|
||||
<CardDescription>{sg("notInstalled.subtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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>
|
||||
|
||||
<Button
|
||||
@@ -1397,7 +1416,7 @@ export function SecureGatewaySetup() {
|
||||
className="bg-cyan-600 hover:bg-cyan-700"
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 mr-2" />
|
||||
Deploy Secure Gateway
|
||||
{sg("notInstalled.deploy")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1418,7 +1437,7 @@ export function SecureGatewaySetup() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||
Secure Gateway Setup
|
||||
{sg("wizard.setupTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1465,7 +1484,7 @@ export function SecureGatewaySetup() {
|
||||
}}
|
||||
disabled={currentStep === 0 || deploying}
|
||||
>
|
||||
Back
|
||||
{sg("actions.back")}
|
||||
</Button>
|
||||
|
||||
{currentStep < wizardSteps.length - 1 ? (
|
||||
@@ -1480,7 +1499,7 @@ export function SecureGatewaySetup() {
|
||||
}}
|
||||
className="bg-cyan-600 hover:bg-cyan-700"
|
||||
>
|
||||
Continue
|
||||
{sg("actions.continue")}
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
@@ -1492,12 +1511,12 @@ export function SecureGatewaySetup() {
|
||||
{deploying ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Deploying...
|
||||
{sg("actions.deploying")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Deploy Gateway
|
||||
{sg("actions.deployGateway")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
+892
-576
File diff suppressed because it is too large
Load Diff
+180
-107
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff } from "lucide-react"
|
||||
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2 } from "lucide-react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { NotificationSettings } from "./notification-settings"
|
||||
@@ -14,6 +14,8 @@ import { Switch } from "./ui/switch"
|
||||
import { Input } from "./ui/input"
|
||||
import { getNetworkUnit } from "../lib/format-network"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider"
|
||||
import type { LanguageCode, LanguageStatus } from "../lib/i18n/languages"
|
||||
|
||||
// GitHub Dark color palette for bash syntax highlighting
|
||||
const BASH_KEYWORDS = new Set([
|
||||
@@ -167,13 +169,13 @@ interface SuppressionCategory {
|
||||
}
|
||||
|
||||
const SUPPRESSION_OPTIONS = [
|
||||
{ value: "24", label: "24 hours" },
|
||||
{ value: "72", label: "3 days" },
|
||||
{ value: "168", label: "1 week" },
|
||||
{ value: "720", label: "1 month" },
|
||||
{ value: "8760", label: "1 year" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
{ value: "-1", label: "Permanent" },
|
||||
{ value: "24", labelKey: "settings.healthMonitor.options.24h" },
|
||||
{ value: "72", labelKey: "settings.healthMonitor.options.3d" },
|
||||
{ value: "168", labelKey: "settings.healthMonitor.options.1w" },
|
||||
{ value: "720", labelKey: "settings.healthMonitor.options.1m" },
|
||||
{ value: "8760", labelKey: "settings.healthMonitor.options.1y" },
|
||||
{ value: "custom", labelKey: "settings.healthMonitor.options.custom" },
|
||||
{ value: "-1", labelKey: "settings.healthMonitor.options.permanent" },
|
||||
]
|
||||
|
||||
const CATEGORY_ICONS: Record<string, React.ElementType> = {
|
||||
@@ -246,6 +248,15 @@ function normalizeErrorKey(key: string): string {
|
||||
return `${desc}: ${resourceParts.join("_")}`
|
||||
}
|
||||
|
||||
function healthCategoryKey(category: SuppressionCategory): string {
|
||||
const raw = category.category || category.key.replace(/^suppress_/, "")
|
||||
const aliases: Record<string, string> = {
|
||||
disks: "disk",
|
||||
pve_services: "services",
|
||||
}
|
||||
return aliases[raw] || raw
|
||||
}
|
||||
|
||||
interface ProxMenuxTool {
|
||||
key: string
|
||||
name: string
|
||||
@@ -297,6 +308,13 @@ interface NetworkInterface {
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const { language, setLanguage, t } = useI18n()
|
||||
const tFallback = (key: string, fallback: string) => {
|
||||
const translated = t(key)
|
||||
return translated === key ? fallback : translated
|
||||
}
|
||||
const interfaceTypeLabel = (type: string) =>
|
||||
tFallback(`network.interfaceTypes.${type.toLowerCase()}`, type)
|
||||
const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
|
||||
const [updatesAvailableCount, setUpdatesAvailableCount] = useState(0)
|
||||
const [loadingTools, setLoadingTools] = useState(true)
|
||||
@@ -469,11 +487,11 @@ export function Settings() {
|
||||
if (entries.length === 0) return
|
||||
const batch = entries.map(e => `${e.source}:${e.function}:${e.key}`).join("\n")
|
||||
const title = entries.length === 1
|
||||
? `Update: ${entries[0].name}`
|
||||
: `Update ${entries.length} optimizations`
|
||||
? t("settings.optimizations.updateOneTitle", { name: entries[0].name })
|
||||
: t("settings.optimizations.updateManyTitle", { count: entries.length })
|
||||
const description = entries.length === 1
|
||||
? `Re-running ${entries[0].function} from the ${entries[0].source} flow.`
|
||||
: `Re-running ${entries.length} post-install functions in sequence.`
|
||||
? t("settings.optimizations.updateOneDescription", { functionName: entries[0].function, source: entries[0].source })
|
||||
: t("settings.optimizations.updateManyDescription", { count: entries.length })
|
||||
setUpdateTerminal({
|
||||
open: true,
|
||||
title,
|
||||
@@ -899,21 +917,82 @@ export function Settings() {
|
||||
k => pendingChanges[k] !== -2
|
||||
)
|
||||
|
||||
const getLanguageStatusLabel = (status: LanguageStatus) => {
|
||||
switch (status) {
|
||||
case "complete":
|
||||
return t("settings.interfaceLanguage.statusComplete")
|
||||
case "partial":
|
||||
return t("settings.interfaceLanguage.statusPartial")
|
||||
case "needs-translation":
|
||||
return t("settings.interfaceLanguage.statusNeedsTranslation")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Settings</h1>
|
||||
<p className="text-muted-foreground mt-2">Manage your dashboard preferences</p>
|
||||
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
|
||||
<p className="text-muted-foreground mt-2">{t("settings.description")}</p>
|
||||
</div>
|
||||
|
||||
{/* Interface Language Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe2 className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle>{t("settings.interfaceLanguage.title")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{t("settings.interfaceLanguage.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{t("settings.interfaceLanguage.label")}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("settings.interfaceLanguage.fallbackNote")}</p>
|
||||
</div>
|
||||
<Select value={language} onValueChange={(value) => setLanguage(value as LanguageCode)}>
|
||||
<SelectTrigger className="w-full sm:w-64">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_LANGUAGES.map((item) => (
|
||||
<SelectItem key={item.code} value={item.code}>
|
||||
{item.nativeName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{SUPPORTED_LANGUAGES.map((item) => (
|
||||
<div
|
||||
key={item.code}
|
||||
className={`rounded-md border px-3 py-2 text-sm ${
|
||||
item.code === language ? "border-blue-500 bg-blue-500/10" : "border-border bg-muted/20"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{item.nativeName}</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{item.code}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{getLanguageStatusLabel(item.status)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network Units Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5 text-green-500" />
|
||||
<CardTitle>Network Units</CardTitle>
|
||||
<CardTitle>{t("settings.networkUnits.title")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Change how network traffic is displayed</CardDescription>
|
||||
<CardDescription>{t("settings.networkUnits.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingUnitSettings ? (
|
||||
@@ -922,14 +1001,14 @@ export function Settings() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-foreground flex items-center justify-between">
|
||||
<div className="flex items-center">Network Unit Display</div>
|
||||
<div className="flex items-center">{t("settings.networkUnits.label")}</div>
|
||||
<Select value={networkUnitSettings} onValueChange={changeNetworkUnit}>
|
||||
<SelectTrigger className="w-28 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Bytes">Bytes</SelectItem>
|
||||
<SelectItem value="Bits">Bits</SelectItem>
|
||||
<SelectItem value="Bytes">{t("settings.networkUnits.bytes")}</SelectItem>
|
||||
<SelectItem value="Bits">{t("settings.networkUnits.bits")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -943,14 +1022,14 @@ export function Settings() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<HeartPulse className="h-5 w-5 text-red-500" />
|
||||
<CardTitle>Health Monitor</CardTitle>
|
||||
<CardTitle>{t("settings.healthMonitor.title")}</CardTitle>
|
||||
</div>
|
||||
{!loadingHealth && (
|
||||
<div className="flex items-center gap-2">
|
||||
{savedAllHealth && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Saved
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{healthEditMode ? (
|
||||
@@ -960,7 +1039,7 @@ export function Settings() {
|
||||
onClick={handleCancelEdit}
|
||||
disabled={savingAllHealth}
|
||||
>
|
||||
Cancel
|
||||
{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"
|
||||
@@ -972,7 +1051,7 @@ export function Settings() {
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
Save
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -981,15 +1060,14 @@ export function Settings() {
|
||||
onClick={() => setHealthEditMode(true)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
Edit
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Configure how long dismissed alerts stay suppressed for each category.
|
||||
Changes apply immediately to both existing and future dismissed alerts.
|
||||
{t("settings.healthMonitor.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -1001,8 +1079,8 @@ export function Settings() {
|
||||
<div className="space-y-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-2 mb-1 border-b border-border">
|
||||
<span className="text-xs font-medium text-muted-foreground">Category</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">Suppression Duration</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t("settings.healthMonitor.category")}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t("settings.healthMonitor.suppressionDuration")}</span>
|
||||
</div>
|
||||
|
||||
{/* Per-category rows */}
|
||||
@@ -1015,13 +1093,14 @@ export function Settings() {
|
||||
const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2
|
||||
const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours
|
||||
const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key)
|
||||
const catLabel = tFallback(`settings.healthMonitor.categories.${healthCategoryKey(cat)}`, cat.label)
|
||||
|
||||
return (
|
||||
<div key={cat.key}>
|
||||
<div className="flex items-center justify-between gap-2 py-2 sm:py-2.5 px-1 sm:px-2">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<IconComp className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs sm:text-sm font-medium">{cat.label}</span>
|
||||
<span className="text-xs sm:text-sm font-medium">{catLabel}</span>
|
||||
{hasChanged && healthEditMode && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 shrink-0" />
|
||||
)}
|
||||
@@ -1035,7 +1114,7 @@ export function Settings() {
|
||||
className="w-16 sm:w-20 h-7 text-xs"
|
||||
value={customValues[cat.key] || ""}
|
||||
onChange={(e) => setCustomValues(prev => ({ ...prev, [cat.key]: e.target.value }))}
|
||||
placeholder="Hours"
|
||||
placeholder={t("settings.healthMonitor.hours")}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">h</span>
|
||||
<button
|
||||
@@ -1074,7 +1153,7 @@ export function Settings() {
|
||||
<SelectContent>
|
||||
{SUPPRESSION_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{t(opt.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -1088,10 +1167,10 @@ export function Settings() {
|
||||
<div className="flex items-start gap-2 ml-6 sm:ml-8 mr-1 mb-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
|
||||
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-blue-400/90 leading-relaxed">
|
||||
Alerts for <span className="font-semibold">{cat.label}</span> will be permanently suppressed when dismissed.
|
||||
{t("settings.healthMonitor.permanentNotice", { category: catLabel })}
|
||||
{cat.category === "temperature" && (
|
||||
<span className="block mt-0.5 text-blue-300/80">
|
||||
Critical CPU temperature alerts will still trigger for hardware safety.
|
||||
{t("settings.healthMonitor.temperatureSafetyNotice")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -1103,7 +1182,7 @@ export function Settings() {
|
||||
<div className="flex items-start gap-2 ml-6 sm:ml-8 mr-1 mb-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
|
||||
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-blue-400/90 leading-relaxed">
|
||||
Long suppression period. Dismissed alerts for this category will not reappear for an extended time.
|
||||
{t("settings.healthMonitor.longSuppressionNotice")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1116,8 +1195,7 @@ export function Settings() {
|
||||
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
|
||||
<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">
|
||||
These settings apply when you dismiss a warning from the Health Monitor.
|
||||
Critical CPU temperature alerts always trigger regardless of settings to protect your hardware.
|
||||
{t("settings.healthMonitor.footerNote")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1133,11 +1211,11 @@ export function Settings() {
|
||||
<div className="pt-8">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<BellOff className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-sm font-medium">Active Suppressions</span>
|
||||
<span className="text-sm font-medium">{t("settings.healthMonitor.activeSuppressions")}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">
|
||||
Alerts you have silenced from the Health Monitor. Permanent dismisses can only be
|
||||
reverted here. Editing requires the Health Monitor <span className="font-mono text-xs">Edit</span> mode at the top of this card.
|
||||
{t("settings.healthMonitor.activeSuppressionsDescription")}{" "}
|
||||
<span className="font-mono text-xs">{t("actions.edit")}</span>.
|
||||
</p>
|
||||
{loadingSuppressions ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
@@ -1145,19 +1223,19 @@ export function Settings() {
|
||||
</div>
|
||||
) : activeSuppressions.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
No active suppressions. Dismissed alerts from the Health Monitor will appear here.
|
||||
{t("settings.healthMonitor.noActiveSuppressions")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activeSuppressions.map((s) => {
|
||||
const remaining = s.suppression_remaining_hours
|
||||
const remainingLabel = s.permanent
|
||||
? "Permanent"
|
||||
? t("settings.healthMonitor.permanent")
|
||||
: remaining === undefined || remaining === null
|
||||
? "Active"
|
||||
? t("status.active")
|
||||
: remaining >= 24
|
||||
? `${Math.round(remaining / 24)}d remaining`
|
||||
: `${Math.max(0, Math.round(remaining))}h remaining`
|
||||
? t("settings.healthMonitor.daysRemaining", { count: Math.round(remaining / 24) })
|
||||
: t("settings.healthMonitor.hoursRemaining", { count: Math.max(0, Math.round(remaining)) })
|
||||
const dismissedAtLabel = s.acknowledged_at
|
||||
? new Date(s.acknowledged_at).toLocaleString()
|
||||
: ""
|
||||
@@ -1174,7 +1252,7 @@ export function Settings() {
|
||||
<div className={`flex items-start gap-2 min-w-0 flex-1 ${isQueued ? "opacity-60" : ""}`}>
|
||||
{s.permanent ? (
|
||||
<Badge variant="outline" className="text-sm px-2 py-0.5 shrink-0 text-amber-400 border-amber-400/40 mt-0.5 font-normal">
|
||||
Permanent
|
||||
{t("settings.healthMonitor.permanent")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-sm px-2 py-0.5 shrink-0 text-blue-400 border-blue-400/30 mt-0.5 font-normal">
|
||||
@@ -1183,12 +1261,12 @@ export function Settings() {
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`text-xs sm:text-sm font-medium text-foreground truncate ${isQueued ? "line-through" : ""}`} title={s.error_key}>
|
||||
{normalizeErrorKey(s.error_key)}
|
||||
{tFallback(`settings.healthMonitor.errorNames.${s.error_key}`, normalizeErrorKey(s.error_key))}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
|
||||
<span>category: <span className="font-medium text-foreground/80">{s.category || "—"}</span></span>
|
||||
{s.severity && <span>severity: <span className="font-medium text-foreground/80">{s.severity}</span></span>}
|
||||
{dismissedAtLabel && <span>dismissed: {dismissedAtLabel}</span>}
|
||||
<span>{t("settings.healthMonitor.labels.category")}: <span className="font-medium text-foreground/80">{s.category ? tFallback(`settings.healthMonitor.categories.${s.category}`, s.category) : "—"}</span></span>
|
||||
{s.severity && <span>{t("settings.healthMonitor.labels.severity")}: <span className="font-medium text-foreground/80">{tFallback(`status.${s.severity.toLowerCase()}`, s.severity)}</span></span>}
|
||||
{dismissedAtLabel && <span>{t("settings.healthMonitor.labels.dismissed")}: {dismissedAtLabel}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1204,13 +1282,13 @@ export function Settings() {
|
||||
onClick={() => handleReEnable(s.error_key)}
|
||||
title={
|
||||
!healthEditMode
|
||||
? "Enable Health Monitor Edit mode to re-enable"
|
||||
? t("settings.healthMonitor.reEnableDisabledTitle")
|
||||
: isQueued
|
||||
? "Cancel re-enable (will not be applied on Save)"
|
||||
: "Queue this alert for re-enable on Save"
|
||||
? t("settings.healthMonitor.reEnableQueuedTitle")
|
||||
: t("settings.healthMonitor.reEnableTitle")
|
||||
}
|
||||
>
|
||||
{isQueued ? "Undo" : "Re-enable"}
|
||||
{isQueued ? t("actions.undo") : t("settings.healthMonitor.reEnable")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -1228,11 +1306,10 @@ export function Settings() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-purple-500" />
|
||||
<CardTitle>Remote Storage Exclusions</CardTitle>
|
||||
<CardTitle>{t("settings.remoteStorage.title")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Exclude remote storages (PBS, NFS, CIFS, etc.) from health monitoring and notifications.
|
||||
Use this for storages that are intentionally offline or have limited API access.
|
||||
{t("settings.remoteStorage.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -1243,18 +1320,18 @@ export function Settings() {
|
||||
) : remoteStorages.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<CloudOff className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
|
||||
<p className="text-muted-foreground">No remote storages detected</p>
|
||||
<p className="text-muted-foreground">{t("settings.remoteStorage.emptyTitle")}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
PBS, NFS, CIFS, and other remote storages will appear here when configured
|
||||
{t("settings.remoteStorage.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 pb-2 mb-1 border-b border-border">
|
||||
<span className="text-xs font-medium text-muted-foreground">Storage</span>
|
||||
<span className="text-xs font-medium text-muted-foreground text-center w-20">Health</span>
|
||||
<span className="text-xs font-medium text-muted-foreground text-center w-20">Alerts</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t("settings.remoteStorage.storage")}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground text-center w-20">{t("settings.common.health")}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground text-center w-20">{t("settings.common.alerts")}</span>
|
||||
</div>
|
||||
|
||||
{/* Storage rows - scrollable container */}
|
||||
@@ -1279,10 +1356,10 @@ export function Settings() {
|
||||
</Badge>
|
||||
</div>
|
||||
{isOffline && (
|
||||
<p className="text-[11px] text-red-400 mt-0.5">Offline or unavailable</p>
|
||||
<p className="text-[11px] text-red-400 mt-0.5">{t("settings.remoteStorage.offline")}</p>
|
||||
)}
|
||||
{isNamespaceRestricted && (
|
||||
<p className="text-[11px] text-blue-400 mt-0.5">Reachable; datastore size hidden by ACL</p>
|
||||
<p className="text-[11px] text-blue-400 mt-0.5">{t("settings.remoteStorage.namespaceRestricted")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1333,9 +1410,9 @@ export function Settings() {
|
||||
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
|
||||
<Info className="h-3.5 w-3.5 text-purple-400 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
<strong>Health:</strong> When OFF, the storage won't trigger warnings/critical alerts in the Health Monitor.
|
||||
<strong>{t("settings.common.health")}:</strong> {t("settings.remoteStorage.healthHelp")}
|
||||
<br />
|
||||
<strong>Alerts:</strong> When OFF, no notifications will be sent for this storage.
|
||||
<strong>{t("settings.common.alerts")}:</strong> {t("settings.remoteStorage.alertsHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1348,11 +1425,10 @@ export function Settings() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle>Network Interface Exclusions</CardTitle>
|
||||
<CardTitle>{t("settings.networkInterfaces.title")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Exclude network interfaces (bridges, bonds, physical NICs) from health monitoring and notifications.
|
||||
Use this for interfaces that are intentionally disabled or unused.
|
||||
{t("settings.networkInterfaces.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -1363,15 +1439,15 @@ export function Settings() {
|
||||
) : networkInterfaces.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
|
||||
<p className="text-muted-foreground">No network interfaces detected</p>
|
||||
<p className="text-muted-foreground">{t("settings.networkInterfaces.emptyTitle")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 pb-2 mb-1 border-b border-border">
|
||||
<span className="text-xs font-medium text-muted-foreground">Interface</span>
|
||||
<span className="text-xs font-medium text-muted-foreground text-center w-20">Health</span>
|
||||
<span className="text-xs font-medium text-muted-foreground text-center w-20">Alerts</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t("settings.networkInterfaces.interface")}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground text-center w-20">{t("settings.common.health")}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground text-center w-20">{t("settings.common.alerts")}</span>
|
||||
</div>
|
||||
|
||||
{/* Interface rows - scrollable container */}
|
||||
@@ -1393,21 +1469,21 @@ export function Settings() {
|
||||
{iface.name}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{iface.type}
|
||||
{interfaceTypeLabel(iface.type)}
|
||||
</Badge>
|
||||
{isDown && !isExcluded && (
|
||||
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
|
||||
DOWN
|
||||
{t("settings.networkInterfaces.down")}
|
||||
</Badge>
|
||||
)}
|
||||
{isExcluded && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-400">
|
||||
Excluded
|
||||
{t("settings.networkInterfaces.excluded")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{iface.ip_address || 'No IP'} {iface.speed > 0 ? `- ${iface.speed} Mbps` : ''}
|
||||
{iface.ip_address || t("settings.networkInterfaces.noIp")} {iface.speed > 0 ? `- ${iface.speed} Mbps` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1460,9 +1536,9 @@ export function Settings() {
|
||||
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
|
||||
<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">
|
||||
<strong>Health:</strong> When OFF, the interface won't trigger warnings/critical alerts in the Health Monitor.
|
||||
<strong>{t("settings.common.health")}:</strong> {t("settings.networkInterfaces.healthHelp")}
|
||||
<br />
|
||||
<strong>Alerts:</strong> When OFF, no notifications will be sent for this interface.
|
||||
<strong>{t("settings.common.alerts")}:</strong> {t("settings.networkInterfaces.alertsHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1492,27 +1568,26 @@ export function Settings() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-cyan-500" />
|
||||
<CardTitle>Snippets storage</CardTitle>
|
||||
<CardTitle>{t("settings.snippets.title")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Where ProxMenux installs hookscripts (e.g. the GPU passthrough guard for VMs/LXCs).
|
||||
Pick a shared storage in cluster setups so VMs and LXCs migrate cleanly between nodes —
|
||||
{t("settings.snippets.description")}{" "}
|
||||
<code className="mx-1">local</code>
|
||||
is node-specific and breaks migration.
|
||||
{" "}{t("settings.snippets.localNote")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-3">
|
||||
<Select value={snippetsStorage || ""} onValueChange={saveSnippetsStorage} disabled={snippetsSaving}>
|
||||
<SelectTrigger className="w-full md:w-72">
|
||||
<SelectValue placeholder="Pick a storage…" />
|
||||
<SelectValue placeholder={t("settings.snippets.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{snippetsCandidates.map(c => (
|
||||
<SelectItem key={c.name} value={c.name} disabled={!c.active}>
|
||||
{c.name}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{c.type}{!c.active && " · inactive"}
|
||||
{c.type}{!c.active && ` · ${t("status.inactive")}`}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -1521,14 +1596,12 @@ export function Settings() {
|
||||
{snippetsSaving && (
|
||||
<span className="text-xs text-muted-foreground inline-flex items-center gap-1.5">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Saving…
|
||||
{t("status.saving")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
Existing VMs/LXCs already configured with the previous storage keep working.
|
||||
Only new GPU passthrough operations (or running "sync hookscripts" on the host)
|
||||
will use the new selection.
|
||||
{t("settings.snippets.footer")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1539,9 +1612,9 @@ export function Settings() {
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="h-5 w-5 text-orange-500" />
|
||||
<CardTitle>ProxMenux Optimizations</CardTitle>
|
||||
<CardTitle>{t("settings.optimizations.title")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>System optimizations and utilities installed via ProxMenux</CardDescription>
|
||||
<CardDescription>{t("settings.optimizations.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingTools ? (
|
||||
@@ -1551,15 +1624,15 @@ export function Settings() {
|
||||
) : proxmenuxTools.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
|
||||
<p className="text-muted-foreground">No ProxMenux optimizations installed yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Run ProxMenux to configure system optimizations</p>
|
||||
<p className="text-muted-foreground">{t("settings.optimizations.emptyTitle")}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t("settings.optimizations.emptyDescription")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between mb-4 pb-2 border-b border-border">
|
||||
<span className="text-sm font-medium text-muted-foreground">Installed Tools</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">{t("settings.optimizations.installedTools")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-orange-500">{proxmenuxTools.length} active</span>
|
||||
<span className="text-sm font-semibold text-orange-500">{t("settings.optimizations.activeCount", { count: proxmenuxTools.length })}</span>
|
||||
{/* Sprint 12B: count badge that doubles as the trigger
|
||||
for the multi-select update modal. Only shown when
|
||||
at least one tool has an available update. */}
|
||||
@@ -1578,10 +1651,10 @@ export function Settings() {
|
||||
setUpdateModalOpen(true)
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-xs font-semibold text-purple-300 bg-purple-500/15 border border-purple-500/40 hover:bg-purple-500/25 transition-colors rounded-full px-3 py-1"
|
||||
title="View available updates"
|
||||
title={t("settings.optimizations.viewUpdates")}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{updatesAvailableCount} {updatesAvailableCount === 1 ? 'update' : 'updates'}
|
||||
{t("settings.optimizations.updateCount", { count: updatesAvailableCount })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1605,7 +1678,7 @@ export function Settings() {
|
||||
key={tool.key}
|
||||
onClick={clickable ? () => viewToolSource(tool) : undefined}
|
||||
className={`flex items-center justify-between gap-2 p-3 rounded-lg border transition-colors ${baseClasses} ${clickable ? 'cursor-pointer' : ''}`}
|
||||
title={clickable ? (isDeprecated ? 'Legacy optimization — click to view source' : 'Click to view source code') : undefined}
|
||||
title={clickable ? (isDeprecated ? t("settings.optimizations.legacySourceTitle") : t("settings.optimizations.sourceTitle")) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
@@ -1614,7 +1687,7 @@ export function Settings() {
|
||||
<span className="text-sm font-medium truncate">{tool.name}</span>
|
||||
{isDeprecated && (
|
||||
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
legacy
|
||||
{t("settings.optimizations.legacy")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1627,7 +1700,7 @@ export function Settings() {
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleSingleToolUpdate(tool) }}
|
||||
className="text-purple-300 hover:text-purple-200 transition-colors"
|
||||
title={`Update ${tool.name} to v${tool.available_version}`}
|
||||
title={t("settings.optimizations.updateToolTitle", { name: tool.name, version: tool.available_version || "?" })}
|
||||
>
|
||||
<ArrowUpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -1662,7 +1735,7 @@ export function Settings() {
|
||||
<h3 className="text-sm font-semibold truncate">{codeModal.toolName}</h3>
|
||||
{codeModal.deprecated && (
|
||||
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
legacy
|
||||
{t("settings.optimizations.legacy")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1688,10 +1761,10 @@ export function Settings() {
|
||||
<button
|
||||
onClick={copySourceCode}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-muted hover:bg-muted/80 transition-colors"
|
||||
title="Copy to clipboard"
|
||||
title={t("actions.copyToClipboard")}
|
||||
>
|
||||
{codeCopied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{codeCopied ? 'Copied' : 'Copy'}
|
||||
{codeCopied ? t("actions.copied") : t("actions.copy")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -1740,9 +1813,9 @@ export function Settings() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles className="h-5 w-5 text-purple-400" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Available updates</h3>
|
||||
<h3 className="text-sm font-semibold">{t("settings.optimizations.availableUpdates")}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{updatesAvailableCount} {updatesAvailableCount === 1 ? 'optimization' : 'optimizations'} can be updated to a newer version.
|
||||
{t("settings.optimizations.availableUpdatesDescription", { count: updatesAvailableCount })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1799,14 +1872,14 @@ export function Settings() {
|
||||
|
||||
<div className="flex items-center justify-between p-4 border-t border-border">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedUpdates.size} of {updatesAvailableCount} selected
|
||||
{t("settings.optimizations.selectedCount", { selected: selectedUpdates.size, total: updatesAvailableCount })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setUpdateModalOpen(false)}
|
||||
className="px-4 py-1.5 text-xs rounded-md bg-muted hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
disabled={selectedUpdates.size === 0}
|
||||
@@ -1830,7 +1903,7 @@ export function Settings() {
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium rounded-md bg-purple-500 hover:bg-purple-600 text-white transition-colors disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed"
|
||||
>
|
||||
<ArrowUpCircle className="h-3.5 w-3.5" />
|
||||
Update selected
|
||||
{t("settings.optimizations.updateSelected")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
const menuItems = [
|
||||
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
||||
@@ -14,6 +15,8 @@ const menuItems = [
|
||||
]
|
||||
|
||||
const Sidebar = ({ currentPath, setOpen }) => {
|
||||
const t = useT()
|
||||
|
||||
const handleNavigation = (tabName: string) => {
|
||||
// Dispatch custom event to change tab in dashboard
|
||||
const event = new CustomEvent("changeTab", { detail: { tab: tabName } })
|
||||
@@ -32,7 +35,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
<span>Overview</span>
|
||||
<span>{t("navigation.overview")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -44,7 +47,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="h-5 w-5" />
|
||||
<span>Storage</span>
|
||||
<span>{t("navigation.storage")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -56,7 +59,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<Network className="h-5 w-5" />
|
||||
<span>Network</span>
|
||||
<span>{t("navigation.network")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -68,7 +71,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<Server className="h-5 w-5" />
|
||||
<span>VMs & LXCs</span>
|
||||
<span>{t("navigation.virtualMachines")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -80,7 +83,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<Cpu className="h-5 w-5" />
|
||||
<span>Hardware</span>
|
||||
<span>{t("navigation.hardware")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -92,7 +95,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
<span>System Logs</span>
|
||||
<span>{t("navigation.systemLogs")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -104,7 +107,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<Terminal className="h-5 w-5" />
|
||||
<span>Terminal</span>
|
||||
<span>{t("navigation.terminal")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -116,7 +119,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
||||
}`}
|
||||
>
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
<span>Settings</span>
|
||||
<span>{t("navigation.settings")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+130
-122
@@ -29,6 +29,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { API_PORT, fetchApi, getApiUrl, getAuthToken } from "@/lib/api-config"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
interface Backup {
|
||||
volid: string
|
||||
@@ -88,6 +89,7 @@ interface CombinedLogEntry {
|
||||
}
|
||||
|
||||
export function SystemLogs() {
|
||||
const t = useT()
|
||||
const [logs, setLogs] = useState<SystemLog[]>([])
|
||||
const [backups, setBackups] = useState<Backup[]>([])
|
||||
const [events, setEvents] = useState<Event[]>([])
|
||||
@@ -150,7 +152,7 @@ export function SystemLogs() {
|
||||
setLogsCounts(countsRes)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setError("Failed to connect to server")
|
||||
setError(t("systemLogs.errors.connectFailed"))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
@@ -177,7 +179,7 @@ export function SystemLogs() {
|
||||
const data = await fetchApi<{ logs?: SystemLog[] } | SystemLog[]>(apiUrl)
|
||||
return Array.isArray(data) ? data : data.logs || []
|
||||
} catch {
|
||||
setError("Failed to load logs. Please try again.")
|
||||
setError(t("systemLogs.errors.loadFailed"))
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -203,26 +205,26 @@ export function SystemLogs() {
|
||||
|
||||
// Generate log content
|
||||
const logContent = [
|
||||
`Proxmox System Logs & Events Export`,
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Total Entries: ${filteredCombinedLogs.length.toLocaleString()}`,
|
||||
t("systemLogs.export.title"),
|
||||
`${t("systemLogs.fields.generated")}: ${new Date().toISOString()}`,
|
||||
`${t("systemLogs.cards.totalEntries")}: ${filteredCombinedLogs.length.toLocaleString()}`,
|
||||
``,
|
||||
`Filters Applied:`,
|
||||
`- Date Range: ${dateFilter === "custom" ? `${customDays} days ago` : `${dateFilter} day(s) ago`}`,
|
||||
`- Level: ${levelFilter === "all" ? "All Levels" : levelFilter}`,
|
||||
`- Service: ${serviceFilter === "all" ? "All Services" : serviceFilter}`,
|
||||
`- Search: ${searchTerm || "None"}`,
|
||||
`${t("systemLogs.export.filtersApplied")}:`,
|
||||
`- ${t("systemLogs.filters.dateRange")}: ${t("systemLogs.filters.daysAgo", { count: dateFilter === "custom" ? customDays : dateFilter })}`,
|
||||
`- ${t("systemLogs.fields.level")}: ${levelFilter === "all" ? t("systemLogs.filters.allLevels") : levelLabel(levelFilter)}`,
|
||||
`- ${t("systemLogs.fields.service")}: ${serviceFilter === "all" ? t("systemLogs.filters.allServices") : serviceFilter}`,
|
||||
`- ${t("systemLogs.fields.search")}: ${searchTerm || t("systemLogs.fields.none")}`,
|
||||
``,
|
||||
`${"=".repeat(80)}`,
|
||||
``,
|
||||
...filteredCombinedLogs.map((log) => {
|
||||
const lines = [
|
||||
`[${log.timestamp}] ${log.level.toUpperCase()} - ${log.service}${log.isEvent ? " [EVENT]" : ""}`,
|
||||
`Message: ${log.message}`,
|
||||
`Source: ${log.source}`,
|
||||
`[${log.timestamp}] ${levelLabel(log.level)} - ${log.service}${log.isEvent ? ` [${t("systemLogs.badges.event")}]` : ""}`,
|
||||
`${t("systemLogs.fields.message")}: ${log.message}`,
|
||||
`${t("systemLogs.fields.source")}: ${log.source}`,
|
||||
]
|
||||
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)}`)
|
||||
return lines.join("\n")
|
||||
}),
|
||||
@@ -273,13 +275,13 @@ export function SystemLogs() {
|
||||
// Download the complete task log
|
||||
const blob = new Blob(
|
||||
[
|
||||
`Proxmox Task Log\n`,
|
||||
`${t("systemLogs.download.taskLog")}\n`,
|
||||
`================\n\n`,
|
||||
`UPID: ${upid}\n`,
|
||||
`Timestamp: ${notification.timestamp}\n`,
|
||||
`Service: ${notification.service}\n`,
|
||||
`Source: ${notification.source}\n\n`,
|
||||
`Complete Task Log:\n`,
|
||||
`${t("systemLogs.fields.timestamp")}: ${notification.timestamp}\n`,
|
||||
`${t("systemLogs.fields.service")}: ${notification.service}\n`,
|
||||
`${t("systemLogs.fields.source")}: ${notification.source}\n\n`,
|
||||
`${t("systemLogs.download.completeTaskLog")}:\n`,
|
||||
`${"-".repeat(80)}\n`,
|
||||
`${taskLog}\n`,
|
||||
],
|
||||
@@ -303,13 +305,13 @@ export function SystemLogs() {
|
||||
// If no UPID or failed to fetch task log, download the notification message
|
||||
const blob = new Blob(
|
||||
[
|
||||
`Notification Details\n`,
|
||||
`${t("systemLogs.modals.notificationTitle")}\n`,
|
||||
`==================\n\n`,
|
||||
`Timestamp: ${notification.timestamp}\n`,
|
||||
`Type: ${notification.type}\n`,
|
||||
`Service: ${notification.service}\n`,
|
||||
`Source: ${notification.source}\n\n`,
|
||||
`Complete Message:\n`,
|
||||
`${t("systemLogs.fields.timestamp")}: ${notification.timestamp}\n`,
|
||||
`${t("systemLogs.fields.type")}: ${notification.type}\n`,
|
||||
`${t("systemLogs.fields.service")}: ${notification.service}\n`,
|
||||
`${t("systemLogs.fields.source")}: ${notification.source}\n\n`,
|
||||
`${t("systemLogs.download.completeMessage")}:\n`,
|
||||
`${notification.message}\n`,
|
||||
],
|
||||
{ type: "text/plain" },
|
||||
@@ -342,7 +344,7 @@ export function SystemLogs() {
|
||||
level: event.level,
|
||||
service: event.type,
|
||||
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,
|
||||
eventData: event,
|
||||
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) => {
|
||||
switch (level) {
|
||||
case "error":
|
||||
@@ -551,15 +559,15 @@ export function SystemLogs() {
|
||||
const getSectionLabel = (section: string) => {
|
||||
switch (section) {
|
||||
case "logs":
|
||||
return "Logs"
|
||||
return t("systemLogs.tabs.logs")
|
||||
case "events":
|
||||
return "Events"
|
||||
return t("systemLogs.tabs.events")
|
||||
case "backups":
|
||||
return "Backups"
|
||||
return t("systemLogs.tabs.backups")
|
||||
case "notifications":
|
||||
return "Notifications"
|
||||
return t("systemLogs.tabs.notifications")
|
||||
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="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading logs...</div>
|
||||
<p className="text-xs text-muted-foreground">Fetching system logs and events</p>
|
||||
<div className="text-sm font-medium text-foreground">{t("systemLogs.loading.title")}</div>
|
||||
<p className="text-xs text-muted-foreground">{t("systemLogs.loading.description")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -585,7 +593,7 @@ export function SystemLogs() {
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
@@ -594,42 +602,42 @@ export function SystemLogs() {
|
||||
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4 xl:gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<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" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{(logsCounts?.total ?? 0).toLocaleString("fr-FR")}
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<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" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<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" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<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" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -645,11 +653,11 @@ export function SystemLogs() {
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Activity className="h-5 w-5 mr-2" />
|
||||
System Logs & Events
|
||||
{t("systemLogs.title")}
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={refreshData} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
{t("actions.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -658,18 +666,18 @@ export function SystemLogs() {
|
||||
<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">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
Logs
|
||||
{t("systemLogs.tabs.logs")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="backups" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
|
||||
<Database className="h-4 w-4 mr-2" />
|
||||
Backups
|
||||
{t("systemLogs.tabs.backups")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="notifications"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white"
|
||||
>
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
Notifications
|
||||
{t("systemLogs.tabs.notifications")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -691,7 +699,7 @@ export function SystemLogs() {
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[280px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Sections</SheetTitle>
|
||||
<SheetTitle>{t("systemLogs.sections")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Button
|
||||
@@ -707,7 +715,7 @@ export function SystemLogs() {
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
Logs
|
||||
{t("systemLogs.tabs.logs")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -722,7 +730,7 @@ export function SystemLogs() {
|
||||
}}
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
Backups
|
||||
{t("systemLogs.tabs.backups")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -737,7 +745,7 @@ export function SystemLogs() {
|
||||
}}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
Notifications
|
||||
{t("systemLogs.tabs.notifications")}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
@@ -751,7 +759,7 @@ export function SystemLogs() {
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search logs & events..."
|
||||
placeholder={t("systemLogs.filters.searchPlaceholder")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 bg-background border-border"
|
||||
@@ -761,22 +769,22 @@ export function SystemLogs() {
|
||||
|
||||
<Select value={dateFilter} onValueChange={setDateFilter}>
|
||||
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
||||
<SelectValue placeholder="Time range" />
|
||||
<SelectValue placeholder={t("systemLogs.filters.timeRange")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 day ago</SelectItem>
|
||||
<SelectItem value="3">3 days ago</SelectItem>
|
||||
<SelectItem value="7">1 week ago</SelectItem>
|
||||
<SelectItem value="14">2 weeks ago</SelectItem>
|
||||
<SelectItem value="30">1 month ago</SelectItem>
|
||||
<SelectItem value="custom">Custom days</SelectItem>
|
||||
<SelectItem value="1">{t("systemLogs.filters.oneDay")}</SelectItem>
|
||||
<SelectItem value="3">{t("systemLogs.filters.threeDays")}</SelectItem>
|
||||
<SelectItem value="7">{t("systemLogs.filters.oneWeek")}</SelectItem>
|
||||
<SelectItem value="14">{t("systemLogs.filters.twoWeeks")}</SelectItem>
|
||||
<SelectItem value="30">{t("systemLogs.filters.oneMonth")}</SelectItem>
|
||||
<SelectItem value="custom">{t("systemLogs.filters.customDays")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{dateFilter === "custom" && (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Days ago"
|
||||
placeholder={t("systemLogs.filters.daysAgoPlaceholder")}
|
||||
value={customDays}
|
||||
onChange={(e) => setCustomDays(e.target.value)}
|
||||
className="w-full sm:w-[120px] bg-background border-border"
|
||||
@@ -786,23 +794,23 @@ export function SystemLogs() {
|
||||
|
||||
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
||||
<SelectValue placeholder="Filter by level" />
|
||||
<SelectValue placeholder={t("systemLogs.filters.byLevel")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Levels</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
<SelectItem value="warning">Warning</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
<SelectItem value="all">{t("systemLogs.filters.allLevels")}</SelectItem>
|
||||
<SelectItem value="error">{t("systemLogs.levels.error")}</SelectItem>
|
||||
<SelectItem value="warning">{t("systemLogs.levels.warning")}</SelectItem>
|
||||
<SelectItem value="info">{t("systemLogs.levels.info")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={serviceFilter} onValueChange={setServiceFilter}>
|
||||
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
||||
<SelectValue placeholder="Filter by service" />
|
||||
<SelectValue placeholder={t("systemLogs.filters.byService")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem key="service-all" value="all">
|
||||
All Services
|
||||
{t("systemLogs.filters.allServices")}
|
||||
</SelectItem>
|
||||
{uniqueServices.map((service) => (
|
||||
<SelectItem key={`service-${service}`} value={service}>
|
||||
@@ -814,7 +822,7 @@ export function SystemLogs() {
|
||||
|
||||
<Button variant="outline" className="border-border bg-transparent" onClick={handleDownloadLogs}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export Logs
|
||||
{t("systemLogs.export.button")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -844,12 +852,12 @@ export function SystemLogs() {
|
||||
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className={getLevelColor(log.level)}>
|
||||
{getLevelIcon(log.level)}
|
||||
{log.level.toUpperCase()}
|
||||
{levelLabel(log.level)}
|
||||
</Badge>
|
||||
{log.eventData && (
|
||||
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
EVENT
|
||||
{t("systemLogs.badges.event")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -866,9 +874,9 @@ export function SystemLogs() {
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate overflow-hidden">
|
||||
{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.hostname && ` • Host: ${log.hostname}`}
|
||||
{log.hostname && ` • ${t("systemLogs.fields.host")}: ${log.hostname}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -878,7 +886,7 @@ export function SystemLogs() {
|
||||
{displayedLogs.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -890,7 +898,7 @@ export function SystemLogs() {
|
||||
className="border-border"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Load More ({filteredCombinedLogs.length - displayedLogsCount} remaining)
|
||||
{t("systemLogs.loadMore", { count: filteredCombinedLogs.length - displayedLogsCount })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -906,19 +914,19 @@ export function SystemLogs() {
|
||||
<Card className="bg-card/50 border-border">
|
||||
<CardContent className="pt-6">
|
||||
<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>
|
||||
</Card>
|
||||
<Card className="bg-card/50 border-border">
|
||||
<CardContent className="pt-6">
|
||||
<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>
|
||||
</Card>
|
||||
<Card className="bg-card/50 border-border hidden md:block">
|
||||
<CardContent className="pt-6">
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -927,7 +935,7 @@ export function SystemLogs() {
|
||||
<Card className="bg-card/50 border-border md:hidden">
|
||||
<CardContent className="pt-6">
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -967,7 +975,7 @@ export function SystemLogs() {
|
||||
{backup.size_human}
|
||||
</Badge>
|
||||
</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">
|
||||
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
|
||||
<span className="truncate">{backup.created}</span>
|
||||
@@ -980,7 +988,7 @@ export function SystemLogs() {
|
||||
{backups.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<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>
|
||||
@@ -1006,12 +1014,12 @@ export function SystemLogs() {
|
||||
>
|
||||
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
|
||||
{(notification.type || "unknown").toUpperCase()}
|
||||
{notification.type ? levelLabel(notification.type) : t("app.unknown")}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
|
||||
{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 || "unknown").toUpperCase()}
|
||||
{notification.source ? notification.source.toUpperCase() : t("app.unknown")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -1026,7 +1034,7 @@ export function SystemLogs() {
|
||||
{notification.message}
|
||||
</div>
|
||||
<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>
|
||||
@@ -1036,7 +1044,7 @@ export function SystemLogs() {
|
||||
{notifications.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<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>
|
||||
@@ -1051,55 +1059,55 @@ export function SystemLogs() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Log Details
|
||||
{t("systemLogs.modals.logTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Complete information about this log entry</DialogDescription>
|
||||
<DialogDescription>{t("systemLogs.modals.logDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedLog && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<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)}>
|
||||
{getLevelIcon(selectedLog.level)}
|
||||
{selectedLog.level.toUpperCase()}
|
||||
{levelLabel(selectedLog.level)}
|
||||
</Badge>
|
||||
</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>
|
||||
<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">
|
||||
{selectedLog.timestamp}
|
||||
</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>
|
||||
{selectedLog.unit && (
|
||||
<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>
|
||||
)}
|
||||
{selectedLog.pid && (
|
||||
<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>
|
||||
)}
|
||||
{selectedLog.hostname && (
|
||||
<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>
|
||||
)}
|
||||
</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">
|
||||
<pre className="text-sm text-foreground whitespace-pre-wrap break-all overflow-hidden">
|
||||
{selectedLog.message}
|
||||
@@ -1116,37 +1124,37 @@ export function SystemLogs() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
Event Details
|
||||
{t("systemLogs.modals.eventTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Complete information about this event</DialogDescription>
|
||||
<DialogDescription>{t("systemLogs.modals.eventDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedEvent && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline" className={getLevelColor(selectedEvent.level)}>
|
||||
{getLevelIcon(selectedEvent.level)}
|
||||
{selectedEvent.level.toUpperCase()}
|
||||
{levelLabel(selectedEvent.level)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
EVENT
|
||||
{t("systemLogs.badges.event")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
{selectedEvent.vmid && (
|
||||
@@ -1156,15 +1164,15 @@ export function SystemLogs() {
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -1186,31 +1194,31 @@ export function SystemLogs() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Backup Details
|
||||
{t("systemLogs.modals.backupTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Complete information about this backup</DialogDescription>
|
||||
<DialogDescription>{t("systemLogs.modals.backupDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedBackup && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<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)}>
|
||||
{getBackupTypeLabel(selectedBackup.volid)}
|
||||
</Badge>
|
||||
</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)}>
|
||||
{getBackupStorageLabel(selectedBackup.volid)}
|
||||
</Badge>
|
||||
</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>
|
||||
<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">
|
||||
{selectedBackup.size_human}
|
||||
</Badge>
|
||||
@@ -1222,12 +1230,12 @@ export function SystemLogs() {
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
</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">
|
||||
<pre className="text-sm text-foreground font-mono whitespace-pre-wrap break-all">
|
||||
{selectedBackup.volid}
|
||||
@@ -1244,38 +1252,38 @@ export function SystemLogs() {
|
||||
<DialogHeader>
|
||||
<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" />
|
||||
<span className="truncate">Notification Details</span>
|
||||
<span className="truncate">{t("systemLogs.modals.notificationTitle")}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
Complete information about this notification
|
||||
{t("systemLogs.modals.notificationDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedNotification && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
||||
<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`}>
|
||||
{(selectedNotification.type || "unknown").toUpperCase()}
|
||||
{selectedNotification.type ? levelLabel(selectedNotification.type) : t("app.unknown")}
|
||||
</Badge>
|
||||
</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">
|
||||
{selectedNotification.timestamp}
|
||||
</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>
|
||||
<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>
|
||||
</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">
|
||||
<pre className="text-xs sm:text-sm text-foreground whitespace-pre-wrap break-all font-mono">
|
||||
{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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { Area, AreaChart, ResponsiveContainer } from "recharts"
|
||||
|
||||
interface TempDataPoint {
|
||||
@@ -171,6 +172,7 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
|
||||
}
|
||||
|
||||
export function SystemOverview() {
|
||||
const t = useT()
|
||||
const [systemData, setSystemData] = useState<SystemData | null>(null)
|
||||
const [vmData, setVmData] = useState<VMData[]>([])
|
||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||
@@ -205,7 +207,7 @@ export function SystemOverview() {
|
||||
setHasAttemptedLoad(true)
|
||||
|
||||
if (!systemResult) {
|
||||
setError("Flask server not available. Please ensure the server is running.")
|
||||
setError(t("overview.errors.serverUnavailableDescription"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -261,7 +263,7 @@ export function SystemOverview() {
|
||||
clearInterval(networkInterval)
|
||||
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
|
||||
}
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
if (!hasAttemptedLoad || loadingStates.system) {
|
||||
return (
|
||||
@@ -270,8 +272,8 @@ export function SystemOverview() {
|
||||
<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>
|
||||
<div className="text-sm font-medium text-foreground">Loading system overview...</div>
|
||||
<p className="text-xs text-muted-foreground">Fetching system status and metrics</p>
|
||||
<div className="text-sm font-medium text-foreground">{t("overview.loadingTitle")}</div>
|
||||
<p className="text-xs text-muted-foreground">{t("overview.loadingDescription")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -284,9 +286,9 @@ export function SystemOverview() {
|
||||
<div className="flex items-center gap-3 text-red-600">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
<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">
|
||||
{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>
|
||||
@@ -305,14 +307,14 @@ export function SystemOverview() {
|
||||
}
|
||||
|
||||
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 < 60) return { 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" }
|
||||
return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-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: t("status.normal"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
if (temp < 75) return { status: t("status.warm"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||
return { status: t("status.hot"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||
}
|
||||
|
||||
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 hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
@@ -322,6 +324,19 @@ export function SystemOverview() {
|
||||
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) => {
|
||||
return (bytes / 1024 ** 3).toFixed(2)
|
||||
}
|
||||
@@ -346,40 +361,14 @@ export function SystemOverview() {
|
||||
|
||||
const getLoadStatus = (load: number, cores: number) => {
|
||||
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) {
|
||||
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 {
|
||||
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 getTimeframeLabel = (timeframe: string): string => {
|
||||
@@ -406,10 +395,10 @@ export function SystemOverview() {
|
||||
<Card
|
||||
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
|
||||
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">
|
||||
<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">
|
||||
<Cpu className="h-4 w-4" />
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
@@ -436,7 +425,7 @@ export function SystemOverview() {
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
@@ -444,7 +433,7 @@ export function SystemOverview() {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -456,10 +445,10 @@ export function SystemOverview() {
|
||||
<Card
|
||||
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
|
||||
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">
|
||||
<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">
|
||||
<MemoryStick className="h-4 w-4" />
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
@@ -486,7 +475,7 @@ export function SystemOverview() {
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
@@ -494,7 +483,7 @@ export function SystemOverview() {
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -505,7 +494,7 @@ export function SystemOverview() {
|
||||
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
|
||||
<Card className="bg-card border-border">
|
||||
<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" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -521,13 +510,19 @@ export function SystemOverview() {
|
||||
<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>
|
||||
</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 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>
|
||||
{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>
|
||||
</>
|
||||
@@ -540,7 +535,7 @@ export function SystemOverview() {
|
||||
onClick={() => systemData.temperature > 0 && setTempModalOpen(true)}
|
||||
>
|
||||
<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">
|
||||
<Thermometer className="h-4 w-4" />
|
||||
{systemData.temperature > 0 && (
|
||||
@@ -551,7 +546,7 @@ export function SystemOverview() {
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<Badge variant="outline" className={`${tempStatus.color}`}>
|
||||
{tempStatus.status}
|
||||
@@ -581,7 +576,7 @@ export function SystemOverview() {
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -613,7 +608,7 @@ export function SystemOverview() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<HardDrive className="h-5 w-5 mr-2" />
|
||||
Storage Overview
|
||||
{t("overview.storageOverview")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -634,7 +629,7 @@ export function SystemOverview() {
|
||||
return totalCapacity > 0 ? (
|
||||
<div className="space-y-2 pb-4 border-b-2 border-border">
|
||||
<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">
|
||||
{formatStorage(totalCapacity)}
|
||||
</span>
|
||||
@@ -646,13 +641,13 @@ export function SystemOverview() {
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Used:{" "}
|
||||
{t("overview.used")}:{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatStorage(totalUsed)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Free:{" "}
|
||||
{t("overview.free")}:{" "}
|
||||
<span className="font-semibold text-green-500">
|
||||
{formatStorage(totalAvailable)}
|
||||
</span>
|
||||
@@ -666,28 +661,28 @@ export function SystemOverview() {
|
||||
|
||||
<div className="space-y-2 pb-3 border-b border-border">
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
{storageData.disk_count} disk{storageData.disk_count !== 1 ? "s" : ""}
|
||||
{storageData.disk_count} {storageData.disk_count === 1 ? t("overview.diskSingular") : t("overview.diskPlural")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vmLxcStorages && vmLxcStorages.length > 0 ? (
|
||||
<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">
|
||||
<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">
|
||||
{formatStorage(vmLxcStorageUsed)}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
{formatStorage(vmLxcStorageAvailable)}
|
||||
</span>
|
||||
@@ -702,28 +697,28 @@ export function SystemOverview() {
|
||||
</div>
|
||||
{vmLxcStorages.length > 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 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-center py-4 text-muted-foreground text-sm">No VM/LXC storage configured</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">{t("overview.noVmLxcStorage")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localStorage && (
|
||||
<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">
|
||||
<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">
|
||||
{formatStorage(localStorage.used)}
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
{formatStorage(localStorage.available)}
|
||||
</span>
|
||||
@@ -740,7 +735,7 @@ export function SystemOverview() {
|
||||
)}
|
||||
</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>
|
||||
</Card>
|
||||
@@ -750,18 +745,18 @@ export function SystemOverview() {
|
||||
<CardTitle className="text-foreground flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Network className="h-5 w-5 mr-2" />
|
||||
Network Overview
|
||||
{t("overview.networkOverview")}
|
||||
</div>
|
||||
<Select value={networkTimeframe} onValueChange={setNetworkTimeframe}>
|
||||
<SelectTrigger className="w-28 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hour">1 Hour</SelectItem>
|
||||
<SelectItem value="day">24 Hours</SelectItem>
|
||||
<SelectItem value="week">7 Days</SelectItem>
|
||||
<SelectItem value="month">30 Days</SelectItem>
|
||||
<SelectItem value="year">1 Year</SelectItem>
|
||||
<SelectItem value="hour">{t("overview.timeframes.hour")}</SelectItem>
|
||||
<SelectItem value="day">{t("overview.timeframes.day")}</SelectItem>
|
||||
<SelectItem value="week">{t("overview.timeframes.week")}</SelectItem>
|
||||
<SelectItem value="month">{t("overview.timeframes.month")}</SelectItem>
|
||||
<SelectItem value="year">{t("overview.timeframes.year")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardTitle>
|
||||
@@ -776,7 +771,7 @@ export function SystemOverview() {
|
||||
) : networkData ? (
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
{(networkData.physical_active_count || 0) + (networkData.bridge_active_count || 0)}
|
||||
</span>
|
||||
@@ -818,7 +813,7 @@ export function SystemOverview() {
|
||||
|
||||
<div className="pt-2 border-t border-border space-y-2">
|
||||
<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">
|
||||
↓{" "}
|
||||
{networkUnit === "Bytes"
|
||||
@@ -828,7 +823,7 @@ export function SystemOverview() {
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
↑{" "}
|
||||
{networkUnit === "Bytes"
|
||||
@@ -848,7 +843,7 @@ export function SystemOverview() {
|
||||
</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>
|
||||
</Card>
|
||||
@@ -859,27 +854,27 @@ export function SystemOverview() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Server className="h-5 w-5 mr-2" />
|
||||
System Information
|
||||
{t("overview.systemInformation")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Uptime:</span>
|
||||
<span className="text-foreground">{systemData.uptime}</span>
|
||||
<span className="text-muted-foreground">{t("overview.uptime")}</span>
|
||||
<span className="text-foreground">{formatSystemUptime(systemData.uptime)}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
{systemData.available_updates !== undefined && systemData.available_updates > 0 && (
|
||||
<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">
|
||||
{systemData.available_updates} packages
|
||||
{systemData.available_updates} {t("overview.packages")}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
@@ -890,13 +885,13 @@ export function SystemOverview() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Zap className="h-5 w-5 mr-2" />
|
||||
System Overview
|
||||
{t("overview.systemOverview")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||
<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 className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold text-foreground font-mono">
|
||||
@@ -909,17 +904,17 @@ export function SystemOverview() {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || "N/A"}
|
||||
</span>
|
||||
|
||||
@@ -8,12 +8,13 @@ import { Thermometer, TrendingDown, TrendingUp, Minus } from "lucide-react"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||
import { useIsMobile } from "../hooks/use-mobile"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: "hour", label: "1 Hour" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "month", label: "30 Days" },
|
||||
{ value: "hour", labelKey: "overview.timeframes.hour" },
|
||||
{ value: "day", labelKey: "overview.timeframes.day" },
|
||||
{ value: "week", labelKey: "overview.timeframes.week" },
|
||||
{ value: "month", labelKey: "overview.timeframes.month" },
|
||||
]
|
||||
|
||||
interface TempHistoryPoint {
|
||||
@@ -70,6 +71,7 @@ const getStatusInfo = (temp: number) => {
|
||||
}
|
||||
|
||||
export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }: TemperatureDetailModalProps) {
|
||||
const t = useT()
|
||||
// Default to 24 h — matches the disk temperature modal and is the
|
||||
// useful timeframe for spotting trends; the 1-h view rarely tells
|
||||
// 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">
|
||||
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||
<Thermometer className="h-5 w-5" />
|
||||
CPU Temperature
|
||||
{t("details.temperature.title")}
|
||||
</DialogTitle>
|
||||
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||
<SelectTrigger className="w-[130px] bg-card border-border">
|
||||
@@ -147,7 +149,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{t(opt.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -158,24 +160,24 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
||||
{/* Stats bar */}
|
||||
<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="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>
|
||||
<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">
|
||||
<TrendingDown className="h-3 w-3" /> Min
|
||||
<TrendingDown className="h-3 w-3" /> {t("details.temperature.min")}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
|
||||
</div>
|
||||
<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">
|
||||
<Minus className="h-3 w-3" /> Avg
|
||||
<Minus className="h-3 w-3" /> {t("details.temperature.avg")}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
|
||||
</div>
|
||||
<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">
|
||||
<TrendingUp className="h-3 w-3" /> Max
|
||||
<TrendingUp className="h-3 w-3" /> {t("details.temperature.max")}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-red-500">{stats.max}°C</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="text-center">
|
||||
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No temperature data available for this period</p>
|
||||
<p className="text-sm mt-1">Data is collected every 60 seconds</p>
|
||||
<p>{t("details.temperature.noData")}</p>
|
||||
<p className="text-sm mt-1">{t("details.temperature.collectionHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -228,7 +230,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
name="Temperature"
|
||||
name={t("details.temperature.seriesName")}
|
||||
stroke={chartColor}
|
||||
strokeWidth={2}
|
||||
fill="url(#tempGradient)"
|
||||
|
||||
@@ -34,6 +34,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||
import type { CheatSheetResult } from "@/lib/cheat-sheet-result" // Declare CheatSheetResult here
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
|
||||
type TerminalPanelProps = {
|
||||
websocketUrl?: string
|
||||
@@ -78,74 +79,30 @@ function getApiUrl(endpoint?: string): string {
|
||||
}
|
||||
|
||||
const proxmoxCommands = [
|
||||
{ cmd: "pvesh get /nodes", desc: "List all Proxmox nodes" },
|
||||
{ cmd: "pvesh get /nodes/{node}/qemu", desc: "List VMs on a node" },
|
||||
{ cmd: "pvesh get /nodes/{node}/lxc", desc: "List LXC containers on a node" },
|
||||
{ cmd: "pvesh get /nodes/{node}/storage", desc: "List storage on a node" },
|
||||
{ cmd: "pvesh get /nodes/{node}/network", desc: "List network interfaces" },
|
||||
{ cmd: "qm list", desc: "List all QEMU/KVM virtual machines" },
|
||||
{ cmd: "qm start <vmid>", desc: "Start a virtual machine" },
|
||||
{ cmd: "qm stop <vmid>", desc: "Stop a virtual machine" },
|
||||
{ cmd: "qm shutdown <vmid>", desc: "Shutdown a virtual machine gracefully" },
|
||||
{ cmd: "qm status <vmid>", desc: "Show VM status" },
|
||||
{ cmd: "qm config <vmid>", desc: "Show VM configuration" },
|
||||
{ cmd: "qm snapshot <vmid> <snapname>", desc: "Create VM snapshot" },
|
||||
{ cmd: "pct list", desc: "List all LXC containers" },
|
||||
{ cmd: "pct start <vmid>", desc: "Start LXC container" },
|
||||
{ cmd: "pct stop <vmid>", desc: "Stop LXC container" },
|
||||
{ cmd: "pct enter <vmid>", desc: "Enter LXC container console" },
|
||||
{ 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" },
|
||||
"pvesh get /nodes", "pvesh get /nodes/{node}/qemu", "pvesh get /nodes/{node}/lxc",
|
||||
"pvesh get /nodes/{node}/storage", "pvesh get /nodes/{node}/network", "qm list",
|
||||
"qm start <vmid>", "qm stop <vmid>", "qm shutdown <vmid>", "qm status <vmid>",
|
||||
"qm config <vmid>", "qm snapshot <vmid> <snapname>", "pct list", "pct start <vmid>",
|
||||
"pct stop <vmid>", "pct enter <vmid>", "pct config <vmid>", "pvesm status",
|
||||
"pvesm list <storage>", "pveperf", "pveversion", "systemctl status pve-cluster",
|
||||
"pvecm status", "pvecm nodes", "zpool status", "zpool list", "zfs list", "ls -la",
|
||||
"cd /path/to/dir", "mkdir dirname", "rm -rf dirname", "cp source dest", "mv source dest",
|
||||
"cat filename", "grep 'pattern' file", "find . -name 'file'", "chmod 755 file",
|
||||
"chown user:group file", "tar -xzf file.tar.gz", "tar -czf archive.tar.gz dir/", "df -h",
|
||||
"du -sh *", "free -h", "top", "ps aux | grep process", "kill -9 PID",
|
||||
"systemctl status service", "systemctl start service", "systemctl stop service",
|
||||
"systemctl restart service", "apt update && apt upgrade", "apt install package",
|
||||
"apt remove package", "docker ps", "docker images", "docker exec -it container bash",
|
||||
"ip addr show", "ping host", "curl -I url", "wget url", "ssh user@host",
|
||||
"scp file user@host:/path", "tail -f /var/log/syslog", "history", "clear",
|
||||
]
|
||||
|
||||
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 [activeTerminalId, setActiveTerminalId] = useState<string>("")
|
||||
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 [searchModalOpen, setSearchModalOpen] = useState(false)
|
||||
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 [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
||||
const [useOnline, setUseOnline] = useState(true)
|
||||
@@ -272,7 +229,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
const searchCheatSh = async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([])
|
||||
setFilteredCommands(proxmoxCommands)
|
||||
setFilteredCommands(localizedCommands)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -287,7 +244,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
})
|
||||
|
||||
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)
|
||||
setSearchResults(formattedResults)
|
||||
} catch (error) {
|
||||
const filtered = proxmoxCommands.filter(
|
||||
const filtered = localizedCommands.filter(
|
||||
(item) =>
|
||||
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
||||
item.desc.toLowerCase().includes(query.toLowerCase()),
|
||||
@@ -318,12 +275,12 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
searchCheatSh(searchQuery)
|
||||
} else {
|
||||
setSearchResults([])
|
||||
setFilteredCommands(proxmoxCommands)
|
||||
setFilteredCommands(localizedCommands)
|
||||
}
|
||||
}, 800)
|
||||
|
||||
return () => clearTimeout(debounce)
|
||||
}, [searchQuery])
|
||||
}, [searchQuery, t])
|
||||
|
||||
// Function to reconnect a terminal when connection is lost
|
||||
// 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
|
||||
|
||||
// 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()
|
||||
// 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) =>
|
||||
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
|
||||
if (terminal.fitAddon) {
|
||||
@@ -384,7 +341,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
@@ -397,7 +354,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
}
|
||||
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) => {
|
||||
@@ -415,7 +372,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
...prev,
|
||||
{
|
||||
id: newId,
|
||||
title: `Terminal ${prev.length + 1}`,
|
||||
title: t("terminal.terminalTitle", { number: prev.length + 1 }),
|
||||
term: null,
|
||||
ws: null,
|
||||
isConnected: false,
|
||||
@@ -570,8 +527,8 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
connectionTimedOut = true
|
||||
ws.close()
|
||||
term.writeln('\x1b[31m[ERROR] Connection timeout. Please check your network and try again.\x1b[0m')
|
||||
term.writeln('\x1b[33m[TIP] If using VPN, ensure the connection is stable.\x1b[0m')
|
||||
term.writeln(`\x1b[31m[ERROR] ${t("terminal.connectionTimeout")}\x1b[0m`)
|
||||
term.writeln(`\x1b[33m[TIP] ${t("terminal.vpnTip")}\x1b[0m`)
|
||||
}
|
||||
}, connectionTimeout)
|
||||
|
||||
@@ -636,7 +593,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
||||
}))
|
||||
// Only show error if not already shown by timeout
|
||||
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
|
||||
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" />
|
||||
<div
|
||||
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>
|
||||
<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 className="flex gap-2">
|
||||
@@ -829,7 +786,7 @@ const handleClose = () => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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" />
|
||||
</Button>
|
||||
@@ -838,7 +795,7 @@ const handleClose = () => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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" />
|
||||
</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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">New</span>
|
||||
<span className="hidden sm:inline">{t("terminal.new")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Search</span>
|
||||
<span className="hidden sm:inline">{t("terminal.search")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Clear</span>
|
||||
<span className="hidden sm:inline">{t("terminal.clear")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Close</span>
|
||||
<span className="hidden sm:inline">{t("actions.close")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1075,29 +1032,29 @@ const handleClose = () => {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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 />
|
||||
<DropdownMenuItem onSelect={() => sendSequence("\x03")}>
|
||||
<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 onSelect={() => sendSequence("\x18")}>
|
||||
<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 onSelect={() => sendSequence("\x12")}>
|
||||
<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>
|
||||
<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() }}>
|
||||
<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 onSelect={() => { void handlePaste() }}>
|
||||
<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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -1107,22 +1064,22 @@ const handleClose = () => {
|
||||
<Dialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
||||
<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">
|
||||
<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={`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>
|
||||
</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="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Search commands... (e.g., tar, docker, qm, systemctl)"
|
||||
placeholder={t("terminal.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
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"
|
||||
@@ -1136,7 +1093,7 @@ const handleClose = () => {
|
||||
{isSearching && (
|
||||
<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" />
|
||||
<p className="text-sm">Searching cheat.sh...</p>
|
||||
<p className="text-sm">{t("terminal.searchingCheatSh")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1164,7 +1121,7 @@ const handleClose = () => {
|
||||
<div className="text-center py-2">
|
||||
<p className="text-xs text-zinc-500">
|
||||
<Lightbulb className="inline-block w-3 h-3 mr-1" />
|
||||
Powered by cheat.sh
|
||||
{t("terminal.poweredByCheatSh")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -1190,13 +1147,13 @@ const handleClose = () => {
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Send
|
||||
{t("terminal.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : !isSearching && !searchQuery && !useOnline ? (
|
||||
proxmoxCommands.map((item, index) => (
|
||||
localizedCommands.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => sendToActiveTerminal(item.cmd)}
|
||||
@@ -1217,7 +1174,7 @@ const handleClose = () => {
|
||||
className="shrink-0 h-7 px-2 text-xs"
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Send
|
||||
{t("terminal.send")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1228,17 +1185,17 @@ const handleClose = () => {
|
||||
<>
|
||||
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||
<div>
|
||||
<p className="text-zinc-400 font-medium">No results found for "{searchQuery}"</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
|
||||
<p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||
<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">
|
||||
<p>Try searching for:</p>
|
||||
<p>{t("terminal.trySearchingFor")}</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||
{["tar", "grep", "docker", "qm", "systemctl"].map((cmd) => (
|
||||
<code
|
||||
@@ -1255,7 +1212,7 @@ const handleClose = () => {
|
||||
{useOnline && (
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
|
||||
<Lightbulb className="w-3 h-3" />
|
||||
<span>Powered by cheat.sh</span>
|
||||
<span>{t("terminal.poweredByCheatSh")}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -1267,9 +1224,9 @@ const handleClose = () => {
|
||||
<div className="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="w-3 h-3" />
|
||||
<span>Tip: Search for any Linux command or Proxmox commands (qm, pct, zpool)</span>
|
||||
<span>{t("terminal.searchTip")}</span>
|
||||
</div>
|
||||
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">Powered by cheat.sh</span>}
|
||||
{useOnline && searchResults.length > 0 && <span className="text-zinc-600">{t("terminal.poweredByCheatSh")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useTheme } from "next-themes"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Button } from "./ui/button"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const t = useT()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
@@ -22,7 +24,7 @@ export function ThemeToggle() {
|
||||
return (
|
||||
<Button variant="outline" size="sm" className="border-border bg-transparent w-9 h-9">
|
||||
<Sun className="h-4 w-4" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
<span className="sr-only">{t("actions.toggleTheme")}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -31,7 +33,7 @@ export function ThemeToggle() {
|
||||
<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" />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Input } from "./ui/input"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface TwoFactorSetupProps {
|
||||
open: boolean
|
||||
@@ -14,6 +15,8 @@ interface 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 [qrCode, setQrCode] = useState("")
|
||||
const [secret, setSecret] = useState("")
|
||||
@@ -41,7 +44,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Failed to setup 2FA")
|
||||
throw new Error(data.message || tf("setupFailed"))
|
||||
}
|
||||
|
||||
setQrCode(data.qr_code)
|
||||
@@ -49,7 +52,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
setBackupCodes(data.backup_codes)
|
||||
setStep(2)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to setup 2FA")
|
||||
setError(err instanceof Error ? err.message : tf("setupFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -57,7 +60,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!verificationCode || verificationCode.length !== 6) {
|
||||
setError("Please enter a 6-digit code")
|
||||
setError(tf("enterSixDigitCode"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,12 +81,12 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Invalid verification code")
|
||||
throw new Error(data.message || tf("invalidCode"))
|
||||
}
|
||||
|
||||
setStep(3)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Verification failed")
|
||||
setError(err instanceof Error ? err.message : tf("verificationFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -141,7 +144,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
// both the Clipboard API and execCommand may be locked down.
|
||||
if (!ok) {
|
||||
try {
|
||||
window.prompt("Copy this value:", text)
|
||||
window.prompt(tf("copyPrompt"), text)
|
||||
ok = true
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -183,9 +186,9 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-blue-500" />
|
||||
Setup Two-Factor Authentication
|
||||
{tf("title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Add an extra layer of security to your account</DialogDescription>
|
||||
<DialogDescription>{tf("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
@@ -199,22 +202,21 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-500">
|
||||
Two-factor authentication (2FA) adds an extra layer of security by requiring a code from your
|
||||
authentication app in addition to your password.
|
||||
{tf("intro")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<li>An authentication app (Google Authenticator, Authy, etc.)</li>
|
||||
<li>Scan a QR code or enter a key manually</li>
|
||||
<li>Store backup codes securely</li>
|
||||
<li>{tf("needApp")}</li>
|
||||
<li>{tf("needQrOrKey")}</li>
|
||||
<li>{tf("needBackupCodes")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -222,24 +224,24 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">1. Scan the QR code</h4>
|
||||
<p className="text-sm text-muted-foreground">Open your authentication app and scan this QR code</p>
|
||||
<h4 className="font-medium">{tf("scanTitle")}</h4>
|
||||
<p className="text-sm text-muted-foreground">{tf("scanDescription")}</p>
|
||||
{qrCode && (
|
||||
<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 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">
|
||||
<Input value={secret} readOnly className="font-mono text-sm" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
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" />}
|
||||
</Button>
|
||||
@@ -247,8 +249,8 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium">2. Enter the verification code</h4>
|
||||
<p className="text-sm text-muted-foreground">Enter the 6-digit code that appears in your app</p>
|
||||
<h4 className="font-medium">{tf("verifyTitle")}</h4>
|
||||
<p className="text-sm text-muted-foreground">{tf("verifyDescription")}</p>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
@@ -262,10 +264,10 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
|
||||
<div className="flex gap-2">
|
||||
<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 onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
|
||||
Cancel
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</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">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
<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">
|
||||
Your account is now protected with two-factor authentication
|
||||
{tf("enabledDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
These codes will allow you to access your account if you lose access to your authentication app. Store
|
||||
them in a safe place.
|
||||
{tf("saveCodesDescription")}
|
||||
</p>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-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")}>
|
||||
{copiedCodes ? (
|
||||
<Check className="h-4 w-4 text-green-500 mr-2" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Copy All
|
||||
{tf("copyAll")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -313,7 +314,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
</div>
|
||||
|
||||
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
|
||||
Finish
|
||||
{tf("finish")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
@@ -34,28 +35,32 @@ const DialogContent = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
hideClose?: boolean
|
||||
}
|
||||
>(({ className, children, hideClose, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
className,
|
||||
)}
|
||||
aria-describedby={props["aria-describedby"] || undefined}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!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">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
>(({ className, children, hideClose, ...props }, ref) => {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
className,
|
||||
)}
|
||||
aria-describedby={props["aria-describedby"] || undefined}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!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">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{t("actions.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
})
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
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
|
||||
// same visual weight as the colored card selectors used elsewhere
|
||||
// (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,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = DialogPrimitive.Root
|
||||
@@ -54,18 +55,22 @@ interface SheetContentProps
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, SheetContentProps>(
|
||||
({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{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">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
),
|
||||
({ side = "right", className, children, ...props }, ref) => {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{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">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{t("actions.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
},
|
||||
)
|
||||
SheetContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
return (
|
||||
<textarea
|
||||
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
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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: "sk", englishName: "Slovak", nativeName: "Slovenčina", status: "complete" },
|
||||
{ code: "es", englishName: "Spanish", nativeName: "Español", status: "needs-translation" },
|
||||
{ code: "fr", englishName: "French", nativeName: "Français", status: "needs-translation" },
|
||||
{ code: "de", englishName: "German", nativeName: "Deutsch", status: "needs-translation" },
|
||||
{ code: "it", englishName: "Italian", nativeName: "Italiano", status: "needs-translation" },
|
||||
{ code: "pt", englishName: "Portuguese", nativeName: "Português", status: "needs-translation" },
|
||||
{ code: "sv", englishName: "Swedish", nativeName: "Svenska", status: "needs-translation" },
|
||||
]
|
||||
|
||||
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,133 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
|
||||
import enMessages from "../../messages/en/common.json"
|
||||
import skMessages from "../../messages/sk/common.json"
|
||||
import esMessages from "../../messages/es/common.json"
|
||||
import frMessages from "../../messages/fr/common.json"
|
||||
import deMessages from "../../messages/de/common.json"
|
||||
import itMessages from "../../messages/it/common.json"
|
||||
import ptMessages from "../../messages/pt/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,
|
||||
sk: skMessages as MessageTree,
|
||||
es: esMessages as MessageTree,
|
||||
fr: frMessages as MessageTree,
|
||||
de: deMessages as MessageTree,
|
||||
it: itMessages as MessageTree,
|
||||
pt: ptMessages 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,12 @@
|
||||
# Monitor dashboard translations
|
||||
|
||||
The ProxMenux Monitor dashboard uses a small client-side i18n layer.
|
||||
|
||||
- English (`en`) is the source language and the fallback.
|
||||
- Slovak (`sk`) is complete.
|
||||
- Spanish, French, German, Italian and Portuguese are registered as
|
||||
community translation targets and currently fall back to English.
|
||||
|
||||
To add or improve a translation, copy the matching keys from
|
||||
`messages/en/common.json` into your locale's `common.json` file and
|
||||
translate only the values. Keep placeholders such as `{uptime}` unchanged.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values."
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values. Placeholders like {vmid} must stay unchanged."
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ProxMenux-Monitor",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.4.1-beta",
|
||||
"description": "Proxmox System Monitoring Dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -653,12 +653,13 @@ def setup_auth(username, password):
|
||||
Set up authentication with username and password
|
||||
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
|
||||
# the existing admin credentials and take over the account. See audit
|
||||
# Tier 1 #4.
|
||||
# the existing admin credentials and take over the account. A declined
|
||||
# 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()
|
||||
if existing.get("configured", False):
|
||||
if existing.get("username") and existing.get("password_hash"):
|
||||
return False, "Authentication is already configured"
|
||||
|
||||
if not username or not password:
|
||||
@@ -668,7 +669,7 @@ def setup_auth(username, password):
|
||||
if pw_err:
|
||||
return False, pw_err
|
||||
|
||||
config = {
|
||||
existing.update({
|
||||
"enabled": True,
|
||||
"username": username,
|
||||
"password_hash": hash_password(password),
|
||||
@@ -677,9 +678,9 @@ def setup_auth(username, password):
|
||||
"totp_enabled": False,
|
||||
"totp_secret": None,
|
||||
"backup_codes": []
|
||||
}
|
||||
})
|
||||
|
||||
if save_auth_config(config):
|
||||
if save_auth_config(existing):
|
||||
return True, "Authentication configured successfully"
|
||||
else:
|
||||
return False, "Failed to save authentication configuration"
|
||||
|
||||
@@ -126,6 +126,8 @@ cp "$SCRIPT_DIR/lxc_mount_points.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
|
||||
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.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/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
|
||||
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/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_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"
|
||||
|
||||
@@ -1548,11 +1548,15 @@ def proxmox_webhook():
|
||||
return _reject(400, 'missing_title', 400)
|
||||
if not isinstance(message, str):
|
||||
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:
|
||||
payload['title'] = title[:256]
|
||||
if len(message) > 4096:
|
||||
payload['message'] = message[:4096]
|
||||
# Severity normalisation: accept the canonical set, default to 'info'.
|
||||
sev = (payload.get('severity') or '').lower()
|
||||
if sev not in {'info', 'warning', 'critical', 'error', 'notice'}:
|
||||
|
||||
@@ -5,6 +5,8 @@ ProxMenux Security Routes
|
||||
Flask blueprint for firewall management and security tool detection.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from jwt_middleware import require_auth
|
||||
|
||||
@@ -234,6 +236,80 @@ def fail2ban_jail_config():
|
||||
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'])
|
||||
@require_auth
|
||||
def fail2ban_apply_jails():
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -478,6 +478,15 @@ def script_websocket(ws, session_id):
|
||||
preexec_fn=os.setsid,
|
||||
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
|
||||
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
||||
@@ -573,6 +582,13 @@ def script_websocket(ws, session_id):
|
||||
|
||||
try:
|
||||
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:
|
||||
pass
|
||||
|
||||
@@ -581,10 +597,16 @@ def script_websocket(ws, session_id):
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = ws.receive(timeout=None)
|
||||
data = ws.receive(timeout=0.25)
|
||||
|
||||
if data is None:
|
||||
break
|
||||
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
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = json.loads(data)
|
||||
@@ -625,6 +647,14 @@ def script_websocket(ws, session_id):
|
||||
break
|
||||
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
@@ -644,10 +674,11 @@ def script_websocket(ws, session_id):
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.close(slave_fd)
|
||||
except:
|
||||
pass
|
||||
if slave_fd is not None:
|
||||
try:
|
||||
os.close(slave_fd)
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.close(web_log_fd)
|
||||
|
||||
@@ -964,6 +964,20 @@ class HealthMonitor:
|
||||
elif lxc_disk_result.get('status') == 'WARNING':
|
||||
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
|
||||
# ones. Each is independently configurable via Settings →
|
||||
# Health Thresholds; defaults are 85/95 to align with the host
|
||||
@@ -6262,6 +6276,149 @@ class HealthMonitor:
|
||||
'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 ─────────────────────────────────────────────
|
||||
#
|
||||
# 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'),
|
||||
('updates', 'pending_updates'), ('updates', 'kernel_pve'),
|
||||
('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_'),
|
||||
# ── Storage keys — HealthMonitor emits these under `storage` category
|
||||
# but they used to fall through to 'general' here because no prefix
|
||||
|
||||
@@ -75,6 +75,16 @@ DEFAULTS: dict[str, Any] = {
|
||||
"warning": {"value": 85, "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": {
|
||||
"warning": {"value": 80, "unit": "°C", "min": 30, "max": 120, "step": 1},
|
||||
"critical": {"value": 90, "unit": "°C", "min": 30, "max": 120, "step": 1},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -297,10 +297,40 @@ def _detect_oci_apps() -> list[dict]:
|
||||
# Stash the raw app_id so the checker can find it without
|
||||
# parsing the prefixed registry id.
|
||||
"_oci_app_id": app_id,
|
||||
# Cache the CT vmid so `_detect_lxc_containers` can flag the
|
||||
# matching LXC row as OCI-managed (avoids the LXC update flow
|
||||
# competing with the Secure Gateway panel's own updater).
|
||||
"_vmid": app.get("vmid"),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _get_oci_managed_vmids() -> dict[str, str]:
|
||||
"""Return {vmid_str: oci_app_id} for every CT under oci_manager.
|
||||
Used by `_detect_lxc_containers` to route those CTs through the
|
||||
Secure Gateway update flow instead of the generic apt/apk path —
|
||||
the two share the same `apk upgrade` at the bottom but the OCI
|
||||
manager also does app-specific hooks (e.g. restarting tailscale
|
||||
when the package moved) that the generic runner is blind to.
|
||||
"""
|
||||
try:
|
||||
import oci_manager
|
||||
except Exception:
|
||||
return {}
|
||||
try:
|
||||
installed = oci_manager.list_installed_apps() or []
|
||||
except Exception:
|
||||
return {}
|
||||
mapping: dict[str, str] = {}
|
||||
for app in installed:
|
||||
vmid = app.get("vmid")
|
||||
app_id = app.get("id") or app.get("app_id")
|
||||
if vmid is None or not app_id:
|
||||
continue
|
||||
mapping[str(vmid)] = str(app_id)
|
||||
return mapping
|
||||
|
||||
|
||||
# ── LXC containers (Phase 1: apt-based update detection) ────────────
|
||||
#
|
||||
# Each running Debian/Ubuntu CT becomes a registry entry of type "lxc".
|
||||
@@ -461,6 +491,246 @@ def _list_pve_lxcs() -> list[dict]:
|
||||
|
||||
_SUPPORTED_OS_FAMILIES = ("debian", "ubuntu", "alpine")
|
||||
|
||||
# Detectors for the CT origin. `pct config` writes machine-friendly
|
||||
# keys that reveal how a container was created. The most reliable
|
||||
# OCI-image indicator across PVE 9.1+ is `lxc.environment.runtime:` —
|
||||
# it's populated from every Dockerfile ENV (nearly universal) whereas
|
||||
# `entrypoint:` requires the image to define ENTRYPOINT (CMD-only
|
||||
# images lack it). We match by prefix, one hit is enough.
|
||||
_OCI_LXC_MARKERS = (
|
||||
"lxc.environment.runtime:",
|
||||
"lxc.init.cwd:",
|
||||
"lxc.signal.halt:",
|
||||
)
|
||||
|
||||
|
||||
def _probe_lxc_is_oci(vmid: str) -> bool:
|
||||
"""Return True if the CT was created from an OCI (Docker) image via
|
||||
PVE 9.1+'s native ``pct create <vmid> <oci-ref>`` path.
|
||||
|
||||
OCI-image containers are IMMUTABLE by design — running apt/apk
|
||||
upgrade inside them contradicts the container model and can break
|
||||
the image (bootstrap deps, baked-in configs). The correct workflow
|
||||
is to pull a newer image tag and rebuild. We use this probe to
|
||||
SUPPRESS the apt/apk detection for these CTs so the UI doesn't
|
||||
show a misleading "packages pending" badge that would nudge users
|
||||
toward the anti-pattern.
|
||||
|
||||
Reads the CT config file directly (cheaper than `pct config`) —
|
||||
the file lives at /etc/pve/lxc/<vmid>.conf and is always present
|
||||
on the node hosting the CT.
|
||||
"""
|
||||
conf_path = f"/etc/pve/lxc/{vmid}.conf"
|
||||
try:
|
||||
with open(conf_path) as f:
|
||||
for line in f:
|
||||
stripped = line.lstrip()
|
||||
for marker in _OCI_LXC_MARKERS:
|
||||
if stripped.startswith(marker):
|
||||
return True
|
||||
except (FileNotFoundError, PermissionError, OSError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# Cross-reference against the ProxMenux helpers catalogue (generated
|
||||
# by .github/scripts/generate_helpers_cache.py from the
|
||||
# community-scripts registry). Each entry carries `updateable: bool`
|
||||
# — the community-scripts folks know which of their apps ship a
|
||||
# working updater and which don't (47 out of 733 at last count are
|
||||
# updateable=false). Without this we'd offer an Apply button on
|
||||
# every CT with /usr/bin/update, and 6-7% of them would fail hard.
|
||||
_HELPERS_CACHE_URL = (
|
||||
"https://raw.githubusercontent.com/MacRimi/ProxMenux/"
|
||||
"refs/heads/main/json/helpers_cache.json"
|
||||
)
|
||||
_HELPERS_CACHE_DISK = "/var/lib/proxmenux/helpers_cache.json"
|
||||
_HELPERS_CACHE_TTL = 7 * 24 * 3600 # 7 days — the catalogue changes rarely
|
||||
_HELPERS_CACHE_HTTP_TIMEOUT = 10
|
||||
_helpers_cache_lock = threading.RLock()
|
||||
_helpers_cache: Optional[dict] = None
|
||||
_helpers_cache_ts: float = 0.0
|
||||
|
||||
_UPDATE_SLUG_RE = re.compile(r"ct/([a-z0-9_-]+)\.sh")
|
||||
|
||||
|
||||
def _fetch_helpers_cache() -> dict:
|
||||
"""Return the slug→metadata index for community-scripts apps.
|
||||
|
||||
Shape: ``{slug: {"name": str, "updateable": bool}}``. Fetched on
|
||||
demand from the ProxMenux repo, cached in memory for 7 days and
|
||||
persisted to :data:`_HELPERS_CACHE_DISK` so a Monitor restart
|
||||
doesn't refetch. On any network failure returns the last known
|
||||
good copy — never raises, so callers can just ``.get(slug)``.
|
||||
"""
|
||||
global _helpers_cache, _helpers_cache_ts
|
||||
with _helpers_cache_lock:
|
||||
now = time.time()
|
||||
if _helpers_cache is not None and (now - _helpers_cache_ts) < _HELPERS_CACHE_TTL:
|
||||
return _helpers_cache
|
||||
# In-memory expired or empty — try network first, then disk.
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
_HELPERS_CACHE_URL,
|
||||
headers={"User-Agent": "ProxMenux-Monitor"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=_HELPERS_CACHE_HTTP_TIMEOUT) as r:
|
||||
raw = json.loads(r.read().decode("utf-8"))
|
||||
index: dict = {}
|
||||
for entry in raw or []:
|
||||
slug = entry.get("slug")
|
||||
if not slug:
|
||||
continue
|
||||
# `default_port` powers the App tab's port pre-fill
|
||||
# fallback for apps that don't have a curated
|
||||
# default_ports entry in app_tracking_hints.json.
|
||||
# `logo` is the selfh.st/icons URL from the
|
||||
# community-scripts catalog — fallback for slugs
|
||||
# whose curated tracking hint doesn't ship one.
|
||||
index[slug] = {
|
||||
"name": entry.get("name") or slug,
|
||||
"updateable": bool(entry.get("updateable")),
|
||||
"default_port": entry.get("port") or 0,
|
||||
"logo": entry.get("logo") or "",
|
||||
}
|
||||
_helpers_cache = index
|
||||
_helpers_cache_ts = now
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_HELPERS_CACHE_DISK), exist_ok=True)
|
||||
tmp = f"{_HELPERS_CACHE_DISK}.tmp.{os.getpid()}"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump({"ts": now, "index": index}, f)
|
||||
os.replace(tmp, _HELPERS_CACHE_DISK)
|
||||
except OSError:
|
||||
# Persistence is best-effort — memory copy is enough.
|
||||
pass
|
||||
return index
|
||||
except Exception:
|
||||
# Network failed. Fall back to whatever we have in memory,
|
||||
# then to the on-disk copy from a previous run.
|
||||
if _helpers_cache is not None:
|
||||
return _helpers_cache
|
||||
try:
|
||||
with open(_HELPERS_CACHE_DISK) as f:
|
||||
disk = json.load(f)
|
||||
_helpers_cache = disk.get("index") or {}
|
||||
_helpers_cache_ts = float(disk.get("ts") or 0)
|
||||
return _helpers_cache
|
||||
except (OSError, json.JSONDecodeError):
|
||||
_helpers_cache = {}
|
||||
_helpers_cache_ts = now # avoid hammering the retry loop
|
||||
return _helpers_cache
|
||||
|
||||
|
||||
def _probe_helper_scripts_slug(vmid: str) -> Optional[str]:
|
||||
"""Return the community-scripts app slug for a CT by extracting the
|
||||
``ct/<slug>.sh`` reference embedded in ``/usr/bin/update``.
|
||||
|
||||
The community-scripts installers write ``/usr/bin/update`` as a
|
||||
single line: ``bash -c "$(curl -fsSL …/ct/<slug>.sh)"``. Parsing
|
||||
that URL gives us both the app identity AND a stable key into
|
||||
:func:`_fetch_helpers_cache`. Returns None when the file is
|
||||
missing, unreadable, or doesn't match the expected pattern.
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[_PCT_BIN, "exec", str(vmid), "--", "cat", "/usr/bin/update"],
|
||||
capture_output=True, text=True,
|
||||
timeout=_LXC_OS_PROBE_TIMEOUT_SEC,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
m = _UPDATE_SLUG_RE.search(r.stdout)
|
||||
return m.group(1) if m else None
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
# Tags the community-scripts installers stamp on the CT config so we
|
||||
# can recognise a CT as a helper-scripts install even when /usr/bin/
|
||||
# update has been deleted or was never created (very old installs).
|
||||
_HELPER_SCRIPTS_TAGS = frozenset({"proxmox-helper-scripts", "community-scripts"})
|
||||
|
||||
|
||||
def _probe_lxc_tags(vmid: str) -> set:
|
||||
"""Return the set of tags configured on the CT (from ``pct config``).
|
||||
Returns empty set on any failure — never raises.
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[_PCT_BIN, "config", str(vmid)],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
return set()
|
||||
if r.returncode != 0:
|
||||
return set()
|
||||
for line in r.stdout.splitlines():
|
||||
if line.startswith("tags:"):
|
||||
raw = line.split(":", 1)[1].strip()
|
||||
return {t.strip().lower() for t in raw.split(";") if t.strip()}
|
||||
return set()
|
||||
|
||||
|
||||
def _normalize_for_fuzzy(s: str) -> str:
|
||||
"""Lowercase + strip non-alphanumeric, for hostname↔slug matching."""
|
||||
return "".join(ch for ch in (s or "").lower() if ch.isalnum())
|
||||
|
||||
|
||||
def _guess_helper_slug_from_hostname(hostname: str) -> Optional[str]:
|
||||
"""Fuzzy-match a CT hostname against community-scripts catalog slugs.
|
||||
|
||||
Tried in this order:
|
||||
1. Exact-normalized match — the safest and only unambiguous case
|
||||
2. Prefix match (hostname is a proper prefix of the slug —
|
||||
e.g. `nginxproxy` → `nginxproxymanager`) — only accepted when
|
||||
there is EXACTLY ONE candidate. A hostname like `paperless`
|
||||
matching all of {paperless-ai, paperless-gpt, paperless-ngx}
|
||||
returns None: the guess would be wrong more often than right.
|
||||
3. Contains match — same "unique or bust" rule.
|
||||
|
||||
Ambiguity → None. The user then goes through the catalog picker
|
||||
or types the app name themselves — accurate manual choice beats
|
||||
silently-wrong auto-suggestion.
|
||||
"""
|
||||
norm_host = _normalize_for_fuzzy(hostname)
|
||||
if not norm_host:
|
||||
return None
|
||||
cache = _fetch_helpers_cache() or {}
|
||||
if not cache:
|
||||
return None
|
||||
norm_slugs = {slug: _normalize_for_fuzzy(slug) for slug in cache}
|
||||
for slug, ns in norm_slugs.items():
|
||||
if ns == norm_host:
|
||||
return slug
|
||||
prefix = [slug for slug, ns in norm_slugs.items() if ns.startswith(norm_host)]
|
||||
if len(prefix) == 1:
|
||||
return prefix[0]
|
||||
if prefix:
|
||||
return None # ambiguous — refuse to guess
|
||||
contains = [slug for slug, ns in norm_slugs.items() if norm_host in ns]
|
||||
if len(contains) == 1:
|
||||
return contains[0]
|
||||
return None
|
||||
|
||||
|
||||
def _infer_helper_slug(vmid: str, hostname: str) -> Optional[str]:
|
||||
"""Best-effort identification of the community-scripts slug for a CT.
|
||||
|
||||
Primary: extract from /usr/bin/update (present on installs from a
|
||||
reasonably modern community-scripts installer). Fallback: if the
|
||||
CT carries a helper-scripts tag but /usr/bin/update is missing
|
||||
(very old installs, or the file was removed), guess by
|
||||
fuzzy-matching the hostname against the helpers_cache slug list.
|
||||
"""
|
||||
slug = _probe_helper_scripts_slug(vmid)
|
||||
if slug:
|
||||
return slug
|
||||
tags = _probe_lxc_tags(vmid)
|
||||
if not (tags & _HELPER_SCRIPTS_TAGS):
|
||||
return None
|
||||
return _guess_helper_slug_from_hostname(hostname)
|
||||
|
||||
|
||||
def _probe_lxc_os(vmid: str) -> Optional[str]:
|
||||
"""Return a normalized family identifier (``debian`` / ``ubuntu`` /
|
||||
@@ -531,6 +801,12 @@ def _detect_lxc_containers() -> list[dict]:
|
||||
}
|
||||
|
||||
cts = _list_pve_lxcs()
|
||||
# Set of CTs currently managed by oci_manager (Secure Gateway etc).
|
||||
# Their update path is the OCI app's own updater — we mark them so
|
||||
# the LXC row in the UI redirects the user there instead of running
|
||||
# our generic apt/apk flow.
|
||||
oci_managed = _get_oci_managed_vmids()
|
||||
|
||||
out: list[dict] = []
|
||||
for ct in cts:
|
||||
if ct["status"] != "running":
|
||||
@@ -538,15 +814,56 @@ def _detect_lxc_containers() -> list[dict]:
|
||||
vmid = ct["vmid"]
|
||||
cid = f"lxc:{vmid}"
|
||||
prior = existing_by_id.get(cid) or {}
|
||||
|
||||
# OCI-image marker is cached — the CT origin doesn't change
|
||||
# over its lifetime, and reading the pct config file is cheap
|
||||
# enough that we don't gain much from skipping the re-probe.
|
||||
is_oci = _probe_lxc_is_oci(vmid)
|
||||
|
||||
# Managed OCI-app membership (Secure Gateway / Tailscale / any
|
||||
# future ProxMenux-shipped OCI app).
|
||||
managed_oci_app = oci_managed.get(str(vmid))
|
||||
|
||||
# OS family is only meaningful for non-OCI CTs. We still cache
|
||||
# it for OCI (some images ARE Ubuntu/Debian underneath and
|
||||
# future features might use it), but we don't require it.
|
||||
os_family = prior.get("_os_family")
|
||||
if not os_family:
|
||||
os_family = _probe_lxc_os(vmid)
|
||||
if os_family not in _SUPPORTED_OS_FAMILIES:
|
||||
# Distribution we don't yet have a package-manager
|
||||
# parser for. Skip silently. The framework marks any
|
||||
# existing entry as removed_at if it stops appearing
|
||||
# in the detector output.
|
||||
if not is_oci and os_family not in _SUPPORTED_OS_FAMILIES:
|
||||
# Non-OCI, non-supported family — the framework has
|
||||
# no way to check its updates. Skip silently.
|
||||
continue
|
||||
|
||||
# Helper-scripts updater detection — only meaningful for
|
||||
# non-OCI, non-managed CTs. Managed OCI apps have their own
|
||||
# updater; OCI-image CTs almost never carry /usr/bin/update
|
||||
# since apps are baked into the image at build time.
|
||||
#
|
||||
# `_has_app_updater` gates whether the "Apply application
|
||||
# update" button appears in the modal. It's only True when
|
||||
# BOTH:
|
||||
# (a) /usr/bin/update exists AND we can extract the
|
||||
# community-scripts slug from it, and
|
||||
# (b) that slug is marked `updateable: true` in the
|
||||
# helpers_cache — 47/733 entries are false, and running
|
||||
# their updaters is a known-broken action.
|
||||
# `_helper_slug` and `_helper_app_name` are surfaced to the UI
|
||||
# so users see which app they'd be updating (e.g. "Update
|
||||
# Jellyfin" rather than a generic "Update").
|
||||
has_app_updater = False
|
||||
helper_slug: Optional[str] = None
|
||||
helper_app_name: Optional[str] = None
|
||||
helper_updateable_known = False # True when we found the slug in the cache
|
||||
if not is_oci and not managed_oci_app:
|
||||
helper_slug = _infer_helper_slug(vmid, ct.get("name") or "")
|
||||
if helper_slug:
|
||||
entry = _fetch_helpers_cache().get(helper_slug)
|
||||
if entry:
|
||||
helper_updateable_known = True
|
||||
helper_app_name = entry.get("name") or helper_slug
|
||||
has_app_updater = bool(entry.get("updateable"))
|
||||
|
||||
out.append({
|
||||
"id": cid,
|
||||
"type": "lxc",
|
||||
@@ -556,8 +873,12 @@ def _detect_lxc_containers() -> list[dict]:
|
||||
"menu_script": None,
|
||||
"_vmid": vmid,
|
||||
"_os_family": os_family,
|
||||
# Phase 2 hook: populate `_helper_script_app` here once we
|
||||
# learn how to read the community-scripts marker.
|
||||
"_is_oci": is_oci,
|
||||
"_managed_oci_app": managed_oci_app,
|
||||
"_has_app_updater": has_app_updater,
|
||||
"_helper_slug": helper_slug,
|
||||
"_helper_app_name": helper_app_name,
|
||||
"_helper_updateable_known": helper_updateable_known,
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -1113,6 +1434,24 @@ def _check_lxc_updates(entry: dict) -> dict:
|
||||
"last_check": _now_iso(), "error": "no vmid in entry",
|
||||
}
|
||||
|
||||
# OCI-image CTs are immutable by design — apt/apk upgrade inside
|
||||
# them is the wrong workflow (update = rebuild from a newer image
|
||||
# tag). Skip the package-manager probe entirely so the UI doesn't
|
||||
# surface a misleading "N packages pending" badge that would nudge
|
||||
# users toward the anti-pattern. The Updates modal renders a
|
||||
# dedicated OCI-container panel using the flag propagated below.
|
||||
#
|
||||
# Same treatment for CTs managed by oci_manager (Secure Gateway
|
||||
# etc.) — those have their own dashboard-driven updater with
|
||||
# app-specific hooks; running our generic apt/apk in parallel
|
||||
# would race and could restart the wrong services.
|
||||
if entry.get("_is_oci") or entry.get("_managed_oci_app"):
|
||||
return {
|
||||
"available": False, "latest": None,
|
||||
"last_check": _now_iso(), "error": None,
|
||||
"_count": 0, "_security_count": 0, "_packages": [],
|
||||
}
|
||||
|
||||
refresh_diag = _refresh_lxc_pkg_cache_if_stale(vmid, family)
|
||||
|
||||
if family in ("debian", "ubuntu"):
|
||||
|
||||
@@ -2772,6 +2772,8 @@ class PollingCollector:
|
||||
if category == 'storage':
|
||||
if error_key.startswith('lxc_disk_'):
|
||||
event_type = 'lxc_disk_low'
|
||||
elif error_key.startswith('vm_disk_'):
|
||||
event_type = 'vm_disk_low'
|
||||
elif error_key.startswith('lxc_mount_'):
|
||||
event_type = 'lxc_mount_low'
|
||||
elif error_key.startswith('pve_storage_full_'):
|
||||
@@ -3506,6 +3508,17 @@ class PollingCollector:
|
||||
print(f"[PollingCollector] managed_installs update run failed: {e}")
|
||||
return
|
||||
|
||||
# Piggy-back on the same 24 h cycle to refresh every
|
||||
# user-registered app watch. Keeps the header badge accurate
|
||||
# in the VMs list without needing a dedicated timer. Errors
|
||||
# are absorbed inside refresh_all_apps — one broken CT never
|
||||
# blocks the others.
|
||||
try:
|
||||
import lxc_apps
|
||||
lxc_apps.refresh_all_apps(force=False)
|
||||
except Exception as e:
|
||||
print(f"[PollingCollector] lxc_apps refresh failed: {e}")
|
||||
|
||||
# Split LXC updates out of the per-item event stream — they get
|
||||
# one grouped notification per cycle instead of one per CT, to
|
||||
# avoid spamming the user when 15 CTs have pending updates the
|
||||
|
||||
@@ -2789,7 +2789,7 @@ class NotificationManager:
|
||||
# injection lands in the system prompt verbatim. Audit Tier 3.2 #4.
|
||||
_ALLOWED_DETAIL_LEVELS = ('brief', 'standard', 'detailed')
|
||||
_ALLOWED_AI_LANGUAGES = (
|
||||
'en', 'es', 'fr', 'de', 'it', 'pt', 'ru',
|
||||
'en', 'sk', 'es', 'fr', 'de', 'it', 'pt', 'ru',
|
||||
'ja', 'zh', 'ko', 'pl', 'nl', 'tr', 'ar',
|
||||
)
|
||||
if short_key.endswith('.ai_detail_level') or short_key == 'ai_detail_level':
|
||||
|
||||
@@ -510,6 +510,27 @@ TEMPLATES = {
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': False,
|
||||
},
|
||||
'lxc_update_applied': {
|
||||
'title': '{hostname}: LXC {ct_name} ({vmid}) update {result}',
|
||||
'body': (
|
||||
'Container {ct_name} (CT {vmid}) — update {result}.\n'
|
||||
'Target: {target} Duration: {duration}'
|
||||
),
|
||||
'label': 'LXC update applied',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'app_update_available': {
|
||||
'title': '{hostname}: {app_name} update available on CT {vmid}',
|
||||
'body': (
|
||||
'{app_name} on CT {vmid} ({ct_name}) has a new version:\n'
|
||||
' {installed} → {latest}\n'
|
||||
'Registered via ProxMenux App Watch.'
|
||||
),
|
||||
'label': 'App update available (App Watch)',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': False,
|
||||
},
|
||||
'vm_start': {
|
||||
'title': '{hostname}: VM {vmname} ({vmid}) started',
|
||||
'body': 'Virtual machine {vmname} (ID: {vmid}) is now running.',
|
||||
@@ -1157,6 +1178,27 @@ TEMPLATES = {
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
# Aggregate filesystem usage reported by the QEMU guest agent for a
|
||||
# running VM. Fires when the guest is filling up regardless of
|
||||
# whether the storage is a virtual disk or a PCI-passthrough drive
|
||||
# (TrueNAS-style appliances included) — the metric is "how full is
|
||||
# the guest", not "how full is the disk PVE knows about".
|
||||
'vm_disk_low': {
|
||||
'title': '{hostname}: VM {vmid} filesystems at {usage_percent}%',
|
||||
'body': (
|
||||
'VM {vmid} ({name}) guest filesystems are at {usage_percent}% '
|
||||
'({disk_bytes_human} / {maxdisk_bytes_human}).\n\n'
|
||||
'Reported by the QEMU guest agent. Includes every persistent '
|
||||
'filesystem the guest mounts on a block device — virtual disks '
|
||||
'and PCI-passthrough drives alike. Free up space inside the '
|
||||
'guest or expand the affected storage before writes start to '
|
||||
'fail.'
|
||||
),
|
||||
'label': 'VM filesystems near full',
|
||||
'group': 'storage',
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
# ── Phase 3 capacity events (Sprint 14.5) ─────────────────────────
|
||||
# Three new events that complete the storage-monitoring picture.
|
||||
# Each fires at the user-configured warning/critical thresholds
|
||||
@@ -1675,6 +1717,8 @@ CATEGORY_EMOJI = {
|
||||
EVENT_EMOJI = {
|
||||
# VM / CT
|
||||
'lxc_updates_available': '\U0001F4E6', # \uD83D\uDCE6 package \u2014 pending CT updates
|
||||
'lxc_update_applied': '\u2705', # \u2705 check \u2014 update applied
|
||||
'app_update_available': '\U0001F195', # \ud83c\udd95 NEW \u2014 upstream app release
|
||||
'vm_start': '\u25B6\uFE0F', # play button
|
||||
'vm_start_warning': '\u26A0\uFE0F', # warning sign - started with warnings
|
||||
'vm_stop': '\u23F9\uFE0F', # stop button
|
||||
@@ -1716,6 +1760,7 @@ EVENT_EMOJI = {
|
||||
'mount_stale': '\U0001F517', # link (broken connection feel)
|
||||
'mount_readonly': '\U0001F512', # lock
|
||||
'lxc_disk_low': '\U0001F4BE', # floppy disk (near-full)
|
||||
'vm_disk_low': '\U0001F4BE', # floppy disk — same shape as LXC counterpart
|
||||
'lxc_mount_low': '\U0001F4C2', # 📂 folder near-full
|
||||
'pve_storage_full': '\U0001F4E6', # 📦 package (running out)
|
||||
'zfs_pool_full': '\U0001F30A', # 🌊 wave (pool is full)
|
||||
@@ -1954,6 +1999,7 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
|
||||
# Supported languages for AI translation
|
||||
AI_LANGUAGES = {
|
||||
'en': 'English',
|
||||
'sk': 'Slovak',
|
||||
'es': 'Spanish',
|
||||
'fr': 'French',
|
||||
'de': 'German',
|
||||
|
||||
@@ -11,6 +11,8 @@ import subprocess
|
||||
import re
|
||||
import fcntl
|
||||
import threading
|
||||
import ipaddress
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
|
||||
# =================================================================
|
||||
@@ -80,6 +82,10 @@ def _is_pve_rule_line(stripped):
|
||||
# and quote/escape tricks. See audit Tier 1 #12b.
|
||||
_JAIL_NAME_RE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_-]{0,63}$')
|
||||
|
||||
FAIL2BAN_TRUSTED_NETWORKS_FILE = "/etc/fail2ban/jail.d/99-proxmenux-ignore.local"
|
||||
FAIL2BAN_LEGACY_GLOBAL_FILE = "/etc/fail2ban/jail.local"
|
||||
_FAIL2BAN_PROTECTED_NETWORKS = ("127.0.0.0/8", "::1")
|
||||
|
||||
# Whitelist for the `level` argument to firewall functions. The audit flagged
|
||||
# that an unconstrained value here could one day be extended to `vm` and become
|
||||
# a path traversal sink. See audit Tier 1 #12d.
|
||||
@@ -137,6 +143,25 @@ def _run_cmd(cmd, timeout=10):
|
||||
return -1, "", str(e)
|
||||
|
||||
|
||||
def _pve_firewall_apply():
|
||||
"""
|
||||
Recompile and apply pending firewall changes.
|
||||
|
||||
pve-firewall 6.x (shipped with PVE 9) dropped the `reload`
|
||||
subcommand — only `restart` recompiles the ruleset and re-applies
|
||||
it to iptables/nftables. Older releases accepted `reload`. Try
|
||||
`reload` first for the cheap path; fall back to `restart` when
|
||||
the subcommand is missing or returns an error. Without this
|
||||
fallback, every mutating firewall call in this module was silently
|
||||
a no-op on PVE 9 (the config file was updated but the kernel
|
||||
ruleset never picked up the change until an unrelated restart).
|
||||
"""
|
||||
rc, out, err = _run_cmd(["pve-firewall", "reload"])
|
||||
if rc == 0:
|
||||
return rc, out, err
|
||||
return _run_cmd(["pve-firewall", "restart"])
|
||||
|
||||
|
||||
def get_firewall_status():
|
||||
"""
|
||||
Get the overall Proxmox firewall status.
|
||||
@@ -392,7 +417,7 @@ def add_firewall_rule(direction="IN", action="ACCEPT", protocol="tcp", dport="",
|
||||
with open(fw_file, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
_run_cmd(["pve-firewall", "reload"])
|
||||
_pve_firewall_apply()
|
||||
|
||||
return True, f"Firewall rule added: {direction} {action} {protocol}{':' + dport if dport else ''}"
|
||||
except PermissionError:
|
||||
@@ -509,7 +534,7 @@ def edit_firewall_rule(rule_index, level="host", direction="IN", action="ACCEPT"
|
||||
with open(fw_file, 'w') as f:
|
||||
f.write("\n".join(new_lines) + "\n")
|
||||
|
||||
_run_cmd(["pve-firewall", "reload"])
|
||||
_pve_firewall_apply()
|
||||
|
||||
return True, f"Firewall rule updated: {direction} {action} {protocol}{':' + dport if dport else ''}"
|
||||
except PermissionError:
|
||||
@@ -571,7 +596,7 @@ def delete_firewall_rule(rule_index, level="host"):
|
||||
with open(fw_file, 'w') as f:
|
||||
f.write("\n".join(new_lines) + "\n")
|
||||
|
||||
_run_cmd(["pve-firewall", "reload"])
|
||||
_pve_firewall_apply()
|
||||
|
||||
return True, f"Firewall rule deleted: {removed_rule}"
|
||||
except PermissionError:
|
||||
@@ -625,7 +650,7 @@ def add_monitor_port_rule():
|
||||
f.write(content)
|
||||
|
||||
# Reload firewall
|
||||
_run_cmd(["pve-firewall", "reload"])
|
||||
_pve_firewall_apply()
|
||||
|
||||
return True, "Firewall rule added: port 8008 (TCP) allowed for ProxMenux Monitor"
|
||||
except PermissionError:
|
||||
@@ -662,7 +687,7 @@ def remove_monitor_port_rule():
|
||||
with open(host_fw, 'w') as f:
|
||||
f.writelines(new_lines)
|
||||
|
||||
_run_cmd(["pve-firewall", "reload"])
|
||||
_pve_firewall_apply()
|
||||
|
||||
return True, "ProxMenux Monitor firewall rule removed"
|
||||
except Exception as e:
|
||||
@@ -673,9 +698,25 @@ def enable_firewall(level="host"):
|
||||
"""
|
||||
Enable the Proxmox firewall at host or cluster level.
|
||||
Returns (success, message)
|
||||
|
||||
Safety net: whoever is calling this endpoint is reaching the Monitor
|
||||
through port 8008. Enabling the firewall without an explicit ACCEPT
|
||||
rule for that port drops the caller's own connection the instant
|
||||
`pve-firewall reload` runs, and they lose the UI they need to fix it.
|
||||
Ensure the rule is in host.fw first; if we can't place it, refuse
|
||||
to enable rather than risk a lock-out.
|
||||
"""
|
||||
if level not in _FIREWALL_LEVELS:
|
||||
return False, f"Invalid level: {level}. Must be one of {_FIREWALL_LEVELS}"
|
||||
|
||||
rule_ok, rule_msg = add_monitor_port_rule()
|
||||
if not rule_ok:
|
||||
return False, (
|
||||
f"Refused to enable firewall: could not ensure port 8008 "
|
||||
f"(ProxMenux Monitor) rule in host.fw — {rule_msg}. "
|
||||
f"Add the rule manually and try again."
|
||||
)
|
||||
|
||||
if level == "cluster":
|
||||
return _set_firewall_enabled(CLUSTER_FW, True)
|
||||
else:
|
||||
@@ -750,7 +791,7 @@ def _set_firewall_enabled(fw_file, enabled):
|
||||
_run_cmd(["systemctl", "enable", "pve-firewall"])
|
||||
_run_cmd(["systemctl", "start", "pve-firewall"])
|
||||
|
||||
_run_cmd(["pve-firewall", "reload"])
|
||||
_pve_firewall_apply()
|
||||
|
||||
state = "enabled" if enabled else "disabled"
|
||||
level = "cluster" if fw_file == CLUSTER_FW else "host"
|
||||
@@ -889,6 +930,192 @@ def classify_ip(ip_address):
|
||||
return "external"
|
||||
|
||||
|
||||
def _normalise_ip_or_network(value):
|
||||
"""Return a canonical IP/CIDR string, or raise ValueError."""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("Enter an IP address or CIDR network")
|
||||
candidate = value.strip()
|
||||
if not candidate or len(candidate) > 128 or any(ch.isspace() for ch in candidate):
|
||||
raise ValueError("Enter one IP address or CIDR network at a time")
|
||||
try:
|
||||
if "/" in candidate:
|
||||
parsed = ipaddress.ip_network(candidate, strict=False)
|
||||
if parsed.prefixlen == 0 or parsed.is_multicast or parsed.is_unspecified:
|
||||
raise ValueError
|
||||
return parsed.with_prefixlen
|
||||
parsed = ipaddress.ip_address(candidate)
|
||||
if parsed.is_multicast or parsed.is_unspecified:
|
||||
raise ValueError
|
||||
return str(parsed)
|
||||
except ValueError:
|
||||
raise ValueError("Invalid IP address or CIDR network")
|
||||
|
||||
|
||||
def _parse_default_ignoreip(path):
|
||||
"""Read ignoreip values from the [DEFAULT] section of one config file."""
|
||||
if not os.path.isfile(path):
|
||||
return []
|
||||
values = []
|
||||
in_default = False
|
||||
try:
|
||||
with open(path, "r") as config_file:
|
||||
for raw_line in config_file:
|
||||
stripped = raw_line.strip()
|
||||
if stripped.startswith("[") and stripped.endswith("]"):
|
||||
in_default = stripped.upper() == "[DEFAULT]"
|
||||
continue
|
||||
if not in_default or not stripped or stripped.startswith(("#", ";")):
|
||||
continue
|
||||
match = re.match(r"^ignoreip\s*=\s*(.*)$", stripped, re.IGNORECASE)
|
||||
if match:
|
||||
raw_values = re.split(r"[\s,]+", match.group(1).strip())
|
||||
values.extend(value for value in raw_values if value)
|
||||
except OSError:
|
||||
return []
|
||||
return values
|
||||
|
||||
|
||||
def _trusted_network_entries():
|
||||
source = (FAIL2BAN_TRUSTED_NETWORKS_FILE
|
||||
if os.path.isfile(FAIL2BAN_TRUSTED_NETWORKS_FILE)
|
||||
else FAIL2BAN_LEGACY_GLOBAL_FILE)
|
||||
entries = []
|
||||
for value in (*_FAIL2BAN_PROTECTED_NETWORKS, *_parse_default_ignoreip(source)):
|
||||
try:
|
||||
normalised = _normalise_ip_or_network(value)
|
||||
except ValueError:
|
||||
continue
|
||||
if normalised not in entries:
|
||||
entries.append(normalised)
|
||||
return entries
|
||||
|
||||
|
||||
def get_fail2ban_trusted_networks():
|
||||
"""Return the global Fail2Ban IP/CIDR allowlist managed by the Monitor."""
|
||||
protected = {_normalise_ip_or_network(value) for value in _FAIL2BAN_PROTECTED_NETWORKS}
|
||||
return [
|
||||
{"value": value, "protected": value in protected}
|
||||
for value in _trusted_network_entries()
|
||||
]
|
||||
|
||||
|
||||
def _write_trusted_networks(entries):
|
||||
target = FAIL2BAN_TRUSTED_NETWORKS_FILE
|
||||
directory = os.path.dirname(target)
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
content = (
|
||||
"# Managed by ProxMenux Monitor. Use the Security page to edit.\n"
|
||||
"[DEFAULT]\n"
|
||||
f"ignoreip = {' '.join(entries)}\n"
|
||||
"ignoreself = true\n"
|
||||
)
|
||||
temp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", dir=directory, prefix=".proxmenux-ignore-", delete=False
|
||||
) as temp_file:
|
||||
temp_file.write(content)
|
||||
temp_path = temp_file.name
|
||||
os.chmod(temp_path, 0o640)
|
||||
os.replace(temp_path, target)
|
||||
finally:
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
|
||||
def _save_trusted_networks(entries):
|
||||
"""Persist and reload atomically; restore the previous file on failure."""
|
||||
target = FAIL2BAN_TRUSTED_NETWORKS_FILE
|
||||
lock_path = target + ".lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
previous = None
|
||||
existed = os.path.isfile(target)
|
||||
if existed:
|
||||
with open(target, "rb") as current_file:
|
||||
previous = current_file.read()
|
||||
|
||||
_write_trusted_networks(entries)
|
||||
rc, _, err = _run_cmd(["fail2ban-client", "reload"])
|
||||
if rc == 0:
|
||||
return True, "Fail2Ban trusted networks updated"
|
||||
|
||||
try:
|
||||
if existed:
|
||||
with open(target, "wb") as restore_file:
|
||||
restore_file.write(previous or b"")
|
||||
elif os.path.exists(target):
|
||||
os.unlink(target)
|
||||
_run_cmd(["fail2ban-client", "reload"])
|
||||
except OSError:
|
||||
pass
|
||||
return False, f"Fail2Ban rejected the configuration: {err or 'reload failed'}"
|
||||
|
||||
|
||||
def add_fail2ban_trusted_network(value):
|
||||
try:
|
||||
normalised = _normalise_ip_or_network(value)
|
||||
except ValueError as exc:
|
||||
return False, str(exc), None
|
||||
|
||||
entries = _trusted_network_entries()
|
||||
candidate_network = ipaddress.ip_network(normalised, strict=False)
|
||||
if any(
|
||||
candidate_network.version == ipaddress.ip_network(entry, strict=False).version
|
||||
and candidate_network.subnet_of(ipaddress.ip_network(entry, strict=False))
|
||||
for entry in entries
|
||||
):
|
||||
return False, "This IP address or network is already trusted", normalised
|
||||
entries.append(normalised)
|
||||
success, message = _save_trusted_networks(entries)
|
||||
return success, message, normalised
|
||||
|
||||
|
||||
def remove_fail2ban_trusted_network(value):
|
||||
try:
|
||||
normalised = _normalise_ip_or_network(value)
|
||||
except ValueError as exc:
|
||||
return False, str(exc)
|
||||
|
||||
protected = {_normalise_ip_or_network(item) for item in _FAIL2BAN_PROTECTED_NETWORKS}
|
||||
if normalised in protected:
|
||||
return False, "Required local addresses cannot be removed"
|
||||
|
||||
entries = _trusted_network_entries()
|
||||
if normalised not in entries:
|
||||
return False, "Trusted IP address or network was not found"
|
||||
entries.remove(normalised)
|
||||
return _save_trusted_networks(entries)
|
||||
|
||||
|
||||
def update_fail2ban_trusted_network(old_value, new_value):
|
||||
try:
|
||||
old_normalised = _normalise_ip_or_network(old_value)
|
||||
new_normalised = _normalise_ip_or_network(new_value)
|
||||
except ValueError as exc:
|
||||
return False, str(exc), None
|
||||
|
||||
protected = {_normalise_ip_or_network(item) for item in _FAIL2BAN_PROTECTED_NETWORKS}
|
||||
if old_normalised in protected:
|
||||
return False, "Required local addresses cannot be changed", None
|
||||
|
||||
entries = _trusted_network_entries()
|
||||
if old_normalised not in entries:
|
||||
return False, "Trusted IP address or network was not found", None
|
||||
|
||||
other_entries = [entry for entry in entries if entry != old_normalised]
|
||||
candidate_network = ipaddress.ip_network(new_normalised, strict=False)
|
||||
if any(
|
||||
candidate_network.version == ipaddress.ip_network(entry, strict=False).version
|
||||
and candidate_network.subnet_of(ipaddress.ip_network(entry, strict=False))
|
||||
for entry in other_entries
|
||||
):
|
||||
return False, "This IP address or network is already trusted", new_normalised
|
||||
|
||||
entries[entries.index(old_normalised)] = new_normalised
|
||||
success, message = _save_trusted_networks(entries)
|
||||
return success, message, new_normalised
|
||||
|
||||
|
||||
def update_jail_config(jail_name, maxretry=None, bantime=None, findtime=None):
|
||||
"""
|
||||
Update Fail2Ban jail configuration (maxretry, bantime, findtime).
|
||||
@@ -1425,10 +1652,13 @@ def run_lynis_audit():
|
||||
global _lynis_audit_running, _lynis_audit_progress
|
||||
try:
|
||||
_lynis_audit_progress = "running"
|
||||
# Remove old report so lynis creates a fresh one
|
||||
report_file = "/var/log/lynis-report.dat"
|
||||
if os.path.isfile(report_file):
|
||||
os.remove(report_file)
|
||||
# Remove old generated files so a failed or interrupted run does
|
||||
# not get mixed with data from an earlier audit. Keep
|
||||
# /var/log/lynis.log: Lynis owns that file and can use it as a
|
||||
# fallback source when terminal capture is unavailable.
|
||||
for report_path in ["/var/log/lynis-report.dat", "/var/log/lynis-output.log"]:
|
||||
if os.path.isfile(report_path):
|
||||
os.remove(report_path)
|
||||
|
||||
# Capture full formatted output. Lynis suppresses its nice
|
||||
# formatted output ([+] sections) when stdout is not a tty.
|
||||
@@ -1538,6 +1768,7 @@ def parse_lynis_report():
|
||||
"""
|
||||
report_file = "/var/log/lynis-report.dat"
|
||||
output_file = "/var/log/lynis-output.log"
|
||||
lynis_log_file = "/var/log/lynis.log"
|
||||
# Need at least one data source
|
||||
if not os.path.isfile(report_file) and not os.path.isfile(output_file):
|
||||
return None
|
||||
@@ -1559,6 +1790,8 @@ def parse_lynis_report():
|
||||
"kernel_version": "",
|
||||
"firewall_active": False,
|
||||
"malware_scanner": False,
|
||||
"is_complete": False,
|
||||
"parse_issue": "",
|
||||
}
|
||||
|
||||
# Collect all raw key-value pairs first for flexible matching
|
||||
@@ -1685,9 +1918,30 @@ def parse_lynis_report():
|
||||
# archivo entero a memoria 2 veces.
|
||||
report["sections"] = []
|
||||
output_file = "/var/log/lynis-output.log"
|
||||
log_file = output_file if os.path.isfile(output_file) else "/var/log/lynis.log"
|
||||
log_file = ""
|
||||
_log_lines = []
|
||||
if os.path.isfile(log_file):
|
||||
|
||||
def _usable_lynis_log(path):
|
||||
if not os.path.isfile(path):
|
||||
return False
|
||||
try:
|
||||
if os.path.getsize(path) <= 0:
|
||||
return False
|
||||
# Avoid mixing a newly created sparse report with a stale log from
|
||||
# an older run. A fresh Lynis log should be at least as recent as
|
||||
# the current report, allowing a small clock/file-system margin.
|
||||
if os.path.isfile(report_file):
|
||||
return os.path.getmtime(path) >= os.path.getmtime(report_file) - 300
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
for candidate in [output_file, lynis_log_file]:
|
||||
if _usable_lynis_log(candidate):
|
||||
log_file = candidate
|
||||
break
|
||||
|
||||
if log_file:
|
||||
try:
|
||||
with open(log_file, 'r') as f:
|
||||
_log_lines = f.readlines()
|
||||
@@ -1764,7 +2018,7 @@ def parse_lynis_report():
|
||||
# Format: "Key: value" or "Key : value"
|
||||
if ":" in stripped:
|
||||
if not report["hardening_index"] and "Hardening index" in stripped:
|
||||
m = re.search(r'Hardening index\s*:\s*(\d+)', stripped)
|
||||
m = re.search(r'Hardening index\s*:?\s*\[?(\d+)\]?', stripped)
|
||||
if m:
|
||||
report["hardening_index"] = int(m.group(1))
|
||||
elif report["tests_performed"] == 0 and "Tests performed" in stripped:
|
||||
@@ -1927,6 +2181,15 @@ def parse_lynis_report():
|
||||
if "malware" in sw_name and sw_status == "V":
|
||||
report["malware_scanner"] = True
|
||||
|
||||
# lynis.log does not contain the formatted "Software
|
||||
# components" block, but it does log the underlying result
|
||||
# lines. Use those as a fallback for the quick status cards.
|
||||
s_lower = sstripped.lower()
|
||||
if "host based firewall or packet filter is active" in s_lower:
|
||||
report["firewall_active"] = True
|
||||
if "no malware scanner found" in s_lower:
|
||||
report["malware_scanner"] = False
|
||||
|
||||
# Parse warning lines: "! Warning text [TEST-ID]"
|
||||
if in_warnings and sstripped.startswith('!'):
|
||||
wm = re.match(r'^!\s+(.+?)\s+\[([A-Z0-9_-]+)\]', sstripped)
|
||||
@@ -2175,10 +2438,12 @@ def parse_lynis_report():
|
||||
# Calculate Proxmox-adjusted score
|
||||
# Lynis score is based on total tests and findings.
|
||||
# We boost the score proportionally to the expected items.
|
||||
raw_score = report["hardening_index"] or 0
|
||||
raw_score = report["hardening_index"]
|
||||
total_findings = len(report["warnings"]) + len(report["suggestions"])
|
||||
expected_findings = pve_expected_warnings + pve_expected_suggestions
|
||||
if total_findings > 0 and raw_score > 0:
|
||||
if raw_score is None:
|
||||
adjusted_score = None
|
||||
elif total_findings > 0 and raw_score > 0:
|
||||
# Each finding roughly reduces the score. Expected findings should
|
||||
# not penalize. We estimate the boost proportionally.
|
||||
penalty_per_finding = (100 - raw_score) / max(total_findings, 1)
|
||||
@@ -2191,6 +2456,9 @@ def parse_lynis_report():
|
||||
report["proxmox_expected_warnings"] = pve_expected_warnings
|
||||
report["proxmox_expected_suggestions"] = pve_expected_suggestions
|
||||
report["proxmox_context_applied"] = True
|
||||
report["is_complete"] = report["hardening_index"] is not None and report["tests_performed"] > 0
|
||||
if not report["is_complete"]:
|
||||
report["parse_issue"] = "Lynis report is incomplete: hardening index or test count is missing."
|
||||
|
||||
return report
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "auth_manager.py"
|
||||
SPEC = importlib.util.spec_from_file_location("auth_manager_under_test", MODULE_PATH)
|
||||
auth_manager = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(auth_manager)
|
||||
|
||||
|
||||
class SetupAuthTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
config_dir = Path(self.temp_dir.name)
|
||||
self.config_patch = mock.patch.multiple(
|
||||
auth_manager,
|
||||
CONFIG_DIR=config_dir,
|
||||
AUTH_CONFIG_FILE=config_dir / "auth.json",
|
||||
)
|
||||
self.config_patch.start()
|
||||
self.addCleanup(self.config_patch.stop)
|
||||
self.hash_patch = mock.patch.object(
|
||||
auth_manager, "hash_password", return_value="test-password-hash"
|
||||
)
|
||||
self.hash_patch.start()
|
||||
self.addCleanup(self.hash_patch.stop)
|
||||
|
||||
def read_config(self):
|
||||
return json.loads(auth_manager.AUTH_CONFIG_FILE.read_text())
|
||||
|
||||
def write_config(self, config):
|
||||
auth_manager.AUTH_CONFIG_FILE.write_text(json.dumps(config))
|
||||
|
||||
def test_fresh_setup_succeeds(self):
|
||||
success, message = auth_manager.setup_auth("admin", "StrongPass1!")
|
||||
|
||||
self.assertTrue(success, message)
|
||||
config = self.read_config()
|
||||
self.assertTrue(config["enabled"])
|
||||
self.assertTrue(config["configured"])
|
||||
self.assertFalse(config["declined"])
|
||||
self.assertEqual(config["username"], "admin")
|
||||
self.assertEqual(config["password_hash"], "test-password-hash")
|
||||
|
||||
def test_setup_succeeds_after_decline(self):
|
||||
success, message = auth_manager.decline_auth()
|
||||
self.assertTrue(success, message)
|
||||
|
||||
success, message = auth_manager.setup_auth("admin", "StrongPass1!")
|
||||
|
||||
self.assertTrue(success, message)
|
||||
config = self.read_config()
|
||||
self.assertTrue(config["enabled"])
|
||||
self.assertFalse(config["declined"])
|
||||
self.assertEqual(config["username"], "admin")
|
||||
self.assertEqual(config["password_hash"], "test-password-hash")
|
||||
|
||||
def test_existing_credentials_cannot_be_overwritten(self):
|
||||
self.write_config({
|
||||
"enabled": True,
|
||||
"configured": True,
|
||||
"declined": False,
|
||||
"username": "existing-admin",
|
||||
"password_hash": "existing-password-hash",
|
||||
})
|
||||
|
||||
success, message = auth_manager.setup_auth("attacker", "StrongPass1!")
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertEqual(message, "Authentication is already configured")
|
||||
config = self.read_config()
|
||||
self.assertEqual(config["username"], "existing-admin")
|
||||
self.assertEqual(config["password_hash"], "existing-password-hash")
|
||||
|
||||
def test_weak_password_is_rejected_without_writing_config(self):
|
||||
success, message = auth_manager.setup_auth("admin", "weak")
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertEqual(message, "Password must be at least 10 characters")
|
||||
self.assertFalse(auth_manager.AUTH_CONFIG_FILE.exists())
|
||||
|
||||
def test_unrelated_fields_are_preserved(self):
|
||||
preserved = {
|
||||
"jwt_secret": "s" * 48,
|
||||
"api_tokens": [{"id": "token-1"}],
|
||||
"revoked_tokens": ["revoked-token-hash"],
|
||||
"display_name": "Server Owner",
|
||||
"custom_future_field": {"keep": True},
|
||||
}
|
||||
self.write_config({
|
||||
"enabled": False,
|
||||
"configured": True,
|
||||
"declined": True,
|
||||
"username": None,
|
||||
"password_hash": None,
|
||||
**preserved,
|
||||
})
|
||||
|
||||
success, message = auth_manager.setup_auth("admin", "StrongPass1!")
|
||||
|
||||
self.assertTrue(success, message)
|
||||
config = self.read_config()
|
||||
for key, value in preserved.items():
|
||||
self.assertEqual(config[key], value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,172 @@
|
||||
import importlib.util
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[1] / "security_manager.py"
|
||||
SPEC = importlib.util.spec_from_file_location("security_manager_under_test", MODULE_PATH)
|
||||
security_manager = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(security_manager)
|
||||
|
||||
|
||||
class Fail2BanTrustedNetworksTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp_dir.cleanup)
|
||||
root = Path(self.temp_dir.name)
|
||||
self.managed_file = root / "jail.d" / "99-proxmenux-ignore.local"
|
||||
self.legacy_file = root / "jail.local"
|
||||
self.paths_patch = mock.patch.multiple(
|
||||
security_manager,
|
||||
FAIL2BAN_TRUSTED_NETWORKS_FILE=str(self.managed_file),
|
||||
FAIL2BAN_LEGACY_GLOBAL_FILE=str(self.legacy_file),
|
||||
)
|
||||
self.paths_patch.start()
|
||||
self.addCleanup(self.paths_patch.stop)
|
||||
self.command_patch = mock.patch.object(
|
||||
security_manager, "_run_cmd", return_value=(0, "OK", "")
|
||||
)
|
||||
self.run_command = self.command_patch.start()
|
||||
self.addCleanup(self.command_patch.stop)
|
||||
|
||||
def write_legacy(self, content):
|
||||
self.legacy_file.write_text(content)
|
||||
|
||||
def test_reads_and_normalises_existing_global_entries(self):
|
||||
self.write_legacy(
|
||||
"[DEFAULT]\nignoreip = 127.0.0.1/8, ::1 192.168.10.15 10.1.2.9/24\n"
|
||||
"\n[sshd]\nenabled = true\n"
|
||||
)
|
||||
|
||||
entries = security_manager.get_fail2ban_trusted_networks()
|
||||
|
||||
self.assertEqual(
|
||||
[entry["value"] for entry in entries],
|
||||
["127.0.0.0/8", "::1", "192.168.10.15", "10.1.2.0/24"],
|
||||
)
|
||||
self.assertTrue(entries[0]["protected"])
|
||||
self.assertTrue(entries[1]["protected"])
|
||||
self.assertFalse(entries[2]["protected"])
|
||||
|
||||
def test_adds_ipv4_network_and_reloads_fail2ban(self):
|
||||
success, message, value = security_manager.add_fail2ban_trusted_network(
|
||||
"192.168.50.123/24"
|
||||
)
|
||||
|
||||
self.assertTrue(success, message)
|
||||
self.assertEqual(value, "192.168.50.0/24")
|
||||
content = self.managed_file.read_text()
|
||||
self.assertIn("ignoreip = 127.0.0.0/8 ::1 192.168.50.0/24", content)
|
||||
self.run_command.assert_called_once_with(["fail2ban-client", "reload"])
|
||||
|
||||
def test_adds_ipv6_network(self):
|
||||
success, message, value = security_manager.add_fail2ban_trusted_network(
|
||||
"fd12:3456:789a::42/64"
|
||||
)
|
||||
|
||||
self.assertTrue(success, message)
|
||||
self.assertEqual(value, "fd12:3456:789a::/64")
|
||||
|
||||
def test_rejects_invalid_or_multiple_values_without_writing(self):
|
||||
for value in ("not-an-ip", "10.0.0.1 10.0.0.2", "10.0.0.0/99"):
|
||||
with self.subTest(value=value):
|
||||
success, _, normalised = security_manager.add_fail2ban_trusted_network(value)
|
||||
self.assertFalse(success)
|
||||
self.assertIsNone(normalised)
|
||||
|
||||
self.assertFalse(self.managed_file.exists())
|
||||
self.run_command.assert_not_called()
|
||||
|
||||
def test_duplicate_is_rejected(self):
|
||||
self.write_legacy("[DEFAULT]\nignoreip = 10.0.0.0/24\n")
|
||||
|
||||
success, message, value = security_manager.add_fail2ban_trusted_network("10.0.0.9/24")
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertIn("already trusted", message)
|
||||
self.assertEqual(value, "10.0.0.0/24")
|
||||
self.run_command.assert_not_called()
|
||||
|
||||
def test_address_already_covered_by_network_is_rejected(self):
|
||||
self.write_legacy("[DEFAULT]\nignoreip = 10.0.0.0/24\n")
|
||||
|
||||
success, message, value = security_manager.add_fail2ban_trusted_network("10.0.0.42")
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertIn("already trusted", message)
|
||||
self.assertEqual(value, "10.0.0.42")
|
||||
|
||||
def test_rejects_network_that_would_disable_all_bans(self):
|
||||
for value in ("0.0.0.0/0", "::/0"):
|
||||
with self.subTest(value=value):
|
||||
success, _, normalised = security_manager.add_fail2ban_trusted_network(value)
|
||||
self.assertFalse(success)
|
||||
self.assertIsNone(normalised)
|
||||
|
||||
def test_protected_network_cannot_be_removed(self):
|
||||
success, message = security_manager.remove_fail2ban_trusted_network("127.0.0.1/8")
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertIn("cannot be removed", message)
|
||||
self.run_command.assert_not_called()
|
||||
|
||||
def test_removes_user_network_and_keeps_other_entries(self):
|
||||
self.managed_file.parent.mkdir(parents=True)
|
||||
self.managed_file.write_text(
|
||||
"[DEFAULT]\nignoreip = 127.0.0.0/8 ::1 10.0.0.0/24 192.168.1.5\n"
|
||||
)
|
||||
|
||||
success, message = security_manager.remove_fail2ban_trusted_network("10.0.0.0/24")
|
||||
|
||||
self.assertTrue(success, message)
|
||||
content = self.managed_file.read_text()
|
||||
self.assertNotIn("10.0.0.0/24", content)
|
||||
self.assertIn("192.168.1.5", content)
|
||||
|
||||
def test_updates_user_network_in_place(self):
|
||||
self.managed_file.parent.mkdir(parents=True)
|
||||
self.managed_file.write_text(
|
||||
"[DEFAULT]\nignoreip = 127.0.0.0/8 ::1 10.0.0.0/24 192.168.1.5\n"
|
||||
)
|
||||
|
||||
success, message, value = security_manager.update_fail2ban_trusted_network(
|
||||
"10.0.0.0/24", "10.20.30.99/24"
|
||||
)
|
||||
|
||||
self.assertTrue(success, message)
|
||||
self.assertEqual(value, "10.20.30.0/24")
|
||||
content = self.managed_file.read_text()
|
||||
self.assertNotIn("10.0.0.0/24", content)
|
||||
self.assertIn("10.20.30.0/24", content)
|
||||
self.assertIn("192.168.1.5", content)
|
||||
|
||||
def test_protected_network_cannot_be_updated(self):
|
||||
success, message, value = security_manager.update_fail2ban_trusted_network(
|
||||
"::1", "::2"
|
||||
)
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertIn("cannot be changed", message)
|
||||
self.assertIsNone(value)
|
||||
|
||||
def test_reload_failure_restores_previous_file(self):
|
||||
self.managed_file.parent.mkdir(parents=True)
|
||||
original = "[DEFAULT]\nignoreip = 127.0.0.0/8 ::1 10.0.0.0/24\n"
|
||||
self.managed_file.write_text(original)
|
||||
self.run_command.side_effect = [
|
||||
(1, "", "configuration error"),
|
||||
(0, "OK", ""),
|
||||
]
|
||||
|
||||
success, message, _ = security_manager.add_fail2ban_trusted_network("192.168.1.0/24")
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertIn("configuration error", message)
|
||||
self.assertEqual(self.managed_file.read_text(), original)
|
||||
self.assertEqual(self.run_command.call_count, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,97 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from notification_templates import _format_vzdump_body, _parse_vzdump_message
|
||||
|
||||
|
||||
def _vzdump_table_row(vmid, name, status="OK", time_value="00:00:12", size="1.23 GiB"):
|
||||
filename = (
|
||||
f"/mnt/pve/fast-zfs-backup/dump/"
|
||||
f"vzdump-lxc-{vmid}-2026_08_09-04_00_00.tar.zst"
|
||||
)
|
||||
return "{:<8}{:<22}{:<10}{:<10}{:<14}{}".format(
|
||||
str(vmid),
|
||||
name,
|
||||
status,
|
||||
time_value,
|
||||
size,
|
||||
filename,
|
||||
)
|
||||
|
||||
|
||||
def _make_long_vzdump_report():
|
||||
header = "{:<8}{:<22}{:<10}{:<10}{:<14}{}".format(
|
||||
"VMID",
|
||||
"Name",
|
||||
"Status",
|
||||
"Time",
|
||||
"Size",
|
||||
"Filename",
|
||||
)
|
||||
rows = [_vzdump_table_row(100 + i, f"ct-{100 + i:03d}") for i in range(28)]
|
||||
dockflare_row = _vzdump_table_row(129, "dockflare")
|
||||
|
||||
message_prefix = (
|
||||
"Proxmox vzdump report\n\n"
|
||||
+ header
|
||||
+ "\n"
|
||||
+ "\n".join(rows)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
# Place the 4096-character cut right after the dockflare VMID + name
|
||||
# columns, before the Status/Time/Size/Filename columns. This reproduces
|
||||
# the production symptom: CT 129 appears as a failed backup with no detail.
|
||||
cut_at = len(message_prefix) + 8 + 22
|
||||
filler = "X" * max(0, 4096 - cut_at) + "\n"
|
||||
|
||||
return (
|
||||
filler
|
||||
+ message_prefix
|
||||
+ dockflare_row
|
||||
+ "\nTotal running time: 00:12:34\nTotal size: 42.0 GiB\n"
|
||||
)
|
||||
|
||||
|
||||
class VzdumpWebhookTruncationTests(unittest.TestCase):
|
||||
def test_truncating_vzdump_report_at_4096_can_create_false_failed_backup(self):
|
||||
full_message = _make_long_vzdump_report()
|
||||
truncated_message = full_message[:4096]
|
||||
|
||||
full_parsed = _parse_vzdump_message(full_message)
|
||||
full_body = _format_vzdump_body(full_parsed, True)
|
||||
truncated_parsed = _parse_vzdump_message(truncated_message)
|
||||
truncated_body = _format_vzdump_body(truncated_parsed, True)
|
||||
|
||||
full_dockflare = [
|
||||
vm for vm in full_parsed["vms"] if vm.get("vmid") == "129"
|
||||
][0]
|
||||
truncated_dockflare = [
|
||||
vm for vm in truncated_parsed["vms"] if vm.get("vmid") == "129"
|
||||
][0]
|
||||
|
||||
self.assertEqual(full_dockflare["status"], "OK")
|
||||
self.assertIn("✅ CT dockflare (129)", full_body)
|
||||
self.assertNotIn("failed", full_body)
|
||||
|
||||
self.assertEqual(truncated_dockflare["name"], "dockflare")
|
||||
self.assertEqual(truncated_dockflare["status"], "")
|
||||
self.assertIn("❌ dockflare (129)", truncated_body)
|
||||
self.assertIn("❌ 1 failed", truncated_body)
|
||||
|
||||
def test_webhook_handler_does_not_truncate_message_before_parsing(self):
|
||||
source = (SCRIPTS_DIR / "flask_notification_routes.py").read_text()
|
||||
|
||||
self.assertNotIn("payload['message'] = message[:4096]", source)
|
||||
self.assertNotIn('payload["message"] = message[:4096]', source)
|
||||
self.assertIn("Keep the full webhook body for downstream parsers", source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.2.4.0
|
||||
1.2.4.0
|
||||
|
||||
@@ -360,7 +360,8 @@ select_language() {
|
||||
"fr" "French" \
|
||||
"de" "German" \
|
||||
"it" "Italian" \
|
||||
"pt" "Portuguese" 3>&1 1>&2 2>&3)
|
||||
"pt" "Portuguese" \
|
||||
"sk" "Slovenčina" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$LANGUAGE" ]; then
|
||||
msg_error "No language selected. Exiting."
|
||||
|
||||
@@ -558,7 +558,8 @@ select_language() {
|
||||
"fr" "French" \
|
||||
"de" "German" \
|
||||
"it" "Italian" \
|
||||
"pt" "Portuguese" 3>&1 1>&2 2>&3)
|
||||
"pt" "Portuguese" \
|
||||
"sk" "Slovenčina" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$LANGUAGE" ]; then
|
||||
msg_error "No language selected. Exiting."
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
{
|
||||
"adguard": {
|
||||
"binary_path": "/opt/AdGuardHome/AdGuardHome",
|
||||
"default_ports": [
|
||||
80
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "binary",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/adguard-home.webp",
|
||||
"repo": "AdguardTeam/AdGuardHome",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://adguard.com/en/adguard-home/overview.html"
|
||||
},
|
||||
"agentdvr": {
|
||||
"default_ports": [
|
||||
8090
|
||||
],
|
||||
"file_path": "/root/.agentdvr",
|
||||
"file_regex": "Agent_[^/]+_([0-9]+(?:_[0-9]+){3})\\.zip",
|
||||
"github_source": "releases",
|
||||
"installed_via": "file",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/agent-dvr.webp",
|
||||
"repo": "ispysoftware/agent-install-scripts",
|
||||
"tag_regex": "v?(\\d+(?:\\.\\d+){3})",
|
||||
"website": "https://www.ispyconnect.com/"
|
||||
},
|
||||
"audiobookshelf": {
|
||||
"default_ports": [
|
||||
13378
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/audiobookshelf.webp",
|
||||
"package": "audiobookshelf",
|
||||
"repo": "advplyr/audiobookshelf",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://www.audiobookshelf.org/"
|
||||
},
|
||||
"cloudflared": {
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/cloudflare.webp",
|
||||
"package": "cloudflared",
|
||||
"repo": "cloudflare/cloudflared",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://www.cloudflare.com/"
|
||||
},
|
||||
"cockpit": {
|
||||
"default_ports": [
|
||||
9090
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/cockpit.webp",
|
||||
"package": "cockpit",
|
||||
"repo": "cockpit-project/cockpit",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://cockpit-project.org/"
|
||||
},
|
||||
"ddclient": {
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/ddclient.webp",
|
||||
"package": "ddclient",
|
||||
"repo": "ddclient/ddclient",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://ddclient.net/"
|
||||
},
|
||||
"docker": {
|
||||
"binary_path": "/usr/bin/docker",
|
||||
"installed_via": "binary",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/docker.webp",
|
||||
"repo": "moby/moby",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://docs.docker.com/engine/"
|
||||
},
|
||||
"docmost": {
|
||||
"default_ports": [
|
||||
3000
|
||||
],
|
||||
"file_fallbacks": [
|
||||
{
|
||||
"path": "/root/.docmost",
|
||||
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
],
|
||||
"file_path": "/opt/docmost/package.json",
|
||||
"file_regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\"",
|
||||
"github_source": "releases",
|
||||
"installed_via": "file",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/docmost.webp",
|
||||
"repo": "docmost/docmost",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://docmost.com/"
|
||||
},
|
||||
"emby": {
|
||||
"binary_path": "/opt/emby-server/bin/emby-server",
|
||||
"default_ports": [
|
||||
8096
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "binary",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/emby.webp",
|
||||
"repo": "MediaBrowser/Emby.Releases",
|
||||
"tag_regex": "(\\d+\\.\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://emby.media/"
|
||||
},
|
||||
"evcc": {
|
||||
"default_ports": [
|
||||
7070
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/evcc.webp",
|
||||
"package": "evcc",
|
||||
"repo": "evcc-io/evcc",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://evcc.io/en/"
|
||||
},
|
||||
"globaleaks": {
|
||||
"default_ports": [
|
||||
443
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/globaleaks.webp",
|
||||
"package": "globaleaks",
|
||||
"repo": "globaleaks/globaleaks-whistleblowing-software",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://www.globaleaks.org/"
|
||||
},
|
||||
"grafana": {
|
||||
"alt_detectors": [
|
||||
{
|
||||
"binary_args": [
|
||||
"server",
|
||||
"-v"
|
||||
],
|
||||
"binary_path": "grafana",
|
||||
"container_name": "grafana",
|
||||
"installed_via": "docker_exec"
|
||||
}
|
||||
],
|
||||
"default_ports": [
|
||||
3000
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/grafana.webp",
|
||||
"package": "grafana",
|
||||
"repo": "grafana/grafana",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://grafana.com/"
|
||||
},
|
||||
"homebridge": {
|
||||
"default_ports": [
|
||||
8581
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/homebridge.webp",
|
||||
"package": "homebridge",
|
||||
"repo": "homebridge/homebridge",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://homebridge.io/"
|
||||
},
|
||||
"hyperhdr": {
|
||||
"default_ports": [
|
||||
8090
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/hyperhdr.webp",
|
||||
"package": "hyperhdr",
|
||||
"repo": "awawa-dev/HyperHDR",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://github.com/awawa-dev/HyperHDR"
|
||||
},
|
||||
"hyperion": {
|
||||
"default_ports": [
|
||||
8090
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/hyperion.webp",
|
||||
"package": "hyperion",
|
||||
"repo": "hyperion-project/hyperion.ng",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://hyperion-project.org/forum/"
|
||||
},
|
||||
"infisical": {
|
||||
"default_ports": [
|
||||
8080
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/infisical.webp",
|
||||
"package": "infisical-core",
|
||||
"repo": "Infisical/infisical",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://infisical.com/"
|
||||
},
|
||||
"influxdb": {
|
||||
"default_ports": [
|
||||
8086
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/influxdb.webp",
|
||||
"package": "influxdb",
|
||||
"repo": "influxdata/influxdb",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://www.influxdata.com/"
|
||||
},
|
||||
"inventree": {
|
||||
"default_ports": [
|
||||
80
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/inventree.webp",
|
||||
"package": "inventree",
|
||||
"repo": "inventree/InvenTree",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://inventree.org"
|
||||
},
|
||||
"jellyfin": {
|
||||
"default_ports": [
|
||||
8096
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/jellyfin.webp",
|
||||
"package": "jellyfin",
|
||||
"repo": "jellyfin/jellyfin",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://jellyfin.org/"
|
||||
},
|
||||
"jenkins": {
|
||||
"default_ports": [
|
||||
8080
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/jenkins.webp",
|
||||
"package": "jenkins",
|
||||
"repo": "jenkinsci/jenkins",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://www.jenkins.io/"
|
||||
},
|
||||
"kiwix": {
|
||||
"default_ports": [
|
||||
8080
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/kiwix.webp",
|
||||
"package": "kiwix-tools",
|
||||
"repo": "kiwix/kiwix-tools",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://www.kiwix.org"
|
||||
},
|
||||
"lldap": {
|
||||
"default_ports": [
|
||||
17170
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/lldap.webp",
|
||||
"package": "lldap",
|
||||
"repo": "lldap/lldap",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://github.com/lldap/lldap"
|
||||
},
|
||||
"loki": {
|
||||
"default_ports": [
|
||||
3100
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/loki.webp",
|
||||
"package": "loki",
|
||||
"repo": "grafana/loki",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://github.com/grafana/loki"
|
||||
},
|
||||
"mattermost": {
|
||||
"default_ports": [
|
||||
8065
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/mattermost.webp",
|
||||
"package": "mattermost",
|
||||
"repo": "mattermost/mattermost",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://mattermost.com/"
|
||||
},
|
||||
"neo4j": {
|
||||
"default_ports": [
|
||||
7474
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/neo4j.webp",
|
||||
"package": "neo4j",
|
||||
"repo": "neo4j/neo4j",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://neo4j.com/product/neo4j-graph-database/"
|
||||
},
|
||||
"netbird": {
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/netbird.webp",
|
||||
"package": "netbird",
|
||||
"repo": "netbirdio/netbird",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://netbird.io/"
|
||||
},
|
||||
"nginxproxymanager": {
|
||||
"default_ports": [
|
||||
81
|
||||
],
|
||||
"file_fallbacks": [
|
||||
{
|
||||
"path": "/app/package.json",
|
||||
"regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\""
|
||||
},
|
||||
{
|
||||
"path": "/root/.nginxproxymanager",
|
||||
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
],
|
||||
"file_path": "/opt/nginxproxymanager/backend/package.json",
|
||||
"file_regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\"",
|
||||
"github_source": "releases",
|
||||
"installed_via": "file",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/nginx-proxy-manager.webp",
|
||||
"repo": "NginxProxyManager/nginx-proxy-manager",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://nginxproxymanager.com/"
|
||||
},
|
||||
"notifiarr": {
|
||||
"default_ports": [
|
||||
5454
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/notifiarr.webp",
|
||||
"package": "notifiarr",
|
||||
"repo": "Notifiarr/notifiarr",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://notifiarr.com/"
|
||||
},
|
||||
"ntfy": {
|
||||
"default_ports": [
|
||||
80
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/ntfy.webp",
|
||||
"package": "ntfy",
|
||||
"repo": "binwiederhier/ntfy",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://ntfy.sh/"
|
||||
},
|
||||
"nzbget": {
|
||||
"default_ports": [
|
||||
6789
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/nzbget.webp",
|
||||
"package": "nzbget",
|
||||
"repo": "nzbget/nzbget",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://nzbget.com/"
|
||||
},
|
||||
"odoo": {
|
||||
"alt_detectors": [
|
||||
{
|
||||
"binary_path": "/usr/bin/odoo",
|
||||
"installed_via": "binary"
|
||||
}
|
||||
],
|
||||
"default_ports": [
|
||||
8069
|
||||
],
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/odoo.webp",
|
||||
"package": "odoo",
|
||||
"tag_regex": "(\\d+\\.\\d+(?:\\.\\d+)?)",
|
||||
"website": "https://www.odoo.com/"
|
||||
},
|
||||
"onlyoffice": {
|
||||
"default_ports": [
|
||||
80
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/onlyoffice.webp",
|
||||
"package": "onlyoffice-documentserver",
|
||||
"repo": "ONLYOFFICE/DocumentServer",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://onlyoffice.com/"
|
||||
},
|
||||
"openproject": {
|
||||
"default_ports": [
|
||||
80
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openproject.webp",
|
||||
"package": "openproject",
|
||||
"repo": "opf/openproject",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://www.openproject.org"
|
||||
},
|
||||
"openwebui": {
|
||||
"default_ports": [
|
||||
8080
|
||||
],
|
||||
"distribution": "open-webui",
|
||||
"github_source": "releases",
|
||||
"installed_via": "python_dist",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/open-webui.webp",
|
||||
"python_path": "/root/.local/share/uv/tools/open-webui/bin/python",
|
||||
"repo": "open-webui/open-webui",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://openwebui.com/"
|
||||
},
|
||||
"openziti-controller": {
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/openziti.webp",
|
||||
"package": "openziti-controller",
|
||||
"repo": "openziti/ziti",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://www.openziti.io/"
|
||||
},
|
||||
"pairdrop": {
|
||||
"default_ports": [
|
||||
3000
|
||||
],
|
||||
"file_fallbacks": [
|
||||
{
|
||||
"path": "/root/.pairdrop",
|
||||
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
],
|
||||
"file_path": "/opt/pairdrop/package.json",
|
||||
"file_regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\"",
|
||||
"github_source": "releases",
|
||||
"installed_via": "file",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/pairdrop.webp",
|
||||
"repo": "schlagmichdoch/PairDrop",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://github.com/schlagmichdoch/PairDrop"
|
||||
},
|
||||
"paperless-ngx": {
|
||||
"alt_detectors": [
|
||||
{
|
||||
"file_path": "/opt/paperless/src/paperless/version.py",
|
||||
"file_regex": "__version__[^\\n=]*=\\s*\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\)",
|
||||
"installed_via": "file"
|
||||
}
|
||||
],
|
||||
"container_name": "paperless-webserver-1",
|
||||
"default_ports": [
|
||||
8000
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "docker_label",
|
||||
"label": "org.opencontainers.image.version",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/paperless-ngx.webp",
|
||||
"repo": "paperless-ngx/paperless-ngx",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://docs.paperless-ngx.com/"
|
||||
},
|
||||
"photoprism": {
|
||||
"binary_path": "/opt/photoprism/bin/photoprism",
|
||||
"default_ports": [
|
||||
2342
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "binary",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/photoprism.webp",
|
||||
"repo": "photoprism/photoprism",
|
||||
"tag_regex": "(\\d{6})",
|
||||
"website": "https://photoprism.app/"
|
||||
},
|
||||
"pihole": {
|
||||
"binary_path": "/usr/local/bin/pihole",
|
||||
"default_ports": [
|
||||
80
|
||||
],
|
||||
"installed_via": "binary",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/pi-hole.webp",
|
||||
"repo": "pi-hole/pi-hole",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://pi-hole.net/"
|
||||
},
|
||||
"plex": {
|
||||
"default_ports": [
|
||||
32400
|
||||
],
|
||||
"installed_via": "dpkg",
|
||||
"package": "plexmediaserver",
|
||||
"installed_regex": "(\\d+\\.\\d+\\.\\d+\\.\\d+)",
|
||||
"upstream_type": "http_json",
|
||||
"upstream_url": "https://plex.tv/api/downloads/5.json?channel=8",
|
||||
"upstream_json_path": "computer.Linux.version",
|
||||
"tag_regex": "(\\d+\\.\\d+\\.\\d+\\.\\d+)",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/plex.webp",
|
||||
"website": "https://www.plex.tv/"
|
||||
},
|
||||
"podman": {
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/podman.webp",
|
||||
"package": "podman",
|
||||
"repo": "containers/podman",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://podman.io/"
|
||||
},
|
||||
"prometheus": {
|
||||
"alt_detectors": [
|
||||
{
|
||||
"binary_path": "/usr/local/bin/prometheus",
|
||||
"installed_via": "binary"
|
||||
}
|
||||
],
|
||||
"binary_args": [
|
||||
"--version"
|
||||
],
|
||||
"binary_path": "/bin/prometheus",
|
||||
"container_name": "prometheus",
|
||||
"default_ports": [
|
||||
9090
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "docker_exec",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/prometheus.webp",
|
||||
"repo": "prometheus/prometheus",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://prometheus.io/"
|
||||
},
|
||||
"proxmox-backup-server": {
|
||||
"default_ports": [
|
||||
8007
|
||||
],
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/proxmox.webp",
|
||||
"package": "proxmox-backup-server",
|
||||
"tag_regex": "(\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://www.proxmox.com/en/proxmox-backup-server/overview"
|
||||
},
|
||||
"qbittorrent": {
|
||||
"binary_path": "/opt/qbittorrent/qbittorrent-nox",
|
||||
"default_ports": [
|
||||
8090
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "binary",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/qbittorrent.webp",
|
||||
"repo": "userdocs/qbittorrent-nox-static",
|
||||
"tag_regex": "(?i)(?:release-)?(\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://www.qbittorrent.org/"
|
||||
},
|
||||
"rabbitmq": {
|
||||
"default_ports": [
|
||||
15672
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/rabbitmq.webp",
|
||||
"package": "rabbitmq-server",
|
||||
"repo": "rabbitmq/rabbitmq-server",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://www.rabbitmq.com/"
|
||||
},
|
||||
"redis": {
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/redis.webp",
|
||||
"package": "redis",
|
||||
"repo": "redis/redis",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://redis.io/"
|
||||
},
|
||||
"sftpgo": {
|
||||
"default_ports": [
|
||||
8080
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/sftpgo.webp",
|
||||
"package": "sftpgo",
|
||||
"repo": "drakkan/sftpgo",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://github.com/drakkan/sftpgo"
|
||||
},
|
||||
"smokeping": {
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/smokeping.webp",
|
||||
"package": "smokeping",
|
||||
"repo": "oetiker/SmokePing",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://oss.oetiker.ch/smokeping/"
|
||||
},
|
||||
"squid": {
|
||||
"default_ports": [
|
||||
3128
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/squid.webp",
|
||||
"package": "squid",
|
||||
"repo": "squid-cache/squid",
|
||||
"tag_regex": "(?i)(?:SQUID_)?(\\d+(?:[._]\\d+){1,3})",
|
||||
"website": "https://www.squid-cache.org/"
|
||||
},
|
||||
"step-ca": {
|
||||
"default_ports": [
|
||||
443
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/step-ca.webp",
|
||||
"package": "step-ca",
|
||||
"repo": "smallstep/certificates",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://github.com/smallstep/certificates"
|
||||
},
|
||||
"syncthing": {
|
||||
"default_ports": [
|
||||
8384
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/syncthing.webp",
|
||||
"package": "syncthing",
|
||||
"repo": "syncthing/syncthing",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://syncthing.net/"
|
||||
},
|
||||
"tandoor": {
|
||||
"default_ports": [
|
||||
8002
|
||||
],
|
||||
"file_fallbacks": [
|
||||
{
|
||||
"path": "/root/.tandoor",
|
||||
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
],
|
||||
"file_path": "/opt/tandoor/cookbook/version_info.py",
|
||||
"file_regex": "TANDOOR_VERSION\\s*=\\s*[\"']v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"github_source": "releases",
|
||||
"installed_via": "file",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/tandoor-recipes.webp",
|
||||
"repo": "TandoorRecipes/recipes",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://tandoor.dev/"
|
||||
},
|
||||
"telegraf": {
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/telegraf.webp",
|
||||
"package": "telegraf",
|
||||
"repo": "influxdata/telegraf",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://github.com/influxdata/telegraf"
|
||||
},
|
||||
"teleport": {
|
||||
"default_ports": [
|
||||
3080
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/teleport.webp",
|
||||
"package": "teleport",
|
||||
"repo": "gravitational/teleport",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://goteleport.com/"
|
||||
},
|
||||
"unbound": {
|
||||
"default_ports": [
|
||||
5335
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/unbound.webp",
|
||||
"package": "unbound",
|
||||
"repo": "NLnetLabs/unbound",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://www.nlnetlabs.nl/projects/unbound/about/"
|
||||
},
|
||||
"urbackupserver": {
|
||||
"default_ports": [
|
||||
55414
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/urbackup.webp",
|
||||
"package": "urbackup-server",
|
||||
"repo": "uroni/urbackup_backend",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://www.urbackup.org/"
|
||||
},
|
||||
"valkey": {
|
||||
"default_ports": [
|
||||
6379
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/valkey.webp",
|
||||
"package": "valkey",
|
||||
"repo": "valkey-io/valkey",
|
||||
"tag_regex": "(?i)(?:v|release[-_/]?)?(\\d+(?:\\.\\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)",
|
||||
"website": "https://valkey.io/"
|
||||
},
|
||||
"vaultwarden": {
|
||||
"alt_detectors": [
|
||||
{
|
||||
"file_path": "/root/.vaultwarden",
|
||||
"file_regex": "v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"installed_via": "file"
|
||||
}
|
||||
],
|
||||
"binary_path": "/opt/vaultwarden/bin/vaultwarden",
|
||||
"default_ports": [
|
||||
8000
|
||||
],
|
||||
"github_source": "releases",
|
||||
"installed_via": "binary",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/vaultwarden.webp",
|
||||
"repo": "dani-garcia/vaultwarden",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://github.com/dani-garcia/vaultwarden/"
|
||||
},
|
||||
"wireguard": {
|
||||
"installed_via": "dpkg",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/wireguard.webp",
|
||||
"package": "wireguard-tools",
|
||||
"repo": "WireGuard/wireguard-tools",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"website": "https://www.wireguard.com/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"observed_at": "2026-08-06",
|
||||
"notes": "Runtime-verified detectors for the LXC app catalog. Every entry is a detector proven to work on a real container. Contribution rules \u2014 accept ONLY: `detector` (required, with installed_via + method-specific fields + repo/tag_regex), optional `alt_detectors` (cross-method fallbacks), optional `file_fallbacks` (same-method secondary paths). REJECT anything that identifies a host: no IP addresses, no VMIDs, no hostnames, no `evidence` blocks with those fields. Report reproduction context in the PR description instead \u2014 the committed JSON must stay generic.",
|
||||
"apps": {
|
||||
"qbittorrent": {
|
||||
"detector": {
|
||||
"installed_via": "binary",
|
||||
"binary_path": "/opt/qbittorrent/qbittorrent-nox",
|
||||
"repo": "userdocs/qbittorrent-nox-static",
|
||||
"github_source": "releases",
|
||||
"tag_regex": "(?i)(?:release-)?(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
},
|
||||
"tandoor": {
|
||||
"detector": {
|
||||
"installed_via": "file",
|
||||
"file_path": "/opt/tandoor/cookbook/version_info.py",
|
||||
"file_regex": "TANDOOR_VERSION\\s*=\\s*[\"']v?(\\d+\\.\\d+\\.\\d+)",
|
||||
"repo": "TandoorRecipes/recipes",
|
||||
"github_source": "releases",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
},
|
||||
"file_fallbacks": [
|
||||
{
|
||||
"path": "/root/.tandoor",
|
||||
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"openwebui": {
|
||||
"operational": false,
|
||||
"remove_from_v1": true,
|
||||
"detector": {
|
||||
"installed_via": "python_dist",
|
||||
"python_path": "/root/.local/share/uv/tools/open-webui/bin/python",
|
||||
"distribution": "open-webui",
|
||||
"repo": "open-webui/open-webui",
|
||||
"github_source": "releases",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
},
|
||||
"proxmox-backup-server": {
|
||||
"detector": {
|
||||
"installed_via": "dpkg",
|
||||
"package": "proxmox-backup-server",
|
||||
"tag_regex": "(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
},
|
||||
"adguard": {
|
||||
"detector": {
|
||||
"installed_via": "binary",
|
||||
"binary_path": "/opt/AdGuardHome/AdGuardHome",
|
||||
"repo": "AdguardTeam/AdGuardHome",
|
||||
"github_source": "releases",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
},
|
||||
"nginxproxymanager": {
|
||||
"detector": {
|
||||
"installed_via": "file",
|
||||
"file_path": "/opt/nginxproxymanager/backend/package.json",
|
||||
"file_regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\"",
|
||||
"repo": "NginxProxyManager/nginx-proxy-manager",
|
||||
"github_source": "releases",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
},
|
||||
"file_fallbacks": [
|
||||
{
|
||||
"path": "/app/package.json",
|
||||
"regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\""
|
||||
},
|
||||
{
|
||||
"path": "/root/.nginxproxymanager",
|
||||
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pairdrop": {
|
||||
"detector": {
|
||||
"installed_via": "file",
|
||||
"file_path": "/opt/pairdrop/package.json",
|
||||
"file_regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\"",
|
||||
"repo": "schlagmichdoch/PairDrop",
|
||||
"github_source": "releases",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
},
|
||||
"file_fallbacks": [
|
||||
{
|
||||
"path": "/root/.pairdrop",
|
||||
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"vaultwarden": {
|
||||
"detector": {
|
||||
"installed_via": "binary",
|
||||
"binary_path": "/opt/vaultwarden/bin/vaultwarden",
|
||||
"repo": "dani-garcia/vaultwarden",
|
||||
"github_source": "releases",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
},
|
||||
"alt_detectors": [
|
||||
{
|
||||
"installed_via": "file",
|
||||
"file_path": "/root/.vaultwarden",
|
||||
"file_regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"docmost": {
|
||||
"detector": {
|
||||
"installed_via": "file",
|
||||
"file_path": "/opt/docmost/package.json",
|
||||
"file_regex": "\"version\"\\s*:\\s*\"(\\d+\\.\\d+\\.\\d+)\"",
|
||||
"repo": "docmost/docmost",
|
||||
"github_source": "releases",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
},
|
||||
"file_fallbacks": [
|
||||
{
|
||||
"path": "/root/.docmost",
|
||||
"regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"agentdvr": {
|
||||
"detector": {
|
||||
"installed_via": "file",
|
||||
"file_path": "/root/.agentdvr",
|
||||
"file_regex": "Agent_[^/]+_([0-9]+(?:_[0-9]+){3})\\.zip",
|
||||
"repo": "ispysoftware/agent-install-scripts",
|
||||
"github_source": "releases",
|
||||
"tag_regex": "v?(\\d+(?:\\.\\d+){3})"
|
||||
}
|
||||
},
|
||||
"odoo": {
|
||||
"detector": {
|
||||
"installed_via": "dpkg",
|
||||
"package": "odoo",
|
||||
"tag_regex": "(\\d+\\.\\d+(?:\\.\\d+)?)"
|
||||
},
|
||||
"alt_detectors": [
|
||||
{
|
||||
"installed_via": "binary",
|
||||
"binary_path": "/usr/bin/odoo"
|
||||
}
|
||||
]
|
||||
},
|
||||
"paperless-ngx": {
|
||||
"operational": false,
|
||||
"detector": {
|
||||
"installed_via": "docker_label",
|
||||
"container_name": "paperless-webserver-1",
|
||||
"label": "org.opencontainers.image.version",
|
||||
"repo": "paperless-ngx/paperless-ngx",
|
||||
"github_source": "releases",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
},
|
||||
"alt_detectors": [
|
||||
{
|
||||
"installed_via": "file",
|
||||
"file_path": "/opt/paperless/src/paperless/version.py",
|
||||
"file_regex": "__version__[^\\n=]*=\\s*\\((\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"plex": {
|
||||
"operational": true,
|
||||
"detector": {
|
||||
"installed_via": "dpkg",
|
||||
"package": "plexmediaserver",
|
||||
"installed_regex": "(\\d+\\.\\d+\\.\\d+\\.\\d+)",
|
||||
"upstream_type": "http_json",
|
||||
"upstream_url": "https://plex.tv/api/downloads/5.json?channel=8",
|
||||
"upstream_json_path": "computer.Linux.version",
|
||||
"tag_regex": "(\\d+\\.\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
},
|
||||
"prometheus": {
|
||||
"operational": false,
|
||||
"detector": {
|
||||
"installed_via": "docker_exec",
|
||||
"container_name": "prometheus",
|
||||
"binary_path": "/bin/prometheus",
|
||||
"binary_args": [
|
||||
"--version"
|
||||
],
|
||||
"repo": "prometheus/prometheus",
|
||||
"github_source": "releases",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
},
|
||||
"grafana": {
|
||||
"operational": false,
|
||||
"detector": {
|
||||
"installed_via": "docker_exec",
|
||||
"container_name": "grafana",
|
||||
"binary_path": "grafana",
|
||||
"binary_args": [
|
||||
"server",
|
||||
"-v"
|
||||
],
|
||||
"repo": "grafana/grafana",
|
||||
"github_source": "releases",
|
||||
"tag_regex": "v?(\\d+\\.\\d+\\.\\d+)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -452,6 +452,7 @@
|
||||
"Cannot proceed with invalid export path.": "Mit ungültigem Exportpfad kann nicht fortgefahren werden.",
|
||||
"Cannot proceed with invalid share name.": "Mit ungültigem Freigabenamen kann nicht fortgefahren werden.",
|
||||
"Cannot reach Proxmox repositories": "Proxmox-Repositorys können nicht erreicht werden",
|
||||
"Cannot reach https://download.proxmox.com (HTTPS 443). Check network, proxy or DNS.": "https://download.proxmox.com (HTTPS 443) kann nicht erreicht werden.Überprüfen Sie Netzwerk, Proxy oder DNS.",
|
||||
"Cannot reach portal:": "Portal kann nicht erreicht werden:",
|
||||
"Cannot reach server": "Server ist nicht erreichbar",
|
||||
"Cannot validate credentials - no shares available for testing.": "Anmeldeinformationen können nicht validiert werden – keine Freigaben zum Testen verfügbar.",
|
||||
@@ -925,7 +926,6 @@
|
||||
"Could not unmount — disk may be busy. Removing fstab entry anyway.": "Die Bereitstellung konnte nicht aufgehoben werden – die Festplatte ist möglicherweise ausgelastet. Fstab-Eintrag trotzdem entfernen.",
|
||||
"Could not update config file.": "Die Konfigurationsdatei konnte nicht aktualisiert werden.",
|
||||
"Could not write to:": "Es konnte nicht geschrieben werden an:",
|
||||
"Create": "Erstellen",
|
||||
"Create Directory": "Verzeichnis erstellen",
|
||||
"Create GPT and one partition:": "Erstellen Sie GPT und eine Partition:",
|
||||
"Create GPT partition": "Erstellen Sie eine GPT-Partition",
|
||||
@@ -936,6 +936,11 @@
|
||||
"Create Shared Directory": "Erstellen Sie ein freigegebenes Verzeichnis",
|
||||
"Create Shared Directory on Host": "Erstellen Sie ein freigegebenes Verzeichnis auf dem Host",
|
||||
"Create Universal NFS Export": "Erstellen Sie einen universellen NFS-Export",
|
||||
"Create VM System Linux": "Linux-VM erstellen",
|
||||
"Create VM System NAS": "VM für NAS erstellen",
|
||||
"Create VM System Others (based Linux)": "VM für ein anderes Linux-basiertes System erstellen",
|
||||
"Create VM System Windows": "Windows-VM erstellen",
|
||||
"Create VM System macOS (OSX-PROXMOX)": "macOS-VM erstellen (OSX-PROXMOX)",
|
||||
"Create VM from template or script": "Erstellen Sie eine VM aus einer Vorlage oder einem Skript",
|
||||
"Create a VM first (machine type q35 + UEFI BIOS), then run this option again.": "Erstellen Sie zuerst eine VM (Maschinentyp q35 + UEFI-BIOS) und führen Sie diese Option dann erneut aus.",
|
||||
"Create a backup of the container configuration:": "Erstellen Sie ein Backup der Containerkonfiguration:",
|
||||
@@ -4200,6 +4205,7 @@
|
||||
"Swap partition detected": "Swap-Partition erkannt",
|
||||
"Swappiness configuration created successfully": "Swappiness-Konfiguration erfolgreich erstellt",
|
||||
"Switch GPU Mode (VM <-> LXC)": "GPU-Modus wechseln (VM <-> LXC)",
|
||||
"Switch Mode": "Modus wechseln",
|
||||
"Switch Script Not Found": "Switch-Skript nicht gefunden",
|
||||
"Switch to GPU -> LXC (native driver mode)": "Wechseln Sie zu GPU -> LXC (nativer Treibermodus)",
|
||||
"Switch to GPU -> VM (VFIO passthrough mode)": "Wechseln Sie zu GPU -> VM (VFIO-Passthrough-Modus)",
|
||||
@@ -4671,6 +4677,7 @@
|
||||
"Using advanced configuration": "Verwenden der erweiterten Konfiguration",
|
||||
"Using default Proxmox logo...": "Standardmäßiges Proxmox-Logo wird verwendet...",
|
||||
"Using existing encryption key:": "Verwendung des vorhandenen Verschlüsselungsschlüssels:",
|
||||
"Utilities": "Dienstprogramme",
|
||||
"Utilities Installation Menu": "Installationsmenü für Dienstprogramme",
|
||||
"Utilities Menu": "Menü „Dienstprogramme“.",
|
||||
"Utilities Verification": "Überprüfung der Dienstprogramme",
|
||||
|
||||
+8
-1
@@ -452,6 +452,7 @@
|
||||
"Cannot proceed with invalid export path.": "No se puede continuar con una ruta de exportación no válida.",
|
||||
"Cannot proceed with invalid share name.": "No se puede continuar con un nombre compartido no válido.",
|
||||
"Cannot reach Proxmox repositories": "No se puede acceder a los repositorios de Proxmox",
|
||||
"Cannot reach https://download.proxmox.com (HTTPS 443). Check network, proxy or DNS.": "No se puede acceder a https://download.proxmox.com (HTTPS 443).Verifique la red, proxy o DNS.",
|
||||
"Cannot reach portal:": "No se puede acceder al portal:",
|
||||
"Cannot reach server": "No puede alcanzar el servidor",
|
||||
"Cannot validate credentials - no shares available for testing.": "No se pueden validar las credenciales: no hay recursos compartidos disponibles para realizar pruebas.",
|
||||
@@ -925,7 +926,6 @@
|
||||
"Could not unmount — disk may be busy. Removing fstab entry anyway.": "No se pudo desmontar: es posible que el disco esté ocupado. Eliminando la entrada fstab de todos modos.",
|
||||
"Could not update config file.": "No se pudo actualizar el archivo de configuración.",
|
||||
"Could not write to:": "No se pudo escribir a:",
|
||||
"Create": "Crear",
|
||||
"Create Directory": "Crear directorio",
|
||||
"Create GPT and one partition:": "Crea GPT y una partición:",
|
||||
"Create GPT partition": "Crear partición GPT",
|
||||
@@ -936,6 +936,11 @@
|
||||
"Create Shared Directory": "Crear directorio compartido",
|
||||
"Create Shared Directory on Host": "Crear directorio compartido en el host",
|
||||
"Create Universal NFS Export": "Crear exportación NFS universal",
|
||||
"Create VM System Linux": "Crear VM con Linux",
|
||||
"Create VM System NAS": "Crear VM para NAS",
|
||||
"Create VM System Others (based Linux)": "Crear VM para otro sistema basado en Linux",
|
||||
"Create VM System Windows": "Crear VM con Windows",
|
||||
"Create VM System macOS (OSX-PROXMOX)": "Crear VM con macOS (OSX-PROXMOX)",
|
||||
"Create VM from template or script": "Crear VM a partir de una plantilla o script",
|
||||
"Create a VM first (machine type q35 + UEFI BIOS), then run this option again.": "Primero cree una máquina virtual (tipo de máquina q35 + UEFI BIOS), luego ejecute esta opción nuevamente.",
|
||||
"Create a backup of the container configuration:": "Cree una copia de seguridad de la configuración del contenedor:",
|
||||
@@ -4200,6 +4205,7 @@
|
||||
"Swap partition detected": "Intercambiar partición detectada",
|
||||
"Swappiness configuration created successfully": "Configuración de intercambio creada exitosamente",
|
||||
"Switch GPU Mode (VM <-> LXC)": "Cambiar el modo GPU (VM <-> LXC)",
|
||||
"Switch Mode": "Switch Mode",
|
||||
"Switch Script Not Found": "Script de cambio no encontrado",
|
||||
"Switch to GPU -> LXC (native driver mode)": "Cambie a GPU -> LXC (modo de controlador nativo)",
|
||||
"Switch to GPU -> VM (VFIO passthrough mode)": "Cambie a GPU -> VM (modo de paso VFIO)",
|
||||
@@ -4671,6 +4677,7 @@
|
||||
"Using advanced configuration": "Usando configuración avanzada",
|
||||
"Using default Proxmox logo...": "Usando el logotipo predeterminado de Proxmox...",
|
||||
"Using existing encryption key:": "Usando la clave de cifrado existente:",
|
||||
"Utilities": "Utilidades",
|
||||
"Utilities Installation Menu": "Menú de instalación de utilidades",
|
||||
"Utilities Menu": "Menú de utilidades",
|
||||
"Utilities Verification": "Comprobación de utilidades",
|
||||
|
||||
+8
-1
@@ -452,6 +452,7 @@
|
||||
"Cannot proceed with invalid export path.": "Impossible de poursuivre avec un chemin d'exportation non valide.",
|
||||
"Cannot proceed with invalid share name.": "Impossible de continuer avec un nom de partage invalide.",
|
||||
"Cannot reach Proxmox repositories": "Impossible d'accéder aux référentiels Proxmox",
|
||||
"Cannot reach https://download.proxmox.com (HTTPS 443). Check network, proxy or DNS.": "Impossible d'accéder à https://download.proxmox.com (HTTPS 443).Vérifiez le réseau, le proxy ou le DNS.",
|
||||
"Cannot reach portal:": "Impossible d'accéder au portail :",
|
||||
"Cannot reach server": "Ne peut pas atteindre le serveur",
|
||||
"Cannot validate credentials - no shares available for testing.": "Impossible de valider les informations d'identification - aucun partage disponible pour les tests.",
|
||||
@@ -925,7 +926,6 @@
|
||||
"Could not unmount — disk may be busy. Removing fstab entry anyway.": "Impossible de démonter : le disque est peut-être occupé. Suppression de l'entrée fstab de toute façon.",
|
||||
"Could not update config file.": "Impossible de mettre à jour le fichier de configuration.",
|
||||
"Could not write to:": "impossible d'écrire vers :",
|
||||
"Create": "Créer",
|
||||
"Create Directory": "Créer un répertoire",
|
||||
"Create GPT and one partition:": "Créez GPT et une partition :",
|
||||
"Create GPT partition": "Créer une partition GPT",
|
||||
@@ -936,6 +936,11 @@
|
||||
"Create Shared Directory": "Créer un répertoire partagé",
|
||||
"Create Shared Directory on Host": "Créer un répertoire partagé sur l'hôte",
|
||||
"Create Universal NFS Export": "Créer une exportation NFS universelle",
|
||||
"Create VM System Linux": "Créer une VM Linux",
|
||||
"Create VM System NAS": "Créer une VM pour NAS",
|
||||
"Create VM System Others (based Linux)": "Créer une VM pour un autre système basé sur Linux",
|
||||
"Create VM System Windows": "Créer une VM Windows",
|
||||
"Create VM System macOS (OSX-PROXMOX)": "Créer une VM macOS (OSX-PROXMOX)",
|
||||
"Create VM from template or script": "Créer une VM à partir d'un modèle ou d'un script",
|
||||
"Create a VM first (machine type q35 + UEFI BIOS), then run this option again.": "Créez d’abord une VM (type de machine q35 + BIOS UEFI), puis réexécutez cette option.",
|
||||
"Create a backup of the container configuration:": "Créez une sauvegarde de la configuration du conteneur :",
|
||||
@@ -4200,6 +4205,7 @@
|
||||
"Swap partition detected": "Partition d'échange détectée",
|
||||
"Swappiness configuration created successfully": "Configuration Swapiness créée avec succès",
|
||||
"Switch GPU Mode (VM <-> LXC)": "Changer de mode GPU (VM <-> LXC)",
|
||||
"Switch Mode": "Changer de mode",
|
||||
"Switch Script Not Found": "Script de commutation introuvable",
|
||||
"Switch to GPU -> LXC (native driver mode)": "Passer au GPU -> LXC (mode pilote natif)",
|
||||
"Switch to GPU -> VM (VFIO passthrough mode)": "Passer au GPU -> VM (mode passthrough VFIO)",
|
||||
@@ -4671,6 +4677,7 @@
|
||||
"Using advanced configuration": "Utilisation de la configuration avancée",
|
||||
"Using default Proxmox logo...": "Utilisation du logo Proxmox par défaut...",
|
||||
"Using existing encryption key:": "Utilisation de la clé de chiffrement existante :",
|
||||
"Utilities": "Utilitaires",
|
||||
"Utilities Installation Menu": "Menu d'installation des utilitaires",
|
||||
"Utilities Menu": "Menu Utilitaires",
|
||||
"Utilities Verification": "Vérification des utilitaires",
|
||||
|
||||
+8
-1
@@ -452,6 +452,7 @@
|
||||
"Cannot proceed with invalid export path.": "Impossibile procedere con un percorso di esportazione non valido.",
|
||||
"Cannot proceed with invalid share name.": "Impossibile procedere con un nome di condivisione non valido.",
|
||||
"Cannot reach Proxmox repositories": "Impossibile raggiungere i repository Proxmox",
|
||||
"Cannot reach https://download.proxmox.com (HTTPS 443). Check network, proxy or DNS.": "impossibile raggiungere https://download.proxmox.com (HTTPS 443).Controlla rete, proxy o DNS.",
|
||||
"Cannot reach portal:": "Impossibile raggiungere il portale:",
|
||||
"Cannot reach server": "Impossibile raggiungere il server",
|
||||
"Cannot validate credentials - no shares available for testing.": "Impossibile convalidare le credenziali: nessuna condivisione disponibile per il test.",
|
||||
@@ -925,7 +926,6 @@
|
||||
"Could not unmount — disk may be busy. Removing fstab entry anyway.": "Impossibile smontare: il disco potrebbe essere occupato. Rimozione comunque della voce fstab.",
|
||||
"Could not update config file.": "Impossibile aggiornare il file di configurazione.",
|
||||
"Could not write to:": "Impossibile scrivere a:",
|
||||
"Create": "Creare",
|
||||
"Create Directory": "Crea directory",
|
||||
"Create GPT and one partition:": "Crea GPT e una partizione:",
|
||||
"Create GPT partition": "Crea partizione GPT",
|
||||
@@ -936,6 +936,11 @@
|
||||
"Create Shared Directory": "Crea directory condivisa",
|
||||
"Create Shared Directory on Host": "Crea directory condivisa sull'host",
|
||||
"Create Universal NFS Export": "Crea esportazione NFS universale",
|
||||
"Create VM System Linux": "Crea VM con Linux",
|
||||
"Create VM System NAS": "Crea VM per NAS",
|
||||
"Create VM System Others (based Linux)": "Crea VM per un altro sistema basato su Linux",
|
||||
"Create VM System Windows": "Crea VM con Windows",
|
||||
"Create VM System macOS (OSX-PROXMOX)": "Crea VM con macOS (OSX-PROXMOX)",
|
||||
"Create VM from template or script": "Crea VM da modello o script",
|
||||
"Create a VM first (machine type q35 + UEFI BIOS), then run this option again.": "Crea prima una VM (tipo di macchina q35 + UEFI BIOS), quindi esegui nuovamente questa opzione.",
|
||||
"Create a backup of the container configuration:": "Crea un backup della configurazione del contenitore:",
|
||||
@@ -4200,6 +4205,7 @@
|
||||
"Swap partition detected": "Partizione di swap rilevata",
|
||||
"Swappiness configuration created successfully": "Configurazione Swappiness creata con successo",
|
||||
"Switch GPU Mode (VM <-> LXC)": "Cambia modalità GPU (VM <-> LXC)",
|
||||
"Switch Mode": "Cambia modalità",
|
||||
"Switch Script Not Found": "Script del cambio non trovato",
|
||||
"Switch to GPU -> LXC (native driver mode)": "Passa a GPU -> LXC (modalità driver nativo)",
|
||||
"Switch to GPU -> VM (VFIO passthrough mode)": "Passa a GPU -> VM (modalità passthrough VFIO)",
|
||||
@@ -4671,6 +4677,7 @@
|
||||
"Using advanced configuration": "Utilizzando la configurazione avanzata",
|
||||
"Using default Proxmox logo...": "Utilizzo del logo Proxmox predefinito...",
|
||||
"Using existing encryption key:": "Utilizzando la chiave di crittografia esistente:",
|
||||
"Utilities": "Utilità",
|
||||
"Utilities Installation Menu": "Menu di installazione delle utilità",
|
||||
"Utilities Menu": "Menù Utilità",
|
||||
"Utilities Verification": "Verifica delle utenze",
|
||||
|
||||
+8
-1
@@ -452,6 +452,7 @@
|
||||
"Cannot proceed with invalid export path.": "Não é possível prosseguir com caminho de exportação inválido.",
|
||||
"Cannot proceed with invalid share name.": "Não é possível continuar com um nome de compartilhamento inválido.",
|
||||
"Cannot reach Proxmox repositories": "Não é possível acessar os repositórios Proxmox",
|
||||
"Cannot reach https://download.proxmox.com (HTTPS 443). Check network, proxy or DNS.": "Não é possível acessar https://download.proxmox.com (HTTPS 443).Verifique a rede, proxy ou DNS.",
|
||||
"Cannot reach portal:": "Não é possível acessar o portal:",
|
||||
"Cannot reach server": "Sem contato com o servidor",
|
||||
"Cannot validate credentials - no shares available for testing.": "Não é possível validar credenciais – não há compartilhamentos disponíveis para teste.",
|
||||
@@ -925,7 +926,6 @@
|
||||
"Could not unmount — disk may be busy. Removing fstab entry anyway.": "Não foi possível desmontar — o disco pode estar ocupado. Removendo a entrada fstab de qualquer maneira.",
|
||||
"Could not update config file.": "Não foi possível atualizar o arquivo de configuração.",
|
||||
"Could not write to:": "Não foi possível escrever para:",
|
||||
"Create": "Criar",
|
||||
"Create Directory": "Criar diretório",
|
||||
"Create GPT and one partition:": "Crie GPT e uma partição:",
|
||||
"Create GPT partition": "Criar partição GPT",
|
||||
@@ -936,6 +936,11 @@
|
||||
"Create Shared Directory": "Criar diretório compartilhado",
|
||||
"Create Shared Directory on Host": "Crie um diretório compartilhado no host",
|
||||
"Create Universal NFS Export": "Criar exportação NFS universal",
|
||||
"Create VM System Linux": "Criar VM com Linux",
|
||||
"Create VM System NAS": "Criar VM para NAS",
|
||||
"Create VM System Others (based Linux)": "Criar VM para outro sistema baseado em Linux",
|
||||
"Create VM System Windows": "Criar VM com Windows",
|
||||
"Create VM System macOS (OSX-PROXMOX)": "Criar VM com macOS (OSX-PROXMOX)",
|
||||
"Create VM from template or script": "Crie VM a partir de modelo ou script",
|
||||
"Create a VM first (machine type q35 + UEFI BIOS), then run this option again.": "Crie primeiro uma VM (tipo de máquina q35 + UEFI BIOS) e, em seguida, execute esta opção novamente.",
|
||||
"Create a backup of the container configuration:": "Crie um backup da configuração do contêiner:",
|
||||
@@ -4200,6 +4205,7 @@
|
||||
"Swap partition detected": "Partição de troca detectada",
|
||||
"Swappiness configuration created successfully": "Configuração de troca criada com sucesso",
|
||||
"Switch GPU Mode (VM <-> LXC)": "Alternar modo GPU (VM <-> LXC)",
|
||||
"Switch Mode": "Alterar modo",
|
||||
"Switch Script Not Found": "Alternar script não encontrado",
|
||||
"Switch to GPU -> LXC (native driver mode)": "Mude para GPU -> LXC (modo de driver nativo)",
|
||||
"Switch to GPU -> VM (VFIO passthrough mode)": "Mude para GPU -> VM (modo de passagem VFIO)",
|
||||
@@ -4671,6 +4677,7 @@
|
||||
"Using advanced configuration": "Usando configuração avançada",
|
||||
"Using default Proxmox logo...": "Usando o logotipo padrão do Proxmox...",
|
||||
"Using existing encryption key:": "Usando a chave de criptografia existente:",
|
||||
"Utilities": "Utilitários",
|
||||
"Utilities Installation Menu": "Menu de instalação de utilitários",
|
||||
"Utilities Menu": "Menu Utilitários",
|
||||
"Utilities Verification": "Verificação de utilitários",
|
||||
|
||||
+5274
File diff suppressed because it is too large
Load Diff
@@ -4077,7 +4077,7 @@ main_menu() {
|
||||
4 "$(_bk_format_menu_item "$(translate "Scheduled backups and retention policies")" "")" \
|
||||
5 "$(_bk_format_menu_item "$(translate "Configure backup destinations (PBS, Borg, local)")" "")" \
|
||||
"" " " \
|
||||
"" "\Z4───────────────────── Community Scripts ─────────────────────\Zn" \
|
||||
"" "\Z4───────────────────── $(translate "Community Scripts") ─────────────────────\Zn" \
|
||||
6 "$(_bk_format_menu_item "PVE Host Backup" "Helper-Scripts")" \
|
||||
7 "$(_bk_format_menu_item "proxmox_toolbox" "Tontonjo")" \
|
||||
8 "$(_bk_format_menu_item "proxsave" "tis24dev")" \
|
||||
@@ -4101,4 +4101,4 @@ main_menu() {
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
main_menu
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -532,6 +532,58 @@ _sb_hydrate_attached_retention() {
|
||||
done < <(hb_pve_prune_to_keep_env "$prune")
|
||||
}
|
||||
|
||||
# Safe replacement for `source <env>`. The job .env is DATA (credentials
|
||||
# + parameters), not code. Sourcing it as bash produces two failure modes
|
||||
# reported from the field:
|
||||
# - a value with spaces (e.g. `ON_CALENDAR=*-*-* 01:00:00`) is parsed
|
||||
# as "assign first token, then run the rest as a command" — the
|
||||
# scheduled runner dies with `01:00:00: command not found` before
|
||||
# doing any work.
|
||||
# - a value containing a bare `$word` under `set -u` triggers an
|
||||
# unbound-variable expansion during sourcing and aborts.
|
||||
# It also opens a code-execution vector (backticks / `$(...)` in a
|
||||
# password would run as root at source time).
|
||||
# The API (shlex.quote) and CLI (printf %q) both quote on write, so
|
||||
# jobs created by current code are safe under source. Legacy jobs on
|
||||
# disk are not — hence this parser.
|
||||
_sb_load_env_file() {
|
||||
local file="$1"
|
||||
[[ -f "$file" ]] || return 1
|
||||
local line key value_raw prev_u prev_f
|
||||
case $- in *u*) prev_u=1 ;; *) prev_u=0 ;; esac
|
||||
case $- in *f*) prev_f=1 ;; *) prev_f=0 ;; esac
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||
[[ "$line" == *=* ]] || continue
|
||||
key="${line%%=*}"
|
||||
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
||||
value_raw="${line#*=}"
|
||||
unset "$key"
|
||||
# Refuse command substitution — no legitimate reason for `$(...)`
|
||||
# or backticks in a data file; treating them as literal is safer
|
||||
# than evaluating them as root.
|
||||
# shellcheck disable=SC2016 # matching literal `$(` and backtick, not expanding
|
||||
if [[ "$value_raw" == *'$('* || "$value_raw" == *'`'* ]]; then
|
||||
declare -gx "$key=$value_raw"
|
||||
continue
|
||||
fi
|
||||
# Try a shell-quoted parse (handles printf %q backslash escapes and
|
||||
# shlex.quote surrounding quotes). Guards: set +u so `$FOO` in an
|
||||
# unquoted value doesn't abort; set -f so `*` doesn't glob-expand
|
||||
# against files on disk. If eval fails (legacy unquoted value with
|
||||
# spaces, unbalanced quotes, etc.), fall through to a raw literal
|
||||
# assignment.
|
||||
set +u
|
||||
set -f
|
||||
if ! eval "declare -gx $key=$value_raw" 2>/dev/null; then
|
||||
unset "$key"
|
||||
declare -gx "$key=$value_raw"
|
||||
fi
|
||||
(( prev_u )) && set -u
|
||||
(( prev_f )) || set +f
|
||||
done <"$file"
|
||||
}
|
||||
|
||||
main() {
|
||||
local job_id="${1:-}"
|
||||
[[ -z "$job_id" ]] && { echo "Usage: $0 <job_id>" >&2; exit 1; }
|
||||
@@ -539,27 +591,61 @@ main() {
|
||||
local job_file="${JOBS_DIR}/${job_id}.env"
|
||||
[[ -f "$job_file" ]] || { echo "Job not found: $job_id" >&2; exit 1; }
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$job_file"
|
||||
_sb_load_env_file "$job_file"
|
||||
|
||||
# Attached jobs: re-read retention from the PVE parent live (see
|
||||
# _sb_hydrate_attached_retention above for the why). Standalone
|
||||
# jobs keep whatever KEEP_* the .env has.
|
||||
_sb_hydrate_attached_retention
|
||||
|
||||
local lock_file="${LOCK_DIR}/proxmenux-backup-${job_id}.lock"
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
exec 9>"$lock_file" || exit 1
|
||||
if ! flock -n 9; then
|
||||
echo "Another run is active for job ${job_id}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create log_file and summary_file BEFORE the flock so any early
|
||||
# failure (lock contention, missing tools, unreadable env) surfaces
|
||||
# in the runner log the Monitor polls, instead of being lost to
|
||||
# stderr/DEVNULL and leaving the UI stuck on "Waiting for runner
|
||||
# to start…" forever.
|
||||
local ts log_file stage_root summary_file
|
||||
ts="$(date +%Y%m%d_%H%M%S)"
|
||||
log_file="${LOG_DIR}/${job_id}-${ts}.log"
|
||||
summary_file="${LOG_DIR}/${job_id}-last.status"
|
||||
|
||||
local lock_file="${LOCK_DIR}/proxmenux-backup-${job_id}.lock"
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
if ! exec 9>"$lock_file"; then
|
||||
{
|
||||
echo "=== Scheduled backup job ${job_id} aborted at $(date -Iseconds) ==="
|
||||
echo "Cannot open lock file: $lock_file"
|
||||
echo "Check that ${LOCK_DIR} exists and is writable by root."
|
||||
} >"$log_file"
|
||||
{
|
||||
echo "JOB_ID=${job_id}"
|
||||
echo "RUN_AT=$(date -Iseconds)"
|
||||
echo "RESULT=failed"
|
||||
echo "REASON=lock_open_failed"
|
||||
} >"$summary_file"
|
||||
exit 1
|
||||
fi
|
||||
if ! flock -n 9; then
|
||||
{
|
||||
echo "=== Scheduled backup job ${job_id} aborted at $(date -Iseconds) ==="
|
||||
echo "Another run is already active for this job (lock held: $lock_file)."
|
||||
echo ""
|
||||
echo "Possible causes:"
|
||||
echo " - A previous run is still in progress (check with: ps auxf | grep run_scheduled_backup)."
|
||||
echo " - A previous run hung and left the lock behind. Kill the stale"
|
||||
echo " process (if any) and remove the lock: rm $lock_file"
|
||||
echo ""
|
||||
echo "This run did NOT execute a backup."
|
||||
} >"$log_file"
|
||||
{
|
||||
echo "JOB_ID=${job_id}"
|
||||
echo "RUN_AT=$(date -Iseconds)"
|
||||
echo "RESULT=failed"
|
||||
echo "REASON=another_run_active"
|
||||
} >"$summary_file"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
stage_root="$(mktemp -d /tmp/proxmenux-sched-stage.XXXXXX)"
|
||||
|
||||
{
|
||||
|
||||
@@ -104,8 +104,26 @@ update_pve_safe() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! ping -c 1 download.proxmox.com >/dev/null 2>&1; then
|
||||
msg_error "$(translate "Cannot reach Proxmox repositories")"
|
||||
# Reachability check: HEAD https://download.proxmox.com over the same
|
||||
# transport apt-get update will use (HTTPS 443). Previously a single
|
||||
# ICMP ping — hosts behind firewalls that filter ICMP but allow 443
|
||||
# (typical corporate / cloud-provider setups) hit a false negative
|
||||
# and the update aborted even though the repository was reachable.
|
||||
# Two attempts with a short pause absorb transient network glitches
|
||||
# without adding perceptible latency when the network is healthy.
|
||||
_repo_reachable() {
|
||||
local url="https://download.proxmox.com/"
|
||||
local attempt
|
||||
for attempt in 1 2; do
|
||||
if curl -sfI --connect-timeout 5 --max-time 10 -o /dev/null "$url"; then
|
||||
return 0
|
||||
fi
|
||||
[[ $attempt -eq 1 ]] && sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
if ! _repo_reachable; then
|
||||
msg_error "$(translate "Cannot reach https://download.proxmox.com (HTTPS 443). Check network, proxy or DNS.")"
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user