mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-05 20:32:15 +00:00
Merge develop into feature/usbip-ssh-sharing
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"repository": "MacRimi/ProxMenux",
|
||||
"updatedAt": "2026-09-02T08:48:44.825Z",
|
||||
"updatedAt": "2026-10-26T04:36:49.492Z",
|
||||
"backfilledAt": "2026-07-22T18:09:20.834Z",
|
||||
"totals": {
|
||||
"downloads": 0,
|
||||
"clones": 22221
|
||||
"clones": 59792
|
||||
},
|
||||
"assets": {},
|
||||
"traffic": {
|
||||
@@ -133,6 +133,218 @@
|
||||
"2026-09-01": {
|
||||
"count": 552,
|
||||
"uniques": 396
|
||||
},
|
||||
"2026-09-02": {
|
||||
"count": 1065,
|
||||
"uniques": 726
|
||||
},
|
||||
"2026-09-03": {
|
||||
"count": 1489,
|
||||
"uniques": 770
|
||||
},
|
||||
"2026-09-04": {
|
||||
"count": 938,
|
||||
"uniques": 642
|
||||
},
|
||||
"2026-09-05": {
|
||||
"count": 722,
|
||||
"uniques": 513
|
||||
},
|
||||
"2026-09-06": {
|
||||
"count": 884,
|
||||
"uniques": 445
|
||||
},
|
||||
"2026-09-07": {
|
||||
"count": 655,
|
||||
"uniques": 485
|
||||
},
|
||||
"2026-09-08": {
|
||||
"count": 597,
|
||||
"uniques": 440
|
||||
},
|
||||
"2026-09-09": {
|
||||
"count": 615,
|
||||
"uniques": 431
|
||||
},
|
||||
"2026-09-10": {
|
||||
"count": 542,
|
||||
"uniques": 383
|
||||
},
|
||||
"2026-09-11": {
|
||||
"count": 646,
|
||||
"uniques": 342
|
||||
},
|
||||
"2026-09-12": {
|
||||
"count": 451,
|
||||
"uniques": 336
|
||||
},
|
||||
"2026-09-13": {
|
||||
"count": 482,
|
||||
"uniques": 362
|
||||
},
|
||||
"2026-09-14": {
|
||||
"count": 610,
|
||||
"uniques": 349
|
||||
},
|
||||
"2026-09-15": {
|
||||
"count": 452,
|
||||
"uniques": 343
|
||||
},
|
||||
"2026-09-16": {
|
||||
"count": 651,
|
||||
"uniques": 335
|
||||
},
|
||||
"2026-09-17": {
|
||||
"count": 881,
|
||||
"uniques": 316
|
||||
},
|
||||
"2026-09-18": {
|
||||
"count": 414,
|
||||
"uniques": 295
|
||||
},
|
||||
"2026-09-19": {
|
||||
"count": 701,
|
||||
"uniques": 300
|
||||
},
|
||||
"2026-09-20": {
|
||||
"count": 405,
|
||||
"uniques": 271
|
||||
},
|
||||
"2026-09-21": {
|
||||
"count": 477,
|
||||
"uniques": 303
|
||||
},
|
||||
"2026-09-22": {
|
||||
"count": 587,
|
||||
"uniques": 347
|
||||
},
|
||||
"2026-09-23": {
|
||||
"count": 436,
|
||||
"uniques": 293
|
||||
},
|
||||
"2026-09-24": {
|
||||
"count": 377,
|
||||
"uniques": 276
|
||||
},
|
||||
"2026-09-25": {
|
||||
"count": 526,
|
||||
"uniques": 350
|
||||
},
|
||||
"2026-09-26": {
|
||||
"count": 723,
|
||||
"uniques": 397
|
||||
},
|
||||
"2026-09-27": {
|
||||
"count": 994,
|
||||
"uniques": 620
|
||||
},
|
||||
"2026-09-28": {
|
||||
"count": 1548,
|
||||
"uniques": 1091
|
||||
},
|
||||
"2026-09-29": {
|
||||
"count": 1043,
|
||||
"uniques": 692
|
||||
},
|
||||
"2026-09-30": {
|
||||
"count": 768,
|
||||
"uniques": 504
|
||||
},
|
||||
"2026-10-01": {
|
||||
"count": 661,
|
||||
"uniques": 443
|
||||
},
|
||||
"2026-10-02": {
|
||||
"count": 596,
|
||||
"uniques": 425
|
||||
},
|
||||
"2026-10-03": {
|
||||
"count": 486,
|
||||
"uniques": 372
|
||||
},
|
||||
"2026-10-04": {
|
||||
"count": 1074,
|
||||
"uniques": 333
|
||||
},
|
||||
"2026-10-05": {
|
||||
"count": 1183,
|
||||
"uniques": 366
|
||||
},
|
||||
"2026-10-06": {
|
||||
"count": 648,
|
||||
"uniques": 367
|
||||
},
|
||||
"2026-10-07": {
|
||||
"count": 527,
|
||||
"uniques": 381
|
||||
},
|
||||
"2026-10-08": {
|
||||
"count": 540,
|
||||
"uniques": 352
|
||||
},
|
||||
"2026-10-09": {
|
||||
"count": 548,
|
||||
"uniques": 363
|
||||
},
|
||||
"2026-10-10": {
|
||||
"count": 443,
|
||||
"uniques": 305
|
||||
},
|
||||
"2026-10-11": {
|
||||
"count": 511,
|
||||
"uniques": 305
|
||||
},
|
||||
"2026-10-12": {
|
||||
"count": 520,
|
||||
"uniques": 362
|
||||
},
|
||||
"2026-10-13": {
|
||||
"count": 564,
|
||||
"uniques": 354
|
||||
},
|
||||
"2026-10-14": {
|
||||
"count": 540,
|
||||
"uniques": 372
|
||||
},
|
||||
"2026-10-15": {
|
||||
"count": 1253,
|
||||
"uniques": 429
|
||||
},
|
||||
"2026-10-16": {
|
||||
"count": 751,
|
||||
"uniques": 342
|
||||
},
|
||||
"2026-10-17": {
|
||||
"count": 821,
|
||||
"uniques": 380
|
||||
},
|
||||
"2026-10-18": {
|
||||
"count": 727,
|
||||
"uniques": 348
|
||||
},
|
||||
"2026-10-19": {
|
||||
"count": 712,
|
||||
"uniques": 368
|
||||
},
|
||||
"2026-10-20": {
|
||||
"count": 908,
|
||||
"uniques": 464
|
||||
},
|
||||
"2026-10-21": {
|
||||
"count": 623,
|
||||
"uniques": 406
|
||||
},
|
||||
"2026-10-22": {
|
||||
"count": 755,
|
||||
"uniques": 419
|
||||
},
|
||||
"2026-10-23": {
|
||||
"count": 601,
|
||||
"uniques": 337
|
||||
},
|
||||
"2026-10-24": {
|
||||
"count": 900,
|
||||
"uniques": 316
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3369,7 +3581,385 @@
|
||||
"stars": 2926,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 22221
|
||||
"clones": 23286
|
||||
},
|
||||
{
|
||||
"date": "2026-09-03",
|
||||
"stars": 2927,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 24775
|
||||
},
|
||||
{
|
||||
"date": "2026-09-04",
|
||||
"stars": 2930,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 25713
|
||||
},
|
||||
{
|
||||
"date": "2026-09-05",
|
||||
"stars": 2944,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 26435
|
||||
},
|
||||
{
|
||||
"date": "2026-09-06",
|
||||
"stars": 2954,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 27319
|
||||
},
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"stars": 2959,
|
||||
"forks": 152,
|
||||
"downloads": 0,
|
||||
"clones": 27974
|
||||
},
|
||||
{
|
||||
"date": "2026-09-08",
|
||||
"stars": 2959,
|
||||
"forks": 153,
|
||||
"downloads": 0,
|
||||
"clones": 28571
|
||||
},
|
||||
{
|
||||
"date": "2026-09-09",
|
||||
"stars": 2960,
|
||||
"forks": 153,
|
||||
"downloads": 0,
|
||||
"clones": 29186
|
||||
},
|
||||
{
|
||||
"date": "2026-09-10",
|
||||
"stars": 2961,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 29728
|
||||
},
|
||||
{
|
||||
"date": "2026-09-11",
|
||||
"stars": 2965,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 30374
|
||||
},
|
||||
{
|
||||
"date": "2026-09-12",
|
||||
"stars": 2967,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 30825
|
||||
},
|
||||
{
|
||||
"date": "2026-09-13",
|
||||
"stars": 2971,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 31307
|
||||
},
|
||||
{
|
||||
"date": "2026-09-14",
|
||||
"stars": 2972,
|
||||
"forks": 154,
|
||||
"downloads": 0,
|
||||
"clones": 31917
|
||||
},
|
||||
{
|
||||
"date": "2026-09-15",
|
||||
"stars": 2974,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 32369
|
||||
},
|
||||
{
|
||||
"date": "2026-09-16",
|
||||
"stars": 2974,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 33020
|
||||
},
|
||||
{
|
||||
"date": "2026-09-17",
|
||||
"stars": 2975,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 33901
|
||||
},
|
||||
{
|
||||
"date": "2026-09-18",
|
||||
"stars": 2978,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 34315
|
||||
},
|
||||
{
|
||||
"date": "2026-09-19",
|
||||
"stars": 2981,
|
||||
"forks": 155,
|
||||
"downloads": 0,
|
||||
"clones": 35016
|
||||
},
|
||||
{
|
||||
"date": "2026-09-20",
|
||||
"stars": 2981,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 35421
|
||||
},
|
||||
{
|
||||
"date": "2026-09-21",
|
||||
"stars": 2984,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 35898
|
||||
},
|
||||
{
|
||||
"date": "2026-09-22",
|
||||
"stars": 2986,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 36485
|
||||
},
|
||||
{
|
||||
"date": "2026-09-23",
|
||||
"stars": 2989,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 36921
|
||||
},
|
||||
{
|
||||
"date": "2026-09-24",
|
||||
"stars": 2991,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 37298
|
||||
},
|
||||
{
|
||||
"date": "2026-09-25",
|
||||
"stars": 2993,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 37824
|
||||
},
|
||||
{
|
||||
"date": "2026-09-26",
|
||||
"stars": 2996,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 38547
|
||||
},
|
||||
{
|
||||
"date": "2026-09-27",
|
||||
"stars": 3007,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 39541
|
||||
},
|
||||
{
|
||||
"date": "2026-09-28",
|
||||
"stars": 3041,
|
||||
"forks": 156,
|
||||
"downloads": 0,
|
||||
"clones": 41089
|
||||
},
|
||||
{
|
||||
"date": "2026-09-29",
|
||||
"stars": 3052,
|
||||
"forks": 157,
|
||||
"downloads": 0,
|
||||
"clones": 42132
|
||||
},
|
||||
{
|
||||
"date": "2026-09-30",
|
||||
"stars": 3062,
|
||||
"forks": 157,
|
||||
"downloads": 0,
|
||||
"clones": 42900
|
||||
},
|
||||
{
|
||||
"date": "2026-10-01",
|
||||
"stars": 3067,
|
||||
"forks": 158,
|
||||
"downloads": 0,
|
||||
"clones": 43561
|
||||
},
|
||||
{
|
||||
"date": "2026-10-02",
|
||||
"stars": 3072,
|
||||
"forks": 159,
|
||||
"downloads": 0,
|
||||
"clones": 44157
|
||||
},
|
||||
{
|
||||
"date": "2026-10-03",
|
||||
"stars": 3077,
|
||||
"forks": 159,
|
||||
"downloads": 0,
|
||||
"clones": 44643
|
||||
},
|
||||
{
|
||||
"date": "2026-10-04",
|
||||
"stars": 3082,
|
||||
"forks": 160,
|
||||
"downloads": 0,
|
||||
"clones": 45717
|
||||
},
|
||||
{
|
||||
"date": "2026-10-05",
|
||||
"stars": 3087,
|
||||
"forks": 160,
|
||||
"downloads": 0,
|
||||
"clones": 46900
|
||||
},
|
||||
{
|
||||
"date": "2026-10-06",
|
||||
"stars": 3090,
|
||||
"forks": 161,
|
||||
"downloads": 0,
|
||||
"clones": 47548
|
||||
},
|
||||
{
|
||||
"date": "2026-10-07",
|
||||
"stars": 3103,
|
||||
"forks": 163,
|
||||
"downloads": 0,
|
||||
"clones": 48075
|
||||
},
|
||||
{
|
||||
"date": "2026-10-08",
|
||||
"stars": 3112,
|
||||
"forks": 163,
|
||||
"downloads": 0,
|
||||
"clones": 48615
|
||||
},
|
||||
{
|
||||
"date": "2026-10-09",
|
||||
"stars": 3123,
|
||||
"forks": 165,
|
||||
"downloads": 0,
|
||||
"clones": 49163
|
||||
},
|
||||
{
|
||||
"date": "2026-10-10",
|
||||
"stars": 3128,
|
||||
"forks": 164,
|
||||
"downloads": 0,
|
||||
"clones": 49606
|
||||
},
|
||||
{
|
||||
"date": "2026-10-11",
|
||||
"stars": 3134,
|
||||
"forks": 165,
|
||||
"downloads": 0,
|
||||
"clones": 50117
|
||||
},
|
||||
{
|
||||
"date": "2026-10-12",
|
||||
"stars": 3138,
|
||||
"forks": 168,
|
||||
"downloads": 0,
|
||||
"clones": 50637
|
||||
},
|
||||
{
|
||||
"date": "2026-10-13",
|
||||
"stars": 3141,
|
||||
"forks": 169,
|
||||
"downloads": 0,
|
||||
"clones": 51201
|
||||
},
|
||||
{
|
||||
"date": "2026-10-14",
|
||||
"stars": 3148,
|
||||
"forks": 170,
|
||||
"downloads": 0,
|
||||
"clones": 51741
|
||||
},
|
||||
{
|
||||
"date": "2026-10-15",
|
||||
"stars": 3160,
|
||||
"forks": 171,
|
||||
"downloads": 0,
|
||||
"clones": 52994
|
||||
},
|
||||
{
|
||||
"date": "2026-10-16",
|
||||
"stars": 3166,
|
||||
"forks": 173,
|
||||
"downloads": 0,
|
||||
"clones": 53745
|
||||
},
|
||||
{
|
||||
"date": "2026-10-17",
|
||||
"stars": 3170,
|
||||
"forks": 173,
|
||||
"downloads": 0,
|
||||
"clones": 54566
|
||||
},
|
||||
{
|
||||
"date": "2026-10-18",
|
||||
"stars": 3174,
|
||||
"forks": 174,
|
||||
"downloads": 0,
|
||||
"clones": 55293
|
||||
},
|
||||
{
|
||||
"date": "2026-10-19",
|
||||
"stars": 3180,
|
||||
"forks": 174,
|
||||
"downloads": 0,
|
||||
"clones": 56005
|
||||
},
|
||||
{
|
||||
"date": "2026-10-20",
|
||||
"stars": 3191,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 56913
|
||||
},
|
||||
{
|
||||
"date": "2026-10-21",
|
||||
"stars": 3205,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 57536
|
||||
},
|
||||
{
|
||||
"date": "2026-10-22",
|
||||
"stars": 3214,
|
||||
"forks": 174,
|
||||
"downloads": 0,
|
||||
"clones": 58291
|
||||
},
|
||||
{
|
||||
"date": "2026-10-23",
|
||||
"stars": 3220,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 58892
|
||||
},
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"stars": 3226,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 59792
|
||||
},
|
||||
{
|
||||
"date": "2026-10-25",
|
||||
"stars": 3229,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 59792
|
||||
},
|
||||
{
|
||||
"date": "2026-10-26",
|
||||
"stars": 3234,
|
||||
"forks": 175,
|
||||
"downloads": 0,
|
||||
"clones": 59792
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -102,6 +102,12 @@ PROTECTED_TECHNICAL_TERMS = (
|
||||
"WebUI",
|
||||
"Intel",
|
||||
"CUDA",
|
||||
"ROCm",
|
||||
"Vulkan",
|
||||
"SYCL",
|
||||
"QSV",
|
||||
"OpenVINO",
|
||||
"OpenCL",
|
||||
"KFD",
|
||||
"GPU",
|
||||
"CPU",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Reject foreign ELF files in a native AppDir without executing any binary."""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import struct
|
||||
|
||||
|
||||
def check(directory, architecture):
|
||||
expected = {'x86_64': 62, 'aarch64': 183}[architecture]
|
||||
checked = 0
|
||||
for path in Path(directory).rglob('*'):
|
||||
if path.is_symlink() or not path.is_file():
|
||||
continue
|
||||
with path.open('rb') as stream:
|
||||
header = stream.read(20)
|
||||
if not header.startswith(b'\x7fELF'):
|
||||
continue
|
||||
if len(header) != 20 or header[5] not in (1, 2):
|
||||
raise ValueError(f'Invalid ELF header: {path}')
|
||||
machine = struct.unpack('<H' if header[5] == 1 else '>H', header[18:20])[0]
|
||||
if machine != expected:
|
||||
raise ValueError(f'Foreign ELF machine {machine} in {architecture} AppDir: {path}')
|
||||
checked += 1
|
||||
if not checked:
|
||||
raise ValueError('No native ELF files found in AppDir')
|
||||
return checked
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('directory', type=Path)
|
||||
parser.add_argument('architecture', choices=('x86_64', 'aarch64'))
|
||||
args = parser.parse_args()
|
||||
print(f'PASS: {check(args.directory, args.architecture)} native ELF files')
|
||||
@@ -2,7 +2,8 @@
|
||||
"""Build a verified ProxMenux LXC application-version tracking catalog.
|
||||
|
||||
The source of truth is a pinned snapshot of community-scripts/ProxmoxVE,
|
||||
downloaded through the GitHub REST API. Only ct/*.sh launchers are considered.
|
||||
plus community-scripts/core for its shared version helpers after the split.
|
||||
Both are downloaded through the GitHub REST API. Only ct/*.sh launchers are considered.
|
||||
|
||||
An operational hint is emitted only when two independent pieces of the helper
|
||||
scripts agree:
|
||||
@@ -37,6 +38,7 @@ from typing import Any, Iterable
|
||||
|
||||
|
||||
DEFAULT_REPOSITORY = "community-scripts/ProxmoxVE"
|
||||
DEFAULT_CORE_REPOSITORY = "community-scripts/core"
|
||||
DEFAULT_REF = "main"
|
||||
API_VERSION = "2022-11-28"
|
||||
USER_AGENT = "ProxMenux-app-tracking-catalog/1.0"
|
||||
@@ -169,6 +171,7 @@ def obtain_snapshot(
|
||||
repository: str,
|
||||
ref: str,
|
||||
cache_dir: Path,
|
||||
required_paths: tuple[str, ...] = ("ct",),
|
||||
) -> tuple[Path, str, str]:
|
||||
commit = client.json(f"/repos/{repository}/commits/{ref}")
|
||||
commit_sha = str(commit.get("sha") or "")
|
||||
@@ -177,7 +180,7 @@ def obtain_snapshot(
|
||||
|
||||
snapshot_dir = cache_dir / repository.replace("/", "--") / commit_sha
|
||||
marker = snapshot_dir / ".snapshot-complete"
|
||||
if marker.exists() and (snapshot_dir / "ct").is_dir():
|
||||
if marker.exists() and all((snapshot_dir / path).exists() for path in required_paths):
|
||||
return snapshot_dir, commit_sha, marker.read_text(encoding="utf-8").strip()
|
||||
|
||||
payload = client.request(
|
||||
@@ -190,6 +193,8 @@ def obtain_snapshot(
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix=f"{commit_sha}.tmp-", dir=temp_parent))
|
||||
try:
|
||||
safe_extract_tar(payload, temp_dir)
|
||||
if not all((temp_dir / path).exists() for path in required_paths):
|
||||
raise CatalogError(f"Invalid source snapshot for {repository}@{commit_sha}")
|
||||
(temp_dir / ".snapshot-complete").write_text(archive_sha + "\n", encoding="utf-8")
|
||||
if snapshot_dir.exists():
|
||||
shutil.rmtree(snapshot_dir)
|
||||
@@ -521,23 +526,66 @@ def repo_from_helper(item: dict[str, Any]) -> str:
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def shared_helper_path(source: Path, core_source: Path | None = None) -> Path:
|
||||
"""Locate source text only; never source or execute upstream shell code."""
|
||||
candidates = [source / "misc" / "tools.func"]
|
||||
if core_source is not None:
|
||||
candidates.extend(core_source / path for path in ("lib/forge.func", "lib/tools.func"))
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
raise CatalogError(
|
||||
"Shared version helpers not found. Older trees use misc/tools.func; "
|
||||
"current trees require community-scripts/core (--core-source-dir for a local checkout)."
|
||||
)
|
||||
|
||||
|
||||
def shell_function(text: str, name: str) -> str:
|
||||
match = re.search(
|
||||
rf"(?ms)^(?:function\s+)?{re.escape(name)}\s*\(\)\s*\{{[^\n]*\n(.*?)^\}}",
|
||||
text,
|
||||
)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def verify_shared_helper(path: Path) -> None:
|
||||
"""Check both ends of the cache contract, including the new forge wrappers."""
|
||||
text = read_text(path)
|
||||
read_body = shell_function(text, "check_for_gh_release")
|
||||
write_body = shell_function(text, "fetch_and_deploy_gh_release")
|
||||
if re.search(r'\bcheck_for_release\s+github\s+"\$1"\s+"\$2"', read_body):
|
||||
read_body = shell_function(text, "check_for_release")
|
||||
if re.search(r'\bfetch_and_deploy_release\s+github\s+"\$1"\s+"\$2"', write_body):
|
||||
write_body = shell_function(text, "fetch_and_deploy_release")
|
||||
normalization = r'app_lc="\$\(echo "\$\{app,,\}" \| tr -d [\'\"] [\'\"]\)"'
|
||||
valid = (
|
||||
re.search(normalization, read_body)
|
||||
and re.search(normalization, write_body)
|
||||
and re.search(r'(?:local\s+)?current_file="\$HOME/\.\$\{app_lc\}"', read_body)
|
||||
and re.search(r'(?:local\s+)?version_file="\$HOME/\.\$\{app_lc\}"', write_body)
|
||||
and re.search(r'echo "\$version" >"\$version_file"', write_body)
|
||||
and re.search(r'\$current_file', read_body)
|
||||
)
|
||||
if not valid:
|
||||
raise CatalogError(f"Could not verify the shared GitHub release cache contract in {path}")
|
||||
|
||||
|
||||
def build_catalog(
|
||||
source: Path,
|
||||
helpers: dict[str, dict[str, Any]],
|
||||
*,
|
||||
core_source: Path | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
|
||||
catalog: dict[str, Any] = {}
|
||||
v2_apps: dict[str, Any] = {}
|
||||
records: list[dict[str, Any]] = []
|
||||
launchers = sorted((source / "ct").glob("*.sh"))
|
||||
tools_func = source / "misc" / "tools.func"
|
||||
tools_text = read_text(tools_func)
|
||||
shared_helper_verified = bool(
|
||||
re.search(r'local version_file="\$HOME/\.\$\{app_lc\}"', tools_text)
|
||||
and re.search(r'echo "\$version" >"\$version_file"', tools_text)
|
||||
and re.search(r'local current_file="\$HOME/\.\$\{app_lc\}"', tools_text)
|
||||
tools_func = shared_helper_path(source, core_source)
|
||||
verify_shared_helper(tools_func)
|
||||
helper_contract = (
|
||||
"misc/tools.func" if tools_func == source / "misc" / "tools.func"
|
||||
else f"core/{tools_func.relative_to(core_source).as_posix()}"
|
||||
)
|
||||
if not shared_helper_verified:
|
||||
raise CatalogError("Could not verify the shared GitHub release cache contract in misc/tools.func")
|
||||
|
||||
for launcher_path in launchers:
|
||||
slug = launcher_path.stem
|
||||
@@ -696,7 +744,7 @@ def build_catalog(
|
||||
"evidence": {
|
||||
"install": f"install/{installer_path.name}: matching deploy helper",
|
||||
"update": f"ct/{launcher_path.name}: matching release check",
|
||||
"contract": "misc/tools.func: shared version-cache contract",
|
||||
"contract": f"{helper_contract}: shared version-cache contract",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -712,7 +760,7 @@ def build_catalog(
|
||||
"evidence": {
|
||||
"install": f"install/{installer_path.name}: fetch_and_deploy_gh_release({release.app}, {release.repo})",
|
||||
"update": f"ct/{launcher_path.name}: check_for_gh_release({release.app}, {release.repo})",
|
||||
"contract": "misc/tools.func writes and reads /root/.<normalized-app>",
|
||||
"contract": f"{helper_contract} writes and reads /root/.<normalized-app>",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -874,7 +922,7 @@ def build_catalog(
|
||||
"status_counts": status_counts,
|
||||
"method_counts": method_counts,
|
||||
"reason_counts": reason_counts,
|
||||
"shared_release_cache_contract_verified": shared_helper_verified,
|
||||
"shared_release_cache_contract_verified": True,
|
||||
"helper_upstream_tag_validation": tag_validation,
|
||||
},
|
||||
"records": records,
|
||||
@@ -1307,6 +1355,12 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", default=DEFAULT_REPOSITORY)
|
||||
parser.add_argument("--ref", default=DEFAULT_REF)
|
||||
parser.add_argument("--core-repository", default=DEFAULT_CORE_REPOSITORY)
|
||||
parser.add_argument("--core-ref", default=DEFAULT_REF)
|
||||
parser.add_argument(
|
||||
"--core-source-dir", type=Path,
|
||||
help="Local community-scripts/core checkout for split source trees (no download)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source-dir",
|
||||
type=Path,
|
||||
@@ -1346,6 +1400,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
client = GitHubClient()
|
||||
commit_sha = "local-source"
|
||||
archive_sha = "not-applicable"
|
||||
core_provenance = None
|
||||
try:
|
||||
if args.source_dir:
|
||||
source = args.source_dir.resolve()
|
||||
@@ -1355,10 +1410,27 @@ def main(argv: list[str] | None = None) -> int:
|
||||
source, commit_sha, archive_sha = obtain_snapshot(
|
||||
client, args.repository, args.ref, args.cache_dir.expanduser().resolve()
|
||||
)
|
||||
if not (source / "ct").is_dir() or not (source / "misc" / "tools.func").is_file():
|
||||
if not (source / "ct").is_dir() or not (source / "install").is_dir():
|
||||
raise CatalogError(f"Not a valid ProxmoxVE source tree: {source}")
|
||||
core_source = None
|
||||
if not (source / "misc" / "tools.func").is_file():
|
||||
if args.core_source_dir:
|
||||
core_source = args.core_source_dir.resolve()
|
||||
core_commit, core_archive = "local-source", "not-applicable"
|
||||
elif args.source_dir:
|
||||
raise CatalogError("Split local source tree: provide --core-source-dir for its shared helpers")
|
||||
else:
|
||||
core_source, core_commit, core_archive = obtain_snapshot(
|
||||
client, args.core_repository, args.core_ref,
|
||||
args.cache_dir.expanduser().resolve(), required_paths=("lib/forge.func",),
|
||||
)
|
||||
core_provenance = {
|
||||
"repository": args.core_repository, "ref": args.core_ref,
|
||||
"commit_sha": core_commit, "archive_sha256": core_archive,
|
||||
"source_dir": str(core_source),
|
||||
}
|
||||
helpers = load_helper_catalog(args.helpers_cache)
|
||||
catalog, audit, v2 = build_catalog(source, helpers)
|
||||
catalog, audit, v2 = build_catalog(source, helpers, core_source=core_source)
|
||||
audit["demoted_helper_markers"] = (
|
||||
[] if args.include_helper_markers else demote_generic_helper_markers(catalog, v2, audit)
|
||||
)
|
||||
@@ -1376,6 +1448,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"ref": args.ref,
|
||||
"commit_sha": commit_sha,
|
||||
"archive_sha256": archive_sha,
|
||||
"core": core_provenance,
|
||||
"source_dir": str(source),
|
||||
"generated_at_unix": int(time.time()),
|
||||
"generator_sha256": sha256_file(Path(__file__).resolve()),
|
||||
|
||||
@@ -13,6 +13,24 @@ import sys
|
||||
import tempfile
|
||||
|
||||
PYTHON_FILES = (
|
||||
"tests/test_host_update_jobs.py",
|
||||
"tests/test_host_update_transport.py",
|
||||
"tests/test_host_update_shell.py",
|
||||
"tests/test_host_update_auth.py",
|
||||
"tests/test_kernel_reboot.py",
|
||||
"tests/test_reboot_health_refresh.py",
|
||||
"tests/test_host_update_controls.py",
|
||||
"tests/test_host_update_packaging.py",
|
||||
"tests/test_host_update_version.py",
|
||||
"tests/test_lxc_update_jobs.py",
|
||||
"tests/test_post_install_i18n.py",
|
||||
"tests/test_remaining_cli_i18n.py",
|
||||
"AppImage/scripts/tests/test_pbs_i18n.py",
|
||||
"AppImage/scripts/tests/test_monitor_links_i18n.py",
|
||||
"AppImage/scripts/tests/test_monitor_links.py",
|
||||
"AppImage/scripts/tests/test_local_app_icon_isolation.py",
|
||||
"AppImage/scripts/tests/test_local_app_icons.py",
|
||||
"AppImage/scripts/tests/test_docker_app_discovery.py",
|
||||
"AppImage/scripts/tests/test_native_app_discovery.py",
|
||||
"tests/test_audit_presentation.py",
|
||||
"tests/test_audit_safety_wording.py",
|
||||
@@ -25,6 +43,23 @@ PYTHON_FILES = (
|
||||
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
|
||||
)
|
||||
NODE_FILES = (
|
||||
"tests/test_host_update_ui.cjs",
|
||||
"tests/test_reboot_health_refresh.cjs",
|
||||
"tests/test_host_update_controls.cjs",
|
||||
"tests/test_reboot_recovery.cjs",
|
||||
"tests/test_proxmenux_version_row.cjs",
|
||||
"tests/test_lxc_update_jobs.cjs",
|
||||
"tests/test_api_import_boundaries.cjs",
|
||||
"tests/test_api_errors.cjs",
|
||||
"tests/test_optimization_labels.cjs",
|
||||
"tests/test_optimization_source.cjs",
|
||||
"tests/lxc_updates/test_app_editor_errors.cjs",
|
||||
"tests/test_oci_update_cancellation.cjs",
|
||||
"tests/test_host_backup_messages.cjs",
|
||||
"tests/test_pbs_i18n.cjs",
|
||||
"tests/test_monitor_links_i18n.cjs",
|
||||
"tests/lxc_updates/test_local_app_icons_ui.cjs",
|
||||
"tests/lxc_updates/test_docker_app_discovery_ui.cjs",
|
||||
"tests/lxc_updates/test_native_app_discovery_ui.cjs",
|
||||
"tests/lxc_updates/test_docker_delegated_ui.cjs",
|
||||
"tests/test_audit_diagnostic_document.cjs",
|
||||
@@ -37,6 +72,7 @@ NODE_FILES = (
|
||||
"tests/test_borg_ssh_guidance.cjs",
|
||||
"tests/test_storage_messages.cjs",
|
||||
"tests/test_health_text.cjs",
|
||||
"tests/test_overview_chart_axis_gutters.cjs",
|
||||
)
|
||||
|
||||
# unittest's CLI accepts a missing pattern as a successful zero-test run.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Exercise the extracted launcher and its real native Python environment,
|
||||
# intercepting only the final server exec so no host-monitoring server starts.
|
||||
set -eu
|
||||
appdir="$1"
|
||||
export PMX_SMOKE_PYTHON
|
||||
PMX_SMOKE_PYTHON="$(command -v python)"
|
||||
fixture_bin="$(mktemp -d)"
|
||||
trap 'rm -rf "$fixture_bin"' EXIT
|
||||
cat > "$fixture_bin/python3" <<'WRAPPER'
|
||||
#!/bin/sh
|
||||
case "$1" in
|
||||
*/flask_server.py)
|
||||
exec "$PMX_SMOKE_PYTHON" -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print("PASS: extracted AppRun native runtime")'
|
||||
;;
|
||||
*) exec "$PMX_SMOKE_PYTHON" "$@" ;;
|
||||
esac
|
||||
WRAPPER
|
||||
chmod +x "$fixture_bin/python3"
|
||||
PATH="$fixture_bin:$PATH" bash "$appdir/AppRun"
|
||||
@@ -30,7 +30,7 @@
|
||||
"Apagar una máquina virtual de forma segura",
|
||||
"Mostrar estado de VM",
|
||||
"Mostrar configuración de VM",
|
||||
"Crear una instantánea de VM",
|
||||
"Crear un snapshot de VM",
|
||||
"Listar todos los contenedores LXC",
|
||||
"Iniciar un contenedor LXC",
|
||||
"Detener un contenedor LXC",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Inert infrastructure for actual endpoint/manual/queued/SQLite release seams."""
|
||||
import ast
|
||||
import datetime
|
||||
import sqlite3
|
||||
import tempfile
|
||||
@@ -36,6 +37,10 @@ def restore_event(warnings):
|
||||
def deliver(event_type, data, severity='INFO', language='en', manual=False, quiet=False, quiet_before=()):
|
||||
"""Execute real dispatch/manual and optional real SQLite buffer+flush."""
|
||||
captured = []
|
||||
delivery_errors = []
|
||||
def record_history(event_type, channel, title, body, severity, success, error, source):
|
||||
if not success:
|
||||
delivery_errors.append(f'{channel}: {error}')
|
||||
channel = object.__new__(EmailChannel); channel.subject_prefix = '[ProxMenux]'
|
||||
def sink(title, body, severity, data):
|
||||
markup = channel._format_html(title, body, severity, data)
|
||||
@@ -48,12 +53,25 @@ def deliver(event_type, data, severity='INFO', language='en', manual=False, quie
|
||||
_claim_delivery=lambda event:'inert', _finish_delivery_claim=lambda *a,**kw:None,
|
||||
_notification_language=lambda:language, _build_ai_config=lambda:{'ai_enabled':'false'},
|
||||
_in_quiet_hours=lambda channel:quiet, _should_buffer_for_digest=lambda *a:False,
|
||||
_record_history=lambda *a:None, _stats={'total_sent':0,'total_errors':0},
|
||||
_record_history=record_history, _stats={'total_sent':0,'total_errors':0},
|
||||
is_event_enabled=lambda event:True)
|
||||
ns = dict(vars(typing), NotificationEvent=types.SimpleNamespace, TEMPLATES=templates.TEMPLATES, render_template=templates.render_template,
|
||||
resolve_notification_hostname=lambda host, config:host or 'node-a',
|
||||
enrich_with_emojis=templates.enrich_with_emojis, datetime=datetime.datetime,
|
||||
_should_bypass_ai=lambda event:True, _AI_BYPASS_EVENTS=frozenset({'backup_complete','backup_fail'}))
|
||||
format_backup_diagnostics_with_ai=templates.format_backup_diagnostics_with_ai,
|
||||
format_with_ai_full=templates.format_with_ai_full,
|
||||
# AI is disabled in this fixture. Execute the actual formatting helpers
|
||||
# synchronously, without a worker pool, providers or operational imports.
|
||||
_format_with_ai_bounded=lambda function,*args,**kwargs:function(*args,**kwargs),
|
||||
enrich_context_for_ai=lambda **kwargs:'')
|
||||
manager_path = SCRIPTS/'notification_manager.py'
|
||||
for node in ast.parse(manager_path.read_text()).body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id in ('_AI_BYPASS_EVENTS', '_AI_NO_SUGGESTION_EVENTS'):
|
||||
ns[target.id] = frozenset(ast.literal_eval(node.value.args[0]))
|
||||
for name in ('_should_bypass_ai', '_format_backup_with_ai_bounded'):
|
||||
extract(manager_path, name, None, ns)
|
||||
for name in ('_dispatch_to_channels', '_dispatch_event', 'send_notification'):
|
||||
setattr(manager, name, types.MethodType(extract(SCRIPTS/'notification_manager.py', name, 'NotificationManager', ns), manager))
|
||||
with tempfile.TemporaryDirectory(prefix='notification-final-') as scratch:
|
||||
@@ -73,10 +91,11 @@ def deliver(event_type, data, severity='INFO', language='en', manual=False, quie
|
||||
if manual:
|
||||
assert not quiet
|
||||
result = manager.send_notification(event_type, severity, '', '', dict(data))
|
||||
assert result['success']
|
||||
assert result['success'], result.get('errors')
|
||||
else:
|
||||
event = types.SimpleNamespace(event_type=event_type, severity=severity, data=dict(data), source='inert', entity_type='node', entity_id='', event_id='inert', fingerprint='inert')
|
||||
manager._dispatch_event(event)
|
||||
assert not delivery_errors, '; '.join(delivery_errors)
|
||||
if quiet:
|
||||
conn = sqlite3.connect(db); rows = conn.execute('SELECT event_type,severity,title,body FROM quiet_pending').fetchall(); conn.close()
|
||||
assert len(rows)==1+len(quiet_before) and not captured
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Source layout regressions for the Helper-Scripts/core repository split."""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "generate_app_tracking_catalog.py"
|
||||
SPEC = importlib.util.spec_from_file_location("tracking_sources_under_test", SCRIPT)
|
||||
generator = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = generator
|
||||
SPEC.loader.exec_module(generator)
|
||||
|
||||
NORMALIZE = ''' app_lc="$(echo "${app,,}" | tr -d ' ')"'''
|
||||
READ_BODY = NORMALIZE + '''
|
||||
current_file="$HOME/.${app_lc}"
|
||||
current="$(<"$current_file")"
|
||||
'''
|
||||
WRITE_BODY = NORMALIZE + '''
|
||||
version_file="$HOME/.${app_lc}"
|
||||
echo "$version" >"$version_file"
|
||||
'''
|
||||
|
||||
|
||||
def function(name, body):
|
||||
return f"{name}() {{\n{body}\n}}\n"
|
||||
|
||||
|
||||
LEGACY = (
|
||||
function("check_for_gh_release", READ_BODY.replace(' current_file=', ' local current_file='))
|
||||
+ function("fetch_and_deploy_gh_release", WRITE_BODY.replace(' version_file=', ' local version_file='))
|
||||
)
|
||||
SPLIT = (
|
||||
function("check_for_gh_release", ' check_for_release github "$1" "$2" "${3:-}"')
|
||||
+ function("fetch_and_deploy_gh_release", ' fetch_and_deploy_release github "$1" "$2" "${3:-tarball}"')
|
||||
+ function("check_for_release", READ_BODY)
|
||||
+ function("fetch_and_deploy_release", WRITE_BODY)
|
||||
)
|
||||
|
||||
|
||||
class TrackingSourcesTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.apps = self.root / "apps"
|
||||
self.core = self.root / "core"
|
||||
self.write(self.apps / "ct/example.sh", '''APP="Example"
|
||||
check_for_gh_release "Example" "example/example"
|
||||
''')
|
||||
self.write(self.apps / "install/example-install.sh", '''
|
||||
fetch_and_deploy_gh_release "Example" "example/example" "tarball" "latest"
|
||||
''')
|
||||
|
||||
def write(self, path, content):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def run_main(self, *extra):
|
||||
output = self.root / "output.json"
|
||||
args = ["--source-dir", str(self.apps), "--output", str(output),
|
||||
"--include-helper-markers",
|
||||
"--audit-output", str(self.root / "audit.json"),
|
||||
"--v2-output", str(self.root / "v2.json"), *extra]
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
result = generator.main(args)
|
||||
return result, output
|
||||
|
||||
def test_legacy_monolithic_contract_is_supported(self):
|
||||
self.write(self.apps / "misc/tools.func", LEGACY)
|
||||
result, output = self.run_main()
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(json.loads(output.read_text())["example"]["file_path"], "/root/.example")
|
||||
|
||||
def test_split_contract_builds_catalog_and_records_actual_evidence(self):
|
||||
self.write(self.core / "lib/forge.func", SPLIT)
|
||||
result, output = self.run_main("--core-source-dir", str(self.core))
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(json.loads(output.read_text())["example"]["repo"], "example/example")
|
||||
audit = json.loads((self.root / "audit.json").read_text())
|
||||
self.assertEqual(audit["provenance"]["core"]["source_dir"], str(self.core.resolve()))
|
||||
v2 = (self.root / "v2.json").read_text()
|
||||
self.assertIn("core/lib/forge.func", v2)
|
||||
self.assertNotIn("misc/tools.func", v2)
|
||||
|
||||
def test_unsplit_core_tools_library_is_supported(self):
|
||||
path = self.write(self.core / "lib/tools.func", LEGACY)
|
||||
self.assertEqual(generator.shared_helper_path(self.apps, self.core), path)
|
||||
generator.verify_shared_helper(path)
|
||||
|
||||
def test_changed_contract_never_overwrites_existing_outputs(self):
|
||||
for old, new in [('echo "$version" >"$version_file"', 'echo "$version" >/tmp/version'),
|
||||
('current_file="$HOME/.${app_lc}"', 'current_file="/tmp/version"'),
|
||||
('tr -d \' \'', 'tr -d \'-\'')]:
|
||||
with self.subTest(change=old):
|
||||
self.write(self.core / "lib/forge.func", SPLIT.replace(old, new))
|
||||
output = self.write(self.root / "output.json", '{"keep": true}\n')
|
||||
result, _ = self.run_main("--core-source-dir", str(self.core))
|
||||
self.assertEqual(result, 1)
|
||||
self.assertEqual(output.read_text(), '{"keep": true}\n')
|
||||
|
||||
def test_matching_text_in_unrelated_functions_is_not_evidence(self):
|
||||
path = self.write(self.core / "lib/forge.func", SPLIT.replace(
|
||||
function("check_for_release", READ_BODY), function("unrelated", READ_BODY)))
|
||||
with self.assertRaises(generator.CatalogError):
|
||||
generator.verify_shared_helper(path)
|
||||
|
||||
def test_split_local_tree_requires_explicit_local_core_without_network(self):
|
||||
with patch.object(generator.GitHubClient, "request", side_effect=AssertionError("Network")):
|
||||
result, output = self.run_main()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_invalid_local_core_fails_without_writing_a_catalog(self):
|
||||
result, output = self.run_main("--core-source-dir", str(self.core))
|
||||
self.assertEqual(result, 1)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_snapshot_cache_accepts_core_without_ct_directory(self):
|
||||
sha = "a" * 40
|
||||
source = self.root / "cache/community-scripts--core" / sha
|
||||
self.write(source / "lib/forge.func", SPLIT)
|
||||
self.write(source / ".snapshot-complete", "archive-hash\n")
|
||||
client = Mock()
|
||||
client.json.return_value = {"sha": sha}
|
||||
path, commit, archive = generator.obtain_snapshot(
|
||||
client, "community-scripts/core", "main", self.root / "cache",
|
||||
required_paths=("lib/forge.func",))
|
||||
self.assertEqual((path, commit, archive), (source, sha, "archive-hash"))
|
||||
client.request.assert_not_called()
|
||||
|
||||
def test_remote_split_source_pins_both_repositories(self):
|
||||
self.write(self.core / "lib/forge.func", SPLIT)
|
||||
output = self.root / "remote.json"
|
||||
with patch.object(generator, "obtain_snapshot", side_effect=[
|
||||
(self.apps, "a" * 40, "apps-archive"),
|
||||
(self.core, "b" * 40, "core-archive"),
|
||||
]) as snapshots, contextlib.redirect_stdout(io.StringIO()):
|
||||
result = generator.main([
|
||||
"--output", str(output), "--include-helper-markers",
|
||||
"--audit-output", str(self.root / "remote-audit.json"),
|
||||
"--v2-output", str(self.root / "remote-v2.json"),
|
||||
])
|
||||
self.assertEqual(result, 0)
|
||||
provenance = json.loads((self.root / "remote-audit.json").read_text())["provenance"]
|
||||
self.assertEqual(provenance["commit_sha"], "a" * 40)
|
||||
self.assertEqual(provenance["core"]["commit_sha"], "b" * 40)
|
||||
self.assertEqual(provenance["core"]["archive_sha256"], "core-archive")
|
||||
self.assertEqual(snapshots.call_args_list[1].args[1], "community-scripts/core")
|
||||
self.assertEqual(snapshots.call_args_list[1].kwargs["required_paths"], ("lib/forge.func",))
|
||||
|
||||
def test_invalid_download_is_not_marked_complete(self):
|
||||
client = Mock()
|
||||
client.json.return_value = {"sha": "b" * 40}
|
||||
client.request.return_value = b"invalid-layout"
|
||||
with patch.object(generator, "safe_extract_tar"):
|
||||
with self.assertRaises(generator.CatalogError):
|
||||
generator.obtain_snapshot(client, "community-scripts/core", "main", self.root / "cache",
|
||||
required_paths=("lib/forge.func",))
|
||||
self.assertEqual(list((self.root / "cache").rglob(".snapshot-complete")), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""A schedule systemd rejects leaves a timer that never fires and reports no
|
||||
error, so the wizard asks for it again instead of saving it."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCHEDULER = Path(__file__).resolve().parents[3] / 'scripts/backup_restore/backup_scheduler.sh'
|
||||
ANALYZE = '#!/bin/sh\ncase "$2" in "daily 02:00") exit 1;; esac\nexit 0\n'
|
||||
|
||||
|
||||
def function(name):
|
||||
text = SCHEDULER.read_text()
|
||||
start = text.index(name + '() {')
|
||||
return text[start:text.index('\n}', start) + 2] + '\n'
|
||||
|
||||
|
||||
class ScheduleValidation(unittest.TestCase):
|
||||
def valid(self, expression, **environment):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
analyze = Path(directory) / 'systemd-analyze'
|
||||
analyze.write_text(ANALYZE)
|
||||
analyze.chmod(0o755)
|
||||
return subprocess.run(
|
||||
['/bin/bash', '-c', function('_calendar_is_valid') + '_calendar_is_valid "$1"', 'test', expression],
|
||||
env={'PATH': f'{directory}:/usr/bin:/bin', **environment},
|
||||
).returncode == 0
|
||||
|
||||
def test_systemd_decides_what_is_valid(self):
|
||||
self.assertTrue(self.valid('02:30'))
|
||||
self.assertFalse(self.valid('daily 02:00'))
|
||||
|
||||
def test_a_scheduler_without_systemctl_is_not_checked(self):
|
||||
self.assertTrue(self.valid('daily 02:00', PMX_BACKUP_NO_SYSTEMCTL='1'))
|
||||
|
||||
def test_the_wizard_asks_again_before_saving_the_job(self):
|
||||
text = SCHEDULER.read_text()
|
||||
check = text.index('while ! _calendar_is_valid "$on_calendar"; do')
|
||||
self.assertLess(check, text.index('"ON_CALENDAR=$on_calendar"'))
|
||||
self.assertGreater(check, text.index('on_calendar=$(dialog'))
|
||||
|
||||
|
||||
STUB_ANALYZE = '#!/bin/sh\ncase "$2" in daily\\ *|weekly\\ *|monthly\\ *) exit 1;; esac\nexit 0\n'
|
||||
JOBS = {
|
||||
'nas': 'ON_CALENDAR=daily\\ 02:00\nENABLED=1\n',
|
||||
'week': 'ON_CALENDAR=weekly\\ 03:15\nENABLED=1\n',
|
||||
'month': 'ON_CALENDAR=monthly\\ 04:00\nENABLED=0\n',
|
||||
'good': 'ON_CALENDAR=Mon..Fri\\ 03:00\nENABLED=1\n',
|
||||
'manual': 'ON_CALENDAR=daily\\ 05:00\nMANUAL_RUN=1\n',
|
||||
'attached': 'ON_CALENDAR=daily\\ 06:00\nPVE_STORAGE=local\n',
|
||||
'custom': 'ON_CALENDAR=every\\ night\nENABLED=1\n',
|
||||
}
|
||||
|
||||
|
||||
class ScheduleRepair(unittest.TestCase):
|
||||
def run_repair(self):
|
||||
names = ('_calendar_is_valid', '_scheduler_systemctl', '_job_env_get', '_job_is_attached',
|
||||
'_write_job_units', '_repaired_calendar', '_repair_job_schedules')
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.folder.cleanup)
|
||||
root = Path(self.folder.name)
|
||||
for name, body in (('systemd-analyze', STUB_ANALYZE),
|
||||
('systemctl', f'#!/bin/sh\necho "$@" >> {root}/systemctl.log\n')):
|
||||
(root / name).write_text(body)
|
||||
(root / name).chmod(0o755)
|
||||
jobs, units = root / 'jobs', root / 'units'
|
||||
jobs.mkdir()
|
||||
for job, body in JOBS.items():
|
||||
(jobs / f'{job}.env').write_text(f'JOB_ID={job}\n{body}')
|
||||
script = (f'JOBS_DIR={jobs}\nSYSTEMD_DIR={units}\nLOCAL_SCRIPTS={root}\nSCRIPT_DIR={root}\n'
|
||||
'_job_file() { echo "${JOBS_DIR}/$1.env"; }\n'
|
||||
'_service_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.service"; }\n'
|
||||
'_timer_file() { echo "${SYSTEMD_DIR}/proxmenux-backup-$1.timer"; }\n'
|
||||
+ ''.join(function(name) for name in names) + '_repair_job_schedules\n')
|
||||
subprocess.run(['/bin/bash', '-c', script], check=True,
|
||||
env={'PATH': f'{root}:/usr/bin:/bin'})
|
||||
return jobs, units, root
|
||||
|
||||
def test_a_time_after_daily_weekly_or_monthly_is_repaired(self):
|
||||
jobs, units, root = self.run_repair()
|
||||
self.assertIn('ON_CALENDAR=02:00\n', (jobs / 'nas.env').read_text())
|
||||
self.assertIn('OnCalendar=02:00\n', (units / 'proxmenux-backup-nas.timer').read_text())
|
||||
self.assertIn('OnCalendar=Mon 03:15\n', (units / 'proxmenux-backup-week.timer').read_text())
|
||||
self.assertIn('OnCalendar=*-*-01 04:00\n', (units / 'proxmenux-backup-month.timer').read_text())
|
||||
log = (root / 'systemctl.log').read_text()
|
||||
self.assertIn('enable --now proxmenux-backup-nas.timer', log)
|
||||
self.assertNotIn('proxmenux-backup-month.timer', log)
|
||||
|
||||
def test_the_rest_of_the_jobs_are_left_as_they_are(self):
|
||||
jobs, units, _ = self.run_repair()
|
||||
for job in ('good', 'manual', 'attached', 'custom'):
|
||||
self.assertEqual((jobs / f'{job}.env').read_text(), f'JOB_ID={job}\n{JOBS[job]}', job)
|
||||
self.assertFalse((units / f'proxmenux-backup-{job}.timer').exists(), job)
|
||||
|
||||
def test_both_installers_repair_on_update(self):
|
||||
root = SCHEDULER.parents[2]
|
||||
for installer in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
|
||||
self.assertIn('backup_scheduler.sh" --repair-schedules', (root / installer).read_text())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Run only the Bashrc functions, with all root files in a private fixture."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
FLOWS = ("auto_post_install.sh", "customizable_post_install.sh")
|
||||
BEGIN = b"# BEGIN PMX_CORE_BASHRC\n"
|
||||
END = b"# END PMX_CORE_BASHRC\n"
|
||||
# The unmarked block shipped in v1.1.3 (fc974995), without its leading blank line.
|
||||
LEGACY = rb'''# ProxMenux customizations
|
||||
export HISTTIMEFORMAT="%d/%m/%y %T "
|
||||
export PS1="\[\e[31m\][\[\e[m\]\[\e[38;5;172m\]\u\[\e[m\]@\[\e[38;5;153m\]\h\[\e[m\] \[\e[38;5;214m\]\W\[\e[m\]\[\e[31m\]]\[\e[m\]\\$ "
|
||||
alias l='ls -CF'
|
||||
alias la='ls -A'
|
||||
alias ll='ls -alF'
|
||||
alias ls='ls --color=auto'
|
||||
alias grep='grep --color=auto'
|
||||
alias fgrep='fgrep --color=auto'
|
||||
alias egrep='egrep --color=auto'
|
||||
source /etc/profile.d/bash_completion.sh
|
||||
'''
|
||||
|
||||
|
||||
def extract(source, name):
|
||||
match = re.search(rf"(?ms)^{name}\(\) \{{\n.*?^\}}$", source)
|
||||
if match is None:
|
||||
raise AssertionError(f"missing function: {name}")
|
||||
return match.group()
|
||||
|
||||
|
||||
class BashrcMigrationTests(unittest.TestCase):
|
||||
def run_flow(self, flow, original, times=1, injection="", style=""):
|
||||
with tempfile.TemporaryDirectory(prefix="bashrc-migration-") as temporary:
|
||||
work = Path(temporary)
|
||||
bashrc = work / ".bashrc"
|
||||
bashrc.write_bytes(original)
|
||||
bashrc.chmod(0o640)
|
||||
before = bashrc.stat()
|
||||
(work / "bin").mkdir()
|
||||
# The embedded Python must use the interpreter running this test.
|
||||
(work / "bin/python3").symlink_to(sys.executable)
|
||||
source = (ROOT / "scripts/post_install" / flow).read_text()
|
||||
functions = "\n".join(extract(source, name) for name in
|
||||
("_migrate_proxmenux_bashrc", "customize_bashrc"))
|
||||
# Relocate only the fixture copy; never source the administrative script.
|
||||
functions = functions.replace("/root/", str(work) + "/")
|
||||
prelude = f'source {shlex.quote(str(ROOT / "scripts/global/pmx_journal.sh"))}\n'
|
||||
prelude += r'''
|
||||
translate() { printf '%s' "$1"; }
|
||||
msg_info() { :; }
|
||||
msg_info2() { :; }
|
||||
msg_warn() { :; }
|
||||
msg_error() { printf '%s\n' "$*" >&2; }
|
||||
msg_ok() { printf 'success\n' >> "$FIXTURE_ROOT/messages"; }
|
||||
register_tool() { printf '%s|%s|%s\n' "$@" >> "$FIXTURE_ROOT/registry"; }
|
||||
'''
|
||||
calls = "\n".join(
|
||||
f'customize_bashrc; rc=$?; printf "%s\\n" "$rc" >> "$FIXTURE_ROOT/status"; '
|
||||
f'cp "$FIXTURE_ROOT/.bashrc" "$FIXTURE_ROOT/pass-{i}"'
|
||||
for i in range(times))
|
||||
env = os.environ.copy()
|
||||
env.update(HOME=str(work), FIXTURE_ROOT=str(work), TMPDIR=str(work),
|
||||
PMX_JOURNAL_ROOT=str(work / "journal"), PMX_BASHRC_PATH_STYLE=style,
|
||||
PATH=str(work / "bin") + os.pathsep + env.get("PATH", ""))
|
||||
result = subprocess.run(
|
||||
["bash", "--noprofile", "--norc", "-c",
|
||||
prelude + functions + "\n" + injection + "\n" + calls],
|
||||
cwd=work, env=env, stdin=subprocess.DEVNULL, capture_output=True,
|
||||
text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
after = bashrc.stat()
|
||||
def read(name):
|
||||
path = work / name
|
||||
return path.read_bytes() if path.exists() else b""
|
||||
return {
|
||||
"passes": [read(f"pass-{i}") for i in range(times)],
|
||||
"status": read("status").splitlines(), "registry": read("registry"),
|
||||
"messages": read("messages"), "backup": read(".bashrc.bak"),
|
||||
"profile": read(".bash_profile"), "stderr": result.stderr,
|
||||
"detected_style": read("style"),
|
||||
"before": (stat.S_IMODE(before.st_mode), before.st_uid, before.st_gid),
|
||||
"after": (stat.S_IMODE(after.st_mode), after.st_uid, after.st_gid),
|
||||
"inode_before": before.st_ino, "inode_after": after.st_ino,
|
||||
"leftovers": [p.name for p in work.iterdir() if "proxmenux" in p.name],
|
||||
"journal": [json.loads(p.read_text()) for p in
|
||||
sorted((work / "journal/spool").glob("*.json"))],
|
||||
}
|
||||
|
||||
def test_inspect_detects_style_without_rewriting_the_original(self):
|
||||
for flow in FLOWS:
|
||||
for block in (LEGACY, LEGACY.replace(rb"\W", rb"\w")):
|
||||
with self.subTest(flow=flow, full=rb"\w" in block):
|
||||
injection = r'''customize_bashrc() {
|
||||
_migrate_proxmenux_bashrc "$FIXTURE_ROOT/.bashrc" inspect > "$FIXTURE_ROOT/style"
|
||||
}'''
|
||||
run = self.run_flow(flow, block, injection=injection)
|
||||
self.assertEqual(run["status"], [b"0"], run["stderr"])
|
||||
self.assertEqual(run["passes"], [block])
|
||||
self.assertEqual(run["detected_style"], b"full\n" if rb"\w" in block else b"short\n")
|
||||
self.assertEqual(run["inode_before"], run["inode_after"])
|
||||
self.assertEqual(run["backup"], b"")
|
||||
self.assertEqual(run["registry"], b"")
|
||||
self.assertEqual(run["journal"], [])
|
||||
|
||||
def test_historical_blocks_migrate_once_with_prompt_backup_and_journal(self):
|
||||
for flow in FLOWS:
|
||||
for full in (False, True):
|
||||
legacy = LEGACY.replace(rb"\W", rb"\w") if full else LEGACY
|
||||
blocks = (legacy, b"${marker_begin}\n" + legacy + b"${marker_end}\n",
|
||||
BEGIN + legacy + END, legacy + BEGIN + legacy + END)
|
||||
for block in blocks:
|
||||
with self.subTest(flow=flow, full=full, block=block[:40]):
|
||||
original = b"# user prefix\n" + block + b"# user suffix\n"
|
||||
run = self.run_flow(flow, original, times=2)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
first, second = run["passes"]
|
||||
self.assertEqual(first, second)
|
||||
self.assertTrue(first.startswith(b"# user prefix\n# user suffix\n"))
|
||||
self.assertEqual(first.count(BEGIN), 1)
|
||||
self.assertEqual(first.count(END), 1)
|
||||
self.assertEqual(first.count(b"alias ll="), 1)
|
||||
self.assertNotIn(b"${marker_", first)
|
||||
self.assertIn(rb"\w" if full else rb"\W", first)
|
||||
self.assertEqual(run["backup"], original)
|
||||
self.assertEqual(len(run["profile"].splitlines()), 1)
|
||||
self.assertEqual(run["registry"], b"bashrc_custom|true|1.3\n" * 2)
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
self.assertEqual(run["leftovers"], [])
|
||||
entries = [e for e in run["journal"] if e["target"].endswith("/.bashrc")]
|
||||
self.assertEqual(len(entries), 1)
|
||||
entry = entries[0]
|
||||
self.assertEqual(entry["operation"], "write_file")
|
||||
self.assertEqual(entry["exactness"], "exact")
|
||||
self.assertEqual(entry["capture"], "present")
|
||||
self.assertNotEqual(entry["before"], entry["after"])
|
||||
self.assertTrue(all("proxmenux." not in e["target"] for e in run["journal"]))
|
||||
syntax = subprocess.run(["bash", "-n"], input=first, capture_output=True)
|
||||
self.assertEqual(syntax.returncode, 0, syntax.stderr)
|
||||
|
||||
def test_incomplete_and_oversized_ranges_are_preserved_on_rerun(self):
|
||||
fragments = (LEGACY.rsplit(b"source", 1)[0], END,
|
||||
b"${marker_begin}\n# user\n", b"${marker_end}\n",
|
||||
BEGIN + b"# user\n" * 33 + END,
|
||||
b"${marker_begin}\n" + b"# user\n" * 33 + b"${marker_end}\n",
|
||||
BEGIN + b"# user\n${marker_begin}\n# inner\n${marker_end}\n# suffix\n")
|
||||
for flow in FLOWS:
|
||||
for fragment in fragments:
|
||||
with self.subTest(flow=flow, fragment=fragment[:40]):
|
||||
run = self.run_flow(flow, fragment, times=2)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
# Complete inner ranges can migrate, but their outer prefix cannot.
|
||||
preserved = BEGIN + b"# user\n# suffix\n" if b"# inner" in fragment else fragment
|
||||
self.assertTrue(run["passes"][0].startswith(preserved))
|
||||
self.assertEqual(run["passes"][0], run["passes"][1])
|
||||
|
||||
def test_nested_ranges_with_outer_end_are_preserved_on_repeated_runs(self):
|
||||
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
|
||||
for flow in FLOWS:
|
||||
for outer_begin, outer_end in markers:
|
||||
for inner_begin, inner_end in markers:
|
||||
with self.subTest(flow=flow, outer=outer_begin, inner=inner_begin):
|
||||
original = (outer_begin + b"# OUTER_USER_PREFIX\n"
|
||||
b"user_function() { :; }\n" + inner_begin
|
||||
+ b"# old inner block\n" + inner_end
|
||||
+ b"# OUTER_USER_SUFFIX\n" + outer_end)
|
||||
run = self.run_flow(flow, original, times=3)
|
||||
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
|
||||
for content in run["passes"]:
|
||||
self.assertTrue(content.startswith(original), content)
|
||||
self.assertEqual(content.count(BEGIN), original.count(BEGIN) + 1)
|
||||
self.assertEqual(content.count(END), original.count(END) + 1)
|
||||
self.assertEqual(content.count(b"# ProxMenux core customizations\n"), 1)
|
||||
self.assertEqual(run["passes"], [run["passes"][0]] * 3)
|
||||
self.assertEqual(run["backup"], original)
|
||||
|
||||
def test_crossed_marker_families_preserve_every_byte_on_repeated_runs(self):
|
||||
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
|
||||
for flow in FLOWS:
|
||||
canonical = self.run_flow(flow, b"")["passes"][0]
|
||||
for outer, inner in (markers, markers[::-1]):
|
||||
for newline in (b"\n", b"\r\n"):
|
||||
with self.subTest(flow=flow, outer=outer[0], newline=newline):
|
||||
original = (outer[0] + b"user_prefix() { :; } # \xff\n"
|
||||
+ inner[0] + outer[1]
|
||||
+ b"user_suffix() { :; }\n" + inner[1])
|
||||
original = original.replace(b"\n", newline)
|
||||
run = self.run_flow(flow, original, times=3)
|
||||
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
|
||||
self.assertEqual(run["passes"], [original + canonical] * 3)
|
||||
self.assertEqual(run["backup"], original)
|
||||
self.assertEqual(run["registry"], b"bashrc_custom|true|1.3\n" * 3)
|
||||
|
||||
def test_marker_ambiguity_matrix_preserves_spans_without_capturing_separate_blocks(self):
|
||||
markers = ((BEGIN, END), (b"${marker_begin}\n", b"${marker_end}\n"))
|
||||
prefix = b"user_prefix() { :; }\n"
|
||||
suffix = b"user_suffix() { :; }\n"
|
||||
for flow in FLOWS:
|
||||
canonical = self.run_flow(flow, b"")["passes"][0]
|
||||
for (begin, end), (foreign_begin, foreign_end) in (markers, markers[::-1]):
|
||||
# Each malformed complete span is ambiguous, even if the foreign
|
||||
# token has no partner. Never expose its user bytes on a rerun.
|
||||
ambiguous = {
|
||||
"unmatched-inner-begin": begin + prefix + foreign_begin + suffix + end,
|
||||
"unmatched-foreign-end": begin + prefix + foreign_end + suffix + end,
|
||||
"inner-begin-extra-end": begin + prefix + foreign_begin + end + suffix + end,
|
||||
"nested-with-stray-end": (begin + prefix + foreign_begin + end
|
||||
+ foreign_end + suffix + end),
|
||||
"overlap-chain": (begin + prefix + foreign_begin + end + begin
|
||||
+ foreign_end + suffix + end),
|
||||
"ambiguous-legacy-signature": (begin + prefix + foreign_end
|
||||
+ LEGACY + suffix + end),
|
||||
"oversized-ambiguous-span": (begin + prefix + foreign_end
|
||||
+ b"# user\n" * 33 + suffix + end),
|
||||
}
|
||||
cases = [(name, fragment, fragment) for name, fragment in ambiguous.items()]
|
||||
# An unclosed old BEGIN is not an enclosing range. Its prefix
|
||||
# must survive, but independent complete blocks must still migrate.
|
||||
cases.extend([
|
||||
("orphan-before-same-family", begin + prefix + begin + b"old\n" + end + suffix,
|
||||
begin + prefix + suffix),
|
||||
("orphan-before-other-family", begin + prefix + foreign_begin + b"old\n"
|
||||
+ foreign_end + suffix, begin + prefix + suffix),
|
||||
("missing-outer-end", begin + prefix + foreign_begin + b"old\n"
|
||||
+ foreign_end + suffix + foreign_end, begin + prefix + suffix + foreign_end),
|
||||
("separated-valid-blocks", foreign_end + prefix + begin + b"old\n" + end
|
||||
+ suffix + foreign_begin + b"old\n" + foreign_end + foreign_begin,
|
||||
foreign_end + prefix + suffix + foreign_begin),
|
||||
("limit-32", begin + b"# old\n" * 31 + end + suffix, suffix),
|
||||
("over-limit-33", begin + b"# user\n" * 32 + end + suffix,
|
||||
begin + b"# user\n" * 32 + end + suffix),
|
||||
])
|
||||
for name, original, preserved in cases:
|
||||
with self.subTest(flow=flow, family=begin, case=name):
|
||||
run = self.run_flow(flow, original, times=3)
|
||||
self.assertEqual(run["status"], [b"0"] * 3, run["stderr"])
|
||||
self.assertEqual(run["passes"], [preserved + canonical] * 3)
|
||||
self.assertEqual(run["backup"], original)
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
self.assertEqual(run["leftovers"], [])
|
||||
|
||||
def test_path_override_and_empty_install_remain_idempotent(self):
|
||||
for flow in FLOWS:
|
||||
for style in ("short", "full"):
|
||||
with self.subTest(flow=flow, style=style):
|
||||
run = self.run_flow(flow, b"", times=2, style=style)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
self.assertEqual(run["passes"][0], run["passes"][1])
|
||||
self.assertIn(rb"\w" if style == "full" else rb"\W", run["passes"][0])
|
||||
|
||||
def test_preparation_and_commit_failures_leave_bashrc_untouched(self):
|
||||
failures = {
|
||||
"inspect": 'python3() { [[ "$3" == inspect ]] && return 71; command python3 "$@"; }',
|
||||
"migrate": 'python3() { [[ "$3" == migrate ]] && return 71; command python3 "$@"; }',
|
||||
"replace": '''python3() {
|
||||
command python3 -c 'import os, sys
|
||||
sys.argv = sys.argv[1:]
|
||||
def fail(*args):
|
||||
raise OSError("injected replace failure")
|
||||
os.replace = fail
|
||||
exec(sys.stdin.read())' "$@"
|
||||
}''',
|
||||
"backup": 'cp() { [[ "${@: -1}" == *.bak ]] && return 71; command cp "$@"; }',
|
||||
"mktemp": 'mktemp() { return 71; }',
|
||||
"copy": 'cp() { if [[ "$1" == -p ]]; then printf partial > "$3"; return 71; fi; command cp "$@"; }',
|
||||
"profile": 'pmx_append_file() { return 71; }',
|
||||
"commit": 'mv() { [[ "${@: -1}" == "$FIXTURE_ROOT/.bashrc" ]] && return 71; command mv "$@"; }',
|
||||
}
|
||||
original = b"# user\n" + LEGACY
|
||||
for flow in FLOWS:
|
||||
for failure, injection in failures.items():
|
||||
with self.subTest(flow=flow, failure=failure):
|
||||
run = self.run_flow(flow, original, injection=injection)
|
||||
self.assertNotEqual(run["status"], [b"0"], run["stderr"])
|
||||
self.assertEqual(run["passes"], [original])
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
self.assertEqual(run["inode_before"], run["inode_after"])
|
||||
self.assertEqual(run["registry"], b"")
|
||||
self.assertEqual(run["messages"], b"")
|
||||
self.assertEqual(run["leftovers"], [])
|
||||
self.assertFalse(any(e["target"].endswith("/.bashrc") for e in run["journal"]))
|
||||
with self.subTest(flow=flow, failure="invalid-style"):
|
||||
run = self.run_flow(flow, original, style="invalid")
|
||||
self.assertNotEqual(run["status"], [b"0"])
|
||||
self.assertEqual(run["passes"], [original])
|
||||
self.assertEqual(run["registry"], b"")
|
||||
|
||||
def test_migration_preserves_unrelated_non_utf8_bytes_and_line_endings(self):
|
||||
prefix = b"# user bytes: \xff\r\n"
|
||||
suffix = b"user_function() { :; }\r\n"
|
||||
original = prefix + BEGIN + b"old block\n" + END + suffix
|
||||
for flow in FLOWS:
|
||||
with self.subTest(flow=flow):
|
||||
run = self.run_flow(flow, original, times=2)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
self.assertTrue(run["passes"][0].startswith(prefix + suffix))
|
||||
self.assertEqual(run["passes"][0], run["passes"][1])
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
|
||||
def test_failed_block_write_preserves_original_without_success(self):
|
||||
original = b"# user content\n" + BEGIN + b"old managed block\n" + END
|
||||
# Fail the real stdin writer after a partial canonical-block write.
|
||||
# The override is confined to this shell fixture, not production.
|
||||
injection = r'''
|
||||
cat() {
|
||||
if [[ $# == 0 ]]; then
|
||||
local first
|
||||
IFS= read -r first
|
||||
if [[ "$first" == '# BEGIN PMX_CORE_BASHRC' ]]; then
|
||||
printf '# partial block'
|
||||
return 71
|
||||
fi
|
||||
printf '%s\n' "$first"
|
||||
fi
|
||||
command cat "$@"
|
||||
}
|
||||
'''
|
||||
for flow in FLOWS:
|
||||
with self.subTest(flow=flow):
|
||||
run = self.run_flow(flow, original, injection=injection)
|
||||
self.assertNotEqual(run["status"], [b"0"], run["stderr"])
|
||||
self.assertEqual(run["passes"], [original])
|
||||
self.assertEqual(run["before"], run["after"])
|
||||
self.assertEqual(run["inode_before"], run["inode_after"])
|
||||
self.assertEqual(run["registry"], b"")
|
||||
self.assertEqual(run["messages"], b"")
|
||||
self.assertEqual(run["leftovers"], [])
|
||||
|
||||
def test_orphan_begin_cannot_consume_user_content_on_rerun(self):
|
||||
original = BEGIN + b"user_function() { :; }\n# user suffix\n"
|
||||
for flow in FLOWS:
|
||||
with self.subTest(flow=flow):
|
||||
run = self.run_flow(flow, original, times=2)
|
||||
self.assertEqual(run["status"], [b"0", b"0"], run["stderr"])
|
||||
for content in run["passes"]:
|
||||
self.assertTrue(content.startswith(original), content)
|
||||
self.assertEqual(run["passes"][0], run["passes"][1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Log2RAM syncs only the archived journal files, so the active file it found
|
||||
on disk when it was installed stays there and is restored at every boot.
|
||||
journald then continues an old sequence and deletes its newest archives first.
|
||||
The post-install makes the sync remove that file from the disk copy."""
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SHARED = ROOT / "scripts/global/utils-install-functions.sh"
|
||||
FLOWS = {"auto_post_install.sh": "install_log2ram_auto", "customizable_post_install.sh": "configure_log2ram"}
|
||||
|
||||
# The sync of Log2RAM in its journald-aware mode, as its project ships it.
|
||||
JOURNALD_AWARE = '''#!/usr/bin/env bash
|
||||
sync_to_disk() {
|
||||
is_safe
|
||||
|
||||
local optional_params=()
|
||||
|
||||
if journald_logrotate; then
|
||||
optional_params+=("--include=journal/*/*@*.journal")
|
||||
optional_params+=("--exclude=journal/*/*")
|
||||
fi
|
||||
|
||||
rsync -aXv --no-acls --delete-after "${optional_params[@]}" "${RAM_LOG}"/ "${HDD_LOG}"/
|
||||
}
|
||||
'''
|
||||
NOT_AWARE = '''#!/usr/bin/env bash
|
||||
sync_to_disk() {
|
||||
rsync -aXv --delete-after "${RAM_LOG}"/ "${HDD_LOG}"/
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def gnu_sed():
|
||||
return shutil.which("bash") and subprocess.run(["sed", "--version"], capture_output=True).returncode == 0
|
||||
|
||||
|
||||
@unittest.skipUnless(gnu_sed(), "bash and GNU sed are needed")
|
||||
class Log2ramJournalSyncTests(unittest.TestCase):
|
||||
def correct(self, folder, content, times=1):
|
||||
script = Path(folder) / "log2ram"
|
||||
script.write_text(content)
|
||||
script.chmod(0o755)
|
||||
calls = "; ".join(['pmx_log2ram_drop_stale_journal "$1"; echo "rc=$?"'] * times)
|
||||
# As the change journal does, the new content is read whole before it is written.
|
||||
write = 'pmx_write_file() { local content; content="$(cat)"; printf "%s\\n" "$content" > "$1"; }'
|
||||
run = subprocess.run(["bash", "-c", f'source "{SHARED}"; {write}; {calls}',
|
||||
"bash", str(script)], capture_output=True, text=True, check=False)
|
||||
return script, run.stdout.split()
|
||||
|
||||
def test_the_sync_removes_what_it_does_not_copy(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
script, results = self.correct(folder, JOURNALD_AWARE)
|
||||
lines = script.read_text().splitlines()
|
||||
position = lines.index(' optional_params+=("--exclude=journal/*/*")')
|
||||
self.assertEqual(lines[position + 1], ' optional_params+=("--delete-excluded")')
|
||||
self.assertEqual(lines[position + 2], " fi")
|
||||
self.assertEqual(results, ["rc=0"])
|
||||
self.assertEqual(subprocess.run(["bash", "-n", str(script)]).returncode, 0)
|
||||
self.assertTrue(script.stat().st_mode & stat.S_IXUSR)
|
||||
self.assertEqual(Path(str(script) + ".proxmenux.bak").read_text(), JOURNALD_AWARE)
|
||||
|
||||
def test_it_is_applied_once(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
script, results = self.correct(folder, JOURNALD_AWARE, times=2)
|
||||
self.assertEqual(results, ["rc=0", "rc=1"])
|
||||
self.assertEqual(script.read_text().count("--delete-excluded"), 1)
|
||||
|
||||
def test_a_sync_that_copies_every_file_is_left_as_it_is(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
script, results = self.correct(folder, NOT_AWARE)
|
||||
self.assertEqual(results, ["rc=1"])
|
||||
self.assertEqual(script.read_text(), NOT_AWARE)
|
||||
self.assertFalse(Path(str(script) + ".proxmenux.bak").exists())
|
||||
|
||||
def test_both_flows_apply_it_to_new_and_existing_installations(self):
|
||||
for name, function in FLOWS.items():
|
||||
with self.subTest(flow=name):
|
||||
text = (ROOT / "scripts/post_install" / name).read_text()
|
||||
self.assertEqual(text.count("pmx_log2ram_drop_stale_journal"), 2)
|
||||
# An existing installation is told that it needs a reboot.
|
||||
self.assertIn('if pmx_log2ram_drop_stale_journal "$log2ram_bin"; then\n NECESSARY_REBOOT=1', text)
|
||||
# The version is what offers the update to existing installations.
|
||||
self.assertIn(f'{function}() {{\n local FUNC_VERSION="1.7"', text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Log2RAM keeps /var/log in RAM while the journal and the task logs of
|
||||
Proxmox VE and Proxmox Backup Server stay on disk behind a link. An update keeps the size
|
||||
and interval the host already has."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
KEEP = ROOT / "scripts/global/log2ram_keep_on_disk.sh"
|
||||
SETUP = ROOT / "scripts/global/log2ram_setup.sh"
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
|
||||
class KeepOnDiskTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(folder.cleanup)
|
||||
self.root = Path(folder.name)
|
||||
self.log = self.root / "var/log"
|
||||
(self.log / "journal/abc").mkdir(parents=True)
|
||||
(self.log / "journal/abc/system.journal").write_text("entries")
|
||||
(self.log / "pveproxy").mkdir()
|
||||
(self.log / "pveproxy/access.log").write_text("GET /")
|
||||
self.config = self.root / "etc/log2ram.conf"
|
||||
self.config.parent.mkdir()
|
||||
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=true\nNOTIFICATION=true\n")
|
||||
|
||||
def run_setup(self, edit_failure=False):
|
||||
# Extract only the shared setup function, never an administrative caller.
|
||||
body = SETUP.read_text().split("pmx_log2ram_setup() {", 1)[1].split("\n}\n", 1)[0]
|
||||
body = body.replace("/etc/", str(self.root / "etc") + "/")
|
||||
for directory in ("cron.d", "tmpfiles.d"):
|
||||
(self.root / "etc" / directory).mkdir(exist_ok=True)
|
||||
stubs = r'''
|
||||
translate() { printf '%s' "$1"; }
|
||||
msg_info() { :; }
|
||||
msg_ok() { :; }
|
||||
pmx_install_pkg() { :; }
|
||||
_pmx_log2ram_install_release() { :; }
|
||||
_pmx_log2ram_pbs_logrotate() { :; }
|
||||
_pmx_log2ram_remove_legacy() { :; }
|
||||
pmx_edit_file() { local file="$1"; shift; sed -i "$@" "$file"; }
|
||||
pmx_append_file() { cat >> "$1"; }
|
||||
pmx_write_file() { cat > "$1"; }
|
||||
pmx_remove_file() { rm -f "$1"; }
|
||||
pmx_apply_setting() { :; }
|
||||
systemctl() { printf '%s\n' "$*" >> "$SYSTEMCTL_TRACE"; }
|
||||
'''
|
||||
if edit_failure:
|
||||
# A failed configuration writer must be handled even by root runners.
|
||||
stubs += "\npmx_edit_file() { return 1; }\n"
|
||||
script = (stubs + "\npmx_log2ram_setup() {" + body + "\n}\n"
|
||||
+ "pmx_log2ram_setup 256 3 false\n")
|
||||
env = dict(os.environ, SYSTEMCTL_TRACE=str(self.root / "systemctl.trace"),
|
||||
PMX_L2R_TAG="1.7.2", PMX_L2R_SCRIPTS=str(KEEP.parent),
|
||||
PMX_L2R_REVERT_UNIT=str(self.root / "etc/revert.service"))
|
||||
return subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True, timeout=10)
|
||||
|
||||
def test_setup_keeps_journal_aware_syncing_until_boot(self):
|
||||
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=false\nNOTIFICATION=true\n")
|
||||
result = self.run_setup()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||
self.assertIn("SIZE=256M\n", self.config.read_text())
|
||||
self.assertFalse((self.log / "journal").is_symlink())
|
||||
self.assertEqual((self.root / "systemctl.trace").read_text(), "daemon-reload\n")
|
||||
|
||||
def test_boot_disables_journal_aware_syncing_after_relocation(self):
|
||||
self.assertEqual(self.run_setup().returncode, 0)
|
||||
result = self.run_script()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
|
||||
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
|
||||
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
|
||||
self.assertIn("SIZE=256M\n", self.config.read_text())
|
||||
# Reapplying the repair to an already-relocated installation is safe.
|
||||
self.assertEqual(self.run_setup().returncode, 0)
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
|
||||
self.assertEqual(sorted(p.name for p in (self.root / "var/hdd.journal").iterdir()), ["abc"])
|
||||
|
||||
def test_boot_migration_failure_is_not_ignored_by_startup(self):
|
||||
self.assertEqual(self.run_setup().returncode, 0)
|
||||
# A non-directory destination causes a real copy failure, without mounts.
|
||||
conflict = self.root / "var/hdd.journal"
|
||||
conflict.write_text("blocked")
|
||||
unit = self.root / "etc/systemd/system/log2ram.service.d/proxmenux.conf"
|
||||
command = next(line.split("=", 1)[1] for line in unit.read_text().splitlines()
|
||||
if line.startswith("ExecStartPre="))
|
||||
result = subprocess.run(["bash", *shlex.split(command.lstrip("-"))],
|
||||
env=dict(os.environ, PMX_L2R_ROOT=str(self.root)),
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||
self.assertFalse(command.startswith("-"), "systemd must not ignore the helper's failure")
|
||||
conflict.unlink()
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
|
||||
|
||||
def test_setup_adds_journal_aware_setting_when_missing(self):
|
||||
self.config.write_text("SIZE=128M\n#JOURNALD_AWARE=false\n")
|
||||
result = self.run_setup()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("\nJOURNALD_AWARE=true\n", self.config.read_text())
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
self.assertIn("\nJOURNALD_AWARE=false\n", self.config.read_text())
|
||||
|
||||
def check_missing_setting_without_final_newline(self, content):
|
||||
self.config.write_text(content)
|
||||
result = self.run_setup()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
for expected in ("true", "false"):
|
||||
with self.subTest(journal_aware=expected):
|
||||
# Read shell semantics in a fresh Bash, not a substring match.
|
||||
sourced = subprocess.run(
|
||||
["bash", "--noprofile", "--norc", "-c",
|
||||
'unset JOURNALD_AWARE NOTIFICATION; source "$1"; '
|
||||
'printf "%s\\n%s\\n" "${JOURNALD_AWARE-UNSET}" "${NOTIFICATION-UNSET}"',
|
||||
"fixture", str(self.config)],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(sourced.returncode, 0, sourced.stderr)
|
||||
self.assertEqual(sourced.stdout, expected + "\nfalse\n")
|
||||
|
||||
if expected == "true":
|
||||
self.assertFalse((self.log / "journal").is_symlink())
|
||||
self.assertEqual((self.log / "journal/abc/system.journal").read_text(), "entries")
|
||||
result = self.run_script()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
|
||||
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
|
||||
|
||||
def test_missing_setting_after_comment_without_final_newline(self):
|
||||
self.check_missing_setting_without_final_newline(
|
||||
"SIZE=128M\nNOTIFICATION=false\n#JOURNALD_AWARE=false")
|
||||
|
||||
def test_missing_setting_after_assignment_without_final_newline(self):
|
||||
self.check_missing_setting_without_final_newline("SIZE=128M\nNOTIFICATION=false")
|
||||
|
||||
def test_setup_reports_configuration_edit_failure(self):
|
||||
self.config.write_text("SIZE=128M\nJOURNALD_AWARE=false\nNOTIFICATION=false\n")
|
||||
result = self.run_setup(edit_failure=True)
|
||||
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("JOURNALD_AWARE=false\n", self.config.read_text())
|
||||
|
||||
def test_an_existing_link_with_an_unusable_disk_target_fails(self):
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
self.config.write_text("JOURNALD_AWARE=true\n")
|
||||
disk = self.root / "var/hdd.journal"
|
||||
shutil.rmtree(disk)
|
||||
disk.write_text("blocked")
|
||||
result = self.run_script()
|
||||
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(self.config.read_text(), "JOURNALD_AWARE=true\n")
|
||||
|
||||
def test_an_unexpected_journal_link_does_not_complete_migration(self):
|
||||
shutil.rmtree(self.log / "journal")
|
||||
(self.log / "journal").symlink_to("pveproxy")
|
||||
result = self.run_script()
|
||||
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(os.readlink(self.log / "journal"), "pveproxy")
|
||||
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||
|
||||
def test_a_missing_journal_with_an_unusable_disk_target_fails(self):
|
||||
shutil.rmtree(self.log / "journal")
|
||||
(self.root / "var/hdd.journal").write_text("blocked")
|
||||
result = self.run_script()
|
||||
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||
self.assertFalse((self.log / "journal").is_symlink())
|
||||
|
||||
def test_failed_source_removal_does_not_complete_migration(self):
|
||||
(self.root / "var/hdd.journal").mkdir()
|
||||
result = subprocess.run(
|
||||
["bash", "-c", 'rm() { return 1; }; export -f rm; exec bash "$1"', "fixture", str(KEEP)],
|
||||
env=dict(os.environ, PMX_L2R_ROOT=str(self.root)), capture_output=True, text=True, timeout=10)
|
||||
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("JOURNALD_AWARE=true\n", self.config.read_text())
|
||||
self.assertTrue((self.log / "journal/abc/system.journal").is_file())
|
||||
|
||||
def test_boot_configuration_failure_is_reported_and_can_be_retried(self):
|
||||
self.config.unlink()
|
||||
self.config.mkdir()
|
||||
result = self.run_script()
|
||||
self.assertNotEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
|
||||
self.config.rmdir()
|
||||
self.config.write_text("JOURNALD_AWARE=true\n")
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
self.assertEqual(self.config.read_text(), "JOURNALD_AWARE=false\n")
|
||||
|
||||
def run_script(self, revert=False):
|
||||
env = dict(os.environ, PMX_L2R_ROOT=str(self.root), PMX_L2R_REVERT="1" if revert else "0")
|
||||
return subprocess.run(["bash", str(KEEP)], env=env, capture_output=True, text=True, timeout=10)
|
||||
|
||||
def test_the_journal_moves_to_disk_behind_a_link(self):
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
journal = self.log / "journal"
|
||||
self.assertTrue(journal.is_symlink())
|
||||
self.assertEqual(os.readlink(journal), "/var/hdd.journal")
|
||||
self.assertEqual((self.root / "var/hdd.journal/abc/system.journal").read_text(), "entries")
|
||||
self.assertTrue((self.log / "pveproxy/access.log").exists())
|
||||
self.assertFalse((self.root / "var/hdd.proxmox-backup").exists())
|
||||
|
||||
def test_running_again_changes_nothing(self):
|
||||
self.run_script()
|
||||
self.assertEqual(self.run_script().returncode, 0)
|
||||
self.assertEqual(os.readlink(self.log / "journal"), "/var/hdd.journal")
|
||||
self.assertEqual(sorted(p.name for p in (self.root / "var/hdd.journal").iterdir()), ["abc"])
|
||||
|
||||
def test_the_task_logs_of_backup_server_stay_on_disk_too(self):
|
||||
(self.log / "proxmox-backup/tasks").mkdir(parents=True)
|
||||
(self.log / "proxmox-backup/tasks/active").write_text("task")
|
||||
self.run_script()
|
||||
self.assertEqual(os.readlink(self.log / "proxmox-backup"), "/var/hdd.proxmox-backup")
|
||||
self.assertEqual((self.root / "var/hdd.proxmox-backup/tasks/active").read_text(), "task")
|
||||
|
||||
def test_files_written_back_to_the_folder_join_the_copy_on_disk(self):
|
||||
self.run_script()
|
||||
(self.log / "journal").unlink()
|
||||
(self.log / "journal/def").mkdir(parents=True)
|
||||
(self.log / "journal/def/system.journal").write_text("later")
|
||||
self.run_script()
|
||||
disk = self.root / "var/hdd.journal"
|
||||
self.assertEqual(sorted(p.name for p in disk.iterdir()), ["abc", "def"])
|
||||
self.assertTrue((self.log / "journal").is_symlink())
|
||||
|
||||
def test_a_host_without_a_journal_folder_gets_one_on_disk(self):
|
||||
shutil.rmtree(self.log / "journal")
|
||||
self.run_script()
|
||||
self.assertTrue((self.root / "var/hdd.journal").is_dir())
|
||||
self.assertTrue((self.log / "journal").is_symlink())
|
||||
|
||||
def test_the_task_logs_of_proxmox_ve_stay_on_disk(self):
|
||||
(self.log / "pve/tasks").mkdir(parents=True)
|
||||
(self.log / "pve/tasks/index").write_text("UPID")
|
||||
self.run_script()
|
||||
self.assertEqual(os.readlink(self.log / "pve"), "/var/hdd.pve")
|
||||
self.assertEqual((self.root / "var/hdd.pve/tasks/index").read_text(), "UPID")
|
||||
|
||||
def test_removing_log2ram_puts_the_folders_back(self):
|
||||
(self.log / "proxmox-backup").mkdir()
|
||||
self.run_script()
|
||||
self.config.unlink() # Revert must not require or rewrite Log2RAM settings.
|
||||
self.assertEqual(self.run_script(revert=True).returncode, 0)
|
||||
for name in ("journal", "proxmox-backup"):
|
||||
self.assertFalse((self.log / name).is_symlink(), name)
|
||||
self.assertTrue((self.log / name).is_dir(), name)
|
||||
self.assertFalse((self.root / f"var/hdd.{name}").exists(), name)
|
||||
self.assertEqual((self.log / "journal/abc/system.journal").read_text(), "entries")
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "bash is needed")
|
||||
class KeptSettingsTests(unittest.TestCase):
|
||||
def read(self, function, files):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
script = SETUP.read_text()
|
||||
for path, content in files.items():
|
||||
target = Path(folder) / path.lstrip("/")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content)
|
||||
script = script.replace(path, str(target))
|
||||
library = Path(folder) / "setup.sh"
|
||||
library.write_text(script)
|
||||
result = subprocess.run(["bash", "-c", f'source "{library}"; {function}'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
return result.stdout.strip()
|
||||
|
||||
def test_the_size_comes_from_the_installed_configuration(self):
|
||||
self.assertEqual(self.read("pmx_log2ram_current_size", {"/etc/log2ram.conf": "SIZE=256M\n"}), "256")
|
||||
|
||||
def test_the_interval_comes_from_the_timer_of_this_version(self):
|
||||
timer = "[Timer]\nOnCalendar=\nOnCalendar=*-*-* 00/6:55:00\n"
|
||||
self.assertEqual(self.read("pmx_log2ram_current_hours",
|
||||
{"/etc/systemd/system/log2ram-daily.timer.d/proxmenux.conf": timer}), "6")
|
||||
|
||||
def test_the_interval_of_an_earlier_version_is_kept(self):
|
||||
cron = "MAILTO=\"\"\n0 */3 * * * root /usr/local/bin/log2ram write >/dev/null 2>&1\n"
|
||||
self.assertEqual(self.read("pmx_log2ram_current_hours", {"/etc/cron.d/log2ram": cron}), "3")
|
||||
|
||||
def test_both_modes_offer_a_repair_to_version_2_0_installs(self):
|
||||
for filename, function, updater in (
|
||||
("auto_post_install.sh", "install_log2ram_auto", "_update_existing_log2ram_auto"),
|
||||
("customizable_post_install.sh", "configure_log2ram", "_update_existing_log2ram_custom"),
|
||||
):
|
||||
with self.subTest(mode=filename), tempfile.TemporaryDirectory() as folder:
|
||||
config = Path(folder) / "log2ram.conf"
|
||||
config.write_text("JOURNALD_AWARE=false\n")
|
||||
source = (ROOT / "scripts/post_install" / filename).read_text()
|
||||
body = source.split(function + "() {", 1)[1].split("\n}\n", 1)[0]
|
||||
body = body.replace("/etc/log2ram.conf", str(config))
|
||||
script = ("pmx_journal_context() { :; }\n"
|
||||
+ updater + "() { printf '%s\\n' \"$1\"; }\n"
|
||||
+ function + "() {" + body + "\n}\n" + function)
|
||||
result = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), "2.1")
|
||||
|
||||
def test_the_release_is_pinned_to_a_verified_commit(self):
|
||||
text = SETUP.read_text()
|
||||
self.assertIn('PMX_L2R_TAG="1.7.2"', text)
|
||||
self.assertIn('PMX_L2R_COMMIT="f171449530f8984374c8f69fff3c92af813a694b"', text)
|
||||
self.assertNotIn("install.sh", text.split("_pmx_log2ram_install_release() {", 1)[1].split("\n}\n", 1)[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -110,7 +110,7 @@ class LynisThresholdContracts(unittest.TestCase):
|
||||
_, module = extract_function(APP / 'scripts/health_monitor.py', '_check_memory_comprehensive', owner='HealthMonitor')
|
||||
# The method is bound to an inert class, not the production constructor.
|
||||
self_obj = SimpleNamespace(state_history=defaultdict(list), MEMORY_DURATION=300, SWAP_CRITICAL_DURATION=300,
|
||||
MEMORY_WARNING=80, SWAP_HIGH_PERCENT=80, AVAILABLE_MIN_PERCENT=20)
|
||||
MEMORY_WARNING=80, MEMORY_CRITICAL=90, SWAP_HIGH_PERCENT=80, AVAILABLE_MIN_PERCENT=20)
|
||||
memory = SimpleNamespace(percent=50, available=10, total=100)
|
||||
swap = SimpleNamespace(percent=90, used=90, total=100)
|
||||
namespace = {'Dict': dict, 'Any': object, 'psutil': SimpleNamespace(virtual_memory=lambda: memory, swap_memory=lambda: swap),
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Human diagnostics translate; IDs, JSON, error types and forbidden effects do not."""
|
||||
from contextlib import ExitStack, nullcontext, redirect_stderr, redirect_stdout
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
import urllib.error
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import test_oci_complete_ui_sk as fixtures
|
||||
from proxmenux_oci import casaos, cli, converter, i18n
|
||||
from proxmenux_oci.catalog import Catalog
|
||||
from proxmenux_oci.github_source import GitHubSource, SourceError
|
||||
import oci_image_cache
|
||||
import oci_dhcp_lease
|
||||
import oci_rocm_check
|
||||
import oci_ui
|
||||
import oci_watchdog
|
||||
import configure_jellyfin_encoding
|
||||
|
||||
|
||||
class TechnicalMessages(unittest.TestCase):
|
||||
setUp = fixtures.CompleteUI.setUp
|
||||
patch = fixtures.CompleteUI.patch
|
||||
|
||||
def remote_language(self):
|
||||
self.patch(oci_ui, '_language', 'sk')
|
||||
self.patch(oci_ui, '_cache', self.sk)
|
||||
|
||||
def test_real_readme_errors_translate_only_at_the_display_boundary(self):
|
||||
for text, expected in [('# App', 'README neobsahuje'),
|
||||
('## docker-compose\nplain text', 'Sekcia docker-compose neobsahuje blok kódu'),
|
||||
('## docker-compose\n```yaml\nservices: {}', 'Blok docker-compose nie je ukončený'),
|
||||
('## docker-compose\n```yaml\nnot-compose\n```', 'Nájdený blok nevyzerá')]:
|
||||
with self.subTest(text=text), self.assertRaises(converter.ConversionError) as caught:
|
||||
converter.extract_compose(text)
|
||||
raw = str(caught.exception)
|
||||
self.assertIn(expected, i18n.diagnostic(caught.exception))
|
||||
self.assertEqual(str(caught.exception), raw)
|
||||
|
||||
def test_schema_error_keeps_field_details_and_original_exception(self):
|
||||
with self.assertRaises(converter.ConversionError) as caught:
|
||||
self.catalog.validate({})
|
||||
raw = str(caught.exception)
|
||||
self.assertTrue(raw.startswith('La plantilla generada no cumple el esquema:'))
|
||||
self.assertEqual(i18n.diagnostic(caught.exception), 'Vygenerovaná šablóna nezodpovedá schéme:' + raw.split(':', 1)[1])
|
||||
|
||||
def test_missing_schema_validator_is_distinct_from_invalid_schema_without_effects(self):
|
||||
template = {}
|
||||
read = self.patch(Path, 'read_text', side_effect=AssertionError('schema must not be read without a validator'))
|
||||
with patch.dict(sys.modules, {'jsonschema': None}):
|
||||
with self.assertRaises(converter.ConversionError) as caught:
|
||||
self.catalog.validate(template)
|
||||
raw = 'python3-jsonschema is required to generate templates'
|
||||
self.assertEqual(str(caught.exception), raw)
|
||||
self.assertEqual(i18n.diagnostic(caught.exception), 'Na generovanie šablón je potrebný python3-jsonschema')
|
||||
self.assertEqual(str(caught.exception), raw)
|
||||
self.assertIsNone(self.catalog.validate(template, required=False))
|
||||
read.assert_not_called()
|
||||
self.assertEqual(template, {})
|
||||
for effect in self.effects:
|
||||
effect.assert_not_called()
|
||||
|
||||
def test_real_casaos_error_preserves_the_protocol_and_localizes_the_message(self):
|
||||
with self.assertRaises(converter.ConversionError) as caught:
|
||||
casaos.parse_casaos_compose('[]')
|
||||
self.assertEqual(str(caught.exception), 'The CasaOS document is not a Compose mapping')
|
||||
self.assertEqual(i18n.diagnostic(caught.exception), 'Dokument Compose musí byť mapa kľúčov a hodnôt.')
|
||||
|
||||
def test_github_rate_limit_keeps_url_and_token_variable_without_a_network_call(self):
|
||||
url = 'https://fiction.invalid/catalogue?ref=example'
|
||||
error = urllib.error.HTTPError(url, 403, 'Forbidden', {'X-RateLimit-Remaining': '0'}, None)
|
||||
request = self.patch(__import__('urllib.request', fromlist=['urlopen']), 'urlopen', side_effect=error)
|
||||
with self.assertRaises(SourceError) as caught:
|
||||
GitHubSource(token='fictional-token')._request(url)
|
||||
request.assert_called_once()
|
||||
text = i18n.diagnostic(caught.exception)
|
||||
self.assertIn('GitHub vrátil HTTP 403', text)
|
||||
self.assertIn(url, text)
|
||||
self.assertIn('GITHUB_TOKEN', text)
|
||||
self.assertNotIn('fictional-token', text)
|
||||
|
||||
def test_github_timeout_retains_external_reason_and_three_attempts(self):
|
||||
request = self.patch(__import__('urllib.request', fromlist=['urlopen']), 'urlopen',
|
||||
side_effect=urllib.error.URLError('fictional external transport reason'))
|
||||
self.patch(__import__('time'), 'sleep')
|
||||
with self.assertRaises(SourceError) as caught:
|
||||
GitHubSource()._request('https://fiction.invalid/template')
|
||||
self.assertEqual(request.call_count, 3)
|
||||
self.assertIn('ani po 3 pokusoch: fictional external transport reason', i18n.diagnostic(caught.exception))
|
||||
|
||||
def test_unknown_diagnostics_and_machine_codes_pass_through_unchanged(self):
|
||||
for value in ['service:db:healthcheck-format', 'external tool: failure at 0x1234',
|
||||
'Check failed', 'Invalid cache', 'Ambiguous application', 'private-operation-id']:
|
||||
self.assertEqual(i18n.diagnostic(value), value)
|
||||
|
||||
def test_cli_help_preserves_flags_actions_defaults_and_numeric_types(self):
|
||||
argv = ['manage', '104', '--action', 'recreate', '--unattended', '--min-image-age-days', '7']
|
||||
slovak = cli.build_parser()
|
||||
self.assertIn('Vypísať aplikácie', slovak.format_help())
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
english = cli.build_parser()
|
||||
self.assertIn('List applications', english.format_help())
|
||||
self.assertEqual(vars(english.parse_args(argv)), vars(slovak.parse_args(argv)))
|
||||
|
||||
def test_cli_summary_preserves_image_reference_ports_mount_paths_and_status_code(self):
|
||||
template = self.catalog.compose('tandoor')
|
||||
saved = copy.deepcopy(template)
|
||||
text = cli._template_summary_text(template)
|
||||
self.assertIn('Obraz: ' + template['container_contract']['image']['reference'], text)
|
||||
self.assertIn('Dátové zväzky:', text)
|
||||
self.assertIn('Stav: ' + template['status'], text)
|
||||
for mount in template['container_contract']['volumes']:
|
||||
self.assertIn(mount['container_path'], text)
|
||||
self.assertEqual(template, saved)
|
||||
|
||||
def test_install_cli_no_stays_successful_cancellation_without_dispatch(self):
|
||||
template = self.catalog.compose('jellyfin')
|
||||
saved = copy.deepcopy(template)
|
||||
self.patch(cli.Catalog, 'compose', return_value=template)
|
||||
self.patch(cli, 'build_deployment', return_value={'fiction': True})
|
||||
self.patch(cli, '_deployment_summary_text', return_value='Fiktívny plán')
|
||||
answer = self.patch(__import__('builtins'), 'input', return_value='n')
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): self.assertEqual(cli.main(['install', 'jellyfin']), 0)
|
||||
self.assertIn('Nainštalovať? [y/N]:', answer.call_args.args[0])
|
||||
self.assertEqual(output.getvalue().count('Inštalácia bola zrušená.'), 1)
|
||||
self.assertEqual(template, saved)
|
||||
|
||||
def test_blocked_install_remains_error_one_and_never_asks_to_install(self):
|
||||
template = self.catalog.compose('jellyfin')
|
||||
template['compatibility'].update(automatic_install_candidate=False, untranslated_blockers=['fiction-blocker-code'])
|
||||
self.patch(cli.Catalog, 'compose', return_value=template)
|
||||
answer = self.patch(__import__('builtins'), 'input', side_effect=AssertionError('blocked installation asked a question'))
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(io.StringIO()), redirect_stderr(output):
|
||||
self.assertEqual(cli.main(['install', 'jellyfin']), 1)
|
||||
self.assertIn('Inštalácia je zablokovaná: fiction-blocker-code', output.getvalue())
|
||||
answer.assert_not_called()
|
||||
|
||||
def test_watchdog_manual_stop_stays_stopped_without_start_or_notice(self):
|
||||
self.remote_language()
|
||||
identity = '11111111-1111-4111-8111-111111111111'
|
||||
self.patch(oci_watchdog, 'watched', return_value={777: ('Fiction', identity)})
|
||||
self.patch(oci_watchdog.instances, 'locked', side_effect=lambda *a: nullcontext())
|
||||
self.patch(oci_watchdog, 'same_installation', return_value=True)
|
||||
self.patch(oci_watchdog, 'is_running', return_value=False)
|
||||
self.patch(oci_watchdog, 'is_busy', return_value=False)
|
||||
self.patch(oci_watchdog, 'read_tasks', return_value=[('vzshutdown', '777', 75)])
|
||||
start = self.patch(oci_watchdog, 'start', side_effect=AssertionError('manual stop was undone'))
|
||||
notice = self.patch(oci_watchdog.oci_operation_notice, 'notify')
|
||||
state = oci_watchdog.new_state(identity); state['seen'] = 50
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): oci_watchdog.look({777: state}, 100)
|
||||
self.assertTrue(state['settled'])
|
||||
self.assertEqual(output.getvalue(), '')
|
||||
start.assert_not_called(); notice.assert_not_called()
|
||||
|
||||
def test_watchdog_localization_does_not_change_retry_state_or_notification_payload(self):
|
||||
identity = '11111111-1111-4111-8111-111111111111'
|
||||
self.patch(oci_watchdog, 'watched', return_value={777: ('Fiction', identity)})
|
||||
self.patch(oci_watchdog.instances, 'locked', side_effect=lambda *a: nullcontext())
|
||||
self.patch(oci_watchdog, 'same_installation', return_value=True)
|
||||
self.patch(oci_watchdog, 'is_running', return_value=False)
|
||||
self.patch(oci_watchdog, 'is_busy', return_value=False)
|
||||
self.patch(oci_watchdog, 'read_tasks', return_value=[])
|
||||
start = self.patch(oci_watchdog, 'start', return_value=True)
|
||||
notice = self.patch(oci_watchdog.oci_operation_notice, 'notify')
|
||||
outcomes = []
|
||||
for locale in ('en', 'sk'):
|
||||
with patch.object(oci_ui, '_language', locale), patch.object(oci_ui, '_cache', self.sk):
|
||||
state = oci_watchdog.new_state(identity); state['seen'] = 50
|
||||
text = io.StringIO()
|
||||
with redirect_stdout(text): oci_watchdog.look({777: state}, 100)
|
||||
outcomes.append((copy.deepcopy(state), notice.call_args, start.call_args))
|
||||
self.assertIn('CT 777 (Fiction)', text.getvalue())
|
||||
if locale == 'sk': self.assertIn('spúšťa sa znova (pokus 1)', text.getvalue())
|
||||
self.assertEqual(outcomes[0], outcomes[1])
|
||||
|
||||
def test_image_cache_json_contract_and_invalid_command_have_no_mutations(self):
|
||||
self.remote_language()
|
||||
self.patch(oci_image_cache.instances, 'locked', side_effect=lambda *a: nullcontext())
|
||||
self.patch(oci_image_cache, 'archives_of', return_value={})
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): self.assertEqual(oci_image_cache.main(['list', '777']), 0)
|
||||
self.assertEqual(json.loads(output.getvalue()), {'archives': [], 'freed': 0})
|
||||
error = io.StringIO()
|
||||
with redirect_stderr(error): self.assertEqual(oci_image_cache.main(['invalid']), 2)
|
||||
self.assertIn('Použitie: oci_image_cache.py [prune | list VMID... | remove VMID...]', error.getvalue())
|
||||
|
||||
def test_rocm_timeout_localizes_but_external_gpu_errors_are_preserved(self):
|
||||
self.remote_language()
|
||||
self.patch(sys, 'argv', ['oci_rocm_check.py', '777'])
|
||||
for raw, expected in [('the GPU did not answer in time', 'GPU neodpovedalo včas'),
|
||||
('external provider exception 0x123', 'external provider exception 0x123')]:
|
||||
with self.subTest(raw=raw), patch.object(oci_rocm_check, 'check', side_effect=RuntimeError(raw)):
|
||||
output = io.StringIO()
|
||||
with redirect_stderr(output): self.assertEqual(oci_rocm_check.main(), 1)
|
||||
self.assertEqual(output.getvalue().strip(), expected)
|
||||
|
||||
def test_jellyfin_completion_distinguishes_changed_from_already_applied(self):
|
||||
self.remote_language()
|
||||
self.patch(sys, 'argv', ['configure_jellyfin_encoding.py', '/fiction-root', '{}'])
|
||||
for changed, expected in [(True, 'Nastavenia Jellyfin boli aktualizované:'),
|
||||
(False, 'Nastavenia Jellyfin už boli použité:')]:
|
||||
with self.subTest(changed=changed), patch.object(configure_jellyfin_encoding, 'update_encoding',
|
||||
return_value=(Path('/fiction-root/config/encoding.xml'), changed)):
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): configure_jellyfin_encoding.main()
|
||||
self.assertEqual(output.getvalue().strip(), expected + ' /config/encoding.xml')
|
||||
|
||||
def test_dhcp_completion_keeps_interface_and_ip_version_without_repairing_a_guest(self):
|
||||
self.remote_language()
|
||||
self.patch(oci_dhcp_lease, 'LOCKS', MagicMock())
|
||||
self.patch(oci_dhcp_lease.fcntl, 'flock')
|
||||
repair = self.patch(oci_dhcp_lease, 'repair', return_value=[('eth0', 4)])
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output): oci_dhcp_lease.check(777, 0)
|
||||
repair.assert_called_once_with(777, 0)
|
||||
self.assertEqual(output.getvalue().strip(), 'CT 777: klient DHCP rozhrania eth0 (IPv4) bol znova spustený')
|
||||
|
||||
|
||||
class StandalonePublisher(unittest.TestCase):
|
||||
def test_mount_error_keeps_host_language_after_entering_the_container_root(self):
|
||||
spec = importlib.util.spec_from_file_location('fiction_namespace_publisher',
|
||||
fixtures.ROOT / 'oci/remote/rclone_mount_publish.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
inside = False
|
||||
def chroot(path):
|
||||
nonlocal inside
|
||||
inside = True
|
||||
def read(path, **kwargs):
|
||||
if inside:
|
||||
raise FileNotFoundError('Host language catalog is outside the container root')
|
||||
return ('{"language":"sk"}' if path.name == 'config.json' else
|
||||
'{"clone source mount tree":"naklonovať strom zdrojového pripojenia"}')
|
||||
libc = Mock(); libc.setns.return_value = 0; libc.syscall.return_value = -1
|
||||
with ExitStack() as guards:
|
||||
for target, name, options in [
|
||||
(sys, 'argv', {'new': ['publisher.py', '777', '/fiction-source', '/fiction-target', 'ro']}),
|
||||
(module.platform, 'machine', {'return_value': 'x86_64'}),
|
||||
(Path, 'read_text', {'autospec': True, 'side_effect': read}),
|
||||
(module.ctypes, 'CDLL', {'return_value': libc}),
|
||||
(module.ctypes, 'get_errno', {'return_value': 13}),
|
||||
(module.os, 'open', {'return_value': 42}),
|
||||
(module.os, 'close', {}), (module.os, 'fchdir', {}),
|
||||
(module.os, 'chroot', {'side_effect': chroot}), (module.os, 'chdir', {})]:
|
||||
guards.enter_context(patch.object(target, name, **options))
|
||||
with self.assertRaises(OSError) as caught:
|
||||
module.main()
|
||||
self.assertTrue(inside)
|
||||
self.assertEqual(caught.exception.errno, 13)
|
||||
self.assertIn('naklonovať strom zdrojového pripojenia', str(caught.exception))
|
||||
self.assertEqual(libc.syscall.call_args.args[0], module.SYSCALLS['x86_64'][0])
|
||||
|
||||
def test_copied_helper_has_localized_errors_without_engine_import_or_mount_syscalls(self):
|
||||
with tempfile.TemporaryDirectory(prefix='oci-publisher-language-') as directory:
|
||||
root = Path(directory); (root / 'lang').mkdir()
|
||||
(root / 'config.json').write_text('{"language":"sk"}')
|
||||
(root / 'lang/sk.json').write_text(json.dumps({'Usage:': 'Použitie:', 'Unsupported host architecture:': 'Nepodporovaná architektúra hostiteľa:'}))
|
||||
source = fixtures.ROOT / 'oci/remote/rclone_mount_publish.py'
|
||||
copied = root / 'publisher.py'; copied.write_bytes(source.read_bytes())
|
||||
spec = importlib.util.spec_from_file_location('fiction_standalone_publisher', copied)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {'oci_ui': None}), patch.dict(__import__('os').environ, {'PMX_BASE_DIR': str(root)}):
|
||||
spec.loader.exec_module(module)
|
||||
with patch.object(module.ctypes, 'CDLL', side_effect=AssertionError('mount syscall path entered')) as syscall:
|
||||
with patch.object(sys, 'argv', ['publisher.py']), redirect_stderr(io.StringIO()) as output:
|
||||
self.assertEqual(module.main(), 2)
|
||||
self.assertIn('Použitie: publisher.py PID SOURCE TARGET rw|ro', output.getvalue())
|
||||
with patch.object(sys, 'argv', ['publisher.py', '1', '/fiction-source', '/fiction-target', 'ro']), \
|
||||
patch.object(module.platform, 'machine', return_value='fiction-architecture'), redirect_stderr(io.StringIO()) as output:
|
||||
self.assertEqual(module.main(), 2)
|
||||
self.assertIn('Nepodporovaná architektúra hostiteľa: fiction-architecture', output.getvalue())
|
||||
syscall.assert_not_called()
|
||||
(root / 'config.json').write_text('invalid json')
|
||||
module._messages.cache_clear()
|
||||
self.assertEqual(module.translate('Usage:'), 'Usage:')
|
||||
(root / 'config.json').write_text('{"language":"en"}')
|
||||
module._messages.cache_clear()
|
||||
self.assertEqual(module.translate('Usage:'), 'Usage:')
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Real catalogue/wizard rendering, with host and installation effects forbidden."""
|
||||
import copy
|
||||
from contextlib import ExitStack, redirect_stdout
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(os.environ.get('OCI_I18N_TEST_ROOT', Path(__file__).resolve().parents[3]))
|
||||
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote'), str(ROOT / 'oci/tests')]
|
||||
from proxmenux_oci import cli, i18n, installer, stack
|
||||
from proxmenux_oci.catalog import Catalog
|
||||
from proxmenux_oci.converter import ConversionError
|
||||
from proxmenux_oci.ui import UserCancelled
|
||||
from test_advanced_flow_order import RecordingUI, addresses, storages
|
||||
|
||||
|
||||
class CompleteUI(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.scope = ExitStack()
|
||||
self.addCleanup(self.scope.close)
|
||||
self.sk = json.loads((ROOT / 'lang/sk.json').read_text())
|
||||
self.catalog = Catalog(ROOT / 'oci')
|
||||
self.patch(i18n, '_language', 'sk')
|
||||
self.patch(i18n, '_cache', self.sk)
|
||||
self.patch(installer.host, 'storages', side_effect=storages)
|
||||
self.patch(installer.host, 'bridges', return_value=[{'iface': 'vmbr0', 'cidr': '192.0.2.10/24'}])
|
||||
self.patch(installer.host, 'timezone', return_value='Europe/Bratislava')
|
||||
self.patch(installer.host, 'gpus', return_value={'intel': [], 'amd': [], 'nvidia': False})
|
||||
self.patch(installer.access, 'ask_addresses', side_effect=addresses)
|
||||
self.patch(installer.access, 'ask_ipv4', return_value=('dhcp', None))
|
||||
self.effects = []
|
||||
for module, name in [(cli, '_install'), (cli, '_rclone_mount'), (cli, 'run_remote_install'),
|
||||
(installer, 'run_remote_install'), (cli.images, 'offer_removal'),
|
||||
(subprocess, 'run'), (subprocess, 'Popen'), (subprocess, 'check_output'),
|
||||
(Path, 'write_text'), (Path, 'write_bytes'), (Path, 'unlink')]:
|
||||
self.effects.append(self.patch(module, name, side_effect=AssertionError('Forbidden effect: ' + name)))
|
||||
self.addCleanup(lambda: [effect.assert_not_called() for effect in self.effects])
|
||||
|
||||
def patch(self, obj, name, *args, **kwargs):
|
||||
return self.scope.enter_context(patch.object(obj, name, *args, **kwargs))
|
||||
|
||||
def test_search_no_match_uses_application_wording_and_never_installs(self):
|
||||
ui = Mock()
|
||||
ui.ask.return_value = 'fiction-no-such-app'
|
||||
cli._search(self.catalog, ui, [])
|
||||
self.assertEqual(ui.ask.call_args.args[0], 'Názov alebo časť popisu aplikácie')
|
||||
self.assertIn('Žiadna zhoda aplikácií', ui.message.call_args.args[0])
|
||||
ui.choose.assert_not_called()
|
||||
|
||||
def test_immich_detail_translates_service_roles_without_changing_template(self):
|
||||
template = self.catalog.compose('immich')
|
||||
before = copy.deepcopy(template)
|
||||
text = cli._app_detail_text(self.catalog, self.catalog.find_item('immich'), template)
|
||||
self.assertIn('Strojové učenie', text)
|
||||
self.assertIn('Aplikácia', text)
|
||||
self.assertIn('Oficiálny obraz', text)
|
||||
self.assertNotIn('Machine learning', text)
|
||||
self.assertEqual(template, before)
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
self.assertIn('Machine learning', cli._app_detail_text(self.catalog, self.catalog.find_item('immich'), template))
|
||||
|
||||
def test_missing_catalogue_entry_preserves_raw_error_and_localizes_cli(self):
|
||||
for language, expected in [('sk', "Aplikácia 'fiction-missing' nie je v indexe"),
|
||||
('en', "The application 'fiction-missing' is not in the index")]:
|
||||
with self.subTest(language=language), patch.object(i18n, '_language', language):
|
||||
with self.assertRaisesRegex(ConversionError, "The application 'fiction-missing' is not in the index"):
|
||||
self.catalog.find_item('fiction-missing')
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(io.StringIO()), patch.object(sys, 'stderr', output), patch.object(cli, 'PROJECT_ROOT', ROOT / 'oci'):
|
||||
self.assertEqual(cli.main(['show', 'fiction-missing']), 1)
|
||||
self.assertEqual(output.getvalue().strip(), 'ERROR: ' + expected)
|
||||
|
||||
def test_missing_template_uses_existing_translation_and_keeps_identifier(self):
|
||||
self.patch(self.catalog, 'find_item', return_value={'id': 'fiction-missing'})
|
||||
with self.assertRaises(ConversionError) as caught:
|
||||
self.catalog.compose('fiction-missing')
|
||||
self.assertEqual(str(caught.exception), "No template is available for 'fiction-missing'")
|
||||
self.assertEqual(i18n.diagnostic(caught.exception), "Pre aplikáciu 'fiction-missing' nie je dostupná šablóna")
|
||||
|
||||
def test_detail_cancel_and_escape_do_not_enter_install_or_rclone(self):
|
||||
for app in ('jellyfin', 'rclone', 'immich', 'suite-arr'):
|
||||
for escape in (False, True):
|
||||
with self.subTest(app=app, escape=escape):
|
||||
ui = Mock()
|
||||
ui.confirm.return_value = False
|
||||
ui.detail_menu.return_value = None
|
||||
if escape:
|
||||
ui.detail_menu.side_effect = UserCancelled()
|
||||
try:
|
||||
cli._app_detail(self.catalog, ui, self.catalog.find_item(app))
|
||||
except UserCancelled:
|
||||
self.assertTrue(escape)
|
||||
|
||||
def test_special_wizard_plans_are_identical_in_english_and_slovak(self):
|
||||
for app in ('immich', 'nextcloud-stack', 'paperless-ngx', 'tandoor'):
|
||||
for mode in (installer.DEFAULT_MODE, installer.ADVANCED_MODE):
|
||||
with self.subTest(app=app, mode=mode):
|
||||
template = self.catalog.compose(app)
|
||||
before = copy.deepcopy(template)
|
||||
plan_sk = installer.build_deployment(template, RecordingUI(), mode)
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
plan_en = installer.build_deployment(template, RecordingUI(), mode)
|
||||
self.assertEqual(plan_sk, plan_en)
|
||||
self.assertEqual(template, before)
|
||||
text = cli._deployment_summary_text(template, plan_sk)
|
||||
self.assertNotIn('Consume/export:', text)
|
||||
if app == 'paperless-ngx':
|
||||
self.assertIn('Priečinky consume a export', text)
|
||||
|
||||
def test_romm_optional_credentials_are_localized_but_variable_names_stay(self):
|
||||
ui = RecordingUI()
|
||||
ui.confirm = lambda text, default=False, **kwargs: (ui.asked.append(text) or text.startswith('Nastaviť IGDB'))
|
||||
ui.password = lambda text, **kwargs: (ui.asked.append(text) or 'fiction-secret')
|
||||
ui.ask = lambda text, default=None, **kwargs: (ui.asked.append(text) or (default if default is not None else 'fiction-value'))
|
||||
plan = installer.build_deployment(self.catalog.compose('romm'), ui, installer.ADVANCED_MODE)
|
||||
self.assertTrue(any('Tajný kľúč klienta IGDB' == text for text in ui.asked))
|
||||
self.assertFalse(any('IGDB Client Secret' == text for text in ui.asked))
|
||||
server = next(service for service in plan['services'] if service['name'] == 'romm')
|
||||
self.assertTrue(any(row['name'] == 'IGDB_CLIENT_SECRET' for row in server['deployment']['environment']))
|
||||
|
||||
def test_tandoor_warning_keeps_negative_requirement_and_first_run_account(self):
|
||||
template = self.catalog.compose('tandoor')
|
||||
warning = i18n.translate(template['proxmox']['security_profile']['warning'])
|
||||
self.assertIn('nevyžaduje privilegovaný', warning)
|
||||
credentials = template['first_run']['credentials']
|
||||
self.assertTrue(any('Vygenerovaný účet správcu Tandoor' == i18n.translate(row['label']) for row in credentials))
|
||||
|
||||
def test_documented_commands_paths_and_literals_remain_copyable(self):
|
||||
fragments = {
|
||||
'rsnapshot starts with the default configuration, which backs up /data into /.snapshots. Edit /config/rsnapshot.conf inside the container to set your own backup points, snapshot root and retention intervals.': ['/data', '/.snapshots', '/config/rsnapshot.conf'],
|
||||
'Each /request command needs a backend: add a [[backends]] block in the same file with the url and api_key of your Sonarr, Radarr or Seerr instance, then restart the container.': ['/request', '[[backends]]', 'url', 'api_key'],
|
||||
'Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>': ['public: false', 'pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>'],
|
||||
'Certificate provider: empty for Let\'s Encrypt, zerossl for ZeroSSL': ["Let's Encrypt", 'zerossl', 'ZeroSSL'],
|
||||
'Libraries: /data/media/movies, /data/media/series and /data/media/music. Select them in the media server.': ['/data/media/movies', '/data/media/series', '/data/media/music'],
|
||||
}
|
||||
for source, tokens in fragments.items():
|
||||
with self.subTest(source=source):
|
||||
for token in tokens:
|
||||
self.assertIn(token, i18n.translate(source))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Exercise the native custom-image and registry UI with the real EN/SK lookup."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / 'oci/src'))
|
||||
from proxmenux_oci import custom, i18n, registry_accounts as accounts, registry_auth as auth
|
||||
from proxmenux_oci.ui import UserCancelled
|
||||
|
||||
COMPOSE = 'services:\n app:\n image: ghcr.io/owner/app:latest\n'
|
||||
|
||||
|
||||
class UI:
|
||||
def __init__(self, choices=(), answers=(), confirms=()):
|
||||
self.choices = iter(choices)
|
||||
self.answers = iter(answers)
|
||||
self.confirms = iter(confirms)
|
||||
self.seen = []
|
||||
|
||||
def choose(self, text, options, default=None, **kwargs):
|
||||
self.seen.append((text, options, kwargs))
|
||||
return next(self.choices)
|
||||
|
||||
def ask(self, text, default=None, **kwargs):
|
||||
self.seen.append(text)
|
||||
return next(self.answers)
|
||||
|
||||
def password(self, text):
|
||||
self.seen.append(text)
|
||||
raise UserCancelled(text)
|
||||
|
||||
def confirm(self, text, default=False, **kwargs):
|
||||
self.seen.append((text, default))
|
||||
return next(self.confirms)
|
||||
|
||||
def review(self, text, title=None, **kwargs):
|
||||
self.seen.append((text, title, kwargs))
|
||||
return False
|
||||
|
||||
def message(self, text, title=None):
|
||||
self.seen.append((text, title))
|
||||
|
||||
info = message
|
||||
|
||||
|
||||
class CustomRegistryLocalizationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name)
|
||||
(self.base / 'lang').mkdir()
|
||||
(self.base / 'config.json').write_text('{"language":"sk"}')
|
||||
(self.base / 'lang/sk.json').write_bytes((ROOT / 'lang/sk.json').read_bytes())
|
||||
for name, value in [('BASE_DIR', self.base), ('_language', None), ('_cache', None)]:
|
||||
item = patch.object(i18n, name, value)
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
# Any unmocked subprocess, network fetch or installation is a failure.
|
||||
self.run = self.guard(custom.subprocess, 'run')
|
||||
self.network = self.guard(custom.urllib.request, 'urlopen')
|
||||
self.install = self.guard(custom, 'install_template')
|
||||
|
||||
def guard(self, module, name):
|
||||
item = patch.object(module, name, side_effect=AssertionError('Forbidden effect: ' + name))
|
||||
mock = item.start()
|
||||
self.addCleanup(item.stop)
|
||||
return mock
|
||||
|
||||
def no_effects(self):
|
||||
self.run.assert_not_called()
|
||||
self.network.assert_not_called()
|
||||
self.install.assert_not_called()
|
||||
|
||||
def manager(self):
|
||||
return SimpleNamespace(path=Path('/fixture/auth.json'),
|
||||
accounts=MagicMock(return_value=[{'registry': 'ghcr.io', 'account': 'owner',
|
||||
'method': 'Používateľské meno / token', 'status': 'Saved'}]),
|
||||
login=MagicMock(), logout=MagicMock())
|
||||
|
||||
def menu(self, ui, manager):
|
||||
with patch.object(accounts, 'require_root'):
|
||||
accounts.registry_accounts_menu(ui, auth=manager)
|
||||
|
||||
def test_dynamic_blockers_are_extracted_and_rendered_in_slovak(self):
|
||||
expected = {
|
||||
'multi-service-compose': 'definuje viac než jeden obraz',
|
||||
'native-multi-lxc-orchestrator-not-yet-implemented': 'definuje viac než jeden obraz',
|
||||
'top-level-configs': 'používa nastavenia Compose configs, ktoré tu nemajú ekvivalent',
|
||||
'devices-format': 'požadované zariadenia sú zadané v nepodporovanom formáte',
|
||||
'healthcheck-format': 'kontrola stavu je zadaná v nepodporovanom formáte',
|
||||
'stop-grace-period-format': 'časový limit zastavenia je zadaný v nepodporovanom formáte',
|
||||
'shm-size-format': 'veľkosť zdieľanej pamäte je zadaná v nepodporovanom formáte',
|
||||
'ulimits-format-or-resource': 'požadované limity systémových prostriedkov nemožno uplatniť',
|
||||
'mem-limit-format-or-below-proxmox-minimum': 'požadovaný limit pamäte nemožno uplatniť',
|
||||
'mem-limit-deploy-consistency-review': 'požaduje dva rozdielne limity pamäte',
|
||||
}
|
||||
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
|
||||
extracted = extractor['extract_python_texts']([ROOT / 'oci/src'])
|
||||
self.assertTrue(set(custom.BLOCKER_TEXTS.values()) <= set(extracted))
|
||||
for code, message in expected.items():
|
||||
with self.subTest(code=code):
|
||||
self.assertEqual(custom.blocker_text('service:app:' + code), message)
|
||||
self.assertEqual(custom.blocker_text('unrecognized:raw-code'), 'unrecognized:raw-code')
|
||||
self.no_effects()
|
||||
|
||||
def test_cancelled_source_menu_is_translated_without_reading_an_image(self):
|
||||
ui = UI([None])
|
||||
with self.assertRaises(UserCancelled) as caught:
|
||||
custom.explore(ui)
|
||||
self.assertEqual(str(caught.exception), 'Výber obrazu bol zrušený')
|
||||
self.assertIn('Obraz, ktorý nie je v katalógu', repr(ui.seen))
|
||||
self.assertIn('Len odkaz na obraz bez súboru Compose', repr(ui.seen))
|
||||
self.no_effects()
|
||||
|
||||
def test_invalid_compose_inputs_reach_localized_error_before_registry_or_install(self):
|
||||
cases = [
|
||||
('services: {}', 'Súbor Compose neobsahuje žiadnu službu'),
|
||||
('services: [', 'Súbor Compose nie je platný YAML:'),
|
||||
(COMPOSE + ' environment: 42\n', 'Položka environment'),
|
||||
(COMPOSE + ' volumes: {}\n', 'Položka volumes'),
|
||||
(COMPOSE + ' ports: invalid\n', 'Položka ports'),
|
||||
(COMPOSE + ' ports: [invalid]\n', 'Neplatný port'),
|
||||
(COMPOSE + ' volumes: [relative:relative]\n', 'Neplatná cesta dátového úložiska'),
|
||||
(COMPOSE + 'x-casaos:\n main: missing\n', 'Položka x-casaos.main'),
|
||||
]
|
||||
for text, expected in cases:
|
||||
with self.subTest(text=text), patch.object(custom, 'read_definition', return_value=(text, 'fixture')), \
|
||||
self.assertRaises(custom.ConversionError) as caught:
|
||||
custom.explore(UI())
|
||||
self.assertIn(expected, str(caught.exception))
|
||||
self.no_effects()
|
||||
|
||||
def test_converter_raw_contract_and_english_ui_are_preserved(self):
|
||||
bad = COMPOSE + ' ports: [invalid]\n'
|
||||
with self.assertRaises(custom.ConversionError) as raw:
|
||||
custom.template_from_compose(bad)
|
||||
self.assertEqual(str(raw.exception), "Puerto no valido: 'invalid'")
|
||||
with patch.object(i18n, '_language', 'en'), \
|
||||
patch.object(custom, 'read_definition', return_value=(bad, 'fixture')), \
|
||||
self.assertRaises(custom.ConversionError) as ui_error:
|
||||
custom.explore(UI())
|
||||
self.assertEqual(str(ui_error.exception), str(raw.exception))
|
||||
self.no_effects()
|
||||
|
||||
def test_conversion_message_retains_literal_diagnostic_data_and_unknown_fallback(self):
|
||||
detail = " {'target': '<path>{literal}\\á'}"
|
||||
message = custom.conversion_message(custom.ConversionError('Ruta de volumen no valida:' + detail))
|
||||
self.assertEqual(message, 'Neplatná cesta dátového úložiska v súbore Compose:' + detail)
|
||||
unknown = 'unknown <diagnostic> {literal}'
|
||||
self.assertEqual(custom.conversion_message(custom.ConversionError(unknown)), unknown)
|
||||
self.no_effects()
|
||||
|
||||
def test_all_legacy_converter_diagnostics_have_slovak_ui_and_unchanged_english(self):
|
||||
messages = [
|
||||
'environment must be a list or a mapping', 'volumes must be a list', 'ports must be a list',
|
||||
'The CasaOS Compose file has no services', 'The CasaOS document is not a Compose mapping',
|
||||
'The Compose file has no x-casaos metadata', 'x-casaos.main no identifica un servicio valido',
|
||||
'The main CasaOS service declares no image', 'Ruta de volumen no valida: /data',
|
||||
'Puerto no valido: invalid', 'Cannot normalise the CasaOS identifier: ???',
|
||||
'Docker Compose CasaOS no valido: fixture',
|
||||
]
|
||||
for text in messages:
|
||||
with self.subTest(text=text):
|
||||
self.assertNotEqual(custom.conversion_message(custom.ConversionError(text)), text)
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
self.assertEqual(custom.conversion_message(custom.ConversionError(text)), text)
|
||||
self.no_effects()
|
||||
|
||||
def test_custom_review_cancel_keeps_installation_disabled(self):
|
||||
ui = UI()
|
||||
with patch.object(custom, 'read_definition', return_value=(COMPOSE, 'vloženého súboru Compose')), \
|
||||
patch.object(custom, 'registry_report', return_value=(True, 'Dostupný obraz')):
|
||||
custom.explore(ui)
|
||||
self.assertIn('Nainštalovať tento obraz?', repr(ui.seen))
|
||||
self.assertIn('Obraz:', repr(ui.seen))
|
||||
self.assertIn('ghcr.io/owner/app:latest', repr(ui.seen))
|
||||
self.no_effects()
|
||||
|
||||
def test_unavailable_image_explains_registry_accounts_without_authentication_or_install(self):
|
||||
ui = UI()
|
||||
with patch.object(custom, 'read_definition', return_value=(COMPOSE, 'fixture')), \
|
||||
patch.object(custom, 'registry_report', return_value=(False, 'Nedostupný obraz')), \
|
||||
patch.object(custom, '_registry_refused', return_value=False), \
|
||||
patch.object(accounts, 'offer_sign_in') as offer:
|
||||
custom.explore(ui)
|
||||
self.assertIn('prihláste cez Účty registrov', repr(ui.seen))
|
||||
offer.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_registry_metadata_dialog_uses_image_and_digest_and_retains_values(self):
|
||||
manager = self.manager()
|
||||
ui = UI(['account-0', 'inspect', None], ['ghcr.io/owner/app:latest'])
|
||||
metadata = dict(architecture='amd64', version='<v>{literal}', size=1024, digest='sha256:' + 'a'*64)
|
||||
with patch.object(accounts, 'inspect_registry_image', return_value=metadata) as inspect:
|
||||
self.menu(ui, manager)
|
||||
inspect.assert_called_once_with('ghcr.io/owner/app:latest')
|
||||
rendered = repr(ui.seen)
|
||||
for value in ('Prístup k obrazu bol potvrdený.', 'Digest: sha256:', '<v>{literal}', 'Účty registrov'):
|
||||
self.assertIn(value, rendered)
|
||||
self.assertNotIn('obráz', rendered)
|
||||
manager.login.assert_not_called()
|
||||
manager.logout.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_credential_help_retains_permissions_and_no_encryption_claim(self):
|
||||
manager = self.manager()
|
||||
ui = UI(['storage', None])
|
||||
self.menu(ui, manager)
|
||||
rendered = repr(ui.seen)
|
||||
for value in ('root', '700', '600', 'base64', 'nie sú šifrované', 'každý uzol klastra', '/fixture/auth.json'):
|
||||
self.assertIn(value, rendered)
|
||||
manager.login.assert_not_called()
|
||||
manager.logout.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_cancelled_password_and_declined_logout_do_not_change_accounts(self):
|
||||
for ui in (UI(['add', None], ['ghcr.io', 'owner']), UI(['account-0', 'logout', None], confirms=[False])):
|
||||
manager = self.manager()
|
||||
self.menu(ui, manager)
|
||||
manager.login.assert_not_called()
|
||||
manager.logout.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_mismatched_registry_is_rejected_before_download(self):
|
||||
manager = self.manager()
|
||||
ui = UI(['account-0', 'download', None], ['quay.io/owner/app:latest'])
|
||||
with patch.object(accounts, 'download_registry_image') as download:
|
||||
self.menu(ui, manager)
|
||||
download.assert_not_called()
|
||||
self.assertIn('Zadajte obraz z tohto registra.', repr(ui.seen))
|
||||
self.no_effects()
|
||||
|
||||
def test_declined_private_image_signin_does_not_even_construct_auth_manager(self):
|
||||
ui = UI(confirms=[False])
|
||||
with patch.object(accounts, 'RegistryAuth') as manager, patch.object(accounts, 'require_root') as root:
|
||||
self.assertFalse(accounts.offer_sign_in(ui, 'ghcr.io/owner/app:latest'))
|
||||
manager.assert_not_called()
|
||||
root.assert_not_called()
|
||||
self.assertIn('Prihlásiť sa teraz?', repr(ui.seen))
|
||||
self.no_effects()
|
||||
|
||||
def test_registry_error_classes_hide_untrusted_remote_data_in_both_languages(self):
|
||||
cases = [('status code: 429', 'obmedzuje počet požiadaviek'),
|
||||
('requested access to the resource is denied', 'odmietol prístup'),
|
||||
('manifest unknown', 'Obraz alebo tag'),
|
||||
('unsupported manifest', 'metadáta obrazu'),
|
||||
('docker-credential-helper', 'správu prihlasovacích údajov'),
|
||||
('unknown failure', 'Obraz sa nepodarilo načítať')]
|
||||
for language in ('sk', 'en'):
|
||||
for error, expected in cases:
|
||||
with self.subTest(language=language, error=error), patch.object(i18n, '_language', language):
|
||||
message = auth.registry_error('registry.test:5000', error + ' REMOTE_PRIVATE_VALUE')
|
||||
self.assertNotIn('REMOTE_PRIVATE_VALUE', message)
|
||||
if language == 'sk':
|
||||
self.assertIn(expected, message)
|
||||
if 'credential' not in error:
|
||||
self.assertIn('registry.test:5000', message)
|
||||
self.no_effects()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Cancel a real editor through Menu/CLI/Monitor contracts, never apply it."""
|
||||
from contextlib import redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_oci_update_cancellation import CancellationFixture, ROOT
|
||||
from proxmenux_oci import cli, management as menu, recreation
|
||||
from proxmenux_oci.ui import UserCancelled
|
||||
|
||||
|
||||
class ModifyCancellationTests(CancellationFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.record.update({
|
||||
'installation_id': 'fixture-oci-edit-cancel',
|
||||
'observed': {'config_sha256': 'fixture-config-hash'},
|
||||
'template': {'id': 'fixture-app', 'catalog_ui': {'title': 'Fixture'},
|
||||
'container_contract': {'image': {'reference': 'example/fixture:latest'}}},
|
||||
'deployment': {'vmid': 110, 'hostname': 'fixture',
|
||||
'resources': {'cores': 2, 'memory_mb': 512},
|
||||
'rootfs': {'storage': 'fixture-storage', 'size_gb': 8},
|
||||
'mounts': [], 'devices': [], 'environment': [
|
||||
{'name': 'PASSWORD', 'value': 'private-fixture-value', 'sensitive': True}],
|
||||
'network': {'bridge': 'vmbr-fixture', 'ipv4': 'dhcp'}}})
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.ui.back_enabled = False
|
||||
self.path_write = self.guard(Path, 'write_text')
|
||||
self.path_bytes = self.guard(Path, 'write_bytes')
|
||||
|
||||
def script(self, choices, answers=(), confirms=(), reviews=()):
|
||||
self.ui.choose.side_effect = choices
|
||||
self.ui.ask.side_effect = answers
|
||||
self.ui.confirm.side_effect = confirms
|
||||
self.ui.review.side_effect = reviews
|
||||
|
||||
def no_effects(self):
|
||||
super().no_effects()
|
||||
self.path_write.assert_not_called()
|
||||
self.path_bytes.assert_not_called()
|
||||
self.assertFalse(self.ui.back_enabled)
|
||||
|
||||
def test_main_cancel_is_distinct_falsy_cancellation(self):
|
||||
self.script([None])
|
||||
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row, action='modify'), menu.CANCELLED)
|
||||
self.assertFalse(menu.CANCELLED)
|
||||
self.no_effects()
|
||||
|
||||
def test_main_discard_without_edits_returns_cancel_code(self):
|
||||
self.script(['discard'])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.ui.confirm.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_cancel_returns_code_six_without_extra_console_output(self):
|
||||
self.script([None])
|
||||
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 6)
|
||||
self.assertEqual(output.getvalue(), '')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_discard_after_edits_never_calls_backup_or_lifecycle(self):
|
||||
self.script(['cores', 'discard'], ['3'], [True])
|
||||
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 6)
|
||||
self.ui.confirm.assert_called_once_with('Discard unapplied changes?', False)
|
||||
self.no_effects()
|
||||
|
||||
def test_main_cancel_after_edits_requires_discard_confirmation(self):
|
||||
self.script(['cores', None], ['3'], [True])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.ui.confirm.assert_called_once_with('Discard unapplied changes?', False)
|
||||
self.no_effects()
|
||||
|
||||
def test_declining_discard_preserves_draft_until_later_main_cancel(self):
|
||||
self.script(['cores', 'discard', None], ['3'], [False, True])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
last_options = dict(self.ui.choose.call_args.args[1])
|
||||
self.assertTrue(last_options['cores'].rstrip().endswith('3'))
|
||||
self.assertIn('Unapplied changes', self.ui.choose.call_args.args[0])
|
||||
self.assertEqual(self.ui.confirm.call_count, 2)
|
||||
self.no_effects()
|
||||
|
||||
def test_section_cancel_returns_to_editor_before_main_cancel(self):
|
||||
self.script(['cores', None], [UserCancelled()])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.assertEqual(self.ui.choose.call_count, 2)
|
||||
self.assertIn('No pending changes', self.ui.choose.call_args.args[0])
|
||||
self.ui.review.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_zero_cores_rejected_then_section_and_main_cancel_are_safe(self):
|
||||
self.script(['cores', None], ['0', UserCancelled()])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.assertIn('Cores: minimum 1', self.ui.message.call_args.args[0])
|
||||
self.assertIn('No pending changes', self.ui.choose.call_args.args[0])
|
||||
self.no_effects()
|
||||
|
||||
def test_review_no_returns_to_editor_with_draft_then_discard_cancels(self):
|
||||
self.script(['cores', 'apply', 'discard'], ['3'], [True], [False])
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
|
||||
self.assertEqual(self.ui.choose.call_count, 3)
|
||||
self.assertIn('Unapplied changes', self.ui.choose.call_args.args[0])
|
||||
self.assertNotIn('private-fixture-value', self.ui.review.call_args.args[0])
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_returns_to_list_after_editor_cancel(self):
|
||||
self.script(['110', 'modify', None, None])
|
||||
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
|
||||
patch.object(menu, '_records_unread', return_value=[]):
|
||||
menu.interactive_management(ROOT/'oci', self.ui)
|
||||
self.assertEqual(self.ui.choose.call_count, 4)
|
||||
self.no_effects()
|
||||
|
||||
def test_legacy_editor_callers_still_receive_none_on_cancel(self):
|
||||
for choice in (None, 'discard'):
|
||||
with self.subTest(choice=choice):
|
||||
self.script([choice])
|
||||
self.assertIsNone(recreation.edit_recreation(self.record, self.ui))
|
||||
self.no_effects()
|
||||
|
||||
def test_unchanged_review_is_not_mislabeled_as_user_cancellation(self):
|
||||
self.script(['apply'])
|
||||
self.assertIsNone(recreation.edit_recreation(self.record, self.ui, cancelled_result=menu.CANCELLED))
|
||||
self.ui.message.assert_called_once_with('Nothing was changed.', 'Modify OCI')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_editor_error_is_not_converted_to_cancellation(self):
|
||||
self.script(['cores'], [RuntimeError('fixture editor failure')])
|
||||
with self.assertRaisesRegex(RuntimeError, 'fixture editor failure'):
|
||||
menu.direct_management(ROOT/'oci', 110, 'modify')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_validation_failure_remains_failure_one(self):
|
||||
self.script(['cores'], [cli.InstallError('fixture validation failure')])
|
||||
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 1)
|
||||
self.no_effects()
|
||||
|
||||
def test_unattended_modify_is_rejected_before_opening_the_editor(self):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify', unattended=True), 1)
|
||||
self.ui.choose.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_accepted_proposal_keeps_original_backend_and_private_payload(self):
|
||||
self.script(['cores', 'apply'], ['3'], reviews=[True])
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
def lifecycle(command, title):
|
||||
self.assertEqual(command[:3], [sys.executable, str(ROOT/'oci/remote/oci_update_current.py'), '110'])
|
||||
proposal_file = Path(command[command.index('--proposal')+1])
|
||||
self.assertEqual(proposal_file.stat().st_mode & 0o777, 0o600)
|
||||
proposal = json.loads(proposal_file.read_text())
|
||||
self.assertEqual(proposal['operation'], 'recreate')
|
||||
self.assertEqual(proposal['candidate']['deployment']['resources']['cores'], 3)
|
||||
self.assertEqual(proposal['base_config_sha256'], 'fixture-config-hash')
|
||||
self.assertNotIn('private-fixture-value', ' '.join(command))
|
||||
return True
|
||||
self.lifecycle.side_effect = lifecycle
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 0)
|
||||
self.backup.assert_called_once()
|
||||
self.lifecycle.assert_called_once()
|
||||
self.assertEqual(self.record, self.original)
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_failed_backup_after_review_remains_failure_without_lifecycle(self):
|
||||
self.script(['cores', 'apply'], ['3'], reviews=[True])
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = False
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 1)
|
||||
self.lifecycle.assert_not_called()
|
||||
self.write.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
@@ -14,15 +14,16 @@ class OciPveVersionGateTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
text = LAUNCHER.read_text(encoding='utf-8')
|
||||
cls.function = re.search(r'^pve_supports_oci\(\) \{\n.*?^\}\n', text, re.M | re.S).group(0)
|
||||
cls.function = ''.join(re.search(rf'^{name}\(\) \{{\n.*?^\}}\n', text, re.M | re.S).group(0)
|
||||
for name in ('pve_manager_version', 'pve_supports_oci'))
|
||||
cls.text = text
|
||||
|
||||
def supported(self, version):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
fake = Path(folder, 'pveversion')
|
||||
# Without Proxmox the command is missing: a stand-in that fails hides the host's own.
|
||||
fake.write_text('#!/bin/sh\nexit 127\n' if version is None else
|
||||
f'#!/bin/sh\necho "pve-manager/{version}/b9984c6d90a4bd80 (running kernel: 6.17)"\n')
|
||||
fake = Path(folder, 'dpkg-query')
|
||||
# Without Proxmox the package is not installed: dpkg-query fails.
|
||||
fake.write_text('#!/bin/sh\nexit 1\n' if version is None else
|
||||
f'#!/bin/sh\nprintf %s "{version}"\n')
|
||||
fake.chmod(0o755)
|
||||
result = subprocess.run(['bash', '-c', f'PATH={folder}:/usr/bin:/bin\n{self.function}pve_supports_oci'],
|
||||
capture_output=True, text=True)
|
||||
@@ -38,6 +39,9 @@ class OciPveVersionGateTests(unittest.TestCase):
|
||||
with self.subTest(version=version):
|
||||
self.assertFalse(self.supported(version))
|
||||
|
||||
def test_the_version_is_read_without_the_slow_pveversion(self):
|
||||
self.assertNotIn('pveversion', self.function)
|
||||
|
||||
def test_it_is_checked_before_anything_is_installed(self):
|
||||
self.assertLess(self.text.index('if ! pve_supports_oci; then'), self.text.index('ensure_oci_dependencies || exit 1'))
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ TRANSACTION = ROOT / 'oci/remote/oci_stack_transaction.py'
|
||||
|
||||
|
||||
def extracted(path, name, scope):
|
||||
if name == 'remove':
|
||||
# Operational cache cleanup is exercised by the engine tests; keep wording tests inert.
|
||||
scope.setdefault('compose_jobs', SimpleNamespace(forget_completed=lambda *args: None))
|
||||
scope.setdefault('public_routes', SimpleNamespace(remove=lambda *args: None))
|
||||
node = next(n for n in ast.parse(path.read_text()).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
|
||||
@@ -71,6 +75,9 @@ class InventoryMessages(unittest.TestCase):
|
||||
self.assertNotIn('Host directories that are kept, with their content:', text)
|
||||
|
||||
def test_removal_notice_qualifies_saved_record_source(self):
|
||||
# Extracted operational functions must never inspect the real pmxcfs.
|
||||
host = Path(self.enterContext(tempfile.TemporaryDirectory()))
|
||||
inert_path = lambda value: host / Path(value).name if str(value).startswith('/etc/pve/') else Path(value)
|
||||
events = []
|
||||
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
|
||||
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
|
||||
@@ -81,7 +88,7 @@ class InventoryMessages(unittest.TestCase):
|
||||
'remove_owned_host_firewall': lambda primary: True,
|
||||
'run': lambda *args: events.append(('run', args)),
|
||||
'subprocess': SimpleNamespace(run=lambda *args, **kwargs: None),
|
||||
'Path': Path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||
'Path': inert_path, 'shutil': SimpleNamespace(rmtree=lambda path: None),
|
||||
'image_cache': SimpleNamespace(prune=lambda root, lock: []),
|
||||
'work_backup': SimpleNamespace(forget=lambda root, vmid: None),
|
||||
'oci_console': SimpleNamespace(remove_log=lambda vmid: events.append(('log', vmid))),
|
||||
@@ -116,7 +123,7 @@ class RecoveryMessages(unittest.TestCase):
|
||||
instances.ROOT = Path('/inert')
|
||||
instances.read = lambda root, vmid: self.primary
|
||||
self.modules = {'oci_instances': instances, 'oci_stack_replay': ModuleType('oci_stack_replay')}
|
||||
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path,
|
||||
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path, 'CANCELLED': None,
|
||||
'translate': lambda text: text, 'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||
'_remove': lambda *args: self.fail('remove must not run'),
|
||||
'_run_lifecycle': lambda command, title: self.calls.append((command, title)) or False}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Pending recovery through real Menu/CLI/Dialog paths; all host effects forbidden."""
|
||||
from contextlib import redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_oci_update_cancellation import CancellationFixture, ROOT
|
||||
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui
|
||||
import oci_instances as instances
|
||||
import oci_instance_transaction as transaction
|
||||
import oci_native_modify as native_modify
|
||||
import oci_stack_transaction as stack_transaction
|
||||
import oci_ui
|
||||
|
||||
ACTUAL_RECOVER = transaction.recover
|
||||
|
||||
|
||||
class PendingRecoveryFixture(CancellationFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.record.update(pending_transaction='/fiction-only/transaction.json')
|
||||
self.row['pending'] = True
|
||||
self.ui.choose.return_value = 'recover'
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.writes = [self.guard(Path, name) for name in ('write_text', 'write_bytes', 'unlink')]
|
||||
self.processes = [self.guard(subprocess, name) for name in ('run', 'Popen', 'check_output')]
|
||||
self.recovery = self.guard(transaction, 'recover')
|
||||
self.native_recovery = self.guard(native_modify, 'recover')
|
||||
self.stack_recovery = self.guard(stack_transaction, 'execute')
|
||||
|
||||
def stack(self):
|
||||
self.row['stack'] = True
|
||||
self.record['pending_stack_transaction'] = '/fiction-only/stack.json'
|
||||
self.original = copy.deepcopy(self.record)
|
||||
|
||||
def no_effects(self):
|
||||
super().no_effects()
|
||||
for mock in [*self.writes, *self.processes, self.recovery, self.native_recovery, self.stack_recovery]:
|
||||
mock.assert_not_called()
|
||||
|
||||
|
||||
class PendingRecoveryTests(PendingRecoveryFixture):
|
||||
def test_single_no_is_distinct_falsy_cancellation(self):
|
||||
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row), menu.CANCELLED)
|
||||
self.assertFalse(menu.CANCELLED)
|
||||
self.no_effects()
|
||||
|
||||
def test_single_menu_cancel_is_distinct_cancellation(self):
|
||||
self.ui.choose.return_value = None
|
||||
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row), menu.CANCELLED)
|
||||
self.ui.review.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_single_no_and_cancel_return_six_without_duplicate_output(self):
|
||||
for choice in ('recover', None):
|
||||
with self.subTest(choice=choice), patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
|
||||
self.ui.choose.return_value = choice
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'update']), 6)
|
||||
self.assertEqual(output.getvalue(), '')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_stack_no_returns_six_before_reading_the_journal(self):
|
||||
self.stack()
|
||||
with patch.object(cli, 'Catalog'), patch.object(Path, 'read_text', side_effect=AssertionError('Journal read after No')) as read:
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', 'update']), 6)
|
||||
read.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_actual_dialog_no_and_escape_cancel_single_and_stack(self):
|
||||
dialog = native_ui.DialogUI()
|
||||
for stacked in (False, True):
|
||||
if stacked:
|
||||
self.stack()
|
||||
for code in (1, 255):
|
||||
with self.subTest(stack=stacked, code=code):
|
||||
answers = ([] if stacked else [subprocess.CompletedProcess('dialog', 0, stderr='2')])
|
||||
answers.append(subprocess.CompletedProcess('dialog', code, stderr=''))
|
||||
with patch.object(native_ui, 'interactive_ui', return_value=dialog), \
|
||||
patch.object(dialog, '_run', side_effect=answers):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_actual_dialog_cancel_and_escape_from_single_operation_menu(self):
|
||||
dialog = native_ui.DialogUI()
|
||||
for code in (1, 255):
|
||||
with self.subTest(code=code), patch.object(native_ui, 'interactive_ui', return_value=dialog), \
|
||||
patch.object(dialog, '_run', return_value=subprocess.CompletedProcess('dialog', code, stderr='')):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_returns_to_its_list_after_pending_single_or_stack_no(self):
|
||||
for stacked in (False, True):
|
||||
if stacked:
|
||||
self.stack()
|
||||
self.ui.choose.reset_mock()
|
||||
self.ui.choose.side_effect = ['110', None] if stacked else ['110', 'recover', None]
|
||||
with self.subTest(stack=stacked), patch.object(menu, '_clean_orphans'), \
|
||||
patch.object(menu, 'offer_recovery'), patch.object(menu, '_records_unread', return_value=[]):
|
||||
menu.interactive_management(ROOT/'oci', self.ui)
|
||||
self.assertEqual(self.ui.choose.call_count, 2 if stacked else 3)
|
||||
self.no_effects()
|
||||
|
||||
def test_confirmed_single_recovery_and_status_preserve_exact_commands_and_results(self):
|
||||
self.ui.review.return_value = True
|
||||
for action in ('recover', 'status'):
|
||||
for success, code in ((True, 0), (False, 1)):
|
||||
self.lifecycle.reset_mock()
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = success
|
||||
self.ui.choose.return_value = action
|
||||
with self.subTest(action=action, result=success):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), code)
|
||||
self.lifecycle.assert_called_once_with(
|
||||
[sys.executable, str(ROOT/'oci/remote/oci_instance_transaction.py'), action, '110'],
|
||||
'Recover OCI' if action == 'recover' else 'OCI management')
|
||||
self.write.assert_not_called(); self.backup.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
|
||||
def test_confirmed_stack_recovery_preserves_command_and_failure_is_not_cancel(self):
|
||||
self.stack()
|
||||
self.ui.review.return_value = True
|
||||
for success, code in ((True, 0), (False, 1)):
|
||||
self.lifecycle.reset_mock()
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = success
|
||||
with self.subTest(result=success), patch.object(Path, 'read_text', return_value=json.dumps({'plan': {'members': [{'vmid': 110}, {'vmid': 111}]}})):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), code)
|
||||
self.lifecycle.assert_called_once_with(
|
||||
[sys.executable, str(ROOT/'oci/remote/oci_stack_native.py'), '110', '--recover', '--acknowledge-external-data'],
|
||||
'Recover OCI stack')
|
||||
self.write.assert_not_called(); self.backup.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
|
||||
def test_identity_failure_stays_failure_and_does_not_offer_recovery(self):
|
||||
with patch.object(menu, 'check_selected', return_value={**self.row, 'reason': 'different-installation'}):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 1)
|
||||
self.ui.choose.assert_not_called(); self.ui.review.assert_not_called()
|
||||
self.no_effects()
|
||||
|
||||
def test_unattended_does_not_turn_pending_menu_into_an_automatic_restore(self):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update', unattended=True), 1)
|
||||
self.no_effects()
|
||||
|
||||
def test_registration_cli_retains_its_separate_entry_point(self):
|
||||
with patch.object(cli, 'Catalog'), patch.object(menu, 'direct_recovery', return_value=0) as register, \
|
||||
patch.object(menu, 'direct_management', side_effect=AssertionError('Registration routed to rollback')):
|
||||
self.assertEqual(cli.main(['recover']), 0)
|
||||
register.assert_called_once_with(ROOT/'oci')
|
||||
self.no_effects()
|
||||
|
||||
|
||||
class RecoverySlovakMeaning(PendingRecoveryFixture):
|
||||
def test_actual_recovery_dialogs_preserve_host_data_and_bounded_recovery_meaning(self):
|
||||
cache = json.loads((ROOT/'lang/sk.json').read_text())
|
||||
with patch.object(i18n, '_language', 'sk'), patch.object(i18n, '_cache', cache):
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row)
|
||||
text = self.ui.review.call_args.args[0]
|
||||
self.assertIn('Zdieľané hostiteľské adresáre sa nevrátia späť', text)
|
||||
self.assertIn('po úspešnom vyčistení', text)
|
||||
self.assertEqual(self.ui.choose.call_args.args[0], 'Prerušená operácia')
|
||||
self.assertEqual(dict(self.ui.choose.call_args.args[1])['status'], 'Zobraziť stav')
|
||||
self.stack()
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row)
|
||||
self.assertEqual(self.ui.review.call_args.args[1], 'Obnoviť OCI aplikáciu s viacerými kontajnermi')
|
||||
self.assertIn('môžu zlyhať', self.ui.review.call_args.args[0])
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Obnoviť alebo dokončiť operáciu?')
|
||||
self.no_effects()
|
||||
|
||||
|
||||
class RecoveryGuardMessages(PendingRecoveryFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
cache = json.loads((ROOT/'lang/sk.json').read_text())
|
||||
for name, value in (('_language', 'sk'), ('_cache', cache)):
|
||||
p = patch.object(oci_ui, name, value)
|
||||
p.start(); self.addCleanup(p.stop)
|
||||
self.record.update(installation_id='fiction-installation', transaction_id='fiction-tx')
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.journal = Path(self.record['pending_transaction'])
|
||||
self.state = {'id': 'fiction-tx', 'vmid': 110, 'phase': 'backup-ready',
|
||||
'record': {'installation_id': 'fiction-installation'}, 'before_config': 'before',
|
||||
'backup': None, 'backup_sha256': 'saved-sha'}
|
||||
|
||||
def rejected(self, text, *, node='fiction-host', changed_backup=False):
|
||||
calls = []
|
||||
def readonly(*args):
|
||||
calls.append(args)
|
||||
if args[0] == 'pvesh':
|
||||
return json.dumps([{'type': 'lxc', 'vmid': 110, 'node': node}]).encode()
|
||||
if args == ('pct', 'config', '110'):
|
||||
return b'changed'
|
||||
raise AssertionError('Unexpected recovery effect: ' + repr(args))
|
||||
with patch.object(Path, 'read_text', return_value=json.dumps(self.state)), \
|
||||
patch.object(Path, 'exists', return_value=True), patch.object(Path, 'is_file', return_value=True), \
|
||||
patch.object(transaction.socket, 'gethostname', return_value='fiction-host'), \
|
||||
patch.object(transaction.host_mounts, 'verify_sources'), patch.object(transaction.gpu_devices, 'verify'), \
|
||||
patch.object(transaction, 'owned', return_value=b'fiction config'), \
|
||||
patch.object(transaction, 'filehash', return_value='changed-sha' if changed_backup else 'saved-sha'), \
|
||||
patch.object(transaction, 'run', side_effect=readonly), redirect_stdout(io.StringIO()):
|
||||
# Only reads before the rejecting guard are supplied. Any next
|
||||
# command or attempt to persist state fails this fixture.
|
||||
with self.assertRaisesRegex(ValueError, text):
|
||||
ACTUAL_RECOVER(ROOT/'oci', self.journal)
|
||||
self.no_effects()
|
||||
return calls
|
||||
|
||||
def test_foreign_transaction_is_rejected_before_probing_the_container(self):
|
||||
self.state['record']['installation_id'] = 'different-installation'
|
||||
self.assertEqual(self.rejected('Záznam nepatrí k tejto operácii'), [])
|
||||
|
||||
def test_missing_verified_backup_cannot_touch_a_changed_container(self):
|
||||
self.assertEqual(len(self.rejected('Chýba overená záloha; zmenený kontajner zostáva nedotknutý')), 2)
|
||||
|
||||
def test_changed_backup_is_rejected_before_decompression_or_restore(self):
|
||||
self.state['backup'] = '/fiction-only/backup.zst'
|
||||
self.assertEqual(len(self.rejected('Záloha bola zmenená; obnova zablokovaná', changed_backup=True)), 2)
|
||||
|
||||
def test_guest_on_another_node_is_never_overwritten(self):
|
||||
self.assertEqual(len(self.rejected('kontajner je na inom uzle; nie je prepísaný', node='another-node')), 1)
|
||||
|
||||
def test_incomplete_stack_recovery_state_fails_before_any_adapter_call(self):
|
||||
adapter = SimpleNamespace(validate=self.recovery, stop=self.native_recovery,
|
||||
restore=self.stack_recovery)
|
||||
state = {'phase': 'recovery-failed', 'plan': {'start_order': [110, 111]},
|
||||
'running': {'110': True}, 'stop_intent': True, 'replacement_intent': False}
|
||||
before = copy.deepcopy(state)
|
||||
with self.assertRaisesRegex(ValueError, 'Záznam operácie obsahuje neúplný stav obnovy'):
|
||||
stack_transaction.recover_state(self.journal, state, adapter)
|
||||
self.assertEqual(state, before)
|
||||
self.no_effects()
|
||||
|
||||
def test_critical_safety_and_untouched_result_have_the_correct_meaning(self):
|
||||
cache = json.loads((ROOT/'lang/sk.json').read_text())
|
||||
self.assertEqual(cache['A host mount is not part of the journal; recovery blocked'],
|
||||
'Pripojenie adresára hostiteľa nie je uvedené v zázname operácie; obnova je zablokovaná')
|
||||
self.assertEqual(cache['Recovery completed. The container had not been modified yet.'],
|
||||
'Obnova dokončená. Kontajner ešte nebol zmenený.')
|
||||
self.assertEqual(cache['A backup was modified'], 'Záloha bola zmenená')
|
||||
self.assertEqual(cache['Recovering the previous installation'], 'Obnovovanie predchádzajúcej inštalácie')
|
||||
self.assertEqual(cache['Recover the previous installation'], 'Obnoviť predchádzajúcu inštaláciu')
|
||||
self.no_effects()
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Restored-app registration through real Menu/CLI/Dialog; no host effects."""
|
||||
from contextlib import nullcontext, redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import string
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_oci_update_cancellation import CancellationFixture, ROOT
|
||||
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui
|
||||
import oci_restore_recovery as restored
|
||||
import oci_ui
|
||||
|
||||
|
||||
class RegistrationFixture(CancellationFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.found = [{'vmid':110, 'hostname':'fiction-primary'}]
|
||||
self.original_found = copy.deepcopy(self.found)
|
||||
self.ui.confirm.return_value = False
|
||||
item = patch.object(menu, 'restored_applications', side_effect=lambda project:self.found)
|
||||
self.pending = item.start();self.addCleanup(item.stop)
|
||||
self.firewall = self.guard(menu, '_restored_firewall_rules')
|
||||
self.backend = [self.guard(restored, name) for name in ('recover','register','run')]
|
||||
self.files = [self.guard(Path, name) for name in ('write_text','write_bytes','unlink')]
|
||||
self.processes = [self.guard(subprocess, name) for name in ('run','Popen','check_output')]
|
||||
|
||||
def members(self):
|
||||
self.found.append({'vmid':111,'hostname':'fiction-dependent'})
|
||||
self.original_found = copy.deepcopy(self.found)
|
||||
|
||||
def no_effects(self):
|
||||
super().no_effects()
|
||||
self.firewall.assert_not_called()
|
||||
self.safe_backend()
|
||||
|
||||
def safe_backend(self):
|
||||
for item in [*self.backend,*self.files,*self.processes]:item.assert_not_called()
|
||||
self.assertEqual(self.found,self.original_found)
|
||||
|
||||
def accepted(self, answers, completed=True):
|
||||
self.ui.confirm.side_effect=answers
|
||||
self.firewall.side_effect=None
|
||||
self.firewall.return_value=[{'vmid':row['vmid'],'port':61208 if row['vmid']==110 else 19999,
|
||||
'source':'192.0.2.0/24'} for row in self.found]
|
||||
self.lifecycle.side_effect=None;self.lifecycle.return_value=completed
|
||||
self.pending.side_effect=lambda project:[] if completed and self.lifecycle.called else self.found
|
||||
|
||||
|
||||
class RegistrationCancellationTests(RegistrationFixture):
|
||||
def test_offer_no_is_explicit_falsy_cancellation(self):
|
||||
self.assertIs(menu.offer_recovery(ROOT/'oci',self.ui),menu.CANCELLED)
|
||||
self.assertFalse(menu.CANCELLED);self.no_effects()
|
||||
|
||||
def test_direct_single_no_finishes_before_a_third_pending_read(self):
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
|
||||
self.assertEqual(self.pending.call_count,2)
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_single_no_returns_six_without_output(self):
|
||||
with patch.object(cli,'Catalog'),redirect_stdout(io.StringIO()) as output:
|
||||
self.assertEqual(cli.main(['recover']),6)
|
||||
self.assertEqual(output.getvalue(),'');self.no_effects()
|
||||
|
||||
def test_real_cli_multi_container_no_is_not_a_guest_action(self):
|
||||
self.members()
|
||||
with patch.object(cli,'Catalog'):
|
||||
self.assertEqual(cli.main(['recover']),6)
|
||||
text=self.ui.confirm.call_args.args[0]
|
||||
self.assertIn('CT 110',text);self.assertIn('CT 111',text)
|
||||
self.no_effects()
|
||||
|
||||
def test_actual_dialog_initial_no_and_escape_for_single_and_multi(self):
|
||||
dialog=native_ui.DialogUI()
|
||||
for multi in (False,True):
|
||||
if multi:self.members()
|
||||
for code in (1,255):
|
||||
with self.subTest(multi=multi,code=code),patch.object(native_ui,'interactive_ui',return_value=dialog), \
|
||||
patch.object(dialog,'_run',return_value=subprocess.CompletedProcess('dialog',code,stderr='')):
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_continues_to_list_after_registration_no(self):
|
||||
self.ui.choose.return_value=None
|
||||
with patch.object(menu,'_clean_orphans'),patch.object(menu,'_records_unread',return_value=[]):
|
||||
menu.interactive_management(ROOT/'oci',self.ui)
|
||||
self.ui.choose.assert_called_once();self.no_effects()
|
||||
|
||||
def test_optional_firewall_and_start_no_registers_without_flags(self):
|
||||
self.members();self.accepted([True,False,False,False])
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.lifecycle.call_args.args[0],
|
||||
[menu.sys.executable,str(ROOT/'oci/remote/oci_restore_recovery.py'),'recover'])
|
||||
self.assertEqual(self.ui.confirm.call_count,4);self.safe_backend()
|
||||
|
||||
def test_single_optional_no_preserves_registration_without_start(self):
|
||||
self.accepted([True,False,False])
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.lifecycle.call_args.args[0],
|
||||
[menu.sys.executable,str(ROOT/'oci/remote/oci_restore_recovery.py'),'recover'])
|
||||
self.assertEqual(self.ui.confirm.call_count,3);self.safe_backend()
|
||||
|
||||
def test_only_one_confirmed_firewall_is_forwarded_and_no_start(self):
|
||||
self.members();self.accepted([True,False,True,False])
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.lifecycle.call_args.args[0][-2:],['--host-firewall','111'])
|
||||
self.assertNotIn('--start',self.lifecycle.call_args.args[0]);self.safe_backend()
|
||||
|
||||
def test_explicit_start_yes_retains_flag_but_never_starts_any_guest_here(self):
|
||||
self.accepted([True,False,True])
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.lifecycle.call_args.args[0][-1],'--start');self.safe_backend()
|
||||
|
||||
def test_failed_registration_stays_failure_with_pending_records(self):
|
||||
self.accepted([True,False,False],completed=False)
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),1)
|
||||
self.safe_backend()
|
||||
|
||||
def test_empty_list_is_success_without_confirmation_or_backend(self):
|
||||
self.found.clear();self.original_found=[]
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.ui.confirm.assert_not_called();self.ui.message.assert_called_once();self.no_effects()
|
||||
|
||||
def test_root_identity_failure_never_probes_restored_apps(self):
|
||||
with patch.object(menu.os,'geteuid',return_value=1000):
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),1)
|
||||
self.pending.assert_not_called();self.ui.confirm.assert_not_called();self.no_effects()
|
||||
|
||||
def test_empty_offer_does_not_cancel_the_native_menu(self):
|
||||
self.found.clear();self.original_found=[]
|
||||
self.assertIsNone(menu.offer_recovery(ROOT/'oci',self.ui))
|
||||
self.ui.confirm.assert_not_called();self.no_effects()
|
||||
|
||||
def test_empty_registration_state_is_rendered_in_slovak_without_recovery(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
with patch.object(i18n,'_language','sk'),patch.object(i18n,'_cache',catalog):
|
||||
self.found.clear();self.original_found=[]
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
|
||||
self.assertEqual(self.ui.message.call_args.args[0],
|
||||
'Žiadna OCI aplikácia obnovená zo zálohy nečaká na opätovnú registráciu.')
|
||||
self.no_effects()
|
||||
|
||||
def test_initial_single_and_multi_dialog_describes_registration_precheck(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
for multi in (False,True):
|
||||
if multi:self.members()
|
||||
with self.subTest(multi=multi),patch.object(i18n,'_language','sk'),patch.object(i18n,'_cache',catalog):
|
||||
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
|
||||
text=self.ui.confirm.call_args.args[0]
|
||||
self.assertIn('Pred opätovnou registráciou',text)
|
||||
self.assertNotIn('Obnova najprv',text)
|
||||
self.assertIn('aplikácie sa potom nespúšťajú automaticky',text)
|
||||
self.no_effects()
|
||||
|
||||
def test_real_backend_blocker_stays_error_and_uses_registration_wording(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
plan={'title':'Fictional restored application','restored':[{'vmid':110}],
|
||||
'blockers':['Fictional missing resource'],'notes':[]}
|
||||
with patch.object(menu.sys,'argv',['fixture','recover','--root','/fiction-only']), \
|
||||
patch.object(restored.instances,'locked',return_value=nullcontext()), \
|
||||
patch.object(restored,'prepare',return_value=[plan]), \
|
||||
patch.object(oci_ui,'_language','sk'),patch.object(oci_ui,'_cache',catalog), \
|
||||
patch.object(restored,'msg_error') as error,patch.object(restored,'msg_info2'):
|
||||
self.assertEqual(restored.main(),1)
|
||||
error.assert_called_once_with('Túto aplikáciu zatiaľ nemožno znova zaregistrovať:')
|
||||
self.no_effects()
|
||||
|
||||
def test_real_host_resource_check_reports_missing_data_without_creating_anything(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
plan={'restored':[{'vmid':110,'config':'mp0: /fiction-only/data,mp=/data\n',
|
||||
'copy':{'record':{'deployment':{},'observed':{}}}}], 'blockers':[],'notes':[]}
|
||||
with patch.object(oci_ui,'_language','sk'),patch.object(oci_ui,'_cache',catalog), \
|
||||
patch.object(restored.os.path,'isdir',return_value=False), \
|
||||
patch.object(restored,'firewall_rule',return_value=None):
|
||||
restored.check_host_resources(plan)
|
||||
self.assertEqual(len(plan['blockers']),1)
|
||||
self.assertIn('/fiction-only/data',plan['blockers'][0])
|
||||
self.assertIn('registráciu zopakujte',plan['blockers'][0])
|
||||
self.no_effects()
|
||||
|
||||
|
||||
class RegistrationContractTests(unittest.TestCase):
|
||||
def test_real_monitor_shell_recover_keeps_global_cli_contract_and_exit_six(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
base=Path(folder);(base/'scripts/oci').mkdir(parents=True)
|
||||
(base/'utils.sh').write_text('load_language() { :; }\ninitialize_cache() { :; }\n')
|
||||
(base/'scripts/oci/oci_manager_apps.sh').write_text('printf "%s\\n" "$@"\nexit 6\n')
|
||||
script=(ROOT/'scripts/oci/manage_instance.sh').read_text()
|
||||
script=script.replace('/usr/local/share/proxmenux',folder)
|
||||
import os
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,
|
||||
env={**os.environ,'ACTION':'recover','VMID':'not-a-vmid'})
|
||||
self.assertEqual(result.returncode,6);self.assertEqual(result.stdout,'recover\n')
|
||||
self.assertEqual(result.stderr,'')
|
||||
|
||||
def test_curated_slovak_registration_words_and_format_fields(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
expected={
|
||||
'Recover restored OCI applications':'Registrácia',
|
||||
'No restored OCI application is waiting to be recovered.':'registráciu',
|
||||
'Recover them now?':'zaregistrovať',
|
||||
'This application cannot be recovered yet:':'zaregistrovať',
|
||||
'The application could not be recovered:':'zaregistrovať',
|
||||
'Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.':'vyberte Nie',
|
||||
'it is locked or its disk could not be mounted':'uzamknutý',
|
||||
'The local storage does not accept snippets':'local',
|
||||
'it uses an NVIDIA GPU and this host has no working NVIDIA driver and Container Toolkit. Install them and run the recovery again.':'NVIDIA Container Toolkit',
|
||||
'the NVIDIA hook of the container is not available:':'spúšťací skript',
|
||||
'its Rclone mount is published again on this host at':'v ceste',
|
||||
'Choose another bridge for the container in Proxmox (Network) and run the recovery again.':'registráciu',
|
||||
'Mount or create it with its data and run the recovery again.':'registráciu',
|
||||
'Connect it, or remove it from the container in Proxmox (Resources), and run the recovery again.':'registráciu',
|
||||
}
|
||||
fields=lambda text:sorted(f for _,f,_,_ in string.Formatter().parse(text) if f)
|
||||
for key,word in expected.items():
|
||||
with self.subTest(key=key):
|
||||
self.assertIn(word,catalog[key]);self.assertEqual(fields(key),fields(catalog[key]))
|
||||
|
||||
def test_registration_web_text_is_distinct_from_previous_installation_recovery(self):
|
||||
for locale in ('en','sk'):
|
||||
data=json.loads((ROOT/'AppImage/messages'/locale/'common.json').read_text())['vmLxc']
|
||||
with self.subTest(locale=locale):
|
||||
self.assertEqual(data['ociUpdates']['registerRestored'],
|
||||
'Znova zaregistrovať' if locale=='sk' else 'Register again')
|
||||
self.assertEqual(data['ociUpdates']['recover'],'Obnoviť' if locale=='sk' else 'Recover')
|
||||
self.assertIn('zaregistrovať' if locale=='sk' else 'Register again',data['updates']['ociRestoredBody'])
|
||||
self.assertIn('Zaregistrovať' if locale=='sk' else 'Register',data['ociUpdates']['terminalTitleRecover'])
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -20,7 +20,7 @@ INSTALL = ROOT / 'oci/remote/install_oci.sh'
|
||||
PREVIEW = 'Private network targeted for release if no other guest uses it:'
|
||||
CONFIRM = 'Remove the application? Its container disks are deleted, and only a backup can bring them back.'
|
||||
RESULT = 'Removal command finished; review any warnings above.'
|
||||
BRIDGE = 'Private network release attempted:'
|
||||
BRIDGE = 'Private network of the application released:'
|
||||
BRIDGE_FAILURE = 'Could not complete private network release:'
|
||||
SUCCESS = 'The application was removed'
|
||||
FIREWALL = 'Could not verify removal of the managed host firewall rule.'
|
||||
@@ -35,6 +35,10 @@ KEYS = (PREVIEW, CONFIRM, RESULT, BRIDGE, BRIDGE_FAILURE, SUCCESS, FIREWALL, POR
|
||||
|
||||
|
||||
def extract(path, name, scope):
|
||||
if name == 'remove':
|
||||
# Operational cache cleanup is exercised by the engine tests; keep wording tests inert.
|
||||
scope.setdefault('compose_jobs', SimpleNamespace(forget_completed=lambda *args: None))
|
||||
scope.setdefault('public_routes', SimpleNamespace(remove=lambda *args: None))
|
||||
node = next(n for n in ast.parse(path.read_text()).body if isinstance(n, ast.FunctionDef) and n.name == name)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
|
||||
return scope[name]
|
||||
@@ -257,7 +261,7 @@ class RemovalWordings(unittest.TestCase):
|
||||
self.assertIn(('warn', FIREWALL), events)
|
||||
self.assertEqual(events[-1], ('ok', RESULT))
|
||||
|
||||
def test_ignored_bridge_command_failures_do_not_claim_release(self):
|
||||
def test_a_failed_bridge_release_warns_instead_of_claiming_it(self):
|
||||
events = []
|
||||
scope = {'members_of': lambda root, vmid: (101, {}, [101]),
|
||||
'instances': SimpleNamespace(read=lambda root, vmid: {'installation_id': 'owned'},
|
||||
@@ -274,11 +278,12 @@ class RemovalWordings(unittest.TestCase):
|
||||
'oci_console': SimpleNamespace(remove_log=lambda vmid: None),
|
||||
'guest_node': lambda vmid: None, 'remove_host_state': lambda vmid: None, 'release_shared_host_files': lambda hookscripts: None, 're': re,
|
||||
'translate': lambda text: text, 'msg_info': lambda text: None,
|
||||
'msg_ok': lambda text: events.append(('ok', text)), 'msg_warn': lambda text: None}
|
||||
'msg_ok': lambda text: events.append(('ok', text)),
|
||||
'msg_warn': lambda text: events.append(('warn', text))}
|
||||
extract(REMOVE, 'remove', scope)(Path('/inert'), 101)
|
||||
self.assertIn(('attempt', 'vmbr9'), events)
|
||||
self.assertIn(('ok', BRIDGE + ' vmbr9'), events)
|
||||
self.assertNotIn(('ok', 'Private network of the application released: vmbr9'), events)
|
||||
self.assertIn(('warn', BRIDGE_FAILURE + ' vmbr9'), events)
|
||||
self.assertNotIn(('ok', BRIDGE + ' vmbr9'), events)
|
||||
|
||||
def test_invalid_managed_firewall_metadata_warns_instead_of_silent_clean(self):
|
||||
events = []
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Actual removal UI/guards in SK and EN; real host effects are forbidden."""
|
||||
from contextlib import nullcontext
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
|
||||
from proxmenux_oci import i18n, management
|
||||
from proxmenux_oci.ui import UserCancelled
|
||||
import oci_instances as instances
|
||||
import oci_remove as removal
|
||||
import oci_ui
|
||||
|
||||
|
||||
class RemovalSlovakTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
cache = json.loads((ROOT / 'lang/sk.json').read_text())
|
||||
for module in (i18n, oci_ui):
|
||||
for name, value in (('_language', 'sk'), ('_cache', cache)):
|
||||
p = patch.object(module, name, value)
|
||||
p.start(); self.addCleanup(p.stop)
|
||||
self.records = {}
|
||||
self.make_records(2)
|
||||
self.read = self.start(instances, 'read', side_effect=lambda root, vmid: self.records[vmid])
|
||||
self.start(management, '_selected_configuration', side_effect=self.config)
|
||||
self.bridge = self.start(removal, 'bridge_in_use', return_value=False)
|
||||
self.ui = Mock()
|
||||
self.ui.review.return_value = False
|
||||
self.forbidden = []
|
||||
for module, name in ((subprocess, 'run'), (subprocess, 'Popen'), (subprocess, 'check_output'),
|
||||
(Path, 'write_text'), (Path, 'write_bytes'), (Path, 'unlink'),
|
||||
(removal.shutil, 'rmtree'), (instances, 'write'), (instances, 'command'),
|
||||
(management, '_run_lifecycle'), (removal, 'run'),
|
||||
(removal, 'remove_host_state'), (removal.oci_console, 'remove_log'),
|
||||
(removal, 'release_bridge'), (removal, 'remove_owned_host_firewall'),
|
||||
(removal, 'release_shared_host_files'), (removal.work_backup, 'forget'),
|
||||
(removal.image_cache, 'prune')):
|
||||
self.forbidden.append(self.start(module, name, side_effect=AssertionError('Forbidden effect: ' + name)))
|
||||
|
||||
def start(self, module, name, **kwargs):
|
||||
p = patch.object(module, name, **kwargs)
|
||||
result = p.start(); self.addCleanup(p.stop)
|
||||
return result
|
||||
|
||||
def make_records(self, count):
|
||||
self.records.clear()
|
||||
for offset in range(count):
|
||||
vmid = 999990 + offset
|
||||
self.records[vmid] = {'vmid': vmid, 'installation_id': 'fixture-' + str(vmid),
|
||||
'template': {'catalog_ui': {'title': 'Fixture App'}},
|
||||
'deployment': {'mounts': [{'type': 'host-bind', 'source': '/fixture/saved',
|
||||
'container_path': '/saved'}]}}
|
||||
if offset:
|
||||
self.records[vmid]['stack_member'] = {'primary_vmid': 999990}
|
||||
if count > 1:
|
||||
self.records[999990]['stack'] = {'members': [{'vmid': vmid} for vmid in self.records],
|
||||
'deployment': {'network': {'private_bridge': 'vmbr999', 'private_subnet': '10.77.99.0/24'}}}
|
||||
self.records[999990]['deployment']['host_firewall'] = {'port': 8080}
|
||||
self.original = copy.deepcopy(self.records)
|
||||
|
||||
def config(self, vmid):
|
||||
return (f'hostname: fixture-{vmid}\nrootfs: fixture:vm-{vmid}-disk-0,size=8G\n'
|
||||
f'mp0: fixture:vm-{vmid}-disk-1,mp=/data,size=16G\n'
|
||||
'mp1: /fixture/shared,mp=/shared\n').encode()
|
||||
|
||||
def tearDown(self):
|
||||
self.assertEqual(self.records, self.original)
|
||||
for effect in self.forbidden:
|
||||
effect.assert_not_called()
|
||||
|
||||
def test_single_summary_separates_deleted_disks_and_retained_host_paths(self):
|
||||
self.make_records(1)
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn('Kontajnery určené na odstránenie:', text)
|
||||
self.assertIn('Údaje kontajnera určené na vymazanie:', text)
|
||||
self.assertIn('/data (16G)', text)
|
||||
kept = text.split('Cesty hostiteľa nájdené')[1]
|
||||
self.assertIn('/fixture/shared', kept); self.assertIn('/fixture/saved', kept)
|
||||
self.assertNotIn('/fixture/shared', text.split('Cesty hostiteľa nájdené')[0])
|
||||
self.assertNotIn('súkromnej siete:', text)
|
||||
|
||||
def test_primary_summary_counts_are_grammatical_for_two_three_and_five(self):
|
||||
for count in (2, 3, 5):
|
||||
with self.subTest(count=count):
|
||||
self.make_records(count)
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn(f'Počet kontajnerov aplikácie Fixture App: {count}.', text)
|
||||
self.assertIn('Všetky sú určené na odstránenie.', text)
|
||||
for vmid in self.records:
|
||||
self.assertIn(f'CT {vmid}', text)
|
||||
|
||||
def test_member_summary_warns_that_entire_application_is_selected(self):
|
||||
text = management._removal_summary(ROOT / 'oci', 999991)
|
||||
self.assertIn('CT 999991 patrí do aplikácie Fixture App (počet kontajnerov: 2).', text)
|
||||
self.assertIn('odstránenie celej aplikácie', text)
|
||||
self.assertNotIn('nádob', text)
|
||||
|
||||
def test_free_bridge_is_only_targeted_and_firewall_removal_is_conditional(self):
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn('Súkromná sieť určená na uvoľnenie, ak ju nepoužíva iná VM alebo iný CT: vmbr999', text)
|
||||
self.assertIn('Môže sa odstrániť aj zodpovedajúce spravované pravidlo firewallu hostiteľa.', text)
|
||||
|
||||
def test_shared_bridge_is_kept_and_not_reported_as_released(self):
|
||||
self.bridge.return_value = True
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn('Súkromná sieť zachovaná', text)
|
||||
self.assertNotIn('určená na uvoľnenie', text)
|
||||
|
||||
def test_missing_config_still_discloses_saved_host_paths_without_deleting(self):
|
||||
with patch.object(management, '_selected_configuration', side_effect=OSError('fixture missing')):
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertIn('/fixture/saved', text)
|
||||
self.assertIn('rootfs -', text)
|
||||
|
||||
def test_no_from_real_remove_review_never_calls_lifecycle(self):
|
||||
self.assertFalse(management._remove(ROOT / 'oci', self.ui, 999991))
|
||||
review = self.ui.review.call_args
|
||||
self.assertFalse(review.kwargs['default'])
|
||||
self.assertEqual(review.kwargs['question'],
|
||||
'Odstrániť aplikáciu? Jej kontajnerové disky sa vymažú a obnoviť ich možno iba zo zálohy.')
|
||||
|
||||
def test_cancelled_real_remove_review_never_calls_lifecycle(self):
|
||||
self.ui.review.side_effect = UserCancelled('fixture cancelled')
|
||||
with self.assertRaises(UserCancelled):
|
||||
management._remove(ROOT / 'oci', self.ui, 999990)
|
||||
|
||||
def test_preparation_error_does_not_show_confirmation_or_call_lifecycle(self):
|
||||
with patch.object(removal, 'members_of', side_effect=ValueError('fixture record invalid')):
|
||||
self.assertFalse(management._remove(ROOT / 'oci', self.ui, 999990))
|
||||
self.ui.review.assert_not_called()
|
||||
self.assertIn('Odstránenie nebolo možné pripraviť:', self.ui.message.call_args.args[0])
|
||||
|
||||
def test_pending_operation_error_precedes_configuration_probe_and_all_mutations(self):
|
||||
self.make_records(1)
|
||||
self.records[999990]['pending_transaction'] = {'fixture': True}
|
||||
self.original = copy.deepcopy(self.records)
|
||||
with patch.object(removal, 'guest_config') as config:
|
||||
with self.assertRaisesRegex(ValueError, 'Operácia tejto inštalácie sa ešte nedokončila'):
|
||||
removal.remove(Path('/fixture/oci'), 999990)
|
||||
config.assert_not_called()
|
||||
|
||||
def test_container_on_other_node_errors_before_host_cleanup(self):
|
||||
with patch.object(removal, 'guest_config', return_value=None), \
|
||||
patch.object(removal, 'guest_node', return_value='fixture-other-node'):
|
||||
with self.assertRaisesRegex(ValueError, 'Kontajner beží na inom uzle klastra'):
|
||||
removal.remove(Path('/fixture/oci'), 999990)
|
||||
|
||||
def run_main(self, result=False, busy=False):
|
||||
with patch.object(sys, 'argv', ['oci_remove.py', '999990']), \
|
||||
patch.object(removal.os, 'geteuid', return_value=0), \
|
||||
patch.object(instances, 'locked', side_effect=BlockingIOError if busy else None,
|
||||
return_value=nullcontext()), \
|
||||
patch.object(removal, 'remove', return_value=result) as mocked_remove, \
|
||||
patch.object(removal, 'msg_ok') as ok, patch.object(removal, 'msg_error') as error:
|
||||
code = removal.main()
|
||||
return code, mocked_remove, ok, error
|
||||
|
||||
def test_busy_registry_error_does_not_enter_removal(self):
|
||||
code, remove, ok, error = self.run_main(busy=True)
|
||||
self.assertEqual(code, 1); remove.assert_not_called(); ok.assert_not_called()
|
||||
self.assertEqual(error.call_args.args[0], 'Register inštancií používa iná operácia OCI')
|
||||
|
||||
def test_partial_completion_is_a_warning_to_review_not_unqualified_success(self):
|
||||
code, remove, ok, error = self.run_main(result=True)
|
||||
self.assertEqual(code, 0); error.assert_not_called()
|
||||
self.assertEqual(ok.call_args.args[0],
|
||||
'Príkaz na odstránenie sa dokončil; skontrolujte všetky upozornenia vyššie.')
|
||||
self.assertNotEqual(ok.call_args.args[0], i18n.translate('The application was removed'))
|
||||
|
||||
def test_clean_completion_keeps_existing_success_contract(self):
|
||||
code, remove, ok, error = self.run_main()
|
||||
self.assertEqual(code, 0); error.assert_not_called()
|
||||
self.assertEqual(ok.call_args.args[0], 'Aplikácia bola odstránená')
|
||||
|
||||
def test_english_primary_and_member_summaries_keep_identical_sentences(self):
|
||||
with patch.object(i18n, '_language', 'en'):
|
||||
primary = management._removal_summary(ROOT / 'oci', 999990)
|
||||
member = management._removal_summary(ROOT / 'oci', 999991)
|
||||
self.assertTrue(primary.startswith('Fixture App runs in 2 containers. All of them are targeted for removal.\n'))
|
||||
self.assertTrue(member.startswith('CT 999991 is one of the 2 containers of Fixture App. It cannot be removed on its own,'))
|
||||
|
||||
def test_missing_translation_falls_back_to_the_same_english_summary(self):
|
||||
with patch.object(i18n, '_cache', {}):
|
||||
text = management._removal_summary(ROOT / 'oci', 999990)
|
||||
self.assertTrue(text.startswith('Fixture App runs in 2 containers. All of them are targeted for removal.\n'))
|
||||
|
||||
def test_yes_only_builds_the_existing_command_when_lifecycle_is_inert(self):
|
||||
self.ui.review.return_value = True
|
||||
with patch.object(management, '_run_lifecycle', return_value=True) as inert:
|
||||
self.assertTrue(management._remove(ROOT / 'oci', self.ui, 999990))
|
||||
self.assertEqual(inert.call_args.args[0],
|
||||
[sys.executable, str(ROOT / 'oci/remote/oci_remove.py'), '999990'])
|
||||
|
||||
def test_single_and_stack_menu_routes_use_the_translated_label_and_real_no_review(self):
|
||||
for count in (1, 2):
|
||||
with self.subTest(count=count):
|
||||
self.make_records(count)
|
||||
self.ui.reset_mock()
|
||||
self.ui.choose.return_value = 'remove'
|
||||
row = {'vmid': 999990, 'stack': count > 1, 'pending': False,
|
||||
'status': 'installed', 'reason': 'matched'}
|
||||
with patch.object(management, 'carry_records', return_value={}), \
|
||||
patch.object(management, 'check_selected', return_value=row):
|
||||
self.assertFalse(management.manage_instance(ROOT / 'oci', self.ui, row))
|
||||
options = self.ui.choose.call_args.args[1]
|
||||
self.assertIn(('remove', 'Odstrániť: zmazať aplikáciu a jej kontajnery'), options)
|
||||
self.ui.review.assert_called_once()
|
||||
self.assertFalse(self.ui.review.call_args.kwargs['default'])
|
||||
|
||||
def test_changed_identity_warning_does_not_claim_the_other_container_was_deleted(self):
|
||||
self.make_records(1)
|
||||
# Exercise the real backend branch; record cleanup is inert as well.
|
||||
with patch.object(removal, 'guest_config', return_value=b'fixture'), \
|
||||
patch.object(instances, 'identity', return_value='fixture-other-identity'), \
|
||||
patch.object(removal, 'remove_owned_host_firewall', return_value=True), \
|
||||
patch.object(removal, 'release_shared_host_files'), \
|
||||
patch.object(removal.work_backup, 'forget'), \
|
||||
patch.object(removal.image_cache, 'prune', return_value=[]), \
|
||||
patch.object(Path, 'exists', return_value=False), \
|
||||
patch.object(Path, 'is_dir', return_value=False), \
|
||||
patch.object(removal, 'msg_warn') as warning, \
|
||||
patch.object(removal, 'msg_ok') as ok, patch.object(removal, 'msg_info'):
|
||||
self.assertTrue(removal.remove(Path('/fixture/oci'), 999990))
|
||||
self.assertEqual(warning.call_args.args[0],
|
||||
'VMID teraz patrí inému kontajneru; ten zostane nedotknutý: CT 999990')
|
||||
self.assertNotIn('Odstránený kontajner:', str(ok.mock_calls))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -133,7 +133,7 @@ class SelectionSetupWording(TestCase):
|
||||
with self.subTest(app=app):
|
||||
path = ROOT / f'oci/remote/install_{app}_stack.sh'
|
||||
lines = path.read_text().splitlines()
|
||||
start = next(i for i, line in enumerate(lines) if line.strip().startswith('curl -fsS "http://${APPLICATION_LAN_IP}' + port + '/" >/dev/null 2>>"$OCI_LOG"'))
|
||||
start = next(i for i, line in enumerate(lines) if line.strip().startswith('oci_curl "$APPLICATION_ID" -fsS "http://${APPLICATION_LAN_IP}' + port + '/" >/dev/null 2>>"$OCI_LOG"'))
|
||||
command = '\n'.join(lines[start:start + (3 if app == 'tandoor' else 2)])
|
||||
# Actual Bash failure expression, never source the parent installer.
|
||||
harness = '''set -u
|
||||
@@ -141,6 +141,7 @@ APPLICATION_LAN_IP=192.0.2.1
|
||||
APPLICATION_ID=101
|
||||
OCI_LOG=/dev/null
|
||||
curl() { return 7; }
|
||||
oci_curl() { shift; curl "$@"; }
|
||||
translate() { printf '%s' "$1"; }
|
||||
print_first_boot_diagnostics() { :; }
|
||||
die() { printf 'ERROR:%s\\n' "$1"; }
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Exercise the multi-container editor in Slovak without applying a change."""
|
||||
from contextlib import redirect_stderr
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
|
||||
from proxmenux_oci import host, i18n, installer, management, stack_recreation as editor
|
||||
import oci_immich_recognition as recognition
|
||||
import oci_instances as instances
|
||||
import oci_stack_modify as modify
|
||||
import oci_ui
|
||||
|
||||
CONFIG = {
|
||||
'rootfs': 'fixture:vm-999991-disk-0,size=8G',
|
||||
'mp0': 'fixture:vm-999991-disk-1,mp=/opt/recipes/mediafiles,size=8G',
|
||||
'mp1': 'fixture:vm-999991-disk-2,mp=/extra,size=4G',
|
||||
'mp2': '/fixture/shared,mp=/shared',
|
||||
'dev0': 'path=/dev/ttyACM7,mode=0660',
|
||||
}
|
||||
|
||||
|
||||
class StackEditorSlovakTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.translations = json.loads((ROOT / 'lang/sk.json').read_text())
|
||||
for module in (i18n, oci_ui):
|
||||
for name, value in (('_language', 'sk'), ('_cache', self.translations)):
|
||||
item = patch.object(module, name, value)
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
self.member = {'vmid': 999991, 'stack_member': {'name': 'application'},
|
||||
'deployment': {'replay_profile': {'adapter': 'install_tandoor_stack.sh',
|
||||
'role': 'application'}}}
|
||||
self.primary = {'vmid': 999990, 'stack': {'members': [self.member,
|
||||
{'vmid': 999992, 'deployment': {'replay_profile': {'role': 'database'}}}]}}
|
||||
self.original = copy.deepcopy(self.primary)
|
||||
self.ui = Mock()
|
||||
self.ui.choose.return_value = None
|
||||
self.ui.confirm.return_value = False
|
||||
self.ui.review.return_value = False
|
||||
self.ui.checklist.side_effect = lambda text, options, defaults: list(defaults)
|
||||
config = patch.object(editor, '_config', return_value=CONFIG)
|
||||
config.start()
|
||||
self.addCleanup(config.stop)
|
||||
self.forbidden = []
|
||||
for target, name in ((subprocess, 'run'), (subprocess, 'Popen'),
|
||||
(subprocess, 'check_output'), (Path, 'write_text'),
|
||||
(Path, 'write_bytes'), (instances, 'write'),
|
||||
(instances, 'command'), (modify, 'apply'),
|
||||
(recognition, 'apply'), (recognition, 'configure'),
|
||||
(management, '_run_lifecycle')):
|
||||
item = patch.object(target, name, side_effect=AssertionError('Forbidden effect: ' + name))
|
||||
self.forbidden.append(item.start())
|
||||
self.addCleanup(item.stop)
|
||||
self.lifecycle = Mock(side_effect=AssertionError('Editor called lifecycle'))
|
||||
self.forbidden.append(self.lifecycle)
|
||||
|
||||
def tearDown(self):
|
||||
self.assertEqual(self.primary, self.original)
|
||||
for callback in self.forbidden:
|
||||
callback.assert_not_called()
|
||||
|
||||
def shown(self):
|
||||
return '\n'.join(str(call) for call in self.ui.mock_calls)
|
||||
|
||||
def immich(self, acceleration='cpu'):
|
||||
self.member['deployment']['replay_profile'] = {
|
||||
'adapter': 'install_immich_stack.sh', 'role': 'machine-learning'}
|
||||
self.member['deployment']['machine_learning'] = {'acceleration': acceleration}
|
||||
self.original = copy.deepcopy(self.primary)
|
||||
return self.member
|
||||
|
||||
def changes(self, **values):
|
||||
return {'add_mounts': [], 'remove_mounts': [], 'add_devices': [],
|
||||
'remove_devices': [], **values}
|
||||
|
||||
def test_editor_only_offers_extra_paths_and_leaves_database_out(self):
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
shown = self.shown()
|
||||
self.assertIn('odznačené sa odstránia po potvrdení', shown)
|
||||
self.assertIn('odznačené sa odpoja po potvrdení', shown)
|
||||
options = self.ui.checklist.call_args_list[0].args[1]
|
||||
self.assertEqual([path for path, label in options], ['/extra', '/shared'])
|
||||
self.assertNotIn('zásobník', shown)
|
||||
|
||||
def test_declining_volume_deletion_keeps_it_and_only_detaches_host_path_in_draft(self):
|
||||
self.ui.checklist.side_effect = [[], ['/dev/ttyACM7']]
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
warning = self.ui.confirm.call_args_list[0].args[0]
|
||||
self.assertIn('trvalé úložisko aj všetky údaje v ňom', warning)
|
||||
self.assertIn('/extra', warning)
|
||||
summary = self.ui.review.call_args.args[0]
|
||||
self.assertIn('- /shared', summary)
|
||||
self.assertNotIn('- /extra', summary)
|
||||
self.assertIn('Spustený kontajner sa reštartuje', summary)
|
||||
self.assertIn('zastavený zostane zastavený', summary)
|
||||
self.assertIn('Databáza ani ostatné kontajnery sa nemenia', summary)
|
||||
|
||||
def test_declined_review_describes_confirmed_volume_deletion_without_performing_it(self):
|
||||
self.ui.checklist.side_effect = [[], []]
|
||||
self.ui.confirm.side_effect = [True, False, False]
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
self.assertIn('- /extra', self.ui.review.call_args.args[0])
|
||||
self.assertIn('vymažú aj údaje', self.ui.review.call_args.args[0])
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Použiť tieto zmeny?')
|
||||
|
||||
def test_member_selection_cancels_before_any_configuration_read(self):
|
||||
other = copy.deepcopy(self.member)
|
||||
other['vmid'] = 999993
|
||||
self.primary['stack']['members'].append(other)
|
||||
self.original = copy.deepcopy(self.primary)
|
||||
with patch.object(editor, '_config') as read:
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
read.assert_not_called()
|
||||
self.assertEqual(self.ui.choose.call_args.args[0], 'Kontajner, ktorý chcete upraviť')
|
||||
|
||||
def test_missing_member_error_uses_application_language(self):
|
||||
self.primary['stack']['members'] = []
|
||||
self.original = copy.deepcopy(self.primary)
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
self.assertIn('Pre túto aplikáciu nie je uložený zoznam kontajnerov.', self.shown())
|
||||
self.assertIn('Správa OCI aplikácie s viacerými kontajnermi', self.shown())
|
||||
|
||||
def test_immich_action_menu_identifies_recognition_instead_of_video_transcoding(self):
|
||||
self.immich()
|
||||
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
|
||||
self.assertIn('Vybrať CPU alebo GPU pre rozpoznávanie', self.shown())
|
||||
self.assertIn('Upraviť pripojené cesty a zariadenia', self.shown())
|
||||
|
||||
def test_cpu_only_host_explains_why_no_gpu_choice_is_offered(self):
|
||||
member = self.immich()
|
||||
with patch.object(host, 'gpus', return_value={'intel': [], 'amd': [], 'nvidia': []}):
|
||||
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
|
||||
member, self.lifecycle))
|
||||
self.assertIn('nenašla použiteľná grafická karta', self.shown())
|
||||
self.assertIn('Rozpoznávanie zostáva na CPU.', self.shown())
|
||||
self.ui.review.assert_not_called()
|
||||
|
||||
def test_gpu_review_explains_whole_application_update_and_kept_data_before_no(self):
|
||||
member = self.immich()
|
||||
self.ui.choose.return_value = 'openvino'
|
||||
found = {'intel': ['/dev/dri/renderD128'], 'amd': [], 'nvidia': []}
|
||||
with patch.object(host, 'gpus', return_value=found):
|
||||
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
|
||||
member, self.lifecycle))
|
||||
choices = self.ui.choose.call_args.args[1]
|
||||
self.assertEqual(choices, [('cpu', 'CPU'), ('openvino', 'Intel GPU')])
|
||||
text = self.ui.review.call_args.args[0]
|
||||
for fragment in ('CPU → Intel GPU', 'pamäť modelov, knižnica a databáza sa zachovajú',
|
||||
'Celá aplikácia sa zastaví a aktualizuje', 'ProxMenux sa pokúsi obnoviť',
|
||||
'ponuky správy OCI'):
|
||||
self.assertIn(fragment, text)
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'],
|
||||
'Použiť zvolený CPU/GPU pre rozpoznávanie?')
|
||||
|
||||
def test_unchanged_recognition_does_not_open_confirmation_or_apply(self):
|
||||
member = self.immich()
|
||||
self.ui.choose.return_value = 'cpu'
|
||||
with patch.object(editor, 'recognition_choices', return_value=[('cpu', 'CPU', {}),
|
||||
('cuda', 'NVIDIA GPU', {})]):
|
||||
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
|
||||
member, self.lifecycle))
|
||||
self.ui.review.assert_not_called()
|
||||
self.assertIn('Nič sa nezmenilo.', self.shown())
|
||||
|
||||
def test_experimental_rocm_can_be_declined_before_final_review(self):
|
||||
member = self.immich()
|
||||
self.ui.choose.return_value = 'rocm'
|
||||
choices = [('cpu', 'CPU', {}), ('rocm', 'AMD GPU', {'experimental': True})]
|
||||
with patch.object(editor, 'recognition_choices', return_value=choices), \
|
||||
patch.object(host, 'amd_gpu_name', return_value='Fixture AMD'):
|
||||
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
|
||||
member, self.lifecycle))
|
||||
text = self.ui.confirm.call_args.args[0]
|
||||
self.assertIn('ROCm túto grafickú kartu oficiálne nepodporuje.', text)
|
||||
self.assertIn('GPU prestať reagovať', text)
|
||||
self.assertIn('bez preinštalovania', text)
|
||||
self.assertIn('Použiť napriek tomu GPU s ROCm?', text)
|
||||
self.assertFalse(self.ui.confirm.call_args.args[1])
|
||||
self.ui.review.assert_not_called()
|
||||
|
||||
def test_backend_invalid_volume_size_preserves_path_and_fails_before_mutation(self):
|
||||
mount = {'type': 'managed-volume', 'container_path': '/new-data',
|
||||
'size_gb': 0, 'source': 'fixture'}
|
||||
with patch.object(modify, 'entries', return_value={}):
|
||||
with self.assertRaisesRegex(ValueError, 'Neplatná veľkosť trvalého úložiska: /new-data'):
|
||||
modify.validate(999991, self.changes(add_mounts=[mount]))
|
||||
|
||||
def test_backend_unsupported_device_explains_separate_profile_before_mutation(self):
|
||||
device = {'kind': 'character-device', 'host_path': '/dev/sda'}
|
||||
with patch.object(modify, 'entries', return_value={}):
|
||||
with self.assertRaisesRegex(ValueError, 'NVIDIA a celé stromy zariadení vyžadujú iný profil: /dev/sda'):
|
||||
modify.validate(999991, self.changes(add_devices=[device]))
|
||||
|
||||
def test_root_error_is_localized_without_starting_the_operation(self):
|
||||
with patch.object(modify.os, 'geteuid', return_value=1000), \
|
||||
patch.object(sys, 'argv', ['fixture', '999991', '--changes', '/not-read.json']), \
|
||||
redirect_stderr(io.StringIO()) as output:
|
||||
with self.assertRaises(SystemExit) as result:
|
||||
modify.main()
|
||||
self.assertEqual(result.exception.code, 2)
|
||||
self.assertIn('Vyžadujú sa oprávnenia používateľa root', output.getvalue())
|
||||
self.assertNotIn('required', output.getvalue())
|
||||
|
||||
def test_invalid_recognition_profile_is_localized_without_any_gpu_probe(self):
|
||||
with self.assertRaisesRegex(ValueError, 'Profil GPU pre Immich nebol overený'):
|
||||
recognition.validate('unknown', None, None)
|
||||
|
||||
def test_recognition_errors_distinguish_unchanged_from_incomplete_operation(self):
|
||||
incomplete = RuntimeError('fixture diagnostic')
|
||||
incomplete.recognition_kept = False
|
||||
for error, expected in ((ValueError('fixture diagnostic'), 'Spôsob rozpoznávania v Immichu sa nezmenil:'),
|
||||
(incomplete, 'Zmenu CPU/GPU pre rozpoznávanie v Immichu sa nepodarilo dokončiť:')):
|
||||
with self.subTest(error=type(error).__name__), \
|
||||
patch.object(recognition.os, 'geteuid', return_value=0), \
|
||||
patch.object(sys, 'argv', ['fixture', '999990', '--acceleration', 'cpu']), \
|
||||
patch.object(recognition, 'change', side_effect=error), \
|
||||
patch.object(recognition, 'msg_error') as message:
|
||||
self.assertEqual(recognition.main(), 1)
|
||||
self.assertEqual(message.call_args.args[0], expected + ' fixture diagnostic')
|
||||
|
||||
def test_english_and_missing_translation_fallback_preserve_source_text(self):
|
||||
changes = self.changes(remove_mounts=['/extra'])
|
||||
for language, cache in (('en', self.translations), ('sk', {})):
|
||||
with self.subTest(language=language, empty_cache=not cache), \
|
||||
patch.object(i18n, '_language', language), patch.object(i18n, '_cache', cache):
|
||||
text = editor.summary(self.member, changes)
|
||||
self.assertIn('A running container is restarted', text)
|
||||
self.assertIn('- /extra', text)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Coordinated update/recreate contracts; no host lifecycle is permitted."""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_oci_update_cancellation import CancellationFixture, ROOT
|
||||
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui, updates
|
||||
import oci_instances as instances
|
||||
import oci_stack_plan as stack_plan
|
||||
import oci_stack_transaction as transaction
|
||||
import oci_ui
|
||||
|
||||
|
||||
class StackFixture(CancellationFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.row['stack'] = True
|
||||
self.record.update(installation_id='fiction-main', stack={
|
||||
'id': 'fiction-main', 'members': [
|
||||
{'vmid': vmid, 'installation_id': identity, 'status': 'installed',
|
||||
'stack_member': {'stack_id': 'fiction-main', 'primary_vmid': 110}}
|
||||
for vmid, identity in [(110, 'fiction-main'), (111, 'fiction-dependent')]],
|
||||
'deployment': {'services': [{'vmid': 111}, {'vmid': 110}]}})
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.extra_effects = [self.guard(Path, name) for name in ('write_text', 'write_bytes', 'unlink')]
|
||||
self.extra_effects += [self.guard(subprocess, name) for name in ('run', 'Popen', 'check_output')]
|
||||
self.extra_effects += [self.guard(transaction, 'execute'), self.guard(transaction, 'save'),
|
||||
self.guard(menu.images, 'offer_removal')]
|
||||
|
||||
def no_effects(self):
|
||||
super().no_effects()
|
||||
for effect in self.extra_effects:
|
||||
effect.assert_not_called()
|
||||
|
||||
|
||||
class StackCancellationTests(StackFixture):
|
||||
def test_update_and_recreate_no_are_falsy_distinct_cancellation(self):
|
||||
for action in ('update', 'recreate'):
|
||||
with self.subTest(action=action):
|
||||
result = menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
|
||||
self.assertIs(result, menu.CANCELLED)
|
||||
self.assertFalse(result)
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_cancel_returns_six_for_both_actions(self):
|
||||
for action in ('update', 'recreate'):
|
||||
with self.subTest(action=action), patch.object(cli, 'Catalog'):
|
||||
self.assertEqual(cli.main(['manage', '110', '--action', action]), 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_actual_dialog_no_and_escape_return_six_before_any_effect(self):
|
||||
dialog = native_ui.DialogUI()
|
||||
for action in ('update', 'recreate'):
|
||||
for code in (1, 255):
|
||||
with self.subTest(action=action, code=code), \
|
||||
patch.object(native_ui, 'interactive_ui', return_value=dialog), \
|
||||
patch.object(dialog, '_run', return_value=subprocess.CompletedProcess('dialog', code, stderr='')):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action), 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_confirmation_defaults_and_member_count_are_preserved(self):
|
||||
for action, default in [('update', True), ('recreate', False)]:
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
|
||||
args, kwargs = self.ui.review.call_args
|
||||
self.assertIn('2 containers', args[0])
|
||||
self.assertIn('main CT: 110', args[0])
|
||||
self.assertEqual(kwargs['default'], default)
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_returns_to_the_application_list_after_no(self):
|
||||
for action in ('update', 'recreate'):
|
||||
with self.subTest(action=action):
|
||||
self.ui.choose.reset_mock()
|
||||
self.ui.choose.side_effect = ['110', action, None]
|
||||
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
|
||||
patch.object(menu, '_records_unread', return_value=[]):
|
||||
menu.interactive_management(ROOT/'oci', self.ui)
|
||||
self.assertEqual(self.ui.choose.call_count, 3)
|
||||
self.no_effects()
|
||||
|
||||
def test_batch_cancel_never_checks_the_next_application(self):
|
||||
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 112, 'title': 'Second'}]
|
||||
self.ui.checklist.return_value = ['all']
|
||||
self.ui.review.side_effect = [True, False]
|
||||
with patch.object(updates, 'UpdateCheck') as checker, \
|
||||
patch.object(menu, 'saved_inventory', return_value=[self.row, {**self.row, 'vmid': 112}]), \
|
||||
patch.object(menu, 'check_selected', side_effect=lambda project, row: row) as selected:
|
||||
checker.return_value.status.return_value = {'available': available}
|
||||
self.assertFalse(updates.select_updates(ROOT/'oci', self.ui, available))
|
||||
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
|
||||
self.no_effects()
|
||||
|
||||
def test_rejected_backup_remains_failure_one_without_dispatch(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = False
|
||||
for action in ('update', 'recreate'):
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action), 1)
|
||||
self.lifecycle.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
|
||||
def test_positive_confirmation_preserves_success_failure_and_exact_commands(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
for action in ('update', 'recreate'):
|
||||
for result in (False, True):
|
||||
self.lifecycle.reset_mock()
|
||||
self.lifecycle.return_value = result
|
||||
with patch.object(menu.images, 'offer_removal') as offer:
|
||||
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action,
|
||||
lifecycle_args=('--keep-backup',)), 0 if result else 1)
|
||||
expected = [sys.executable, str(ROOT/'oci/remote/oci_stack_native.py'), '110', '--keep-backup']
|
||||
if action == 'recreate':
|
||||
expected += ['--operation', 'recreate']
|
||||
self.assertEqual(self.lifecycle.call_args.args[0], expected+['--acknowledge-external-data'])
|
||||
self.assertEqual(offer.call_count, int(result))
|
||||
self.assertEqual(self.record, self.original)
|
||||
self.write.assert_not_called()
|
||||
self.command.assert_not_called()
|
||||
|
||||
def test_acknowledgement_is_not_duplicated(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = False
|
||||
menu.direct_management(ROOT/'oci', 110, 'recreate', lifecycle_args=('--acknowledge-external-data',))
|
||||
self.assertEqual(self.lifecycle.call_args.args[0].count('--acknowledge-external-data'), 1)
|
||||
|
||||
|
||||
class StackSlovakTests(StackFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.catalog = json.loads((ROOT/'lang/sk.json').read_text())
|
||||
for module in (i18n, oci_ui):
|
||||
for name, value in [('_language', 'sk'), ('_cache', self.catalog)]:
|
||||
item = patch.object(module, name, value)
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
|
||||
def test_questions_and_titles_name_the_whole_application(self):
|
||||
for action, question in [('update', 'Aktualizovať celú aplikáciu?'),
|
||||
('recreate', 'Znovu vytvoriť všetky kontajnery aplikácie?')]:
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'], question)
|
||||
self.assertIn('aplikáciu s viacerými kontajnermi', self.ui.review.call_args.args[1])
|
||||
self.no_effects()
|
||||
|
||||
def test_recreation_dialog_explains_saved_images_and_restoring_backups(self):
|
||||
menu.manage_instance(ROOT/'oci', self.ui, self.row, action='recreate')
|
||||
text = self.ui.review.call_args.args[0]
|
||||
self.assertIn('uložených identifikátorov obrazov', text)
|
||||
self.assertIn('2', text)
|
||||
self.assertIn('110', text)
|
||||
self.assertIn('zálohy sa obnovia', text)
|
||||
self.assertNotIn('Stack', text)
|
||||
self.no_effects()
|
||||
|
||||
def test_shared_missing_primary_error_in_plan_and_publish_before_any_effect(self):
|
||||
primary = copy.deepcopy(self.record)
|
||||
primary['vmid'] = 112
|
||||
with self.assertRaisesRegex(ValueError, '^Chýba hlavný kontajner aplikácie$'):
|
||||
stack_plan.build(primary, {}, {}, 'update')
|
||||
with self.assertRaisesRegex(ValueError, '^Chýba hlavný kontajner aplikácie$'):
|
||||
instances.publish_stack(Path('/fiction-only'), 110, {}, {}, [])
|
||||
self.no_effects()
|
||||
|
||||
def test_missing_member_error_keeps_volume_verification_requirement(self):
|
||||
records = {row['vmid']: row for row in self.record['stack']['members']}
|
||||
with self.assertRaisesRegex(ValueError, 'znova ich vytvorte až po overení ich dátových zväzkov'):
|
||||
stack_plan.build(self.record, records, {110: 'fiction-main'}, 'update')
|
||||
self.no_effects()
|
||||
|
||||
def test_pending_and_conflicting_member_errors_keep_the_identifier(self):
|
||||
records = {row['vmid']: copy.deepcopy(row) for row in self.record['stack']['members']}
|
||||
inventory = {key: row['installation_id'] for key, row in records.items()}
|
||||
records[111]['status'] = 'pending'
|
||||
with self.assertRaisesRegex(ValueError, '^Nedokončená operácia existuje pre CT 111$'):
|
||||
stack_plan.build(self.record, records, inventory, 'update')
|
||||
records[111]['status'] = 'installed'
|
||||
inventory[111] = 'different-installation'
|
||||
with self.assertRaisesRegex(ValueError, '^Iná inštancia už používa VMID 111$'):
|
||||
stack_plan.build(self.record, records, inventory, 'update')
|
||||
self.no_effects()
|
||||
|
||||
def test_plan_failure_is_read_only_and_distinguishes_invalid_identity(self):
|
||||
primary = copy.deepcopy(self.record)
|
||||
primary['stack']['id'] = 'different-stack'
|
||||
with self.assertRaisesRegex(ValueError, '^Nekonzistentná identita aplikácie s viacerými kontajnermi$'):
|
||||
stack_plan.build(primary, {}, {}, 'recreate')
|
||||
self.no_effects()
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Slovak storage questions through real builders and UI; installation forbidden."""
|
||||
from contextlib import redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[3]
|
||||
sys.path[:0]=[str(ROOT/'oci/src'),str(ROOT/'oci/remote')]
|
||||
from proxmenux_oci import catalog_refresh, cli, custom, custom_mounts, extra_devices, i18n, installer, ui as native_ui, updates
|
||||
from proxmenux_oci.catalog import Catalog
|
||||
|
||||
|
||||
class Recorder:
|
||||
def __init__(self, answers=None):
|
||||
self.answers=answers or {}
|
||||
self.asked=[]
|
||||
self.messages=[]
|
||||
def _answer(self,text,default):
|
||||
self.asked.append(text)
|
||||
return self.answers.get(text,default)
|
||||
def ask(self,text,default=None,**kwargs):return self._answer(text,default if default is not None else '')
|
||||
def password(self,text,**kwargs):return self._answer(text,'fictional-value-for-tests-000000')
|
||||
def choose(self,text,options,default=None,**kwargs):return self._answer(text,default)
|
||||
def confirm(self,text,default=False,**kwargs):return self._answer(text,default)
|
||||
def checklist(self,text,options,defaults=None,**kwargs):return self._answer(text,defaults or [])
|
||||
def review(self,*args,**kwargs):return False
|
||||
def info(self,text):pass
|
||||
def message(self,text,*args):self.messages.append(text)
|
||||
|
||||
|
||||
class StorageFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.catalog=Catalog(ROOT/'oci')
|
||||
self.sk=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
self.forbidden=[]
|
||||
self.patch(i18n,'_language','sk')
|
||||
self.patch(i18n,'_cache',self.sk)
|
||||
self.patch(installer.host,'storages',side_effect=lambda content: [
|
||||
{'storage':'local-lvm','type':'lvmthin','avail':100*2**30},
|
||||
{'storage':'fiction-storage','type':'dir','avail':200*2**30}] if content=='rootdir' else [
|
||||
{'storage':'local','type':'dir','avail':100*2**30}])
|
||||
self.patch(installer.host,'bridges',return_value=[{'iface':'vmbr0','cidr':'192.0.2.10/24'}])
|
||||
self.patch(installer.host,'timezone',return_value='Europe/Bratislava')
|
||||
self.patch(installer.host,'gpus',return_value={'intel':[],'amd':[],'nvidia':False})
|
||||
self.patch(installer.access,'ask_addresses',side_effect=lambda ui,bridge,names,*a:({name:'dhcp' for name in names},None))
|
||||
self.patch(installer.access,'ask_ipv4',return_value=('dhcp',None))
|
||||
for module,name in [(installer,'run_remote_install'),(cli,'run_remote_install'),
|
||||
(cli.images,'offer_removal'),(subprocess,'run'),(subprocess,'Popen'),(subprocess,'check_output'),
|
||||
(Path,'write_text'),(Path,'write_bytes'),(Path,'unlink')]:
|
||||
self.forbidden.append(self.patch(module,name,side_effect=AssertionError('Forbidden effect: '+name)))
|
||||
self.addCleanup(self.no_effects)
|
||||
def patch(self,module,name,*args,**kwargs):
|
||||
item=patch.object(module,name,*args,**kwargs);value=item.start();self.addCleanup(item.stop);return value
|
||||
def no_effects(self):
|
||||
for effect in self.forbidden:effect.assert_not_called()
|
||||
def recorder(self,answers=None):
|
||||
return Recorder({i18n.translate(key):value for key,value in (answers or {}).items()})
|
||||
def build(self,app,answers=None,mode=installer.ADVANCED_MODE):
|
||||
ui=self.recorder(answers)
|
||||
plan=installer.build_deployment(self.catalog.compose(app),ui,mode)
|
||||
return ui,plan
|
||||
|
||||
|
||||
class StorageWordingTests(StorageFixture):
|
||||
def test_tandoor_files_question_and_minimum_error_keep_the_limit(self):
|
||||
ui,plan=self.build('tandoor',{'Files volume size in GB':'9'})
|
||||
self.assertIn('Veľkosť dátového zväzku pre súbory v GB',ui.asked)
|
||||
self.assertEqual(plan['media']['size_gb'],9)
|
||||
with self.assertRaisesRegex(installer.InstallError,'Dátový zväzok súborov Tandoor musí mať najmenej 2 GB'):
|
||||
self.build('tandoor',{'Files volume size in GB':'1'})
|
||||
|
||||
def test_paperless_storage_documents_and_consume_export_names(self):
|
||||
ui,plan=self.build('paperless-ngx',{'Documents volume size in GB':'30',
|
||||
'Where to store the consume and export folders':'managed-volume','Size of each consume/export volume in GB':'3'})
|
||||
for text in ['Úložisko pre údaje a dokumenty aplikácie Paperless',
|
||||
'Kam uložiť priečinky consume a export',
|
||||
'Veľkosť dátového zväzku pre dokumenty v GB',
|
||||
'Veľkosť každého dátového zväzku pre consume a export v GB']:
|
||||
self.assertIn(text,ui.asked)
|
||||
self.assertEqual((plan['media_size_gb'],plan['transfer']['size_gb'],plan['transfer']['backup']),(30,3,True))
|
||||
|
||||
def test_paperless_volume_limit_and_transfer_limit_errors(self):
|
||||
with self.assertRaisesRegex(installer.InstallError,'Trvalé dátové zväzky aplikácie Paperless musia mať najmenej 8 GB'):
|
||||
self.build('paperless-ngx',{'Documents volume size in GB':'7'})
|
||||
with self.assertRaisesRegex(installer.InstallError,'Dátové zväzky pre consume a export musia mať najmenej 1 GB'):
|
||||
self.build('paperless-ngx',{'Where to store the consume and export folders':'managed-volume',
|
||||
'Size of each consume/export volume in GB':'0'})
|
||||
|
||||
def test_postgresql_question_and_error_are_shared_by_immich_and_nextcloud(self):
|
||||
for app in ('immich','nextcloud-stack'):
|
||||
with self.subTest(app=app):
|
||||
ui,plan=self.build(app,{'PostgreSQL volume size in GB':'12'})
|
||||
self.assertIn('Veľkosť dátového zväzku PostgreSQL v GB',ui.asked)
|
||||
self.assertEqual(plan['database_size_gb'],12)
|
||||
with self.assertRaisesRegex(installer.InstallError,'Dátový zväzok PostgreSQL musí mať najmenej 8 GB'):
|
||||
self.build(app,{'PostgreSQL volume size in GB':'7'})
|
||||
|
||||
def test_host_directory_remains_outside_the_container_backup(self):
|
||||
ui,plan=self.build('nextcloud-stack',{'Where to store the Nextcloud files, configuration and data':'host-bind',
|
||||
'Shared host directory':'/fiction-only/existing-data'})
|
||||
self.assertIn('Zdieľaný adresár hostiteľa',ui.asked)
|
||||
self.assertEqual((plan['application']['mode'],plan['application']['host_path'],plan['application']['backup']),
|
||||
('host-bind','/fiction-only/existing-data',False))
|
||||
|
||||
def test_default_and_advanced_builders_preserve_language_independent_plans(self):
|
||||
for app in ('jellyfin','immich','nextcloud-stack','paperless-ngx','tandoor'):
|
||||
for mode in (installer.DEFAULT_MODE,installer.ADVANCED_MODE):
|
||||
with self.subTest(app=app,mode=mode):
|
||||
_ui,slovak=self.build(app,mode=mode)
|
||||
with patch.object(i18n,'_language','en'):
|
||||
_ui,english=self.build(app,mode=mode)
|
||||
self.assertEqual(slovak,english)
|
||||
|
||||
def test_multi_path_cancel_and_invalid_selection_preserve_all_members(self):
|
||||
services=[{'name':'main','main':True,'deployment':{'mounts':[]}},
|
||||
{'name':'database','deployment':{'mounts':[]}}]
|
||||
original=copy.deepcopy(services)
|
||||
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':[]})
|
||||
with self.assertRaisesRegex(ValueError,'^Vyberte aspoň jeden kontajner aplikácie$'):
|
||||
custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
|
||||
self.assertIn('Pridať ďalšie cesty do aplikácie s viacerými kontajnermi',ui.asked)
|
||||
self.assertEqual(services,original)
|
||||
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':['main'],
|
||||
'Data location':None})
|
||||
with self.assertRaises(native_ui.UserCancelled):custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
|
||||
self.assertEqual(services,original)
|
||||
|
||||
def test_shared_selection_error_in_device_flow_also_preserves_members(self):
|
||||
services=[{'name':'main','main':True,'deployment':{}}];original=copy.deepcopy(services)
|
||||
ui=self.recorder()
|
||||
with patch.object(extra_devices,'ask_extra_devices',return_value=[{'kind':'usb','host_path':'/fiction-only/device'}]), \
|
||||
patch.object(ui,'checklist',return_value=[]):
|
||||
with self.assertRaisesRegex(ValueError,'^Vyberte aspoň jeden kontajner aplikácie$'):
|
||||
extra_devices.ask_stack_extra_devices(ui,services)
|
||||
self.assertEqual(services,original)
|
||||
|
||||
def test_selected_path_is_added_only_to_selected_member_with_backup(self):
|
||||
services=[{'name':'main','main':True,'deployment':{'mounts':[]}},
|
||||
{'name':'database','deployment':{'mounts':[]}}]
|
||||
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':['main']})
|
||||
custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
|
||||
self.assertEqual(services[1]['deployment']['mounts'],[])
|
||||
mount=services[0]['deployment']['mounts'][0]
|
||||
self.assertEqual((mount['source'],mount['container_path'],mount['size_gb'],mount['backup']),
|
||||
('fiction-storage','/media-extra',8,True))
|
||||
self.assertIn('Pridať ďalšiu cestu do aplikácie s viacerými kontajnermi',ui.asked)
|
||||
|
||||
def test_rclone_shared_directory_question_is_localized_without_creating_it(self):
|
||||
ui=self.recorder({'Shared directory for copy/sync operations':'/fiction-only/rclone'})
|
||||
with patch.object(ui,'confirm',return_value=True):
|
||||
plan=installer.build_deployment(self.catalog.compose('rclone'),ui,installer.ADVANCED_MODE)
|
||||
self.assertIn('Zdieľaný adresár na kopírovanie a synchronizáciu',ui.asked)
|
||||
self.assertTrue(any(m.get('source')=='/fiction-only/rclone' and not m['backup'] for m in plan['mounts']))
|
||||
|
||||
|
||||
class InputAndCancellationTests(StorageFixture):
|
||||
def test_dialog_required_value_and_password_use_slovak_then_retry(self):
|
||||
dialog=native_ui.DialogUI()
|
||||
empty=subprocess.CompletedProcess('dialog',0,stderr='')
|
||||
value=subprocess.CompletedProcess('dialog',0,stderr='fiction-value')
|
||||
with patch.object(dialog,'_run',side_effect=[empty,value]),patch.object(dialog,'message') as message:
|
||||
self.assertEqual(dialog.ask('fiction-field'),'fiction-value')
|
||||
message.assert_called_once_with('Táto hodnota je povinná.')
|
||||
with patch.object(dialog,'_run',side_effect=[empty,value,value]),patch.object(dialog,'message') as message:
|
||||
self.assertEqual(dialog.password('fiction-field'),'fiction-value')
|
||||
message.assert_called_once_with('Táto hodnota je povinná.')
|
||||
|
||||
def test_terminal_required_value_and_password_are_also_localized(self):
|
||||
terminal=native_ui.TerminalUI()
|
||||
with patch('builtins.input',side_effect=['','fiction-value']),redirect_stdout(io.StringIO()) as output:
|
||||
self.assertEqual(terminal.ask('fiction-field'),'fiction-value')
|
||||
self.assertIn('Táto hodnota je povinná.',output.getvalue())
|
||||
with patch.object(native_ui.getpass,'getpass',side_effect=['','fiction-value','fiction-value']),redirect_stdout(io.StringIO()) as output:
|
||||
self.assertEqual(terminal.password('fiction-field'),'fiction-value')
|
||||
self.assertIn('Táto hodnota je povinná.',output.getvalue())
|
||||
|
||||
def test_real_install_flow_storage_cancel_and_final_no_never_dispatch(self):
|
||||
template=self.catalog.compose('tandoor');original=copy.deepcopy(template)
|
||||
dialog=native_ui.DialogUI()
|
||||
for code in (1,255):
|
||||
replies=[subprocess.CompletedProcess('dialog',0,stderr=value) for value in ('','fiction-stack','2','2048')]
|
||||
replies.append(subprocess.CompletedProcess('dialog',code,stderr=''))
|
||||
with self.subTest(code=code),patch.object(dialog,'_run',side_effect=replies) as run:
|
||||
with self.assertRaises(native_ui.UserCancelled):cli.install_template(dialog,template,'tandoor',installer.ADVANCED_MODE)
|
||||
self.assertEqual(run.call_count,5)
|
||||
self.assertEqual(template,original)
|
||||
self.assertIsNone(cli.install_template(self.recorder(),template,'tandoor',installer.ADVANCED_MODE))
|
||||
self.assertEqual(template,original)
|
||||
|
||||
def test_numeric_retry_keeps_previous_answers_and_already_correct_slovak(self):
|
||||
ui=Recorder();wizard=native_ui.BacktrackUI(ui)
|
||||
wizard.answers=[('ask','previous'),('ask','abc')];wizard.cursor=2
|
||||
self.assertTrue(wizard.retry_last(ValueError("invalid literal for int() with base 10: 'abc'")))
|
||||
self.assertEqual(ui.messages,["Hodnota musí byť číslo: 'abc'\n\nZadajte hodnotu znova."])
|
||||
self.assertEqual((wizard.answers,wizard.cursor),([('ask','previous')],0));wizard.close()
|
||||
|
||||
def test_real_menu_error_handler_has_slovak_heading_and_preserves_detail(self):
|
||||
ui=Mock();ui.choose.side_effect=['custom',None]
|
||||
with patch.object(cli,'interactive_ui',return_value=ui),patch.object(cli,'_catalog_options',return_value=([],{}, {}, {}, [])), \
|
||||
patch.object(custom,'explore',side_effect=ValueError('fiction-detail')), \
|
||||
patch.object(updates,'UpdateCheck') as checker, \
|
||||
patch.object(catalog_refresh,'CatalogRefresh') as catalog_checker:
|
||||
# Keep this wording/error-handler test offline and free of writes.
|
||||
# The real background worker has its own catalog lifecycle tests.
|
||||
catalog_check=catalog_checker.return_value.__enter__.return_value
|
||||
catalog_check.active=False;catalog_check.consume.return_value=False
|
||||
catalog_check.failed=False;catalog_check.label.return_value='Refresh catalog'
|
||||
check=checker.return_value.__enter__.return_value
|
||||
check.active=False;check.notice.return_value='';check.status.return_value={'available':[]}
|
||||
check.label.return_value='Check for updates'
|
||||
self.assertEqual(cli.interactive(self.catalog),0)
|
||||
ui.message.assert_called_once_with('Operáciu sa nepodarilo dokončiť:\n\nfiction-detail')
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Real native OCI menu/CLI paths with all external effects replaced."""
|
||||
from contextlib import redirect_stdout
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
|
||||
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui, updates
|
||||
import oci_instances as instances
|
||||
import oci_instance_reconcile as reconcile
|
||||
|
||||
|
||||
class CancellationFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.record = {'status': 'installed', 'vmid': 110, 'deployment': {}, 'template': {}}
|
||||
self.original = copy.deepcopy(self.record)
|
||||
self.row = dict(vmid=110, reason='matched', stack=False, pending=False, status='installed',
|
||||
title='Fixture application', hostname='fixture', image='fixture:latest')
|
||||
self.ui = Mock()
|
||||
self.ui.unattended = False
|
||||
self.ui.review.return_value = False
|
||||
for module, name, options in [
|
||||
(i18n, '_language', dict(new='en')), (i18n, '_cache', dict(new={})),
|
||||
(menu.os, 'geteuid', dict(return_value=0)), (menu.shutil, 'which', dict(return_value='/fixture/pct')),
|
||||
(native_ui, 'interactive_ui', dict(return_value=self.ui)),
|
||||
(menu, 'recover_automatically', dict(return_value=None)),
|
||||
(menu, 'carry_records', dict(return_value={})),
|
||||
(menu, 'check_selected', dict(side_effect=lambda project, row: row)),
|
||||
(menu, 'saved_inventory', dict(return_value=[self.row])),
|
||||
(menu, '_selected_configuration', dict(return_value=b'fixture configuration')),
|
||||
(instances, 'read', dict(return_value=self.record)),
|
||||
(reconcile, 'propose', dict(return_value=None)),
|
||||
]:
|
||||
item = patch.object(module, name, **options)
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
self.lifecycle = self.guard(menu, '_run_lifecycle')
|
||||
self.backup = self.guard(menu, '_work_backup_ready')
|
||||
self.write = self.guard(instances, 'write')
|
||||
self.command = self.guard(instances, 'command')
|
||||
self.commit = self.guard(reconcile, 'commit')
|
||||
|
||||
def guard(self, module, name):
|
||||
item = patch.object(module, name, side_effect=AssertionError('Forbidden effect: ' + name))
|
||||
mock = item.start()
|
||||
self.addCleanup(item.stop)
|
||||
return mock
|
||||
|
||||
def no_effects(self):
|
||||
for mock in [self.lifecycle, self.backup, self.write, self.command, self.commit]:
|
||||
mock.assert_not_called()
|
||||
self.assertEqual(self.record, self.original)
|
||||
|
||||
|
||||
class UpdateCancellationTests(CancellationFixture):
|
||||
def test_no_is_a_distinct_falsy_result_before_backup_or_lifecycle(self):
|
||||
result = menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
|
||||
self.assertIs(result, menu.CANCELLED)
|
||||
self.assertFalse(result)
|
||||
self.no_effects()
|
||||
|
||||
def test_real_cli_no_returns_cancel_code_instead_of_failure_or_success(self):
|
||||
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
|
||||
result = cli.main(['manage', '110', '--action', 'update'])
|
||||
self.assertEqual(result, 6)
|
||||
self.assertEqual(output.getvalue(), '') # The terminal renders the localized outcome once.
|
||||
self.no_effects()
|
||||
|
||||
def test_no_in_stack_confirmation_keeps_every_member_unchanged(self):
|
||||
self.row['stack'] = True
|
||||
self.record['stack'] = {'members': [{'vmid': 110}, {'vmid': 111}]}
|
||||
self.original = copy.deepcopy(self.record)
|
||||
with redirect_stdout(io.StringIO()):
|
||||
result = menu.direct_management(ROOT / 'oci', 110, 'update')
|
||||
self.assertEqual(result, 6)
|
||||
self.no_effects()
|
||||
|
||||
def test_native_menu_returns_to_its_list_after_no(self):
|
||||
self.ui.choose.side_effect = ['110', 'update', None]
|
||||
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
|
||||
patch.object(menu, '_records_unread', return_value=[]):
|
||||
menu.interactive_management(ROOT / 'oci', self.ui)
|
||||
self.assertEqual(self.ui.choose.call_count, 3)
|
||||
self.no_effects()
|
||||
|
||||
def test_batch_no_does_not_start_the_next_application(self):
|
||||
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 111, 'title': 'Second'}]
|
||||
rows = [self.row, {**self.row, 'vmid': 111}]
|
||||
self.ui.checklist.return_value = ['all']
|
||||
self.ui.review.side_effect = [True, False] # Batch review, first application's confirmation.
|
||||
with patch.object(updates, 'UpdateCheck') as checker, patch.object(menu, 'saved_inventory', return_value=rows), \
|
||||
patch.object(menu, 'check_selected', wraps=lambda project, row: row) as selected:
|
||||
checker.return_value.status.return_value = {'available': available}
|
||||
self.assertFalse(updates.select_updates(ROOT / 'oci', self.ui, available))
|
||||
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
|
||||
self.no_effects()
|
||||
|
||||
def test_accepted_update_failure_keeps_failure_code_one(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = False
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
|
||||
self.lifecycle.assert_not_called()
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_accepted_update_preserves_success_and_existing_lifecycle_command(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = True
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 0)
|
||||
self.lifecycle.assert_called_once()
|
||||
command = self.lifecycle.call_args.args[0]
|
||||
self.assertEqual(command[:3], [sys.executable, str(ROOT / 'oci/remote/oci_update_current.py'), '110'])
|
||||
self.assertIn('--acknowledge-external-data', command)
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_actual_lifecycle_failure_remains_failure_after_positive_confirmation(self):
|
||||
self.ui.review.return_value = True
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = False
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
|
||||
self.lifecycle.assert_called_once()
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_unattended_positive_default_still_reaches_existing_update(self):
|
||||
self.backup.side_effect = None
|
||||
self.backup.return_value = True
|
||||
self.lifecycle.side_effect = None
|
||||
self.lifecycle.return_value = True
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 0)
|
||||
self.lifecycle.assert_called_once()
|
||||
self.write.assert_not_called()
|
||||
|
||||
def test_existing_unattended_review_age_busy_and_missing_codes_are_preserved(self):
|
||||
def declined(unattended, *_args, **_kwargs):
|
||||
unattended.declined = 'review'
|
||||
return False
|
||||
with patch.object(reconcile, 'propose', return_value={'details': ['external change'], 'review': True}), \
|
||||
patch.object(menu.UnattendedUI, 'review', declined), redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 4)
|
||||
with patch.object(menu, '_image_too_recent', return_value=True), redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True, min_image_age_days=3), 5)
|
||||
with patch.object(menu, 'saved_inventory', return_value=[]):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 2)
|
||||
with patch.object(menu, 'recover_automatically', side_effect=BlockingIOError()):
|
||||
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 3)
|
||||
self.no_effects()
|
||||
|
||||
def test_slovak_dialog_and_recovery_choice_have_the_approved_meaning(self):
|
||||
cache = json.loads((ROOT / 'lang/sk.json').read_text())
|
||||
with patch.object(i18n, '_language', 'sk'), patch.object(i18n, '_cache', cache):
|
||||
menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
|
||||
self.assertEqual(i18n.translate('Recover the previous installation'), 'Obnoviť predchádzajúcu inštaláciu')
|
||||
text = self.ui.review.call_args.args[0]
|
||||
for part in ['novší obraz aplikácie', 'overí sa jej integrita', 'pokúsi obnoviť', 'Pripojené adresáre hostiteľa']:
|
||||
self.assertIn(part, text)
|
||||
self.assertNotIn('kanál obrazov', text)
|
||||
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Spustiť aktualizáciu?')
|
||||
self.no_effects()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -24,9 +24,12 @@ OLD_GUARD = 'The current template changes the image or identity; an explicit mig
|
||||
GUARD = 'The current template changes the template identity or image repository; an explicit migration is required'
|
||||
OLD_PREVIEW = ('The saved image channel is checked for a newer image. If replacement is needed, the CT is stopped and '
|
||||
'a native backup is verified before its root is replaced. Host directories are outside that backup.')
|
||||
PREVIEW = ('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with '
|
||||
'the new image to update the container. If the update fails after the container was changed, the backup '
|
||||
'is restored. An update that stops halfway is recovered from the OCI management menu.')
|
||||
PREVIEW = ('If a newer application image is available, the container is stopped and backed up, and the backup '
|
||||
'integrity is verified. The container is then updated using the new image.\n\n'
|
||||
'If the update fails after the container was changed, ProxMenux attempts to restore the previous '
|
||||
'installation from the backup. If the operation remains incomplete, select the application in OCI '
|
||||
'management and choose "Recover the previous installation".\n\n'
|
||||
'Host directories mounted in the container are not restored by the container backup.')
|
||||
|
||||
|
||||
def extracted(path, name, scope):
|
||||
@@ -130,7 +133,7 @@ class UpdateWording(unittest.TestCase):
|
||||
reconcile = ModuleType('oci_instance_reconcile')
|
||||
reconcile.propose = lambda *args: None
|
||||
scope = {'sys': SimpleNamespace(path=[], executable='python3'),
|
||||
'os': os, 'tempfile': tempfile, 'Path': Path,
|
||||
'os': os, 'tempfile': tempfile, 'Path': Path, 'CANCELLED': None,
|
||||
'translate': lambda text: text, 'check_selected': lambda project, row: row,
|
||||
'_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)),
|
||||
'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""The watchdog of an OCI application is reachable from the Monitor and worded in every language."""
|
||||
import ast
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from unittest import TestCase
|
||||
from unittest.mock import Mock
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||
@@ -65,9 +69,45 @@ class OciWatchdogInstaller(TestCase):
|
||||
self.assertLess(install.index('deployment = build_deployment(candidate, wizard, mode)'), asked)
|
||||
self.assertLess(asked, install.index('approved = wizard.review('))
|
||||
self.assertLess(install.index('watchdog = bool(deployment.pop("watchdog", False))'), install.index('run_remote_install('))
|
||||
self.assertIn('set_watchdog(PROJECT_ROOT, sorted(vmids), True)', install)
|
||||
self._assert_jobs_are_excluded_from_installed_watchdog()
|
||||
self.assertIn('row(translate("Watchdog"), _yes_no(plan["watchdog"]))', self.CLI)
|
||||
|
||||
def _assert_jobs_are_excluded_from_installed_watchdog(self):
|
||||
node = next(item for item in ast.parse(self.CLI).body
|
||||
if isinstance(item, ast.FunctionDef) and item.name == 'install_template')
|
||||
class InertImports(ast.NodeTransformer):
|
||||
def visit_ImportFrom(self, item):
|
||||
return None
|
||||
node = InertImports().visit(node)
|
||||
deployment = {'services': [
|
||||
{'name': 'migration', 'deployment': {'execution_mode': 'one-shot'}},
|
||||
{'name': 'database', 'deployment': {}},
|
||||
{'name': 'worker', 'deployment': {}}]}
|
||||
result = {'vmid': 4800, 'stack_vmids': {'web': 4800, 'migration': 4801,
|
||||
'database': 4802, 'worker': 4803}}
|
||||
set_watchdog = Mock(return_value=True)
|
||||
class UI:
|
||||
def confirm(self, *args, **kwargs): return True
|
||||
def review(self, *args, **kwargs): return True
|
||||
def close(self): pass
|
||||
def install(project, template, plan, target):
|
||||
self.assertNotIn('watchdog', plan)
|
||||
return result
|
||||
scope = {'Any': Any, 'copy': copy, 'BacktrackUI': lambda ui: ui,
|
||||
'RestartWizard': RuntimeError, 'InstallError': ValueError,
|
||||
'build_deployment': lambda *args: copy.deepcopy(deployment),
|
||||
'watchdog_default': lambda *args: False,
|
||||
'translate': lambda value: value, '_deployment_summary_text': lambda *args: '',
|
||||
'PROJECT_ROOT': Path('/inert'), 'source_text': lambda value: 'test', 'APP_TITLE': 'test',
|
||||
'console': SimpleNamespace(**{name: lambda *args: None for name in
|
||||
('show_logo', 'msg_title', 'msg_error', 'msg_ok', 'msg_warn', 'wait_for_enter')}),
|
||||
'run_remote_install': install, 'carry_records': lambda *args, **kwargs: None,
|
||||
'images': SimpleNamespace(offer_removal=lambda *args: (False, None)),
|
||||
'_print_installation_summary': lambda *args: None, 'set_watchdog': set_watchdog}
|
||||
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])), 'cli.py', 'exec'), scope)
|
||||
scope['install_template'](UI(), {'catalog_ui': {'title': {'en_US': 'test'}}}, 'test', 'advanced')
|
||||
set_watchdog.assert_called_once_with(Path('/inert'), [4800, 4802, 4803], True)
|
||||
|
||||
def test_both_management_menus_offer_it(self):
|
||||
self.assertEqual(self.MENU.count("('watchdog', "), 2)
|
||||
self.assertEqual(self.MENU.count("if action == 'watchdog':"), 2)
|
||||
|
||||
@@ -20,8 +20,7 @@ AFTER = 'The recovery did not complete. Review the log and choose "{choice}" aga
|
||||
REGISTRY_ADVICE = ('Check the image reference and registry access. If the registry is unavailable, '
|
||||
'try again later. For a private image, sign in from Registry accounts in the OCI menu.')
|
||||
NEW_KEYS = (
|
||||
'The Compose file describes several services:',
|
||||
'Only one service at a time can be installed this way.',
|
||||
'Containers of the application:',
|
||||
'No docker run command was given',
|
||||
'Image selection was cancelled',
|
||||
'Could not inspect the image in its registry:',
|
||||
@@ -77,15 +76,19 @@ class OciWordingTests(TestCase):
|
||||
setattr(module, '_cache', {NEW_KEYS[0]: 'Several services (translated):'})
|
||||
self.assertEqual(module.translate(NEW_KEYS[0]), 'Several services (translated):')
|
||||
|
||||
def test_multiple_services_named_not_images(self):
|
||||
def test_multiple_services_keep_their_names_and_enter_stack_conversion(self):
|
||||
# Reusing one image twice still creates two distinct Compose services.
|
||||
document = {'services': {'alpha': {'image': 'same:latest'},
|
||||
'beta': {'image': 'same:latest'}}}
|
||||
converted = object()
|
||||
stack = Mock(return_value=converted)
|
||||
template = function(CUSTOM, 'template_from_compose',
|
||||
_services=lambda doc: doc['services'], _check_mounts=lambda _: None)
|
||||
with self.assertRaises(ConversionError) as caught:
|
||||
template(json.dumps({'services': {'alpha': {'image': 'same:latest'},
|
||||
'beta': {'image': 'same:latest'}}}))
|
||||
self.assertEqual(str(caught.exception),
|
||||
'The Compose file describes several services: alpha, beta. '
|
||||
'Only one service at a time can be installed this way.')
|
||||
_services=lambda doc: doc['services'], _template_from_stack=stack)
|
||||
self.assertIs(template(json.dumps(document)), converted)
|
||||
stack.assert_called_once()
|
||||
self.assertEqual(stack.call_args.args, (document, None))
|
||||
self.assertFalse(stack.call_args.kwargs['interpolated'])
|
||||
self.assertIsNone(stack.call_args.kwargs.get('image_entrypoints'))
|
||||
|
||||
def test_empty_paste_is_specific_and_nonempty_unchanged(self):
|
||||
for title, expected in ((None, 'No Compose file was given'),
|
||||
@@ -108,7 +111,8 @@ class OciWordingTests(TestCase):
|
||||
def test_registry_nonzero_and_success_command_unchanged(self):
|
||||
result = SimpleNamespace(returncode=1, stdout='', stderr='connection timed out')
|
||||
run = Mock(return_value=result)
|
||||
report = function(CUSTOM, 'registry_report', subprocess=SimpleNamespace(run=run))
|
||||
report = function(CUSTOM, 'registry_report', subprocess=SimpleNamespace(run=run),
|
||||
_registry_lookup=lambda reference: reference)
|
||||
self.assertEqual(report('example:latest'),
|
||||
(False, 'Could not inspect the image in its registry: example:latest'))
|
||||
run.assert_called_once_with(['skopeo', 'inspect', '--raw', 'docker://example:latest'],
|
||||
@@ -121,7 +125,7 @@ class OciWordingTests(TestCase):
|
||||
|
||||
def test_failed_registry_inspection_points_to_registry_accounts(self):
|
||||
ui = SimpleNamespace(message=Mock())
|
||||
fn = function(CUSTOM, 'explore', read_definition=lambda _: ('definition', 'source'),
|
||||
fn = function(CUSTOM, '_explore', read_definition=lambda _: ('definition', 'source'),
|
||||
ignored_settings=lambda _: [], template_from_compose=lambda _: {
|
||||
'container_contract': {'image': {'reference': 'example:latest'}}},
|
||||
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'),
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Wording contracts through the real metadata parser and exact-match formatter.
|
||||
|
||||
No post-install function or scanner is imported or executed. Node 22.6+ is
|
||||
required, as in test_command_descriptions.py; no npm dependencies are needed.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class OptimizationDescriptionWordingTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Admit only the pure parser and its actual dependencies, not the scanner.
|
||||
path = ROOT / "AppImage/scripts/post_install_versions.py"
|
||||
functions = {"_read_text", "_version_tuple", "parse_post_install_script"}
|
||||
constants = {"_FN_DEF_RE", "_VERSION_RE", "_DESC_RE", "_REGISTER_RE", "_HEREDOC_RE"}
|
||||
nodes: list[ast.stmt] = [node for node in ast.parse(path.read_text(encoding="utf-8")).body
|
||||
if (isinstance(node, ast.FunctionDef) and node.name in functions)
|
||||
or (isinstance(node, ast.Assign) and any(
|
||||
isinstance(target, ast.Name) and target.id in constants
|
||||
for target in node.targets))]
|
||||
if len(nodes) != len(functions) + len(constants):
|
||||
raise AssertionError("Metadata parser seam changed: review its dependencies")
|
||||
scope = {"re": re, "Path": Path}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(path), "exec"), scope)
|
||||
cls.metadata = {
|
||||
source: scope["parse_post_install_script"](ROOT / "scripts/post_install" / filename)
|
||||
for source, filename in (("auto", "auto_post_install.sh"),
|
||||
("custom", "customizable_post_install.sh"))
|
||||
}
|
||||
cls.catalogs = {
|
||||
locale: json.loads((ROOT / f"AppImage/messages/{locale}/common.json").read_text(encoding="utf-8"))
|
||||
for locale in ("en", "it")
|
||||
}
|
||||
node = os.environ.get("NODE_BINARY") or shutil.which("node")
|
||||
if not node:
|
||||
raise AssertionError("Install Node.js 22.6+ or set NODE_BINARY")
|
||||
cls.node = node
|
||||
version = subprocess.run([node, "--version"], capture_output=True, text=True, check=True).stdout
|
||||
match = re.fullmatch(r"v(\d+)\.(\d+)\.(\d+)\s*", version)
|
||||
if not match or tuple(map(int, match.groups())) < (22, 6, 0):
|
||||
raise AssertionError(f"Node.js 22.6+ required; detected {version.strip()}")
|
||||
# Replace only the JSON import; the formatter body executes unchanged.
|
||||
formatter = (ROOT / "AppImage/lib/optimization-descriptions.ts").read_text(encoding="utf-8")
|
||||
json_import = 'import enMessages from "../messages/en/common.json"'
|
||||
if formatter.count(json_import) != 1:
|
||||
raise AssertionError("Formatter import seam changed")
|
||||
fixture = formatter.replace(json_import, "const enMessages = " + json.dumps(cls.catalogs["en"]))
|
||||
fixture += '''
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
const [key, description, italian, catalogs] = JSON.parse(readFileSync(0, "utf8"));
|
||||
for (const locale of ["en", "it"]) {
|
||||
const calls: string[] = [];
|
||||
const t = (path: string) => {
|
||||
calls.push(path);
|
||||
return path.split(".").reduce((value, part) => value[part], catalogs[locale]);
|
||||
};
|
||||
assert.equal(optimizationDescription(description, t), locale === "en" ? description : italian);
|
||||
assert.deepEqual(calls, [`settings.optimizations.toolDescriptions.${key}`]);
|
||||
calls.length = 0;
|
||||
const unknown = description + " [new server wording]";
|
||||
assert.equal(optimizationDescription(unknown, t), unknown);
|
||||
assert.deepEqual(calls, []);
|
||||
}
|
||||
console.log("PASS actual exact lookup: EN/IT and unknown-description fallback");
|
||||
'''
|
||||
cls.directory = tempfile.TemporaryDirectory(prefix="optimization-wording-")
|
||||
cls.addClassCleanup(cls.directory.cleanup)
|
||||
cls.fixture = Path(cls.directory.name) / "formatter.mts"
|
||||
cls.fixture.write_text(fixture, encoding="utf-8")
|
||||
|
||||
def assert_description(self, key, source, tool, function, english, italian):
|
||||
for locale, expected in (("en", english), ("it", italian)):
|
||||
with self.subTest(key=key, locale=locale):
|
||||
self.assertEqual(self.catalogs[locale]["settings"]["optimizations"]["toolDescriptions"][key], expected)
|
||||
metadata = self.metadata[source][tool]
|
||||
self.assertEqual(metadata["function"], function)
|
||||
self.assertEqual(metadata["description"], english)
|
||||
result = subprocess.run(
|
||||
[self.node, "--experimental-strip-types", str(self.fixture)],
|
||||
input=json.dumps([key, metadata["description"], italian, self.catalogs]),
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("PASS actual exact lookup", result.stdout)
|
||||
|
||||
def test_log2ram_auto_fresh_install_detection_and_update_size(self):
|
||||
self.assert_description(
|
||||
"log2ramAuto", "auto", "log2ram", "install_log2ram_auto",
|
||||
"Fresh installs size Log2RAM from host RAM and ask for confirmation if storage is not detected as SSD. Updates preserve the configured size.",
|
||||
"Alla prima installazione, dimensiona Log2RAM in base alla RAM dell'host e chiede conferma se il dispositivo di archiviazione non viene rilevato come SSD. Gli aggiornamenti mantengono la dimensione configurata.")
|
||||
|
||||
def test_kexec_prepares_unit_and_alias_not_hotkey(self):
|
||||
self.assert_description(
|
||||
"kexec", "custom", "kexec", "enable_kexec",
|
||||
"Install kexec-tools and prepare a systemd unit plus the reboot-quick alias for fast reboots that skip BIOS/POST.",
|
||||
"Installa kexec-tools e prepara un'unità systemd e l'alias reboot-quick per riavvii rapidi che saltano BIOS/POST.")
|
||||
|
||||
def test_amd_only_claims_idle_parameter_and_kvm_module_options(self):
|
||||
self.assert_description(
|
||||
"amd", "custom", "amd_fixes", "apply_amd_fixes",
|
||||
"Apply idle=nomwait and KVM module options on AMD EPYC/Ryzen hosts.",
|
||||
"Applica idle=nomwait e opzioni del modulo KVM sugli host AMD EPYC/Ryzen.")
|
||||
|
||||
def test_guest_agent_only_lists_supported_environments_and_skip(self):
|
||||
self.assert_description(
|
||||
"guestAgent", "custom", "guest_agent", "install_guest_agent",
|
||||
"Detect QEMU/KVM, VMware or VirtualBox and install the matching guest agent; skip other environments.",
|
||||
"Rileva QEMU/KVM, VMware o VirtualBox e installa il guest agent corrispondente; salta gli altri ambienti.")
|
||||
|
||||
def test_backup_describes_global_defaults_without_speed_guarantee(self):
|
||||
self.assert_description(
|
||||
"backupLimits", "custom", "vzdump_speed", "optimize_vzdump",
|
||||
"Set global vzdump defaults to bwlimit: 0 and ionice: 5. This does not guarantee faster backups.",
|
||||
"Imposta i valori predefiniti globali di vzdump su bwlimit: 0 e ionice: 5. Questo non garantisce backup più veloci.")
|
||||
|
||||
def test_log2ram_custom_choices_and_update_preservation(self):
|
||||
self.assert_description(
|
||||
"log2ramCustom", "custom", "log2ram", "configure_log2ram",
|
||||
"Install Log2RAM with a chosen size, sync interval and optional autosync. Updates preserve the size and autosync choice.",
|
||||
"Installa Log2RAM con dimensione e intervallo di sincronizzazione a scelta e autosync facoltativo. Gli aggiornamenti mantengono la dimensione e la scelta sull'autosync.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -20,7 +20,11 @@ class OutcomePromises(TestCase):
|
||||
texts = oci_menu_texts()
|
||||
self.assertEqual([text for text in texts if 'If anything fails' in text], [])
|
||||
previews = [text for text in texts if 'stops halfway is recovered from the OCI management menu' in text]
|
||||
self.assertEqual(len(previews), 3)
|
||||
self.assertEqual(len(previews), 2) # Coordinated stack update/recreation previews.
|
||||
update = next(text for text in texts if text.startswith('If a newer application image is available'))
|
||||
self.assertIn('attempts to restore the previous installation', update)
|
||||
self.assertIn('If the operation remains incomplete', update)
|
||||
self.assertIn('choose "Recover the previous installation"', update)
|
||||
|
||||
def test_the_recovery_preview_says_when_each_step_happens(self):
|
||||
preview = next(text for text in oci_menu_texts() if 'previous native backup' in text)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""PBS CLI language and no-effect boundaries using real functions, isolated files."""
|
||||
import contextlib
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def function(path, name):
|
||||
text = (ROOT/path).read_text()
|
||||
start = text.index(name+'() {')
|
||||
return text[start:text.index('\n}\n', start)+3]
|
||||
|
||||
|
||||
def repository(components, suite='trixie'):
|
||||
return dict(Types=['deb'], URIs=['https://credential.invalid/private'], Suites=[suite],
|
||||
Components=components.split(), Enabled=True)
|
||||
|
||||
|
||||
class RepositoryCliTests(unittest.TestCase):
|
||||
def run_policy(self, case, language='sk', mode='plan', malformed=None, product='pbs'):
|
||||
spec = importlib.util.spec_from_file_location('policy_fixture', ROOT/'scripts/global/repository_policy.py')
|
||||
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||
state = dict(files=[dict(path='/etc/apt/sources.list.d/pbs.sources', repositories=[repository('pbs-enterprise')]),
|
||||
dict(path='/etc/apt/sources.list.d/debian.sources', repositories=[repository('main')])],
|
||||
errors=[], digest='fixture', **{'standard-repos':[{'handle':'no-subscription'}]})
|
||||
status='notfound'; calls=[]
|
||||
if case=='inventory': state['errors']=['PRIVATE RAW DIAGNOSTIC']
|
||||
if case=='status': status='unknown'
|
||||
if case=='suite': state['files'][0]['repositories'][0]['Suites']=['bookworm']
|
||||
if case=='active': status='active'; state['files']=state['files'][1:]
|
||||
if case=='debian': state['files']=state['files'][:1]
|
||||
if case=='mixed': state['files'][0]['repositories'][0]['Components'].append('main')
|
||||
if case=='handle': state['standard-repos']=[]
|
||||
if case=='preserve': state['files'][0]['repositories'][0]['Components']=['pbs-no-subscription']
|
||||
original=copy.deepcopy(state)
|
||||
def run(args):
|
||||
calls.append(args)
|
||||
if args[0]=='proxmox-backup-manager': return json.dumps(dict(status=status, serverid='PRIVATE SERVER ID'))
|
||||
if 'create' in args:
|
||||
state['files'][0]['repositories'][0]['Enabled']=False
|
||||
if case=='partial': state['errors']=['PRIVATE RAW DIAGNOSTIC']
|
||||
return ''
|
||||
if 'set' in args:
|
||||
state['files'].append(dict(path='/etc/apt/sources.list.d/public.sources',repositories=[repository('pbs-no-subscription')]))
|
||||
return ''
|
||||
return json.dumps(state)
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-policy-i18n-') as folder:
|
||||
module.BASE=Path(folder)
|
||||
(module.BASE/'lang').mkdir()
|
||||
(module.BASE/'config.json').write_text(json.dumps({'language':language}))
|
||||
catalog=(ROOT/'lang/sk.json').read_text() if malformed is None else malformed
|
||||
(module.BASE/'lang/sk.json').write_text(catalog)
|
||||
stdout,stderr=io.StringIO(),io.StringIO()
|
||||
suite='sid' if case=='unsupported' else 'trixie'
|
||||
with patch.object(module,'run',side_effect=run), patch.object(module.sys,'argv',['repository_policy.py',mode,suite,product]), contextlib.redirect_stdout(stdout),contextlib.redirect_stderr(stderr):
|
||||
result=module.main()
|
||||
return result,stdout.getvalue(),stderr.getvalue(),calls,state,original
|
||||
|
||||
def test_pbs_errors_are_localized_without_changing_decisions_or_exposing_raw_data(self):
|
||||
expectations={'inventory':'Zoznam repozitárov APT', 'status':'Stav predplatného',
|
||||
'suite':'iné vydanie', 'active':'aktívne predplatné', 'debian':'základný repozitár Debianu',
|
||||
'mixed':'spoločný záznam APT', 'handle':'no-subscription', 'unsupported':'Nepodporované vydanie',
|
||||
'partial':'stav repozitárov je neznámy'}
|
||||
for case,expected in expectations.items():
|
||||
with self.subTest(case=case):
|
||||
result,out,error,calls,state,original=self.run_policy(case,mode='apply' if case=='partial' else 'plan')
|
||||
self.assertEqual(result,1); self.assertEqual(out,''); self.assertIn(expected,error)
|
||||
self.assertNotIn('PRIVATE',error); self.assertNotIn('credential.invalid',error)
|
||||
writes=[c for c in calls if 'create' in c or 'set' in c]
|
||||
if case=='partial':
|
||||
self.assertEqual(len(writes),1)
|
||||
self.assertFalse(state['files'][0]['repositories'][0]['Enabled'])
|
||||
self.assertIn('Automatický návrat',error)
|
||||
else:
|
||||
self.assertEqual(writes,[]); self.assertEqual(state,original)
|
||||
if case=='unsupported': self.assertEqual(calls,[])
|
||||
|
||||
def test_english_error_and_stdout_tokens_remain_compatible(self):
|
||||
en=self.run_policy('unsupported',language='en')
|
||||
self.assertEqual(en[2],'Unsupported Proxmox suite; no repository changed.\n')
|
||||
pve=self.run_policy('unsupported',product='pve')
|
||||
self.assertEqual(pve[2],en[2])
|
||||
for mode,expected in [('plan','offer\n'),('apply','changed\n')]:
|
||||
result,out,error,*_=self.run_policy('offer',mode=mode)
|
||||
self.assertEqual((result,out,error),(0,expected,''))
|
||||
result,out,error,calls,*_=self.run_policy('preserve',mode='apply')
|
||||
self.assertEqual((result,out,error),(0,'preserve\n',''))
|
||||
self.assertFalse(any(c[0]=='proxmox-backup-manager' or 'set' in c or 'create' in c for c in calls))
|
||||
|
||||
def test_bad_or_missing_translation_falls_back_to_original_english(self):
|
||||
for catalog in ('{}','not json','[]',json.dumps({'Unsupported Proxmox suite; no repository changed.':42}),
|
||||
json.dumps({'Unsupported Proxmox suite; no repository changed.':'{unknown}'})):
|
||||
with self.subTest(catalog=catalog):
|
||||
result,out,error,calls,*_=self.run_policy('unsupported',malformed=catalog)
|
||||
self.assertEqual(result,1);self.assertEqual(out,'');self.assertEqual(calls,[])
|
||||
self.assertEqual(error,'Unsupported Proxmox suite; no repository changed.\n')
|
||||
|
||||
def test_translation_cannot_drop_actual_repository_details(self):
|
||||
spec=importlib.util.spec_from_file_location('policy_format_fixture',ROOT/'scripts/global/repository_policy.py')
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-policy-format-') as folder:
|
||||
module.BASE=Path(folder);(module.BASE/'lang').mkdir()
|
||||
(module.BASE/'config.json').write_text('{"language":"sk"}')
|
||||
error=module.PolicyError('Inspect {ui}; no repository changed.',ui='Administration > Repositories')
|
||||
for invalid in ('Skontrolujte repozitáre.','{ui.__class__}','{other}','{ui:.0}','{ui!r}'):
|
||||
(module.BASE/'lang/sk.json').write_text(json.dumps({error.template:invalid}))
|
||||
self.assertEqual(module.cli_error(error,'pbs'),str(error))
|
||||
for invalid in ('not json','[]','{"language":"../../private"}'):
|
||||
(module.BASE/'config.json').write_text(invalid)
|
||||
self.assertEqual(module.cli_error(error,'pbs'),str(error))
|
||||
|
||||
|
||||
UPDATE_HARNESS=r'''
|
||||
BASE_DIR="$FIXTURE"
|
||||
LANG_DIR="$REPO/lang"
|
||||
SCRIPT_TITLE='fixture'
|
||||
TAB='' BGN='' CL='' GN='' BL=''
|
||||
mkdir -p "$FIXTURE/log" "$FIXTURE/tmp" "$FIXTURE/boot" "$FIXTURE/run"
|
||||
: > "$FIXTURE/calls"
|
||||
record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; }
|
||||
pmx_journal_context() { :; }
|
||||
pmx_is_pbs() { [[ "$CASE" != pve ]]; }
|
||||
pmx_platform_field() { [[ "$1" != supported ]] && printf '%s' "$SUITE" || printf true; }
|
||||
ensure_pbs_repositories() { record repository-plan; return 0; }
|
||||
dpkg-query() { [[ "$SUITE" == bookworm ]] && printf 3.4.0 || printf 4.0.1; }
|
||||
apt-cache() { record apt-cache "$@"; printf ' Candidate: 4.0.2\n'; }
|
||||
df() { printf 'Filesystem 1K-blocks Used Available Use%% Mounted\nfixture 10000 1000 %s 10%% /\n' "$([[ "$CASE" == disk ]] && echo 512000 || echo 4096000)"; }
|
||||
apt-get() { record apt-get "$@"; [[ "$CASE" != refresh ]]; }
|
||||
apt() {
|
||||
record apt "$@"
|
||||
if [[ "$*" == 'list --upgradable' ]]; then
|
||||
printf 'Listing...\n'
|
||||
[[ "$CASE" == current ]] || printf 'fixture/trixie 1.2 amd64 [upgradable from: 1.1]\n'
|
||||
return 0
|
||||
fi
|
||||
[[ "$CASE" != failure ]]
|
||||
}
|
||||
fixture_env() { "$@"; }
|
||||
APT_ENV='fixture_env'
|
||||
pmx_format_upgradable() { cat; }
|
||||
pmx_record_execution() { record journal "$1"; }
|
||||
pmx_dpkg_changes_since() { :; }
|
||||
msg_error() { printf '%s\n' "$*"; }
|
||||
msg_ok() { printf '%s\n' "$*"; }
|
||||
msg_info2() { printf '%s\n' "$*"; }
|
||||
msg_warn() { printf '%s\n' "$*"; }
|
||||
msg_title() { :; }
|
||||
clear() { :; }
|
||||
show_proxmenux_logo() { :; }
|
||||
whiptail() { printf '%s\n' "$*" >> "$FIXTURE/dialog"; [[ "$CASE" != cancel ]]; }
|
||||
uname() { printf old-kernel; }
|
||||
touch "$FIXTURE/boot/vmlinuz-new-kernel"
|
||||
'''
|
||||
|
||||
|
||||
class UpdateCliTests(unittest.TestCase):
|
||||
def run_update(self,case,language='sk',suite='trixie'):
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-update-i18n-') as folder:
|
||||
body=function('scripts/global/update-pbs.sh','update_pbs')
|
||||
for old,new in [('/var/log/',folder+'/log/'),('/tmp/proxmenux_screen_capture_',folder+'/tmp/proxmenux_screen_capture_'),('/var/run/',folder+'/run/'),('/boot/',folder+'/boot/')]: body=body.replace(old,new)
|
||||
script=UPDATE_HARNESS+function('scripts/utils.sh','translate')+function('scripts/global/common-functions.sh','pmx_confirm_update')+body+'\nupdate_pbs\n'
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'REPO':str(ROOT),'FIXTURE':folder,'CASE':case,'LANGUAGE':language,'SUITE':suite})
|
||||
files={str(p.relative_to(folder)):p.read_text() for p in Path(folder).rglob('*') if p.is_file()}
|
||||
return result,files
|
||||
|
||||
def test_pve_guard_cannot_reach_repository_or_apt_calls(self):
|
||||
result,files=self.run_update('pve')
|
||||
self.assertEqual(result.returncode,1,result.stderr)
|
||||
self.assertEqual(files['calls'],'')
|
||||
self.assertFalse(any(name.startswith('log/') for name in files))
|
||||
|
||||
def test_disk_and_upgrade_errors_keep_their_actual_parameters(self):
|
||||
result,files=self.run_update('disk')
|
||||
self.assertEqual(result.returncode,1,result.stderr)
|
||||
self.assertIn('Nedostatok miesta na disku. Dostupné:',result.stdout)
|
||||
self.assertIn('500MB',result.stdout)
|
||||
self.assertNotIn('apt',files['calls'])
|
||||
result,files=self.run_update('failure')
|
||||
self.assertEqual(result.returncode,1,result.stderr)
|
||||
self.assertIn('Aktualizácia systému zlyhala. Skontrolujte denník:',result.stdout)
|
||||
self.assertIn('/log/proxmox-update-',result.stdout)
|
||||
self.assertNotIn('autoremove',files['calls'])
|
||||
|
||||
def test_update_status_cancel_refresh_error_and_reboot_notice(self):
|
||||
for suite in ('bookworm','trixie'):
|
||||
for case in ('current','cancel','refresh','success'):
|
||||
with self.subTest(suite=suite,case=case):
|
||||
result,files=self.run_update(case,suite=suite)
|
||||
self.assertEqual(result.returncode,1 if case=='refresh' else 0,result.stderr)
|
||||
if case!='success': self.assertNotIn('full-upgrade',files['calls'])
|
||||
if case=='success': self.assertIn('reštartujte server',result.stdout)
|
||||
if case in ('current','cancel'): self.assertIn('Aktuálna verzia PBS',files['dialog'])
|
||||
|
||||
|
||||
class DialogTests(unittest.TestCase):
|
||||
def test_automated_confirmation_and_banner_cancel_do_not_run_actions(self):
|
||||
cases=[('scripts/menus/menu_post_install.sh','confirm_automated_script',
|
||||
['repozitár no-subscription','upozornenie na predplatné','pamäť','jadro','sieť']),
|
||||
('scripts/post_install/auto_post_install.sh','remove_subscription_banner',
|
||||
['Proxmox Backup Server – odstránenie upozornenia na predplatné'])]
|
||||
for path,name,expected in cases:
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-dialog-') as folder:
|
||||
script=function('scripts/utils.sh','translate')+function(path,name)+r'''
|
||||
PMX_ON_PBS=true
|
||||
LOCAL_SCRIPTS='/fixture/scripts'
|
||||
clear() { :; }; sleep() { :; }; stop_spinner() { :; }
|
||||
msg_warn() { printf '%s\n' "$*"; }
|
||||
bash() { printf forbidden >> "$CAPTURE/actions"; }
|
||||
register_tool() { printf forbidden >> "$CAPTURE/actions"; }
|
||||
dialog() { printf '%s\n' "$*" >> "$CAPTURE/dialog"; return 1; }
|
||||
whiptail() { dialog "$@"; }
|
||||
'''+name+'\n'
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'CAPTURE':folder})
|
||||
self.assertEqual(result.returncode,1 if name=='remove_subscription_banner' else 0,result.stderr)
|
||||
dialog=(Path(folder)/'dialog').read_text()
|
||||
for text in expected:self.assertIn(text,dialog)
|
||||
self.assertFalse((Path(folder)/'actions').exists())
|
||||
if name=='remove_subscription_banner':self.assertIn('bolo zrušené používateľom',result.stdout)
|
||||
|
||||
def test_community_labels_preserve_dispatch_names_and_translate_display(self):
|
||||
script=function('scripts/utils.sh','translate')+function('scripts/menus/menu_post_install.sh','post_install_label')+'\npost_install_label "$1"\n'
|
||||
for text,expected in [('Proxmox Backup Server Post Install','nastavenie po inštalácii'),
|
||||
('Proxmox Backup Server Microcode','mikrokód procesora')]:
|
||||
result=subprocess.run(['bash','-c',script,'bash',text],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang')})
|
||||
self.assertEqual(result.returncode,0,result.stderr);self.assertIn(expected,result.stdout)
|
||||
|
||||
def test_failed_apt_hook_check_removes_only_fixture_hook_with_localized_warning(self):
|
||||
with tempfile.TemporaryDirectory(prefix='pbs-hook-') as folder:
|
||||
script=function('scripts/utils.sh','translate')+function('scripts/global/remove-banner-pve-v3.sh','create_apt_hook')+r'''
|
||||
apt-config() { return 1; }
|
||||
msg_warn() { printf '%s\n' "$*"; }
|
||||
create_apt_hook
|
||||
'''
|
||||
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
|
||||
env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'APT_HOOK':folder+'/hook'})
|
||||
self.assertEqual(result.returncode,0,result.stderr)
|
||||
self.assertIn('Nesprávna syntax hooku APT',result.stdout)
|
||||
self.assertFalse((Path(folder)/'hook').exists())
|
||||
@@ -69,13 +69,44 @@ class TerminalCatalogsTests(unittest.TestCase):
|
||||
def test_shipped_active_catalogs_keep_literal_contracts(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(ROOT), '--require-complete'],
|
||||
'--root', str(ROOT)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
for locale in MODULE.DEFAULT_LANGUAGES:
|
||||
self.assertIn(f'[{locale}] active=', result.stdout)
|
||||
self.assertNotIn('missing=1', result.stdout)
|
||||
# New strings are reviewed in Slovak. Other locales keep the runtime
|
||||
# English fallback until the translation bot supplies their values;
|
||||
# still validate every translation already present in those locales.
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(ROOT), '--languages', 'sk', '--require-complete'],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn('missing=0', result.stdout)
|
||||
|
||||
def test_optional_completeness_still_rejects_invalid_present_translations(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / 'scripts').mkdir()
|
||||
(root / 'lang').mkdir()
|
||||
(root / 'scripts/check.sh').write_text('translate "Check /etc/pve/storage.cfg"')
|
||||
(root / 'lang/sk.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Skontrolujte /etc/pve/storage.cfg'}))
|
||||
(root / 'lang/es.json').write_text('{}')
|
||||
|
||||
def check(languages, *flags):
|
||||
return subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(root), '--languages', languages, *flags],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
|
||||
self.assertEqual(check('es,sk').returncode, 0)
|
||||
self.assertEqual(check('sk', '--require-complete').returncode, 0)
|
||||
self.assertNotEqual(check('es', '--require-complete').returncode, 0)
|
||||
(root / 'lang/es.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Compruebe /etc/PVE/storage.cfg'}))
|
||||
self.assertNotEqual(check('es,sk').returncode, 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""A label made only of product and API names is right when it comes back
|
||||
unchanged, so the translation bot keeps it instead of retrying and dropping it."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import build_translation_cache as cache # noqa: E402
|
||||
|
||||
|
||||
class ProtectedTermsTests(unittest.TestCase):
|
||||
def test_acceleration_profile_labels_are_kept_unchanged(self):
|
||||
for label in ("Intel/AMD (Vulkan)", "AMD (ROCm)", "Intel (SYCL)", "NVIDIA (CUDA)",
|
||||
"Intel/AMD (VA-API)", "Intel (OpenVINO)"):
|
||||
with self.subTest(label=label):
|
||||
self.assertTrue(cache.is_fully_protected(label))
|
||||
self.assertFalse(cache.looks_untranslated(label, label))
|
||||
|
||||
def test_a_sentence_coming_back_unchanged_is_still_a_failure(self):
|
||||
sentence = "The GPU is used through Vulkan when it is available"
|
||||
self.assertTrue(cache.looks_untranslated(sentence, sentence))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -24,7 +24,10 @@ class VmBackupJobsWiring(TestCase):
|
||||
source = ROUTES.read_text()
|
||||
self.assertNotIn('shell=True', source)
|
||||
self.assertNotIn('os.system', source)
|
||||
self.assertEqual(source.count('subprocess.run('), 1)
|
||||
# pvesh, and the transient unit a backup started by hand runs in.
|
||||
programs = re.findall(r'subprocess\.run\(\s*\[\s*"([a-z-]+)"', source)
|
||||
self.assertEqual(len(programs), source.count('subprocess.run('))
|
||||
self.assertEqual(set(programs), {'pvesh', 'systemd-run', 'systemctl'})
|
||||
self.assertIn('subprocess.run(["pvesh", *args]', source)
|
||||
|
||||
def test_the_monitor_ships_and_registers_it(self):
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Render the real ZRAM CLI paths in isolated files with host effects stubbed."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
DETAILS = {
|
||||
"version": ("Cannot determine installed systemd-zram-generator version", "Nepodarilo sa zistiť verziu nainštalovaného balíka systemd-zram-generator"),
|
||||
"mismatch": ("Existing ZRAM profile is active but does not match its configuration", "Existujúci profil ZRAM je aktívny, ale nezodpovedá svojej konfigurácii"),
|
||||
"stop": ("Could not stop existing dev-zram0.swap", "Nepodarilo sa zastaviť existujúcu jednotku dev-zram0.swap"),
|
||||
"directory": ("Could not create ZRAM generator directory", "Nepodarilo sa vytvoriť adresár pre konfiguráciu generátora ZRAM"),
|
||||
"config": ("Could not write ZRAM generator configuration", "Nepodarilo sa zapísať konfiguráciu generátora ZRAM"),
|
||||
"marker": ("Could not write ZRAM package ownership state", "Nepodarilo sa zapísať údaj o tom, či balík ZRAM nainštaloval ProxMenux"),
|
||||
"start": ("Could not activate dev-zram0.swap", "Nepodarilo sa aktivovať jednotku dev-zram0.swap"),
|
||||
"verify": ("Active ZRAM algorithm, capacity or swap sources did not match the profile", "Algoritmus kompresie, veľkosť alebo zdroje swapu aktívnej ZRAM nezodpovedajú profilu"),
|
||||
}
|
||||
REFUSAL = "Another ZRAM generator configuration exists; refusing to overwrite it:"
|
||||
REFUSAL_SK = "Existuje iná konfigurácia generátora ZRAM; neprepíše sa:"
|
||||
|
||||
|
||||
def function(path, name):
|
||||
text = path.read_text()
|
||||
start = text.index(name + "() {")
|
||||
return text[start:text.index("\n}\n", start) + 3]
|
||||
|
||||
|
||||
HARNESS = r'''
|
||||
set -u
|
||||
BASE_DIR="$FIXTURE/state"
|
||||
LANG_DIR="$REPO/lang"
|
||||
mkdir -p "$BASE_DIR" "$FIXTURE/etc/systemd/zram-generator.conf.d" "$FIXTURE/proc"
|
||||
CONFIG="$FIXTURE/etc/systemd/zram-generator.conf.d/90-proxmenux.conf"
|
||||
MARKER="$BASE_DIR/zram-swap.state"
|
||||
printf 'Filename\tType\tSize\tUsed\tPriority\n' > "$FIXTURE/proc/swaps"
|
||||
printf '/swapfile none swap sw 0 0\n' > "$FIXTURE/etc/fstab"
|
||||
: > "$FIXTURE/calls"
|
||||
record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; }
|
||||
pmx_journal_context() { :; }
|
||||
msg_error() { printf '%s\n' "$*"; }
|
||||
msg_success() { printf '%s\n' "$*"; }
|
||||
register_tool() { record register "$@"; }
|
||||
pmx_install_pkg() { record package-install; return 1; }
|
||||
apt-get() { record apt "$@"; return 1; }
|
||||
dpkg-query() {
|
||||
record dpkg-query
|
||||
case "$*" in
|
||||
*'${Status}'*) printf 'install ok installed\n';;
|
||||
*'${Version}'*) [[ "$CASE" == version ]] || printf '1.2.0-1\n';;
|
||||
esac
|
||||
}
|
||||
dpkg() { return 0; }
|
||||
install() { record directory; [[ "$CASE" != directory ]]; }
|
||||
pmx_write_file() {
|
||||
record write "$1"
|
||||
if [[ "$CASE" == config && "$1" == "$CONFIG" ]] ||
|
||||
[[ "$CASE" == marker && "$1" == "$MARKER" ]]; then
|
||||
cat >/dev/null
|
||||
return 1
|
||||
fi
|
||||
cat > "$1"
|
||||
}
|
||||
pmx_remove_file() { record remove "$1"; rm -f -- "$1"; }
|
||||
pmx_record_uninstall() { record record-uninstall; }
|
||||
swapoff() { record swapoff; return 0; }
|
||||
systemctl() {
|
||||
record systemctl "$@"
|
||||
[[ "$CASE" != start || "$1" != start ]] &&
|
||||
[[ "$CASE" != stop || "$1" != stop ]]
|
||||
}
|
||||
source "$REPO/scripts/post_install/zram_profile_helpers.sh"
|
||||
zram_profile_verify_active() { [[ "$CASE" != verify && "$CASE" != mismatch ]]; }
|
||||
if [[ "$CASE" == mismatch || "$CASE" == stop ]]; then
|
||||
printf '# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n' > "$CONFIG"
|
||||
[[ "$CASE" != mismatch ]] || zram_render_generator_config true > "$CONFIG"
|
||||
printf 'installed_by_proxmenux=false\n' > "$MARKER"
|
||||
printf '/dev/zram0\tpartition\t3355443\t8\t32767\n' >> "$FIXTURE/proc/swaps"
|
||||
fi
|
||||
if [[ "$CASE" == foreign ]]; then
|
||||
printf '[zram1]\nzram-size = ram / 5\n' > "$FIXTURE/etc/systemd/zram-generator.conf"
|
||||
fi
|
||||
if [[ "$CASE" == unreadable ]]; then
|
||||
zram_foreign_generator_config() { return 2; }
|
||||
fi
|
||||
'''
|
||||
|
||||
|
||||
class ZramLocalizationTests(unittest.TestCase):
|
||||
def test_missing_optional_translation_uses_original_english(self):
|
||||
with tempfile.TemporaryDirectory(prefix="zram-fallback-") as folder:
|
||||
(Path(folder) / 'xx.json').write_text('{}')
|
||||
script = function(ROOT / "scripts/utils.sh", "translate") + '\ntranslate "$1"\n'
|
||||
result = subprocess.run(["bash", "-c", script, "bash", DETAILS['version'][0]],
|
||||
text=True, capture_output=True, timeout=15,
|
||||
env={**os.environ, 'LANGUAGE': 'xx', 'LANG_DIR': folder})
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), DETAILS['version'][0])
|
||||
|
||||
def run_case(self, case, language="sk"):
|
||||
with tempfile.TemporaryDirectory(prefix="zram-i18n-") as folder:
|
||||
directory = Path(folder)
|
||||
installer = function(ROOT / "scripts/post_install/customizable_post_install.sh", "configure_zram_swap")
|
||||
installer = installer.replace("/etc/systemd/zram-generator.conf.d/90-proxmenux.conf", folder + "/etc/systemd/zram-generator.conf.d/90-proxmenux.conf")
|
||||
installer = installer.replace("/proc/swaps", folder + "/proc/swaps").replace("/etc/fstab", folder + "/etc/fstab")
|
||||
installer = installer.replace('zram_foreign_generator_config "$config"', 'zram_foreign_generator_config "$config" "$FIXTURE"')
|
||||
installer = installer.replace('zram_list_foreign_systemd_zram_units)', 'zram_list_foreign_systemd_zram_units "$FIXTURE")')
|
||||
script = HARNESS + function(ROOT / "scripts/utils.sh", "translate") + installer + "\nconfigure_zram_swap\n"
|
||||
result = subprocess.run(["bash", "-c", script], text=True, capture_output=True,
|
||||
env={**os.environ, "REPO": str(ROOT), "FIXTURE": folder, "CASE": case, "LANGUAGE": language}, timeout=15)
|
||||
calls = (directory / "calls").read_text().splitlines()
|
||||
files = {str(p.relative_to(directory)): p.read_text() for p in directory.rglob("*") if p.is_file()}
|
||||
return result, calls, files
|
||||
|
||||
def test_all_activation_details_are_localized_and_english_is_preserved(self):
|
||||
for case, (english, slovak) in DETAILS.items():
|
||||
for language, expected in (("sk", slovak), ("en", english)):
|
||||
with self.subTest(case=case, language=language):
|
||||
result, calls, files = self.run_case(case, language)
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertIn(expected, result.stdout)
|
||||
self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n")
|
||||
if case in ("version", "mismatch"):
|
||||
self.assertTrue(all(call == "dpkg-query" for call in calls), calls)
|
||||
elif case == "stop":
|
||||
self.assertEqual(files["state/zram-swap.state"], "installed_by_proxmenux=false\n")
|
||||
self.assertIn("# ProxMenux ZRAM profile", files["etc/systemd/zram-generator.conf.d/90-proxmenux.conf"])
|
||||
else:
|
||||
self.assertNotIn("state/zram-swap.state", files)
|
||||
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
|
||||
self.assertFalse(any(call.startswith(("apt ", "package-install", "register ")) for call in calls), calls)
|
||||
|
||||
def test_foreign_generator_is_refused_in_both_languages_without_any_effect(self):
|
||||
for language, expected in (("sk", REFUSAL_SK), ("en", REFUSAL)):
|
||||
result, calls, files = self.run_case("foreign", language)
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertIn(expected, result.stdout)
|
||||
self.assertIn("/etc/systemd/zram-generator.conf", result.stdout)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertEqual(files["etc/systemd/zram-generator.conf"], "[zram1]\nzram-size = ram / 5\n")
|
||||
self.assertNotIn("state/zram-swap.state", files)
|
||||
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
|
||||
self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n")
|
||||
self.assertEqual(files["proc/swaps"], "Filename\tType\tSize\tUsed\tPriority\n")
|
||||
|
||||
def test_failed_inspection_is_not_mistaken_for_no_conflict(self):
|
||||
result, calls, files = self.run_case("unreadable")
|
||||
self.assertEqual(result.returncode, 1, result.stderr)
|
||||
self.assertIn("Nie je možné skontrolovať existujúcu konfiguráciu generátora ZRAM", result.stdout)
|
||||
self.assertEqual(calls, [])
|
||||
self.assertNotIn("state/zram-swap.state", files)
|
||||
self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
|
||||
|
||||
def test_uninstall_label_and_cancel_do_not_call_uninstall(self):
|
||||
for language, label in (("sk", "Profil swapu ZRAM"), ("en", "ZRAM swap profile")):
|
||||
with tempfile.TemporaryDirectory(prefix="zram-menu-") as folder:
|
||||
tools = Path(folder) / "tools.json"
|
||||
tools.write_text('{"zram_swap":true}')
|
||||
script = function(ROOT / "scripts/utils.sh", "translate")
|
||||
script += function(ROOT / "scripts/post_install/uninstall-tools.sh", "show_uninstall_menu")
|
||||
script += '\nensure_tools_json() { :; }; migrate_installed_tools() { :; }\nuninstall_zram_swap() { printf forbidden; return 1; }\ndialog() { printf "%s\\n" "$@" > "$MENU_CAPTURE"; return 1; }\nshow_uninstall_menu\n'
|
||||
result = subprocess.run(["bash", "-c", script], text=True, capture_output=True,
|
||||
env={**os.environ, "LANGUAGE": language, "LANG_DIR": str(ROOT / "lang"), "TOOLS_JSON": str(tools), "MENU_CAPTURE": folder + "/dialog.txt"}, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn(label, (Path(folder) / "dialog.txt").read_text())
|
||||
self.assertNotIn("forbidden", result.stdout + result.stderr)
|
||||
self.assertEqual(json.loads(tools.read_text()), {"zram_swap": True})
|
||||
@@ -33,7 +33,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: develop
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -25,6 +25,11 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to the same branch or pull request replaces the run in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -32,7 +37,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
@@ -45,5 +49,6 @@ jobs:
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
BASE: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$BASE"
|
||||
git show "$BASE:oci/catalog/verification.json" > "$RUNNER_TEMP/base.json" 2>/dev/null || : > "$RUNNER_TEMP/base.json"
|
||||
python3 .github/scripts/oci_validation.py authors --base "$RUNNER_TEMP/base.json" --author "$AUTHOR"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Native Monitor AppImage validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-appimage-native.yml'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/scripts/appimage_arch.sh'
|
||||
- 'AppImage/scripts/borg_native_arm.sh'
|
||||
- '.github/scripts/smoke_native_apprun.sh'
|
||||
- '.github/scripts/check_appimage_elf.py'
|
||||
- 'AppImage/scripts/AppRun'
|
||||
- 'tests/test_appimage_arch.py'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: native-appimage-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
native-build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-22.04
|
||||
arch: x86_64
|
||||
- runner: ubuntu-22.04-arm
|
||||
arch: aarch64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Validate native architecture selection
|
||||
run: |
|
||||
test "$(uname -m)" = '${{ matrix.arch }}'
|
||||
python -B -m unittest discover -s tests -p test_appimage_arch.py -v
|
||||
- name: Refresh package indexes for bundled monitoring tools
|
||||
# Third-party runner repositories occasionally fail; the build reports missing tools.
|
||||
run: sudo apt-get update || true
|
||||
- name: Build native AppImage without publishing it
|
||||
run: bash AppImage/scripts/build_appimage.sh
|
||||
- name: Reject foreign ELF payloads
|
||||
run: python .github/scripts/check_appimage_elf.py /tmp/proxmenux_build/ProxMenux.AppDir '${{ matrix.arch }}'
|
||||
- name: Extract native AppImage without FUSE
|
||||
run: |
|
||||
mkdir /tmp/proxmenux-native-extract
|
||||
cd /tmp/proxmenux-native-extract
|
||||
image=$(find "$GITHUB_WORKSPACE/AppImage/dist" -name '*.AppImage' -type f -print -quit)
|
||||
test -n "$image"
|
||||
chmod +x "$image"
|
||||
"$image" --appimage-extract
|
||||
test -f squashfs-root/usr/bin/flask_server.py
|
||||
test -f squashfs-root/usr/bin/appimage_arch.sh
|
||||
test -f squashfs-root/web/index.html
|
||||
- name: Import bundled Python 3.11 native WebSocket runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/vendor/cp311:/tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/dist-packages
|
||||
run: |
|
||||
python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print(gevent.__version__)'
|
||||
bash .github/scripts/smoke_native_apprun.sh /tmp/proxmenux-native-extract/squashfs-root
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Import bundled Python 3.13 native WebSocket runtime
|
||||
env:
|
||||
PYTHONPATH: /tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/vendor/cp313:/tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/dist-packages
|
||||
run: |
|
||||
python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print(gevent.__version__)'
|
||||
bash .github/scripts/smoke_native_apprun.sh /tmp/proxmenux-native-extract/squashfs-root
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Apprise Slack and Matrix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-apprise-services.yml'
|
||||
- 'AppImage/scripts/notification_channels.py'
|
||||
- 'AppImage/components/apprise-service-help.tsx'
|
||||
- 'AppImage/components/notification-settings.tsx'
|
||||
- 'tests/test_apprise_services.py'
|
||||
- 'tests/test_apprise_service_help.cjs'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/notification_channels.py'
|
||||
- 'AppImage/components/apprise-service-help.tsx'
|
||||
- 'tests/test_apprise_services.py'
|
||||
- 'tests/test_apprise_service_help.cjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
apprise-services:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
apprise: ['1.7.0', '2.0.1']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Install fixture dependencies
|
||||
run: |
|
||||
python -m pip install 'apprise==${{ matrix.apprise }}'
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
- name: Test real provider plugins with mocked HTTP
|
||||
run: python -B -m unittest discover -s tests -p test_apprise_services.py -v
|
||||
- name: Render provider help and locale fallback
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||
run: node tests/test_apprise_service_help.cjs
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Backup storage retention
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-backup-storage-retention.yml'
|
||||
- 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||
- 'AppImage/components/backup-storage-retention.tsx'
|
||||
- 'AppImage/components/backup-tab.tsx'
|
||||
- 'AppImage/components/vm-backup-jobs.tsx'
|
||||
- 'AppImage/lib/backup-retention.ts'
|
||||
- 'tests/test_vm_backup_jobs_api.py'
|
||||
- 'tests/test_backup_storage_retention.cjs'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||
- 'AppImage/components/backup-storage-retention.tsx'
|
||||
- 'AppImage/lib/backup-retention.ts'
|
||||
- 'tests/test_vm_backup_jobs_api.py'
|
||||
- 'tests/test_backup_storage_retention.cjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
storage-retention:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Install fixture dependencies
|
||||
run: |
|
||||
python -m pip install 'Flask==3.1.2'
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
- name: Test native job and storage configuration APIs
|
||||
run: python -B -m unittest discover -s tests -p test_vm_backup_jobs_api.py -v
|
||||
- name: Test shared retention properties
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||
run: node tests/test_backup_storage_retention.cjs
|
||||
@@ -0,0 +1,467 @@
|
||||
name: Feature tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-features.yml'
|
||||
- 'AppImage/scripts/notification_manager.py'
|
||||
- 'AppImage/scripts/notification_templates.py'
|
||||
- 'AppImage/scripts/ai_context_enrichment.py'
|
||||
- 'AppImage/scripts/ai_providers/**'
|
||||
- 'AppImage/config/verified_ai_models.json'
|
||||
- 'AppImage/scripts/tests/test_groq_model_recommendations.py'
|
||||
- 'AppImage/scripts/tests/test_backup_ai_diagnostics.py'
|
||||
- 'AppImage/scripts/notification_channels.py'
|
||||
- 'AppImage/components/apprise-service-help.tsx'
|
||||
- 'AppImage/components/notification-settings.tsx'
|
||||
- 'tests/test_apprise_services.py'
|
||||
- 'tests/test_apprise_service_help.cjs'
|
||||
- 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||
- 'AppImage/components/backup-storage-retention.tsx'
|
||||
- 'AppImage/components/backup-tab.tsx'
|
||||
- 'AppImage/components/vm-backup-jobs.tsx'
|
||||
- 'AppImage/lib/backup-retention.ts'
|
||||
- 'tests/test_vm_backup_jobs_api.py'
|
||||
- 'tests/test_backup_storage_retention.cjs'
|
||||
- 'tests/test_figurine_installation.py'
|
||||
- 'scripts/gpu_tpu/nvidia_installer.sh'
|
||||
- 'tests/test_nvidia*.py'
|
||||
- 'AppImage/scripts/managed_installs.py'
|
||||
- 'scripts/post_install/customizable_post_install.sh'
|
||||
- 'AppImage/scripts/post_install_versions.py'
|
||||
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||
- 'AppImage/scripts/guest_backup_archives.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/ovs_topology.py'
|
||||
- 'AppImage/scripts/tests/test_ovs_topology.py'
|
||||
- 'AppImage/components/network-flow.tsx'
|
||||
- 'AppImage/components/network-metrics.tsx'
|
||||
- 'AppImage/tests/ovs-network-flow.cjs'
|
||||
- 'AppImage/scripts/jwt_middleware.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/components/guest-backup-list.tsx'
|
||||
- 'AppImage/components/virtual-machines.tsx'
|
||||
- 'AppImage/lib/guest-backup-archives.ts'
|
||||
- 'AppImage/messages/**'
|
||||
- 'tests/test_guest_backup_archives.*'
|
||||
- 'AppImage/scripts/lvm_device_scope.py'
|
||||
- 'AppImage/scripts/disk_temperature_history.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/flask_notification_routes.py'
|
||||
- 'AppImage/scripts/proxmox_storage_monitor.py'
|
||||
- 'AppImage/scripts/tests/test_host_backup_node.py'
|
||||
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
|
||||
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
|
||||
- 'tests/test_lvm_disk_read_policy.py'
|
||||
- 'oci/**'
|
||||
- 'AppImage/scripts/oci_menu_bridge.py'
|
||||
- 'AppImage/scripts/lxc_apps.py'
|
||||
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
|
||||
- 'AppImage/scripts/*pbs*.py'
|
||||
- 'AppImage/scripts/tests/test_pbs.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/auth_manager.py'
|
||||
- 'AppImage/components/pbs-*.tsx'
|
||||
- 'AppImage/components/proxmox-dashboard.tsx'
|
||||
- 'AppImage/tests/pbs-navigation.cjs'
|
||||
- 'scripts/global/proxmox_platform.py'
|
||||
- 'scripts/global/platform-functions.sh'
|
||||
- 'scripts/global/repository-functions.sh'
|
||||
- 'scripts/menus/pbs_menu.sh'
|
||||
- 'scripts/utilities/pbs_update.sh'
|
||||
- 'scripts/global/update-pbs.sh'
|
||||
- 'scripts/menus/menu_post_install.sh'
|
||||
- 'scripts/post_install/*.sh'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- '.github/workflows/test-features.yml'
|
||||
- 'AppImage/scripts/notification_manager.py'
|
||||
- 'AppImage/scripts/notification_templates.py'
|
||||
- 'AppImage/scripts/ai_context_enrichment.py'
|
||||
- 'AppImage/scripts/ai_providers/**'
|
||||
- 'AppImage/config/verified_ai_models.json'
|
||||
- 'AppImage/scripts/tests/test_groq_model_recommendations.py'
|
||||
- 'AppImage/scripts/tests/test_backup_ai_diagnostics.py'
|
||||
- 'AppImage/scripts/notification_channels.py'
|
||||
- 'AppImage/components/apprise-service-help.tsx'
|
||||
- 'tests/test_apprise_services.py'
|
||||
- 'tests/test_apprise_service_help.cjs'
|
||||
- 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||
- 'AppImage/components/backup-storage-retention.tsx'
|
||||
- 'AppImage/lib/backup-retention.ts'
|
||||
- 'tests/test_vm_backup_jobs_api.py'
|
||||
- 'tests/test_backup_storage_retention.cjs'
|
||||
- 'tests/test_figurine_installation.py'
|
||||
- 'scripts/post_install/customizable_post_install.sh'
|
||||
- 'AppImage/scripts/post_install_versions.py'
|
||||
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||
- 'AppImage/scripts/guest_backup_archives.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/ovs_topology.py'
|
||||
- 'AppImage/scripts/tests/test_ovs_topology.py'
|
||||
- 'AppImage/components/network-flow.tsx'
|
||||
- 'AppImage/components/network-metrics.tsx'
|
||||
- 'AppImage/tests/ovs-network-flow.cjs'
|
||||
- 'AppImage/scripts/jwt_middleware.py'
|
||||
- 'AppImage/components/guest-backup-list.tsx'
|
||||
- 'AppImage/components/virtual-machines.tsx'
|
||||
- 'AppImage/lib/guest-backup-archives.ts'
|
||||
- 'tests/test_guest_backup_archives.*'
|
||||
- 'AppImage/scripts/lvm_device_scope.py'
|
||||
- 'AppImage/scripts/disk_temperature_history.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/flask_notification_routes.py'
|
||||
- 'AppImage/scripts/proxmox_storage_monitor.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/scripts/tests/test_host_backup_node.py'
|
||||
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
|
||||
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
|
||||
- 'tests/test_lvm_disk_read_policy.py'
|
||||
- 'scripts/gpu_tpu/nvidia_installer.sh'
|
||||
- 'tests/test_nvidia*.py'
|
||||
- 'AppImage/scripts/managed_installs.py'
|
||||
- 'oci/**'
|
||||
- 'AppImage/scripts/oci_menu_bridge.py'
|
||||
- 'AppImage/scripts/lxc_apps.py'
|
||||
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
|
||||
- 'AppImage/scripts/*pbs*.py'
|
||||
- 'scripts/global/proxmox_platform.py'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to the same branch or pull request replaces the run in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
apprise-services:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
apprise: ['1.7.0', '2.0.1']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Install fixture dependencies
|
||||
run: |
|
||||
python -m pip install 'apprise==${{ matrix.apprise }}'
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
- name: Test real provider plugins with mocked HTTP
|
||||
run: python -B -m unittest discover -s tests -p test_apprise_services.py -v
|
||||
- name: Render provider help and locale fallback
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||
run: node tests/test_apprise_service_help.cjs
|
||||
|
||||
storage-retention:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Install fixture dependencies
|
||||
run: |
|
||||
python -m pip install 'Flask==3.1.2'
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
- name: Test native job and storage configuration APIs
|
||||
run: python -B -m unittest discover -s tests -p test_vm_backup_jobs_api.py -v
|
||||
- name: Test shared retention properties
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||
run: node tests/test_backup_storage_retention.cjs
|
||||
|
||||
nvidia-installer:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Check NVIDIA parser, cache and Secure Boot regressions
|
||||
run: |
|
||||
bash -n scripts/gpu_tpu/nvidia_installer.sh
|
||||
python3 -B -m unittest discover -s tests -p 'test_nvidia*.py' -v
|
||||
|
||||
ai-model-availability:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Test model ordering and curated availability without network or credentials
|
||||
run: python -B -m unittest discover -s AppImage/scripts/tests -p test_groq_model_recommendations.py -v
|
||||
|
||||
installer-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Check shell syntax and isolated installer regressions
|
||||
run: |
|
||||
bash -n scripts/post_install/customizable_post_install.sh
|
||||
python3 -B -m unittest discover -s tests -p test_figurine_installation.py -v
|
||||
- name: Install ARM user-mode emulation for release smoke tests
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends qemu-user
|
||||
- name: Verify and run the pinned AMD64, ARM64 and ARMv7 release binaries
|
||||
env:
|
||||
FIGURINE_VERSION: '2.1.1'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scratch="$(mktemp -d)"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
while read -r arch sha; do
|
||||
archive="figurine_linux_${arch}_v${FIGURINE_VERSION}.tar.gz"
|
||||
target="$scratch/$arch"
|
||||
mkdir -p "$target"
|
||||
curl --fail --location --retry 3 --output "$target/$archive" \
|
||||
"https://github.com/arsham/figurine/releases/download/v${FIGURINE_VERSION}/$archive"
|
||||
printf '%s %s\n' "$sha" "$target/$archive" | sha256sum --check
|
||||
tar -xf "$target/$archive" -C "$target"
|
||||
runner=()
|
||||
case "$arch" in
|
||||
arm64) runner=(qemu-aarch64) ;;
|
||||
arm) runner=(qemu-arm) ;;
|
||||
esac
|
||||
"${runner[@]}" "$target/deploy/figurine" --version | tee "$target/version"
|
||||
grep -F "figurine version v$FIGURINE_VERSION" "$target/version"
|
||||
"${runner[@]}" "$target/deploy/figurine" -f "3d.flf" "ProxMenux-test" > "$target/banner"
|
||||
test -s "$target/banner"
|
||||
printf 'PASS: %s version and login banner\n' "$arch"
|
||||
done <<'ASSETS'
|
||||
amd64 f2b1c076942c94009d133df0f304a234a0994bf590b40f2927668324d578306a
|
||||
arm64 611550ddb31a75437be3f8587bcdd029d0d890f4d820b7f494a2c6615fecba10
|
||||
arm 429e66bded486d49f730c4a6559e728cd1e9a163a0f56a85867e2cbec010171d
|
||||
ASSETS
|
||||
|
||||
guest-backups:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Install fixture dependencies
|
||||
run: |
|
||||
python -m pip install 'Flask==3.1.2'
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
- name: Test native deletion, protection, authorization and cache invalidation
|
||||
run: python -B -m unittest discover -s tests -p test_guest_backup_archives.py -v
|
||||
- name: Test frontend deletion and completion handling
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||
run: node tests/test_guest_backup_archives.cjs
|
||||
|
||||
disk-policy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Test automatic LVM readers with mocked inventory and policy
|
||||
run: python -I -B -m unittest discover -s tests -p test_lvm_disk_read_policy.py -v
|
||||
- name: Test backup node ownership and former jobs
|
||||
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_host_backup_node.py -v
|
||||
- name: Test local node lookup without Proxmox API commands
|
||||
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_proxmox_node_name.py -v
|
||||
- name: Test notification configuration writes against a cluster filesystem fixture
|
||||
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_pve_notifications_cfg_writes.py -v
|
||||
- name: Test AI diagnostic explanations and backup inventory integrity
|
||||
run: |
|
||||
python -I -B -m unittest discover -s AppImage/scripts/tests -p test_backup_ai_diagnostics.py -v
|
||||
python -I -B -m unittest discover -s AppImage/scripts/tests -p test_vzdump_ai_integrity.py -v
|
||||
|
||||
registry-accounts:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Install OCI test dependencies
|
||||
run: python3 -m pip install -r oci/requirements.txt
|
||||
- name: Run watchdog identity and lifecycle regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s oci/tests -p test_watchdog.py -v
|
||||
- name: Run OCI image reuse and resource edit regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_native_modify.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_review.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_review_transaction.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_stage_release.py -v
|
||||
- name: Run carried record, recovery and configuration cache regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p 'test_carried*.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p test_restore_recovery.py -v
|
||||
- name: Run read-only volume preparation regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
test -x /bin/bash
|
||||
test -x /usr/bin/jq
|
||||
python3 -m unittest discover -s oci/tests -p test_readonly_volume_preparation.py -v
|
||||
- name: Run offline registry account tests
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v
|
||||
- name: Run OCI menu update checks and selection regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p 'test_update*.py' -v
|
||||
python3 -m unittest discover -s AppImage/scripts/tests -p 'test_oci*.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p test_recreate_registry_digest.py -v
|
||||
- name: Run OCI Modify latency regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_latency.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_menu_performance.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_dialog_escape.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_work_backup.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
|
||||
- name: Run OCI modification menu regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_menu.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_manage_sync_notice.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_shared_directories.py -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_wizard*.py' -v
|
||||
- name: Run external OCI settings regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p 'test_external_tags.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_guessed_endpoint_healthcheck.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_runtime_home_*.py' -v
|
||||
- name: Run managed volume startup regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s oci/tests -p 'test_managed_volume_*.py' -v
|
||||
- name: Run catalog web-link regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s oci/tests -p test_catalog_web_links.py -v
|
||||
- name: Run full OCI engine test suite
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s oci/tests -v
|
||||
|
||||
pbs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python: ['3.11', '3.13']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- name: Install test dependencies
|
||||
run: python -m pip install Flask PyJWT cryptography pyotp segno
|
||||
- name: Platform, real TLS, APIs, health and AppImage packaging
|
||||
run: |
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs_i18n.py -v
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
|
||||
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
|
||||
bash -n "$script"
|
||||
done
|
||||
- uses: actions/setup-node@v6
|
||||
if: matrix.python == '3.11'
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Dashboard platform routing
|
||||
if: matrix.python == '3.11'
|
||||
run: |
|
||||
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
||||
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/pbs-navigation.cjs
|
||||
|
||||
native-image-import:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Exercise private images with isolated mapped Linux readers
|
||||
run: sudo /usr/bin/python3 -B -m unittest discover -s oci/tests -p test_native_import_access.py -v
|
||||
|
||||
network-topology:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Test read-only OVS discovery and mixed network API
|
||||
run: python -B -m unittest discover -s AppImage/scripts/tests -p test_ovs_topology.py -v
|
||||
- name: Test both Network Flow layouts
|
||||
run: |
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/ovs-network-flow.cjs
|
||||
@@ -1,73 +0,0 @@
|
||||
name: Figurine installation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-figurine.yml'
|
||||
- 'tests/test_figurine_installation.py'
|
||||
- 'scripts/post_install/customizable_post_install.sh'
|
||||
- 'AppImage/scripts/post_install_versions.py'
|
||||
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- '.github/workflows/test-figurine.yml'
|
||||
- 'tests/test_figurine_installation.py'
|
||||
- 'scripts/post_install/customizable_post_install.sh'
|
||||
- 'AppImage/scripts/post_install_versions.py'
|
||||
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
installer-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Check shell syntax and isolated installer regressions
|
||||
run: |
|
||||
bash -n scripts/post_install/customizable_post_install.sh
|
||||
python3 -B -m unittest discover -s tests -p test_figurine_installation.py -v
|
||||
- name: Install ARM user-mode emulation for release smoke tests
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends qemu-user
|
||||
- name: Verify and run the pinned AMD64, ARM64 and ARMv7 release binaries
|
||||
env:
|
||||
FIGURINE_VERSION: '2.1.1'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scratch="$(mktemp -d)"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
while read -r arch sha; do
|
||||
archive="figurine_linux_${arch}_v${FIGURINE_VERSION}.tar.gz"
|
||||
target="$scratch/$arch"
|
||||
mkdir -p "$target"
|
||||
curl --fail --location --retry 3 --output "$target/$archive" \
|
||||
"https://github.com/arsham/figurine/releases/download/v${FIGURINE_VERSION}/$archive"
|
||||
printf '%s %s\n' "$sha" "$target/$archive" | sha256sum --check
|
||||
tar -xf "$target/$archive" -C "$target"
|
||||
runner=()
|
||||
case "$arch" in
|
||||
arm64) runner=(qemu-aarch64) ;;
|
||||
arm) runner=(qemu-arm) ;;
|
||||
esac
|
||||
"${runner[@]}" "$target/deploy/figurine" --version | tee "$target/version"
|
||||
grep -F "figurine version v$FIGURINE_VERSION" "$target/version"
|
||||
"${runner[@]}" "$target/deploy/figurine" -f "3d.flf" "ProxMenux-test" > "$target/banner"
|
||||
test -s "$target/banner"
|
||||
printf 'PASS: %s version and login banner\n' "$arch"
|
||||
done <<'ASSETS'
|
||||
amd64 f2b1c076942c94009d133df0f304a234a0994bf590b40f2927668324d578306a
|
||||
arm64 611550ddb31a75437be3f8587bcdd029d0d890f4d820b7f494a2c6615fecba10
|
||||
arm 429e66bded486d49f730c4a6559e728cd1e9a163a0f56a85867e2cbec010171d
|
||||
ASSETS
|
||||
@@ -1,54 +0,0 @@
|
||||
name: Guest backup archives
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-guest-backups.yml'
|
||||
- 'AppImage/scripts/guest_backup_archives.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/jwt_middleware.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/components/guest-backup-list.tsx'
|
||||
- 'AppImage/components/virtual-machines.tsx'
|
||||
- 'AppImage/lib/guest-backup-archives.ts'
|
||||
- 'AppImage/messages/**'
|
||||
- 'tests/test_guest_backup_archives.*'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/guest_backup_archives.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/jwt_middleware.py'
|
||||
- 'AppImage/components/guest-backup-list.tsx'
|
||||
- 'AppImage/components/virtual-machines.tsx'
|
||||
- 'AppImage/lib/guest-backup-archives.ts'
|
||||
- 'tests/test_guest_backup_archives.*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
guest-backups:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Install fixture dependencies
|
||||
run: |
|
||||
python -m pip install 'Flask==3.1.2'
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
- name: Test native deletion, protection, authorization and cache invalidation
|
||||
run: python -B -m unittest discover -s tests -p test_guest_backup_archives.py -v
|
||||
- name: Test frontend deletion and completion handling
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||
run: node tests/test_guest_backup_archives.cjs
|
||||
@@ -51,6 +51,11 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to the same branch or pull request replaces the run in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Preserve this check ID for existing branch protection rules. This suite
|
||||
# includes menu, download and scheduler regressions as well as i18n tests.
|
||||
@@ -71,8 +76,8 @@ jobs:
|
||||
run: |
|
||||
test -x /bin/bash
|
||||
test -x /usr/bin/jq
|
||||
- name: Install OCI wording test dependency
|
||||
run: python3 -m pip install 'PyYAML>=6,<7'
|
||||
- name: Install OCI wording test dependencies
|
||||
run: python3 -m pip install 'PyYAML>=6,<7' 'jsonschema==4.25.1'
|
||||
- name: Run offline i18n and script regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Disk policy and host monitor regressions
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-lvm-disk-policy.yml'
|
||||
- 'AppImage/scripts/lvm_device_scope.py'
|
||||
- 'AppImage/scripts/disk_temperature_history.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/flask_notification_routes.py'
|
||||
- 'AppImage/scripts/proxmox_storage_monitor.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/scripts/tests/test_host_backup_node.py'
|
||||
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
|
||||
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
|
||||
- 'tests/test_lvm_disk_read_policy.py'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/lvm_device_scope.py'
|
||||
- 'AppImage/scripts/disk_temperature_history.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/flask_notification_routes.py'
|
||||
- 'AppImage/scripts/proxmox_storage_monitor.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/scripts/tests/test_host_backup_node.py'
|
||||
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
|
||||
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
|
||||
- 'tests/test_lvm_disk_read_policy.py'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
disk-policy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Test automatic LVM readers with mocked inventory and policy
|
||||
run: python -I -B -m unittest discover -s tests -p test_lvm_disk_read_policy.py -v
|
||||
- name: Test backup node ownership and former jobs
|
||||
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_host_backup_node.py -v
|
||||
- name: Test local node lookup without Proxmox API commands
|
||||
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_proxmox_node_name.py -v
|
||||
- name: Test notification configuration writes against a cluster filesystem fixture
|
||||
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_pve_notifications_cfg_writes.py -v
|
||||
@@ -1,82 +0,0 @@
|
||||
name: Offline OCI registry tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'oci/**'
|
||||
- '.github/workflows/test-oci-registries.yml'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'oci/**'
|
||||
- '.github/workflows/test-oci-registries.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
registry-accounts:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Install OCI test dependencies
|
||||
run: python3 -m pip install -r oci/requirements.txt
|
||||
- name: Run OCI image reuse and resource edit regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_native_modify.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_review.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_review_transaction.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_stage_release.py -v
|
||||
- name: Run carried record, recovery and configuration cache regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p 'test_carried*.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p test_restore_recovery.py -v
|
||||
- name: Run read-only volume preparation regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
test -x /bin/bash
|
||||
test -x /usr/bin/jq
|
||||
python3 -m unittest discover -s oci/tests -p test_readonly_volume_preparation.py -v
|
||||
- name: Run offline registry account tests
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v
|
||||
- name: Run OCI Modify latency regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_latency.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_menu_performance.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_dialog_escape.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_work_backup.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
|
||||
- name: Run OCI modification menu regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_menu.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_manage_sync_notice.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_shared_directories.py -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_wizard*.py' -v
|
||||
- name: Run external OCI settings regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p 'test_external_tags.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_guessed_endpoint_healthcheck.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_runtime_home_*.py' -v
|
||||
@@ -13,6 +13,11 @@ on:
|
||||
- 'AppImage/scripts/*.py'
|
||||
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
|
||||
- 'AppImage/scripts/tests/test_native_app_discovery.py'
|
||||
- 'AppImage/scripts/tests/test_local_app_icons.py'
|
||||
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
|
||||
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
|
||||
- 'AppImage/app/**'
|
||||
- 'AppImage/components/**'
|
||||
- 'AppImage/hooks/**'
|
||||
@@ -34,6 +39,11 @@ on:
|
||||
- 'AppImage/scripts/*.py'
|
||||
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
|
||||
- 'AppImage/scripts/tests/test_native_app_discovery.py'
|
||||
- 'AppImage/scripts/tests/test_local_app_icons.py'
|
||||
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
|
||||
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
|
||||
- 'AppImage/scripts/tests/test_pbs_i18n.py'
|
||||
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
|
||||
- 'AppImage/app/**'
|
||||
- 'AppImage/components/**'
|
||||
- 'AppImage/hooks/**'
|
||||
@@ -47,6 +57,11 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to the same branch or pull request replaces the run in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
offline-python:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -59,7 +74,7 @@ jobs:
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install native discovery route fixture dependency
|
||||
run: python3 -m pip install 'Flask>=3,<4'
|
||||
run: python3 -m pip install 'Flask>=3,<4' 'Pillow==12.3.0' 'defusedxml==0.7.1' 'PyJWT>=2,<3' 'gevent>=25,<27' 'flask-sock==0.7.0' requests
|
||||
- name: Run qualified Python files
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
name: PBS support
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-pbs.yml'
|
||||
- 'AppImage/scripts/*pbs*.py'
|
||||
- 'AppImage/scripts/tests/test_pbs.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/auth_manager.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/components/pbs-*.tsx'
|
||||
- 'AppImage/components/proxmox-dashboard.tsx'
|
||||
- 'AppImage/tests/pbs-navigation.cjs'
|
||||
- 'scripts/global/proxmox_platform.py'
|
||||
- 'scripts/global/platform-functions.sh'
|
||||
- 'scripts/global/repository-functions.sh'
|
||||
- 'scripts/menus/pbs_menu.sh'
|
||||
- 'scripts/utilities/pbs_update.sh'
|
||||
- 'scripts/global/update-pbs.sh'
|
||||
- 'scripts/menus/menu_post_install.sh'
|
||||
- 'scripts/post_install/*.sh'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/*pbs*.py'
|
||||
- 'scripts/global/proxmox_platform.py'
|
||||
- '.github/workflows/test-pbs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pbs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python: ['3.11', '3.13']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- name: Install test dependencies
|
||||
run: python -m pip install Flask PyJWT cryptography pyotp segno
|
||||
- name: Platform, real TLS, APIs, health and AppImage packaging
|
||||
run: |
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
|
||||
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
|
||||
bash -n "$script"
|
||||
done
|
||||
- uses: actions/setup-node@v6
|
||||
if: matrix.python == '3.11'
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Dashboard platform routing
|
||||
if: matrix.python == '3.11'
|
||||
run: |
|
||||
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
||||
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/pbs-navigation.cjs
|
||||
@@ -14,6 +14,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/scripts/generate_app_tracking_catalog.py"
|
||||
- ".github/scripts/tests/test_app_tracking_sources.py"
|
||||
- ".github/workflows/update-app-tracking-hints.yml"
|
||||
- "json/runtime_verified_overrides.json"
|
||||
|
||||
@@ -37,6 +38,9 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Test Helper-Scripts source layouts
|
||||
run: python -B -m unittest discover -s .github/scripts/tests -p test_app_tracking_sources.py -v
|
||||
|
||||
- name: ⚙️ Generate app_tracking_hints.generated.json (intermediate)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -54,6 +54,7 @@ Thumbs.db
|
||||
|
||||
# Cache
|
||||
.cache
|
||||
/oci/.catalog-cache/
|
||||
/web/.cache
|
||||
|
||||
# IDE/editor files
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
79ba79ea553e9ba473b33c8614f4dae5f684c960390f42272dc282c902627020 ProxMenux-1.2.6.2-beta.AppImage
|
||||
488f19197c38af62a1f3356ce5ef3b84dc8370efe51eb65cfc6b52d301b9df74 ProxMenux-1.2.8.AppImage
|
||||
|
||||
@@ -18,7 +18,6 @@ interface DiskEntry {
|
||||
rotational: boolean
|
||||
excluded: boolean
|
||||
excluded_at: string | null
|
||||
idle: boolean
|
||||
present: boolean
|
||||
}
|
||||
|
||||
@@ -206,15 +205,6 @@ export function DiskExclusions() {
|
||||
{t("settings.diskExclusions.excluded")}
|
||||
</Badge>
|
||||
)}
|
||||
{!excluded && disk.idle && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground"
|
||||
title={t("storage.idleTitle")}
|
||||
>
|
||||
{t("storage.idle")}
|
||||
</Badge>
|
||||
)}
|
||||
{!disk.present && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-muted text-muted-foreground">
|
||||
{t("settings.diskExclusions.notConnected")}
|
||||
@@ -252,8 +242,6 @@ export function DiskExclusions() {
|
||||
<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">
|
||||
{t("settings.diskExclusions.help")}
|
||||
<br />
|
||||
{t("settings.diskExclusions.idleHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -633,6 +633,13 @@ export default function Hardware() {
|
||||
return realtimeGPUData.has_monitoring_tool === true
|
||||
}
|
||||
|
||||
const diskCountBySlot = new Map<string, number>()
|
||||
for (const disk of hardwareData?.storage_devices ?? []) {
|
||||
if (disk.controller_slot) {
|
||||
diskCountBySlot.set(disk.controller_slot, (diskCountBySlot.get(disk.controller_slot) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
@@ -2358,14 +2365,23 @@ return (
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{hardwareData.pci_devices.map((device, index) => (
|
||||
{hardwareData.pci_devices.map((device, index) => {
|
||||
const diskCount = diskCountBySlot.get(device.slot) ?? 0
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => setSelectedPCIDevice(device)}
|
||||
className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Badge className={`${getDeviceTypeColor(device.type)} text-xs shrink-0`}>{translateDeviceType(device.type, t)}</Badge>
|
||||
{diskCount > 0 && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{t("hardware.counts.devices", { count: diskCount })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-mono text-xs text-muted-foreground shrink-0">{device.slot}</span>
|
||||
</div>
|
||||
<p className="font-medium text-sm line-clamp-2 break-words">{device.device}</p>
|
||||
@@ -2374,7 +2390,8 @@ return (
|
||||
<p className="mt-1 font-mono text-xs text-green-500 truncate">{t("hardware.labels.driver")}: {device.driver}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
@@ -2434,6 +2451,37 @@ return (
|
||||
<span className="font-mono text-sm">{selectedPCIDevice.kernel_module}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const attachedDisks = (hardwareData?.storage_devices ?? []).filter(
|
||||
(disk) => disk.controller_slot === selectedPCIDevice.slot,
|
||||
)
|
||||
if (attachedDisks.length === 0) return null
|
||||
return (
|
||||
<div className="flex justify-between gap-4 border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("hardware.labels.attachedDisks")} ({attachedDisks.length})
|
||||
</span>
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{attachedDisks.map((disk) => (
|
||||
<button
|
||||
key={disk.name}
|
||||
type="button"
|
||||
title={disk.model}
|
||||
onClick={() => {
|
||||
setSelectedPCIDevice(null)
|
||||
setSelectedDisk(disk)
|
||||
}}
|
||||
>
|
||||
<Badge variant="outline" className="font-mono hover:bg-accent">
|
||||
{disk.name}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
@@ -2827,6 +2875,34 @@ return (
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDisk.controller_slot && (() => {
|
||||
// Fall back to the bare slot when the controller's PCI class isn't one we list
|
||||
const controller = hardwareData?.pci_devices?.find((d) => d.slot === selectedDisk.controller_slot)
|
||||
return (
|
||||
<div className="flex justify-between gap-4 border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.storageController")}</span>
|
||||
<span className="text-sm text-right">
|
||||
{controller ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-blue-400 hover:underline text-right"
|
||||
onClick={() => {
|
||||
setSelectedDisk(null)
|
||||
setSelectedPCIDevice(controller)
|
||||
}}
|
||||
>
|
||||
{/* lspci's "Device 84ca" placeholder means no subsystem name, so use the chip name */}
|
||||
{controller.sdevice && !/^Device [0-9a-f]{4}$/.test(controller.sdevice)
|
||||
? controller.sdevice
|
||||
: controller.device}
|
||||
</button>
|
||||
) : null}
|
||||
<span className="ml-2 font-mono text-muted-foreground">{selectedDisk.controller_slot}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{selectedDisk.rotation_rate !== undefined && selectedDisk.rotation_rate !== null && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">{t("hardware.labels.rotationRate")}</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { getAuthToken } from "@/lib/api-config"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -41,6 +41,9 @@ import {
|
||||
HelpCircle,
|
||||
} from "lucide-react"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { HostUpdateJobs, type HostUpdateJob } from "./host-update-jobs"
|
||||
import { HostUpdateResult } from "./host-update-result"
|
||||
import { RestartAfterUpdate, ProxMenuxVersionCard } from "./host-update-status"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { useIsPbs } from "@/lib/use-platform"
|
||||
|
||||
@@ -126,12 +129,23 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
||||
const [customSuppressions, setCustomSuppressions] = useState<CustomSuppression[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [snapshotUnavailable, setSnapshotUnavailable] = useState(false)
|
||||
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
|
||||
const [hostJob, setHostJob] = useState<{ runId: string; resume: boolean; kind: "host" } | null>(null)
|
||||
const [hostJobs, setHostJobs] = useState<HostUpdateJob[]>([])
|
||||
const [resultJobId, setResultJobId] = useState<string | null>(null)
|
||||
const healthRequest = useRef<AbortController | null>(null)
|
||||
const previousAutoExpanded = useRef<Set<string>>(new Set())
|
||||
|
||||
const fetchHealthDetails = useCallback(async (force = false) => {
|
||||
setLoading(true)
|
||||
const fetchHealthDetails = useCallback(async (force = false, cachedOnly = false) => {
|
||||
if (cachedOnly && healthRequest.current) return
|
||||
healthRequest.current?.abort()
|
||||
const controller = new AbortController()
|
||||
healthRequest.current = controller
|
||||
const timeout = cachedOnly ? setTimeout(() => controller.abort(), 10000) : undefined
|
||||
if (!cachedOnly) setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
@@ -144,14 +158,26 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
authHeaders["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(getApiUrl(force ? "/api/health/full?refresh=1" : "/api/health/full"), { headers: authHeaders })
|
||||
const path = cachedOnly ? "/api/health/full?cached=1" : force ? "/api/health/full?refresh=1" : "/api/health/full"
|
||||
const response = await fetch(getApiUrl(path), { headers: authHeaders, signal: controller.signal })
|
||||
if (healthRequest.current !== controller) return
|
||||
let infoCount = 0
|
||||
|
||||
if (!response.ok) {
|
||||
if (cachedOnly && response.status === 503) {
|
||||
// Completion invalidates the snapshot before the collector refills it.
|
||||
// Keep the dated result, explicitly unavailable; never start an audit.
|
||||
setSnapshotUnavailable(true)
|
||||
return
|
||||
}
|
||||
// Polling cannot fall back to a legacy endpoint that runs a full audit.
|
||||
if (cachedOnly) throw new Error(t("healthStatus.errors.fetchFailed"))
|
||||
// Fallback to legacy endpoint
|
||||
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders })
|
||||
const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders, signal: controller.signal })
|
||||
if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed"))
|
||||
const data = await legacyResponse.json()
|
||||
if (healthRequest.current !== controller) return
|
||||
setSnapshotUnavailable(false)
|
||||
setHealthData(data)
|
||||
setDismissedItems([])
|
||||
setCustomSuppressions([])
|
||||
@@ -168,6 +194,9 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
}
|
||||
} else {
|
||||
const fullData: FullHealthData = await response.json()
|
||||
if (healthRequest.current !== controller) return
|
||||
if (!fullData?.health?.details) throw new Error(t("healthStatus.errors.fetchFailed"))
|
||||
setSnapshotUnavailable(false)
|
||||
setHealthData(fullData.health)
|
||||
setDismissedItems(fullData.dismissed || [])
|
||||
setCustomSuppressions(fullData.custom_suppressions || [])
|
||||
@@ -207,10 +236,17 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
} catch (err) {
|
||||
if (healthRequest.current === controller) {
|
||||
setSnapshotUnavailable(false)
|
||||
setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown"))
|
||||
}
|
||||
} finally {
|
||||
if (timeout !== undefined) clearTimeout(timeout)
|
||||
if (healthRequest.current === controller) {
|
||||
healthRequest.current = null
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [getApiUrl, t])
|
||||
|
||||
// Tick counter to force re-render every 30s so "X minutes ago" stays current
|
||||
@@ -225,9 +261,21 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchHealthDetails()
|
||||
// Auto-refresh every 5 minutes while modal is open
|
||||
const refreshInterval = setInterval(() => fetchHealthDetails(), 300000)
|
||||
return () => clearInterval(refreshInterval)
|
||||
// Recheck reboot evidence promptly without running APT/full health checks.
|
||||
const refresh = () => {
|
||||
if (document.visibilityState === "visible") void fetchHealthDetails(false, true)
|
||||
}
|
||||
const refreshInterval = setInterval(refresh, 30000)
|
||||
window.addEventListener("focus", refresh)
|
||||
document.addEventListener("visibilitychange", refresh)
|
||||
return () => {
|
||||
clearInterval(refreshInterval)
|
||||
window.removeEventListener("focus", refresh)
|
||||
document.removeEventListener("visibilitychange", refresh)
|
||||
const controller = healthRequest.current
|
||||
healthRequest.current = null
|
||||
controller?.abort()
|
||||
}
|
||||
}
|
||||
}, [open, fetchHealthDetails])
|
||||
|
||||
@@ -245,7 +293,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
nonOkCategories.add(key)
|
||||
}
|
||||
})
|
||||
setExpandedCategories(nonOkCategories)
|
||||
const newlyDegraded = [...nonOkCategories].filter(key => !previousAutoExpanded.current.has(key))
|
||||
previousAutoExpanded.current = nonOkCategories
|
||||
if (newlyDegraded.length) {
|
||||
setExpandedCategories(previous => new Set([...previous, ...newlyDegraded]))
|
||||
}
|
||||
}
|
||||
}, [healthData])
|
||||
|
||||
@@ -300,6 +352,10 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
|
||||
const translateHealthText = (value?: string): string => {
|
||||
if (!value) return ""
|
||||
// Health summaries include the category before the same detail text.
|
||||
if (value.startsWith("Updates: ")) {
|
||||
return `${t("healthStatus.categories.updates")}: ${translateHealthText(value.slice(9))}`
|
||||
}
|
||||
const exact: Record<string, string> = {
|
||||
"No temperature sensor detected - install lm-sensors if hardware supports it": t("healthStatus.details.noTemperatureSensor"),
|
||||
"Sustained high CPU usage": t("healthStatus.details.cpuHighSustained"),
|
||||
@@ -330,6 +386,10 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
"Active": t("healthStatus.details.active"),
|
||||
"UP": t("healthStatus.details.up"),
|
||||
"Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"),
|
||||
"No reboot required": t("healthStatus.hostUpdate.noReboot"),
|
||||
"No pending updates": t("healthStatus.details.noPendingUpdates"),
|
||||
"Reboot required by the operating system": t("healthStatus.hostUpdate.rebootMarker"),
|
||||
"Could not determine whether a reboot is required": t("healthStatus.hostUpdate.rebootUnknown"),
|
||||
"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"),
|
||||
@@ -341,6 +401,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
if (Object.prototype.hasOwnProperty.call(exact, value)) return exact[value]
|
||||
|
||||
let match = value.match(/^Latency ([\d.]+)ms to gateway$/)
|
||||
const reboot = value.match(/^Reboot required: running (.+); installed (.+)$/)
|
||||
if (reboot) return t("healthStatus.hostUpdate.rebootKernel", { running: reboot[1], installed: reboot[2] })
|
||||
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] })
|
||||
@@ -623,7 +685,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
"smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity",
|
||||
"qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode",
|
||||
"log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors",
|
||||
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime",
|
||||
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime", "reboot_required",
|
||||
"certificates", "login_attempts", "fail2ban", "proxmox_storages",
|
||||
])
|
||||
if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`)
|
||||
@@ -634,6 +696,17 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
const translateHealthSummary = (snapshot: HealthDetails): string => {
|
||||
if (snapshot.summary === "Updates") {
|
||||
const reboot = snapshot.details?.updates?.checks?.reboot_required
|
||||
if (reboot?.status === "INFO" && typeof reboot.detail === "string" && reboot.detail) {
|
||||
return translateHealthText(reboot.detail)
|
||||
}
|
||||
return t("healthStatus.categories.updates")
|
||||
}
|
||||
return translateHealthText(snapshot.summary)
|
||||
}
|
||||
|
||||
const renderChecks = (
|
||||
checks: Record<string, { status: string; detail: string; dismissable?: boolean; [key: string]: any }>,
|
||||
categoryKey: string
|
||||
@@ -645,6 +718,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
{Object.entries(checks)
|
||||
.filter(([, checkData]) => checkData.installed !== false)
|
||||
.map(([checkKey, checkData]) => {
|
||||
if (categoryKey === "updates" && checkKey === "reboot_required" && checkData.status === "INFO") {
|
||||
return <RestartAfterUpdate key={checkKey} getApiUrl={getApiUrl}
|
||||
unavailable={loading || snapshotUnavailable || !!error}
|
||||
disabled={hostJobs.some(job => job.state !== "complete")} label={formatCheckLabel(checkKey)}
|
||||
detail={translateHealthText(checkData.detail)} icon={getStatusIcon(checkData.status, "sm")} />
|
||||
}
|
||||
const isDismissable = checkData.dismissable === true
|
||||
const checkStatus = checkData.status?.toUpperCase() || "OK"
|
||||
|
||||
@@ -711,6 +790,14 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<HostUpdateJobs open={open} activeId={showUpdateTerminal ? hostJob?.runId : undefined}
|
||||
onJobs={setHostJobs} onSettled={() => { void fetchHealthDetails(true) }}
|
||||
onResume={job => {
|
||||
if (job.state === "complete") { setResultJobId(job.id); return }
|
||||
setHostJob({ runId: job.id, resume: true, kind: "host" })
|
||||
setShowUpdateTerminal(true)
|
||||
}} />
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
@@ -724,8 +811,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
</div>
|
||||
)}
|
||||
|
||||
{healthData && !loading && (
|
||||
<div className="space-y-4">
|
||||
{snapshotUnavailable && (
|
||||
<div role="status" className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<p>{t("healthStatus.snapshotUnavailable")}</p>
|
||||
{healthData && <p className="mt-1">{t("healthStatus.lastKnownResults")}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{healthData && (
|
||||
<fieldset disabled={loading} className="space-y-4 min-w-0">
|
||||
{/* Overall Stats Summary */}
|
||||
<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">
|
||||
@@ -760,7 +854,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
|
||||
{healthData.summary && healthData.summary !== "All systems operational" && (
|
||||
<div className="text-xs sm:text-sm p-3 rounded-lg bg-muted/20 border overflow-hidden max-w-full">
|
||||
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthText(healthData.summary)}</p>
|
||||
<p className="font-medium text-foreground break-words whitespace-pre-wrap">{translateHealthSummary(healthData)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -845,18 +939,30 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
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">
|
||||
{key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
|
||||
<p className="px-2 sm:px-3 mt-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
{t("healthStatus.packagePlanNote")}
|
||||
</p>
|
||||
)}
|
||||
{key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
|
||||
<div className="flex justify-end px-2 sm:px-3 pt-3 pb-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"
|
||||
disabled={hostJobs.some(job => job.state !== "complete")}
|
||||
onClick={() => {
|
||||
const runId = window.crypto?.randomUUID?.()
|
||||
|| `host-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
||||
setHostJob({ runId, resume: false, kind: "host" })
|
||||
setShowUpdateTerminal(true)
|
||||
}}
|
||||
className="h-10 sm:h-[34px] bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
|
||||
>
|
||||
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
||||
{t("healthStatus.updateNow")}
|
||||
{t("healthStatus.updateSystem")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{key === "updates" && <ProxMenuxVersionCard getApiUrl={getApiUrl} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -964,11 +1070,16 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
{t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</DialogContent>
|
||||
<HostUpdateResult jobId={resultJobId} getApiUrl={getApiUrl} onClose={() => setResultJobId(null)} />
|
||||
<ScriptTerminalModal
|
||||
key={hostJob?.runId || "host-update"}
|
||||
open={showUpdateTerminal}
|
||||
backgroundUpdate={hostJob || undefined}
|
||||
cancelledExitCode={130}
|
||||
onComplete={() => { void fetchHealthDetails(true) }}
|
||||
onClose={() => {
|
||||
setShowUpdateTerminal(false)
|
||||
// Force a fresh read (cache-busting via ?refresh=1) so the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIsPbs } from "../lib/use-platform"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Input } from "./ui/input"
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
FolderOpen,
|
||||
Database,
|
||||
Waves,
|
||||
Monitor,
|
||||
} from "lucide-react"
|
||||
import { getApiUrl, getAuthToken } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
@@ -84,6 +86,7 @@ interface ThresholdsTree {
|
||||
memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_high: ThresholdLeaf; available_min: ThresholdLeaf }
|
||||
host_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
lxc_rootfs: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
vm_disk: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
cpu_temperature: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
disk_temperature: {
|
||||
hdd: { warning: ThresholdLeaf; critical: ThresholdLeaf }
|
||||
@@ -201,6 +204,17 @@ const SECTIONS: SectionDef[] = [
|
||||
{ path: ["lxc_rootfs", "critical"], label: "Critical" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "vm_disk",
|
||||
title: "Disk space — VMs",
|
||||
icon: Monitor,
|
||||
description:
|
||||
"Filesystems of running VMs as the QEMU guest agent reports them, passthrough drives included. VMs without the guest agent are not evaluated.",
|
||||
fields: [
|
||||
{ path: ["vm_disk", "warning"], label: "Warning" },
|
||||
{ path: ["vm_disk", "critical"], label: "Critical" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "lxc_mount",
|
||||
title: "LXC mount points",
|
||||
@@ -283,8 +297,12 @@ function computeVisualRange(
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Guests and PVE storages do not exist on a Proxmox Backup Server.
|
||||
const PVE_ONLY_SECTIONS = new Set(["lxc_rootfs", "vm_disk", "lxc_mount", "pve_storage"])
|
||||
|
||||
export function HealthThresholds() {
|
||||
const t = useT()
|
||||
const isPbs = useIsPbs()
|
||||
const tFallback = (key: string, fallback: string) => {
|
||||
const translated = t(key)
|
||||
return translated === key ? fallback : translated
|
||||
@@ -773,7 +791,7 @@ export function HealthThresholds() {
|
||||
Mobile (<md) stays single-column as today.
|
||||
*/}
|
||||
<div className="columns-1 md:columns-2 2xl:columns-3 gap-4 space-y-4 [&>*]:break-inside-avoid">
|
||||
{SECTIONS.map((section) => {
|
||||
{SECTIONS.filter((section) => !isPbs || !PVE_ONLY_SECTIONS.has(section.id)).map((section) => {
|
||||
const Icon = section.icon
|
||||
return (
|
||||
<div key={section.id} className="rounded-md border border-border/50 px-3 py-2">
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { RestoreProgressCard } from "./restore-progress-card"
|
||||
import { fetchApi, getApiUrl } from "../lib/api-config"
|
||||
import { hostBackupErrorText } from "../lib/host-backup-messages"
|
||||
import { fetchTerminalTicket } from "../lib/terminal-ws"
|
||||
import { formatStorage, formatBytes, cancelButtonClass } from "../lib/utils"
|
||||
import { getStorageUsageColor } from "../lib/storage-usage-color"
|
||||
@@ -525,7 +526,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
|
||||
await mutateInfo()
|
||||
closeUpload()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setErr(hostBackupErrorText(e, t))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -542,7 +543,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
|
||||
await mutateInfo()
|
||||
closeDelete()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setErr(hostBackupErrorText(e, t))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -765,7 +766,7 @@ function KeyfileActionsBar({
|
||||
setPass1("")
|
||||
setPass2("")
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setErr(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -956,7 +957,7 @@ export function HostBackup() {
|
||||
mutateJobs()
|
||||
setJobToDelete(null)
|
||||
} catch (e) {
|
||||
setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${e instanceof Error ? e.message : String(e)}` }))
|
||||
setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${hostBackupErrorText(e, t)}` }))
|
||||
} finally {
|
||||
setBusyJobId(null)
|
||||
}
|
||||
@@ -1334,7 +1335,7 @@ export function HostBackup() {
|
||||
<div className="font-medium">{t("backup.archives.remoteQueryWarning")}</div>
|
||||
{remoteArchivesResp.errors.map((e, i) => (
|
||||
<div key={i} className="font-mono break-all">
|
||||
{e.backend}/{e.repo_name}: {e.error}
|
||||
{e.backend}/{e.repo_name}: {hostBackupErrorText(e.error, t)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1761,7 +1762,7 @@ function InspectModal({
|
||||
setImportFile(null)
|
||||
setImportPath("")
|
||||
} catch (e) {
|
||||
setImportError(e instanceof Error ? e.message : String(e))
|
||||
setImportError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
@@ -1812,7 +1813,7 @@ function InspectModal({
|
||||
: undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
setRestoreError(e instanceof Error ? e.message : String(e))
|
||||
setRestoreError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setRestorePreparing(false)
|
||||
}
|
||||
@@ -1841,7 +1842,7 @@ function InspectModal({
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
} catch (e) {
|
||||
setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
// The download itself runs in the browser's network stack;
|
||||
// we just initiated it. Clear the spinner immediately.
|
||||
@@ -1914,7 +1915,7 @@ function InspectModal({
|
||||
document.body.removeChild(a)
|
||||
setExportTask(null)
|
||||
} catch (e) {
|
||||
setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
@@ -1962,7 +1963,7 @@ function InspectModal({
|
||||
setShowDeleteArchiveConfirm(false)
|
||||
if (onDeleted) onDeleted(); else onClose()
|
||||
} catch (e) {
|
||||
setError(t("backup.errors.deleteFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setError(t("backup.errors.deleteFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
setDeletingArchive(false)
|
||||
}
|
||||
@@ -1989,7 +1990,7 @@ function InspectModal({
|
||||
) as NonNullable<typeof instructions>
|
||||
setInstructions(res)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setFetchingInstructions(false)
|
||||
}
|
||||
@@ -2021,7 +2022,7 @@ function InspectModal({
|
||||
)
|
||||
setReport(res)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("backup.errors.preflightFailed"))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
@@ -2164,7 +2165,7 @@ function InspectModal({
|
||||
<span className="text-muted-foreground">— {exportTask.message}</span>
|
||||
</div>
|
||||
{exportTask.state === "failed" && exportTask.error && (
|
||||
<div className="text-red-500 mt-1">{exportTask.error}</div>
|
||||
<div className="text-red-500 mt-1">{hostBackupErrorText(exportTask.error, t)}</div>
|
||||
)}
|
||||
{exportTask.state === "completed" && exportTask.size_bytes > 0 && (
|
||||
<div className="text-emerald-400">{t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}</div>
|
||||
@@ -2706,7 +2707,7 @@ function PreflightReportView({ report }: { report: PreflightReport }) {
|
||||
<Icon className={`h-3.5 w-3.5 ${color} flex-shrink-0 mt-0.5`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={`font-mono ${color}`}>{c.id}</span>
|
||||
<span className="text-muted-foreground ml-2">{c.message}</span>
|
||||
<span className="text-muted-foreground ml-2">{hostBackupErrorText(c.message, t)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -3326,9 +3327,9 @@ function CreateJobDialog({
|
||||
} catch (e) {
|
||||
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
|
||||
const detail = err.body?.tool_output
|
||||
? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: err.message
|
||||
setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
|
||||
? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: hostBackupErrorText(err, t)
|
||||
setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) }))
|
||||
setPbsImportBusy(false)
|
||||
setSubmitting(false)
|
||||
return
|
||||
@@ -3391,7 +3392,7 @@ function CreateJobDialog({
|
||||
})
|
||||
onCreated()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -3764,7 +3765,7 @@ function CreateJobDialog({
|
||||
</>
|
||||
) : (
|
||||
<div className="text-red-500">
|
||||
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {calendarPreview.error}
|
||||
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {hostBackupErrorText(calendarPreview.error, t)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -4696,9 +4697,9 @@ function ManualBackupDialog({
|
||||
} catch (e) {
|
||||
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
|
||||
const detail = err.body?.tool_output
|
||||
? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: err.message
|
||||
setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
|
||||
? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
||||
: hostBackupErrorText(err, t)
|
||||
setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) }))
|
||||
setPbsImportBusy(false)
|
||||
setSubmitting(false)
|
||||
return
|
||||
@@ -4738,7 +4739,7 @@ function ManualBackupDialog({
|
||||
)
|
||||
onLaunched(resp.job_id)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -5542,7 +5543,7 @@ function DestinationsSection({
|
||||
await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -5554,7 +5555,7 @@ function DestinationsSection({
|
||||
await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -5571,7 +5572,7 @@ function DestinationsSection({
|
||||
)
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -5599,7 +5600,7 @@ function DestinationsSection({
|
||||
})
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -6042,7 +6043,7 @@ function DestinationRow({
|
||||
|
||||
{capacity?.error && (
|
||||
<p className="mt-3 text-[11px] text-muted-foreground italic">
|
||||
{t("backup.destinations.capacityUnavailable", { error: capacity.error })}
|
||||
{t("backup.destinations.capacityUnavailable", { error: hostBackupErrorText(capacity.error, t) })}
|
||||
</p>
|
||||
)}
|
||||
{!capacity && (
|
||||
@@ -6341,7 +6342,7 @@ function AddDestinationDialog({
|
||||
)
|
||||
setGeneratedKey(resp)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setGeneratingKey(false)
|
||||
}
|
||||
@@ -6405,7 +6406,7 @@ function AddDestinationDialog({
|
||||
}
|
||||
onSaved(savedRepo)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -6756,7 +6757,7 @@ function UsbPicker({
|
||||
mutate()
|
||||
if (resp?.mountpoint) onPick(resp.mountpoint)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -6784,7 +6785,7 @@ function UsbPicker({
|
||||
setFormatTyped("")
|
||||
if (resp?.mountpoint) onPick(resp.mountpoint)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7001,7 +7002,7 @@ function ExtraPathsSection() {
|
||||
setNewPath("")
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -7014,7 +7015,7 @@ function ExtraPathsSection() {
|
||||
await fetchApi(`/api/host-backups/extra-paths?path=${encodeURIComponent(p)}`, { method: "DELETE" })
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyPath(null)
|
||||
}
|
||||
@@ -7180,7 +7181,7 @@ function UsbDrivesSection() {
|
||||
})
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7198,7 +7199,7 @@ function UsbDrivesSection() {
|
||||
})
|
||||
mutate()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7222,7 +7223,7 @@ function UsbDrivesSection() {
|
||||
setFormatTarget(null)
|
||||
setFormatTyped("")
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
@@ -7508,7 +7509,7 @@ function JobDetailModal({
|
||||
setRunning(false)
|
||||
setRunBaseline(null)
|
||||
setRunBaselineLogPath(null)
|
||||
setActionError(t("backup.errors.runFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setActionError(t("backup.errors.runFailed", { error: hostBackupErrorText(e, t) }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7522,7 +7523,7 @@ function JobDetailModal({
|
||||
await refetch()
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setActionError(t("backup.errors.toggleFailed", { error: e instanceof Error ? e.message : String(e) }))
|
||||
setActionError(t("backup.errors.toggleFailed", { error: hostBackupErrorText(e, t) }))
|
||||
} finally {
|
||||
setBusy("")
|
||||
}
|
||||
@@ -8155,7 +8156,7 @@ function PbsKeyfileRecoveryDialog({
|
||||
})
|
||||
onRecovered()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setError(hostBackupErrorText(e, t))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -8342,7 +8343,7 @@ function ArchiveContentsModal({
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((r: any) => setData(r))
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.catch((e) => setError(hostBackupErrorText(e, t)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [open, source, repo_name, snapshot, path])
|
||||
|
||||
@@ -8571,7 +8572,7 @@ function PlanSummary({ plan }: { plan: any }) {
|
||||
<div className="text-[10px] uppercase tracking-wider text-red-400 mb-1">{t("backup.plan.blockers")}</div>
|
||||
<ul className="space-y-0.5 ml-4 list-disc">
|
||||
{blockers.map((b, i) => (
|
||||
<li key={i} className="text-red-400">{typeof b === "string" ? b : (b.message || JSON.stringify(b))}</li>
|
||||
<li key={i} className="text-red-400">{hostBackupErrorText(typeof b === "string" ? b : (b.message || JSON.stringify(b)), t)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -8581,7 +8582,7 @@ function PlanSummary({ plan }: { plan: any }) {
|
||||
<div className="text-[10px] uppercase tracking-wider text-amber-400 mb-1">{t("backup.plan.warnings")}</div>
|
||||
<ul className="space-y-0.5 ml-4 list-disc">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={i} className="text-amber-400">{typeof w === "string" ? w : (w.message || JSON.stringify(w))}</li>
|
||||
<li key={i} className="text-amber-400">{hostBackupErrorText(typeof w === "string" ? w : (w.message || JSON.stringify(w)), t)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -8611,7 +8612,7 @@ function StorageSection({ storage }: { storage: any }) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<code className="font-mono">{e.name || e.storage || `entry ${i}`}</code>
|
||||
{e.type && <span className="text-muted-foreground ml-2">{e.type}</span>}
|
||||
{e.message && <div className="text-muted-foreground text-[11px]">{e.message}</div>}
|
||||
{e.message && <div className="text-muted-foreground text-[11px]">{hostBackupErrorText(e.message, t)}</div>}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
export type HostUpdateJob = {
|
||||
id: string
|
||||
state: "starting" | "running" | "attention" | "finalizing" | "complete"
|
||||
exit_code: number | null
|
||||
}
|
||||
|
||||
export function HostUpdateJobs({ open, activeId, onResume, onJobs, onSettled }: {
|
||||
open: boolean
|
||||
activeId?: string
|
||||
onResume: (job: HostUpdateJob) => void
|
||||
onJobs: (jobs: HostUpdateJob[]) => void
|
||||
onSettled: () => void
|
||||
}) {
|
||||
const t = useT()
|
||||
const [jobs, setJobs] = useState<HostUpdateJob[]>([])
|
||||
const [hidden, setHidden] = useState<Set<string>>(new Set())
|
||||
const callbacks = useRef({ onJobs, onSettled })
|
||||
callbacks.current = { onJobs, onSettled }
|
||||
const observed = useRef(new Set<string>())
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let closed = false
|
||||
const read = async () => {
|
||||
try {
|
||||
const data = await fetchApi<{ jobs: HostUpdateJob[] }>("/api/host-update-jobs")
|
||||
if (closed) return
|
||||
const next = Array.isArray(data.jobs) ? data.jobs : []
|
||||
setJobs(next)
|
||||
callbacks.current.onJobs(next)
|
||||
for (const job of next) {
|
||||
if (job.state === "complete" && !observed.current.has(job.id)) {
|
||||
observed.current.add(job.id)
|
||||
callbacks.current.onSettled()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Authentication recovery belongs to fetchApi. Never retry a start.
|
||||
}
|
||||
}
|
||||
void read()
|
||||
const timer = setInterval(() => { void read() }, 5000)
|
||||
return () => { closed = true; clearInterval(timer) }
|
||||
}, [open])
|
||||
const visible = jobs.filter(job => job.id !== activeId && !(job.state === "complete" && hidden.has(job.id)))
|
||||
if (!visible.length) return null
|
||||
return <div className="space-y-2" role="status">
|
||||
{visible.map(job => <div key={job.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-violet-500/30 bg-violet-500/5 p-3">
|
||||
<p className="text-sm">{t(`healthStatus.hostUpdate.${job.state === "complete" ? "finished" : job.state === "attention" ? "attention" : "running"}`)}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => onResume(job)}>
|
||||
{t(job.state === "complete" ? "healthStatus.hostUpdate.viewResult" : "healthStatus.hostUpdate.view")}
|
||||
</Button>
|
||||
{job.state === "complete" && <Button size="sm" variant="ghost" onClick={() => setHidden(previous => new Set([...previous, job.id]))}>
|
||||
{t("healthStatus.hostUpdate.hideResult")}
|
||||
</Button>}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||
import { Button } from "./ui/button"
|
||||
import { getAuthToken } from "../lib/api-config"
|
||||
import { scriptCompletion } from "../lib/script-completion"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import "xterm/css/xterm.css"
|
||||
|
||||
export function HostUpdateResult({ jobId, getApiUrl, onClose }: {
|
||||
jobId: string | null
|
||||
getApiUrl: (path: string) => string
|
||||
onClose: () => void
|
||||
}) {
|
||||
const t = useT()
|
||||
const container = useRef<HTMLDivElement>(null)
|
||||
const [revision, setRevision] = useState(0)
|
||||
const [exitCode, setExitCode] = useState<number | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!jobId) return
|
||||
const controller = new AbortController()
|
||||
let terminal: import("xterm").Terminal | undefined
|
||||
let observer: ResizeObserver | undefined
|
||||
let disposed = false
|
||||
setExitCode(null); setFailed(false)
|
||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||
void (async () => {
|
||||
try {
|
||||
const token = getAuthToken()
|
||||
const response = await fetch(getApiUrl(`/api/host-update-jobs/${encodeURIComponent(jobId)}/result`), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) throw Error("unavailable")
|
||||
const value = await response.json()
|
||||
if (value.state !== "complete" || !Number.isSafeInteger(value.exit_code)
|
||||
|| typeof value.text !== "string" || value.text.length > 256 * 1024
|
||||
|| typeof value.truncated !== "boolean") throw Error("invalid result")
|
||||
const [{ Terminal }, { FitAddon }] = await Promise.all([import("xterm"), import("xterm-addon-fit")])
|
||||
if (controller.signal.aborted || !container.current) return
|
||||
terminal = new Terminal({ disableStdin: true, convertEol: true, fontSize: 12,
|
||||
screenReaderMode: true, theme: { background: "#000000", foreground: "#dddddd" } })
|
||||
const fit = new FitAddon()
|
||||
terminal.loadAddon(fit); terminal.open(container.current); fit.fit()
|
||||
if (value.truncated) terminal.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
|
||||
terminal.write(value.text)
|
||||
observer = new ResizeObserver(() => { if (!controller.signal.aborted) fit.fit() })
|
||||
observer.observe(container.current)
|
||||
setExitCode(value.exit_code)
|
||||
} catch {
|
||||
if (!disposed) setFailed(true)
|
||||
} finally { clearTimeout(timeout) }
|
||||
})()
|
||||
return () => { disposed = true; controller.abort(); clearTimeout(timeout); observer?.disconnect(); terminal?.dispose() }
|
||||
}, [jobId, getApiUrl, revision, t])
|
||||
const kind = exitCode === null ? null : scriptCompletion(exitCode, 130).kind
|
||||
return <Dialog open={jobId !== null} onOpenChange={open => { if (!open) onClose() }}>
|
||||
<DialogContent className="max-w-4xl w-[95vw] max-h-[85vh] overflow-y-auto p-4 sm:p-5">
|
||||
<DialogHeader><DialogTitle>{t("healthStatus.hostUpdate.resultTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("healthStatus.hostUpdate.resultReadOnly")}</DialogDescription></DialogHeader>
|
||||
{failed && <div role="alert" className="text-sm text-amber-500">
|
||||
<p>{t("healthStatus.hostUpdate.resultUnavailable")}</p>
|
||||
<Button size="sm" variant="outline" className="mt-3" onClick={() => setRevision(value => value + 1)}>{t("healthStatus.hostUpdate.retryResult")}</Button>
|
||||
</div>}
|
||||
<div ref={container} className="h-[45vh] min-h-[180px] bg-black rounded overflow-hidden" />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p role="status" className={`text-xs ${exitCode === 0 ? "text-green-500" : "text-muted-foreground"}`}>
|
||||
{kind === null ? t(failed ? "healthStatus.hostUpdate.resultUnavailable" : "healthStatus.hostUpdate.loadingResult")
|
||||
: kind === "cancelled" ? t("scriptTerminal.cancelled") : exitCode === 0
|
||||
? t("scriptTerminal.completedSuccessfully") : t("scriptTerminal.completedWithError", { code: exitCode })}
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>{t("healthStatus.hostUpdate.closeResult")}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react"
|
||||
import * as Confirm from "@radix-ui/react-alert-dialog"
|
||||
import { Button } from "./ui/button"
|
||||
import { getAuthToken } from "@/lib/api-config"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { CheckCircle2, AlertTriangle, Info, ChevronRight } from "lucide-react"
|
||||
|
||||
function headers() {
|
||||
const token = getAuthToken()
|
||||
return { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }
|
||||
}
|
||||
type Url = (path: string) => string
|
||||
type Context = { hostname: string; boot_id: string; allowed: boolean; reason: string }
|
||||
const compact = "h-10 sm:h-[34px] px-3 text-xs"
|
||||
|
||||
export function RestartAfterUpdate({ getApiUrl, disabled, unavailable = false, label, detail, icon }: {
|
||||
getApiUrl: Url; disabled: boolean; unavailable?: boolean; label: string; detail: string; icon: ReactNode
|
||||
}) {
|
||||
const t = useT()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [context, setContext] = useState<Context | null>(null)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const sending = useRef(false)
|
||||
const alive = useRef(true)
|
||||
const postRequest = useRef<AbortController | null>(null)
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; postRequest.current?.abort() } }, [])
|
||||
useEffect(() => {
|
||||
let disposed = false, reading = false
|
||||
let active: AbortController | undefined
|
||||
setContext(null); setSubmitted(false); setError(false); sending.current = false
|
||||
const read = async () => {
|
||||
if (reading) return
|
||||
reading = true
|
||||
const controller = new AbortController(); active = controller
|
||||
const timeout = setTimeout(() => controller.abort(), 6000)
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/health/reboot-context"), { headers: headers(), signal: controller.signal })
|
||||
if (!response.ok) throw Error("unavailable")
|
||||
const value: Context = await response.json()
|
||||
if (!value || typeof value.hostname !== "string" || typeof value.allowed !== "boolean" || typeof value.boot_id !== "string" || !value.boot_id) throw Error("invalid")
|
||||
if (!disposed) { setContext(value); setError(false) }
|
||||
} catch { if (!disposed) { setContext(null); setError(true) } }
|
||||
finally { clearTimeout(timeout); reading = false }
|
||||
}
|
||||
void read()
|
||||
// A retained health snapshot must never grant permission after reconnect
|
||||
// or remount. This cheap read does not run APT or a full health check.
|
||||
const interval = setInterval(read, open ? 2000 : 30000)
|
||||
return () => { disposed = true; clearInterval(interval); active?.abort() }
|
||||
}, [open, getApiUrl])
|
||||
useEffect(() => {
|
||||
if (!accepted) return
|
||||
let disposed = false, reading = false
|
||||
const controller = new AbortController()
|
||||
const interval = setInterval(async () => {
|
||||
if (reading) return
|
||||
reading = true
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/health/reboot-after-update/status"), { headers: headers(), signal: controller.signal })
|
||||
if (!response.ok) return
|
||||
const value = await response.json()
|
||||
if (!disposed && (value.state === "failed" || value.state === "cancelled")) { setAccepted(false); setError(true) }
|
||||
} catch { /* Loss of connection does not prove reboot completion. */ }
|
||||
finally { reading = false }
|
||||
}, 2000)
|
||||
const timeout = setTimeout(() => { clearInterval(interval); controller.abort() }, 10000)
|
||||
return () => { disposed = true; controller.abort(); clearInterval(interval); clearTimeout(timeout) }
|
||||
}, [accepted, getApiUrl])
|
||||
const confirm = async () => {
|
||||
if (disabled || unavailable || !context?.allowed || sending.current || submitted) return
|
||||
sending.current = true; setSubmitted(true); setPending(true); setError(false)
|
||||
const controller = new AbortController(); postRequest.current = controller
|
||||
const timeout = setTimeout(() => controller.abort(), 15000)
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/health/reboot-after-update"), {
|
||||
method: "POST", headers: headers(), body: JSON.stringify({ confirm: true, hostname: context.hostname, boot_id: context.boot_id }), signal: controller.signal,
|
||||
})
|
||||
if (response.status !== 202 || (await response.json()).accepted !== true) throw Error("unconfirmed")
|
||||
if (alive.current) { setAccepted(true); setOpen(false) }
|
||||
} catch { if (alive.current) setError(true) }
|
||||
finally { clearTimeout(timeout); postRequest.current = null; if (alive.current) setPending(false) }
|
||||
}
|
||||
return <section className="py-1.5 px-2 sm:px-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0 text-[10px] sm:text-xs"><span className="shrink-0">{icon}</span>
|
||||
<div><span className="font-medium mr-2">{label}</span><span className="text-muted-foreground break-words">{detail}</span></div></div>
|
||||
<Button variant="outline" className={`${compact} self-start sm:shrink-0 border-amber-500/40 text-amber-500`}
|
||||
disabled={disabled || unavailable || !context?.allowed || pending || accepted} onClick={() => setOpen(true)}>{t("healthStatus.restart.button")}</Button>
|
||||
</div>
|
||||
{disabled && <p className="mt-2 text-xs text-muted-foreground">{t("healthStatus.restart.blocked")}</p>}
|
||||
{accepted && <p role="status" className="mt-2 text-xs text-blue-500">{t("healthStatus.restart.accepted")}</p>}
|
||||
{error && !open && <p role="alert" className="mt-2 text-xs text-amber-500">{t("healthStatus.restart.unconfirmed")}</p>}
|
||||
<Confirm.Root open={open} onOpenChange={setOpen}><Confirm.Portal>
|
||||
<Confirm.Overlay className="fixed inset-0 bg-black/75 z-[70]" />
|
||||
<Confirm.Content className="fixed z-[80] w-[92vw] max-w-[520px] rounded-lg border bg-card text-foreground p-5 shadow-lg" style={{ top: "50%", left: "50%", transform: "translate(-50%,-50%)" }}>
|
||||
<Confirm.Title className="text-base font-semibold">{t("healthStatus.restart.title", { server: context?.hostname ?? "…" })}</Confirm.Title>
|
||||
<Confirm.Description className="mt-3 text-sm text-muted-foreground">{t("healthStatus.restart.impact")}</Confirm.Description>
|
||||
{(disabled || context?.reason === "busy") && <p className="mt-3 text-xs text-amber-500">{t("healthStatus.restart.blocked")}</p>}
|
||||
{context?.reason === "current" && <p className="mt-3 text-xs text-muted-foreground">{t("healthStatus.hostUpdate.noReboot")}</p>}
|
||||
{(error || context?.reason === "unknown") && <p role="alert" className="mt-3 text-xs text-amber-500">{t("healthStatus.restart.unconfirmed")}</p>}
|
||||
<div className="mt-5 flex justify-end gap-2.5">
|
||||
<Confirm.Cancel asChild><Button variant="outline" className={compact} disabled={pending}>{t("healthStatus.restart.cancel")}</Button></Confirm.Cancel>
|
||||
<Confirm.Action asChild><Button variant="destructive" className={compact} disabled={disabled || unavailable || !context?.allowed || pending || submitted}
|
||||
onClick={event => { event.preventDefault(); void confirm() }}>{t("healthStatus.restart.confirm")}</Button></Confirm.Action>
|
||||
</div>
|
||||
</Confirm.Content>
|
||||
</Confirm.Portal></Confirm.Root>
|
||||
</section>
|
||||
}
|
||||
|
||||
type Versions = { installed: string | null; available: string | null; state: "available" | "current" | "unknown"; checked_at: string | null; channel: "stable" | "beta" }
|
||||
export function ProxMenuxVersionCard({ getApiUrl }: { getApiUrl: Url }) {
|
||||
const t = useT()
|
||||
const [value, setValue] = useState<Versions | null>(null)
|
||||
useEffect(() => {
|
||||
let disposed = false, reading = false
|
||||
let active: AbortController | undefined
|
||||
const read = async () => {
|
||||
if (reading || document.visibilityState !== "visible") return
|
||||
reading = true
|
||||
const controller = new AbortController(); active = controller
|
||||
const timeout = setTimeout(() => controller.abort(), 6000)
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/health/proxmenux-version"), { headers: headers(), signal: controller.signal })
|
||||
if (!response.ok) throw Error("unavailable")
|
||||
const data: Versions = await response.json()
|
||||
if (!["available", "current", "unknown"].includes(data?.state)) throw Error("invalid")
|
||||
const validVersion = (version: unknown) => typeof version === "string" && /^\d+(?:\.\d+){1,5}$/.test(version)
|
||||
if (!["stable", "beta"].includes(data.channel) ||
|
||||
(data.installed !== null && !validVersion(data.installed)) ||
|
||||
(data.available !== null && !validVersion(data.available)) ||
|
||||
(data.state !== "unknown" && (!validVersion(data.installed) || !validVersion(data.available) || !data.checked_at)) ||
|
||||
(data.checked_at !== null && !Number.isFinite(Date.parse(data.checked_at)))) throw Error("invalid")
|
||||
if (!disposed) setValue(data)
|
||||
} catch { if (!disposed) setValue(previous => previous ? { ...previous, state: "unknown" } : null) }
|
||||
finally { clearTimeout(timeout); reading = false }
|
||||
}
|
||||
void read()
|
||||
const interval = setInterval(read, 300000)
|
||||
window.addEventListener("focus", read); document.addEventListener("visibilitychange", read)
|
||||
return () => { disposed = true; active?.abort(); clearInterval(interval); window.removeEventListener("focus", read); document.removeEventListener("visibilitychange", read) }
|
||||
}, [getApiUrl])
|
||||
const state = value?.state ?? "unknown"
|
||||
const versions = <dl className="grid grid-cols-2 gap-4">
|
||||
<div><dt className="text-muted-foreground mb-1">{t("healthStatus.versions.installed")}</dt><dd className="font-mono font-semibold">{value?.installed ?? t("healthStatus.versions.unverified")}</dd></div>
|
||||
<div><dt className="text-muted-foreground mb-1">{t(state === "unknown" && value?.available ? "healthStatus.versions.lastKnown" : "healthStatus.versions.latest")}</dt><dd className="font-mono font-semibold">{value?.available ?? t("healthStatus.versions.unverified")}</dd></div>
|
||||
</dl>
|
||||
const metadata = <>
|
||||
{value && <p className="mt-3 text-muted-foreground">{t(`healthStatus.versions.${value.channel}`)}</p>}
|
||||
{value?.checked_at && <p className="mt-1 text-muted-foreground">{t("healthStatus.versions.checked", { time: new Date(value.checked_at).toLocaleString() })}</p>}
|
||||
</>
|
||||
if (state !== "available") return <details aria-label={t("healthStatus.versions.title")} className="group text-[10px] sm:text-xs">
|
||||
<summary className="flex items-start gap-2 py-1.5 px-2 sm:px-3 rounded-md hover:bg-muted/40 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
|
||||
{state === "current" ? <CheckCircle2 aria-hidden="true" className="h-3 w-3 sm:h-3.5 sm:w-3.5 shrink-0 text-green-500" /> : <AlertTriangle aria-hidden="true" className="h-3 w-3 sm:h-3.5 sm:w-3.5 shrink-0 text-amber-500" />}
|
||||
<span className="font-medium shrink-0">ProxMenux</span>
|
||||
<span className={`flex-1 min-w-0 break-words ${state === "current" ? "text-muted-foreground" : "text-amber-500"}`}>
|
||||
{state === "current" ? t("healthStatus.versions.currentVersion", { version: value!.installed! }) : t("healthStatus.versions.unknown")}
|
||||
</span>
|
||||
<ChevronRight aria-hidden="true" className="h-3 w-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
|
||||
</summary>
|
||||
<div className="ml-7 mr-2 sm:mr-3 pb-2">
|
||||
{state === "unknown" && versions}
|
||||
{metadata}
|
||||
</div>
|
||||
</details>
|
||||
return <section aria-label={t("healthStatus.versions.title")} className="mt-3 mx-2 sm:mx-3 rounded-lg border bg-muted/10 p-3 text-[10px] sm:text-xs">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3"><h3 className="font-semibold">{t("healthStatus.versions.title")}</h3>
|
||||
<span className="flex items-center gap-1.5 text-blue-500"><Info aria-hidden="true" className="h-3.5 w-3.5" />{t("healthStatus.versions.available")}</span></div>
|
||||
{versions}
|
||||
{metadata}
|
||||
</section>
|
||||
}
|
||||
@@ -255,6 +255,8 @@ export function Login({ onLogin }: LoginProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Password managers read the field's siblings and skip it when they mention a backup code. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="totp-code" className="text-sm">
|
||||
{t("login.authenticationCode")}
|
||||
@@ -271,6 +273,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("login.backupCodeHint")}
|
||||
</p>
|
||||
|
||||
@@ -32,11 +32,12 @@ import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config"
|
||||
import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached, subscribeLxcApps } from "../lib/lxc-apps-cache"
|
||||
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
|
||||
import { getCategoryLabel } from "../lib/category-label"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { localizeAppEditorError } from "../lib/lxc-app-editor-errors"
|
||||
|
||||
// installed_via is optional now — an empty value means "register only,
|
||||
// no version tracking, no warnings, just a clickable link". Docker
|
||||
@@ -125,13 +126,14 @@ interface PortDetail {
|
||||
interface NativeDiscovery {
|
||||
vmid: number
|
||||
status: "not_scanned" | "complete" | "partial" | "error" | "busy"
|
||||
issues?: string[]
|
||||
stale: boolean
|
||||
candidates: DetectedApp[]
|
||||
}
|
||||
|
||||
interface DetectedApp {
|
||||
discovery_id?: string
|
||||
source?: "native_metadata"
|
||||
source?: "native_metadata" | "docker_metadata"
|
||||
installed_version?: string | null
|
||||
evidence?: Array<{ kind: string; value: string }>
|
||||
slug: string
|
||||
@@ -355,6 +357,7 @@ interface ManagedAppInfo {
|
||||
|
||||
interface Props {
|
||||
vmid: number
|
||||
cacheRevision?: number
|
||||
ctIp?: string | null
|
||||
onChange?: () => void
|
||||
managed?: ManagedAppInfo | null
|
||||
@@ -394,10 +397,18 @@ const SELFHST_THEME_LOGOS: Record<string, { lightTheme: string; darkTheme: strin
|
||||
lightTheme: `${SELFHST_WEBP_BASE}/frigate-dark.webp`,
|
||||
darkTheme: `${SELFHST_WEBP_BASE}/frigate-light.webp`,
|
||||
},
|
||||
"llama-cpp": {
|
||||
lightTheme: `${SELFHST_WEBP_BASE}/llama-cpp.webp`,
|
||||
darkTheme: `${SELFHST_WEBP_BASE}/llama-cpp-light.webp`,
|
||||
},
|
||||
portainer: {
|
||||
lightTheme: `${SELFHST_WEBP_BASE}/portainer-dark.webp`,
|
||||
darkTheme: `${SELFHST_WEBP_BASE}/portainer-light.webp`,
|
||||
},
|
||||
tailscale: {
|
||||
lightTheme: `${SELFHST_WEBP_BASE}/tailscale.webp`,
|
||||
darkTheme: `${SELFHST_WEBP_BASE}/tailscale-light.webp`,
|
||||
},
|
||||
vaultwarden: {
|
||||
lightTheme: `${SELFHST_WEBP_BASE}/vaultwarden.webp`,
|
||||
darkTheme: `${SELFHST_WEBP_BASE}/vaultwarden-light.webp`,
|
||||
@@ -406,11 +417,44 @@ const SELFHST_THEME_LOGOS: Record<string, { lightTheme: string; darkTheme: strin
|
||||
|
||||
function selfhstThemeLogos(src: string) {
|
||||
if (!src.toLowerCase().includes("cdn.jsdelivr.net/gh/selfhst/icons")) return null
|
||||
const match = src.toLowerCase().match(/\/(frigate|portainer|vaultwarden)(?:-(?:dark|light))?\.webp(?:[?#].*)?$/)
|
||||
const match = src.toLowerCase().match(/\/(frigate|llama-cpp|portainer|tailscale|vaultwarden)(?:-(?:dark|light))?\.webp(?:[?#].*)?$/)
|
||||
return match ? SELFHST_THEME_LOGOS[match[1]] : null
|
||||
}
|
||||
|
||||
const LOCAL_LOGO_CACHE_PATH = /^\/api\/vms\/[1-9]\d*\/apps\/logo-cache\/[a-f0-9]{32}$/
|
||||
|
||||
function CachedLocalLogo({ src, className }: { src: string; className: string }) {
|
||||
const [image, setImage] = useState<{ src: string; url: string } | null>(null)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
let active = true
|
||||
let url: string | null = null
|
||||
const token = getAuthToken()
|
||||
fetch(getApiUrl(src), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
signal: controller.signal, cache: "no-store",
|
||||
}).then(async response => {
|
||||
if (!response.ok) throw new Error("cached icon unavailable")
|
||||
const blob = await response.blob()
|
||||
const mime = blob.type.split(";", 1)[0].trim().toLowerCase()
|
||||
if (!["image/png", "image/svg+xml"].includes(mime) || blob.size > 262144) {
|
||||
throw new Error("invalid cached icon")
|
||||
}
|
||||
if (!active) return
|
||||
url = URL.createObjectURL(blob)
|
||||
setImage({ src, url })
|
||||
}).catch(() => { if (active) setImage(null) })
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
}
|
||||
}, [src])
|
||||
return image?.src === src ? <img src={image.url} alt="" className={className} /> : null
|
||||
}
|
||||
|
||||
export function ThemeAwareLogo({ src, className }: { src: string; className: string }) {
|
||||
if (LOCAL_LOGO_CACHE_PATH.test(src)) return <CachedLocalLogo src={src} className={className} />
|
||||
const themed = selfhstThemeLogos(src)
|
||||
const hideBroken = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
e.currentTarget.style.display = "none"
|
||||
@@ -463,7 +507,7 @@ function parseArgvInput(value: string): string[] {
|
||||
return value.split(",").map((item) => item.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: Props) {
|
||||
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, cacheRevision = 0 }: Props) {
|
||||
const t = useT()
|
||||
const isLightTheme = useIsLightTheme()
|
||||
// Seed from `initialData` first, then fall back to the shared cache
|
||||
@@ -476,6 +520,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
|
||||
const [nativeDiscovery, setNativeDiscovery] = useState<NativeDiscovery | null>(null)
|
||||
const nativeRequest = useRef(0)
|
||||
const [dockerDiscovery, setDockerDiscovery] = useState<NativeDiscovery | null>(null)
|
||||
const dockerRequest = useRef(0)
|
||||
const targetRef = useRef(vmid)
|
||||
targetRef.current = vmid
|
||||
const targetGeneration = useRef(0)
|
||||
@@ -485,26 +531,64 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
targetGeneration.current += 1
|
||||
}
|
||||
useEffect(() => {
|
||||
const generation = targetGeneration.current
|
||||
const request = ++nativeRequest.current
|
||||
setNativeDiscovery(null)
|
||||
setDockerDiscovery(null)
|
||||
setEditing(null)
|
||||
setDetectionNotice(null)
|
||||
setSearchingApplications(false)
|
||||
setSaving(false)
|
||||
return () => { targetGeneration.current += 1 }
|
||||
}, [vmid, managed])
|
||||
useEffect(() => {
|
||||
const generation = targetGeneration.current
|
||||
const request = nativeRequest.current
|
||||
let cancelled = false
|
||||
if (managed) return
|
||||
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`).then(result => {
|
||||
if (targetRef.current === vmid && targetGeneration.current === generation
|
||||
if (!cancelled && targetRef.current === vmid && targetGeneration.current === generation
|
||||
&& nativeRequest.current === request) setNativeDiscovery(result)
|
||||
}).catch(() => { /* cached metadata is optional; never initiate a scan */ })
|
||||
return () => { targetGeneration.current += 1 }
|
||||
return () => { cancelled = true }
|
||||
}, [vmid, managed, cacheRevision])
|
||||
useEffect(() => {
|
||||
const generation = targetGeneration.current
|
||||
const request = dockerRequest.current
|
||||
let cancelled = false
|
||||
if (managed) return
|
||||
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`).then(result => {
|
||||
if (!cancelled && targetRef.current === vmid && targetGeneration.current === generation
|
||||
&& dockerRequest.current === request) setDockerDiscovery(result)
|
||||
}).catch(() => { /* optional cached result; never initiate a scan */ })
|
||||
return () => { cancelled = true }
|
||||
}, [vmid, managed])
|
||||
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchingApplications, setSearchingApplications] = useState(false)
|
||||
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
|
||||
// Editor state
|
||||
const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig; targetVmid: number } | null>(null)
|
||||
const [editing, setEditingState] = useState<{ appId: string | null; draft: AppConfig; targetVmid: number } | null>(null)
|
||||
const editingRef = useRef(editing)
|
||||
editingRef.current = editing
|
||||
const [iconEndpoint, setIconEndpoint] = useState("")
|
||||
const [discoveringLocalLogo, setDiscoveringLocalLogo] = useState(false)
|
||||
const iconRequest = useRef(0)
|
||||
const iconAbort = useRef<AbortController | null>(null)
|
||||
const setEditing: typeof setEditingState = value => {
|
||||
// Invalidate synchronously, including before React commits a batched edit.
|
||||
iconRequest.current += 1
|
||||
iconAbort.current?.abort()
|
||||
setDiscoveringLocalLogo(false)
|
||||
setEditingState(value)
|
||||
}
|
||||
useEffect(() => {
|
||||
iconRequest.current += 1
|
||||
iconAbort.current?.abort()
|
||||
setDiscoveringLocalLogo(false)
|
||||
return () => {
|
||||
iconRequest.current += 1
|
||||
iconAbort.current?.abort()
|
||||
}
|
||||
}, [editing, vmid, iconEndpoint])
|
||||
const [binaryArgsInput, setBinaryArgsInput] = useState("")
|
||||
const [commandArgvInput, setCommandArgvInput] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -582,7 +666,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
if (isCurrent()) setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [vmid, managed])
|
||||
}, [vmid, managed, cacheRevision])
|
||||
|
||||
useEffect(() => {
|
||||
if (managed) return
|
||||
@@ -653,7 +737,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
if (lower.startsWith("registry unreachable")) {
|
||||
return t("vmLxc.appEditor.upstreamErrorRegistry")
|
||||
}
|
||||
return msg
|
||||
return localizeAppEditorError(msg, t)
|
||||
}
|
||||
|
||||
const editorOpen = !!editing && editing.targetVmid === vmid
|
||||
@@ -692,7 +776,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
// (`extras[]` from the backend). Both use the same DetectedApp
|
||||
// shape so the empty state renders them uniformly.
|
||||
const detectedList: DetectedApp[] = useMemo(() => {
|
||||
const out: DetectedApp[] = [...(nativeDiscovery?.vmid === vmid ? nativeDiscovery.candidates : [])]
|
||||
const out: DetectedApp[] = [...(nativeDiscovery?.vmid === vmid ? nativeDiscovery.candidates : []),
|
||||
...(dockerDiscovery?.vmid === vmid ? dockerDiscovery.candidates || [] : [])]
|
||||
if (!suggestions) return out
|
||||
// A ProxMenux OCI install has no helper slug — nothing was installed by a
|
||||
// community script — so its template id identifies it instead.
|
||||
@@ -718,7 +803,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [suggestions, nativeDiscovery, vmid])
|
||||
}, [suggestions, nativeDiscovery, dockerDiscovery, vmid])
|
||||
|
||||
// Registered slugs — used to filter the detection list down to
|
||||
// what the user hasn't already registered on this CT.
|
||||
@@ -791,6 +876,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
const searchInstalledApplications = async () => {
|
||||
const generation = targetGeneration.current
|
||||
const request = ++nativeRequest.current
|
||||
++dockerRequest.current
|
||||
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
|
||||
&& nativeRequest.current === request
|
||||
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
|
||||
@@ -804,9 +890,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
if (!isCurrent()) return
|
||||
setNativeDiscovery(native)
|
||||
if (native.status === "error" || native.status === "busy") {
|
||||
setDetectionNotice({ found: false, text: t("vmLxc.appEditor.nativeScanFailed") })
|
||||
const stopped = native.status === "error" && Array.isArray(native.issues)
|
||||
&& native.issues.includes("guest_stopped")
|
||||
setDetectionNotice({ found: false, text: t(stopped
|
||||
? "vmLxc.appEditor.nativeScanStopped" : "vmLxc.appEditor.nativeScanFailed") })
|
||||
return
|
||||
}
|
||||
let docker = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`, {
|
||||
method: "POST",
|
||||
})
|
||||
if (!isCurrent()) return
|
||||
setDockerDiscovery(docker)
|
||||
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
|
||||
method: "POST",
|
||||
})
|
||||
@@ -815,6 +909,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
native = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`)
|
||||
if (!isCurrent()) return
|
||||
setNativeDiscovery(native)
|
||||
docker = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`)
|
||||
if (!isCurrent()) return
|
||||
setDockerDiscovery(docker)
|
||||
setSuggestions(result)
|
||||
if (sidecar) setLxcAppsCached(vmid, sidecar, result)
|
||||
|
||||
@@ -823,11 +920,16 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
for (const item of result.extras || []) detected.add(item.slug)
|
||||
for (const item of result.docker_workloads || []) detected.add(item.slug)
|
||||
for (const item of native.candidates) detected.add(item.slug)
|
||||
for (const item of docker.candidates || []) detected.add(item.slug)
|
||||
const visible = [...detected].filter(
|
||||
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
|
||||
)
|
||||
const newCount = visible.filter((slug) => !before.has(slug)).length
|
||||
if (native.status === "partial") {
|
||||
if (docker.status === "error" || docker.status === "busy") {
|
||||
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.dockerScanFailed") })
|
||||
} else if (docker.status === "partial" || docker.stale) {
|
||||
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.dockerScanPartial") })
|
||||
} else if (native.status === "partial") {
|
||||
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.nativeScanPartial") })
|
||||
} else if (newCount === 1) {
|
||||
setDetectionNotice({ found: true, text: t("vmLxc.appEditor.oneNewApplicationDetected") })
|
||||
@@ -1119,7 +1221,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
}
|
||||
|
||||
const removeOne = async (appId: string) => {
|
||||
if (!confirm("Remove this application from the CT's App tab?")) return
|
||||
if (!confirm(t("vmLxc.appEditor.confirmRemove"))) return
|
||||
setBusyAppId(appId)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -1361,6 +1463,40 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
setDetectorTest(null)
|
||||
setEditing({ ...editing, draft: { ...draft, ...patch } })
|
||||
}
|
||||
const iconPorts = draft.ports.filter(entry => typeof entry.port === "number" && entry.port > 0 && entry.port <= 65535)
|
||||
const endpointKey = (entry: PortEntry) => `${entry.scheme || defaultSchemeFor(entry.port)}:${entry.port}`
|
||||
const selectedIconPort = iconPorts.find(entry => endpointKey(entry) === iconEndpoint) || iconPorts[0]
|
||||
const discoverLocalLogo = async () => {
|
||||
if (!selectedIconPort || discoveringLocalLogo) return
|
||||
const snapshot = editing
|
||||
const generation = targetGeneration.current
|
||||
const requestId = ++iconRequest.current
|
||||
const controller = new AbortController()
|
||||
iconAbort.current = controller
|
||||
const current = () => iconRequest.current === requestId && editingRef.current === snapshot
|
||||
&& targetRef.current === vmid && targetGeneration.current === generation
|
||||
setDiscoveringLocalLogo(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await fetchApi<{ logo_url: string }>(`/api/vms/${vmid}/apps/logo-discovery`, {
|
||||
method: "POST", signal: controller.signal,
|
||||
body: JSON.stringify({ port: selectedIconPort.port, scheme: selectedIconPort.scheme || defaultSchemeFor(selectedIconPort.port) }),
|
||||
})
|
||||
if (!current()) return
|
||||
if (!LOCAL_LOGO_CACHE_PATH.test(result.logo_url) || !result.logo_url.startsWith(`/api/vms/${vmid}/`)) {
|
||||
throw new Error("invalid icon response")
|
||||
}
|
||||
setEditing(previous => previous === snapshot
|
||||
? { ...previous, draft: { ...previous.draft, logo_url: result.logo_url } } : previous)
|
||||
} catch (error: unknown) {
|
||||
if (current()) {
|
||||
const code = (error as { body?: { code?: string } })?.body?.code
|
||||
setError(t(code === "local_logo_stopped" ? "vmLxc.appEditor.localLogoStopped" : "vmLxc.appEditor.localLogoUnavailable"))
|
||||
}
|
||||
} finally {
|
||||
if (current()) setDiscoveringLocalLogo(false)
|
||||
}
|
||||
}
|
||||
// Editing the Name auto-fills the Package field on packaged
|
||||
// methods when it's still empty. Rationale: 90% of the time the
|
||||
// dpkg/apk package name mirrors the friendly app name (jellyfin,
|
||||
@@ -1593,18 +1729,47 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
from https://selfh.st/icons); empty → no logo in the
|
||||
app card header. */}
|
||||
<div className="pt-2 border-t border-border/50">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label htmlFor="app-logo" className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t("vmLxc.appEditor.logoUrlLabel")} <span className="normal-case tracking-normal text-[10px] opacity-70">{t("vmLxc.appEditor.optionalSuffix")}</span>
|
||||
</Label>
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
className="h-8 px-2 text-xs"
|
||||
disabled={!selectedIconPort || discoveringLocalLogo || saving}
|
||||
onClick={discoverLocalLogo}>
|
||||
{discoveringLocalLogo ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Search className="h-3.5 w-3.5 mr-1" />}
|
||||
{t("vmLxc.appEditor.discoverLocalLogo")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
id="app-logo"
|
||||
type="url"
|
||||
type="text"
|
||||
value={draft.logo_url || ""}
|
||||
onChange={(e) => setField({ logo_url: e.target.value })}
|
||||
placeholder={t("vmLxc.appEditor.portLogoPlaceholder")}
|
||||
maxLength={512}
|
||||
className="text-sm mt-2 font-mono"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
{draft.logo_url && LOCAL_LOGO_CACHE_PATH.test(draft.logo_url) &&
|
||||
<ThemeAwareLogo src={draft.logo_url} className="h-8 w-8 shrink-0 object-contain" />}
|
||||
</div>
|
||||
{iconPorts.length > 1 && <select
|
||||
aria-label={t("vmLxc.appEditor.localLogoPort")}
|
||||
value={selectedIconPort ? endpointKey(selectedIconPort) : ""}
|
||||
onChange={event => {
|
||||
iconRequest.current += 1
|
||||
iconAbort.current?.abort()
|
||||
setDiscoveringLocalLogo(false)
|
||||
setIconEndpoint(event.target.value)
|
||||
}}
|
||||
className="mt-2 rounded border bg-background px-2 py-1 text-xs"
|
||||
>
|
||||
{iconPorts.map(entry => <option key={endpointKey(entry)} value={endpointKey(entry)}>
|
||||
{endpointKey(entry)}
|
||||
</option>)}
|
||||
</select>}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t("vmLxc.appEditor.localLogoHelp")}</p>
|
||||
</div>
|
||||
|
||||
{/* Web links — each port becomes a clickable link (built as
|
||||
@@ -1913,14 +2078,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
id="app-package"
|
||||
value={draft.package || ""}
|
||||
onChange={(e) => setField({ package: e.target.value })}
|
||||
placeholder={method === "dpkg" ? "e.g. jellyfin-server" : "e.g. tailscale"}
|
||||
placeholder={t("vmLxc.appEditor.packageExample", { package: method === "dpkg" ? "jellyfin-server" : "tailscale" })}
|
||||
/>
|
||||
<div className="text-[10px] text-muted-foreground mt-1">
|
||||
Auto-filled from Name. Verify with{" "}
|
||||
{t("vmLxc.appEditor.packageHelpBefore")}{" "}
|
||||
<code className="text-foreground/70">
|
||||
{method === "dpkg" ? "dpkg -l | grep <app>" : "apk info | grep <app>"}
|
||||
</code>{" "}
|
||||
inside the CT.
|
||||
{t("vmLxc.appEditor.packageHelpAfter")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1936,8 +2101,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<div className="text-[10px] text-muted-foreground mt-1">
|
||||
Absolute path. Find it with{" "}
|
||||
<code className="text-foreground/70">which <app></code> or{" "}
|
||||
{t("vmLxc.appEditor.binaryHelpBefore")}{" "}
|
||||
<code className="text-foreground/70">which <app></code>{" "}{t("vmLxc.appEditor.binaryHelpOr")}{" "}
|
||||
<code className="text-foreground/70">systemctl show <service> -p ExecStart</code>.
|
||||
</div>
|
||||
</div>
|
||||
@@ -2402,7 +2567,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
{detectorTest.installed.version ? (
|
||||
<div className="font-mono text-sm text-emerald-400">{detectorTest.installed.version}</div>
|
||||
) : (
|
||||
<div className="text-xs text-amber-300">{detectorTest.installed.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
|
||||
<div className="text-xs text-amber-300">{localizeUpstreamError(detectorTest.installed.error) || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
|
||||
)}
|
||||
<div className="mt-1.5 text-[10px] text-muted-foreground break-all">
|
||||
{detectorTest.installed.method || "—"}
|
||||
@@ -2418,7 +2583,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
) : detectorTest.upstream.version ? (
|
||||
<div className="font-mono text-sm text-emerald-400">{detectorTest.upstream.version}</div>
|
||||
) : (
|
||||
<div className="text-xs text-amber-300">{detectorTest.upstream.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
|
||||
<div className="text-xs text-amber-300">{localizeUpstreamError(detectorTest.upstream.error) || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
|
||||
)}
|
||||
{detectorTest.upstream.type && (
|
||||
<div className="mt-1.5 text-[10px] text-muted-foreground">{detectorTest.upstream.type}</div>
|
||||
@@ -2431,7 +2596,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 flex items-start gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
<span>{localizeAppEditorError(error, t)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2522,22 +2687,29 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" })
|
||||
: t("vmLxc.appEditor.detectedInContainer")}
|
||||
</div>
|
||||
{d.source === "native_metadata" && (
|
||||
{(d.source === "native_metadata" || d.source === "docker_metadata") && (
|
||||
<div className="text-xs text-muted-foreground mt-1 break-all">
|
||||
<div>{d.installed_version
|
||||
? t("vmLxc.appEditor.versionDetected", { version: d.installed_version })
|
||||
: t("vmLxc.appEditor.nativeVersionUnknown")}</div>
|
||||
{(nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && (
|
||||
{(d.source === "docker_metadata"
|
||||
? dockerDiscovery?.stale || dockerDiscovery?.status === "partial"
|
||||
: nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && (
|
||||
<div className="text-amber-400">{t("vmLxc.appEditor.nativeEvidenceStale")}</div>
|
||||
)}
|
||||
{d.evidence?.map((e, index) => <div key={index}>
|
||||
{t(e.kind === "tcp_listener" ? "vmLxc.appEditor.nativeEvidencePort"
|
||||
{t(e.kind === "docker_container" ? "vmLxc.appEditor.dockerEvidenceContainer"
|
||||
: e.kind === "docker_image" ? "vmLxc.appEditor.dockerEvidenceImage"
|
||||
: e.kind === "compose_project" ? "vmLxc.appEditor.dockerEvidenceProject"
|
||||
: e.kind === "tcp_listener" ? (d.source === "docker_metadata"
|
||||
? "vmLxc.appEditor.dockerEvidencePort" : "vmLxc.appEditor.nativeEvidencePort")
|
||||
: e.kind === "installation_file" ? "vmLxc.appEditor.nativeEvidenceFile"
|
||||
: e.kind === "process_executable" ? "vmLxc.appEditor.nativeEvidenceProcess"
|
||||
: e.kind === "working_directory" ? "vmLxc.appEditor.nativeEvidenceDirectory"
|
||||
: "vmLxc.appEditor.nativeEvidenceService")}: {e.value}
|
||||
</div>)}
|
||||
<div>{t("vmLxc.appEditor.nativeRegisterOnly")}</div>
|
||||
<div>{t(d.source === "docker_metadata"
|
||||
? "vmLxc.appEditor.dockerEndpointReview" : "vmLxc.appEditor.nativeRegisterOnly")}</div>
|
||||
</div>
|
||||
)}
|
||||
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
|
||||
@@ -3180,7 +3352,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 flex items-start gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
<span>{localizeAppEditorError(error, t)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { viewedUpdateJobs, type LxcUpdateJob } from "../lib/lxc-update-jobs"
|
||||
|
||||
export function LxcUpdateJobs({ activeRunId, onResume }: {
|
||||
activeRunId?: string
|
||||
onResume: (job: LxcUpdateJob) => void
|
||||
}) {
|
||||
const t = useT()
|
||||
const [jobs, setJobs] = useState<LxcUpdateJob[]>([])
|
||||
const [seen, setSeen] = useState<string[]>([])
|
||||
useEffect(() => {
|
||||
let closed = false
|
||||
const read = () => {
|
||||
void fetchApi<{ jobs: LxcUpdateJob[] }>("/api/lxc-update-jobs")
|
||||
.then((data) => { if (!closed) setJobs(Array.isArray(data.jobs) ? data.jobs : []) })
|
||||
.catch(() => {}) // Auth recovery belongs to fetchApi, never to a job retry.
|
||||
}
|
||||
const viewed = () => setSeen(viewedUpdateJobs())
|
||||
viewed()
|
||||
read()
|
||||
const timer = setInterval(read, 5000)
|
||||
window.addEventListener("proxmenux-update-jobs-viewed", viewed)
|
||||
return () => {
|
||||
closed = true
|
||||
clearInterval(timer)
|
||||
window.removeEventListener("proxmenux-update-jobs-viewed", viewed)
|
||||
}
|
||||
}, [])
|
||||
const visible = jobs.filter((job) => job.id !== activeRunId
|
||||
&& (job.state !== "complete" || !seen.includes(job.id)))
|
||||
if (!visible.length) return null
|
||||
return <div className="space-y-2" role="status">
|
||||
{visible.map((job) => <div key={job.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-violet-500/30 bg-violet-500/5 p-3">
|
||||
<p className="text-sm">{t(job.state === "complete"
|
||||
? "vmLxc.updates.backgroundFinished" : "vmLxc.updates.backgroundRunning",
|
||||
{ vmid: job.vmid, name: job.ct_name || `CT-${job.vmid}` })}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => onResume(job)}>
|
||||
{t("vmLxc.updates.viewBackgroundJob")}
|
||||
</Button>
|
||||
</div>)}
|
||||
</div>
|
||||
}
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import useSWR, { mutate as refreshCache } from "swr"
|
||||
import { ArrowLeft, ChevronDown, DatabaseBackup, ExternalLink, Plus, Server, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, ChevronDown, DatabaseBackup, ExternalLink, Globe, Pencil, Plus, Server, Trash2 } from "lucide-react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Switch } from "./ui/switch"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { monitorLinkError } from "../lib/monitor-link-errors"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface MonitorLink {
|
||||
@@ -21,9 +23,11 @@ interface MonitorLink {
|
||||
product: "pve" | "pbs"
|
||||
group: "cluster" | "standalone" | "pbs"
|
||||
url: string | null
|
||||
status: "online" | "offline" | "unknown"
|
||||
status: "online" | "offline" | "unknown" | "local"
|
||||
native_url?: string | null
|
||||
manual: boolean
|
||||
// A PBS installed on this same host.
|
||||
local?: boolean
|
||||
hidden: boolean
|
||||
custom_url: boolean
|
||||
}
|
||||
@@ -32,8 +36,52 @@ interface MonitorLinks { self: { name: string; product: string }; links: Monitor
|
||||
const GROUPS = ["cluster", "standalone", "pbs"] as const
|
||||
const fetcher = (url: string) => fetchApi<MonitorLinks>(url)
|
||||
const ORIGIN_KEY = "proxmenux-came-from"
|
||||
const DECLINED_KEY = "proxmenux-links-declined"
|
||||
const MAX_CARRIED = 16
|
||||
|
||||
interface Origin { url: string; name: string; product: "pve" | "pbs" }
|
||||
interface Carried { url: string; name: string; product: "pve" | "pbs" }
|
||||
interface Origin extends Carried { links?: Carried[] }
|
||||
|
||||
// A Monitor address is accepted only as a bare http(s) origin.
|
||||
function bareOrigin(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
return /^https?:$/.test(parsed.protocol) && parsed.origin === value ? parsed.origin : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// localhost names the computer that opens the address, never another node.
|
||||
function isLoopback(url: string): boolean {
|
||||
try {
|
||||
const host = new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase()
|
||||
return host === "localhost" || host.endsWith(".localhost") || /^127\./.test(host)
|
||||
|| host === "::1" || /^::ffff:127\./.test(host) || /^::ffff:7f/.test(host)
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
// The Monitors the source already links, carried in the jump so this one can add them too.
|
||||
function readCarried(raw: string | null): Carried[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const rows = JSON.parse(decodeURIComponent(escape(atob(raw.replace(/-/g, "+").replace(/_/g, "/")))))
|
||||
if (!Array.isArray(rows)) return []
|
||||
const seen = new Set<string>()
|
||||
const out: Carried[] = []
|
||||
for (const row of rows.slice(0, MAX_CARRIED)) {
|
||||
const url = bareOrigin(row?.u)
|
||||
if (!url || url === window.location.origin || isLoopback(url) || seen.has(url)) continue
|
||||
seen.add(url)
|
||||
out.push({ url, name: String(row?.n || new URL(url).hostname).slice(0, 64), product: row?.p === "pbs" ? "pbs" : "pve" })
|
||||
}
|
||||
return out
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function writeCarried(rows: Carried[]): string {
|
||||
const json = JSON.stringify(rows.slice(0, MAX_CARRIED).map(row => ({ n: row.name, u: row.url, p: row.product })))
|
||||
return btoa(unescape(encodeURIComponent(json))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
|
||||
// The Monitor a jump came from, as the browser reached it. Only a bare http(s)
|
||||
// origin is accepted, and the parameters leave the address at once.
|
||||
@@ -44,13 +92,15 @@ function takeOrigin(): (Origin & { fresh?: boolean }) | null {
|
||||
if (from !== null) {
|
||||
const name = params.get("pmx_name") || ""
|
||||
const product = params.get("pmx_product") === "pbs" ? "pbs" : "pve"
|
||||
for (const key of ["pmx_from", "pmx_name", "pmx_product"]) params.delete(key)
|
||||
const links = readCarried(params.get("pmx_links"))
|
||||
for (const key of ["pmx_from", "pmx_name", "pmx_product", "pmx_links"]) params.delete(key)
|
||||
const query = params.toString()
|
||||
window.history.replaceState(null, "", window.location.pathname + (query ? `?${query}` : "") + window.location.hash)
|
||||
try {
|
||||
const parsed = new URL(from)
|
||||
if (/^https?:$/.test(parsed.protocol) && parsed.origin === from && parsed.origin !== window.location.origin) {
|
||||
const origin: Origin = { url: parsed.origin, name: (name || parsed.hostname).slice(0, 64), product }
|
||||
const origin: Origin = { url: parsed.origin, name: (name || parsed.hostname).slice(0, 64), product,
|
||||
links: links.filter(link => link.url !== parsed.origin) }
|
||||
try { sessionStorage.setItem(ORIGIN_KEY, JSON.stringify(origin)) } catch { /* private mode */ }
|
||||
return { ...origin, fresh: true }
|
||||
}
|
||||
@@ -64,6 +114,7 @@ function takeOrigin(): (Origin & { fresh?: boolean }) | null {
|
||||
|
||||
// Where a link leads: its Monitor, or the native PBS interface when no Monitor answers there.
|
||||
function destination(link: MonitorLink): { href: string; native: boolean } | null {
|
||||
if (link.local && !link.url) return { href: `https://${window.location.hostname}:8007`, native: true }
|
||||
if (link.product === "pbs" && link.status !== "online" && link.native_url) return { href: link.native_url, native: true }
|
||||
return link.url ? { href: link.url, native: false } : null
|
||||
}
|
||||
@@ -80,18 +131,40 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
|
||||
const [origin, setOrigin] = useState<(Origin & { fresh?: boolean }) | null>(null)
|
||||
const [offerOrigin, setOfferOrigin] = useState(false)
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [offered, setOffered] = useState(false)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addError, setAddError] = useState("")
|
||||
useEffect(() => { setOrigin(takeOrigin()) }, [])
|
||||
const originKnown = !!origin && !!data?.links.some(link => link.url === origin.url)
|
||||
const known = (url: string) => !!data?.links.some(link => link.url === url)
|
||||
const originKnown = !!origin && known(origin.url)
|
||||
// The source Monitor and the Monitors it links that this one does not know yet.
|
||||
const candidates: Carried[] = origin && data
|
||||
? [origin, ...(origin.links || [])].filter(row => !known(row.url) && !isLoopback(row.url))
|
||||
: []
|
||||
const declined = (url: string) => {
|
||||
try { return (JSON.parse(sessionStorage.getItem(DECLINED_KEY) || "[]") as string[]).includes(url) } catch { return false }
|
||||
}
|
||||
useEffect(() => {
|
||||
// Offered once, right after arriving, when this Monitor has no way back yet.
|
||||
if (origin?.fresh && data && !originKnown) setOfferOrigin(true)
|
||||
}, [origin, data, originKnown])
|
||||
// Offered right after arriving, until every node the source links is known here.
|
||||
if (!offered && origin?.fresh && data && candidates.length > 0 && !declined(origin.url)) {
|
||||
setOffered(true)
|
||||
setSelected(candidates.map(row => row.url))
|
||||
setOfferOrigin(true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [origin, data])
|
||||
|
||||
// The address carries where the jump comes from, so the other Monitor can offer the way back.
|
||||
// The address carries where the jump comes from and the Monitors this one links,
|
||||
// so the other Monitor can offer the way back and the rest of the nodes.
|
||||
const withOrigin = (href: string) => {
|
||||
const target = bareOrigin(href.replace(/\/+$/, ""))
|
||||
const carried = (data?.links || [])
|
||||
.filter(link => link.url && link.status === "online" && !destination(link)?.native && link.url !== target
|
||||
&& !isLoopback(link.url))
|
||||
.map(link => ({ url: link.url as string, name: link.name, product: link.product }))
|
||||
const params = new URLSearchParams({ pmx_from: window.location.origin, pmx_name: nodeName, pmx_product: data?.self.product === "pbs" ? "pbs" : "pve" })
|
||||
if (carried.length > 0) params.set("pmx_links", writeCarried(carried))
|
||||
return `${href}/?${params.toString()}`
|
||||
}
|
||||
const open = (href: string, newTab: boolean) => {
|
||||
@@ -104,17 +177,29 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
if (target.native) open(target.href, true)
|
||||
else open(withOrigin(target.href), newTab)
|
||||
}
|
||||
const addOrigin = async () => {
|
||||
if (!origin) return
|
||||
const addSelected = async () => {
|
||||
setAdding(true); setAddError("")
|
||||
try {
|
||||
await fetchApi("/api/monitor-links", { method: "POST", body: JSON.stringify({ name: origin.name, url: origin.url, product: origin.product }) })
|
||||
for (const row of candidates.filter(row => selected.includes(row.url))) {
|
||||
await fetchApi("/api/monitor-links", { method: "POST", body: JSON.stringify({ name: row.name, url: row.url, product: row.product }) })
|
||||
}
|
||||
await refreshCache("/api/monitor-links")
|
||||
setOfferOrigin(false)
|
||||
} catch (err) {
|
||||
setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
|
||||
setAddError(monitorLinkError(err, t))
|
||||
try { await refreshCache("/api/monitor-links") }
|
||||
catch { /* SWR shows the load error; keep the localized mutation error. */ }
|
||||
} finally { setAdding(false) }
|
||||
}
|
||||
const notNow = () => {
|
||||
if (origin) {
|
||||
try {
|
||||
const list = JSON.parse(sessionStorage.getItem(DECLINED_KEY) || "[]") as string[]
|
||||
sessionStorage.setItem(DECLINED_KEY, JSON.stringify([...list.filter(url => url !== origin.url), origin.url]))
|
||||
} catch { /* private mode */ }
|
||||
}
|
||||
setOfferOrigin(false)
|
||||
}
|
||||
|
||||
return <div onClick={event => event.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
@@ -147,13 +232,19 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t(`monitorLinks.groups.${group}`)}</DropdownMenuLabel>
|
||||
{rows.map(link => {
|
||||
const target = destination(link)
|
||||
// A PBS without a Monitor opens its own web interface, not another Monitor.
|
||||
if (target?.native) return <DropdownMenuItem key={link.key} className="gap-2" onSelect={() => go(link, false)}>
|
||||
<span className="h-2 w-2 shrink-0" />
|
||||
<ProductIcon product={link.product} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{link.name}</span>
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.pbsWebInterface")}</span>
|
||||
<Globe className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</DropdownMenuItem>
|
||||
return <DropdownMenuItem key={link.key} disabled={!target} className="gap-2" onSelect={() => go(link, false)}>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${link.status === "online" ? "bg-green-500" : "bg-gray-500"}`} />
|
||||
<ProductIcon product={link.product} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{link.name}</span>
|
||||
{target?.native
|
||||
? <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.pbsInterface")}</span>
|
||||
: link.status !== "online" && <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.noResponse")}</span>}
|
||||
{link.status !== "online" && <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.noResponse")}</span>}
|
||||
{target && <button type="button" aria-label={t("monitorLinks.openNewTab")} title={t("monitorLinks.openNewTab")}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={event => { event.preventDefault(); event.stopPropagation(); go(link, true) }}>
|
||||
@@ -170,17 +261,29 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
|
||||
{origin && <Dialog open={offerOrigin} onOpenChange={setOfferOrigin}>
|
||||
<DialogContent className="max-w-md" onClick={event => event.stopPropagation()}>
|
||||
{origin && <Dialog open={offerOrigin} onOpenChange={open => { if (!open) notNow() }}>
|
||||
<DialogContent className="grid-cols-1 max-w-md" onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.cameFromTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.cameFromBody", { name: origin.name })}</DialogDescription>
|
||||
<DialogTitle>{t("monitorLinks.knownTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.knownBody", { name: origin.name })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="break-all rounded-md border border-border bg-muted/40 px-3 py-2 font-mono text-sm">{origin.url}</p>
|
||||
<div className="max-h-72 space-y-2 overflow-y-auto">
|
||||
{candidates.map(row => <label key={row.url} className="flex cursor-pointer items-start gap-3 rounded-md border border-border bg-card px-3 py-2">
|
||||
<Checkbox className="mt-0.5" checked={selected.includes(row.url)} disabled={adding}
|
||||
onCheckedChange={checked => setSelected(current => checked === true ? [...current, row.url] : current.filter(url => url !== row.url))} />
|
||||
<ProductIcon product={row.product} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">{row.name}</span>
|
||||
<span className="block break-all font-mono text-xs text-muted-foreground">{row.url}</span>
|
||||
</span>
|
||||
</label>)}
|
||||
</div>
|
||||
{addError && <p role="alert" className="text-sm text-red-500">{addError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" disabled={adding} onClick={() => setOfferOrigin(false)}>{t("monitorLinks.notNow")}</Button>
|
||||
<Button disabled={adding} onClick={addOrigin}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
|
||||
<Button variant="outline" disabled={adding} onClick={notNow}>{t("monitorLinks.notNow")}</Button>
|
||||
<Button className="bg-blue-600 text-white hover:bg-blue-700" disabled={adding || selected.length === 0} onClick={addSelected}>
|
||||
<Plus className="mr-2 h-4 w-4" />{t("monitorLinks.addSelected")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>}
|
||||
@@ -189,9 +292,10 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
|
||||
function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const t = useT()
|
||||
const { data, mutate } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
|
||||
const { data, mutate, error: loadError, isLoading } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
|
||||
const [form, setForm] = useState({ name: "", url: "", product: "pve" })
|
||||
const [addresses, setAddresses] = useState<Record<string, string>>({})
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
@@ -202,14 +306,15 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
await mutate()
|
||||
await refreshCache("/api/monitor-links")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
|
||||
setError(monitorLinkError(err, t))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
const send = (path: string, method: string, body?: unknown) =>
|
||||
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
|
||||
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto" onClick={event => event.stopPropagation()}>
|
||||
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.dialogDescription")}</DialogDescription>
|
||||
@@ -217,7 +322,7 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label>
|
||||
<Input id="monitor-link-name" value={form.name} disabled={busy} onChange={event => setForm({ ...form, name: event.target.value })} />
|
||||
@@ -238,7 +343,7 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" disabled={busy || !form.url.trim()} onClick={() => run(async () => {
|
||||
<Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100" disabled={busy || !form.url.trim()} onClick={() => run(async () => {
|
||||
await send("/api/monitor-links", "POST", form)
|
||||
setForm({ name: "", url: "", product: "pve" })
|
||||
})}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
|
||||
@@ -247,10 +352,16 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">{t("monitorLinks.checking")}</p>}
|
||||
{loadError && <p role="alert" className="text-sm text-amber-500">{t("monitorLinks.loadFailed")}</p>}
|
||||
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||
{data?.links.map(link => {
|
||||
const address = addresses[link.key] ?? (link.manual || link.custom_url ? link.url || "" : "")
|
||||
return <div key={link.key} className={`space-y-2 rounded-lg border border-border p-3 ${link.hidden ? "opacity-60" : ""}`}>
|
||||
const own = link.manual || link.custom_url
|
||||
const shown = link.url || ""
|
||||
const draft = addresses[link.key] ?? (own ? shown : "")
|
||||
const isEditing = editing === link.key
|
||||
// A read-only card is a panel; while its address is edited it lets the field stand out.
|
||||
return <div key={link.key} className={`space-y-2 rounded-lg border border-border p-3 ${isEditing ? "" : "bg-card"} ${link.hidden ? "opacity-60" : ""}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProductIcon product={link.product} className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{link.name}</span>
|
||||
@@ -265,16 +376,34 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={address} disabled={busy} aria-label={t("monitorLinks.address")}
|
||||
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${link.url ? `: ${link.url}` : ""}`}
|
||||
{link.local && !link.custom_url
|
||||
? <div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{t("monitorLinks.pbsWebInterface")}</span>
|
||||
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.thisNode")}</span>
|
||||
</div>
|
||||
: isEditing
|
||||
? <div className="grid grid-cols-2 gap-2 sm:flex">
|
||||
<Input className="col-span-2 min-w-0 sm:flex-1" value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
|
||||
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
|
||||
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
|
||||
<Button variant="outline" size="sm" className="h-10" disabled={busy || addresses[link.key] === undefined}
|
||||
<Button size="sm" className="h-10 bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100"
|
||||
disabled={busy || addresses[link.key] === undefined || (link.manual && !draft.trim())}
|
||||
onClick={() => run(async () => {
|
||||
await send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { url: addresses[link.key] })
|
||||
setAddresses(({ [link.key]: _, ...rest }) => rest)
|
||||
setEditing(null)
|
||||
})}>{t("monitorLinks.save")}</Button>
|
||||
<Button variant="outline" size="sm" className="h-10" disabled={busy}
|
||||
onClick={() => { setAddresses(({ [link.key]: _, ...rest }) => rest); setEditing(null) }}>{t("monitorLinks.cancel")}</Button>
|
||||
</div>
|
||||
: <div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-sm text-foreground">{shown || "—"}</span>
|
||||
{!link.manual && !link.custom_url && <span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.autoAddress")}</span>}
|
||||
<Button variant="ghost" size="sm" disabled={busy} aria-label={t("monitorLinks.changeAddress")} title={t("monitorLinks.changeAddress")}
|
||||
onClick={() => setEditing(link.key)}>
|
||||
<Pencil className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ type Bridge = {
|
||||
type Guest = {
|
||||
id: string
|
||||
label: string
|
||||
kind: "lxc" | "vm" | "host"
|
||||
kind: "lxc" | "vm" | "host" | "interface"
|
||||
bridge: string
|
||||
rx: number
|
||||
tx: number
|
||||
@@ -87,6 +87,8 @@ const ICONS: Record<string, string> = {
|
||||
vm: `<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>`,
|
||||
}
|
||||
|
||||
ICONS.interface = ICONS.bridge
|
||||
|
||||
const COLORS = {
|
||||
host: "var(--amber-500, #f59e0b)",
|
||||
lxc: "var(--cyan-500, #06b6d4)",
|
||||
@@ -97,6 +99,7 @@ const COLORS = {
|
||||
// just another NIC.
|
||||
bond: "var(--orange-500, #f97316)",
|
||||
bridge: "var(--cyan-500, #06b6d4)",
|
||||
interface: "var(--cyan-500, #06b6d4)",
|
||||
gray: "#525252",
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,9 @@ interface NetworkInterface {
|
||||
// Master device resolved from /sys/class/net/<iface>/master — the
|
||||
// bridge for a guest tap, the bond for a slave NIC.
|
||||
bridge_owner?: string
|
||||
network_backend?: "ovs"
|
||||
ovs_tag?: number | null
|
||||
ovs_trunks?: number[]
|
||||
bridge_members?: string[]
|
||||
bridge_physical_interface?: string
|
||||
bridge_bond_slaves?: string[]
|
||||
@@ -568,6 +571,7 @@ export function NetworkMetrics() {
|
||||
...(networkData.bond_interfaces || []),
|
||||
...(networkData.bridge_interfaces || []),
|
||||
...(networkData.vm_lxc_interfaces || []),
|
||||
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
|
||||
]
|
||||
|
||||
const vmLxcInterfaces = (networkData.vm_lxc_interfaces || []).sort((a, b) => {
|
||||
@@ -867,6 +871,7 @@ export function NetworkMetrics() {
|
||||
...(networkData.bond_interfaces || []),
|
||||
...(networkData.bridge_interfaces || []),
|
||||
...(networkData.vm_lxc_interfaces || []),
|
||||
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
|
||||
]
|
||||
const flowData: NetworkFlowData = {
|
||||
nics: (networkData.physical_interfaces || []).map((p) => ({
|
||||
@@ -916,7 +921,9 @@ export function NetworkMetrics() {
|
||||
tx: runningGuests.reduce((a, v) => a + toMBps(v.tx_Bps), 0),
|
||||
}
|
||||
})(),
|
||||
...(networkData.vm_lxc_interfaces || []).map((v) => {
|
||||
...[...(networkData.vm_lxc_interfaces || []),
|
||||
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
|
||||
].map((v) => {
|
||||
// Authoritative bridge from the kernel (read by the
|
||||
// backend from /sys/class/net/<iface>/master). Fallback
|
||||
// to bridge_members scan, then first bridge as last
|
||||
@@ -929,8 +936,8 @@ export function NetworkMetrics() {
|
||||
(networkData.bridge_interfaces?.[0]?.name || "")
|
||||
return {
|
||||
id: v.name,
|
||||
label: v.vm_name || v.name,
|
||||
kind: (v.vm_type === "vm" ? "vm" : "lxc") as "vm" | "lxc",
|
||||
label: v.vm_name || (v.ovs_tag != null ? `${v.name} · VLAN ${v.ovs_tag}` : v.name),
|
||||
kind: v.type === "vlan" ? "interface" as const : (v.vm_type === "vm" ? "vm" as const : "lxc" as const),
|
||||
bridge: ownerName,
|
||||
rx: toMBps(v.rx_Bps),
|
||||
tx: toMBps(v.tx_Bps),
|
||||
@@ -1213,6 +1220,7 @@ export function NetworkMetrics() {
|
||||
...(modalNetworkData.bond_interfaces || []),
|
||||
...(modalNetworkData.bridge_interfaces || []),
|
||||
...(modalNetworkData.vm_lxc_interfaces || []),
|
||||
...(modalNetworkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
|
||||
].find((iface) => iface.name === selectedInterface.name)
|
||||
: selectedInterface
|
||||
|
||||
@@ -1298,7 +1306,7 @@ export function NetworkMetrics() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">MTU</div>
|
||||
<div className="font-medium">{displayInterface.mtu}</div>
|
||||
<div className="font-medium">{displayInterface.mtu || t("common.notAvailable")}</div>
|
||||
</div>
|
||||
{displayInterface.mac_address && (
|
||||
<div className="col-span-2">
|
||||
|
||||
@@ -291,7 +291,7 @@ export function NetworkTrafficChart({
|
||||
<>
|
||||
<MetricsCacheNotice lastChecked={staleAt} />
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<AreaChart data={data} margin={{ bottom: 80, left: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
|
||||
@@ -426,7 +426,7 @@ export function NodeMetricsCharts() {
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 md:px-6">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 10, right: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
@@ -509,7 +509,7 @@ export function NodeMetricsCharts() {
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 pr-2 md:px-6">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 10, right: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
|
||||
@@ -874,7 +874,7 @@ export function NotificationSettings() {
|
||||
// Flatten per-provider API keys
|
||||
if (cfg.ai_api_keys) {
|
||||
for (const [provider, key] of Object.entries(cfg.ai_api_keys)) {
|
||||
if (key) {
|
||||
if (key || provider === "ollama") {
|
||||
flat[`ai_api_key_${provider}`] = key
|
||||
}
|
||||
}
|
||||
@@ -1530,7 +1530,7 @@ export function NotificationSettings() {
|
||||
<Input
|
||||
type={showSecrets["tg_token"] ? "text" : "password"}
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="7595377878:AAGE6Fb2cy... (with or without 'bot' prefix)"
|
||||
placeholder={t("settings.notifications.ui.botTokenPlaceholder")}
|
||||
value={secretValue("tg_token", config.channels.telegram?.bot_token || "")}
|
||||
onChange={e => updateChannel("telegram", "bot_token", e.target.value)}
|
||||
disabled={!editMode}
|
||||
@@ -1542,6 +1542,7 @@ export function NotificationSettings() {
|
||||
{showSecrets["tg_token"] ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t("settings.notifications.ui.botTokenHelp")}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.chatId")}</Label>
|
||||
@@ -2304,6 +2305,12 @@ export function NotificationSettings() {
|
||||
|
||||
// Restore previously saved model for the new provider (if any)
|
||||
const savedModel = config.ai_models?.[v] || ''
|
||||
setShowSecrets(p => ({ ...p, ai_key: false }))
|
||||
setRevealedSecrets(p => {
|
||||
const next = { ...p }
|
||||
delete next.ai_key
|
||||
return next
|
||||
})
|
||||
|
||||
updateConfig(p => ({
|
||||
...p,
|
||||
@@ -2363,11 +2370,13 @@ export function NotificationSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key (not shown for Ollama) */}
|
||||
{config.ai_provider !== "ollama" && (
|
||||
{/* Ollama gateways may require a key; native servers do not. */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs sm:text-sm text-foreground/80">{t("settings.notifications.ai.apiKey")}</Label>
|
||||
<Label htmlFor="notification-ai-api-key" className="text-xs sm:text-sm text-foreground/80">{t("settings.notifications.ai.apiKey")}</Label>
|
||||
{config.ai_provider === "ollama" && (
|
||||
<span className="text-xs text-muted-foreground">({t("settings.notifications.ui.optional")})</span>
|
||||
)}
|
||||
{AI_PROVIDERS.find(p => p.value === config.ai_provider)?.keyUrl && (
|
||||
<a
|
||||
href={AI_PROVIDERS.find(p => p.value === config.ai_provider)?.keyUrl}
|
||||
@@ -2381,9 +2390,10 @@ export function NotificationSettings() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="notification-ai-api-key"
|
||||
type={showSecrets["ai_key"] ? "text" : "password"}
|
||||
className="h-9 text-sm font-mono"
|
||||
placeholder="sk-..."
|
||||
placeholder={config.ai_provider === "ollama" ? "" : "sk-..."}
|
||||
value={secretValue("ai_key", config.ai_api_keys?.[config.ai_provider] || "")}
|
||||
onChange={e => updateConfig(p => ({
|
||||
...p,
|
||||
@@ -2401,8 +2411,12 @@ export function NotificationSettings() {
|
||||
{showSecrets["ai_key"] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{config.ai_provider === "ollama" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.notifications.ai.ollamaApiKeyHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model - selector with Load button for all providers */}
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -4,7 +4,8 @@ import { CalendarClock, Database } from "lucide-react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
|
||||
export interface PBSDatastore {
|
||||
name: string
|
||||
@@ -13,6 +14,7 @@ export interface PBSDatastore {
|
||||
used?: number | null
|
||||
avail?: number | null
|
||||
error?: string
|
||||
error_code?: string
|
||||
"backend-type"?: string
|
||||
"estimated-full-date"?: number
|
||||
"gc-status"?: { upid?: string | null; "removed-bytes"?: number; "pending-bytes"?: number }
|
||||
@@ -42,7 +44,7 @@ export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatast
|
||||
<span className="font-medium text-foreground truncate">{store.name}</span>
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-500">{store.error || t("pbs.capacityUnavailable")}</p>
|
||||
<PBSProblemMessage className="text-xs text-amber-500" problem={{ code: store.error_code }} raw={store.error} fallback="pbs.capacityUnavailable" />
|
||||
</div>
|
||||
}
|
||||
const tone = usageTone(percent)
|
||||
@@ -62,10 +64,11 @@ export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatast
|
||||
// Same layout as the "Proxmox storage" cards of the PVE Monitor.
|
||||
export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const percent = datastorePercent(store)
|
||||
const tone = percent == null ? null : usageTone(percent)
|
||||
const fullDate = store["estimated-full-date"]
|
||||
const type = store["backend-type"] || "datastore"
|
||||
const type = store["backend-type"] || t("pbs.datastoreType")
|
||||
return <div className={`border rounded-lg p-4 ${store.error ? "border-red-500/50 bg-red-500/5" : ""}`}>
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 md:gap-3 min-w-0">
|
||||
@@ -99,13 +102,13 @@ export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{store.error || t("storage.capacityNotReported")}</p>
|
||||
<PBSProblemMessage className="text-sm text-muted-foreground" problem={{ code: store.error_code }} raw={store.error} fallback="pbs.capacityUnavailable" />
|
||||
)}
|
||||
{(store.path || fullDate !== undefined) && <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{store.path && <span className="font-mono truncate" title={store.path}>{store.path}</span>}
|
||||
{fullDate !== undefined && <span className="flex items-center">
|
||||
<CalendarClock className="mr-1.5 h-3.5 w-3.5" />{t("pbs.estimatedFull")}:
|
||||
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString() : t("pbs.estimatedFullUnknown")}</span>
|
||||
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString(language) : t("pbs.estimatedFullUnknown")}</span>
|
||||
</span>}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
@@ -12,21 +12,27 @@ import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
import { pbsDiagnostic, pbsResultText, pbsServiceState, pbsTaskType, formatPBSDuration } from "../lib/pbs-messages"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
|
||||
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
|
||||
interface Connections { local_available: boolean; servers: Connection[] }
|
||||
interface Detected { name: string; url: string; fingerprint: string }
|
||||
interface Connections { local_available: boolean; servers: Connection[]; detected?: Detected[] }
|
||||
interface Task { upid: string; worker_type?: string; worker_id?: string; starttime?: number; endtime?: number; status?: string }
|
||||
interface Job { id: string; store?: string; schedule?: string; "last-run-state"?: string }
|
||||
interface PBSStatus {
|
||||
name: string; version: string | null; fetched_at: number; services: Record<string, string>;
|
||||
datastores: PBSDatastore[] | null; tasks: Task[] | null;
|
||||
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
|
||||
error_details?: Record<string, { code: string; params?: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "" }
|
||||
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "", username: "", password: "" }
|
||||
type Method = "password" | "token"
|
||||
const NEW_CONNECTION = "__new__"
|
||||
const badgeTone = {
|
||||
ok: "bg-green-500/10 text-green-500 border-green-500/20",
|
||||
@@ -43,42 +49,40 @@ const TASK_ICONS: Record<string, typeof Activity> = {
|
||||
termproxy: Terminal, dircreate: Database, "create-datastore": Database,
|
||||
}
|
||||
|
||||
// PBS reports "OK", "WARNINGS: n" or the error text; a task without one is still running.
|
||||
// PBS reports "unknown", "OK", "WARNINGS: n" or an error; no state is still running.
|
||||
function taskState(task: Task): keyof typeof badgeTone {
|
||||
if (!task.status) return task.endtime ? "muted" : "running"
|
||||
if (task.status === "unknown") return "muted"
|
||||
if (task.status === "OK") return "ok"
|
||||
if (task.status.startsWith("WARNINGS")) return "warn"
|
||||
return "error"
|
||||
}
|
||||
|
||||
// PBS reports "OK", "WARNINGS: n" or the error text of the last run.
|
||||
function jobState(state?: string): keyof typeof badgeTone {
|
||||
if (!state) return "muted"
|
||||
if (state === "ok") return "ok"
|
||||
if (!state || state === "unknown") return "muted"
|
||||
if (state.toLowerCase() === "ok") return "ok"
|
||||
if (state.toLowerCase().startsWith("warn")) return "warn"
|
||||
return "error"
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "—"
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return hours < 24 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`
|
||||
}
|
||||
|
||||
// `localOnly` is the PBS host's own Monitor: no connection picker, the
|
||||
// header Refresh already reloads the page data.
|
||||
export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
// header Refresh already reloads the page data. Its tabs show one `view` each.
|
||||
export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: boolean; view?: "all" | "datastores" | "tasks" } = {}) {
|
||||
const showStores = view !== "tasks"
|
||||
const showTasks = view !== "datastores"
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
|
||||
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
|
||||
const [selected, setSelected] = useState("")
|
||||
const [manage, setManage] = useState(false)
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [method, setMethod] = useState<Method>("password")
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [errorFallback, setErrorFallback] = useState("pbs.saveFailed")
|
||||
const { data, error: statusError, isLoading, mutate } = useSWR<PBSStatus>(
|
||||
selected ? `/api/pbs/status?server=${encodeURIComponent(selected)}` : null,
|
||||
url => fetchApi<PBSStatus>(url), { refreshInterval: 30000, revalidateOnFocus: false })
|
||||
@@ -90,28 +94,44 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
setSelected(connections.local_available ? "local" : connections.servers[0]?.id || "")
|
||||
}, [connections, selected])
|
||||
|
||||
const openManage = (next = emptyForm) => {
|
||||
setForm(next); setMethod(next.id ? "token" : "password"); setError(""); setMessage(""); setManage(true)
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setErrorFallback("pbs.saveFailed")
|
||||
setBusy(true); setError(""); setMessage("")
|
||||
let saved = false
|
||||
try {
|
||||
const result = await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
|
||||
...form, id: form.id || undefined, token_secret: form.token_secret || undefined,
|
||||
// A password is sent once to create a read-only token; only the token is kept.
|
||||
const { username, password, ...connection } = form
|
||||
const result = !form.id && method === "password"
|
||||
? await fetchApi<{ id: string }>("/api/pbs/servers/login", { method: "POST", body: JSON.stringify({
|
||||
name: form.name, url: form.url, fingerprint: form.fingerprint, username, password,
|
||||
}) })
|
||||
: await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
|
||||
...connection, id: form.id || undefined, token_secret: form.token_secret || undefined,
|
||||
}) })
|
||||
saved = true
|
||||
await reloadConnections()
|
||||
setSelected(result.id)
|
||||
setForm(emptyForm)
|
||||
setMessage(t("pbs.saved"))
|
||||
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
|
||||
setManage(false)
|
||||
} catch (err) { setError(saved ? { body: { code: "pbs_saved_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
if (!form.id || !window.confirm(t("pbs.removeConfirm"))) return
|
||||
setErrorFallback("pbs.removeFailed")
|
||||
setBusy(true); setError(""); setMessage("")
|
||||
let removed = false
|
||||
try {
|
||||
await fetchApi(`/api/pbs/servers/${encodeURIComponent(form.id)}`, { method: "DELETE" })
|
||||
removed = true
|
||||
setForm(emptyForm)
|
||||
await reloadConnections()
|
||||
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
|
||||
} catch (err) { setError(removed ? { body: { code: "pbs_removed_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const now = Date.now() / 1000
|
||||
@@ -122,28 +142,51 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
<Select value={selected || undefined} onValueChange={setSelected}>
|
||||
<SelectTrigger aria-label={t("pbs.server")} className="w-full sm:ml-auto sm:w-56"><SelectValue placeholder={t("pbs.chooseServer")} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections?.local_available && <SelectItem value="local">{t("pbs.local")}</SelectItem>}
|
||||
{connections?.local_available && <SelectItem value="local">{t("pbs.thisNode")}</SelectItem>}
|
||||
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" disabled={isLoading || !selected} onClick={() => mutate()}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />{t("pbs.refresh")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setManage(!manage)}>
|
||||
<Button variant="outline" size="sm" onClick={() => openManage()}>
|
||||
<Settings2 className="mr-2 h-4 w-4" />{t("pbs.manage")}
|
||||
</Button>
|
||||
</div>}
|
||||
{(connectionError || statusError) && <p role="alert" className="text-sm text-red-500">
|
||||
{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>}
|
||||
{(connectionError || statusError) && <div role="alert" className="text-sm text-red-500">
|
||||
<PBSProblemMessage problem={connectionError || statusError} raw={pbsDiagnostic(connectionError || statusError)} />
|
||||
</div>}
|
||||
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</p>}
|
||||
{!selected && !connectionError && connections && <p className="text-sm text-muted-foreground">{t("pbs.noServer")}</p>}
|
||||
{manage && !localOnly && <Card>
|
||||
<CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.permissionsHelp")}</p>
|
||||
<Select value={form.id || NEW_CONNECTION} onValueChange={value => {
|
||||
{!localOnly && !manage && (connections?.detected?.length || 0) > 0 && <Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><DatabaseBackup className="mr-2 h-5 w-5" />{t("pbs.detectedTitle")}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.detectedHelp")}</p>
|
||||
{connections?.detected?.map(row => <div key={row.url} className="flex flex-wrap items-center gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">{row.name}</span>
|
||||
<span className="block break-all font-mono text-xs text-muted-foreground">{row.url}</span>
|
||||
</span>
|
||||
<Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700"
|
||||
onClick={() => openManage({ ...emptyForm, name: row.name, url: row.url, fingerprint: row.fingerprint })}>
|
||||
{t("pbs.connect")}
|
||||
</Button>
|
||||
</div>)}
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{!localOnly && <Dialog open={manage} onOpenChange={open => { if (!busy) setManage(open) }}>
|
||||
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("pbs.manage")}</DialogTitle>
|
||||
<DialogDescription>{method === "password" && !form.id ? t("pbs.passwordHelp") : t("pbs.permissionsHelp")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("pbs.editConnection")}</Label>
|
||||
<Select value={form.id || NEW_CONNECTION} disabled={busy} onValueChange={value => {
|
||||
const row = connections?.servers.find(item => item.id === value)
|
||||
setForm(row ? { ...row, token_secret: "" } : emptyForm)
|
||||
setForm(row ? { ...emptyForm, ...row, token_secret: "" } : emptyForm)
|
||||
setMethod(row ? "token" : "password"); setError("")
|
||||
}}>
|
||||
<SelectTrigger aria-label={t("pbs.editConnection")} className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -151,33 +194,66 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field =>
|
||||
<div key={field} className="space-y-2">
|
||||
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
|
||||
<Input id={`pbs-${field}`} disabled={busy} type={field === "token_secret" ? "password" : field === "url" ? "url" : "text"}
|
||||
autoComplete={field === "token_secret" ? "new-password" : "off"}
|
||||
value={form[field]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
|
||||
</div>)}
|
||||
</div>
|
||||
{!form.id && <div className="space-y-2">
|
||||
<Label>{t("pbs.signInWith")}</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(["password", "token"] as const).map(option =>
|
||||
<Button key={option} type="button" variant="outline" disabled={busy} onClick={() => { setMethod(option); setError("") }}
|
||||
className={method === option ? "border-blue-600 bg-blue-600/15 text-foreground hover:bg-blue-600/20" : "bg-background"}>
|
||||
{t(option === "password" ? "pbs.methodPassword" : "pbs.methodToken")}
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{(["name", "url", ...(method === "password" && !form.id ? ["username", "password"] : ["token_id", "token_secret"]), "fingerprint"] as const).map(field => {
|
||||
const secret = field === "token_secret" || field === "password"
|
||||
const help = field === "url" ? "pbs.urlHelp" : field === "username" ? "pbs.usernameHelp"
|
||||
: field === "token_id" ? "pbs.tokenIdHelp" : field === "token_secret"
|
||||
? form.id ? "pbs.tokenSecretKeepHelp" : "pbs.tokenSecretHelp" : undefined
|
||||
return <div key={field} className="min-w-0 space-y-2">
|
||||
<Label htmlFor={`pbs-${field}`} className="block leading-5">{t(`pbs.${field}`)}</Label>
|
||||
<Input id={`pbs-${field}`} disabled={busy} type={secret ? "password" : field === "url" ? "url" : "text"}
|
||||
aria-describedby={help ? `pbs-${field}-help` : undefined}
|
||||
autoComplete={secret ? "new-password" : "off"} placeholder={field === "username" ? "root@pam" : undefined}
|
||||
value={form[field as keyof typeof form]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
|
||||
{help && <p id={`pbs-${field}-help`} className="text-xs text-muted-foreground">{t(help)}</p>}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
{!!error && <div role="alert" className="text-sm text-red-500">
|
||||
<PBSProblemMessage problem={error} raw={pbsDiagnostic(error)} fallback={errorFallback} />
|
||||
</div>}
|
||||
{message && <p role="status" className="text-sm text-green-500">{message}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button disabled={busy} onClick={save}>{busy ? t("pbs.testing") : t("pbs.save")}</Button>
|
||||
{form.id && <Button variant="destructive" disabled={busy} onClick={remove}>{t("pbs.remove")}</Button>}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{form.id && <Button variant="outline" disabled={busy} className="mr-auto text-red-500 hover:text-red-500" onClick={remove}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />{t("pbs.remove")}
|
||||
</Button>}
|
||||
<Button variant="outline" disabled={busy} onClick={() => setManage(false)}>{t("pbs.cancel")}</Button>
|
||||
<Button className="bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100"
|
||||
disabled={busy || !form.url.trim() || !form.name.trim()} onClick={save}>
|
||||
{busy ? t("pbs.testing") : t("pbs.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>}
|
||||
{data && <>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}</span>
|
||||
{Object.entries(data.services).map(([service, state]) =>
|
||||
<Badge key={service} variant="outline" className={state === "active" ? badgeTone.ok : badgeTone.error}>{service}: {state}</Badge>)}
|
||||
</div>
|
||||
{Object.entries(data.errors).map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
|
||||
{!localOnly && <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString(language) })}</span>
|
||||
{Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active")
|
||||
? <Badge variant="outline" className={badgeTone.ok}>{t("pbs.servicesActive")}</Badge>
|
||||
: Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) =>
|
||||
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {pbsServiceState(state, t)}</Badge>)}
|
||||
</div>}
|
||||
{Object.entries(data.errors).filter(([section]) => section === "datastores" ? showStores : showTasks)
|
||||
.map(([section, detail]) => <div key={section} role="alert" className="text-sm text-amber-500">
|
||||
<p className="font-medium">{t(`pbs.${["datastores", "tasks", "sync", "verify", "prune"].includes(section) ? section : "title"}`)}</p>
|
||||
<PBSProblemMessage problem={data.error_details?.[section]} raw={detail} />
|
||||
</div>)}
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
{showStores && <Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><Database className="mr-2 h-5 w-5" />{t("pbs.datastores")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{data.datastores?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>}
|
||||
@@ -185,8 +261,9 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
{data.datastores.map(store => <PBSDatastoreCard key={store.name} store={store} />)}
|
||||
</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>}
|
||||
|
||||
{showTasks && <>
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><Activity className="mr-2 h-5 w-5" />{t("pbs.tasks")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
@@ -205,14 +282,14 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
return <tr key={row.upid} className="border-b border-border/60 last:border-0">
|
||||
<td className="py-2 pr-3"><span className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-foreground">{row.worker_type}</span>
|
||||
<span className="text-foreground" title={row.worker_type}>{pbsTaskType(row.worker_type, t)}</span>
|
||||
{row.worker_id && <span className="truncate text-muted-foreground">{row.worker_id}</span>}
|
||||
</span></td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}</td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatDuration(end - row.starttime) : "—"}</td>
|
||||
<td className="py-2"><Badge variant="outline" className={`${badgeTone[state]} max-w-[16rem] truncate`} title={row.status}>
|
||||
{state === "running" ? t("pbs.running") : row.status || "—"}
|
||||
</Badge></td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString(language) : "—"}</td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatPBSDuration(end - row.starttime, t) : "—"}</td>
|
||||
<td className="py-2">
|
||||
<PBSResultBadge status={row.status} running={state === "running"} tone={badgeTone[state]} />
|
||||
</td>
|
||||
</tr>
|
||||
})}</tbody>
|
||||
</table></div>}
|
||||
@@ -236,12 +313,25 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
<td className="py-2 pr-3"><span className="text-foreground">{t(`pbs.${kind}`)}</span> <span className="text-muted-foreground">· {row.id}</span></td>
|
||||
<td className="py-2 pr-3 text-muted-foreground">{row.store || "—"}</td>
|
||||
<td className="py-2 pr-3 font-mono text-xs text-muted-foreground">{row.schedule || "—"}</td>
|
||||
<td className="py-2"><Badge variant="outline" className={badgeTone[jobState(row["last-run-state"])]}>{row["last-run-state"] || "—"}</Badge></td>
|
||||
<td className="py-2"><PBSResultBadge status={row["last-run-state"]} tone={badgeTone[jobState(row["last-run-state"])]} /></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function PBSResultBadge({ status, running = false, tone }: { status?: string; running?: boolean; tone: string }) {
|
||||
const t = useT()
|
||||
const badge = <Badge variant="outline" className={`${tone} max-w-[16rem] truncate`}>
|
||||
{running ? t("pbs.running") : pbsResultText(status, t)}
|
||||
</Badge>
|
||||
if (running || !status || status === "unknown" || /^ok$/i.test(status) || /^WARNINGS:\s*\d+\s*$/i.test(status)) return badge
|
||||
return <details className="max-w-xs">
|
||||
<summary className="cursor-pointer" aria-label={t("pbs.technicalDetails")}>{badge}</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words text-xs text-muted-foreground">{status}</pre>
|
||||
</details>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { pbsErrorText, pbsIsKnownError } from "../lib/pbs-messages"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// Known application messages are already explained in the active language.
|
||||
// Preserve unrecognized external diagnostics as escaped technical details.
|
||||
export function PBSProblemMessage({ problem, raw, fallback, className = "" }: {
|
||||
problem?: unknown; raw?: string; fallback?: string; className?: string
|
||||
}) {
|
||||
const t = useT()
|
||||
return <div className={`min-w-0 space-y-1 ${className}`}>
|
||||
<p>{pbsErrorText(problem, t, fallback)}</p>
|
||||
{raw && !pbsIsKnownError(problem) && <details className="text-xs">
|
||||
<summary className="cursor-pointer">{t("pbs.technicalDetails")}</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words font-mono">{raw}</pre>
|
||||
</details>}
|
||||
</div>
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react"
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"
|
||||
import useSWR from "swr"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Server,
|
||||
ListChecks,
|
||||
Menu,
|
||||
LayoutDashboard,
|
||||
HardDrive,
|
||||
@@ -116,11 +117,11 @@ function PBSDashboard() {
|
||||
}
|
||||
|
||||
type Product = "pve" | "pbs"
|
||||
type TopTab = TabId | "datastores"
|
||||
type TopTab = TabId | "datastores" | "pbs-tasks"
|
||||
|
||||
// Proxmox Backup Server has no guests, apps or PVE storage: it keeps the
|
||||
// same header, health and administration with its own top-level sections.
|
||||
const PBS_TABS: TopTab[] = ["overview", "datastores", "terminal", "admin"]
|
||||
const PBS_TABS: TopTab[] = ["overview", "datastores", "pbs-tasks", "terminal", "admin"]
|
||||
|
||||
function MonitorDashboard({ product }: { product: Product }) {
|
||||
const t = useT()
|
||||
@@ -184,10 +185,18 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const healthRequest = useRef<AbortController | null>(null)
|
||||
// Fetch health info count independently (for initial load and refresh)
|
||||
const fetchHealthInfoCount = useCallback(async () => {
|
||||
const fetchHealthInfoCount = useCallback(async (cachedOnly = false) => {
|
||||
if (cachedOnly && healthRequest.current) return
|
||||
healthRequest.current?.abort()
|
||||
const controller = new AbortController()
|
||||
healthRequest.current = controller
|
||||
const timeout = cachedOnly ? setTimeout(() => controller.abort(), 10000) : undefined
|
||||
try {
|
||||
const response = await fetchApi("/api/health/full")
|
||||
const response = await fetchApi(cachedOnly ? "/api/health/full?cached=1" : "/api/health/full", { signal: controller.signal })
|
||||
if (healthRequest.current !== controller) return
|
||||
if (!response?.health?.details) throw new Error("Invalid health snapshot")
|
||||
let calculatedInfoCount = 0
|
||||
|
||||
if (response && response.health?.details) {
|
||||
@@ -217,7 +226,10 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
|
||||
setInfoCount(calculatedInfoCount)
|
||||
} catch (error) {
|
||||
// Silently fail - infoCount will remain at 0
|
||||
// Preserve the last known count; a failed poll is not a healthy result.
|
||||
} finally {
|
||||
if (timeout !== undefined) clearTimeout(timeout)
|
||||
if (healthRequest.current === controller) healthRequest.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -268,20 +280,29 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
fetchHealthInfoCount()
|
||||
fetchUpdateStatus()
|
||||
|
||||
// Overview polls health every 30 s; the other tabs every 60 s.
|
||||
// Header reboot information stays current on every tab, without full audits.
|
||||
const refreshHealth = () => {
|
||||
if (document.visibilityState === "visible") void fetchHealthInfoCount(true)
|
||||
}
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
let healthInterval: ReturnType<typeof setInterval> | null = null
|
||||
if (activeTab === "overview") {
|
||||
interval = setInterval(fetchSystemData, 30000) // 30 segundos
|
||||
healthInterval = setInterval(fetchHealthInfoCount, 30000) // Also refresh info count
|
||||
} else {
|
||||
interval = setInterval(fetchSystemData, 60000) // 60 segundos
|
||||
healthInterval = setInterval(fetchHealthInfoCount, 60000) // Also refresh info count
|
||||
}
|
||||
|
||||
healthInterval = setInterval(refreshHealth, 30000)
|
||||
window.addEventListener("focus", refreshHealth)
|
||||
document.addEventListener("visibilitychange", refreshHealth)
|
||||
return () => {
|
||||
if (interval) clearInterval(interval)
|
||||
if (healthInterval) clearInterval(healthInterval)
|
||||
window.removeEventListener("focus", refreshHealth)
|
||||
document.removeEventListener("visibilitychange", refreshHealth)
|
||||
const controller = healthRequest.current
|
||||
healthRequest.current = null
|
||||
controller?.abort()
|
||||
}
|
||||
}, [fetchSystemData, fetchHealthInfoCount, fetchUpdateStatus, activeTab])
|
||||
|
||||
@@ -390,6 +411,7 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
const refreshData = async () => {
|
||||
setIsRefreshing(true)
|
||||
await fetchSystemData()
|
||||
await fetchHealthInfoCount()
|
||||
setComponentKey((prev) => prev + 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
setIsRefreshing(false)
|
||||
@@ -421,6 +443,7 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
switch (activeTab) {
|
||||
case "overview": return t("navigation.overview")
|
||||
case "datastores": return t("pbs.datastores")
|
||||
case "pbs-tasks": return t("pbs.tasksTab")
|
||||
case "apps": return t("navigation.apps")
|
||||
case "vms": return t("navigation.virtualMachines")
|
||||
case "storage": return t("navigation.storage")
|
||||
@@ -717,6 +740,7 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
switch (id) {
|
||||
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
|
||||
case "datastores": return renderDirect("datastores", Database, t("pbs.datastores"))
|
||||
case "pbs-tasks": return renderDirect("pbs-tasks", ListChecks, t("pbs.tasksTab"))
|
||||
case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps"))
|
||||
case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines"))
|
||||
case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup"))
|
||||
@@ -726,7 +750,7 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<TabsList className={`hidden lg:grid w-full ${isPbs ? "grid-cols-4" : "grid-cols-7"} bg-card border border-border`}>
|
||||
<TabsList className={`hidden lg:grid w-full ${isPbs ? "grid-cols-5" : "grid-cols-7"} bg-card border border-border`}>
|
||||
{tabOrder.map(renderTop)}
|
||||
</TabsList>
|
||||
)
|
||||
@@ -788,6 +812,7 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
switch (id) {
|
||||
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
|
||||
case "datastores": return btn("datastores", Database, t("pbs.datastores"))
|
||||
case "pbs-tasks": return btn("pbs-tasks", ListChecks, t("pbs.tasksTab"))
|
||||
case "apps": return btn("apps", Grid3x3, t("navigation.apps"))
|
||||
case "vms": return btn("vms", Boxes, t("navigation.virtualMachines"))
|
||||
case "backup": return btn("backup", DatabaseBackup, t("navigation.backup"))
|
||||
@@ -842,7 +867,13 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
|
||||
{isPbs && (
|
||||
<TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0">
|
||||
<PBSPanel key={`datastores-${componentKey}`} localOnly />
|
||||
<PBSPanel key={`datastores-${componentKey}`} localOnly view="datastores" />
|
||||
<StorageOverview key={`datastore-disks-${componentKey}`} disksOnly />
|
||||
</TabsContent>
|
||||
)}
|
||||
{isPbs && (
|
||||
<TabsContent value="pbs-tasks" className="space-y-4 md:space-y-6 mt-0">
|
||||
<PBSPanel key={`pbs-tasks-${componentKey}`} localOnly view="tasks" />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone, Languages, Package, ScrollText, ArrowUpCircle } from "lucide-react"
|
||||
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup, Smartphone, Languages, Package, ScrollText, ArrowUpCircle, Search } from "lucide-react"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { APP_VERSION } from "../lib/version"
|
||||
@@ -18,6 +18,64 @@ interface ReleaseNote {
|
||||
}
|
||||
|
||||
export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
"1.2.8": {
|
||||
date: "November 2, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Custom image installs Compose files with several services, one LXC per service on a private network (contributed by @jcastro).",
|
||||
"OCI: a VLAN tag for the access interface (requested by @xistoso), and Ollama and llama.cpp on Intel and AMD integrated GPUs.",
|
||||
"Seventeen new OCI applications, among them Authentik, TeslaMate, Zigbee2MQTT, Umami, Scrypted and NetAlertX (contributed by @jcastro) and Scanopy (suggested by @gabglus).",
|
||||
"Proxmox updates run as a job on the server and carry on after signing out, and a pending reboot is shown after a new kernel (contributed by @Vaso73).",
|
||||
"ZFS pool layout, scrub and device health, and the storage controller of each disk (contributed by @DrOwl).",
|
||||
"VM disk thresholds in Settings (suggested by @byte21516), and an optional API key for local Ollama.",
|
||||
],
|
||||
changed: [
|
||||
"Variables added or changed in Proxmox stay in an OCI container: they are saved in its oci-compose and the update carries on with them, manual or scheduled, on single applications and on every member of a stack.",
|
||||
"OCI updates keep the rest of what is changed in Proxmox: start command, network options, DNS, hostname, features, protection, disk sizes and lxc.* lines.",
|
||||
"Log2RAM 2.0: the journal and the task logs of Proxmox stay on disk with their history (reported by @shtefko).",
|
||||
"Every disk is monitored unless it is excluded in Settings (reported by @ovikiss and @peterurbanec).",
|
||||
"A service reloading or restarting counts as running, and nothing is concluded during a package update (reported by @Joshua1264).",
|
||||
],
|
||||
fixed: [
|
||||
"Host backup jobs run on their schedule, and updating ProxMenux repairs saved jobs.",
|
||||
"The Intel GPU view ends intel_gpu_top with each reading (reported by @FromTheCToTheG).",
|
||||
"VM disk usage counts every filesystem of the guest (fix proposed by @TheFoxCraft).",
|
||||
"The LVM check keeps the Proxmox filter (reported by @osipovdm-dot), and Secure Gateway installs on BTRFS hosts (reported by @78v5).",
|
||||
"The Monitor installs as an app (PWA) from the browser, with its own web app manifest and service worker.",
|
||||
"A disk I/O error is recorded on its disk.",
|
||||
"The Y-axis titles stay inside the overview charts (contributed by @f3rs3n).",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.2.7": {
|
||||
date: "October 26, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Audit & Report — a new page under Admin that assesses the node against forty-three Proxmox checks, keeps a journal of everything ProxMenux changed on the host, lets the expected state be declared as a policy and prints focused reports.",
|
||||
"OCI manager Apps (Technology Preview) — applications installed from their official container images as native Proxmox LXC, with no Docker engine: a catalog of 514 applications, multi-container applications, hardware acceleration, updates that keep the data and recovery after a restore.",
|
||||
"OCI applications in the Monitor — registered in the App tab, updated and modified from the Updates tab, with a Logs tab that follows their console and a watchdog switch.",
|
||||
"ProxMenux on Proxmox Backup Server — the same installer, a menu, post-install and a Monitor of its own; a remote PBS can also be watched from PVE with a read-only token (contributed by @jcastro).",
|
||||
"Search in the main menu across ProxMenux options, OCI applications and Helper-Scripts, and Helper-Scripts addons run on the host or inside the LXC they belong to.",
|
||||
"Backup jobs of VMs and LXCs listed, created, edited and run from the Backup tab (contributed by @MattiaC46); VM and CT backups can be deleted and the retention of each backup storage edited (contributed by @jcastro).",
|
||||
"Notifications in the reader's language (contributed by @Vaso73), a delivery history in the notification settings and grouped application update notifications.",
|
||||
"The NVIDIA Container Toolkit installs with the driver, a ZRAM swap profile joins the customizable post-install (contributed by @phyrexia) and FygoOS joins the NAS systems of Create VM.",
|
||||
],
|
||||
changed: [
|
||||
"Read-only API tokens only read: every action that changes something, opening a terminal and revealing a stored credential require a full-admin token (reported by @f3rs3n).",
|
||||
"Docker applications are registered with their own identity while their updates stay tied to the image (contributed by @byGarcia).",
|
||||
"The observations of a disk belong to its serial number, and idle mechanical disks are left to spin down (reported by @spectante).",
|
||||
"Find applications recognises services installed by hand and Docker containers with published ports, and LXC package checks and installs are separate steps (contributed by @Vaso73).",
|
||||
"The node name in the header opens the other Monitors of the cluster and the configured PBS servers.",
|
||||
],
|
||||
fixed: [
|
||||
"Proxmox notifications arrive with HTTPS enabled on the Monitor, through a local listener that needs no certificate.",
|
||||
"The Monitor leaves /etc/pve alone while Proxmox restarts its cluster service (reported by @shtefko).",
|
||||
"Host backups work on hosts with SELinux (reported by @sbaghdadi), and the node web certificate is restored on the node it belongs to (reported by @tropicaljoe).",
|
||||
"Log2RAM keeps the newest records of the journal and compresses rotated logs at once (reported by @f3rs3n).",
|
||||
"Changing the mode of a GPU leaves the devices that share its slot alone (reported by @LucasR3C3).",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.2.6": {
|
||||
date: "September 2, 2026",
|
||||
changes: {
|
||||
@@ -306,48 +364,38 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
icon: <Package className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociManager",
|
||||
text: "OCI manager Apps (Technology Preview) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries over 360 applications.",
|
||||
},
|
||||
{
|
||||
icon: <ScrollText className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociInMonitor",
|
||||
text: "OCI applications in the Monitor — the App tab recognises them, the Updates tab updates and modifies them as the menu does, and a Logs tab follows the console output of the application live.",
|
||||
},
|
||||
{
|
||||
icon: <Cpu className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociAcceleration",
|
||||
text: "Hardware acceleration for OCI applications — the installer offers only the profiles the host can run: NVIDIA, Intel and AMD GPUs and, for Frigate, an Intel NPU or a Coral. The NVIDIA Container Toolkit installs with the driver.",
|
||||
},
|
||||
{
|
||||
icon: <DatabaseBackup className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociRecovery",
|
||||
text: "An OCI application travels with its backup — restored on this host or on another one, it is registered and managed again, and in a cluster it starts on whichever node it moves to.",
|
||||
key: "releaseNotes.currentFeatures.composeStacks",
|
||||
text: "Compose files with several services — Custom image installs one LXC per service on a private network, started in dependency order. Updates, backups and removal work on the whole application.",
|
||||
},
|
||||
{
|
||||
icon: <RefreshCw className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociWatchdog",
|
||||
text: "Watchdog for OCI applications — an application that stops on its own is started again and a notification says so. It is not restarted when the user stops it, or while a backup or a migration is in progress.",
|
||||
key: "releaseNotes.currentFeatures.keepProxmoxChanges",
|
||||
text: "OCI updates keep what you change in Proxmox — variables, start command, network options, DNS, features, disk sizes and lxc.* lines stay on the rebuilt container, also in scheduled updates and on every member of a stack.",
|
||||
},
|
||||
{
|
||||
icon: <Server className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.shareCoveX",
|
||||
text: "ShareCoveX joins the catalog — a file server with a web panel that shares host directories or container volumes over SMB, NFS and Time Machine.",
|
||||
icon: <Globe className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociVlan",
|
||||
text: "VLAN tag for OCI applications — the advanced installation asks for it next to the address, and Modify changes it on a single application. The host needs no access to that VLAN.",
|
||||
},
|
||||
{
|
||||
icon: <Calendar className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.guestBackupJobs",
|
||||
text: "Backup jobs of VMs and LXCs — the Backup tab lists the scheduled jobs of Proxmox and creates, edits, enables and runs them; they are the same ones as Datacenter → Backup (contributed by @MattiaC46).",
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.ociNewApps",
|
||||
text: "New in the OCI catalog — Authentik, TeslaMate, Zigbee2MQTT, Scanopy, Umami, Scrypted, NetAlertX and ten more applications, and Ollama and llama.cpp run on Intel and AMD integrated GPUs.",
|
||||
},
|
||||
{
|
||||
icon: <Languages className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.notificationLanguage",
|
||||
text: "Notifications in your language — titles, bodies and daily digests are written in the language chosen for notifications (contributed by @Vaso73).",
|
||||
icon: <ArrowUpCircle className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.hostUpdateJob",
|
||||
text: "Proxmox updates that carry on after signing out — the update runs as a job on the server and signing back in reopens it. A pending reboot is shown after a new kernel, with a restart button that checks the host first.",
|
||||
},
|
||||
{
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.auditReviewDate",
|
||||
text: "Audit & Report — an accepted finding can carry a review date that brings it back without withdrawing the decision, and the comparison says whether a finding got worse, got better or held (with @Vaso73).",
|
||||
icon: <HardDrive className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.storageDetails",
|
||||
text: "Storage details — ZFS pools show their layout, scrub and device health, and each disk shows the storage controller it hangs off, also in the Hardware page. VM disk thresholds join Settings.",
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
key: "releaseNotes.currentFeatures.log2ramV2",
|
||||
text: "Log2RAM 2.0 — the journal and the task logs of Proxmox stay on disk with their history, and only the logs that change all the time go to RAM. Installed hosts update to it with ProxMenux.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import "xterm/css/xterm.css"
|
||||
import { API_PORT } from "@/lib/api-config"
|
||||
import { getTicketedWsUrl } from "@/lib/terminal-ws"
|
||||
import { scriptCompletion, scriptCompletionOutput } from "@/lib/script-completion"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface WebInteraction {
|
||||
@@ -52,6 +53,9 @@ interface ScriptTerminalModalProps {
|
||||
params?: Record<string, string>
|
||||
completedSuccessfullyMessage?: string
|
||||
completedWithErrorMessage?: (exitCode: number) => string
|
||||
cancelledExitCode?: number
|
||||
// Explicit server-owned transports; ordinary /ws/script keeps its contract.
|
||||
backgroundUpdate?: { runId: string; vmid?: number; resume: boolean; kind?: "host" }
|
||||
// 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
|
||||
@@ -69,6 +73,8 @@ export function ScriptTerminalModal({
|
||||
params = { EXECUTION_MODE: "web" },
|
||||
completedSuccessfullyMessage,
|
||||
completedWithErrorMessage,
|
||||
cancelledExitCode,
|
||||
backgroundUpdate,
|
||||
onComplete,
|
||||
}: ScriptTerminalModalProps) {
|
||||
const t = useT()
|
||||
@@ -78,6 +84,7 @@ export function ScriptTerminalModal({
|
||||
// after dynamic imports resolve — captures the latest value without
|
||||
// re-binding the closure.
|
||||
const isOpenRef = useRef<boolean>(false)
|
||||
const openGenerationRef = useRef(0)
|
||||
const fitAddonRef = useRef<any>(null)
|
||||
const sessionIdRef = useRef<string>(Math.random().toString(36).substring(2, 8))
|
||||
|
||||
@@ -90,6 +97,7 @@ export function ScriptTerminalModal({
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const completionReceivedRef = useRef(false)
|
||||
const outputOffsetRef = useRef(0)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isTablet, setIsTablet] = useState(false)
|
||||
|
||||
@@ -103,10 +111,12 @@ export function ScriptTerminalModal({
|
||||
|
||||
const getCompletionMessage = useCallback(
|
||||
(exitCode: number) =>
|
||||
exitCode === 0
|
||||
scriptCompletion(exitCode, cancelledExitCode).kind === "cancelled"
|
||||
? t("scriptTerminal.cancelled")
|
||||
: exitCode === 0
|
||||
? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully"))
|
||||
: (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })),
|
||||
[completedSuccessfullyMessage, completedWithErrorMessage, t],
|
||||
[completedSuccessfullyMessage, completedWithErrorMessage, cancelledExitCode, t],
|
||||
)
|
||||
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -125,7 +135,7 @@ export function ScriptTerminalModal({
|
||||
}, [onComplete])
|
||||
|
||||
const attemptReconnect = useCallback(() => {
|
||||
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||
if (!isOpenRef.current || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -139,12 +149,20 @@ export function ScriptTerminalModal({
|
||||
reconnectTimeoutRef.current = setTimeout(async () => {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
|
||||
if (wsRef.current) {
|
||||
// Replacing a stale socket is not a script result. Its close event
|
||||
// must not complete the operation before the new socket reports it.
|
||||
wsRef.current.onclose = null
|
||||
wsRef.current.onerror = null
|
||||
wsRef.current.onmessage = null
|
||||
wsRef.current.close()
|
||||
}
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
|
||||
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
|
||||
const generation = openGenerationRef.current
|
||||
const ticketedUrl = await getTicketedWsUrl(wsUrl)
|
||||
if (!isOpenRef.current || !termRef.current || generation !== openGenerationRef.current) return
|
||||
const ws = new WebSocket(ticketedUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -160,9 +178,13 @@ export function ScriptTerminalModal({
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
const initMessage = backgroundUpdate?.kind === "host" ? {
|
||||
resume: true, offset: outputOffsetRef.current,
|
||||
} : {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
...(backgroundUpdate ? { resume: true, vmid: backgroundUpdate.vmid,
|
||||
offset: outputOffsetRef.current } : {}),
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
@@ -176,6 +198,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (completionReceivedRef.current) return
|
||||
// Filter out pong responses from heartbeat
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
@@ -184,15 +207,15 @@ const initMessage = {
|
||||
// The PTY worker always emits this final line. Treat it as a
|
||||
// completion fallback because some WebSocket servers tear down the
|
||||
// connection before the following structured message is flushed.
|
||||
const exitMatch = typeof event.data === "string"
|
||||
const exitMatch = !backgroundUpdate && typeof event.data === "string"
|
||||
? event.data.match(/\[Script exited with code (-?\d+)\]/)
|
||||
: null
|
||||
if (exitMatch) {
|
||||
const exitCode = Number(exitMatch[1])
|
||||
termRef.current?.write(event.data)
|
||||
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") termRef.current?.write(event.data)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
termRef.current?.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -200,6 +223,35 @@ const initMessage = {
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "host_update_state") {
|
||||
if (msg.state === "attention") termRef.current?.writeln(t("healthStatus.hostUpdate.attention"))
|
||||
return
|
||||
}
|
||||
if (msg.type === "job_output") {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
if (msg.truncated) termRef.current?.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
|
||||
termRef.current?.write(msg.text)
|
||||
outputOffsetRef.current = msg.offset
|
||||
return
|
||||
}
|
||||
if (msg.type === "job_unavailable") {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
if (backgroundUpdate && msg.type === "web_interaction" && msg.interaction === null) {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
return
|
||||
}
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
@@ -220,10 +272,15 @@ const initMessage = {
|
||||
return
|
||||
}
|
||||
if (msg.type === "script_complete") {
|
||||
if (backgroundUpdate) {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
const exitCode = Number(msg.exit_code ?? 1)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
termRef.current?.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -244,6 +301,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!isOpenRef.current) return
|
||||
setConnectionStatus("offline")
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
@@ -254,14 +312,18 @@ const initMessage = {
|
||||
}
|
||||
if (!isComplete && reconnectAttemptsRef.current < 3) {
|
||||
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
|
||||
} else {
|
||||
if (backgroundUpdate) {
|
||||
termRef.current?.writeln(t("vmLxc.updates.backgroundDetached"))
|
||||
} else {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, [isOpen, isComplete, scriptPath, getCompletionMessage, t])
|
||||
}, [isOpen, isComplete, scriptPath, getCompletionMessage, cancelledExitCode, t, backgroundUpdate])
|
||||
|
||||
const sendKey = useCallback((key: string) => {
|
||||
if (!termRef.current) return
|
||||
@@ -289,12 +351,13 @@ const initMessage = {
|
||||
// otherwise we attach a Terminal to a stale ref and open a WS that
|
||||
// nobody reads.
|
||||
const wasOpenAtCall = isOpenRef.current
|
||||
const generation = ++openGenerationRef.current
|
||||
const [TerminalClass, FitAddonClass] = await Promise.all([
|
||||
import("xterm").then((mod) => mod.Terminal),
|
||||
import("xterm-addon-fit").then((mod) => mod.FitAddon),
|
||||
import("xterm/css/xterm.css"),
|
||||
])
|
||||
if (!wasOpenAtCall || !isOpenRef.current) return
|
||||
if (!wasOpenAtCall || !isOpenRef.current || generation !== openGenerationRef.current) return
|
||||
|
||||
const fontSize = window.innerWidth < 768 ? 12 : 16
|
||||
|
||||
@@ -355,7 +418,9 @@ const initMessage = {
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
// Single-use auth ticket appended as ?ticket=... — see lib/terminal-ws.ts.
|
||||
const ws = new WebSocket(await getTicketedWsUrl(wsUrl))
|
||||
const ticketedUrl = await getTicketedWsUrl(wsUrl)
|
||||
if (!isOpenRef.current || termRef.current !== term) return
|
||||
const ws = new WebSocket(ticketedUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -370,9 +435,13 @@ const initMessage = {
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
const initMessage = backgroundUpdate?.kind === "host" ? {
|
||||
resume: backgroundUpdate.resume, offset: outputOffsetRef.current,
|
||||
} : {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
...(backgroundUpdate ? { resume: backgroundUpdate.resume,
|
||||
vmid: backgroundUpdate.vmid, offset: outputOffsetRef.current } : {}),
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
@@ -392,6 +461,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (completionReceivedRef.current) return
|
||||
// Filter out pong responses from heartbeat - don't display in terminal
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
@@ -400,15 +470,15 @@ const initMessage = {
|
||||
// See the reconnect handler above. The exit line is guaranteed to be
|
||||
// sent with the PTY output and is therefore a robust fallback when a
|
||||
// final JSON frame is lost during server-side socket teardown.
|
||||
const exitMatch = typeof event.data === "string"
|
||||
const exitMatch = !backgroundUpdate && typeof event.data === "string"
|
||||
? event.data.match(/\[Script exited with code (-?\d+)\]/)
|
||||
: null
|
||||
if (exitMatch) {
|
||||
const exitCode = Number(exitMatch[1])
|
||||
term.write(event.data)
|
||||
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") term.write(event.data)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
term.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -416,7 +486,36 @@ const initMessage = {
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "host_update_state") {
|
||||
if (msg.state === "attention") term.writeln(t("healthStatus.hostUpdate.attention"))
|
||||
return
|
||||
}
|
||||
if (msg.type === "job_output") {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
if (msg.truncated) term.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
|
||||
term.write(msg.text)
|
||||
outputOffsetRef.current = msg.offset
|
||||
return
|
||||
}
|
||||
if (msg.type === "job_unavailable") {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (backgroundUpdate && msg.type === "web_interaction" && msg.interaction === null) {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
return
|
||||
}
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
@@ -438,10 +537,15 @@ const initMessage = {
|
||||
return
|
||||
}
|
||||
if (msg.type === "script_complete") {
|
||||
if (backgroundUpdate) {
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
const exitCode = Number(msg.exit_code ?? 1)
|
||||
completionReceivedRef.current = true
|
||||
setIsComplete(true)
|
||||
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
|
||||
term.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
|
||||
onCompleteRef.current?.(exitCode)
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
|
||||
return
|
||||
@@ -466,6 +570,7 @@ const initMessage = {
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (!isOpenRef.current) return
|
||||
setConnectionStatus("offline")
|
||||
if (!completionReceivedRef.current) {
|
||||
term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`)
|
||||
@@ -477,10 +582,15 @@ const initMessage = {
|
||||
}
|
||||
|
||||
if (!completionReceivedRef.current && !isComplete) {
|
||||
if (backgroundUpdate) {
|
||||
term.writeln(t("vmLxc.updates.backgroundDetached"))
|
||||
attemptReconnect()
|
||||
} else {
|
||||
setIsComplete(true)
|
||||
onCompleteRef.current?.(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read `wsRef.current` inside the handler so reconnect (which swaps
|
||||
// `wsRef.current` to a fresh WebSocket) doesn't leave us writing to the
|
||||
@@ -540,6 +650,7 @@ const initMessage = {
|
||||
if (isOpen) {
|
||||
initializeTerminal()
|
||||
} else {
|
||||
openGenerationRef.current++
|
||||
if (checkConnectionInterval.current) {
|
||||
clearInterval(checkConnectionInterval.current)
|
||||
}
|
||||
@@ -566,6 +677,7 @@ const initMessage = {
|
||||
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
|
||||
reconnectAttemptsRef.current = 0
|
||||
completionReceivedRef.current = false
|
||||
outputOffsetRef.current = 0
|
||||
setIsComplete(false)
|
||||
setInteractionInput("")
|
||||
setCurrentInteraction(null)
|
||||
@@ -574,6 +686,28 @@ const initMessage = {
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (!backgroundUpdate) return // Transport mode is fixed for this instance.
|
||||
// Conditional parents can unmount an open modal. Detach that viewer too;
|
||||
// only the background job owns its process and completion result.
|
||||
isOpenRef.current = false
|
||||
openGenerationRef.current++
|
||||
for (const ref of [checkConnectionInterval, keepAliveIntervalRef]) {
|
||||
if (ref.current) clearInterval(ref.current)
|
||||
}
|
||||
for (const ref of [waitingTimeoutRef, reconnectTimeoutRef]) {
|
||||
if (ref.current) clearTimeout(ref.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null
|
||||
wsRef.current.onmessage = null
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
termRef.current?.dispose()
|
||||
termRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const updateDeviceType = () => {
|
||||
const width = window.innerWidth
|
||||
@@ -629,8 +763,11 @@ const initMessage = {
|
||||
}, [isOpen, isComplete, attemptReconnect])
|
||||
|
||||
const getScriptWebSocketUrl = (sid: string): string => {
|
||||
const route = backgroundUpdate
|
||||
? `/ws/${backgroundUpdate.kind === "host" ? "host-update" : "lxc-update"}/${encodeURIComponent(backgroundUpdate.runId)}`
|
||||
: `/ws/script/${sid}`
|
||||
if (typeof window === "undefined") {
|
||||
return `ws://localhost:${API_PORT}/ws/script/${sid}`
|
||||
return `ws://localhost:${API_PORT}${route}`
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
@@ -638,9 +775,9 @@ const initMessage = {
|
||||
const wsProtocol = protocol === "https:" ? "wss:" : "ws:"
|
||||
|
||||
if (isStandardPort) {
|
||||
return `${wsProtocol}//${hostname}/ws/script/${sid}`
|
||||
return `${wsProtocol}//${hostname}${route}`
|
||||
} else {
|
||||
return `${wsProtocol}//${hostname}:${API_PORT}/ws/script/${sid}`
|
||||
return `${wsProtocol}//${hostname}:${API_PORT}${route}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,6 +789,10 @@ const initMessage = {
|
||||
if (value === "cancel" || value === "") {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
if (backgroundUpdate) {
|
||||
wsRef.current.send(JSON.stringify({ type: "cancel" }))
|
||||
return
|
||||
}
|
||||
handleCloseModal()
|
||||
return
|
||||
}
|
||||
@@ -814,7 +955,7 @@ const initMessage = {
|
||||
)}
|
||||
|
||||
{(isMobile || isTablet) && (
|
||||
<div className="flex items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex flex-wrap items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { 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, Github } from "lucide-react"
|
||||
import { Badge } from "./ui/badge"
|
||||
@@ -9,6 +9,7 @@ import { NotificationSettings } from "./notification-settings"
|
||||
import { HealthThresholds } from "./health-thresholds"
|
||||
import { LxcUpdateDetection } from "./lxc-update-detection"
|
||||
import { DiskExclusions } from "./disk-exclusions"
|
||||
import { optimizationDescription, optimizationToolName } from "../lib/optimization-descriptions"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Switch } from "./ui/switch"
|
||||
@@ -19,6 +20,8 @@ import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider"
|
||||
import { useIsPbs } from "../lib/use-platform"
|
||||
import type { LanguageCode } from "../lib/i18n/languages"
|
||||
import { NavTabOrderCard } from "./nav-tab-order-card"
|
||||
import { StorageDisplayCard } from "./storage-display-card"
|
||||
import { optimizationSourceError, optimizationSourceName, type OptimizationSourceError, type OptimizationSourceResponse } from "../lib/optimization-source-errors"
|
||||
|
||||
// GitHub Dark color palette for bash syntax highlighting
|
||||
const BASH_KEYWORDS = new Set([
|
||||
@@ -354,15 +357,31 @@ export function Settings() {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
toolName: string
|
||||
toolKey: string
|
||||
version: string
|
||||
availableVersion: string
|
||||
functionName: string
|
||||
source: string
|
||||
script: string
|
||||
error: string
|
||||
error: OptimizationSourceError | null
|
||||
deprecated: boolean
|
||||
}>({ open: false, loading: false, toolName: '', version: '', availableVersion: '', functionName: '', source: '', script: '', error: '', deprecated: false })
|
||||
}>({ open: false, loading: false, toolName: '', toolKey: '', version: '', availableVersion: '', functionName: '', source: '', script: '', error: null, deprecated: false })
|
||||
const [codeCopied, setCodeCopied] = useState(false)
|
||||
const codeRequest = useRef(0)
|
||||
const closeCodeModal = () => {
|
||||
codeRequest.current += 1
|
||||
setCodeModal(prev => ({ ...prev, open: false }))
|
||||
setCodeCopied(false)
|
||||
}
|
||||
useEffect(() => () => { codeRequest.current += 1 }, [])
|
||||
useEffect(() => {
|
||||
if (!codeModal.open) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") closeCodeModal()
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
return () => window.removeEventListener("keydown", onKeyDown)
|
||||
}, [codeModal.open])
|
||||
|
||||
// Health Monitor suppression settings
|
||||
const [suppressionCategories, setSuppressionCategories] = useState<SuppressionCategory[]>([])
|
||||
@@ -580,7 +599,7 @@ export function Settings() {
|
||||
if (entries.length === 0) return
|
||||
const batch = entries.map(e => `${e.source}:${e.function}:${e.key}`).join("\n")
|
||||
const title = entries.length === 1
|
||||
? t("settings.optimizations.updateOneTitle", { name: entries[0].name })
|
||||
? t("settings.optimizations.updateOneTitle", { name: optimizationToolName(entries[0].key, entries[0].name, t) })
|
||||
: t("settings.optimizations.updateManyTitle", { count: entries.length })
|
||||
const description = entries.length === 1
|
||||
? t("settings.optimizations.updateOneDescription", { functionName: entries[0].function, source: entries[0].source })
|
||||
@@ -663,31 +682,39 @@ export function Settings() {
|
||||
}
|
||||
|
||||
const viewToolSource = async (tool: ProxMenuxTool) => {
|
||||
const request = ++codeRequest.current
|
||||
setCodeCopied(false)
|
||||
setCodeModal({
|
||||
open: true,
|
||||
loading: true,
|
||||
toolName: tool.name,
|
||||
toolKey: tool.key,
|
||||
version: tool.version || '1.0',
|
||||
availableVersion: tool.available_version || tool.version || '1.0',
|
||||
functionName: '',
|
||||
source: '',
|
||||
script: '',
|
||||
error: '',
|
||||
error: null,
|
||||
deprecated: !!tool.deprecated,
|
||||
})
|
||||
try {
|
||||
const data = await fetchApi(`/api/proxmenux/tool-source/${tool.key}`)
|
||||
if (data.success) {
|
||||
setCodeModal(prev => ({ ...prev, loading: false, functionName: data.function, source: data.source, script: data.script, deprecated: !!data.deprecated }))
|
||||
const data = await fetchApi<OptimizationSourceResponse | null>(`/api/proxmenux/tool-source/${tool.key}`)
|
||||
if (request !== codeRequest.current) return
|
||||
if (data?.success === true && typeof data.source === "string" && data.source.trim()) {
|
||||
const source = data.source
|
||||
setCodeModal(prev => ({ ...prev, loading: false, functionName: typeof data.function === "string" ? data.function : '', source, script: typeof data.script === "string" ? data.script : '', deprecated: !!data.deprecated }))
|
||||
} else {
|
||||
setCodeModal(prev => ({ ...prev, loading: false, error: data.error || 'Source code not available' }))
|
||||
setCodeModal(prev => ({ ...prev, loading: false, error: { kind: "unavailable", detail: typeof data?.error === "string" ? data.error : undefined } }))
|
||||
}
|
||||
} catch {
|
||||
setCodeModal(prev => ({ ...prev, loading: false, error: 'Failed to load source code' }))
|
||||
} catch (error: any) {
|
||||
if (request !== codeRequest.current) return
|
||||
const detail = typeof error?.body?.error === "string" ? error.body.error : undefined
|
||||
setCodeModal(prev => ({ ...prev, loading: false, error: { kind: "loadFailed", detail } }))
|
||||
}
|
||||
}
|
||||
|
||||
const copySourceCode = async () => {
|
||||
const request = codeRequest.current
|
||||
const text = codeModal.source
|
||||
let ok = false
|
||||
|
||||
@@ -721,9 +748,9 @@ export function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
if (ok && request === codeRequest.current) {
|
||||
setCodeCopied(true)
|
||||
setTimeout(() => setCodeCopied(false), 2000)
|
||||
setTimeout(() => { if (request === codeRequest.current) setCodeCopied(false) }, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1207,6 +1234,9 @@ export function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Storage Display: group physical disks by controller */}
|
||||
<StorageDisplayCard />
|
||||
|
||||
{/* Health Monitor Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1878,7 +1908,7 @@ export function Settings() {
|
||||
the matching toggle in NotificationSettings is hidden (the
|
||||
preference is preserved in the DB and reappears when detection
|
||||
is re-enabled). */}
|
||||
<LxcUpdateDetection />
|
||||
{!isPbs && <LxcUpdateDetection />}
|
||||
|
||||
{!isPbs && <>
|
||||
{/* GitHub API — optional authentication for app upstream checks. */}
|
||||
@@ -2117,7 +2147,7 @@ export function Settings() {
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
hasUpdate ? 'bg-purple-400' : (isDeprecated ? 'bg-amber-500' : 'bg-green-500')
|
||||
}`} />
|
||||
<span className="text-sm font-medium truncate">{tool.name}</span>
|
||||
<span className="text-sm font-medium truncate">{optimizationToolName(tool.key, tool.name, t)}</span>
|
||||
{isDeprecated && (
|
||||
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
{t("settings.optimizations.legacy")}
|
||||
@@ -2133,7 +2163,7 @@ export function Settings() {
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleSingleToolUpdate(tool) }}
|
||||
className="text-purple-300 hover:text-purple-200 transition-colors"
|
||||
title={t("settings.optimizations.updateToolTitle", { name: tool.name, version: tool.available_version || "?" })}
|
||||
title={t("settings.optimizations.updateToolTitle", { name: optimizationToolName(tool.key, tool.name, t), version: tool.available_version || "?" })}
|
||||
>
|
||||
<ArrowUpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -2153,10 +2183,13 @@ export function Settings() {
|
||||
|
||||
{/* Code Viewer Modal */}
|
||||
{codeModal.open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setCodeModal(prev => ({ ...prev, open: false }))}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ margin: 0 }} onClick={closeCodeModal}>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("settings.optimizations.sourceDialogTitle", { name: optimizationSourceName(codeModal.toolKey, codeModal.toolName, t) })}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
@@ -2165,7 +2198,7 @@ export function Settings() {
|
||||
<Code className={`h-5 w-5 flex-shrink-0 ${codeModal.deprecated ? 'text-amber-500' : 'text-orange-500'}`} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-sm font-semibold truncate">{codeModal.toolName}</h3>
|
||||
<h3 className="text-sm font-semibold truncate">{optimizationSourceName(codeModal.toolKey, codeModal.toolName, t)}</h3>
|
||||
{codeModal.deprecated && (
|
||||
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
{t("settings.optimizations.legacy")}
|
||||
@@ -2201,7 +2234,8 @@ export function Settings() {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCodeModal(prev => ({ ...prev, open: false }))}
|
||||
onClick={closeCodeModal}
|
||||
aria-label={t("actions.close")}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -2211,13 +2245,13 @@ export function Settings() {
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-0">
|
||||
{codeModal.loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center justify-center py-16" role="status" aria-label={t("settings.optimizations.sourceLoading")}>
|
||||
<div className="animate-spin h-8 w-8 border-4 border-orange-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : codeModal.error ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Code className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="text-sm">{codeModal.error}</p>
|
||||
<p className="text-sm" role="alert">{optimizationSourceError(codeModal.error, t)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
@@ -2289,13 +2323,13 @@ export function Settings() {
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{tool.name}</span>
|
||||
<span className="text-sm font-medium">{optimizationToolName(tool.key, tool.name, t)}</span>
|
||||
<span className="text-[10px] text-purple-300 bg-purple-500/15 border border-purple-500/30 px-1.5 py-0.5 rounded font-mono">
|
||||
v{tool.version || '1.0'} → v{tool.available_version || '?'}
|
||||
</span>
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-snug">{tool.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-snug">{optimizationDescription(tool.description, t)}</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Switch } from "./ui/switch"
|
||||
import { Check, HardDrive, Settings2 } from "lucide-react"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { useGroupDisksByController } from "../lib/storage-display"
|
||||
|
||||
export function StorageDisplayCard() {
|
||||
const t = useT()
|
||||
const [groupByController, setGroupByController] = useGroupDisksByController()
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [pending, setPending] = useState<boolean | null>(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const handleCancel = () => {
|
||||
setPending(null)
|
||||
setEditMode(false)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (pending !== null && pending !== groupByController) {
|
||||
setGroupByController(pending)
|
||||
}
|
||||
setPending(null)
|
||||
setEditMode(false)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle>{t("settings.storageDisplay.title")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("status.saved")}
|
||||
</span>
|
||||
)}
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{t("actions.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
onClick={handleSave}
|
||||
disabled={pending === null || pending === groupByController}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{t("actions.save")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1.5"
|
||||
onClick={() => setEditMode(true)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
{t("actions.edit")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>{t("settings.storageDisplay.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className={editMode ? "bg-accent" : undefined}>
|
||||
<div className="text-foreground flex items-center justify-between gap-4">
|
||||
<div className="flex items-center">{t("settings.storageDisplay.groupByController")}</div>
|
||||
<Switch
|
||||
checked={pending ?? groupByController}
|
||||
disabled={!editMode}
|
||||
onCheckedChange={(checked) => setPending(checked)}
|
||||
className={`data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border ${!editMode ? "opacity-60" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power, Moon, EyeOff } from "lucide-react"
|
||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer, Archive, Info, Clock, Usb, Server, Activity, FileText, Play, Loader2, Download, Plus, Trash2, Settings, Power, EyeOff } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
@@ -14,6 +14,7 @@ import { DiskTemperatureCard } from "./disk-temperature-card"
|
||||
import { getDiskType as resolveDiskType } from "../lib/disk-type"
|
||||
import { APP_VERSION } from "../lib/version"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { useGroupDisksByController } from "../lib/storage-display"
|
||||
import {
|
||||
useDiskTempThresholds,
|
||||
loadDiskTempThresholds,
|
||||
@@ -70,7 +71,6 @@ interface DiskInfo {
|
||||
// badge AND to suppress the (stale) temperature value, so the
|
||||
// operator understands the graph is frozen on purpose — issue #232.
|
||||
standby?: boolean
|
||||
idle?: boolean
|
||||
excluded?: boolean
|
||||
health: string
|
||||
power_on_hours?: number
|
||||
@@ -102,6 +102,7 @@ interface DiskInfo {
|
||||
}
|
||||
observations_count?: number
|
||||
connection_type?: 'usb' | 'sata' | 'nvme' | 'sas' | 'internal' | 'unknown'
|
||||
controller_slot?: string | null // PCI slot of the storage controller, key into StorageData.controllers
|
||||
removable?: boolean
|
||||
is_system_disk?: boolean
|
||||
system_usage?: string[]
|
||||
@@ -121,12 +122,65 @@ interface DiskObservation {
|
||||
model: string
|
||||
}
|
||||
|
||||
// Pool layout from `zpool status -j` (OpenZFS 2.3+). Only present when
|
||||
// details_available is true; older ZFS gets the basic fields above.
|
||||
interface ZFSDevice {
|
||||
path: string
|
||||
disk: string | null // base block device (e.g. "sdb"), null for file vdevs
|
||||
state: string // ONLINE | DEGRADED | FAULTED | OFFLINE | UNAVAIL | REMOVED | AVAIL | INUSE
|
||||
read_errors: number
|
||||
write_errors: number
|
||||
checksum_errors: number
|
||||
slow_ios: number
|
||||
via: string | null // "spare-0" / "replacing-0" while a device is being swapped
|
||||
}
|
||||
|
||||
interface ZFSVdev {
|
||||
name: string
|
||||
type: string // mirror | raidz | draid | disk
|
||||
parity: number | null
|
||||
class: "normal" | "special" | "dedup" | "log" | "cache" | "spare"
|
||||
state: string
|
||||
read_errors: number
|
||||
write_errors: number
|
||||
checksum_errors: number
|
||||
devices: ZFSDevice[]
|
||||
}
|
||||
|
||||
interface ZFSScan {
|
||||
function: "SCRUB" | "RESILVER" | string
|
||||
state: "SCANNING" | "FINISHED" | "CANCELED" | string
|
||||
start_time: number | null
|
||||
end_time: number | null
|
||||
errors: number
|
||||
repaired_bytes: number
|
||||
percent: number | null // only while SCANNING
|
||||
}
|
||||
|
||||
interface ZFSPool {
|
||||
name: string
|
||||
size: string
|
||||
allocated: string
|
||||
free: string
|
||||
health: string
|
||||
fragmentation?: number | null
|
||||
capacity?: number | null
|
||||
dedup_ratio?: number | null
|
||||
details_available?: boolean
|
||||
status?: string | null
|
||||
action?: string | null
|
||||
error_count?: number
|
||||
scan?: ZFSScan | null
|
||||
vdevs?: ZFSVdev[]
|
||||
}
|
||||
|
||||
interface StorageController {
|
||||
slot: string
|
||||
class: string
|
||||
vendor: string
|
||||
device: string
|
||||
sdevice?: string | null
|
||||
driver?: string | null
|
||||
}
|
||||
|
||||
interface StorageData {
|
||||
@@ -134,6 +188,7 @@ interface StorageData {
|
||||
used: number
|
||||
available: number
|
||||
disks: DiskInfo[]
|
||||
controllers?: Record<string, StorageController>
|
||||
zfs_pools: ZFSPool[]
|
||||
disk_count: number
|
||||
healthy_disks: number
|
||||
@@ -231,7 +286,44 @@ function translateAtaError(raw: string, t: (key: string) => string): string | nu
|
||||
return out.length ? out.join('; ') : null
|
||||
}
|
||||
|
||||
export function StorageOverview() {
|
||||
// Bucket disks by controller PCI slot, ordered by slot so groups follow
|
||||
// the bus layout. Disks with no resolvable controller go last.
|
||||
function groupDisksBySlot(disks: DiskInfo[]): [string | null, DiskInfo[]][] {
|
||||
const groups = new Map<string | null, DiskInfo[]>()
|
||||
for (const disk of disks) {
|
||||
const slot = disk.controller_slot ?? null
|
||||
groups.set(slot, [...(groups.get(slot) ?? []), disk])
|
||||
}
|
||||
return [...groups.entries()].sort(([a], [b]) => {
|
||||
if (a === null) return 1
|
||||
if (b === null) return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
}
|
||||
|
||||
// `disksOnly` is the PBS Monitor: its disks and pools, without Proxmox VE storages.
|
||||
// Same ring as the disk wear gauge, with a faint track so 0% still reads.
|
||||
// Used for the fragmentation of a ZFS pool.
|
||||
function PercentRing({ value, color, label }: { value: number; color: string; label: string }) {
|
||||
const pct = Math.max(0, Math.min(100, value))
|
||||
return (
|
||||
<svg width="88" height="88" viewBox="0 0 88 88" className="flex-shrink-0">
|
||||
<circle cx="44" cy="44" r="35" fill="none" stroke={color} strokeOpacity="0.15" strokeWidth="6" />
|
||||
<circle cx="44" cy="44" r="35" fill="none" stroke={color} strokeWidth="6"
|
||||
strokeDasharray={`${pct * 2.199} 219.9`}
|
||||
strokeLinecap="round" transform="rotate(-90 44 44)" />
|
||||
<text x="44" y="40" textAnchor="middle" fill={color} fontSize="20" fontWeight="700">{pct}%</text>
|
||||
<text x="44" y="56" textAnchor="middle" fill="currentColor" fontSize="12" className="text-muted-foreground">{label}</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// OpenZFS reports a pool without its newer features enabled, which needs no action.
|
||||
const zfsStatusIsInformational = (status?: string | null) => /features are not enabled/i.test(status ?? "")
|
||||
|
||||
const zfsFragmentationColor = (pct: number) => (pct > 80 ? "#ef4444" : pct > 50 ? "#eab308" : "#06b6d4")
|
||||
|
||||
export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {}) {
|
||||
const t = useT()
|
||||
|
||||
// User-configurable disk temperature thresholds (Settings → Health
|
||||
@@ -239,6 +331,7 @@ export function StorageOverview() {
|
||||
// sensible defaults from `lib/health-thresholds`, so first paint
|
||||
// never blocks on the network.
|
||||
const dtThresholds = useDiskTempThresholds()
|
||||
const [groupDisksByController] = useGroupDisksByController()
|
||||
|
||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||
const [proxmoxStorage, setProxmoxStorage] = useState<ProxmoxStorageData | null>(null)
|
||||
@@ -249,6 +342,7 @@ export function StorageOverview() {
|
||||
const [mountDetail, setMountDetail] = useState<RemoteMount | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedDisk, setSelectedDisk] = useState<DiskInfo | null>(null)
|
||||
const [selectedPool, setSelectedPool] = useState<ZFSPool | null>(null)
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const [diskObservations, setDiskObservations] = useState<DiskObservation[]>([])
|
||||
const [loadingObservations, setLoadingObservations] = useState(false)
|
||||
@@ -258,6 +352,12 @@ export function StorageOverview() {
|
||||
// 85 vh capped by the visual viewport otherwise). Keeps every
|
||||
// detail modal at a matching size across the app.
|
||||
const [isStandalone, setIsStandalone] = useState(false)
|
||||
// Disk, ZFS pool and remote mount details share one size.
|
||||
const detailDialogClass = `max-w-4xl flex flex-col p-0 overflow-hidden ${
|
||||
isStandalone
|
||||
? "h-[95vh] sm:h-[90vh]"
|
||||
: "h-[85vh] sm:h-[85vh] max-h-[calc(100dvh-env(safe-area-inset-top)-env(safe-area-inset-bottom)-40px)]"
|
||||
}`
|
||||
|
||||
useEffect(() => {
|
||||
const checkStandalone = () => {
|
||||
@@ -284,7 +384,7 @@ export function StorageOverview() {
|
||||
try {
|
||||
const [data, proxmoxData, mountsData] = await Promise.all([
|
||||
fetchApi<StorageData>("/api/storage"),
|
||||
fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
|
||||
disksOnly ? Promise.resolve(null) : fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
|
||||
// Sprint 13 — host-level NFS/CIFS/SMB mounts. Wrapped in catch
|
||||
// so a failure here doesn't blank the whole storage tab.
|
||||
fetchApi<RemoteMountsData>("/api/mounts").catch(() => ({ mounts: [], available: false } as RemoteMountsData)),
|
||||
@@ -396,6 +496,57 @@ export function StorageOverview() {
|
||||
}
|
||||
}
|
||||
|
||||
// ZFS device/vdev states are shown verbatim, as `zpool status` prints them.
|
||||
const getZfsStateBadge = (state: string) => {
|
||||
const style =
|
||||
state === "ONLINE"
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: state === "AVAIL" || state === "INUSE"
|
||||
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
|
||||
: state === "DEGRADED"
|
||||
? "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
return <Badge className={`font-mono text-xs ${style}`}>{state}</Badge>
|
||||
}
|
||||
|
||||
const getZfsVdevLabel = (vdev: ZFSVdev) => {
|
||||
if (vdev.type === "mirror") return t("storage.zfs.mirror")
|
||||
if (vdev.type === "raidz") return t("storage.zfs.raidz", { parity: vdev.parity ?? 1 })
|
||||
if (vdev.type === "draid") return t("storage.zfs.draid", { parity: vdev.parity ?? 1 })
|
||||
return t("storage.zfs.singleDisk")
|
||||
}
|
||||
|
||||
// One-line pool shape for the card, e.g. "RAIDZ2", "2 x Mirror", "Stripe".
|
||||
const getZfsLayoutSummary = (pool: ZFSPool) => {
|
||||
const data = (pool.vdevs ?? []).filter((v) => v.class === "normal")
|
||||
if (data.length === 0) return null
|
||||
if (data.every((v) => v.type === "disk")) {
|
||||
return data.length === 1 ? t("storage.zfs.singleDisk") : t("storage.zfs.stripe")
|
||||
}
|
||||
const labels = [...new Set(data.map(getZfsVdevLabel))]
|
||||
if (labels.length === 1 && data.length > 1) return `${data.length} x ${labels[0]}`
|
||||
return labels.join(" + ")
|
||||
}
|
||||
|
||||
const formatZfsTime = (epoch: number | null | undefined) =>
|
||||
epoch ? new Date(epoch * 1000).toLocaleString() : t("common.notAvailable")
|
||||
|
||||
const getZfsScanSummary = (scan: ZFSScan | null | undefined) => {
|
||||
if (!scan) return t("storage.zfs.neverScrubbed")
|
||||
const kind = scan.function === "RESILVER" ? t("storage.zfs.resilver") : t("storage.zfs.scrub")
|
||||
if (scan.state === "SCANNING") {
|
||||
return t("storage.zfs.scanRunning", { kind, percent: scan.percent ?? 0 })
|
||||
}
|
||||
if (scan.state === "CANCELED") return t("storage.zfs.scanCanceled", { kind })
|
||||
return t("storage.zfs.scanFinished", {
|
||||
kind,
|
||||
date: scan.end_time ? new Date(scan.end_time * 1000).toLocaleDateString() : "",
|
||||
})
|
||||
}
|
||||
|
||||
const zfsDeviceErrors = (d: { read_errors: number; write_errors: number; checksum_errors: number }) =>
|
||||
d.read_errors + d.write_errors + d.checksum_errors
|
||||
|
||||
// Tiny coloured dot that prefixes status / counter values. Adds
|
||||
// accessibility-friendly redundancy (colour + position) to fields
|
||||
// that today rely on colour alone, so an "all OK" disk reads as
|
||||
@@ -436,7 +587,7 @@ export function StorageOverview() {
|
||||
// in 4 different disk-list views (system / data / pool / other) and we
|
||||
// want them all to behave identically — issue #232 fix.
|
||||
// Why a disk shows no live temperature, when it doesn't: excluded by the
|
||||
// user, parked, or idle and deliberately not read. Shared by every view
|
||||
// user, or parked. Shared by every view
|
||||
// that paints a disk's temperature, so they cannot disagree.
|
||||
const renderNoReadingBadge = (disk: DiskInfo) => {
|
||||
if (disk.excluded) {
|
||||
@@ -461,17 +612,6 @@ export function StorageOverview() {
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (disk.idle) {
|
||||
return (
|
||||
<Badge
|
||||
className="bg-muted text-muted-foreground border-border gap-1"
|
||||
title={t("storage.idleTitle")}
|
||||
>
|
||||
<Moon className="h-3 w-3" />
|
||||
{t("storage.idle")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -664,7 +804,7 @@ export function StorageOverview() {
|
||||
</div>
|
||||
)}
|
||||
{/* Reallocated only meaningful on rotating disks. */}
|
||||
{disk.reallocated_sectors !== undefined && (disk.rotation_rate ?? 0) > 0 && (
|
||||
{disk.reallocated_sectors !== undefined && resolveDiskType(disk.name, disk.rotation_rate) === "HDD" && (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{t("storage.reallocatedShort")}
|
||||
@@ -720,12 +860,7 @@ export function StorageOverview() {
|
||||
// Resolve disk class → threshold pair from the user-configurable
|
||||
// backend (single source of truth). The semantics: temp BELOW warn
|
||||
// is green, between warn and hot is amber, hot or above is red.
|
||||
let cls: keyof DiskTempMap = "HDD"
|
||||
if (diskName?.startsWith("nvme")) {
|
||||
cls = "NVMe"
|
||||
} else if (!rotationRate || rotationRate === 0) {
|
||||
cls = "SSD"
|
||||
}
|
||||
const cls: keyof DiskTempMap = resolveDiskType(diskName ?? "", rotationRate)
|
||||
const t = dtThresholds[cls]
|
||||
if (temp >= t.hot) return "text-red-500"
|
||||
if (temp >= t.warn) return "text-yellow-500"
|
||||
@@ -756,9 +891,9 @@ export function StorageOverview() {
|
||||
return t("storage.duration.daysShort", { days: totalDays })
|
||||
}
|
||||
|
||||
const formatRotationRate = (rpm: number | undefined) => {
|
||||
if (!rpm || rpm === 0) return "SSD"
|
||||
return `${rpm.toLocaleString()} RPM`
|
||||
const formatRotationRate = (diskName: string, rpm: number | undefined) => {
|
||||
if (getDiskType(diskName, rpm) !== "HDD") return "SSD"
|
||||
return rpm && rpm > 0 ? `${rpm.toLocaleString()} RPM` : t("common.notAvailable")
|
||||
}
|
||||
|
||||
// Thin wrapper over the shared classifier so the rest of the file
|
||||
@@ -975,7 +1110,7 @@ export function StorageOverview() {
|
||||
|
||||
storageData.disks.forEach((disk) => {
|
||||
// Match disk rows: policy-suppressed cached readings are not live temperatures.
|
||||
if (disk.standby || disk.idle || disk.excluded || typeof disk.temperature !== "number" ||
|
||||
if (disk.standby || disk.excluded || typeof disk.temperature !== "number" ||
|
||||
!Number.isFinite(disk.temperature) || disk.temperature <= 0) {
|
||||
unavailable++
|
||||
return
|
||||
@@ -1134,7 +1269,7 @@ export function StorageOverview() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Storage Summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
|
||||
{!disksOnly && <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
|
||||
{/* ── Total Storage (preview restyle: headline + stacked bar Local·Remote·Free) ── */}
|
||||
{(() => {
|
||||
const totalGB = (totalLocalCapacity || 0) + (totalRemoteCapacity || 0)
|
||||
@@ -1355,9 +1490,9 @@ export function StorageOverview() {
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
|
||||
{!disksOnly && proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -1610,7 +1745,7 @@ export function StorageOverview() {
|
||||
overlay with text-xs/text-[10px] all over and looked
|
||||
shrunken next to the rest of the modals. */}
|
||||
<Dialog open={!!mountDetail} onOpenChange={(open) => { if (!open) setMountDetail(null) }}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] sm:max-h-[85vh] overflow-hidden flex flex-col p-0">
|
||||
<DialogContent className={detailDialogClass}>
|
||||
{mountDetail && (() => {
|
||||
const m = mountDetail
|
||||
const isStale = m.status === "stale"
|
||||
@@ -1657,7 +1792,7 @@ export function StorageOverview() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 pb-6 overflow-auto space-y-5">
|
||||
<div className="flex-1 min-h-0 px-6 pb-6 overflow-y-auto space-y-5">
|
||||
{/* Type + tags */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge className={getStorageTypeBadge(m.fstype)}>{m.fstype}</Badge>
|
||||
@@ -1768,14 +1903,58 @@ export function StorageOverview() {
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{storageData.zfs_pools.map((pool) => (
|
||||
<div key={pool.name} className="border rounded-lg p-4">
|
||||
<div
|
||||
key={pool.name}
|
||||
className="border border-white/10 rounded-lg p-4 cursor-pointer bg-card hover:bg-white/5 transition-colors"
|
||||
onClick={() => setSelectedPool(pool)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-lg">{pool.name}</h3>
|
||||
{getHealthBadge(pool.health)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{pool.capacity != null && <span className="text-sm font-medium">{pool.capacity}%</span>}
|
||||
{getHealthIcon(pool.health)}
|
||||
</div>
|
||||
</div>
|
||||
{pool.details_available && (() => {
|
||||
const dataDevices = (pool.vdevs ?? [])
|
||||
.filter((v) => v.class === "normal")
|
||||
.reduce((n, v) => n + v.devices.length, 0)
|
||||
const problems = (pool.vdevs ?? []).some(
|
||||
(v) => v.devices.some((d) => zfsDeviceErrors(d) > 0) || zfsDeviceErrors(v) > 0,
|
||||
) || (pool.error_count ?? 0) > 0
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground mb-3 flex flex-wrap items-center gap-x-2">
|
||||
<span className="font-medium text-foreground">{getZfsLayoutSummary(pool)}</span>
|
||||
<span>|</span>
|
||||
<span>
|
||||
{t(dataDevices === 1 ? "storage.zfs.diskCountOne" : "storage.zfs.diskCountMany", { count: dataDevices })}
|
||||
</span>
|
||||
<span>|</span>
|
||||
<span>{getZfsScanSummary(pool.scan)}</span>
|
||||
{problems && (
|
||||
<span className="flex items-center gap-1 text-red-500">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{t("storage.zfs.errorsReported")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
{pool.capacity != null && (
|
||||
<Progress
|
||||
value={pool.capacity}
|
||||
className={`h-2 mb-2 ${
|
||||
pool.capacity > 90
|
||||
? "[&>div]:bg-red-500"
|
||||
: pool.capacity > 75
|
||||
? "[&>div]:bg-yellow-500"
|
||||
: "[&>div]:bg-blue-500"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("storage.size")}</p>
|
||||
@@ -1797,6 +1976,221 @@ export function StorageOverview() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ZFS Pool Details Dialog: re-resolved from storageData so it follows
|
||||
the background refresh while open (e.g. resilver progress). */}
|
||||
<Dialog open={selectedPool !== null} onOpenChange={(open) => { if (!open) setSelectedPool(null) }}>
|
||||
<DialogContent className={detailDialogClass}>
|
||||
{selectedPool && (() => {
|
||||
const pool = storageData.zfs_pools.find((p) => p.name === selectedPool.name) ?? selectedPool
|
||||
const classOrder: ZFSVdev["class"][] = ["normal", "special", "dedup", "log", "cache", "spare"]
|
||||
const vdevs = [...(pool.vdevs ?? [])].sort(
|
||||
(a, b) => classOrder.indexOf(a.class) - classOrder.indexOf(b.class),
|
||||
)
|
||||
const showSlowIos = vdevs.some((v) => v.devices.some((d) => d.slow_ios > 0))
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="px-6 pt-6 pb-2">
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<Database className="h-5 w-5" />
|
||||
{pool.name}
|
||||
{getHealthBadge(pool.health)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("storage.zfs.poolDetails")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 px-6 pb-6 overflow-y-auto flex flex-col gap-5">
|
||||
{(pool.status || pool.action) && (
|
||||
<div className={`rounded-lg border p-3 text-sm space-y-1 ${
|
||||
zfsStatusIsInformational(pool.status)
|
||||
? "border-blue-500/30 bg-blue-500/10"
|
||||
: "border-yellow-500/30 bg-yellow-500/10"
|
||||
}`}>
|
||||
{pool.status && <p><span className="font-medium">{t("storage.zfs.status")}:</span> {pool.status}</p>}
|
||||
{pool.action && <p><span className="font-medium">{t("storage.zfs.action")}:</span> {pool.action}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-border p-4 flex flex-col justify-center gap-3">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("storage.free")}</p>
|
||||
<p className="text-lg font-semibold">{pool.free}</p>
|
||||
</div>
|
||||
{pool.capacity != null && <span className="text-sm font-medium">{pool.capacity}%</span>}
|
||||
</div>
|
||||
{pool.capacity != null && (
|
||||
<Progress
|
||||
value={pool.capacity}
|
||||
className={`h-2 ${
|
||||
pool.capacity > 90
|
||||
? "[&>div]:bg-red-500"
|
||||
: pool.capacity > 75
|
||||
? "[&>div]:bg-yellow-500"
|
||||
: "[&>div]:bg-blue-500"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.allocated")}</p>
|
||||
<p className="font-medium">{pool.allocated}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-muted-foreground">{t("storage.zfs.totalSize")}</p>
|
||||
<p className="font-medium">{pool.size}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(pool.fragmentation != null || pool.dedup_ratio != null) && (
|
||||
<div className="rounded-lg border border-border p-4 flex items-center gap-5">
|
||||
{pool.fragmentation != null && (
|
||||
<PercentRing value={pool.fragmentation} color={zfsFragmentationColor(pool.fragmentation)} label={t("storage.zfs.fragShort")} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 space-y-3 text-sm">
|
||||
{pool.fragmentation != null && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.zfs.fragmentation")}</p>
|
||||
<p className="font-medium">{pool.fragmentation}%</p>
|
||||
</div>
|
||||
)}
|
||||
{pool.dedup_ratio != null && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.zfs.dedupRatio")}</p>
|
||||
<p className="font-medium">{pool.dedup_ratio.toFixed(2)}x</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!pool.details_available ? (
|
||||
<p className="text-sm text-muted-foreground italic">{t("storage.zfs.detailsUnavailable")}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Scrub / resilver */}
|
||||
<div className="rounded-lg border border-border p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{t("storage.zfs.scanTitle")}
|
||||
</h3>
|
||||
<p className="text-sm">{getZfsScanSummary(pool.scan)}</p>
|
||||
{pool.scan?.state === "SCANNING" && <Progress value={pool.scan.percent ?? 0} className="h-2" />}
|
||||
{pool.scan && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.zfs.started")}</p>
|
||||
<p className="font-medium">{formatZfsTime(pool.scan.start_time)}</p>
|
||||
</div>
|
||||
{pool.scan.state !== "SCANNING" && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.zfs.ended")}</p>
|
||||
<p className="font-medium">{formatZfsTime(pool.scan.end_time)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.zfs.repaired")}</p>
|
||||
<p className="font-medium">{formatStorage(pool.scan.repaired_bytes / 1024 ** 3)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.zfs.scanErrors")}</p>
|
||||
<p className={`font-medium ${pool.scan.errors > 0 ? "text-red-500" : ""}`}>{pool.scan.errors}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className={`text-sm ${(pool.error_count ?? 0) > 0 ? "text-red-500" : "text-green-500"}`}>
|
||||
{(pool.error_count ?? 0) > 0
|
||||
? t("storage.zfs.dataErrors", { count: pool.error_count ?? 0 })
|
||||
: t("storage.zfs.noDataErrors")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Layout: one block per top-level vdev; the last one
|
||||
reaches the bottom of the dialog. */}
|
||||
<div className="flex-1 flex flex-col gap-3">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{t("storage.zfs.layout")}
|
||||
</h3>
|
||||
{vdevs.map((vdev, i) => (
|
||||
<div key={`${vdev.class}-${vdev.name}-${i}`} className={`border rounded-lg ${i === vdevs.length - 1 ? "flex-1" : ""}`}>
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b bg-muted/30">
|
||||
<Badge variant="outline" className="text-xs">{t(`storage.zfs.classes.${vdev.class}`)}</Badge>
|
||||
<span className="text-sm font-medium">{getZfsVdevLabel(vdev)}</span>
|
||||
{vdev.type !== "disk" && (
|
||||
<span className="font-mono text-xs text-muted-foreground">{vdev.name}</span>
|
||||
)}
|
||||
<span className="ml-auto">{getZfsStateBadge(vdev.state)}</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-muted-foreground">
|
||||
<th className="text-left font-normal px-3 py-1.5">{t("storage.zfs.device")}</th>
|
||||
<th className="text-left font-normal px-2 py-1.5">{t("storage.zfs.disk")}</th>
|
||||
<th className="text-left font-normal px-2 py-1.5">{t("storage.zfs.state")}</th>
|
||||
<th className="text-right font-normal px-2 py-1.5" title={t("storage.zfs.readErrors")}>{t("storage.zfs.readShort")}</th>
|
||||
<th className="text-right font-normal px-2 py-1.5" title={t("storage.zfs.writeErrors")}>{t("storage.zfs.writeShort")}</th>
|
||||
<th className="text-right font-normal px-2 py-1.5" title={t("storage.zfs.checksumErrors")}>{t("storage.zfs.checksumShort")}</th>
|
||||
{showSlowIos && (
|
||||
<th className="text-right font-normal px-3 py-1.5">{t("storage.zfs.slowIos")}</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vdev.devices.map((dev) => {
|
||||
const diskInfo = dev.disk ? storageData.disks.find((d) => d.name === dev.disk) : undefined
|
||||
const errCls = (n: number) => `text-right px-2 py-1.5 font-mono ${n > 0 ? "text-red-500 font-semibold" : "text-muted-foreground"}`
|
||||
return (
|
||||
<tr key={`${dev.path}-${dev.via ?? ""}`} className="border-t border-border/50">
|
||||
<td className="px-3 py-1.5 font-mono text-xs break-all" title={dev.path}>
|
||||
{dev.path.split("/").pop()}
|
||||
{dev.via && (
|
||||
<span className="ml-2 text-muted-foreground">({t("storage.zfs.via", { name: dev.via })})</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
{diskInfo ? (
|
||||
<button
|
||||
type="button"
|
||||
title={diskInfo.model}
|
||||
onClick={() => {
|
||||
setSelectedPool(null)
|
||||
handleDiskClick(diskInfo)
|
||||
}}
|
||||
>
|
||||
<Badge variant="outline" className="font-mono hover:bg-accent">{diskInfo.name}</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-muted-foreground">{dev.disk ?? "-"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">{getZfsStateBadge(dev.state)}</td>
|
||||
<td className={errCls(dev.read_errors)}>{dev.read_errors}</td>
|
||||
<td className={errCls(dev.write_errors)}>{dev.write_errors}</td>
|
||||
<td className={errCls(dev.checksum_errors)}>{dev.checksum_errors}</td>
|
||||
{showSlowIos && (
|
||||
<td className={`text-right px-3 py-1.5 font-mono ${dev.slow_ios > 0 ? "text-yellow-500" : "text-muted-foreground"}`}>
|
||||
{dev.slow_ios}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Physical Disks (internal only) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1806,11 +2200,43 @@ export function StorageOverview() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{groupDisksByController ? (
|
||||
<div className="space-y-6">
|
||||
{groupDisksBySlot(storageData.disks.filter((d) => d.connection_type !== 'usb')).map(([slot, disks]) => {
|
||||
const controller = slot ? storageData.controllers?.[slot] : undefined
|
||||
return (
|
||||
<div key={slot ?? "none"}>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3 pb-2 border-b border-border/50">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{controller
|
||||
? controller.sdevice || controller.device
|
||||
: slot ?? t("storage.noController")}
|
||||
</span>
|
||||
{slot && <span className="font-mono text-xs text-muted-foreground">{slot}</span>}
|
||||
{controller?.driver && (
|
||||
<Badge variant="outline" className="font-mono text-xs text-green-500">
|
||||
{controller.driver}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="ml-auto text-xs">
|
||||
{t(disks.length === 1 ? "storage.diskCountOne" : "storage.diskCountMany", { count: disks.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{disks.map((disk) => renderDiskCardV2(disk))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{storageData.disks
|
||||
.filter((d) => d.connection_type !== 'usb')
|
||||
.map((disk) => renderDiskCardV2(disk))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1842,13 +2268,7 @@ export function StorageOverview() {
|
||||
setSmartJsonData(null)
|
||||
}
|
||||
}}>
|
||||
<DialogContent
|
||||
className={`max-w-4xl flex flex-col p-0 overflow-hidden ${
|
||||
isStandalone
|
||||
? "h-[95vh] sm:h-[90vh]"
|
||||
: "h-[85vh] sm:h-[85vh] max-h-[calc(100dvh-env(safe-area-inset-top)-env(safe-area-inset-bottom)-40px)]"
|
||||
}`}
|
||||
>
|
||||
<DialogContent className={detailDialogClass}>
|
||||
<DialogHeader className="px-6 pt-6 pb-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{selectedDisk?.connection_type === 'usb' ? (
|
||||
@@ -1965,6 +2385,28 @@ export function StorageOverview() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedDisk.controller_slot && (() => {
|
||||
const controller = storageData.controllers?.[selectedDisk.controller_slot]
|
||||
return (
|
||||
<div className="col-span-2">
|
||||
<p className="text-sm text-muted-foreground">{t("storage.storageController")}</p>
|
||||
<p className="font-medium">
|
||||
{controller ? controller.sdevice || controller.device : selectedDisk.controller_slot}
|
||||
{controller && (
|
||||
<span className="ml-2 font-mono text-xs text-muted-foreground">{selectedDisk.controller_slot}</span>
|
||||
)}
|
||||
{controller?.driver && (
|
||||
<Badge variant="outline" className="ml-2 font-mono text-xs text-green-500">
|
||||
{controller.driver}
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
{controller && controller.sdevice && (
|
||||
<p className="text-xs text-muted-foreground">{controller.vendor} - {controller.device}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Wear & Lifetime — DiskInfo (real-time, 60s refresh) for NVMe + SSD. SMART JSON as fallback. HDD: hidden. */}
|
||||
@@ -2179,7 +2621,7 @@ export function StorageOverview() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("storage.rotationRate")}</p>
|
||||
<p className="font-medium">{formatRotationRate(selectedDisk.rotation_rate)}</p>
|
||||
<p className="font-medium">{formatRotationRate(selectedDisk.name, selectedDisk.rotation_rate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t("storage.powerCycles")}</p>
|
||||
@@ -2424,14 +2866,12 @@ function openSmartReport(disk: DiskInfo, testStatus: SmartTestStatus, smartAttri
|
||||
|
||||
// Determine disk type (SAS detected via backend flag or connection_type)
|
||||
const isSasDisk = sd?.is_sas === true || disk.connection_type === 'sas'
|
||||
let diskType = "HDD"
|
||||
if (disk.name.startsWith("nvme")) {
|
||||
diskType = "NVMe"
|
||||
} else if (isSasDisk) {
|
||||
let diskType: string = resolveDiskType(disk.name, disk.rotation_rate)
|
||||
if (diskType !== "NVMe" && isSasDisk) {
|
||||
diskType = "SAS"
|
||||
} else if (!disk.rotation_rate || disk.rotation_rate === 0) {
|
||||
diskType = "SSD"
|
||||
}
|
||||
const rpm = disk.rotation_rate && disk.rotation_rate > 0 ? disk.rotation_rate : 0
|
||||
const known = (value?: string) => (value && value !== "Unknown" ? value : undefined)
|
||||
|
||||
// Health status styling
|
||||
const healthStatus = String(testStatus.smart_status || (testStatus.smart_data?.smart_status) || 'unknown')
|
||||
@@ -3377,11 +3817,11 @@ function pmxPrint(){
|
||||
<div class="grid-4">
|
||||
<div class="card">
|
||||
<div class="card-label">${tSmart("labels.model")}</div>
|
||||
<div class="card-value" style="font-size:11px;">${disk.model || sd?.model || t("app.unknown")}</div>
|
||||
<div class="card-value" style="font-size:11px;">${known(disk.model) || known(sd?.model) || t("app.unknown")}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">${tSmart("labels.serial")}</div>
|
||||
<div class="card-value" style="font-size:11px;font-family:monospace;">${disk.serial || sd?.serial || t("app.unknown")}</div>
|
||||
<div class="card-value" style="font-size:11px;font-family:monospace;">${known(disk.serial) || known(sd?.serial) || t("app.unknown")}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">${tSmart("labels.capacity")}</div>
|
||||
@@ -3389,7 +3829,7 @@ function pmxPrint(){
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">${tSmart("labels.type")}</div>
|
||||
<div class="card-value" style="font-size:11px;">${diskType === 'SAS' ? (disk.rotation_rate ? `SAS ${disk.rotation_rate} RPM` : 'SAS SSD') : diskType === 'HDD' && disk.rotation_rate ? `HDD ${disk.rotation_rate} RPM` : diskType}</div>
|
||||
<div class="card-value" style="font-size:11px;">${diskType === 'SAS' ? (rpm ? `SAS ${rpm} RPM` : disk.rotation_rate === -1 ? 'SAS HDD' : 'SAS SSD') : diskType === 'HDD' && rpm ? `HDD ${rpm} RPM` : diskType}</div>
|
||||
</div>
|
||||
</div>
|
||||
${(modelFamily || formFactor || sataVersion || ifaceSpeed) ? `
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Progress } from "./ui/progress"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight, Database } from "lucide-react"
|
||||
import { DatastoreUsageRow, type PBSDatastore } from "./pbs-datastore-usage"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
import { pbsDiagnostic } from "../lib/pbs-messages"
|
||||
import { NodeMetricsCharts } from "./node-metrics-charts"
|
||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||
import { TemperatureDetailModal } from "./temperature-detail-modal"
|
||||
@@ -187,14 +189,20 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
|
||||
return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes"
|
||||
}
|
||||
|
||||
interface PBSOverviewStatus {
|
||||
datastores: PBSDatastore[] | null
|
||||
errors?: Record<string, string>
|
||||
error_details?: Record<string, { code: string }>
|
||||
}
|
||||
|
||||
export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" } = {}) {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const isPbs = product === "pbs"
|
||||
// On Proxmox Backup Server the datastores take the place of guests and PVE storage.
|
||||
const { data: pbsStatus, error: pbsError } = useSWR<{ datastores: PBSDatastore[] | null }>(
|
||||
const { data: pbsStatus, error: pbsError } = useSWR<PBSOverviewStatus>(
|
||||
isPbs ? "/api/pbs/status?server=local" : null,
|
||||
(url: string) => fetchApi<{ datastores: PBSDatastore[] | null }>(url),
|
||||
(url: string) => fetchApi<PBSOverviewStatus>(url),
|
||||
{ refreshInterval: 60000, revalidateOnFocus: false },
|
||||
)
|
||||
const datastores = pbsStatus?.datastores ?? null
|
||||
@@ -545,13 +553,15 @@ export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" }
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!datastores && !pbsError ? (
|
||||
{!pbsStatus && !pbsError ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
<div className="h-10 bg-muted rounded w-20"></div>
|
||||
<div className="h-5 bg-muted rounded w-32"></div>
|
||||
</div>
|
||||
) : !datastores ? (
|
||||
<p className="text-sm text-amber-500">{t("pbs.loadFailed")}</p>
|
||||
<div role="alert" className="text-sm text-amber-500">
|
||||
<PBSProblemMessage problem={pbsError || pbsStatus?.error_details?.datastores} raw={pbsDiagnostic(pbsError) || pbsStatus?.errors?.datastores} />
|
||||
</div>
|
||||
) : datastores.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>
|
||||
) : (
|
||||
@@ -762,8 +772,12 @@ export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" }
|
||||
{isPbs ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("pbs.datastores")}</div>
|
||||
{!datastores ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{pbsError ? t("pbs.loadFailed") : t("pbs.loading")}</div>
|
||||
{!pbsStatus && !pbsError ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.loading")}</div>
|
||||
) : !datastores ? (
|
||||
<div role="alert" className="py-4 text-amber-500 text-sm">
|
||||
<PBSProblemMessage problem={pbsError || pbsStatus?.error_details?.datastores} raw={pbsDiagnostic(pbsError) || pbsStatus?.errors?.datastores} />
|
||||
</div>
|
||||
) : datastores.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.noDatastores")}</div>
|
||||
) : datastores.map(store => <DatastoreUsageRow key={store.name} store={store} />)}
|
||||
|
||||
@@ -22,6 +22,9 @@ import useSWR from "swr"
|
||||
import { MetricsView } from "./metrics-dialog"
|
||||
import { LxcTerminalModal } from "./lxc-terminal-modal"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { LxcUpdateJobs } from "./lxc-update-jobs"
|
||||
import { markUpdateJobViewed, type LxcUpdateJob } from "../lib/lxc-update-jobs"
|
||||
import { OCI_CANCELLED_EXIT_CODE, shouldRefreshOciAfterCompletion } from "@/lib/script-completion"
|
||||
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
|
||||
import { GuestBackupList } from "./guest-backup-list"
|
||||
import type { GuestBackup } from "../lib/guest-backup-archives"
|
||||
@@ -977,7 +980,9 @@ export function VirtualMachines() {
|
||||
// A container restored from a backup carries the mark of its installation
|
||||
// and has no record on this host until it is recovered from the menu.
|
||||
const [ociRestored, setOciRestored] = useState(false)
|
||||
const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "modify" | "recover" } | null>(null)
|
||||
const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "modify" | "recover"; recoverPrevious?: boolean } | null>(null)
|
||||
const ociCompletionCodeRef = useRef<number | undefined>(undefined)
|
||||
useEffect(() => { ociCompletionCodeRef.current = undefined }, [ociAction])
|
||||
|
||||
// Firewall log state — fetched only when the operator opens that tab
|
||||
// so a CT/VM without firewall use doesn't pay the pvesh cost on every
|
||||
@@ -1995,6 +2000,7 @@ export function VirtualMachines() {
|
||||
const [applyRestart, setApplyRestart] = useState(false)
|
||||
const [applyStartedAt, setApplyStartedAt] = useState<number>(0)
|
||||
const [applyRunId, setApplyRunId] = useState<string>("")
|
||||
const [applyResume, setApplyResume] = useState(false)
|
||||
const [applyCheckedOs, setApplyCheckedOs] = useState(false)
|
||||
const [applyTargetIds, setApplyTargetIds] = useState<string[]>([])
|
||||
const [applyTargetLabels, setApplyTargetLabels] = useState<string[]>([])
|
||||
@@ -2636,6 +2642,7 @@ export function VirtualMachines() {
|
||||
setApplyVmid(vmid)
|
||||
setApplyTarget(target)
|
||||
setApplyRunId(runId)
|
||||
setApplyResume(false)
|
||||
setApplyCheckedOs(opts?.checkedOs === true)
|
||||
setApplyTargetIds(opts?.targetIds?.length ? opts.targetIds : fallbackTargets)
|
||||
setApplyTargetLabels(opts?.targetLabels?.length ? opts.targetLabels : fallbackLabels)
|
||||
@@ -2656,13 +2663,22 @@ export function VirtualMachines() {
|
||||
setApplyStartedAt(Date.now())
|
||||
setApplyOpen(true)
|
||||
}
|
||||
const handleApplyComplete = async (exitCode = 0) => {
|
||||
const resumeApplyJob = (job: LxcUpdateJob) => {
|
||||
setApplyVmid(job.vmid)
|
||||
setApplyTarget(job.target)
|
||||
setApplyRunId(job.id)
|
||||
setApplyCheckedOs(job.script_path.endsWith("/install_checked_os_updates.sh"))
|
||||
setApplyResume(true)
|
||||
setApplyStartedAt(job.created_at * 1000)
|
||||
setApplyOpen(true)
|
||||
}
|
||||
const handleApplyComplete = async (exitCode = 0, notifyBackend = true) => {
|
||||
if (applyVmid == null) return
|
||||
const duration = Math.max(0, Math.round((Date.now() - applyStartedAt) / 1000))
|
||||
// The modal fires onComplete on any WS close (success or user cancel).
|
||||
// Report the same run ID so the backend can collapse this compatibility
|
||||
// callback with its authoritative process-completion hook.
|
||||
// Legacy callers may report the same run ID to the compatibility route.
|
||||
// Background Updates only refresh the view after the server's real exit.
|
||||
try {
|
||||
if (notifyBackend) {
|
||||
await fetchApi(`/api/lxc-updates/${applyVmid}/applied`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -2677,6 +2693,7 @@ export function VirtualMachines() {
|
||||
refresh_docker_inventory: applyDockerRefresh,
|
||||
}),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — the backend process hook owns finalization.
|
||||
}
|
||||
@@ -2694,6 +2711,12 @@ export function VirtualMachines() {
|
||||
// performs the requests (the data map is only a render seed), so no
|
||||
// invalidation is needed and there is no "Loading applications" flash.
|
||||
void fetchLxcApps(applyVmid)
|
||||
// Recovery can be opened from the job banner while another CT is selected.
|
||||
// Refresh the operated guest without changing that other guest's result UI.
|
||||
if (selectedVMRef.current?.vmid !== applyVmid) {
|
||||
void mutate()
|
||||
return
|
||||
}
|
||||
// Enter the "Comprobando resultado…" state on the Updates tab.
|
||||
// Baseline snapshot lets a downstream useEffect detect when the
|
||||
// SWR poll actually delivers the post-apply counts (as opposed
|
||||
@@ -5227,7 +5250,9 @@ export function VirtualMachines() {
|
||||
null
|
||||
return (
|
||||
<LxcAppPanel
|
||||
key={`${selectedVM.vmid}-${selectedVM.modal_cache_revision ?? 0}`}
|
||||
// Refresh server data without destroying an unsaved editor.
|
||||
key={selectedVM.vmid}
|
||||
cacheRevision={selectedVM.modal_cache_revision ?? 0}
|
||||
vmid={selectedVM.vmid}
|
||||
ctIp={ctIp}
|
||||
onChange={() => mutate()}
|
||||
@@ -5513,7 +5538,7 @@ export function VirtualMachines() {
|
||||
<Button
|
||||
size="sm"
|
||||
className={updateBtnCls}
|
||||
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "update" })}
|
||||
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "update", recoverPrevious: !!ociInstance.pending })}
|
||||
>
|
||||
{hasUpdate
|
||||
? <ArrowUpCircle className="h-4 w-4 mr-1.5" />
|
||||
@@ -5742,7 +5767,7 @@ export function VirtualMachines() {
|
||||
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "recover" })}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1.5" />
|
||||
{t("vmLxc.ociUpdates.recover")}
|
||||
{t("vmLxc.ociUpdates.registerRestored")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -7955,6 +7980,8 @@ export function VirtualMachines() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<LxcUpdateJobs activeRunId={applyOpen ? applyRunId : undefined} onResume={resumeApplyJob} />
|
||||
|
||||
{/* LXC Update Apply Terminal — streams the apply_updates.sh
|
||||
script over WS/PTY (same wiring as the hardware installers).
|
||||
The backend finalizes on process exit; the close callback only
|
||||
@@ -7966,7 +7993,13 @@ export function VirtualMachines() {
|
||||
setApplyOpen(false)
|
||||
setApplyVmid(null)
|
||||
}}
|
||||
onComplete={handleApplyComplete}
|
||||
onComplete={(exitCode) => {
|
||||
markUpdateJobViewed(applyRunId)
|
||||
// The server-owned job is the sole completion authority.
|
||||
void handleApplyComplete(exitCode ?? 1, false)
|
||||
}}
|
||||
backgroundUpdate={{ runId: applyRunId, vmid: applyVmid, resume: applyResume }}
|
||||
cancelledExitCode={130}
|
||||
scriptPath={applyCheckedOs
|
||||
? "/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh"
|
||||
: "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"}
|
||||
@@ -7976,13 +8009,7 @@ export function VirtualMachines() {
|
||||
completedWithErrorMessage={(exitCode) =>
|
||||
t("vmLxc.updates.terminalCompletedWithError", { code: exitCode })
|
||||
}
|
||||
description={
|
||||
applyTarget === "os"
|
||||
? t("vmLxc.updates.terminalDescriptionOs")
|
||||
: applyTarget === "app"
|
||||
? t("vmLxc.updates.terminalDescriptionApp")
|
||||
: t("vmLxc.updates.terminalDescriptionBoth")
|
||||
}
|
||||
description={t("vmLxc.updates.backgroundDescription")}
|
||||
params={{
|
||||
VMID: String(applyVmid),
|
||||
RUN_ID: applyRunId,
|
||||
@@ -8012,6 +8039,7 @@ export function VirtualMachines() {
|
||||
onClose={() => {
|
||||
const vmid = ociAction.vmid
|
||||
setOciAction(null)
|
||||
if (!shouldRefreshOciAfterCompletion(ociCompletionCodeRef.current)) return
|
||||
// The image, the digest and the record changed: read them again.
|
||||
fetchApi(`/api/vms/${vmid}/apps/check`, { method: "POST" })
|
||||
.catch(() => undefined)
|
||||
@@ -8025,7 +8053,10 @@ export function VirtualMachines() {
|
||||
}}
|
||||
scriptPath="/usr/local/share/proxmenux/scripts/oci/manage_instance.sh"
|
||||
scriptName="oci_manage_instance"
|
||||
title={ociAction.action === "update" ? t("vmLxc.ociUpdates.terminalTitleUpdate")
|
||||
cancelledExitCode={OCI_CANCELLED_EXIT_CODE}
|
||||
onComplete={(exitCode) => { ociCompletionCodeRef.current = exitCode }}
|
||||
title={ociAction.action === "update" ? t(ociAction.recoverPrevious
|
||||
? "vmLxc.ociUpdates.terminalTitleRecoverPrevious" : "vmLxc.ociUpdates.terminalTitleUpdate")
|
||||
: ociAction.action === "recover" ? t("vmLxc.ociUpdates.terminalTitleRecover")
|
||||
: t("vmLxc.ociUpdates.terminalTitleModify")}
|
||||
description={t("vmLxc.ociUpdates.terminalDescription")}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"_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-09-02",
|
||||
"_updated": "2026-10-26",
|
||||
"_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": {
|
||||
"models": [
|
||||
"allam-2-7b",
|
||||
"qwen/qwen3.8-27b",
|
||||
"openai/gpt-oss-20b",
|
||||
"groq/compound-mini"
|
||||
"openai/gpt-oss-120b"
|
||||
],
|
||||
"recommended": "openai/gpt-oss-20b",
|
||||
"_note": "Verified 2026-09-02 with the Groq API. 4 of 12 tested pass. `llama-3.3-70b-versatile` no longer served by Groq. Recommended kept as `openai/gpt-oss-20b` (english-capable) — `allam-2-7b` is fastest but Arabic-focused; add manually if serving Arabic notifications."
|
||||
"_note": "Verified 2026-10-26 with the Groq API. 4 of 8 tested pass. `groq/compound-mini` is no longer served and was dropped; `openai/gpt-oss-120b` added. Recommended kept as `openai/gpt-oss-20b` (english-capable) — `allam-2-7b` is fastest but Arabic-focused."
|
||||
},
|
||||
"gemini": {
|
||||
"models": [
|
||||
@@ -21,7 +21,10 @@
|
||||
"gemini-flash-lite-latest",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3.5-flash"
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.6-flash",
|
||||
"gemini-3.7-flash",
|
||||
"gemini-3.8-flash"
|
||||
],
|
||||
"recommended": "gemini-2.5-flash-lite",
|
||||
"_deprecated": [
|
||||
@@ -31,50 +34,46 @@
|
||||
"gemini-1.0-pro",
|
||||
"gemini-pro"
|
||||
],
|
||||
"_note": "Verified 2026-09-02. gemini-flash-lite-latest passes consistently but gemini-2.5-flash-lite remains recommended because 'latest' aliases can drift. gemini-3.1-flash-lite is the stable successor to 3-flash-preview. Pro variants continue to reject thinkingBudget=0 and are overkill for notification translation."
|
||||
"_note": "Verified 2026-10-26. Added gemini-3.6-flash, 3.7-flash and 3.8-flash. gemini-flash-lite-latest passes consistently but gemini-2.5-flash-lite remains recommended because 'latest' aliases can drift. Pro variants continue to reject thinkingBudget=0 and are overkill for notification translation; gemini-4-argon passes but takes ~20s per message."
|
||||
},
|
||||
"openai": {
|
||||
"models": [
|
||||
"gpt-4.1-nano-2025-04-14",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4.1-2025-04-14",
|
||||
"gpt-4.1",
|
||||
"gpt-4o-2024-11-20",
|
||||
"gpt-4o-2024-05-13",
|
||||
"gpt-4o-mini",
|
||||
"gpt-3.5-turbo-16k",
|
||||
"gpt-4o-mini-2024-07-18",
|
||||
"gpt-5-nano",
|
||||
"gpt-3.5-turbo-1106",
|
||||
"gpt-3.5-turbo-0125",
|
||||
"gpt-5-nano-2025-08-07",
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1-mini-2025-04-14",
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-4o",
|
||||
"gpt-4o-2024-08-06",
|
||||
"gpt-4",
|
||||
"gpt-4-turbo",
|
||||
"gpt-4-turbo-2024-04-09",
|
||||
"gpt-4-0613"
|
||||
"gpt-6-luna",
|
||||
"gpt-5.6-luna",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4-mini-2026-03-17",
|
||||
"gpt-6-sol",
|
||||
"gpt-6.1-sol",
|
||||
"gpt-5.6-terra",
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-2025-12-11",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-2026-03-05",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.5",
|
||||
"gpt-5.5-2026-04-23",
|
||||
"gpt-6-astra"
|
||||
],
|
||||
"recommended": "gpt-4.1-nano",
|
||||
"_note": "Verified 2026-09-02 with the OpenAI API — 22 of 74 tested pass. Includes stable aliases (gpt-4.1-nano, gpt-4o-mini, ...), dated snapshots for pinning (gpt-4o-2024-11-20, gpt-4.1-nano-2025-04-14) and legacy families (gpt-3.5-turbo, gpt-4) for cost-optimised use cases. Recommended kept as `gpt-4.1-nano` (stable alias, 1.39s)."
|
||||
"recommended": "gpt-6-luna",
|
||||
"_note": "Verified 2026-10-26 against the live API. Recommended `gpt-6-luna` (cheapest current model). Dropped every model that is retired (gpt-4.1-nano, gpt-3.5-turbo, gpt-4, gpt-4-turbo, ...) or has a shutdown date (gpt-4.1, gpt-4o, gpt-4o-mini, gpt-5-nano, gpt-5.1, gpt-5.4-nano)."
|
||||
},
|
||||
"anthropic": {
|
||||
"models": [
|
||||
"claude-haiku-5-5",
|
||||
"claude-haiku-4-5-20251001",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-opus-4-7",
|
||||
"claude-sonnet-5-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-5-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4-5-20251101",
|
||||
"claude-fable-5",
|
||||
"claude-fable-5-1",
|
||||
"claude-opus-4-6"
|
||||
"claude-fable-5-1"
|
||||
],
|
||||
"recommended": "claude-haiku-4-5-20251001",
|
||||
"_note": "Verified 2026-09-02 — 9 of 11 tested pass. The `claude-haiku-4-5` alias no longer resolves upstream; the working snapshot is `claude-haiku-4-5-20251001` — used as recommended (2.89s). Full family surfaced: haiku/sonnet/opus/fable across generations 4.5/4.6/4.7/4.8/5."
|
||||
"recommended": "claude-haiku-5-5",
|
||||
"_note": "Verified 2026-10-26 against the live API. Added the 5.x family, recommended `claude-haiku-5-5`. Dropped `claude-sonnet-4-5` (retires 2026-11-30). `claude-opus-5` left out: false refusals on plain notifications."
|
||||
},
|
||||
"openrouter": {
|
||||
"models": [
|
||||
@@ -111,15 +110,10 @@
|
||||
"openai/gpt-oss-120b",
|
||||
"meta-llama/llama-3.1-8b-instruct",
|
||||
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
||||
"nvidia/nemotron-3-super-120b-a12b:free",
|
||||
"google/gemma-4-26b-a4b-it:free",
|
||||
"nvidia/nemotron-nano-12b-v2-vl:free",
|
||||
"nvidia/nemotron-3-nano-30b-a3b:free",
|
||||
"poolside/laguna-s-2.1:free",
|
||||
"openai/gpt-oss-20b:free"
|
||||
"google/gemma-4-26b-a4b-it:free"
|
||||
],
|
||||
"recommended": "meta-llama/llama-3.3-70b-instruct",
|
||||
"_note": "Curated manually from the 249+ models OpenRouter serves — the auto-verifier passes ~200 today but only a subset are practical for notification translation. Includes the 11 previously-curated paid models plus 22 new mainstream additions (gpt-5.1, claude-opus-4.8, sonnet-5, grok-4.3/4.20, kimi-k2, deepseek-v3.2, nova, cohere-a, sonar, ...) and 6 `:free` variants re-added manually — the free tier is intentionally supported per user request even though today's rate limits blocked some of them from passing the test."
|
||||
"_note": "Curated manually from the 249+ models OpenRouter serves — the auto-verifier passes ~200 today but only a subset are practical for notification translation. Includes the 11 previously-curated paid models plus 22 new mainstream additions (gpt-5.1, claude-opus-4.8, sonnet-5, grok-4.3/4.20, kimi-k2, deepseek-v3.2, nova, cohere-a, sonar, ...) and 6 `:free` variants re-added manually — the free tier is intentionally supported per user request even though today's rate limits blocked some of them from passing the test. Reviewed 2026-10-26: three `:free` variants no longer served by OpenRouter were dropped. Availability reviewed 2026-11-04 against the public models endpoint: expired poolside/laguna-s-2.1:free and unlisted nvidia/nemotron-3-super-120b-a12b:free removed. This availability check is not a new inference verification; no untested models were added."
|
||||
},
|
||||
"ollama": {
|
||||
"_note": "Ollama models are local, we don't filter them. User manages their own models.",
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
* API Configuration for ProxMenux Monitor
|
||||
* Handles API URL generation with automatic proxy detection
|
||||
*/
|
||||
// Resolve wording only for a generic transport failure. Normal responses and
|
||||
// backend error payloads keep the transport free of locale-loading work.
|
||||
async function apiFailure(...args: Parameters<typeof import("./api-errors").apiFailure>): Promise<string> {
|
||||
const messages = await import("./api-errors")
|
||||
return messages.apiFailure(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* API Server Port Configuration
|
||||
@@ -114,7 +120,7 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
||||
hadToken = true
|
||||
}
|
||||
if (!hadToken) {
|
||||
throw new Error(`Unauthorized: ${endpoint}`)
|
||||
throw new Error(await apiFailure("unauthorized", { endpoint }))
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem("proxmenux-auth-token")
|
||||
@@ -131,7 +137,7 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
throw new Error(`Unauthorized: ${endpoint}`)
|
||||
throw new Error(await apiFailure("unauthorized", { endpoint }))
|
||||
}
|
||||
// Try to surface the backend's JSON error payload instead of a
|
||||
// bare `500 INTERNAL SERVER ERROR`. The Flask routes consistently
|
||||
@@ -168,7 +174,7 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
||||
throw parseErr
|
||||
}
|
||||
}
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`)
|
||||
throw new Error(await apiFailure("http", { status: response.status, statusText: response.statusText }))
|
||||
}
|
||||
|
||||
// Check content type to ensure we're getting JSON
|
||||
@@ -176,13 +182,13 @@ export async function fetchApi<T>(endpoint: string, options?: RequestInit): Prom
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
const text = await response.text()
|
||||
console.error("fetchApi: Expected JSON but got:", contentType, "- Body preview:", text.substring(0, 200))
|
||||
throw new Error(`Expected JSON response but got ${contentType || "unknown content type"}`)
|
||||
throw new Error(await apiFailure("unexpectedContentType", { contentType: contentType || await apiFailure("unknownContentType") }))
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json()
|
||||
} catch (jsonError) {
|
||||
console.error("fetchApi: JSON parse error for", endpoint, "-", jsonError)
|
||||
throw new Error(`Invalid JSON response from ${endpoint}`)
|
||||
throw new Error(await apiFailure("invalidJson", { endpoint }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MESSAGE_CATALOG } from "./i18n/catalogs"
|
||||
import { detectBrowserLanguage, isSupportedLanguage, LANGUAGE_STORAGE_KEY } from "./i18n/languages"
|
||||
|
||||
type Failure = keyof (typeof import("../messages/en/common.json"))["apiErrors"]
|
||||
|
||||
// fetchApi also runs outside React hooks. Read the provider's current document
|
||||
// language, with its stored/browser preference as a fallback.
|
||||
export function apiFailure(key: Failure, params: Record<string, string | number> = {}): string {
|
||||
let preference = typeof document === "undefined" ? "" : document.documentElement?.lang
|
||||
if (!isSupportedLanguage(preference) && typeof window !== "undefined") {
|
||||
try { preference = window.localStorage.getItem(LANGUAGE_STORAGE_KEY) || "" } catch { /* private browsing */ }
|
||||
}
|
||||
const language = isSupportedLanguage(preference) ? preference : detectBrowserLanguage()
|
||||
const messages = MESSAGE_CATALOG[language].apiErrors
|
||||
const localized = messages && typeof messages === "object" ? (messages as Record<string, unknown>)[key] : undefined
|
||||
const fallback = (MESSAGE_CATALOG.en.apiErrors as Record<Failure, string>)[key]
|
||||
const template = typeof localized === "string" ? localized : fallback
|
||||
return template.replace(/\{(\w+)\}/g, (match, name) => params[name] === undefined ? match : String(params[name]))
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Legacy HostBackup API messages are an English wire contract shared with CLI callers.
|
||||
// Match complete, source-verified messages only; never derive a translation key from input.
|
||||
// This presentation-only adapter keeps unknown tool diagnostics and parameter text intact.
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const fixed = new Map<string, string>([
|
||||
["path must be absolute", "backup.apiErrors.absolutePath"],
|
||||
["this path is already configured", "backup.apiErrors.pathConfigured"],
|
||||
["path is required", "backup.apiErrors.pathRequired"],
|
||||
["path query parameter is required", "backup.apiErrors.pathQuery"],
|
||||
["path not found", "backup.apiErrors.pathNotFound"],
|
||||
["invalid job id", "backup.apiErrors.invalidJobId"],
|
||||
["job not found", "backup.apiErrors.jobNotFound"],
|
||||
["runner script not installed", "backup.apiErrors.runnerMissing"],
|
||||
["no restore state to dismiss", "backup.apiErrors.noRestore"],
|
||||
["invalid file name", "backup.apiErrors.invalidFile"],
|
||||
["not found", "backup.apiErrors.notFound"],
|
||||
["log path outside allowed directory", "backup.apiErrors.logOutside"],
|
||||
["targets must be a list", "backup.apiErrors.targetsList"],
|
||||
["empty expression", "backup.apiErrors.emptyExpression"],
|
||||
["invalid id (use letters, digits, _ or -)", "backup.apiErrors.invalidId"],
|
||||
["backend must be pbs, local or borg", "backup.apiErrors.backend"],
|
||||
["backend must be pbs or borg", "backup.apiErrors.remoteBackend"],
|
||||
["backend must be 'pbs' or 'borg'", "backup.apiErrors.remoteBackendQuoted"],
|
||||
["profile_mode must be default or custom", "backup.apiErrors.profileMode"],
|
||||
["borg cannot be attached (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttached"],
|
||||
["borg is not a valid backend for attached jobs (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttachedLong"],
|
||||
["pve_parent_job_id and pve_storage are required for attached", "backup.apiErrors.parentRequired"],
|
||||
["paths must be a list of absolute paths", "backup.apiErrors.absolutePaths"],
|
||||
["custom profile needs at least one path", "backup.apiErrors.customPath"],
|
||||
["could not resolve default profile paths", "backup.apiErrors.defaultPaths"],
|
||||
["retention must be an object", "backup.apiErrors.retentionObject"],
|
||||
["pbs_repository is required", "backup.apiErrors.pbsRepository"],
|
||||
["pbs_encrypt_mode must be 'none', 'new' or 'existing'", "backup.apiErrors.pbsEncryption"],
|
||||
["failed to create PBS encryption keyfile", "backup.apiErrors.pbsKeyFailed"],
|
||||
["failed to create PBS encryption keyfile (is proxmox-backup-client installed?)", "backup.apiErrors.pbsClientMissing"],
|
||||
["borg_repo is required", "backup.apiErrors.borgRepository"],
|
||||
["borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.borgEncryption"],
|
||||
["encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.encryption"],
|
||||
["borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)", "backup.apiErrors.borgPassphrase"],
|
||||
["passphrase is required when encryption is enabled", "backup.apiErrors.passphrase"],
|
||||
["password is required", "backup.apiErrors.password"],
|
||||
["confirm_device must match device exactly (typo / safety check)", "backup.apiErrors.deviceConfirmation"],
|
||||
["invalid device path", "backup.apiErrors.invalidDevice"],
|
||||
["absolute mountpoint required", "backup.apiErrors.absoluteMountpoint"],
|
||||
["archive layout not recognized — no rootfs/ found", "backup.apiErrors.archiveLayout"],
|
||||
["archive not found", "backup.apiErrors.archiveMissing"],
|
||||
["archive path is not a regular file", "backup.apiErrors.archiveNotFile"],
|
||||
["invalid name", "backup.apiErrors.invalidName"],
|
||||
["invalid staging_path", "backup.apiErrors.invalidStaging"],
|
||||
["job name cannot be changed (delete + recreate to rename)", "backup.apiErrors.jobRename"],
|
||||
["key_path must be absolute", "backup.apiErrors.absoluteKey"],
|
||||
["mode must be \"local\" or \"ssh\"", "backup.apiErrors.mode"],
|
||||
["name is required (letters, digits, _ or -)", "backup.apiErrors.nameRequired"],
|
||||
["no log available for this job yet", "backup.apiErrors.noLog"],
|
||||
["no manual PBS configs file", "backup.apiErrors.noPbsConfig"],
|
||||
["output file missing", "backup.apiErrors.outputMissing"],
|
||||
["parser output was not valid JSON", "backup.apiErrors.parserJson"],
|
||||
["run_restore output was not valid JSON", "backup.apiErrors.restoreJson"],
|
||||
["repo is required", "backup.apiErrors.repoRequired"],
|
||||
["repo_name and snapshot are required", "backup.apiErrors.repoSnapshot"],
|
||||
["repo_name and snapshot are required for borg", "backup.apiErrors.borgRepoSnapshot"],
|
||||
["repo_name and snapshot are required for pbs", "backup.apiErrors.pbsRepoSnapshot"],
|
||||
["repo_name is required", "backup.apiErrors.repoName"],
|
||||
["restore tooling not installed on this host", "backup.apiErrors.restoreMissing"],
|
||||
["server and datastore are required", "backup.apiErrors.serverDatastore"],
|
||||
["snapshot is required", "backup.apiErrors.snapshotRequired"],
|
||||
["source must be pbs, borg, or local", "backup.apiErrors.source"],
|
||||
["ssh_port must be an integer between 1 and 65535", "backup.apiErrors.sshPort"],
|
||||
["ssh_user, ssh_host and ssh_remote_path are required for ssh mode", "backup.apiErrors.sshRequired"],
|
||||
["target has no repository path", "backup.apiErrors.repositoryPath"],
|
||||
["task not found or expired", "backup.apiErrors.taskExpired"],
|
||||
["invalid expression", "backup.apiErrors.invalidExpression"],
|
||||
["no report emitted", "backup.apiErrors.noReport"],
|
||||
["parse_manifest exited non-zero", "backup.apiErrors.parseManifestFailed"],
|
||||
])
|
||||
|
||||
const parameterized: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[new RegExp("^job already exists: ([\\s\\S]+)$"), "backup.apiErrors.jobExists"],
|
||||
[new RegExp("^path does not exist on this host: ([\\s\\S]+)$"), "backup.apiErrors.pathMissing"],
|
||||
[new RegExp("^device does not exist: ([\\s\\S]+)$"), "backup.apiErrors.deviceMissing"],
|
||||
[new RegExp("^not a mountpoint: ([\\s\\S]+)$"), "backup.apiErrors.mountpoint"],
|
||||
[new RegExp("^local archive not found: ([\\s\\S]+)$"), "backup.apiErrors.localArchiveMissing"],
|
||||
[new RegExp("^Borg destination '([\\s\\S]+)' not found$"), "backup.apiErrors.borgDestination"],
|
||||
[new RegExp("^PBS destination '([\\s\\S]+)' not found$"), "backup.apiErrors.pbsDestination"],
|
||||
[new RegExp("^no password available for PBS repo '([\\s\\S]+)'$"), "backup.apiErrors.pbsPassword"],
|
||||
[new RegExp("^Borg repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.borgRepoMissing"],
|
||||
[new RegExp("^PBS repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.pbsRepoMissing"],
|
||||
[new RegExp("^Borg target not found: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetMissing"],
|
||||
[new RegExp("^PBS repo not found: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetMissing"],
|
||||
[new RegExp("^PVE vzdump job not found: ([\\s\\S]+)$"), "backup.apiErrors.pveJobMissing"],
|
||||
[new RegExp("^cannot persist state: ([\\s\\S]+)$"), "backup.apiErrors.statePersist"],
|
||||
[new RegExp("^cannot prepare ssh dir: ([\\s\\S]+)$"), "backup.apiErrors.sshDirectory"],
|
||||
[new RegExp("^cannot read log: ([\\s\\S]+)$"), "backup.apiErrors.logRead"],
|
||||
[new RegExp("^could not update \\.env: ([\\s\\S]+)$"), "backup.apiErrors.envUpdate"],
|
||||
[new RegExp("^failed to persist PBS config: ([\\s\\S]+)$"), "backup.apiErrors.pbsConfigWrite"],
|
||||
[new RegExp("^failed to persist: ([\\s\\S]+)$"), "backup.apiErrors.persist"],
|
||||
[new RegExp("^failed to remove archive: ([\\s\\S]+)$"), "backup.apiErrors.archiveRemove"],
|
||||
[new RegExp("^failed to start: ([\\s\\S]+)$"), "backup.apiErrors.start"],
|
||||
[new RegExp("^failed to update borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsUpdate"],
|
||||
[new RegExp("^failed to update pbs-manual-configs\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetsUpdate"],
|
||||
[new RegExp("^failed to update systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsUpdate"],
|
||||
[new RegExp("^failed to write borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsWrite"],
|
||||
[new RegExp("^failed to write job files: ([\\s\\S]+)$"), "backup.apiErrors.jobWrite"],
|
||||
[new RegExp("^failed to write local-target\\.conf: ([\\s\\S]+)$"), "backup.apiErrors.localTargetWrite"],
|
||||
[new RegExp("^failed to write systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsWrite"],
|
||||
[new RegExp("^format failed: ([\\s\\S]+)$"), "backup.apiErrors.format"],
|
||||
[new RegExp("^invalid on_calendar: ([\\s\\S]+)$"), "backup.apiErrors.calendar"],
|
||||
[new RegExp("^mount failed: ([\\s\\S]+)$"), "backup.apiErrors.mount"],
|
||||
[new RegExp("^parser invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.parserInvocation"],
|
||||
[new RegExp("^preflight invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.preflightInvocation"],
|
||||
[new RegExp("^public key not found after generation: ([\\s\\S]+)$"), "backup.apiErrors.publicKeyMissing"],
|
||||
[new RegExp("^retention\\.([\\s\\S]+) must be an integer$"), "backup.apiErrors.retentionInteger"],
|
||||
[new RegExp("^retention\\.([\\s\\S]+) must be non-negative$"), "backup.apiErrors.retentionNegative"],
|
||||
[new RegExp("^ssh-keygen failed: ([\\s\\S]+)$"), "backup.apiErrors.keygen"],
|
||||
[new RegExp("^systemctl failed: ([\\s\\S]+)$"), "backup.apiErrors.systemctl"],
|
||||
[new RegExp("^umount failed: ([\\s\\S]+)$"), "backup.apiErrors.unmount"],
|
||||
[new RegExp("^task is in state \"([\\s\\S]+)\", not completed$"), "backup.apiErrors.taskIncomplete"],
|
||||
[new RegExp("^unknown kind: ([\\s\\S]+)$"), "backup.apiErrors.unknownKind"],
|
||||
[new RegExp("^unknown mode \"([\\s\\S]+)\"$"), "backup.apiErrors.unknownMode"],
|
||||
[new RegExp("^([\\s\\S]+) is the built-in default and cannot be removed$"), "backup.apiErrors.defaultRemove"],
|
||||
[new RegExp("^([\\s\\S]+) is the built-in default; no need to add it$"), "backup.apiErrors.defaultAdd"],
|
||||
[new RegExp("^pbs_encrypt_mode='existing' but no keyfile is installed at ([\\s\\S]+) — import one first with POST /api/host-backups/pbs-encryption/import$"), "backup.apiErrors.keyImport"],
|
||||
[new RegExp("^PVE job storage mismatch \\(job uses ([\\s\\S]+), payload says ([\\s\\S]+)\\)$"), "backup.apiErrors.storageMismatch"],
|
||||
]
|
||||
|
||||
export function hostBackupErrorText(problem: unknown, t: Translate): string {
|
||||
const message = typeof problem === "string" ? problem
|
||||
: problem && typeof problem === "object" && "message" in problem && typeof problem.message === "string"
|
||||
? problem.message : ""
|
||||
if (!message) return t("backup.apiErrors.requestFailed")
|
||||
const key = fixed.get(message)
|
||||
if (key) return t(key)
|
||||
// Keep unusually large tool output verbatim, without running pattern matching over it.
|
||||
if (message.length > 16384) return message
|
||||
for (const [pattern, patternKey] of parameterized) {
|
||||
const match = pattern.exec(message)
|
||||
if (match) return t(patternKey, { value: match[1], other: match[2] ?? "" })
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import enMessages from "../../messages/en/common.json"
|
||||
import deMessages from "../../messages/de/common.json"
|
||||
import esMessages from "../../messages/es/common.json"
|
||||
import frMessages from "../../messages/fr/common.json"
|
||||
import itMessages from "../../messages/it/common.json"
|
||||
import ptMessages from "../../messages/pt/common.json"
|
||||
import skMessages from "../../messages/sk/common.json"
|
||||
import svMessages from "../../messages/sv/common.json"
|
||||
import type { LanguageCode } from "./languages"
|
||||
|
||||
type MessageTree = Record<string, unknown>
|
||||
|
||||
// Shared by React translations and error handling outside React hooks.
|
||||
export const MESSAGE_CATALOG: Record<LanguageCode, MessageTree> = {
|
||||
en: enMessages as MessageTree,
|
||||
de: deMessages as MessageTree,
|
||||
es: esMessages as MessageTree,
|
||||
fr: frMessages as MessageTree,
|
||||
it: itMessages as MessageTree,
|
||||
pt: ptMessages as MessageTree,
|
||||
sk: skMessages as MessageTree,
|
||||
sv: svMessages as MessageTree,
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
|
||||
import enMessages from "../../messages/en/common.json"
|
||||
import deMessages from "../../messages/de/common.json"
|
||||
import esMessages from "../../messages/es/common.json"
|
||||
import frMessages from "../../messages/fr/common.json"
|
||||
import itMessages from "../../messages/it/common.json"
|
||||
import ptMessages from "../../messages/pt/common.json"
|
||||
import skMessages from "../../messages/sk/common.json"
|
||||
import svMessages from "../../messages/sv/common.json"
|
||||
import { MESSAGE_CATALOG } from "./catalogs"
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
LANGUAGE_STORAGE_KEY,
|
||||
@@ -21,17 +14,6 @@ import {
|
||||
type MessageTree = Record<string, unknown>
|
||||
type TranslationParams = Record<string, string | number>
|
||||
|
||||
const MESSAGE_CATALOG: Record<LanguageCode, MessageTree> = {
|
||||
en: enMessages as MessageTree,
|
||||
de: deMessages as MessageTree,
|
||||
es: esMessages as MessageTree,
|
||||
fr: frMessages as MessageTree,
|
||||
it: itMessages as MessageTree,
|
||||
pt: ptMessages as MessageTree,
|
||||
sk: skMessages as MessageTree,
|
||||
sv: svMessages as MessageTree,
|
||||
}
|
||||
|
||||
interface I18nContextValue {
|
||||
language: LanguageCode
|
||||
setLanguage: (language: LanguageCode) => void
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
// Exact lxc_apps validation/persistence messages; never change their API contract.
|
||||
// Anchored patterns leave unknown diagnostics intact, including paths and commands.
|
||||
const RULES: [RegExp, string, string[]][] = [
|
||||
[/^an OCI operation on this container is running$/, "ociBusy", []],
|
||||
[/^([\s\S]+) is not installed via dpkg$/, "dpkgMissing", ["package"]],
|
||||
[/^dpkg\-query failed$/, "dpkgFailed", []],
|
||||
[/^no version matched in dpkg output$/, "dpkgNoVersion", []],
|
||||
[/^apk info failed$/, "apkFailed", []],
|
||||
[/^no version matched in apk output$/, "apkNoVersion", []],
|
||||
[/^could not read ([\s\S]+)$/, "fileRead", ["path"]],
|
||||
[/^file_regex did not match ([\s\S]+)$/, "fileNoMatch", ["path"]],
|
||||
[/^binary invocation failed$/, "binaryFailed", []],
|
||||
[/^no version matched in binary output$/, "binaryNoVersion", []],
|
||||
[/^python \-c importlib\.metadata failed$/, "pythonFailed", []],
|
||||
[/^no version matched in Python distribution output$/, "pythonNoVersion", []],
|
||||
[/^could not resolve the container's installed image$/, "dockerImageResolve", []],
|
||||
[/^docker inspect failed$/, "dockerInspectFailed", []],
|
||||
[/^container has no ([\s\S]+) label$/, "dockerLabelMissing", ["label"]],
|
||||
[/^docker label reports ([\s\S]+) \(mutable tag, not a version\)$/, "dockerLabelMutable", ["tag"]],
|
||||
[/^no version matched in docker label output$/, "dockerLabelNoVersion", []],
|
||||
[/^docker exec failed$/, "dockerExecFailed", []],
|
||||
[/^no version matched in docker exec output$/, "dockerExecNoVersion", []],
|
||||
[/^command_argv is empty$/, "commandEmpty", []],
|
||||
[/^command failed$/, "commandFailed", []],
|
||||
[/^no version matched in command output$/, "commandNoVersion", []],
|
||||
[/^unsupported method: ([\s\S]+)$/, "unsupportedMethod", ["method"]],
|
||||
[/^unknown upstream_type: ([\s\S]+)$/, "unknownUpstream", ["type"]],
|
||||
[/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+): not found$/, "githubNotFound", ["repo"]],
|
||||
[/^github rejected the request \(403\)$/, "githubRejected", []],
|
||||
[/^github error (\d+)$/, "githubError", ["code"]],
|
||||
[/^no tag \/ release name in response$/, "noRelease", []],
|
||||
[/^tag_regex did not match '([\s\S]+)'$/, "tagNoMatch", ["tag"]],
|
||||
[/^http error (\d+)$/, "httpError", ["code"]],
|
||||
[/^invalid JSON response: ([\s\S]+)$/, "invalidJson", ["detail"]],
|
||||
[/^json_path '([\s\S]+)' did not resolve$/, "jsonPathUnresolved", ["path"]],
|
||||
[/^empty value at json_path$/, "jsonEmpty", []],
|
||||
[/^no tags matched \(docker_hub\)$/, "dockerNoTags", []],
|
||||
[/^tag_regex matched only moving tags; use Docker image digest tracking$/, "dockerMovingOnly", []],
|
||||
[/^docker hub error (\d+)$/, "dockerHubError", ["code"]],
|
||||
[/^invalid docker hub JSON: ([\s\S]+)$/, "dockerHubJson", ["detail"]],
|
||||
[/^no tags returned by docker hub$/, "dockerHubEmpty", []],
|
||||
[/^payload must be a JSON object$/, "payload", []],
|
||||
[/^name is required and must be 1\-64 chars of letters\/digits\/spaces\/\._\+\-\(\)\/$/, "name", []],
|
||||
[/^installed_via must be one of: ([\s\S]+) or empty$/, "method", ["values"]],
|
||||
[/^package is required \(letters\/digits\/\._\+@:\/ up to 127 chars\)$/, "package", []],
|
||||
[/^file_path is required and must be an absolute path$/, "filePath", []],
|
||||
[/^file_regex is required$/, "fileRegex", []],
|
||||
[/^file_regex is not a valid regex: ([\s\S]+)$/, "fileRegexInvalid", ["detail"]],
|
||||
[/^binary_path is required and must be an absolute path$/, "binaryPath", []],
|
||||
[/^python_path is required and must be an absolute path$/, "pythonPath", []],
|
||||
[/^distribution is required \(PEP 503 name\)$/, "distribution", []],
|
||||
[/^container_name is required \(docker naming rules\)$/, "container", []],
|
||||
[/^label is required \(OCI label naming rules\)$/, "label", []],
|
||||
[/^binary_path is required \(absolute path or bare command\)$/, "dockerBinary", []],
|
||||
[/^installed_version is required for the manual method$/, "manualVersion", []],
|
||||
[/^installed_version exceeds (\d+) chars$/, "versionLength", ["max"]],
|
||||
[/^installed_version contains a forbidden control character$/, "versionControl", []],
|
||||
[/^installed_regex is not a valid regex: ([\s\S]+)$/, "installedRegexInvalid", ["detail"]],
|
||||
[/^upstream_type must be one of: ([\s\S]+)$/, "upstreamType", ["values"]],
|
||||
[/^tag_regex is not a valid regex: ([\s\S]+)$/, "tagRegexInvalid", ["detail"]],
|
||||
[/^repo is required for upstream_type=github$/, "repoRequired", []],
|
||||
[/^repo must be 'owner\/name'$/, "repoFormat", []],
|
||||
[/^github_source must be one of: ([\s\S]+)$/, "githubSource", ["values"]],
|
||||
[/^upstream_url is required for upstream_type=http_json$/, "upstreamUrlRequired", []],
|
||||
[/^upstream_url must be an http\(s\) URL$/, "upstreamUrl", []],
|
||||
[/^upstream_url exceeds (\d+) chars$/, "upstreamUrlLength", ["max"]],
|
||||
[/^upstream_json_path is required for upstream_type=http_json$/, "jsonPathRequired", []],
|
||||
[/^upstream_json_path exceeds (\d+) chars$/, "jsonPathLength", ["max"]],
|
||||
[/^upstream_json_path uses forbidden characters$/, "jsonPathCharacters", []],
|
||||
[/^docker_image is required for upstream_type=docker_hub$/, "dockerImageRequired", []],
|
||||
[/^docker_image exceeds (\d+) chars$/, "dockerImageLength", ["max"]],
|
||||
[/^docker_image must match Docker Hub naming \(owner\/name or name\)$/, "dockerImageFormat", []],
|
||||
[/^health_path must be a valid URL path \(max 255 chars\)$/, "healthPath", []],
|
||||
[/^logo_url must be an http\(s\) URL or this VM's cached local icon$/, "logoUrl", []],
|
||||
[/^invalid discovery_id$/, "discoveryId", []],
|
||||
[/^helper_slug must be a lowercase slug \(letters\/digits\/\._\-\)$/, "helperSlug", []],
|
||||
[/^update_via only accepts 'docker'$/, "updateVia", []],
|
||||
[/^update_via=docker requires a docker_label or docker_exec detector$/, "dockerDetector", []],
|
||||
[/^update_via=docker cannot be combined with ([\s\S]+)$/, "dockerConflict", ["field"]],
|
||||
[/^update_command must be a string$/, "updateCommandType", []],
|
||||
[/^update_command exceeds (\d+) chars$/, "updateCommandLength", ["max"]],
|
||||
[/^update_command contains a null byte$/, "updateCommandNull", []],
|
||||
[/^update_method must be none, helper or custom$/, "updateMethod", []],
|
||||
[/^update_command is required for update_method=custom$/, "updateCommandRequired", []],
|
||||
[/^update_command is only allowed for update_method=custom$/, "updateCommandOnlyCustom", []],
|
||||
[/^a supported helper_slug is required for update_method=helper$/, "helperRequired", []],
|
||||
[/^Docker\-delegated apps update through their image, not a separate app updater$/, "dockerUpdater", []],
|
||||
[/^command_argv is required \(non\-empty list\)$/, "commandRequired", []],
|
||||
[/^command_argv must be a non\-empty list of strings$/, "commandList", []],
|
||||
[/^command_argv accepts at most (\d+) entries$/, "commandCount", ["max"]],
|
||||
[/^command_argv\[(\d+)\] must be a non\-empty string$/, "commandEntry", ["index"]],
|
||||
[/^command_argv\[(\d+)\] exceeds (\d+) chars$/, "commandEntryLength", ["index", "max"]],
|
||||
[/^command_argv\[(\d+)\] contains a forbidden control character$/, "commandEntryControl", ["index"]],
|
||||
[/^binary_args must be a list of strings$/, "binaryArgsList", []],
|
||||
[/^binary_args accepts at most (\d+) entries$/, "binaryArgsCount", ["max"]],
|
||||
[/^binary_args\[(\d+)\] must be a non\-empty string$/, "binaryArgEntry", ["index"]],
|
||||
[/^binary_args\[(\d+)\] exceeds (\d+) chars$/, "binaryArgLength", ["index", "max"]],
|
||||
[/^binary_args\[(\d+)\] contains a forbidden control character$/, "binaryArgControl", ["index"]],
|
||||
[/^ports must be a list of \{port, description, scheme\}$/, "portsList", []],
|
||||
[/^ports\[(\d+)\] must be an object$/, "portObject", ["index"]],
|
||||
[/^ports\[(\d+)\]\.port is required$/, "portRequired", ["index"]],
|
||||
[/^ports\[(\d+)\]\.port must be an integer$/, "portInteger", ["index"]],
|
||||
[/^ports\[(\d+)\]\.port must be 1\-65535$/, "portRange", ["index"]],
|
||||
[/^port (\d+) appears more than once for this app$/, "portDuplicate", ["port"]],
|
||||
[/^ports\[(\d+)\]\.description has invalid characters$/, "portDescription", ["index"]],
|
||||
[/^ports\[(\d+)\]\.scheme must be 'http' or 'https'$/, "portScheme", ["index"]],
|
||||
[/^ports\[(\d+)\]\.web_path must be a valid URL path \(max 255 chars\)$/, "portPath", ["index"]],
|
||||
[/^ports\[(\d+)\]\.logo_url must be an http\(s\) URL \(max 512 chars\)$/, "portLogo", ["index"]],
|
||||
[/^ports\[(\d+)\]\.category has invalid characters or is too long$/, "portCategory", ["index"]],
|
||||
[/^ports\[(\d+)\]\.custom_url must be an http\(s\) URL \(max 512 chars\)$/, "portCustomUrl", ["index"]],
|
||||
[/^tag_regex exceeds 512 chars$/, "tagRegexLength", []],
|
||||
[/^could not persist sidecar \(permission\?\)$/, "persistPermission", []],
|
||||
[/^could not persist sidecar$/, "persist", []],
|
||||
[/^no apps registered for this vmid$/, "noApps", []],
|
||||
[/^app_id '([\s\S]+)' not found$/, "appMissing", ["appId"]],
|
||||
[/^app not found$/, "appNotFound", []],
|
||||
]
|
||||
|
||||
export function localizeAppEditorError(message: string | null | undefined, t: Translate): string {
|
||||
if (!message) return ""
|
||||
for (const [pattern, key, names] of RULES) {
|
||||
const match = pattern.exec(message)
|
||||
if (!match) continue
|
||||
const params = Object.fromEntries(names.map((name, index) => [name, match[index + 1]]))
|
||||
return t(`vmLxc.appEditor.errors.${key}`, params)
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Opaque viewer metadata only; updater commands and credentials stay server-side. */
|
||||
export type LxcUpdateJob = {
|
||||
id: string
|
||||
vmid: number
|
||||
state: "running" | "finalizing" | "complete"
|
||||
target: "os" | "app" | "both"
|
||||
ct_name: string
|
||||
script_path: string
|
||||
created_at: number
|
||||
exit_code: number | null
|
||||
}
|
||||
|
||||
const SEEN = "proxmenux-viewed-update-jobs"
|
||||
|
||||
export function viewedUpdateJobs(): string[] {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(SEEN) || "[]")
|
||||
return Array.isArray(value) ? value.filter((id) => typeof id === "string").slice(-128) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function markUpdateJobViewed(id: string) {
|
||||
try {
|
||||
localStorage.setItem(SEEN, JSON.stringify([...new Set([...viewedUpdateJobs(), id])].slice(-128)))
|
||||
window.dispatchEvent(new Event("proxmenux-update-jobs-viewed"))
|
||||
} catch {
|
||||
// Unavailable browser storage never controls or restarts server work.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const ERROR_KEYS: Record<string, string> = {
|
||||
monitor_link_address_required: "addressRequired",
|
||||
monitor_link_invalid_address: "invalidAddress",
|
||||
monitor_link_address_credentials: "addressCredentials",
|
||||
monitor_link_invalid_port: "invalidPort",
|
||||
monitor_link_invalid_request: "invalidRequest",
|
||||
monitor_link_duplicate: "duplicate",
|
||||
monitor_link_not_found: "notFound",
|
||||
monitor_link_detected_remove: "detectedRemove",
|
||||
}
|
||||
|
||||
// fetchApi preserves the structured response on Error.body. Never expose raw
|
||||
// server/network messages or treat arbitrary error codes as translation keys.
|
||||
export function monitorLinkError(error: unknown, t: Translate): string {
|
||||
const fallback = () => t("monitorLinks.saveFailed")
|
||||
if (!error || typeof error !== "object" || !("body" in error)) return fallback()
|
||||
const body = error.body
|
||||
if (!body || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return fallback()
|
||||
if (body.code === "monitor_link_limit") {
|
||||
const params = "params" in body ? body.params : null
|
||||
const count = params && typeof params === "object" && "count" in params ? params.count : null
|
||||
return typeof count === "number" && Number.isSafeInteger(count) && count > 0
|
||||
? t("monitorLinks.errors.limit", { count }) : fallback()
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code)
|
||||
? t(`monitorLinks.errors.${ERROR_KEYS[body.code]}`) : fallback()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import enMessages from "../messages/en/common.json"
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
// Descriptions are live server metadata. Translate only exact known wording;
|
||||
// a newer script description must remain visible rather than become stale text.
|
||||
export function optimizationDescription(description: string, t: Translate): string {
|
||||
const entries = Object.entries(enMessages.settings.optimizations.toolDescriptions)
|
||||
const match = entries.find(([, english]) => english === description)
|
||||
return match ? t(`settings.optimizations.toolDescriptions.${match[0]}`) : description
|
||||
}
|
||||
|
||||
// Unknown tools retain the server's name until their key is translated.
|
||||
export function optimizationToolName(tool: string, fallback: string, t: Translate): string {
|
||||
return Object.prototype.hasOwnProperty.call(enMessages.settings.optimizations.toolNames, tool)
|
||||
? t(`settings.optimizations.toolNames.${tool}`)
|
||||
: fallback
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const SOURCE_TOOLS = new Set([
|
||||
"subscription_banner", "time_sync", "apt_languages", "journald", "logrotate",
|
||||
"system_limits", "memory_settings", "zram_swap", "kernel_panic", "apt_ipv4",
|
||||
"kexec", "rpc", "motd", "system_utils", "network_optimization", "bashrc_custom",
|
||||
"figurine", "fastfetch", "log2ram", "zfs_autotrim", "amd_fixes", "persistent_network",
|
||||
"vfio_iommu", "lvm_repair", "repo_cleanup", "proxmox_repos", "entropy",
|
||||
])
|
||||
|
||||
export function optimizationSourceName(tool: string, fallback: string, t: Translate): string {
|
||||
return SOURCE_TOOLS.has(tool) ? t(`settings.optimizations.sourceToolNames.${tool}`) : fallback
|
||||
}
|
||||
|
||||
export interface OptimizationSourceError {
|
||||
kind: "unavailable" | "loadFailed"
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface OptimizationSourceResponse {
|
||||
success?: unknown
|
||||
source?: unknown
|
||||
function?: unknown
|
||||
script?: unknown
|
||||
deprecated?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
// Keep the blueprint's English API contract intact; translate only its display.
|
||||
export function optimizationSourceError(error: OptimizationSourceError, t: Translate): string {
|
||||
const detail = error.detail || ""
|
||||
const key = "settings.optimizations."
|
||||
if (detail === "Function not found in available scripts") return t(key + "sourceFunctionMissing")
|
||||
if (detail.startsWith("Unknown tool: ")) return t(key + "sourceUnknownTool", { tool: detail.slice(14) })
|
||||
if (detail.startsWith("No function mapping for ")) return t(key + "sourceNoMapping", { tool: detail.slice(24) })
|
||||
if (detail === "Source code not available") return t(key + "sourceUnavailable")
|
||||
const message = t(key + (error.kind === "unavailable" ? "sourceUnavailable" : "sourceLoadFailed"))
|
||||
return detail ? `${message} ${detail}` : message
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const ERROR_KEYS: Record<string, string> = {
|
||||
pbs_config_read: "pbs.errors.configRead", pbs_invalid_config: "pbs.errors.invalidConfig",
|
||||
pbs_invalid_field: "pbs.errors.invalidField", pbs_invalid_url: "pbs.errors.invalidUrl",
|
||||
pbs_name_required: "pbs.errors.nameRequired", pbs_token_id_format: "pbs.errors.tokenIdFormat",
|
||||
pbs_token_secret: "pbs.errors.tokenSecret", pbs_invalid_token_id: "pbs.errors.invalidTokenId",
|
||||
pbs_invalid_fingerprint: "pbs.errors.invalidFingerprint", pbs_fingerprint_mismatch: "pbs.errors.fingerprintMismatch",
|
||||
pbs_token_rejected: "pbs.errors.tokenRejected", pbs_invalid_response: "pbs.errors.invalidResponse",
|
||||
pbs_invalid_json: "pbs.errors.invalidJson", pbs_connect_failed: "pbs.errors.connectFailed",
|
||||
pbs_invalid_id: "pbs.errors.invalidId", pbs_invalid_datastores: "pbs.errors.invalidDatastores",
|
||||
pbs_username: "pbs.errors.username", pbs_password: "pbs.errors.password",
|
||||
pbs_login_rejected: "pbs.errors.loginRejected", pbs_invalid_login: "pbs.errors.invalidLogin",
|
||||
pbs_token_create_denied: "pbs.errors.tokenCreateDenied", pbs_invalid_token: "pbs.errors.invalidToken",
|
||||
pbs_audit_role_denied: "pbs.errors.auditRoleDenied", pbs_not_found: "pbs.errors.notFound",
|
||||
pbs_read_failed: "pbs.errors.readFailed", pbs_invalid_list: "pbs.errors.invalidList",
|
||||
pbs_local_unavailable: "pbs.errors.localUnavailable", pbs_auth_required: "pbs.errors.authRequired",
|
||||
pbs_save_failed: "pbs.saveFailed", pbs_remove_failed: "pbs.removeFailed",
|
||||
pbs_saved_refresh_failed: "pbs.savedRefreshFailed", pbs_removed_refresh_failed: "pbs.removedRefreshFailed",
|
||||
pbs_datastore_offline: "pbs.errors.datastoreOffline", pbs_datastore_unavailable: "pbs.errors.datastoreUnavailable",
|
||||
}
|
||||
|
||||
// Accept structured codes, never arbitrary error text as a translation key.
|
||||
function pbsErrorTranslation(error: unknown): { key: string; params?: { count: number } } | null {
|
||||
const body = error && typeof error === "object" && "body" in error ? error.body : error
|
||||
if (!body || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return null
|
||||
if (body.code === "pbs_connection_limit") {
|
||||
const params = "params" in body ? body.params : null
|
||||
const count = params && typeof params === "object" && "count" in params ? params.count : null
|
||||
return typeof count === "number" && Number.isSafeInteger(count) && count > 0
|
||||
? { key: "pbs.errors.connectionLimit", params: { count } } : null
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code) ? { key: ERROR_KEYS[body.code] } : null
|
||||
}
|
||||
|
||||
export function pbsIsKnownError(error: unknown): boolean {
|
||||
return pbsErrorTranslation(error) !== null
|
||||
}
|
||||
|
||||
export function pbsErrorText(error: unknown, t: Translate, fallback = "pbs.loadFailed"): string {
|
||||
const translated = pbsErrorTranslation(error)
|
||||
return translated ? t(translated.key, translated.params) : t(fallback)
|
||||
}
|
||||
|
||||
export function pbsDiagnostic(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== "object") return undefined
|
||||
const body = "body" in error ? error.body : null
|
||||
if (body && typeof body === "object" && "error" in body && typeof body.error === "string") return body.error
|
||||
return "message" in error && typeof error.message === "string" ? error.message : undefined
|
||||
}
|
||||
|
||||
const TASK_TYPES: Record<string, string> = {
|
||||
backup: "backup", reader: "restore", verify: "verify", verificationjob: "verify",
|
||||
verify_group: "verifyGroup", verify_snapshot: "verifySnapshot", prune: "prune", prunejob: "prune",
|
||||
garbage_collection: "garbageCollection", sync: "sync", syncjob: "sync", aptupdate: "updateCheck",
|
||||
termproxy: "terminal", dircreate: "createDatastore", "create-datastore": "createDatastore",
|
||||
}
|
||||
export function pbsTaskType(type: string | undefined, t: Translate): string {
|
||||
return type && Object.prototype.hasOwnProperty.call(TASK_TYPES, type)
|
||||
? t(`pbs.taskTypes.${TASK_TYPES[type]}`) : t("pbs.taskTypes.other", { type: type || "—" })
|
||||
}
|
||||
|
||||
export function pbsResultText(status: string | undefined, t: Translate): string {
|
||||
if (!status || status === "unknown") return t("pbs.states.unknown")
|
||||
if (status.toLowerCase() === "ok") return t("pbs.states.ok")
|
||||
const warnings = /^WARNINGS:\s*(\d+)\s*$/i.exec(status)
|
||||
if (warnings && Number.isSafeInteger(Number(warnings[1]))) return t("pbs.states.warnings", { count: Number(warnings[1]) })
|
||||
return /^warn/i.test(status) ? t("pbs.states.warning") : t("pbs.states.error")
|
||||
}
|
||||
|
||||
export function pbsServiceState(state: string, t: Translate): string {
|
||||
const known = ["active", "inactive", "failed", "activating", "deactivating", "unknown"]
|
||||
return t(`pbs.serviceStates.${known.includes(state) ? state : "unknown"}`)
|
||||
}
|
||||
|
||||
export function formatPBSDuration(seconds: number, t: Translate): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "—"
|
||||
const unit = (key: string, count: number) => t(`pbs.durationUnits.${key}`, { count })
|
||||
if (seconds < 60) return unit("seconds", Math.round(seconds))
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${unit("minutes", minutes)} ${unit("seconds", Math.round(seconds % 60))}`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return hours < 24 ? `${unit("hours", hours)} ${unit("minutes", minutes % 60)}`
|
||||
: `${unit("days", Math.floor(hours / 24))} ${unit("hours", hours % 24)}`
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Native OCI `manage` reserves this code for explicit user cancellation.
|
||||
export const OCI_CANCELLED_EXIT_CODE = 6
|
||||
|
||||
export function scriptCompletion(exitCode: number, cancelledExitCode?: number) {
|
||||
if (exitCode === 0) return { kind: "success", color: "32" } as const
|
||||
if (cancelledExitCode !== undefined && exitCode === cancelledExitCode) {
|
||||
return { kind: "cancelled", color: "36" } as const
|
||||
}
|
||||
return { kind: "error", color: "31" } as const
|
||||
}
|
||||
|
||||
export function shouldRefreshOciAfterCompletion(exitCode?: number) {
|
||||
return exitCode !== OCI_CANCELLED_EXIT_CODE
|
||||
}
|
||||
|
||||
export function scriptCompletionOutput(exitCode: number, message: string, cancelledExitCode?: number) {
|
||||
const completion = scriptCompletion(exitCode, cancelledExitCode)
|
||||
// Queue RIS with the result so earlier ncurses writes finish before reset.
|
||||
// A synchronous terminal.reset() could be overwritten by that pending output.
|
||||
const reset = completion.kind === "cancelled" ? "\x1bc" : ""
|
||||
return `${reset}\x1b[${completion.color}m${message}\x1b[0m`
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
|
||||
// Per-browser display preference for the Storage view: whether the
|
||||
// "Physical Disks & SMART Status" card groups disks under the storage
|
||||
// controller they hang off. Off by default so the layout is unchanged
|
||||
// until the user opts in from Settings.
|
||||
|
||||
const STORAGE_KEY = "proxmenux-group-disks-by-controller"
|
||||
const CHANGE_EVENT = "proxmenux-group-disks-by-controller-changed"
|
||||
|
||||
export function readGroupDisksByController(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === "true"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function writeGroupDisksByController(enabled: boolean): void {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, String(enabled))
|
||||
window.dispatchEvent(new CustomEvent(CHANGE_EVENT))
|
||||
} catch {
|
||||
// Storage full / disabled; the in-memory state still updates.
|
||||
}
|
||||
}
|
||||
|
||||
// Hook that keeps the Settings card and the Storage view in sync in the
|
||||
// same tab (custom event) and across tabs (storage event).
|
||||
export function useGroupDisksByController(): [boolean, (enabled: boolean) => void] {
|
||||
const [enabled, setEnabledState] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setEnabledState(readGroupDisksByController())
|
||||
const onChange = () => setEnabledState(readGroupDisksByController())
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY) onChange()
|
||||
}
|
||||
window.addEventListener(CHANGE_EVENT, onChange)
|
||||
window.addEventListener("storage", onStorage)
|
||||
return () => {
|
||||
window.removeEventListener(CHANGE_EVENT, onChange)
|
||||
window.removeEventListener("storage", onStorage)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setEnabled = useCallback((next: boolean) => {
|
||||
writeGroupDisksByController(next)
|
||||
setEnabledState(next)
|
||||
}, [])
|
||||
|
||||
return [enabled, setEnabled]
|
||||
}
|
||||
@@ -8,4 +8,4 @@
|
||||
// 3. beta_version.txt ← bash pipeline (build_appimage.sh)
|
||||
//
|
||||
// Keep the three in sync on every bump.
|
||||
export const APP_VERSION = "1.2.6.2-beta"
|
||||
export const APP_VERSION = "1.2.8"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values.",
|
||||
"app": {
|
||||
"title": "ProxMenux Monitor",
|
||||
"description": "Proxmox System Dashboard",
|
||||
"description": "Proxmox-Systemübersicht",
|
||||
"loading": "Laden...",
|
||||
"connecting": "Verbindung zum ProxMenux Monitor herstellen",
|
||||
"unknown": "Unbekannt",
|
||||
@@ -827,7 +827,63 @@
|
||||
"temperatureCriticalCount": "Kritische Temperatur: {count}",
|
||||
"temperatureUnavailable": "Temperatur nicht verfügbar",
|
||||
"temperatureIncomplete": "Temperaturdaten unvollständig",
|
||||
"temperatureUnavailableCount": "Nicht verfügbar: {count}"
|
||||
"temperatureUnavailableCount": "Nicht verfügbar: {count}",
|
||||
"zfs": {
|
||||
"classes": {
|
||||
"normal": "Daten",
|
||||
"special": "Speziell",
|
||||
"dedup": "Dedup",
|
||||
"log": "Protokoll",
|
||||
"cache": "Cache",
|
||||
"spare": "Ersatz"
|
||||
},
|
||||
"poolDetails": "Pool-Layout, Scrub-Status und Gerätezustand",
|
||||
"detailsUnavailable": "Detailliertes Poollayout erfordert OpenZFS 2.3 oder neuer.",
|
||||
"status": "Status",
|
||||
"action": "Aktion",
|
||||
"capacity": "Kapazität",
|
||||
"fragmentation": "Fragmentierung",
|
||||
"dedupRatio": "Dedup-Verhältnis",
|
||||
"layout": "Layout",
|
||||
"singleDisk": "Einzelne Festplatte",
|
||||
"stripe": "Streifen",
|
||||
"mirror": "Spiegel",
|
||||
"raidz": "RAIDZ{parity}",
|
||||
"draid": "dRAID{parity}",
|
||||
"diskCountOne": "{count}-Datenträger",
|
||||
"diskCountMany": "{count}-Festplatten",
|
||||
"errorsReported": "Fehler gemeldet",
|
||||
"scanTitle": "Scrub / Resilver",
|
||||
"scrub": "Scrub",
|
||||
"resilver": "Resilver",
|
||||
"neverScrubbed": "Nie geschrubbt",
|
||||
"scanRunning": "{kind} in Bearbeitung ({percent} %)",
|
||||
"scanCanceled": "{kind} abgebrochen",
|
||||
"scanFinished": "{kind} fertig {date}",
|
||||
"started": "Gestartet",
|
||||
"ended": "Fertig",
|
||||
"repaired": "Repariert",
|
||||
"scanErrors": "Scanfehler",
|
||||
"dataErrors": "{count}-Datenfehler",
|
||||
"noDataErrors": "Keine bekannten Datenfehler",
|
||||
"device": "Gerät",
|
||||
"disk": "Festplatte",
|
||||
"state": "Staat",
|
||||
"readErrors": "Lesefehler",
|
||||
"writeErrors": "Schreibfehler",
|
||||
"checksumErrors": "Prüfsummenfehler",
|
||||
"readShort": "R",
|
||||
"writeShort": "W",
|
||||
"checksumShort": "CK",
|
||||
"slowIos": "Langsame E/As",
|
||||
"via": "über {name}",
|
||||
"totalSize": "Gesamtgröße",
|
||||
"fragShort": "Frag."
|
||||
},
|
||||
"storageController": "Speichercontroller",
|
||||
"noController": "Kein Controller erkannt",
|
||||
"diskCountOne": "{count}-Datenträger",
|
||||
"diskCountMany": "{count}-Festplatten"
|
||||
},
|
||||
"details": {
|
||||
"temperature": {
|
||||
@@ -1231,7 +1287,9 @@
|
||||
"update": "Aktualisieren",
|
||||
"modify": "Ändern",
|
||||
"recover": "Wiederherstellen",
|
||||
"registerRestored": "Erneut registrieren",
|
||||
"terminalTitleUpdate": "OCI-Anwendung aktualisieren",
|
||||
"terminalTitleRecoverPrevious": "Vorherige OCI-Installation wiederherstellen",
|
||||
"terminalTitleModify": "OCI-Anwendung ändern",
|
||||
"terminalTitleRecover": "Wiederhergestellte OCI-Anwendung registrieren",
|
||||
"terminalDescription": "Es läuft derselbe Vorgang wie im ProxMenux-Menü: OCI manager Apps → Installierte OCI-Anwendungen verwalten.",
|
||||
@@ -1319,9 +1377,9 @@
|
||||
"Freitag",
|
||||
"Samstag"
|
||||
],
|
||||
"runSkippedStatus": "Lauf übersprungen",
|
||||
"reasonStoppedContainer": "Container ist gestoppt; das geplante Update wurde übersprungen.",
|
||||
"reasonStoppedContainerHistorical": "Der Container wurde zum geplanten Aktualisierungszeitpunkt gestoppt."
|
||||
"runSkippedStatus": "Ausführung übersprungen",
|
||||
"reasonStoppedContainer": "Der Container ist gestoppt; die geplante Aktualisierung wurde übersprungen.",
|
||||
"reasonStoppedContainerHistorical": "Der Container war zum Zeitpunkt der geplanten Aktualisierung gestoppt."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "Host-Cron erkannt",
|
||||
@@ -1493,7 +1551,13 @@
|
||||
"startBeforeCheck": "Starten Sie den Container und suchen Sie nach Updates. Kein Abzeichen bedeutet nicht, dass es keine Updates gibt.",
|
||||
"runCustomCommand": "Benutzerdefinierten Befehl ausführen",
|
||||
"installedVersionUnverified": "Die installierte Version ist nicht bestätigt. Die Verfügbarkeit des Updates konnte nicht bestätigt werden.",
|
||||
"versionStatusUnknown": "Die Verfügbarkeit des Updates ist nicht bestätigt. Versionsinformationen sind nicht verfügbar oder unvollständig."
|
||||
"versionStatusUnknown": "Die Verfügbarkeit des Updates ist nicht bestätigt. Versionsinformationen sind nicht verfügbar oder unvollständig.",
|
||||
"backgroundRunning": "{name} (CT {vmid}): Auf dem Server läuft ein Update.",
|
||||
"backgroundOutputTrimmed": "Die frühere Ausgabe wurde gekürzt; das Update läuft normal weiter.",
|
||||
"backgroundFinished": "{name} (CT {vmid}): Ein Update ist abgeschlossen. Ergebnis ansehen.",
|
||||
"viewBackgroundJob": "Update anzeigen",
|
||||
"backgroundDetached": "Die Anzeige wurde getrennt. Das Update läuft auf dem Server weiter; verbinden Sie sich nach der Anmeldung erneut.",
|
||||
"backgroundDescription": "Das Schließen dieses Fensters beendet das Update nicht. Mit Strg+C brechen Sie es ab."
|
||||
},
|
||||
"bulkUpdate": {
|
||||
"title": "Sammelaktualisierung",
|
||||
@@ -1694,6 +1758,11 @@
|
||||
"catalogPort": "Port {port}",
|
||||
"trackingAvailable": "Tracking verfügbar",
|
||||
"logoUrlLabel": "Logo-URL",
|
||||
"discoverLocalLogo": "Symbol suchen",
|
||||
"localLogoPort": "Anwendungsport für die Symbolsuche",
|
||||
"localLogoHelp": "Liest nur den gewählten Port dieses laufenden Containers. Externe Symbole werden nicht abgerufen. Das Symbol wird beim Speichern übernommen.",
|
||||
"localLogoStopped": "Der Container ist gestoppt. Starten Sie ihn, bevor Sie sein Symbol suchen.",
|
||||
"localLogoUnavailable": "Kein verwendbares lokales Symbol gefunden. Prüfen Sie den gewählten Webport und versuchen Sie es erneut. Ihr aktuelles Symbol bleibt unverändert.",
|
||||
"webLinks": "Weblinks",
|
||||
"addPort": "Port hinzufügen",
|
||||
"detectedPorts": "Im Container erkannte Ports – klicken Sie zum Hinzufügen:",
|
||||
@@ -1744,10 +1813,139 @@
|
||||
"nativeEvidenceDirectory": "Arbeitsverzeichnis",
|
||||
"nativeEvidenceService": "Dienstdefinition",
|
||||
"nativeScanFailed": "Die Anwendungssuche konnte nicht abgeschlossen werden. Frühere Ergebnisse sind möglicherweise veraltet; versuchen Sie es erneut.",
|
||||
"nativeScanStopped": "Dieser Container ist gestoppt. Die Anwendungssuche ist nur verfügbar, während der Container läuft.",
|
||||
"nativeScanPartial": "Die Anwendungssuche ist unvollständig. Prüfen Sie die gefundenen Hinweise und versuchen Sie es erneut.",
|
||||
"nativeVersionUnknown": "Installierte Version unbekannt",
|
||||
"nativeEvidenceStale": "Die Hinweise sind möglicherweise veraltet oder unvollständig.",
|
||||
"nativeRegisterOnly": "Bestätigen Sie die Registrierung im Editor. Eine bekannte Version wird als manueller Wert gespeichert; es wird kein Updater eingerichtet."
|
||||
"nativeRegisterOnly": "Bestätigen Sie die Registrierung im Editor. Eine bekannte Version wird als manueller Wert gespeichert; es wird kein Updater eingerichtet.",
|
||||
"dockerScanFailed": "Die Docker-Suche konnte nicht abgeschlossen werden. Frühere Ergebnisse sind möglicherweise veraltet; versuchen Sie es erneut.",
|
||||
"dockerScanPartial": "Die Docker-Suche ist unvollständig. Einige Container konnten nicht geprüft werden.",
|
||||
"dockerEvidenceContainer": "Docker-Container",
|
||||
"dockerEvidenceImage": "Image",
|
||||
"dockerEvidencePort": "Veröffentlichter TCP-Port",
|
||||
"dockerEvidenceProject": "Compose-Projekt",
|
||||
"dockerEndpointReview": "Container mit veröffentlichten TCP-Ports. Bestätigen Sie Namen und Webadresse vor dem Speichern. Die Registrierung richtet keine Updates ein.",
|
||||
"confirmRemove": "Den Eintrag dieser Anwendung aus der Registerkarte App entfernen? Die Anwendung wird dadurch nicht deinstalliert.",
|
||||
"packageExample": "z. B. {package}",
|
||||
"packageHelpBefore": "Automatisch aus dem Namen ausgefüllt. Prüfen Sie dies mit",
|
||||
"packageHelpAfter": "innerhalb des CT.",
|
||||
"binaryHelpBefore": "Absoluter Pfad. Ermitteln Sie ihn mit",
|
||||
"binaryHelpOr": "oder",
|
||||
"errors": {
|
||||
"ociBusy": "ein OCI-Vorgang an diesem Container läuft",
|
||||
"dpkgMissing": "{package} ist nicht über dpkg installiert",
|
||||
"dpkgFailed": "dpkg-query ist fehlgeschlagen",
|
||||
"dpkgNoVersion": "keine passende Version in der dpkg-Ausgabe",
|
||||
"apkFailed": "apk info ist fehlgeschlagen",
|
||||
"apkNoVersion": "keine passende Version in der apk-Ausgabe",
|
||||
"fileRead": "{path} konnte nicht gelesen werden",
|
||||
"fileNoMatch": "file_regex hat in {path} keine Übereinstimmung gefunden",
|
||||
"binaryFailed": "Aufruf der Binärdatei fehlgeschlagen",
|
||||
"binaryNoVersion": "keine passende Version in der Ausgabe der Binärdatei",
|
||||
"pythonFailed": "python -c importlib.metadata ist fehlgeschlagen",
|
||||
"pythonNoVersion": "keine passende Version in der Ausgabe der Python-Distribution",
|
||||
"dockerImageResolve": "das installierte Image des Containers konnte nicht ermittelt werden",
|
||||
"dockerInspectFailed": "docker inspect ist fehlgeschlagen",
|
||||
"dockerLabelMissing": "der Container hat kein Label {label}",
|
||||
"dockerLabelMutable": "das Docker-Label meldet {tag} (veränderlicher Tag, keine Version)",
|
||||
"dockerLabelNoVersion": "keine passende Version in der Ausgabe des Docker-Labels",
|
||||
"dockerExecFailed": "docker exec ist fehlgeschlagen",
|
||||
"dockerExecNoVersion": "keine passende Version in der Ausgabe von docker exec",
|
||||
"commandEmpty": "command_argv ist leer",
|
||||
"commandFailed": "Befehl fehlgeschlagen",
|
||||
"commandNoVersion": "keine passende Version in der Befehlsausgabe",
|
||||
"unsupportedMethod": "nicht unterstützte Methode: {method}",
|
||||
"unknownUpstream": "unbekannter upstream_type: {type}",
|
||||
"githubNotFound": "{repo}: nicht gefunden",
|
||||
"githubRejected": "GitHub hat die Anfrage abgelehnt (403)",
|
||||
"githubError": "GitHub-Fehler {code}",
|
||||
"noRelease": "kein Tag / Release-Name in der Antwort",
|
||||
"tagNoMatch": "tag_regex passt nicht auf '{tag}'",
|
||||
"httpError": "HTTP-Fehler {code}",
|
||||
"invalidJson": "ungültige JSON-Antwort: {detail}",
|
||||
"jsonPathUnresolved": "json_path '{path}' konnte nicht aufgelöst werden",
|
||||
"jsonEmpty": "leerer Wert bei json_path",
|
||||
"dockerNoTags": "keine passenden Tags (docker_hub)",
|
||||
"dockerMovingOnly": "tag_regex passt nur auf bewegliche Tags; verwenden Sie die Digest-Verfolgung der Docker-Images",
|
||||
"dockerHubError": "Docker-Hub-Fehler {code}",
|
||||
"dockerHubJson": "ungültiges JSON von Docker Hub: {detail}",
|
||||
"dockerHubEmpty": "Docker Hub hat keine Tags zurückgegeben",
|
||||
"payload": "die Nutzdaten müssen ein JSON-Objekt sein",
|
||||
"name": "name ist erforderlich und muss 1-64 Zeichen aus Buchstaben/Ziffern/Leerzeichen/._+-()/ enthalten",
|
||||
"method": "installed_via muss einer der folgenden Werte sein: {values} oder leer",
|
||||
"package": "package ist erforderlich (Buchstaben/Ziffern/._+@:/ bis zu 127 Zeichen)",
|
||||
"filePath": "file_path ist erforderlich und muss ein absoluter Pfad sein",
|
||||
"fileRegex": "file_regex ist erforderlich",
|
||||
"fileRegexInvalid": "file_regex ist kein gültiger regulärer Ausdruck: {detail}",
|
||||
"binaryPath": "binary_path ist erforderlich und muss ein absoluter Pfad sein",
|
||||
"pythonPath": "python_path ist erforderlich und muss ein absoluter Pfad sein",
|
||||
"distribution": "distribution ist erforderlich (Name nach PEP 503)",
|
||||
"container": "container_name ist erforderlich (Docker-Namensregeln)",
|
||||
"label": "label ist erforderlich (OCI-Namensregeln für Labels)",
|
||||
"dockerBinary": "binary_path ist erforderlich (absoluter Pfad oder bloßer Befehl)",
|
||||
"manualVersion": "installed_version ist für die manuelle Methode erforderlich",
|
||||
"versionLength": "installed_version überschreitet {max} Zeichen",
|
||||
"versionControl": "installed_version enthält ein unzulässiges Steuerzeichen",
|
||||
"installedRegexInvalid": "installed_regex ist kein gültiger regulärer Ausdruck: {detail}",
|
||||
"upstreamType": "upstream_type muss einer der folgenden Werte sein: {values}",
|
||||
"tagRegexInvalid": "tag_regex ist kein gültiger regulärer Ausdruck: {detail}",
|
||||
"repoRequired": "repo ist für upstream_type=github erforderlich",
|
||||
"repoFormat": "repo muss 'owner/name' sein",
|
||||
"githubSource": "github_source muss einer der folgenden Werte sein: {values}",
|
||||
"upstreamUrlRequired": "upstream_url ist für upstream_type=http_json erforderlich",
|
||||
"upstreamUrl": "upstream_url muss eine http(s)-URL sein",
|
||||
"upstreamUrlLength": "upstream_url überschreitet {max} Zeichen",
|
||||
"jsonPathRequired": "upstream_json_path ist für upstream_type=http_json erforderlich",
|
||||
"jsonPathLength": "upstream_json_path überschreitet {max} Zeichen",
|
||||
"jsonPathCharacters": "upstream_json_path verwendet unzulässige Zeichen",
|
||||
"dockerImageRequired": "docker_image ist für upstream_type=docker_hub erforderlich",
|
||||
"dockerImageLength": "docker_image überschreitet {max} Zeichen",
|
||||
"dockerImageFormat": "docker_image muss der Docker-Hub-Benennung entsprechen (owner/name oder name)",
|
||||
"healthPath": "health_path muss ein gültiger URL-Pfad sein (max. 255 Zeichen)",
|
||||
"logoUrl": "logo_url muss eine http(s)-URL oder das lokal zwischengespeicherte Symbol dieser VM sein",
|
||||
"discoveryId": "ungültige discovery_id",
|
||||
"helperSlug": "helper_slug muss ein Slug in Kleinbuchstaben sein (Buchstaben/Ziffern/._-)",
|
||||
"updateVia": "update_via akzeptiert nur 'docker'",
|
||||
"dockerDetector": "update_via=docker erfordert einen Detektor docker_label oder docker_exec",
|
||||
"dockerConflict": "update_via=docker kann nicht mit {field} kombiniert werden",
|
||||
"updateCommandType": "update_command muss eine Zeichenkette sein",
|
||||
"updateCommandLength": "update_command überschreitet {max} Zeichen",
|
||||
"updateCommandNull": "update_command enthält ein Null-Byte",
|
||||
"updateMethod": "update_method muss none, helper oder custom sein",
|
||||
"updateCommandRequired": "update_command ist für update_method=custom erforderlich",
|
||||
"updateCommandOnlyCustom": "update_command ist nur für update_method=custom zulässig",
|
||||
"helperRequired": "für update_method=helper ist ein unterstützter helper_slug erforderlich",
|
||||
"dockerUpdater": "An Docker delegierte Apps werden über ihr Image aktualisiert, nicht über einen separaten App-Updater",
|
||||
"commandRequired": "command_argv ist erforderlich (nicht leere Liste)",
|
||||
"commandList": "command_argv muss eine nicht leere Liste von Zeichenketten sein",
|
||||
"commandCount": "command_argv akzeptiert höchstens {max} Einträge",
|
||||
"commandEntry": "command_argv[{index}] muss eine nicht leere Zeichenkette sein",
|
||||
"commandEntryLength": "command_argv[{index}] überschreitet {max} Zeichen",
|
||||
"commandEntryControl": "command_argv[{index}] enthält ein unzulässiges Steuerzeichen",
|
||||
"binaryArgsList": "binary_args muss eine Liste von Zeichenketten sein",
|
||||
"binaryArgsCount": "binary_args akzeptiert höchstens {max} Einträge",
|
||||
"binaryArgEntry": "binary_args[{index}] muss eine nicht leere Zeichenkette sein",
|
||||
"binaryArgLength": "binary_args[{index}] überschreitet {max} Zeichen",
|
||||
"binaryArgControl": "binary_args[{index}] enthält ein unzulässiges Steuerzeichen",
|
||||
"portsList": "ports muss eine Liste von {port, description, scheme} sein",
|
||||
"portObject": "ports[{index}] muss ein Objekt sein",
|
||||
"portRequired": "ports[{index}].port ist erforderlich",
|
||||
"portInteger": "ports[{index}].port muss eine Ganzzahl sein",
|
||||
"portRange": "ports[{index}].port muss zwischen 1 und 65535 liegen",
|
||||
"portDuplicate": "Port {port} kommt für diese App mehrfach vor",
|
||||
"portDescription": "ports[{index}].description enthält ungültige Zeichen",
|
||||
"portScheme": "ports[{index}].scheme muss 'http' oder 'https' sein",
|
||||
"portPath": "ports[{index}].web_path muss ein gültiger URL-Pfad sein (max. 255 Zeichen)",
|
||||
"portLogo": "ports[{index}].logo_url muss eine http(s)-URL sein (max. 512 Zeichen)",
|
||||
"portCategory": "ports[{index}].category enthält ungültige Zeichen oder ist zu lang",
|
||||
"portCustomUrl": "ports[{index}].custom_url muss eine http(s)-URL sein (max. 512 Zeichen)",
|
||||
"tagRegexLength": "tag_regex überschreitet 512 Zeichen",
|
||||
"persistPermission": "Sidecar konnte nicht gespeichert werden (Berechtigung?)",
|
||||
"persist": "Sidecar konnte nicht gespeichert werden",
|
||||
"noApps": "für diese vmid sind keine Apps registriert",
|
||||
"appMissing": "app_id '{appId}' nicht gefunden",
|
||||
"appNotFound": "App nicht gefunden"
|
||||
}
|
||||
},
|
||||
"statusFilter": {
|
||||
"ariaLabel": "Virtuelle Maschinen und Container filtern",
|
||||
@@ -2144,7 +2342,9 @@
|
||||
"appriseSlackDocs": "Slack-URL-Formate und Einrichtung",
|
||||
"appriseMatrixSteps": "Verwenden Sie ein Benachrichtigungskonto, das in den Raum schreiben darf. Ersetzen Sie den Beispielbenutzer, das Passwort, den Homeserver und die vollständige Raum-ID; kodieren Sie Sonderzeichen in Zugangsdaten für URLs. Das Schema matrixs verwendet HTTPS. Informationen zur Unterstützung von Tokens, SSO und verschlüsselten Räumen in Ihrer installierten Apprise-Version finden Sie in der Dokumentation.",
|
||||
"appriseMatrixDocs": "Matrix-URL-Formate und Einrichtung",
|
||||
"appriseServiceFinish": "Aktivieren Sie Apprise, geben Sie Ihre URL ein, wählen Sie Benachrichtigungskategorien oder einzelne Ereignisse aus, speichern Sie und verwenden Sie anschließend Test senden. Diese Beispiele ändern Ihre gespeicherte URL nicht. Dieser Apprise-Kanal hat ein Ziel; verwenden Sie die Apprise-API für mehrere Dienste gleichzeitig."
|
||||
"appriseServiceFinish": "Aktivieren Sie Apprise, geben Sie Ihre URL ein, wählen Sie Benachrichtigungskategorien oder einzelne Ereignisse aus, speichern Sie und verwenden Sie anschließend Test senden. Diese Beispiele ändern Ihre gespeicherte URL nicht. Dieser Apprise-Kanal hat ein Ziel; verwenden Sie die Apprise-API für mehrere Dienste gleichzeitig.",
|
||||
"botTokenPlaceholder": "7595377878:AAGE6Fb2cy...",
|
||||
"botTokenHelp": "Das Token kann mit oder ohne das Präfix 'bot' eingegeben werden."
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI-Verbesserung",
|
||||
@@ -2168,6 +2368,7 @@
|
||||
"testing": "Testen…",
|
||||
"testConnection": "Testverbindung",
|
||||
"ollamaUrl": "Ollama-URL",
|
||||
"ollamaApiKeyHint": "Bei einem normalen Ollama-Server leer lassen. Wenn dein Proxy oder Gateway einen Schlüssel verlangt, wird er als Authorization: Bearer gesendet. Dieses Feld aktiviert keine Authentifizierung auf dem Server.",
|
||||
"getKey": "Holen Sie sich den Schlüssel",
|
||||
"loadModelsPlaceholder": "Klicken Sie auf „Laden“, um Modelle abzurufen",
|
||||
"selectModel": "Modell auswählen",
|
||||
@@ -2281,12 +2482,114 @@
|
||||
"updateManyDescription": "Wiederholen Sie die Nachinstallationsfunktionen von {count} nacheinander.",
|
||||
"legacySourceTitle": "Legacy-Optimierung – klicken Sie, um die Quelle anzuzeigen",
|
||||
"sourceTitle": "Klicken Sie hier, um den Quellcode anzuzeigen",
|
||||
"sourceDialogTitle": "Quellcode: {name}",
|
||||
"sourceLoading": "Quellcode wird geladen…",
|
||||
"sourceUnavailable": "Der Quellcode ist nicht verfügbar.",
|
||||
"sourceLoadFailed": "Der Quellcode konnte nicht geladen werden. Versuchen Sie es erneut.",
|
||||
"sourceFunctionMissing": "Die Funktion wurde in den verfügbaren Skripten nicht gefunden.",
|
||||
"sourceUnknownTool": "Unbekanntes Tool: {tool}",
|
||||
"sourceNoMapping": "Dem Tool {tool} ist keine Funktion zugeordnet.",
|
||||
"sourceToolNames": {
|
||||
"subscription_banner": "Entfernung des Abonnementbanners",
|
||||
"time_sync": "Zeitsynchronisierung",
|
||||
"apt_languages": "APT-Sprachdateien überspringen",
|
||||
"journald": "Journald-Optimierung",
|
||||
"logrotate": "Logrotate-Optimierung",
|
||||
"system_limits": "Erhöhung der Systemlimits",
|
||||
"memory_settings": "Optimierung der Speichereinstellungen",
|
||||
"zram_swap": "ZRAM-Swap-Profil",
|
||||
"kernel_panic": "Konfiguration bei Kernel Panic",
|
||||
"apt_ipv4": "APT IPv4 erzwingen",
|
||||
"kexec": "kexec für schnelle Neustarts",
|
||||
"rpc": "RPC / rpcbind deaktivieren",
|
||||
"motd": "Benutzerdefiniertes MOTD-Banner",
|
||||
"system_utils": "Systemdienstprogramme",
|
||||
"network_optimization": "Netzwerkoptimierungen",
|
||||
"bashrc_custom": "Bashrc-Anpassung",
|
||||
"figurine": "Figurine",
|
||||
"fastfetch": "Fastfetch",
|
||||
"log2ram": "Log2ram (SSD-Schutz)",
|
||||
"zfs_autotrim": "ZFS Autotrim",
|
||||
"amd_fixes": "Korrekturen für AMD-CPUs (Ryzen/EPYC)",
|
||||
"persistent_network": "Persistente Netzwerkschnittstellen festlegen",
|
||||
"vfio_iommu": "VFIO/IOMMU-Passthrough",
|
||||
"lvm_repair": "Reparatur der LVM-PV-Header",
|
||||
"repo_cleanup": "Repository-Bereinigung",
|
||||
"proxmox_repos": "Proxmox-APT-Repositorys",
|
||||
"entropy": "Entropie-Erzeugung (haveged)"
|
||||
},
|
||||
"legacy": "Vermächtnis",
|
||||
"updateToolTitle": "Aktualisieren Sie {name} auf v{version}",
|
||||
"availableUpdates": "Verfügbare Updates",
|
||||
"availableUpdatesDescription": "{count} Optimierungen können auf eine neuere Version aktualisiert werden.",
|
||||
"selectedCount": "{selected} von {total} ausgewählt",
|
||||
"updateSelected": "Update ausgewählt"
|
||||
"updateSelected": "Update ausgewählt",
|
||||
"toolDescriptions": {
|
||||
"subscriptionAuto": "Die Proxmox-Weboberfläche patchen, um den Abonnementdialog zu unterdrücken, und einen erfolgreichen Patch registrieren.",
|
||||
"timeSync": "Die Zeitzone anhand der öffentlichen IP erkennen und die systemd-Zeitsynchronisierung (NTP) aktivieren.",
|
||||
"aptLanguages": "Verhindern, dass APT Übersetzungsdateien herunterlädt, um Updates zu beschleunigen.",
|
||||
"journald": "Die Größe von journald begrenzen, das Ratenlimit erhöhen und Protokollierung auf Info-Ebene erzwingen, damit die Protokollanzeige und Fail2Ban funktionieren.",
|
||||
"logrotate": "logrotate.conf durch ein Log2RAM-freundliches Profil ersetzen (tägliche Rotation, copytruncate).",
|
||||
"systemLimits": "inotify-Watches, Dateideskriptoren, Prozessschlüssel und PID-Limits auf Enterprise-Niveau anheben.",
|
||||
"memory": "Swappiness, Dirty-Page-Verhältnisse und proaktive Kompaktierung für VM-Hosts abstimmen, ohne die Memory-Overcommit-Richtlinie des Kernels zu überschreiben.",
|
||||
"kernelPanic": "Automatischer Neustart bei Kernel Panic / Oops / Hardlockup; Crash-Dumps nach /var/crash schreiben.",
|
||||
"aptIpv4": "APT zur Verwendung von IPv4 zwingen, um Hänger auf Hosts mit fehlerhafter IPv6-Konnektivität zu vermeiden.",
|
||||
"network": "TCP-Puffer, somaxconn und IPv4-Härtung abstimmen und rp_filter auf fw-Bridges deaktivieren (kompatibel mit PVE 9).",
|
||||
"bashrc": "Den verwalteten ProxMenux-Bash-Prompt und die Aliase installieren und sicher migrieren, wobei der kurze/vollständige Stil des Arbeitsverzeichnisses beibehalten oder ausgewählt wird.",
|
||||
"log2ramAuto": "Bei einer Neuinstallation die Größe von Log2RAM am RAM des Hosts ausrichten und nachfragen, wenn der Speicher nicht als SSD erkannt wird. Aktualisierungen behalten die eingestellte Größe bei.",
|
||||
"zfsAutotrim": "ZFS-Autotrim auf erkannten Pools aktivieren und nur die von ProxMenux geänderten Pools erfassen.",
|
||||
"persistentNetwork": "NIC-Namen über systemd-.link-Dateien an MAC-Adressen binden, damit Kernel-Updates die Schnittstellennamen nicht vertauschen.",
|
||||
"kexec": "kexec-tools installieren und eine systemd-Unit sowie den Alias reboot-quick für schnelle Neustarts ohne BIOS/POST einrichten.",
|
||||
"amd": "idle=nomwait und Optionen des KVM-Moduls auf AMD-EPYC/Ryzen-Hosts anwenden.",
|
||||
"openVSwitch": "OpenVSwitch für softwaredefinierte Netzwerke in VMs und Containern installieren.",
|
||||
"tcpBbr": "TCP Fast Open (Clients + Server) und BBR-Überlastkontrolle für bessere Latenz unter Last aktivieren.",
|
||||
"ceph": "Ceph (Client- + Serverpakete) für verteilten RBD/CephFS-Speicher installieren; Repository-Auswahl passend zu PVE 8/9.",
|
||||
"zfsArc": "ZFS ARC max gemäß der Proxmox-VE-Richtlinie von 10 % des RAM (Obergrenze 16 GiB) begrenzen, widersprüchliche Moduleinstellungen sicher abgleichen und vor der Anwendung den Richtwert zur Pool-Größe anzeigen.",
|
||||
"zfsSnapshots": "zfs-auto-snapshot mit Cron-Zeitplänen für stündliche/tägliche/wöchentliche/monatliche Snapshots installieren.",
|
||||
"rpc": "rpcbind-Dienst/-Socket deaktivieren und dabei den genauen vorherigen systemd-Zustand für ein Rollback bewahren.",
|
||||
"pigz": "gzip durch pigz (parallele Implementierung) ersetzen, um die vzdump-Backup-Komprimierung zu beschleunigen.",
|
||||
"guestAgent": "QEMU/KVM, VMware oder VirtualBox erkennen und den passenden Gast-Agenten installieren; andere Umgebungen werden übersprungen.",
|
||||
"vfio": "IOMMU aktivieren und VFIO-Module laden, um GPU/PCI-Passthrough an VMs zu ermöglichen.",
|
||||
"motd": "Das ProxMenux-MOTD-Banner hinzufügen und dabei den ursprünglichen Dateiinhalt oder das Fehlen der Datei für ein Rollback bewahren.",
|
||||
"subscriptionCustom": "Die Proxmox-Weboberfläche patchen, um den Dialog \"no valid subscription\" zu unterdrücken (Varianten für PVE 8 + 9 unterstützt).",
|
||||
"zram": "Ein optionales ZRAM-Swap-Profil mit einem Zehntel der RAM-Größe aktivieren.",
|
||||
"backupLimits": "Die globalen vzdump-Standardwerte auf bwlimit: 0 und ionice: 5 setzen. Das garantiert keine schnelleren Backups.",
|
||||
"ovh": "Bei OVH gemietete Hardware per whois-Abfrage erkennen und OVH Real-Time Monitoring installieren (ohne Wirkung außerhalb von OVH).",
|
||||
"ha": "Den Proxmox-HA-Stack (pve-ha-lrm, pve-ha-crm, corosync) für Cluster-Failover aktivieren.",
|
||||
"fastfetch": "Das Systemübersichtstool Fastfetch mit dem ProxMenux-Logo + Statusblock als SSH-Anmeldebanner installieren.",
|
||||
"figurine": "Figurine (ASCII-Art-Banner mit dem Hostnamen) installieren und in die SSH-Anmeldung einbinden.",
|
||||
"log2ramCustom": "Log2RAM mit gewählter Größe, Synchronisierungsintervall und optionaler automatischer Synchronisierung installieren. Aktualisierungen behalten Größe und Wahl der automatischen Synchronisierung bei.",
|
||||
"systemUtilities": "Ausgewählte Systemdienstprogramme installieren und nur die Pakete erfassen, die von ProxMenux neu hinzugefügt wurden."
|
||||
},
|
||||
"toolNames": {
|
||||
"subscription_banner": "Entfernung des Abonnementbanners",
|
||||
"time_sync": "Zeitsynchronisierung",
|
||||
"apt_languages": "APT-Sprachdateien überspringen",
|
||||
"journald": "Journald-Optimierung",
|
||||
"logrotate": "Logrotate-Optimierung",
|
||||
"system_limits": "Erhöhung der Systemlimits",
|
||||
"memory_settings": "Optimierung der Speichereinstellungen",
|
||||
"zram_swap": "ZRAM-Swap-Profil",
|
||||
"kernel_panic": "Konfiguration bei Kernel Panic",
|
||||
"apt_ipv4": "APT IPv4 erzwingen",
|
||||
"kexec": "kexec für schnelle Neustarts",
|
||||
"rpc": "RPC / rpcbind deaktivieren",
|
||||
"motd": "Benutzerdefiniertes MOTD-Banner",
|
||||
"system_utils": "Systemdienstprogramme",
|
||||
"network_optimization": "Netzwerkoptimierungen",
|
||||
"bashrc_custom": "Bashrc-Anpassung",
|
||||
"figurine": "Figurine",
|
||||
"fastfetch": "Fastfetch",
|
||||
"log2ram": "Log2ram (SSD-Schutz)",
|
||||
"zfs_autotrim": "ZFS Autotrim",
|
||||
"amd_fixes": "Korrekturen für AMD-CPUs (Ryzen/EPYC)",
|
||||
"persistent_network": "Persistente Netzwerkschnittstellen festlegen",
|
||||
"vfio_iommu": "VFIO/IOMMU-Passthrough",
|
||||
"lvm_repair": "Reparatur der LVM-PV-Header",
|
||||
"repo_cleanup": "Repository-Bereinigung",
|
||||
"proxmox_repos": "Proxmox-APT-Repositorys",
|
||||
"entropy": "Entropie-Erzeugung (haveged)"
|
||||
}
|
||||
},
|
||||
"healthThresholds": {
|
||||
"title": "Schwellenwerte für den Health-Monitor",
|
||||
@@ -2329,6 +2632,10 @@
|
||||
"title": "Speicherplatz – LXC rootfs",
|
||||
"description": "Root-Laufwerk pro Container, bewertet anhand der Rootfs-Größe von PVE."
|
||||
},
|
||||
"vm_disk": {
|
||||
"title": "Speicherplatz - VMs",
|
||||
"description": "Dateisysteme laufender VMs, wie der QEMU-Gastagent sie meldet, einschließlich durchgereichter Laufwerke. VMs ohne Gastagent werden nicht ausgewertet."
|
||||
},
|
||||
"lxc_mount": {
|
||||
"title": "LXC-Montagepunkte",
|
||||
"description": "Kapazität der Mountpunkte innerhalb laufender CTs (mp0, mp1, NFS, Bind-Mounts). Schließt die Rootfs aus; das steht oben."
|
||||
@@ -2363,8 +2670,12 @@
|
||||
"excluded": "Ausgeschlossen",
|
||||
"notConnected": "nicht verbunden",
|
||||
"saveFailed": "Konnte die Laufwerksausschlüsse nicht retten.",
|
||||
"help": "Für ein ausgeschlossenes Laufwerk werden weder Temperatur noch SMART-Werte planmäßig ausgelesen, nicht einmal der Energiestatus wird abgefragt, sodass es völlig in Ruhe gelassen wird. Beim Aufrufen der SMART-Details werden diese immer noch gelsesn.",
|
||||
"idleHelp": "Mechanische Laufwerke ohne Aktivität werden bereits automatisch in Ruhe gelassen, bis sie wieder verwendet werden."
|
||||
"help": "Für ein ausgeschlossenes Laufwerk werden weder Temperatur noch SMART-Werte planmäßig ausgelesen, nicht einmal der Energiestatus wird abgefragt, sodass es völlig in Ruhe gelassen wird. Beim Aufrufen der SMART-Details werden diese immer noch gelsesn."
|
||||
},
|
||||
"storageDisplay": {
|
||||
"title": "Speicheranzeige",
|
||||
"description": "Ändern Sie die Anordnung der Festplatten auf der Seite „Speicher“.",
|
||||
"groupByController": "Physische Festplatten nach Speichercontroller gruppieren"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
@@ -3483,7 +3794,17 @@
|
||||
"shareCoveX": "ShareCoveX kommt in den Katalog – ein Dateiserver mit Web-Panel, der Host-Verzeichnisse oder Container-Volumes über SMB, NFS und Time Machine freigibt.",
|
||||
"guestBackupJobs": "Backup-Jobs von VMs und LXCs – die Backup-Registerkarte listet die geplanten Jobs von Proxmox auf und erstellt, bearbeitet, aktiviert und startet sie; es sind dieselben wie unter Datacenter → Backup (beigetragen von @MattiaC46).",
|
||||
"notificationLanguage": "Benachrichtigungen in Ihrer Sprache – Titel, Texte und tägliche Zusammenfassungen werden in der für Benachrichtigungen gewählten Sprache verfasst (beigetragen von @Vaso73).",
|
||||
"auditReviewDate": "Audit & Report – ein akzeptierter Befund kann ein Überprüfungsdatum tragen, das ihn wieder vorlegt, ohne die Entscheidung zurückzuziehen, und der Vergleich gibt an, ob sich ein Befund verschlechtert, verbessert oder gehalten hat (mit @Vaso73)."
|
||||
"auditReviewDate": "Audit & Report – ein akzeptierter Befund kann ein Überprüfungsdatum tragen, das ihn wieder vorlegt, ohne die Entscheidung zurückzuziehen, und der Vergleich gibt an, ob sich ein Befund verschlechtert, verbessert oder gehalten hat (mit @Vaso73).",
|
||||
"ociCatalog": "OCI manager Apps (Technology Preview) – ein neuer Eintrag im ProxMenux-Menü installiert Anwendungen aus ihren offiziellen Container-Images als native Proxmox-LXC, ohne Docker-Engine. Der Katalog umfasst 514 Anwendungen.",
|
||||
"pbsSupport": "ProxMenux auf Proxmox Backup Server – es wird mit demselben Befehl installiert und öffnet ein eigenes Menü, mit Post-Install und einem Monitor, der Zustand, Datastores und Aufgaben des Servers zeigt (beigetragen von @jcastro).",
|
||||
"menuSearch": "Suche im Hauptmenü – ein Name oder ein Teil davon findet eine ProxMenux-Option, eine OCI-Anwendung oder ein Helper-Script und öffnet es auf seinem ersten Bildschirm.",
|
||||
"composeStacks": "Compose-Dateien mit mehreren Diensten — Custom image installiert je Dienst einen LXC in einem privaten Netzwerk, gestartet in der Reihenfolge der Abhängigkeiten. Updates, Backups und Entfernen gelten für die gesamte Anwendung.",
|
||||
"keepProxmoxChanges": "OCI-Updates behalten, was du in Proxmox änderst — Variablen, Startbefehl, Netzwerkoptionen, DNS, Features, Festplattengrößen und lxc.*-Zeilen bleiben im neu erstellten Container erhalten, auch bei geplanten Updates und für jedes Mitglied eines Stacks.",
|
||||
"ociVlan": "VLAN-Tag für OCI-Anwendungen — die erweiterte Installation fragt ihn neben der Adresse ab, und Ändern passt ihn bei einer Einzelanwendung an. Der Host braucht keinen Zugriff auf dieses VLAN.",
|
||||
"ociNewApps": "Neu im OCI-Katalog — Authentik, TeslaMate, Zigbee2MQTT, Scanopy, Umami, Scrypted, NetAlertX und zehn weitere Anwendungen, und Ollama und llama.cpp laufen auf integrierten GPUs von Intel und AMD.",
|
||||
"hostUpdateJob": "Proxmox-Updates, die nach dem Abmelden weiterlaufen — das Update läuft als Auftrag auf dem Server und öffnet sich bei der erneuten Anmeldung wieder. Nach einem neuen Kernel wird ein ausstehender Neustart angezeigt, mit einer Schaltfläche, die zuerst den Host prüft.",
|
||||
"storageDetails": "Speicherdetails — ZFS-Pools zeigen Aufbau, Scrub und Gerätezustand, und jede Festplatte zeigt ihren Speichercontroller, auch auf der Hardware-Seite. Die Schwellenwerte für VM-Festplatten kommen in die Einstellungen.",
|
||||
"log2ramV2": "Log2RAM 2.0 — das Journal und die Aufgabenprotokolle von Proxmox bleiben mit ihrem Verlauf auf der Festplatte, und nur ständig wechselnde Protokolle kommen in den RAM. Bestehende Installationen werden mit ProxMenux aktualisiert."
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
@@ -3840,7 +4161,9 @@
|
||||
"interface": "Schnittstelle",
|
||||
"rotationRate": "Rotationsrate",
|
||||
"classCode": "Klassencode",
|
||||
"serial": "Seriell"
|
||||
"serial": "Seriell",
|
||||
"storageController": "Speichercontroller",
|
||||
"attachedDisks": "Angehängte Datenträger"
|
||||
},
|
||||
"values": {
|
||||
"cores": "{count} Kerne",
|
||||
@@ -4807,6 +5130,124 @@
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Aufbewahrung speichern",
|
||||
"useDefault": "Proxmox-Standard verwenden (keine Richtlinie für diesen Speicher)"
|
||||
},
|
||||
"apiErrors": {
|
||||
"requestFailed": "Die Anfrage ist fehlgeschlagen.",
|
||||
"absolutePath": "der Pfad muss absolut sein",
|
||||
"pathConfigured": "dieser Pfad ist bereits konfiguriert",
|
||||
"pathRequired": "der Pfad ist erforderlich",
|
||||
"pathQuery": "der Abfrageparameter path ist erforderlich",
|
||||
"pathNotFound": "Pfad nicht gefunden",
|
||||
"invalidJobId": "ungültige Auftrags-ID",
|
||||
"jobNotFound": "Auftrag nicht gefunden",
|
||||
"runnerMissing": "Ausführungsskript nicht installiert",
|
||||
"noRestore": "kein Wiederherstellungsstatus zum Verwerfen vorhanden",
|
||||
"invalidFile": "ungültiger Dateiname",
|
||||
"notFound": "nicht gefunden",
|
||||
"logOutside": "Protokollpfad außerhalb des zulässigen Verzeichnisses",
|
||||
"targetsList": "targets muss eine Liste sein",
|
||||
"emptyExpression": "leerer Ausdruck",
|
||||
"invalidId": "ungültige ID (Buchstaben, Ziffern, _ oder - verwenden)",
|
||||
"backend": "backend muss pbs, local oder borg sein",
|
||||
"remoteBackend": "backend muss pbs oder borg sein",
|
||||
"remoteBackendQuoted": "backend muss 'pbs' oder 'borg' sein",
|
||||
"profileMode": "profile_mode muss default oder custom sein",
|
||||
"borgAttached": "borg kann nicht angehängt werden (PVE vzdump schreibt nur auf pbs/local-Speicher)",
|
||||
"borgAttachedLong": "borg ist kein gültiges Backend für angehängte Aufträge (PVE vzdump schreibt nur auf pbs/local-Speicher)",
|
||||
"parentRequired": "pve_parent_job_id und pve_storage sind für attached erforderlich",
|
||||
"absolutePaths": "paths muss eine Liste absoluter Pfade sein",
|
||||
"customPath": "das benutzerdefinierte Profil benötigt mindestens einen Pfad",
|
||||
"defaultPaths": "die Pfade des Standardprofils konnten nicht ermittelt werden",
|
||||
"retentionObject": "retention muss ein Objekt sein",
|
||||
"pbsRepository": "pbs_repository ist erforderlich",
|
||||
"pbsEncryption": "pbs_encrypt_mode muss 'none', 'new' oder 'existing' sein",
|
||||
"pbsKeyFailed": "die PBS-Verschlüsselungsschlüsseldatei konnte nicht erstellt werden",
|
||||
"pbsClientMissing": "die PBS-Verschlüsselungsschlüsseldatei konnte nicht erstellt werden (ist proxmox-backup-client installiert?)",
|
||||
"borgRepository": "borg_repo ist erforderlich",
|
||||
"borgEncryption": "borg_encrypt_mode muss einer der folgenden Werte sein: none, repokey, keyfile, authenticated",
|
||||
"encryption": "encrypt_mode muss einer der folgenden Werte sein: none, repokey, keyfile, authenticated",
|
||||
"borgPassphrase": "borg_passphrase ist erforderlich, wenn die Verschlüsselung aktiviert ist (am Ziel speichern oder in der Anfrage übergeben)",
|
||||
"passphrase": "die Passphrase ist erforderlich, wenn die Verschlüsselung aktiviert ist",
|
||||
"password": "das Passwort ist erforderlich",
|
||||
"deviceConfirmation": "confirm_device muss exakt mit device übereinstimmen (Tippfehler-/Sicherheitsprüfung)",
|
||||
"invalidDevice": "ungültiger Gerätepfad",
|
||||
"absoluteMountpoint": "absoluter Einhängepunkt erforderlich",
|
||||
"archiveLayout": "Archivstruktur nicht erkannt – kein rootfs/ gefunden",
|
||||
"archiveMissing": "Archiv nicht gefunden",
|
||||
"archiveNotFile": "der Archivpfad ist keine reguläre Datei",
|
||||
"invalidName": "ungültiger Name",
|
||||
"invalidStaging": "ungültiger staging_path",
|
||||
"jobRename": "der Auftragsname kann nicht geändert werden (zum Umbenennen löschen und neu erstellen)",
|
||||
"absoluteKey": "key_path muss absolut sein",
|
||||
"mode": "mode muss \"local\" oder \"ssh\" sein",
|
||||
"nameRequired": "der Name ist erforderlich (Buchstaben, Ziffern, _ oder -)",
|
||||
"noLog": "für diesen Auftrag ist noch kein Protokoll verfügbar",
|
||||
"noPbsConfig": "keine Datei mit manuellen PBS-Konfigurationen",
|
||||
"outputMissing": "Ausgabedatei fehlt",
|
||||
"parserJson": "die Ausgabe des Parsers war kein gültiges JSON",
|
||||
"restoreJson": "die Ausgabe von run_restore war kein gültiges JSON",
|
||||
"repoRequired": "repo ist erforderlich",
|
||||
"repoSnapshot": "repo_name und snapshot sind erforderlich",
|
||||
"borgRepoSnapshot": "repo_name und snapshot sind für borg erforderlich",
|
||||
"pbsRepoSnapshot": "repo_name und snapshot sind für pbs erforderlich",
|
||||
"repoName": "repo_name ist erforderlich",
|
||||
"restoreMissing": "die Wiederherstellungswerkzeuge sind auf diesem Host nicht installiert",
|
||||
"serverDatastore": "server und datastore sind erforderlich",
|
||||
"snapshotRequired": "snapshot ist erforderlich",
|
||||
"source": "source muss pbs, borg oder local sein",
|
||||
"sshPort": "ssh_port muss eine Ganzzahl zwischen 1 und 65535 sein",
|
||||
"sshRequired": "ssh_user, ssh_host und ssh_remote_path sind für den ssh-Modus erforderlich",
|
||||
"repositoryPath": "das Ziel hat keinen Repository-Pfad",
|
||||
"taskExpired": "Aufgabe nicht gefunden oder abgelaufen",
|
||||
"invalidExpression": "ungültiger Ausdruck",
|
||||
"noReport": "kein Bericht ausgegeben",
|
||||
"parseManifestFailed": "parse_manifest wurde mit einem Fehlercode beendet",
|
||||
"jobExists": "Auftrag existiert bereits: {value}",
|
||||
"pathMissing": "Pfad existiert auf diesem Host nicht: {value}",
|
||||
"deviceMissing": "Gerät existiert nicht: {value}",
|
||||
"mountpoint": "kein Einhängepunkt: {value}",
|
||||
"localArchiveMissing": "lokales Archiv nicht gefunden: {value}",
|
||||
"borgDestination": "Borg-Ziel '{value}' nicht gefunden",
|
||||
"pbsDestination": "PBS-Ziel '{value}' nicht gefunden",
|
||||
"pbsPassword": "kein Passwort für das PBS-Repo '{value}' verfügbar",
|
||||
"borgRepoMissing": "Borg-Repo \"{value}\" nicht konfiguriert",
|
||||
"pbsRepoMissing": "PBS-Repo \"{value}\" nicht konfiguriert",
|
||||
"borgTargetMissing": "Borg-Ziel nicht gefunden: {value}",
|
||||
"pbsTargetMissing": "PBS-Repo nicht gefunden: {value}",
|
||||
"pveJobMissing": "PVE-vzdump-Auftrag nicht gefunden: {value}",
|
||||
"statePersist": "Status kann nicht gespeichert werden: {value}",
|
||||
"sshDirectory": "SSH-Verzeichnis kann nicht vorbereitet werden: {value}",
|
||||
"logRead": "Protokoll kann nicht gelesen werden: {value}",
|
||||
"envUpdate": ".env konnte nicht aktualisiert werden: {value}",
|
||||
"pbsConfigWrite": "PBS-Konfiguration konnte nicht gespeichert werden: {value}",
|
||||
"persist": "Speichern fehlgeschlagen: {value}",
|
||||
"archiveRemove": "Archiv konnte nicht entfernt werden: {value}",
|
||||
"start": "Start fehlgeschlagen: {value}",
|
||||
"borgTargetsUpdate": "borg-targets.txt konnte nicht aktualisiert werden: {value}",
|
||||
"pbsTargetsUpdate": "pbs-manual-configs.txt konnte nicht aktualisiert werden: {value}",
|
||||
"unitsUpdate": "systemd-Units konnten nicht aktualisiert werden: {value}",
|
||||
"borgTargetsWrite": "borg-targets.txt konnte nicht geschrieben werden: {value}",
|
||||
"jobWrite": "Auftragsdateien konnten nicht geschrieben werden: {value}",
|
||||
"localTargetWrite": "local-target.conf konnte nicht geschrieben werden: {value}",
|
||||
"unitsWrite": "systemd-Units konnten nicht geschrieben werden: {value}",
|
||||
"format": "Formatieren fehlgeschlagen: {value}",
|
||||
"calendar": "ungültiger on_calendar-Wert: {value}",
|
||||
"mount": "Einhängen fehlgeschlagen: {value}",
|
||||
"parserInvocation": "Aufruf des Parsers fehlgeschlagen: {value}",
|
||||
"preflightInvocation": "Aufruf der Vorabprüfung fehlgeschlagen: {value}",
|
||||
"publicKeyMissing": "öffentlicher Schlüssel nach der Erzeugung nicht gefunden: {value}",
|
||||
"retentionInteger": "retention.{value} muss eine Ganzzahl sein",
|
||||
"retentionNegative": "retention.{value} darf nicht negativ sein",
|
||||
"keygen": "ssh-keygen fehlgeschlagen: {value}",
|
||||
"systemctl": "systemctl fehlgeschlagen: {value}",
|
||||
"unmount": "umount fehlgeschlagen: {value}",
|
||||
"taskIncomplete": "die Aufgabe ist im Status \"{value}\", nicht abgeschlossen",
|
||||
"unknownKind": "unbekannte Art: {value}",
|
||||
"unknownMode": "unbekannter Modus \"{value}\"",
|
||||
"defaultRemove": "{value} ist der integrierte Standard und kann nicht entfernt werden",
|
||||
"defaultAdd": "{value} ist der integrierte Standard; er muss nicht hinzugefügt werden",
|
||||
"keyImport": "Unter {value} ist keine PBS-Schlüsseldatei installiert. Importieren Sie zuerst die Schlüsseldatei.",
|
||||
"storageMismatch": "Speicher des PVE-Auftrags stimmt nicht überein (Auftrag verwendet {value}, Nutzdaten geben {other} an)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
@@ -4870,7 +5311,8 @@
|
||||
"certificates": "Zertifikate",
|
||||
"login_attempts": "Anmeldeversuche",
|
||||
"fail2ban": "Fail2Ban",
|
||||
"proxmox_storages": "Proxmox-Speicher"
|
||||
"proxmox_storages": "Proxmox-Speicher",
|
||||
"reboot_required": "Neustart erforderlich"
|
||||
},
|
||||
"details": {
|
||||
"allOperational": "Alle Systeme betriebsbereit",
|
||||
@@ -4949,7 +5391,8 @@
|
||||
"securityCheckUnavailable": "Sicherheitsüberprüfung nicht verfügbar: {error}",
|
||||
"certificateExpired": "Zertifikat abgelaufen",
|
||||
"certificateExpiresDays": "Zertifikatsablauf (Tage): {days}",
|
||||
"certificateInconclusive": "Zertifikatsprüfung nicht schlüssig"
|
||||
"certificateInconclusive": "Zertifikatsprüfung nicht schlüssig",
|
||||
"noPendingUpdates": "Keine ausstehenden Aktualisierungen"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "soeben",
|
||||
@@ -4986,7 +5429,53 @@
|
||||
"years": "{count} Jahre",
|
||||
"24hours": "24 Stunden",
|
||||
"7days": "7 Tage"
|
||||
}
|
||||
},
|
||||
"restart": {
|
||||
"button": "Server neu starten",
|
||||
"title": "Server {server} neu starten?",
|
||||
"impact": "Durch einen Neustart werden der Server, die virtuellen Maschinen, Container und deren Dienste vorübergehend unterbrochen.",
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Neustart bestätigen",
|
||||
"blocked": "Neustart ist verfügbar, nachdem das Host-Update abgeschlossen ist.",
|
||||
"accepted": "Neustartanforderung angenommen.Überprüfen Sie, ob der Server wieder online ist.",
|
||||
"unconfirmed": "Die Neustartanforderung konnte nicht überprüft werden.Überprüfen Sie den Server, bevor Sie es erneut versuchen."
|
||||
},
|
||||
"versions": {
|
||||
"title": "ProxMenux-Update",
|
||||
"installed": "Installierte Version",
|
||||
"latest": "Verfügbare Version",
|
||||
"lastKnown": "Letzte bekannte verfügbare Version",
|
||||
"available": "Update verfügbar",
|
||||
"current": "ProxMenux ist aktuell",
|
||||
"currentVersion": "Version {version} ist aktuell",
|
||||
"unknown": "Die verfügbare Version konnte nicht überprüft werden",
|
||||
"unverified": "Nicht bestätigt",
|
||||
"stable": "Stabiler Kanal",
|
||||
"beta": "Beta-Kanal",
|
||||
"checked": "Letzte erfolgreiche Prüfung: {time}"
|
||||
},
|
||||
"hostUpdate": {
|
||||
"running": "Das Host-Update wird auch dann fortgesetzt, wenn Sie sich abmelden.Öffnen Sie es nach der Anmeldung erneut.",
|
||||
"finished": "Das letzte Host-Update ist abgeschlossen.Das Ergebnis liegt vor.",
|
||||
"attention": "Das Host-Update erfordert Aufmerksamkeit.Es wurde nicht gestoppt;Stellen Sie die Verbindung erneut her, um es zu überprüfen.",
|
||||
"view": "Update anzeigen",
|
||||
"viewResult": "Ergebnis anzeigen",
|
||||
"hideResult": "Ergebnis ausblenden",
|
||||
"resultTitle": "Host-Update-Ergebnis",
|
||||
"resultReadOnly": "Gespeicherte Ausgabe eines abgeschlossenen Updates.Beim Öffnen wird kein weiteres Update ausgeführt.",
|
||||
"resultUnavailable": "Das gespeicherte Ergebnis konnte nicht geladen werden.Es wurde kein Update gestartet.",
|
||||
"retryResult": "Ergebnis neu laden",
|
||||
"loadingResult": "Gespeichertes Ergebnis wird geladen…",
|
||||
"closeResult": "Ergebnis schließen",
|
||||
"noReboot": "Kein Neustart erforderlich",
|
||||
"rebootMarker": "Das Betriebssystem erfordert einen Neustart.",
|
||||
"rebootUnknown": "Es konnte nicht festgestellt werden, ob ein Neustart erforderlich ist.",
|
||||
"rebootKernel": "Zum Aktivieren von {installed} ist ein Neustart erforderlich.Derzeit läuft: {running}."
|
||||
},
|
||||
"updateSystem": "System aktualisieren",
|
||||
"packagePlanNote": "Der Plan umfasst auch neue Pakete, etwa einen neuen Kernel.Die Übersicht zählt nur Aktualisierungen bestehender Pakete.",
|
||||
"snapshotUnavailable": "Es sind noch keine neuen Gesundheitsergebnisse verfügbar.",
|
||||
"lastKnownResults": "Zeigt die letzten verfügbaren Ergebnisse mit ihrer ursprünglichen Prüfzeit an."
|
||||
},
|
||||
"profilePage": {
|
||||
"loading": "Profil wird geladen…",
|
||||
@@ -5307,6 +5796,7 @@
|
||||
"websocketError": "Es ist ein WebSocket-Fehler aufgetreten",
|
||||
"connectionClosed": "Verbindung geschlossen",
|
||||
"completedSuccessfully": "Skript erfolgreich abgeschlossen",
|
||||
"cancelled": "Vorgang abgebrochen.",
|
||||
"completedWithError": "Skript mit Fehlercode {code} beendet",
|
||||
"yes": "Ja",
|
||||
"yourInput": "Ihr Beitrag:",
|
||||
@@ -5350,7 +5840,7 @@
|
||||
"customLinkDeleteError": "Löschen fehlgeschlagen",
|
||||
"categoryLabels": {
|
||||
"arrSuite": "*Arr Suite",
|
||||
"aiCodingDevTools": "AI / Coding & Dev-Tools",
|
||||
"aiCodingDevTools": "KI / Programmierung und Entwicklungswerkzeuge",
|
||||
"adblockDns": "Adblock & DNS",
|
||||
"authenticationSecurity": "Authentication & Security",
|
||||
"automationScheduling": "Automation & Scheduling",
|
||||
@@ -5365,18 +5855,18 @@
|
||||
"financeBudgeting": "Finance & Budgeting",
|
||||
"gamingLeisure": "Gaming & Leisure",
|
||||
"hostManagement": "Host Management",
|
||||
"iotSmartHome": "IoT & Smart Home",
|
||||
"iotSmartHome": "IoT und Smart Home",
|
||||
"mediaStreaming": "Media & Streaming",
|
||||
"messagingNotifications": "Messaging & Notifications",
|
||||
"miscellaneous": "Miscellaneous",
|
||||
"monitoringAnalytics": "Monitoring & Analytics",
|
||||
"nvrCameras": "NVR & Cameras",
|
||||
"networkFirewall": "Network & Firewall",
|
||||
"operatingSystemsAppliances": "Operating Systems & Appliances",
|
||||
"operatingSystemsAppliances": "Betriebssysteme und virtuelle Appliances",
|
||||
"productivityWorkflows": "Productivity & Workflows",
|
||||
"remoteAccessVpn": "Remote Access & VPN",
|
||||
"remoteAccessVpn": "Fernzugriff und VPN",
|
||||
"webserversProxies": "Webservers & Proxies",
|
||||
"zigbeeZwaveMatter": "ZigBee, Z-Wave & Matter"
|
||||
"zigbeeZwaveMatter": "ZigBee, Z-Wave und Matter"
|
||||
},
|
||||
"emptyTitle": "Noch keine Apps mit einem Weblink.",
|
||||
"emptyHint": "Registrieren Sie einen Weblink für eine beliebige App (App-Tab eines CT), um ihn hier zu sehen.",
|
||||
@@ -7279,7 +7769,11 @@
|
||||
"unconfirmedBody": "Das Backup-Ergebnis ist nicht bestätigt.",
|
||||
"warningTitle": "{hostname}: Sicherung mit Warnungen abgeschlossen",
|
||||
"warningBody": "Sicherung mit Warnungen abgeschlossen.",
|
||||
"diagnosticsOmitted": "Weitere Diagnosezeilen oder Text ausgelassen: {count}. Originalbericht bleibt erhalten."
|
||||
"diagnosticsOmitted": "Weitere Diagnosezeilen oder Text ausgelassen: {count}. Originalbericht bleibt erhalten.",
|
||||
"quotaCause": "Ein Speicherkontingent verhinderte den Abschluss der Sicherung.",
|
||||
"quotaAffected": "Sicherungen {ids}: ein Speicherkontingent verhinderte den Abschluss.",
|
||||
"quotaCheck": "Prüfe das Kontingent des Sicherungsspeichers {storage}, bevor du es erneut versuchst.",
|
||||
"causeMissing": "Sicherungen {ids}: die Zusammenfassung nennt keine Ursache; prüfe die vollständigen Aufgabenprotokolle."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7288,6 +7782,20 @@
|
||||
"server": "PBS-Server",
|
||||
"chooseServer": "PBS-Server auswählen",
|
||||
"local": "Lokaler PBS",
|
||||
"thisNode": "PBS auf diesem Knoten",
|
||||
"updatedAt": "Aktualisiert um {time}",
|
||||
"servicesActive": "Dienste aktiv",
|
||||
"detectedTitle": "Im Proxmox-Speicher erkannte PBS",
|
||||
"detectedHelp": "Diese Proxmox Backup Server sind als Backup-Speicher eingerichtet. Verbinden Sie jeden mit einem schreibgeschützten API-Token, um hier seine Datastores, Aufgaben und Jobs zu sehen.",
|
||||
"connect": "Verbinden",
|
||||
"tasksTab": "Aufgaben",
|
||||
"signInWith": "Anmelden mit",
|
||||
"methodPassword": "Benutzer und Passwort",
|
||||
"methodToken": "API-Token",
|
||||
"username": "Benutzer",
|
||||
"password": "Passwort",
|
||||
"passwordHelp": "Das Passwort wird nur einmal verwendet, um ein schreibgeschütztes API-Token (Rolle Audit) für diesen Monitor zu erstellen. Gespeichert wird nur dieses Token, nie das Passwort. Aktivieren Sie zuerst die Monitor-Authentifizierung.",
|
||||
"cancel": "Abbrechen",
|
||||
"refresh": "Aktualisieren",
|
||||
"manage": "PBS-Verbindungen verwalten",
|
||||
"loadFailed": "PBS-Status konnte nicht geladen werden.",
|
||||
@@ -7297,10 +7805,10 @@
|
||||
"editConnection": "Zu bearbeitende Verbindung",
|
||||
"add": "Neue Verbindung hinzufügen",
|
||||
"name": "Name",
|
||||
"url": "HTTPS-URL (für den nativen PBS-Port :8007 angeben)",
|
||||
"token_id": "API-Token-ID (user@realm!token)",
|
||||
"token_secret": "API-Token-Geheimnis (leer lassen, um das gespeicherte Geheimnis beizubehalten)",
|
||||
"fingerprint": "SHA256-Zertifikatfingerabdruck (optional)",
|
||||
"url": "HTTPS-Adresse",
|
||||
"token_id": "API-Token-ID",
|
||||
"token_secret": "API-Token-Geheimnis",
|
||||
"fingerprint": "SHA256-Fingerabdruck (optional)",
|
||||
"tlsHelp": "Lassen Sie den Fingerabdruck für die normale Prüfung von Zertifizierungsstelle und Hostname leer. Kopieren Sie bei einem selbstsignierten Zertifikat dessen SHA256-Fingerabdruck aus der PBS-Konsole oder einem anderen vertrauenswürdigen Kanal. Der Fingerabdruck wird vor der Übermittlung von Zugangsdaten geprüft.",
|
||||
"saved": "PBS-Verbindung geprüft und gespeichert.",
|
||||
"saveFailed": "PBS-Verbindung konnte nicht gespeichert werden.",
|
||||
@@ -7342,7 +7850,85 @@
|
||||
"estimatedFull": "Voraussichtlich voll",
|
||||
"estimatedFullUnknown": "Noch nicht genügend Daten",
|
||||
"duration": "Dauer",
|
||||
"noJobs": "Es sind keine Synchronisierungs-, Überprüfungs- oder Löschzeitpläne konfiguriert."
|
||||
"noJobs": "Es sind keine Synchronisierungs-, Überprüfungs- oder Löschzeitpläne konfiguriert.",
|
||||
"errors": {
|
||||
"configRead": "Die gespeicherten PBS-Verbindungen konnten nicht gelesen werden. Prüfen Sie die Konfigurationsdatei und die Berechtigungen.",
|
||||
"invalidConfig": "Die Einstellungen der PBS-Verbindung sind ungültig. Prüfen Sie das Formular und versuchen Sie es erneut.",
|
||||
"invalidField": "Ein Verbindungsfeld ist ungültig oder zu lang.",
|
||||
"invalidUrl": "Verwenden Sie eine HTTPS-Adresse des PBS ohne eingebettete Zugangsdaten, Abfrageparameter oder Fragment.",
|
||||
"nameRequired": "Geben Sie einen Servernamen mit höchstens 128 Zeichen ein.",
|
||||
"tokenIdFormat": "Geben Sie die PBS-API-Token-ID im Format user@realm!token ein.",
|
||||
"tokenSecret": "Geben Sie ein gültiges API-Token-Geheimnis ohne Leerzeichen oder Steuerzeichen ein.",
|
||||
"invalidTokenId": "Die API-Token-ID darf nur ASCII-Zeichen enthalten.",
|
||||
"invalidFingerprint": "Geben Sie den SHA256-Fingerabdruck des Zertifikats ein: 64 Hexadezimalzeichen, optional durch Doppelpunkte getrennt.",
|
||||
"fingerprintMismatch": "Das PBS-Zertifikat stimmt nicht mit dem Fingerabdruck überein. Es wurden keine Zugangsdaten gesendet.",
|
||||
"tokenRejected": "PBS hat das API-Token oder seine Berechtigungen abgelehnt. Prüfen Sie die ACLs sowohl des Benutzers als auch des Tokens.",
|
||||
"invalidResponse": "PBS hat eine unerwartete Antwort zurückgegeben. Weiterleitungen werden nicht verfolgt.",
|
||||
"invalidJson": "PBS hat ein ungültiges JSON-Dokument zurückgegeben.",
|
||||
"connectFailed": "Über verifiziertes HTTPS konnte keine Verbindung zu PBS hergestellt werden. Prüfen Sie Adresse, Zertifikat und Dienst.",
|
||||
"invalidId": "Die ID der PBS-Verbindung ist ungültig. Laden Sie die Verbindungsliste neu.",
|
||||
"invalidDatastores": "PBS hat eine ungültige Datenspeicherliste zurückgegeben.",
|
||||
"connectionLimit": "Es können höchstens {count} PBS-Verbindungen konfiguriert werden.",
|
||||
"username": "Geben Sie den PBS-Benutzer im Format user@realm ein, zum Beispiel root@pam.",
|
||||
"password": "Geben Sie das Passwort des PBS-Benutzers ein.",
|
||||
"loginRejected": "PBS hat den Benutzernamen oder das Passwort abgelehnt.",
|
||||
"invalidLogin": "PBS hat eine ungültige Anmeldeantwort zurückgegeben.",
|
||||
"tokenCreateDenied": "Dieser PBS-Benutzer kann keine API-Tokens erstellen.",
|
||||
"invalidToken": "PBS hat ein ungültiges API-Token zurückgegeben.",
|
||||
"auditRoleDenied": "Dieser PBS-Benutzer kann die Rolle Audit nicht vergeben. Melden Sie sich als root@pam oder als anderer Administrator an.",
|
||||
"notFound": "Die PBS-Verbindung wurde nicht gefunden. Laden Sie die Verbindungsliste neu.",
|
||||
"readFailed": "PBS-Daten konnten nicht gelesen werden. Prüfen Sie den Dienst und die Berechtigungen.",
|
||||
"invalidList": "PBS hat eine ungültige Datenliste zurückgegeben.",
|
||||
"localUnavailable": "PBS ist auf diesem Host nicht verfügbar.",
|
||||
"authRequired": "Aktivieren Sie die lokale Authentifizierung im Monitor, bevor Sie entfernte PBS-Verbindungen verwalten.",
|
||||
"datastoreOffline": "Der Datenspeicher ist offline, im Wartungsmodus oder sein zugrunde liegendes Gerät ist nicht eingehängt.",
|
||||
"datastoreUnavailable": "Das Dateisystem des Datenspeichers ist nicht verfügbar."
|
||||
},
|
||||
"taskTypes": {
|
||||
"backup": "Backup",
|
||||
"restore": "Wiederherstellung",
|
||||
"verify": "Verifizierung",
|
||||
"verifyGroup": "Verifizierung der Backup-Gruppe",
|
||||
"verifySnapshot": "Verifizierung des Snapshots",
|
||||
"prune": "Bereinigung",
|
||||
"garbageCollection": "Garbage Collection",
|
||||
"sync": "Synchronisierung",
|
||||
"updateCheck": "Nach Updates suchen",
|
||||
"terminal": "Terminal",
|
||||
"createDatastore": "Datenspeicher erstellen",
|
||||
"other": "Andere Aufgabe ({type})"
|
||||
},
|
||||
"states": {
|
||||
"ok": "OK",
|
||||
"unknown": "Unbekannt",
|
||||
"warnings": "Warnungen: {count}",
|
||||
"warning": "Warnung",
|
||||
"error": "Fehler"
|
||||
},
|
||||
"serviceStates": {
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"activating": "Wird gestartet",
|
||||
"deactivating": "Wird gestoppt",
|
||||
"unknown": "Unbekannt"
|
||||
},
|
||||
"durationUnits": {
|
||||
"seconds": "{count} s",
|
||||
"minutes": "{count} min",
|
||||
"hours": "{count} h",
|
||||
"days": "{count} T"
|
||||
},
|
||||
"urlHelp": "Geben Sie :8007 für den Standard-PBS-Port an, zum Beispiel https://pbs.example:8007.",
|
||||
"usernameHelp": "Verwenden Sie user@realm, zum Beispiel root@pam.",
|
||||
"tokenIdHelp": "Verwenden Sie user@realm!token, zum Beispiel monitor@pbs!proxmenux.",
|
||||
"tokenSecretHelp": "Geben Sie das Geheimnis dieses API-Tokens ein.",
|
||||
"tokenSecretKeepHelp": "Leer lassen, um das gespeicherte Token-Geheimnis beizubehalten.",
|
||||
"removeFailed": "Die PBS-Verbindung konnte nicht entfernt werden.",
|
||||
"savedRefreshFailed": "Die PBS-Verbindung wurde gespeichert, aber die Verbindungsliste konnte nicht aktualisiert werden. Laden Sie die Seite neu, bevor Sie eine weitere Änderung vornehmen.",
|
||||
"removedRefreshFailed": "Die PBS-Verbindung wurde entfernt, aber die Verbindungsliste konnte nicht aktualisiert werden. Laden Sie die Seite neu.",
|
||||
"technicalDetails": "Technische Details",
|
||||
"datastoreType": "Datenspeicher"
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
@@ -7375,6 +7961,30 @@
|
||||
"backTo": "Zurück zu {name}",
|
||||
"cameFromTitle": "Monitorknoten hinzufügen?",
|
||||
"cameFromBody": "{name} zu den verknüpften Knoten dieses Monitors hinzufügen.",
|
||||
"notNow": "Nicht jetzt"
|
||||
"notNow": "Nicht jetzt",
|
||||
"knownTitle": "Verknüpfte Knoten hinzufügen?",
|
||||
"knownBody": "{name} verknüpft diese Monitore. Wählen Sie aus, welche Sie diesem Monitor hinzufügen möchten.",
|
||||
"addSelected": "Auswahl hinzufügen",
|
||||
"pbsWebInterface": "PBS-Weboberfläche",
|
||||
"changeAddress": "Adresse ändern",
|
||||
"cancel": "Abbrechen",
|
||||
"errors": {
|
||||
"addressRequired": "Geben Sie die Adresse des Monitors ein.",
|
||||
"invalidAddress": "Die Adresse muss mit http:// oder https:// beginnen und einen Host angeben.",
|
||||
"addressCredentials": "Die Adresse darf keinen Benutzer und kein Passwort enthalten.",
|
||||
"invalidPort": "Der Port der Adresse ist ungültig.",
|
||||
"invalidRequest": "Die Anfrage ist ungültig. Laden Sie die Seite neu und versuchen Sie es erneut.",
|
||||
"limit": "Es können höchstens {count} Knoten hinzugefügt werden.",
|
||||
"duplicate": "Diese Adresse ist bereits in der Liste.",
|
||||
"notFound": "Der Knoten wurde nicht gefunden. Laden Sie die Liste neu und versuchen Sie es erneut.",
|
||||
"detectedRemove": "Nur manuell hinzugefügte Knoten können entfernt werden. Erkannte Knoten können ausgeblendet werden."
|
||||
}
|
||||
},
|
||||
"apiErrors": {
|
||||
"http": "API-Anfrage fehlgeschlagen: {status} {statusText}",
|
||||
"unexpectedContentType": "JSON-Antwort erwartet, aber {contentType} erhalten",
|
||||
"unknownContentType": "unbekannter Inhaltstyp",
|
||||
"invalidJson": "Ungültige JSON-Antwort von {endpoint}",
|
||||
"unauthorized": "Nicht autorisiert: {endpoint}"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user