mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-06 15:56:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d781d570c | ||
|
|
e581aa0121 | ||
|
|
ef53fa0827 | ||
|
|
86517860ff | ||
|
|
d62fc29581 | ||
|
|
39e7a7d2db | ||
|
|
88f1d09cb8 | ||
|
|
2109cf2508 | ||
|
|
04a028245b | ||
|
|
f10ff18abc | ||
|
|
359a5c269c | ||
|
|
2a23cc9079 | ||
|
|
7fcbc467df | ||
|
|
f2d9c9fb8e | ||
|
|
2c05c800d9 | ||
|
|
89fa4e73a5 | ||
|
|
c4a574422b | ||
|
|
e7a9e3a5b6 | ||
|
|
cbb46cb70a | ||
|
|
37189ed67e | ||
|
|
ebd41b8a46 | ||
|
|
42ca256bac | ||
|
|
3d3b1fc902 | ||
|
|
f5aa0db9ef | ||
|
|
03a7f58dfe | ||
|
|
61c778cc80 | ||
|
|
fb06ed4f8a | ||
|
|
b4a4bb3ddd | ||
|
|
497934f854 | ||
|
|
20f1372816 | ||
|
|
34ec56324f | ||
|
|
031084decd | ||
|
|
91d503ea67 | ||
|
|
057136b1e0 | ||
|
|
1eeb44bae6 | ||
|
|
763ed5dcc6 | ||
|
|
7bf709a679 | ||
|
|
1e6cb58a7c | ||
|
|
61b45a5331 | ||
|
|
b9c9a8bb13 | ||
|
|
06b546427b | ||
|
|
37f06b9e88 | ||
|
|
807edc5700 | ||
|
|
1c0910563b | ||
|
|
abf5ec7405 | ||
|
|
d9aea2ed1d | ||
|
|
047217dc93 | ||
|
|
46cc91e52b | ||
|
|
d94554302f | ||
|
|
af5cf30e34 | ||
|
|
fd3986ccfd | ||
|
|
91355372b6 | ||
|
|
70ae8326f2 | ||
|
|
d24d4372a7 | ||
|
|
2b7c498570 | ||
|
|
ad90f6c99a | ||
|
|
98d8badb52 | ||
|
|
dda0492152 | ||
|
|
005f668d7e | ||
|
|
76ddc8b408 | ||
|
|
8645ee0744 | ||
|
|
78c5765330 | ||
|
|
4f5ccc4933 | ||
|
|
acebb1755d | ||
|
|
b461b85dec | ||
|
|
05dc2cbd6a | ||
|
|
c8613fc864 | ||
|
|
827ccbab57 | ||
|
|
c53289753d | ||
|
|
f2516010c7 | ||
|
|
96f88d3eb5 | ||
|
|
2330179036 | ||
|
|
fd951f134c | ||
|
|
ff0f8f1133 | ||
|
|
b2c6a6c536 | ||
|
|
c81230b0f3 | ||
|
|
cf5871981d | ||
|
|
2e0746e850 | ||
|
|
1fb57c77c6 | ||
|
|
dcf682af76 | ||
|
|
9f014a9784 | ||
|
|
c2442565bd | ||
|
|
6b02c13e23 | ||
|
|
016681caba | ||
|
|
4630be1921 | ||
|
|
451f541342 | ||
|
|
ab4120a81e | ||
|
|
c21441a56e | ||
|
|
8108c42eae | ||
|
|
3c5cad6ab5 | ||
|
|
52d7e20979 | ||
|
|
db79470d32 | ||
|
|
9dc077feec | ||
|
|
1f3702b700 | ||
|
|
21c8b7a62e | ||
|
|
2770c8172b | ||
|
|
f4cd661480 | ||
|
|
3f61bca07c | ||
|
|
af7bb9aa3f | ||
|
|
bcb8ec0f81 | ||
|
|
e0fd85faac | ||
|
|
0b01bad764 | ||
|
|
032ec197d6 | ||
|
|
2fdf94f322 | ||
|
|
ed9b027c19 | ||
|
|
dee9d4ae40 | ||
|
|
4ac112ff39 | ||
|
|
3915b219cc | ||
|
|
ddd9e35c83 | ||
|
|
695dd1da7d | ||
|
|
2a17561477 | ||
|
|
892a90fa3c | ||
|
|
bbebef6929 | ||
|
|
1b992988eb | ||
|
|
fe6e70fa61 | ||
|
|
4fe335224d | ||
|
|
b4dddebd6c | ||
|
|
8060f59d69 | ||
|
|
ac531ff42f | ||
|
|
1872a309ec | ||
|
|
1b623cd275 | ||
|
|
362409e654 | ||
|
|
e771a11441 | ||
|
|
c43bf19bae | ||
|
|
aaab644e47 | ||
|
|
a1167dad4b | ||
|
|
63a6378f2d | ||
|
|
fffeb2c06c | ||
|
|
4d4eabf922 | ||
|
|
627cd80b5e | ||
|
|
ebc668077f | ||
|
|
f5c72f19b8 | ||
|
|
2f85ca47c3 | ||
|
|
cf59b3c621 | ||
|
|
6ad92a908c | ||
|
|
07da709ffa | ||
|
|
b3cfdd28be | ||
|
|
1242cb8ae8 | ||
|
|
89d658ab2e | ||
|
|
aa3714c2ed | ||
|
|
837d5cd95a | ||
|
|
5783b6e0bc | ||
|
|
17246ebdde | ||
|
|
2d3b1fd823 | ||
|
|
3b96837fba | ||
|
|
87454b5b2d | ||
|
|
20f85001d5 | ||
|
|
71780fccf4 | ||
|
|
c0e7a96406 | ||
|
|
1f5fc9ad88 | ||
|
|
65a5755120 | ||
|
|
5e368cd8b3 | ||
|
|
77ae299512 | ||
|
|
1b6776a53f | ||
|
|
d57924e25a | ||
|
|
cc71d14dfa | ||
|
|
197998d231 | ||
|
|
8b6fdcf9e1 | ||
|
|
04a1cffe7f | ||
|
|
a2a0fb4705 | ||
|
|
13435a8b41 | ||
|
|
341681a3dc | ||
|
|
0722469fb5 | ||
|
|
f4406c8770 | ||
|
|
2586819a47 | ||
|
|
f64c7269c7 | ||
|
|
4789371f4d | ||
|
|
6f0fc68c3d | ||
|
|
201849e691 | ||
|
|
63f82d971d | ||
|
|
a037cd15af | ||
|
|
6ae7494a45 | ||
|
|
64c8f4fbb3 | ||
|
|
9c90722ef9 | ||
|
|
9f94d72b69 | ||
|
|
7156af1965 | ||
|
|
20d10fc268 | ||
|
|
8df8a77bf1 | ||
|
|
e07c83f4a9 | ||
|
|
487ab04a14 | ||
|
|
fd1aeb1ead | ||
|
|
e35ef38fea | ||
|
|
26b47e63d9 | ||
|
|
a4f74929fa | ||
|
|
4b022d05f2 | ||
|
|
118d271ea4 | ||
|
|
def007d743 | ||
|
|
7acf18508d | ||
|
|
e7b11c6498 | ||
|
|
b833ca23ba | ||
|
|
380a25a546 | ||
|
|
f92af374c7 | ||
|
|
5b22039600 | ||
|
|
6560d0ced7 | ||
|
|
8bcfcd6059 | ||
|
|
6c1c317921 | ||
|
|
7fc7125c71 | ||
|
|
9f03164258 | ||
|
|
29bca610a0 | ||
|
|
790c8d2fd4 | ||
|
|
877f7a3d81 | ||
|
|
d59b1af8a9 | ||
|
|
85184e174d | ||
|
|
720f3fdbd2 | ||
|
|
0d6c7290e5 | ||
|
|
f768d9eff8 | ||
|
|
66dd3ec014 | ||
|
|
29ebfdc324 | ||
|
|
17de0a5a77 | ||
|
|
efc07056aa | ||
|
|
9a81c631fa | ||
|
|
2f01950d45 | ||
|
|
c25441cca2 | ||
|
|
35cb10ff44 | ||
|
|
eb67fb9bd9 | ||
|
|
c455d66b91 | ||
|
|
153aae659c | ||
|
|
a00a6e5f2b | ||
|
|
2eef425f41 | ||
|
|
ce3ad61f8f | ||
|
|
0ad84572a7 | ||
|
|
c6fb7182e9 | ||
|
|
003ae3d31a | ||
|
|
f17a83a1b3 | ||
|
|
827c88d24f | ||
|
|
872f79a9ea | ||
|
|
0b2879ead0 | ||
|
|
357b2e8ac0 | ||
|
|
efa84b0fa1 | ||
|
|
8705f638d5 | ||
|
|
bc3c771137 | ||
|
|
a92420d654 | ||
|
|
c0b950e637 | ||
|
|
f0e79e93b6 | ||
|
|
f2b0b1b039 | ||
|
|
8235549e4f | ||
|
|
6b173c42b6 | ||
|
|
f5d25f735c | ||
|
|
ac4370f6f3 | ||
|
|
d522b9a337 | ||
|
|
33a8f4baa5 | ||
|
|
cbb58d0363 | ||
|
|
35ebc90fa2 | ||
|
|
2501b08c02 | ||
|
|
24fd2d6e38 | ||
|
|
e79b1065d9 | ||
|
|
2494f9a773 | ||
|
|
9d3c2dd0d3 | ||
|
|
b2753be204 | ||
|
|
a3d282f33e | ||
|
|
51b9285980 | ||
|
|
0167e46eb7 | ||
|
|
ecfdcf1bac | ||
|
|
63b9d69f3f | ||
|
|
a56afecccf | ||
|
|
484f0ce897 | ||
|
|
4efd5ae2d3 | ||
|
|
2052308be0 | ||
|
|
cdb4522e5f | ||
|
|
4f2494e135 | ||
|
|
202068124b | ||
|
|
87a29f324b | ||
|
|
61b9fd12bb | ||
|
|
b7380fd582 | ||
|
|
cd2a075fab | ||
|
|
9f23c6e852 | ||
|
|
93553574b3 | ||
|
|
5807159e5f | ||
|
|
6ab9d4ca27 | ||
|
|
b5882cee04 | ||
|
|
cfdd78244d | ||
|
|
194523c13a | ||
|
|
84b53fe64c | ||
|
|
99e0227fec | ||
|
|
00ecf0497c | ||
|
|
beb3e7e0c4 | ||
|
|
14a865dff4 | ||
|
|
3b365f8ad5 | ||
|
|
b8038f4d41 | ||
|
|
68c8c03642 | ||
|
|
be4c31be92 | ||
|
|
c4cab77319 | ||
|
|
9d099ba358 | ||
|
|
62fd69b147 | ||
|
|
f50265a212 | ||
|
|
e5669dd982 | ||
|
|
7e6022da59 | ||
|
|
d807c9f085 | ||
|
|
d9a56ae0f1 | ||
|
|
a9661a71ff | ||
|
|
7080570b43 | ||
|
|
a47198b37f | ||
|
|
cab9c81c63 | ||
|
|
25612b2252 | ||
|
|
09a67bf4e6 | ||
|
|
3bfb7fa796 | ||
|
|
de118ac259 | ||
|
|
6c7520b75e | ||
|
|
5d36b41c1e | ||
|
|
8e92df5bd7 | ||
|
|
57e936785d | ||
|
|
d7b67ba4a8 | ||
|
|
7192b7b2eb | ||
|
|
3fd7f4b2a4 | ||
|
|
320dbe1948 | ||
|
|
73d4d141bc | ||
|
|
273966e77f | ||
|
|
1c0c4df3e3 | ||
|
|
fe79d35cc0 | ||
|
|
132a52c7b2 | ||
|
|
bf1f68d5e6 | ||
|
|
22a8cbc402 | ||
|
|
c4447eae5e | ||
|
|
d88d0f765e | ||
|
|
7ea9f10d6f | ||
|
|
024ca83afd | ||
|
|
2f01c815e8 | ||
|
|
4c65d5a07a | ||
|
|
905fa4afce | ||
|
|
217d9735a6 | ||
|
|
e3e9d740ff | ||
|
|
0920659693 | ||
|
|
ae68d59a20 | ||
|
|
8b5d7c65d9 | ||
|
|
b45f032f89 | ||
|
|
ee58c21fef | ||
|
|
2a198db593 | ||
|
|
f8be7b06d7 | ||
|
|
70ed3c6f1f | ||
|
|
64356965fb | ||
|
|
75dcb8935d | ||
|
|
d66dc07ae1 | ||
|
|
f066eb0a60 | ||
|
|
d9fee64c35 | ||
|
|
9039064652 | ||
|
|
e3ce042be4 | ||
|
|
c37d3fa34e | ||
|
|
a592eb896f | ||
|
|
2d3c8f5713 | ||
|
|
c6d93278cd | ||
|
|
03b6f25e14 | ||
|
|
6ccb54e64a | ||
|
|
6e1e47d9fd | ||
|
|
761357b737 | ||
|
|
57f1ebc358 | ||
|
|
345d66e0fd | ||
|
|
b5cdf1d2a6 | ||
|
|
fe81ae8f7d | ||
|
|
382493ca84 | ||
|
|
450fceec62 | ||
|
|
41de70a1b9 | ||
|
|
9bedb4f311 | ||
|
|
7166a10ac2 | ||
|
|
ed924b67fe | ||
|
|
0b8c0f57f8 | ||
|
|
a4d1e9fbb9 | ||
|
|
9afbf0ea5e | ||
|
|
8e9c853a81 | ||
|
|
3d9ade0f37 | ||
|
|
6094ab8e1c | ||
|
|
1f13a35c51 | ||
|
|
38845197d8 | ||
|
|
f9cf931828 | ||
|
|
7a88971114 | ||
|
|
827aa7154f | ||
|
|
1ebf4d0a28 | ||
|
|
df95b50f8c | ||
|
|
7ad5508623 | ||
|
|
4dc8be7387 | ||
|
|
d9d63b8f1c | ||
|
|
165e8c9636 | ||
|
|
cff2ca3c95 | ||
|
|
d41871cc53 | ||
|
|
6b3c42e0ed | ||
|
|
d41eaef8a2 | ||
|
|
f54118843e | ||
|
|
f0b8474350 | ||
|
|
61ff665cec | ||
|
|
dac5ff72a3 | ||
|
|
6844406cf7 | ||
|
|
083f8e5fd7 | ||
|
|
218ab2aa89 | ||
|
|
61ff98e830 | ||
|
|
9a938d129b | ||
|
|
66419777d8 | ||
|
|
09abef2d15 | ||
|
|
0a09fa4987 | ||
|
|
d401e5f7de | ||
|
|
3191f5250d | ||
|
|
9656b04a3e | ||
|
|
3629fe8848 | ||
|
|
e6fe598e7a | ||
|
|
e855fca0b3 | ||
|
|
9b0e498c6d | ||
|
|
371f61fa08 | ||
|
|
fa6ff43c6f | ||
|
|
bde36dd241 | ||
|
|
ae91fc4cdd | ||
|
|
6c7a8cae92 | ||
|
|
7cea5563a7 | ||
|
|
755c289894 | ||
|
|
17c5b89cc8 | ||
|
|
32a3e20c76 | ||
|
|
5e795a654d |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Build the ProxMenux translation cache from translate calls in scripts/.
|
||||||
|
|
||||||
|
The generated JSON keeps the same shape used by scripts/utils.sh:
|
||||||
|
|
||||||
|
{
|
||||||
|
"Original English text": {
|
||||||
|
"es": "Translated text",
|
||||||
|
"fr": "Translated text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt", "sk")
|
||||||
|
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)""",
|
||||||
|
re.VERBOSE | re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_script_files(
|
||||||
|
scripts_dir: Path, extra_files: Iterable[Path] = ()
|
||||||
|
) -> Iterable[Path]:
|
||||||
|
# Walk the main scripts tree.
|
||||||
|
for path in sorted(scripts_dir.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
if path.name == "utils.sh":
|
||||||
|
continue
|
||||||
|
if path.suffix not in {".sh", ".func"}:
|
||||||
|
continue
|
||||||
|
yield path
|
||||||
|
# Yield additional files passed explicitly (e.g. the root-level `menu`
|
||||||
|
# entry point or install_proxmenux*.sh). These live outside scripts/
|
||||||
|
# but still contain translate "..." calls we want in the cache.
|
||||||
|
# No extension filter and no utils.sh skip — the caller decided
|
||||||
|
# they belong, we just check the file actually exists.
|
||||||
|
for extra in extra_files:
|
||||||
|
if extra.is_file():
|
||||||
|
yield extra
|
||||||
|
|
||||||
|
|
||||||
|
def decode_shell_string(raw: str, quote_char: str) -> str:
|
||||||
|
if quote_char == "'":
|
||||||
|
return raw
|
||||||
|
try:
|
||||||
|
return ast.literal_eval(f'"{raw}"')
|
||||||
|
except Exception:
|
||||||
|
return raw.replace(r"\"", '"').replace(r"\\", "\\")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_translate_texts(
|
||||||
|
scripts_dir: Path, extra_files: Iterable[Path] = ()
|
||||||
|
) -> list[str]:
|
||||||
|
found: dict[str, None] = {}
|
||||||
|
for path in iter_script_files(scripts_dir, extra_files):
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
content = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
for match in TRANSLATE_CALL_RE.finditer(content):
|
||||||
|
text = decode_shell_string(match.group("text"), match.group("quote"))
|
||||||
|
text = text.strip()
|
||||||
|
if text and "$" not in text and "`" not in text:
|
||||||
|
found.setdefault(text, None)
|
||||||
|
|
||||||
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
|
def translate_googletrans(text: str, dest_lang: str, context: str) -> str:
|
||||||
|
try:
|
||||||
|
from googletrans import Translator # type: ignore
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"googletrans is not installed. Install googletrans==4.0.0-rc1 "
|
||||||
|
"or run with --provider google-web."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
translator = Translator()
|
||||||
|
full_text = f"{context} {text}".strip()
|
||||||
|
return translator.translate(full_text, dest=dest_lang).text
|
||||||
|
|
||||||
|
|
||||||
|
def translate_google_web(text: str, dest_lang: str, context: str, timeout: int) -> str:
|
||||||
|
# The public Google endpoint is not prompt-aware: if we prepend context,
|
||||||
|
# it often translates and returns that context as part of the result.
|
||||||
|
full_text = text
|
||||||
|
url = (
|
||||||
|
"https://translate.googleapis.com/translate_a/single"
|
||||||
|
f"?client=gtx&sl=en&tl={quote(dest_lang)}&dt=t&q={quote(full_text)}"
|
||||||
|
)
|
||||||
|
req = Request(url, headers={"User-Agent": "ProxMenux translation cache builder"})
|
||||||
|
with urlopen(req, timeout=timeout) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
return "".join(part[0] for part in payload[0] if part and part[0])
|
||||||
|
|
||||||
|
|
||||||
|
def translate_appimage(
|
||||||
|
text: str,
|
||||||
|
dest_lang: str,
|
||||||
|
context: str,
|
||||||
|
timeout: int,
|
||||||
|
appimage_path: Path,
|
||||||
|
) -> str:
|
||||||
|
if not appimage_path.exists():
|
||||||
|
prev_path = appimage_path.with_name(appimage_path.name + ".prev")
|
||||||
|
if prev_path.exists():
|
||||||
|
appimage_path = prev_path
|
||||||
|
else:
|
||||||
|
raise FileNotFoundError(f"AppImage not found: {appimage_path}")
|
||||||
|
|
||||||
|
req = {
|
||||||
|
"text": text,
|
||||||
|
"dest_lang": dest_lang,
|
||||||
|
"context": context,
|
||||||
|
"cache_file": "",
|
||||||
|
}
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.setdefault("APPIMAGE_EXTRACT_AND_RUN", "1")
|
||||||
|
completed = subprocess.run(
|
||||||
|
[str(appimage_path), "--translate"],
|
||||||
|
input=json.dumps(req, ensure_ascii=False),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout,
|
||||||
|
check=False,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise RuntimeError((completed.stderr or completed.stdout).strip())
|
||||||
|
|
||||||
|
# AppRun may print a startup line before translate_cli.py emits JSON.
|
||||||
|
for line in reversed(completed.stdout.splitlines()):
|
||||||
|
line = line.strip()
|
||||||
|
if not line.startswith("{"):
|
||||||
|
continue
|
||||||
|
payload = json.loads(line)
|
||||||
|
if payload.get("success"):
|
||||||
|
return str(payload.get("text", text))
|
||||||
|
raise RuntimeError(str(payload.get("error", "unknown AppImage translation error")))
|
||||||
|
|
||||||
|
raise RuntimeError(f"AppImage did not return JSON: {completed.stdout.strip()}")
|
||||||
|
|
||||||
|
|
||||||
|
def clean_translation(value: str) -> str:
|
||||||
|
separator = r"[\s\u00a0]*[::]"
|
||||||
|
translate_labels = "Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir"
|
||||||
|
context_labels = "Context|Contexto|Contexte|Kontext|Contesto"
|
||||||
|
value = re.sub(
|
||||||
|
rf"^.*?({translate_labels}){separator}",
|
||||||
|
"",
|
||||||
|
value,
|
||||||
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
value = re.sub(
|
||||||
|
rf"^.*?({context_labels}){separator}.*?({translate_labels}){separator}",
|
||||||
|
"",
|
||||||
|
value,
|
||||||
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
value = re.sub(
|
||||||
|
rf"^.*?({context_labels}){separator}",
|
||||||
|
"",
|
||||||
|
value,
|
||||||
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def translate_text(
|
||||||
|
text: str,
|
||||||
|
dest_lang: str,
|
||||||
|
provider: str,
|
||||||
|
context: str,
|
||||||
|
timeout: int,
|
||||||
|
appimage_path: Path,
|
||||||
|
) -> str:
|
||||||
|
if provider == "googletrans":
|
||||||
|
translated = translate_googletrans(text, dest_lang, context)
|
||||||
|
elif provider == "google-web":
|
||||||
|
translated = translate_google_web(text, dest_lang, context, timeout)
|
||||||
|
elif provider == "appimage":
|
||||||
|
translated = translate_appimage(text, dest_lang, context, timeout, appimage_path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown provider: {provider}")
|
||||||
|
return clean_translation(translated) or text
|
||||||
|
|
||||||
|
|
||||||
|
def load_language_cache(path: Path) -> dict[str, str]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
return {str(text): str(value) for text, value in data.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def write_language_cache(path: Path, cache: dict[str, str]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp_path.write_text(
|
||||||
|
json.dumps(cache, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
tmp_path.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def build_arg_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Extract translate calls from scripts/ and build json/cache.json."
|
||||||
|
)
|
||||||
|
parser.add_argument("--scripts-dir", default="scripts", type=Path)
|
||||||
|
parser.add_argument(
|
||||||
|
"--extra-file",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
type=Path,
|
||||||
|
metavar="PATH",
|
||||||
|
help=(
|
||||||
|
"Extra individual files to scan for translate calls in addition "
|
||||||
|
"to --scripts-dir. Useful for the root-level `menu` entry point "
|
||||||
|
"and install_proxmenux*.sh, which sit outside scripts/. "
|
||||||
|
"Pass multiple times to add more than one file."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-dir",
|
||||||
|
default=Path("lang"),
|
||||||
|
type=Path,
|
||||||
|
help="Directory where per-language JSON files are written. Default: lang",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
default=None,
|
||||||
|
type=Path,
|
||||||
|
help="Deprecated combined cache path. If used, per-language files are written next to it under its parent directory.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--languages",
|
||||||
|
default=",".join(DEFAULT_LANGUAGES),
|
||||||
|
help="Comma-separated destination languages. Default: es,fr,de,it,pt,sk",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--provider",
|
||||||
|
choices=("appimage", "googletrans", "google-web"),
|
||||||
|
default="appimage",
|
||||||
|
help="Translation provider to use. Default: appimage",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--appimage-path",
|
||||||
|
default=Path("/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"),
|
||||||
|
type=Path,
|
||||||
|
help="Path to the ProxMenux AppImage when using --provider appimage.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--context", default=DEFAULT_CONTEXT)
|
||||||
|
parser.add_argument("--timeout", default=30, type=int)
|
||||||
|
parser.add_argument("--sleep", default=0.15, type=float)
|
||||||
|
parser.add_argument(
|
||||||
|
"--refresh",
|
||||||
|
action="store_true",
|
||||||
|
help="Translate all entries again instead of reusing existing cache values.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--extract-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Only update the cache keys; missing translations are left empty.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--limit",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help="Only process the first N extracted strings. Useful for test runs.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--save-every",
|
||||||
|
type=int,
|
||||||
|
default=1,
|
||||||
|
help="Write the output JSON every N translated items. Default: 1",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = build_arg_parser().parse_args()
|
||||||
|
scripts_dir = args.scripts_dir.resolve()
|
||||||
|
if args.output is not None:
|
||||||
|
output_dir = args.output.resolve().parent / "lang"
|
||||||
|
else:
|
||||||
|
output_dir = args.output_dir.resolve()
|
||||||
|
languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()]
|
||||||
|
|
||||||
|
if not scripts_dir.is_dir():
|
||||||
|
print(f"Scripts directory not found: {scripts_dir}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not languages:
|
||||||
|
print("No destination languages selected.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
texts = extract_translate_texts(scripts_dir, args.extra_file)
|
||||||
|
if args.limit > 0:
|
||||||
|
texts = texts[: args.limit]
|
||||||
|
existing_by_lang = {
|
||||||
|
lang: load_language_cache(output_dir / f"{lang}.json")
|
||||||
|
for lang in languages
|
||||||
|
}
|
||||||
|
next_by_lang: dict[str, dict[str, str]] = {lang: {} for lang in languages}
|
||||||
|
print(f"Found {len(texts)} unique translate strings.", flush=True)
|
||||||
|
print(f"Output directory: {output_dir}", flush=True)
|
||||||
|
print(f"Languages: {', '.join(languages)}", flush=True)
|
||||||
|
|
||||||
|
failures: list[tuple[str, str, str]] = []
|
||||||
|
total = len(texts) * len(languages)
|
||||||
|
done = 0
|
||||||
|
|
||||||
|
for lang in languages:
|
||||||
|
existing = existing_by_lang.get(lang, {})
|
||||||
|
print(f"Starting language: {lang}", flush=True)
|
||||||
|
|
||||||
|
for index, text in enumerate(texts, start=1):
|
||||||
|
done += 1
|
||||||
|
if not args.refresh and existing.get(text):
|
||||||
|
next_by_lang[lang][text] = existing[text]
|
||||||
|
continue
|
||||||
|
if args.extract_only:
|
||||||
|
next_by_lang[lang][text] = existing.get(text, "")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"[{done}/{total}] {lang} ({index}/{len(texts)}): {text[:80]}", flush=True)
|
||||||
|
try:
|
||||||
|
next_by_lang[lang][text] = translate_text(
|
||||||
|
text,
|
||||||
|
lang,
|
||||||
|
args.provider,
|
||||||
|
args.context,
|
||||||
|
args.timeout,
|
||||||
|
args.appimage_path,
|
||||||
|
)
|
||||||
|
print(f" => {next_by_lang[lang][text][:100]}", flush=True)
|
||||||
|
except Exception as exc:
|
||||||
|
next_by_lang[lang][text] = existing.get(text, text)
|
||||||
|
failures.append((text, lang, str(exc)))
|
||||||
|
print(f" failed: {exc}", file=sys.stderr, flush=True)
|
||||||
|
if args.save_every > 0 and index % args.save_every == 0:
|
||||||
|
write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang])
|
||||||
|
time.sleep(args.sleep)
|
||||||
|
|
||||||
|
write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang])
|
||||||
|
print(f"Completed language: {lang}", flush=True)
|
||||||
|
|
||||||
|
for lang, cache in next_by_lang.items():
|
||||||
|
write_language_cache(output_dir / f"{lang}.json", cache)
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print(f"Completed with {len(failures)} translation failures.", file=sys.stderr, flush=True)
|
||||||
|
for text, lang, error in failures[:20]:
|
||||||
|
print(f"- {lang}: {text[:80]} -> {error}", file=sys.stderr, flush=True)
|
||||||
|
if len(failures) > 20:
|
||||||
|
print(f"... and {len(failures) - 20} more.", file=sys.stderr, flush=True)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
print("Translation cache generated successfully.", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
name: Build translation cache
|
||||||
|
|
||||||
|
# Regenerates lang/*.json whenever a bash script under scripts/ changes.
|
||||||
|
# The runtime translate() in scripts/utils.sh reads these JSON files and
|
||||||
|
# falls back to the English source on miss, so keeping them up-to-date is
|
||||||
|
# what makes ProxMenux multilingual without any runtime googletrans
|
||||||
|
# dependency on the user's host.
|
||||||
|
#
|
||||||
|
# Triggers:
|
||||||
|
# - push to develop touching scripts/**/*.sh
|
||||||
|
# - manual via workflow_dispatch (force --refresh)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
paths:
|
||||||
|
- 'scripts/**/*.sh'
|
||||||
|
- 'menu'
|
||||||
|
- 'install_proxmenux.sh'
|
||||||
|
- 'install_proxmenux_beta.sh'
|
||||||
|
- '.github/scripts/build_translation_cache.py'
|
||||||
|
- '.github/workflows/build-translation-cache.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
refresh:
|
||||||
|
description: 'Re-translate every entry (ignore cached values)'
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
# Avoid two cache rebuilds from racing each other on the same branch and
|
||||||
|
# fighting over the auto-commit.
|
||||||
|
concurrency:
|
||||||
|
group: build-translation-cache-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rebuild-cache:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write # auto-commit lang/*.json back to develop
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout develop
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: develop
|
||||||
|
# Need full history so the auto-commit doesn't fail when the
|
||||||
|
# cache job runs minutes after the trigger push (GH default
|
||||||
|
# fetch-depth=1 sometimes diverges from origin/develop after a
|
||||||
|
# quick follow-up push).
|
||||||
|
fetch-depth: 0
|
||||||
|
# Use a PAT (or default GITHUB_TOKEN if branch protections allow
|
||||||
|
# it) so the push back to develop actually fires later steps
|
||||||
|
# (workflow runs from auto-commits) if you ever need them.
|
||||||
|
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
|
||||||
|
# 4.0.0-rc1 is the same pin that build_translation_cache.py
|
||||||
|
# was written against. Bump in lockstep with the script.
|
||||||
|
pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0'
|
||||||
|
|
||||||
|
- name: Regenerate lang/*.json
|
||||||
|
run: |
|
||||||
|
REFRESH_FLAG=""
|
||||||
|
if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then
|
||||||
|
REFRESH_FLAG="--refresh"
|
||||||
|
fi
|
||||||
|
# Extra files outside scripts/ that also contain translate "..."
|
||||||
|
# calls. Keep this list in sync with the `paths` trigger above.
|
||||||
|
python .github/scripts/build_translation_cache.py \
|
||||||
|
--scripts-dir scripts \
|
||||||
|
--extra-file menu \
|
||||||
|
--extra-file install_proxmenux.sh \
|
||||||
|
--extra-file install_proxmenux_beta.sh \
|
||||||
|
--output-dir lang \
|
||||||
|
--provider googletrans \
|
||||||
|
$REFRESH_FLAG
|
||||||
|
|
||||||
|
- name: Commit + push if changed
|
||||||
|
run: |
|
||||||
|
if git diff --quiet -- lang/; then
|
||||||
|
echo "No translation changes — skipping commit."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git config user.name "ProxMenuxBot"
|
||||||
|
git config user.email "bot@proxmenux.local"
|
||||||
|
git add lang/
|
||||||
|
git commit -m "chore(lang): auto-rebuild translation cache
|
||||||
|
|
||||||
|
Source: ${GITHUB_SHA::7}
|
||||||
|
Triggered by: ${{ github.event_name }}"
|
||||||
|
git push origin develop
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Update project growth
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 4 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: repo-growth
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: MacRimi/repo-growth@v1
|
||||||
|
with:
|
||||||
|
title: ProxMenux growth
|
||||||
|
output: images/project-growth.svg
|
||||||
|
history: .github/repo-growth/history.json
|
||||||
|
metrics: stars,forks
|
||||||
|
layout: dashboard
|
||||||
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -1 +1 @@
|
|||||||
e0128ac327ea74b645b37bcfab03aa744f067a355f9960a822b411c6ac75cb02 ProxMenux-1.2.2.AppImage
|
5dcad163093e68c6d7742c6724fb7df8f3d5bdd5542462f3120d2a00ad7bf699 ProxMenux-1.2.4.AppImage
|
||||||
|
|||||||
+102
-34
@@ -27,19 +27,97 @@ A modern, responsive dashboard for monitoring Proxmox VE systems built with Next
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
**ProxMenux Monitor** is a comprehensive, real-time monitoring dashboard for Proxmox VE environments. Built with modern web technologies, it provides an intuitive interface to monitor system resources, virtual machines, containers, storage, network traffic, and system logs.
|
**ProxMenux Monitor** is a comprehensive, real-time monitoring dashboard for Proxmox VE environments. Built with modern web technologies, it provides an intuitive interface to monitor system resources, virtual machines, containers, storage, network traffic, backups, health status and system logs — all from a single browser tab.
|
||||||
|
|
||||||
The application runs as a standalone AppImage on your Proxmox server and serves a web interface accessible from any device on your network.
|
The application runs as a standalone AppImage on your Proxmox server and serves a web interface accessible from any device on your network.
|
||||||
|
|
||||||
|
**Full documentation:** [proxmenux.com/docs/monitor](https://proxmenux.com/docs/monitor) — per-feature walkthroughs, API reference and integration guides.
|
||||||
|
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
Get a quick overview of ProxMenux Monitor's main features:
|
A tour of the Monitor's main areas. See the [public documentation](https://proxmenux.com/docs/monitor) for the full walkthrough.
|
||||||
|
|
||||||
|
### Dashboard overview
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="public/images/onboarding/imagen1.png" alt="Overview Dashboard" width="800"/>
|
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/dashboard-home.png" alt="Dashboard home" width="900"/>
|
||||||
<br/>
|
<br/>
|
||||||
<em>System Overview - Monitor CPU, memory, temperature, and uptime in real-time</em>
|
<em>Real-time CPU, memory, temperature, storage and network activity on one glance-optimised page.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Backup & Restore
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/images/docs/backup-restore/scheduled-backup-monitor.png" alt="Scheduled backup jobs" width="900"/>
|
||||||
|
<br/>
|
||||||
|
<em>Integrated host backup & restore — Local, PBS or Borg destinations; own timer or attached to a PVE vzdump job with live-inherited retention; PBS encryption with paired recovery blobs; direction-aware cross-kernel restore.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Storage & SMART
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/storage-top-row.png" alt="Storage top row" width="900"/>
|
||||||
|
<br/>
|
||||||
|
<em>Per-disk cards with capacity palette shared across Storage and Backups. USB-NVMe enclosures (ASMedia / JMicron / Realtek) show the drive's real identity and temperature, not the bridge.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/disk-modal-smart.png" alt="Disk SMART modal" width="900"/>
|
||||||
|
<br/>
|
||||||
|
<em>Per-disk detail modal — SMART attributes, temperature history, observations and downloadable PDF report.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Network — live topology
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/network-flow-overview.png" alt="Network Flow diagram" width="900"/>
|
||||||
|
<br/>
|
||||||
|
<em>Network Flow — live view of NICs → host → bridges → guests with animated rx / tx pulses on every link. Bridges without active guests are hidden.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/network-latency-historical.png" alt="Network latency historical" width="900"/>
|
||||||
|
<br/>
|
||||||
|
<em>Latency modal — historical view and real-time ping test against Gateway / Cloudflare / Google, with a downloadable PDF report.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Virtual machines & LXCs
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/vms-top-row.png" alt="VMs & LXCs overview" width="900"/>
|
||||||
|
<br/>
|
||||||
|
<em>Inventory of running VMs and containers with resource usage and per-guest controls.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/vms-modal-status.png" alt="VM detail modal — status" width="900"/>
|
||||||
|
<br/>
|
||||||
|
<em>Per-guest modal with status, backups, mounts and (for LXC) apt / apk / community-scripts update inventory.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Health Monitor
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/health-monitor.png" alt="Health Monitor" width="900"/>
|
||||||
|
<br/>
|
||||||
|
<em>Ten categories of proactive health checks with hysteresis, per-error dismiss (24 h / 7 d / permanent) and an Active Suppressions panel to revoke.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### System overview — top processes
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/system-overview-top-processes.png" alt="Top processes" width="900"/>
|
||||||
|
<br/>
|
||||||
|
<em>Per-process CPU / memory / I/O sorted by consumer, with a click-through detail modal.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/mobile-home.png" alt="Mobile responsive layout" width="360"/>
|
||||||
|
<br/>
|
||||||
|
<em>Fully responsive — every panel adapts to phone and tablet layouts.</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
@@ -47,18 +125,21 @@ Get a quick overview of ProxMenux Monitor's main features:
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **System Overview**: Real-time monitoring of CPU, memory, temperature, and system uptime
|
- **Host Backup & Restore** — integrated section covering Local, PBS and Borg destinations; schedule with a systemd timer or attach to an existing PVE vzdump job; PBS encryption with paired recovery blobs; direction-aware cross-kernel restore with kernel-agnostic hydration (IOMMU / VFIO / GRUB), cascade-safe package replay, and NIC auto-remap by MAC after a motherboard swap
|
||||||
- **Storage Management**: Visual representation of storage distribution, disk health, and SMART data
|
- **System Overview** — real-time CPU / memory / temperature / uptime with a per-process drill-down (Top Processes view) and click-through detail modal
|
||||||
- **Network Monitoring**: Network interface statistics, real-time traffic graphs, and bandwidth usage
|
- **Storage & SMART** — per-disk cards in a responsive grid; temperature history, SMART attributes and a downloadable PDF SMART report per drive. USB-NVMe bridges (ASMedia / JMicron / Realtek) show the real drive's identity and temperature instead of the enclosure's
|
||||||
- **Virtual Machines & LXC**: Comprehensive view of all VMs and containers with resource usage and controls
|
- **Network** — live topology (Network Flow) with animated rx / tx pulses, per-interface RRD charts, latency modal against Gateway / Cloudflare / Google with a downloadable PDF report
|
||||||
- **Hardware Information**: Detailed hardware specifications including CPU, GPU, PCIe devices, and disks
|
- **Virtual Machines & LXCs** — inventory, per-guest metrics and controls, per-LXC update inventory (apt / apk / community-scripts), backup state, mount inspection
|
||||||
- **System Logs**: Real-time system log monitoring with filtering and search capabilities
|
- **Hardware** — CPU / GPU / PCIe / disks / NICs, with correct SSD vs HDD classification even behind USB-SATA bridges
|
||||||
- **Health Monitoring**: Proactive system health checks with persistent error tracking
|
- **System Logs** — journalctl with severity / since / free-text filters and a download-as-text action
|
||||||
- **Authentication & 2FA**: Optional password protection with TOTP-based two-factor authentication
|
- **Health Monitor** — ten categories of proactive checks with hysteresis and configurable thresholds; per-error dismiss (24 h / 7 d / permanent) and an Active Suppressions panel to revoke
|
||||||
- **RESTful API**: Complete API access for integrations with Homepage, Home Assistant, and custom dashboards
|
- **Notifications** — five channels (Telegram, Discord, Gotify, Email, Apprise for ~80 endpoints), per-event toggles, Quiet Hours, Daily Digest, optional AI enrichment (Groq / OpenAI / Ollama / Gemini / Anthropic / OpenRouter)
|
||||||
- **Dark/Light Theme**: Toggle between themes with Proxmox-inspired design
|
- **Authentication & 2FA** — optional password protection with TOTP-based two-factor authentication and long-lived API tokens (365 days) for integrations
|
||||||
- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
|
- **RESTful API** — complete access with JWT auth; ready-made recipes for Homepage, Home Assistant and custom dashboards; Prometheus scrape endpoint
|
||||||
- **Release Notes**: Automatic notifications of new features and improvements
|
- **Reports (PDF)** — SMART report per-disk and Network Latency report per-target, ready to send to a vendor or an ISP
|
||||||
|
- **Dark / Light Theme** — Proxmox-inspired palette with live switch
|
||||||
|
- **Responsive Design** — desktop, tablet and mobile layouts
|
||||||
|
- **Release Notes** — automatic notification of new features on each Monitor upgrade
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
@@ -106,7 +187,7 @@ On first launch, you'll be presented with three options:
|
|||||||
2. **Enable 2FA** - Add TOTP-based two-factor authentication for enhanced security
|
2. **Enable 2FA** - Add TOTP-based two-factor authentication for enhanced security
|
||||||
3. **Skip** - Continue without authentication (not recommended for production environments)
|
3. **Skip** - Continue without authentication (not recommended for production environments)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Two-Factor Authentication (2FA)
|
### Two-Factor Authentication (2FA)
|
||||||
|
|
||||||
@@ -118,7 +199,7 @@ After setting up your password, you can enable 2FA using any TOTP authenticator
|
|||||||
4. Enter the 6-digit code to verify
|
4. Enter the 6-digit code to verify
|
||||||
5. Save your backup codes in a secure location
|
5. Save your backup codes in a secure location
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Security Best Practices for API Tokens
|
### Security Best Practices for API Tokens
|
||||||
|
|
||||||
@@ -254,7 +335,7 @@ The easiest way to generate an API token is through the ProxMenux Monitor web in
|
|||||||
6. Click **Generate Token**
|
6. Click **Generate Token**
|
||||||
7. Copy the token immediately - it will not be shown again
|
7. Copy the token immediately - it will not be shown again
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The token will be valid for **365 days** (1 year) and can be used for integrations with Homepage, Home Assistant, or any custom application.
|
The token will be valid for **365 days** (1 year) and can be used for integrations with Homepage, Home Assistant, or any custom application.
|
||||||
|
|
||||||
@@ -643,8 +724,6 @@ Finally, reference the secret in your `services.yaml`:
|
|||||||
format: bytes
|
format: bytes
|
||||||
```
|
```
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Home Assistant Integration
|
### Home Assistant Integration
|
||||||
|
|
||||||
[Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform.
|
[Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform.
|
||||||
@@ -727,24 +806,13 @@ entities:
|
|||||||
icon: mdi:clock-outline
|
icon: mdi:clock-outline
|
||||||
```
|
```
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the **Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)**.
|
This project is licensed under the **GNU General Public License, version 3 (GPL-3.0)**.
|
||||||
|
|
||||||
You are free to:
|
You are free to use, study, share and modify the software under the terms of the licence. Any distributed derivative work must be licensed under the same terms and include the full source code — see the [full licence text](https://www.gnu.org/licenses/gpl-3.0.html) or the [`LICENSE`](https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) file at the repository root.
|
||||||
- Share — copy and redistribute the material in any medium or format
|
|
||||||
- Adapt — remix, transform, and build upon the material
|
|
||||||
|
|
||||||
Under the following terms:
|
|
||||||
- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made
|
|
||||||
- NonCommercial — You may not use the material for commercial purposes
|
|
||||||
|
|
||||||
For more details, see the [full license](https://creativecommons.org/licenses/by-nc/4.0/).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -3,6 +3,9 @@ import type { Metadata, Viewport } from "next"
|
|||||||
import { GeistSans } from "geist/font/sans"
|
import { GeistSans } from "geist/font/sans"
|
||||||
import { GeistMono } from "geist/font/mono"
|
import { GeistMono } from "geist/font/mono"
|
||||||
import { ThemeProvider } from "../components/theme-provider"
|
import { ThemeProvider } from "../components/theme-provider"
|
||||||
|
import { PwaRegister } from "../components/pwa-register"
|
||||||
|
import { PwaInstallPrompt } from "../components/pwa-install-prompt"
|
||||||
|
import { I18nProvider } from "../lib/i18n/provider"
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
|
||||||
@@ -41,11 +44,15 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-background text-foreground`}>
|
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-background text-foreground`}>
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<Suspense fallback={null}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
<I18nProvider>
|
||||||
{children}
|
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
||||||
</ThemeProvider>
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
<PwaInstallPrompt />
|
||||||
|
</I18nProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<PwaRegister />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { ProxmoxDashboard } from "../components/proxmox-dashboard"
|
|||||||
import { Login } from "../components/login"
|
import { Login } from "../components/login"
|
||||||
import { AuthSetup } from "../components/auth-setup"
|
import { AuthSetup } from "../components/auth-setup"
|
||||||
import { getApiUrl } from "../lib/api-config"
|
import { getApiUrl } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const t = useT()
|
||||||
const [authStatus, setAuthStatus] = useState<{
|
const [authStatus, setAuthStatus] = useState<{
|
||||||
loading: boolean
|
loading: boolean
|
||||||
authEnabled: boolean
|
authEnabled: boolean
|
||||||
@@ -113,8 +115,8 @@ export default function Home() {
|
|||||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-foreground">Loading...</div>
|
<div className="text-sm font-medium text-foreground">{t("app.loading")}</div>
|
||||||
<p className="text-xs text-muted-foreground">Connecting to ProxMenux Monitor</p>
|
<p className="text-xs text-muted-foreground">{t("app.connecting")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||||
import { APP_VERSION } from "./release-notes-modal"
|
import { APP_VERSION } from "./release-notes-modal"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
// Issue #191: a dedicated About tab. Centralises project metadata
|
// Issue #191: a dedicated About tab. Centralises project metadata
|
||||||
// (version, license, author) and every external link the project
|
// (version, license, author) and every external link the project
|
||||||
@@ -22,8 +23,8 @@ import { APP_VERSION } from "./release-notes-modal"
|
|||||||
// without re-cluttering the dashboard footer.
|
// without re-cluttering the dashboard footer.
|
||||||
|
|
||||||
interface LinkRow {
|
interface LinkRow {
|
||||||
label: string
|
labelKey: string
|
||||||
description: string
|
descriptionKey: string
|
||||||
href: string
|
href: string
|
||||||
Icon: React.ComponentType<{ className?: string }>
|
Icon: React.ComponentType<{ className?: string }>
|
||||||
accent?: keyof typeof ACCENT_CLASSES
|
accent?: keyof typeof ACCENT_CLASSES
|
||||||
@@ -42,29 +43,29 @@ const ACCENT_CLASSES = {
|
|||||||
|
|
||||||
const PROJECT_LINKS: LinkRow[] = [
|
const PROJECT_LINKS: LinkRow[] = [
|
||||||
{
|
{
|
||||||
label: "GitHub repository",
|
labelKey: "about.links.repository.label",
|
||||||
description: "Source code, releases and issue tracker.",
|
descriptionKey: "about.links.repository.description",
|
||||||
href: "https://github.com/MacRimi/ProxMenux",
|
href: "https://github.com/MacRimi/ProxMenux",
|
||||||
Icon: Github,
|
Icon: Github,
|
||||||
accent: "gray",
|
accent: "gray",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Documentation",
|
labelKey: "about.links.documentation.label",
|
||||||
description: "Full user guide for ProxMenux and the Monitor.",
|
descriptionKey: "about.links.documentation.description",
|
||||||
href: "https://proxmenux.com",
|
href: "https://proxmenux.com",
|
||||||
Icon: BookOpen,
|
Icon: BookOpen,
|
||||||
accent: "blue",
|
accent: "blue",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Discussions",
|
labelKey: "about.links.discussions.label",
|
||||||
description: "Ask questions, share custom AI prompts, swap ideas.",
|
descriptionKey: "about.links.discussions.description",
|
||||||
href: "https://github.com/MacRimi/ProxMenux/discussions",
|
href: "https://github.com/MacRimi/ProxMenux/discussions",
|
||||||
Icon: MessageSquare,
|
Icon: MessageSquare,
|
||||||
accent: "purple",
|
accent: "purple",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Report a bug or request a feature",
|
labelKey: "about.links.issues.label",
|
||||||
description: "Open an issue on GitHub — bugs, ideas, regressions.",
|
descriptionKey: "about.links.issues.description",
|
||||||
href: "https://github.com/MacRimi/ProxMenux/issues",
|
href: "https://github.com/MacRimi/ProxMenux/issues",
|
||||||
Icon: Bug,
|
Icon: Bug,
|
||||||
accent: "red",
|
accent: "red",
|
||||||
@@ -73,8 +74,8 @@ const PROJECT_LINKS: LinkRow[] = [
|
|||||||
|
|
||||||
const SUPPORT_LINKS: LinkRow[] = [
|
const SUPPORT_LINKS: LinkRow[] = [
|
||||||
{
|
{
|
||||||
label: "Support the project on Ko-fi",
|
labelKey: "about.links.support.label",
|
||||||
description: "ProxMenux is free and open source. Donations cover hosting and dev time.",
|
descriptionKey: "about.links.support.description",
|
||||||
href: "https://ko-fi.com/macrimi",
|
href: "https://ko-fi.com/macrimi",
|
||||||
Icon: Heart,
|
Icon: Heart,
|
||||||
accent: "pink",
|
accent: "pink",
|
||||||
@@ -82,6 +83,7 @@ const SUPPORT_LINKS: LinkRow[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
function LinkCard({ row }: { row: LinkRow }) {
|
function LinkCard({ row }: { row: LinkRow }) {
|
||||||
|
const t = useT()
|
||||||
const accentClass = ACCENT_CLASSES[row.accent ?? "blue"]
|
const accentClass = ACCENT_CLASSES[row.accent ?? "blue"]
|
||||||
// Style mirrors the PCI Devices cards in the Hardware tab: subtle
|
// Style mirrors the PCI Devices cards in the Hardware tab: subtle
|
||||||
// translucent background by default, slightly lighter on hover, no
|
// translucent background by default, slightly lighter on hover, no
|
||||||
@@ -101,16 +103,17 @@ function LinkCard({ row }: { row: LinkRow }) {
|
|||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||||
{row.label}
|
{t(row.labelKey)}
|
||||||
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{row.description}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{t(row.descriptionKey)}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function About() {
|
export function About() {
|
||||||
|
const t = useT()
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
{/* Hero — logo, name, version, one-line description. */}
|
{/* Hero — logo, name, version, one-line description. */}
|
||||||
@@ -120,7 +123,7 @@ export function About() {
|
|||||||
<div className="relative w-24 h-24 md:w-28 md:h-28 flex-shrink-0">
|
<div className="relative w-24 h-24 md:w-28 md:h-28 flex-shrink-0">
|
||||||
<Image
|
<Image
|
||||||
src="/images/proxmenux-logo.png"
|
src="/images/proxmenux-logo.png"
|
||||||
alt="ProxMenux logo"
|
alt={t("about.logoAlt")}
|
||||||
fill
|
fill
|
||||||
priority
|
priority
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
@@ -131,9 +134,7 @@ export function About() {
|
|||||||
ProxMenux Monitor
|
ProxMenux Monitor
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
A web dashboard and management layer for Proxmox VE — health monitoring,
|
{t("about.heroDescription")}
|
||||||
notifications, terminal, optimization tracker and more, packaged as a single
|
|
||||||
AppImage.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mt-3">
|
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2 mt-3">
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/30 px-2.5 py-1 text-xs font-mono">
|
<span className="inline-flex items-center gap-1.5 rounded-md bg-blue-500/10 text-blue-500 border border-blue-500/30 px-2.5 py-1 text-xs font-mono">
|
||||||
@@ -151,7 +152,7 @@ export function About() {
|
|||||||
const href = isPrerelease
|
const href = isPrerelease
|
||||||
? "https://github.com/MacRimi/ProxMenux/releases"
|
? "https://github.com/MacRimi/ProxMenux/releases"
|
||||||
: "https://proxmenux.com/en/changelog"
|
: "https://proxmenux.com/en/changelog"
|
||||||
const label = isPrerelease ? "Release notes" : "Changelog"
|
const label = isPrerelease ? t("about.releaseNotes") : t("about.changelog")
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
@@ -175,9 +176,9 @@ export function About() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Github className="h-4 w-4 text-muted-foreground" />
|
<Github className="h-4 w-4 text-muted-foreground" />
|
||||||
Project
|
{t("about.project.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Repository, documentation and community channels.</CardDescription>
|
<CardDescription>{t("about.project.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
@@ -195,11 +196,10 @@ export function About() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<Heart className="h-4 w-4 text-pink-500" />
|
<Heart className="h-4 w-4 text-pink-500" />
|
||||||
Support & License
|
{t("about.support.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
ProxMenux is free and open source under the GPL-3.0 license. If it's useful to
|
{t("about.support.description")}
|
||||||
you, a one-off contribution helps keep it that way.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -218,11 +218,11 @@ export function About() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||||
GPL-3.0 license
|
{t("about.license.label")}
|
||||||
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
<ExternalLink className="h-3 w-3 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||||
Free software — see the LICENSE file for the full text.
|
{t("about.license.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import { Input } from "./ui/input"
|
|||||||
import { Label } from "./ui/label"
|
import { Label } from "./ui/label"
|
||||||
import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react"
|
import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react"
|
||||||
import { getApiUrl } from "../lib/api-config"
|
import { getApiUrl } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface AuthSetupProps {
|
interface AuthSetupProps {
|
||||||
onComplete: () => void
|
onComplete: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthSetup({ onComplete }: AuthSetupProps) {
|
export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||||
|
const t = useT()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [step, setStep] = useState<"choice" | "setup">("choice")
|
const [step, setStep] = useState<"choice" | "setup">("choice")
|
||||||
const [username, setUsername] = useState("")
|
const [username, setUsername] = useState("")
|
||||||
@@ -74,7 +76,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || "Failed to skip authentication")
|
throw new Error(data.error || t("authSetup.skipFailed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.auth_declined) {
|
if (data.auth_declined) {
|
||||||
@@ -86,7 +88,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
onComplete()
|
onComplete()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Auth skip error:", err)
|
console.error("Auth skip error:", err)
|
||||||
setError(err instanceof Error ? err.message : "Failed to save preference")
|
setError(err instanceof Error ? err.message : t("authSetup.savePreferenceFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -108,17 +110,17 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
setError("Please fill in all fields")
|
setError(t("authSetup.fillFields"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
setError("Passwords do not match")
|
setError(t("authSetup.passwordMismatch"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 6) {
|
if (password.length < 6) {
|
||||||
setError("Password must be at least 6 characters")
|
setError(t("authSetup.passwordTooShort"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +139,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || "Failed to setup authentication")
|
throw new Error(data.error || t("authSetup.setupFailed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
@@ -204,7 +206,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
onComplete()
|
onComplete()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Auth setup error:", err)
|
console.error("Auth setup error:", err)
|
||||||
setError(err instanceof Error ? err.message : "Failed to setup authentication")
|
setError(err instanceof Error ? err.message : t("authSetup.setupFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -214,7 +216,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
<DialogTitle className="sr-only">
|
<DialogTitle className="sr-only">
|
||||||
{step === "choice" ? "Setup Dashboard Protection" : "Create Password"}
|
{step === "choice" ? t("authSetup.choiceTitle") : t("authSetup.passwordTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
{step === "choice" ? (
|
{step === "choice" ? (
|
||||||
<div className="space-y-6 py-2">
|
<div className="space-y-6 py-2">
|
||||||
@@ -222,16 +224,16 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
|
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
|
||||||
<Shield className="h-8 w-8 text-blue-500" />
|
<Shield className="h-8 w-8 text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold">Protect Your Dashboard?</h2>
|
<h2 className="text-2xl font-bold">{t("authSetup.protectTitle")}</h2>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Add an extra layer of security to protect your Proxmox data when accessing from non-private networks.
|
{t("authSetup.protectDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
|
<Button onClick={() => setStep("setup")} className="w-full bg-blue-500 hover:bg-blue-600" size="lg">
|
||||||
<Lock className="h-4 w-4 mr-2" />
|
<Lock className="h-4 w-4 mr-2" />
|
||||||
Yes, Setup Password
|
{t("authSetup.setupPassword")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSkipAuth}
|
onClick={handleSkipAuth}
|
||||||
@@ -240,11 +242,11 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
size="lg"
|
size="lg"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
No, Continue Without Protection
|
{t("authSetup.skipProtection")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-center text-muted-foreground">You can always enable this later in Settings</p>
|
<p className="text-xs text-center text-muted-foreground">{t("authSetup.enableLater")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6 py-2">
|
<div className="space-y-6 py-2">
|
||||||
@@ -252,8 +254,8 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
|
<div className="mx-auto w-16 h-16 bg-blue-500/10 rounded-full flex items-center justify-center">
|
||||||
<Lock className="h-8 w-8 text-blue-500" />
|
<Lock className="h-8 w-8 text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold">Setup Authentication</h2>
|
<h2 className="text-2xl font-bold">{t("authSetup.setupTitle")}</h2>
|
||||||
<p className="text-muted-foreground text-sm">Create a username and password to protect your dashboard</p>
|
<p className="text-muted-foreground text-sm">{t("authSetup.setupDescription")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -266,14 +268,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username" className="text-sm">
|
<Label htmlFor="username" className="text-sm">
|
||||||
Username
|
{t("authSetup.username")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter username"
|
placeholder={t("authSetup.usernamePlaceholder")}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className="pl-10 text-base"
|
className="pl-10 text-base"
|
||||||
@@ -285,14 +287,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password" className="text-sm">
|
<Label htmlFor="password" className="text-sm">
|
||||||
Password
|
{t("authSetup.password")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder="Enter password"
|
placeholder={t("authSetup.passwordPlaceholder")}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="pl-10 text-base"
|
className="pl-10 text-base"
|
||||||
@@ -312,14 +314,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirm-password" className="text-sm">
|
<Label htmlFor="confirm-password" className="text-sm">
|
||||||
Confirm Password
|
{t("authSetup.confirmPassword")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="confirm-password"
|
id="confirm-password"
|
||||||
type={showConfirmPassword ? "text" : "password"}
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
placeholder="Confirm password"
|
placeholder={t("authSetup.confirmPasswordPlaceholder")}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
className="pl-10 text-base"
|
className="pl-10 text-base"
|
||||||
@@ -345,19 +347,19 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
setup endpoint returns the JWT. */}
|
setup endpoint returns the JWT. */}
|
||||||
<div className="pt-3 border-t border-border/60 space-y-4">
|
<div className="pt-3 border-t border-border/60 space-y-4">
|
||||||
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
<p className="text-xs text-muted-foreground uppercase tracking-wider">
|
||||||
Profile · optional
|
{t("authSetup.profileOptional")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="display-name" className="text-sm">
|
<Label htmlFor="display-name" className="text-sm">
|
||||||
Display name
|
{t("authSetup.displayName")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="display-name"
|
id="display-name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Shown above the username in the menu"
|
placeholder={t("authSetup.displayNamePlaceholder")}
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
maxLength={64}
|
maxLength={64}
|
||||||
@@ -366,12 +368,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Leave empty to render the username itself. Up to 64 characters.
|
{t("authSetup.displayNameHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm">Avatar</Label>
|
<Label className="text-sm">{t("authSetup.avatar")}</Label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{avatarPreviewUrl ? (
|
{avatarPreviewUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@@ -407,7 +409,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
>
|
>
|
||||||
<Upload className="h-3 w-3 mr-1.5" />
|
<Upload className="h-3 w-3 mr-1.5" />
|
||||||
{avatarFile ? "Change" : "Choose image"}
|
{avatarFile ? t("authSetup.change") : t("authSetup.chooseImage")}
|
||||||
</Button>
|
</Button>
|
||||||
{avatarFile && (
|
{avatarFile && (
|
||||||
<Button
|
<Button
|
||||||
@@ -419,12 +421,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
className="h-7 text-xs text-red-500 hover:text-red-500 hover:bg-red-500/10"
|
className="h-7 text-xs text-red-500 hover:text-red-500 hover:bg-red-500/10"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3 mr-1.5" />
|
<Trash2 className="h-3 w-3 mr-1.5" />
|
||||||
Clear
|
{t("authSetup.clear")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results.
|
{t("authSetup.avatarHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -434,10 +436,10 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||||
{loading ? "Setting up..." : "Setup Authentication"}
|
{loading ? t("authSetup.settingUp") : t("authSetup.setupAuthentication")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
|
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
|
||||||
Back
|
{t("authSetup.back")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "./ui/dropdown-menu"
|
} from "./ui/dropdown-menu"
|
||||||
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface AuthStatus {
|
interface AuthStatus {
|
||||||
auth_enabled?: boolean
|
auth_enabled?: boolean
|
||||||
@@ -57,6 +58,8 @@ interface AvatarMenuProps {
|
|||||||
* proper /api/auth/logout that revokes the JWT server-side too.
|
* proper /api/auth/logout that revokes the JWT server-side too.
|
||||||
*/
|
*/
|
||||||
export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) {
|
export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
// IMPORTANT — all hooks must run unconditionally on every render. The
|
// IMPORTANT — all hooks must run unconditionally on every render. The
|
||||||
// previous version short-circuited with `if (!auth_enabled) return null`
|
// previous version short-circuited with `if (!auth_enabled) return null`
|
||||||
// BEFORE the avatar blob hooks, so the hook count changed between
|
// BEFORE the avatar blob hooks, so the hook count changed between
|
||||||
@@ -201,7 +204,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="rounded-full hover:ring-2 hover:ring-cyan-500/30 transition-all relative z-50 focus:outline-none focus-visible:outline-none active:outline-none data-[state=open]:outline-none data-[state=open]:ring-0 select-none"
|
className="rounded-full hover:ring-2 hover:ring-cyan-500/30 transition-all relative z-50 focus:outline-none focus-visible:outline-none active:outline-none data-[state=open]:outline-none data-[state=open]:ring-0 select-none"
|
||||||
aria-label="Open user menu"
|
aria-label={t("actions.openUserMenu")}
|
||||||
// WebKit ignores `outline` for the tap-highlight overlay
|
// WebKit ignores `outline` for the tap-highlight overlay
|
||||||
// shown on iOS / Android Chrome after a touch. That overlay
|
// shown on iOS / Android Chrome after a touch. That overlay
|
||||||
// was the white border that lingered on the avatar after
|
// was the white border that lingered on the avatar after
|
||||||
@@ -248,7 +251,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
|||||||
<div className="text-xs text-muted-foreground truncate">{username}</div>
|
<div className="text-xs text-muted-foreground truncate">{username}</div>
|
||||||
)}
|
)}
|
||||||
{!profile?.display_name && (
|
{!profile?.display_name && (
|
||||||
<div className="text-xs text-muted-foreground truncate">Signed in</div>
|
<div className="text-xs text-muted-foreground truncate">{t("account.signedIn")}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,13 +260,13 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
|||||||
{onOpenProfile && (
|
{onOpenProfile && (
|
||||||
<DropdownMenuItem onClick={onOpenProfile}>
|
<DropdownMenuItem onClick={onOpenProfile}>
|
||||||
<User className="h-4 w-4 mr-2" />
|
<User className="h-4 w-4 mr-2" />
|
||||||
View profile
|
{t("account.viewProfile")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{onOpenSecurity && (
|
{onOpenSecurity && (
|
||||||
<DropdownMenuItem onClick={onOpenSecurity}>
|
<DropdownMenuItem onClick={onOpenSecurity}>
|
||||||
<Shield className="h-4 w-4 mr-2" />
|
<Shield className="h-4 w-4 mr-2" />
|
||||||
Security
|
{t("account.security")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />}
|
{(onOpenProfile || onOpenSecurity) && <DropdownMenuSeparator />}
|
||||||
@@ -272,7 +275,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
|
|||||||
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4 mr-2" />
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
Sign out
|
{t("account.signOut")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { Thermometer } from "lucide-react"
|
import { Thermometer } from "lucide-react"
|
||||||
import { Badge } from "./ui/badge"
|
import { Badge } from "./ui/badge"
|
||||||
import { AreaChart, Area, ResponsiveContainer, Tooltip } from "recharts"
|
import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
import { useDiskTempThresholds } from "@/lib/health-thresholds"
|
import { useDiskTempThresholds } from "@/lib/health-thresholds"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
interface TempPoint {
|
interface TempPoint {
|
||||||
timestamp: number
|
timestamp: number
|
||||||
@@ -24,11 +25,11 @@ interface DiskTemperatureCardProps {
|
|||||||
// Disk-temperature thresholds come from the user-configurable backend
|
// Disk-temperature thresholds come from the user-configurable backend
|
||||||
// (lib/health-thresholds.ts). The classifier here takes the resolved
|
// (lib/health-thresholds.ts). The classifier here takes the resolved
|
||||||
// pair so the consumer can read it from the hook once per render.
|
// pair so the consumer can read it from the hook once per render.
|
||||||
function statusFor(temp: number, t: { warn: number; hot: number }) {
|
function statusFor(temp: number, thresholds: { warn: number; hot: number }) {
|
||||||
if (temp <= 0) return { label: "N/A", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
|
if (temp <= 0) return { labelKey: "common.notAvailable", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
|
||||||
if (temp >= t.hot) return { label: "Hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" }
|
if (temp >= thresholds.hot) return { labelKey: "details.temperature.status.hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" }
|
||||||
if (temp >= t.warn) return { label: "Warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
|
if (temp >= thresholds.warn) return { labelKey: "details.temperature.status.warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
|
||||||
return { label: "Normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
|
return { labelKey: "details.temperature.status.normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const MiniTooltip = ({ active, payload }: any) => {
|
const MiniTooltip = ({ active, payload }: any) => {
|
||||||
@@ -55,6 +56,7 @@ export function DiskTemperatureCard({
|
|||||||
diskType,
|
diskType,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
}: DiskTemperatureCardProps) {
|
}: DiskTemperatureCardProps) {
|
||||||
|
const t = useT()
|
||||||
const [data, setData] = useState<TempPoint[]>([])
|
const [data, setData] = useState<TempPoint[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const cancelled = useRef(false)
|
const cancelled = useRef(false)
|
||||||
@@ -64,8 +66,11 @@ export function DiskTemperatureCard({
|
|||||||
const fetchHistory = async () => {
|
const fetchHistory = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
// 24-h timeframe gives a more useful "is this drive trending
|
||||||
|
// up over a day" view; the 1-h window was too short to spot
|
||||||
|
// anything that mattered.
|
||||||
const result = await fetchApi<{ data: TempPoint[] }>(
|
const result = await fetchApi<{ data: TempPoint[] }>(
|
||||||
`/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=hour`,
|
`/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=day`,
|
||||||
)
|
)
|
||||||
if (cancelled.current) return
|
if (cancelled.current) return
|
||||||
setData(result?.data || [])
|
setData(result?.data || [])
|
||||||
@@ -95,7 +100,7 @@ export function DiskTemperatureCard({
|
|||||||
})()
|
})()
|
||||||
const status = statusFor(liveTemperature, dt)
|
const status = statusFor(liveTemperature, dt)
|
||||||
const lineColor = status.color
|
const lineColor = status.color
|
||||||
const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : "N/A"
|
const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : t("common.notAvailable")
|
||||||
const samples = data.length
|
const samples = data.length
|
||||||
|
|
||||||
const interactive = !!onOpenDetail
|
const interactive = !!onOpenDetail
|
||||||
@@ -109,11 +114,11 @@ export function DiskTemperatureCard({
|
|||||||
"w-full text-left border border-white/10 rounded-lg p-3 bg-white/[0.02]",
|
"w-full text-left border border-white/10 rounded-lg p-3 bg-white/[0.02]",
|
||||||
interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "",
|
interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
title={interactive ? "Open temperature history" : undefined}
|
title={interactive ? t("details.temperature.openHistory") : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3 mb-1.5">
|
<div className="flex items-start justify-between gap-3 mb-1.5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">Temperature</p>
|
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">{t("details.temperature.diskTitle")}</p>
|
||||||
<p className="text-xl font-bold leading-tight mt-0.5" style={{ color: lineColor }}>
|
<p className="text-xl font-bold leading-tight mt-0.5" style={{ color: lineColor }}>
|
||||||
{tempDisplay}
|
{tempDisplay}
|
||||||
</p>
|
</p>
|
||||||
@@ -121,7 +126,7 @@ export function DiskTemperatureCard({
|
|||||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||||
<Thermometer className="h-3.5 w-3.5" style={{ color: lineColor }} />
|
<Thermometer className="h-3.5 w-3.5" style={{ color: lineColor }} />
|
||||||
<Badge variant="outline" className={`${status.className} text-[10px] px-2 py-0`}>
|
<Badge variant="outline" className={`${status.className} text-[10px] px-2 py-0`}>
|
||||||
{status.label}
|
{t(status.labelKey)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +136,7 @@ export function DiskTemperatureCard({
|
|||||||
<div className="h-full w-full animate-pulse bg-white/[0.03] rounded" />
|
<div className="h-full w-full animate-pulse bg-white/[0.03] rounded" />
|
||||||
) : samples < 2 ? (
|
) : samples < 2 ? (
|
||||||
<div className="h-full flex items-center justify-center text-[10px] text-muted-foreground">
|
<div className="h-full flex items-center justify-center text-[10px] text-muted-foreground">
|
||||||
Collecting samples — chart populates after ~2 minutes
|
{t("details.temperature.collectingSamples")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
@@ -142,6 +147,18 @@ export function DiskTemperatureCard({
|
|||||||
<stop offset="100%" stopColor={lineColor} stopOpacity={0.02} />
|
<stop offset="100%" stopColor={lineColor} stopOpacity={0.02} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
{/* Y domain is computed the same way the detail modal
|
||||||
|
does — floor(min−3) / ceil(max+3), floor at 0 — so
|
||||||
|
the line shape here matches the bigger chart instead
|
||||||
|
of recharts' auto-domain collapsing 24 → 29 °C into
|
||||||
|
a near-flat line that doesn't look like the modal. */}
|
||||||
|
<YAxis
|
||||||
|
hide
|
||||||
|
domain={[
|
||||||
|
(dataMin: number) => Math.max(0, Math.floor(dataMin - 3)),
|
||||||
|
(dataMax: number) => Math.ceil(dataMax + 3),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Tooltip content={<MiniTooltip />} cursor={{ stroke: lineColor, strokeOpacity: 0.3, strokeWidth: 1 }} />
|
<Tooltip content={<MiniTooltip />} cursor={{ stroke: lineColor, strokeOpacity: 0.3, strokeWidth: 1 }} />
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
|||||||
import { useIsMobile } from "../hooks/use-mobile"
|
import { useIsMobile } from "../hooks/use-mobile"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
import { useDiskTempThresholds, type DiskTempThreshold } from "@/lib/health-thresholds"
|
import { useDiskTempThresholds, type DiskTempThreshold } from "@/lib/health-thresholds"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
const TIMEFRAME_OPTIONS = [
|
const TIMEFRAME_OPTIONS = [
|
||||||
{ value: "hour", label: "1 Hour" },
|
{ value: "hour", labelKey: "details.temperature.timeframes.hour" },
|
||||||
{ value: "day", label: "24 Hours" },
|
{ value: "day", labelKey: "details.temperature.timeframes.day" },
|
||||||
{ value: "week", label: "7 Days" },
|
{ value: "week", labelKey: "details.temperature.timeframes.week" },
|
||||||
{ value: "month", label: "30 Days" },
|
{ value: "month", labelKey: "details.temperature.timeframes.month" },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface TempHistoryPoint {
|
interface TempHistoryPoint {
|
||||||
@@ -69,10 +70,10 @@ function colorFor(temp: number, t: DiskTempThreshold): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function statusInfoFor(temp: number, t: DiskTempThreshold) {
|
function statusInfoFor(temp: number, t: DiskTempThreshold) {
|
||||||
if (temp <= 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
if (temp <= 0) return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||||
if (temp >= t.hot) return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
if (temp >= t.hot) return { color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||||
if (temp >= t.warn) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
if (temp >= t.warn) return { color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||||
return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
return { color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiskTemperatureDetailModal({
|
export function DiskTemperatureDetailModal({
|
||||||
@@ -83,6 +84,7 @@ export function DiskTemperatureDetailModal({
|
|||||||
liveTemperature,
|
liveTemperature,
|
||||||
diskType,
|
diskType,
|
||||||
}: DiskTemperatureDetailModalProps) {
|
}: DiskTemperatureDetailModalProps) {
|
||||||
|
const t = useT()
|
||||||
const [timeframe, setTimeframe] = useState("day")
|
const [timeframe, setTimeframe] = useState("day")
|
||||||
const [data, setData] = useState<TempHistoryPoint[]>([])
|
const [data, setData] = useState<TempHistoryPoint[]>([])
|
||||||
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
|
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
|
||||||
@@ -168,7 +170,7 @@ export function DiskTemperatureDetailModal({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TIMEFRAME_OPTIONS.map((opt) => (
|
{TIMEFRAME_OPTIONS.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{t(opt.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -181,24 +183,24 @@ export function DiskTemperatureDetailModal({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
|
||||||
<div className={`rounded-lg p-3 text-center border ${currentStatus.color}`}>
|
<div className={`rounded-lg p-3 text-center border ${currentStatus.color}`}>
|
||||||
<div className="text-xs opacity-80 mb-1">Current</div>
|
<div className="text-xs opacity-80 mb-1">{t("details.temperature.current")}</div>
|
||||||
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : "N/A"}</div>
|
<div className="text-lg font-bold">{currentTemp > 0 ? `${currentTemp}°C` : t("common.notAvailable")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||||
<TrendingDown className="h-3 w-3" /> Min
|
<TrendingDown className="h-3 w-3" /> {t("details.temperature.min")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
|
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||||
<Minus className="h-3 w-3" /> Avg
|
<Minus className="h-3 w-3" /> {t("details.temperature.avg")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
|
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||||
<TrendingUp className="h-3 w-3" /> Max
|
<TrendingUp className="h-3 w-3" /> {t("details.temperature.max")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
|
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,8 +218,8 @@ export function DiskTemperatureDetailModal({
|
|||||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
<p>No temperature data yet for this disk</p>
|
<p>{t("details.temperature.noData")}</p>
|
||||||
<p className="text-sm mt-1">Samples are collected every 60 seconds</p>
|
<p className="text-sm mt-1">{t("details.temperature.sampleInterval")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -250,7 +252,7 @@ export function DiskTemperatureDetailModal({
|
|||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
name="Temperature"
|
name={t("details.temperature.seriesName")}
|
||||||
stroke={chartColor}
|
stroke={chartColor}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill={`url(#diskTempGradient-${diskName})`}
|
fill={`url(#diskTempGradient-${diskName})`}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface SriovInfo {
|
interface SriovInfo {
|
||||||
role: "vf" | "pf-active" | "pf-idle"
|
role: "vf" | "pf-active" | "pf-idle"
|
||||||
@@ -26,6 +27,8 @@ export function GpuSwitchModeIndicator({
|
|||||||
className,
|
className,
|
||||||
sriovInfo,
|
sriovInfo,
|
||||||
}: GpuSwitchModeIndicatorProps) {
|
}: GpuSwitchModeIndicatorProps) {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
// SR-IOV is a non-editable hardware state. Pending toggles don't apply here.
|
// SR-IOV is a non-editable hardware state. Pending toggles don't apply here.
|
||||||
const displayMode = mode === "sriov" ? "sriov" : (pendingMode ?? mode)
|
const displayMode = mode === "sriov" ? "sriov" : (pendingMode ?? mode)
|
||||||
const isLxcActive = displayMode === "lxc"
|
const isLxcActive = displayMode === "lxc"
|
||||||
@@ -69,9 +72,11 @@ export function GpuSwitchModeIndicator({
|
|||||||
// exactly how many VFs are active; for a VF we show its parent PF.
|
// exactly how many VFs are active; for a VF we show its parent PF.
|
||||||
const sriovBadgeText = (() => {
|
const sriovBadgeText = (() => {
|
||||||
if (!isSriovActive) return ""
|
if (!isSriovActive) return ""
|
||||||
if (sriovInfo?.role === "vf") return "SR-IOV VF"
|
if (sriovInfo?.role === "vf") return t("hardware.gpuSwitch.sriovVf")
|
||||||
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) return `SR-IOV ×${sriovInfo.vfCount}`
|
if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) {
|
||||||
return "SR-IOV"
|
return t("hardware.gpuSwitch.sriovCount", { count: sriovInfo.vfCount })
|
||||||
|
}
|
||||||
|
return t("hardware.gpuSwitch.sriov")
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -124,7 +129,7 @@ export function GpuSwitchModeIndicator({
|
|||||||
className="text-[14px] font-bold transition-all duration-300"
|
className="text-[14px] font-bold transition-all duration-300"
|
||||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||||
>
|
>
|
||||||
GPU
|
{t("hardware.gpuSwitch.gpu")}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
@@ -268,7 +273,7 @@ export function GpuSwitchModeIndicator({
|
|||||||
)}
|
)}
|
||||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||||
>
|
>
|
||||||
LXC
|
{t("hardware.gpuSwitch.lxc")}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
{isSriovActive && (
|
{isSriovActive && (
|
||||||
@@ -279,7 +284,7 @@ export function GpuSwitchModeIndicator({
|
|||||||
className="text-[9px] font-medium"
|
className="text-[9px] font-medium"
|
||||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||||
>
|
>
|
||||||
LXC
|
{t("hardware.gpuSwitch.lxc")}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -332,7 +337,7 @@ export function GpuSwitchModeIndicator({
|
|||||||
)}
|
)}
|
||||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||||
>
|
>
|
||||||
VM
|
{t("hardware.gpuSwitch.vm")}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
{isSriovActive && (
|
{isSriovActive && (
|
||||||
@@ -343,7 +348,7 @@ export function GpuSwitchModeIndicator({
|
|||||||
className="text-[9px] font-medium"
|
className="text-[9px] font-medium"
|
||||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||||
>
|
>
|
||||||
VM
|
{t("hardware.gpuSwitch.vm")}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
@@ -363,34 +368,47 @@ export function GpuSwitchModeIndicator({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSriovActive
|
{isSriovActive
|
||||||
? "SR-IOV active"
|
? t("hardware.gpuSwitch.sriovActive")
|
||||||
: isLxcActive
|
: isLxcActive
|
||||||
? "Ready for LXC containers"
|
? t("hardware.gpuSwitch.readyForLxc")
|
||||||
: isVmActive
|
: isVmActive
|
||||||
? "Ready for VM passthrough"
|
? t("hardware.gpuSwitch.readyForVm")
|
||||||
: "Mode unknown"}
|
: t("hardware.gpuSwitch.modeUnknown")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{isSriovActive
|
{isSriovActive
|
||||||
? "Virtual Functions managed externally"
|
? t("hardware.gpuSwitch.virtualFunctionsExternal")
|
||||||
: isLxcActive
|
: isLxcActive
|
||||||
? "Native driver active"
|
? t("hardware.gpuSwitch.nativeDriverActive")
|
||||||
: isVmActive
|
: isVmActive
|
||||||
? "VFIO-PCI driver active"
|
? t("hardware.gpuSwitch.vfioDriverActive")
|
||||||
: "No driver detected"}
|
: t("hardware.gpuSwitch.noDriverDetected")}
|
||||||
</span>
|
</span>
|
||||||
{isSriovActive && sriovInfo && (
|
{isSriovActive && sriovInfo && (
|
||||||
<span className="text-xs font-mono text-teal-600/80 dark:text-teal-400/80">
|
<span className="text-xs font-mono text-teal-600/80 dark:text-teal-400/80">
|
||||||
{sriovInfo.role === "vf"
|
{sriovInfo.role === "vf"
|
||||||
? `Virtual Function${sriovInfo.physfn ? ` · parent PF ${sriovInfo.physfn}` : ""}`
|
? t(
|
||||||
|
sriovInfo.physfn
|
||||||
|
? "hardware.gpuSwitch.virtualFunctionWithParent"
|
||||||
|
: "hardware.gpuSwitch.virtualFunction",
|
||||||
|
{ parent: sriovInfo.physfn || "" },
|
||||||
|
)
|
||||||
: sriovInfo.vfCount !== undefined
|
: sriovInfo.vfCount !== undefined
|
||||||
? `1 PF + ${sriovInfo.vfCount} VF${sriovInfo.vfCount === 1 ? "" : "s"}${sriovInfo.totalvfs ? ` / ${sriovInfo.totalvfs} max` : ""}`
|
? t(
|
||||||
|
sriovInfo.totalvfs
|
||||||
|
? "hardware.gpuSwitch.physicalFunctionWithMax"
|
||||||
|
: "hardware.gpuSwitch.physicalFunction",
|
||||||
|
{
|
||||||
|
count: sriovInfo.vfCount,
|
||||||
|
max: sriovInfo.totalvfs || "",
|
||||||
|
},
|
||||||
|
)
|
||||||
: null}
|
: null}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{hasChanged && (
|
{hasChanged && (
|
||||||
<span className="text-sm text-amber-500 font-medium animate-pulse">
|
<span className="text-sm text-amber-500 font-medium animate-pulse">
|
||||||
Change pending...
|
{t("hardware.gpuSwitch.changePending")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+346
-313
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Shield,
|
Shield,
|
||||||
|
Download,
|
||||||
X,
|
X,
|
||||||
Clock,
|
Clock,
|
||||||
BellOff,
|
BellOff,
|
||||||
@@ -39,6 +40,8 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
interface CategoryCheck {
|
interface CategoryCheck {
|
||||||
status: string
|
status: string
|
||||||
@@ -102,19 +105,20 @@ interface HealthStatusModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ key: "cpu", category: "temperature", label: "CPU Usage & Temperature", Icon: Cpu },
|
{ key: "cpu", category: "temperature", Icon: Cpu },
|
||||||
{ key: "memory", category: "memory", label: "Memory & Swap", Icon: MemoryStick },
|
{ key: "memory", category: "memory", Icon: MemoryStick },
|
||||||
{ key: "storage", category: "storage", label: "Storage Mounts & Space", Icon: HardDrive },
|
{ key: "storage", category: "storage", Icon: HardDrive },
|
||||||
{ key: "disks", category: "disks", label: "Disk I/O & Errors", Icon: Disc },
|
{ key: "disks", category: "disks", Icon: Disc },
|
||||||
{ key: "network", category: "network", label: "Network Interfaces", Icon: Network },
|
{ key: "network", category: "network", Icon: Network },
|
||||||
{ key: "vms", category: "vms", label: "VMs & Containers", Icon: Box },
|
{ key: "vms", category: "vms", Icon: Box },
|
||||||
{ key: "services", category: "pve_services", label: "PVE Services", Icon: Settings },
|
{ key: "services", category: "pve_services", Icon: Settings },
|
||||||
{ key: "logs", category: "logs", label: "System Logs", Icon: FileText },
|
{ key: "logs", category: "logs", Icon: FileText },
|
||||||
{ key: "updates", category: "updates", label: "System Updates", Icon: RefreshCw },
|
{ key: "updates", category: "updates", Icon: RefreshCw },
|
||||||
{ key: "security", category: "security", label: "Security & Certificates", Icon: Shield },
|
{ key: "security", category: "security", Icon: Shield },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
|
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
|
||||||
|
const t = useT()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
|
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
|
||||||
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
||||||
@@ -122,8 +126,9 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
|
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||||
|
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
|
||||||
|
|
||||||
const fetchHealthDetails = useCallback(async () => {
|
const fetchHealthDetails = useCallback(async (force = false) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
@@ -137,13 +142,13 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
authHeaders["Authorization"] = `Bearer ${token}`
|
authHeaders["Authorization"] = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(getApiUrl("/api/health/full"), { headers: authHeaders })
|
const response = await fetch(getApiUrl(force ? "/api/health/full?refresh=1" : "/api/health/full"), { headers: authHeaders })
|
||||||
let infoCount = 0
|
let infoCount = 0
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Fallback to legacy endpoint
|
// Fallback to legacy endpoint
|
||||||
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
|
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
|
||||||
if (!legacyResponse.ok) throw new Error("Failed to fetch health details")
|
if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
|
||||||
const data = await legacyResponse.json()
|
const data = await legacyResponse.json()
|
||||||
setHealthData(data)
|
setHealthData(data)
|
||||||
setDismissedItems([])
|
setDismissedItems([])
|
||||||
@@ -200,11 +205,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
})
|
})
|
||||||
window.dispatchEvent(event)
|
window.dispatchEvent(event)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Unknown error")
|
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [getApiUrl])
|
}, [getApiUrl, t])
|
||||||
|
|
||||||
// Tick counter to force re-render every 30s so "X minutes ago" stays current
|
// Tick counter to force re-render every 30s so "X minutes ago" stays current
|
||||||
const [, setTick] = useState(0)
|
const [, setTick] = useState(0)
|
||||||
@@ -219,7 +224,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
if (open) {
|
if (open) {
|
||||||
fetchHealthDetails()
|
fetchHealthDetails()
|
||||||
// Auto-refresh every 5 minutes while modal is open
|
// Auto-refresh every 5 minutes while modal is open
|
||||||
const refreshInterval = setInterval(fetchHealthDetails, 300000)
|
const refreshInterval = setInterval(() => fetchHealthDetails(), 300000)
|
||||||
return () => clearInterval(refreshInterval)
|
return () => clearInterval(refreshInterval)
|
||||||
}
|
}
|
||||||
}, [open, fetchHealthDetails])
|
}, [open, fetchHealthDetails])
|
||||||
@@ -277,20 +282,90 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
const statusUpper = status?.toUpperCase()
|
const statusUpper = status?.toUpperCase()
|
||||||
switch (statusUpper) {
|
switch (statusUpper) {
|
||||||
case "OK":
|
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":
|
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":
|
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":
|
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":
|
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:
|
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)
|
// Get categories that have dismissed items (to show as INFO)
|
||||||
const getCategoriesWithDismissed = () => {
|
const getCategoriesWithDismissed = () => {
|
||||||
const customCats = new Set(customSuppressions.map(cs => cs.category))
|
const customCats = new Set(customSuppressions.map(cs => cs.category))
|
||||||
@@ -441,11 +516,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diffMs = now.getTime() - checkTime.getTime()
|
const diffMs = now.getTime() - checkTime.getTime()
|
||||||
const diffMin = Math.floor(diffMs / 60000)
|
const diffMin = Math.floor(diffMs / 60000)
|
||||||
if (diffMin < 1) return "just now"
|
if (diffMin < 1) return t("healthStatus.time.justNow")
|
||||||
if (diffMin === 1) return "1 minute ago"
|
if (diffMin === 1) return t("healthStatus.time.oneMinuteAgo")
|
||||||
if (diffMin < 60) return `${diffMin} minutes ago`
|
if (diffMin < 60) return t("healthStatus.time.minutesAgo", { count: diffMin })
|
||||||
const diffHours = Math.floor(diffMin / 60)
|
const diffHours = Math.floor(diffMin / 60)
|
||||||
return `${diffHours}h ${diffMin % 60}m ago`
|
return t("healthStatus.time.hoursMinutesAgo", { hours: diffHours, minutes: diffMin % 60 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCategoryRowStyle = (status: string) => {
|
const getCategoryRowStyle = (status: string) => {
|
||||||
@@ -468,49 +543,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatCheckLabel = (key: string): string => {
|
const formatCheckLabel = (key: string): string => {
|
||||||
const labels: Record<string, string> = {
|
const knownKeys = new Set([
|
||||||
// CPU
|
"cpu_usage", "cpu_temperature", "ram_usage", "swap_usage", "root_filesystem",
|
||||||
cpu_usage: "CPU Usage",
|
"smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity",
|
||||||
cpu_temperature: "Temperature",
|
"qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode",
|
||||||
// Memory
|
"log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors",
|
||||||
ram_usage: "RAM Usage",
|
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime",
|
||||||
swap_usage: "Swap Usage",
|
"certificates", "login_attempts", "fail2ban", "proxmox_storages",
|
||||||
// Disk I/O
|
])
|
||||||
root_filesystem: "Root Filesystem",
|
if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`)
|
||||||
smart_health: "SMART Health",
|
|
||||||
io_errors: "I/O Errors",
|
|
||||||
zfs_pools: "ZFS Pools",
|
|
||||||
lvm_volumes: "LVM Volumes",
|
|
||||||
lvm_check: "LVM Status",
|
|
||||||
// Network
|
|
||||||
connectivity: "Connectivity",
|
|
||||||
// VMs & CTs
|
|
||||||
qmp_communication: "QMP Communication",
|
|
||||||
container_startup: "Container Startup",
|
|
||||||
vm_startup: "VM Startup",
|
|
||||||
oom_killer: "OOM Killer",
|
|
||||||
// Services
|
|
||||||
cluster_mode: "Cluster Mode",
|
|
||||||
// Logs (prefixed with log_)
|
|
||||||
log_error_cascade: "Error Cascade",
|
|
||||||
log_error_spike: "Error Spike",
|
|
||||||
log_persistent_errors: "Persistent Errors",
|
|
||||||
log_critical_errors: "Critical Errors",
|
|
||||||
// Updates
|
|
||||||
pve_version: "Proxmox VE Version",
|
|
||||||
security_updates: "Security Updates",
|
|
||||||
system_age: "System Age",
|
|
||||||
pending_updates: "Pending Updates",
|
|
||||||
kernel_pve: "Kernel / PVE",
|
|
||||||
// Security
|
|
||||||
uptime: "Uptime",
|
|
||||||
certificates: "Certificates",
|
|
||||||
login_attempts: "Login Attempts",
|
|
||||||
fail2ban: "Fail2Ban",
|
|
||||||
// Storage (Proxmox)
|
|
||||||
proxmox_storages: "Proxmox Storages",
|
|
||||||
}
|
|
||||||
if (labels[key]) return labels[key]
|
|
||||||
// Convert snake_case or camelCase to Title Case
|
// Convert snake_case or camelCase to Title Case
|
||||||
return key
|
return key
|
||||||
.replace(/_/g, " ")
|
.replace(/_/g, " ")
|
||||||
@@ -540,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">
|
<div className="flex items-start gap-1.5 sm:gap-2 min-w-0 flex-1">
|
||||||
<span className="mt-0.5 shrink-0">{getStatusIcon(checkData.dismissed ? "INFO" : checkData.status, "sm")}</span>
|
<span className="mt-0.5 shrink-0">{getStatusIcon(checkData.dismissed ? "INFO" : checkData.status, "sm")}</span>
|
||||||
<span className="font-medium shrink-0">{formatCheckLabel(checkKey)}</span>
|
<span className="font-medium shrink-0">{formatCheckLabel(checkKey)}</span>
|
||||||
<span className="text-muted-foreground break-words whitespace-pre-wrap min-w-0">{checkData.detail}</span>
|
<span className="text-muted-foreground break-words whitespace-pre-wrap min-w-0">{translateHealthText(checkData.detail)}</span>
|
||||||
{checkData.dismissed && (
|
{checkData.dismissed && (
|
||||||
checkData.permanent ? (
|
checkData.permanent ? (
|
||||||
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-amber-400 border-amber-400/40">
|
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-amber-400 border-amber-400/40">
|
||||||
Permanent
|
{t("healthStatus.permanent")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-blue-400 border-blue-400/30">
|
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 shrink-0 text-blue-400 border-blue-400/30">
|
||||||
Dismissed
|
{t("healthStatus.dismissed")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -560,6 +601,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
handleAcknowledge(checkData.error_key || checkKey, hours)
|
handleAcknowledge(checkData.error_key || checkKey, hours)
|
||||||
}
|
}
|
||||||
busy={dismissingKey === (checkData.error_key || checkKey)}
|
busy={dismissingKey === (checkData.error_key || checkKey)}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -579,12 +621,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<DialogTitle className="flex items-center gap-2 flex-1 min-w-0">
|
<DialogTitle className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<Activity className="h-5 w-5 sm:h-6 sm:w-6 shrink-0" />
|
<Activity className="h-5 w-5 sm:h-6 sm:w-6 shrink-0" />
|
||||||
<span className="truncate text-base sm:text-lg">System Health Status</span>
|
<span className="truncate text-base sm:text-lg">{t("healthStatus.title")}</span>
|
||||||
{healthData && <div className="shrink-0">{getStatusBadge(healthData.overall)}</div>}
|
{healthData && <div className="shrink-0">{getStatusBadge(healthData.overall)}</div>}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</div>
|
</div>
|
||||||
<DialogDescription className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:text-sm">
|
<DialogDescription className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:text-sm">
|
||||||
<span>Detailed health checks for all system components</span>
|
<span>{t("healthStatus.description")}</span>
|
||||||
{getTimeSinceCheck() && (
|
{getTimeSinceCheck() && (
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
@@ -602,7 +644,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200">
|
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800 dark:bg-red-950 dark:border-red-800 dark:text-red-200">
|
||||||
<p className="font-medium">Error loading health status</p>
|
<p className="font-medium">{t("healthStatus.errors.loading")}</p>
|
||||||
<p className="text-sm">{error}</p>
|
<p className="text-sm">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -613,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={`grid gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg bg-muted/30 border ${stats.info > 0 ? "grid-cols-5" : "grid-cols-4"}`}>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg sm:text-2xl font-bold">{stats.total}</div>
|
<div className="text-lg sm:text-2xl font-bold">{stats.total}</div>
|
||||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Total</div>
|
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.total")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg sm:text-2xl font-bold text-green-500">{stats.healthy}</div>
|
<div className="text-lg sm:text-2xl font-bold text-green-500">{stats.healthy}</div>
|
||||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Healthy</div>
|
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.healthy")}</div>
|
||||||
</div>
|
</div>
|
||||||
{stats.info > 0 && (
|
{stats.info > 0 && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg sm:text-2xl font-bold text-blue-500">{stats.info}</div>
|
<div className="text-lg sm:text-2xl font-bold text-blue-500">{stats.info}</div>
|
||||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Info</div>
|
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.info")}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg sm:text-2xl font-bold text-yellow-500">{stats.warnings}</div>
|
<div className="text-lg sm:text-2xl font-bold text-yellow-500">{stats.warnings}</div>
|
||||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Warn</div>
|
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.warning")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg sm:text-2xl font-bold text-red-500">{stats.critical}</div>
|
<div className="text-lg sm:text-2xl font-bold text-red-500">{stats.critical}</div>
|
||||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Critical</div>
|
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.critical")}</div>
|
||||||
</div>
|
</div>
|
||||||
{stats.unknown > 0 && (
|
{stats.unknown > 0 && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg sm:text-2xl font-bold text-amber-400">{stats.unknown}</div>
|
<div className="text-lg sm:text-2xl font-bold text-amber-400">{stats.unknown}</div>
|
||||||
<div className="text-[10px] sm:text-xs text-muted-foreground">Unknown</div>
|
<div className="text-[10px] sm:text-xs text-muted-foreground">{t("healthStatus.stats.unknown")}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{healthData.summary && healthData.summary !== "All systems operational" && (
|
{healthData.summary && healthData.summary !== "All systems operational" && (
|
||||||
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
|
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
|
||||||
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{healthData.summary}</p>
|
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthText(healthData.summary)}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Category List */}
|
{/* Category List */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{CATEGORIES.map(({ key, label, Icon }) => {
|
{CATEGORIES.map(({ key, Icon }) => {
|
||||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||||
const originalStatus = categoryData?.status || "UNKNOWN"
|
const originalStatus = categoryData?.status || "UNKNOWN"
|
||||||
const status = getEffectiveStatus(key, originalStatus)
|
const status = getEffectiveStatus(key, originalStatus)
|
||||||
const reason = categoryData?.reason
|
const reason = translateHealthText(categoryData?.reason)
|
||||||
const checks = categoryData?.checks
|
const checks = categoryData?.checks
|
||||||
const isExpanded = expandedCategories.has(key)
|
const isExpanded = expandedCategories.has(key)
|
||||||
const hasChecks = checks && Object.keys(checks).length > 0
|
const hasChecks = checks && Object.keys(checks).length > 0
|
||||||
@@ -674,7 +716,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||||
<p className="font-medium text-xs sm:text-sm truncate">{label}</p>
|
<p className="font-medium text-xs sm:text-sm truncate">{t(`healthStatus.categories.${key}`)}</p>
|
||||||
{hasChecks && (
|
{hasChecks && (
|
||||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||||
({Object.values(checks).filter(c => c.installed !== false).length})
|
({Object.values(checks).filter(c => c.installed !== false).length})
|
||||||
@@ -687,7 +729,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||||
<Badge variant="outline" className={`text-[10px] sm:text-xs px-1.5 sm:px-2.5 ${getOutlineBadgeStyle(status)}`}>
|
<Badge variant="outline" className={`text-[10px] sm:text-xs px-1.5 sm:px-2.5 ${getOutlineBadgeStyle(status)}`}>
|
||||||
{status}
|
{formatStatus(status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground transition-transform duration-200 ${
|
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground transition-transform duration-200 ${
|
||||||
@@ -710,6 +752,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
handleAcknowledge(`category_${key}_unknown`, hours)
|
handleAcknowledge(`category_${key}_unknown`, hours)
|
||||||
}
|
}
|
||||||
busy={dismissingKey === `category_${key}_unknown`}
|
busy={dismissingKey === `category_${key}_unknown`}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -719,7 +762,24 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-3 py-2">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground px-3 py-2">
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||||
No issues detected
|
{t("healthStatus.noIssues")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Only offer "Update Now" when the category is not
|
||||||
|
already OK — hiding it when there's nothing
|
||||||
|
pending prevents the operator from spawning a
|
||||||
|
terminal that would only report "System is
|
||||||
|
already up to date". */}
|
||||||
|
{key === "updates" && status?.toUpperCase() !== "OK" && (
|
||||||
|
<div className="flex justify-end px-3 py-2 pt-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
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" />
|
||||||
|
{t("healthStatus.updateNow")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -738,12 +798,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground pt-2">
|
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground pt-2">
|
||||||
<BellOff className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
<BellOff className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
Dismissed Items ({filteredDismissed.length})
|
{t("healthStatus.dismissedItems", { count: filteredDismissed.length })}
|
||||||
</div>
|
</div>
|
||||||
{filteredDismissed.map((item) => {
|
{filteredDismissed.map((item) => {
|
||||||
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
||||||
const CatIcon = catMeta?.Icon || BellOff
|
const CatIcon = catMeta?.Icon || BellOff
|
||||||
const catLabel = catMeta?.label || item.category
|
const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category
|
||||||
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
|
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -758,34 +818,28 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<div className="flex items-start justify-between gap-2 mb-1">
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
<p className="font-medium text-xs sm:text-sm text-muted-foreground truncate">{catLabel}</p>
|
<p className="font-medium text-xs sm:text-sm text-muted-foreground truncate">{catLabel}</p>
|
||||||
<p className="text-[10px] sm:text-xs text-muted-foreground/70 break-words line-clamp-2">{item.reason}</p>
|
<p className="text-[10px] sm:text-xs text-muted-foreground/70 break-words line-clamp-2">{translateHealthText(item.reason)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
{isPermanent ? (
|
{isPermanent ? (
|
||||||
<Badge variant="outline" className="text-[9px] sm:text-xs border-amber-500/50 text-amber-500/70 bg-transparent whitespace-nowrap">
|
<Badge variant="outline" className="text-[9px] sm:text-xs border-amber-500/50 text-amber-500/70 bg-transparent whitespace-nowrap">
|
||||||
Permanent
|
{t("healthStatus.permanent")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-[9px] sm:text-xs border-blue-500/50 text-blue-500/70 bg-transparent whitespace-nowrap">
|
<Badge variant="outline" className="text-[9px] sm:text-xs border-blue-500/50 text-blue-500/70 bg-transparent whitespace-nowrap">
|
||||||
Dismissed
|
{t("healthStatus.dismissed")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge variant="outline" className={`text-[9px] sm:text-xs whitespace-nowrap ${getOutlineBadgeStyle(item.severity)}`}>
|
<Badge variant="outline" className={`text-[9px] sm:text-xs whitespace-nowrap ${getOutlineBadgeStyle(item.severity)}`}>
|
||||||
was {item.severity}
|
{t("healthStatus.wasStatus", { status: formatStatus(item.severity) })}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] sm:text-xs text-muted-foreground flex items-center gap-1">
|
<p className="text-[10px] sm:text-xs text-muted-foreground flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
{isPermanent
|
{isPermanent
|
||||||
? "Permanently suppressed"
|
? t("healthStatus.permanentlySuppressed")
|
||||||
: `Suppressed for ${
|
: t("healthStatus.suppressedForMore", { duration: formatDuration(item.suppression_remaining_hours) })
|
||||||
item.suppression_remaining_hours < 24
|
|
||||||
? `${Math.round(item.suppression_remaining_hours)}h`
|
|
||||||
: item.suppression_remaining_hours < 720
|
|
||||||
? `${Math.round(item.suppression_remaining_hours / 24)} days`
|
|
||||||
: `${Math.round(item.suppression_remaining_hours / 720)} month(s)`
|
|
||||||
} more`
|
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -801,30 +855,20 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium text-muted-foreground">
|
||||||
<Settings2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
<Settings2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
Custom Suppression Settings
|
{t("healthStatus.customSuppressionSettings")}
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-2.5 sm:p-3">
|
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-2.5 sm:p-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{customSuppressions.map((cs) => {
|
{customSuppressions.map((cs) => {
|
||||||
const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category || c.label === cs.label)
|
const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category)
|
||||||
const CatIcon = catMeta?.Icon || Settings2
|
const CatIcon = catMeta?.Icon || Settings2
|
||||||
const durationLabel = cs.hours === -1
|
const durationLabel = formatDuration(cs.hours)
|
||||||
? "Permanent"
|
|
||||||
: cs.hours >= 8760
|
|
||||||
? `${Math.floor(cs.hours / 8760)} year(s)`
|
|
||||||
: cs.hours >= 720
|
|
||||||
? `${Math.floor(cs.hours / 720)} month(s)`
|
|
||||||
: cs.hours >= 168
|
|
||||||
? `${Math.floor(cs.hours / 168)} week(s)`
|
|
||||||
: cs.hours >= 72
|
|
||||||
? `${Math.floor(cs.hours / 24)} days`
|
|
||||||
: `${cs.hours}h`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cs.key} className="flex items-center justify-between gap-2">
|
<div key={cs.key} className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
|
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
|
||||||
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{cs.label}</span>
|
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
|
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
|
||||||
{durationLabel}
|
{durationLabel}
|
||||||
@@ -834,7 +878,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground/60 mt-2 pt-1.5 border-t border-blue-500/10">
|
<p className="text-[10px] text-muted-foreground/60 mt-2 pt-1.5 border-t border-blue-500/10">
|
||||||
Alerts in these categories are auto-suppressed when detected.
|
{t("healthStatus.autoSuppressedHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -842,12 +886,29 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
|
|
||||||
{healthData.timestamp && (
|
{healthData.timestamp && (
|
||||||
<div className="text-xs text-muted-foreground text-center pt-2">
|
<div className="text-xs text-muted-foreground text-center pt-2">
|
||||||
Last updated: {new Date(healthData.timestamp).toLocaleString()}
|
{t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
<ScriptTerminalModal
|
||||||
|
open={showUpdateTerminal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowUpdateTerminal(false)
|
||||||
|
// Force a fresh read (cache-busting via ?refresh=1) so the
|
||||||
|
// "System Updates" row reflects the state right after the
|
||||||
|
// update finished, instead of the pre-update cached value.
|
||||||
|
fetchHealthDetails(true).catch(() => {})
|
||||||
|
}}
|
||||||
|
scriptPath="/usr/local/share/proxmenux/scripts/utilities/proxmox_update.sh"
|
||||||
|
scriptName="proxmox_update"
|
||||||
|
params={{
|
||||||
|
EXECUTION_MODE: "web",
|
||||||
|
}}
|
||||||
|
title={t("healthStatus.updateTerminalTitle")}
|
||||||
|
description={t("healthStatus.updateTerminalDescription")}
|
||||||
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -859,9 +920,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
|||||||
function DismissDropdown({
|
function DismissDropdown({
|
||||||
onSelect,
|
onSelect,
|
||||||
busy,
|
busy,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
onSelect: (suppressionHours: number) => void
|
onSelect: (suppressionHours: number) => void
|
||||||
busy: boolean
|
busy: boolean
|
||||||
|
t: ReturnType<typeof useT>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -878,27 +941,27 @@ function DismissDropdown({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<X className="h-3 w-3 sm:mr-0.5" />
|
<X className="h-3 w-3 sm:mr-0.5" />
|
||||||
<span className="hidden sm:inline">Dismiss</span>
|
<span className="hidden sm:inline">{t("healthStatus.dismiss")}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
|
<DropdownMenuContent align="end" className="w-44" onClick={(e) => e.stopPropagation()}>
|
||||||
<DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
<DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
Silence this alert for
|
{t("healthStatus.silenceFor")}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onSelect={() => onSelect(24)} className="text-xs">
|
<DropdownMenuItem onSelect={() => onSelect(24)} className="text-xs">
|
||||||
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> 24 hours
|
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> {t("healthStatus.duration.24hours")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => onSelect(168)} className="text-xs">
|
<DropdownMenuItem onSelect={() => onSelect(168)} className="text-xs">
|
||||||
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> 7 days
|
<Clock className="h-3 w-3 mr-2 text-muted-foreground" /> {t("healthStatus.duration.7days")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => onSelect(-1)}
|
onSelect={() => onSelect(-1)}
|
||||||
className="text-xs text-red-500 focus:text-red-500 focus:bg-red-500/10"
|
className="text-xs text-red-500 focus:text-red-500 focus:bg-red-500/10"
|
||||||
>
|
>
|
||||||
<BellOff className="h-3 w-3 mr-2" /> Permanently
|
<BellOff className="h-3 w-3 mr-2" /> {t("healthStatus.permanently")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Waves,
|
Waves,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { getApiUrl, getAuthToken } from "../lib/api-config"
|
import { getApiUrl, getAuthToken } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
// Local fetch wrapper that *preserves* the JSON body on non-2xx
|
// Local fetch wrapper that *preserves* the JSON body on non-2xx
|
||||||
// responses so we can surface backend validation messages
|
// responses so we can surface backend validation messages
|
||||||
@@ -250,9 +251,43 @@ function pathKey(path: string[]): string {
|
|||||||
return path.join("/")
|
return path.join("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trim the visible slider range to a window around the saved +
|
||||||
|
// recommended values so the track has usable resolution (e.g. CPU
|
||||||
|
// 60–100 instead of the backend's 0–100). Derived from stable inputs
|
||||||
|
// so the range does NOT shift under an active drag.
|
||||||
|
function computeVisualRange(
|
||||||
|
values: number[],
|
||||||
|
backendMin: number,
|
||||||
|
backendMax: number,
|
||||||
|
step: number,
|
||||||
|
): { min: number; max: number } {
|
||||||
|
const totalRange = Math.max(1, backendMax - backendMin)
|
||||||
|
// Margin ≈ 25% of total range, clamped to at least 5 steps so tiny
|
||||||
|
// step sizes (e.g. step=1 on 0–100) still get a usable window.
|
||||||
|
const rawMargin = Math.max(step * 5, Math.round(totalRange * 0.25))
|
||||||
|
const lo = Math.min(...values)
|
||||||
|
const hi = Math.max(...values)
|
||||||
|
const snap = (n: number) => Math.round(n / step) * step
|
||||||
|
let visMin = Math.max(backendMin, snap(lo - rawMargin))
|
||||||
|
let visMax = Math.min(backendMax, snap(hi + rawMargin))
|
||||||
|
// Ensure the window is at least 4 steps wide so the slider has
|
||||||
|
// room to move even if all inputs collapse to one value.
|
||||||
|
if (visMax - visMin < step * 4) {
|
||||||
|
const mid = (visMax + visMin) / 2
|
||||||
|
visMin = Math.max(backendMin, snap(mid - step * 2))
|
||||||
|
visMax = Math.min(backendMax, snap(mid + step * 2))
|
||||||
|
}
|
||||||
|
return { min: visMin, max: visMax }
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function HealthThresholds() {
|
export function HealthThresholds() {
|
||||||
|
const t = useT()
|
||||||
|
const tFallback = (key: string, fallback: string) => {
|
||||||
|
const translated = t(key)
|
||||||
|
return translated === key ? fallback : translated
|
||||||
|
}
|
||||||
const [tree, setTree] = useState<ThresholdsTree | null>(null)
|
const [tree, setTree] = useState<ThresholdsTree | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [editMode, setEditMode] = useState(false)
|
const [editMode, setEditMode] = useState(false)
|
||||||
@@ -270,7 +305,7 @@ export function HealthThresholds() {
|
|||||||
)
|
)
|
||||||
if (res?.success && res.thresholds) setTree(res.thresholds)
|
if (res?.success && res.thresholds) setTree(res.thresholds)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load thresholds")
|
setError(err instanceof Error ? err.message : t("settings.healthThresholds.loadFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -293,7 +328,7 @@ export function HealthThresholds() {
|
|||||||
if (trimmed === "") continue
|
if (trimmed === "") continue
|
||||||
const num = Number(trimmed)
|
const num = Number(trimmed)
|
||||||
if (!isFinite(num)) {
|
if (!isFinite(num)) {
|
||||||
setError(`Invalid value for ${key}: must be a number`)
|
setError(t("settings.healthThresholds.invalidValue", { key }))
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// Walk into payload mirroring the path
|
// Walk into payload mirroring the path
|
||||||
@@ -333,7 +368,7 @@ export function HealthThresholds() {
|
|||||||
{ method: "PUT", body: JSON.stringify(payload) },
|
{ method: "PUT", body: JSON.stringify(payload) },
|
||||||
)
|
)
|
||||||
if (!data.success || !data.thresholds) {
|
if (!data.success || !data.thresholds) {
|
||||||
setError(data.message || "Save failed")
|
setError(data.message || t("status.saveFailed"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTree(data.thresholds)
|
setTree(data.thresholds)
|
||||||
@@ -342,14 +377,16 @@ export function HealthThresholds() {
|
|||||||
setSavedFlash(true)
|
setSavedFlash(true)
|
||||||
setTimeout(() => setSavedFlash(false), 2000)
|
setTimeout(() => setSavedFlash(false), 2000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Network error while saving")
|
setError(err instanceof Error ? err.message : t("status.networkErrorWhileSaving"))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResetSection = async (sectionId: string) => {
|
const handleResetSection = async (sectionId: string) => {
|
||||||
if (!confirm(`Reset all "${SECTIONS.find((s) => s.id === sectionId)?.title}" thresholds to recommended values?`))
|
const section = SECTIONS.find((s) => s.id === sectionId)
|
||||||
|
const sectionTitle = section ? tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title) : sectionId
|
||||||
|
if (!confirm(t("settings.healthThresholds.resetSectionConfirm", { section: sectionTitle })))
|
||||||
return
|
return
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
|
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
|
||||||
@@ -357,7 +394,7 @@ export function HealthThresholds() {
|
|||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
)
|
)
|
||||||
if (!data.success || !data.thresholds) {
|
if (!data.success || !data.thresholds) {
|
||||||
setError(data.message || "Reset failed")
|
setError(data.message || t("settings.healthThresholds.resetFailed"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTree(data.thresholds)
|
setTree(data.thresholds)
|
||||||
@@ -371,52 +408,36 @@ export function HealthThresholds() {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Network error while resetting")
|
setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResetAll = async () => {
|
const handleResetAll = async () => {
|
||||||
if (!confirm("Reset ALL thresholds to recommended values? This affects every section.")) return
|
if (!confirm(t("settings.healthThresholds.resetAllConfirm"))) return
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
|
const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>(
|
||||||
"/api/health/thresholds/reset",
|
"/api/health/thresholds/reset",
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
)
|
)
|
||||||
if (!data.success || !data.thresholds) {
|
if (!data.success || !data.thresholds) {
|
||||||
setError(data.message || "Reset failed")
|
setError(data.message || t("settings.healthThresholds.resetFailed"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTree(data.thresholds)
|
setTree(data.thresholds)
|
||||||
setPending({})
|
setPending({})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Network error while resetting")
|
setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderField = (path: string[], label: string) => {
|
const renderField = (path: string[], label: string) => {
|
||||||
|
// Kept for single-value leaves that don't have a warn/crit pair
|
||||||
|
// (e.g. Memory's swap_critical). The pair-cases route to
|
||||||
|
// renderThresholdRange below.
|
||||||
const leaf = getLeaf(tree, path)
|
const leaf = getLeaf(tree, path)
|
||||||
if (!leaf) return null
|
if (!leaf) return null
|
||||||
const key = pathKey(path)
|
const key = pathKey(path)
|
||||||
const editingValue = pending[key] ?? String(leaf.value)
|
const editingValue = pending[key] ?? String(leaf.value)
|
||||||
// Visual rules (rebuilt — the original used /40 opacity borders +
|
|
||||||
// a blue ring stacked on top of the colour border, both of which
|
|
||||||
// were nearly invisible in read-only mode and stacked weirdly when
|
|
||||||
// a value was customised):
|
|
||||||
//
|
|
||||||
// • Read-only mode (editMode=false): keep severity colour on the
|
|
||||||
// border at a higher opacity (/70 instead of /40) and on the
|
|
||||||
// background (/10) so the field is clearly readable, and
|
|
||||||
// restore foreground colour (no `opacity-70` washout). This is
|
|
||||||
// the default state the user sees most of the time — it must
|
|
||||||
// match the visual weight of the rest of the Settings page.
|
|
||||||
// • Edit mode + value matches the recommended default: severity
|
|
||||||
// border + soft severity bg, same as read-only.
|
|
||||||
// • Edit mode + value customised: ONE border in blue, replacing
|
|
||||||
// (not stacking on top of) the severity border. This is the
|
|
||||||
// single signal that "this value differs from recommended".
|
|
||||||
//
|
|
||||||
// `swap_critical` and any other `*_critical` leaf falls into the
|
|
||||||
// red bucket via the substring check.
|
|
||||||
const last = path[path.length - 1] || ""
|
const last = path[path.length - 1] || ""
|
||||||
const isCritical = last.toLowerCase().includes("critical")
|
const isCritical = last.toLowerCase().includes("critical")
|
||||||
const isWarning = last.toLowerCase().includes("warning")
|
const isWarning = last.toLowerCase().includes("warning")
|
||||||
@@ -428,7 +449,7 @@ export function HealthThresholds() {
|
|||||||
const isCustomised = leaf.customised && !(key in pending)
|
const isCustomised = leaf.customised && !(key in pending)
|
||||||
const customisedClass = "border-blue-500 bg-blue-500/10 focus-visible:border-blue-500"
|
const customisedClass = "border-blue-500 bg-blue-500/10 focus-visible:border-blue-500"
|
||||||
const fieldClass = isCustomised ? customisedClass : severityClass
|
const fieldClass = isCustomised ? customisedClass : severityClass
|
||||||
const recommendedTooltip = `Recommended: ${leaf.recommended}${leaf.unit}`
|
const recommendedTooltip = `${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${leaf.unit}`
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex items-center justify-between gap-2 py-1.5 px-1">
|
<div key={key} className="flex items-center justify-between gap-2 py-1.5 px-1">
|
||||||
<span className="text-xs sm:text-sm text-foreground/90 min-w-0">
|
<span className="text-xs sm:text-sm text-foreground/90 min-w-0">
|
||||||
@@ -456,20 +477,220 @@ export function HealthThresholds() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single-handle slider for thresholds that don't have a warn/crit
|
||||||
|
// pair (only Memory's swap_critical today). Same visual language as
|
||||||
|
// the dual-handle: red handle, value above, OK / CRIT zones below
|
||||||
|
// — so the operator doesn't read it as a different control.
|
||||||
|
const renderSingleThresholdSlider = (path: string[], severity: "warning" | "critical" = "critical") => {
|
||||||
|
const leaf = getLeaf(tree, path)
|
||||||
|
if (!leaf) return null
|
||||||
|
const key = pathKey(path)
|
||||||
|
const val = Number(pending[key] ?? leaf.value)
|
||||||
|
const step = leaf.step || 1
|
||||||
|
const unit = leaf.unit || ""
|
||||||
|
const { min, max } = computeVisualRange(
|
||||||
|
[leaf.value, leaf.recommended],
|
||||||
|
leaf.min,
|
||||||
|
leaf.max,
|
||||||
|
step,
|
||||||
|
)
|
||||||
|
const pct = ((Math.max(min, Math.min(max, val)) - min) / (max - min)) * 100
|
||||||
|
const custom = leaf.customised && !(key in pending)
|
||||||
|
const color = severity === "critical" ? "red" : "amber"
|
||||||
|
const handleClass = severity === "critical"
|
||||||
|
? "[&::-webkit-slider-thumb]:bg-red-500 [&::-moz-range-thumb]:bg-red-500"
|
||||||
|
: "[&::-webkit-slider-thumb]:bg-amber-500 [&::-moz-range-thumb]:bg-amber-500"
|
||||||
|
const numberColor = custom
|
||||||
|
? "text-blue-400"
|
||||||
|
: severity === "critical"
|
||||||
|
? "text-red-500"
|
||||||
|
: "text-amber-500"
|
||||||
|
const fillColor = severity === "critical" ? "bg-red-500/30" : "bg-amber-500/30"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-1 py-3">
|
||||||
|
<div className="relative h-6 sm:h-5 mb-1 select-none">
|
||||||
|
<span
|
||||||
|
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${numberColor}`}
|
||||||
|
style={{ left: `${pct}%` }}
|
||||||
|
>
|
||||||
|
{val}{unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative h-9 sm:h-6">
|
||||||
|
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full bg-muted" />
|
||||||
|
<div
|
||||||
|
className={`absolute top-1/2 -translate-y-1/2 h-1.5 rounded-r-full ${fillColor}`}
|
||||||
|
style={{ left: `${pct}%`, right: 0 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
disabled={!editMode}
|
||||||
|
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={`${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>{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dual-handle range slider replacing the two stacked number inputs
|
||||||
|
// for warn/crit pairs. Visual model: a horizontal track split into
|
||||||
|
// three zones — OK (left of warning, muted), WARN→CRIT (between
|
||||||
|
// handles, amber-to-red gradient), and OVER-CRIT (right of critical,
|
||||||
|
// dark red overlay). The handles themselves stay coloured (amber for
|
||||||
|
// warning, red for critical) so the operator reads the same severity
|
||||||
|
// mapping they're used to from the old inputs. Numbers ride above
|
||||||
|
// each handle and double as a click-to-edit affordance — clicking a
|
||||||
|
// number swaps it for a tight `<Input type="number">` so precise
|
||||||
|
// values are still keyboard-friendly. Customised values surface as a
|
||||||
|
// small blue dot on the affected handle (same signal as the old blue
|
||||||
|
// ring, less visual weight).
|
||||||
|
const renderThresholdRange = (
|
||||||
|
basePath: string[],
|
||||||
|
options?: { hideLabels?: boolean }
|
||||||
|
) => {
|
||||||
|
const wPath = [...basePath, "warning"]
|
||||||
|
const cPath = [...basePath, "critical"]
|
||||||
|
const wLeaf = getLeaf(tree, wPath)
|
||||||
|
const cLeaf = getLeaf(tree, cPath)
|
||||||
|
if (!wLeaf || !cLeaf) return null
|
||||||
|
|
||||||
|
const wKey = pathKey(wPath)
|
||||||
|
const cKey = pathKey(cPath)
|
||||||
|
const wVal = Number(pending[wKey] ?? wLeaf.value)
|
||||||
|
const cVal = Number(pending[cKey] ?? cLeaf.value)
|
||||||
|
|
||||||
|
// Backend validates warning <= critical on save.
|
||||||
|
const step = Math.max(wLeaf.step, cLeaf.step) || 1
|
||||||
|
const backendMin = Math.min(wLeaf.min, cLeaf.min)
|
||||||
|
const backendMax = Math.max(wLeaf.max, cLeaf.max)
|
||||||
|
const { min, max } = computeVisualRange(
|
||||||
|
[wLeaf.value, cLeaf.value, wLeaf.recommended, cLeaf.recommended],
|
||||||
|
backendMin,
|
||||||
|
backendMax,
|
||||||
|
step,
|
||||||
|
)
|
||||||
|
const pct = (v: number) => ((Math.max(min, Math.min(max, v)) - min) / (max - min)) * 100
|
||||||
|
const wPct = pct(wVal)
|
||||||
|
const cPct = pct(cVal)
|
||||||
|
const unit = wLeaf.unit || cLeaf.unit || ""
|
||||||
|
|
||||||
|
const wCustom = wLeaf.customised && !(wKey in pending)
|
||||||
|
const cCustom = cLeaf.customised && !(cKey in pending)
|
||||||
|
|
||||||
|
const setVal = (key: string, value: number, peer: number, isWarn: boolean) => {
|
||||||
|
// Clamp on the fly: warning can't cross critical and vice-versa,
|
||||||
|
// matching the backend invariant so the user can't drag into an
|
||||||
|
// invalid state.
|
||||||
|
let v = value
|
||||||
|
if (isWarn && v >= peer) v = peer - step
|
||||||
|
if (!isWarn && v <= peer) v = peer + step
|
||||||
|
setPending((p) => ({ ...p, [key]: String(v) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-1 py-3">
|
||||||
|
{/* Numeric labels above each handle, positioned absolutely so
|
||||||
|
they ride above the corresponding thumb regardless of the
|
||||||
|
slider width. Pointer events disabled so they don't steal
|
||||||
|
clicks from the underlying range inputs. */}
|
||||||
|
<div className="relative h-6 sm:h-5 mb-1 select-none">
|
||||||
|
<span
|
||||||
|
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${wCustom ? "text-blue-400" : "text-amber-500"}`}
|
||||||
|
style={{ left: `${wPct}%` }}
|
||||||
|
>
|
||||||
|
{wVal}{unit}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`absolute -translate-x-1/2 text-xs font-semibold tabular-nums ${cCustom ? "text-blue-400" : "text-red-500"}`}
|
||||||
|
style={{ left: `${cPct}%` }}
|
||||||
|
>
|
||||||
|
{cVal}{unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two range inputs stacked. Mobile track box is taller so the
|
||||||
|
enlarged thumbs (h-7) have room to sit without clipping. */}
|
||||||
|
<div className="relative h-9 sm:h-6">
|
||||||
|
{/* Background track: OK zone (muted) running the full width */}
|
||||||
|
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full bg-muted" />
|
||||||
|
{/* Warn-to-Crit gradient between the two handles */}
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
left: `${wPct}%`,
|
||||||
|
width: `${Math.max(0, cPct - wPct)}%`,
|
||||||
|
background: "linear-gradient(90deg, rgb(245 158 11), rgb(239 68 68))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* OVER-CRIT zone (right of critical) — solid red dim */}
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-r-full bg-red-500/30"
|
||||||
|
style={{ left: `${cPct}%`, right: 0 }}
|
||||||
|
/>
|
||||||
|
{/* Two superposed range inputs. Pointer-events on the thumb
|
||||||
|
only, so the inert track bar above stays visible. */}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
disabled={!editMode}
|
||||||
|
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={`${t("settings.healthThresholds.warning")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${wLeaf.recommended}${unit})`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
disabled={!editMode}
|
||||||
|
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={`${t("settings.healthThresholds.critical")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${cLeaf.recommended}${unit})`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone labels — explicit ranges so the operator knows where
|
||||||
|
"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>{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<SlidersHorizontal className="h-5 w-5 text-amber-500" />
|
<SlidersHorizontal className="h-5 w-5 text-amber-500" />
|
||||||
<CardTitle>Health Monitor Thresholds</CardTitle>
|
<CardTitle>{t("settings.healthThresholds.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{savedFlash && (
|
{savedFlash && (
|
||||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
Saved
|
{t("status.saved")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
@@ -479,7 +700,7 @@ export function HealthThresholds() {
|
|||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||||
@@ -491,7 +712,7 @@ export function HealthThresholds() {
|
|||||||
) : (
|
) : (
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
Save
|
{t("actions.save")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -499,17 +720,17 @@ export function HealthThresholds() {
|
|||||||
<button
|
<button
|
||||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground flex items-center gap-1.5"
|
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground flex items-center gap-1.5"
|
||||||
onClick={handleResetAll}
|
onClick={handleResetAll}
|
||||||
title="Reset every threshold to its recommended value"
|
title={t("settings.healthThresholds.resetAllTitle")}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3 w-3" />
|
<RotateCcw className="h-3 w-3" />
|
||||||
Reset all
|
{t("actions.resetAll")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
>
|
>
|
||||||
<Settings2 className="h-3 w-3" />
|
<Settings2 className="h-3 w-3" />
|
||||||
Edit
|
{t("actions.edit")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -517,10 +738,7 @@ export function HealthThresholds() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
The Health Monitor and notifications fire when these thresholds are crossed.
|
{t("settings.healthThresholds.description")}
|
||||||
Amber inputs are warning levels, red inputs are critical levels. A blue ring
|
|
||||||
marks a value you've customised away from the recommended default — hover the
|
|
||||||
field to see the recommendation, or use Reset to restore it.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -529,7 +747,7 @@ export function HealthThresholds() {
|
|||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : !tree ? (
|
) : !tree ? (
|
||||||
<div className="text-sm text-muted-foreground">Failed to load thresholds.</div>
|
<div className="text-sm text-muted-foreground">{t("settings.healthThresholds.loadFailed")}</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -554,13 +772,13 @@ export function HealthThresholds() {
|
|||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||||
<h4 className="text-sm font-medium">{section.title}</h4>
|
<h4 className="text-sm font-medium">{tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title)}</h4>
|
||||||
</div>
|
</div>
|
||||||
{!editMode && (
|
{editMode && (
|
||||||
<button
|
<button
|
||||||
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors flex items-center justify-center"
|
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors flex items-center justify-center"
|
||||||
onClick={() => handleResetSection(section.id)}
|
onClick={() => handleResetSection(section.id)}
|
||||||
title="Reset this section to recommended"
|
title={t("settings.healthThresholds.resetSectionTitle")}
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-3 w-3" />
|
<RotateCcw className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -568,21 +786,54 @@ export function HealthThresholds() {
|
|||||||
</div>
|
</div>
|
||||||
{section.description && (
|
{section.description && (
|
||||||
<p className="text-[11px] text-muted-foreground mb-1.5 leading-snug">
|
<p className="text-[11px] text-muted-foreground mb-1.5 leading-snug">
|
||||||
{section.description}
|
{tFallback(`settings.healthThresholds.sections.${section.id}.description`, section.description)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="divide-y divide-border/40">
|
<div>
|
||||||
{section.rowGroups
|
{section.rowGroups ? (
|
||||||
? section.rowGroups.map((group) => (
|
// Per-class disk temperature: one slider per row
|
||||||
<div key={group.subKey} className="py-1.5">
|
// (HDD / SSD / NVMe / SAS). Group label sits on
|
||||||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-0.5 px-1">
|
// top of each slider so the operator scans the
|
||||||
{group.label}
|
// column from top down without losing context.
|
||||||
</div>
|
section.rowGroups.map((group) => (
|
||||||
{renderField([section.id, group.subKey, "warning"], "Warning")}
|
<div key={group.subKey} className="py-1.5 border-b border-border/40 last:border-b-0">
|
||||||
{renderField([section.id, group.subKey, "critical"], "Critical")}
|
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
|
||||||
|
{group.label}
|
||||||
</div>
|
</div>
|
||||||
))
|
{renderThresholdRange([section.id, group.subKey])}
|
||||||
: section.fields.map((f) => renderField(f.path, f.label))}
|
</div>
|
||||||
|
))
|
||||||
|
) : section.id === "memory" ? (
|
||||||
|
// Memory & Swap is special: warn/crit pair for
|
||||||
|
// RAM, plus a single Swap threshold that has no
|
||||||
|
// companion (it's a "critical only" metric).
|
||||||
|
// Both use sliders so the section reads as one
|
||||||
|
// visual language end to end.
|
||||||
|
<>
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-muted-foreground px-1">
|
||||||
|
{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">
|
||||||
|
{t("settings.healthThresholds.swapCriticalOnly")}
|
||||||
|
</div>
|
||||||
|
{renderSingleThresholdSlider(["memory", "swap_critical"], "critical")}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : section.fields.length === 2 &&
|
||||||
|
section.fields[0].path[section.fields[0].path.length - 1] === "warning" &&
|
||||||
|
section.fields[1].path[section.fields[1].path.length - 1] === "critical" ? (
|
||||||
|
// Generic warn+crit pair (CPU, CPU temp, storage
|
||||||
|
// capacities …) → single slider.
|
||||||
|
renderThresholdRange([section.id])
|
||||||
|
) : (
|
||||||
|
// Fallback for any future section shape — keep
|
||||||
|
// the original per-field number inputs.
|
||||||
|
<div className="divide-y divide-border/40">
|
||||||
|
{section.fields.map((f) => renderField(f.path, f.label))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,19 +9,22 @@ import { Activity, TrendingDown, TrendingUp, Minus, RefreshCw, Wifi, FileText, S
|
|||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from "recharts"
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from "recharts"
|
||||||
import { useIsMobile } from "../hooks/use-mobile"
|
import { useIsMobile } from "../hooks/use-mobile"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
|
type TFunction = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
const TIMEFRAME_OPTIONS = [
|
const TIMEFRAME_OPTIONS = [
|
||||||
{ value: "hour", label: "1 Hour" },
|
{ value: "hour", labelKey: "network.latency.timeframes.hour" },
|
||||||
{ value: "6hour", label: "6 Hours" },
|
{ value: "6hour", labelKey: "network.latency.timeframes.sixHours" },
|
||||||
{ value: "day", label: "24 Hours" },
|
{ value: "day", labelKey: "network.latency.timeframes.day" },
|
||||||
{ value: "3day", label: "3 Days" },
|
{ value: "3day", labelKey: "network.latency.timeframes.threeDays" },
|
||||||
{ value: "week", label: "7 Days" },
|
{ value: "week", labelKey: "network.latency.timeframes.week" },
|
||||||
]
|
]
|
||||||
|
|
||||||
const TARGET_OPTIONS = [
|
const TARGET_OPTIONS = [
|
||||||
{ value: "gateway", label: "Gateway (Router)", shortLabel: "Gateway", realtime: false },
|
{ value: "gateway", labelKey: "network.latency.targets.gateway", shortLabelKey: "network.latency.targets.gatewayShort", realtime: false },
|
||||||
{ value: "cloudflare", label: "Cloudflare (1.1.1.1)", shortLabel: "Cloudflare", realtime: true },
|
{ value: "cloudflare", labelKey: "network.latency.targets.cloudflare", shortLabelKey: "network.latency.targets.cloudflareShort", realtime: true },
|
||||||
{ value: "google", label: "Google DNS (8.8.8.8)", shortLabel: "Google DNS", realtime: true },
|
{ value: "google", labelKey: "network.latency.targets.google", shortLabelKey: "network.latency.targets.googleShort", realtime: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Realtime test configuration
|
// Realtime test configuration
|
||||||
@@ -60,7 +63,22 @@ interface LatencyDetailModalProps {
|
|||||||
currentLatency?: number
|
currentLatency?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
const getLatencyTimeframeLabel = (value: string, t: TFunction): string =>
|
||||||
|
TIMEFRAME_OPTIONS.find((option) => option.value === value)
|
||||||
|
? t(TIMEFRAME_OPTIONS.find((option) => option.value === value)!.labelKey)
|
||||||
|
: value
|
||||||
|
|
||||||
|
const getLatencyTargetLabel = (value: string, t: TFunction): string =>
|
||||||
|
TARGET_OPTIONS.find((option) => option.value === value)
|
||||||
|
? t(TARGET_OPTIONS.find((option) => option.value === value)!.labelKey)
|
||||||
|
: value
|
||||||
|
|
||||||
|
const getLatencyTargetShortLabel = (value: string, t: TFunction): string =>
|
||||||
|
TARGET_OPTIONS.find((option) => option.value === value)
|
||||||
|
? t(TARGET_OPTIONS.find((option) => option.value === value)!.shortLabelKey)
|
||||||
|
: value
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label, t }: any) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
const entry = payload[0]
|
const entry = payload[0]
|
||||||
const data = entry?.payload
|
const data = entry?.payload
|
||||||
@@ -76,17 +94,17 @@ const CustomTooltip = ({ active, payload, label }: any) => {
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-green-500" />
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-green-500" />
|
||||||
<span className="text-xs text-gray-300 min-w-[60px]">Min:</span>
|
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.min")}:</span>
|
||||||
<span className="text-sm font-semibold text-green-400">{data.min} ms</span>
|
<span className="text-sm font-semibold text-green-400">{data.min} ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
|
||||||
<span className="text-xs text-gray-300 min-w-[60px]">Avg:</span>
|
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.avg")}:</span>
|
||||||
<span className="text-sm font-semibold text-white">{data.value} ms</span>
|
<span className="text-sm font-semibold text-white">{data.value} ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-red-500" />
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-red-500" />
|
||||||
<span className="text-xs text-gray-300 min-w-[60px]">Max:</span>
|
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.max")}:</span>
|
||||||
<span className="text-sm font-semibold text-red-400">{data.max} ms</span>
|
<span className="text-sm font-semibold text-red-400">{data.max} ms</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -94,14 +112,14 @@ const CustomTooltip = ({ active, payload, label }: any) => {
|
|||||||
// Simple latency display for single data points
|
// Simple latency display for single data points
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-blue-500" />
|
||||||
<span className="text-xs text-gray-300 min-w-[60px]">Latency:</span>
|
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.latency")}:</span>
|
||||||
<span className="text-sm font-semibold text-white">{entry.value} ms</span>
|
<span className="text-sm font-semibold text-white">{entry.value} ms</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{packetLoss !== undefined && packetLoss > 0 && (
|
{packetLoss !== undefined && packetLoss > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-orange-500" />
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0 bg-orange-500" />
|
||||||
<span className="text-xs text-gray-300 min-w-[60px]">Pkt Loss:</span>
|
<span className="text-xs text-gray-300 min-w-[60px]">{t("network.labels.packetLossShort")}:</span>
|
||||||
<span className="text-sm font-semibold text-orange-400">{packetLoss}%</span>
|
<span className="text-sm font-semibold text-orange-400">{packetLoss}%</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -118,20 +136,38 @@ const getStatusColor = (latency: number) => {
|
|||||||
return "#22c55e"
|
return "#22c55e"
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusInfo = (latency: number | null) => {
|
const getStatusInfo = (latency: number | null, t: TFunction) => {
|
||||||
if (latency === null || latency === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
if (latency === null || latency === 0) return { status: t("common.notAvailable"), color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||||
if (latency < 50) return { status: "Excellent", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
if (latency < 50) return { status: t("network.latency.status.excellent"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||||
if (latency < 100) return { status: "Good", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
if (latency < 100) return { status: t("network.latency.status.good"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||||
if (latency < 200) return { status: "Fair", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
if (latency < 200) return { status: t("network.latency.status.fair"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||||
return { status: "Poor", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
return { status: t("network.latency.status.poor"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusText = (latency: number | null): string => {
|
const getStatusKey = (latency: number | null): "na" | "excellent" | "good" | "fair" | "poor" => {
|
||||||
if (latency === null || latency === 0) return "N/A"
|
if (latency === null || latency === 0) return "na"
|
||||||
if (latency < 50) return "Excellent"
|
if (latency < 50) return "excellent"
|
||||||
if (latency < 100) return "Good"
|
if (latency < 100) return "good"
|
||||||
if (latency < 200) return "Fair"
|
if (latency < 200) return "fair"
|
||||||
return "Poor"
|
return "poor"
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (latency: number | null, t: TFunction): string => {
|
||||||
|
const key = getStatusKey(latency)
|
||||||
|
return key === "na" ? t("common.notAvailable") : t(`network.latency.status.${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatReportDuration = (seconds: number | undefined, t: TFunction, compact = false): string => {
|
||||||
|
if (!seconds || seconds <= 0) {
|
||||||
|
return compact ? t("network.latency.report.realTime") : t("network.latency.report.testPeriod")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seconds < 60) {
|
||||||
|
return t(compact ? "network.latency.report.secondsShort" : "network.latency.report.seconds", { count: seconds })
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.max(1, Math.round(seconds / 60))
|
||||||
|
return t(compact ? "network.latency.report.minutesShort" : "network.latency.report.minutes", { count: minutes })
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReportData {
|
interface ReportData {
|
||||||
@@ -145,9 +181,10 @@ interface ReportData {
|
|||||||
testDuration?: number
|
testDuration?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateLatencyReport = (report: ReportData) => {
|
const generateLatencyReport = (report: ReportData, t: TFunction) => {
|
||||||
const now = new Date().toLocaleString()
|
const now = new Date().toLocaleString()
|
||||||
const logoUrl = `${window.location.origin}/images/proxmenux-logo.png`
|
const logoUrl = `${window.location.origin}/images/proxmenux-logo.png`
|
||||||
|
const htmlLang = document.documentElement.lang || "en"
|
||||||
|
|
||||||
// Calculate stats for realtime results - all values are individual ping measurements in latency_avg
|
// Calculate stats for realtime results - all values are individual ping measurements in latency_avg
|
||||||
const validRealtimeValues = report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
|
const validRealtimeValues = report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
|
||||||
@@ -160,29 +197,57 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
} : null
|
} : null
|
||||||
|
|
||||||
const statusText = report.isRealtime
|
const statusText = report.isRealtime
|
||||||
? getStatusText(realtimeStats?.current ?? null)
|
? getStatusText(realtimeStats?.current ?? null, t)
|
||||||
: getStatusText(report.stats.current)
|
: getStatusText(report.stats.current, t)
|
||||||
|
|
||||||
// Colors matching Lynis report
|
// Colors matching Lynis report
|
||||||
const statusColorMap: Record<string, string> = {
|
const statusColorMap: Record<string, string> = {
|
||||||
"Excellent": "#16a34a",
|
excellent: "#16a34a",
|
||||||
"Good": "#16a34a",
|
good: "#16a34a",
|
||||||
"Fair": "#ca8a04",
|
fair: "#ca8a04",
|
||||||
"Poor": "#dc2626",
|
poor: "#dc2626",
|
||||||
"N/A": "#64748b"
|
na: "#64748b",
|
||||||
}
|
}
|
||||||
const statusColor = statusColorMap[statusText] || "#64748b"
|
const statusKey = report.isRealtime
|
||||||
|
? getStatusKey(realtimeStats?.current ?? null)
|
||||||
|
: getStatusKey(report.stats.current)
|
||||||
|
const statusColor = statusColorMap[statusKey] || "#64748b"
|
||||||
|
|
||||||
const timeframeLabel = TIMEFRAME_OPTIONS.find(t => t.value === report.timeframe)?.label || report.timeframe
|
const timeframeLabel = getLatencyTimeframeLabel(report.timeframe, t)
|
||||||
|
const reportId = `PMXL-${Date.now().toString(36).toUpperCase()}`
|
||||||
|
const notAvailable = t("common.notAvailable")
|
||||||
|
const modeLabel = report.isRealtime
|
||||||
|
? t("network.latency.report.realTimeTest")
|
||||||
|
: t("network.latency.report.historicalAnalysis")
|
||||||
|
const realtimeDurationText = formatReportDuration(report.testDuration, t)
|
||||||
|
const realtimePacketLossText =
|
||||||
|
realtimeStats && realtimeStats.avgPacketLoss > 0
|
||||||
|
? `<span style="color:#dc2626">${t("network.latency.report.averagePacketLoss", {
|
||||||
|
value: realtimeStats.avgPacketLoss.toFixed(1),
|
||||||
|
})}</span>`
|
||||||
|
: `<span style="color:#16a34a">${t("network.latency.report.noPacketLoss")}</span>`
|
||||||
|
const testPeriodValue = report.isRealtime
|
||||||
|
? formatReportDuration(report.testDuration, t, true)
|
||||||
|
: timeframeLabel
|
||||||
|
const targetIpLabel =
|
||||||
|
report.target === "gateway"
|
||||||
|
? t("network.latency.report.defaultGateway")
|
||||||
|
: report.target === "cloudflare"
|
||||||
|
? "1.1.1.1"
|
||||||
|
: "8.8.8.8"
|
||||||
|
const detailSectionNumber =
|
||||||
|
(report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0)
|
||||||
|
? "6"
|
||||||
|
: "5"
|
||||||
|
|
||||||
// Build test results table for realtime mode - each row is now an individual ping measurement
|
// Build test results table for realtime mode - each row is now an individual ping measurement
|
||||||
const realtimeTableRows = report.realtimeResults.map((r, i) => `
|
const realtimeTableRows = report.realtimeResults.map((r, i) => `
|
||||||
<tr${r.packet_loss > 0 ? ' class="warn"' : ''}>
|
<tr${r.packet_loss > 0 ? ' class="warn"' : ''}>
|
||||||
<td>${i + 1}</td>
|
<td>${i + 1}</td>
|
||||||
<td>${new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</td>
|
<td>${new Date(r.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</td>
|
||||||
<td style="font-weight:600;color:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}">${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : 'Failed'}</td>
|
<td style="font-weight:600;color:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}">${r.latency_avg !== null ? r.latency_avg.toFixed(1) + ' ms' : t("network.latency.report.failed")}</td>
|
||||||
<td${r.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${r.packet_loss}%</td>
|
<td${r.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${r.packet_loss}%</td>
|
||||||
<td><span class="f-tag" style="background:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}15;color:${statusColorMap[getStatusText(r.latency_avg)] || '#64748b'}">${getStatusText(r.latency_avg)}</span></td>
|
<td><span class="f-tag" style="background:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}15;color:${statusColorMap[getStatusKey(r.latency_avg)] || '#64748b'}">${getStatusText(r.latency_avg, t)}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')
|
`).join('')
|
||||||
|
|
||||||
@@ -199,9 +264,9 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
<tr${d.packet_loss && d.packet_loss > 0 ? ' class="warn"' : ''}>
|
<tr${d.packet_loss && d.packet_loss > 0 ? ' class="warn"' : ''}>
|
||||||
<td>${i + 1}</td>
|
<td>${i + 1}</td>
|
||||||
<td>${new Date(d.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
|
<td>${new Date(d.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</td>
|
||||||
<td style="font-weight:600;color:${statusColorMap[getStatusText(d.value)] || '#64748b'}">${d.value !== null ? d.value.toFixed(1) + ' ms' : 'Failed'}</td>
|
<td style="font-weight:600;color:${statusColorMap[getStatusKey(d.value)] || '#64748b'}">${d.value !== null ? d.value.toFixed(1) + ' ms' : t("network.latency.report.failed")}</td>
|
||||||
<td${d.packet_loss && d.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${d.packet_loss?.toFixed(1) ?? 0}%</td>
|
<td${d.packet_loss && d.packet_loss > 0 ? ' style="color:#dc2626;font-weight:600;"' : ''}>${d.packet_loss?.toFixed(1) ?? 0}%</td>
|
||||||
<td><span class="f-tag" style="background:${statusColorMap[getStatusText(d.value)] || '#64748b'}15;color:${statusColorMap[getStatusText(d.value)] || '#64748b'}">${getStatusText(d.value)}</span></td>
|
<td><span class="f-tag" style="background:${statusColorMap[getStatusKey(d.value)] || '#64748b'}15;color:${statusColorMap[getStatusKey(d.value)] || '#64748b'}">${getStatusText(d.value, t)}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')
|
`).join('')
|
||||||
|
|
||||||
@@ -210,7 +275,7 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
? report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
|
? report.realtimeResults.filter(r => r.latency_avg !== null).map(r => r.latency_avg!)
|
||||||
: report.data.map(d => d.value || 0)
|
: report.data.map(d => d.value || 0)
|
||||||
|
|
||||||
let chartSvg = '<p style="text-align:center;color:#64748b;padding:20px;">Not enough data points for chart</p>'
|
let chartSvg = `<p style="text-align:center;color:#64748b;padding:20px;">${t("network.latency.report.notEnoughData")}</p>`
|
||||||
if (chartData.length >= 2) {
|
if (chartData.length >= 2) {
|
||||||
const rawMin = Math.min(...chartData)
|
const rawMin = Math.min(...chartData)
|
||||||
const rawMax = Math.max(...chartData)
|
const rawMax = Math.max(...chartData)
|
||||||
@@ -253,17 +318,17 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
<text x="${padding - 5}" y="${height - padding + 4}" font-size="9" fill="#64748b" text-anchor="end">${Math.round(minVal)}ms</text>
|
<text x="${padding - 5}" y="${height - padding + 4}" font-size="9" fill="#64748b" text-anchor="end">${Math.round(minVal)}ms</text>
|
||||||
<polygon points="${areaPoints}" fill="url(#areaGrad)"/>
|
<polygon points="${areaPoints}" fill="url(#areaGrad)"/>
|
||||||
<polyline points="${points}" fill="none" stroke="#3b82f6" stroke-width="2"/>
|
<polyline points="${points}" fill="none" stroke="#3b82f6" stroke-width="2"/>
|
||||||
<text x="${width / 2}" y="${height - 5}" font-size="9" fill="#64748b" text-anchor="middle">${chartData.length} samples</text>
|
<text x="${width / 2}" y="${height - 5}" font-size="9" fill="#64748b" text-anchor="middle">${t("network.latency.report.samples", { count: chartData.length })}</text>
|
||||||
</svg>
|
</svg>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = `<!DOCTYPE html>
|
const html = `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="${htmlLang}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Network Latency Report - ${report.targetLabel}</title>
|
<title>${t("network.latency.report.title")} - ${report.targetLabel}</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; }
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a2e; background: #fff; font-size: 13px; line-height: 1.5; }
|
||||||
@@ -463,11 +528,11 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
<div class="top-bar no-print">
|
<div class="top-bar no-print">
|
||||||
<div class="top-bar-left">
|
<div class="top-bar-left">
|
||||||
<div>
|
<div>
|
||||||
<div class="top-bar-title">ProxMenux Network Latency Report</div>
|
<div class="top-bar-title">${t("network.latency.report.topBarTitle")}</div>
|
||||||
<div class="top-bar-subtitle">Review the report, then print or save as PDF</div>
|
<div class="top-bar-subtitle">${t("network.latency.report.topBarSubtitle")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="window.print()">Print / Save as PDF</button>
|
<button onclick="window.print()">${t("network.latency.report.printSavePdf")}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -475,21 +540,21 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
<div class="rpt-header-left">
|
<div class="rpt-header-left">
|
||||||
<img src="${logoUrl}" alt="ProxMenux" onerror="this.style.display='none'" />
|
<img src="${logoUrl}" alt="ProxMenux" onerror="this.style.display='none'" />
|
||||||
<div>
|
<div>
|
||||||
<h1>Network Latency Report</h1>
|
<h1>${t("network.latency.report.title")}</h1>
|
||||||
<p>ProxMenux Monitor - Network Performance Analysis</p>
|
<p>${t("network.latency.report.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rpt-header-right">
|
<div class="rpt-header-right">
|
||||||
<div><strong>Date:</strong> ${now}</div>
|
<div><strong>${t("network.latency.report.date")}:</strong> ${now}</div>
|
||||||
<div><strong>Target:</strong> ${report.targetLabel}</div>
|
<div><strong>${t("network.latency.report.target")}:</strong> ${report.targetLabel}</div>
|
||||||
<div><strong>Mode:</strong> ${report.isRealtime ? 'Real-time Test' : 'Historical Analysis'}</div>
|
<div><strong>${t("network.latency.report.mode")}:</strong> ${modeLabel}</div>
|
||||||
<div class="rid">ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
|
<div class="rid">ID: ${reportId}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 1. Executive Summary -->
|
<!-- 1. Executive Summary -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">1. Executive Summary</div>
|
<div class="section-title">1. ${t("network.latency.report.executiveSummary")}</div>
|
||||||
<div class="exec-box">
|
<div class="exec-box">
|
||||||
<div class="latency-gauge">
|
<div class="latency-gauge">
|
||||||
<svg viewBox="0 0 120 90" width="160" height="120">
|
<svg viewBox="0 0 120 90" width="160" height="120">
|
||||||
@@ -508,35 +573,41 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
<text x="98" y="87" font-size="7" fill="#64748b">300+</text>
|
<text x="98" y="87" font-size="7" fill="#64748b">300+</text>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="gauge-value" style="color:${statusColor};">
|
<div class="gauge-value" style="color:${statusColor};">
|
||||||
<span class="gauge-num">${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? 'N/A') : report.stats.avg}</span>
|
<span class="gauge-num">${report.isRealtime ? (realtimeStats?.avg?.toFixed(0) ?? notAvailable) : report.stats.avg}</span>
|
||||||
<span class="gauge-unit">ms</span>
|
<span class="gauge-unit">ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gauge-status" style="color:${statusColor};">${statusText}</div>
|
<div class="gauge-status" style="color:${statusColor};">${statusText}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="exec-text">
|
<div class="exec-text">
|
||||||
<h3>Network Latency Assessment${report.isRealtime ? ' (Real-time)' : ''}</h3>
|
<h3>${t("network.latency.report.assessmentTitle")}${report.isRealtime ? ` (${t("network.latency.report.realTime")})` : ""}</h3>
|
||||||
<p>
|
<p>
|
||||||
${report.isRealtime
|
${report.isRealtime
|
||||||
? `Real-time latency test to <strong>${report.targetLabel}</strong> with <strong>${report.realtimeResults.length} samples</strong> collected over ${report.testDuration ? Math.round(report.testDuration / 60) + ' minute(s)' : 'the test period'}.
|
? `${t("network.latency.report.realtimeSummary", {
|
||||||
Average latency: <strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? 'N/A'} ms</strong>.
|
target: `<strong>${report.targetLabel}</strong>`,
|
||||||
${realtimeStats && realtimeStats.avgPacketLoss > 0 ? `<span style="color:#dc2626">Average packet loss: ${realtimeStats.avgPacketLoss.toFixed(1)}%.</span>` : '<span style="color:#16a34a">No packet loss detected.</span>'}`
|
count: `<strong>${report.realtimeResults.length}</strong>`,
|
||||||
: `Historical latency analysis to <strong>Gateway</strong> over <strong>${timeframeLabel.toLowerCase()}</strong>.
|
duration: realtimeDurationText,
|
||||||
<strong>${report.data.length} samples</strong> analyzed.
|
avg: `<strong style="color:${statusColor}">${realtimeStats?.avg?.toFixed(1) ?? notAvailable} ms</strong>`,
|
||||||
Average latency: <strong style="color:${statusColor}">${report.stats.avg} ms</strong>.`
|
})} ${realtimePacketLossText}`
|
||||||
|
: `${t("network.latency.report.historicalSummary", {
|
||||||
|
target: t("network.latency.targets.gatewayShort"),
|
||||||
|
timeframe: timeframeLabel.toLowerCase(),
|
||||||
|
count: report.data.length,
|
||||||
|
avg: report.stats.avg,
|
||||||
|
})}`
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<div class="latency-range">
|
<div class="latency-range">
|
||||||
<div class="range-item">
|
<div class="range-item">
|
||||||
<span class="range-label">Minimum</span>
|
<span class="range-label">${t("network.labels.minimum")}</span>
|
||||||
<span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min} ms</span>
|
<span class="range-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min} ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="range-item">
|
<div class="range-item">
|
||||||
<span class="range-label">Average</span>
|
<span class="range-label">${t("network.labels.average")}</span>
|
||||||
<span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg} ms</span>
|
<span class="range-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg} ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="range-item">
|
<div class="range-item">
|
||||||
<span class="range-label">Maximum</span>
|
<span class="range-label">${t("network.labels.maximum")}</span>
|
||||||
<span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max} ms</span>
|
<span class="range-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max} ms</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -545,42 +616,40 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
|
|
||||||
<!-- 2. Statistics -->
|
<!-- 2. Statistics -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">2. Latency Statistics</div>
|
<div class="section-title">2. ${t("network.latency.report.latencyStatistics")}</div>
|
||||||
<div class="grid-4">
|
<div class="grid-4">
|
||||||
<div class="card card-c">
|
<div class="card card-c">
|
||||||
<div class="card-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? 'N/A') : report.stats.current}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
<div class="card-value" style="color:${statusColor};">${report.isRealtime ? (realtimeStats?.current?.toFixed(1) ?? notAvailable) : report.stats.current}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||||
<div class="card-label">Current</div>
|
<div class="card-label">${t("network.labels.current")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card card-c">
|
<div class="card card-c">
|
||||||
<div class="card-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? 'N/A') : report.stats.min}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
<div class="card-value" style="color:#16a34a;">${report.isRealtime ? (realtimeStats?.min?.toFixed(1) ?? notAvailable) : report.stats.min}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||||
<div class="card-label">Minimum</div>
|
<div class="card-label">${t("network.labels.minimum")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card card-c">
|
<div class="card card-c">
|
||||||
<div class="card-value">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? 'N/A') : report.stats.avg}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
<div class="card-value">${report.isRealtime ? (realtimeStats?.avg?.toFixed(1) ?? notAvailable) : report.stats.avg}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||||
<div class="card-label">Average</div>
|
<div class="card-label">${t("network.labels.average")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card card-c">
|
<div class="card card-c">
|
||||||
<div class="card-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? 'N/A') : report.stats.max}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
<div class="card-value" style="color:#dc2626;">${report.isRealtime ? (realtimeStats?.max?.toFixed(1) ?? notAvailable) : report.stats.max}<span style="font-size:10px;color:#64748b;"> ms</span></div>
|
||||||
<div class="card-label">Maximum</div>
|
<div class="card-label">${t("network.labels.maximum")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-3">
|
<div class="grid-3">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Sample Count</div>
|
<div class="card-label">${t("network.latency.report.sampleCount")}</div>
|
||||||
<div class="card-value">${report.isRealtime ? report.realtimeResults.length : report.data.length}</div>
|
<div class="card-value">${report.isRealtime ? report.realtimeResults.length : report.data.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Packet Loss (Avg)</div>
|
<div class="card-label">${t("network.latency.report.packetLossAvg")}</div>
|
||||||
<div class="card-value" style="color:${(report.isRealtime ? (realtimeStats?.avgPacketLoss ?? 0) : parseFloat(historyStats?.avgPacketLoss ?? '0')) > 0 ? '#dc2626' : '#16a34a'};">
|
<div class="card-value" style="color:${(report.isRealtime ? (realtimeStats?.avgPacketLoss ?? 0) : parseFloat(historyStats?.avgPacketLoss ?? '0')) > 0 ? '#dc2626' : '#16a34a'};">
|
||||||
${report.isRealtime ? (realtimeStats?.avgPacketLoss?.toFixed(1) ?? '0') : (historyStats?.avgPacketLoss ?? '0')}%
|
${report.isRealtime ? (realtimeStats?.avgPacketLoss?.toFixed(1) ?? '0') : (historyStats?.avgPacketLoss ?? '0')}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Test Period</div>
|
<div class="card-label">${t("network.latency.report.testPeriodLabel")}</div>
|
||||||
<div class="card-value" style="font-size:11px;">
|
<div class="card-value" style="font-size:11px;">
|
||||||
${report.isRealtime
|
${testPeriodValue}
|
||||||
? (report.testDuration ? Math.round(report.testDuration / 60) + ' min' : 'Real-time')
|
|
||||||
: timeframeLabel}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -588,7 +657,7 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
|
|
||||||
<!-- 3. Latency Graph (always section 3) -->
|
<!-- 3. Latency Graph (always section 3) -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">3. Latency Graph</div>
|
<div class="section-title">3. ${t("network.latency.report.latencyGraph")}</div>
|
||||||
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;">
|
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:16px;">
|
||||||
${chartSvg}
|
${chartSvg}
|
||||||
</div>
|
</div>
|
||||||
@@ -596,37 +665,37 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
|
|
||||||
<!-- 4. Performance Thresholds (always section 4) -->
|
<!-- 4. Performance Thresholds (always section 4) -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">4. Performance Thresholds</div>
|
<div class="section-title">4. ${t("network.latency.report.performanceThresholds")}</div>
|
||||||
<div class="threshold-item">
|
<div class="threshold-item">
|
||||||
<div class="threshold-dot" style="background:#16a34a;"></div>
|
<div class="threshold-dot" style="background:#16a34a;"></div>
|
||||||
<p><strong>Excellent (< 50ms):</strong> Optimal for real-time applications, gaming, and video calls.</p>
|
<p><strong>${t("network.latency.status.excellent")} (< 50ms):</strong> ${t("network.latency.report.thresholdExcellent")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="threshold-item">
|
<div class="threshold-item">
|
||||||
<div class="threshold-dot" style="background:#16a34a;"></div>
|
<div class="threshold-dot" style="background:#16a34a;"></div>
|
||||||
<p><strong>Good (50-100ms):</strong> Acceptable for most applications with minimal impact.</p>
|
<p><strong>${t("network.latency.status.good")} (50-100ms):</strong> ${t("network.latency.report.thresholdGood")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="threshold-item">
|
<div class="threshold-item">
|
||||||
<div class="threshold-dot" style="background:#ca8a04;"></div>
|
<div class="threshold-dot" style="background:#ca8a04;"></div>
|
||||||
<p><strong>Fair (100-200ms):</strong> Noticeable delay. May affect VoIP and interactive applications.</p>
|
<p><strong>${t("network.latency.status.fair")} (100-200ms):</strong> ${t("network.latency.report.thresholdFair")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="threshold-item">
|
<div class="threshold-item">
|
||||||
<div class="threshold-dot" style="background:#dc2626;"></div>
|
<div class="threshold-dot" style="background:#dc2626;"></div>
|
||||||
<p><strong>Poor (> 200ms):</strong> Significant latency. Investigation recommended.</p>
|
<p><strong>${t("network.latency.status.poor")} (> 200ms):</strong> ${t("network.latency.report.thresholdPoor")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${report.isRealtime && report.realtimeResults.length > 0 ? `
|
${report.isRealtime && report.realtimeResults.length > 0 ? `
|
||||||
<!-- 5. Detailed Test Results (for Cloudflare / Google DNS) -->
|
<!-- 5. Detailed Test Results (for Cloudflare / Google DNS) -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">5. Detailed Test Results</div>
|
<div class="section-title">5. ${t("network.latency.report.detailedTestResults")}</div>
|
||||||
<table class="chk-tbl">
|
<table class="chk-tbl">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
<th>Time</th>
|
<th>${t("network.labels.time")}</th>
|
||||||
<th>Latency</th>
|
<th>${t("network.labels.latency")}</th>
|
||||||
<th>Packet Loss</th>
|
<th>${t("network.labels.packetLoss")}</th>
|
||||||
<th>Status</th>
|
<th>${t("network.labels.status")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -639,15 +708,15 @@ const generateLatencyReport = (report: ReportData) => {
|
|||||||
${!report.isRealtime && report.data.length > 0 ? `
|
${!report.isRealtime && report.data.length > 0 ? `
|
||||||
<!-- 5. Detailed History (for Gateway) -->
|
<!-- 5. Detailed History (for Gateway) -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">5. Latency History (Last ${Math.min(20, report.data.length)} Records)</div>
|
<div class="section-title">5. ${t("network.latency.report.latencyHistory", { count: Math.min(20, report.data.length) })}</div>
|
||||||
<table class="chk-tbl">
|
<table class="chk-tbl">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
<th>Time</th>
|
<th>${t("network.labels.time")}</th>
|
||||||
<th>Latency</th>
|
<th>${t("network.labels.latency")}</th>
|
||||||
<th>Packet Loss</th>
|
<th>${t("network.labels.packetLoss")}</th>
|
||||||
<th>Status</th>
|
<th>${t("network.labels.status")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -659,41 +728,41 @@ ${!report.isRealtime && report.data.length > 0 ? `
|
|||||||
|
|
||||||
<!-- Methodology -->
|
<!-- Methodology -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">${(report.isRealtime && report.realtimeResults.length > 0) || (!report.isRealtime && report.data.length > 0) ? '6' : '5'}. Methodology</div>
|
<div class="section-title">${detailSectionNumber}. ${t("network.latency.report.methodology")}</div>
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Test Method</div>
|
<div class="card-label">${t("network.latency.report.testMethod")}</div>
|
||||||
<div class="card-value" style="font-size:12px;">ICMP Echo Request (Ping)</div>
|
<div class="card-value" style="font-size:12px;">${t("network.latency.report.icmpEchoRequest")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Samples per Test</div>
|
<div class="card-label">${t("network.latency.report.samplesPerTest")}</div>
|
||||||
<div class="card-value" style="font-size:12px;">3 consecutive pings</div>
|
<div class="card-value" style="font-size:12px;">${t("network.latency.report.threeConsecutivePings")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Target</div>
|
<div class="card-label">${t("network.latency.report.target")}</div>
|
||||||
<div class="card-value" style="font-size:12px;">${report.targetLabel}</div>
|
<div class="card-value" style="font-size:12px;">${report.targetLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-label">Target IP</div>
|
<div class="card-label">${t("network.latency.report.targetIp")}</div>
|
||||||
<div class="card-value" style="font-size:12px;">${report.target === 'gateway' ? 'Default Gateway' : report.target === 'cloudflare' ? '1.1.1.1' : '8.8.8.8'}</div>
|
<div class="card-value" style="font-size:12px;">${targetIpLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<h4>Performance Assessment</h4>
|
<h4>${t("network.latency.report.performanceAssessment")}</h4>
|
||||||
<p>${
|
<p>${
|
||||||
statusText === 'Excellent' ? 'Network latency is excellent. No action required.' :
|
statusKey === 'excellent' ? t("network.latency.report.assessmentExcellent") :
|
||||||
statusText === 'Good' ? 'Network latency is within acceptable parameters.' :
|
statusKey === 'good' ? t("network.latency.report.assessmentGood") :
|
||||||
statusText === 'Fair' ? 'Network latency is elevated. Consider investigating network congestion or routing issues.' :
|
statusKey === 'fair' ? t("network.latency.report.assessmentFair") :
|
||||||
statusText === 'Poor' ? 'Network latency is critically high. Immediate investigation recommended.' :
|
statusKey === 'poor' ? t("network.latency.report.assessmentPoor") :
|
||||||
'Unable to determine network status.'
|
t("network.latency.report.assessmentUnknown")
|
||||||
}</p>
|
}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="rpt-footer">
|
<div class="rpt-footer">
|
||||||
<div>ProxMenux Monitor - Network Performance Report</div>
|
<div>${t("network.latency.report.footerTitle")}</div>
|
||||||
<div>Generated: ${now} | Report ID: PMXL-${Date.now().toString(36).toUpperCase()}</div>
|
<div>${t("network.latency.report.generated")}: ${now} | ${t("network.latency.report.reportId")}: ${reportId}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
@@ -706,6 +775,7 @@ ${!report.isRealtime && report.data.length > 0 ? `
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) {
|
export function LatencyDetailModal({ open, onOpenChange, currentLatency }: LatencyDetailModalProps) {
|
||||||
|
const t = useT()
|
||||||
const [timeframe, setTimeframe] = useState("hour")
|
const [timeframe, setTimeframe] = useState("hour")
|
||||||
const [target, setTarget] = useState("gateway")
|
const [target, setTarget] = useState("gateway")
|
||||||
const [data, setData] = useState<LatencyHistoryPoint[]>([])
|
const [data, setData] = useState<LatencyHistoryPoint[]>([])
|
||||||
@@ -882,7 +952,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
avg: Math.round((realtimeStats?.avg ?? 0) * 10) / 10,
|
avg: Math.round((realtimeStats?.avg ?? 0) * 10) / 10,
|
||||||
} : stats
|
} : stats
|
||||||
|
|
||||||
const statusInfo = getStatusInfo(displayStats.current)
|
const statusInfo = getStatusInfo(displayStats.current, t)
|
||||||
|
|
||||||
// Calculate test duration for report based on first and last result timestamps
|
// Calculate test duration for report based on first and last result timestamps
|
||||||
const testDuration = realtimeResults.length >= 2
|
const testDuration = realtimeResults.length >= 2
|
||||||
@@ -897,20 +967,20 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||||
<Wifi className="h-5 w-5 text-blue-500" />
|
<Wifi className="h-5 w-5 text-blue-500" />
|
||||||
Network Latency
|
{t("network.cards.latency")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex items-center gap-2 mt-1 flex-nowrap">
|
<div className="flex items-center gap-2 mt-1 flex-nowrap">
|
||||||
<Select value={target} onValueChange={setTarget}>
|
<Select value={target} onValueChange={setTarget}>
|
||||||
<SelectTrigger className="w-[140px] sm:w-[180px] h-8 text-xs shrink-0">
|
<SelectTrigger className="w-[140px] sm:w-[180px] h-8 text-xs shrink-0">
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{TARGET_OPTIONS.find(t => t.value === target)?.shortLabel || target}
|
{getLatencyTargetShortLabel(target, t)}
|
||||||
</span>
|
</span>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TARGET_OPTIONS.map(opt => (
|
{TARGET_OPTIONS.map(opt => (
|
||||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||||
{opt.label}
|
{t(opt.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -923,7 +993,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TIMEFRAME_OPTIONS.map(opt => (
|
{TIMEFRAME_OPTIONS.map(opt => (
|
||||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||||
{opt.label}
|
{t(opt.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -938,7 +1008,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
className="gap-1.5 text-red-500 border-red-500/30 hover:bg-red-500/10 shrink-0 h-8 px-3"
|
className="gap-1.5 text-red-500 border-red-500/30 hover:bg-red-500/10 shrink-0 h-8 px-3"
|
||||||
>
|
>
|
||||||
<Square className="h-3 w-3 fill-current" />
|
<Square className="h-3 w-3 fill-current" />
|
||||||
Stop
|
{t("network.latency.actions.stop")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@@ -948,7 +1018,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
className="gap-1.5 shrink-0 h-8 px-3"
|
className="gap-1.5 shrink-0 h-8 px-3"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3 w-3" />
|
<RefreshCw className="h-3 w-3" />
|
||||||
Test Again
|
{t("network.latency.actions.testAgain")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -957,19 +1027,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => generateLatencyReport({
|
onClick={() => generateLatencyReport({
|
||||||
target,
|
target,
|
||||||
targetLabel: TARGET_OPTIONS.find(t => t.value === target)?.label || target,
|
targetLabel: getLatencyTargetLabel(target, t),
|
||||||
isRealtime,
|
isRealtime,
|
||||||
stats,
|
stats,
|
||||||
realtimeResults,
|
realtimeResults,
|
||||||
data,
|
data,
|
||||||
timeframe,
|
timeframe,
|
||||||
testDuration: isRealtime ? testDuration : undefined,
|
testDuration: isRealtime ? testDuration : undefined,
|
||||||
})}
|
}, t)}
|
||||||
disabled={isRealtime ? realtimeResults.length === 0 : data.length === 0}
|
disabled={isRealtime ? realtimeResults.length === 0 : data.length === 0}
|
||||||
className="gap-1.5 shrink-0 h-8 px-3"
|
className="gap-1.5 shrink-0 h-8 px-3"
|
||||||
>
|
>
|
||||||
<FileText className="h-3.5 w-3.5" />
|
<FileText className="h-3.5 w-3.5" />
|
||||||
Report
|
{t("network.latency.actions.report")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -977,8 +1047,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
{isRealtime && realtimeTesting && (
|
{isRealtime && realtimeTesting && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
|
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
|
||||||
<span>Testing... {Math.round(testProgress)}%</span>
|
<span>{t("network.latency.testingProgress", { percent: Math.round(testProgress) })}</span>
|
||||||
<span>{Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100)))}s remaining</span>
|
<span>{t("network.latency.secondsRemaining", { seconds: Math.round((REALTIME_TEST_DURATION * (1 - testProgress / 100))) })}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -992,7 +1062,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
{/* Stats Cards - Compact single row */}
|
{/* Stats Cards - Compact single row */}
|
||||||
<div className="flex items-center justify-between gap-1 mb-2 py-2 px-1 bg-muted/20 rounded-lg">
|
<div className="flex items-center justify-between gap-1 mb-2 py-2 px-1 bg-muted/20 rounded-lg">
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
<span className="text-[10px] text-muted-foreground">Current</span>
|
<span className="text-[10px] text-muted-foreground">{t("network.labels.current")}</span>
|
||||||
<span className="text-base font-bold" style={{ color: getStatusColor(displayStats.current || 0) }}>
|
<span className="text-base font-bold" style={{ color: getStatusColor(displayStats.current || 0) }}>
|
||||||
{displayStats.current || '-'}
|
{displayStats.current || '-'}
|
||||||
</span>
|
</span>
|
||||||
@@ -1000,19 +1070,19 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
<TrendingDown className="h-3 w-3 text-green-500 shrink-0" />
|
<TrendingDown className="h-3 w-3 text-green-500 shrink-0" />
|
||||||
<span className="text-[10px] text-muted-foreground">Min</span>
|
<span className="text-[10px] text-muted-foreground">{t("network.labels.min")}</span>
|
||||||
<span className="text-base font-bold text-green-500">{displayStats.min || '-'}</span>
|
<span className="text-base font-bold text-green-500">{displayStats.min || '-'}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">ms</span>
|
<span className="text-[10px] text-muted-foreground">ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
<Minus className="h-3 w-3 shrink-0" />
|
<Minus className="h-3 w-3 shrink-0" />
|
||||||
<span className="text-[10px] text-muted-foreground">Avg</span>
|
<span className="text-[10px] text-muted-foreground">{t("network.labels.avg")}</span>
|
||||||
<span className="text-base font-bold">{displayStats.avg || '-'}</span>
|
<span className="text-base font-bold">{displayStats.avg || '-'}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">ms</span>
|
<span className="text-[10px] text-muted-foreground">ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 min-w-0">
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
<TrendingUp className="h-3 w-3 text-red-500 shrink-0" />
|
<TrendingUp className="h-3 w-3 text-red-500 shrink-0" />
|
||||||
<span className="text-[10px] text-muted-foreground">Max</span>
|
<span className="text-[10px] text-muted-foreground">{t("network.labels.max")}</span>
|
||||||
<span className="text-base font-bold text-red-500">{displayStats.max || '-'}</span>
|
<span className="text-base font-bold text-red-500">{displayStats.max || '-'}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">ms</span>
|
<span className="text-[10px] text-muted-foreground">ms</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1025,8 +1095,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
</Badge>
|
</Badge>
|
||||||
{isRealtime && (
|
{isRealtime && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{realtimeResults.length} sample{realtimeResults.length !== 1 ? 's' : ''} collected
|
{t("network.latency.samplesCollected", { count: realtimeResults.length })}
|
||||||
{realtimeStats?.packetLoss ? ` | ${realtimeStats.packetLoss}% packet loss` : ''}
|
{realtimeStats?.packetLoss ? ` | ${t("network.latency.packetLossValue", { value: realtimeStats.packetLoss })}` : ""}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1058,7 +1128,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
domain={['dataMin - 1', 'dataMax + 2']}
|
domain={['dataMin - 1', 'dataMax + 2']}
|
||||||
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
|
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip t={t} />} />
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
@@ -1075,7 +1145,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||||
<Activity className="h-12 w-12 mb-3 opacity-30" />
|
<Activity className="h-12 w-12 mb-3 opacity-30" />
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{realtimeTesting ? 'Collecting data...' : 'No data yet. Click "Test Again" to start.'}
|
{realtimeTesting ? t("network.latency.collectingData") : t("network.latency.noRealtimeData")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1107,7 +1177,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
domain={['dataMin - 1', 'dataMax + 2']}
|
domain={['dataMin - 1', 'dataMax + 2']}
|
||||||
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
|
tickFormatter={(v) => `${Number(v).toFixed(1)}ms`}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomTooltip />} />
|
<Tooltip content={<CustomTooltip t={t} />} />
|
||||||
{/* For longer timeframes (6h+), show max values to preserve spikes.
|
{/* For longer timeframes (6h+), show max values to preserve spikes.
|
||||||
For 1 hour view, show avg values since there's no downsampling */}
|
For 1 hour view, show avg values since there's no downsampling */}
|
||||||
<Area
|
<Area
|
||||||
@@ -1123,8 +1193,8 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
) : (
|
) : (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||||
<Activity className="h-12 w-12 mb-3 opacity-30" />
|
<Activity className="h-12 w-12 mb-3 opacity-30" />
|
||||||
<p className="text-sm">No latency data available for this period</p>
|
<p className="text-sm">{t("network.latency.noDataForPeriod")}</p>
|
||||||
<p className="text-xs mt-1">Data is collected every 60 seconds</p>
|
<p className="text-xs mt-1">{t("network.latency.collectionInterval")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1133,8 +1203,7 @@ export function LatencyDetailModal({ open, onOpenChange, currentLatency }: Laten
|
|||||||
{isRealtime && (
|
{isRealtime && (
|
||||||
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
|
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
|
||||||
<p className="text-xs text-blue-400">
|
<p className="text-xs text-blue-400">
|
||||||
<strong>Real-time Mode:</strong> Tests run for 2 minutes with readings every 5 seconds.
|
<strong>{t("network.latency.realTimeMode")}:</strong> {t("network.latency.realTimeModeDescription")}
|
||||||
Click "Test Again" to add more samples. All data is included in the report.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Label } from "./ui/label"
|
|||||||
import { Checkbox } from "./ui/checkbox"
|
import { Checkbox } from "./ui/checkbox"
|
||||||
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
|
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
|
||||||
import { getApiUrl } from "../lib/api-config"
|
import { getApiUrl } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
|
||||||
interface LoginProps {
|
interface LoginProps {
|
||||||
@@ -16,6 +17,7 @@ interface LoginProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Login({ onLogin }: LoginProps) {
|
export function Login({ onLogin }: LoginProps) {
|
||||||
|
const t = useT()
|
||||||
const [username, setUsername] = useState("")
|
const [username, setUsername] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [totpCode, setTotpCode] = useState("")
|
const [totpCode, setTotpCode] = useState("")
|
||||||
@@ -56,12 +58,12 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
setError("Please enter username and password")
|
setError(t("login.missingCredentials"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requiresTotp && !totpCode) {
|
if (requiresTotp && !totpCode) {
|
||||||
setError("Please enter your 2FA code")
|
setError(t("login.missingTotp"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,14 +82,20 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (data.requires_totp) {
|
if (response.ok && data.requires_totp) {
|
||||||
setRequiresTotp(true)
|
setRequiresTotp(true)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.message || "Login failed")
|
if (response.status === 429) {
|
||||||
|
throw new Error(t("login.tooManyAttempts"))
|
||||||
|
}
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error(data.requires_totp ? t("login.invalidTotp") : t("login.invalidCredentials"))
|
||||||
|
}
|
||||||
|
throw new Error(t("login.loginFailed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem("proxmenux-auth-token", data.token)
|
localStorage.setItem("proxmenux-auth-token", data.token)
|
||||||
@@ -107,7 +115,7 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
|
|
||||||
onLogin()
|
onLogin()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Login failed")
|
setError(err instanceof Error ? err.message : t("login.loginFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -139,8 +147,8 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">ProxMenux Monitor</h1>
|
<h1 className="text-3xl font-bold">{t("app.title")}</h1>
|
||||||
<p className="text-muted-foreground mt-2">Sign in to access your dashboard</p>
|
<p className="text-muted-foreground mt-2">{t("login.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -157,14 +165,14 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="login-username" className="text-sm">
|
<Label htmlFor="login-username" className="text-sm">
|
||||||
Username
|
{t("login.username")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="login-username"
|
id="login-username"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter your username"
|
placeholder={t("login.usernamePlaceholder")}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className="pl-10 text-base"
|
className="pl-10 text-base"
|
||||||
@@ -176,14 +184,14 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="login-password" className="text-sm">
|
<Label htmlFor="login-password" className="text-sm">
|
||||||
Password
|
{t("login.password")}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="login-password"
|
id="login-password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder="Enter your password"
|
placeholder={t("login.passwordPlaceholder")}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="pl-10 pr-10 text-base"
|
className="pl-10 pr-10 text-base"
|
||||||
@@ -214,7 +222,7 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
|
<Label htmlFor="remember-me" className="text-sm font-normal cursor-pointer select-none">
|
||||||
Remember me
|
{t("login.rememberMe")}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -223,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">
|
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 flex items-start gap-2">
|
||||||
<Shield className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
<Shield className="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-blue-500">Two-Factor Authentication</p>
|
<p className="text-sm font-medium text-blue-500">{t("login.twoFactorTitle")}</p>
|
||||||
<p className="text-xs text-blue-500 mt-1">Enter the 6-digit code from your authentication app</p>
|
<p className="text-xs text-blue-500 mt-1">{t("login.twoFactorDescription")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="totp-code" className="text-sm">
|
<Label htmlFor="totp-code" className="text-sm">
|
||||||
Authentication Code
|
{t("login.authenticationCode")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="totp-code"
|
id="totp-code"
|
||||||
@@ -245,7 +253,7 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
You can also use a backup code (format: XXXX-XXXX)
|
{t("login.backupCodeHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -260,18 +268,18 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Back to login
|
{t("login.backToLogin")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
<Button type="submit" className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||||
{loading ? "Signing in..." : requiresTotp ? "Verify Code" : "Sign In"}
|
{loading ? t("login.signingIn") : requiresTotp ? t("login.verifyCode") : t("login.signIn")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.2</p>
|
<p className="text-center text-sm text-muted-foreground">{t("login.version")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useState, useEffect, useRef, useCallback } from "react"
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react"
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +37,7 @@ import { Dialog as SearchDialog, DialogContent as SearchDialogContent, DialogTit
|
|||||||
import "xterm/css/xterm.css"
|
import "xterm/css/xterm.css"
|
||||||
import { API_PORT, fetchApi } from "@/lib/api-config"
|
import { API_PORT, fetchApi } from "@/lib/api-config"
|
||||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
interface LxcTerminalModalProps {
|
interface LxcTerminalModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -51,33 +52,35 @@ interface CheatSheetResult {
|
|||||||
examples: string[]
|
examples: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxmoxCommands = [
|
const LXC_COMMANDS = [
|
||||||
{ cmd: "ls -la", desc: "List all files with details" },
|
{ cmd: "ls -la", descKey: "listFiles" },
|
||||||
{ cmd: "cd /path/to/dir", desc: "Change directory" },
|
{ cmd: "cd /path/to/dir", descKey: "changeDirectory" },
|
||||||
{ cmd: "cat filename", desc: "Display file contents" },
|
{ cmd: "cat filename", descKey: "displayFile" },
|
||||||
{ cmd: "grep 'pattern' file", desc: "Search for pattern in file" },
|
{ cmd: "grep 'pattern' file", descKey: "searchPattern" },
|
||||||
{ cmd: "find . -name 'file'", desc: "Find files by name" },
|
{ cmd: "find . -name 'file'", descKey: "findFiles" },
|
||||||
{ cmd: "df -h", desc: "Show disk usage" },
|
{ cmd: "df -h", descKey: "diskUsage" },
|
||||||
{ cmd: "du -sh *", desc: "Show directory sizes" },
|
{ cmd: "du -sh *", descKey: "directorySizes" },
|
||||||
{ cmd: "free -h", desc: "Show memory usage" },
|
{ cmd: "free -h", descKey: "memoryUsage" },
|
||||||
{ cmd: "top", desc: "Show running processes" },
|
{ cmd: "top", descKey: "runningProcesses" },
|
||||||
{ cmd: "ps aux | grep process", desc: "Find running process" },
|
{ cmd: "ps aux | grep process", descKey: "findProcess" },
|
||||||
{ cmd: "systemctl status service", desc: "Check service status" },
|
{ cmd: "systemctl status service", descKey: "serviceStatus" },
|
||||||
{ cmd: "systemctl restart service", desc: "Restart a service" },
|
{ cmd: "systemctl restart service", descKey: "restartService" },
|
||||||
{ cmd: "apt update && apt upgrade", desc: "Update packages" },
|
{ cmd: "apt update && apt upgrade", descKey: "updatePackages" },
|
||||||
{ cmd: "apt install package", desc: "Install package" },
|
{ cmd: "apt install package", descKey: "installPackage" },
|
||||||
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file" },
|
{ cmd: "tail -f /var/log/syslog", descKey: "followLog" },
|
||||||
{ cmd: "chmod 755 file", desc: "Change file permissions" },
|
{ cmd: "chmod 755 file", descKey: "changePermissions" },
|
||||||
{ cmd: "chown user:group file", desc: "Change file owner" },
|
{ cmd: "chown user:group file", descKey: "changeOwner" },
|
||||||
{ cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" },
|
{ cmd: "tar -xzf file.tar.gz", descKey: "extractArchive" },
|
||||||
{ cmd: "docker ps", desc: "List running containers" },
|
{ cmd: "docker ps", descKey: "listContainers" },
|
||||||
{ cmd: "docker images", desc: "List Docker images" },
|
{ cmd: "docker images", descKey: "listImages" },
|
||||||
{ cmd: "ip addr show", desc: "Show IP addresses" },
|
{ cmd: "ip addr show", descKey: "showIpAddresses" },
|
||||||
{ cmd: "ping host", desc: "Test network connectivity" },
|
{ cmd: "ping host", descKey: "testConnectivity" },
|
||||||
{ cmd: "curl -I url", desc: "Get HTTP headers" },
|
{ cmd: "curl -I url", descKey: "httpHeaders" },
|
||||||
{ cmd: "history", desc: "Show command history" },
|
{ cmd: "history", descKey: "commandHistory" },
|
||||||
{ cmd: "clear", desc: "Clear terminal screen" },
|
{ cmd: "clear", descKey: "clearScreen" },
|
||||||
]
|
] as const
|
||||||
|
|
||||||
|
type LocalCommand = { cmd: string; desc: string }
|
||||||
|
|
||||||
function getWebSocketUrl(): string {
|
function getWebSocketUrl(): string {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
@@ -101,6 +104,7 @@ export function LxcTerminalModal({
|
|||||||
vmid,
|
vmid,
|
||||||
vmName,
|
vmName,
|
||||||
}: LxcTerminalModalProps) {
|
}: LxcTerminalModalProps) {
|
||||||
|
const t = useT()
|
||||||
const termRef = useRef<any>(null)
|
const termRef = useRef<any>(null)
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const fitAddonRef = useRef<any>(null)
|
const fitAddonRef = useRef<any>(null)
|
||||||
@@ -121,12 +125,18 @@ export function LxcTerminalModal({
|
|||||||
// Search state
|
// Search state
|
||||||
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands)
|
const localCommands = useMemo<LocalCommand[]>(
|
||||||
|
() => LXC_COMMANDS.map((item) => ({ cmd: item.cmd, desc: t(`lxcTerminal.commands.${item.descKey}`) })),
|
||||||
|
[t],
|
||||||
|
)
|
||||||
|
const [filteredCommands, setFilteredCommands] = useState<LocalCommand[]>([])
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
||||||
const [useOnline, setUseOnline] = useState(true)
|
const [useOnline, setUseOnline] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFilteredCommands(localCommands)
|
||||||
|
}, [localCommands])
|
||||||
|
|
||||||
// Detect mobile/tablet
|
// Detect mobile/tablet
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -278,7 +288,7 @@ export function LxcTerminalModal({
|
|||||||
// through Number without losing fidelity.
|
// through Number without losing fidelity.
|
||||||
const id = Number(vmid)
|
const id = Number(vmid)
|
||||||
if (!Number.isInteger(id) || id <= 0 || id >= 1_000_000) {
|
if (!Number.isInteger(id) || id <= 0 || id >= 1_000_000) {
|
||||||
term.writeln('\r\n\x1b[31m[ERROR] Invalid VMID — refusing to execute pct enter\x1b[0m')
|
term.writeln(`\r\n\x1b[31m[ERROR] ${t("lxcTerminal.errors.invalidVmid")}\x1b[0m`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ws.send(`pct enter ${id}\r`)
|
ws.send(`pct enter ${id}\r`)
|
||||||
@@ -287,7 +297,7 @@ export function LxcTerminalModal({
|
|||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
setConnectionStatus("offline")
|
setConnectionStatus("offline")
|
||||||
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
|
term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.websocketError")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
@@ -295,7 +305,7 @@ export function LxcTerminalModal({
|
|||||||
if (pingIntervalRef.current) {
|
if (pingIntervalRef.current) {
|
||||||
clearInterval(pingIntervalRef.current)
|
clearInterval(pingIntervalRef.current)
|
||||||
}
|
}
|
||||||
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
|
term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
|
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
@@ -395,7 +405,7 @@ export function LxcTerminalModal({
|
|||||||
termRef.current.dispose()
|
termRef.current.dispose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen, vmid])
|
}, [isOpen, vmid, t])
|
||||||
|
|
||||||
// Resize handling
|
// Resize handling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -478,7 +488,7 @@ export function LxcTerminalModal({
|
|||||||
const searchCheatSh = async (query: string) => {
|
const searchCheatSh = async (query: string) => {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
setFilteredCommands(proxmoxCommands)
|
setFilteredCommands(localCommands)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +501,7 @@ export function LxcTerminalModal({
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!data.success || !data.examples || data.examples.length === 0) {
|
if (!data.success || !data.examples || data.examples.length === 0) {
|
||||||
throw new Error("No examples found")
|
throw new Error(t("terminal.noExamplesFound"))
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
|
const formattedResults: CheatSheetResult[] = data.examples.map((example: any) => ({
|
||||||
@@ -503,7 +513,7 @@ export function LxcTerminalModal({
|
|||||||
setUseOnline(true)
|
setUseOnline(true)
|
||||||
setSearchResults(formattedResults)
|
setSearchResults(formattedResults)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const filtered = proxmoxCommands.filter(
|
const filtered = localCommands.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
item.desc.toLowerCase().includes(query.toLowerCase()),
|
item.desc.toLowerCase().includes(query.toLowerCase()),
|
||||||
@@ -521,12 +531,12 @@ export function LxcTerminalModal({
|
|||||||
searchCheatSh(searchQuery)
|
searchCheatSh(searchQuery)
|
||||||
} else {
|
} else {
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
setFilteredCommands(proxmoxCommands)
|
setFilteredCommands(localCommands)
|
||||||
}
|
}
|
||||||
}, 800)
|
}, 800)
|
||||||
|
|
||||||
return () => clearTimeout(debounce)
|
return () => clearTimeout(debounce)
|
||||||
}, [searchQuery])
|
}, [searchQuery, localCommands, t])
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
if (termRef.current) {
|
if (termRef.current) {
|
||||||
@@ -565,7 +575,7 @@ export function LxcTerminalModal({
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800">
|
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-800">
|
||||||
<DialogTitle className="text-sm font-medium text-white">
|
<DialogTitle className="text-sm font-medium text-white">
|
||||||
Terminal: {vmName} (ID: {vmid})
|
{t("lxcTerminal.title", { name: vmName, id: vmid })}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -576,7 +586,7 @@ export function LxcTerminalModal({
|
|||||||
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
|
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Search</span>
|
<span className="hidden sm:inline">{t("terminal.search")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
@@ -586,7 +596,7 @@ export function LxcTerminalModal({
|
|||||||
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
|
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Clear</span>
|
<span className="hidden sm:inline">{t("terminal.clear")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -673,29 +683,29 @@ export function LxcTerminalModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onSelect={() => sendKey("\x03")}>
|
<DropdownMenuItem onSelect={() => sendKey("\x03")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||||
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
|
<DropdownMenuItem onSelect={() => sendKey("\x18")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||||
<span className="text-muted-foreground text-xs">Exit (nano)</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => sendKey("\x12")}>
|
<DropdownMenuItem onSelect={() => sendKey("\x12")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
||||||
<span className="text-muted-foreground text-xs">Search history</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
||||||
<Copy className="h-3.5 w-3.5 mr-2" />
|
<Copy className="h-3.5 w-3.5 mr-2" />
|
||||||
<span className="text-xs">Copy selection</span>
|
<span className="text-xs">{t("scriptTerminal.copySelection")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
||||||
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
||||||
<span className="text-xs">Paste</span>
|
<span className="text-xs">{t("scriptTerminal.paste")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -716,7 +726,7 @@ export function LxcTerminalModal({
|
|||||||
: "bg-red-500"
|
: "bg-red-500"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-zinc-400 capitalize">{connectionStatus}</span>
|
<span className="text-xs text-zinc-400">{t(`scriptTerminal.${connectionStatus}`)}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -725,7 +735,7 @@ export function LxcTerminalModal({
|
|||||||
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Close</span>
|
<span className="hidden sm:inline">{t("actions.close")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -734,22 +744,22 @@ export function LxcTerminalModal({
|
|||||||
<SearchDialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
<SearchDialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
||||||
<SearchDialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
<SearchDialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
|
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
|
||||||
<SearchDialogTitle className="text-xl font-semibold">Search Commands</SearchDialogTitle>
|
<SearchDialogTitle className="text-xl font-semibold">{t("terminal.searchCommands")}</SearchDialogTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
|
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
|
||||||
title={useOnline ? "Online - Using cheat.sh API" : "Offline - Using local commands"}
|
title={useOnline ? t("terminal.onlineSource") : t("terminal.offlineSource")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogDescription className="sr-only">Search for Linux commands</DialogDescription>
|
<DialogDescription className="sr-only">{t("terminal.searchDescription")}</DialogDescription>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search commands... (e.g., tar, docker, systemctl)"
|
placeholder={t("terminal.searchPlaceholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
|
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
|
||||||
@@ -763,7 +773,7 @@ export function LxcTerminalModal({
|
|||||||
{isSearching && (
|
{isSearching && (
|
||||||
<div className="text-center py-4 text-zinc-400">
|
<div className="text-center py-4 text-zinc-400">
|
||||||
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
|
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
|
||||||
<p className="text-sm">Searching cheat.sh...</p>
|
<p className="text-sm">{t("terminal.searchingCheatSh")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -790,7 +800,7 @@ export function LxcTerminalModal({
|
|||||||
<div className="text-center py-2">
|
<div className="text-center py-2">
|
||||||
<p className="text-xs text-zinc-500">
|
<p className="text-xs text-zinc-500">
|
||||||
<Lightbulb className="inline-block w-3 h-3 mr-1" />
|
<Lightbulb className="inline-block w-3 h-3 mr-1" />
|
||||||
Powered by cheat.sh
|
{t("terminal.poweredByCheatSh")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -816,13 +826,13 @@ export function LxcTerminalModal({
|
|||||||
className="shrink-0 h-7 px-2 text-xs"
|
className="shrink-0 h-7 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<Send className="h-3 w-3 mr-1" />
|
<Send className="h-3 w-3 mr-1" />
|
||||||
Send
|
{t("terminal.send")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : !isSearching && !searchQuery && !useOnline ? (
|
) : !isSearching && !searchQuery && !useOnline ? (
|
||||||
proxmoxCommands.map((item, index) => (
|
localCommands.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => sendToTerminal(item.cmd)}
|
onClick={() => sendToTerminal(item.cmd)}
|
||||||
@@ -843,7 +853,7 @@ export function LxcTerminalModal({
|
|||||||
className="shrink-0 h-7 px-2 text-xs"
|
className="shrink-0 h-7 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<Send className="h-3 w-3 mr-1" />
|
<Send className="h-3 w-3 mr-1" />
|
||||||
Send
|
{t("terminal.send")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -854,17 +864,17 @@ export function LxcTerminalModal({
|
|||||||
<>
|
<>
|
||||||
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
|
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-zinc-400 font-medium">{"No results found for \""}{searchQuery}{"\""}</p>
|
<p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
|
||||||
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
|
<p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-zinc-400 font-medium mb-2">Search for any command</p>
|
<p className="text-zinc-400 font-medium mb-2">{t("terminal.searchAnyCommand")}</p>
|
||||||
<div className="text-sm text-zinc-500 space-y-1">
|
<div className="text-sm text-zinc-500 space-y-1">
|
||||||
<p>Try searching for:</p>
|
<p>{t("terminal.trySearchingFor")}</p>
|
||||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||||
{["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => (
|
{["tar", "grep", "docker", "systemctl", "curl"].map((cmd) => (
|
||||||
<code
|
<code
|
||||||
@@ -881,7 +891,7 @@ export function LxcTerminalModal({
|
|||||||
{useOnline && (
|
{useOnline && (
|
||||||
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
|
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
|
||||||
<Lightbulb className="w-3 h-3" />
|
<Lightbulb className="w-3 h-3" />
|
||||||
<span>Powered by cheat.sh</span>
|
<span>{t("terminal.poweredByCheatSh")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -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="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Lightbulb className="w-3 h-3" />
|
<Lightbulb className="w-3 h-3" />
|
||||||
<span>Tip: Search for any Linux command</span>
|
<span>{t("terminal.searchTip")}</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</SearchDialogContent>
|
</SearchDialogContent>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Boxes, Info, Loader2, Settings2, CheckCircle2 } from "lucide-react"
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||||
import { Badge } from "./ui/badge"
|
import { Badge } from "./ui/badge"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface DetectionResponse {
|
interface DetectionResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -14,6 +15,7 @@ interface DetectionResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LxcUpdateDetection() {
|
export function LxcUpdateDetection() {
|
||||||
|
const t = useT()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [enabled, setEnabled] = useState<boolean>(true)
|
const [enabled, setEnabled] = useState<boolean>(true)
|
||||||
@@ -32,11 +34,11 @@ export function LxcUpdateDetection() {
|
|||||||
setEnabled(data.enabled)
|
setEnabled(data.enabled)
|
||||||
setPending(data.enabled)
|
setPending(data.enabled)
|
||||||
} else {
|
} else {
|
||||||
setError(data.message || "Failed to load setting")
|
setError(data.message || t("settings.lxcUpdateDetection.loadFailed"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
if (!cancelled) setError(String(e))
|
if (!cancelled) setError(t("settings.lxcUpdateDetection.loadFailed"))
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setLoading(false)
|
if (!cancelled) setLoading(false)
|
||||||
@@ -77,7 +79,7 @@ export function LxcUpdateDetection() {
|
|||||||
body: JSON.stringify({ enabled: pending }),
|
body: JSON.stringify({ enabled: pending }),
|
||||||
})
|
})
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
setError(data.message || "Failed to save setting")
|
setError(data.message || t("settings.lxcUpdateDetection.saveFailed"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setEnabled(pending)
|
setEnabled(pending)
|
||||||
@@ -95,7 +97,7 @@ export function LxcUpdateDetection() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e))
|
setError(t("settings.lxcUpdateDetection.saveFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -111,14 +113,14 @@ export function LxcUpdateDetection() {
|
|||||||
breakpoint thanks to `items-center` + leading-tight title. */}
|
breakpoint thanks to `items-center` + leading-tight title. */}
|
||||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||||
<Boxes className="h-5 w-5 text-purple-500 shrink-0" />
|
<Boxes className="h-5 w-5 text-purple-500 shrink-0" />
|
||||||
<CardTitle className="leading-tight">LXC Update Detection</CardTitle>
|
<CardTitle className="leading-tight">{t("settings.lxcUpdateDetection.title")}</CardTitle>
|
||||||
{enabled ? (
|
{enabled ? (
|
||||||
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
|
<Badge variant="outline" className="text-[10px] border-green-500/30 text-green-500">
|
||||||
Active
|
{t("status.active")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground">
|
<Badge variant="outline" className="text-[10px] border-muted-foreground/30 text-muted-foreground">
|
||||||
Disabled
|
{t("status.disabled")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +128,7 @@ export function LxcUpdateDetection() {
|
|||||||
{saved && (
|
{saved && (
|
||||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
Saved
|
{t("status.saved")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{error && !editMode && (
|
{error && !editMode && (
|
||||||
@@ -134,7 +136,7 @@ export function LxcUpdateDetection() {
|
|||||||
className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate"
|
className="flex items-center gap-1 text-xs text-red-500 max-w-[40ch] truncate"
|
||||||
title={error}
|
title={error}
|
||||||
>
|
>
|
||||||
Save failed: {error}
|
{t("status.saveFailed")}: {error}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{editMode ? (
|
{editMode ? (
|
||||||
@@ -144,7 +146,7 @@ export function LxcUpdateDetection() {
|
|||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||||
@@ -152,7 +154,7 @@ export function LxcUpdateDetection() {
|
|||||||
disabled={saving || !hasChanges}
|
disabled={saving || !hasChanges}
|
||||||
>
|
>
|
||||||
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||||
Save
|
{t("actions.save")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -162,16 +164,15 @@ export function LxcUpdateDetection() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<Settings2 className="h-3 w-3" />
|
<Settings2 className="h-3 w-3" />
|
||||||
Edit
|
{t("actions.edit")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Periodically check running Debian/Ubuntu/Alpine LXC containers for pending package updates
|
{t("settings.lxcUpdateDetection.descriptionStart")}{" "}
|
||||||
(<code>apt list --upgradable</code> / <code>apk list -u</code>) and surface them on the dashboard. The
|
(<code>apt list --upgradable</code> / <code>apk list -u</code>)
|
||||||
corresponding notification toggle in <strong>Notifications → Services</strong> appears only while detection
|
{t("settings.lxcUpdateDetection.descriptionEnd")}
|
||||||
is enabled.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
@@ -185,7 +186,7 @@ export function LxcUpdateDetection() {
|
|||||||
<Boxes
|
<Boxes
|
||||||
className={`h-4 w-4 shrink-0 ${pending ? "text-purple-500" : "text-muted-foreground"}`}
|
className={`h-4 w-4 shrink-0 ${pending ? "text-purple-500" : "text-muted-foreground"}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium truncate">Enable LXC update detection</span>
|
<span className="text-sm font-medium truncate">{t("settings.lxcUpdateDetection.enableLabel")}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
|
className={`relative w-10 h-5 rounded-full transition-colors shrink-0 ${
|
||||||
@@ -195,7 +196,7 @@ export function LxcUpdateDetection() {
|
|||||||
disabled={!editMode || saving}
|
disabled={!editMode || saving}
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={pending}
|
aria-checked={pending}
|
||||||
aria-label="Enable LXC update detection"
|
aria-label={t("settings.lxcUpdateDetection.enableLabel")}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
className={`absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||||
@@ -209,8 +210,7 @@ export function LxcUpdateDetection() {
|
|||||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 border border-border">
|
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/50 border border-border">
|
||||||
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
||||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||||
{lastPurged} LXC entries removed from the registry. Re-enabling detection will repopulate them on the
|
{t("settings.lxcUpdateDetection.purgedMessage", { count: lastPurged })}
|
||||||
next scan cycle.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { ArrowLeft, Loader2 } from "lucide-react"
|
import { ArrowLeft, Loader2 } from "lucide-react"
|
||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
|
import { useI18n } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
interface MetricsViewProps {
|
interface MetricsViewProps {
|
||||||
vmid: number
|
vmid: number
|
||||||
@@ -15,12 +16,12 @@ interface MetricsViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TIMEFRAME_OPTIONS = [
|
const TIMEFRAME_OPTIONS = [
|
||||||
{ value: "hour", label: "1 Hour" },
|
{ value: "hour", labelKey: "vmMetrics.timeframes.hour" },
|
||||||
{ value: "day", label: "24 Hours" },
|
{ value: "day", labelKey: "vmMetrics.timeframes.day" },
|
||||||
{ value: "week", label: "7 Days" },
|
{ value: "week", labelKey: "vmMetrics.timeframes.week" },
|
||||||
{ value: "month", label: "30 Days" },
|
{ value: "month", labelKey: "vmMetrics.timeframes.month" },
|
||||||
{ value: "year", label: "1 Year" },
|
{ value: "year", labelKey: "vmMetrics.timeframes.year" },
|
||||||
]
|
] as const
|
||||||
|
|
||||||
const CustomCPUTooltip = ({ active, payload, label }: any) => {
|
const CustomCPUTooltip = ({ active, payload, label }: any) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
@@ -103,6 +104,7 @@ const CustomNetworkTooltip = ({ active, payload, label }: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps) {
|
export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps) {
|
||||||
|
const { language, t } = useI18n()
|
||||||
const [timeframe, setTimeframe] = useState("week")
|
const [timeframe, setTimeframe] = useState("week")
|
||||||
const [data, setData] = useState<any[]>([])
|
const [data, setData] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -112,7 +114,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMetrics()
|
fetchMetrics()
|
||||||
}, [vmid, timeframe])
|
}, [vmid, timeframe, language])
|
||||||
|
|
||||||
const fetchMetrics = async () => {
|
const fetchMetrics = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -126,19 +128,19 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
let timeLabel = ""
|
let timeLabel = ""
|
||||||
|
|
||||||
if (timeframe === "hour") {
|
if (timeframe === "hour") {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
})
|
})
|
||||||
} else if (timeframe === "day") {
|
} else if (timeframe === "day") {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
})
|
})
|
||||||
} else if (timeframe === "week") {
|
} else if (timeframe === "week") {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@@ -146,12 +148,12 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
hour12: false,
|
hour12: false,
|
||||||
})
|
})
|
||||||
} else if (timeframe === "month") {
|
} else if (timeframe === "month") {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
month: "short",
|
month: "short",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
})
|
})
|
||||||
@@ -173,7 +175,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
|
|
||||||
setData(transformedData)
|
setData(transformedData)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "Error loading metrics")
|
setError(err.message || t("vmMetrics.errors.loading"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -203,7 +205,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[400px]">
|
<div className="flex items-center justify-center h-[400px]">
|
||||||
<p className="text-muted-foreground">No data available</p>
|
<p className="text-muted-foreground">{t("vmMetrics.noData")}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -214,7 +216,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* CPU Chart */}
|
{/* CPU Chart */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">CPU Usage</h3>
|
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.cpu")}</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||||
@@ -244,7 +246,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#3b82f6"
|
fill="#3b82f6"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="CPU %"
|
name={t("vmMetrics.series.cpu")}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -252,7 +254,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
|
|
||||||
{/* Memory Chart */}
|
{/* Memory Chart */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">Memory Usage</h3>
|
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.memory")}</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||||
@@ -282,7 +284,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
fill="#10b981"
|
fill="#10b981"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Memory GB"
|
name={t("vmMetrics.series.memoryGb")}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -290,7 +292,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
|
|
||||||
{/* Disk I/O Chart */}
|
{/* Disk I/O Chart */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">Disk I/O</h3>
|
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.diskIo")}</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||||
@@ -321,7 +323,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
fill="#10b981"
|
fill="#10b981"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Read"
|
name={t("vmMetrics.series.read")}
|
||||||
hide={hiddenDiskLines.includes("diskread")}
|
hide={hiddenDiskLines.includes("diskread")}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
@@ -331,7 +333,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
fill="#3b82f6"
|
fill="#3b82f6"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Write"
|
name={t("vmMetrics.series.write")}
|
||||||
hide={hiddenDiskLines.includes("diskwrite")}
|
hide={hiddenDiskLines.includes("diskwrite")}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
@@ -340,7 +342,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
|
|
||||||
{/* Network I/O Chart */}
|
{/* Network I/O Chart */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">Network I/O</h3>
|
<h3 className="text-lg font-semibold mb-4">{t("vmMetrics.charts.networkIo")}</h3>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||||
@@ -371,7 +373,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
fill="#10b981"
|
fill="#10b981"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Download"
|
name={t("vmMetrics.series.download")}
|
||||||
hide={hiddenNetworkLines.includes("netin")}
|
hide={hiddenNetworkLines.includes("netin")}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
@@ -381,7 +383,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
fill="#3b82f6"
|
fill="#3b82f6"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
name="Upload"
|
name={t("vmMetrics.series.upload")}
|
||||||
hide={hiddenNetworkLines.includes("netout")}
|
hide={hiddenNetworkLines.includes("netout")}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
@@ -461,9 +463,9 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">Metrics - {vmName}</h2>
|
<h2 className="text-xl font-semibold">{t("vmMetrics.title", { name: vmName })}</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
VMID: {vmid} • Type: {vmType.toUpperCase()}
|
VMID: {vmid} • {t("vmMetrics.type")}: {vmType.toUpperCase()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -474,7 +476,7 @@ export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps)
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TIMEFRAME_OPTIONS.map((option) => (
|
{TIMEFRAME_OPTIONS.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{t(option.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { Wifi, Zap } from 'lucide-react'
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
|
type TFunction = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
interface NetworkCardProps {
|
interface NetworkCardProps {
|
||||||
interface_: {
|
interface_: {
|
||||||
@@ -32,43 +35,51 @@ interface NetworkCardProps {
|
|||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const getInterfaceTypeBadge = (type: string) => {
|
const getInterfaceTypeBadge = (type: string, t: TFunction) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "physical":
|
case "physical":
|
||||||
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: "Physical" }
|
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: t("network.interfaceTypes.physical") }
|
||||||
case "bridge":
|
case "bridge":
|
||||||
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: "Bridge" }
|
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: t("network.interfaceTypes.bridge") }
|
||||||
case "bond":
|
case "bond":
|
||||||
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "Bond" }
|
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: t("network.interfaceTypes.bond") }
|
||||||
case "vlan":
|
case "vlan":
|
||||||
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "VLAN" }
|
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: t("network.interfaceTypes.vlan") }
|
||||||
case "vm_lxc":
|
case "vm_lxc":
|
||||||
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
|
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
|
||||||
case "virtual":
|
case "virtual":
|
||||||
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
|
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: t("network.interfaceTypes.virtual") }
|
||||||
default:
|
default:
|
||||||
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
|
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getVMTypeBadge = (vmType: string | undefined) => {
|
const getVMTypeBadge = (vmType: string | undefined, t: TFunction) => {
|
||||||
if (vmType === "lxc") {
|
if (vmType === "lxc") {
|
||||||
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
|
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
|
||||||
} else if (vmType === "vm") {
|
} else if (vmType === "vm") {
|
||||||
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" }
|
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" }
|
||||||
}
|
}
|
||||||
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
|
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: t("common.unknown") }
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatSpeed = (speed: number): string => {
|
const formatInterfaceStatus = (status: string | undefined, t: TFunction): string => {
|
||||||
if (speed === 0) return "N/A"
|
const normalized = (status || "").toLowerCase()
|
||||||
|
if (normalized === "up") return t("network.status.up")
|
||||||
|
if (normalized === "down") return t("network.status.down")
|
||||||
|
return status || t("common.unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSpeed = (speed: number, unavailable = "N/A"): string => {
|
||||||
|
if (speed === 0) return unavailable
|
||||||
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
|
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
|
||||||
return `${speed} Mbps`
|
return `${speed} Mbps`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps) {
|
export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps) {
|
||||||
const typeBadge = getInterfaceTypeBadge(interface_.type)
|
const t = useT()
|
||||||
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type) : null
|
const typeBadge = getInterfaceTypeBadge(interface_.type, t)
|
||||||
|
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type, t) : null
|
||||||
|
|
||||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(getNetworkUnit())
|
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(getNetworkUnit())
|
||||||
|
|
||||||
@@ -125,17 +136,17 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
|
|||||||
const getTimeframeLabel = () => {
|
const getTimeframeLabel = () => {
|
||||||
switch (timeframe) {
|
switch (timeframe) {
|
||||||
case "hour":
|
case "hour":
|
||||||
return "Last Hour"
|
return t("network.timeframes.last.hour")
|
||||||
case "day":
|
case "day":
|
||||||
return "Last 24 Hours"
|
return t("network.timeframes.last.day")
|
||||||
case "week":
|
case "week":
|
||||||
return "Last 7 Days"
|
return t("network.timeframes.last.week")
|
||||||
case "month":
|
case "month":
|
||||||
return "Last 30 Days"
|
return t("network.timeframes.last.month")
|
||||||
case "year":
|
case "year":
|
||||||
return "Last Year"
|
return t("network.timeframes.last.year")
|
||||||
default:
|
default:
|
||||||
return "Last 24 Hours"
|
return t("network.timeframes.last.day")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +185,7 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
|
|||||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{interface_.status.toUpperCase()}
|
{formatInterfaceStatus(interface_.status, t)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -182,22 +193,22 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
{interface_.type === "vm_lxc" ? "VMID" : "IP Address"}
|
{interface_.type === "vm_lxc" ? "VMID" : t("network.labels.ipAddress")}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-medium text-foreground font-mono text-sm truncate">
|
<div className="font-medium text-foreground font-mono text-sm truncate">
|
||||||
{interface_.type === "vm_lxc"
|
{interface_.type === "vm_lxc"
|
||||||
? (interface_.vmid ?? "N/A")
|
? (interface_.vmid ?? t("common.notAvailable"))
|
||||||
: interface_.addresses.length > 0
|
: interface_.addresses.length > 0
|
||||||
? interface_.addresses[0].ip
|
? interface_.addresses[0].ip
|
||||||
: "N/A"}
|
: t("common.notAvailable")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-muted-foreground text-xs">Speed</div>
|
<div className="text-muted-foreground text-xs">{t("network.labels.speed")}</div>
|
||||||
<div className="font-medium text-foreground flex items-center gap-1 text-xs">
|
<div className="font-medium text-foreground flex items-center gap-1 text-xs">
|
||||||
<Zap className="h-3 w-3" />
|
<Zap className="h-3 w-3" />
|
||||||
{formatSpeed(interface_.speed)}
|
{formatSpeed(interface_.speed, t("common.notAvailable"))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
|||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
import { getNetworkUnit } from "../lib/format-network"
|
import { getNetworkUnit } from "../lib/format-network"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface NetworkMetricsData {
|
interface NetworkMetricsData {
|
||||||
time: string
|
time: string
|
||||||
@@ -50,6 +51,7 @@ export function NetworkTrafficChart({
|
|||||||
refreshInterval = 60000,
|
refreshInterval = 60000,
|
||||||
networkUnit: networkUnitProp, // Rename prop to avoid conflict
|
networkUnit: networkUnitProp, // Rename prop to avoid conflict
|
||||||
}: NetworkTrafficChartProps) {
|
}: NetworkTrafficChartProps) {
|
||||||
|
const t = useT()
|
||||||
const [data, setData] = useState<NetworkMetricsData[]>([])
|
const [data, setData] = useState<NetworkMetricsData[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -114,7 +116,7 @@ export function NetworkTrafficChart({
|
|||||||
const result = await fetchApi<any>(apiPath)
|
const result = await fetchApi<any>(apiPath)
|
||||||
|
|
||||||
if (!result.data || !Array.isArray(result.data)) {
|
if (!result.data || !Array.isArray(result.data)) {
|
||||||
throw new Error("Invalid data format received from server")
|
throw new Error(t("network.chart.invalidDataFormat"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.data.length === 0) {
|
if (result.data.length === 0) {
|
||||||
@@ -207,7 +209,7 @@ export function NetworkTrafficChart({
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Error fetching network metrics:", err)
|
console.error("Error fetching network metrics:", err)
|
||||||
setError(err.message || "Error loading metrics")
|
setError(err.message || t("network.chart.loadError"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -255,7 +257,7 @@ export function NetworkTrafficChart({
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-[300px] gap-2">
|
<div className="flex flex-col items-center justify-center h-[300px] gap-2">
|
||||||
<p className="text-muted-foreground text-sm">Network metrics not available yet</p>
|
<p className="text-muted-foreground text-sm">{t("overview.networkMetricsUnavailable")}</p>
|
||||||
<p className="text-xs text-red-500">{error}</p>
|
<p className="text-xs text-red-500">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -264,7 +266,7 @@ export function NetworkTrafficChart({
|
|||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-[300px]">
|
<div className="flex items-center justify-center h-[300px]">
|
||||||
<p className="text-muted-foreground text-sm">No network metrics available</p>
|
<p className="text-muted-foreground text-sm">{t("overview.noNetworkMetrics")}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -295,7 +297,7 @@ export function NetworkTrafficChart({
|
|||||||
}}
|
}}
|
||||||
domain={[0, "auto"]}
|
domain={[0, "auto"]}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} /> // Pass networkUnit to tooltip
|
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} />
|
||||||
<Legend verticalAlign="top" height={36} content={renderLegend} />
|
<Legend verticalAlign="top" height={36} content={renderLegend} />
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
@@ -304,7 +306,7 @@ export function NetworkTrafficChart({
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#10b981"
|
fill="#10b981"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="Received"
|
name={t("overview.receivedShort")}
|
||||||
hide={!visibleLines.netIn}
|
hide={!visibleLines.netIn}
|
||||||
isAnimationActive={true}
|
isAnimationActive={true}
|
||||||
animationDuration={300}
|
animationDuration={300}
|
||||||
@@ -317,7 +319,7 @@ export function NetworkTrafficChart({
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#3b82f6"
|
fill="#3b82f6"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="Sent"
|
name={t("overview.sentShort")}
|
||||||
hide={!visibleLines.netOut}
|
hide={!visibleLines.netOut}
|
||||||
isAnimationActive={true}
|
isAnimationActive={true}
|
||||||
animationDuration={300}
|
animationDuration={300}
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
|||||||
import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
|
import { Loader2, TrendingUp, MemoryStick } from "lucide-react"
|
||||||
import { useIsMobile } from "../hooks/use-mobile"
|
import { useIsMobile } from "../hooks/use-mobile"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
|
import { useI18n } from "../lib/i18n/provider"
|
||||||
|
|
||||||
const TIMEFRAME_OPTIONS = [
|
const TIMEFRAME_OPTIONS = [
|
||||||
{ value: "hour", label: "1 Hour" },
|
{ value: "hour", labelKey: "overview.timeframes.hour" },
|
||||||
{ value: "day", label: "24 Hours" },
|
{ value: "day", labelKey: "overview.timeframes.day" },
|
||||||
{ value: "week", label: "7 Days" },
|
{ value: "week", labelKey: "overview.timeframes.week" },
|
||||||
{ value: "month", label: "30 Days" },
|
{ value: "month", labelKey: "overview.timeframes.month" },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface NodeMetricsData {
|
interface NodeMetricsData {
|
||||||
@@ -66,11 +67,71 @@ const CustomMemoryTooltip = ({ active, payload, label }: any) => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MetricsError {
|
||||||
|
headline: string
|
||||||
|
details?: string
|
||||||
|
suggestion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AVG / MAX / MIN chip row for the chart card headers. Values come
|
||||||
|
// from the backend `period_stats` (calculated over the raw RRD points
|
||||||
|
// BEFORE downsampling), not from the displayed chart points — that's
|
||||||
|
// what makes a 1-minute CPU spike still appear in the 24h MAX even
|
||||||
|
// though the chart shows 5-min bucket averages.
|
||||||
|
//
|
||||||
|
// Colour choice: all three values render in the same foreground tone.
|
||||||
|
// The previous red(max)/green(min) scheme misread as severity (a
|
||||||
|
// healthy 10 % CPU max showed in red and looked like an alert).
|
||||||
|
//
|
||||||
|
// Responsive: on ≥sm the chips sit to the right of the title; on
|
||||||
|
// mobile they wrap below in their own row (the parent CardHeader uses
|
||||||
|
// `flex-col sm:flex-row`). Smaller text + tabular-nums keeps the
|
||||||
|
// chips compact enough that they don't crowd long titles.
|
||||||
|
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))
|
||||||
|
return (
|
||||||
|
<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">{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">{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">{labels.min}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function NodeMetricsCharts() {
|
export function NodeMetricsCharts() {
|
||||||
|
const { language, t } = useI18n()
|
||||||
const [timeframe, setTimeframe] = useState("day")
|
const [timeframe, setTimeframe] = useState("day")
|
||||||
const [data, setData] = useState<NodeMetricsData[]>([])
|
const [data, setData] = useState<NodeMetricsData[]>([])
|
||||||
|
// period_stats from the backend — computed over the raw RRD points
|
||||||
|
// BEFORE the 5-min downsampling so the chart header's MAX/MIN
|
||||||
|
// captures real per-minute extremes (a 1-min CPU spike still shows
|
||||||
|
// up on the 24h view's MAX).
|
||||||
|
const [periodStats, setPeriodStats] = useState<{
|
||||||
|
cpu?: PeriodStat
|
||||||
|
memory_used?: PeriodStat
|
||||||
|
}>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<MetricsError | null>(null)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const [visibleLines, setVisibleLines] = useState({
|
const [visibleLines, setVisibleLines] = useState({
|
||||||
@@ -84,7 +145,7 @@ export function NodeMetricsCharts() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMetrics()
|
fetchMetrics()
|
||||||
}, [timeframe])
|
}, [timeframe, language])
|
||||||
|
|
||||||
const fetchMetrics = async () => {
|
const fetchMetrics = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -96,7 +157,7 @@ export function NodeMetricsCharts() {
|
|||||||
|
|
||||||
if (!result.data || !Array.isArray(result.data)) {
|
if (!result.data || !Array.isArray(result.data)) {
|
||||||
console.error("Invalid data format - data is not an array:", result)
|
console.error("Invalid data format - data is not an array:", result)
|
||||||
throw new Error("Invalid data format received from server")
|
throw new Error(t("overview.invalidMetricsData"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.data.length === 0) {
|
if (result.data.length === 0) {
|
||||||
@@ -114,26 +175,26 @@ export function NodeMetricsCharts() {
|
|||||||
let timeLabel = ""
|
let timeLabel = ""
|
||||||
|
|
||||||
if (timeframe === "hour") {
|
if (timeframe === "hour") {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
})
|
})
|
||||||
} else if (timeframe === "day") {
|
} else if (timeframe === "day") {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
})
|
})
|
||||||
} else if (timeframe === "week") {
|
} else if (timeframe === "week") {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
hour12: false,
|
hour12: false,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
timeLabel = date.toLocaleString("en-US", {
|
timeLabel = date.toLocaleString(language, {
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})
|
})
|
||||||
@@ -158,11 +219,19 @@ export function NodeMetricsCharts() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
setData(transformedData)
|
setData(transformedData)
|
||||||
|
setPeriodStats(result.period_stats || {})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Error fetching node metrics:", err)
|
console.error("Error fetching node metrics:", err)
|
||||||
console.error("Error message:", err.message)
|
// fetchApi attaches the parsed JSON body to err.body. The metrics
|
||||||
console.error("Error stack:", err.stack)
|
// endpoint enriches 503 responses with `details` (Proxmox-side
|
||||||
setError(err.message || "Error loading metrics")
|
// diagnostic) and `suggestion` (how to fix). Pull them through so
|
||||||
|
// the user sees actionable text instead of a bare "503".
|
||||||
|
const body = err?.body
|
||||||
|
setError({
|
||||||
|
headline: body?.error || err?.message || t("overview.metricsLoadError"),
|
||||||
|
details: body?.details,
|
||||||
|
suggestion: body?.suggestion,
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -231,24 +300,36 @@ export function NodeMetricsCharts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
// Both panels carry the same error — render an identical card on
|
||||||
|
// each side. The headline is the short cause, the details block
|
||||||
|
// explains it's a Proxmox-host issue (not a Monitor bug), and the
|
||||||
|
// suggestion is the exact command the operator should run.
|
||||||
|
const errorCard = (
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col items-start justify-center h-[300px] gap-2 px-2 overflow-auto">
|
||||||
|
<p className="text-sm font-semibold text-red-400">{error.headline}</p>
|
||||||
|
{error.details && (
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">{error.details}</p>
|
||||||
|
)}
|
||||||
|
{error.suggestion && (
|
||||||
|
<div className="w-full mt-2">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground mb-1">
|
||||||
|
{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}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card className="bg-card border-border">
|
{errorCard}
|
||||||
<CardContent className="p-6">
|
{errorCard}
|
||||||
<div className="flex flex-col items-center justify-center h-[300px] gap-2">
|
|
||||||
<p className="text-muted-foreground text-sm">Metrics data not available yet</p>
|
|
||||||
<p className="text-xs text-red-500">{error}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="bg-card border-border">
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<div className="flex flex-col items-center justify-center h-[300px] gap-2">
|
|
||||||
<p className="text-muted-foreground text-sm">Metrics data not available yet</p>
|
|
||||||
<p className="text-xs text-red-500">{error}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -259,14 +340,14 @@ export function NodeMetricsCharts() {
|
|||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-center h-[300px]">
|
<div className="flex items-center justify-center h-[300px]">
|
||||||
<p className="text-muted-foreground text-sm">No metrics data available</p>
|
<p className="text-muted-foreground text-sm">{t("overview.noMetricsData")}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center justify-center h-[300px]">
|
<div className="flex items-center justify-center h-[300px]">
|
||||||
<p className="text-muted-foreground text-sm">No metrics data available</p>
|
<p className="text-muted-foreground text-sm">{t("overview.noMetricsData")}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -286,7 +367,7 @@ export function NodeMetricsCharts() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TIMEFRAME_OPTIONS.map((option) => (
|
{TIMEFRAME_OPTIONS.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
{option.label}
|
{t(option.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -298,10 +379,21 @@ export function NodeMetricsCharts() {
|
|||||||
{/* CPU Usage + Load Average Chart */}
|
{/* CPU Usage + Load Average Chart */}
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="px-4 md:px-6">
|
<CardHeader className="px-4 md:px-6">
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||||
<TrendingUp className="h-5 w-5 mr-2" />
|
<CardTitle className="text-foreground flex items-center">
|
||||||
CPU Usage & Load Average
|
<TrendingUp className="h-5 w-5 mr-2" />
|
||||||
</CardTitle>
|
{t("overview.cpuUsageLoadAverage")}
|
||||||
|
</CardTitle>
|
||||||
|
<ChartStatsHeader
|
||||||
|
stats={periodStats.cpu ?? null}
|
||||||
|
suffix="%"
|
||||||
|
labels={{
|
||||||
|
avg: t("overview.stats.avg"),
|
||||||
|
max: t("overview.stats.max"),
|
||||||
|
min: t("overview.stats.min"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0 md:px-6">
|
<CardContent className="px-0 md:px-6">
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
@@ -334,7 +426,7 @@ export function NodeMetricsCharts() {
|
|||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||||
label={
|
label={
|
||||||
isMobile ? undefined : { value: "Load", angle: 90, position: "insideRight", fill: "currentColor" }
|
isMobile ? undefined : { value: t("overview.loadAxis"), angle: 90, position: "insideRight", fill: "currentColor" }
|
||||||
}
|
}
|
||||||
domain={[0, "dataMax"]}
|
domain={[0, "dataMax"]}
|
||||||
/>
|
/>
|
||||||
@@ -348,7 +440,7 @@ export function NodeMetricsCharts() {
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#3b82f6"
|
fill="#3b82f6"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="CPU %"
|
name={t("overview.cpuPercent")}
|
||||||
hide={!visibleLines.cpu.cpu}
|
hide={!visibleLines.cpu.cpu}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
@@ -359,7 +451,7 @@ export function NodeMetricsCharts() {
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#10b981"
|
fill="#10b981"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="Load Avg"
|
name={t("overview.loadAverage")}
|
||||||
hide={!visibleLines.cpu.load}
|
hide={!visibleLines.cpu.load}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
@@ -370,10 +462,21 @@ export function NodeMetricsCharts() {
|
|||||||
{/* Memory Usage Chart */}
|
{/* Memory Usage Chart */}
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="px-4 md:px-6">
|
<CardHeader className="px-4 md:px-6">
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||||
<MemoryStick className="h-5 w-5 mr-2" />
|
<CardTitle className="text-foreground flex items-center">
|
||||||
Memory Usage
|
<MemoryStick className="h-5 w-5 mr-2" />
|
||||||
</CardTitle>
|
{t("overview.memoryUsage")}
|
||||||
|
</CardTitle>
|
||||||
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="px-0 pr-2 md:px-6">
|
<CardContent className="px-0 pr-2 md:px-6">
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
@@ -407,7 +510,7 @@ export function NodeMetricsCharts() {
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#3b82f6"
|
fill="#3b82f6"
|
||||||
fillOpacity={0.1}
|
fillOpacity={0.1}
|
||||||
name="Total"
|
name={t("overview.total")}
|
||||||
hide={!visibleLines.memory.memoryTotal}
|
hide={!visibleLines.memory.memoryTotal}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
@@ -417,7 +520,7 @@ export function NodeMetricsCharts() {
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#10b981"
|
fill="#10b981"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="Used"
|
name={t("overview.used")}
|
||||||
hide={!visibleLines.memory.memoryUsed}
|
hide={!visibleLines.memory.memoryUsed}
|
||||||
/>
|
/>
|
||||||
{/* Only show ZFS ARC if there's data */}
|
{/* Only show ZFS ARC if there's data */}
|
||||||
@@ -442,7 +545,7 @@ export function NodeMetricsCharts() {
|
|||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="#06b6d4"
|
fill="#06b6d4"
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
name="Free"
|
name={t("overview.free")}
|
||||||
hide={!visibleLines.memory.memoryFree}
|
hide={!visibleLines.memory.memoryFree}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,11 +20,12 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { Checkbox } from "./ui/checkbox"
|
import { Checkbox } from "./ui/checkbox"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface OnboardingSlide {
|
interface OnboardingSlide {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
titleKey: string
|
||||||
description: string
|
descriptionKey: string
|
||||||
image?: string
|
image?: string
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
gradient: string
|
gradient: string
|
||||||
@@ -33,77 +34,70 @@ interface OnboardingSlide {
|
|||||||
const slides: OnboardingSlide[] = [
|
const slides: OnboardingSlide[] = [
|
||||||
{
|
{
|
||||||
id: 0,
|
id: 0,
|
||||||
title: "Welcome to ProxMenux Monitor!",
|
titleKey: "onboarding.slides.welcome.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.welcome.description",
|
||||||
"Your new monitoring tool for Proxmox. Discover all the features that will help you manage and supervise your infrastructure efficiently.",
|
|
||||||
icon: <Sparkles className="h-16 w-16" />,
|
icon: <Sparkles className="h-16 w-16" />,
|
||||||
gradient: "from-blue-500 via-purple-500 to-pink-500",
|
gradient: "from-blue-500 via-purple-500 to-pink-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
title: "System Overview",
|
titleKey: "onboarding.slides.overview.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.overview.description",
|
||||||
"Monitor your server's status in real-time: CPU, memory, temperature, system load and more. Everything in an intuitive and easy-to-understand dashboard.",
|
|
||||||
image: "/images/onboarding/imagen1.png",
|
image: "/images/onboarding/imagen1.png",
|
||||||
icon: <LayoutDashboard className="h-12 w-12" />,
|
icon: <LayoutDashboard className="h-12 w-12" />,
|
||||||
gradient: "from-blue-500 to-cyan-500",
|
gradient: "from-blue-500 to-cyan-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
title: "Storage Management",
|
titleKey: "onboarding.slides.storage.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.storage.description",
|
||||||
"Visualize the status of all your disks and volumes. Detailed information on capacity, usage, SMART health, temperature and performance of each storage device.",
|
|
||||||
image: "/images/onboarding/imagen2.png",
|
image: "/images/onboarding/imagen2.png",
|
||||||
icon: <HardDrive className="h-12 w-12" />,
|
icon: <HardDrive className="h-12 w-12" />,
|
||||||
gradient: "from-cyan-500 to-teal-500",
|
gradient: "from-cyan-500 to-teal-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
title: "Network Metrics",
|
titleKey: "onboarding.slides.network.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.network.description",
|
||||||
"Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.",
|
|
||||||
image: "/images/onboarding/imagen3.png",
|
image: "/images/onboarding/imagen3.png",
|
||||||
icon: <Network className="h-12 w-12" />,
|
icon: <Network className="h-12 w-12" />,
|
||||||
gradient: "from-teal-500 to-green-500",
|
gradient: "from-teal-500 to-green-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
title: "Virtual Machines & Containers",
|
titleKey: "onboarding.slides.virtualMachines.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.virtualMachines.description",
|
||||||
"Manage all your VMs and LXC containers from one place. Status, allocated resources, current usage and quick controls for each virtual machine.",
|
|
||||||
image: "/images/onboarding/imagen4.png",
|
image: "/images/onboarding/imagen4.png",
|
||||||
icon: <Box className="h-12 w-12" />,
|
icon: <Box className="h-12 w-12" />,
|
||||||
gradient: "from-green-500 to-emerald-500",
|
gradient: "from-green-500 to-emerald-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
title: "Hardware Information",
|
titleKey: "onboarding.slides.hardware.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.hardware.description",
|
||||||
"Complete details of your server hardware: CPU, RAM, GPU, disks, network, UPS and more. Technical specifications, models, serial numbers and status of each component.",
|
|
||||||
image: "/images/onboarding/imagen5.png",
|
image: "/images/onboarding/imagen5.png",
|
||||||
icon: <Cpu className="h-12 w-12" />,
|
icon: <Cpu className="h-12 w-12" />,
|
||||||
gradient: "from-emerald-500 to-blue-500",
|
gradient: "from-emerald-500 to-blue-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
title: "System Logs",
|
titleKey: "onboarding.slides.logs.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.logs.description",
|
||||||
"Access system logs in real-time. Filter by event type, search for specific errors and keep complete track of your server activity. Download the displayed logs for further analysis.",
|
|
||||||
image: "/images/onboarding/imagen6.png",
|
image: "/images/onboarding/imagen6.png",
|
||||||
icon: <FileText className="h-12 w-12" />,
|
icon: <FileText className="h-12 w-12" />,
|
||||||
gradient: "from-blue-500 to-indigo-500",
|
gradient: "from-blue-500 to-indigo-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 7,
|
||||||
title: "Ready for the Future!",
|
titleKey: "onboarding.slides.future.title",
|
||||||
description:
|
descriptionKey: "onboarding.slides.future.description",
|
||||||
"ProxMenux Monitor is prepared to receive updates and improvements that will be added gradually, improving the user experience and being able to execute ProxMenux functions from the web panel.",
|
|
||||||
icon: <Rocket className="h-16 w-16" />,
|
icon: <Rocket className="h-16 w-16" />,
|
||||||
gradient: "from-indigo-500 via-purple-500 to-pink-500",
|
gradient: "from-indigo-500 via-purple-500 to-pink-500",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function OnboardingCarousel() {
|
export function OnboardingCarousel() {
|
||||||
|
const t = useT()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [currentSlide, setCurrentSlide] = useState(0)
|
const [currentSlide, setCurrentSlide] = useState(0)
|
||||||
const [direction, setDirection] = useState<"next" | "prev">("next")
|
const [direction, setDirection] = useState<"next" | "prev">("next")
|
||||||
@@ -155,11 +149,13 @@ export function OnboardingCarousel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const slide = slides[currentSlide]
|
const slide = slides[currentSlide]
|
||||||
|
const slideTitle = t(slide.titleKey)
|
||||||
|
const slideDescription = t(slide.descriptionKey)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent">
|
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent">
|
||||||
<DialogTitle className="sr-only">ProxMenux Onboarding</DialogTitle>
|
<DialogTitle className="sr-only">{t("onboarding.dialogTitle")}</DialogTitle>
|
||||||
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
|
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -181,7 +177,7 @@ export function OnboardingCarousel() {
|
|||||||
<div className="relative w-full h-36 md:h-48 flex items-center justify-center px-4">
|
<div className="relative w-full h-36 md:h-48 flex items-center justify-center px-4">
|
||||||
<Image
|
<Image
|
||||||
src={slide.image || "/placeholder.svg"}
|
src={slide.image || "/placeholder.svg"}
|
||||||
alt={slide.title}
|
alt={slideTitle}
|
||||||
width={600}
|
width={600}
|
||||||
height={400}
|
height={400}
|
||||||
className="rounded-lg shadow-2xl object-cover max-h-36 md:max-h-48"
|
className="rounded-lg shadow-2xl object-cover max-h-36 md:max-h-48"
|
||||||
@@ -207,9 +203,9 @@ export function OnboardingCarousel() {
|
|||||||
|
|
||||||
<div className="p-4 md:p-8 space-y-3 md:space-y-6 max-h-[60vh] md:max-h-none overflow-y-auto">
|
<div className="p-4 md:p-8 space-y-3 md:space-y-6 max-h-[60vh] md:max-h-none overflow-y-auto">
|
||||||
<div className="space-y-2 md:space-y-3">
|
<div className="space-y-2 md:space-y-3">
|
||||||
<h2 className="text-xl md:text-3xl font-bold text-foreground text-balance">{slide.title}</h2>
|
<h2 className="text-xl md:text-3xl font-bold text-foreground text-balance">{slideTitle}</h2>
|
||||||
<p className="text-sm md:text-lg text-muted-foreground leading-relaxed text-pretty">
|
<p className="text-sm md:text-lg text-muted-foreground leading-relaxed text-pretty">
|
||||||
{slide.description}
|
{slideDescription}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -223,7 +219,7 @@ export function OnboardingCarousel() {
|
|||||||
? "w-8 h-2.5 bg-blue-500 shadow-lg shadow-blue-500/50"
|
? "w-8 h-2.5 bg-blue-500 shadow-lg shadow-blue-500/50"
|
||||||
: "w-2.5 h-2.5 bg-muted-foreground/60 hover:bg-muted-foreground/80 border border-muted-foreground/40"
|
: "w-2.5 h-2.5 bg-muted-foreground/60 hover:bg-muted-foreground/80 border border-muted-foreground/40"
|
||||||
}`}
|
}`}
|
||||||
aria-label={`Go to slide ${index + 1}`}
|
aria-label={t("onboarding.goToSlide", { number: index + 1 })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -236,7 +232,7 @@ export function OnboardingCarousel() {
|
|||||||
className="gap-2 w-full sm:w-auto text-sm"
|
className="gap-2 w-full sm:w-auto text-sm"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" />
|
||||||
Previous
|
{t("onboarding.previous")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex gap-2 w-full sm:w-auto">
|
<div className="flex gap-2 w-full sm:w-auto">
|
||||||
@@ -247,13 +243,13 @@ export function OnboardingCarousel() {
|
|||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="flex-1 sm:flex-none bg-transparent text-sm"
|
className="flex-1 sm:flex-none bg-transparent text-sm"
|
||||||
>
|
>
|
||||||
Skip
|
{t("onboarding.skip")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none text-sm"
|
className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none text-sm"
|
||||||
>
|
>
|
||||||
Next
|
{t("onboarding.next")}
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -262,7 +258,7 @@ export function OnboardingCarousel() {
|
|||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 w-full sm:w-auto text-sm"
|
className="gap-2 bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-600 hover:to-purple-600 w-full sm:w-auto text-sm"
|
||||||
>
|
>
|
||||||
Get Started!
|
{t("onboarding.getStarted")}
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -279,7 +275,7 @@ export function OnboardingCarousel() {
|
|||||||
htmlFor="dont-show-again"
|
htmlFor="dont-show-again"
|
||||||
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
|
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
|
||||||
>
|
>
|
||||||
Don't show this again
|
{t("onboarding.dontShowAgain")}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||||
|
import { Input } from "./ui/input"
|
||||||
|
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
|
||||||
|
/** Parent process PID — equal to `pid` for process rows, different
|
||||||
|
* for thread rows (CPU sort enumerates per-thread). The detail modal
|
||||||
|
* always loads the parent, since /proc/<pid>/cmdline etc. only exist
|
||||||
|
* at the process level. */
|
||||||
|
parent_pid?: number
|
||||||
|
user: string
|
||||||
|
cpu: number
|
||||||
|
/** CPU% averaged over the process's whole lifetime. Surfaces
|
||||||
|
* long-running idle processes that consume a steady baseline but
|
||||||
|
* don't spike during the 1-s sample window (an orphan `bash -s`
|
||||||
|
* in a sleep-loop, a polling daemon, etc.). Only meaningful on
|
||||||
|
* the CPU sort response. */
|
||||||
|
cpu_avg?: number
|
||||||
|
mem: number
|
||||||
|
rss_kb: number
|
||||||
|
command: string
|
||||||
|
/** Full command line. Used for filter matching and hover tooltips so
|
||||||
|
* searching e.g. "proxmenux" finds a process whose short name is just
|
||||||
|
* "python3" but whose cmdline is `python3 /.../proxmenux.py`. */
|
||||||
|
cmdline?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessesResponse {
|
||||||
|
processes: ProcessInfo[]
|
||||||
|
sort: "cpu" | "mem"
|
||||||
|
captured_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessDetailModalProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
/** Which metric the parent card represents (drives default sort + emphasis) */
|
||||||
|
sort: "cpu" | "mem"
|
||||||
|
}
|
||||||
|
|
||||||
|
const REFRESH_MS = 3000
|
||||||
|
// FETCH_LIMIT is how many rows the server returns. DISPLAY_LIMIT is what
|
||||||
|
// the user actually sees when no filter is set. We over-fetch so the
|
||||||
|
// filter can find processes that aren't in the top-N by metric — e.g.,
|
||||||
|
// searching "proxmenux" in the Memory modal should find it even though
|
||||||
|
// it's nowhere near the top 25 by RSS.
|
||||||
|
const FETCH_LIMIT = 200
|
||||||
|
const DISPLAY_LIMIT = 25
|
||||||
|
|
||||||
|
const formatRss = (kb: number): string => {
|
||||||
|
if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(2)} GB`
|
||||||
|
if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`
|
||||||
|
return `${kb} KB`
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
const [filter, setFilter] = useState("")
|
||||||
|
const [selectedPid, setSelectedPid] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const fetchProcesses = async (silent = false) => {
|
||||||
|
if (!silent) setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetchApi<ProcessesResponse>(`/api/processes?sort=${sort}&limit=${FETCH_LIMIT}`)
|
||||||
|
setData(res)
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || t("details.processes.loadFailed"))
|
||||||
|
} finally {
|
||||||
|
if (!silent) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
fetchProcesses()
|
||||||
|
const id = setInterval(() => fetchProcesses(true), REFRESH_MS)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, sort])
|
||||||
|
|
||||||
|
// Reset filter when dialog closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setFilter("")
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// When no filter is set, the user just wants the top N by metric
|
||||||
|
// (CPU usage or memory). When they type a query, they want EVERY
|
||||||
|
// match — including processes that aren't in the top N — which is
|
||||||
|
// why we over-fetch on the server.
|
||||||
|
const allMatches = (data?.processes ?? []).filter((p) => {
|
||||||
|
if (!filter) return true
|
||||||
|
const q = filter.toLowerCase()
|
||||||
|
return (
|
||||||
|
p.command.toLowerCase().includes(q) ||
|
||||||
|
(p.cmdline?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
p.user.toLowerCase().includes(q) ||
|
||||||
|
String(p.pid).includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const filtered = filter ? allMatches : allMatches.slice(0, DISPLAY_LIMIT)
|
||||||
|
|
||||||
|
const Icon = sort === "cpu" ? Cpu : MemoryStick
|
||||||
|
const title = sort === "cpu" ? t("details.processes.topByCpu") : t("details.processes.topByMemory")
|
||||||
|
const description =
|
||||||
|
sort === "cpu"
|
||||||
|
? 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
|
||||||
|
// the same hues so the modal feels like a continuation of the card.
|
||||||
|
const accent = sort === "cpu"
|
||||||
|
? { dot: "#3b82f6", bar: "#3b82f6", text: "text-blue-500" }
|
||||||
|
: { dot: "#6366f1", bar: "#6366f1", text: "text-indigo-400" }
|
||||||
|
|
||||||
|
// Scale bars to the largest value in the (filtered) list so the visual
|
||||||
|
// ranking is preserved even when no process is near 100 %. CPU can
|
||||||
|
// exceed 100 % on multi-threaded apps — falling back to max=1 prevents
|
||||||
|
// a divide-by-zero when the list is empty.
|
||||||
|
const maxPrimary = Math.max(
|
||||||
|
1,
|
||||||
|
...filtered.map((p) => (sort === "cpu" ? p.cpu : p.mem))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mobile drops PID + USER; desktop keeps the full 5-column layout.
|
||||||
|
// CPU and MEM columns are wider on desktop with a real gap between
|
||||||
|
// them so the two metrics don't feel glued together.
|
||||||
|
const gridCols =
|
||||||
|
"grid-cols-[minmax(0,1fr)_70px_90px] sm:grid-cols-[60px_96px_minmax(140px,1fr)_110px_120px]"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-3xl"
|
||||||
|
/* Prevent Radix from focusing the search Input on open — the
|
||||||
|
auto-focus pops the on-screen keyboard on touch devices and
|
||||||
|
covers half the modal. The user can still tap the field to
|
||||||
|
start filtering. */
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Icon className={`h-5 w-5 ${accent.text}`} />
|
||||||
|
{title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs">{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<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={t("details.processes.filterPlaceholder")}
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
className="pl-8 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="text-sm text-red-500 py-4">{error}</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-[440px] border border-border rounded-md">
|
||||||
|
<div className="min-w-full">
|
||||||
|
{/* Sticky solid header so scrolled rows don't bleed through */}
|
||||||
|
<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">{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">
|
||||||
|
{t("details.processes.noMatches")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((p) => {
|
||||||
|
const primary = sort === "cpu" ? p.cpu : p.mem
|
||||||
|
const barPct = Math.min(100, (primary / maxPrimary) * 100)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.pid}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedPid(p.parent_pid ?? p.pid)}
|
||||||
|
className={`w-full text-left grid items-center gap-x-3 sm:gap-x-6 px-3 py-2 border-b border-border/40 hover:bg-white/5 transition-colors ${gridCols}`}
|
||||||
|
>
|
||||||
|
<div className="hidden sm:flex font-mono text-xs items-center gap-1.5 min-w-0">
|
||||||
|
<span
|
||||||
|
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: accent.dot }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{p.pid}</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block font-mono text-xs truncate" title={p.user}>{p.user}</div>
|
||||||
|
<div className="font-mono text-xs truncate min-w-0 flex items-center gap-1.5" title={p.cmdline || p.command}>
|
||||||
|
{/* Mobile only: keep the accent dot since PID column is gone */}
|
||||||
|
<span
|
||||||
|
className="sm:hidden w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: accent.dot }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{p.command}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary metric: value + sized progress bar in the accent colour */}
|
||||||
|
{sort === "cpu" ? (
|
||||||
|
<div className="flex flex-col items-end gap-1 min-w-0">
|
||||||
|
<span className={`font-mono text-sm font-semibold ${accent.text}`}>{p.cpu.toFixed(1)}</span>
|
||||||
|
{/* Show lifetime average only when it
|
||||||
|
materially differs from the live
|
||||||
|
sample (process consumes a steady
|
||||||
|
baseline but was idle at sample time
|
||||||
|
— orphaned bash loop, polling daemon).
|
||||||
|
The 0.5 / 1.5x thresholds skip cases
|
||||||
|
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={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">
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${barPct}%`, background: accent.bar }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="font-mono text-xs text-right text-muted-foreground">{p.cpu.toFixed(1)}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Secondary column: mem % when CPU is primary, RSS when memory is primary */}
|
||||||
|
{sort === "cpu" ? (
|
||||||
|
<div className="font-mono text-xs text-right text-muted-foreground">{p.mem.toFixed(1)}</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-end gap-1 min-w-0">
|
||||||
|
<span className={`font-mono text-sm font-semibold ${accent.text}`}>{formatRss(p.rss_kb)}</span>
|
||||||
|
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${barPct}%`, background: accent.bar }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.captured_at && (
|
||||||
|
<div className="text-[10px] text-muted-foreground text-right mt-1">
|
||||||
|
{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>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ProcessInfoModal
|
||||||
|
pid={selectedPid}
|
||||||
|
accent={accent}
|
||||||
|
onClose={() => setSelectedPid(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||||
|
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
|
||||||
|
comm: string
|
||||||
|
cmdline: string
|
||||||
|
exe: string | null
|
||||||
|
cwd: string | null
|
||||||
|
state: string
|
||||||
|
ppid: number
|
||||||
|
parent_name: string | null
|
||||||
|
threads: number
|
||||||
|
vm_rss_kb: number
|
||||||
|
vm_size_kb: number
|
||||||
|
vm_swap_kb: number
|
||||||
|
user: string
|
||||||
|
group: string
|
||||||
|
uid: number
|
||||||
|
gid: number
|
||||||
|
start_time: string | null
|
||||||
|
elapsed: string | null
|
||||||
|
cpu: number
|
||||||
|
mem: number
|
||||||
|
io_read_bytes: number | null
|
||||||
|
io_write_bytes: number | null
|
||||||
|
fd_count: number | null
|
||||||
|
captured_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessInfoModalProps {
|
||||||
|
pid: number | null
|
||||||
|
accent: { dot: string; bar: string; text: string }
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const REFRESH_MS = 3000
|
||||||
|
|
||||||
|
const formatKb = (kb: number | null | undefined): string => {
|
||||||
|
if (kb == null) return "—"
|
||||||
|
if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(2)} GB`
|
||||||
|
if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`
|
||||||
|
return `${kb} KB`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (b: number | null | undefined): string => {
|
||||||
|
if (b == null) return "—"
|
||||||
|
if (b >= 1024 * 1024 * 1024) return `${(b / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||||
|
if (b >= 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
if (b >= 1024) return `${(b / 1024).toFixed(1)} KB`
|
||||||
|
return `${b} B`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, 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: "diskWait",
|
||||||
|
Z: "zombie",
|
||||||
|
T: rawLetter === "t" ? "tracingStop" : "stopped",
|
||||||
|
X: "dead",
|
||||||
|
I: "idle",
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
const [exited, setExited] = useState(false)
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const open = pid != null
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
intervalRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDetail = async (silent = false) => {
|
||||||
|
if (pid == null) return
|
||||||
|
if (!silent) setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetchApi<ProcessDetail>(`/api/processes/${pid}`)
|
||||||
|
setData(res)
|
||||||
|
} catch (e: any) {
|
||||||
|
// 404 = the process exited while the modal was open. Expected for
|
||||||
|
// short-lived helpers (pct exec, backup subprocesses, the `ps` snapshot
|
||||||
|
// itself). Keep the last good snapshot on screen, stop polling, and
|
||||||
|
// surface an info banner — NOT an error — so it doesn't look like a bug.
|
||||||
|
if (e?.message?.includes("404")) {
|
||||||
|
setExited(true)
|
||||||
|
stopPolling()
|
||||||
|
} else {
|
||||||
|
setError(t("details.processInfo.fetchFailed"))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!silent) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pid == null) {
|
||||||
|
setData(null)
|
||||||
|
setError(null)
|
||||||
|
setExited(false)
|
||||||
|
stopPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExited(false)
|
||||||
|
fetchDetail()
|
||||||
|
intervalRef.current = setInterval(() => fetchDetail(true), REFRESH_MS)
|
||||||
|
return () => stopPolling()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pid])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 min-w-0">
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: accent.dot }}
|
||||||
|
/>
|
||||||
|
<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
|
||||||
|
? t("details.processInfo.descriptionExited", { pid: pid ?? "" })
|
||||||
|
: t("details.processInfo.descriptionLive", { pid: pid ?? "", seconds: REFRESH_MS / 1000 })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Info banner when the process has finished. Amber, not red — this is
|
||||||
|
expected behavior for short-lived processes, not an error. */}
|
||||||
|
{exited && (
|
||||||
|
<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">{t("details.processInfo.finishedTitle")}</div>
|
||||||
|
<div className="text-amber-300/80 mt-0.5">
|
||||||
|
{t("details.processInfo.finishedDescription")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !data ? (
|
||||||
|
<div className="text-sm text-red-500 py-4">{error}</div>
|
||||||
|
) : !data ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||||
|
{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={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={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={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={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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.captured_at && (
|
||||||
|
<div className="text-[10px] text-muted-foreground text-right mt-1">
|
||||||
|
{exited ? t("details.processInfo.lastSeen") : t("details.processInfo.captured")}{" "}
|
||||||
|
{new Date(data.captured_at * 1000).toLocaleTimeString(language)}
|
||||||
|
{error ? ` · ${error}` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ icon, title, children }: { icon: React.ReactNode; title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-md overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-card text-xs font-medium uppercase tracking-wider text-muted-foreground border-b border-border">
|
||||||
|
{icon}
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border/40">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
wrap,
|
||||||
|
valueClass,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
mono?: boolean
|
||||||
|
wrap?: boolean
|
||||||
|
valueClass?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[110px_minmax(0,1fr)] gap-2 px-3 py-1.5 text-xs">
|
||||||
|
<div className="text-muted-foreground">{label}</div>
|
||||||
|
<div
|
||||||
|
className={`${mono ? "font-mono" : ""} ${wrap ? "break-all" : "truncate"} ${valueClass || ""}`}
|
||||||
|
title={value}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { Button } from "./ui/button"
|
|||||||
import { Input } from "./ui/input"
|
import { Input } from "./ui/input"
|
||||||
import { Label } from "./ui/label"
|
import { Label } from "./ui/label"
|
||||||
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface ProfileData {
|
interface ProfileData {
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -51,6 +52,7 @@ interface ProfileProps {
|
|||||||
* the operator hits Edit to start typing.
|
* the operator hits Edit to start typing.
|
||||||
*/
|
*/
|
||||||
export function Profile({ onOpenSecurity }: ProfileProps) {
|
export function Profile({ onOpenSecurity }: ProfileProps) {
|
||||||
|
const t = useT()
|
||||||
const [profile, setProfile] = useState<ProfileData | null>(null)
|
const [profile, setProfile] = useState<ProfileData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -146,7 +148,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
body: JSON.stringify({ display_name: displayDraft }),
|
body: JSON.stringify({ display_name: displayDraft }),
|
||||||
})
|
})
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
setError(data.message || "Failed to save display name")
|
setError(data.message || t("profilePage.errors.saveDisplayNameFailed"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setProfile(data)
|
setProfile(data)
|
||||||
@@ -182,7 +184,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
})
|
})
|
||||||
const data: ProfileData = await r.json().catch(() => ({ success: false }))
|
const data: ProfileData = await r.json().catch(() => ({ success: false }))
|
||||||
if (!r.ok || !data.success) {
|
if (!r.ok || !data.success) {
|
||||||
setAvatarError(data.message || `Upload failed (${r.status})`)
|
setAvatarError(data.message || t("profilePage.errors.uploadFailed", { status: r.status }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setProfile(data)
|
setProfile(data)
|
||||||
@@ -212,7 +214,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
})
|
})
|
||||||
const data: ProfileData = await r.json().catch(() => ({ success: false }))
|
const data: ProfileData = await r.json().catch(() => ({ success: false }))
|
||||||
if (!r.ok || !data.success) {
|
if (!r.ok || !data.success) {
|
||||||
setAvatarError(data.message || `Delete failed (${r.status})`)
|
setAvatarError(data.message || t("profilePage.errors.deleteFailed", { status: r.status }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setProfile(data)
|
setProfile(data)
|
||||||
@@ -232,7 +234,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-8 flex items-center justify-center text-muted-foreground">
|
<CardContent className="p-8 flex items-center justify-center text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
Loading profile…
|
{t("profilePage.loading")}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,7 +249,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
<div className="flex items-start gap-2 text-red-500">
|
<div className="flex items-start gap-2 text-red-500">
|
||||||
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
|
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">Failed to load profile</div>
|
<div className="font-medium">{t("profilePage.loadFailed")}</div>
|
||||||
<div className="text-xs text-muted-foreground mt-1 break-all">{error}</div>
|
<div className="text-xs text-muted-foreground mt-1 break-all">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,13 +270,13 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserIcon className="h-5 w-5 text-cyan-500" />
|
<UserIcon className="h-5 w-5 text-cyan-500" />
|
||||||
<CardTitle>User Profile</CardTitle>
|
<CardTitle>{t("profilePage.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{savedDisplay && (
|
{savedDisplay && (
|
||||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
Saved
|
{t("status.saved")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{displayEditMode ? (
|
{displayEditMode ? (
|
||||||
@@ -286,7 +288,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
disabled={savingDisplay}
|
disabled={savingDisplay}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -299,7 +301,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
) : (
|
) : (
|
||||||
<CheckCircle2 className="h-3 w-3 mr-1.5" />
|
<CheckCircle2 className="h-3 w-3 mr-1.5" />
|
||||||
)}
|
)}
|
||||||
Save
|
{t("actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -310,14 +312,13 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
>
|
>
|
||||||
<Settings2 className="h-3 w-3 mr-1.5" />
|
<Settings2 className="h-3 w-3 mr-1.5" />
|
||||||
Edit
|
{t("actions.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Personal details rendered in the header avatar menu. None of this is required —
|
{t("profilePage.description")}
|
||||||
the username already covers identity. Display name and avatar are decorative.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
@@ -327,7 +328,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
image they uploaded. `object-cover` keeps the aspect
|
image they uploaded. `object-cover` keeps the aspect
|
||||||
ratio and crops to fit the circle. */}
|
ratio and crops to fit the circle. */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm">Avatar</Label>
|
<Label className="text-sm">{t("profilePage.avatar.label")}</Label>
|
||||||
<div className="flex flex-col sm:flex-row items-start gap-6 mt-3">
|
<div className="flex flex-col sm:flex-row items-start gap-6 mt-3">
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
{avatarBlobUrl ? (
|
{avatarBlobUrl ? (
|
||||||
@@ -367,7 +368,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
className="justify-start"
|
className="justify-start"
|
||||||
>
|
>
|
||||||
<Upload className="h-3.5 w-3.5 mr-2" />
|
<Upload className="h-3.5 w-3.5 mr-2" />
|
||||||
{profile?.has_avatar ? "Replace avatar" : "Upload avatar"}
|
{profile?.has_avatar ? t("profilePage.avatar.replace") : t("profilePage.avatar.upload")}
|
||||||
</Button>
|
</Button>
|
||||||
{profile?.has_avatar && (
|
{profile?.has_avatar && (
|
||||||
<Button
|
<Button
|
||||||
@@ -378,12 +379,11 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
className="justify-start text-red-500 hover:text-red-500 hover:bg-red-500/10"
|
className="justify-start text-red-500 hover:text-red-500 hover:bg-red-500/10"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||||
Remove avatar
|
{t("profilePage.avatar.remove")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<p className="text-[11px] text-muted-foreground leading-relaxed max-w-xs">
|
<p className="text-[11px] text-muted-foreground leading-relaxed max-w-xs">
|
||||||
PNG, JPEG, WebP or GIF. Up to 2 MB. The image isn't resized —
|
{t("profilePage.avatar.hint")}
|
||||||
render it square or pre-crop for best results in the header.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,7 +397,7 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
|
|
||||||
{/* ─── Username (read-only) ─── */}
|
{/* ─── Username (read-only) ─── */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm" htmlFor="profile-username">Username</Label>
|
<Label className="text-sm" htmlFor="profile-username">{t("profilePage.username.label")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="profile-username"
|
id="profile-username"
|
||||||
value={profile?.username || ""}
|
value={profile?.username || ""}
|
||||||
@@ -405,28 +405,26 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
|
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground mt-1">
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
The login name. To change it, disable authentication and reconfigure from
|
{t("profilePage.username.help")}
|
||||||
Security.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── Display name (Edit controls live in the card header) ─── */}
|
{/* ─── Display name (Edit controls live in the card header) ─── */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-sm" htmlFor="profile-display">
|
<Label className="text-sm" htmlFor="profile-display">
|
||||||
Display name <span className="text-muted-foreground font-normal">(optional)</span>
|
{t("profilePage.displayName.label")} <span className="text-muted-foreground font-normal">{t("profilePage.displayName.optional")}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="profile-display"
|
id="profile-display"
|
||||||
value={displayDraft}
|
value={displayDraft}
|
||||||
onChange={(e) => setDisplayDraft(e.target.value)}
|
onChange={(e) => setDisplayDraft(e.target.value)}
|
||||||
placeholder={profile?.username || "Display name"}
|
placeholder={profile?.username || t("profilePage.displayName.placeholder")}
|
||||||
maxLength={64}
|
maxLength={64}
|
||||||
disabled={!displayEditMode || savingDisplay}
|
disabled={!displayEditMode || savingDisplay}
|
||||||
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
|
className="mt-2 max-w-sm disabled:opacity-100 disabled:cursor-default"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground mt-1">
|
<p className="text-[11px] text-muted-foreground mt-1">
|
||||||
Shown above the username inside the avatar menu. Leave empty to show the
|
{t("profilePage.displayName.help")}
|
||||||
username itself. Up to 64 characters.
|
|
||||||
</p>
|
</p>
|
||||||
{error && displayEditMode && (
|
{error && displayEditMode && (
|
||||||
<div className="mt-2 text-xs text-red-500 flex items-start gap-1.5">
|
<div className="mt-2 text-xs text-red-500 flex items-start gap-1.5">
|
||||||
@@ -443,21 +441,21 @@ export function Profile({ onOpenSecurity }: ProfileProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Shield className="h-5 w-5 text-orange-500" />
|
<Shield className="h-5 w-5 text-orange-500" />
|
||||||
<CardTitle>Account security</CardTitle>
|
<CardTitle>{t("profilePage.accountSecurity.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Password, two-factor authentication and API tokens live in the Security panel.
|
{t("profilePage.accountSecurity.description")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{onOpenSecurity ? (
|
{onOpenSecurity ? (
|
||||||
<Button variant="outline" onClick={onOpenSecurity}>
|
<Button variant="outline" onClick={onOpenSecurity}>
|
||||||
<Lock className="h-4 w-4 mr-2" />
|
<Lock className="h-4 w-4 mr-2" />
|
||||||
Open Security settings
|
{t("profilePage.accountSecurity.openSecurity")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Open the Security tab from the navigation.
|
{t("profilePage.accountSecurity.fallback")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Settings } from "./settings"
|
|||||||
import { Security } from "./security"
|
import { Security } from "./security"
|
||||||
import { Profile } from "./profile"
|
import { Profile } from "./profile"
|
||||||
import { About } from "./about"
|
import { About } from "./about"
|
||||||
|
import { HostBackup } from "./host-backup"
|
||||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||||
import { HealthStatusModal } from "./health-status-modal"
|
import { HealthStatusModal } from "./health-status-modal"
|
||||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||||
@@ -30,17 +31,27 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
NetworkIcon,
|
NetworkIcon,
|
||||||
Box,
|
Boxes,
|
||||||
Cpu,
|
Cpu,
|
||||||
FileText,
|
ScrollText,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
Settings2,
|
||||||
Terminal,
|
Terminal,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Info,
|
Info,
|
||||||
|
DatabaseBackup,
|
||||||
|
ChevronDown,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { ThemeToggle } from "./theme-toggle"
|
import { ThemeToggle } from "./theme-toggle"
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet"
|
import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "./ui/dropdown-menu"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface SystemStatus {
|
interface SystemStatus {
|
||||||
status: "healthy" | "warning" | "critical"
|
status: "healthy" | "warning" | "critical"
|
||||||
@@ -70,6 +81,7 @@ interface FlaskSystemInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ProxmoxDashboard() {
|
export function ProxmoxDashboard() {
|
||||||
|
const t = useT()
|
||||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
|
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
|
||||||
status: "healthy",
|
status: "healthy",
|
||||||
uptime: "Loading...",
|
uptime: "Loading...",
|
||||||
@@ -88,6 +100,8 @@ export function ProxmoxDashboard() {
|
|||||||
const [lastScrollY, setLastScrollY] = useState(0)
|
const [lastScrollY, setLastScrollY] = useState(0)
|
||||||
const [showHealthModal, setShowHealthModal] = useState(false)
|
const [showHealthModal, setShowHealthModal] = useState(false)
|
||||||
const { showReleaseNotes, setShowReleaseNotes } = useVersionCheck()
|
const { showReleaseNotes, setShowReleaseNotes } = useVersionCheck()
|
||||||
|
const displayServerName = systemStatus.serverName === "Loading..." ? t("app.loading") : systemStatus.serverName
|
||||||
|
const displayUptime = systemStatus.uptime === "Loading..." ? t("app.loading") : systemStatus.uptime || t("app.notAvailable")
|
||||||
|
|
||||||
// Category keys for health info count calculation
|
// Category keys for health info count calculation
|
||||||
const HEALTH_CATEGORY_KEYS = [
|
const HEALTH_CATEGORY_KEYS = [
|
||||||
@@ -158,7 +172,7 @@ export function ProxmoxDashboard() {
|
|||||||
const data: FlaskSystemInfo = await fetchApi("/api/system-info")
|
const data: FlaskSystemInfo = await fetchApi("/api/system-info")
|
||||||
|
|
||||||
const uptimeValue =
|
const uptimeValue =
|
||||||
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : "N/A"
|
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : t("app.notAvailable")
|
||||||
|
|
||||||
const backendStatus = data.health?.status?.toUpperCase() || "OK"
|
const backendStatus = data.health?.status?.toUpperCase() || "OK"
|
||||||
let healthStatus: "healthy" | "warning" | "critical"
|
let healthStatus: "healthy" | "warning" | "critical"
|
||||||
@@ -175,8 +189,8 @@ export function ProxmoxDashboard() {
|
|||||||
status: healthStatus,
|
status: healthStatus,
|
||||||
uptime: uptimeValue,
|
uptime: uptimeValue,
|
||||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||||
serverName: data.hostname || "Unknown",
|
serverName: data.hostname || t("app.unknown"),
|
||||||
nodeId: data.node_id || "Unknown",
|
nodeId: data.node_id || t("app.unknown"),
|
||||||
})
|
})
|
||||||
setIsServerConnected(true)
|
setIsServerConnected(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -186,13 +200,13 @@ export function ProxmoxDashboard() {
|
|||||||
setSystemStatus((prev) => ({
|
setSystemStatus((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
status: "critical",
|
status: "critical",
|
||||||
serverName: "Server Offline",
|
serverName: t("app.serverOffline"),
|
||||||
nodeId: "Server Offline",
|
nodeId: t("app.serverOffline"),
|
||||||
uptime: "N/A",
|
uptime: t("app.notAvailable"),
|
||||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Siempre fetch inicial
|
// Siempre fetch inicial
|
||||||
@@ -284,13 +298,13 @@ export function ProxmoxDashboard() {
|
|||||||
if (
|
if (
|
||||||
systemStatus.serverName &&
|
systemStatus.serverName &&
|
||||||
systemStatus.serverName !== "Loading..." &&
|
systemStatus.serverName !== "Loading..." &&
|
||||||
systemStatus.serverName !== "Server Offline"
|
systemStatus.serverName !== t("app.serverOffline")
|
||||||
) {
|
) {
|
||||||
document.title = `${systemStatus.serverName} - ProxMenux Monitor`
|
document.title = `${systemStatus.serverName} - ProxMenux Monitor`
|
||||||
} else {
|
} else {
|
||||||
document.title = "ProxMenux Monitor"
|
document.title = "ProxMenux Monitor"
|
||||||
}
|
}
|
||||||
}, [systemStatus.serverName])
|
}, [systemStatus.serverName, t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -352,28 +366,19 @@ export function ProxmoxDashboard() {
|
|||||||
|
|
||||||
const getActiveTabLabel = () => {
|
const getActiveTabLabel = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case "overview":
|
case "overview": return t("navigation.overview")
|
||||||
return "Overview"
|
case "vms": return t("navigation.virtualMachines")
|
||||||
case "storage":
|
case "storage": return t("navigation.storage")
|
||||||
return "Storage"
|
case "network": return t("navigation.network")
|
||||||
case "network":
|
case "hardware": return t("navigation.hardware")
|
||||||
return "Network"
|
case "backup": return t("navigation.backup")
|
||||||
case "vms":
|
case "terminal": return t("navigation.terminal")
|
||||||
return "VMs & LXCs"
|
case "logs": return t("navigation.systemLogs")
|
||||||
case "hardware":
|
case "security": return t("navigation.security")
|
||||||
return "Hardware"
|
case "settings": return t("navigation.settings")
|
||||||
case "terminal":
|
case "about": return t("navigation.about")
|
||||||
return "Terminal"
|
case "profile": return t("navigation.profile")
|
||||||
case "logs":
|
default: return t("navigation.menu")
|
||||||
return "System Logs"
|
|
||||||
case "security":
|
|
||||||
return "Security"
|
|
||||||
case "settings":
|
|
||||||
return "Settings"
|
|
||||||
case "profile":
|
|
||||||
return "Profile"
|
|
||||||
default:
|
|
||||||
return "Navigation Menu"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,13 +392,13 @@ export function ProxmoxDashboard() {
|
|||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
<div className="flex items-center space-x-2 text-red-500 mb-2">
|
<div className="flex items-center space-x-2 text-red-500 mb-2">
|
||||||
<XCircle className="h-5 w-5" />
|
<XCircle className="h-5 w-5" />
|
||||||
<span className="font-medium">ProxMenux Server Connection Failed</span>
|
<span className="font-medium">{t("status.connectionFailed")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-red-500/80 space-y-1 ml-7">
|
<div className="text-sm text-red-500/80 space-y-1 ml-7">
|
||||||
<p>• Check that the monitor.service is running correctly.</p>
|
<p>• {t("status.checkService")}</p>
|
||||||
<p>• The ProxMenux server should start automatically on port 8008</p>
|
<p>• {t("status.serverPort")}</p>
|
||||||
<p>
|
<p>
|
||||||
• Try accessing:{" "}
|
• {t("status.tryAccessing")}{" "}
|
||||||
<a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline">
|
<a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline">
|
||||||
{getApiUrl("/api/health")}
|
{getApiUrl("/api/health")}
|
||||||
</a>
|
</a>
|
||||||
@@ -432,11 +437,11 @@ export function ProxmoxDashboard() {
|
|||||||
<Server className="h-8 w-8 md:h-6 md:w-6 text-primary absolute fallback-icon hidden" />
|
<Server className="h-8 w-8 md:h-6 md:w-6 text-primary absolute fallback-icon hidden" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">ProxMenux Monitor</h1>
|
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">{t("app.title")}</h1>
|
||||||
<p className="text-xs md:text-sm text-muted-foreground">Proxmox System Dashboard</p>
|
<p className="text-xs md:text-sm text-muted-foreground">{t("app.description")}</p>
|
||||||
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||||
<Server className="h-3 w-3" />
|
<Server className="h-3 w-3" />
|
||||||
<span className="truncate">Node: {systemStatus.serverName}</span>
|
<span className="truncate">{t("status.node", { node: displayServerName })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,14 +451,14 @@ export function ProxmoxDashboard() {
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Server className="h-4 w-4 text-muted-foreground" />
|
<Server className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="font-medium text-foreground">Node: {systemStatus.serverName}</div>
|
<div className="font-medium text-foreground">{t("status.node", { node: displayServerName })}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className={statusColor}>
|
<Badge variant="outline" className={statusColor}>
|
||||||
{statusIcon}
|
{statusIcon}
|
||||||
<span className="ml-1 capitalize">{systemStatus.status}</span>
|
<span className="ml-1">{t(`status.${systemStatus.status}`)}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
{systemStatus.status === "healthy" && infoCount > 0 && (
|
{systemStatus.status === "healthy" && infoCount > 0 && (
|
||||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
|
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
|
||||||
@@ -464,7 +469,7 @@ export function ProxmoxDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
Uptime: {systemStatus.uptime || "N/A"}
|
{t("status.uptime", { uptime: displayUptime })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -478,7 +483,7 @@ export function ProxmoxDashboard() {
|
|||||||
className="border-border/50 bg-transparent hover:bg-secondary"
|
className="border-border/50 bg-transparent hover:bg-secondary"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||||
Refresh
|
{t("actions.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -512,7 +517,7 @@ export function ProxmoxDashboard() {
|
|||||||
}}
|
}}
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
className="h-8 w-8 p-0 border-border/50 bg-transparent hover:bg-secondary"
|
className="h-8 w-8 p-0 border-border/50 bg-transparent hover:bg-secondary"
|
||||||
aria-label="Refresh"
|
aria-label={t("actions.refresh")}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -540,7 +545,7 @@ export function ProxmoxDashboard() {
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
|
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
|
||||||
{statusIcon}
|
{statusIcon}
|
||||||
<span className="ml-1 capitalize">{systemStatus.status}</span>
|
<span className="ml-1">{t(`status.${systemStatus.status}`)}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
{systemStatus.status === "healthy" && infoCount > 0 && (
|
{systemStatus.status === "healthy" && infoCount > 0 && (
|
||||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs px-2">
|
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs px-2">
|
||||||
@@ -550,7 +555,7 @@ export function ProxmoxDashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
Uptime: {systemStatus.uptime || "N/A"}
|
{t("status.uptime", { uptime: displayUptime })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -565,71 +570,128 @@ export function ProxmoxDashboard() {
|
|||||||
>
|
>
|
||||||
<div className="container mx-auto px-4 lg:px-6 pt-4 lg:pt-6">
|
<div className="container mx-auto px-4 lg:px-6 pt-4 lg:pt-6">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
|
||||||
{/* Issue #191: 10 tabs after adding About. The grid wraps via
|
{/* Sprint 13D nav redesign — 6 top-level slots in usage order:
|
||||||
Tabs primitives so the extra column doesn't push the
|
Overview · VMs & LXCs · Node ⌄ · Backup · Terminal · Admin ⌄
|
||||||
triggers off-screen on common laptop widths. */}
|
Node groups Storage / Network / Hardware (3 sub-items).
|
||||||
<TabsList className="hidden lg:grid w-full grid-cols-10 bg-card border border-border">
|
Admin groups System Logs / Security / Settings / About
|
||||||
<TabsTrigger
|
(will split when RBAC arrives in 1.5.0).
|
||||||
value="overview"
|
Backup is direct now (only Host Backup); becomes a dropdown
|
||||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
when VM/LXC centralised backup ships. */}
|
||||||
>
|
{(() => {
|
||||||
Overview
|
const triggerActiveClass =
|
||||||
</TabsTrigger>
|
"data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||||
<TabsTrigger
|
// Each dropdown lists its children in the order they
|
||||||
value="storage"
|
// render. When one of them is the active tab, the dropdown
|
||||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
// trigger swaps its label + icon to that child — same
|
||||||
>
|
// pattern macOS Settings uses inside a category: the
|
||||||
Storage
|
// crumb shows where you are, the chevron tells you the
|
||||||
</TabsTrigger>
|
// siblings are one click away.
|
||||||
<TabsTrigger
|
const NODE_ITEMS = [
|
||||||
value="network"
|
{ value: "storage", label: t("navigation.storage"), Icon: HardDrive, default: false },
|
||||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
{ value: "network", label: t("navigation.network"), Icon: NetworkIcon, default: false },
|
||||||
>
|
{ value: "hardware", label: t("navigation.hardware"), Icon: Cpu, default: false },
|
||||||
Network
|
]
|
||||||
</TabsTrigger>
|
const ADMIN_ITEMS = [
|
||||||
<TabsTrigger
|
{ value: "logs", label: t("navigation.systemLogs"), Icon: ScrollText, default: false },
|
||||||
value="vms"
|
{ value: "security", label: t("navigation.security"), Icon: ShieldCheck, default: false },
|
||||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
|
||||||
>
|
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
|
||||||
VMs & LXCs
|
]
|
||||||
</TabsTrigger>
|
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
|
||||||
<TabsTrigger
|
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
|
||||||
value="hardware"
|
const isNodeActive = activeNodeItem !== undefined
|
||||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
const isAdminActive = activeAdminItem !== undefined
|
||||||
>
|
// The trigger label + icon shown on the bar. When a child
|
||||||
Hardware
|
// is active we surface IT; otherwise the group default.
|
||||||
</TabsTrigger>
|
const NodeTriggerIcon = activeNodeItem ? activeNodeItem.Icon : Server
|
||||||
<TabsTrigger
|
const NodeTriggerLabel = activeNodeItem ? activeNodeItem.label : t("navigation.node")
|
||||||
value="logs"
|
const AdminTriggerIcon = activeAdminItem ? activeAdminItem.Icon : Settings2
|
||||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
const AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : t("navigation.admin")
|
||||||
>
|
// Dropdown trigger styling: parity with TabsTrigger so the
|
||||||
System Logs
|
// parent visibly carries the "I'm the selected section"
|
||||||
</TabsTrigger>
|
// signal when any of its children is the active tab —
|
||||||
<TabsTrigger
|
// same blue background + white text + rounded as a direct
|
||||||
value="terminal"
|
// tab. Without this the user lands on Storage and the
|
||||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
// entire top bar looks idle.
|
||||||
>
|
const dropdownBtnClass = (active: boolean) =>
|
||||||
Terminal
|
`inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${
|
||||||
</TabsTrigger>
|
active
|
||||||
<TabsTrigger
|
? "bg-blue-500 text-white rounded-md"
|
||||||
value="security"
|
: "text-muted-foreground hover:text-foreground rounded-sm"
|
||||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
}`
|
||||||
>
|
|
||||||
Security
|
return (
|
||||||
</TabsTrigger>
|
<TabsList className="hidden lg:grid w-full grid-cols-6 bg-card border border-border">
|
||||||
<TabsTrigger
|
{/* Direct: Overview */}
|
||||||
value="settings"
|
<TabsTrigger value="overview" className={triggerActiveClass}>
|
||||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||||
>
|
{t("navigation.overview")}
|
||||||
Settings
|
</TabsTrigger>
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
{/* Direct: VMs & LXCs — first-class because Proxmox IS
|
||||||
value="about"
|
a hypervisor; workloads belong at top level. */}
|
||||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
<TabsTrigger value="vms" className={triggerActiveClass}>
|
||||||
>
|
<Boxes className="mr-2 h-4 w-4" />
|
||||||
About
|
{t("navigation.virtualMachines")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
|
||||||
|
{/* Dropdown: Node (Storage / Network / Hardware) */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className={dropdownBtnClass(isNodeActive)}>
|
||||||
|
<NodeTriggerIcon className="mr-2 h-4 w-4" />
|
||||||
|
{NodeTriggerLabel}
|
||||||
|
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" className="min-w-[180px]">
|
||||||
|
{NODE_ITEMS.map(({ value, label, Icon }) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={value}
|
||||||
|
onClick={() => setActiveTab(value)}
|
||||||
|
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
|
||||||
|
>
|
||||||
|
<Icon className="mr-2 h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Direct: Backup (today: Host Backup only). When VM/LXC
|
||||||
|
backup ships this becomes a dropdown. */}
|
||||||
|
<TabsTrigger value="backup" className={triggerActiveClass}>
|
||||||
|
<DatabaseBackup className="mr-2 h-4 w-4" />
|
||||||
|
{t("navigation.backup")}
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
{/* Direct: Terminal */}
|
||||||
|
<TabsTrigger value="terminal" className={triggerActiveClass}>
|
||||||
|
<Terminal className="mr-2 h-4 w-4" />
|
||||||
|
{t("navigation.terminal")}
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
{/* Dropdown: Admin (System Logs / Security / Settings / About) */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className={dropdownBtnClass(isAdminActive)}>
|
||||||
|
<AdminTriggerIcon className="mr-2 h-4 w-4" />
|
||||||
|
{AdminTriggerLabel}
|
||||||
|
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" className="min-w-[180px]">
|
||||||
|
{ADMIN_ITEMS.map(({ value, label, Icon }) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={value}
|
||||||
|
onClick={() => setActiveTab(value)}
|
||||||
|
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
|
||||||
|
>
|
||||||
|
<Icon className="mr-2 h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TabsList>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
@@ -646,158 +708,74 @@ export function ProxmoxDashboard() {
|
|||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
</div>
|
</div>
|
||||||
<SheetContent side="top" className="bg-card border-border">
|
<SheetContent side="top" className="bg-card border-border">
|
||||||
<div className="flex flex-col gap-2 mt-4">
|
{(() => {
|
||||||
<Button
|
// Sheet items mirror the desktop layout: 6 sections,
|
||||||
variant="ghost"
|
// with two of them (Node, Admin) collapsing into a
|
||||||
onClick={() => {
|
// header + nested items. Direct tabs (Overview, VMs,
|
||||||
setActiveTab("overview")
|
// Backup, Terminal) sit at the top level.
|
||||||
setMobileMenuOpen(false)
|
const select = (v: string) => {
|
||||||
}}
|
setActiveTab(v)
|
||||||
className={`w-full justify-start gap-3 ${
|
setMobileMenuOpen(false)
|
||||||
activeTab === "overview"
|
}
|
||||||
|
const itemClass = (active: boolean) =>
|
||||||
|
`w-full justify-start gap-3 ${
|
||||||
|
active
|
||||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`
|
||||||
>
|
// Mobile sheet is a flat list (no section headers).
|
||||||
<LayoutDashboard className="h-5 w-5" />
|
// The desktop layout uses dropdowns to express the
|
||||||
<span>Overview</span>
|
// Node/Admin grouping; here we just enumerate items
|
||||||
</Button>
|
// in the same visual order.
|
||||||
<Button
|
return (
|
||||||
variant="ghost"
|
<div className="flex flex-col gap-1 mt-4">
|
||||||
onClick={() => {
|
<Button variant="ghost" onClick={() => select("overview")} className={itemClass(activeTab === "overview")}>
|
||||||
setActiveTab("storage")
|
<LayoutDashboard className="h-5 w-5" />
|
||||||
setMobileMenuOpen(false)
|
<span>{t("navigation.overview")}</span>
|
||||||
}}
|
</Button>
|
||||||
className={`w-full justify-start gap-3 ${
|
<Button variant="ghost" onClick={() => select("vms")} className={itemClass(activeTab === "vms")}>
|
||||||
activeTab === "storage"
|
<Boxes className="h-5 w-5" />
|
||||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
<span>{t("navigation.virtualMachines")}</span>
|
||||||
: ""
|
</Button>
|
||||||
}`}
|
<Button variant="ghost" onClick={() => select("storage")} className={itemClass(activeTab === "storage")}>
|
||||||
>
|
<HardDrive className="h-5 w-5" />
|
||||||
<HardDrive className="h-5 w-5" />
|
<span>{t("navigation.storage")}</span>
|
||||||
<span>Storage</span>
|
</Button>
|
||||||
</Button>
|
<Button variant="ghost" onClick={() => select("network")} className={itemClass(activeTab === "network")}>
|
||||||
<Button
|
<NetworkIcon className="h-5 w-5" />
|
||||||
variant="ghost"
|
<span>{t("navigation.network")}</span>
|
||||||
onClick={() => {
|
</Button>
|
||||||
setActiveTab("network")
|
<Button variant="ghost" onClick={() => select("hardware")} className={itemClass(activeTab === "hardware")}>
|
||||||
setMobileMenuOpen(false)
|
<Cpu className="h-5 w-5" />
|
||||||
}}
|
<span>{t("navigation.hardware")}</span>
|
||||||
className={`w-full justify-start gap-3 ${
|
</Button>
|
||||||
activeTab === "network"
|
<Button variant="ghost" onClick={() => select("backup")} className={itemClass(activeTab === "backup")}>
|
||||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
<DatabaseBackup className="h-5 w-5" />
|
||||||
: ""
|
<span>{t("navigation.backup")}</span>
|
||||||
}`}
|
</Button>
|
||||||
>
|
<Button variant="ghost" onClick={() => select("terminal")} className={itemClass(activeTab === "terminal")}>
|
||||||
<NetworkIcon className="h-5 w-5" />
|
<Terminal className="h-5 w-5" />
|
||||||
<span>Network</span>
|
<span>{t("navigation.terminal")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" onClick={() => select("logs")} className={itemClass(activeTab === "logs")}>
|
||||||
variant="ghost"
|
<ScrollText className="h-5 w-5" />
|
||||||
onClick={() => {
|
<span>{t("navigation.systemLogs")}</span>
|
||||||
setActiveTab("vms")
|
</Button>
|
||||||
setMobileMenuOpen(false)
|
<Button variant="ghost" onClick={() => select("security")} className={itemClass(activeTab === "security")}>
|
||||||
}}
|
<ShieldCheck className="h-5 w-5" />
|
||||||
className={`w-full justify-start gap-3 ${
|
<span>{t("navigation.security")}</span>
|
||||||
activeTab === "vms"
|
</Button>
|
||||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
<Button variant="ghost" onClick={() => select("settings")} className={itemClass(activeTab === "settings")}>
|
||||||
: ""
|
<SettingsIcon className="h-5 w-5" />
|
||||||
}`}
|
<span>{t("navigation.settings")}</span>
|
||||||
>
|
</Button>
|
||||||
<Box className="h-5 w-5" />
|
<Button variant="ghost" onClick={() => select("about")} className={itemClass(activeTab === "about")}>
|
||||||
<span>VMs & LXCs</span>
|
<Info className="h-5 w-5" />
|
||||||
</Button>
|
<span>{t("navigation.about")}</span>
|
||||||
<Button
|
</Button>
|
||||||
variant="ghost"
|
</div>
|
||||||
onClick={() => {
|
)
|
||||||
setActiveTab("hardware")
|
})()}
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full justify-start gap-3 ${
|
|
||||||
activeTab === "hardware"
|
|
||||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Cpu className="h-5 w-5" />
|
|
||||||
<span>Hardware</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTab("logs")
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full justify-start gap-3 ${
|
|
||||||
activeTab === "logs"
|
|
||||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<FileText className="h-5 w-5" />
|
|
||||||
<span>System Logs</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTab("terminal")
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full justify-start gap-3 ${
|
|
||||||
activeTab === "terminal"
|
|
||||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Terminal className="h-5 w-5" />
|
|
||||||
<span>Terminal</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTab("security")
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full justify-start gap-3 ${
|
|
||||||
activeTab === "security"
|
|
||||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ShieldCheck className="h-5 w-5" />
|
|
||||||
<span>Security</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTab("settings")
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full justify-start gap-3 ${
|
|
||||||
activeTab === "settings"
|
|
||||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<SettingsIcon className="h-5 w-5" />
|
|
||||||
<span>Settings</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTab("about")
|
|
||||||
setMobileMenuOpen(false)
|
|
||||||
}}
|
|
||||||
className={`w-full justify-start gap-3 ${
|
|
||||||
activeTab === "about"
|
|
||||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Info className="h-5 w-5" />
|
|
||||||
<span>About</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -830,6 +808,10 @@ export function ProxmoxDashboard() {
|
|||||||
<SystemLogs key={`logs-${componentKey}`} />
|
<SystemLogs key={`logs-${componentKey}`} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
|
||||||
|
<HostBackup key={`backup-${componentKey}`} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="terminal" className="mt-0">
|
<TabsContent value="terminal" className="mt-0">
|
||||||
<TerminalPanel key={`terminal-${componentKey}`} />
|
<TerminalPanel key={`terminal-${componentKey}`} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -858,7 +840,7 @@ export function ProxmoxDashboard() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
||||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.2</p>
|
<p className="font-medium mb-2">ProxMenux Monitor v1.2.4.1-beta</p>
|
||||||
<p>
|
<p>
|
||||||
<a
|
<a
|
||||||
href="https://ko-fi.com/macrimi"
|
href="https://ko-fi.com/macrimi"
|
||||||
@@ -866,7 +848,7 @@ export function ProxmoxDashboard() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-500 hover:text-blue-600 hover:underline transition-colors"
|
className="text-blue-500 hover:text-blue-600 hover:underline transition-colors"
|
||||||
>
|
>
|
||||||
Support and contribute to the project
|
{t("app.supportProject")}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react"
|
||||||
|
import { Plus, Share, X } from "lucide-react"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// PwaInstallPrompt
|
||||||
|
// ==========================================================
|
||||||
|
// Bottom-sheet shown on mobile when the Monitor is opened in
|
||||||
|
// a browser (not launched as an installed PWA). Two variants:
|
||||||
|
// iOS Safari → manual 3-step instructions
|
||||||
|
// Android → generic "browser menu → Add to Home Screen"
|
||||||
|
//
|
||||||
|
// No `beforeinstallprompt` handling: capturing the event to
|
||||||
|
// drive a custom Install button interacts badly with Chrome's
|
||||||
|
// own "Add to Home Screen" menu — Chromium degrades the manual
|
||||||
|
// path to a plain shortcut when a page has intercepted the
|
||||||
|
// event but hasn't yet called `prompt()`. Installation goes
|
||||||
|
// through the browser's own menu entry and produces a real PWA.
|
||||||
|
//
|
||||||
|
// Never shown on desktop, or when already running standalone.
|
||||||
|
// Dismissal options:
|
||||||
|
// "Not now" → temporary, hidden for 30 days
|
||||||
|
// "Don't show again" → permanent (no expiry)
|
||||||
|
// Backdrop / X → session-only dismiss (reappears on
|
||||||
|
// the next page load)
|
||||||
|
// ==========================================================
|
||||||
|
|
||||||
|
const DISMISSED_FOREVER_KEY = "proxmenux-install-dismissed"
|
||||||
|
const DISMISSED_UNTIL_KEY = "proxmenux-install-dismissed-until"
|
||||||
|
const NOT_NOW_DAYS = 30
|
||||||
|
|
||||||
|
function isMobileDevice(): boolean {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
// Prefer feature detection (coarse pointer + touch) over UA sniffing,
|
||||||
|
// and fall back to UA for the corner case where a mobile browser
|
||||||
|
// reports fine pointer under a desktop-mode toggle.
|
||||||
|
const coarse = window.matchMedia("(pointer: coarse)").matches
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
const uaMobile = /Android|iPhone|iPad|iPod|Mobile|Opera Mini|BlackBerry|IEMobile/i.test(ua)
|
||||||
|
return coarse || uaMobile
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStandalone(): boolean {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
const displayModeStandalone = window.matchMedia("(display-mode: standalone)").matches
|
||||||
|
const iosStandalone = (window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||||
|
return displayModeStandalone || iosStandalone
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIOS(): boolean {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
// iPadOS 13+ reports as MacIntel — detect that too when maxTouchPoints > 1.
|
||||||
|
const iPadMasqueradingAsMac =
|
||||||
|
ua.includes("Macintosh") && (navigator as Navigator & { maxTouchPoints?: number }).maxTouchPoints! > 1
|
||||||
|
return /iPhone|iPad|iPod/i.test(ua) || iPadMasqueradingAsMac
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PwaInstallPrompt() {
|
||||||
|
const t = useT()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [platform, setPlatform] = useState<"ios" | "android" | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
if (!isMobileDevice() || isStandalone()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem(DISMISSED_FOREVER_KEY) === "1") return
|
||||||
|
const untilRaw = localStorage.getItem(DISMISSED_UNTIL_KEY)
|
||||||
|
if (untilRaw) {
|
||||||
|
const until = Number.parseInt(untilRaw, 10)
|
||||||
|
// Corrupt / non-numeric values fall through and the prompt shows,
|
||||||
|
// which is the safe default.
|
||||||
|
if (Number.isFinite(until) && until > Date.now()) return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable (private mode etc.) — treat as not dismissed.
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlatform(isIOS() ? "ios" : "android")
|
||||||
|
setOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleNotNow = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const until = Date.now() + NOT_NOW_DAYS * 24 * 60 * 60 * 1000
|
||||||
|
localStorage.setItem(DISMISSED_UNTIL_KEY, String(until))
|
||||||
|
} catch {
|
||||||
|
// Best-effort; if localStorage fails the user will see the prompt
|
||||||
|
// again next visit, which is the safe default.
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleNeverAgain = useCallback(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DISMISSED_FOREVER_KEY, "1")
|
||||||
|
} catch {
|
||||||
|
// Best-effort; if localStorage fails the user will see the prompt
|
||||||
|
// again next visit, which is the safe default.
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
// Session-only dismiss: closing via X or backdrop does NOT persist,
|
||||||
|
// so the prompt reappears on the next page load. Users who want to
|
||||||
|
// silence it for longer must use "Not now" (30 d) or "Don't show again".
|
||||||
|
setOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!open || !platform) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="pwa-install-title"
|
||||||
|
className="fixed inset-0 z-[100] flex items-end justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) handleClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-t-2xl bg-background text-foreground shadow-2xl border-t border-border animate-in slide-in-from-bottom duration-300"
|
||||||
|
style={{ paddingBottom: "max(1.25rem, env(safe-area-inset-bottom))" }}
|
||||||
|
>
|
||||||
|
<div className="relative px-5 pt-5">
|
||||||
|
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-border" aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
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" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mb-4 flex items-start gap-3.5">
|
||||||
|
<div className="flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-xl bg-muted p-1 shadow-md">
|
||||||
|
<img src="/icon.svg" alt="ProxMenux Monitor" className="h-full w-full object-contain" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 id="pwa-install-title" className="text-[17px] font-bold leading-tight tracking-tight text-foreground">
|
||||||
|
{t("pwaInstall.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-[13px] leading-snug text-muted-foreground">
|
||||||
|
{platform === "ios"
|
||||||
|
? t("pwaInstall.iosDescription")
|
||||||
|
: t("pwaInstall.androidDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{platform === "ios" ? (
|
||||||
|
<ol className="mb-4 flex flex-col gap-2" role="list">
|
||||||
|
<li className="flex items-center gap-3 rounded-xl bg-primary/10 px-3.5 py-3 text-[13.5px] leading-tight">
|
||||||
|
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-bold text-primary-foreground">
|
||||||
|
1
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{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" />
|
||||||
|
{t("pwaInstall.ios.share")}
|
||||||
|
</span>{" "}
|
||||||
|
{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">
|
||||||
|
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-bold text-primary-foreground">
|
||||||
|
2
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{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" />
|
||||||
|
{t("pwaInstall.addToHomeScreen")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-3 rounded-xl bg-primary/10 px-3.5 py-3 text-[13.5px] leading-tight">
|
||||||
|
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-bold text-primary-foreground">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{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">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-1 flex flex-col gap-1 border-t border-border pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNotNow}
|
||||||
|
className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-muted-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
{t("pwaInstall.neverAgain")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
// Unregister any Service Worker on this origin at mount. A SW here
|
||||||
|
// interacts badly with mobile battery throttling behind reverse
|
||||||
|
// proxies. `sw.js` is kept for a future PWA-offline revisit.
|
||||||
|
export function PwaRegister() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
if (!("serviceWorker" in navigator)) return
|
||||||
|
navigator.serviceWorker
|
||||||
|
.getRegistrations()
|
||||||
|
.then((regs) => {
|
||||||
|
if (regs.length === 0) return
|
||||||
|
return Promise.all(regs.map((r) => r.unregister()))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -3,10 +3,11 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||||
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell } from "lucide-react"
|
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 { Checkbox } from "./ui/checkbox"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
const APP_VERSION = "1.2.2" // Sync with AppImage/package.json
|
const APP_VERSION = "1.2.4.1-beta" // Sync with AppImage/package.json
|
||||||
|
|
||||||
interface ReleaseNote {
|
interface ReleaseNote {
|
||||||
date: string
|
date: string
|
||||||
@@ -18,6 +19,21 @@ interface ReleaseNote {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CHANGELOG: Record<string, ReleaseNote> = {
|
export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||||
|
"1.2.3": {
|
||||||
|
date: "July 15, 2026",
|
||||||
|
changes: {
|
||||||
|
added: [
|
||||||
|
"Backups integrated in the Monitor — a new first-class section to create, schedule and restore host backups against Local, PBS or Borg destinations from the Web dashboard. Jobs run on a proper systemd timer or attach to an existing PVE vzdump job with retention live-inherited from the parent. Encrypted PBS backups store a paired recovery blob next to each snapshot so a fresh install can always get the key back. After a reboot the tab shows a real-time restore progress card with milestones, per-component status (NVIDIA, Intel GPU tools, Coral, AMD tools), boot sanity warnings and a rollback delta listing anything on the host that wasn't in the backup.",
|
||||||
|
"Network Flow diagram — a new live topology view on the Network tab showing NICs → host → bridges → LXCs / VMs with animated rx / tx pulses on every internal link, so the operator can see in real time how traffic distributes inside the host and which guests are pulling or pushing data.",
|
||||||
|
"Physical Disks and Physical Interfaces cards redesigned — clearer per-item presentation on the Storage and Network tabs. USB-NVMe / USB-SATA enclosures reporting removable=0 (ASMedia, JMicron, Realtek, ASM105x) now walk sysfs to detect USB attachment, so the -d snt* pass-through is tried and the drive's real model, serial, temperature, power-on hours and health surface — instead of the bridge's chatter.",
|
||||||
|
"Richer notifications out of the box — for users not running an AI agent, the templated body now identifies the affected object (which storage, which interface, which container), surfaces the top offenders with an \"…and N more\" tail when the list is long, and preserves the same identity in the recovery message. Users with AI enrichment enabled continue to get their tailored rewrite on top of this improved base.",
|
||||||
|
],
|
||||||
|
changed: [
|
||||||
|
"Redesigned cards across Overview, VM / LXC, Storage and Network — layouts reworked for faster reading and denser, more practical information: key numbers surface at a glance, grouped by relevance, and the responsive grid now behaves cleanly from a phone up to an ultrawide.",
|
||||||
|
"Health Monitor Thresholds — the Settings panel that controls per-category Warning and Critical levels (CPU, memory, temperature, storage, disks, ...) was reworked with clearer visual grouping and inline hints, so tuning a threshold now takes a couple of clicks instead of scrolling through a wall of numbers.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
"1.2.2": {
|
"1.2.2": {
|
||||||
date: "May 31, 2026",
|
date: "May 31, 2026",
|
||||||
changes: {
|
changes: {
|
||||||
@@ -217,20 +233,12 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
|||||||
|
|
||||||
const CURRENT_VERSION_FEATURES = [
|
const CURRENT_VERSION_FEATURES = [
|
||||||
{
|
{
|
||||||
icon: <Sliders className="h-5 w-5" />,
|
icon: <RefreshCw className="h-5 w-5" />,
|
||||||
text: "Health Monitor Thresholds - Per-category Warning and Critical levels for CPU, memory, temperature, storage and more, configurable from Settings. The same numbers feed the colour ranges of the dashboard widgets, so every green / amber / red state maps to a definite range relative to the configured pair",
|
text: "One-click host update from the Health Monitor — new Update Now button in the System Updates section runs the Proxmox update flow in an in-dashboard terminal, without leaving the browser.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BellOff className="h-5 w-5" />,
|
icon: <Sparkles className="h-5 w-5" />,
|
||||||
text: "Granular dismiss control - Each Health Monitor alert can now be dismissed for 24 hours, 7 days or Permanently via a per-event dropdown. A new Active Suppressions panel in Settings lists every silenced alert with a Re-enable button, gated by Edit mode. Permanent dismisses can only be reverted from there",
|
text: "In-app Install prompt for mobile — first-time visitors on Android and iOS Safari now see a bottom-sheet with clear steps for adding the Monitor to their home screen as a PWA.",
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Bell className="h-5 w-5" />,
|
|
||||||
text: "Apprise notification channel - One Apprise URL reaches ~80 services (Pushover, ntfy, Slack, Matrix, mailto, signal, ...) with full feature parity to the native channels: per-event toggles, Quiet Hours and Daily Digest all apply",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Server className="h-5 w-5" />,
|
|
||||||
text: "LXC update detection - Per-CT apt list --upgradable / apk list -u scan from Settings, with an automatic cache refresh on long-running containers so months-old metadata no longer hides real upstream backlog",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -240,6 +248,7 @@ interface ReleaseNotesModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||||
|
const t = useT()
|
||||||
const [dontShowAgain, setDontShowAgain] = useState(false)
|
const [dontShowAgain, setDontShowAgain] = useState(false)
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -252,7 +261,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-2xl max-h-[85vh] p-0 gap-0 border-0 bg-transparent">
|
<DialogContent className="max-w-2xl max-h-[85vh] p-0 gap-0 border-0 bg-transparent">
|
||||||
<DialogTitle className="sr-only">Release Notes - Version {APP_VERSION}</DialogTitle>
|
<DialogTitle className="sr-only">{t("releaseNotes.dialogTitle", { version: APP_VERSION })}</DialogTitle>
|
||||||
<div className="relative bg-card rounded-lg shadow-2xl h-full flex flex-col max-h-[85vh]">
|
<div className="relative bg-card rounded-lg shadow-2xl h-full flex flex-col max-h-[85vh]">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -278,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="flex-1 overflow-y-auto p-6 md:p-8 space-y-4 md:space-y-6 min-h-0">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-xl md:text-2xl font-bold text-foreground text-balance">
|
<h2 className="text-xl md:text-2xl font-bold text-foreground text-balance">
|
||||||
What's New in Version {APP_VERSION}
|
{t("releaseNotes.title", { version: APP_VERSION })}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
We've added exciting new features and improvements to make ProxMenux Monitor even better!
|
{t("releaseNotes.intro")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -292,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"
|
className="flex items-start gap-2 md:gap-3 p-3 rounded-lg bg-muted/50 border border-border/50 hover:bg-muted/70 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="text-orange-500 mt-0.5 flex-shrink-0">{feature.icon}</div>
|
<div className="text-orange-500 mt-0.5 flex-shrink-0">{feature.icon}</div>
|
||||||
<p className="text-xs md:text-sm text-foreground leading-relaxed">{feature.text}</p>
|
<p className="text-xs md:text-sm text-foreground leading-relaxed">
|
||||||
|
{t(index === 0 ? "releaseNotes.currentFeatures.hostUpdate" : "releaseNotes.currentFeatures.mobileInstall")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -305,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"
|
className="w-full bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4 mr-2" />
|
<Sparkles className="h-4 w-4 mr-2" />
|
||||||
Got it!
|
{t("releaseNotes.gotIt")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
@@ -318,7 +329,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
|||||||
htmlFor="dont-show-version-again"
|
htmlFor="dont-show-version-again"
|
||||||
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
|
className="text-xs md:text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer select-none"
|
||||||
>
|
>
|
||||||
Don't show again for this version
|
{t("releaseNotes.dontShowAgain")}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,723 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
// Live inline card + detail modal for the post-boot restore.
|
||||||
|
//
|
||||||
|
// apply_cluster_postboot.sh writes /var/lib/proxmenux/restore-state.json
|
||||||
|
// as it works through the milestones (apply cluster config, initramfs,
|
||||||
|
// grub, per-component reinstalls, sanity check, finalize). The Flask
|
||||||
|
// endpoints /api/host-backups/restore/{status,dismiss,history,log}
|
||||||
|
// expose that state to this component. While the restore is running we
|
||||||
|
// poll every 2s; once it's terminal (complete|failed) we back off to
|
||||||
|
// 30s so the card can still be re-opened as a summary. Once the
|
||||||
|
// operator hits Dismiss the card collapses and the History button
|
||||||
|
// keeps the run browsable.
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import useSWR from "swr"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { Badge } from "./ui/badge"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "./ui/dialog"
|
||||||
|
import { ScrollArea } from "./ui/scroll-area"
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
History,
|
||||||
|
RotateCcw,
|
||||||
|
ChevronRight,
|
||||||
|
Cpu,
|
||||||
|
FileText,
|
||||||
|
ArrowDownAZ,
|
||||||
|
Filter,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
|
// ── Shape contracts with the backend ──────────────────────────
|
||||||
|
|
||||||
|
interface RestoreComponent {
|
||||||
|
name: string
|
||||||
|
status: "installing" | "ok" | "failed"
|
||||||
|
log: string
|
||||||
|
exit_code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RestoreSummary {
|
||||||
|
hostname: string
|
||||||
|
guests: string
|
||||||
|
stubs: string
|
||||||
|
stale_nodes: string
|
||||||
|
components: string
|
||||||
|
duration: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RestoreRollback {
|
||||||
|
vms_to_remove?: string[]
|
||||||
|
lxcs_to_remove?: string[]
|
||||||
|
components_to_uninstall?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataPoolsImport {
|
||||||
|
ok: string[]
|
||||||
|
forced: string[]
|
||||||
|
partial: string[]
|
||||||
|
missing: string[]
|
||||||
|
failed: string[]
|
||||||
|
finished_at?: string
|
||||||
|
log_path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RestoreState {
|
||||||
|
status: "running" | "complete" | "failed"
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
current_step: string
|
||||||
|
steps_done: number
|
||||||
|
steps_total: number
|
||||||
|
log_path: string
|
||||||
|
components: RestoreComponent[]
|
||||||
|
rollback_delta: RestoreRollback
|
||||||
|
sanity_warnings: string[]
|
||||||
|
summary: RestoreSummary | null
|
||||||
|
acknowledged: boolean
|
||||||
|
duration?: string
|
||||||
|
data_pools_import?: DataPoolsImport
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryEntry {
|
||||||
|
file: string
|
||||||
|
mtime: number
|
||||||
|
status: string
|
||||||
|
started_at: string | null
|
||||||
|
finished_at: string | null
|
||||||
|
duration: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetchApi(url)
|
||||||
|
|
||||||
|
const COMPONENT_LABEL: Record<string, string> = {
|
||||||
|
nvidia_driver: "NVIDIA driver",
|
||||||
|
amdgpu_top: "amdgpu_top",
|
||||||
|
intel_gpu_tools: "Intel GPU tools",
|
||||||
|
coral_driver: "Google Coral TPU driver",
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatComponent = (name: string) => COMPONENT_LABEL[name] ?? name
|
||||||
|
|
||||||
|
const formatIso = (iso: string | null | undefined) => {
|
||||||
|
if (!iso) return "—"
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rough time-remaining estimate derived from steps_done + elapsed.
|
||||||
|
// Best-effort: at step 0 there's no data yet, so it returns
|
||||||
|
// "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, t: Translator): string => {
|
||||||
|
if (state.status !== "running") return "—"
|
||||||
|
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 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" />
|
||||||
|
{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" />
|
||||||
|
{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" />
|
||||||
|
{t("restoreProgress.status.failed")}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
return <Badge variant="outline">{status}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComponentStatusIcon: React.FC<{ status: string }> = ({ status }) => {
|
||||||
|
if (status === "installing")
|
||||||
|
return <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-400" />
|
||||||
|
if (status === "ok")
|
||||||
|
return <CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
|
return <XCircle className="h-3.5 w-3.5 text-red-400" />
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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)}` : ""}`
|
||||||
|
: null
|
||||||
|
const { data, isLoading } = useSWR<{ lines: string[]; total_lines: number; path: string | null }>(
|
||||||
|
swrKey,
|
||||||
|
fetcher,
|
||||||
|
{ refreshInterval: historyOnly ? 0 : 4000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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 ?? t("restoreProgress.log.noLog")}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={filter === "all" ? "default" : "outline"}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => setFilter("all")}
|
||||||
|
>
|
||||||
|
<ArrowDownAZ className="h-3 w-3 mr-1" />
|
||||||
|
{t("restoreProgress.log.full")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={filter === "issues" ? "default" : "outline"}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => setFilter("issues")}
|
||||||
|
>
|
||||||
|
<Filter className="h-3 w-3 mr-1" />
|
||||||
|
{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 ? t("app.loading") : (data?.lines?.join("\n") || t("restoreProgress.log.noOutput"))}
|
||||||
|
</pre>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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">
|
||||||
|
{t("restoreProgress.rollback.empty")}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const Row: React.FC<{ label: string; items: string[]; cmd: (id: string) => string }> = ({ label, items, cmd }) =>
|
||||||
|
items.length === 0 ? null : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{items.map((id) => (
|
||||||
|
<Badge key={id} variant="outline" className="font-mono text-xs">
|
||||||
|
{id}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<details className="text-xs">
|
||||||
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||||
|
{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")}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("restoreProgress.rollback.description")}
|
||||||
|
</div>
|
||||||
|
<Row
|
||||||
|
label={t("restoreProgress.rollback.vms")}
|
||||||
|
items={vms}
|
||||||
|
cmd={(id) => `qm stop ${id} 2>/dev/null; qm destroy ${id} --purge`}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label={t("restoreProgress.rollback.lxcs")}
|
||||||
|
items={lxcs}
|
||||||
|
cmd={(id) => `pct stop ${id} 2>/dev/null; pct destroy ${id} --purge`}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label={t("restoreProgress.rollback.components")}
|
||||||
|
items={comps}
|
||||||
|
cmd={(name) => t("restoreProgress.rollback.uninstallComponentCommand", { name })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail modal ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const RestoreDetailModal: React.FC<{
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<RotateCcw className="h-5 w-5 text-blue-500" />
|
||||||
|
{t("restoreProgress.title")}
|
||||||
|
<StatusBadge status={state.status} />
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{state.current_step || "—"}</span>
|
||||||
|
<span>
|
||||||
|
{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">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-500 ${
|
||||||
|
state.status === "failed" ? "bg-red-500" : state.status === "complete" ? "bg-emerald-500" : "bg-blue-500"
|
||||||
|
}`}
|
||||||
|
style={{ width: `${progressPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.components.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Cpu className="h-4 w-4" />
|
||||||
|
{t("restoreProgress.sections.components")}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{state.components.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.name}
|
||||||
|
className="flex items-center justify-between rounded-md border border-border bg-muted/30 px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ComponentStatusIcon status={c.status} />
|
||||||
|
<span className="font-medium">{formatComponent(c.name)}</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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.sanity_warnings.length > 0 && (
|
||||||
|
<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" />
|
||||||
|
{t("restoreProgress.sections.bootWarnings")}
|
||||||
|
</div>
|
||||||
|
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-1">
|
||||||
|
{state.sanity_warnings.map((w) => (
|
||||||
|
<li key={w}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.data_pools_import && <DataPoolsBlock section={state.data_pools_import} />}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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">{t("restoreProgress.sections.log")}</div>
|
||||||
|
<LogViewer path={state.log_path} historyOnly={historyMode} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t("actions.close")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 +
|
||||||
|
section.partial.length +
|
||||||
|
section.missing.length +
|
||||||
|
section.failed.length
|
||||||
|
if (total === 0) return null
|
||||||
|
|
||||||
|
const Row: React.FC<{
|
||||||
|
label: string
|
||||||
|
tone: "ok" | "warn" | "info" | "error"
|
||||||
|
items: string[]
|
||||||
|
help?: string
|
||||||
|
}> = ({ label, tone, items, help }) => {
|
||||||
|
if (items.length === 0) return null
|
||||||
|
const toneClass =
|
||||||
|
tone === "ok"
|
||||||
|
? "text-emerald-400"
|
||||||
|
: tone === "warn"
|
||||||
|
? "text-amber-400"
|
||||||
|
: tone === "error"
|
||||||
|
? "text-red-400"
|
||||||
|
: "text-blue-400"
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
|
||||||
|
<div className={`font-medium ${toneClass} flex items-center gap-2`}>
|
||||||
|
{tone === "ok" && <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||||
|
{tone === "warn" && <AlertTriangle className="h-3.5 w-3.5" />}
|
||||||
|
{tone === "error" && <XCircle className="h-3.5 w-3.5" />}
|
||||||
|
{tone === "info" && <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="text-muted-foreground">({items.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-mono text-muted-foreground break-all">{items.join(", ")}</div>
|
||||||
|
{help && <div className="mt-1 text-muted-foreground">{help}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Cpu className="h-4 w-4" />
|
||||||
|
{t("restoreProgress.dataPools.title")}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Row label={t("restoreProgress.dataPools.imported")} tone="ok" items={section.ok} />
|
||||||
|
<Row
|
||||||
|
label={t("restoreProgress.dataPools.importedForced")}
|
||||||
|
tone="info"
|
||||||
|
items={section.forced}
|
||||||
|
help={t("restoreProgress.dataPools.importedForcedHelp")}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label={t("restoreProgress.dataPools.skippedPartial")}
|
||||||
|
tone="warn"
|
||||||
|
items={section.partial}
|
||||||
|
help={t("restoreProgress.dataPools.skippedPartialHelp")}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label={t("restoreProgress.dataPools.skippedMissing")}
|
||||||
|
tone="warn"
|
||||||
|
items={section.missing}
|
||||||
|
help={t("restoreProgress.dataPools.skippedMissingHelp")}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label={t("restoreProgress.dataPools.importFailed")}
|
||||||
|
tone="error"
|
||||||
|
items={section.failed}
|
||||||
|
help={t("restoreProgress.dataPools.importFailedHelp")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{section.log_path && (
|
||||||
|
<div className="text-xs text-muted-foreground font-mono">{t("restoreProgress.dataPools.logPath", { path: section.log_path })}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 }>(
|
||||||
|
detailFile ? `/api/host-backups/restore/history?file=${encodeURIComponent(detailFile)}` : null,
|
||||||
|
fetcher,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<History className="h-5 w-5" />
|
||||||
|
{t("restoreProgress.history.title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{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">{t("restoreProgress.history.empty")}</div>
|
||||||
|
) : (
|
||||||
|
(data?.entries ?? []).map((e) => (
|
||||||
|
<button
|
||||||
|
key={e.file}
|
||||||
|
onClick={() => setDetailFile(e.file)}
|
||||||
|
className="w-full flex items-center justify-between rounded-md border border-border bg-muted/30 hover:bg-muted px-3 py-2 text-xs text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusBadge status={e.status} />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{e.started_at ? formatIso(e.started_at) : formatIso(new Date(e.mtime * 1000).toISOString())}
|
||||||
|
</span>
|
||||||
|
{e.duration && <span className="text-muted-foreground">· {e.duration}</span>}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t("actions.close")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{detailFile && detailResp?.state && (
|
||||||
|
<RestoreDetailModal
|
||||||
|
open={!!detailFile}
|
||||||
|
onClose={() => setDetailFile(null)}
|
||||||
|
state={detailResp.state}
|
||||||
|
historyMode
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main inline card ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export const RestoreProgressCard: React.FC = () => {
|
||||||
|
const t = useT()
|
||||||
|
const { data, mutate } = useSWR<{ state: RestoreState | null }>(
|
||||||
|
"/api/host-backups/restore/status",
|
||||||
|
fetcher,
|
||||||
|
{
|
||||||
|
refreshInterval: (latest) => (latest?.state?.status === "running" ? 2000 : 30000),
|
||||||
|
revalidateOnFocus: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
|
const [historyOpen, setHistoryOpen] = useState(false)
|
||||||
|
const [dismissing, setDismissing] = useState(false)
|
||||||
|
|
||||||
|
const state = data?.state ?? null
|
||||||
|
const progressPct = useMemo(() => {
|
||||||
|
if (!state || state.steps_total <= 0) return 0
|
||||||
|
return Math.round((state.steps_done / state.steps_total) * 100)
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
const dismiss = async () => {
|
||||||
|
if (!state) return
|
||||||
|
setDismissing(true)
|
||||||
|
try {
|
||||||
|
await fetchApi("/api/host-backups/restore/dismiss", { method: "POST" })
|
||||||
|
await mutate()
|
||||||
|
} finally {
|
||||||
|
setDismissing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hidden entirely when: no restore run has ever happened, OR the
|
||||||
|
// last run is terminal AND acknowledged. History button is still
|
||||||
|
// reachable from the main card header (rendered elsewhere).
|
||||||
|
if (!state) return null
|
||||||
|
if (state.status !== "running" && state.acknowledged) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setHistoryOpen(true)}>
|
||||||
|
<History className="h-3.5 w-3.5 mr-1" />
|
||||||
|
{t("restoreProgress.history.title")}
|
||||||
|
</Button>
|
||||||
|
<RestoreHistoryModal open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasWarnings = state.sanity_warnings.length > 0
|
||||||
|
const pools = state.data_pools_import
|
||||||
|
const poolCount =
|
||||||
|
(pools?.ok.length ?? 0) +
|
||||||
|
(pools?.forced.length ?? 0) +
|
||||||
|
(pools?.partial.length ?? 0) +
|
||||||
|
(pools?.missing.length ?? 0) +
|
||||||
|
(pools?.failed.length ?? 0)
|
||||||
|
const poolWarnings = (pools?.partial.length ?? 0) + (pools?.missing.length ?? 0) + (pools?.failed.length ?? 0)
|
||||||
|
const barColor =
|
||||||
|
state.status === "failed" ? "bg-red-500" : state.status === "complete" ? "bg-emerald-500" : "bg-blue-500"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<RotateCcw
|
||||||
|
className={`h-5 w-5 ${state.status === "running" ? "text-blue-500 animate-spin" : "text-blue-500"}`}
|
||||||
|
/>
|
||||||
|
{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" />
|
||||||
|
{t("restoreProgress.badges.bootWarnings", { count: state.sanity_warnings.length })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{poolCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
poolWarnings > 0
|
||||||
|
? "text-amber-400 border-amber-500/40 bg-amber-500/10 gap-1"
|
||||||
|
: "text-emerald-400 border-emerald-500/40 bg-emerald-500/10 gap-1"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Cpu className="h-3 w-3" />
|
||||||
|
{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)}>
|
||||||
|
{t("restoreProgress.actions.details")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setHistoryOpen(true)}>
|
||||||
|
<History className="h-3.5 w-3.5 mr-1" />
|
||||||
|
{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" /> : t("restoreProgress.actions.dismiss")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span className="truncate">
|
||||||
|
{state.current_step || "—"} · {t("restoreProgress.startedRelative", { time: formatRelative(state.started_at, t) })}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{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>
|
||||||
|
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div className={`h-full transition-all duration-500 ${barColor}`} style={{ width: `${progressPct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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">{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">{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">{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">{t("restoreProgress.summary.components")}</div>
|
||||||
|
<div className="font-medium">{state.summary.components}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<RestoreDetailModal open={detailOpen} onClose={() => setDetailOpen(false)} state={state} />
|
||||||
|
<RestoreHistoryModal open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RestoreProgressCard
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
import "xterm/css/xterm.css"
|
import "xterm/css/xterm.css"
|
||||||
import { API_PORT } from "@/lib/api-config"
|
import { API_PORT } from "@/lib/api-config"
|
||||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface WebInteraction {
|
interface WebInteraction {
|
||||||
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
|
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
|
||||||
@@ -49,6 +50,12 @@ interface ScriptTerminalModalProps {
|
|||||||
description: string
|
description: string
|
||||||
scriptName?: string
|
scriptName?: string
|
||||||
params?: Record<string, string>
|
params?: Record<string, 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
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScriptTerminalModal({
|
export function ScriptTerminalModal({
|
||||||
@@ -58,7 +65,9 @@ export function ScriptTerminalModal({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
params = { EXECUTION_MODE: "web" },
|
params = { EXECUTION_MODE: "web" },
|
||||||
|
onComplete,
|
||||||
}: ScriptTerminalModalProps) {
|
}: ScriptTerminalModalProps) {
|
||||||
|
const t = useT()
|
||||||
const termRef = useRef<any>(null)
|
const termRef = useRef<any>(null)
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
// Mirrors `isOpen` for use inside async closures (initializeTerminal)
|
// Mirrors `isOpen` for use inside async closures (initializeTerminal)
|
||||||
@@ -95,6 +104,13 @@ export function ScriptTerminalModal({
|
|||||||
paramsRef.current = params
|
paramsRef.current = params
|
||||||
}, [params])
|
}, [params])
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
useEffect(() => {
|
||||||
|
onCompleteRef.current = onComplete
|
||||||
|
}, [onComplete])
|
||||||
|
|
||||||
const attemptReconnect = useCallback(() => {
|
const attemptReconnect = useCallback(() => {
|
||||||
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
|
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||||
return
|
return
|
||||||
@@ -195,6 +211,7 @@ const initMessage = {
|
|||||||
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
|
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
|
||||||
} else {
|
} else {
|
||||||
setIsComplete(true)
|
setIsComplete(true)
|
||||||
|
onCompleteRef.current?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -284,6 +301,11 @@ const initMessage = {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (fitAddonRef.current && termRef.current) {
|
if (fitAddonRef.current && termRef.current) {
|
||||||
fitAddonRef.current.fit()
|
fitAddonRef.current.fit()
|
||||||
|
// Send the keyboard to the xterm instance so any --yesno /
|
||||||
|
// --menu the script opens receives Enter / arrow keys
|
||||||
|
// directly. Without this the modal's Close button (or any
|
||||||
|
// other focusable descendant) intercepts the keystrokes.
|
||||||
|
termRef.current.focus()
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
@@ -368,12 +390,12 @@ const initMessage = {
|
|||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
setConnectionStatus("offline")
|
setConnectionStatus("offline")
|
||||||
term.writeln("\x1b[31mWebSocket error occurred\x1b[0m")
|
term.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
setConnectionStatus("offline")
|
setConnectionStatus("offline")
|
||||||
term.writeln("\x1b[33mConnection closed\x1b[0m")
|
term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`)
|
||||||
|
|
||||||
if (keepAliveIntervalRef.current) {
|
if (keepAliveIntervalRef.current) {
|
||||||
clearInterval(keepAliveIntervalRef.current)
|
clearInterval(keepAliveIntervalRef.current)
|
||||||
@@ -382,6 +404,7 @@ const initMessage = {
|
|||||||
|
|
||||||
if (!isComplete) {
|
if (!isComplete) {
|
||||||
setIsComplete(true)
|
setIsComplete(true)
|
||||||
|
onCompleteRef.current?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,6 +689,13 @@ const initMessage = {
|
|||||||
}}
|
}}
|
||||||
onInteractOutside={(e) => e.preventDefault()}
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||||
|
// Radix defaults to focusing the first focusable descendant
|
||||||
|
// when a Dialog opens — that used to land on the Close button
|
||||||
|
// and every Enter/Space keystroke intended for the shell
|
||||||
|
// dialogs INSIDE the terminal would close the modal instead.
|
||||||
|
// Preempting the auto-focus lets the xterm focus() call in
|
||||||
|
// initializeTerminal below own the keyboard from the start.
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
hideClose
|
hideClose
|
||||||
>
|
>
|
||||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||||
@@ -684,7 +714,7 @@ const initMessage = {
|
|||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||||
<p className="text-sm text-muted-foreground">Processing...</p>
|
<p className="text-sm text-muted-foreground">{t("scriptTerminal.processing")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -807,29 +837,29 @@ const initMessage = {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onSelect={() => sendCommand("\x03")}>
|
<DropdownMenuItem onSelect={() => sendCommand("\x03")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||||
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => sendCommand("\x18")}>
|
<DropdownMenuItem onSelect={() => sendCommand("\x18")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||||
<span className="text-muted-foreground text-xs">Exit (nano)</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => sendCommand("\x12")}>
|
<DropdownMenuItem onSelect={() => sendCommand("\x12")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
||||||
<span className="text-muted-foreground text-xs">Search history</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
||||||
<Copy className="h-3.5 w-3.5 mr-2" />
|
<Copy className="h-3.5 w-3.5 mr-2" />
|
||||||
<span className="text-xs">Copy selection</span>
|
<span className="text-xs">{t("scriptTerminal.copySelection")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
||||||
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
||||||
<span className="text-xs">Paste</span>
|
<span className="text-xs">{t("scriptTerminal.paste")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -849,18 +879,18 @@ const initMessage = {
|
|||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
connectionStatus === "online"
|
connectionStatus === "online"
|
||||||
? "Connected"
|
? t("scriptTerminal.connected")
|
||||||
: connectionStatus === "connecting"
|
: connectionStatus === "connecting"
|
||||||
? "Connecting"
|
? t("scriptTerminal.connecting")
|
||||||
: "Disconnected"
|
: t("scriptTerminal.disconnected")
|
||||||
}
|
}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{connectionStatus === "online"
|
{connectionStatus === "online"
|
||||||
? "Online"
|
? t("scriptTerminal.online")
|
||||||
: connectionStatus === "connecting"
|
: connectionStatus === "connecting"
|
||||||
? "Connecting..."
|
? t("scriptTerminal.connectingStatus")
|
||||||
: "Offline"}
|
: t("scriptTerminal.offline")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -869,7 +899,7 @@ const initMessage = {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
className="bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||||
>
|
>
|
||||||
Close
|
{t("actions.close")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -905,14 +935,14 @@ const initMessage = {
|
|||||||
onClick={() => handleInteractionResponse("yes")}
|
onClick={() => handleInteractionResponse("yes")}
|
||||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white transition-all duration-150"
|
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white transition-all duration-150"
|
||||||
>
|
>
|
||||||
Yes
|
{t("scriptTerminal.yes")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleInteractionResponse("cancel")}
|
onClick={() => handleInteractionResponse("cancel")}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -935,14 +965,14 @@ const initMessage = {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
className="w-full hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && (
|
{(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Your input:</Label>
|
<Label>{t("scriptTerminal.yourInput")}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={interactionInput}
|
value={interactionInput}
|
||||||
onChange={(e) => setInteractionInput(e.target.value)}
|
onChange={(e) => setInteractionInput(e.target.value)}
|
||||||
@@ -959,14 +989,14 @@ const initMessage = {
|
|||||||
onClick={() => handleInteractionResponse(interactionInput)}
|
onClick={() => handleInteractionResponse(interactionInput)}
|
||||||
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
||||||
>
|
>
|
||||||
Submit
|
{t("scriptTerminal.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleInteractionResponse("cancel")}
|
onClick={() => handleInteractionResponse("cancel")}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -978,14 +1008,14 @@ const initMessage = {
|
|||||||
onClick={() => handleInteractionResponse("ok")}
|
onClick={() => handleInteractionResponse("ok")}
|
||||||
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
||||||
>
|
>
|
||||||
OK
|
{t("scriptTerminal.ok")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleInteractionResponse("cancel")}
|
onClick={() => handleInteractionResponse("cancel")}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
ArrowUpCircle,
|
ArrowUpCircle,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface NetworkInfo {
|
interface NetworkInfo {
|
||||||
interface: string
|
interface: string
|
||||||
@@ -77,6 +78,20 @@ interface WizardStep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SecureGatewaySetup() {
|
export function SecureGatewaySetup() {
|
||||||
|
const t = useT()
|
||||||
|
const sg = (key: string, params?: Record<string, string | number>) => t(`securityPage.secureGateway.${key}`, params)
|
||||||
|
const maybeSg = (key: string, fallback?: string) => {
|
||||||
|
const fullKey = `securityPage.secureGateway.${key}`
|
||||||
|
const value = t(fullKey)
|
||||||
|
return value === fullKey ? fallback || "" : value
|
||||||
|
}
|
||||||
|
const fieldText = (fieldName: string, part: string, fallback?: string) =>
|
||||||
|
maybeSg(`schema.${fieldName}.${part}`, fallback)
|
||||||
|
const optionText = (fieldName: string, value: string, part: string, fallback?: string) =>
|
||||||
|
maybeSg(`schema.${fieldName}.options.${value}.${part}`, fallback)
|
||||||
|
const stepText = (step: WizardStep, part: "title" | "description") =>
|
||||||
|
maybeSg(`steps.${step.id}.${part}`, step[part])
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [runtimeAvailable, setRuntimeAvailable] = useState(false)
|
const [runtimeAvailable, setRuntimeAvailable] = useState(false)
|
||||||
@@ -207,7 +222,7 @@ export function SecureGatewaySetup() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load data:", err)
|
console.error("Failed to load data:", err)
|
||||||
setLoadError(err instanceof Error ? err.message : "Failed to load wizard data")
|
setLoadError(err instanceof Error ? err.message : sg("errors.loadWizardFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -265,7 +280,7 @@ export function SecureGatewaySetup() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
if (res?.success) {
|
if (res?.success) {
|
||||||
setUpdateResultMsg(res.message || "Update applied")
|
setUpdateResultMsg(res.message || sg("messages.updateApplied"))
|
||||||
// Re-probe with force=true so the panel flips back to "No
|
// Re-probe with force=true so the panel flips back to "No
|
||||||
// updates available" immediately, bypassing the 24h server
|
// updates available" immediately, bypassing the 24h server
|
||||||
// cache which may still hold the pre-apply "available" entry.
|
// cache which may still hold the pre-apply "available" entry.
|
||||||
@@ -274,10 +289,10 @@ export function SecureGatewaySetup() {
|
|||||||
// refresh that too so the action buttons render the right state.
|
// refresh that too so the action buttons render the right state.
|
||||||
await loadStatus()
|
await loadStatus()
|
||||||
} else {
|
} else {
|
||||||
setUpdateError(res?.message || "Update failed")
|
setUpdateError(res?.message || sg("errors.updateFailed"))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setUpdateError(err instanceof Error ? err.message : "Network error during update")
|
setUpdateError(err instanceof Error ? err.message : sg("errors.networkUpdateFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setUpdateApplying(false)
|
setUpdateApplying(false)
|
||||||
}
|
}
|
||||||
@@ -293,7 +308,7 @@ export function SecureGatewaySetup() {
|
|||||||
if (deploying) return
|
if (deploying) return
|
||||||
setDeploying(true)
|
setDeploying(true)
|
||||||
setDeployError("")
|
setDeployError("")
|
||||||
setDeployProgress("Preparing deployment...")
|
setDeployProgress(sg("messages.preparingDeployment"))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
@@ -302,7 +317,7 @@ export function SecureGatewaySetup() {
|
|||||||
for (const fieldName of step.fields) {
|
for (const fieldName of step.fields) {
|
||||||
const field = configSchema?.[fieldName]
|
const field = configSchema?.[fieldName]
|
||||||
if (field?.required && !config[fieldName]) {
|
if (field?.required && !config[fieldName]) {
|
||||||
setDeployError(`${field.label} is required`)
|
setDeployError(sg("errors.fieldRequired", { field: fieldText(fieldName, "label", field.label) }))
|
||||||
setDeploying(false)
|
setDeploying(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -326,7 +341,7 @@ export function SecureGatewaySetup() {
|
|||||||
}
|
}
|
||||||
// For "custom", the user has already selected networks manually
|
// For "custom", the user has already selected networks manually
|
||||||
|
|
||||||
setDeployProgress("Creating LXC container...")
|
setDeployProgress(sg("messages.creatingLxc"))
|
||||||
|
|
||||||
const result = await fetchApi("/api/oci/deploy", {
|
const result = await fetchApi("/api/oci/deploy", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -338,16 +353,16 @@ export function SecureGatewaySetup() {
|
|||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
// Make runtime errors more user-friendly
|
// Make runtime errors more user-friendly
|
||||||
let errorMsg = result.message || "Deployment failed"
|
let errorMsg = result.message || sg("errors.deploymentFailed")
|
||||||
if (errorMsg.includes("9.1") || errorMsg.includes("OCI") || errorMsg.includes("not supported")) {
|
if (errorMsg.includes("9.1") || errorMsg.includes("OCI") || errorMsg.includes("not supported")) {
|
||||||
errorMsg = "OCI containers require Proxmox VE 9.1 or later. Please upgrade your Proxmox installation to use this feature."
|
errorMsg = sg("errors.ociRequiresPve")
|
||||||
}
|
}
|
||||||
setDeployError(errorMsg)
|
setDeployError(errorMsg)
|
||||||
setDeploying(false)
|
setDeploying(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeployProgress("Gateway deployed successfully!")
|
setDeployProgress(sg("messages.gatewayDeployed"))
|
||||||
|
|
||||||
// Wipe the Tailscale auth_key from React state so it's no longer
|
// Wipe the Tailscale auth_key from React state so it's no longer
|
||||||
// reachable from a future XSS / state-inspection. The key only needs
|
// reachable from a future XSS / state-inspection. The key only needs
|
||||||
@@ -376,7 +391,7 @@ export function SecureGatewaySetup() {
|
|||||||
}, 2000)
|
}, 2000)
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setDeployError(err.message || "Deployment failed")
|
setDeployError(err.message || sg("errors.deploymentFailed"))
|
||||||
setDeploying(false)
|
setDeploying(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,7 +415,7 @@ export function SecureGatewaySetup() {
|
|||||||
|
|
||||||
const handleUpdateAuthKey = async () => {
|
const handleUpdateAuthKey = async () => {
|
||||||
if (!newAuthKey.trim()) {
|
if (!newAuthKey.trim()) {
|
||||||
setUpdateAuthKeyError("Auth Key is required")
|
setUpdateAuthKeyError(sg("errors.authKeyRequired"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +432,7 @@ export function SecureGatewaySetup() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setUpdateAuthKeyError(result.message || "Failed to update auth key")
|
setUpdateAuthKeyError(result.message || sg("errors.updateAuthKeyFailed"))
|
||||||
setUpdateAuthKeyLoading(false)
|
setUpdateAuthKeyLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -427,7 +442,7 @@ export function SecureGatewaySetup() {
|
|||||||
setNewAuthKey("")
|
setNewAuthKey("")
|
||||||
await loadStatus()
|
await loadStatus()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setUpdateAuthKeyError(err.message || "Failed to update auth key")
|
setUpdateAuthKeyError(err.message || sg("errors.updateAuthKeyFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setUpdateAuthKeyLoading(false)
|
setUpdateAuthKeyLoading(false)
|
||||||
}
|
}
|
||||||
@@ -456,10 +471,10 @@ export function SecureGatewaySetup() {
|
|||||||
try {
|
try {
|
||||||
const result = await fetchApi("/api/oci/installed/secure-gateway/logs?lines=100")
|
const result = await fetchApi("/api/oci/installed/secure-gateway/logs?lines=100")
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setLogs(result.logs || "No logs available")
|
setLogs(result.logs || sg("logs.empty"))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLogs("Failed to load logs")
|
setLogs(sg("logs.failed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLogsLoading(false)
|
setLogsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -476,16 +491,16 @@ export function SecureGatewaySetup() {
|
|||||||
// date-only string. Used in the Updates panel — the user wants to know
|
// date-only string. Used in the Updates panel — the user wants to know
|
||||||
// "how stale is this number" without seeing the raw 2026-05-09T10:23Z.
|
// "how stale is this number" without seeing the raw 2026-05-09T10:23Z.
|
||||||
const formatLastChecked = (iso?: string): string => {
|
const formatLastChecked = (iso?: string): string => {
|
||||||
if (!iso) return "never"
|
if (!iso) return sg("values.never")
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
if (isNaN(d.getTime())) return "unknown"
|
if (isNaN(d.getTime())) return t("common.unknown")
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const ageMs = now - d.getTime()
|
const ageMs = now - d.getTime()
|
||||||
const sameDay = new Date(now).toDateString() === d.toDateString()
|
const sameDay = new Date(now).toDateString() === d.toDateString()
|
||||||
const yesterday = new Date(now - 86_400_000).toDateString() === d.toDateString()
|
const yesterday = new Date(now - 86_400_000).toDateString() === d.toDateString()
|
||||||
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
if (sameDay) return time
|
if (sameDay) return time
|
||||||
if (yesterday) return `yesterday ${time}`
|
if (yesterday) return sg("values.yesterdayAt", { time })
|
||||||
if (ageMs < 7 * 86_400_000) {
|
if (ageMs < 7 * 86_400_000) {
|
||||||
return d.toLocaleDateString([], { weekday: "short" }) + " " + time
|
return d.toLocaleDateString([], { weekday: "short" }) + " " + time
|
||||||
}
|
}
|
||||||
@@ -495,6 +510,11 @@ export function SecureGatewaySetup() {
|
|||||||
const renderField = (fieldName: string) => {
|
const renderField = (fieldName: string) => {
|
||||||
const field = configSchema?.[fieldName]
|
const field = configSchema?.[fieldName]
|
||||||
if (!field) return null
|
if (!field) return null
|
||||||
|
const translatedLabel = fieldText(fieldName, "label", field.label)
|
||||||
|
const translatedDescription = fieldText(fieldName, "description", field.description)
|
||||||
|
const translatedPlaceholder = fieldText(fieldName, "placeholder", field.placeholder)
|
||||||
|
const translatedWarning = fieldText(fieldName, "warning", field.warning)
|
||||||
|
const translatedHelpText = fieldText(fieldName, "helpText", field.help_text)
|
||||||
|
|
||||||
// Check depends_on
|
// Check depends_on
|
||||||
if (field.depends_on) {
|
if (field.depends_on) {
|
||||||
@@ -511,7 +531,7 @@ export function SecureGatewaySetup() {
|
|||||||
return (
|
return (
|
||||||
<div key={fieldName} className="space-y-2">
|
<div key={fieldName} className="space-y-2">
|
||||||
<Label htmlFor={fieldName} className="text-sm font-medium">
|
<Label htmlFor={fieldName} className="text-sm font-medium">
|
||||||
{field.label}
|
{translatedLabel}
|
||||||
{field.required && <span className="text-red-500 ml-1">*</span>}
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -520,7 +540,7 @@ export function SecureGatewaySetup() {
|
|||||||
type={isVisible ? "text" : "password"}
|
type={isVisible ? "text" : "password"}
|
||||||
value={config[fieldName] || ""}
|
value={config[fieldName] || ""}
|
||||||
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
|
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
|
||||||
placeholder={field.placeholder}
|
placeholder={translatedPlaceholder}
|
||||||
className="pr-10 bg-background border-border"
|
className="pr-10 bg-background border-border"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -536,7 +556,7 @@ export function SecureGatewaySetup() {
|
|||||||
{isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
|
||||||
{field.help_url && (
|
{field.help_url && (
|
||||||
<a
|
<a
|
||||||
href={field.help_url}
|
href={field.help_url}
|
||||||
@@ -544,7 +564,7 @@ export function SecureGatewaySetup() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1"
|
className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{field.help_text || "Learn more"} <ExternalLink className="h-3 w-3" />
|
{translatedHelpText || sg("learnMore")} <ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -554,7 +574,7 @@ export function SecureGatewaySetup() {
|
|||||||
return (
|
return (
|
||||||
<div key={fieldName} className="space-y-2">
|
<div key={fieldName} className="space-y-2">
|
||||||
<Label htmlFor={fieldName} className="text-sm font-medium">
|
<Label htmlFor={fieldName} className="text-sm font-medium">
|
||||||
{field.label}
|
{translatedLabel}
|
||||||
{field.required && <span className="text-red-500 ml-1">*</span>}
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -562,10 +582,10 @@ export function SecureGatewaySetup() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={config[fieldName] || ""}
|
value={config[fieldName] || ""}
|
||||||
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
|
onChange={(e) => setConfig({ ...config, [fieldName]: e.target.value })}
|
||||||
placeholder={field.placeholder}
|
placeholder={translatedPlaceholder}
|
||||||
className="bg-background border-border"
|
className="bg-background border-border"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -596,7 +616,7 @@ export function SecureGatewaySetup() {
|
|||||||
return (
|
return (
|
||||||
<div key={fieldName} className="space-y-3">
|
<div key={fieldName} className="space-y-3">
|
||||||
<Label className="text-sm font-medium">
|
<Label className="text-sm font-medium">
|
||||||
{field.label}
|
{translatedLabel}
|
||||||
{field.required && <span className="text-red-500 ml-1">*</span>}
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -619,15 +639,15 @@ export function SecureGatewaySetup() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="font-medium text-sm">{opt.label}</p>
|
<p className="font-medium text-sm">{optionText(fieldName, opt.value, "label", opt.label)}</p>
|
||||||
{opt.description && (
|
{opt.description && (
|
||||||
<p className="text-xs text-muted-foreground">{opt.description}</p>
|
<p className="text-xs text-muted-foreground">{optionText(fieldName, opt.value, "description", opt.description)}</p>
|
||||||
)}
|
)}
|
||||||
{/* Show selected network for proxmox_network */}
|
{/* Show selected network for proxmox_network */}
|
||||||
{fieldName === "access_mode" && opt.value === "proxmox_network" && config[fieldName] === "proxmox_network" && (
|
{fieldName === "access_mode" && opt.value === "proxmox_network" && config[fieldName] === "proxmox_network" && (
|
||||||
<p className="text-xs text-cyan-400 mt-1 flex items-center gap-1">
|
<p className="text-xs text-cyan-400 mt-1 flex items-center gap-1">
|
||||||
<Network className="h-3 w-3" />
|
<Network className="h-3 w-3" />
|
||||||
{networks.find((n) => n.recommended)?.subnet || networks[0]?.subnet || "No network detected"}
|
{networks.find((n) => n.recommended)?.subnet || networks[0]?.subnet || sg("noNetworkDetected")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -642,13 +662,13 @@ export function SecureGatewaySetup() {
|
|||||||
return (
|
return (
|
||||||
<div key={fieldName} className="space-y-3">
|
<div key={fieldName} className="space-y-3">
|
||||||
<Label className="text-sm font-medium">
|
<Label className="text-sm font-medium">
|
||||||
{field.label}
|
{translatedLabel}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
|
||||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||||
{networks.length === 0 ? (
|
{networks.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground p-3 bg-muted/30 rounded">
|
<p className="text-sm text-muted-foreground p-3 bg-muted/30 rounded">
|
||||||
No networks detected
|
{sg("noNetworksDetected")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
networks.map((net) => {
|
networks.map((net) => {
|
||||||
@@ -676,7 +696,7 @@ export function SecureGatewaySetup() {
|
|||||||
<span className="font-mono text-sm">{net.subnet}</span>
|
<span className="font-mono text-sm">{net.subnet}</span>
|
||||||
{net.recommended && (
|
{net.recommended && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
|
||||||
Recommended
|
{sg("recommended")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -705,12 +725,12 @@ export function SecureGatewaySetup() {
|
|||||||
>
|
>
|
||||||
<Checkbox checked={config[fieldName] || false} className="pointer-events-none mt-0.5" />
|
<Checkbox checked={config[fieldName] || false} className="pointer-events-none mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">{field.label}</p>
|
<p className="font-medium text-sm">{translatedLabel}</p>
|
||||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
<p className="text-xs text-muted-foreground">{translatedDescription}</p>
|
||||||
{field.warning && config[fieldName] && (
|
{field.warning && config[fieldName] && (
|
||||||
<p className="text-xs text-cyan-400 mt-2 flex items-start gap-1.5 bg-cyan-500/10 p-2 rounded">
|
<p className="text-xs text-cyan-400 mt-2 flex items-start gap-1.5 bg-cyan-500/10 p-2 rounded">
|
||||||
<Info className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
<Info className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||||
{field.warning}
|
{translatedWarning}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -736,40 +756,40 @@ export function SecureGatewaySetup() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<h3 className="text-lg font-semibold">Secure Remote Access</h3>
|
<h3 className="text-lg font-semibold">{sg("wizard.introTitle")}</h3>
|
||||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||||
Deploy a VPN gateway using Tailscale for secure, zero-trust access to your Proxmox infrastructure without opening ports.
|
{sg("wizard.introDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||||
<h4 className="text-sm font-medium">What you{"'"}ll get:</h4>
|
<h4 className="text-sm font-medium">{sg("wizard.whatYouGet")}</h4>
|
||||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
Access ProxMenux Monitor from anywhere
|
{sg("wizard.benefitMonitorAnywhere")}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
Secure access to Proxmox web UI
|
{sg("wizard.benefitProxmoxUi")}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
Optionally expose VMs and LXC containers
|
{sg("wizard.benefitVmLxc")}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
End-to-end encryption
|
{sg("wizard.benefitEncryption")}
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center gap-2">
|
<li className="flex items-center gap-2">
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||||
No port forwarding required
|
{sg("wizard.benefitNoPorts")}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-3">
|
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-3">
|
||||||
<p className="text-xs text-cyan-400 flex items-start gap-2">
|
<p className="text-xs text-cyan-400 flex items-start gap-2">
|
||||||
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||||
You{"'"}ll need a free Tailscale account. If you don{"'"}t have one, you can create it at{" "}
|
{sg("wizard.tailscaleAccountBefore")}{" "}
|
||||||
<a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="underline hover:text-cyan-300">
|
<a href="https://tailscale.com" target="_blank" rel="noopener noreferrer" className="underline hover:text-cyan-300">
|
||||||
tailscale.com
|
tailscale.com
|
||||||
</a>
|
</a>
|
||||||
@@ -783,17 +803,17 @@ export function SecureGatewaySetup() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<h3 className="text-lg font-semibold">Review & Deploy</h3>
|
<h3 className="text-lg font-semibold">{sg("wizard.reviewDeploy")}</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Review your configuration before deploying the gateway.
|
{sg("wizard.reviewDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Storage selector */}
|
{/* Storage selector */}
|
||||||
{storages.length > 1 && (
|
{storages.length > 1 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-sm font-medium">Storage Location</Label>
|
<Label className="text-sm font-medium">{sg("wizard.storageLocation")}</Label>
|
||||||
<p className="text-xs text-muted-foreground">Select where to create the container disk.</p>
|
<p className="text-xs text-muted-foreground">{sg("wizard.storageDescription")}</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{storages.filter(s => s.active && s.enabled).map((storage) => (
|
{storages.filter(s => s.active && s.enabled).map((storage) => (
|
||||||
<div
|
<div
|
||||||
@@ -819,12 +839,12 @@ export function SecureGatewaySetup() {
|
|||||||
<span className="text-xs text-muted-foreground">({storage.type})</span>
|
<span className="text-xs text-muted-foreground">({storage.type})</span>
|
||||||
{storage.recommended && (
|
{storage.recommended && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/10 text-green-500">
|
||||||
Recommended
|
{sg("recommended")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{(storage.avail / 1024 / 1024 / 1024).toFixed(1)} GB available
|
{sg("wizard.gbAvailable", { amount: (storage.avail / 1024 / 1024 / 1024).toFixed(1) })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -835,41 +855,41 @@ export function SecureGatewaySetup() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||||
<h4 className="text-sm font-medium">Configuration Summary</h4>
|
<h4 className="text-sm font-medium">{sg("wizard.configurationSummary")}</h4>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Hostname:</span>
|
<span className="text-muted-foreground">{sg("wizard.hostname")}:</span>
|
||||||
<span className="font-mono">{config.hostname || "proxmox-gateway"}</span>
|
<span className="font-mono">{config.hostname || "proxmox-gateway"}</span>
|
||||||
</div>
|
</div>
|
||||||
{storages.length > 1 && (
|
{storages.length > 1 && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Storage:</span>
|
<span className="text-muted-foreground">{sg("wizard.storage")}:</span>
|
||||||
<span className="font-mono">{config.storage || storages[0]?.name}</span>
|
<span className="font-mono">{config.storage || storages[0]?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Access Mode:</span>
|
<span className="text-muted-foreground">{sg("wizard.accessMode")}:</span>
|
||||||
<span>{config.access_mode === "host_only" ? "Host Only" : config.access_mode === "proxmox_network" ? "Proxmox Network" : "Custom Networks"}</span>
|
<span>{config.access_mode === "host_only" ? sg("wizard.accessModes.hostOnly") : config.access_mode === "proxmox_network" ? sg("wizard.accessModes.proxmoxNetwork") : sg("wizard.accessModes.customNetworks")}</span>
|
||||||
</div>
|
</div>
|
||||||
{config.access_mode === "host_only" && hostIp && (
|
{config.access_mode === "host_only" && hostIp && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Host Access:</span>
|
<span className="text-muted-foreground">{sg("wizard.hostAccess")}:</span>
|
||||||
<span className="text-right font-mono text-xs">{hostIp}/32</span>
|
<span className="text-right font-mono text-xs">{hostIp}/32</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(config.access_mode === "proxmox_network" || config.access_mode === "custom") && config.advertise_routes?.length > 0 && (
|
{(config.access_mode === "proxmox_network" || config.access_mode === "custom") && config.advertise_routes?.length > 0 && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Networks:</span>
|
<span className="text-muted-foreground">{sg("wizard.networks")}:</span>
|
||||||
<span className="text-right font-mono text-xs">{config.advertise_routes.join(", ")}</span>
|
<span className="text-right font-mono text-xs">{config.advertise_routes.join(", ")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Exit Node:</span>
|
<span className="text-muted-foreground">{sg("wizard.exitNode")}:</span>
|
||||||
<span>{config.exit_node ? "Yes" : "No"}</span>
|
<span>{config.exit_node ? sg("values.yes") : sg("values.no")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Accept Routes:</span>
|
<span className="text-muted-foreground">{sg("wizard.acceptRoutes")}:</span>
|
||||||
<span>{config.accept_routes ? "Yes" : "No"}</span>
|
<span>{config.accept_routes ? sg("values.yes") : sg("values.no")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -880,12 +900,12 @@ export function SecureGatewaySetup() {
|
|||||||
<p className="text-xs text-cyan-400 flex items-start gap-2">
|
<p className="text-xs text-cyan-400 flex items-start gap-2">
|
||||||
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
<Info className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||||
<span>
|
<span>
|
||||||
<strong>Important:</strong> After deployment, you must approve the subnet route in Tailscale Admin for remote access to work.
|
<strong>{sg("wizard.important")}:</strong> {sg("wizard.approvalRequired")}
|
||||||
{config.exit_node && <span> You{"'"}ll also need to approve the exit node.</span>}
|
{config.exit_node && <span> {sg("wizard.exitNodeApprovalRequired")}</span>}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground ml-6">
|
<p className="text-xs text-muted-foreground ml-6">
|
||||||
We{"'"}ll show you exactly what to do after the gateway is deployed.
|
{sg("wizard.showAfterDeploy")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -915,8 +935,8 @@ export function SecureGatewaySetup() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<h3 className="text-lg font-semibold">{step.title}</h3>
|
<h3 className="text-lg font-semibold">{stepText(step, "title")}</h3>
|
||||||
<p className="text-sm text-muted-foreground">{step.description}</p>
|
<p className="text-sm text-muted-foreground">{stepText(step, "description")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{step.fields?.map((fieldName) => renderField(fieldName))}
|
{step.fields?.map((fieldName) => renderField(fieldName))}
|
||||||
@@ -932,7 +952,7 @@ export function SecureGatewaySetup() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
<CardTitle className="text-base">{sg("title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -953,14 +973,14 @@ export function SecureGatewaySetup() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
<CardTitle className="text-base">{sg("title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3 py-2">
|
<div className="space-y-3 py-2">
|
||||||
<p className="text-sm text-red-500">Could not load setup data: {loadError}</p>
|
<p className="text-sm text-red-500">{sg("errors.couldNotLoadSetupData")} {loadError}</p>
|
||||||
<Button size="sm" variant="outline" onClick={() => loadInitialData()}>
|
<Button size="sm" variant="outline" onClick={() => loadInitialData()}>
|
||||||
Retry
|
{sg("retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -981,7 +1001,7 @@ export function SecureGatewaySetup() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
<CardTitle className="text-base">{sg("title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${
|
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
isRunning ? "bg-green-500/10 text-green-500" :
|
isRunning ? "bg-green-500/10 text-green-500" :
|
||||||
@@ -991,16 +1011,16 @@ export function SecureGatewaySetup() {
|
|||||||
{isRunning ? <Wifi className="h-3 w-3" /> :
|
{isRunning ? <Wifi className="h-3 w-3" /> :
|
||||||
isStopped ? <Square className="h-3 w-3" /> :
|
isStopped ? <Square className="h-3 w-3" /> :
|
||||||
<XCircle className="h-3 w-3" />}
|
<XCircle className="h-3 w-3" />}
|
||||||
{isRunning ? "Connected" : isStopped ? "Stopped" : "Error"}
|
{isRunning ? sg("status.connected") : isStopped ? sg("status.stopped") : sg("status.error")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>Tailscale VPN Gateway</CardDescription>
|
<CardDescription>{sg("installed.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Status info */}
|
{/* Status info */}
|
||||||
{isRunning && appStatus.uptime_seconds > 0 && (
|
{isRunning && appStatus.uptime_seconds > 0 && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Uptime: {formatUptime(appStatus.uptime_seconds)}
|
{sg("installed.uptime")}: {formatUptime(appStatus.uptime_seconds)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1018,7 +1038,7 @@ export function SecureGatewaySetup() {
|
|||||||
) : (
|
) : (
|
||||||
<Play className="h-4 w-4 mr-1" />
|
<Play className="h-4 w-4 mr-1" />
|
||||||
)}
|
)}
|
||||||
Start
|
{sg("actions.start")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isRunning && (
|
{isRunning && (
|
||||||
@@ -1034,7 +1054,7 @@ export function SecureGatewaySetup() {
|
|||||||
) : (
|
) : (
|
||||||
<Square className="h-4 w-4 mr-1" />
|
<Square className="h-4 w-4 mr-1" />
|
||||||
)}
|
)}
|
||||||
Stop
|
{sg("actions.stop")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1047,7 +1067,7 @@ export function SecureGatewaySetup() {
|
|||||||
) : (
|
) : (
|
||||||
<RotateCw className="h-4 w-4 mr-1" />
|
<RotateCw className="h-4 w-4 mr-1" />
|
||||||
)}
|
)}
|
||||||
Restart
|
{sg("actions.restart")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1060,7 +1080,7 @@ export function SecureGatewaySetup() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4 mr-1" />
|
<FileText className="h-4 w-4 mr-1" />
|
||||||
Logs
|
{sg("actions.logs")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1070,7 +1090,7 @@ export function SecureGatewaySetup() {
|
|||||||
disabled={actionLoading !== null}
|
disabled={actionLoading !== null}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
Remove
|
{sg("actions.remove")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1083,9 +1103,9 @@ export function SecureGatewaySetup() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Last checked: {formatLastChecked(updateInfo.last_checked_iso)} ·{" "}
|
{sg("updates.lastChecked")}: {formatLastChecked(updateInfo.last_checked_iso)} ·{" "}
|
||||||
<span className="text-purple-400 font-medium">
|
<span className="text-purple-400 font-medium">
|
||||||
Tailscale v{updateInfo.latest_version} available
|
{sg("updates.tailscaleAvailable", { version: updateInfo.latest_version || "" })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1101,24 +1121,23 @@ export function SecureGatewaySetup() {
|
|||||||
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
||||||
)}
|
)}
|
||||||
{updateApplying
|
{updateApplying
|
||||||
? "Updating…"
|
? sg("updates.updating")
|
||||||
: `Update to v${updateInfo.latest_version}`}
|
: sg("updates.updateToVersion", { version: updateInfo.latest_version || "" })}
|
||||||
</Button>
|
</Button>
|
||||||
{updateInfo.packages && updateInfo.packages.length > 1 && (
|
{updateInfo.packages && updateInfo.packages.length > 1 && (
|
||||||
<div className="text-[11px] text-muted-foreground">
|
<div className="text-[11px] text-muted-foreground">
|
||||||
+{updateInfo.packages.length - 1} other package
|
{sg("updates.otherPackagesPending", { count: updateInfo.packages.length - 1 })}
|
||||||
{updateInfo.packages.length > 2 ? "s" : ""} pending in the container
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Last checked: {formatLastChecked(updateInfo.last_checked_iso)}
|
{sg("updates.lastChecked")}: {formatLastChecked(updateInfo.last_checked_iso)}
|
||||||
{updateInfo.current_version
|
{updateInfo.current_version
|
||||||
? ` · Tailscale v${updateInfo.current_version}`
|
? ` · Tailscale v${updateInfo.current_version}`
|
||||||
: ""}
|
: ""}
|
||||||
{" · "}
|
{" · "}
|
||||||
<span className="text-green-500/80">No updates available</span>
|
<span className="text-green-500/80">{sg("updates.noneAvailable")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{updateError && (
|
{updateError && (
|
||||||
@@ -1146,7 +1165,7 @@ export function SecureGatewaySetup() {
|
|||||||
className="text-xs h-7 px-2"
|
className="text-xs h-7 px-2"
|
||||||
>
|
>
|
||||||
<Key className="h-3 w-3 mr-1" />
|
<Key className="h-3 w-3 mr-1" />
|
||||||
Update Auth Key
|
{sg("authKey.update")}
|
||||||
</Button>
|
</Button>
|
||||||
<a
|
<a
|
||||||
href="https://login.tailscale.com/admin/machines"
|
href="https://login.tailscale.com/admin/machines"
|
||||||
@@ -1154,7 +1173,7 @@ export function SecureGatewaySetup() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1"
|
className="text-xs text-cyan-500 hover:text-cyan-400 inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
Open Tailscale Admin <ExternalLink className="h-3 w-3" />
|
{sg("tailscale.openAdmin")} <ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -1164,8 +1183,8 @@ export function SecureGatewaySetup() {
|
|||||||
<Dialog open={showLogs} onOpenChange={setShowLogs}>
|
<Dialog open={showLogs} onOpenChange={setShowLogs}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Secure Gateway Logs</DialogTitle>
|
<DialogTitle>{sg("logs.title")}</DialogTitle>
|
||||||
<DialogDescription>Recent container logs</DialogDescription>
|
<DialogDescription>{sg("logs.description")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="bg-black/50 rounded-lg p-4 max-h-96 overflow-auto">
|
<div className="bg-black/50 rounded-lg p-4 max-h-96 overflow-auto">
|
||||||
{logsLoading ? (
|
{logsLoading ? (
|
||||||
@@ -1174,14 +1193,14 @@ export function SecureGatewaySetup() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<pre className="text-xs font-mono text-green-400 whitespace-pre-wrap">
|
<pre className="text-xs font-mono text-green-400 whitespace-pre-wrap">
|
||||||
{logs || "No logs available"}
|
{logs || sg("logs.empty")}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button variant="outline" size="sm" onClick={loadLogs}>
|
<Button variant="outline" size="sm" onClick={loadLogs}>
|
||||||
<RotateCw className="h-4 w-4 mr-1" />
|
<RotateCw className="h-4 w-4 mr-1" />
|
||||||
Refresh
|
{t("actions.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -1191,14 +1210,14 @@ export function SecureGatewaySetup() {
|
|||||||
<Dialog open={showRemoveConfirm} onOpenChange={setShowRemoveConfirm}>
|
<Dialog open={showRemoveConfirm} onOpenChange={setShowRemoveConfirm}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Remove Secure Gateway?</DialogTitle>
|
<DialogTitle>{sg("remove.title")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
This will stop and remove the gateway container. Your Tailscale state will be preserved for re-deployment.
|
{sg("remove.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => setShowRemoveConfirm(false)}>
|
<Button variant="outline" onClick={() => setShowRemoveConfirm(false)}>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -1210,7 +1229,7 @@ export function SecureGatewaySetup() {
|
|||||||
) : (
|
) : (
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
)}
|
)}
|
||||||
Remove
|
{sg("actions.remove")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -1228,16 +1247,16 @@ export function SecureGatewaySetup() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Key className="h-5 w-5 text-cyan-500" />
|
<Key className="h-5 w-5 text-cyan-500" />
|
||||||
Update Auth Key
|
{sg("authKey.update")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Enter a new Tailscale auth key to re-authenticate the gateway. This is useful if your previous key has expired.
|
{sg("authKey.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">New Auth Key</label>
|
<label className="text-sm font-medium">{sg("authKey.newKey")}</label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={newAuthKey}
|
value={newAuthKey}
|
||||||
@@ -1246,14 +1265,14 @@ export function SecureGatewaySetup() {
|
|||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Generate a new key at{" "}
|
{sg("authKey.generateAt")}{" "}
|
||||||
<a
|
<a
|
||||||
href="https://login.tailscale.com/admin/settings/keys"
|
href="https://login.tailscale.com/admin/settings/keys"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-cyan-500 hover:text-cyan-400 underline"
|
className="text-cyan-500 hover:text-cyan-400 underline"
|
||||||
>
|
>
|
||||||
Tailscale Admin > Settings > Keys
|
{sg("authKey.adminKeys")}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1267,7 +1286,7 @@ export function SecureGatewaySetup() {
|
|||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="outline" onClick={() => setShowUpdateAuthKey(false)}>
|
<Button variant="outline" onClick={() => setShowUpdateAuthKey(false)}>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpdateAuthKey}
|
onClick={handleUpdateAuthKey}
|
||||||
@@ -1279,7 +1298,7 @@ export function SecureGatewaySetup() {
|
|||||||
) : (
|
) : (
|
||||||
<Key className="h-4 w-4 mr-2" />
|
<Key className="h-4 w-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
Update Key
|
{sg("authKey.updateKey")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -1291,10 +1310,10 @@ export function SecureGatewaySetup() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
Gateway Deployed Successfully
|
{sg("postDeploy.title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
One more step to complete the setup
|
{sg("postDeploy.description")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -1302,17 +1321,17 @@ export function SecureGatewaySetup() {
|
|||||||
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-4">
|
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-lg p-4">
|
||||||
<p className="text-sm font-medium text-cyan-400 flex items-center gap-2 mb-2">
|
<p className="text-sm font-medium text-cyan-400 flex items-center gap-2 mb-2">
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
Next Step: Approve in Tailscale Admin
|
{sg("postDeploy.nextStep")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
You need to approve the following settings in your Tailscale admin console for them to take effect:
|
{sg("postDeploy.approveDescription")}
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
{deployedConfig.advertise_routes?.length > 0 && (
|
{deployedConfig.advertise_routes?.length > 0 && (
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<Network className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
|
<Network className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Subnet Routes:</span>
|
<span className="font-medium">{sg("postDeploy.subnetRoutes")}:</span>
|
||||||
<span className="text-muted-foreground ml-1">
|
<span className="text-muted-foreground ml-1">
|
||||||
{deployedConfig.advertise_routes.join(", ")}
|
{deployedConfig.advertise_routes.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
@@ -1323,9 +1342,9 @@ export function SecureGatewaySetup() {
|
|||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<Globe className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
|
<Globe className="h-4 w-4 text-cyan-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Exit Node:</span>
|
<span className="font-medium">{sg("postDeploy.exitNode")}:</span>
|
||||||
<span className="text-muted-foreground ml-1">
|
<span className="text-muted-foreground ml-1">
|
||||||
Route all internet traffic
|
{sg("postDeploy.routeAllTraffic")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -1334,30 +1353,30 @@ export function SecureGatewaySetup() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
|
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
|
||||||
<p className="text-sm font-medium">How to approve:</p>
|
<p className="text-sm font-medium">{sg("postDeploy.howToApprove")}</p>
|
||||||
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||||
<li>Click the button below to open Tailscale Admin</li>
|
<li>{sg("postDeploy.stepOpenAdmin")}</li>
|
||||||
<li>Find <span className="font-mono text-cyan-400">{deployedConfig.hostname || "proxmox-gateway"}</span> in the machines list</li>
|
<li>{sg("postDeploy.stepFindBefore")} <span className="font-mono text-cyan-400">{deployedConfig.hostname || "proxmox-gateway"}</span> {sg("postDeploy.stepFindAfter")}</li>
|
||||||
<li>Click on it to open machine details</li>
|
<li>{sg("postDeploy.stepOpenDetails")}</li>
|
||||||
<li>In the <strong>Subnets</strong> section, click <strong>Edit</strong> and enable the route</li>
|
<li>{sg("postDeploy.stepSubnetsBefore")} <strong>Subnets</strong> {sg("postDeploy.stepSubnetsMiddle")} <strong>Edit</strong> {sg("postDeploy.stepSubnetsAfter")}</li>
|
||||||
{deployedConfig.exit_node && (
|
{deployedConfig.exit_node && (
|
||||||
<li>In <strong>Routing Settings</strong>, enable <strong>Exit Node</strong></li>
|
<li>{sg("postDeploy.stepRoutingBefore")} <strong>Routing Settings</strong>, {sg("postDeploy.stepRoutingMiddle")} <strong>Exit Node</strong></li>
|
||||||
)}
|
)}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3">
|
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-3">
|
||||||
<p className="text-xs text-green-400">
|
<p className="text-xs text-green-400">
|
||||||
Once approved, you can access your Proxmox host at{" "}
|
{sg("postDeploy.accessAfterApproval")}{" "}
|
||||||
<span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8006</span> (Proxmox UI) or{" "}
|
<span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8006</span> (Proxmox UI) {sg("postDeploy.or")}{" "}
|
||||||
<span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8008</span> (ProxMenux Monitor) from any device with Tailscale.
|
<span className="font-mono">{deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8008</span> (ProxMenux Monitor) {sg("postDeploy.fromAnyDevice")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="outline" onClick={() => setShowPostDeployInfo(false)}>
|
<Button variant="outline" onClick={() => setShowPostDeployInfo(false)}>
|
||||||
I{"'"}ll do it later
|
{sg("postDeploy.doLater")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1366,7 +1385,7 @@ export function SecureGatewaySetup() {
|
|||||||
}}
|
}}
|
||||||
className="bg-cyan-600 hover:bg-cyan-700"
|
className="bg-cyan-600 hover:bg-cyan-700"
|
||||||
>
|
>
|
||||||
Open Tailscale Admin
|
{sg("tailscale.openAdmin")}
|
||||||
<ExternalLink className="h-4 w-4 ml-2" />
|
<ExternalLink className="h-4 w-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1383,13 +1402,13 @@ export function SecureGatewaySetup() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
<CardTitle className="text-base">Secure Gateway</CardTitle>
|
<CardTitle className="text-base">{sg("title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>VPN access without opening ports</CardDescription>
|
<CardDescription>{sg("notInstalled.subtitle")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Deploy a Tailscale VPN gateway for secure remote access to your Proxmox infrastructure. No port forwarding required.
|
{sg("notInstalled.description")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -1397,7 +1416,7 @@ export function SecureGatewaySetup() {
|
|||||||
className="bg-cyan-600 hover:bg-cyan-700"
|
className="bg-cyan-600 hover:bg-cyan-700"
|
||||||
>
|
>
|
||||||
<ShieldCheck className="h-4 w-4 mr-2" />
|
<ShieldCheck className="h-4 w-4 mr-2" />
|
||||||
Deploy Secure Gateway
|
{sg("notInstalled.deploy")}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1418,7 +1437,7 @@ export function SecureGatewaySetup() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||||
Secure Gateway Setup
|
{sg("wizard.setupTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -1465,7 +1484,7 @@ export function SecureGatewaySetup() {
|
|||||||
}}
|
}}
|
||||||
disabled={currentStep === 0 || deploying}
|
disabled={currentStep === 0 || deploying}
|
||||||
>
|
>
|
||||||
Back
|
{sg("actions.back")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{currentStep < wizardSteps.length - 1 ? (
|
{currentStep < wizardSteps.length - 1 ? (
|
||||||
@@ -1480,7 +1499,7 @@ export function SecureGatewaySetup() {
|
|||||||
}}
|
}}
|
||||||
className="bg-cyan-600 hover:bg-cyan-700"
|
className="bg-cyan-600 hover:bg-cyan-700"
|
||||||
>
|
>
|
||||||
Continue
|
{sg("actions.continue")}
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -1492,12 +1511,12 @@ export function SecureGatewaySetup() {
|
|||||||
{deploying ? (
|
{deploying ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
Deploying...
|
{sg("actions.deploying")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Play className="h-4 w-4 mr-2" />
|
<Play className="h-4 w-4 mr-2" />
|
||||||
Deploy Gateway
|
{sg("actions.deployGateway")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+663
-556
File diff suppressed because it is too large
Load Diff
+180
-107
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
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 { Badge } from "./ui/badge"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { NotificationSettings } from "./notification-settings"
|
import { NotificationSettings } from "./notification-settings"
|
||||||
@@ -14,6 +14,8 @@ import { Switch } from "./ui/switch"
|
|||||||
import { Input } from "./ui/input"
|
import { Input } from "./ui/input"
|
||||||
import { getNetworkUnit } from "../lib/format-network"
|
import { getNetworkUnit } from "../lib/format-network"
|
||||||
import { fetchApi } from "../lib/api-config"
|
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
|
// GitHub Dark color palette for bash syntax highlighting
|
||||||
const BASH_KEYWORDS = new Set([
|
const BASH_KEYWORDS = new Set([
|
||||||
@@ -167,13 +169,13 @@ interface SuppressionCategory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SUPPRESSION_OPTIONS = [
|
const SUPPRESSION_OPTIONS = [
|
||||||
{ value: "24", label: "24 hours" },
|
{ value: "24", labelKey: "settings.healthMonitor.options.24h" },
|
||||||
{ value: "72", label: "3 days" },
|
{ value: "72", labelKey: "settings.healthMonitor.options.3d" },
|
||||||
{ value: "168", label: "1 week" },
|
{ value: "168", labelKey: "settings.healthMonitor.options.1w" },
|
||||||
{ value: "720", label: "1 month" },
|
{ value: "720", labelKey: "settings.healthMonitor.options.1m" },
|
||||||
{ value: "8760", label: "1 year" },
|
{ value: "8760", labelKey: "settings.healthMonitor.options.1y" },
|
||||||
{ value: "custom", label: "Custom" },
|
{ value: "custom", labelKey: "settings.healthMonitor.options.custom" },
|
||||||
{ value: "-1", label: "Permanent" },
|
{ value: "-1", labelKey: "settings.healthMonitor.options.permanent" },
|
||||||
]
|
]
|
||||||
|
|
||||||
const CATEGORY_ICONS: Record<string, React.ElementType> = {
|
const CATEGORY_ICONS: Record<string, React.ElementType> = {
|
||||||
@@ -246,6 +248,15 @@ function normalizeErrorKey(key: string): string {
|
|||||||
return `${desc}: ${resourceParts.join("_")}`
|
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 {
|
interface ProxMenuxTool {
|
||||||
key: string
|
key: string
|
||||||
name: string
|
name: string
|
||||||
@@ -297,6 +308,13 @@ interface NetworkInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Settings() {
|
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 [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
|
||||||
const [updatesAvailableCount, setUpdatesAvailableCount] = useState(0)
|
const [updatesAvailableCount, setUpdatesAvailableCount] = useState(0)
|
||||||
const [loadingTools, setLoadingTools] = useState(true)
|
const [loadingTools, setLoadingTools] = useState(true)
|
||||||
@@ -469,11 +487,11 @@ export function Settings() {
|
|||||||
if (entries.length === 0) return
|
if (entries.length === 0) return
|
||||||
const batch = entries.map(e => `${e.source}:${e.function}:${e.key}`).join("\n")
|
const batch = entries.map(e => `${e.source}:${e.function}:${e.key}`).join("\n")
|
||||||
const title = entries.length === 1
|
const title = entries.length === 1
|
||||||
? `Update: ${entries[0].name}`
|
? t("settings.optimizations.updateOneTitle", { name: entries[0].name })
|
||||||
: `Update ${entries.length} optimizations`
|
: t("settings.optimizations.updateManyTitle", { count: entries.length })
|
||||||
const description = entries.length === 1
|
const description = entries.length === 1
|
||||||
? `Re-running ${entries[0].function} from the ${entries[0].source} flow.`
|
? t("settings.optimizations.updateOneDescription", { functionName: entries[0].function, source: entries[0].source })
|
||||||
: `Re-running ${entries.length} post-install functions in sequence.`
|
: t("settings.optimizations.updateManyDescription", { count: entries.length })
|
||||||
setUpdateTerminal({
|
setUpdateTerminal({
|
||||||
open: true,
|
open: true,
|
||||||
title,
|
title,
|
||||||
@@ -899,21 +917,82 @@ export function Settings() {
|
|||||||
k => pendingChanges[k] !== -2
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Settings</h1>
|
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
|
||||||
<p className="text-muted-foreground mt-2">Manage your dashboard preferences</p>
|
<p className="text-muted-foreground mt-2">{t("settings.description")}</p>
|
||||||
</div>
|
</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 */}
|
{/* Network Units Settings */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Ruler className="h-5 w-5 text-green-500" />
|
<Ruler className="h-5 w-5 text-green-500" />
|
||||||
<CardTitle>Network Units</CardTitle>
|
<CardTitle>{t("settings.networkUnits.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>Change how network traffic is displayed</CardDescription>
|
<CardDescription>{t("settings.networkUnits.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loadingUnitSettings ? (
|
{loadingUnitSettings ? (
|
||||||
@@ -922,14 +1001,14 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-foreground flex items-center justify-between">
|
<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}>
|
<Select value={networkUnitSettings} onValueChange={changeNetworkUnit}>
|
||||||
<SelectTrigger className="w-28 h-8 text-xs">
|
<SelectTrigger className="w-28 h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="Bytes">Bytes</SelectItem>
|
<SelectItem value="Bytes">{t("settings.networkUnits.bytes")}</SelectItem>
|
||||||
<SelectItem value="Bits">Bits</SelectItem>
|
<SelectItem value="Bits">{t("settings.networkUnits.bits")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -943,14 +1022,14 @@ export function Settings() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<HeartPulse className="h-5 w-5 text-red-500" />
|
<HeartPulse className="h-5 w-5 text-red-500" />
|
||||||
<CardTitle>Health Monitor</CardTitle>
|
<CardTitle>{t("settings.healthMonitor.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
{!loadingHealth && (
|
{!loadingHealth && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{savedAllHealth && (
|
{savedAllHealth && (
|
||||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
Saved
|
{t("status.saved")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{healthEditMode ? (
|
{healthEditMode ? (
|
||||||
@@ -960,7 +1039,7 @@ export function Settings() {
|
|||||||
onClick={handleCancelEdit}
|
onClick={handleCancelEdit}
|
||||||
disabled={savingAllHealth}
|
disabled={savingAllHealth}
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||||
@@ -972,7 +1051,7 @@ export function Settings() {
|
|||||||
) : (
|
) : (
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
Save
|
{t("actions.save")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -981,15 +1060,14 @@ export function Settings() {
|
|||||||
onClick={() => setHealthEditMode(true)}
|
onClick={() => setHealthEditMode(true)}
|
||||||
>
|
>
|
||||||
<Settings2 className="h-3 w-3" />
|
<Settings2 className="h-3 w-3" />
|
||||||
Edit
|
{t("actions.edit")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Configure how long dismissed alerts stay suppressed for each category.
|
{t("settings.healthMonitor.description")}
|
||||||
Changes apply immediately to both existing and future dismissed alerts.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -1001,8 +1079,8 @@ export function Settings() {
|
|||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between pb-2 mb-1 border-b border-border">
|
<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">{t("settings.healthMonitor.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.suppressionDuration")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Per-category rows */}
|
{/* Per-category rows */}
|
||||||
@@ -1015,13 +1093,14 @@ export function Settings() {
|
|||||||
const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2
|
const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2
|
||||||
const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours
|
const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours
|
||||||
const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key)
|
const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key)
|
||||||
|
const catLabel = tFallback(`settings.healthMonitor.categories.${healthCategoryKey(cat)}`, cat.label)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={cat.key}>
|
<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 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">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<IconComp className="h-4 w-4 text-muted-foreground shrink-0" />
|
<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 && (
|
{hasChanged && healthEditMode && (
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 shrink-0" />
|
<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"
|
className="w-16 sm:w-20 h-7 text-xs"
|
||||||
value={customValues[cat.key] || ""}
|
value={customValues[cat.key] || ""}
|
||||||
onChange={(e) => setCustomValues(prev => ({ ...prev, [cat.key]: e.target.value }))}
|
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>
|
<span className="text-xs text-muted-foreground">h</span>
|
||||||
<button
|
<button
|
||||||
@@ -1074,7 +1153,7 @@ export function Settings() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SUPPRESSION_OPTIONS.map((opt) => (
|
{SUPPRESSION_OPTIONS.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{t(opt.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -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">
|
<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" />
|
<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">
|
<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" && (
|
{cat.category === "temperature" && (
|
||||||
<span className="block mt-0.5 text-blue-300/80">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</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">
|
<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" />
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1116,8 +1195,7 @@ export function Settings() {
|
|||||||
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
|
<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" />
|
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
||||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||||
These settings apply when you dismiss a warning from the Health Monitor.
|
{t("settings.healthMonitor.footerNote")}
|
||||||
Critical CPU temperature alerts always trigger regardless of settings to protect your hardware.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1133,11 +1211,11 @@ export function Settings() {
|
|||||||
<div className="pt-8">
|
<div className="pt-8">
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
<BellOff className="h-4 w-4 text-amber-500" />
|
<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>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">
|
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">
|
||||||
Alerts you have silenced from the Health Monitor. Permanent dismisses can only be
|
{t("settings.healthMonitor.activeSuppressionsDescription")}{" "}
|
||||||
reverted here. Editing requires the Health Monitor <span className="font-mono text-xs">Edit</span> mode at the top of this card.
|
<span className="font-mono text-xs">{t("actions.edit")}</span>.
|
||||||
</p>
|
</p>
|
||||||
{loadingSuppressions ? (
|
{loadingSuppressions ? (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className="flex items-center justify-center py-4">
|
||||||
@@ -1145,19 +1223,19 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
) : activeSuppressions.length === 0 ? (
|
) : activeSuppressions.length === 0 ? (
|
||||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{activeSuppressions.map((s) => {
|
{activeSuppressions.map((s) => {
|
||||||
const remaining = s.suppression_remaining_hours
|
const remaining = s.suppression_remaining_hours
|
||||||
const remainingLabel = s.permanent
|
const remainingLabel = s.permanent
|
||||||
? "Permanent"
|
? t("settings.healthMonitor.permanent")
|
||||||
: remaining === undefined || remaining === null
|
: remaining === undefined || remaining === null
|
||||||
? "Active"
|
? t("status.active")
|
||||||
: remaining >= 24
|
: remaining >= 24
|
||||||
? `${Math.round(remaining / 24)}d remaining`
|
? t("settings.healthMonitor.daysRemaining", { count: Math.round(remaining / 24) })
|
||||||
: `${Math.max(0, Math.round(remaining))}h remaining`
|
: t("settings.healthMonitor.hoursRemaining", { count: Math.max(0, Math.round(remaining)) })
|
||||||
const dismissedAtLabel = s.acknowledged_at
|
const dismissedAtLabel = s.acknowledged_at
|
||||||
? new Date(s.acknowledged_at).toLocaleString()
|
? 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" : ""}`}>
|
<div className={`flex items-start gap-2 min-w-0 flex-1 ${isQueued ? "opacity-60" : ""}`}>
|
||||||
{s.permanent ? (
|
{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">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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="min-w-0 flex-1">
|
||||||
<div className={`text-xs sm:text-sm font-medium text-foreground truncate ${isQueued ? "line-through" : ""}`} title={s.error_key}>
|
<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>
|
||||||
<div className="text-sm text-muted-foreground flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
|
<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>
|
<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>severity: <span className="font-medium text-foreground/80">{s.severity}</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>dismissed: {dismissedAtLabel}</span>}
|
{dismissedAtLabel && <span>{t("settings.healthMonitor.labels.dismissed")}: {dismissedAtLabel}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1204,13 +1282,13 @@ export function Settings() {
|
|||||||
onClick={() => handleReEnable(s.error_key)}
|
onClick={() => handleReEnable(s.error_key)}
|
||||||
title={
|
title={
|
||||||
!healthEditMode
|
!healthEditMode
|
||||||
? "Enable Health Monitor Edit mode to re-enable"
|
? t("settings.healthMonitor.reEnableDisabledTitle")
|
||||||
: isQueued
|
: isQueued
|
||||||
? "Cancel re-enable (will not be applied on Save)"
|
? t("settings.healthMonitor.reEnableQueuedTitle")
|
||||||
: "Queue this alert for re-enable on Save"
|
: t("settings.healthMonitor.reEnableTitle")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isQueued ? "Undo" : "Re-enable"}
|
{isQueued ? t("actions.undo") : t("settings.healthMonitor.reEnable")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1228,11 +1306,10 @@ export function Settings() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="h-5 w-5 text-purple-500" />
|
<Database className="h-5 w-5 text-purple-500" />
|
||||||
<CardTitle>Remote Storage Exclusions</CardTitle>
|
<CardTitle>{t("settings.remoteStorage.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Exclude remote storages (PBS, NFS, CIFS, etc.) from health monitoring and notifications.
|
{t("settings.remoteStorage.description")}
|
||||||
Use this for storages that are intentionally offline or have limited API access.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -1243,18 +1320,18 @@ export function Settings() {
|
|||||||
) : remoteStorages.length === 0 ? (
|
) : remoteStorages.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<CloudOff className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 pb-2 mb-1 border-b border-border">
|
<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">{t("settings.remoteStorage.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">{t("settings.common.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 text-center w-20">{t("settings.common.alerts")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Storage rows - scrollable container */}
|
{/* Storage rows - scrollable container */}
|
||||||
@@ -1279,10 +1356,10 @@ export function Settings() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{isOffline && (
|
{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 && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -1333,9 +1410,9 @@ export function Settings() {
|
|||||||
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
|
<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" />
|
<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">
|
<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 />
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1348,11 +1425,10 @@ export function Settings() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Network className="h-5 w-5 text-blue-500" />
|
<Network className="h-5 w-5 text-blue-500" />
|
||||||
<CardTitle>Network Interface Exclusions</CardTitle>
|
<CardTitle>{t("settings.networkInterfaces.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Exclude network interfaces (bridges, bonds, physical NICs) from health monitoring and notifications.
|
{t("settings.networkInterfaces.description")}
|
||||||
Use this for interfaces that are intentionally disabled or unused.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -1363,15 +1439,15 @@ export function Settings() {
|
|||||||
) : networkInterfaces.length === 0 ? (
|
) : networkInterfaces.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 pb-2 mb-1 border-b border-border">
|
<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">{t("settings.networkInterfaces.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">{t("settings.common.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 text-center w-20">{t("settings.common.alerts")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Interface rows - scrollable container */}
|
{/* Interface rows - scrollable container */}
|
||||||
@@ -1393,21 +1469,21 @@ export function Settings() {
|
|||||||
{iface.name}
|
{iface.name}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||||
{iface.type}
|
{interfaceTypeLabel(iface.type)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{isDown && !isExcluded && (
|
{isDown && !isExcluded && (
|
||||||
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
|
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
|
||||||
DOWN
|
{t("settings.networkInterfaces.down")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{isExcluded && (
|
{isExcluded && (
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-400">
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-400">
|
||||||
Excluded
|
{t("settings.networkInterfaces.excluded")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1460,9 +1536,9 @@ export function Settings() {
|
|||||||
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
|
<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" />
|
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
||||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||||
<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 />
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1492,27 +1568,26 @@ export function Settings() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileText className="h-5 w-5 text-cyan-500" />
|
<FileText className="h-5 w-5 text-cyan-500" />
|
||||||
<CardTitle>Snippets storage</CardTitle>
|
<CardTitle>{t("settings.snippets.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Where ProxMenux installs hookscripts (e.g. the GPU passthrough guard for VMs/LXCs).
|
{t("settings.snippets.description")}{" "}
|
||||||
Pick a shared storage in cluster setups so VMs and LXCs migrate cleanly between nodes —
|
|
||||||
<code className="mx-1">local</code>
|
<code className="mx-1">local</code>
|
||||||
is node-specific and breaks migration.
|
{" "}{t("settings.snippets.localNote")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col md:flex-row md:items-center gap-3">
|
<div className="flex flex-col md:flex-row md:items-center gap-3">
|
||||||
<Select value={snippetsStorage || ""} onValueChange={saveSnippetsStorage} disabled={snippetsSaving}>
|
<Select value={snippetsStorage || ""} onValueChange={saveSnippetsStorage} disabled={snippetsSaving}>
|
||||||
<SelectTrigger className="w-full md:w-72">
|
<SelectTrigger className="w-full md:w-72">
|
||||||
<SelectValue placeholder="Pick a storage…" />
|
<SelectValue placeholder={t("settings.snippets.placeholder")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{snippetsCandidates.map(c => (
|
{snippetsCandidates.map(c => (
|
||||||
<SelectItem key={c.name} value={c.name} disabled={!c.active}>
|
<SelectItem key={c.name} value={c.name} disabled={!c.active}>
|
||||||
{c.name}
|
{c.name}
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
{c.type}{!c.active && " · inactive"}
|
{c.type}{!c.active && ` · ${t("status.inactive")}`}
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -1521,14 +1596,12 @@ export function Settings() {
|
|||||||
{snippetsSaving && (
|
{snippetsSaving && (
|
||||||
<span className="text-xs text-muted-foreground inline-flex items-center gap-1.5">
|
<span className="text-xs text-muted-foreground inline-flex items-center gap-1.5">
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
Saving…
|
{t("status.saving")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-3">
|
<p className="text-xs text-muted-foreground mt-3">
|
||||||
Existing VMs/LXCs already configured with the previous storage keep working.
|
{t("settings.snippets.footer")}
|
||||||
Only new GPU passthrough operations (or running "sync hookscripts" on the host)
|
|
||||||
will use the new selection.
|
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1539,9 +1612,9 @@ export function Settings() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Wrench className="h-5 w-5 text-orange-500" />
|
<Wrench className="h-5 w-5 text-orange-500" />
|
||||||
<CardTitle>ProxMenux Optimizations</CardTitle>
|
<CardTitle>{t("settings.optimizations.title")}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>System optimizations and utilities installed via ProxMenux</CardDescription>
|
<CardDescription>{t("settings.optimizations.description")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loadingTools ? (
|
{loadingTools ? (
|
||||||
@@ -1551,15 +1624,15 @@ export function Settings() {
|
|||||||
) : proxmenuxTools.length === 0 ? (
|
) : proxmenuxTools.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Package className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
|
<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-muted-foreground">{t("settings.optimizations.emptyTitle")}</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">Run ProxMenux to configure system optimizations</p>
|
<p className="text-sm text-muted-foreground mt-1">{t("settings.optimizations.emptyDescription")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between mb-4 pb-2 border-b border-border">
|
<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">
|
<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
|
{/* Sprint 12B: count badge that doubles as the trigger
|
||||||
for the multi-select update modal. Only shown when
|
for the multi-select update modal. Only shown when
|
||||||
at least one tool has an available update. */}
|
at least one tool has an available update. */}
|
||||||
@@ -1578,10 +1651,10 @@ export function Settings() {
|
|||||||
setUpdateModalOpen(true)
|
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"
|
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" />
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
{updatesAvailableCount} {updatesAvailableCount === 1 ? 'update' : 'updates'}
|
{t("settings.optimizations.updateCount", { count: updatesAvailableCount })}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1605,7 +1678,7 @@ export function Settings() {
|
|||||||
key={tool.key}
|
key={tool.key}
|
||||||
onClick={clickable ? () => viewToolSource(tool) : undefined}
|
onClick={clickable ? () => viewToolSource(tool) : undefined}
|
||||||
className={`flex items-center justify-between gap-2 p-3 rounded-lg border transition-colors ${baseClasses} ${clickable ? 'cursor-pointer' : ''}`}
|
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="flex items-center gap-2 min-w-0">
|
||||||
<div className={`w-2 h-2 rounded-full flex-shrink-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>
|
<span className="text-sm font-medium truncate">{tool.name}</span>
|
||||||
{isDeprecated && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1627,7 +1700,7 @@ export function Settings() {
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleSingleToolUpdate(tool) }}
|
onClick={(e) => { e.stopPropagation(); handleSingleToolUpdate(tool) }}
|
||||||
className="text-purple-300 hover:text-purple-200 transition-colors"
|
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" />
|
<ArrowUpCircle className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1662,7 +1735,7 @@ export function Settings() {
|
|||||||
<h3 className="text-sm font-semibold truncate">{codeModal.toolName}</h3>
|
<h3 className="text-sm font-semibold truncate">{codeModal.toolName}</h3>
|
||||||
{codeModal.deprecated && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1688,10 +1761,10 @@ export function Settings() {
|
|||||||
<button
|
<button
|
||||||
onClick={copySourceCode}
|
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"
|
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 ? <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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -1740,9 +1813,9 @@ export function Settings() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Sparkles className="h-5 w-5 text-purple-400" />
|
<Sparkles className="h-5 w-5 text-purple-400" />
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1799,14 +1872,14 @@ export function Settings() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between p-4 border-t border-border">
|
<div className="flex items-center justify-between p-4 border-t border-border">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{selectedUpdates.size} of {updatesAvailableCount} selected
|
{t("settings.optimizations.selectedCount", { selected: selectedUpdates.size, total: updatesAvailableCount })}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setUpdateModalOpen(false)}
|
onClick={() => setUpdateModalOpen(false)}
|
||||||
className="px-4 py-1.5 text-xs rounded-md bg-muted hover:bg-muted/80 transition-colors"
|
className="px-4 py-1.5 text-xs rounded-md bg-muted hover:bg-muted/80 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
disabled={selectedUpdates.size === 0}
|
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"
|
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" />
|
<ArrowUpCircle className="h-3.5 w-3.5" />
|
||||||
Update selected
|
{t("settings.optimizations.updateSelected")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
|
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
||||||
@@ -14,6 +15,8 @@ const menuItems = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const Sidebar = ({ currentPath, setOpen }) => {
|
const Sidebar = ({ currentPath, setOpen }) => {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
const handleNavigation = (tabName: string) => {
|
const handleNavigation = (tabName: string) => {
|
||||||
// Dispatch custom event to change tab in dashboard
|
// Dispatch custom event to change tab in dashboard
|
||||||
const event = new CustomEvent("changeTab", { detail: { tab: tabName } })
|
const event = new CustomEvent("changeTab", { detail: { tab: tabName } })
|
||||||
@@ -32,7 +35,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="h-5 w-5" />
|
<LayoutDashboard className="h-5 w-5" />
|
||||||
<span>Overview</span>
|
<span>{t("navigation.overview")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -44,7 +47,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<HardDrive className="h-5 w-5" />
|
<HardDrive className="h-5 w-5" />
|
||||||
<span>Storage</span>
|
<span>{t("navigation.storage")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -56,7 +59,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Network className="h-5 w-5" />
|
<Network className="h-5 w-5" />
|
||||||
<span>Network</span>
|
<span>{t("navigation.network")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -68,7 +71,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Server className="h-5 w-5" />
|
<Server className="h-5 w-5" />
|
||||||
<span>VMs & LXCs</span>
|
<span>{t("navigation.virtualMachines")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -80,7 +83,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Cpu className="h-5 w-5" />
|
<Cpu className="h-5 w-5" />
|
||||||
<span>Hardware</span>
|
<span>{t("navigation.hardware")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -92,7 +95,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
<span>System Logs</span>
|
<span>{t("navigation.systemLogs")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -104,7 +107,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Terminal className="h-5 w-5" />
|
<Terminal className="h-5 w-5" />
|
||||||
<span>Terminal</span>
|
<span>{t("navigation.terminal")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -116,7 +119,7 @@ const Sidebar = ({ currentPath, setOpen }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<SettingsIcon className="h-5 w-5" />
|
<SettingsIcon className="h-5 w-5" />
|
||||||
<span>Settings</span>
|
<span>{t("navigation.settings")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function StorageMetrics() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Storage Overview Cards */}
|
{/* Storage Overview Cards */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
<div className="grid grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Storage</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">Total Storage</CardTitle>
|
||||||
|
|||||||
+1328
-1208
File diff suppressed because it is too large
Load Diff
+131
-123
@@ -29,6 +29,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useState, useEffect, useMemo } from "react"
|
import { useState, useEffect, useMemo } from "react"
|
||||||
import { API_PORT, fetchApi, getApiUrl, getAuthToken } from "@/lib/api-config"
|
import { API_PORT, fetchApi, getApiUrl, getAuthToken } from "@/lib/api-config"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
interface Backup {
|
interface Backup {
|
||||||
volid: string
|
volid: string
|
||||||
@@ -88,6 +89,7 @@ interface CombinedLogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SystemLogs() {
|
export function SystemLogs() {
|
||||||
|
const t = useT()
|
||||||
const [logs, setLogs] = useState<SystemLog[]>([])
|
const [logs, setLogs] = useState<SystemLog[]>([])
|
||||||
const [backups, setBackups] = useState<Backup[]>([])
|
const [backups, setBackups] = useState<Backup[]>([])
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
@@ -150,7 +152,7 @@ export function SystemLogs() {
|
|||||||
setLogsCounts(countsRes)
|
setLogsCounts(countsRes)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setError("Failed to connect to server")
|
setError(t("systemLogs.errors.connectFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false)
|
if (!cancelled) setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -177,7 +179,7 @@ export function SystemLogs() {
|
|||||||
const data = await fetchApi<{ logs?: SystemLog[] } | SystemLog[]>(apiUrl)
|
const data = await fetchApi<{ logs?: SystemLog[] } | SystemLog[]>(apiUrl)
|
||||||
return Array.isArray(data) ? data : data.logs || []
|
return Array.isArray(data) ? data : data.logs || []
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to load logs. Please try again.")
|
setError(t("systemLogs.errors.loadFailed"))
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,26 +205,26 @@ export function SystemLogs() {
|
|||||||
|
|
||||||
// Generate log content
|
// Generate log content
|
||||||
const logContent = [
|
const logContent = [
|
||||||
`Proxmox System Logs & Events Export`,
|
t("systemLogs.export.title"),
|
||||||
`Generated: ${new Date().toISOString()}`,
|
`${t("systemLogs.fields.generated")}: ${new Date().toISOString()}`,
|
||||||
`Total Entries: ${filteredCombinedLogs.length.toLocaleString()}`,
|
`${t("systemLogs.cards.totalEntries")}: ${filteredCombinedLogs.length.toLocaleString()}`,
|
||||||
``,
|
``,
|
||||||
`Filters Applied:`,
|
`${t("systemLogs.export.filtersApplied")}:`,
|
||||||
`- Date Range: ${dateFilter === "custom" ? `${customDays} days ago` : `${dateFilter} day(s) ago`}`,
|
`- ${t("systemLogs.filters.dateRange")}: ${t("systemLogs.filters.daysAgo", { count: dateFilter === "custom" ? customDays : dateFilter })}`,
|
||||||
`- Level: ${levelFilter === "all" ? "All Levels" : levelFilter}`,
|
`- ${t("systemLogs.fields.level")}: ${levelFilter === "all" ? t("systemLogs.filters.allLevels") : levelLabel(levelFilter)}`,
|
||||||
`- Service: ${serviceFilter === "all" ? "All Services" : serviceFilter}`,
|
`- ${t("systemLogs.fields.service")}: ${serviceFilter === "all" ? t("systemLogs.filters.allServices") : serviceFilter}`,
|
||||||
`- Search: ${searchTerm || "None"}`,
|
`- ${t("systemLogs.fields.search")}: ${searchTerm || t("systemLogs.fields.none")}`,
|
||||||
``,
|
``,
|
||||||
`${"=".repeat(80)}`,
|
`${"=".repeat(80)}`,
|
||||||
``,
|
``,
|
||||||
...filteredCombinedLogs.map((log) => {
|
...filteredCombinedLogs.map((log) => {
|
||||||
const lines = [
|
const lines = [
|
||||||
`[${log.timestamp}] ${log.level.toUpperCase()} - ${log.service}${log.isEvent ? " [EVENT]" : ""}`,
|
`[${log.timestamp}] ${levelLabel(log.level)} - ${log.service}${log.isEvent ? ` [${t("systemLogs.badges.event")}]` : ""}`,
|
||||||
`Message: ${log.message}`,
|
`${t("systemLogs.fields.message")}: ${log.message}`,
|
||||||
`Source: ${log.source}`,
|
`${t("systemLogs.fields.source")}: ${log.source}`,
|
||||||
]
|
]
|
||||||
if (log.pid) lines.push(`PID: ${log.pid}`)
|
if (log.pid) lines.push(`PID: ${log.pid}`)
|
||||||
if (log.hostname) lines.push(`Hostname: ${log.hostname}`)
|
if (log.hostname) lines.push(`${t("systemLogs.fields.hostname")}: ${log.hostname}`)
|
||||||
lines.push(`${"-".repeat(80)}`)
|
lines.push(`${"-".repeat(80)}`)
|
||||||
return lines.join("\n")
|
return lines.join("\n")
|
||||||
}),
|
}),
|
||||||
@@ -273,13 +275,13 @@ export function SystemLogs() {
|
|||||||
// Download the complete task log
|
// Download the complete task log
|
||||||
const blob = new Blob(
|
const blob = new Blob(
|
||||||
[
|
[
|
||||||
`Proxmox Task Log\n`,
|
`${t("systemLogs.download.taskLog")}\n`,
|
||||||
`================\n\n`,
|
`================\n\n`,
|
||||||
`UPID: ${upid}\n`,
|
`UPID: ${upid}\n`,
|
||||||
`Timestamp: ${notification.timestamp}\n`,
|
`${t("systemLogs.fields.timestamp")}: ${notification.timestamp}\n`,
|
||||||
`Service: ${notification.service}\n`,
|
`${t("systemLogs.fields.service")}: ${notification.service}\n`,
|
||||||
`Source: ${notification.source}\n\n`,
|
`${t("systemLogs.fields.source")}: ${notification.source}\n\n`,
|
||||||
`Complete Task Log:\n`,
|
`${t("systemLogs.download.completeTaskLog")}:\n`,
|
||||||
`${"-".repeat(80)}\n`,
|
`${"-".repeat(80)}\n`,
|
||||||
`${taskLog}\n`,
|
`${taskLog}\n`,
|
||||||
],
|
],
|
||||||
@@ -303,13 +305,13 @@ export function SystemLogs() {
|
|||||||
// If no UPID or failed to fetch task log, download the notification message
|
// If no UPID or failed to fetch task log, download the notification message
|
||||||
const blob = new Blob(
|
const blob = new Blob(
|
||||||
[
|
[
|
||||||
`Notification Details\n`,
|
`${t("systemLogs.modals.notificationTitle")}\n`,
|
||||||
`==================\n\n`,
|
`==================\n\n`,
|
||||||
`Timestamp: ${notification.timestamp}\n`,
|
`${t("systemLogs.fields.timestamp")}: ${notification.timestamp}\n`,
|
||||||
`Type: ${notification.type}\n`,
|
`${t("systemLogs.fields.type")}: ${notification.type}\n`,
|
||||||
`Service: ${notification.service}\n`,
|
`${t("systemLogs.fields.service")}: ${notification.service}\n`,
|
||||||
`Source: ${notification.source}\n\n`,
|
`${t("systemLogs.fields.source")}: ${notification.source}\n\n`,
|
||||||
`Complete Message:\n`,
|
`${t("systemLogs.download.completeMessage")}:\n`,
|
||||||
`${notification.message}\n`,
|
`${notification.message}\n`,
|
||||||
],
|
],
|
||||||
{ type: "text/plain" },
|
{ type: "text/plain" },
|
||||||
@@ -342,7 +344,7 @@ export function SystemLogs() {
|
|||||||
level: event.level,
|
level: event.level,
|
||||||
service: event.type,
|
service: event.type,
|
||||||
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
|
message: `${event.type}${event.vmid ? ` (VM/CT ${event.vmid})` : ""} - ${event.status}`,
|
||||||
source: `Node: ${event.node} • User: ${event.user}`,
|
source: `${t("systemLogs.fields.node")}: ${event.node} • ${t("systemLogs.fields.user")}: ${event.user}`,
|
||||||
isEvent: true,
|
isEvent: true,
|
||||||
eventData: event,
|
eventData: event,
|
||||||
sortTimestamp: new Date(event.starttime).getTime(),
|
sortTimestamp: new Date(event.starttime).getTime(),
|
||||||
@@ -392,6 +394,12 @@ export function SystemLogs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const levelLabel = (level: string) => {
|
||||||
|
const key = `systemLogs.levels.${safeToLowerCase(level)}`
|
||||||
|
const translated = t(key)
|
||||||
|
return translated === key ? String(level).toUpperCase() : translated
|
||||||
|
}
|
||||||
|
|
||||||
const getLevelIcon = (level: string) => {
|
const getLevelIcon = (level: string) => {
|
||||||
switch (level) {
|
switch (level) {
|
||||||
case "error":
|
case "error":
|
||||||
@@ -551,15 +559,15 @@ export function SystemLogs() {
|
|||||||
const getSectionLabel = (section: string) => {
|
const getSectionLabel = (section: string) => {
|
||||||
switch (section) {
|
switch (section) {
|
||||||
case "logs":
|
case "logs":
|
||||||
return "Logs"
|
return t("systemLogs.tabs.logs")
|
||||||
case "events":
|
case "events":
|
||||||
return "Events"
|
return t("systemLogs.tabs.events")
|
||||||
case "backups":
|
case "backups":
|
||||||
return "Backups"
|
return t("systemLogs.tabs.backups")
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return "Notifications"
|
return t("systemLogs.tabs.notifications")
|
||||||
default:
|
default:
|
||||||
return "Logs"
|
return t("systemLogs.tabs.logs")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,8 +578,8 @@ export function SystemLogs() {
|
|||||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-foreground">Loading logs...</div>
|
<div className="text-sm font-medium text-foreground">{t("systemLogs.loading.title")}</div>
|
||||||
<p className="text-xs text-muted-foreground">Fetching system logs and events</p>
|
<p className="text-xs text-muted-foreground">{t("systemLogs.loading.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -585,51 +593,51 @@ export function SystemLogs() {
|
|||||||
<div className="h-10 w-10 rounded-full border-2 border-muted"></div>
|
<div className="h-10 w-10 rounded-full border-2 border-muted"></div>
|
||||||
<div className="absolute inset-0 h-10 w-10 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
<div className="absolute inset-0 h-10 w-10 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-foreground">Loading logs...</div>
|
<div className="text-sm font-medium text-foreground">{t("systemLogs.loading.title")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Statistics Cards */}
|
{/* Statistics Cards */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
|
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4 xl:gap-6">
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Entries</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.totalEntries")}</CardTitle>
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-foreground">
|
<div className="text-2xl font-bold text-foreground">
|
||||||
{(logsCounts?.total ?? 0).toLocaleString("fr-FR")}
|
{(logsCounts?.total ?? 0).toLocaleString("fr-FR")}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">In selected range</p>
|
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.selectedRange")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Errors</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.errors")}</CardTitle>
|
||||||
<XCircle className="h-4 w-4 text-red-500" />
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-red-500">{(logsCounts?.errors ?? 0).toLocaleString("fr-FR")}</div>
|
<div className="text-2xl font-bold text-red-500">{(logsCounts?.errors ?? 0).toLocaleString("fr-FR")}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">Requires attention</p>
|
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.requiresAttention")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Warnings</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.warnings")}</CardTitle>
|
||||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold text-yellow-500">{(logsCounts?.warnings ?? 0).toLocaleString("fr-FR")}</div>
|
<div className="text-2xl font-bold text-yellow-500">{(logsCounts?.warnings ?? 0).toLocaleString("fr-FR")}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">Monitor closely</p>
|
<p className="text-xs text-muted-foreground mt-2">{t("systemLogs.cards.monitorClosely")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Backups</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("systemLogs.cards.backups")}</CardTitle>
|
||||||
<Database className="h-4 w-4 text-blue-500" />
|
<Database className="h-4 w-4 text-blue-500" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -645,11 +653,11 @@ export function SystemLogs() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-foreground flex items-center">
|
||||||
<Activity className="h-5 w-5 mr-2" />
|
<Activity className="h-5 w-5 mr-2" />
|
||||||
System Logs & Events
|
{t("systemLogs.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button variant="outline" size="sm" onClick={refreshData} disabled={loading}>
|
<Button variant="outline" size="sm" onClick={refreshData} disabled={loading}>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||||
Refresh
|
{t("actions.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -658,18 +666,18 @@ export function SystemLogs() {
|
|||||||
<TabsList className="hidden md:grid w-full grid-cols-3">
|
<TabsList className="hidden md:grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="logs" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
|
<TabsTrigger value="logs" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
|
||||||
<Terminal className="h-4 w-4 mr-2" />
|
<Terminal className="h-4 w-4 mr-2" />
|
||||||
Logs
|
{t("systemLogs.tabs.logs")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="backups" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
|
<TabsTrigger value="backups" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
|
||||||
<Database className="h-4 w-4 mr-2" />
|
<Database className="h-4 w-4 mr-2" />
|
||||||
Backups
|
{t("systemLogs.tabs.backups")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="notifications"
|
value="notifications"
|
||||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white"
|
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white"
|
||||||
>
|
>
|
||||||
<Bell className="h-4 w-4 mr-2" />
|
<Bell className="h-4 w-4 mr-2" />
|
||||||
Notifications
|
{t("systemLogs.tabs.notifications")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -691,7 +699,7 @@ export function SystemLogs() {
|
|||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" className="w-[280px]">
|
<SheetContent side="left" className="w-[280px]">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>Sections</SheetTitle>
|
<SheetTitle>{t("systemLogs.sections")}</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="mt-6 space-y-2">
|
<div className="mt-6 space-y-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -707,7 +715,7 @@ export function SystemLogs() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Terminal className="h-4 w-4" />
|
<Terminal className="h-4 w-4" />
|
||||||
Logs
|
{t("systemLogs.tabs.logs")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -722,7 +730,7 @@ export function SystemLogs() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
Backups
|
{t("systemLogs.tabs.backups")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -737,7 +745,7 @@ export function SystemLogs() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
Notifications
|
{t("systemLogs.tabs.notifications")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -751,7 +759,7 @@ export function SystemLogs() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search logs & events..."
|
placeholder={t("systemLogs.filters.searchPlaceholder")}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-10 bg-background border-border"
|
className="pl-10 bg-background border-border"
|
||||||
@@ -761,22 +769,22 @@ export function SystemLogs() {
|
|||||||
|
|
||||||
<Select value={dateFilter} onValueChange={setDateFilter}>
|
<Select value={dateFilter} onValueChange={setDateFilter}>
|
||||||
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
||||||
<SelectValue placeholder="Time range" />
|
<SelectValue placeholder={t("systemLogs.filters.timeRange")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="1">1 day ago</SelectItem>
|
<SelectItem value="1">{t("systemLogs.filters.oneDay")}</SelectItem>
|
||||||
<SelectItem value="3">3 days ago</SelectItem>
|
<SelectItem value="3">{t("systemLogs.filters.threeDays")}</SelectItem>
|
||||||
<SelectItem value="7">1 week ago</SelectItem>
|
<SelectItem value="7">{t("systemLogs.filters.oneWeek")}</SelectItem>
|
||||||
<SelectItem value="14">2 weeks ago</SelectItem>
|
<SelectItem value="14">{t("systemLogs.filters.twoWeeks")}</SelectItem>
|
||||||
<SelectItem value="30">1 month ago</SelectItem>
|
<SelectItem value="30">{t("systemLogs.filters.oneMonth")}</SelectItem>
|
||||||
<SelectItem value="custom">Custom days</SelectItem>
|
<SelectItem value="custom">{t("systemLogs.filters.customDays")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{dateFilter === "custom" && (
|
{dateFilter === "custom" && (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Days ago"
|
placeholder={t("systemLogs.filters.daysAgoPlaceholder")}
|
||||||
value={customDays}
|
value={customDays}
|
||||||
onChange={(e) => setCustomDays(e.target.value)}
|
onChange={(e) => setCustomDays(e.target.value)}
|
||||||
className="w-full sm:w-[120px] bg-background border-border"
|
className="w-full sm:w-[120px] bg-background border-border"
|
||||||
@@ -786,23 +794,23 @@ export function SystemLogs() {
|
|||||||
|
|
||||||
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||||
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
||||||
<SelectValue placeholder="Filter by level" />
|
<SelectValue placeholder={t("systemLogs.filters.byLevel")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Levels</SelectItem>
|
<SelectItem value="all">{t("systemLogs.filters.allLevels")}</SelectItem>
|
||||||
<SelectItem value="error">Error</SelectItem>
|
<SelectItem value="error">{t("systemLogs.levels.error")}</SelectItem>
|
||||||
<SelectItem value="warning">Warning</SelectItem>
|
<SelectItem value="warning">{t("systemLogs.levels.warning")}</SelectItem>
|
||||||
<SelectItem value="info">Info</SelectItem>
|
<SelectItem value="info">{t("systemLogs.levels.info")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={serviceFilter} onValueChange={setServiceFilter}>
|
<Select value={serviceFilter} onValueChange={setServiceFilter}>
|
||||||
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
<SelectTrigger className="w-full sm:w-[180px] bg-background border-border">
|
||||||
<SelectValue placeholder="Filter by service" />
|
<SelectValue placeholder={t("systemLogs.filters.byService")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem key="service-all" value="all">
|
<SelectItem key="service-all" value="all">
|
||||||
All Services
|
{t("systemLogs.filters.allServices")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{uniqueServices.map((service) => (
|
{uniqueServices.map((service) => (
|
||||||
<SelectItem key={`service-${service}`} value={service}>
|
<SelectItem key={`service-${service}`} value={service}>
|
||||||
@@ -814,7 +822,7 @@ export function SystemLogs() {
|
|||||||
|
|
||||||
<Button variant="outline" className="border-border bg-transparent" onClick={handleDownloadLogs}>
|
<Button variant="outline" className="border-border bg-transparent" onClick={handleDownloadLogs}>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
Export Logs
|
{t("systemLogs.export.button")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -844,12 +852,12 @@ export function SystemLogs() {
|
|||||||
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
||||||
<Badge variant="outline" className={getLevelColor(log.level)}>
|
<Badge variant="outline" className={getLevelColor(log.level)}>
|
||||||
{getLevelIcon(log.level)}
|
{getLevelIcon(log.level)}
|
||||||
{log.level.toUpperCase()}
|
{levelLabel(log.level)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{log.eventData && (
|
{log.eventData && (
|
||||||
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
|
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
|
||||||
<Activity className="h-3 w-3 mr-1" />
|
<Activity className="h-3 w-3 mr-1" />
|
||||||
EVENT
|
{t("systemLogs.badges.event")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -866,9 +874,9 @@ export function SystemLogs() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground truncate overflow-hidden">
|
<div className="text-xs text-muted-foreground truncate overflow-hidden">
|
||||||
{log.source}
|
{log.source}
|
||||||
{log.unit && log.unit !== log.service && ` • Unit: ${log.unit}`}
|
{log.unit && log.unit !== log.service && ` • ${t("systemLogs.fields.unit")}: ${log.unit}`}
|
||||||
{log.pid && ` • PID: ${log.pid}`}
|
{log.pid && ` • PID: ${log.pid}`}
|
||||||
{log.hostname && ` • Host: ${log.hostname}`}
|
{log.hostname && ` • ${t("systemLogs.fields.host")}: ${log.hostname}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -878,7 +886,7 @@ export function SystemLogs() {
|
|||||||
{displayedLogs.length === 0 && (
|
{displayedLogs.length === 0 && (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
<p>No logs found matching your criteria</p>
|
<p>{t("systemLogs.empty.logs")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -890,7 +898,7 @@ export function SystemLogs() {
|
|||||||
className="border-border"
|
className="border-border"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Load More ({filteredCombinedLogs.length - displayedLogsCount} remaining)
|
{t("systemLogs.loadMore", { count: filteredCombinedLogs.length - displayedLogsCount })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -906,19 +914,19 @@ export function SystemLogs() {
|
|||||||
<Card className="bg-card/50 border-border">
|
<Card className="bg-card/50 border-border">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-2xl font-bold text-cyan-500">{backupStats.qemu}</div>
|
<div className="text-2xl font-bold text-cyan-500">{backupStats.qemu}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">VM Backups</p>
|
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.vm")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-card/50 border-border">
|
<Card className="bg-card/50 border-border">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-2xl font-bold text-orange-500">{backupStats.lxc}</div>
|
<div className="text-2xl font-bold text-orange-500">{backupStats.lxc}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">LXC Backups</p>
|
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.lxc")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-card/50 border-border hidden md:block">
|
<Card className="bg-card/50 border-border hidden md:block">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div>
|
<div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Total Size</p>
|
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.totalSize")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -927,7 +935,7 @@ export function SystemLogs() {
|
|||||||
<Card className="bg-card/50 border-border md:hidden">
|
<Card className="bg-card/50 border-border md:hidden">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div>
|
<div className="text-2xl font-bold text-foreground">{formatBytes(backupStats.totalSize)}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Total Size</p>
|
<p className="text-xs text-muted-foreground mt-1">{t("systemLogs.backups.totalSize")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -967,7 +975,7 @@ export function SystemLogs() {
|
|||||||
{backup.size_human}
|
{backup.size_human}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground mb-1 truncate">Storage: {backup.storage}</div>
|
<div className="text-xs text-muted-foreground mb-1 truncate">{t("systemLogs.fields.storage")}: {backup.storage}</div>
|
||||||
<div className="text-xs text-muted-foreground flex items-center">
|
<div className="text-xs text-muted-foreground flex items-center">
|
||||||
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
|
<Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
|
||||||
<span className="truncate">{backup.created}</span>
|
<span className="truncate">{backup.created}</span>
|
||||||
@@ -980,7 +988,7 @@ export function SystemLogs() {
|
|||||||
{backups.length === 0 && (
|
{backups.length === 0 && (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<Database className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
<Database className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
<p>No backups found</p>
|
<p>{t("systemLogs.empty.backups")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1006,12 +1014,12 @@ export function SystemLogs() {
|
|||||||
>
|
>
|
||||||
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
<div className="flex-shrink-0 flex gap-2 flex-wrap">
|
||||||
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
|
<Badge variant="outline" className={getNotificationTypeColor(notification.type)}>
|
||||||
{(notification.type || "unknown").toUpperCase()}
|
{notification.type ? levelLabel(notification.type) : t("app.unknown")}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
|
<Badge variant="outline" className={getNotificationSourceColor(notification.source)}>
|
||||||
{notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />}
|
{notification.source === "task-log" && <Activity className="h-3 w-3 mr-1" />}
|
||||||
{notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />}
|
{notification.source === "journal" && <FileText className="h-3 w-3 mr-1" />}
|
||||||
{(notification.source || "unknown").toUpperCase()}
|
{notification.source ? notification.source.toUpperCase() : t("app.unknown")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1026,7 +1034,7 @@ export function SystemLogs() {
|
|||||||
{notification.message}
|
{notification.message}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground break-words overflow-hidden">
|
<div className="text-xs text-muted-foreground break-words overflow-hidden">
|
||||||
Service: {notification.service} • Source: {notification.source}
|
{t("systemLogs.fields.service")}: {notification.service} • {t("systemLogs.fields.source")}: {notification.source}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1036,7 +1044,7 @@ export function SystemLogs() {
|
|||||||
{notifications.length === 0 && (
|
{notifications.length === 0 && (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<Bell className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
<Bell className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
<p>No notifications found</p>
|
<p>{t("systemLogs.empty.notifications")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1051,55 +1059,55 @@ export function SystemLogs() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5" />
|
||||||
Log Details
|
{t("systemLogs.modals.logTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>Complete information about this log entry</DialogDescription>
|
<DialogDescription>{t("systemLogs.modals.logDescription")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{selectedLog && (
|
{selectedLog && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Level</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.level")}</div>
|
||||||
<Badge variant="outline" className={getLevelColor(selectedLog.level)}>
|
<Badge variant="outline" className={getLevelColor(selectedLog.level)}>
|
||||||
{getLevelIcon(selectedLog.level)}
|
{getLevelIcon(selectedLog.level)}
|
||||||
{selectedLog.level.toUpperCase()}
|
{levelLabel(selectedLog.level)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Service</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.service")}</div>
|
||||||
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.service}</div>
|
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.service}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Timestamp</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.timestamp")}</div>
|
||||||
<div className="text-sm text-foreground font-mono break-all overflow-hidden">
|
<div className="text-sm text-foreground font-mono break-all overflow-hidden">
|
||||||
{selectedLog.timestamp}
|
{selectedLog.timestamp}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Source</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.source")}</div>
|
||||||
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.source}</div>
|
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.source}</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedLog.unit && (
|
{selectedLog.unit && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Systemd Unit</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.systemdUnit")}</div>
|
||||||
<div className="text-sm text-foreground font-mono break-all overflow-hidden">{selectedLog.unit}</div>
|
<div className="text-sm text-foreground font-mono break-all overflow-hidden">{selectedLog.unit}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedLog.pid && (
|
{selectedLog.pid && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Process ID</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.processId")}</div>
|
||||||
<div className="text-sm text-foreground font-mono">{selectedLog.pid}</div>
|
<div className="text-sm text-foreground font-mono">{selectedLog.pid}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedLog.hostname && (
|
{selectedLog.hostname && (
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Hostname</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.hostname")}</div>
|
||||||
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.hostname}</div>
|
<div className="text-sm text-foreground break-all overflow-hidden">{selectedLog.hostname}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-2">Message</div>
|
<div className="text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.message")}</div>
|
||||||
<div className="p-4 rounded-lg bg-muted/50 border border-border overflow-hidden">
|
<div className="p-4 rounded-lg bg-muted/50 border border-border overflow-hidden">
|
||||||
<pre className="text-sm text-foreground whitespace-pre-wrap break-all overflow-hidden">
|
<pre className="text-sm text-foreground whitespace-pre-wrap break-all overflow-hidden">
|
||||||
{selectedLog.message}
|
{selectedLog.message}
|
||||||
@@ -1116,37 +1124,37 @@ export function SystemLogs() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Activity className="h-5 w-5" />
|
<Activity className="h-5 w-5" />
|
||||||
Event Details
|
{t("systemLogs.modals.eventTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>Complete information about this event</DialogDescription>
|
<DialogDescription>{t("systemLogs.modals.eventDescription")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{selectedEvent && (
|
{selectedEvent && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Badge variant="outline" className={getLevelColor(selectedEvent.level)}>
|
<Badge variant="outline" className={getLevelColor(selectedEvent.level)}>
|
||||||
{getLevelIcon(selectedEvent.level)}
|
{getLevelIcon(selectedEvent.level)}
|
||||||
{selectedEvent.level.toUpperCase()}
|
{levelLabel(selectedEvent.level)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
|
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20">
|
||||||
<Activity className="h-3 w-3 mr-1" />
|
<Activity className="h-3 w-3 mr-1" />
|
||||||
EVENT
|
{t("systemLogs.badges.event")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Message</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.message")}</div>
|
||||||
<div className="text-sm text-foreground break-words">{selectedEvent.status}</div>
|
<div className="text-sm text-foreground break-words">{selectedEvent.status}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Type</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.type")}</div>
|
||||||
<div className="text-sm text-foreground break-words">{selectedEvent.type}</div>
|
<div className="text-sm text-foreground break-words">{selectedEvent.type}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Node</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.node")}</div>
|
||||||
<div className="text-sm text-foreground">{selectedEvent.node}</div>
|
<div className="text-sm text-foreground">{selectedEvent.node}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">User</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.user")}</div>
|
||||||
<div className="text-sm text-foreground break-words">{selectedEvent.user}</div>
|
<div className="text-sm text-foreground break-words">{selectedEvent.user}</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedEvent.vmid && (
|
{selectedEvent.vmid && (
|
||||||
@@ -1156,15 +1164,15 @@ export function SystemLogs() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Duration</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.duration")}</div>
|
||||||
<div className="text-sm text-foreground">{selectedEvent.duration}</div>
|
<div className="text-sm text-foreground">{selectedEvent.duration}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Start Time</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.startTime")}</div>
|
||||||
<div className="text-sm text-foreground break-words">{selectedEvent.starttime}</div>
|
<div className="text-sm text-foreground break-words">{selectedEvent.starttime}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">End Time</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.endTime")}</div>
|
||||||
<div className="text-sm text-foreground break-words">{selectedEvent.endtime}</div>
|
<div className="text-sm text-foreground break-words">{selectedEvent.endtime}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1186,31 +1194,31 @@ export function SystemLogs() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Database className="h-5 w-5" />
|
<Database className="h-5 w-5" />
|
||||||
Backup Details
|
{t("systemLogs.modals.backupTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>Complete information about this backup</DialogDescription>
|
<DialogDescription>{t("systemLogs.modals.backupDescription")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{selectedBackup && (
|
{selectedBackup && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Type</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.type")}</div>
|
||||||
<Badge variant="outline" className={getBackupTypeColor(selectedBackup.volid)}>
|
<Badge variant="outline" className={getBackupTypeColor(selectedBackup.volid)}>
|
||||||
{getBackupTypeLabel(selectedBackup.volid)}
|
{getBackupTypeLabel(selectedBackup.volid)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Storage Type</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.storageType")}</div>
|
||||||
<Badge variant="outline" className={getBackupStorageColor(selectedBackup.volid)}>
|
<Badge variant="outline" className={getBackupStorageColor(selectedBackup.volid)}>
|
||||||
{getBackupStorageLabel(selectedBackup.volid)}
|
{getBackupStorageLabel(selectedBackup.volid)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Storage</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.storage")}</div>
|
||||||
<div className="text-sm text-foreground break-words">{selectedBackup.storage}</div>
|
<div className="text-sm text-foreground break-words">{selectedBackup.storage}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Size</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.size")}</div>
|
||||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
{selectedBackup.size_human}
|
{selectedBackup.size_human}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -1222,12 +1230,12 @@ export function SystemLogs() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-1">Created</div>
|
<div className="text-sm font-medium text-muted-foreground mb-1">{t("systemLogs.fields.created")}</div>
|
||||||
<div className="text-sm text-foreground break-words">{selectedBackup.created}</div>
|
<div className="text-sm text-foreground break-words">{selectedBackup.created}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-muted-foreground mb-2">Volume ID</div>
|
<div className="text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.volumeId")}</div>
|
||||||
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
||||||
<pre className="text-sm text-foreground font-mono whitespace-pre-wrap break-all">
|
<pre className="text-sm text-foreground font-mono whitespace-pre-wrap break-all">
|
||||||
{selectedBackup.volid}
|
{selectedBackup.volid}
|
||||||
@@ -1244,38 +1252,38 @@ export function SystemLogs() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg pr-8">
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg pr-8">
|
||||||
<Bell className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
|
<Bell className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
|
||||||
<span className="truncate">Notification Details</span>
|
<span className="truncate">{t("systemLogs.modals.notificationTitle")}</span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
Complete information about this notification
|
{t("systemLogs.modals.notificationDescription")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{selectedNotification && (
|
{selectedNotification && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Type</div>
|
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.type")}</div>
|
||||||
<Badge variant="outline" className={`${getNotificationTypeColor(selectedNotification.type)} text-xs`}>
|
<Badge variant="outline" className={`${getNotificationTypeColor(selectedNotification.type)} text-xs`}>
|
||||||
{(selectedNotification.type || "unknown").toUpperCase()}
|
{selectedNotification.type ? levelLabel(selectedNotification.type) : t("app.unknown")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Timestamp</div>
|
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.timestamp")}</div>
|
||||||
<div className="text-xs sm:text-sm text-foreground font-mono break-all">
|
<div className="text-xs sm:text-sm text-foreground font-mono break-all">
|
||||||
{selectedNotification.timestamp}
|
{selectedNotification.timestamp}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Service</div>
|
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.service")}</div>
|
||||||
<div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.service}</div>
|
<div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.service}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">Source</div>
|
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-1.5">{t("systemLogs.fields.source")}</div>
|
||||||
<div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.source}</div>
|
<div className="text-xs sm:text-sm text-foreground break-words">{selectedNotification.source}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-2">Message</div>
|
<div className="text-xs sm:text-sm font-medium text-muted-foreground mb-2">{t("systemLogs.fields.message")}</div>
|
||||||
<div className="p-3 sm:p-4 rounded-lg bg-muted/50 border border-border max-h-[180px] sm:max-h-[300px] overflow-y-auto">
|
<div className="p-3 sm:p-4 rounded-lg bg-muted/50 border border-border max-h-[180px] sm:max-h-[300px] overflow-y-auto">
|
||||||
<pre className="text-xs sm:text-sm text-foreground whitespace-pre-wrap break-all font-mono">
|
<pre className="text-xs sm:text-sm text-foreground whitespace-pre-wrap break-all font-mono">
|
||||||
{selectedNotification.message}
|
{selectedNotification.message}
|
||||||
@@ -1289,7 +1297,7 @@ export function SystemLogs() {
|
|||||||
className="border-border w-full sm:w-auto text-xs sm:text-sm h-9 sm:h-10"
|
className="border-border w-full sm:w-auto text-xs sm:text-sm h-9 sm:h-10"
|
||||||
>
|
>
|
||||||
<Download className="h-3 w-3 sm:h-4 sm:w-4 mr-2" />
|
<Download className="h-3 w-3 sm:h-4 sm:w-4 mr-2" />
|
||||||
<span className="truncate">Download Complete Message</span>
|
<span className="truncate">{t("systemLogs.download.completeMessageButton")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ import { useState, useEffect } from "react"
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||||
import { Progress } from "./ui/progress"
|
import { Progress } from "./ui/progress"
|
||||||
import { Badge } from "./ui/badge"
|
import { Badge } from "./ui/badge"
|
||||||
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network } from "lucide-react"
|
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight } from "lucide-react"
|
||||||
import { NodeMetricsCharts } from "./node-metrics-charts"
|
import { NodeMetricsCharts } from "./node-metrics-charts"
|
||||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||||
import { TemperatureDetailModal } from "./temperature-detail-modal"
|
import { TemperatureDetailModal } from "./temperature-detail-modal"
|
||||||
|
import { ProcessDetailModal } from "./process-detail-modal"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||||
import { formatStorage } from "../lib/utils"
|
import { formatStorage } from "../lib/utils"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
import { Area, AreaChart, ResponsiveContainer } from "recharts"
|
import { Area, AreaChart, ResponsiveContainer } from "recharts"
|
||||||
|
|
||||||
interface TempDataPoint {
|
interface TempDataPoint {
|
||||||
@@ -21,9 +23,12 @@ interface TempDataPoint {
|
|||||||
|
|
||||||
interface SystemData {
|
interface SystemData {
|
||||||
cpu_usage: number
|
cpu_usage: number
|
||||||
|
cpu_user?: number // preview restyle
|
||||||
|
cpu_system?: number // preview restyle
|
||||||
memory_usage: number
|
memory_usage: number
|
||||||
memory_total: number
|
memory_total: number
|
||||||
memory_used: number
|
memory_used: number
|
||||||
|
memory_cached?: number // preview restyle
|
||||||
temperature: number
|
temperature: number
|
||||||
temperature_sparkline?: TempDataPoint[]
|
temperature_sparkline?: TempDataPoint[]
|
||||||
uptime: string
|
uptime: string
|
||||||
@@ -167,6 +172,7 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SystemOverview() {
|
export function SystemOverview() {
|
||||||
|
const t = useT()
|
||||||
const [systemData, setSystemData] = useState<SystemData | null>(null)
|
const [systemData, setSystemData] = useState<SystemData | null>(null)
|
||||||
const [vmData, setVmData] = useState<VMData[]>([])
|
const [vmData, setVmData] = useState<VMData[]>([])
|
||||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||||
@@ -184,6 +190,8 @@ export function SystemOverview() {
|
|||||||
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
|
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
|
||||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes") // Added networkUnit state
|
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes") // Added networkUnit state
|
||||||
const [tempModalOpen, setTempModalOpen] = useState(false)
|
const [tempModalOpen, setTempModalOpen] = useState(false)
|
||||||
|
const [cpuProcModalOpen, setCpuProcModalOpen] = useState(false)
|
||||||
|
const [memProcModalOpen, setMemProcModalOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAllData = async () => {
|
const fetchAllData = async () => {
|
||||||
@@ -199,7 +207,7 @@ export function SystemOverview() {
|
|||||||
setHasAttemptedLoad(true)
|
setHasAttemptedLoad(true)
|
||||||
|
|
||||||
if (!systemResult) {
|
if (!systemResult) {
|
||||||
setError("Flask server not available. Please ensure the server is running.")
|
setError(t("overview.errors.serverUnavailableDescription"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +263,7 @@ export function SystemOverview() {
|
|||||||
clearInterval(networkInterval)
|
clearInterval(networkInterval)
|
||||||
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
|
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
if (!hasAttemptedLoad || loadingStates.system) {
|
if (!hasAttemptedLoad || loadingStates.system) {
|
||||||
return (
|
return (
|
||||||
@@ -264,8 +272,8 @@ export function SystemOverview() {
|
|||||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-foreground">Loading system overview...</div>
|
<div className="text-sm font-medium text-foreground">{t("overview.loadingTitle")}</div>
|
||||||
<p className="text-xs text-muted-foreground">Fetching system status and metrics</p>
|
<p className="text-xs text-muted-foreground">{t("overview.loadingDescription")}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -278,9 +286,9 @@ export function SystemOverview() {
|
|||||||
<div className="flex items-center gap-3 text-red-600">
|
<div className="flex items-center gap-3 text-red-600">
|
||||||
<AlertCircle className="h-6 w-6" />
|
<AlertCircle className="h-6 w-6" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-lg mb-1">Flask Server Not Available</div>
|
<div className="font-semibold text-lg mb-1">{t("overview.errors.serverUnavailableTitle")}</div>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{error || "Unable to connect to the Flask server. Please ensure the server is running and try again."}
|
{error || t("overview.errors.serverUnavailableDescription")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,14 +307,14 @@ export function SystemOverview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getTemperatureStatus = (temp: number) => {
|
const getTemperatureStatus = (temp: number) => {
|
||||||
if (temp === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
if (temp === 0) return { status: t("app.notAvailable"), color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||||
if (temp < 60) return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
if (temp < 60) return { status: t("status.normal"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||||
if (temp < 75) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
if (temp < 75) return { status: t("status.warm"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||||
return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
return { status: t("status.hot"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatUptime = (seconds: number) => {
|
const formatUptime = (seconds: number) => {
|
||||||
if (!seconds || seconds === 0) return "Stopped"
|
if (!seconds || seconds === 0) return t("status.stopped")
|
||||||
const days = Math.floor(seconds / 86400)
|
const days = Math.floor(seconds / 86400)
|
||||||
const hours = Math.floor((seconds % 86400) / 3600)
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
const minutes = Math.floor((seconds % 3600) / 60)
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
@@ -316,6 +324,19 @@ export function SystemOverview() {
|
|||||||
return `${minutes}m`
|
return `${minutes}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatSystemUptime = (uptime: string) => {
|
||||||
|
const trimmed = uptime?.trim()
|
||||||
|
if (!trimmed) return t("app.unknown")
|
||||||
|
|
||||||
|
const dayMatch = trimmed.match(/^(\d+)\s+days?,\s*(.+)$/)
|
||||||
|
if (!dayMatch) return trimmed
|
||||||
|
|
||||||
|
const days = Number(dayMatch[1])
|
||||||
|
const dayKey = days === 1 ? "dayOne" : days >= 2 && days <= 4 ? "dayFew" : "dayMany"
|
||||||
|
|
||||||
|
return `${t(`overview.uptimeDuration.${dayKey}`, { count: days })}, ${dayMatch[2]}`
|
||||||
|
}
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
return (bytes / 1024 ** 3).toFixed(2)
|
return (bytes / 1024 ** 3).toFixed(2)
|
||||||
}
|
}
|
||||||
@@ -340,40 +361,14 @@ export function SystemOverview() {
|
|||||||
|
|
||||||
const getLoadStatus = (load: number, cores: number) => {
|
const getLoadStatus = (load: number, cores: number) => {
|
||||||
if (load < cores) {
|
if (load < cores) {
|
||||||
return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
return { status: t("status.normal"), color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||||
} else if (load < cores * 1.5) {
|
} else if (load < cores * 1.5) {
|
||||||
return { status: "Moderate", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
return { status: t("status.moderate"), color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||||
} else {
|
} else {
|
||||||
return { status: "High", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
return { status: t("status.high"), color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemAlerts = []
|
|
||||||
if (systemData.available_updates && systemData.available_updates > 0) {
|
|
||||||
systemAlerts.push({
|
|
||||||
type: "warning",
|
|
||||||
message: `${systemData.available_updates} updates available`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (vmStats.stopped > 0) {
|
|
||||||
systemAlerts.push({
|
|
||||||
type: "info",
|
|
||||||
message: `${vmStats.stopped} VM${vmStats.stopped > 1 ? "s" : ""} stopped`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (systemData.temperature > 75) {
|
|
||||||
systemAlerts.push({
|
|
||||||
type: "warning",
|
|
||||||
message: "High temperature detected",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (localStorage && localStorage.percent > 90) {
|
|
||||||
systemAlerts.push({
|
|
||||||
type: "warning",
|
|
||||||
message: "System storage almost full",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadStatus = getLoadStatus(systemData.load_average[0], systemData.cpu_cores || 8)
|
const loadStatus = getLoadStatus(systemData.load_average[0], systemData.cpu_cores || 8)
|
||||||
|
|
||||||
const getTimeframeLabel = (timeframe: string): string => {
|
const getTimeframeLabel = (timeframe: string): string => {
|
||||||
@@ -395,64 +390,141 @@ export function SystemOverview() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
|
||||||
<Card className="bg-card border-border">
|
{/* ── CPU Usage (preview restyle v2: tamaño igual a System Info, bars más anchas) ── */}
|
||||||
|
<Card
|
||||||
|
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
|
||||||
|
onClick={() => setCpuProcModalOpen(true)}
|
||||||
|
title={t("overview.topProcessesCpu")}
|
||||||
|
>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">CPU Usage</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.cpuUsage")}</CardTitle>
|
||||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Cpu className="h-4 w-4" />
|
||||||
|
<ChevronRight className="h-4 w-4 opacity-60" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{systemData.cpu_usage}%</div>
|
<div className="flex items-center gap-4">
|
||||||
<Progress value={systemData.cpu_usage} className="mt-2 [&>div]:bg-blue-500" />
|
<svg viewBox="0 0 36 36" className="w-[72px] h-[72px] flex-shrink-0">
|
||||||
<p className="text-xs text-muted-foreground mt-2">Real-time usage</p>
|
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="rgba(99,102,241,0.15)" strokeWidth="3"/>
|
||||||
|
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="#3b82f6" strokeWidth="3"
|
||||||
|
strokeDasharray={`${systemData.cpu_usage} 100`} strokeLinecap="round"
|
||||||
|
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}/>
|
||||||
|
<text x="18" y="19.5" textAnchor="middle" fontSize="10" fontWeight="700" fill="currentColor">{Math.round(systemData.cpu_usage)}%</text>
|
||||||
|
</svg>
|
||||||
|
<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">{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">
|
||||||
|
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${systemData.cpu_user ?? 0}%` }}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<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">
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${systemData.cpu_system ?? 0}%`, background: 'rgba(99,102,241,0.55)' }}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-card border-border">
|
{/* ── Memory (preview restyle v2: tamaño igual a System Info, bars más anchas) ── */}
|
||||||
|
<Card
|
||||||
|
className="bg-card border-border cursor-pointer hover:bg-white/5 transition-colors"
|
||||||
|
onClick={() => setMemProcModalOpen(true)}
|
||||||
|
title={t("overview.topProcessesMemory")}
|
||||||
|
>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Memory Usage</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.memory")}</CardTitle>
|
||||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<MemoryStick className="h-4 w-4" />
|
||||||
|
<ChevronRight className="h-4 w-4 opacity-60" />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{systemData.memory_used.toFixed(1)} GB</div>
|
<div className="flex items-center gap-4">
|
||||||
<Progress value={systemData.memory_usage} className="mt-2 [&>div]:bg-blue-500" />
|
<svg viewBox="0 0 36 36" className="w-[72px] h-[72px] flex-shrink-0">
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="rgba(99,102,241,0.15)" strokeWidth="3"/>
|
||||||
<span className="text-green-500 font-medium">{systemData.memory_usage.toFixed(1)}%</span> of{" "}
|
<circle cx="18" cy="18" r="15.9155" fill="none" stroke="#3b82f6" strokeWidth="3"
|
||||||
{systemData.memory_total} GB
|
strokeDasharray={`${systemData.memory_usage} 100`} strokeLinecap="round"
|
||||||
</p>
|
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}/>
|
||||||
|
<text x="18" y="19.5" textAnchor="middle" fontSize="10" fontWeight="700" fill="currentColor">{Math.round(systemData.memory_usage)}%</text>
|
||||||
|
</svg>
|
||||||
|
<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">{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">
|
||||||
|
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${systemData.memory_usage}%` }}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<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">
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${systemData.memory_cached !== undefined && systemData.memory_total > 0 ? (systemData.memory_cached / systemData.memory_total) * 100 : 0}%`, background: 'rgba(99,102,241,0.55)' }}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
|
||||||
<Card className="bg-card border-border">
|
<Card className="bg-card border-border">
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.activeVmLxc")}</CardTitle>
|
||||||
<Server className="h-5 w-5 mr-2" />
|
<Server className="h-4 w-4 text-muted-foreground" />
|
||||||
Active VM & LXC
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loadingStates.vms ? (
|
{loadingStates.vms ? (
|
||||||
<div className="space-y-2 animate-pulse">
|
<div className="space-y-2 animate-pulse">
|
||||||
<div className="h-8 bg-muted rounded w-12"></div>
|
<div className="h-10 bg-muted rounded w-20"></div>
|
||||||
<div className="h-5 bg-muted rounded w-24"></div>
|
<div className="h-5 bg-muted rounded w-32"></div>
|
||||||
<div className="h-4 bg-muted rounded w-32"></div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{vmStats.running}</div>
|
<div className="flex items-end justify-between">
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
<div>
|
||||||
|
<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">
|
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
{vmStats.running} Running
|
{t("overview.runningCount", { count: vmStats.running })}
|
||||||
</Badge>
|
</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">
|
||||||
|
{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 && (
|
{vmStats.stopped > 0 && (
|
||||||
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">
|
<Badge variant="outline" className="bg-muted text-muted-foreground border-border">
|
||||||
{vmStats.stopped} Stopped
|
{t("overview.stoppedCount", { count: vmStats.stopped })}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
Total: {vmStats.vms} VMs, {vmStats.lxc} LXC
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -463,15 +535,20 @@ export function SystemOverview() {
|
|||||||
onClick={() => systemData.temperature > 0 && setTempModalOpen(true)}
|
onClick={() => systemData.temperature > 0 && setTempModalOpen(true)}
|
||||||
>
|
>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Temperature</CardTitle>
|
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.temperature")}</CardTitle>
|
||||||
<Thermometer className="h-4 w-4 text-muted-foreground" />
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<Thermometer className="h-4 w-4" />
|
||||||
|
{systemData.temperature > 0 && (
|
||||||
|
<ChevronRight className="h-4 w-4 opacity-60" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xl lg:text-2xl font-bold text-foreground">
|
<span className="text-xl lg:text-2xl font-bold text-foreground">
|
||||||
{systemData.temperature === 0 ? "N/A" : `${Math.round(systemData.temperature * 10) / 10}°C`}
|
{systemData.temperature === 0 ? t("app.notAvailable") : `${Math.round(systemData.temperature * 10) / 10}°C`}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="outline" className={tempStatus.color}>
|
<Badge variant="outline" className={`${tempStatus.color}`}>
|
||||||
{tempStatus.status}
|
{tempStatus.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,7 +576,7 @@ export function SystemOverview() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
{systemData.temperature === 0 ? "No sensor available" : "Collecting data..."}
|
{systemData.temperature === 0 ? t("overview.noSensorAvailable") : t("overview.collectingData")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -512,6 +589,18 @@ export function SystemOverview() {
|
|||||||
liveTemperature={systemData.temperature}
|
liveTemperature={systemData.temperature}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProcessDetailModal
|
||||||
|
open={cpuProcModalOpen}
|
||||||
|
onOpenChange={setCpuProcModalOpen}
|
||||||
|
sort="cpu"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProcessDetailModal
|
||||||
|
open={memProcModalOpen}
|
||||||
|
onOpenChange={setMemProcModalOpen}
|
||||||
|
sort="mem"
|
||||||
|
/>
|
||||||
|
|
||||||
<NodeMetricsCharts />
|
<NodeMetricsCharts />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
@@ -519,7 +608,7 @@ export function SystemOverview() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-foreground flex items-center">
|
||||||
<HardDrive className="h-5 w-5 mr-2" />
|
<HardDrive className="h-5 w-5 mr-2" />
|
||||||
Storage Overview
|
{t("overview.storageOverview")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -540,7 +629,7 @@ export function SystemOverview() {
|
|||||||
return totalCapacity > 0 ? (
|
return totalCapacity > 0 ? (
|
||||||
<div className="space-y-2 pb-4 border-b-2 border-border">
|
<div className="space-y-2 pb-4 border-b-2 border-border">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm font-medium text-foreground">Total Node Capacity:</span>
|
<span className="text-sm font-medium text-foreground">{t("overview.totalNodeCapacity")}</span>
|
||||||
<span className="text-lg font-bold text-foreground">
|
<span className="text-lg font-bold text-foreground">
|
||||||
{formatStorage(totalCapacity)}
|
{formatStorage(totalCapacity)}
|
||||||
</span>
|
</span>
|
||||||
@@ -552,13 +641,13 @@ export function SystemOverview() {
|
|||||||
<div className="flex justify-between items-center mt-1">
|
<div className="flex justify-between items-center mt-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Used:{" "}
|
{t("overview.used")}:{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{formatStorage(totalUsed)}
|
{formatStorage(totalUsed)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Free:{" "}
|
{t("overview.free")}:{" "}
|
||||||
<span className="font-semibold text-green-500">
|
<span className="font-semibold text-green-500">
|
||||||
{formatStorage(totalAvailable)}
|
{formatStorage(totalAvailable)}
|
||||||
</span>
|
</span>
|
||||||
@@ -572,28 +661,28 @@ export function SystemOverview() {
|
|||||||
|
|
||||||
<div className="space-y-2 pb-3 border-b border-border">
|
<div className="space-y-2 pb-3 border-b border-border">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-muted-foreground">Total Capacity:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.totalCapacity")}</span>
|
||||||
<span className="text-lg font-semibold text-foreground">{storageData.total} TB</span>
|
<span className="text-lg font-semibold text-foreground">{storageData.total} TB</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-muted-foreground">Physical Disks:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.physicalDisks")}</span>
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-semibold text-foreground">
|
||||||
{storageData.disk_count} disk{storageData.disk_count !== 1 ? "s" : ""}
|
{storageData.disk_count} {storageData.disk_count === 1 ? t("overview.diskSingular") : t("overview.diskPlural")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{vmLxcStorages && vmLxcStorages.length > 0 ? (
|
{vmLxcStorages && vmLxcStorages.length > 0 ? (
|
||||||
<div className="space-y-2 pb-3 border-b border-border">
|
<div className="space-y-2 pb-3 border-b border-border">
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-2">VM/LXC Storage</div>
|
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-xs text-muted-foreground">Used:</span>
|
<span className="text-xs text-muted-foreground">{t("overview.used")}:</span>
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-semibold text-foreground">
|
||||||
{formatStorage(vmLxcStorageUsed)}
|
{formatStorage(vmLxcStorageUsed)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-xs text-muted-foreground">Available:</span>
|
<span className="text-xs text-muted-foreground">{t("overview.available")}:</span>
|
||||||
<span className="text-sm font-semibold text-green-500">
|
<span className="text-sm font-semibold text-green-500">
|
||||||
{formatStorage(vmLxcStorageAvailable)}
|
{formatStorage(vmLxcStorageAvailable)}
|
||||||
</span>
|
</span>
|
||||||
@@ -608,28 +697,28 @@ export function SystemOverview() {
|
|||||||
</div>
|
</div>
|
||||||
{vmLxcStorages.length > 1 && (
|
{vmLxcStorages.length > 1 && (
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
{vmLxcStorages.length} storage volume{vmLxcStorages.length > 1 ? "s" : ""}
|
{vmLxcStorages.length} {vmLxcStorages.length === 1 ? t("overview.storageVolumeSingular") : t("overview.storageVolumePlural")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 pb-3 border-b border-border">
|
<div className="space-y-2 pb-3 border-b border-border">
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-2">VM/LXC Storage</div>
|
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
|
||||||
<div className="text-center py-4 text-muted-foreground text-sm">No VM/LXC storage configured</div>
|
<div className="text-center py-4 text-muted-foreground text-sm">{t("overview.noVmLxcStorage")}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{localStorage && (
|
{localStorage && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-2">Local Storage (System)</div>
|
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.localStorageSystem")}</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-xs text-muted-foreground">Used:</span>
|
<span className="text-xs text-muted-foreground">{t("overview.used")}:</span>
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<span className="text-sm font-semibold text-foreground">
|
||||||
{formatStorage(localStorage.used)}
|
{formatStorage(localStorage.used)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-xs text-muted-foreground">Available:</span>
|
<span className="text-xs text-muted-foreground">{t("overview.available")}:</span>
|
||||||
<span className="text-sm font-semibold text-green-500">
|
<span className="text-sm font-semibold text-green-500">
|
||||||
{formatStorage(localStorage.available)}
|
{formatStorage(localStorage.available)}
|
||||||
</span>
|
</span>
|
||||||
@@ -646,7 +735,7 @@ export function SystemOverview() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-muted-foreground">Storage data not available</div>
|
<div className="text-center py-8 text-muted-foreground">{t("overview.storageDataUnavailable")}</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -656,18 +745,18 @@ export function SystemOverview() {
|
|||||||
<CardTitle className="text-foreground flex items-center justify-between">
|
<CardTitle className="text-foreground flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Network className="h-5 w-5 mr-2" />
|
<Network className="h-5 w-5 mr-2" />
|
||||||
Network Overview
|
{t("overview.networkOverview")}
|
||||||
</div>
|
</div>
|
||||||
<Select value={networkTimeframe} onValueChange={setNetworkTimeframe}>
|
<Select value={networkTimeframe} onValueChange={setNetworkTimeframe}>
|
||||||
<SelectTrigger className="w-28 h-8 text-xs">
|
<SelectTrigger className="w-28 h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="hour">1 Hour</SelectItem>
|
<SelectItem value="hour">{t("overview.timeframes.hour")}</SelectItem>
|
||||||
<SelectItem value="day">24 Hours</SelectItem>
|
<SelectItem value="day">{t("overview.timeframes.day")}</SelectItem>
|
||||||
<SelectItem value="week">7 Days</SelectItem>
|
<SelectItem value="week">{t("overview.timeframes.week")}</SelectItem>
|
||||||
<SelectItem value="month">30 Days</SelectItem>
|
<SelectItem value="month">{t("overview.timeframes.month")}</SelectItem>
|
||||||
<SelectItem value="year">1 Year</SelectItem>
|
<SelectItem value="year">{t("overview.timeframes.year")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
@@ -682,7 +771,7 @@ export function SystemOverview() {
|
|||||||
) : networkData ? (
|
) : networkData ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||||
<span className="text-sm text-muted-foreground">Active Interfaces:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.activeInterfaces")}</span>
|
||||||
<span className="text-lg font-semibold text-foreground">
|
<span className="text-lg font-semibold text-foreground">
|
||||||
{(networkData.physical_active_count || 0) + (networkData.bridge_active_count || 0)}
|
{(networkData.physical_active_count || 0) + (networkData.bridge_active_count || 0)}
|
||||||
</span>
|
</span>
|
||||||
@@ -724,7 +813,7 @@ export function SystemOverview() {
|
|||||||
|
|
||||||
<div className="pt-2 border-t border-border space-y-2">
|
<div className="pt-2 border-t border-border space-y-2">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-muted-foreground">Received:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.received")}</span>
|
||||||
<span className="text-lg font-semibold text-green-500 flex items-center gap-1">
|
<span className="text-lg font-semibold text-green-500 flex items-center gap-1">
|
||||||
↓{" "}
|
↓{" "}
|
||||||
{networkUnit === "Bytes"
|
{networkUnit === "Bytes"
|
||||||
@@ -734,7 +823,7 @@ export function SystemOverview() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-muted-foreground">Sent:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.sent")}</span>
|
||||||
<span className="text-lg font-semibold text-blue-500 flex items-center gap-1">
|
<span className="text-lg font-semibold text-blue-500 flex items-center gap-1">
|
||||||
↑{" "}
|
↑{" "}
|
||||||
{networkUnit === "Bytes"
|
{networkUnit === "Bytes"
|
||||||
@@ -754,7 +843,7 @@ export function SystemOverview() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-muted-foreground">Network data not available</div>
|
<div className="text-center py-8 text-muted-foreground">{t("overview.networkDataUnavailable")}</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -765,27 +854,27 @@ export function SystemOverview() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-foreground flex items-center">
|
||||||
<Server className="h-5 w-5 mr-2" />
|
<Server className="h-5 w-5 mr-2" />
|
||||||
System Information
|
{t("overview.systemInformation")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Uptime:</span>
|
<span className="text-muted-foreground">{t("overview.uptime")}</span>
|
||||||
<span className="text-foreground">{systemData.uptime}</span>
|
<span className="text-foreground">{formatSystemUptime(systemData.uptime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Proxmox Version:</span>
|
<span className="text-muted-foreground">{t("overview.proxmoxVersion")}</span>
|
||||||
<span className="text-foreground">{systemData.proxmox_version || "N/A"}</span>
|
<span className="text-foreground">{systemData.proxmox_version || "N/A"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Kernel:</span>
|
<span className="text-muted-foreground">{t("overview.kernel")}</span>
|
||||||
<span className="text-foreground font-mono text-sm">{systemData.kernel_version || "Linux"}</span>
|
<span className="text-foreground font-mono text-sm">{systemData.kernel_version || "Linux"}</span>
|
||||||
</div>
|
</div>
|
||||||
{systemData.available_updates !== undefined && systemData.available_updates > 0 && (
|
{systemData.available_updates !== undefined && systemData.available_updates > 0 && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Available Updates:</span>
|
<span className="text-muted-foreground">{t("overview.availableUpdates")}</span>
|
||||||
<Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">
|
<Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">
|
||||||
{systemData.available_updates} packages
|
{systemData.available_updates} {t("overview.packages")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -796,13 +885,13 @@ export function SystemOverview() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-foreground flex items-center">
|
<CardTitle className="text-foreground flex items-center">
|
||||||
<Zap className="h-5 w-5 mr-2" />
|
<Zap className="h-5 w-5 mr-2" />
|
||||||
System Overview
|
{t("overview.systemOverview")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm text-muted-foreground">Load Average (1m):</span>
|
<span className="text-sm text-muted-foreground">{t("overview.loadAverage1m")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-lg font-semibold text-foreground font-mono">
|
<span className="text-lg font-semibold text-foreground font-mono">
|
||||||
@@ -815,17 +904,17 @@ export function SystemOverview() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||||
<span className="text-sm text-muted-foreground">CPU Threads:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.cpuThreads")}</span>
|
||||||
<span className="text-lg font-semibold text-foreground">{systemData.cpu_threads || "N/A"}</span>
|
<span className="text-lg font-semibold text-foreground">{systemData.cpu_threads || "N/A"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||||
<span className="text-sm text-muted-foreground">Physical Disks:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.physicalDisks")}</span>
|
||||||
<span className="text-lg font-semibold text-foreground">{storageData?.disk_count || "N/A"}</span>
|
<span className="text-lg font-semibold text-foreground">{storageData?.disk_count || "N/A"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-muted-foreground">Network Interfaces:</span>
|
<span className="text-sm text-muted-foreground">{t("overview.networkInterfaces")}</span>
|
||||||
<span className="text-lg font-semibold text-foreground">
|
<span className="text-lg font-semibold text-foreground">
|
||||||
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || "N/A"}
|
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || "N/A"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import { Thermometer, TrendingDown, TrendingUp, Minus } from "lucide-react"
|
|||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||||
import { useIsMobile } from "../hooks/use-mobile"
|
import { useIsMobile } from "../hooks/use-mobile"
|
||||||
import { fetchApi } from "@/lib/api-config"
|
import { fetchApi } from "@/lib/api-config"
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
const TIMEFRAME_OPTIONS = [
|
const TIMEFRAME_OPTIONS = [
|
||||||
{ value: "hour", label: "1 Hour" },
|
{ value: "hour", labelKey: "overview.timeframes.hour" },
|
||||||
{ value: "day", label: "24 Hours" },
|
{ value: "day", labelKey: "overview.timeframes.day" },
|
||||||
{ value: "week", label: "7 Days" },
|
{ value: "week", labelKey: "overview.timeframes.week" },
|
||||||
{ value: "month", label: "30 Days" },
|
{ value: "month", labelKey: "overview.timeframes.month" },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface TempHistoryPoint {
|
interface TempHistoryPoint {
|
||||||
@@ -70,7 +71,11 @@ const getStatusInfo = (temp: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }: TemperatureDetailModalProps) {
|
export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }: TemperatureDetailModalProps) {
|
||||||
const [timeframe, setTimeframe] = useState("hour")
|
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.
|
||||||
|
const [timeframe, setTimeframe] = useState("day")
|
||||||
const [data, setData] = useState<TempHistoryPoint[]>([])
|
const [data, setData] = useState<TempHistoryPoint[]>([])
|
||||||
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
|
const [stats, setStats] = useState<TempStats>({ min: 0, max: 0, avg: 0, current: 0 })
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -135,7 +140,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
|||||||
<div className="flex items-center justify-between pr-6">
|
<div className="flex items-center justify-between pr-6">
|
||||||
<DialogTitle className="text-foreground flex items-center gap-2">
|
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||||
<Thermometer className="h-5 w-5" />
|
<Thermometer className="h-5 w-5" />
|
||||||
CPU Temperature
|
{t("details.temperature.title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<Select value={timeframe} onValueChange={setTimeframe}>
|
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||||
<SelectTrigger className="w-[130px] bg-card border-border">
|
<SelectTrigger className="w-[130px] bg-card border-border">
|
||||||
@@ -144,7 +149,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TIMEFRAME_OPTIONS.map((opt) => (
|
{TIMEFRAME_OPTIONS.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{t(opt.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -155,24 +160,24 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
|||||||
{/* Stats bar */}
|
{/* Stats bar */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
|
||||||
<div className={`rounded-lg p-3 text-center ${currentStatus.color}`}>
|
<div className={`rounded-lg p-3 text-center ${currentStatus.color}`}>
|
||||||
<div className="text-xs opacity-80 mb-1">Current</div>
|
<div className="text-xs opacity-80 mb-1">{t("details.temperature.current")}</div>
|
||||||
<div className="text-lg font-bold">{currentTemp}°C</div>
|
<div className="text-lg font-bold">{currentTemp}°C</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||||
<TrendingDown className="h-3 w-3" /> Min
|
<TrendingDown className="h-3 w-3" /> {t("details.temperature.min")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
|
<div className="text-lg font-bold text-green-500">{stats.min}°C</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||||
<Minus className="h-3 w-3" /> Avg
|
<Minus className="h-3 w-3" /> {t("details.temperature.avg")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
|
<div className="text-lg font-bold text-foreground">{stats.avg}°C</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
<div className="bg-muted/50 rounded-lg p-3 text-center">
|
||||||
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
<div className="text-xs text-muted-foreground mb-1 flex items-center justify-center gap-1">
|
||||||
<TrendingUp className="h-3 w-3" /> Max
|
<TrendingUp className="h-3 w-3" /> {t("details.temperature.max")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
|
<div className="text-lg font-bold text-red-500">{stats.max}°C</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,8 +196,8 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
|||||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
<Thermometer className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||||
<p>No temperature data available for this period</p>
|
<p>{t("details.temperature.noData")}</p>
|
||||||
<p className="text-sm mt-1">Data is collected every 60 seconds</p>
|
<p className="text-sm mt-1">{t("details.temperature.collectionHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -225,7 +230,7 @@ export function TemperatureDetailModal({ open, onOpenChange, liveTemperature }:
|
|||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
name="Temperature"
|
name={t("details.temperature.seriesName")}
|
||||||
stroke={chartColor}
|
stroke={chartColor}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
fill="url(#tempGradient)"
|
fill="url(#tempGradient)"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||||
import type { CheatSheetResult } from "@/lib/cheat-sheet-result" // Declare CheatSheetResult here
|
import type { CheatSheetResult } from "@/lib/cheat-sheet-result" // Declare CheatSheetResult here
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
|
|
||||||
type TerminalPanelProps = {
|
type TerminalPanelProps = {
|
||||||
websocketUrl?: string
|
websocketUrl?: string
|
||||||
@@ -78,74 +79,30 @@ function getApiUrl(endpoint?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const proxmoxCommands = [
|
const proxmoxCommands = [
|
||||||
{ cmd: "pvesh get /nodes", desc: "List all Proxmox nodes" },
|
"pvesh get /nodes", "pvesh get /nodes/{node}/qemu", "pvesh get /nodes/{node}/lxc",
|
||||||
{ cmd: "pvesh get /nodes/{node}/qemu", desc: "List VMs on a node" },
|
"pvesh get /nodes/{node}/storage", "pvesh get /nodes/{node}/network", "qm list",
|
||||||
{ cmd: "pvesh get /nodes/{node}/lxc", desc: "List LXC containers on a node" },
|
"qm start <vmid>", "qm stop <vmid>", "qm shutdown <vmid>", "qm status <vmid>",
|
||||||
{ cmd: "pvesh get /nodes/{node}/storage", desc: "List storage on a node" },
|
"qm config <vmid>", "qm snapshot <vmid> <snapname>", "pct list", "pct start <vmid>",
|
||||||
{ cmd: "pvesh get /nodes/{node}/network", desc: "List network interfaces" },
|
"pct stop <vmid>", "pct enter <vmid>", "pct config <vmid>", "pvesm status",
|
||||||
{ cmd: "qm list", desc: "List all QEMU/KVM virtual machines" },
|
"pvesm list <storage>", "pveperf", "pveversion", "systemctl status pve-cluster",
|
||||||
{ cmd: "qm start <vmid>", desc: "Start a virtual machine" },
|
"pvecm status", "pvecm nodes", "zpool status", "zpool list", "zfs list", "ls -la",
|
||||||
{ cmd: "qm stop <vmid>", desc: "Stop a virtual machine" },
|
"cd /path/to/dir", "mkdir dirname", "rm -rf dirname", "cp source dest", "mv source dest",
|
||||||
{ cmd: "qm shutdown <vmid>", desc: "Shutdown a virtual machine gracefully" },
|
"cat filename", "grep 'pattern' file", "find . -name 'file'", "chmod 755 file",
|
||||||
{ cmd: "qm status <vmid>", desc: "Show VM status" },
|
"chown user:group file", "tar -xzf file.tar.gz", "tar -czf archive.tar.gz dir/", "df -h",
|
||||||
{ cmd: "qm config <vmid>", desc: "Show VM configuration" },
|
"du -sh *", "free -h", "top", "ps aux | grep process", "kill -9 PID",
|
||||||
{ cmd: "qm snapshot <vmid> <snapname>", desc: "Create VM snapshot" },
|
"systemctl status service", "systemctl start service", "systemctl stop service",
|
||||||
{ cmd: "pct list", desc: "List all LXC containers" },
|
"systemctl restart service", "apt update && apt upgrade", "apt install package",
|
||||||
{ cmd: "pct start <vmid>", desc: "Start LXC container" },
|
"apt remove package", "docker ps", "docker images", "docker exec -it container bash",
|
||||||
{ cmd: "pct stop <vmid>", desc: "Stop LXC container" },
|
"ip addr show", "ping host", "curl -I url", "wget url", "ssh user@host",
|
||||||
{ cmd: "pct enter <vmid>", desc: "Enter LXC container console" },
|
"scp file user@host:/path", "tail -f /var/log/syslog", "history", "clear",
|
||||||
{ cmd: "pct config <vmid>", desc: "Show container configuration" },
|
|
||||||
{ cmd: "pvesm status", desc: "Show storage status" },
|
|
||||||
{ cmd: "pvesm list <storage>", desc: "List storage content" },
|
|
||||||
{ cmd: "pveperf", desc: "Test Proxmox system performance" },
|
|
||||||
{ cmd: "pveversion", desc: "Show Proxmox VE version" },
|
|
||||||
{ cmd: "systemctl status pve-cluster", desc: "Check cluster status" },
|
|
||||||
{ cmd: "pvecm status", desc: "Show cluster status" },
|
|
||||||
{ cmd: "pvecm nodes", desc: "List cluster nodes" },
|
|
||||||
{ cmd: "zpool status", desc: "Show ZFS pool status" },
|
|
||||||
{ cmd: "zpool list", desc: "List all ZFS pools" },
|
|
||||||
{ cmd: "zfs list", desc: "List all ZFS datasets" },
|
|
||||||
{ cmd: "ls -la", desc: "List all files with details" },
|
|
||||||
{ cmd: "cd /path/to/dir", desc: "Change directory" },
|
|
||||||
{ cmd: "mkdir dirname", desc: "Create new directory" },
|
|
||||||
{ cmd: "rm -rf dirname", desc: "Remove directory recursively" },
|
|
||||||
{ cmd: "cp source dest", desc: "Copy files or directories" },
|
|
||||||
{ cmd: "mv source dest", desc: "Move or rename files" },
|
|
||||||
{ cmd: "cat filename", desc: "Display file contents" },
|
|
||||||
{ cmd: "grep 'pattern' file", desc: "Search for pattern in file" },
|
|
||||||
{ cmd: "find . -name 'file'", desc: "Find files by name" },
|
|
||||||
{ cmd: "chmod 755 file", desc: "Change file permissions" },
|
|
||||||
{ cmd: "chown user:group file", desc: "Change file owner" },
|
|
||||||
{ cmd: "tar -xzf file.tar.gz", desc: "Extract tar.gz archive" },
|
|
||||||
{ cmd: "tar -czf archive.tar.gz dir/", desc: "Create tar.gz archive" },
|
|
||||||
{ cmd: "df -h", desc: "Show disk usage" },
|
|
||||||
{ cmd: "du -sh *", desc: "Show directory sizes" },
|
|
||||||
{ cmd: "free -h", desc: "Show memory usage" },
|
|
||||||
{ cmd: "top", desc: "Show running processes" },
|
|
||||||
{ cmd: "ps aux | grep process", desc: "Find running process" },
|
|
||||||
{ cmd: "kill -9 PID", desc: "Force kill process" },
|
|
||||||
{ cmd: "systemctl status service", desc: "Check service status" },
|
|
||||||
{ cmd: "systemctl start service", desc: "Start a service" },
|
|
||||||
{ cmd: "systemctl stop service", desc: "Stop a service" },
|
|
||||||
{ cmd: "systemctl restart service", desc: "Restart a service" },
|
|
||||||
{ cmd: "apt update && apt upgrade", desc: "Update Debian/Ubuntu packages" },
|
|
||||||
{ cmd: "apt install package", desc: "Install package on Debian/Ubuntu" },
|
|
||||||
{ cmd: "apt remove package", desc: "Remove package" },
|
|
||||||
{ cmd: "docker ps", desc: "List running containers" },
|
|
||||||
{ cmd: "docker images", desc: "List Docker images" },
|
|
||||||
{ cmd: "docker exec -it container bash", desc: "Enter container shell" },
|
|
||||||
{ cmd: "ip addr show", desc: "Show IP addresses" },
|
|
||||||
{ cmd: "ping host", desc: "Test network connectivity" },
|
|
||||||
{ cmd: "curl -I url", desc: "Get HTTP headers" },
|
|
||||||
{ cmd: "wget url", desc: "Download file from URL" },
|
|
||||||
{ cmd: "ssh user@host", desc: "Connect via SSH" },
|
|
||||||
{ cmd: "scp file user@host:/path", desc: "Copy file via SSH" },
|
|
||||||
{ cmd: "tail -f /var/log/syslog", desc: "Follow log file in real-time" },
|
|
||||||
{ cmd: "history", desc: "Show command history" },
|
|
||||||
{ cmd: "clear", desc: "Clear terminal screen" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onClose }) => {
|
export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onClose }) => {
|
||||||
|
const t = useT()
|
||||||
|
const localizedCommands = proxmoxCommands.map((cmd, index) => ({
|
||||||
|
cmd,
|
||||||
|
desc: t(`terminal.commandDescriptions.${index}`),
|
||||||
|
}))
|
||||||
const [terminals, setTerminals] = useState<TerminalInstance[]>([])
|
const [terminals, setTerminals] = useState<TerminalInstance[]>([])
|
||||||
const [activeTerminalId, setActiveTerminalId] = useState<string>("")
|
const [activeTerminalId, setActiveTerminalId] = useState<string>("")
|
||||||
const [layout, setLayout] = useState<"single" | "grid">("grid")
|
const [layout, setLayout] = useState<"single" | "grid">("grid")
|
||||||
@@ -154,7 +111,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
const [terminalHeight, setTerminalHeight] = useState<number>(500) // altura por defecto en px
|
const [terminalHeight, setTerminalHeight] = useState<number>(500) // altura por defecto en px
|
||||||
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
const [searchModalOpen, setSearchModalOpen] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(proxmoxCommands)
|
const [filteredCommands, setFilteredCommands] = useState<Array<{ cmd: string; desc: string }>>(localizedCommands)
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
const [searchResults, setSearchResults] = useState<CheatSheetResult[]>([])
|
||||||
const [useOnline, setUseOnline] = useState(true)
|
const [useOnline, setUseOnline] = useState(true)
|
||||||
@@ -272,7 +229,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
const searchCheatSh = async (query: string) => {
|
const searchCheatSh = async (query: string) => {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
setFilteredCommands(proxmoxCommands)
|
setFilteredCommands(localizedCommands)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +244,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!data.success || !data.examples || data.examples.length === 0) {
|
if (!data.success || !data.examples || data.examples.length === 0) {
|
||||||
throw new Error("No examples found")
|
throw new Error(t("terminal.noExamplesFound"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -300,7 +257,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
setUseOnline(true)
|
setUseOnline(true)
|
||||||
setSearchResults(formattedResults)
|
setSearchResults(formattedResults)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const filtered = proxmoxCommands.filter(
|
const filtered = localizedCommands.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
item.cmd.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
item.desc.toLowerCase().includes(query.toLowerCase()),
|
item.desc.toLowerCase().includes(query.toLowerCase()),
|
||||||
@@ -318,12 +275,12 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
searchCheatSh(searchQuery)
|
searchCheatSh(searchQuery)
|
||||||
} else {
|
} else {
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
setFilteredCommands(proxmoxCommands)
|
setFilteredCommands(localizedCommands)
|
||||||
}
|
}
|
||||||
}, 800)
|
}, 800)
|
||||||
|
|
||||||
return () => clearTimeout(debounce)
|
return () => clearTimeout(debounce)
|
||||||
}, [searchQuery])
|
}, [searchQuery, t])
|
||||||
|
|
||||||
// Function to reconnect a terminal when connection is lost
|
// Function to reconnect a terminal when connection is lost
|
||||||
// This is called when page visibility changes (user returns from another app)
|
// This is called when page visibility changes (user returns from another app)
|
||||||
@@ -332,7 +289,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
if (!terminal || !terminal.term) return
|
if (!terminal || !terminal.term) return
|
||||||
|
|
||||||
// Show reconnecting message
|
// Show reconnecting message
|
||||||
terminal.term.writeln('\r\n\x1b[33m[INFO] Reconnecting...\x1b[0m')
|
terminal.term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.reconnecting")}\x1b[0m`)
|
||||||
|
|
||||||
const wsUrl = websocketUrl || getWebSocketUrl()
|
const wsUrl = websocketUrl || getWebSocketUrl()
|
||||||
// Append the single-use auth ticket so the backend handshake can validate.
|
// Append the single-use auth ticket so the backend handshake can validate.
|
||||||
@@ -358,7 +315,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
setTerminals((prev) =>
|
setTerminals((prev) =>
|
||||||
prev.map((t) => (t.id === terminalId ? { ...t, isConnected: true, ws, pingInterval } : t))
|
prev.map((t) => (t.id === terminalId ? { ...t, isConnected: true, ws, pingInterval } : t))
|
||||||
)
|
)
|
||||||
terminal.term.writeln('\r\n\x1b[32m[INFO] Reconnected successfully\x1b[0m')
|
terminal.term.writeln(`\r\n\x1b[32m[INFO] ${t("terminal.reconnected")}\x1b[0m`)
|
||||||
|
|
||||||
// Sync terminal size
|
// Sync terminal size
|
||||||
if (terminal.fitAddon) {
|
if (terminal.fitAddon) {
|
||||||
@@ -384,7 +341,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
terminal.term.writeln('\r\n\x1b[31m[ERROR] Reconnection failed\x1b[0m')
|
terminal.term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.reconnectionFailed")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
@@ -397,7 +354,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
}
|
}
|
||||||
return t
|
return t
|
||||||
}))
|
}))
|
||||||
terminal.term.writeln('\r\n\x1b[33m[INFO] Connection closed\x1b[0m')
|
terminal.term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.term.onData((data: string) => {
|
terminal.term.onData((data: string) => {
|
||||||
@@ -415,7 +372,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: newId,
|
id: newId,
|
||||||
title: `Terminal ${prev.length + 1}`,
|
title: t("terminal.terminalTitle", { number: prev.length + 1 }),
|
||||||
term: null,
|
term: null,
|
||||||
ws: null,
|
ws: null,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
@@ -570,8 +527,8 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
if (ws.readyState !== WebSocket.OPEN) {
|
if (ws.readyState !== WebSocket.OPEN) {
|
||||||
connectionTimedOut = true
|
connectionTimedOut = true
|
||||||
ws.close()
|
ws.close()
|
||||||
term.writeln('\x1b[31m[ERROR] Connection timeout. Please check your network and try again.\x1b[0m')
|
term.writeln(`\x1b[31m[ERROR] ${t("terminal.connectionTimeout")}\x1b[0m`)
|
||||||
term.writeln('\x1b[33m[TIP] If using VPN, ensure the connection is stable.\x1b[0m')
|
term.writeln(`\x1b[33m[TIP] ${t("terminal.vpnTip")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
}, connectionTimeout)
|
}, connectionTimeout)
|
||||||
|
|
||||||
@@ -636,7 +593,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
}))
|
}))
|
||||||
// Only show error if not already shown by timeout
|
// Only show error if not already shown by timeout
|
||||||
if (!connectionTimedOut) {
|
if (!connectionTimedOut) {
|
||||||
term.writeln("\r\n\x1b[31m[ERROR] WebSocket connection error\x1b[0m")
|
term.writeln(`\r\n\x1b[31m[ERROR] ${t("terminal.websocketError")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,7 +610,7 @@ export const TerminalPanel: React.FC<TerminalPanelProps> = ({ websocketUrl, onCl
|
|||||||
}))
|
}))
|
||||||
// Only show close message if not already shown by timeout
|
// Only show close message if not already shown by timeout
|
||||||
if (!connectionTimedOut) {
|
if (!connectionTimedOut) {
|
||||||
term.writeln("\r\n\x1b[33m[INFO] Connection closed\x1b[0m")
|
term.writeln(`\r\n\x1b[33m[INFO] ${t("terminal.connectionClosed")}\x1b[0m`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,9 +773,9 @@ const handleClose = () => {
|
|||||||
<Activity className="h-5 w-5 text-blue-500" />
|
<Activity className="h-5 w-5 text-blue-500" />
|
||||||
<div
|
<div
|
||||||
className={`w-2 h-2 rounded-full ${activeTerminal?.isConnected ? "bg-green-500" : "bg-red-500"}`}
|
className={`w-2 h-2 rounded-full ${activeTerminal?.isConnected ? "bg-green-500" : "bg-red-500"}`}
|
||||||
title={activeTerminal?.isConnected ? "Connected" : "Disconnected"}
|
title={activeTerminal?.isConnected ? t("terminal.connected") : t("terminal.disconnected")}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-xs text-zinc-500">{terminals.length} / 4 terminals</span>
|
<span className="text-xs text-zinc-500">{t("terminal.terminalCount", { count: terminals.length })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -829,7 +786,7 @@ const handleClose = () => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={`h-8 px-2 ${layout === "single" ? "bg-blue-500/20 border-blue-500" : ""}`}
|
className={`h-8 px-2 ${layout === "single" ? "bg-blue-500/20 border-blue-500" : ""}`}
|
||||||
title="Vista apilada (filas)"
|
title={t("terminal.stackedLayout")}
|
||||||
>
|
>
|
||||||
<AlignJustify className="h-4 w-4" />
|
<AlignJustify className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -838,7 +795,7 @@ const handleClose = () => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={`h-8 px-2 ${layout === "grid" ? "bg-blue-500/20 border-blue-500" : ""}`}
|
className={`h-8 px-2 ${layout === "grid" ? "bg-blue-500/20 border-blue-500" : ""}`}
|
||||||
title="Vista cuadrícula 2x2"
|
title={t("terminal.gridLayout")}
|
||||||
>
|
>
|
||||||
<Grid2X2 className="h-4 w-4" />
|
<Grid2X2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -852,7 +809,7 @@ const handleClose = () => {
|
|||||||
className="h-8 gap-2 bg-green-600/20 hover:bg-green-600/30 border-green-600/50 text-green-400 disabled:opacity-50"
|
className="h-8 gap-2 bg-green-600/20 hover:bg-green-600/30 border-green-600/50 text-green-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">New</span>
|
<span className="hidden sm:inline">{t("terminal.new")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setSearchModalOpen(true)}
|
onClick={() => setSearchModalOpen(true)}
|
||||||
@@ -862,7 +819,7 @@ const handleClose = () => {
|
|||||||
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
|
className="h-8 gap-2 bg-blue-600/20 hover:bg-blue-600/30 border-blue-600/50 text-blue-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Search</span>
|
<span className="hidden sm:inline">{t("terminal.search")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
@@ -872,7 +829,7 @@ const handleClose = () => {
|
|||||||
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
|
className="h-8 gap-2 bg-yellow-600/20 hover:bg-yellow-600/30 border-yellow-600/50 text-yellow-400 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Clear</span>
|
<span className="hidden sm:inline">{t("terminal.clear")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
@@ -881,7 +838,7 @@ const handleClose = () => {
|
|||||||
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
className="h-8 gap-2 bg-red-600/20 hover:bg-red-600/30 border-red-600/50 text-red-400"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Close</span>
|
<span className="hidden sm:inline">{t("actions.close")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1075,29 +1032,29 @@ const handleClose = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Control Sequences</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.controlSequences")}</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onSelect={() => sendSequence("\x03")}>
|
<DropdownMenuItem onSelect={() => sendSequence("\x03")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
<span className="font-mono text-xs mr-2">Ctrl+C</span>
|
||||||
<span className="text-muted-foreground text-xs">Cancel/Interrupt</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => sendSequence("\x18")}>
|
<DropdownMenuItem onSelect={() => sendSequence("\x18")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
<span className="font-mono text-xs mr-2">Ctrl+X</span>
|
||||||
<span className="text-muted-foreground text-xs">Exit (nano)</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => sendSequence("\x12")}>
|
<DropdownMenuItem onSelect={() => sendSequence("\x12")}>
|
||||||
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
<span className="font-mono text-xs mr-2">Ctrl+R</span>
|
||||||
<span className="text-muted-foreground text-xs">Search history</span>
|
<span className="text-muted-foreground text-xs">{t("scriptTerminal.searchHistory")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Clipboard</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">{t("scriptTerminal.clipboard")}</DropdownMenuLabel>
|
||||||
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
<DropdownMenuItem onSelect={() => { void handleCopy() }}>
|
||||||
<Copy className="h-3.5 w-3.5 mr-2" />
|
<Copy className="h-3.5 w-3.5 mr-2" />
|
||||||
<span className="text-xs">Copy selection</span>
|
<span className="text-xs">{t("scriptTerminal.copySelection")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
<DropdownMenuItem onSelect={() => { void handlePaste() }}>
|
||||||
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
<Clipboard className="h-3.5 w-3.5 mr-2" />
|
||||||
<span className="text-xs">Paste</span>
|
<span className="text-xs">{t("scriptTerminal.paste")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -1107,22 +1064,22 @@ const handleClose = () => {
|
|||||||
<Dialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
<Dialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
||||||
<DialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
<DialogContent className="max-w-3xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
|
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-zinc-800">
|
||||||
<DialogTitle className="text-xl font-semibold">Search Commands</DialogTitle>
|
<DialogTitle className="text-xl font-semibold">{t("terminal.searchCommands")}</DialogTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
|
className={`w-2 h-2 rounded-full ${useOnline ? "bg-green-500" : "bg-red-500"}`}
|
||||||
title={useOnline ? "Online - Using cheat.sh API" : "Offline - Using local commands"}
|
title={useOnline ? t("terminal.onlineSource") : t("terminal.offlineSource")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogDescription className="sr-only">Search for Linux and Proxmox commands</DialogDescription>
|
<DialogDescription className="sr-only">{t("terminal.searchDescription")}</DialogDescription>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search commands... (e.g., tar, docker, qm, systemctl)"
|
placeholder={t("terminal.searchPlaceholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
|
className="pl-10 bg-zinc-900 border-zinc-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-base"
|
||||||
@@ -1136,7 +1093,7 @@ const handleClose = () => {
|
|||||||
{isSearching && (
|
{isSearching && (
|
||||||
<div className="text-center py-4 text-zinc-400">
|
<div className="text-center py-4 text-zinc-400">
|
||||||
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
|
<div className="animate-spin inline-block w-6 h-6 border-2 border-current border-t-transparent rounded-full mb-2" />
|
||||||
<p className="text-sm">Searching cheat.sh...</p>
|
<p className="text-sm">{t("terminal.searchingCheatSh")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1164,7 +1121,7 @@ const handleClose = () => {
|
|||||||
<div className="text-center py-2">
|
<div className="text-center py-2">
|
||||||
<p className="text-xs text-zinc-500">
|
<p className="text-xs text-zinc-500">
|
||||||
<Lightbulb className="inline-block w-3 h-3 mr-1" />
|
<Lightbulb className="inline-block w-3 h-3 mr-1" />
|
||||||
Powered by cheat.sh
|
{t("terminal.poweredByCheatSh")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -1190,13 +1147,13 @@ const handleClose = () => {
|
|||||||
className="shrink-0 h-7 px-2 text-xs"
|
className="shrink-0 h-7 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<Send className="h-3 w-3 mr-1" />
|
<Send className="h-3 w-3 mr-1" />
|
||||||
Send
|
{t("terminal.send")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : !isSearching && !searchQuery && !useOnline ? (
|
) : !isSearching && !searchQuery && !useOnline ? (
|
||||||
proxmoxCommands.map((item, index) => (
|
localizedCommands.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => sendToActiveTerminal(item.cmd)}
|
onClick={() => sendToActiveTerminal(item.cmd)}
|
||||||
@@ -1217,7 +1174,7 @@ const handleClose = () => {
|
|||||||
className="shrink-0 h-7 px-2 text-xs"
|
className="shrink-0 h-7 px-2 text-xs"
|
||||||
>
|
>
|
||||||
<Send className="h-3 w-3 mr-1" />
|
<Send className="h-3 w-3 mr-1" />
|
||||||
Send
|
{t("terminal.send")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1228,17 +1185,17 @@ const handleClose = () => {
|
|||||||
<>
|
<>
|
||||||
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
|
<Search className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-zinc-400 font-medium">No results found for "{searchQuery}"</p>
|
<p className="text-zinc-400 font-medium">{t("terminal.noResults", { query: searchQuery })}</p>
|
||||||
<p className="text-xs text-zinc-500 mt-1">Try a different command or check your spelling</p>
|
<p className="text-xs text-zinc-500 mt-1">{t("terminal.tryDifferentSearch")}</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
<Terminal className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-zinc-400 font-medium mb-2">Search for any command</p>
|
<p className="text-zinc-400 font-medium mb-2">{t("terminal.searchAnyCommand")}</p>
|
||||||
<div className="text-sm text-zinc-500 space-y-1">
|
<div className="text-sm text-zinc-500 space-y-1">
|
||||||
<p>Try searching for:</p>
|
<p>{t("terminal.trySearchingFor")}</p>
|
||||||
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
<div className="flex flex-wrap justify-center gap-2 mt-2">
|
||||||
{["tar", "grep", "docker", "qm", "systemctl"].map((cmd) => (
|
{["tar", "grep", "docker", "qm", "systemctl"].map((cmd) => (
|
||||||
<code
|
<code
|
||||||
@@ -1255,7 +1212,7 @@ const handleClose = () => {
|
|||||||
{useOnline && (
|
{useOnline && (
|
||||||
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
|
<div className="flex items-center justify-center gap-2 text-xs text-zinc-600 mt-4">
|
||||||
<Lightbulb className="w-3 h-3" />
|
<Lightbulb className="w-3 h-3" />
|
||||||
<span>Powered by cheat.sh</span>
|
<span>{t("terminal.poweredByCheatSh")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -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="pt-2 border-t border-zinc-800 flex items-center justify-between text-xs text-zinc-500">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Lightbulb className="w-3 h-3" />
|
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { useTheme } from "next-themes"
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
|
const t = useT()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ export function ThemeToggle() {
|
|||||||
return (
|
return (
|
||||||
<Button variant="outline" size="sm" className="border-border bg-transparent w-9 h-9">
|
<Button variant="outline" size="sm" className="border-border bg-transparent w-9 h-9">
|
||||||
<Sun className="h-4 w-4" />
|
<Sun className="h-4 w-4" />
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">{t("actions.toggleTheme")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -31,7 +33,7 @@ export function ThemeToggle() {
|
|||||||
<Button variant="outline" size="sm" onClick={handleThemeToggle} className="border-border bg-transparent w-9 h-9">
|
<Button variant="outline" size="sm" onClick={handleThemeToggle} className="border-border bg-transparent w-9 h-9">
|
||||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">{t("actions.toggleTheme")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Input } from "./ui/input"
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||||
import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react"
|
import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react"
|
||||||
import { getApiUrl } from "../lib/api-config"
|
import { getApiUrl } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
interface TwoFactorSetupProps {
|
interface TwoFactorSetupProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -14,6 +15,8 @@ interface TwoFactorSetupProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps) {
|
export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps) {
|
||||||
|
const t = useT()
|
||||||
|
const tf = (key: string) => t(`securityPage.twoFactorSetup.${key}`)
|
||||||
const [step, setStep] = useState(1)
|
const [step, setStep] = useState(1)
|
||||||
const [qrCode, setQrCode] = useState("")
|
const [qrCode, setQrCode] = useState("")
|
||||||
const [secret, setSecret] = useState("")
|
const [secret, setSecret] = useState("")
|
||||||
@@ -41,7 +44,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.message || "Failed to setup 2FA")
|
throw new Error(data.message || tf("setupFailed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
setQrCode(data.qr_code)
|
setQrCode(data.qr_code)
|
||||||
@@ -49,7 +52,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
setBackupCodes(data.backup_codes)
|
setBackupCodes(data.backup_codes)
|
||||||
setStep(2)
|
setStep(2)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to setup 2FA")
|
setError(err instanceof Error ? err.message : tf("setupFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -57,7 +60,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
|
|
||||||
const handleVerify = async () => {
|
const handleVerify = async () => {
|
||||||
if (!verificationCode || verificationCode.length !== 6) {
|
if (!verificationCode || verificationCode.length !== 6) {
|
||||||
setError("Please enter a 6-digit code")
|
setError(tf("enterSixDigitCode"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,12 +81,12 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.message || "Invalid verification code")
|
throw new Error(data.message || tf("invalidCode"))
|
||||||
}
|
}
|
||||||
|
|
||||||
setStep(3)
|
setStep(3)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Verification failed")
|
setError(err instanceof Error ? err.message : tf("verificationFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -92,33 +95,59 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
const copyToClipboard = async (text: string, type: "secret" | "codes") => {
|
const copyToClipboard = async (text: string, type: "secret" | "codes") => {
|
||||||
let ok = false
|
let ok = false
|
||||||
|
|
||||||
// Preferred path (HTTPS / localhost). On plain HTTP the Promise rejects,
|
// Path 1: modern Clipboard API. Only works on HTTPS / localhost.
|
||||||
// so we catch and fall through to the textarea fallback.
|
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard?.writeText) {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
ok = true
|
ok = true
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to execCommand fallback
|
// fall through
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path 2: legacy execCommand. Picky — some browsers (iOS Safari
|
||||||
|
// especially) refuse to copy from an element placed off-screen
|
||||||
|
// (`left: -9999px`), which is the previous version's mistake.
|
||||||
|
// Keep the textarea inside the viewport but visually invisible.
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
const textarea = document.createElement("textarea")
|
||||||
|
textarea.value = text
|
||||||
|
textarea.style.position = "fixed"
|
||||||
|
textarea.style.top = "0"
|
||||||
|
textarea.style.left = "0"
|
||||||
|
textarea.style.width = "2em"
|
||||||
|
textarea.style.height = "2em"
|
||||||
|
textarea.style.padding = "0"
|
||||||
|
textarea.style.border = "none"
|
||||||
|
textarea.style.outline = "none"
|
||||||
|
textarea.style.boxShadow = "none"
|
||||||
|
textarea.style.background = "transparent"
|
||||||
|
textarea.style.opacity = "0"
|
||||||
|
textarea.setAttribute("readonly", "")
|
||||||
|
textarea.setAttribute("aria-hidden", "true")
|
||||||
|
document.body.appendChild(textarea)
|
||||||
try {
|
try {
|
||||||
const textarea = document.createElement("textarea")
|
|
||||||
textarea.value = text
|
|
||||||
textarea.style.position = "fixed"
|
|
||||||
textarea.style.left = "-9999px"
|
|
||||||
textarea.style.top = "-9999px"
|
|
||||||
textarea.style.opacity = "0"
|
|
||||||
textarea.readOnly = true
|
|
||||||
document.body.appendChild(textarea)
|
|
||||||
textarea.focus()
|
textarea.focus()
|
||||||
textarea.select()
|
textarea.select()
|
||||||
|
textarea.setSelectionRange(0, text.length)
|
||||||
ok = document.execCommand("copy")
|
ok = document.execCommand("copy")
|
||||||
document.body.removeChild(textarea)
|
|
||||||
} catch {
|
} catch {
|
||||||
ok = false
|
ok = false
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path 3: last-resort window.prompt — ugly but unblockable. The
|
||||||
|
// user can select+copy from the prompt manually. This guarantees
|
||||||
|
// they can finish the 2FA setup even on plain-HTTP Monitor where
|
||||||
|
// both the Clipboard API and execCommand may be locked down.
|
||||||
|
if (!ok) {
|
||||||
|
try {
|
||||||
|
window.prompt(tf("copyPrompt"), text)
|
||||||
|
ok = true
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,9 +186,9 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Shield className="h-5 w-5 text-blue-500" />
|
<Shield className="h-5 w-5 text-blue-500" />
|
||||||
Setup Two-Factor Authentication
|
{tf("title")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>Add an extra layer of security to your account</DialogDescription>
|
<DialogDescription>{tf("description")}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -173,22 +202,21 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
|
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
|
||||||
<p className="text-sm text-blue-500">
|
<p className="text-sm text-blue-500">
|
||||||
Two-factor authentication (2FA) adds an extra layer of security by requiring a code from your
|
{tf("intro")}
|
||||||
authentication app in addition to your password.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium">You will need:</h4>
|
<h4 className="font-medium">{tf("youWillNeed")}</h4>
|
||||||
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
||||||
<li>An authentication app (Google Authenticator, Authy, etc.)</li>
|
<li>{tf("needApp")}</li>
|
||||||
<li>Scan a QR code or enter a key manually</li>
|
<li>{tf("needQrOrKey")}</li>
|
||||||
<li>Store backup codes securely</li>
|
<li>{tf("needBackupCodes")}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleSetupStart} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
<Button onClick={handleSetupStart} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||||
{loading ? "Starting..." : "Start Setup"}
|
{loading ? tf("starting") : tf("startSetup")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -196,24 +224,24 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium">1. Scan the QR code</h4>
|
<h4 className="font-medium">{tf("scanTitle")}</h4>
|
||||||
<p className="text-sm text-muted-foreground">Open your authentication app and scan this QR code</p>
|
<p className="text-sm text-muted-foreground">{tf("scanDescription")}</p>
|
||||||
{qrCode && (
|
{qrCode && (
|
||||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||||
<img src={qrCode || "/placeholder.svg"} alt="QR Code" width={200} height={200} className="rounded" />
|
<img src={qrCode || "/placeholder.svg"} alt={tf("qrCodeAlt")} width={200} height={200} className="rounded" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium">Or enter the key manually:</h4>
|
<h4 className="font-medium">{tf("manualKey")}</h4>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input value={secret} readOnly className="font-mono text-sm" />
|
<Input value={secret} readOnly className="font-mono text-sm" />
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => copyToClipboard(secret, "secret")}
|
onClick={() => copyToClipboard(secret, "secret")}
|
||||||
title="Copy key"
|
title={tf("copyKey")}
|
||||||
>
|
>
|
||||||
{copiedSecret ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
{copiedSecret ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -221,8 +249,8 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium">2. Enter the verification code</h4>
|
<h4 className="font-medium">{tf("verifyTitle")}</h4>
|
||||||
<p className="text-sm text-muted-foreground">Enter the 6-digit code that appears in your app</p>
|
<p className="text-sm text-muted-foreground">{tf("verifyDescription")}</p>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="000000"
|
placeholder="000000"
|
||||||
@@ -236,10 +264,10 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={handleVerify} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
<Button onClick={handleVerify} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||||
{loading ? "Verifying..." : "Verify and Enable"}
|
{loading ? tf("verifying") : tf("verifyAndEnable")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
|
<Button onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
|
||||||
Cancel
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,30 +278,29 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4 flex items-start gap-2">
|
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4 flex items-start gap-2">
|
||||||
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-green-500">2FA Enabled Successfully</p>
|
<p className="font-medium text-green-500">{tf("enabledTitle")}</p>
|
||||||
<p className="text-sm text-green-500 mt-1">
|
<p className="text-sm text-green-500 mt-1">
|
||||||
Your account is now protected with two-factor authentication
|
{tf("enabledDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium text-orange-500">Important: Save your backup codes</h4>
|
<h4 className="font-medium text-orange-500">{tf("saveCodesTitle")}</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
These codes will allow you to access your account if you lose access to your authentication app. Store
|
{tf("saveCodesDescription")}
|
||||||
them in a safe place.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
|
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="text-sm font-medium">Backup Codes</span>
|
<span className="text-sm font-medium">{tf("backupCodes")}</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => copyToClipboard(backupCodes.join("\n"), "codes")}>
|
<Button variant="outline" size="sm" onClick={() => copyToClipboard(backupCodes.join("\n"), "codes")}>
|
||||||
{copiedCodes ? (
|
{copiedCodes ? (
|
||||||
<Check className="h-4 w-4 text-green-500 mr-2" />
|
<Check className="h-4 w-4 text-green-500 mr-2" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="h-4 w-4 mr-2" />
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
Copy All
|
{tf("copyAll")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -287,7 +314,7 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
|
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
|
||||||
Finish
|
{tf("finish")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as React from "react"
|
|||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
import { X } from "lucide-react"
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root
|
const Dialog = DialogPrimitive.Root
|
||||||
@@ -34,28 +35,32 @@ const DialogContent = React.forwardRef<
|
|||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
hideClose?: boolean
|
hideClose?: boolean
|
||||||
}
|
}
|
||||||
>(({ className, children, hideClose, ...props }, ref) => (
|
>(({ className, children, hideClose, ...props }, ref) => {
|
||||||
<DialogPortal>
|
const t = useT()
|
||||||
<DialogOverlay />
|
|
||||||
<DialogPrimitive.Content
|
return (
|
||||||
ref={ref}
|
<DialogPortal>
|
||||||
className={cn(
|
<DialogOverlay />
|
||||||
"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",
|
<DialogPrimitive.Content
|
||||||
className,
|
ref={ref}
|
||||||
)}
|
className={cn(
|
||||||
aria-describedby={props["aria-describedby"] || undefined}
|
"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",
|
||||||
{...props}
|
className,
|
||||||
>
|
)}
|
||||||
{children}
|
aria-describedby={props["aria-describedby"] || undefined}
|
||||||
{!hideClose && (
|
{...props}
|
||||||
<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" />
|
{children}
|
||||||
<span className="sr-only">Close</span>
|
{!hideClose && (
|
||||||
</DialogPrimitive.Close>
|
<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" />
|
||||||
</DialogPrimitive.Content>
|
<span className="sr-only">{t("actions.close")}</span>
|
||||||
</DialogPortal>
|
</DialogPrimitive.Close>
|
||||||
))
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
})
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"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-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 hover:border-ring/50",
|
// The previous focus style was `ring-2 ring-ring ring-offset-2`, which
|
||||||
|
// painted a 2px white ring with a 2px gap outside the border. Inside a
|
||||||
|
// ScrollArea or any container with `overflow-hidden` the ring's left
|
||||||
|
// edge got clipped and the result looked broken. We replace it with a
|
||||||
|
// 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",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"
|
|||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { X } from "lucide-react"
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { useT } from "@/lib/i18n/provider"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Sheet = DialogPrimitive.Root
|
const Sheet = DialogPrimitive.Root
|
||||||
@@ -54,18 +55,22 @@ interface SheetContentProps
|
|||||||
VariantProps<typeof sheetVariants> {}
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
const SheetContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, SheetContentProps>(
|
const SheetContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, SheetContentProps>(
|
||||||
({ side = "right", className, children, ...props }, ref) => (
|
({ side = "right", className, children, ...props }, ref) => {
|
||||||
<SheetPortal>
|
const t = useT()
|
||||||
<SheetOverlay />
|
|
||||||
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
return (
|
||||||
{children}
|
<SheetPortal>
|
||||||
<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">
|
<SheetOverlay />
|
||||||
<X className="h-4 w-4" />
|
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||||
<span className="sr-only">Close</span>
|
{children}
|
||||||
</DialogPrimitive.Close>
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
</DialogPrimitive.Content>
|
<X className="h-4 w-4" />
|
||||||
</SheetPortal>
|
<span className="sr-only">{t("actions.close")}</span>
|
||||||
),
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
SheetContent.displayName = DialogPrimitive.Content.displayName
|
SheetContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-slate-300 dark:data-[state=unchecked]:bg-slate-600",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 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 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,31 @@
|
|||||||
{
|
{
|
||||||
"_description": "Verified AI models for ProxMenux notifications. Only models listed here will be shown to users. Models are tested to work with the chat/completions API format.",
|
"_description": "Verified AI models for ProxMenux notifications. Only models listed here will be shown to users. Models are tested to work with the chat/completions API format.",
|
||||||
"_updated": "2026-04-19",
|
"_updated": "2026-07-14",
|
||||||
"_verifier": "Refreshed with tools/ai-models-verifier (private). Re-run before each ProxMenux release to keep the list current. The verifier and ProxMenux share the same reasoning/thinking-model handlers so their verdicts stay aligned with runtime behaviour.",
|
"_verifier": "Refreshed with tools/ai-models-verifier (private). Re-run before each ProxMenux release to keep the list current. The verifier and ProxMenux share the same reasoning/thinking-model handlers so their verdicts stay aligned with runtime behaviour.",
|
||||||
|
|
||||||
"groq": {
|
"groq": {
|
||||||
"models": [
|
"models": [
|
||||||
"llama-3.3-70b-versatile",
|
"llama-3.3-70b-versatile",
|
||||||
"llama-3.1-70b-versatile",
|
|
||||||
"llama-3.1-8b-instant",
|
"llama-3.1-8b-instant",
|
||||||
"llama3-70b-8192",
|
"meta-llama/llama-4-scout-17b-16e-instruct",
|
||||||
"llama3-8b-8192",
|
"openai/gpt-oss-120b",
|
||||||
"mixtral-8x7b-32768",
|
"openai/gpt-oss-20b"
|
||||||
"gemma2-9b-it"
|
|
||||||
],
|
],
|
||||||
"recommended": "llama-3.3-70b-versatile",
|
"recommended": "llama-3.3-70b-versatile",
|
||||||
"_note": "Not yet re-verified in 2026-04 refresh — kept from previous curation. Run the verifier with a Groq key to prune deprecated entries."
|
"_note": "Verified functionally 2026-07-14 with the Groq API (15 models discovered, 9 passed). Legacy llama-3.1-70b-versatile / llama3-70b-8192 / llama3-8b-8192 / mixtral-8x7b-32768 / gemma2-9b-it removed (retired upstream). llama-4-scout added (current-gen Llama 4, 0.47s). openai/gpt-oss-120b / gpt-oss-20b confirmed. Passing but excluded: allam-2-7b (Arabic-focused), qwen/qwen3-32b (Chinese-first, unreliable Spanish output), openai/gpt-oss-safeguard-20b (safety-classifier variant), groq/compound-mini (agentic system, wrong fit for notification translation)."
|
||||||
},
|
},
|
||||||
|
|
||||||
"gemini": {
|
"gemini": {
|
||||||
"models": [
|
"models": [
|
||||||
|
"gemini-flash-lite-latest",
|
||||||
"gemini-2.5-flash-lite",
|
"gemini-2.5-flash-lite",
|
||||||
"gemini-2.5-flash",
|
"gemini-2.5-flash",
|
||||||
"gemini-3-flash-preview"
|
"gemini-3.1-flash-lite",
|
||||||
|
"gemini-3-flash-preview",
|
||||||
|
"gemini-3.5-flash"
|
||||||
],
|
],
|
||||||
"recommended": "gemini-2.5-flash-lite",
|
"recommended": "gemini-2.5-flash-lite",
|
||||||
"_note": "flash-lite / flash pass the verifier consistently; pro variants reject thinkingBudget=0 and are overkill for notification translation anyway. 'latest' aliases (gemini-flash-latest, gemini-flash-lite-latest) are intentionally omitted because they resolved to different models across runs and produced timeouts in some regions.",
|
"_note": "Verified 2026-07-13. gemini-flash-lite-latest now passes consistently (1.6s) and is fastest, but gemini-2.5-flash-lite remains recommended because 'latest' aliases can drift over time. gemini-3.1-flash-lite is the stable successor to 3-flash-preview. Pro variants continue to reject thinkingBudget=0 and are overkill for notification translation.",
|
||||||
"_deprecated": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-flash", "gemini-1.0-pro", "gemini-pro"]
|
"_deprecated": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-flash", "gemini-1.0-pro", "gemini-pro"]
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -36,21 +37,23 @@
|
|||||||
"gpt-4.1",
|
"gpt-4.1",
|
||||||
"gpt-4o",
|
"gpt-4o",
|
||||||
"gpt-5-chat-latest",
|
"gpt-5-chat-latest",
|
||||||
"gpt-5.4-nano",
|
"gpt-5-nano"
|
||||||
"gpt-5.4-mini"
|
|
||||||
],
|
],
|
||||||
"recommended": "gpt-4.1-nano",
|
"recommended": "gpt-4.1-nano",
|
||||||
"_note": "Reasoning models (o-series, gpt-5/5.1/5.2 non-chat variants) are supported by openai_provider.py via max_completion_tokens + reasoning_effort=minimal, but not listed here by default: their latency is higher than the chat models and they do not improve translation quality for notifications. Add specific reasoning IDs to this list only if a user explicitly wants them."
|
"_note": "Verified 2026-07-13. gpt-5.4-nano / gpt-5.4-mini removed (HTTP 400 — provider params rejected). gpt-5-nano added (2.0s, current-gen fast). Reasoning models (o-series, gpt-5/5.1/5.2 non-chat variants) are supported by openai_provider.py via max_completion_tokens + reasoning_effort=minimal, but not listed here: their latency is higher and they do not improve translation quality for notifications. Add specific reasoning IDs to this list only if a user explicitly wants them."
|
||||||
},
|
},
|
||||||
|
|
||||||
"anthropic": {
|
"anthropic": {
|
||||||
"models": [
|
"models": [
|
||||||
"claude-3-5-haiku-latest",
|
"claude-haiku-4-5",
|
||||||
"claude-3-5-sonnet-latest",
|
"claude-sonnet-5",
|
||||||
"claude-3-opus-latest"
|
"claude-opus-4-8",
|
||||||
|
"claude-sonnet-4-6",
|
||||||
|
"claude-opus-4-6",
|
||||||
|
"claude-fable-5"
|
||||||
],
|
],
|
||||||
"recommended": "claude-3-5-haiku-latest",
|
"recommended": "claude-haiku-4-5",
|
||||||
"_note": "Not re-verified in 2026-04 refresh — kept from previous curation. Add claude-4.x / claude-4.5 / claude-4.6 / claude-4.7 variants after running the verifier with an Anthropic key."
|
"_note": "Verified 2026-07-13 with all 10 discovered models passing after aligning the verifier with anthropic_provider.py (temperature omitted — newest generations reject it with 'temperature is deprecated for this model'). Legacy claude-3-5-haiku-latest / claude-3-5-sonnet-latest / claude-3-opus-latest removed (deprecated upstream, not in the Models API). haiku-4-5 is the sweet spot for notification translation (3.6s, $1/$5 per MTok); sonnet-5 for slightly richer output (3.1s, $3/$15); opus-4-8 / fable-5 for demanding cases."
|
||||||
},
|
},
|
||||||
|
|
||||||
"openrouter": {
|
"openrouter": {
|
||||||
@@ -58,15 +61,16 @@
|
|||||||
"meta-llama/llama-3.3-70b-instruct",
|
"meta-llama/llama-3.3-70b-instruct",
|
||||||
"meta-llama/llama-3.1-70b-instruct",
|
"meta-llama/llama-3.1-70b-instruct",
|
||||||
"meta-llama/llama-3.1-8b-instruct",
|
"meta-llama/llama-3.1-8b-instruct",
|
||||||
"anthropic/claude-3.5-haiku",
|
"meta-llama/llama-4-scout",
|
||||||
"anthropic/claude-3.5-sonnet",
|
"anthropic/claude-haiku-4.5",
|
||||||
"google/gemini-flash-1.5",
|
"anthropic/claude-sonnet-4.6",
|
||||||
|
"google/gemini-2.5-flash-lite",
|
||||||
|
"google/gemini-2.5-flash",
|
||||||
"openai/gpt-4o-mini",
|
"openai/gpt-4o-mini",
|
||||||
"mistralai/mistral-7b-instruct",
|
"mistralai/mistral-small-3.2-24b-instruct"
|
||||||
"mistralai/mixtral-8x7b-instruct"
|
|
||||||
],
|
],
|
||||||
"recommended": "meta-llama/llama-3.3-70b-instruct",
|
"recommended": "meta-llama/llama-3.3-70b-instruct",
|
||||||
"_note": "Not re-verified in 2026-04 refresh. google/gemini-flash-2.5-flash-lite was malformed in the previous entry and has been replaced with google/gemini-flash-1.5."
|
"_note": "Verified functionally 2026-07-14 with the OpenRouter API — all 10 curated candidates pass the Spanish-translation notification test. Fastest: llama-4-scout (0.51s), gemini-2.5-flash-lite (1.14s), gemini-2.5-flash (1.94s), llama-3.3-70b-instruct (2.29s), claude-haiku-4.5 (2.71s). Legacy anthropic/claude-3.5-* / google/gemini-flash-1.5 / mistralai/mistral-7b-instruct / mixtral-8x7b-instruct removed (GONE from catalog). Modern replacements added: llama-4-scout (Meta's current gen — dramatically fastest), claude-haiku-4.5 / claude-sonnet-4.6, gemini-2.5-flash / flash-lite, mistral-small-3.2-24b. recommended kept as llama-3.3-70b for capability/latency balance; llama-4-scout is a faster alternative worth considering as recommended after a broader release."
|
||||||
},
|
},
|
||||||
|
|
||||||
"ollama": {
|
"ollama": {
|
||||||
|
|||||||
@@ -138,6 +138,12 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
|||||||
// return `{error: "..."}` on failure (e.g. /api/vms/<id>/control
|
// return `{error: "..."}` on failure (e.g. /api/vms/<id>/control
|
||||||
// includes the pvesh stderr — telling the user "no space left on
|
// includes the pvesh stderr — telling the user "no space left on
|
||||||
// device" is infinitely more useful than the raw status text).
|
// device" is infinitely more useful than the raw status text).
|
||||||
|
//
|
||||||
|
// We also attach the FULL parsed JSON body to the thrown Error
|
||||||
|
// as `.body` so callers that want the optional `details` /
|
||||||
|
// `suggestion` fields (e.g. /api/node/metrics) can render them
|
||||||
|
// without re-fetching. Callers that just read `err.message`
|
||||||
|
// keep working exactly as before.
|
||||||
try {
|
try {
|
||||||
const ct = response.headers.get("content-type") || ""
|
const ct = response.headers.get("content-type") || ""
|
||||||
if (ct.includes("application/json")) {
|
if (ct.includes("application/json")) {
|
||||||
@@ -145,14 +151,22 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
|||||||
const detail =
|
const detail =
|
||||||
(body && (body.error || body.message)) || ""
|
(body && (body.error || body.message)) || ""
|
||||||
if (detail) {
|
if (detail) {
|
||||||
throw new Error(detail)
|
const e: Error & { body?: unknown; status?: number } = new Error(detail)
|
||||||
|
e.body = body
|
||||||
|
e.status = response.status
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
if (parseErr instanceof Error && parseErr.message.includes("API request failed")) {
|
// Backend-supplied detail (the explicit `throw new Error(detail)`
|
||||||
|
// above) MUST propagate so the UI shows "path does not exist…"
|
||||||
|
// instead of the generic "API request failed: 400 BAD REQUEST".
|
||||||
|
// Only swallow when the JSON itself failed to parse — that's a
|
||||||
|
// real SyntaxError and falling through to the generic message
|
||||||
|
// is the right behaviour there.
|
||||||
|
if (!(parseErr instanceof SyntaxError)) {
|
||||||
throw parseErr
|
throw parseErr
|
||||||
}
|
}
|
||||||
// JSON parse failed — fall through to the generic message.
|
|
||||||
}
|
}
|
||||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Shared classifier for physical-disk type. Lives here because the
|
||||||
|
// Storage page and the Hardware page used to ship their own copies
|
||||||
|
// and silently drifted — old SSDs (e.g. OCZ-SOLID2) that don't expose
|
||||||
|
// a SMART rotation rate fell through Hardware's HDD-as-default branch
|
||||||
|
// and got mislabelled, while the Storage page got it right.
|
||||||
|
//
|
||||||
|
// Backend convention for `rotation_rate`:
|
||||||
|
// undefined / null / 0 → SSD (no platters reported)
|
||||||
|
// -1 → HDD detected via /sys rotational flag,
|
||||||
|
// but the drive doesn't expose RPM
|
||||||
|
// > 0 → HDD with known RPM
|
||||||
|
// string "Solid State" → SSD (smartctl wording on a few vendors)
|
||||||
|
|
||||||
|
export type DiskType = "NVMe" | "SSD" | "HDD"
|
||||||
|
|
||||||
|
export function getDiskType(
|
||||||
|
diskName: string,
|
||||||
|
rotationRate: number | string | null | undefined,
|
||||||
|
): DiskType {
|
||||||
|
if (diskName.startsWith("nvme")) return "NVMe"
|
||||||
|
if (rotationRate === -1) return "HDD"
|
||||||
|
if (typeof rotationRate === "string") {
|
||||||
|
if (rotationRate.includes("Solid State")) return "SSD"
|
||||||
|
const parsed = Number.parseInt(rotationRate, 10)
|
||||||
|
if (Number.isNaN(parsed) || parsed === 0) return "SSD"
|
||||||
|
return "HDD"
|
||||||
|
}
|
||||||
|
if (rotationRate == null || rotationRate === 0) return "SSD"
|
||||||
|
return "HDD"
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export const LANGUAGE_STORAGE_KEY = "proxmenux-ui-language"
|
||||||
|
export const DEFAULT_LANGUAGE = "en"
|
||||||
|
|
||||||
|
export type LanguageCode = "en" | "es" | "fr" | "de" | "it" | "pt" | "sk"
|
||||||
|
|
||||||
|
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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
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,48 @@
|
|||||||
|
// Shared usage-bar palette for storage capacity widgets. Extracted
|
||||||
|
// so the Storage page (overview cards + per-storage rows) and the
|
||||||
|
// Backups page (Available Archives → per-archive capacity bar) flag
|
||||||
|
// a full datastore with the same colour. Previously the Backups bar
|
||||||
|
// was hard-coded to blue, so a 100%-full PBS-Cloud appeared in red
|
||||||
|
// on Storage and in blue on Backups — same datastore, two different
|
||||||
|
// signals.
|
||||||
|
//
|
||||||
|
// Thresholds: < 75 % blue (normal — no alert), 75–89 % amber,
|
||||||
|
// ≥ 90 % red. Matches the Storage page palette: the normal state
|
||||||
|
// stays on the project's brand blue and only switches to amber/red
|
||||||
|
// when the operator should look at it. Green is reserved for OK
|
||||||
|
// signals where green has meaning (SMART status, wear level), not
|
||||||
|
// for ambient bars.
|
||||||
|
|
||||||
|
export type UsageBarColor = {
|
||||||
|
/** Inline `background` value for SVG / style={} consumers. */
|
||||||
|
hex: string
|
||||||
|
/** Tailwind `bg-*` class for div consumers. */
|
||||||
|
bgClass: string
|
||||||
|
/** Tailwind `text-*` class for "Free" / counters that should
|
||||||
|
* share the urgency signal. */
|
||||||
|
textClass: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLUE: UsageBarColor = {
|
||||||
|
hex: "#3b82f6",
|
||||||
|
bgClass: "bg-blue-500",
|
||||||
|
// No text-blue override for the "Free" counter — at normal usage
|
||||||
|
// the foreground colour reads better than tinted text.
|
||||||
|
textClass: "",
|
||||||
|
}
|
||||||
|
const AMBER: UsageBarColor = {
|
||||||
|
hex: "#f59e0b",
|
||||||
|
bgClass: "bg-amber-500",
|
||||||
|
textClass: "text-amber-400",
|
||||||
|
}
|
||||||
|
const RED: UsageBarColor = {
|
||||||
|
hex: "#ef4444",
|
||||||
|
bgClass: "bg-red-500",
|
||||||
|
textClass: "text-red-400",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStorageUsageColor(percent: number): UsageBarColor {
|
||||||
|
if (percent >= 90) return RED
|
||||||
|
if (percent >= 75) return AMBER
|
||||||
|
return BLUE
|
||||||
|
}
|
||||||
@@ -19,3 +19,20 @@ export function formatStorage(sizeInGB: number): string {
|
|||||||
return `${tb % 1 === 0 ? tb.toFixed(0) : tb.toFixed(1)} TB`
|
return `${tb % 1 === 0 ? tb.toFixed(0) : tb.toFixed(1)} TB`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Byte-aware formatter. Scales B → KB → MB → GB → TB. Use when the
|
||||||
|
// raw value comes in bytes (log file sizes from os.path.getsize(),
|
||||||
|
// PBS / Borg datastore capacity reported by the backend in bytes).
|
||||||
|
// The Backups page used to ship its own copy that capped at GB, so a
|
||||||
|
// 7 TB datastore showed up as "7311.55 GB" — extracted here so it
|
||||||
|
// can't drift again.
|
||||||
|
export function formatBytes(n: number): string {
|
||||||
|
if (!Number.isFinite(n) || n < 0) return "—"
|
||||||
|
if (n < 1024) return `${n} B`
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||||
|
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
if (n < 1024 * 1024 * 1024 * 1024) {
|
||||||
|
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
||||||
|
}
|
||||||
|
return `${(n / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ProxMenux-Monitor",
|
"name": "ProxMenux-Monitor",
|
||||||
"version": "1.2.2",
|
"version": "1.2.2.2-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ProxMenux-Monitor",
|
"name": "ProxMenux-Monitor",
|
||||||
"version": "1.2.2",
|
"version": "1.2.2.2-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-accordion": "1.2.2",
|
"@radix-ui/react-accordion": "1.2.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ProxMenux-Monitor",
|
"name": "ProxMenux-Monitor",
|
||||||
"version": "1.2.2",
|
"version": "1.2.4.1-beta",
|
||||||
"description": "Proxmox System Monitoring Dashboard",
|
"description": "Proxmox System Monitoring Dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -3,14 +3,29 @@
|
|||||||
"short_name": "ProxMenux",
|
"short_name": "ProxMenux",
|
||||||
"description": "Proxmox System Dashboard and Monitor",
|
"description": "Proxmox System Dashboard and Monitor",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
"background_color": "#2b2f36",
|
"background_color": "#2b2f36",
|
||||||
"theme_color": "#2b2f36",
|
"theme_color": "#2b2f36",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/images/proxmenux-logo.png",
|
"src": "/icons/icon-192.png",
|
||||||
"sizes": "256x256",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// ==========================================================
|
||||||
|
// ProxMenux Monitor — Service Worker
|
||||||
|
// ==========================================================
|
||||||
|
// Minimal SW whose only job is to make Chrome (Android) treat
|
||||||
|
// the Monitor as an installable PWA. The Monitor lives on the
|
||||||
|
// operator's LAN, hits a self-signed HTTPS endpoint and has
|
||||||
|
// ZERO offline value (every page calls /api/* over the wire),
|
||||||
|
// so we do NOT cache pages or API responses — caching them
|
||||||
|
// would only cause stale-data bugs after an AppImage update.
|
||||||
|
//
|
||||||
|
// The install / activate handlers clean up old SW caches from
|
||||||
|
// previous Monitor versions so a beta-to-stable upgrade never
|
||||||
|
// strands the browser on a cached old shell.
|
||||||
|
// ==========================================================
|
||||||
|
|
||||||
|
const SW_VERSION = 'proxmenux-monitor-v1';
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
// Take over as soon as installed; no skipWaiting handshake.
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil((async () => {
|
||||||
|
// Wipe any cache name that isn't ours — survives renames /
|
||||||
|
// version bumps without piling up stale entries.
|
||||||
|
const names = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
names.filter((n) => n !== SW_VERSION).map((n) => caches.delete(n))
|
||||||
|
);
|
||||||
|
await self.clients.claim();
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Network-only fetch. The SW exists so Chrome marks the site as
|
||||||
|
// installable; we deliberately do not serve cached responses.
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
// Let the browser handle it normally — no respondWith → no cache.
|
||||||
|
return;
|
||||||
|
});
|
||||||
@@ -15,12 +15,21 @@ class AnthropicProvider(AIProvider):
|
|||||||
API_URL = "https://api.anthropic.com/v1/messages"
|
API_URL = "https://api.anthropic.com/v1/messages"
|
||||||
API_VERSION = "2023-06-01"
|
API_VERSION = "2023-06-01"
|
||||||
|
|
||||||
# Known stable model aliases (Anthropic doesn't have a public models list API)
|
# Anthropic model aliases that resolve to pinned snapshots (per docs
|
||||||
# These use "-latest" which auto-updates to the newest version
|
# at platform.claude.com/docs/en/docs/about-claude/models). Kept as a
|
||||||
|
# runtime fallback for the `/api/notifications/provider-models`
|
||||||
|
# intersection with `verified_ai_models.json`; the JSON is the
|
||||||
|
# authoritative UI-facing list. Refreshed 2026-07-13 to drop the
|
||||||
|
# deprecated claude-3-5-* aliases (retired upstream, no longer in the
|
||||||
|
# Models API response) and add the current-generation IDs. Ordered
|
||||||
|
# cheapest → most capable so the first entry is a safe default.
|
||||||
KNOWN_MODELS = [
|
KNOWN_MODELS = [
|
||||||
"claude-3-5-haiku-latest",
|
"claude-haiku-4-5",
|
||||||
"claude-3-5-sonnet-latest",
|
"claude-sonnet-5",
|
||||||
"claude-3-opus-latest",
|
"claude-sonnet-4-6",
|
||||||
|
"claude-opus-4-8",
|
||||||
|
"claude-opus-4-6",
|
||||||
|
"claude-fable-5",
|
||||||
]
|
]
|
||||||
|
|
||||||
def list_models(self) -> List[str]:
|
def list_models(self) -> List[str]:
|
||||||
|
|||||||
@@ -653,12 +653,13 @@ def setup_auth(username, password):
|
|||||||
Set up authentication with username and password
|
Set up authentication with username and password
|
||||||
Returns (success: bool, message: str)
|
Returns (success: bool, message: str)
|
||||||
"""
|
"""
|
||||||
# Refuse if auth has already been configured. Without this guard an
|
# Refuse if real credentials already exist. Without this guard an
|
||||||
# unauthenticated POST to /api/auth/setup would let an attacker overwrite
|
# unauthenticated POST to /api/auth/setup would let an attacker overwrite
|
||||||
# the existing admin credentials and take over the account. See audit
|
# the existing admin credentials and take over the account. A declined
|
||||||
# Tier 1 #4.
|
# setup is marked configured but deliberately has no credentials, so it
|
||||||
|
# must remain possible to finish setup later. See audit Tier 1 #4.
|
||||||
existing = load_auth_config()
|
existing = load_auth_config()
|
||||||
if existing.get("configured", False):
|
if existing.get("username") and existing.get("password_hash"):
|
||||||
return False, "Authentication is already configured"
|
return False, "Authentication is already configured"
|
||||||
|
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
@@ -668,7 +669,7 @@ def setup_auth(username, password):
|
|||||||
if pw_err:
|
if pw_err:
|
||||||
return False, pw_err
|
return False, pw_err
|
||||||
|
|
||||||
config = {
|
existing.update({
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"username": username,
|
"username": username,
|
||||||
"password_hash": hash_password(password),
|
"password_hash": hash_password(password),
|
||||||
@@ -677,9 +678,9 @@ def setup_auth(username, password):
|
|||||||
"totp_enabled": False,
|
"totp_enabled": False,
|
||||||
"totp_secret": None,
|
"totp_secret": None,
|
||||||
"backup_codes": []
|
"backup_codes": []
|
||||||
}
|
})
|
||||||
|
|
||||||
if save_auth_config(config):
|
if save_auth_config(existing):
|
||||||
return True, "Authentication configured successfully"
|
return True, "Authentication configured successfully"
|
||||||
else:
|
else:
|
||||||
return False, "Failed to save authentication configuration"
|
return False, "Failed to save authentication configuration"
|
||||||
|
|||||||
@@ -166,129 +166,54 @@ else
|
|||||||
echo "⚠️ config directory not found"
|
echo "⚠️ config directory not found"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📋 Adding translation support..."
|
# Translation handling lives in scripts/utils.sh now. It reads
|
||||||
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
|
# /usr/local/share/proxmenux/lang/<lang>.json (pre-built by the
|
||||||
#!/usr/bin/env python3
|
# build_translation_cache.py CI job) and falls back to the English
|
||||||
# -*- coding: utf-8 -*-
|
# source string on miss. The Monitor AppImage no longer ships the
|
||||||
"""
|
# runtime translate_cli.py — the JSON files belong to the host install,
|
||||||
ProxMenux translate CLI
|
# not to the Flask dashboard.
|
||||||
stdin JSON -> {"text":"...", "dest_lang":"es", "context":"...", "cache_file":"/usr/local/share/proxmenux/cache.json"}
|
|
||||||
stdout JSON -> {"success":true,"text":"..."} or {"success":false,"error":"..."}
|
|
||||||
"""
|
|
||||||
import sys, json, re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Ensure embedded site-packages are discoverable
|
# ── Borg standalone binary ─────────────────────────────────────────
|
||||||
HERE = Path(__file__).resolve().parents[2] # .../AppDir
|
# Ship the official borg standalone binary inside the AppImage so the
|
||||||
DIST = HERE / "usr" / "lib" / "python3" / "dist-packages"
|
# host-backup / restore workflows can run without an internet round-trip
|
||||||
SITE = HERE / "usr" / "lib" / "python3" / "site-packages"
|
# at install time. Pinned to the same version that proxmenux's
|
||||||
for p in (str(DIST), str(SITE)):
|
# hb_ensure_borg used to download on demand — kept in lockstep so both
|
||||||
if p not in sys.path:
|
# code paths see the same version semantics. SHA256 is the upstream
|
||||||
sys.path.insert(0, p)
|
# release checksum; bump both together.
|
||||||
|
BORG_VERSION="1.2.8"
|
||||||
# Python 3.13 compat: inline 'cgi' shim
|
BORG_URL="https://github.com/borgbackup/borg/releases/download/${BORG_VERSION}/borg-linux64"
|
||||||
try:
|
BORG_SHA256="cfa50fb704a93d3a4fa258120966345fddb394f960dca7c47fcb774d0172f40b"
|
||||||
import cgi
|
echo "📦 Downloading borg ${BORG_VERSION} into AppImage..."
|
||||||
except Exception:
|
BORG_TARGET="$APP_DIR/usr/bin/borg"
|
||||||
import types, html
|
# GitHub releases serve borg-linux64 via a 302 redirect to a signed
|
||||||
def _parse_header(value: str):
|
# release-assets.githubusercontent.com URL. wget -qO silently dropped
|
||||||
value = str(value or "")
|
# the redirect once during the 2026-06-15 build, killing the AppImage
|
||||||
parts = [p.strip() for p in value.split(";")]
|
# pipeline. curl -L --retry 3 is more robust and falls back to wget
|
||||||
if not parts:
|
# only when curl is missing.
|
||||||
return "", {}
|
if command -v curl >/dev/null 2>&1; then
|
||||||
key = parts[0].lower()
|
DOWNLOAD_OK=0
|
||||||
params = {}
|
if curl -sSL --retry 3 --retry-delay 2 --max-time 120 -o "$BORG_TARGET" "$BORG_URL"; then
|
||||||
for item in parts[1:]:
|
DOWNLOAD_OK=1
|
||||||
if not item:
|
fi
|
||||||
continue
|
else
|
||||||
if "=" in item:
|
DOWNLOAD_OK=0
|
||||||
k, v = item.split("=", 1)
|
if wget -qO "$BORG_TARGET" "$BORG_URL"; then
|
||||||
k = k.strip().lower()
|
DOWNLOAD_OK=1
|
||||||
v = v.strip().strip('"').strip("'")
|
fi
|
||||||
params[k] = v
|
fi
|
||||||
else:
|
if [ "$DOWNLOAD_OK" = "1" ]; then
|
||||||
params[item.strip().lower()] = ""
|
if echo "${BORG_SHA256} ${BORG_TARGET}" | sha256sum -c - >/dev/null 2>&1; then
|
||||||
return key, params
|
chmod +x "$BORG_TARGET"
|
||||||
cgi = types.SimpleNamespace(parse_header=_parse_header, escape=html.escape)
|
echo "✅ borg ${BORG_VERSION} bundled (sha256 verified)"
|
||||||
|
else
|
||||||
try:
|
echo "❌ borg sha256 verification failed — removing"
|
||||||
from googletrans import Translator
|
rm -f "$BORG_TARGET"
|
||||||
except Exception as e:
|
exit 1
|
||||||
print(json.dumps({"success": False, "error": f"ImportError: {e}"}))
|
fi
|
||||||
sys.exit(0)
|
else
|
||||||
|
echo "❌ borg download failed from $BORG_URL"
|
||||||
def load_json_stdin():
|
exit 1
|
||||||
try:
|
fi
|
||||||
return json.load(sys.stdin)
|
|
||||||
except Exception as e:
|
|
||||||
print(json.dumps({"success": False, "error": f"Invalid JSON input: {e}"}))
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
def ensure_cache(path: Path):
|
|
||||||
try:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
if not path.exists():
|
|
||||||
path.write_text("{}", encoding="utf-8")
|
|
||||||
json.loads(path.read_text(encoding="utf-8") or "{}")
|
|
||||||
except Exception:
|
|
||||||
path.write_text("{}", encoding="utf-8")
|
|
||||||
|
|
||||||
def read_cache(path: Path):
|
|
||||||
try:
|
|
||||||
return json.loads(path.read_text(encoding="utf-8") or "{}")
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def write_cache(path: Path, cache: dict):
|
|
||||||
tmp = path.with_suffix(".tmp")
|
|
||||||
tmp.write_text(json.dumps(cache, ensure_ascii=False), encoding="utf-8")
|
|
||||||
tmp.replace(path)
|
|
||||||
|
|
||||||
def clean_translated(s: str) -> str:
|
|
||||||
s = re.sub(r'^.*?(Translate:|Traducir:|Traduire:|Übersetzen:|Tradurre:|Traduzir:|翻译:|翻訳:)', '', s, flags=re.IGNORECASE | re.DOTALL).strip()
|
|
||||||
s = re.sub(r'^.*?(Context:|Contexto:|Contexte:|Kontext:|Contesto:|上下文:|コンテキスト:).*?:', '', s, flags=re.IGNORECASE | re.DOTALL).strip()
|
|
||||||
return s.strip()
|
|
||||||
|
|
||||||
def main():
|
|
||||||
req = load_json_stdin()
|
|
||||||
text = req.get("text", "")
|
|
||||||
dest = req.get("dest_lang", "en") or "en"
|
|
||||||
context = req.get("context", "")
|
|
||||||
cache_file = Path(req.get("cache_file", "")) if req.get("cache_file") else None
|
|
||||||
|
|
||||||
if dest == "en":
|
|
||||||
print(json.dumps({"success": True, "text": text}))
|
|
||||||
return
|
|
||||||
|
|
||||||
cache = {}
|
|
||||||
if cache_file:
|
|
||||||
ensure_cache(cache_file)
|
|
||||||
cache = read_cache(cache_file)
|
|
||||||
if text in cache and (dest in cache[text] or "notranslate" in cache[text]):
|
|
||||||
found = cache[text].get(dest) or cache[text].get("notranslate")
|
|
||||||
print(json.dumps({"success": True, "text": found}))
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
full = (context + " " + text).strip() if context else text
|
|
||||||
tr = Translator()
|
|
||||||
result = tr.translate(full, dest=dest).text
|
|
||||||
result = clean_translated(result)
|
|
||||||
|
|
||||||
if cache_file:
|
|
||||||
cache.setdefault(text, {})
|
|
||||||
cache[text][dest] = result
|
|
||||||
write_cache(cache_file, cache)
|
|
||||||
|
|
||||||
print(json.dumps({"success": True, "text": result}))
|
|
||||||
except Exception as e:
|
|
||||||
print(json.dumps({"success": False, "error": str(e)}))
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
PYEOF
|
|
||||||
|
|
||||||
chmod +x "$APP_DIR/usr/bin/translate_cli.py"
|
|
||||||
|
|
||||||
# Copy Next.js build
|
# Copy Next.js build
|
||||||
echo "📋 Copying web dashboard..."
|
echo "📋 Copying web dashboard..."
|
||||||
@@ -332,7 +257,7 @@ cat > "$APP_DIR/proxmenux-monitor.desktop" << EOF
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=ProxMenux Monitor
|
Name=ProxMenux Monitor
|
||||||
Comment=Proxmox System Monitoring Dashboard with Translation Support
|
Comment=Proxmox System Monitoring Dashboard
|
||||||
Exec=AppRun
|
Exec=AppRun
|
||||||
Icon=proxmenux-monitor
|
Icon=proxmenux-monitor
|
||||||
Categories=System;Monitor;
|
Categories=System;Monitor;
|
||||||
@@ -361,14 +286,12 @@ if [ -f "$APP_DIR/proxmenux-monitor.png" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📦 Installing Python dependencies..."
|
echo "📦 Installing Python dependencies..."
|
||||||
# Phase 1: Install googletrans with its old dependencies
|
# Flask/WebSocket dependencies for the Monitor dashboard. The previous
|
||||||
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \
|
# Phase-1 (googletrans==4.0.0-rc1 + httpx 0.13.3 + httpcore 0.9.1 +
|
||||||
googletrans==4.0.0-rc1 \
|
# h11 0.9.0) is gone — translation is now a static-lookup feature on
|
||||||
httpx==0.13.3 \
|
# the host, so the AppImage no longer needs any runtime translator.
|
||||||
httpcore==0.9.1 \
|
# Removing those pins also unblocks the h11>=0.14.0 family without the
|
||||||
h11==0.9.0 || true
|
# conflict workaround we used to ship.
|
||||||
|
|
||||||
# Phase 2: Install modern Flask/WebSocket dependencies (will upgrade h11 and related packages)
|
|
||||||
# Note: cryptography removed due to Python version compatibility issues (PyO3 modules)
|
# Note: cryptography removed due to Python version compatibility issues (PyO3 modules)
|
||||||
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-deps \
|
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-deps \
|
||||||
flask \
|
flask \
|
||||||
@@ -380,7 +303,7 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-de
|
|||||||
segno \
|
segno \
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
|
|
||||||
# Phase 3: Install WebSocket with newer h11
|
# WebSocket with modern h11 (no need for the legacy pin anymore)
|
||||||
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
|
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
|
||||||
h11>=0.14.0 \
|
h11>=0.14.0 \
|
||||||
wsproto>=1.2.0 \
|
wsproto>=1.2.0 \
|
||||||
|
|||||||
@@ -237,22 +237,64 @@ def _list_target_disks() -> list[str]:
|
|||||||
return fresh
|
return fresh
|
||||||
|
|
||||||
|
|
||||||
|
def _is_disk_usb(disk_name: str) -> bool:
|
||||||
|
"""True if the disk sits behind a USB bus, checked via the resolved
|
||||||
|
sysfs device path. USB-NVMe bridges (ASMedia, JMicron, Realtek) and
|
||||||
|
plain USB-HDDs both report `/sys/block/<disk>/removable = 0`, so the
|
||||||
|
older removable-flag heuristic missed them and the temperature
|
||||||
|
poller never tried the snt* driver variants that are the only way
|
||||||
|
to reach the NVMe controller behind those bridges."""
|
||||||
|
try:
|
||||||
|
base = disk_name[5:] if disk_name.startswith('/dev/') else disk_name
|
||||||
|
real = os.path.realpath(f'/sys/block/{base}')
|
||||||
|
return any(seg.startswith('usb') and (len(seg) == 3 or seg[3:].isdigit())
|
||||||
|
for seg in real.split('/'))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _smartctl_cmd_for(disk_name: str, probe: str) -> list[str]:
|
def _smartctl_cmd_for(disk_name: str, probe: str) -> list[str]:
|
||||||
"""Build the smartctl invocation for a given probe key."""
|
"""Build the smartctl invocation for a given probe key.
|
||||||
cmd = ["smartctl", "-A", "-j"]
|
|
||||||
|
`-n standby` makes smartctl exit immediately with code 2 (no disk
|
||||||
|
I/O) when the drive is already in standby. Without it, this
|
||||||
|
once-a-minute poller was spinning HDDs back up on every cycle,
|
||||||
|
breaking NAS / SnapRAID setups that rely on hdparm-driven spin-down
|
||||||
|
(issue #232).
|
||||||
|
"""
|
||||||
|
cmd = ["smartctl", "-n", "standby", "-A", "-j"]
|
||||||
if probe != "auto":
|
if probe != "auto":
|
||||||
cmd.extend(["-d", probe])
|
cmd.extend(["-d", probe])
|
||||||
cmd.append(f"/dev/{disk_name}")
|
cmd.append(f"/dev/{disk_name}")
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
# Sentinel returned by `_try_probe` when the drive is in standby. Distinct
|
||||||
|
# from `None` (read failure / no temperature attribute), so the caller
|
||||||
|
# can keep the last known reading instead of marking the disk as failing.
|
||||||
|
_STANDBY = "standby"
|
||||||
|
|
||||||
|
|
||||||
def _try_probe(disk_name: str, probe: str) -> Optional[float]:
|
def _try_probe(disk_name: str, probe: str) -> Optional[float]:
|
||||||
"""Run a single smartctl invocation and parse the temperature."""
|
"""Run a single smartctl invocation and parse the temperature.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
* a float — current temperature in °C.
|
||||||
|
* the string ``_STANDBY`` — drive is in standby, NOT read.
|
||||||
|
* ``None`` — read failed for any other reason.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
_smartctl_cmd_for(disk_name, probe),
|
_smartctl_cmd_for(disk_name, probe),
|
||||||
capture_output=True, text=True, timeout=_SMARTCTL_TIMEOUT,
|
capture_output=True, text=True, timeout=_SMARTCTL_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
# `-n standby` makes smartctl exit with code 2 when the drive is
|
||||||
|
# parked. We must not treat that as a read failure (would trigger
|
||||||
|
# the backoff and stop polling that drive forever) — surface it
|
||||||
|
# as the dedicated _STANDBY sentinel so the caller skips the
|
||||||
|
# update cleanly.
|
||||||
|
if proc.returncode == 2:
|
||||||
|
return _STANDBY # type: ignore[return-value]
|
||||||
# smartctl returns non-zero on warnings (bit 0x40 etc.) even when
|
# smartctl returns non-zero on warnings (bit 0x40 etc.) even when
|
||||||
# JSON is fully populated. Don't gate on returncode — parse the
|
# JSON is fully populated. Don't gate on returncode — parse the
|
||||||
# body regardless.
|
# body regardless.
|
||||||
@@ -264,8 +306,24 @@ def _try_probe(disk_name: str, probe: str) -> Optional[float]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Disks that returned "standby" on their last poll. Used by the
|
||||||
|
# /api/storage/disks endpoint to render a Standby badge so the operator
|
||||||
|
# understands why the temperature graph for that drive is frozen — the
|
||||||
|
# disk really is parked, not the monitor that's broken.
|
||||||
|
_standby_state: dict[str, float] = {} # disk_name -> last-seen timestamp
|
||||||
|
_STANDBY_TTL = 600 # treat as stale after 10 min of no observation
|
||||||
|
|
||||||
|
|
||||||
|
def is_disk_in_standby(disk_name: str) -> bool:
|
||||||
|
"""True if our last smartctl poll for this disk hit a standby spindle.
|
||||||
|
Falls back to False when the cached observation is older than the
|
||||||
|
TTL — the drive may have woken up between polls."""
|
||||||
|
ts = _standby_state.get(disk_name)
|
||||||
|
return ts is not None and (time.time() - ts) < _STANDBY_TTL
|
||||||
|
|
||||||
|
|
||||||
def _read_temperature(disk_name: str) -> Optional[float]:
|
def _read_temperature(disk_name: str) -> Optional[float]:
|
||||||
"""Pull the current temperature from ``smartctl -A -j``.
|
"""Pull the current temperature from ``smartctl -n standby -A -j``.
|
||||||
|
|
||||||
Caching strategy:
|
Caching strategy:
|
||||||
* If we've previously found a working probe for this disk we go
|
* If we've previously found a working probe for this disk we go
|
||||||
@@ -275,6 +333,9 @@ def _read_temperature(disk_name: str) -> Optional[float]:
|
|||||||
and update the cache with whatever does work.
|
and update the cache with whatever does work.
|
||||||
* Disks that never report a temperature get rate-limited via the
|
* Disks that never report a temperature get rate-limited via the
|
||||||
backoff table so we don't smartctl them every minute forever.
|
backoff table so we don't smartctl them every minute forever.
|
||||||
|
* Disks in standby return ``None`` but DON'T count toward the
|
||||||
|
failure backoff — they're not broken, they're just parked.
|
||||||
|
The standby state is recorded so the UI can show a badge.
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
@@ -285,10 +346,23 @@ def _read_temperature(disk_name: str) -> Optional[float]:
|
|||||||
if retry_at > now:
|
if retry_at > now:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _handle(result):
|
||||||
|
"""Clear failure state + record standby observation. Returns the
|
||||||
|
numeric temperature if the result is one (else None / standby)."""
|
||||||
|
if result == _STANDBY:
|
||||||
|
_standby_state[disk_name] = time.time()
|
||||||
|
return _STANDBY
|
||||||
|
if isinstance(result, (int, float)) and result > 0:
|
||||||
|
_standby_state.pop(disk_name, None)
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
# Fast path: cached probe.
|
# Fast path: cached probe.
|
||||||
if cached_probe is not None:
|
if cached_probe is not None:
|
||||||
temp = _try_probe(disk_name, cached_probe)
|
temp = _handle(_try_probe(disk_name, cached_probe))
|
||||||
if temp is not None and temp > 0:
|
if temp == _STANDBY:
|
||||||
|
return None # parked — skip update, don't penalise
|
||||||
|
if temp is not None:
|
||||||
with _cache_lock:
|
with _cache_lock:
|
||||||
_disk_fail_counts.pop(disk_name, None)
|
_disk_fail_counts.pop(disk_name, None)
|
||||||
_disk_fail_backoff.pop(disk_name, None)
|
_disk_fail_backoff.pop(disk_name, None)
|
||||||
@@ -296,19 +370,29 @@ def _read_temperature(disk_name: str) -> Optional[float]:
|
|||||||
# Cached probe stopped working — fall through and re-detect.
|
# Cached probe stopped working — fall through and re-detect.
|
||||||
|
|
||||||
# Slow path: try every probe and remember the first one that works.
|
# Slow path: try every probe and remember the first one that works.
|
||||||
for probe in ("auto", "nvme", "ata", "sat"):
|
# For USB-attached disks we prepend the three snt* driver variants —
|
||||||
|
# USB-NVMe bridges (ASMedia / JMicron / Realtek) don't answer the
|
||||||
|
# plain probes with real SMART; only snt* passes through to the NVMe
|
||||||
|
# controller so temperature actually comes back. Non-USB disks skip
|
||||||
|
# them, so this adds zero overhead on internal drives.
|
||||||
|
probes: tuple[str, ...] = ("auto", "nvme", "ata", "sat")
|
||||||
|
if _is_disk_usb(disk_name):
|
||||||
|
probes = ("sntasmedia", "sntjmicron", "sntrealtek") + probes
|
||||||
|
for probe in probes:
|
||||||
if probe == cached_probe:
|
if probe == cached_probe:
|
||||||
continue # already tried above
|
continue # already tried above
|
||||||
temp = _try_probe(disk_name, probe)
|
temp = _handle(_try_probe(disk_name, probe))
|
||||||
if temp is not None and temp > 0:
|
if temp == _STANDBY:
|
||||||
|
return None
|
||||||
|
if temp is not None:
|
||||||
with _cache_lock:
|
with _cache_lock:
|
||||||
_disk_probe_cache[disk_name] = probe
|
_disk_probe_cache[disk_name] = probe
|
||||||
_disk_fail_counts.pop(disk_name, None)
|
_disk_fail_counts.pop(disk_name, None)
|
||||||
_disk_fail_backoff.pop(disk_name, None)
|
_disk_fail_backoff.pop(disk_name, None)
|
||||||
return temp
|
return temp
|
||||||
|
|
||||||
# All probes failed. Bump the failure counter and trip the backoff
|
# All probes failed (none returned a temperature OR standby). Bump
|
||||||
# if we've crossed the threshold.
|
# the failure counter and trip the backoff if threshold crossed.
|
||||||
with _cache_lock:
|
with _cache_lock:
|
||||||
n = _disk_fail_counts.get(disk_name, 0) + 1
|
n = _disk_fail_counts.get(disk_name, 0) + 1
|
||||||
_disk_fail_counts[disk_name] = n
|
_disk_fail_counts[disk_name] = n
|
||||||
|
|||||||
@@ -101,7 +101,14 @@ def acknowledge_error():
|
|||||||
'security': 'security_check',
|
'security': 'security_check',
|
||||||
'temperature': 'cpu_check',
|
'temperature': 'cpu_check',
|
||||||
'network': 'network_check',
|
'network': 'network_check',
|
||||||
|
# Both 'disks' (kept for compat) and 'storage' land on the
|
||||||
|
# same cache — the two categories share `storage_check` in
|
||||||
|
# health_monitor. Without the 'storage' entry, dismissing
|
||||||
|
# a storage_unavailable / mount_stale / lxc_mount_low
|
||||||
|
# error persisted but never invalidated the cache, so the
|
||||||
|
# error stayed visible in the next fetch.
|
||||||
'disks': 'storage_check',
|
'disks': 'storage_check',
|
||||||
|
'storage': 'storage_check',
|
||||||
'vms': 'vms_check',
|
'vms': 'vms_check',
|
||||||
}
|
}
|
||||||
cache_key = cache_key_map.get(category)
|
cache_key = cache_key_map.get(category)
|
||||||
@@ -223,9 +230,20 @@ def get_full_health():
|
|||||||
Get complete health data in a single request: detailed status + active errors + dismissed.
|
Get complete health data in a single request: detailed status + active errors + dismissed.
|
||||||
Uses background-cached results if fresh (< 6 min) for instant response,
|
Uses background-cached results if fresh (< 6 min) for instant response,
|
||||||
otherwise runs a fresh check.
|
otherwise runs a fresh check.
|
||||||
|
|
||||||
|
?refresh=1 busts the background + per-check caches for updates/services/
|
||||||
|
security before returning, so an event that just changed underlying state
|
||||||
|
(Update Now finished, dismiss action) sees the new value immediately
|
||||||
|
instead of waiting for the next polling tick.
|
||||||
"""
|
"""
|
||||||
import time as _time
|
import time as _time
|
||||||
try:
|
try:
|
||||||
|
if request.args.get('refresh') == '1':
|
||||||
|
for ck in ('updates_check', 'pve_services', 'security_check',
|
||||||
|
'_bg_detailed', '_bg_overall', 'overall_health'):
|
||||||
|
health_monitor.last_check_times.pop(ck, None)
|
||||||
|
health_monitor.cached_results.pop(ck, None)
|
||||||
|
|
||||||
# Try to use the background-cached detailed result for instant response
|
# Try to use the background-cached detailed result for instant response
|
||||||
bg_key = '_bg_detailed'
|
bg_key = '_bg_detailed'
|
||||||
bg_last = health_monitor.last_check_times.get(bg_key, 0)
|
bg_last = health_monitor.last_check_times.get(bg_key, 0)
|
||||||
|
|||||||
@@ -214,6 +214,53 @@ def _is_loopback_addr(value: str) -> bool:
|
|||||||
return value == 'localhost'
|
return value == 'localhost'
|
||||||
|
|
||||||
|
|
||||||
|
def _is_own_host_ip(value: str) -> bool:
|
||||||
|
"""Return True when ``value`` is loopback OR an IP bound to any local iface.
|
||||||
|
|
||||||
|
``_pve_webhook_url()`` may register a URL that resolves to the host's
|
||||||
|
LAN/VPN IP when SSL is on and a hostname cert is loaded (issue #239).
|
||||||
|
In that case PVE POSTs to e.g. ``https://<fqdn>:8008`` and — on Linux —
|
||||||
|
the connection is routed to the local interface holding that IP; the
|
||||||
|
Flask socket sees the peer as the interface IP, NOT ``127.0.0.1``. The
|
||||||
|
request is still coming from THIS host, so the loopback trust path
|
||||||
|
should extend to any of this host's own interface IPs (Tailscale/Zerotier
|
||||||
|
CGNAT, WireGuard, LAN, IPv6 GUA…). Without this, PVE hits the layer 3
|
||||||
|
``X-ProxMenux-Timestamp`` check — a header PVE cannot inject dynamically —
|
||||||
|
and every notification target test returns ``401 missing_timestamp``.
|
||||||
|
|
||||||
|
IMPORTANT: Flask bound to ``*:8008`` (dual-stack) reports IPv4 peers
|
||||||
|
in v4-mapped IPv6 form (``::ffff:192.168.0.55``). psutil reports the
|
||||||
|
same interface as plain IPv4 (``192.168.0.55``). Without unmapping,
|
||||||
|
literal comparison fails and the fix effectively does nothing in
|
||||||
|
production — reproduced end-to-end on 192.168.0.55: passing the raw
|
||||||
|
literal returned True, but ``::ffff:192.168.0.55`` returned False,
|
||||||
|
which is what Flask actually hands us at runtime.
|
||||||
|
"""
|
||||||
|
if _is_loopback_addr(value):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
import psutil
|
||||||
|
addr = ipaddress.ip_address(value)
|
||||||
|
mapped = getattr(addr, 'ipv4_mapped', None)
|
||||||
|
if mapped is not None:
|
||||||
|
addr = mapped
|
||||||
|
client = addr.compressed
|
||||||
|
for _iface, addrs in psutil.net_if_addrs().items():
|
||||||
|
for a in addrs:
|
||||||
|
if a.family in (socket.AF_INET, socket.AF_INET6):
|
||||||
|
ip_str = a.address.split('%')[0] # strip IPv6 zone id
|
||||||
|
try:
|
||||||
|
if ipaddress.ip_address(ip_str).compressed == client:
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _validate_event_type(value: str) -> bool:
|
def _validate_event_type(value: str) -> bool:
|
||||||
return isinstance(value, str) and bool(_EVENT_TYPE_RE.match(value))
|
return isinstance(value, str) and bool(_EVENT_TYPE_RE.match(value))
|
||||||
|
|
||||||
@@ -266,6 +313,74 @@ def save_notification_settings():
|
|||||||
return jsonify({'error': f'Internal error ({type(e).__name__})'}), 500
|
return jsonify({'error': f'Internal error ({type(e).__name__})'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@notification_bp.route('/api/notifications/reveal-secret', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def reveal_notification_secret():
|
||||||
|
"""Return one sensitive config value in cleartext.
|
||||||
|
|
||||||
|
Backs the "eye" toggle in the Settings UI. The settings GET masks
|
||||||
|
every entry in SENSITIVE_KEYS with `'************'` so the secret
|
||||||
|
never leaves the server just because someone loaded the page; this
|
||||||
|
endpoint lets an authenticated operator explicitly request the
|
||||||
|
real value for a single key when they need to inspect it.
|
||||||
|
|
||||||
|
Body schema (one of):
|
||||||
|
{"ai_provider": "groq" | "anthropic" | …}
|
||||||
|
{"channel": "telegram", "field": "bot_token"}
|
||||||
|
{"key": "webhook_secret"}
|
||||||
|
|
||||||
|
Returns ``{"value": "<cleartext>"}`` or 404 when nothing is stored.
|
||||||
|
400 on a malformed request, 403 if the requested key isn't on the
|
||||||
|
whitelist (i.e. the UI is asking for something we never agreed to
|
||||||
|
expose — defence against a future bug binding the eye to a
|
||||||
|
non-secret field).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from notification_manager import SENSITIVE_KEYS
|
||||||
|
nm = notification_manager # singleton already imported at module top
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
# Resolve the requested config key from the three accepted
|
||||||
|
# body shapes. Whitelist-checked below before we touch the DB.
|
||||||
|
cfg_key = None
|
||||||
|
provider = (payload.get('ai_provider') or '').strip().lower()
|
||||||
|
channel = (payload.get('channel') or '').strip().lower()
|
||||||
|
field = (payload.get('field') or '').strip().lower()
|
||||||
|
raw_key = (payload.get('key') or '').strip()
|
||||||
|
|
||||||
|
if provider:
|
||||||
|
cfg_key = f'ai_api_key_{provider}'
|
||||||
|
elif channel and field:
|
||||||
|
cfg_key = f'{channel}.{field}'
|
||||||
|
elif raw_key:
|
||||||
|
cfg_key = raw_key
|
||||||
|
|
||||||
|
if not cfg_key:
|
||||||
|
return jsonify({'error': 'missing_target'}), 400
|
||||||
|
|
||||||
|
# Whitelist enforcement — never reveal a value the rest of the
|
||||||
|
# system doesn't recognise as a secret. Stops a hypothetical
|
||||||
|
# future bug where the UI passes a non-secret config key and
|
||||||
|
# we hand back something we shouldn't.
|
||||||
|
if cfg_key not in SENSITIVE_KEYS:
|
||||||
|
return jsonify({'error': 'forbidden_key'}), 403
|
||||||
|
|
||||||
|
# Read the raw value through notification_manager (loads + caches
|
||||||
|
# the config). Mask placeholder is the same string the GET would
|
||||||
|
# return — treat that as "not stored" so we don't echo it back.
|
||||||
|
if not nm._config:
|
||||||
|
nm._load_config()
|
||||||
|
value = nm._config.get(cfg_key, '') or ''
|
||||||
|
if not value or value == '************':
|
||||||
|
return jsonify({'value': ''}), 200
|
||||||
|
|
||||||
|
return jsonify({'value': value}), 200
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[notification_routes] {request.path} failed: {type(e).__name__}: {e}")
|
||||||
|
return jsonify({'error': f'Internal error ({type(e).__name__})'}), 500
|
||||||
|
|
||||||
|
|
||||||
@notification_bp.route('/api/notifications/test', methods=['POST'])
|
@notification_bp.route('/api/notifications/test', methods=['POST'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def test_notification():
|
def test_notification():
|
||||||
@@ -721,23 +836,108 @@ _PVE_OUR_HEADERS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _pve_webhook_url() -> str:
|
def _ssl_cert_hostname(cert_path: str) -> str:
|
||||||
"""Return http:// or https:// based on the current SSL config.
|
"""Pull the most useful hostname out of an x509 cert.
|
||||||
|
|
||||||
Hardcoded `http://...` previously broke webhook delivery whenever the
|
Preference order: first DNS SAN → CN. Returns '' on any failure.
|
||||||
user enabled SSL — Flask only listened on HTTPS, so PVE got connection
|
Used to build a webhook URL that won't fail PVE's TLS verification
|
||||||
refused and notifications stopped. Issue #194. PVE may still need
|
(issue #239 — PVE has no `--insecure` flag and the user's ACME cert
|
||||||
`update-ca-certificates` if the cert is self-signed; that's a doc
|
is bound to a hostname, not to `127.0.0.1`).
|
||||||
step on the user side.
|
"""
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
out = subprocess.run(
|
||||||
|
['openssl', 'x509', '-in', cert_path, '-noout',
|
||||||
|
'-ext', 'subjectAltName', '-subject'],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
if out.returncode != 0:
|
||||||
|
return ''
|
||||||
|
text = out.stdout or ''
|
||||||
|
# SAN line example: " DNS:pve.example.com, DNS:pve, IP Address:..."
|
||||||
|
import re
|
||||||
|
for line in text.splitlines():
|
||||||
|
m = re.search(r'DNS:([A-Za-z0-9.\-]+)', line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
# CN fallback. "subject= CN = pve.example.com" or "...CN=pve.example.com"
|
||||||
|
m = re.search(r'CN\s*=\s*([A-Za-z0-9.\-]+)', text)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _hostname_resolves_locally(hostname: str) -> bool:
|
||||||
|
"""True when `hostname` resolves to one of this host's own IPs.
|
||||||
|
|
||||||
|
Anti-misconfig: refuse to build a webhook URL that points
|
||||||
|
elsewhere (a stale DNS entry pointing at the previous host, a CN
|
||||||
|
that names a different node in the cluster, etc.). PVE delivers
|
||||||
|
webhooks from the same node, so the URL has to round-trip to
|
||||||
|
ourselves.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import socket
|
||||||
|
import ipaddress
|
||||||
|
target_ips = set()
|
||||||
|
for info in socket.getaddrinfo(hostname, None):
|
||||||
|
ip = info[4][0]
|
||||||
|
target_ips.add(ipaddress.ip_address(ip).compressed)
|
||||||
|
# Collect our own IPs from /proc/net/fib_trie isn't portable; use
|
||||||
|
# psutil if available, otherwise fall back to socket on the
|
||||||
|
# hostname itself.
|
||||||
|
local_ips = {'127.0.0.1', '::1'}
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
for _iface, addrs in psutil.net_if_addrs().items():
|
||||||
|
for a in addrs:
|
||||||
|
if a.family in (socket.AF_INET, socket.AF_INET6):
|
||||||
|
local_ips.add(ipaddress.ip_address(a.address.split('%')[0]).compressed)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return bool(target_ips & local_ips)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _pve_webhook_url() -> str:
|
||||||
|
"""Return the URL we register with PVE as our webhook target.
|
||||||
|
|
||||||
|
Three branches:
|
||||||
|
1. SSL off → http://127.0.0.1:8008 (always works, no cert).
|
||||||
|
2. SSL on + cert hostname extractable and resolves locally →
|
||||||
|
https://<cert-hostname>:8008. This is what PVE's TLS layer
|
||||||
|
actually validates against. Without this, PVE rejects the
|
||||||
|
self/ACME cert with "IP address mismatch" (issue #239).
|
||||||
|
3. SSL on but hostname extraction/check failed → fall back to
|
||||||
|
https://127.0.0.1:8008 and accept that the user may still
|
||||||
|
hit the cert-mismatch error. We log so the operator can
|
||||||
|
diagnose. Better than silently emitting a wrong URL.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from auth_manager import load_ssl_config
|
from auth_manager import load_ssl_config
|
||||||
cfg = load_ssl_config() or {}
|
cfg = load_ssl_config() or {}
|
||||||
if cfg.get('enabled'):
|
if not cfg.get('enabled'):
|
||||||
return 'https://127.0.0.1:8008/api/notifications/webhook'
|
return 'http://127.0.0.1:8008/api/notifications/webhook'
|
||||||
|
|
||||||
|
cert_path = cfg.get('cert_path') or ''
|
||||||
|
if cert_path:
|
||||||
|
host = _ssl_cert_hostname(cert_path)
|
||||||
|
if host and _hostname_resolves_locally(host):
|
||||||
|
return f'https://{host}:8008/api/notifications/webhook'
|
||||||
|
if host:
|
||||||
|
print(
|
||||||
|
f"[ProxMenux] webhook URL fallback to 127.0.0.1: "
|
||||||
|
f"cert hostname '{host}' does not resolve to a local "
|
||||||
|
f"IP — PVE will likely report a TLS verification "
|
||||||
|
f"error. Fix by ensuring the FQDN resolves on this "
|
||||||
|
f"host (e.g. /etc/hosts entry)."
|
||||||
|
)
|
||||||
|
return 'https://127.0.0.1:8008/api/notifications/webhook'
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
return 'http://127.0.0.1:8008/api/notifications/webhook'
|
||||||
return 'http://127.0.0.1:8008/api/notifications/webhook'
|
|
||||||
|
|
||||||
|
|
||||||
# Backward-compat alias for callers that read this at import time. Most
|
# Backward-compat alias for callers that read this at import time. Most
|
||||||
@@ -1254,7 +1454,10 @@ def proxmox_webhook():
|
|||||||
_reject = lambda code, error, status: (jsonify({'accepted': False, 'error': error}), status)
|
_reject = lambda code, error, status: (jsonify({'accepted': False, 'error': error}), status)
|
||||||
|
|
||||||
client_ip = request.remote_addr or ''
|
client_ip = request.remote_addr or ''
|
||||||
is_localhost = _is_loopback_addr(client_ip)
|
# Trust loopback AND any IP bound to a local interface — see
|
||||||
|
# `_is_own_host_ip` for the FQDN/CGNAT rationale. Layer 1 rate
|
||||||
|
# limiting still applies to every request.
|
||||||
|
is_localhost = _is_own_host_ip(client_ip)
|
||||||
|
|
||||||
# CSRF defence-in-depth: reject `application/x-www-form-urlencoded`
|
# CSRF defence-in-depth: reject `application/x-www-form-urlencoded`
|
||||||
# bodies. PVE always sends `application/json`; form-encoded bodies
|
# bodies. PVE always sends `application/json`; form-encoded bodies
|
||||||
@@ -1407,3 +1610,72 @@ def internal_shutdown_event():
|
|||||||
return jsonify({'success': True, 'event_type': event_type}), 200
|
return jsonify({'success': True, 'event_type': event_type}), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
|
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Internal Restore Event Endpoint ─────────────────────────────
|
||||||
|
|
||||||
|
@notification_bp.route('/api/internal/restore-event', methods=['POST'])
|
||||||
|
def internal_restore_event():
|
||||||
|
"""
|
||||||
|
Internal endpoint called by apply_cluster_postboot.sh when the post-boot
|
||||||
|
dispatcher finishes. Tells the user the backgrounded restore tasks
|
||||||
|
(DKMS compile, apt installs, cluster apply, ...) are done so commands
|
||||||
|
like nvidia-smi now work.
|
||||||
|
|
||||||
|
Only accepts requests from localhost (127.0.0.1) for security.
|
||||||
|
"""
|
||||||
|
# Flask bound to 0.0.0.0 receives loopback connections as the
|
||||||
|
# IPv4-mapped IPv6 form (`::ffff:127.0.0.1`) on dual-stack hosts,
|
||||||
|
# not as bare `127.0.0.1`. Unwrap the mapped IPv4 before asking the
|
||||||
|
# stdlib classifier so every loopback representation (v4, v6,
|
||||||
|
# IPv4-mapped) is accepted.
|
||||||
|
remote_addr = request.remote_addr or ''
|
||||||
|
try:
|
||||||
|
import ipaddress
|
||||||
|
addr = ipaddress.ip_address(remote_addr.split('%')[0])
|
||||||
|
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||||
|
addr = addr.ipv4_mapped
|
||||||
|
is_loopback = addr.is_loopback
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
is_loopback = remote_addr in ('127.0.0.1', '::1', 'localhost')
|
||||||
|
if not is_loopback:
|
||||||
|
return jsonify({'error': 'forbidden', 'detail': 'localhost only'}), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
hostname = data.get('hostname', 'unknown')
|
||||||
|
guests = data.get('guests', '0')
|
||||||
|
stubs = data.get('stubs', '0')
|
||||||
|
stale_nodes = data.get('stale_nodes', '0')
|
||||||
|
components = data.get('components', 'none')
|
||||||
|
duration = data.get('duration', 'unknown')
|
||||||
|
# Boot sanity-check warnings surfaced by apply_cluster_postboot.sh.
|
||||||
|
# Empty on a clean restore; populated on the cross-version path
|
||||||
|
# when the sanity check found something the operator should know
|
||||||
|
# about (missing /lib/modules for the default kernel, no ESP
|
||||||
|
# configured, dangling /vmlinuz, ...).
|
||||||
|
warnings = data.get('warnings', '').strip()
|
||||||
|
severity = 'WARNING' if warnings else 'INFO'
|
||||||
|
warnings_block = f'\n⚠️ Boot sanity: {warnings}\n' if warnings else ''
|
||||||
|
|
||||||
|
notification_manager.emit_event(
|
||||||
|
event_type='system_restore_completed',
|
||||||
|
severity=severity,
|
||||||
|
data={
|
||||||
|
'hostname': hostname,
|
||||||
|
'guests': guests,
|
||||||
|
'stubs': stubs,
|
||||||
|
'stale_nodes': stale_nodes,
|
||||||
|
'components': components,
|
||||||
|
'duration': duration,
|
||||||
|
'warnings': warnings,
|
||||||
|
'warnings_block': warnings_block,
|
||||||
|
},
|
||||||
|
source='proxmenux',
|
||||||
|
entity='node',
|
||||||
|
entity_id='',
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'event_type': 'system_restore_completed'}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ TOOL_METADATA = {
|
|||||||
'vfio_iommu': {'name': 'VFIO/IOMMU Passthrough', 'function': 'enable_vfio_iommu', 'version': '1.0'},
|
'vfio_iommu': {'name': 'VFIO/IOMMU Passthrough', 'function': 'enable_vfio_iommu', 'version': '1.0'},
|
||||||
'lvm_repair': {'name': 'LVM PV Headers Repair', 'function': 'repair_lvm_headers', 'version': '1.0'},
|
'lvm_repair': {'name': 'LVM PV Headers Repair', 'function': 'repair_lvm_headers', 'version': '1.0'},
|
||||||
'repo_cleanup': {'name': 'Repository Cleanup', 'function': 'cleanup_repos', 'version': '1.0'},
|
'repo_cleanup': {'name': 'Repository Cleanup', 'function': 'cleanup_repos', 'version': '1.0'},
|
||||||
|
# 1.1 = setup_proxmox_repositories now re-applies chmod 0644 to existing
|
||||||
|
# .sources/.list files. Legacy users (repos created with old 0640 perms
|
||||||
|
# but no entry in installed_tools.json) are surfaced as v1.0 via the
|
||||||
|
# legacy detector in get_installed_tools() below, so the Settings page
|
||||||
|
# shows them an "Update available" they can apply without touching apt.
|
||||||
|
'proxmox_repos': {'name': 'Proxmox APT Repositories', 'function': 'setup_proxmox_repositories', 'version': '1.1'},
|
||||||
# ── Legacy / Deprecated entries ──
|
# ── Legacy / Deprecated entries ──
|
||||||
# These optimizations were applied by previous ProxMenux versions but are
|
# These optimizations were applied by previous ProxMenux versions but are
|
||||||
# no longer needed or have been removed from the current scripts. We still
|
# no longer needed or have been removed from the current scripts. We still
|
||||||
@@ -226,8 +232,15 @@ def get_installed_tools():
|
|||||||
'message': 'No ProxMenux optimizations installed yet'
|
'message': 'No ProxMenux optimizations installed yet'
|
||||||
})
|
})
|
||||||
|
|
||||||
with open(installed_tools_path, 'r') as f:
|
# Use the shared loader so both update detection and this
|
||||||
raw = json.load(f)
|
# endpoint see the same set of entries — including any
|
||||||
|
# synthetic v1.0 entries injected by `_apply_legacy_detectors`
|
||||||
|
# for tools that the host has configured but were never
|
||||||
|
# recorded in installed_tools.json (e.g. `proxmox_repos` on a
|
||||||
|
# pre-1.2.2 install). Without this, the update detector would
|
||||||
|
# surface a fix as available but the Settings endpoint
|
||||||
|
# wouldn't list the row, leaving the user nothing to click.
|
||||||
|
loaded = post_install_versions.load_installed_tools()
|
||||||
|
|
||||||
# Sprint 12A: index update list by tool key for has_update lookup.
|
# Sprint 12A: index update list by tool key for has_update lookup.
|
||||||
try:
|
try:
|
||||||
@@ -237,20 +250,11 @@ def get_installed_tools():
|
|||||||
update_by_key = {u['key']: u for u in piv_snapshot.get('updates', [])}
|
update_by_key = {u['key']: u for u in piv_snapshot.get('updates', [])}
|
||||||
|
|
||||||
tools = []
|
tools = []
|
||||||
for tool_key, value in raw.items():
|
for tool_key, value in loaded.items():
|
||||||
# Normalize legacy bool vs new structured entry.
|
if not value.get('installed', False):
|
||||||
if isinstance(value, bool):
|
|
||||||
if not value:
|
|
||||||
continue
|
|
||||||
installed_version = '1.0'
|
|
||||||
source = ''
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
if not value.get('installed', False):
|
|
||||||
continue
|
|
||||||
installed_version = str(value.get('version', '1.0')) or '1.0'
|
|
||||||
source = str(value.get('source', '') or '')
|
|
||||||
else:
|
|
||||||
continue
|
continue
|
||||||
|
installed_version = str(value.get('version', '1.0')) or '1.0'
|
||||||
|
source = str(value.get('source', '') or '')
|
||||||
|
|
||||||
# Hard-coded display metadata (display name, deprecated flag).
|
# Hard-coded display metadata (display name, deprecated flag).
|
||||||
meta = TOOL_METADATA.get(tool_key, {})
|
meta = TOOL_METADATA.get(tool_key, {})
|
||||||
|
|||||||
+7706
-143
File diff suppressed because it is too large
Load Diff
@@ -262,6 +262,10 @@ def terminal_websocket(ws):
|
|||||||
_term_env.setdefault('COLORTERM', 'truecolor')
|
_term_env.setdefault('COLORTERM', 'truecolor')
|
||||||
_term_env.setdefault('LANG', 'C.UTF-8')
|
_term_env.setdefault('LANG', 'C.UTF-8')
|
||||||
_term_env.setdefault('LC_ALL', 'C.UTF-8')
|
_term_env.setdefault('LC_ALL', 'C.UTF-8')
|
||||||
|
# Inherited by every child of this shell (including `menu`), so the
|
||||||
|
# update path can tell it's running inside a WebSocket-backed session
|
||||||
|
# that would be cut mid-install if the Monitor service restarted.
|
||||||
|
_term_env['PROXMENUX_TERMINAL'] = 'monitor'
|
||||||
_term_env.pop('PS1', None)
|
_term_env.pop('PS1', None)
|
||||||
_home = _term_env.get('HOME') or os.path.expanduser('~') or '/root'
|
_home = _term_env.get('HOME') or os.path.expanduser('~') or '/root'
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -487,6 +487,54 @@ class HealthPersistence:
|
|||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _entity_from_details(details: Optional[Dict]) -> str:
|
||||||
|
"""Derive a short display identifier for the affected object from
|
||||||
|
the details blob so notification titles can name it. Returns ''
|
||||||
|
when no per-object identity is available (aggregate-only checks).
|
||||||
|
|
||||||
|
Order of preference matches what users actually recognize:
|
||||||
|
friendly name first, then id-with-name, then bare id, then path.
|
||||||
|
"""
|
||||||
|
if not details:
|
||||||
|
return ''
|
||||||
|
d = details
|
||||||
|
# Storage / mounts / pools
|
||||||
|
if d.get('storage_name'):
|
||||||
|
st = d.get('storage_type') or d.get('type')
|
||||||
|
return f"{d['storage_name']} ({st})" if st else d['storage_name']
|
||||||
|
if d.get('pool_name'):
|
||||||
|
return d['pool_name']
|
||||||
|
if d.get('mount_point'):
|
||||||
|
return d['mount_point']
|
||||||
|
if d.get('mount_target'):
|
||||||
|
return d['mount_target']
|
||||||
|
# VMs / CTs
|
||||||
|
if d.get('vm_name') and d.get('vmid'):
|
||||||
|
return f"{d['vm_name']} ({d['vmid']})"
|
||||||
|
if d.get('ct_name') and d.get('vmid'):
|
||||||
|
return f"{d['ct_name']} ({d['vmid']})"
|
||||||
|
if d.get('vmid'):
|
||||||
|
return str(d['vmid'])
|
||||||
|
# Disks / devices
|
||||||
|
if d.get('device'):
|
||||||
|
return d['device']
|
||||||
|
if d.get('disk'):
|
||||||
|
return d['disk']
|
||||||
|
# Network / services
|
||||||
|
if d.get('interface'):
|
||||||
|
return d['interface']
|
||||||
|
if d.get('iface'):
|
||||||
|
return d['iface']
|
||||||
|
if d.get('service_name'):
|
||||||
|
return d['service_name']
|
||||||
|
if d.get('service'):
|
||||||
|
return d['service']
|
||||||
|
# Generic fallback
|
||||||
|
if d.get('name'):
|
||||||
|
return str(d['name'])
|
||||||
|
return ''
|
||||||
|
|
||||||
def record_error(self, error_key: str, category: str, severity: str,
|
def record_error(self, error_key: str, category: str, severity: str,
|
||||||
reason: str, details: Optional[Dict] = None) -> Dict[str, Any]:
|
reason: str, details: Optional[Dict] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -601,6 +649,8 @@ class HealthPersistence:
|
|||||||
event_info = {'type': 'new', 'needs_notification': True}
|
event_info = {'type': 'new', 'needs_notification': True}
|
||||||
self._record_event(cursor, 'new', error_key,
|
self._record_event(cursor, 'new', error_key,
|
||||||
{'severity': severity, 'reason': reason,
|
{'severity': severity, 'reason': reason,
|
||||||
|
'entity': self._entity_from_details(details),
|
||||||
|
'details': details,
|
||||||
'note': 'Re-triggered after suppression expired'})
|
'note': 'Re-triggered after suppression expired'})
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return event_info
|
return event_info
|
||||||
@@ -653,9 +703,15 @@ class HealthPersistence:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return event_info
|
return event_info
|
||||||
|
|
||||||
# Record event
|
# Record event with the caller-supplied `details` so downstream
|
||||||
|
# notification templates can render the affected object's name
|
||||||
|
# (storage_name, vm_name, mount_point, …) in the title/body
|
||||||
|
# via `_SafeDict`. Without this, only the aggregate `reason`
|
||||||
|
# travels and titles fall back to bare category labels.
|
||||||
self._record_event(cursor, event_info['type'], error_key,
|
self._record_event(cursor, event_info['type'], error_key,
|
||||||
{'severity': severity, 'reason': reason})
|
{'severity': severity, 'reason': reason,
|
||||||
|
'entity': self._entity_from_details(details),
|
||||||
|
'details': details})
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
@@ -690,7 +746,26 @@ class HealthPersistence:
|
|||||||
''', (now, reason, error_key))
|
''', (now, reason, error_key))
|
||||||
|
|
||||||
if cursor.rowcount > 0:
|
if cursor.rowcount > 0:
|
||||||
self._record_event(cursor, 'resolved', error_key, {'reason': reason})
|
# Reload the resolved error's details so the resolution
|
||||||
|
# event can name the same entity that was named when it
|
||||||
|
# was created — otherwise "Storage 'Tuxis' unavailable"
|
||||||
|
# comes back as "Resolved - Storage" with no identity.
|
||||||
|
cursor.execute(
|
||||||
|
'SELECT details FROM errors WHERE error_key = ? ORDER BY id DESC LIMIT 1',
|
||||||
|
(error_key,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
stored_details = None
|
||||||
|
if row and row[0]:
|
||||||
|
try:
|
||||||
|
stored_details = json.loads(row[0])
|
||||||
|
except Exception:
|
||||||
|
stored_details = None
|
||||||
|
self._record_event(cursor, 'resolved', error_key, {
|
||||||
|
'reason': reason,
|
||||||
|
'entity': self._entity_from_details(stored_details),
|
||||||
|
'details': stored_details or {},
|
||||||
|
})
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
@@ -843,11 +918,28 @@ class HealthPersistence:
|
|||||||
# Try to infer category from the error_key prefix.
|
# Try to infer category from the error_key prefix.
|
||||||
category = ''
|
category = ''
|
||||||
# Order matters: more specific prefixes MUST come before shorter ones
|
# Order matters: more specific prefixes MUST come before shorter ones
|
||||||
# e.g. 'security_updates' (updates) before 'security_' (security)
|
# e.g. 'security_updates' (updates) before 'security_' (security),
|
||||||
|
# and 'lxc_disk_low_' / 'zfs_pool_full_' (storage) before the shorter
|
||||||
|
# 'disk_' / 'zfs_pool_' fallbacks that map to 'disks'.
|
||||||
for cat, prefix in [('updates', 'security_updates'), ('updates', 'system_age'),
|
for cat, prefix in [('updates', 'security_updates'), ('updates', 'system_age'),
|
||||||
('updates', 'pending_updates'), ('updates', 'kernel_pve'),
|
('updates', 'pending_updates'), ('updates', 'kernel_pve'),
|
||||||
('security', 'security_'),
|
('security', 'security_'),
|
||||||
|
# `vm_disk_<vmid>` is a storage-category key that WOULD otherwise
|
||||||
|
# match the `vm_` prefix below and end up mis-tagged under `vms`;
|
||||||
|
# putting the storage-specific override first keeps the Dismiss
|
||||||
|
# flow honest (invalidates the storage cache, groups with the
|
||||||
|
# other capacity events).
|
||||||
|
('storage', 'vm_disk_'),
|
||||||
('pve_services', 'pve_service_'), ('vms', 'vmct_'), ('vms', 'vm_'), ('vms', 'ct_'),
|
('pve_services', 'pve_service_'), ('vms', 'vmct_'), ('vms', 'vm_'), ('vms', 'ct_'),
|
||||||
|
# ── Storage keys — HealthMonitor emits these under `storage` category
|
||||||
|
# but they used to fall through to 'general' here because no prefix
|
||||||
|
# matched, breaking the Dismiss flow (the acknowledge would persist
|
||||||
|
# but the storage cache wouldn't be invalidated because the ack was
|
||||||
|
# tagged with the wrong category).
|
||||||
|
('storage', 'storage_unavailable_'), ('storage', 'mount_stale'),
|
||||||
|
('storage', 'mount_readonly'), ('storage', 'lxc_disk_low_'),
|
||||||
|
('storage', 'lxc_mount_low_'), ('storage', 'pve_storage_full_'),
|
||||||
|
('storage', 'zfs_pool_full_'),
|
||||||
('disks', 'disk_smart_'), ('disks', 'disk_'), ('disks', 'smart_'), ('disks', 'zfs_pool_'),
|
('disks', 'disk_smart_'), ('disks', 'disk_'), ('disks', 'smart_'), ('disks', 'zfs_pool_'),
|
||||||
('logs', 'log_'), ('network', 'net_'),
|
('logs', 'log_'), ('network', 'net_'),
|
||||||
('temperature', 'temp_')]:
|
('temperature', 'temp_')]:
|
||||||
@@ -1367,6 +1459,8 @@ class HealthPersistence:
|
|||||||
_zfs_pools_cache = None
|
_zfs_pools_cache = None
|
||||||
_mount_points_cache = None
|
_mount_points_cache = None
|
||||||
_pve_services_cache = None
|
_pve_services_cache = None
|
||||||
|
_pvesm_storages_cache = None
|
||||||
|
_remote_mount_targets_cache = None
|
||||||
|
|
||||||
def check_vm_ct_cached(vmid):
|
def check_vm_ct_cached(vmid):
|
||||||
if vmid not in _vm_ct_exists_cache:
|
if vmid not in _vm_ct_exists_cache:
|
||||||
@@ -1446,6 +1540,67 @@ class HealthPersistence:
|
|||||||
_mount_points_cache = set()
|
_mount_points_cache = set()
|
||||||
return _mount_points_cache
|
return _mount_points_cache
|
||||||
|
|
||||||
|
def get_pvesm_storages():
|
||||||
|
"""Return the set of pvesm storage IDs currently configured.
|
||||||
|
|
||||||
|
Used to auto-resolve `storage_unavailable_*` and
|
||||||
|
`pve_storage_full_*` errors after the user removes the
|
||||||
|
corresponding entry from `pvesm`/Datacenter > Storage. The
|
||||||
|
check function would otherwise keep firing on a path that
|
||||||
|
no longer has any business existing.
|
||||||
|
"""
|
||||||
|
nonlocal _pvesm_storages_cache
|
||||||
|
if _pvesm_storages_cache is None:
|
||||||
|
_pvesm_storages_cache = set()
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['pvesm', 'status'],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
for line in result.stdout.strip().split('\n')[1:]:
|
||||||
|
parts = line.split()
|
||||||
|
if parts:
|
||||||
|
_pvesm_storages_cache.add(parts[0])
|
||||||
|
except Exception:
|
||||||
|
# On failure leave the cache as an empty set rather
|
||||||
|
# than `None` — that prevents us from re-trying every
|
||||||
|
# row in the active_errors loop, and the empty set
|
||||||
|
# means we won't auto-resolve anything (safer than
|
||||||
|
# falsely resolving when pvesm is momentarily down).
|
||||||
|
_pvesm_storages_cache = set()
|
||||||
|
return _pvesm_storages_cache
|
||||||
|
return _pvesm_storages_cache
|
||||||
|
|
||||||
|
def get_remote_mount_targets():
|
||||||
|
"""Return the set of mount targets currently in /proc/mounts
|
||||||
|
for remote filesystems (NFS/CIFS/SMB).
|
||||||
|
|
||||||
|
Lets us tell apart a `mount_stale_<target>` whose underlying
|
||||||
|
mount the user has umount'd (so the alert is now stale data
|
||||||
|
that should self-clear) from one the user genuinely needs
|
||||||
|
attention on (the mount is still active but the share is
|
||||||
|
unreachable). Without this distinction the alert pinned
|
||||||
|
forever once the user removed the PVE storage and lazy-
|
||||||
|
umount'd it, which is the case @UBLI-WLAN reported.
|
||||||
|
"""
|
||||||
|
nonlocal _remote_mount_targets_cache
|
||||||
|
if _remote_mount_targets_cache is None:
|
||||||
|
_remote_mount_targets_cache = set()
|
||||||
|
try:
|
||||||
|
with open('/proc/mounts', 'r', encoding='utf-8', errors='replace') as f:
|
||||||
|
for line in f:
|
||||||
|
parts = line.strip().split()
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
fstype = parts[2]
|
||||||
|
# Match the same fstypes mount_monitor watches.
|
||||||
|
if fstype in ('nfs', 'nfs4', 'cifs', 'smb', 'smbfs') or fstype.startswith(('nfs', 'cifs', 'smb')):
|
||||||
|
_remote_mount_targets_cache.add(parts[1])
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return _remote_mount_targets_cache
|
||||||
|
|
||||||
def get_pve_services_status():
|
def get_pve_services_status():
|
||||||
nonlocal _pve_services_cache
|
nonlocal _pve_services_cache
|
||||||
if _pve_services_cache is None:
|
if _pve_services_cache is None:
|
||||||
@@ -1617,9 +1772,72 @@ class HealthPersistence:
|
|||||||
should_resolve = True
|
should_resolve = True
|
||||||
resolution_reason = 'No longer in cluster'
|
resolution_reason = 'No longer in cluster'
|
||||||
|
|
||||||
|
# === PVE STORAGE REMOVED ===
|
||||||
|
# Errors that name a PVE storage (storage_unavailable_<id>,
|
||||||
|
# pve_storage_full_<id>) outlive the storage itself when the
|
||||||
|
# user removes it from pvesm. Until this hook landed, the
|
||||||
|
# check function kept stat'ing /mnt/pve/<id> after every
|
||||||
|
# iteration, found the path missing, and persisted a fresh
|
||||||
|
# CRITICAL — reported by @UBLI-WLAN on June 4 2026.
|
||||||
|
if not should_resolve and error_key:
|
||||||
|
storage_match = None
|
||||||
|
if error_key.startswith('storage_unavailable_'):
|
||||||
|
storage_match = error_key[len('storage_unavailable_'):]
|
||||||
|
elif error_key.startswith('pve_storage_full_'):
|
||||||
|
storage_match = error_key[len('pve_storage_full_'):]
|
||||||
|
if storage_match:
|
||||||
|
pvesm_set = get_pvesm_storages()
|
||||||
|
# Only treat as removed when `pvesm status` ran AND
|
||||||
|
# returned a non-empty list. An empty set could mean
|
||||||
|
# pvesm timed out, in which case it's safer not to
|
||||||
|
# resolve anything.
|
||||||
|
if pvesm_set and storage_match not in pvesm_set:
|
||||||
|
should_resolve = True
|
||||||
|
resolution_reason = f'Storage {storage_match} removed from pvesm'
|
||||||
|
|
||||||
|
# === LXC MOUNT FOR DELETED CT ===
|
||||||
|
# `_check_lxc_mount_capacity` records
|
||||||
|
# `lxc_mount_<vmid>_<mount>`, which the VM/CT block above
|
||||||
|
# misses because the prefix isn't one of `vm_/ct_/vmct_`.
|
||||||
|
# When the CT is gone the disk-fill alert is meaningless.
|
||||||
|
if not should_resolve and error_key and error_key.startswith('lxc_mount_'):
|
||||||
|
# `lxc_mount_<vmid>_<mount-path-tokens>` — VMID is the
|
||||||
|
# first integer block after the prefix.
|
||||||
|
m = re.match(r'^lxc_mount_(\d+)_', error_key)
|
||||||
|
if m:
|
||||||
|
lxc_vmid = m.group(1)
|
||||||
|
if not check_vm_ct_cached(lxc_vmid):
|
||||||
|
should_resolve = True
|
||||||
|
resolution_reason = f'CT {lxc_vmid} no longer exists'
|
||||||
|
|
||||||
|
# === ORPHAN REMOTE MOUNT ===
|
||||||
|
# `_check_remote_mounts` records `mount_<status>_<target>`
|
||||||
|
# for every NFS/CIFS/SMB target that's in /proc/mounts but
|
||||||
|
# fails to stat. When the user removes the PVE storage,
|
||||||
|
# PVE often does a lazy umount: the kernel mount entry is
|
||||||
|
# gone (or the /mnt/pve/<id> target was deleted on top), so
|
||||||
|
# subsequent scans never see the mount again — but the
|
||||||
|
# already-persisted error has no auto-resolve path.
|
||||||
|
# Resolve the error when the target is no longer in
|
||||||
|
# /proc/mounts as a remote mount.
|
||||||
|
if not should_resolve and error_key and error_key.startswith('mount_'):
|
||||||
|
# `mount_stale_<target>` or `mount_readonly_<target>`
|
||||||
|
# — possibly LXC-scoped as `mount_<status>_ct<id>:<target>`.
|
||||||
|
stripped = error_key.split('_', 2)
|
||||||
|
if len(stripped) == 3:
|
||||||
|
key_target = stripped[2]
|
||||||
|
# LXC-scoped entries (`ct123:/mnt/foo`) are left for
|
||||||
|
# the VM/CT cleanup path; the host-side reconciler
|
||||||
|
# only owns host-level targets.
|
||||||
|
if not key_target.startswith('ct'):
|
||||||
|
targets = get_remote_mount_targets()
|
||||||
|
if key_target not in targets:
|
||||||
|
should_resolve = True
|
||||||
|
resolution_reason = 'Remote mount no longer present (orphan auto-cleared)'
|
||||||
|
|
||||||
# === TEMPERATURE ERRORS ===
|
# === TEMPERATURE ERRORS ===
|
||||||
# Temperature errors - check if sensor still exists (unlikely to change, resolve after 24h of no activity)
|
# Temperature errors - check if sensor still exists (unlikely to change, resolve after 24h of no activity)
|
||||||
elif category == 'temperature':
|
if not should_resolve and category == 'temperature':
|
||||||
if last_seen_hours > 24:
|
if last_seen_hours > 24:
|
||||||
should_resolve = True
|
should_resolve = True
|
||||||
resolution_reason = 'Temperature error stale (>24h no activity)'
|
resolution_reason = 'Temperature error stale (>24h no activity)'
|
||||||
|
|||||||
@@ -75,6 +75,16 @@ DEFAULTS: dict[str, Any] = {
|
|||||||
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
|
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
|
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
},
|
},
|
||||||
|
"vm_disk": {
|
||||||
|
# Aggregate guest-filesystem usage for running QEMU VMs, read
|
||||||
|
# from the guest agent (mirrors `lxc_rootfs` for VMs). Includes
|
||||||
|
# every persistent filesystem the guest reports on a block
|
||||||
|
# device, so PCI-passthrough drives and add-on storage count
|
||||||
|
# towards the threshold — the metric is "how full is the
|
||||||
|
# guest", not "how full is the disk PVE knows about".
|
||||||
|
"warning": {"value": 85, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
|
"critical": {"value": 95, "unit": "%", "min": 1, "max": 100, "step": 1},
|
||||||
|
},
|
||||||
"cpu_temperature": {
|
"cpu_temperature": {
|
||||||
"warning": {"value": 80, "unit": "°C", "min": 30, "max": 120, "step": 1},
|
"warning": {"value": 80, "unit": "°C", "min": 30, "max": 120, "step": 1},
|
||||||
"critical": {"value": 90, "unit": "°C", "min": 30, "max": 120, "step": 1},
|
"critical": {"value": 90, "unit": "°C", "min": 30, "max": 120, "step": 1},
|
||||||
|
|||||||
@@ -66,6 +66,47 @@ def require_auth(f):
|
|||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth_or_ticket(f):
|
||||||
|
"""Like `require_auth` but ALSO accepts a single-use `?ticket=...`
|
||||||
|
query parameter (same tickets `/api/terminal/ticket` issues for
|
||||||
|
WebSockets). Use on endpoints that the browser needs to invoke
|
||||||
|
from an <a download href="..."> tag — anchor tags can't send the
|
||||||
|
Authorization header, so the caller fetches a ticket first and
|
||||||
|
appends it to the URL.
|
||||||
|
|
||||||
|
Use only for streaming downloads where adding `?ticket=...` is
|
||||||
|
the only practical way to authenticate; everything else stays
|
||||||
|
on plain Bearer-token auth."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
config = load_auth_config()
|
||||||
|
if not config.get("enabled", False) or config.get("declined", False):
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
# First try the Bearer header (fetch from JS uses this).
|
||||||
|
auth_header = request.headers.get('Authorization')
|
||||||
|
if auth_header:
|
||||||
|
parts = auth_header.split()
|
||||||
|
if len(parts) == 2 and parts[0].lower() == 'bearer':
|
||||||
|
if verify_token(parts[1]):
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
# Fall through to single-use ticket (works for <a download>).
|
||||||
|
try:
|
||||||
|
from flask_terminal_routes import _consume_terminal_ticket
|
||||||
|
if _consume_terminal_ticket(request.args.get('ticket', '')):
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"error": "Authentication required",
|
||||||
|
"message": "Provide a Bearer token in the Authorization header or a fresh ?ticket=... from /api/terminal/ticket"
|
||||||
|
}), 401
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
def require_admin_scope(f):
|
def require_admin_scope(f):
|
||||||
"""Like `require_auth` but ALSO requires the token's `scope == full_admin`.
|
"""Like `require_auth` but ALSO requires the token's `scope == full_admin`.
|
||||||
|
|
||||||
|
|||||||
@@ -170,19 +170,46 @@ def _detect_nvidia_xfree86() -> Optional[dict]:
|
|||||||
def _detect_coral_host() -> list[dict]:
|
def _detect_coral_host() -> list[dict]:
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
|
|
||||||
# PCIe / M.2 — gasket-dkms package version, falling back to the
|
# PCIe / M.2 — version detection has three sources, tried in this
|
||||||
# registered DKMS version if the package was force-removed but the
|
# order of trust:
|
||||||
# built modules still exist.
|
#
|
||||||
|
# 1. The marker file `/var/lib/proxmenux/coral_gasket_version`
|
||||||
|
# written by `install_coral.sh` after a successful DKMS
|
||||||
|
# install — contains the feranick release tag actually
|
||||||
|
# installed (e.g. `1.0-18.4`). This is the only source that
|
||||||
|
# knows the fork's patch level.
|
||||||
|
# 2. `dpkg-query gasket-dkms` — the Debian package version, only
|
||||||
|
# present when the user installed via .deb rather than the
|
||||||
|
# ProxMenux script.
|
||||||
|
# 3. `dkms status` — the upstream module version registered with
|
||||||
|
# DKMS, which is always the bare `1.0`. Useful as a "modules
|
||||||
|
# are present" indicator but doesn't reveal the fork patch
|
||||||
|
# level, so the update-availability check would always fire a
|
||||||
|
# false positive against feranick's `1.0-N` tags. Reported on
|
||||||
|
# .50 after a successful re-install kept showing the update
|
||||||
|
# notification.
|
||||||
pcie_version: Optional[str] = None
|
pcie_version: Optional[str] = None
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
with open("/var/lib/proxmenux/coral_gasket_version",
|
||||||
["dpkg-query", "-W", "-f=${Status}|${Version}", "gasket-dkms"],
|
"r", encoding="utf-8", errors="replace") as fh:
|
||||||
capture_output=True, text=True, timeout=3,
|
marker = fh.read().strip()
|
||||||
)
|
# Sanity check: the file should hold something that looks
|
||||||
if r.returncode == 0 and "ok installed" in r.stdout:
|
# like a version tag, not an error message or empty line.
|
||||||
pcie_version = r.stdout.split("|", 1)[1].strip()
|
if marker and re.match(r"^[A-Za-z0-9._+-]+$", marker):
|
||||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
pcie_version = marker
|
||||||
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if not pcie_version:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["dpkg-query", "-W", "-f=${Status}|${Version}", "gasket-dkms"],
|
||||||
|
capture_output=True, text=True, timeout=3,
|
||||||
|
)
|
||||||
|
if r.returncode == 0 and "ok installed" in r.stdout:
|
||||||
|
pcie_version = r.stdout.split("|", 1)[1].strip()
|
||||||
|
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
if not pcie_version:
|
if not pcie_version:
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
|
|||||||
@@ -983,6 +983,10 @@ class EmailChannel(NotificationChannel):
|
|||||||
elif group == 'backup':
|
elif group == 'backup':
|
||||||
_add('VM/CT ID', data.get('vmid'), 'code')
|
_add('VM/CT ID', data.get('vmid'), 'code')
|
||||||
_add('Name', data.get('vmname'), 'bold')
|
_add('Name', data.get('vmname'), 'bold')
|
||||||
|
# Storage / destination — the piece a multi-PBS operator needs to
|
||||||
|
# tell which target the backup ran against. Reported gap: emails
|
||||||
|
# showed no way to distinguish which PBS failed with 2+ configured.
|
||||||
|
_add('Storage', data.get('storage') or data.get('storage_name'), 'code')
|
||||||
_add('Status', 'Failed' if 'fail' in event_type else 'Completed' if 'complete' in event_type else 'Started',
|
_add('Status', 'Failed' if 'fail' in event_type else 'Completed' if 'complete' in event_type else 'Started',
|
||||||
'severity' if 'fail' in event_type else '')
|
'severity' if 'fail' in event_type else '')
|
||||||
_add('Size', data.get('size'))
|
_add('Size', data.get('size'))
|
||||||
@@ -1082,7 +1086,11 @@ class EmailChannel(NotificationChannel):
|
|||||||
)
|
)
|
||||||
rows.append((esc('Important Packages'), pkg_html))
|
rows.append((esc('Important Packages'), pkg_html))
|
||||||
_add('Current Version', data.get('current_version'), 'code')
|
_add('Current Version', data.get('current_version'), 'code')
|
||||||
_add('New Version', data.get('new_version'), 'code')
|
# `new_version` is the field used by generic package-update events;
|
||||||
|
# driver-update templates (nvidia, coral) populate `latest_version`.
|
||||||
|
# Read both so the tabular row is never empty when the template's
|
||||||
|
# title/body already printed the new version.
|
||||||
|
_add('New Version', data.get('new_version') or data.get('latest_version'), 'code')
|
||||||
|
|
||||||
# ── Other / unknown ──
|
# ── Other / unknown ──
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -396,6 +396,74 @@ def is_vzdump_active_on_host() -> bool:
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
# ─── APT / dpkg activity gate ────────────────────────────────────
|
||||||
|
# PVE services (pve-cluster, pveproxy, pvedaemon, corosync…) are
|
||||||
|
# routinely killed and restarted as part of a package upgrade, so
|
||||||
|
# every full-upgrade produces a burst of `service_fail` events that
|
||||||
|
# are entirely expected. We gate `_check_service_failure` on this
|
||||||
|
# helper so those events are silently dropped while apt is running
|
||||||
|
# (plus a short grace window after it exits).
|
||||||
|
|
||||||
|
_APT_ACTIVE_MARKER = '/var/run/proxmenux-update-in-progress'
|
||||||
|
_APT_FINISHED_MARKER = '/var/run/proxmenux-update-just-finished'
|
||||||
|
_APT_GRACE_SECONDS = 60
|
||||||
|
_DPKG_LOCK_FILE = '/var/lib/dpkg/lock-frontend'
|
||||||
|
_APT_ACTIVE_CACHE_TTL = 3.0
|
||||||
|
_apt_active_cache_ts = 0.0
|
||||||
|
_apt_active_cache_value = False
|
||||||
|
|
||||||
|
|
||||||
|
def is_apt_active_on_host() -> bool:
|
||||||
|
"""Return True while apt/dpkg is running on this host.
|
||||||
|
|
||||||
|
Sources checked, in order:
|
||||||
|
1. `/var/run/proxmenux-update-in-progress` — created by
|
||||||
|
`scripts/utilities/proxmox_update.sh` around its full-upgrade
|
||||||
|
call so ProxMenux-driven updates are always covered.
|
||||||
|
2. `fuser` on `/var/lib/dpkg/lock-frontend` — covers a manual
|
||||||
|
`apt`/`dpkg`/`apt-get` invocation by the operator, or any
|
||||||
|
other tool holding the lock.
|
||||||
|
3. Grace window (60 s) after `/var/run/proxmenux-update-just-finished`
|
||||||
|
was touched — catches the tail of service_fail events that
|
||||||
|
only reach the journal shortly after apt itself exits.
|
||||||
|
|
||||||
|
Cached 3 s so a burst of journal events doesn't spawn a fuser
|
||||||
|
subprocess per line. Caller-safe: returns False on any error.
|
||||||
|
"""
|
||||||
|
global _apt_active_cache_ts, _apt_active_cache_value
|
||||||
|
now = time.time()
|
||||||
|
if now - _apt_active_cache_ts < _APT_ACTIVE_CACHE_TTL:
|
||||||
|
return _apt_active_cache_value
|
||||||
|
|
||||||
|
active = False
|
||||||
|
try:
|
||||||
|
if os.path.exists(_APT_ACTIVE_MARKER):
|
||||||
|
active = True
|
||||||
|
elif os.path.exists(_APT_FINISHED_MARKER):
|
||||||
|
try:
|
||||||
|
if now - os.path.getmtime(_APT_FINISHED_MARKER) < _APT_GRACE_SECONDS:
|
||||||
|
active = True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['fuser', _DPKG_LOCK_FILE],
|
||||||
|
capture_output=True, timeout=2,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
active = True
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
active = False
|
||||||
|
|
||||||
|
_apt_active_cache_ts = now
|
||||||
|
_apt_active_cache_value = active
|
||||||
|
return active
|
||||||
|
|
||||||
|
|
||||||
# ─── Journal Watcher (Real-time) ─────────────────────────────────
|
# ─── Journal Watcher (Real-time) ─────────────────────────────────
|
||||||
|
|
||||||
class JournalWatcher:
|
class JournalWatcher:
|
||||||
@@ -597,22 +665,50 @@ class JournalWatcher:
|
|||||||
if self._running:
|
if self._running:
|
||||||
time.sleep(5) # Wait before restart
|
time.sleep(5) # Wait before restart
|
||||||
|
|
||||||
|
# Discard a saved cursor older than this many seconds — resuming
|
||||||
|
# from a stale cursor replays every event journald has produced
|
||||||
|
# since the file was last touched, which surfaces as duplicate
|
||||||
|
# notifications. 15 min comfortably covers graceful shutdowns,
|
||||||
|
# cron-restart loops and brief outages while keeping a multi-day
|
||||||
|
# downtime from spamming the operator. Reported on RimegraVE
|
||||||
|
# (.1.10): the service was last stopped 11 days before a restart;
|
||||||
|
# on respawn the JournalWatcher fired 16 backup_start notifs in
|
||||||
|
# 40 min for vzdump runs that PVE had logged hours-to-days
|
||||||
|
# earlier. source=journal in notification_history proved the
|
||||||
|
# event chain came from this replay path.
|
||||||
|
_CURSOR_STALE_AFTER_SEC = 15 * 60
|
||||||
|
|
||||||
def _run_journalctl(self):
|
def _run_journalctl(self):
|
||||||
"""Run journalctl -f and process output line by line."""
|
"""Run journalctl -f and process output line by line.
|
||||||
# Persist the cursor across watcher restarts so we don't lose events
|
|
||||||
# in the 5s gap between subprocess crash and respawn. journalctl
|
The cursor file lets us pick up after a short watcher crash
|
||||||
# writes the file with the latest seen cursor and on next start
|
without dropping events, but we drop it before invoking
|
||||||
# resumes from there. Falls back to -n 0 (start from now) only on
|
journalctl if it's older than _CURSOR_STALE_AFTER_SEC.
|
||||||
# the very first run when the cursor file doesn't exist yet.
|
That forces journalctl back to `-n 0` (start from now)
|
||||||
|
instead of replaying days of history as if it were live.
|
||||||
|
"""
|
||||||
cursor_file = '/usr/local/share/proxmenux/journal_cursor.txt'
|
cursor_file = '/usr/local/share/proxmenux/journal_cursor.txt'
|
||||||
try:
|
try:
|
||||||
Path(cursor_file).parent.mkdir(parents=True, exist_ok=True)
|
Path(cursor_file).parent.mkdir(parents=True, exist_ok=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
cursor_path = Path(cursor_file)
|
||||||
|
if cursor_path.exists():
|
||||||
|
try:
|
||||||
|
age = time.time() - cursor_path.stat().st_mtime
|
||||||
|
if age > self._CURSOR_STALE_AFTER_SEC:
|
||||||
|
cursor_path.unlink(missing_ok=True)
|
||||||
|
print(
|
||||||
|
f"[JournalWatcher] Cursor stale "
|
||||||
|
f"({int(age)}s old) — restarting from now to "
|
||||||
|
f"avoid replaying historical events"
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
cmd = ['journalctl', '-f', '-o', 'json', '--no-pager',
|
cmd = ['journalctl', '-f', '-o', 'json', '--no-pager',
|
||||||
f'--cursor-file={cursor_file}']
|
f'--cursor-file={cursor_file}']
|
||||||
if not Path(cursor_file).exists():
|
if not cursor_path.exists():
|
||||||
cmd.extend(['-n', '0']) # First run: don't replay history
|
cmd.extend(['-n', '0']) # First run (or stale): don't replay history
|
||||||
|
|
||||||
self._process = subprocess.Popen(
|
self._process = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||||
@@ -1023,6 +1119,14 @@ class JournalWatcher:
|
|||||||
|
|
||||||
def _check_service_failure(self, msg: str, unit: str):
|
def _check_service_failure(self, msg: str, unit: str):
|
||||||
"""Detect critical service failures with enriched context."""
|
"""Detect critical service failures with enriched context."""
|
||||||
|
# Skip while apt/dpkg is running. PVE services (pve-cluster,
|
||||||
|
# pveproxy, pvedaemon, corosync…) get killed and restarted as a
|
||||||
|
# normal part of every package upgrade, so their `service_fail`
|
||||||
|
# events during that window are expected noise, not real
|
||||||
|
# failures. See `is_apt_active_on_host()` for detection details.
|
||||||
|
if is_apt_active_on_host():
|
||||||
|
return
|
||||||
|
|
||||||
# Filter out noise -- these are normal systemd transient units,
|
# Filter out noise -- these are normal systemd transient units,
|
||||||
# not real service failures worth alerting about.
|
# not real service failures worth alerting about.
|
||||||
_NOISE_PATTERNS = [
|
_NOISE_PATTERNS = [
|
||||||
@@ -1773,6 +1877,50 @@ class TaskWatcher:
|
|||||||
# Log error for debugging but return status as fallback
|
# Log error for debugging but return status as fallback
|
||||||
return status
|
return status
|
||||||
|
|
||||||
|
def _get_task_context(self, upid: str, task_type: str) -> dict:
|
||||||
|
"""Extract task-type-specific fields from a task log.
|
||||||
|
|
||||||
|
For snapshots and migrations the interesting metadata (snapshot
|
||||||
|
name, target node) lives inside the task log body — not in the
|
||||||
|
UPID itself. Without it, `snapshot_complete` bodies render as
|
||||||
|
`Snapshot "" created` and `migration_complete` as `... migrated
|
||||||
|
to node .` — both real rendering bugs.
|
||||||
|
"""
|
||||||
|
wanted = None
|
||||||
|
if task_type in ('qmsnapshot', 'vzsnapshot'):
|
||||||
|
wanted = 'snapshot'
|
||||||
|
elif task_type in ('qmigrate', 'vzmigrate'):
|
||||||
|
wanted = 'migration'
|
||||||
|
if wanted is None:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parts = upid.split(':')
|
||||||
|
if len(parts) < 5:
|
||||||
|
return {}
|
||||||
|
starttime_hex = parts[4]
|
||||||
|
if not starttime_hex:
|
||||||
|
return {}
|
||||||
|
subdir = starttime_hex[-1].upper()
|
||||||
|
log_path = os.path.join(self.TASK_DIR, subdir, upid)
|
||||||
|
if not os.path.exists(log_path):
|
||||||
|
return {}
|
||||||
|
with open(log_path, 'r', errors='replace') as f:
|
||||||
|
head = ''.join(f.readline() for _ in range(30))
|
||||||
|
ctx: dict = {}
|
||||||
|
if wanted == 'snapshot':
|
||||||
|
m = (re.search(r"snapshot\s+['\"]([^'\"\n]{1,80})['\"]", head, re.IGNORECASE)
|
||||||
|
or re.search(r"snapshot(?:\s+name)?\s+([A-Za-z0-9._\-]{1,80})", head, re.IGNORECASE))
|
||||||
|
if m:
|
||||||
|
ctx['snapshot_name'] = m.group(1).strip()
|
||||||
|
elif wanted == 'migration':
|
||||||
|
m = (re.search(r"to\s+node\s+([A-Za-z0-9._\-]{1,60})", head, re.IGNORECASE)
|
||||||
|
or re.search(r"migration to\s+(?:node\s+)?([A-Za-z0-9._\-]{1,60})", head, re.IGNORECASE))
|
||||||
|
if m:
|
||||||
|
ctx['target_node'] = m.group(1).strip()
|
||||||
|
return ctx
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
# Map PVE task types to our event types
|
# Map PVE task types to our event types
|
||||||
TASK_MAP = {
|
TASK_MAP = {
|
||||||
'qmstart': ('vm_start', 'INFO'),
|
'qmstart': ('vm_start', 'INFO'),
|
||||||
@@ -2084,15 +2232,20 @@ class TaskWatcher:
|
|||||||
else:
|
else:
|
||||||
reason = ''
|
reason = ''
|
||||||
|
|
||||||
|
# Populate task-type-specific fields (target_node for migrations,
|
||||||
|
# snapshot_name for snapshots) so the corresponding template bodies
|
||||||
|
# don't render "to node ." or `Snapshot ""`.
|
||||||
|
ctx = self._get_task_context(upid, task_type)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'vmid': vmid,
|
'vmid': vmid,
|
||||||
'vmname': vmname or f'ID {vmid}',
|
'vmname': vmname or f'ID {vmid}',
|
||||||
'hostname': self._hostname,
|
'hostname': self._hostname,
|
||||||
'user': user,
|
'user': user,
|
||||||
'reason': reason,
|
'reason': reason,
|
||||||
'target_node': '',
|
'target_node': ctx.get('target_node', ''),
|
||||||
'size': '',
|
'size': '',
|
||||||
'snapshot_name': '',
|
'snapshot_name': ctx.get('snapshot_name', ''),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Determine entity type from task type
|
# Determine entity type from task type
|
||||||
@@ -2516,12 +2669,17 @@ class PollingCollector:
|
|||||||
if not error_key:
|
if not error_key:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Keep the raw `details` blob in the tracker so recovery
|
||||||
|
# notifications later can name the same entity ("Storage
|
||||||
|
# 'Tuxis'") that the original alert did — without this, the
|
||||||
|
# resolved notification defaults to the bare category.
|
||||||
current_keys[error_key] = {
|
current_keys[error_key] = {
|
||||||
'category': error.get('category', ''),
|
'category': error.get('category', ''),
|
||||||
'severity': error.get('severity', 'WARNING'),
|
'severity': error.get('severity', 'WARNING'),
|
||||||
'reason': error.get('reason', ''),
|
'reason': error.get('reason', ''),
|
||||||
'first_seen': error.get('first_seen', ''),
|
'first_seen': error.get('first_seen', ''),
|
||||||
'error_key': error_key,
|
'error_key': error_key,
|
||||||
|
'details': error.get('details'),
|
||||||
}
|
}
|
||||||
category = error.get('category', '')
|
category = error.get('category', '')
|
||||||
severity = error.get('severity', 'WARNING')
|
severity = error.get('severity', 'WARNING')
|
||||||
@@ -2614,6 +2772,8 @@ class PollingCollector:
|
|||||||
if category == 'storage':
|
if category == 'storage':
|
||||||
if error_key.startswith('lxc_disk_'):
|
if error_key.startswith('lxc_disk_'):
|
||||||
event_type = 'lxc_disk_low'
|
event_type = 'lxc_disk_low'
|
||||||
|
elif error_key.startswith('vm_disk_'):
|
||||||
|
event_type = 'vm_disk_low'
|
||||||
elif error_key.startswith('lxc_mount_'):
|
elif error_key.startswith('lxc_mount_'):
|
||||||
event_type = 'lxc_mount_low'
|
event_type = 'lxc_mount_low'
|
||||||
elif error_key.startswith('pve_storage_full_'):
|
elif error_key.startswith('pve_storage_full_'):
|
||||||
@@ -2852,6 +3012,18 @@ class PollingCollector:
|
|||||||
'duration': duration_label,
|
'duration': duration_label,
|
||||||
'is_recovery': True,
|
'is_recovery': True,
|
||||||
}
|
}
|
||||||
|
# Spread the original details blob so the resolved notification
|
||||||
|
# can use the same {storage_name}/{vm_name}/{device} placeholders
|
||||||
|
# the alert did. Mirrors what the alert path does on line ~2718.
|
||||||
|
resolved_details = old_meta.get('details')
|
||||||
|
if isinstance(resolved_details, str):
|
||||||
|
try:
|
||||||
|
resolved_details = json.loads(resolved_details)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
resolved_details = None
|
||||||
|
if isinstance(resolved_details, dict):
|
||||||
|
for _k, _v in resolved_details.items():
|
||||||
|
data.setdefault(_k, _v)
|
||||||
|
|
||||||
self._queue.put(NotificationEvent(
|
self._queue.put(NotificationEvent(
|
||||||
'error_resolved', 'OK', data, source='health',
|
'error_resolved', 'OK', data, source='health',
|
||||||
@@ -2972,6 +3144,7 @@ class PollingCollector:
|
|||||||
'system_startup', severity, data, source='polling',
|
'system_startup', severity, data, source='polling',
|
||||||
entity='node', entity_id='',
|
entity='node', entity_id='',
|
||||||
))
|
))
|
||||||
|
startup_grace.mark_startup_aggregated()
|
||||||
|
|
||||||
# ── Update check (enriched) ────────────────────────────────
|
# ── Update check (enriched) ────────────────────────────────
|
||||||
|
|
||||||
@@ -3475,13 +3648,20 @@ class PollingCollector:
|
|||||||
about the final shape."""
|
about the final shape."""
|
||||||
item_type = item.get('type', '')
|
item_type = item.get('type', '')
|
||||||
update = item.get('update_check', {}) or {}
|
update = item.get('update_check', {}) or {}
|
||||||
|
# Version fallbacks: if `latest` is missing (checker couldn't
|
||||||
|
# determine an upstream — network hiccup, or the app itself
|
||||||
|
# isn't in the update list because only sidecar packages need
|
||||||
|
# updating), anchor to the current version so template
|
||||||
|
# placeholders never render as "v" with nothing after.
|
||||||
|
_cur = item.get('current_version') or ''
|
||||||
|
_lat = update.get('latest') or _cur or 'unknown'
|
||||||
common = {
|
common = {
|
||||||
'hostname': self._hostname,
|
'hostname': self._hostname,
|
||||||
'name': item.get('name') or item.get('id'),
|
'name': item.get('name') or item.get('id'),
|
||||||
'menu_label': item.get('menu_label') or '',
|
'menu_label': item.get('menu_label') or '',
|
||||||
'menu_script': item.get('menu_script') or '',
|
'menu_script': item.get('menu_script') or '',
|
||||||
'current_version': item.get('current_version') or '',
|
'current_version': _cur or 'unknown',
|
||||||
'latest_version': update.get('latest') or '',
|
'latest_version': _lat,
|
||||||
}
|
}
|
||||||
|
|
||||||
if item_type == 'oci_app':
|
if item_type == 'oci_app':
|
||||||
@@ -3491,12 +3671,24 @@ class PollingCollector:
|
|||||||
f" → {p.get('latest', '?')}"
|
f" → {p.get('latest', '?')}"
|
||||||
for p in packages
|
for p in packages
|
||||||
]
|
]
|
||||||
|
# Decide the wording based on whether Tailscale itself moved.
|
||||||
|
# When current == latest, showing "v1.90 → v1.90" reads as a
|
||||||
|
# bug ("update to same version?"); switch to a neutral line
|
||||||
|
# that makes it clear only sidecar packages are updating.
|
||||||
|
if _cur and _lat and _cur == _lat:
|
||||||
|
update_title_suffix = f' — v{_cur} (packages only)'
|
||||||
|
version_line = f'🔹 Tailscale: v{_cur} (unchanged — only sidecar packages need updating)'
|
||||||
|
else:
|
||||||
|
update_title_suffix = f' — v{_lat}'
|
||||||
|
version_line = f'🔹 Current Tailscale: v{_cur or "unknown"} → 🟢 Latest: v{_lat}'
|
||||||
data = {
|
data = {
|
||||||
**common,
|
**common,
|
||||||
'app_id': item.get('id', '').removeprefix('oci:'),
|
'app_id': item.get('id', '').removeprefix('oci:'),
|
||||||
'app_name': common['name'],
|
'app_name': common['name'],
|
||||||
'package_count': len(packages),
|
'package_count': len(packages),
|
||||||
'package_list': '\n'.join(pkg_lines) or ' (no detail)',
|
'package_list': '\n'.join(pkg_lines) or ' (no detail)',
|
||||||
|
'update_title_suffix': update_title_suffix,
|
||||||
|
'version_line': version_line,
|
||||||
}
|
}
|
||||||
return 'secure_gateway_update_available', data
|
return 'secure_gateway_update_available', data
|
||||||
|
|
||||||
@@ -3828,6 +4020,44 @@ class ProxmoxHookWatcher:
|
|||||||
'job_id': pve_job_id,
|
'job_id': pve_job_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# `system_problem` is the generic fallback of `_classify_pve` for
|
||||||
|
# unknown/empty pve_type. Without a populated `reason`, the template
|
||||||
|
# renders "Reason: " (empty) and `_summarize_event` falls back to
|
||||||
|
# printing the raw event_type ("problema_del_sistema") in every
|
||||||
|
# `burst_system` aggregate, producing the useless
|
||||||
|
# "🔵 constructor: Problema del sistema detectado" / "+1 problema
|
||||||
|
# más del sistema (Problemas adicionales: - problema_del_sistema)"
|
||||||
|
# messages the operator sees on Telegram. Preserve the PVE payload
|
||||||
|
# as `reason` so both surfaces have concrete text to render.
|
||||||
|
if event_type == 'system_problem':
|
||||||
|
reason_text = (message or title or '').strip()
|
||||||
|
if reason_text:
|
||||||
|
data['reason'] = reason_text[:500]
|
||||||
|
|
||||||
|
# ProxMenux Host Backup: pull the extra fields that the runner
|
||||||
|
# packs into payload.fields so the host_backup_* templates can
|
||||||
|
# render backend, destination, sizes, etc. without falling back
|
||||||
|
# to '{field}' literals or empty placeholders.
|
||||||
|
if pve_type.startswith('proxmenux-host-backup-'):
|
||||||
|
backend = (fields.get('backend') or '').strip().lower()
|
||||||
|
backend_label_map = {
|
||||||
|
'pbs': 'Proxmox Backup Server',
|
||||||
|
'local': 'Local archive',
|
||||||
|
'borg': 'Borg repository',
|
||||||
|
}
|
||||||
|
data['backend'] = backend
|
||||||
|
data['backend_label'] = backend_label_map.get(backend, backend or 'unknown')
|
||||||
|
data['destination'] = fields.get('destination', '') or ''
|
||||||
|
data['profile_mode'] = fields.get('profile_mode', 'default') or 'default'
|
||||||
|
data['data_size'] = fields.get('data_size', '') or '-'
|
||||||
|
data['archive_size'] = fields.get('archive_size', '') or '-'
|
||||||
|
data['duration'] = fields.get('duration', '') or '-'
|
||||||
|
data['log_file'] = fields.get('log_file', '') or ''
|
||||||
|
# `reason` is what the body template substitutes for the
|
||||||
|
# failure case; fall back to the human message the runner
|
||||||
|
# sent so the operator never sees an empty Reason line.
|
||||||
|
data['reason'] = fields.get('reason', message) or message
|
||||||
|
|
||||||
# ── Extract clean reason for system-mail events ──
|
# ── Extract clean reason for system-mail events ──
|
||||||
# smartd and other system mail contains verbose boilerplate.
|
# smartd and other system mail contains verbose boilerplate.
|
||||||
# Extract just the actionable warning/error lines.
|
# Extract just the actionable warning/error lines.
|
||||||
@@ -3875,6 +4105,13 @@ class ProxmoxHookWatcher:
|
|||||||
dur_m = re.search(r'Total running time:\s*(.+?)(?:\n|$)', message)
|
dur_m = re.search(r'Total running time:\s*(.+?)(?:\n|$)', message)
|
||||||
if dur_m:
|
if dur_m:
|
||||||
data['duration'] = dur_m.group(1).strip()
|
data['duration'] = dur_m.group(1).strip()
|
||||||
|
# Extract storage / destination from the "starting new backup job" line
|
||||||
|
# so the notification (email HTML, Telegram title) can show which
|
||||||
|
# PBS / storage the backup landed on. Operators with multiple PBS
|
||||||
|
# targets need this to diagnose which destination failed.
|
||||||
|
storage_m = re.search(r'--storage\s+(\S+)', message)
|
||||||
|
if storage_m:
|
||||||
|
data['storage'] = storage_m.group(1).strip()
|
||||||
|
|
||||||
# Capture journal context for critical/warning events (helps AI provide better context)
|
# Capture journal context for critical/warning events (helps AI provide better context)
|
||||||
if severity in ('CRITICAL', 'WARNING') and event_type not in ('backup_complete', 'update_available'):
|
if severity in ('CRITICAL', 'WARNING') and event_type not in ('backup_complete', 'update_available'):
|
||||||
@@ -3924,6 +4161,21 @@ class ProxmoxHookWatcher:
|
|||||||
return 'backup_fail', 'vm', ''
|
return 'backup_fail', 'vm', ''
|
||||||
return 'backup_complete', 'vm', ''
|
return 'backup_complete', 'vm', ''
|
||||||
|
|
||||||
|
# ProxMenux Host Backups run as a standalone systemd timer +
|
||||||
|
# `run_scheduled_backup.sh` (or manually via `backup_host.sh`),
|
||||||
|
# NOT as PVE vzdump tasks. Routed to dedicated event types
|
||||||
|
# (`host_backup_*`) so the operator can toggle them separately
|
||||||
|
# from VM/CT backup events and the body can show host-backup
|
||||||
|
# specifics (backend, destination, data/archive size). Without
|
||||||
|
# this branch the Host Backup ran fine but never produced a
|
||||||
|
# notification — operator-reported.
|
||||||
|
if pve_type == 'proxmenux-host-backup-start':
|
||||||
|
return 'host_backup_start', 'node', ''
|
||||||
|
if pve_type == 'proxmenux-host-backup-complete':
|
||||||
|
return 'host_backup_complete', 'node', ''
|
||||||
|
if pve_type == 'proxmenux-host-backup-fail':
|
||||||
|
return 'host_backup_fail', 'node', ''
|
||||||
|
|
||||||
if pve_type == 'fencing':
|
if pve_type == 'fencing':
|
||||||
return 'split_brain', 'node', ''
|
return 'split_brain', 'node', ''
|
||||||
|
|
||||||
|
|||||||
@@ -69,10 +69,17 @@ SENSITIVE_KEYS = {
|
|||||||
'ai_api_key_anthropic',
|
'ai_api_key_anthropic',
|
||||||
'ai_api_key_openai',
|
'ai_api_key_openai',
|
||||||
'ai_api_key_openrouter',
|
'ai_api_key_openrouter',
|
||||||
'telegram.token',
|
# `telegram.bot_token` — was previously listed as `telegram.token`,
|
||||||
|
# which never matched the real config_key (`bot_token` in
|
||||||
|
# CHANNEL_TYPES). The mismatch silently sent Telegram bot tokens
|
||||||
|
# in cleartext on every GET /api/notifications/settings since the
|
||||||
|
# masking layer was introduced. Fixed alongside the eye-reveal
|
||||||
|
# endpoint so the operator can still inspect the value on demand.
|
||||||
|
'telegram.bot_token',
|
||||||
'gotify.token',
|
'gotify.token',
|
||||||
'discord.webhook_url',
|
'discord.webhook_url',
|
||||||
'email.password',
|
'email.password',
|
||||||
|
'apprise.url',
|
||||||
'webhook_secret',
|
'webhook_secret',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,7 +940,20 @@ class NotificationManager:
|
|||||||
def start(self):
|
def start(self):
|
||||||
"""Start the notification service in server mode.
|
"""Start the notification service in server mode.
|
||||||
|
|
||||||
Launches watchers and dispatch loop as daemon threads.
|
Detection (the polling collector + watchers) ALWAYS runs because
|
||||||
|
the managed_installs registry — NVIDIA, ProxMenux, Coral, OCI
|
||||||
|
updates — drives the dashboard's "update available" UI even when
|
||||||
|
notifications are off. Caught on .89 in June 2026: the user had
|
||||||
|
disabled notifications back in May and the NVIDIA card was stuck
|
||||||
|
on a stale "v580.159.03 available" because the polling collector
|
||||||
|
was gated behind `self._enabled` here and never ran again.
|
||||||
|
|
||||||
|
Notification *delivery* (channel setup, cooldown reset, PVE
|
||||||
|
webhook, dispatch loop emitting events) stays conditional on
|
||||||
|
`self._enabled` — the dispatch loop itself bails early when
|
||||||
|
disabled, so events from the watchers queue up briefly and get
|
||||||
|
dropped without ever being sent.
|
||||||
|
|
||||||
Called by flask_server.py on startup.
|
Called by flask_server.py on startup.
|
||||||
"""
|
"""
|
||||||
if self._running:
|
if self._running:
|
||||||
@@ -942,13 +962,34 @@ class NotificationManager:
|
|||||||
self._load_config()
|
self._load_config()
|
||||||
self._load_cooldowns_from_db()
|
self._load_cooldowns_from_db()
|
||||||
|
|
||||||
if not self._enabled:
|
|
||||||
print("[NotificationManager] Service is disabled. Skipping start.")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._running = True
|
self._running = True
|
||||||
self._stats['started_at'] = datetime.now().isoformat()
|
self._stats['started_at'] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# ── Detection (always on) ────────────────────────────────
|
||||||
|
# Even when notifications are disabled, these watchers and the
|
||||||
|
# polling collector keep the managed_installs registry, the
|
||||||
|
# error history, and the task state up to date.
|
||||||
|
self._journal_watcher = JournalWatcher(self._event_queue)
|
||||||
|
self._task_watcher = TaskWatcher(self._event_queue)
|
||||||
|
self._polling_collector = PollingCollector(self._event_queue)
|
||||||
|
|
||||||
|
self._journal_watcher.start()
|
||||||
|
self._task_watcher.start()
|
||||||
|
self._polling_collector.start()
|
||||||
|
|
||||||
|
# Dispatch loop runs unconditionally too; its internal
|
||||||
|
# `if not self._enabled` guards drop events when disabled.
|
||||||
|
# Without it the event queue would grow forever.
|
||||||
|
self._dispatch_thread = threading.Thread(
|
||||||
|
target=self._dispatch_loop, daemon=True, name='notification-dispatch'
|
||||||
|
)
|
||||||
|
self._dispatch_thread.start()
|
||||||
|
|
||||||
|
if not self._enabled:
|
||||||
|
print("[NotificationManager] Notifications disabled — detection on, delivery off.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Delivery setup (only when enabled) ────────────────────
|
||||||
# Reset cooldowns for the curated event-type set so the user gets
|
# Reset cooldowns for the curated event-type set so the user gets
|
||||||
# a fresh status report (update_summary, …) and a fresh security
|
# a fresh status report (update_summary, …) and a fresh security
|
||||||
# signal (auth_fail) after every Monitor deploy/restart. The 24h
|
# signal (auth_fail) after every Monitor deploy/restart. The 24h
|
||||||
@@ -972,21 +1013,6 @@ class NotificationManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[NotificationManager] PVE webhook setup error: {e}")
|
print(f"[NotificationManager] PVE webhook setup error: {e}")
|
||||||
|
|
||||||
# Start event watchers
|
|
||||||
self._journal_watcher = JournalWatcher(self._event_queue)
|
|
||||||
self._task_watcher = TaskWatcher(self._event_queue)
|
|
||||||
self._polling_collector = PollingCollector(self._event_queue)
|
|
||||||
|
|
||||||
self._journal_watcher.start()
|
|
||||||
self._task_watcher.start()
|
|
||||||
self._polling_collector.start()
|
|
||||||
|
|
||||||
# Start dispatch loop
|
|
||||||
self._dispatch_thread = threading.Thread(
|
|
||||||
target=self._dispatch_loop, daemon=True, name='notification-dispatch'
|
|
||||||
)
|
|
||||||
self._dispatch_thread.start()
|
|
||||||
|
|
||||||
print(f"[NotificationManager] Started with channels: {list(self._channels.keys())}")
|
print(f"[NotificationManager] Started with channels: {list(self._channels.keys())}")
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
@@ -1514,7 +1540,22 @@ class NotificationManager:
|
|||||||
def _flush_digest_for_channel(self, ch_name: str, channel: Any,
|
def _flush_digest_for_channel(self, ch_name: str, channel: Any,
|
||||||
now: datetime) -> None:
|
now: datetime) -> None:
|
||||||
"""Read pending rows for the channel, render a grouped summary,
|
"""Read pending rows for the channel, render a grouped summary,
|
||||||
send it, and delete the buffer entries on success."""
|
send it, and delete the buffer entries on success.
|
||||||
|
|
||||||
|
Every path through here records a `digest` entry in the
|
||||||
|
notification history (success or fail, empty buffer or not) and
|
||||||
|
bumps `_stats['total_sent']` / `total_errors` accordingly — the
|
||||||
|
rest of the notification system does this in
|
||||||
|
`_dispatch_to_channels`, and skipping it here was the reason the
|
||||||
|
operator's `/api/notifications/history` and `total_sent` counter
|
||||||
|
showed zero digest entries even when the schedule was firing
|
||||||
|
(issue #233).
|
||||||
|
"""
|
||||||
|
host = _hostname(self._config)
|
||||||
|
summary_title = (
|
||||||
|
f"{host}: 24h summary ({now.strftime('%Y-%m-%d %H:%M')})"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||||
conn.execute('PRAGMA journal_mode=WAL')
|
conn.execute('PRAGMA journal_mode=WAL')
|
||||||
@@ -1528,6 +1569,12 @@ class NotificationManager:
|
|||||||
conn.close()
|
conn.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[NotificationManager] digest read failed for {ch_name}: {e}")
|
print(f"[NotificationManager] digest read failed for {ch_name}: {e}")
|
||||||
|
self._record_history(
|
||||||
|
'digest', ch_name, summary_title,
|
||||||
|
f'digest read failed: {e}', 'INFO',
|
||||||
|
False, str(e), 'digest_scheduler',
|
||||||
|
)
|
||||||
|
self._stats['total_errors'] += 1
|
||||||
return
|
return
|
||||||
|
|
||||||
# Mark `last_at` even if there's nothing to send — otherwise an
|
# Mark `last_at` even if there's nothing to send — otherwise an
|
||||||
@@ -1535,24 +1582,46 @@ class NotificationManager:
|
|||||||
self._save_setting(f'{ch_name}.digest_last_at', now.isoformat())
|
self._save_setting(f'{ch_name}.digest_last_at', now.isoformat())
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
|
# Empty digest: don't ping the channel (no point in sending
|
||||||
|
# "nothing to report"), but DO log the run in history so the
|
||||||
|
# operator can see the schedule fired. Without this the digest
|
||||||
|
# looked dead silent when in fact it was working — there was
|
||||||
|
# just nothing INFO non-exempt to summarize.
|
||||||
|
self._record_history(
|
||||||
|
'digest', ch_name, summary_title,
|
||||||
|
'No INFO events buffered for this digest window.',
|
||||||
|
'INFO', True, '', 'digest_scheduler',
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
host = _hostname(self._config)
|
|
||||||
summary_title = (
|
|
||||||
f"{host}: 24h summary ({now.strftime('%Y-%m-%d %H:%M')})"
|
|
||||||
)
|
|
||||||
summary_body = self._compose_digest_body(rows)
|
summary_body = self._compose_digest_body(rows)
|
||||||
|
|
||||||
|
result: dict = {'success': False, 'error': ''}
|
||||||
try:
|
try:
|
||||||
channel.send(summary_title, summary_body, severity='INFO',
|
result = channel.send(summary_title, summary_body, severity='INFO',
|
||||||
data={'_digest': True, '_count': len(rows)})
|
data={'_digest': True, '_count': len(rows)}) or result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[NotificationManager] digest send failed for "
|
print(f"[NotificationManager] digest send failed for "
|
||||||
f"{ch_name}: {e}")
|
f"{ch_name}: {e}")
|
||||||
return
|
result = {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
self._stats['total_sent'] += 1
|
||||||
|
self._stats['last_sent_at'] = datetime.now().isoformat()
|
||||||
|
else:
|
||||||
|
self._stats['total_errors'] += 1
|
||||||
|
self._record_history(
|
||||||
|
'digest', ch_name, summary_title, summary_body, 'INFO',
|
||||||
|
result.get('success', False), result.get('error', '') or '',
|
||||||
|
'digest_scheduler',
|
||||||
|
)
|
||||||
|
|
||||||
# Delete only after a successful send so a transient failure
|
# Delete only after a successful send so a transient failure
|
||||||
# doesn't lose the day's data.
|
# doesn't lose the day's data. The pre-fix version deleted
|
||||||
|
# unconditionally as long as `channel.send` didn't raise — a
|
||||||
|
# silent `{'success': False}` would still wipe the buffer.
|
||||||
|
if not result.get('success'):
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
ids = [r[0] for r in rows]
|
ids = [r[0] for r in rows]
|
||||||
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||||
@@ -2720,7 +2789,7 @@ class NotificationManager:
|
|||||||
# injection lands in the system prompt verbatim. Audit Tier 3.2 #4.
|
# injection lands in the system prompt verbatim. Audit Tier 3.2 #4.
|
||||||
_ALLOWED_DETAIL_LEVELS = ('brief', 'standard', 'detailed')
|
_ALLOWED_DETAIL_LEVELS = ('brief', 'standard', 'detailed')
|
||||||
_ALLOWED_AI_LANGUAGES = (
|
_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',
|
'ja', 'zh', 'ko', 'pl', 'nl', 'tr', 'ar',
|
||||||
)
|
)
|
||||||
if short_key.endswith('.ai_detail_level') or short_key == 'ai_detail_level':
|
if short_key.endswith('.ai_detail_level') or short_key == 'ai_detail_level':
|
||||||
|
|||||||
@@ -448,35 +448,44 @@ TEMPLATES = {
|
|||||||
# status oscillation (OK->WARNING->OK) which creates noise.
|
# status oscillation (OK->WARNING->OK) which creates noise.
|
||||||
# The health_persistent and new_error templates cover this better.
|
# The health_persistent and new_error templates cover this better.
|
||||||
'state_change': {
|
'state_change': {
|
||||||
'title': '{hostname}: {category} changed to {current}',
|
'title': '{hostname}: {category} changed to {current}{entity_suffix}',
|
||||||
'body': '{category} status changed from {previous} to {current}.\n{reason}',
|
'body': '{category} status changed from {previous} to {current}.\n{reason}',
|
||||||
'label': 'Health state changed',
|
'label': 'Health state changed',
|
||||||
'group': 'health',
|
'group': 'health',
|
||||||
'default_enabled': False,
|
'default_enabled': False,
|
||||||
},
|
},
|
||||||
'new_error': {
|
'new_error': {
|
||||||
'title': '{hostname}: New {severity} - {category}',
|
'title': '{hostname}: New {severity} - {category}{entity_suffix}',
|
||||||
'body': '{reason}',
|
'body': '{reason}',
|
||||||
'label': 'New health issue',
|
'label': 'New health issue',
|
||||||
'group': 'health',
|
'group': 'health',
|
||||||
'default_enabled': True,
|
'default_enabled': True,
|
||||||
},
|
},
|
||||||
'error_resolved': {
|
'error_resolved': {
|
||||||
'title': '{hostname}: Resolved - {category}',
|
# `{entity}` is populated by health_persistence.resolve_error()
|
||||||
|
# (via _entity_from_details) and by PollingCollector's spread of
|
||||||
|
# the original details blob. When absent, _SafeDict elides the
|
||||||
|
# placeholder and the title collapses back to "Resolved - <cat>"
|
||||||
|
# without a trailing dash.
|
||||||
|
'title': '{hostname}: Resolved - {category}{entity_suffix}',
|
||||||
'body': 'The {category} issue has been resolved.\n{reason}\n\U0001F6A6 Previous severity: {original_severity}\n\u23F1\uFE0F Duration: {duration}',
|
'body': 'The {category} issue has been resolved.\n{reason}\n\U0001F6A6 Previous severity: {original_severity}\n\u23F1\uFE0F Duration: {duration}',
|
||||||
'label': 'Recovery notification',
|
'label': 'Recovery notification',
|
||||||
'group': 'health',
|
'group': 'health',
|
||||||
'default_enabled': True,
|
'default_enabled': True,
|
||||||
},
|
},
|
||||||
'error_escalated': {
|
'error_escalated': {
|
||||||
'title': '{hostname}: Escalated to {severity} - {category}',
|
'title': '{hostname}: Escalated to {severity} - {category}{entity_suffix}',
|
||||||
'body': '{reason}',
|
'body': '{reason}',
|
||||||
'label': 'Health issue escalated',
|
'label': 'Health issue escalated',
|
||||||
'group': 'health',
|
'group': 'health',
|
||||||
'default_enabled': True,
|
'default_enabled': True,
|
||||||
},
|
},
|
||||||
'health_degraded': {
|
'health_degraded': {
|
||||||
'title': '{hostname}: Health check degraded',
|
# flask_server._health_collector already builds the rich title
|
||||||
|
# (e.g. "prox: Storage CRITICAL — Tuxis (dir)") and passes it in
|
||||||
|
# data['title']. Fall back to the constant when absent so hand-
|
||||||
|
# rolled callers keep working.
|
||||||
|
'title': '{title_or_default}',
|
||||||
'body': '{reason}',
|
'body': '{reason}',
|
||||||
'label': 'Health check degraded',
|
'label': 'Health check degraded',
|
||||||
'group': 'health',
|
'group': 'health',
|
||||||
@@ -637,26 +646,57 @@ TEMPLATES = {
|
|||||||
'default_enabled': False,
|
'default_enabled': False,
|
||||||
},
|
},
|
||||||
'backup_complete': {
|
'backup_complete': {
|
||||||
'title': '{hostname}: Backup complete — {vmname} ({vmid})',
|
'title': '{hostname} → {storage}: Backup complete — {vmname} ({vmid})',
|
||||||
'body': 'Backup of {vmname} (ID: {vmid}) completed successfully.\nSize: {size}',
|
'body': 'Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}',
|
||||||
'label': 'Backup complete',
|
'label': 'Backup complete',
|
||||||
'group': 'backup',
|
'group': 'backup',
|
||||||
'default_enabled': True,
|
'default_enabled': True,
|
||||||
},
|
},
|
||||||
'backup_warning': {
|
'backup_warning': {
|
||||||
'title': '{hostname}: Backup complete with warnings — {vmname} ({vmid})',
|
'title': '{hostname} → {storage}: Backup complete with warnings — {vmname} ({vmid})',
|
||||||
'body': 'Backup of {vmname} (ID: {vmid}) completed but encountered warnings.\nWarnings: {reason}',
|
'body': 'Backup of {vmname} (ID: {vmid}) on {storage} completed but encountered warnings.\nWarnings: {reason}',
|
||||||
'label': 'Backup (warnings)',
|
'label': 'Backup (warnings)',
|
||||||
'group': 'backup',
|
'group': 'backup',
|
||||||
'default_enabled': True,
|
'default_enabled': True,
|
||||||
},
|
},
|
||||||
'backup_fail': {
|
'backup_fail': {
|
||||||
'title': '{hostname}: Backup FAILED — {vmname} ({vmid})',
|
'title': '{hostname} → {storage}: Backup FAILED — {vmname} ({vmid})',
|
||||||
'body': 'Backup of {vmname} (ID: {vmid}) failed.\nReason: {reason}',
|
'body': 'Backup of {vmname} (ID: {vmid}) failed on {storage}.\nReason: {reason}',
|
||||||
'label': 'Backup FAILED',
|
'label': 'Backup FAILED',
|
||||||
'group': 'backup',
|
'group': 'backup',
|
||||||
'default_enabled': True,
|
'default_enabled': True,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# ── ProxMenux Host Backup events ──
|
||||||
|
# Distinct event types from `backup_*` (which are for vzdump VM/CT
|
||||||
|
# backups). The runner — both `run_scheduled_backup.sh` (timer) and
|
||||||
|
# the manual flow from `backup_host.sh` — POSTs to the local webhook
|
||||||
|
# with these explicit pve_type values, so the operator can toggle
|
||||||
|
# Host Backup notifications separately from VM/CT backup ones.
|
||||||
|
# Backend label (PBS / local / Borg) is highlighted in the title so
|
||||||
|
# the operator can tell at a glance where the data went.
|
||||||
|
'host_backup_start': {
|
||||||
|
'title': '{hostname}: Host backup started → {backend_label}',
|
||||||
|
'body': 'Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nProfile: {profile_mode}',
|
||||||
|
'label': 'Host backup started',
|
||||||
|
'group': 'backup',
|
||||||
|
'default_enabled': False,
|
||||||
|
},
|
||||||
|
'host_backup_complete': {
|
||||||
|
'title': '{hostname}: Host backup complete → {backend_label}',
|
||||||
|
'body': 'Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nData size: {data_size}\nArchive size: {archive_size}\nDuration: {duration}',
|
||||||
|
'label': 'Host backup complete',
|
||||||
|
'group': 'backup',
|
||||||
|
'default_enabled': True,
|
||||||
|
},
|
||||||
|
'host_backup_fail': {
|
||||||
|
'title': '{hostname}: Host backup FAILED → {backend_label}',
|
||||||
|
'body': 'Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nDuration before failure: {duration}\nReason: {reason}\nLog: {log_file}',
|
||||||
|
'label': 'Host backup FAILED',
|
||||||
|
'group': 'backup',
|
||||||
|
'default_enabled': True,
|
||||||
|
},
|
||||||
|
|
||||||
'snapshot_complete': {
|
'snapshot_complete': {
|
||||||
'title': '{hostname}: Snapshot created — {vmname} ({vmid})',
|
'title': '{hostname}: Snapshot created — {vmname} ({vmid})',
|
||||||
'body': 'Snapshot "{snapshot_name}" created for {vmname} (ID: {vmid}).',
|
'body': 'Snapshot "{snapshot_name}" created for {vmname} (ID: {vmid}).',
|
||||||
@@ -756,7 +796,7 @@ TEMPLATES = {
|
|||||||
'default_enabled': True,
|
'default_enabled': True,
|
||||||
},
|
},
|
||||||
'pci_passthrough_conflict': {
|
'pci_passthrough_conflict': {
|
||||||
'title': '{hostname}: PCIe device conflict detected',
|
'title': '{hostname}: PCIe device conflict detected — {device_pci}',
|
||||||
'body': (
|
'body': (
|
||||||
'A PCIe device is assigned to multiple guests.\n'
|
'A PCIe device is assigned to multiple guests.\n'
|
||||||
'Device: {device_pci}\n'
|
'Device: {device_pci}\n'
|
||||||
@@ -778,7 +818,7 @@ TEMPLATES = {
|
|||||||
|
|
||||||
# ── Network events ──
|
# ── Network events ──
|
||||||
'network_down': {
|
'network_down': {
|
||||||
'title': '{hostname}: Network connectivity lost',
|
'title': '{hostname}: Network connectivity lost{entity_suffix}',
|
||||||
'body': 'The node has lost network connectivity.\nReason: {reason}',
|
'body': 'The node has lost network connectivity.\nReason: {reason}',
|
||||||
'label': 'Network connectivity lost',
|
'label': 'Network connectivity lost',
|
||||||
'group': 'network',
|
'group': 'network',
|
||||||
@@ -808,7 +848,7 @@ TEMPLATES = {
|
|||||||
'default_enabled': True,
|
'default_enabled': True,
|
||||||
},
|
},
|
||||||
'firewall_issue': {
|
'firewall_issue': {
|
||||||
'title': '{hostname}: Firewall issue detected',
|
'title': '{hostname}: Firewall issue detected{entity_suffix}',
|
||||||
'body': 'A firewall configuration issue has been detected.\nReason: {reason}',
|
'body': 'A firewall configuration issue has been detected.\nReason: {reason}',
|
||||||
'label': 'Firewall issue detected',
|
'label': 'Firewall issue detected',
|
||||||
'group': 'security',
|
'group': 'security',
|
||||||
@@ -868,8 +908,24 @@ TEMPLATES = {
|
|||||||
'group': 'services',
|
'group': 'services',
|
||||||
'default_enabled': True,
|
'default_enabled': True,
|
||||||
},
|
},
|
||||||
|
'system_restore_completed': {
|
||||||
|
'title': '{hostname}: Host restore finished',
|
||||||
|
'body': (
|
||||||
|
'Post-restore tasks completed in background.\n\n'
|
||||||
|
'Guests applied: {guests}\n'
|
||||||
|
'Bind-mount stubs: {stubs}\n'
|
||||||
|
'Stale node dirs removed: {stale_nodes}\n'
|
||||||
|
'Components reinstalled: {components}\n'
|
||||||
|
'Duration: {duration}\n'
|
||||||
|
'{warnings_block}\n'
|
||||||
|
'The node is now fully ready to use.'
|
||||||
|
),
|
||||||
|
'label': 'Host restore completed',
|
||||||
|
'group': 'services',
|
||||||
|
'default_enabled': True,
|
||||||
|
},
|
||||||
'system_problem': {
|
'system_problem': {
|
||||||
'title': '{hostname}: System problem detected',
|
'title': '{hostname}: System problem detected{entity_suffix}',
|
||||||
'body': 'A system-level problem has been detected.\nReason: {reason}',
|
'body': 'A system-level problem has been detected.\nReason: {reason}',
|
||||||
'label': 'System problem detected',
|
'label': 'System problem detected',
|
||||||
'group': 'services',
|
'group': 'services',
|
||||||
@@ -892,7 +948,7 @@ TEMPLATES = {
|
|||||||
|
|
||||||
# ── Hidden internal templates (not shown in UI) ──
|
# ── Hidden internal templates (not shown in UI) ──
|
||||||
'service_fail_batch': {
|
'service_fail_batch': {
|
||||||
'title': '{hostname}: {service_count} services failed',
|
'title': '{hostname}: {service_count} services failed{entity_suffix}',
|
||||||
'body': '{reason}',
|
'body': '{reason}',
|
||||||
'label': 'Service fail batch',
|
'label': 'Service fail batch',
|
||||||
'group': 'services',
|
'group': 'services',
|
||||||
@@ -957,7 +1013,7 @@ TEMPLATES = {
|
|||||||
'hidden': True,
|
'hidden': True,
|
||||||
},
|
},
|
||||||
'unknown_persistent': {
|
'unknown_persistent': {
|
||||||
'title': '{hostname}: Check unavailable - {category}',
|
'title': '{hostname}: Check unavailable - {category}{entity_suffix}',
|
||||||
'body': 'Health check for {category} has been unavailable for 3+ cycles.\n{reason}',
|
'body': 'Health check for {category} has been unavailable for 3+ cycles.\n{reason}',
|
||||||
'label': 'Check unavailable',
|
'label': 'Check unavailable',
|
||||||
'group': 'health',
|
'group': 'health',
|
||||||
@@ -967,21 +1023,21 @@ TEMPLATES = {
|
|||||||
|
|
||||||
# ── Health Monitor events ──
|
# ── Health Monitor events ──
|
||||||
'health_persistent': {
|
'health_persistent': {
|
||||||
'title': '{hostname}: {count} active health issue(s)',
|
'title': '{hostname}: {count} active health issue(s){entity_suffix}',
|
||||||
'body': 'The following health issues remain unresolved:\n{issue_list}\n\nThis digest is sent once every 24 hours while issues persist.',
|
'body': 'The following health issues remain unresolved:\n{issue_list}\n\nThis digest is sent once every 24 hours while issues persist.',
|
||||||
'label': 'Active health issues (daily)',
|
'label': 'Active health issues (daily)',
|
||||||
'group': 'health',
|
'group': 'health',
|
||||||
'default_enabled': True,
|
'default_enabled': True,
|
||||||
},
|
},
|
||||||
'health_issue_new': {
|
'health_issue_new': {
|
||||||
'title': '{hostname}: New health issue — {category}',
|
'title': '{hostname}: New health issue — {category}{entity_suffix}',
|
||||||
'body': 'New {severity} issue detected in: {category}\nDetails: {reason}',
|
'body': 'New {severity} issue detected in: {category}\nDetails: {reason}',
|
||||||
'label': 'New health issue',
|
'label': 'New health issue',
|
||||||
'group': 'health',
|
'group': 'health',
|
||||||
'default_enabled': True,
|
'default_enabled': True,
|
||||||
},
|
},
|
||||||
'health_issue_resolved': {
|
'health_issue_resolved': {
|
||||||
'title': '{hostname}: Resolved - {category}',
|
'title': '{hostname}: Resolved - {category}{entity_suffix}',
|
||||||
'body': '{category} issue has been resolved.\n{reason}\nDuration: {duration}',
|
'body': '{category} issue has been resolved.\n{reason}\nDuration: {duration}',
|
||||||
'label': 'Health issue resolved',
|
'label': 'Health issue resolved',
|
||||||
'group': 'health',
|
'group': 'health',
|
||||||
@@ -1091,7 +1147,7 @@ TEMPLATES = {
|
|||||||
'title': '{hostname}: CT {vmid} rootfs at {usage_percent}%',
|
'title': '{hostname}: CT {vmid} rootfs at {usage_percent}%',
|
||||||
'body': (
|
'body': (
|
||||||
'CT {vmid} ({name}) rootfs is at {usage_percent}% '
|
'CT {vmid} ({name}) rootfs is at {usage_percent}% '
|
||||||
'({disk_bytes} / {maxdisk_bytes}).\n\n'
|
'({disk_bytes_human} / {maxdisk_bytes_human}).\n\n'
|
||||||
'A full LXC rootfs prevents the container from booting cleanly. '
|
'A full LXC rootfs prevents the container from booting cleanly. '
|
||||||
'Either expand the rootfs (pct resize {vmid} rootfs +1G) or free '
|
'Either expand the rootfs (pct resize {vmid} rootfs +1G) or free '
|
||||||
'space inside the container.'
|
'space inside the container.'
|
||||||
@@ -1101,6 +1157,27 @@ TEMPLATES = {
|
|||||||
'default_enabled': True,
|
'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) ─────────────────────────
|
# ── Phase 3 capacity events (Sprint 14.5) ─────────────────────────
|
||||||
# Three new events that complete the storage-monitoring picture.
|
# Three new events that complete the storage-monitoring picture.
|
||||||
# Each fires at the user-configured warning/critical thresholds
|
# Each fires at the user-configured warning/critical thresholds
|
||||||
@@ -1177,11 +1254,19 @@ TEMPLATES = {
|
|||||||
# bullet list make sure the operator sees exactly what's moving
|
# bullet list make sure the operator sees exactly what's moving
|
||||||
# without opening the dashboard first.
|
# without opening the dashboard first.
|
||||||
'secure_gateway_update_available': {
|
'secure_gateway_update_available': {
|
||||||
'title': '{hostname}: {app_name} update available — v{latest_version}',
|
# `{update_title_suffix}` and `{version_line}` are computed in
|
||||||
|
# `_build_managed_install_event` so the same template can render
|
||||||
|
# both scenarios cleanly:
|
||||||
|
# - Tailscale itself moved: "— v<new>" + "current → latest" line
|
||||||
|
# - Only sidecar packages moved: "— v<current> (packages only)" +
|
||||||
|
# single-line "Tailscale unchanged" body
|
||||||
|
# Prevents the confusing "v1.90.9-r6 → v1.90.9-r6" render when
|
||||||
|
# Tailscale is stable and only alpine libs are updating.
|
||||||
|
'title': '{hostname}: {app_name} update available{update_title_suffix}',
|
||||||
'body': (
|
'body': (
|
||||||
'{app_name} (managed by ProxMenux) has 📦 {package_count} package update(s) '
|
'{app_name} (managed by ProxMenux) has 📦 {package_count} package update(s) '
|
||||||
'pending in its container.\n'
|
'pending in its container.\n'
|
||||||
'🔹 Current Tailscale: v{current_version} → 🟢 Latest: v{latest_version}\n\n'
|
'{version_line}\n\n'
|
||||||
'💡 Open ProxMenux Monitor > Settings > Secure Gateway and click '
|
'💡 Open ProxMenux Monitor > Settings > Secure Gateway and click '
|
||||||
'"Update" to apply.\n\n'
|
'"Update" to apply.\n\n'
|
||||||
'🗂️ Packages:\n{package_list}'
|
'🗂️ Packages:\n{package_list}'
|
||||||
@@ -1338,6 +1423,28 @@ def _get_hostname() -> str:
|
|||||||
return 'proxmox'
|
return 'proxmox'
|
||||||
|
|
||||||
|
|
||||||
|
def _format_bytes_human(n: Any) -> str:
|
||||||
|
"""Compact human-readable size (base 1024): '35.3 GiB', '4.8 TiB'.
|
||||||
|
|
||||||
|
Matches what `df -h` and the Proxmox UI show, so operators reading a
|
||||||
|
notification aren't asked to translate a 12-digit byte count.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
size = float(n)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return ''
|
||||||
|
if size <= 0:
|
||||||
|
return '0 B'
|
||||||
|
units = ('B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB')
|
||||||
|
i = 0
|
||||||
|
while size >= 1024.0 and i < len(units) - 1:
|
||||||
|
size /= 1024.0
|
||||||
|
i += 1
|
||||||
|
if i == 0:
|
||||||
|
return f'{int(size)} B'
|
||||||
|
return f'{size:.1f} {units[i]}'
|
||||||
|
|
||||||
|
|
||||||
def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Render a template into a structured notification object.
|
"""Render a template into a structured notification object.
|
||||||
|
|
||||||
@@ -1381,13 +1488,55 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
'issue_list': '', 'error_key': '',
|
'issue_list': '', 'error_key': '',
|
||||||
'storage_name': '', 'storage_type': '',
|
'storage_name': '', 'storage_type': '',
|
||||||
'important_list': 'none',
|
'important_list': 'none',
|
||||||
|
# Host Backup specifics (run_scheduled_backup.sh + backup_host.sh).
|
||||||
|
'job_id': '', 'backend': '', 'backend_label': '',
|
||||||
|
'destination': '', 'profile_mode': '',
|
||||||
|
'data_size': '', 'archive_size': '',
|
||||||
|
'log_file': '',
|
||||||
}
|
}
|
||||||
variables.update(data)
|
variables.update(data)
|
||||||
|
|
||||||
|
# Humanise raw-byte fields so templates can render '35.3 GiB' instead
|
||||||
|
# of '37952020480'. Producers keep emitting raw ints (needed by APIs
|
||||||
|
# and the dashboard); the humanised twin is derived on the fly here.
|
||||||
|
for _byte_key in ('disk_bytes', 'maxdisk_bytes'):
|
||||||
|
if _byte_key in data:
|
||||||
|
variables[f'{_byte_key}_human'] = _format_bytes_human(data[_byte_key])
|
||||||
|
|
||||||
# Ensure important_list is never blank (fallback to 'none')
|
# Ensure important_list is never blank (fallback to 'none')
|
||||||
if not variables.get('important_list', '').strip():
|
if not variables.get('important_list', '').strip():
|
||||||
variables['important_list'] = 'none'
|
variables['important_list'] = 'none'
|
||||||
|
|
||||||
|
# Derive the affected object's display name for titles that use it.
|
||||||
|
# Priority: caller-supplied `entity` (health_monitor.emit_event) →
|
||||||
|
# per-detail fields spread in from `record_error`'s details blob.
|
||||||
|
# `entity_suffix` gives templates a way to render "…- Storage 'Tuxis'"
|
||||||
|
# without the trailing " - " when the field is empty.
|
||||||
|
_ent = str(variables.get('entity', '')).strip()
|
||||||
|
if not _ent:
|
||||||
|
_ent = (
|
||||||
|
variables.get('storage_name')
|
||||||
|
or variables.get('mount_point')
|
||||||
|
or variables.get('device')
|
||||||
|
or variables.get('interface')
|
||||||
|
or variables.get('vm_name')
|
||||||
|
or ''
|
||||||
|
)
|
||||||
|
_ent = str(_ent).strip()
|
||||||
|
variables['entity'] = _ent
|
||||||
|
variables['entity_suffix'] = f' — {_ent}' if _ent else ''
|
||||||
|
|
||||||
|
# `title_or_default` lets a template surface a caller-computed title
|
||||||
|
# when the caller already knows the entity context (e.g.
|
||||||
|
# flask_server's health collector), and fall back to a stock string
|
||||||
|
# otherwise. Prevents empty subject lines when the caller forgot to
|
||||||
|
# populate `title`.
|
||||||
|
_caller_title = str(variables.get('title', '')).strip()
|
||||||
|
if not _caller_title:
|
||||||
|
hn = variables.get('hostname', '')
|
||||||
|
_caller_title = f'{hn}: Health check degraded' if hn else 'Health check degraded'
|
||||||
|
variables['title_or_default'] = _caller_title
|
||||||
|
|
||||||
# `format_map` with a SafeDict avoids the KeyError → "show raw template
|
# `format_map` with a SafeDict avoids the KeyError → "show raw template
|
||||||
# with `{placeholder}` literal" failure mode. If a template gets a new
|
# with `{placeholder}` literal" failure mode. If a template gets a new
|
||||||
# field that nobody populated in `data`/`variables`, the user sees the
|
# field that nobody populated in `data`/`variables`, the user sees the
|
||||||
@@ -1567,6 +1716,9 @@ EVENT_EMOJI = {
|
|||||||
'replication_complete': '\u2705',
|
'replication_complete': '\u2705',
|
||||||
# Backups
|
# Backups
|
||||||
'backup_start': '\U0001F4BE\U0001F680', # 💾🚀 floppy + rocket
|
'backup_start': '\U0001F4BE\U0001F680', # 💾🚀 floppy + rocket
|
||||||
|
'host_backup_start': '\U0001F5C4️\U0001F680', # 🗄️🚀 cabinet + rocket
|
||||||
|
'host_backup_complete': '\U0001F5C4️✅', # 🗄️✅ cabinet + check
|
||||||
|
'host_backup_fail': '\U0001F5C4️❌', # 🗄️❌ cabinet + cross
|
||||||
'backup_complete': '\U0001F4BE\u2705', # 💾✅ floppy + check
|
'backup_complete': '\U0001F4BE\u2705', # 💾✅ floppy + check
|
||||||
'backup_warning': '\U0001F4BE\u26A0\uFE0F', # 💾⚠️ floppy + warning
|
'backup_warning': '\U0001F4BE\u26A0\uFE0F', # 💾⚠️ floppy + warning
|
||||||
'backup_fail': '\U0001F4BE\u274C', # 💾❌ floppy + cross
|
'backup_fail': '\U0001F4BE\u274C', # 💾❌ floppy + cross
|
||||||
@@ -1585,6 +1737,7 @@ EVENT_EMOJI = {
|
|||||||
'mount_stale': '\U0001F517', # link (broken connection feel)
|
'mount_stale': '\U0001F517', # link (broken connection feel)
|
||||||
'mount_readonly': '\U0001F512', # lock
|
'mount_readonly': '\U0001F512', # lock
|
||||||
'lxc_disk_low': '\U0001F4BE', # floppy disk (near-full)
|
'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
|
'lxc_mount_low': '\U0001F4C2', # 📂 folder near-full
|
||||||
'pve_storage_full': '\U0001F4E6', # 📦 package (running out)
|
'pve_storage_full': '\U0001F4E6', # 📦 package (running out)
|
||||||
'zfs_pool_full': '\U0001F30A', # 🌊 wave (pool is full)
|
'zfs_pool_full': '\U0001F30A', # 🌊 wave (pool is full)
|
||||||
@@ -1604,6 +1757,7 @@ EVENT_EMOJI = {
|
|||||||
'system_startup': '\U0001F680', # rocket (startup)
|
'system_startup': '\U0001F680', # rocket (startup)
|
||||||
'system_shutdown': '\u23FB\uFE0F', # power symbol (Unicode)
|
'system_shutdown': '\u23FB\uFE0F', # power symbol (Unicode)
|
||||||
'system_reboot': '\U0001F504',
|
'system_reboot': '\U0001F504',
|
||||||
|
'system_restore_completed': '✅', # check mark
|
||||||
'system_problem': '\u26A0\uFE0F',
|
'system_problem': '\u26A0\uFE0F',
|
||||||
'service_fail': '\u274C',
|
'service_fail': '\u274C',
|
||||||
'oom_kill': '\U0001F4A3', # bomb
|
'oom_kill': '\U0001F4A3', # bomb
|
||||||
@@ -1822,6 +1976,7 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
|
|||||||
# Supported languages for AI translation
|
# Supported languages for AI translation
|
||||||
AI_LANGUAGES = {
|
AI_LANGUAGES = {
|
||||||
'en': 'English',
|
'en': 'English',
|
||||||
|
'sk': 'Slovak',
|
||||||
'es': 'Spanish',
|
'es': 'Spanish',
|
||||||
'fr': 'French',
|
'fr': 'French',
|
||||||
'de': 'German',
|
'de': 'German',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user