mirror of
https://github.com/Joulenap/joulenap.git
synced 2026-08-11 13:21:43 +02:00
Merge pull request #20 from Joulenap/release-0.5.0-advanced-update-check
release: 0.5.0 — Advanced settings tab, config.yaml editor, update check
This commit is contained in:
@@ -13,7 +13,7 @@ body:
|
||||
attributes:
|
||||
label: Joulenap version
|
||||
description: Shown in the UI footer.
|
||||
placeholder: "0.4.4"
|
||||
placeholder: "0.5.0"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
+21
-1
@@ -7,6 +7,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0]
|
||||
|
||||
### Added
|
||||
|
||||
- **Update check (opt-in, off by default)** — Joulenap can ask GitHub once a day whether a newer
|
||||
release exists and show a badge in the footer linking to the release notes. It is disabled
|
||||
unless you turn it on in Settings -> Integrations: with it off the app makes no outbound
|
||||
internet request at all. The check never runs as part of the container healthcheck.
|
||||
- **Advanced settings tab** — the settings that previously existed only in `config.yaml` now have a
|
||||
home in the interface: backup mode (snapshot / suspend / stop), bandwidth limit, the keep-last and
|
||||
keep-yearly retention buckets, how long run history is kept, and the server's port, session
|
||||
lifetime and HTTPS-only cookie flag.
|
||||
- **Edit config.yaml from the browser** — the same tab embeds a YAML editor with syntax
|
||||
highlighting for the whole configuration, so anything the forms don't cover is still reachable
|
||||
without shelling into the container. Secrets are shown as `***REDACTED***` and are never sent to
|
||||
the browser; leaving them untouched keeps the stored value. The document is validated before
|
||||
anything is written, a key you delete keeps its current value, and a Copy button gives you a
|
||||
secret-free copy of your configuration to attach to a bug report.
|
||||
|
||||
## [0.4.4]
|
||||
|
||||
### Changed
|
||||
@@ -244,7 +263,8 @@ Backup Server, all from a web UI.
|
||||
- Config-driven via `config.yaml` (pydantic-validated); secrets stay in `config.yaml` and are
|
||||
redacted from API responses.
|
||||
|
||||
[Unreleased]: https://github.com/Joulenap/joulenap/compare/v0.4.4...HEAD
|
||||
[Unreleased]: https://github.com/Joulenap/joulenap/compare/v0.5.0...HEAD
|
||||
[0.5.0]: https://github.com/Joulenap/joulenap/compare/v0.4.4...v0.5.0
|
||||
[0.4.4]: https://github.com/Joulenap/joulenap/compare/v0.4.3...v0.4.4
|
||||
[0.4.3]: https://github.com/Joulenap/joulenap/compare/v0.4.2...v0.4.3
|
||||
[0.4.2]: https://github.com/Joulenap/joulenap/compare/v0.4.1...v0.4.2
|
||||
|
||||
@@ -50,7 +50,7 @@ Joulenap **owns the schedule** itself (internal scheduler), so nothing on the Pr
|
||||
|
||||
## Status
|
||||
|
||||
**v0.4.4.** Feature-complete: scheduler + Wake-on-LAN + vzdump + retention + GC + verify +
|
||||
**v0.5.0.** Feature-complete: scheduler + Wake-on-LAN + vzdump + retention + GC + verify +
|
||||
notifications + setup wizard, packaged as a Docker image — with transport hardening (PBS TLS
|
||||
pinning + SSH host-key verification) and auth hardening (login rate-limit, session hardening).
|
||||
Includes a read-only [dashboard integration](docs/INTEGRATIONS.md) (Homepage/Homarr/Dashy/Glance),
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Joulenap — web UI + scheduler for energy-saving Proxmox backups to a normally-off PBS."""
|
||||
|
||||
__version__ = "0.4.4"
|
||||
__version__ = "0.5.0"
|
||||
|
||||
@@ -14,6 +14,7 @@ from . import (
|
||||
power,
|
||||
scheduler,
|
||||
status,
|
||||
update,
|
||||
wizard,
|
||||
wol,
|
||||
)
|
||||
@@ -31,5 +32,6 @@ api_router.include_router(wol.router)
|
||||
api_router.include_router(notify.router)
|
||||
api_router.include_router(logs.router)
|
||||
api_router.include_router(wizard.router)
|
||||
api_router.include_router(update.router)
|
||||
|
||||
__all__ = ["api_router"]
|
||||
|
||||
@@ -9,9 +9,10 @@ from __future__ import annotations
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import ValidationError
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from ..config import (
|
||||
Config,
|
||||
@@ -41,6 +42,17 @@ def put_config(
|
||||
store: ConfigStore = Depends(get_config_store),
|
||||
scheduler: Scheduler = Depends(get_scheduler),
|
||||
) -> dict[str, Any]:
|
||||
return _apply_config(incoming, store, scheduler)
|
||||
|
||||
|
||||
def _apply_config(
|
||||
incoming: dict[str, Any], store: ConfigStore, scheduler: Scheduler
|
||||
) -> dict[str, Any]:
|
||||
"""Validate + persist an incoming (partial, possibly redacted) config and re-arm.
|
||||
|
||||
Shared by PUT /api/config and PUT /api/config/yaml so the YAML editor goes through the
|
||||
exact same validation and secret-restoration path as the settings forms.
|
||||
"""
|
||||
# Deep-merge over the stored config so PUT means "apply these changes", not "replace
|
||||
# everything": an omitted section/field keeps its current value (a partial body can no
|
||||
# longer wipe secrets). Then resolve any ***REDACTED*** the client echoed back, and force
|
||||
@@ -104,6 +116,71 @@ def put_config(
|
||||
return redacted_dict(new_config)
|
||||
|
||||
|
||||
# --- raw YAML editing (Settings -> Advanced) ---------------------------------
|
||||
#
|
||||
# The editor is served the *redacted* config re-serialised with the same dumper save_config
|
||||
# uses, so the text matches the on-disk file's shape without ever sending secrets to the
|
||||
# browser. Saving goes through _apply_config, i.e. the same merge/restore/validate path as
|
||||
# the settings forms: a key the user deletes keeps its stored value rather than being wiped.
|
||||
|
||||
|
||||
class YamlBody(BaseModel):
|
||||
yaml: str
|
||||
|
||||
|
||||
def _dump_yaml(cfg: Any) -> str:
|
||||
return yaml.safe_dump(cfg, sort_keys=False, allow_unicode=True, default_flow_style=False)
|
||||
|
||||
|
||||
def _yaml_error(message: str, line: int | None = None) -> HTTPException:
|
||||
return HTTPException(status_code=422, detail={"message": message, "line": line})
|
||||
|
||||
|
||||
def _flatten(detail: Any) -> str:
|
||||
"""Turn _apply_config's error detail into text the editor can show.
|
||||
|
||||
Pydantic's ``errors()`` list becomes one ``field.path: message`` per line; anything else
|
||||
(the cron/MAC guards) is already a plain string.
|
||||
ponytail: no loc -> line mapping — only YAML syntax errors carry a line number.
|
||||
"""
|
||||
if isinstance(detail, list):
|
||||
return "\n".join(
|
||||
f"{'.'.join(str(p) for p in e.get('loc', ()))}: {e.get('msg', '')}".lstrip(": ")
|
||||
for e in detail
|
||||
)
|
||||
return str(detail)
|
||||
|
||||
|
||||
@router.get("/config/yaml")
|
||||
def get_config_yaml(store: ConfigStore = Depends(get_config_store)) -> dict[str, str]:
|
||||
return {"yaml": _dump_yaml(redacted_dict(store.config))}
|
||||
|
||||
|
||||
@router.put("/config/yaml")
|
||||
def put_config_yaml(
|
||||
body: YamlBody,
|
||||
store: ConfigStore = Depends(get_config_store),
|
||||
scheduler: Scheduler = Depends(get_scheduler),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
incoming = yaml.safe_load(body.yaml) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
mark = getattr(exc, "problem_mark", None)
|
||||
raise _yaml_error(str(exc), mark.line + 1 if mark else None) from exc
|
||||
if not isinstance(incoming, dict):
|
||||
raise _yaml_error(
|
||||
f"The document must be a YAML mapping, got {type(incoming).__name__}."
|
||||
)
|
||||
try:
|
||||
return _apply_config(incoming, store, scheduler)
|
||||
except HTTPException as exc:
|
||||
# Re-shape validation failures into the editor's {message, line} contract; PUT
|
||||
# /api/config keeps its own. A 500 (unwritable config.yaml) passes through as-is.
|
||||
if exc.status_code != 422:
|
||||
raise
|
||||
raise _yaml_error(_flatten(exc.detail)) from exc
|
||||
|
||||
|
||||
@router.post("/config/api-key", status_code=status.HTTP_200_OK)
|
||||
def generate_api_key(store: ConfigStore = Depends(get_config_store)) -> dict[str, str]:
|
||||
"""Generate (or rotate) the dashboard integration key; returns it once."""
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""GET /api/update — is a newer Joulenap release out? Opt-in, cached, best-effort.
|
||||
|
||||
Deliberately *not* part of /api/health: that endpoint is the container HEALTHCHECK target
|
||||
and must stay fast and offline-safe. This one is called by the footer instead, at most one
|
||||
outbound request a day (in-memory cache), and only when ``app.update_check`` is on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import __version__
|
||||
from ..core.config_store import ConfigStore
|
||||
from .deps import get_config_store, require_auth
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_auth)], tags=["meta"])
|
||||
|
||||
_RELEASES_API = "https://api.github.com/repos/Joulenap/joulenap/releases/latest"
|
||||
_RELEASES_PAGE = "https://github.com/Joulenap/joulenap/releases"
|
||||
_TTL = 86400.0 # one check a day; the cache is memory-only, so a restart re-checks
|
||||
|
||||
# (checked_at monotonic, latest tag or "" when the check failed). Failures are cached too:
|
||||
# offline means every page load would otherwise pay the request timeout.
|
||||
_cache: tuple[float, str] | None = None
|
||||
|
||||
|
||||
def _parse(v: str) -> tuple[int, ...]:
|
||||
"""``"v0.4.4"`` -> ``(0, 4, 4)``. Stops at the first non-numeric part, so a suffixed
|
||||
tag ("0.5.0-beta") compares equal to its final release — we only publish finals."""
|
||||
out: list[int] = []
|
||||
for part in v.lstrip("vV").split("."):
|
||||
m = re.match(r"\d+", part)
|
||||
if not m:
|
||||
break
|
||||
out.append(int(m.group()))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _fetch_latest() -> str:
|
||||
"""Latest release tag from GitHub, or "" on any failure — never raises."""
|
||||
try:
|
||||
resp = httpx.get(
|
||||
_RELEASES_API, timeout=4, headers={"Accept": "application/vnd.github+json"}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return str(resp.json().get("tag_name") or "")
|
||||
except Exception: # noqa: BLE001 — offline, rate-limited or garbage all mean "don't know"
|
||||
return ""
|
||||
|
||||
|
||||
class UpdateResponse(BaseModel):
|
||||
current: str
|
||||
latest: str = "" # "" when unknown: check disabled, offline, or rate-limited
|
||||
update_available: bool = False
|
||||
url: str = _RELEASES_PAGE
|
||||
|
||||
|
||||
@router.get("/update", response_model=UpdateResponse)
|
||||
def get_update(store: ConfigStore = Depends(get_config_store)) -> UpdateResponse:
|
||||
global _cache
|
||||
if not store.config.app.update_check:
|
||||
return UpdateResponse(current=__version__)
|
||||
|
||||
now = time.monotonic()
|
||||
if _cache is None or now - _cache[0] > _TTL:
|
||||
_cache = (now, _fetch_latest())
|
||||
latest = _cache[1]
|
||||
return UpdateResponse(
|
||||
current=__version__,
|
||||
latest=latest,
|
||||
update_available=bool(latest) and _parse(latest) > _parse(__version__),
|
||||
)
|
||||
@@ -68,6 +68,9 @@ class AppConfig(_Base):
|
||||
# Read-only integration key for GET /api/dashboard (empty => integration disabled).
|
||||
# Managed only via POST/DELETE /api/config/api-key; PUT /api/config never touches it.
|
||||
api_key: str = ""
|
||||
# Opt-in: let GET /api/update ask GitHub (once a day) whether a newer release exists.
|
||||
# Off by default — the app makes no outbound internet call unless the user asks for it.
|
||||
update_check: bool = False
|
||||
auth: AuthConfig = Field(default_factory=AuthConfig)
|
||||
session: SessionConfig = Field(default_factory=SessionConfig)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "joulenap"
|
||||
version = "0.4.4"
|
||||
version = "0.5.0"
|
||||
description = "Self-hosted web UI + scheduler for energy-saving Proxmox backups to a normally-off PBS."
|
||||
readme = "../README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""GET/PUT /api/config/yaml — the Advanced tab's raw config editor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import REDACTED, load_config
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(temp_config, temp_db):
|
||||
app = create_app()
|
||||
with TestClient(app) as c:
|
||||
c.post("/api/auth/setup", json={"username": "admin", "password": "secret12"})
|
||||
yield c
|
||||
|
||||
|
||||
def _text(client) -> str:
|
||||
resp = client.get("/api/config/yaml")
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["yaml"]
|
||||
|
||||
|
||||
def _put(client, text: str):
|
||||
return client.put("/api/config/yaml", json={"yaml": text})
|
||||
|
||||
|
||||
def test_get_returns_the_redacted_config_as_yaml(client):
|
||||
doc = yaml.safe_load(_text(client))
|
||||
assert doc["pve"]["host"] == "192.0.2.10"
|
||||
assert doc["pve"]["api_token_secret"] == REDACTED # never ships the real secret
|
||||
assert doc["app"]["auth"]["password_hash"] == REDACTED
|
||||
|
||||
|
||||
def test_round_trip_changes_nothing(client, temp_config):
|
||||
before = load_config(temp_config).model_dump()
|
||||
assert _put(client, _text(client)).status_code == 200
|
||||
assert load_config(temp_config).model_dump() == before
|
||||
|
||||
|
||||
def test_edit_applies_and_secrets_survive(client, temp_config):
|
||||
text = _text(client).replace("bwlimit: 0", "bwlimit: 5000")
|
||||
assert _put(client, text).status_code == 200
|
||||
|
||||
saved = load_config(temp_config)
|
||||
assert saved.backup.bwlimit == 5000
|
||||
assert saved.pve.api_token_secret == "test-pve-secret" # restored from the sentinel
|
||||
|
||||
|
||||
def test_omitted_keys_keep_their_stored_value(client, temp_config):
|
||||
# Deep-merge semantics: deleting a section must not wipe the token behind it.
|
||||
assert _put(client, "backup:\n bwlimit: 42\n").status_code == 200
|
||||
saved = load_config(temp_config)
|
||||
assert saved.backup.bwlimit == 42
|
||||
assert saved.pve.api_token_secret == "test-pve-secret"
|
||||
assert saved.pve.host == "192.0.2.10"
|
||||
|
||||
|
||||
def test_malformed_yaml_reports_a_line(client):
|
||||
resp = _put(client, "app:\n language: en\n bad indent: x\n")
|
||||
assert resp.status_code == 422
|
||||
detail = resp.json()["detail"]
|
||||
assert detail["line"] == 3
|
||||
assert detail["message"]
|
||||
|
||||
|
||||
def test_non_mapping_document_is_rejected(client):
|
||||
resp = _put(client, "- just\n- a list\n")
|
||||
assert resp.status_code == 422
|
||||
assert "mapping" in resp.json()["detail"]["message"]
|
||||
|
||||
|
||||
def test_unknown_key_is_rejected_with_its_path(client):
|
||||
resp = _put(client, "backup:\n bwlimitt: 10\n")
|
||||
assert resp.status_code == 422
|
||||
assert "backup.bwlimitt" in resp.json()["detail"]["message"]
|
||||
|
||||
|
||||
def test_invalid_cron_is_rejected_through_the_yaml_path(client):
|
||||
# Proves the shared _apply_config guards (BE-B1) still run for the editor.
|
||||
resp = _put(client, "backup:\n schedule: not a cron\n")
|
||||
assert resp.status_code == 422
|
||||
assert "schedule" in resp.json()["detail"]["message"]
|
||||
|
||||
|
||||
def test_requires_auth(temp_config, temp_db):
|
||||
app = create_app()
|
||||
with TestClient(app) as c:
|
||||
assert c.get("/api/config/yaml").status_code == 401
|
||||
assert c.put("/api/config/yaml", json={"yaml": "app: {}"}).status_code == 401
|
||||
@@ -0,0 +1,95 @@
|
||||
"""GET /api/update — opt-in, cached GitHub release check."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import __version__
|
||||
from app.api import update
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(temp_config, temp_db, monkeypatch):
|
||||
monkeypatch.setattr(update, "_cache", None)
|
||||
app = create_app()
|
||||
with TestClient(app) as c:
|
||||
c.post("/api/auth/setup", json={"username": "admin", "password": "secret12"})
|
||||
yield c
|
||||
|
||||
|
||||
def _enable(client):
|
||||
body = client.get("/api/config").json()
|
||||
body["app"]["update_check"] = True
|
||||
assert client.put("/api/config", json=body).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("latest", "current", "expected"),
|
||||
[
|
||||
("v0.5.0", "0.4.4", True),
|
||||
("v0.4.4", "0.4.4", False),
|
||||
("v0.4.3", "0.4.4", False),
|
||||
("v1.0.0", "0.9.9", True),
|
||||
("0.10.0", "0.9.0", True), # numeric, not lexicographic
|
||||
("v0.5.0-beta", "0.5.0", False), # suffix ignored: same release
|
||||
("garbage", "0.4.4", False),
|
||||
],
|
||||
)
|
||||
def test_version_compare(latest, current, expected):
|
||||
assert (update._parse(latest) > update._parse(current)) is expected
|
||||
|
||||
|
||||
def test_disabled_by_default_never_calls_out(client, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
update, "_fetch_latest", lambda: pytest.fail("no network when update_check is off")
|
||||
)
|
||||
body = client.get("/api/update").json()
|
||||
assert body == {
|
||||
"current": __version__,
|
||||
"latest": "",
|
||||
"update_available": False,
|
||||
"url": update._RELEASES_PAGE,
|
||||
}
|
||||
|
||||
|
||||
def test_reports_and_caches_a_newer_release(client, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake():
|
||||
calls.append(1)
|
||||
return "v99.0.0"
|
||||
|
||||
monkeypatch.setattr(update, "_fetch_latest", fake)
|
||||
_enable(client)
|
||||
|
||||
body = client.get("/api/update").json()
|
||||
assert body["update_available"] is True
|
||||
assert body["latest"] == "v99.0.0"
|
||||
assert body["current"] == __version__
|
||||
|
||||
client.get("/api/update")
|
||||
assert len(calls) == 1 # second call served from the cache
|
||||
|
||||
|
||||
def test_fetch_failure_is_silent(client, monkeypatch):
|
||||
monkeypatch.setattr(update, "_fetch_latest", lambda: "")
|
||||
_enable(client)
|
||||
body = client.get("/api/update").json()
|
||||
assert body["latest"] == ""
|
||||
assert body["update_available"] is False
|
||||
|
||||
|
||||
def test_fetch_latest_swallows_transport_errors(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("offline")
|
||||
|
||||
monkeypatch.setattr(update.httpx, "get", boom)
|
||||
assert update._fetch_latest() == ""
|
||||
|
||||
|
||||
def test_requires_auth(temp_config, temp_db):
|
||||
app = create_app()
|
||||
with TestClient(app) as c:
|
||||
assert c.get("/api/update").status_code == 401
|
||||
@@ -15,6 +15,10 @@ app:
|
||||
# Read-only key for the dashboard integration endpoint (GET /api/dashboard).
|
||||
# Leave empty to disable. Generate/rotate it from Settings -> Integrations in the UI.
|
||||
api_key: ""
|
||||
# Ask GitHub once a day whether a newer Joulenap release exists, and show a footer
|
||||
# badge if so. Off by default: the app makes no outbound internet call unless you
|
||||
# turn this on (Settings -> Integrations).
|
||||
update_check: false
|
||||
session:
|
||||
https_only: false
|
||||
max_age_days: 14
|
||||
|
||||
@@ -43,6 +43,9 @@ Everything is served under `/api`. Auth is a signed **session cookie** started b
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/health` | version + liveness (used by the Docker healthcheck) |
|
||||
| GET | `/api/update` | running version, plus the latest GitHub release when `app.update_check` is on (cached 24h; no outbound call when off) |
|
||||
| GET | `/api/config/yaml` | the redacted config serialised as YAML, for the Advanced tab's editor |
|
||||
| PUT | `/api/config/yaml` | apply an edited YAML document (same validation and merge as `PUT /api/config`) |
|
||||
| GET | `/api/auth/status` | whether first-run setup is still needed / already signed in |
|
||||
| POST | `/api/auth/setup` | first run: create the admin account |
|
||||
| POST | `/api/login` | authenticate, start session |
|
||||
|
||||
Generated
+153
-2
@@ -1,13 +1,20 @@
|
||||
{
|
||||
"name": "joulenap-frontend",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "joulenap-frontend",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.4",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/lint": "^6.9.7",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.6",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"i18next": "^26.3.5",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
@@ -30,6 +37,91 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
|
||||
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-yaml": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
|
||||
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"@lezer/yaml": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
|
||||
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
|
||||
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
@@ -64,6 +156,47 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/yaml": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
|
||||
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
|
||||
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
@@ -432,6 +565,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -958,6 +1097,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -1092,6 +1237,12 @@
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "joulenap-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.4",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -10,6 +10,13 @@
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/lint": "^6.9.7",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.6",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"i18next": "^26.3.5",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
|
||||
@@ -18,12 +18,16 @@ import type {
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
// The backend's `detail` as parsed, when it wasn't a plain string — the YAML editor reads
|
||||
// {message, line} off it to mark the offending line. `message` stays a string either way.
|
||||
raw?: unknown
|
||||
// A plain field assignment, not a `public status` parameter property: the frontend test
|
||||
// harness runs `node --test` in strip-only TS mode, which rejects parameter properties.
|
||||
constructor(status: number, message: string) {
|
||||
constructor(status: number, message: string, raw?: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.raw = raw
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,15 +88,23 @@ async function req<T>(method: string, path: string, body?: unknown, timeoutMs =
|
||||
onUnauthorized?.()
|
||||
}
|
||||
let detail: string = res.statusText
|
||||
let raw: unknown
|
||||
try {
|
||||
const j = await res.json()
|
||||
if (j && typeof j.detail !== 'undefined') {
|
||||
detail = typeof j.detail === 'string' ? j.detail : JSON.stringify(j.detail)
|
||||
if (typeof j.detail === 'string') {
|
||||
detail = j.detail
|
||||
} else {
|
||||
raw = j.detail
|
||||
// A structured detail: prefer its own message, else fall back to the JSON dump.
|
||||
const m = (j.detail as { message?: unknown }).message
|
||||
detail = typeof m === 'string' ? m : JSON.stringify(j.detail)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// non-JSON error body — keep statusText
|
||||
}
|
||||
throw new ApiError(res.status, detail)
|
||||
throw new ApiError(res.status, detail, raw)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
const text = await res.text()
|
||||
@@ -102,6 +114,12 @@ async function req<T>(method: string, path: string, body?: unknown, timeoutMs =
|
||||
export const api = {
|
||||
// meta (unauthenticated) — app version for the footer
|
||||
health: () => req<{ status: string; version: string }>('GET', '/health'),
|
||||
// meta — running version + (only when app.update_check is on) the latest release
|
||||
update: () =>
|
||||
req<{ current: string; latest: string; update_available: boolean; url: string }>(
|
||||
'GET',
|
||||
'/update',
|
||||
),
|
||||
|
||||
// auth
|
||||
authStatus: () => req<AuthStatus>('GET', '/auth/status'),
|
||||
@@ -122,6 +140,10 @@ export const api = {
|
||||
status: () => req<StatusResponse>('GET', '/status'),
|
||||
getConfig: () => req<Config>('GET', '/config'),
|
||||
putConfig: (config: Config) => req<Config>('PUT', '/config', config),
|
||||
// Raw config.yaml (redacted) for the Advanced tab's editor; PUT goes through the same
|
||||
// validation as putConfig, so a rejected document leaves the stored config untouched.
|
||||
getConfigYaml: () => req<{ yaml: string }>('GET', '/config/yaml'),
|
||||
putConfigYaml: (text: string) => req<Config>('PUT', '/config/yaml', { yaml: text }),
|
||||
generateApiKey: () => req<{ api_key: string }>('POST', '/config/api-key'),
|
||||
deleteApiKey: () => req<void>('DELETE', '/config/api-key'),
|
||||
guests: () => req<GuestInfo[]>('GET', '/guests'),
|
||||
|
||||
@@ -151,7 +151,9 @@ export interface AppConfig {
|
||||
timezone: string
|
||||
secret_key: string
|
||||
api_key: string
|
||||
update_check: boolean
|
||||
auth: { username: string; password_hash: string }
|
||||
session: { https_only: boolean; max_age_days: number }
|
||||
}
|
||||
|
||||
export interface TelegramConfig {
|
||||
|
||||
+78
-1
@@ -45,7 +45,9 @@ const CONFIG: Config = {
|
||||
timezone: 'Europe/Rome',
|
||||
secret_key: 'stub-secret-key',
|
||||
api_key: 'stub-api-key',
|
||||
update_check: true,
|
||||
auth: { username: 'admin', password_hash: 'stub' },
|
||||
session: { https_only: false, max_age_days: 14 },
|
||||
},
|
||||
pve: {
|
||||
host: '192.168.1.10',
|
||||
@@ -218,13 +220,88 @@ const WIZARD_SSH_TRUST: { trusted: boolean } = { trusted: true }
|
||||
|
||||
const WIZARD_RESET: { ok: boolean } = { ok: true }
|
||||
|
||||
// What GET /config/yaml returns: the redacted config re-serialised, same shape the backend
|
||||
// dumps. Hand-written here (no YAML dumper in the stub) but kept in step with CONFIG above.
|
||||
const CONFIG_YAML = `app:
|
||||
language: en
|
||||
theme: dark
|
||||
port: 8080
|
||||
timezone: Europe/Rome
|
||||
secret_key: '***REDACTED***'
|
||||
api_key: '***REDACTED***'
|
||||
update_check: true
|
||||
auth:
|
||||
username: admin
|
||||
password_hash: '***REDACTED***'
|
||||
session:
|
||||
https_only: false
|
||||
max_age_days: 14
|
||||
pve:
|
||||
host: 192.168.1.10
|
||||
port: 8006
|
||||
node: pve
|
||||
verify_tls: false
|
||||
api_token_id: root@pam!joulenap
|
||||
api_token_secret: '***REDACTED***'
|
||||
storage_id: pbs
|
||||
pbs:
|
||||
host: 192.168.1.50
|
||||
port: 8007
|
||||
datastore: backup
|
||||
mac: aa:bb:cc:dd:ee:ff
|
||||
wol_broadcast_iface: eth0
|
||||
wait_timeout: 180
|
||||
wol_retries: 2
|
||||
poweroff_task_wait: 600
|
||||
ssh_user: root
|
||||
ssh_key_path: /app/data/id_ed25519
|
||||
backup:
|
||||
enabled: true
|
||||
schedule: 30 2 * * 1,3,5
|
||||
mode: snapshot
|
||||
bwlimit: 0
|
||||
min_free_percent: 10
|
||||
guests:
|
||||
mode: all
|
||||
auto_include_new: true
|
||||
list: []
|
||||
retention:
|
||||
keep_last: 0
|
||||
keep_daily: 7
|
||||
keep_weekly: 4
|
||||
keep_monthly: 6
|
||||
keep_yearly: 0
|
||||
maintenance:
|
||||
gc:
|
||||
enabled: true
|
||||
verify:
|
||||
after_backup: false
|
||||
enabled: true
|
||||
schedule: 0 4 * * 0
|
||||
reverify_days: 30
|
||||
history:
|
||||
retention_days: 90
|
||||
notifications:
|
||||
on_success: true
|
||||
on_failure: true
|
||||
custom_urls: []
|
||||
`
|
||||
|
||||
const ROUTES: Record<string, unknown> = {
|
||||
'GET /health': { status: 'ok', version: '0.4.4-stub' },
|
||||
'GET /health': { status: 'ok', version: '0.5.0-stub' },
|
||||
'GET /update': {
|
||||
current: '0.5.0-stub',
|
||||
latest: '0.5.0',
|
||||
update_available: true,
|
||||
url: 'https://github.com/Joulenap/joulenap/releases',
|
||||
},
|
||||
'GET /auth/status': AUTH_STATUS,
|
||||
'GET /auth/me': ME,
|
||||
'GET /status': STATUS,
|
||||
'GET /config': CONFIG,
|
||||
'PUT /config': CONFIG,
|
||||
'GET /config/yaml': { yaml: CONFIG_YAML },
|
||||
'PUT /config/yaml': CONFIG,
|
||||
'GET /guests': GUESTS,
|
||||
'GET /tasklog': TASKLOG,
|
||||
'POST /wizard/pve/connect': WIZARD_PVE_CONNECT,
|
||||
|
||||
@@ -151,7 +151,9 @@
|
||||
"safety": "Backup safety",
|
||||
"safetyHint": "Pre-flight & power-off guards",
|
||||
"integrations": "Integrations",
|
||||
"integrationsHint": "Dashboard widgets (Homepage, ...)"
|
||||
"integrationsHint": "Dashboard widgets, updates",
|
||||
"advanced": "Advanced",
|
||||
"advancedHint": "Expert knobs & config.yaml"
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localization",
|
||||
@@ -373,6 +375,53 @@
|
||||
"disableConfirmTitle": "Disable integration?",
|
||||
"disableConfirmBody": "The endpoint will return 403 and any dashboard widget using it will stop working.",
|
||||
"keyPlaceholder": "<your-api-key>"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Update check",
|
||||
"subtitle": "Ask GitHub once a day whether a newer Joulenap release exists, and show a badge in the footer if so.",
|
||||
"toggle": "Check for updates",
|
||||
"toggleHint": "Off by default: with this disabled Joulenap never contacts the internet.",
|
||||
"upToDate": "Up to date — v{{version}} is the latest release.",
|
||||
"unknown": "Couldn't reach GitHub — will retry later.",
|
||||
"available": "v{{version}} is available."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced",
|
||||
"subtitle": "Settings with no home on the other screens. If you are not sure what one does, leave it alone.",
|
||||
"saved": "✓ Saved",
|
||||
"backupSection": "Backup job",
|
||||
"mode": "Backup mode",
|
||||
"modeSnapshot": "Snapshot (no downtime)",
|
||||
"modeSuspend": "Suspend",
|
||||
"modeStop": "Stop",
|
||||
"modeHint": "Snapshot backs up a running guest live. Suspend and Stop pause or shut down each guest for the duration of its backup — more consistent, but the guest is unavailable.",
|
||||
"bwlimit": "Bandwidth limit (KiB/s)",
|
||||
"bwlimitHint": "Caps the vzdump write rate. 0 means unlimited.",
|
||||
"retentionSection": "Extra retention buckets",
|
||||
"retentionHint": "Daily, weekly and monthly are on the dashboard's Schedule card. These two are the remaining vzdump buckets.",
|
||||
"keepLast": "Keep last",
|
||||
"keepLastHint": "Always keep this many most-recent backups regardless of age. 0 disables the bucket.",
|
||||
"keepYearly": "Keep yearly",
|
||||
"keepYearlyHint": "Number of yearly backups to keep. 0 disables the bucket.",
|
||||
"historySection": "Run history",
|
||||
"historyDays": "Keep history for (days)",
|
||||
"historyDaysHint": "Run history and activity-log rows older than this are pruned daily so the database can't grow without bound. 0 keeps everything forever.",
|
||||
"serverSection": "Server",
|
||||
"restartHint": "⚠️ These three only take effect after the container is restarted.",
|
||||
"port": "Web UI port",
|
||||
"portHint": "Port the app listens on inside the container. Changing it also means changing the port mapping of your container.",
|
||||
"sessionDays": "Session lifetime (days)",
|
||||
"sessionDaysHint": "How long a sign-in stays valid before the login screen comes back.",
|
||||
"httpsOnly": "HTTPS-only session cookie",
|
||||
"httpsOnlyHint": "Enable when Joulenap is served over HTTPS or behind a TLS-terminating proxy. Leaving it on over plain HTTP will lock you out of signing in.",
|
||||
"yamlTitle": "Edit config.yaml",
|
||||
"yamlSubtitle": "The whole configuration, exactly as it is stored. Everything the forms don't cover lives here.",
|
||||
"yamlHint": "Secrets are shown as ***REDACTED*** — leave them as-is to keep the stored value, or type a new one to replace it. A key you delete keeps its current value; the document is validated before anything is written.",
|
||||
"yamlApply": "Apply changes",
|
||||
"yamlSaved": "✓ Configuration saved",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyFailed": "Couldn't copy automatically — select the text and copy it manually."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,9 @@
|
||||
"safety": "Sicurezza backup",
|
||||
"safetyHint": "Controlli pre-volo e spegnimento",
|
||||
"integrations": "Integrazioni",
|
||||
"integrationsHint": "Widget dashboard (Homepage, ...)"
|
||||
"integrationsHint": "Widget dashboard, aggiornamenti",
|
||||
"advanced": "Avanzate",
|
||||
"advancedHint": "Opzioni esperte & config.yaml"
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localizzazione",
|
||||
@@ -373,6 +375,53 @@
|
||||
"disableConfirmTitle": "Disattivare l'integrazione?",
|
||||
"disableConfirmBody": "L'endpoint restituirà 403 e ogni widget che lo usa smetterà di funzionare.",
|
||||
"keyPlaceholder": "<la-tua-chiave-api>"
|
||||
},
|
||||
"updates": {
|
||||
"title": "Controllo aggiornamenti",
|
||||
"subtitle": "Chiedi a GitHub una volta al giorno se esiste una versione più recente di Joulenap, e mostra un badge nel footer se c'è.",
|
||||
"toggle": "Controlla gli aggiornamenti",
|
||||
"toggleHint": "Disattivo di default: con questa opzione spenta Joulenap non contatta mai internet.",
|
||||
"upToDate": "Aggiornato — v{{version}} è l'ultima versione.",
|
||||
"unknown": "GitHub non raggiungibile — riproverà più tardi.",
|
||||
"available": "La v{{version}} è disponibile."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Avanzate",
|
||||
"subtitle": "Impostazioni che non trovano posto nelle altre schermate. Se non sai cosa fa una voce, lasciala com'è.",
|
||||
"saved": "✓ Salvato",
|
||||
"backupSection": "Job di backup",
|
||||
"mode": "Modalità di backup",
|
||||
"modeSnapshot": "Snapshot (nessun fermo)",
|
||||
"modeSuspend": "Suspend",
|
||||
"modeStop": "Stop",
|
||||
"modeHint": "Snapshot esegue il backup della guest mentre è in funzione. Suspend e Stop la sospendono o la spengono per tutta la durata del suo backup — più consistente, ma la guest resta non disponibile.",
|
||||
"bwlimit": "Limite di banda (KiB/s)",
|
||||
"bwlimitHint": "Limita la velocità di scrittura di vzdump. 0 significa nessun limite.",
|
||||
"retentionSection": "Bucket di retention aggiuntivi",
|
||||
"retentionHint": "Giornalieri, settimanali e mensili sono nella card Pianificazione della dashboard. Questi due sono i bucket vzdump rimanenti.",
|
||||
"keepLast": "Mantieni ultimi",
|
||||
"keepLastHint": "Mantiene sempre questo numero di backup più recenti, indipendentemente dall'età. 0 disattiva il bucket.",
|
||||
"keepYearly": "Mantieni annuali",
|
||||
"keepYearlyHint": "Numero di backup annuali da mantenere. 0 disattiva il bucket.",
|
||||
"historySection": "Storico esecuzioni",
|
||||
"historyDays": "Conserva lo storico per (giorni)",
|
||||
"historyDaysHint": "Le righe di storico e del registro attività più vecchie di così vengono eliminate ogni giorno, così il database non cresce all'infinito. 0 conserva tutto per sempre.",
|
||||
"serverSection": "Server",
|
||||
"restartHint": "⚠️ Queste tre opzioni hanno effetto solo dopo il riavvio del container.",
|
||||
"port": "Porta interfaccia web",
|
||||
"portHint": "Porta su cui l'app ascolta dentro il container. Cambiandola va cambiata anche la mappatura delle porte del container.",
|
||||
"sessionDays": "Durata della sessione (giorni)",
|
||||
"sessionDaysHint": "Per quanto tempo resta valido un accesso prima che ricompaia la schermata di login.",
|
||||
"httpsOnly": "Cookie di sessione solo HTTPS",
|
||||
"httpsOnlyHint": "Attivalo se Joulenap è servito via HTTPS o dietro un proxy che termina il TLS. Lasciarlo attivo su HTTP semplice ti impedirà di accedere.",
|
||||
"yamlTitle": "Modifica config.yaml",
|
||||
"yamlSubtitle": "L'intera configurazione, esattamente come è memorizzata. Tutto ciò che i form non coprono si trova qui.",
|
||||
"yamlHint": "I segreti sono mostrati come ***REDACTED*** — lasciali così per mantenere il valore salvato, oppure scrivine uno nuovo per sostituirlo. Una chiave che cancelli mantiene il valore attuale; il documento viene validato prima di scrivere qualsiasi cosa.",
|
||||
"yamlApply": "Applica modifiche",
|
||||
"yamlSaved": "✓ Configurazione salvata",
|
||||
"copy": "Copia",
|
||||
"copied": "Copiato",
|
||||
"copyFailed": "Copia automatica non riuscita — seleziona il testo e copialo manualmente."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,21 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useUnsavedGuard } from '../shell/UnsavedGuard'
|
||||
import { c } from '../theme'
|
||||
import { Account } from './settings/Account'
|
||||
import { Advanced } from './settings/Advanced'
|
||||
import { BackupSafety } from './settings/BackupSafety'
|
||||
import { Integrations } from './settings/Integrations'
|
||||
import { Integrations, UpdateCheck } from './settings/Integrations'
|
||||
import { Localization } from './settings/Localization'
|
||||
import { Notifications } from './settings/Notifications'
|
||||
import { SetupWizard } from './settings/SetupWizard'
|
||||
|
||||
export type Tab = 'localization' | 'account' | 'notifications' | 'setup' | 'safety' | 'integrations'
|
||||
export type Tab =
|
||||
| 'localization'
|
||||
| 'account'
|
||||
| 'notifications'
|
||||
| 'setup'
|
||||
| 'safety'
|
||||
| 'integrations'
|
||||
| 'advanced'
|
||||
|
||||
const NAV: { key: Tab }[] = [
|
||||
{ key: 'localization' },
|
||||
@@ -18,6 +26,7 @@ const NAV: { key: Tab }[] = [
|
||||
{ key: 'setup' },
|
||||
{ key: 'safety' },
|
||||
{ key: 'integrations' },
|
||||
{ key: 'advanced' },
|
||||
]
|
||||
|
||||
export function Settings(_props: { onClose: () => void; initialTab?: Tab }) {
|
||||
@@ -89,7 +98,13 @@ export function Settings(_props: { onClose: () => void; initialTab?: Tab }) {
|
||||
{tab === 'notifications' && <Notifications />}
|
||||
{tab === 'setup' && <SetupWizard />}
|
||||
{tab === 'safety' && <BackupSafety />}
|
||||
{tab === 'integrations' && <Integrations />}
|
||||
{tab === 'integrations' && (
|
||||
<>
|
||||
<Integrations />
|
||||
<UpdateCheck />
|
||||
</>
|
||||
)}
|
||||
{tab === 'advanced' && <Advanced />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ApiError } from '../../api/client'
|
||||
import type { Config } from '../../api/types'
|
||||
import { Dropdown } from '../../components/Dropdown'
|
||||
import { Spinner } from '../../components/Spinner'
|
||||
import { Toggle } from '../../components/Toggle'
|
||||
import { useConfig } from '../../config/ConfigContext'
|
||||
import { useRegisterDirty } from '../../shell/UnsavedGuard'
|
||||
import { c, inputStyle, labelStyle, panelStyle, primaryBtn } from '../../theme'
|
||||
|
||||
// Config knobs the backend honours but no other screen exposes (re-review 11.8). Everything
|
||||
// else remains reachable through the YAML editor below, which is code-split so CodeMirror
|
||||
// only downloads when this tab is opened.
|
||||
const YamlEditor = lazy(() => import('./YamlEditor'))
|
||||
|
||||
interface Draft {
|
||||
mode: 'snapshot' | 'suspend' | 'stop'
|
||||
bwlimit: number
|
||||
keep_last: number
|
||||
keep_yearly: number
|
||||
history_days: number
|
||||
session_days: number
|
||||
https_only: boolean
|
||||
port: number
|
||||
}
|
||||
|
||||
function draftOf(config: Config): Draft {
|
||||
return {
|
||||
mode: config.backup.mode,
|
||||
bwlimit: config.backup.bwlimit,
|
||||
keep_last: config.backup.retention.keep_last,
|
||||
keep_yearly: config.backup.retention.keep_yearly,
|
||||
history_days: config.maintenance.history.retention_days,
|
||||
session_days: config.app.session.max_age_days,
|
||||
https_only: config.app.session.https_only,
|
||||
port: config.app.port,
|
||||
}
|
||||
}
|
||||
|
||||
export function Advanced() {
|
||||
const { t } = useTranslation()
|
||||
const { config, save } = useConfig()
|
||||
const [draft, setDraft] = useState<Draft | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [savedNote, setSavedNote] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (config) setDraft(draftOf(config))
|
||||
}, [config])
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (!config || !draft) return false
|
||||
const stored = draftOf(config)
|
||||
return (Object.keys(stored) as (keyof Draft)[]).some((k) => stored[k] !== draft[k])
|
||||
}, [config, draft])
|
||||
useRegisterDirty(dirty)
|
||||
|
||||
const ns = 'settings.advanced'
|
||||
|
||||
if (!config || !draft) return null
|
||||
|
||||
function patch(next: Partial<Draft>) {
|
||||
setDraft((d) => (d ? { ...d, ...next } : d))
|
||||
setSavedNote(false)
|
||||
setErr(null)
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!config || !draft) return
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await save({
|
||||
...config,
|
||||
app: {
|
||||
...config.app,
|
||||
port: draft.port,
|
||||
session: { max_age_days: draft.session_days, https_only: draft.https_only },
|
||||
},
|
||||
backup: {
|
||||
...config.backup,
|
||||
mode: draft.mode,
|
||||
bwlimit: draft.bwlimit,
|
||||
retention: {
|
||||
...config.backup.retention,
|
||||
keep_last: draft.keep_last,
|
||||
keep_yearly: draft.keep_yearly,
|
||||
},
|
||||
},
|
||||
maintenance: {
|
||||
...config.maintenance,
|
||||
history: { retention_days: draft.history_days },
|
||||
},
|
||||
})
|
||||
setSavedNote(true)
|
||||
} catch (e) {
|
||||
setErr(e instanceof ApiError ? e.message : t('common.saveFailed'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const numberField = (
|
||||
key: keyof Draft,
|
||||
label: string,
|
||||
hint: string,
|
||||
opts?: { min?: number; max?: number },
|
||||
) => (
|
||||
<label style={{ display: 'block' }}>
|
||||
<span style={labelStyle}>{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={String(draft[key])}
|
||||
min={opts?.min ?? 0}
|
||||
max={opts?.max}
|
||||
onChange={(e) => {
|
||||
let n = Math.floor(Number(e.target.value)) || 0
|
||||
n = Math.max(opts?.min ?? 0, n)
|
||||
if (opts?.max != null) n = Math.min(opts.max, n)
|
||||
patch({ [key]: n } as Partial<Draft>)
|
||||
}}
|
||||
style={{ ...inputStyle, maxWidth: 200 }}
|
||||
/>
|
||||
<span style={{ display: 'block', fontSize: 11, color: c.textFaint, marginTop: 5, lineHeight: 1.5 }}>
|
||||
{hint}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
|
||||
const section = (title: string, body: React.ReactNode) => (
|
||||
<div
|
||||
style={{
|
||||
background: c.panelAlt,
|
||||
border: `1px solid ${c.borderSoft}`,
|
||||
borderRadius: 10,
|
||||
padding: '16px 18px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: c.textMid }}>{title}</span>
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ ...panelStyle, padding: '24px 26px', maxWidth: 640 }}>
|
||||
<span style={{ display: 'block', fontSize: 16, fontWeight: 700, marginBottom: 5 }}>
|
||||
{t(`${ns}.title`)}
|
||||
</span>
|
||||
<span style={{ display: 'block', fontSize: 13, color: c.textDim, lineHeight: 1.5, marginBottom: 22 }}>
|
||||
{t(`${ns}.subtitle`)}
|
||||
</span>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{section(
|
||||
t(`${ns}.backupSection`),
|
||||
<>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span style={labelStyle}>{t(`${ns}.mode`)}</span>
|
||||
<div style={{ maxWidth: 200 }}>
|
||||
<Dropdown
|
||||
value={draft.mode}
|
||||
options={[
|
||||
{ value: 'snapshot', label: t(`${ns}.modeSnapshot`) },
|
||||
{ value: 'suspend', label: t(`${ns}.modeSuspend`) },
|
||||
{ value: 'stop', label: t(`${ns}.modeStop`) },
|
||||
]}
|
||||
onChange={(v) => patch({ mode: v as Draft['mode'] })}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ display: 'block', fontSize: 11, color: c.textFaint, marginTop: 5, lineHeight: 1.5 }}>
|
||||
{t(`${ns}.modeHint`)}
|
||||
</span>
|
||||
</label>
|
||||
{numberField('bwlimit', t(`${ns}.bwlimit`), t(`${ns}.bwlimitHint`))}
|
||||
</>,
|
||||
)}
|
||||
|
||||
{section(
|
||||
t(`${ns}.retentionSection`),
|
||||
<>
|
||||
<span style={{ fontSize: 12, color: c.textFaint, lineHeight: 1.5, marginTop: -6 }}>
|
||||
{t(`${ns}.retentionHint`)}
|
||||
</span>
|
||||
{numberField('keep_last', t(`${ns}.keepLast`), t(`${ns}.keepLastHint`))}
|
||||
{numberField('keep_yearly', t(`${ns}.keepYearly`), t(`${ns}.keepYearlyHint`))}
|
||||
</>,
|
||||
)}
|
||||
|
||||
{section(
|
||||
t(`${ns}.historySection`),
|
||||
numberField('history_days', t(`${ns}.historyDays`), t(`${ns}.historyDaysHint`)),
|
||||
)}
|
||||
|
||||
{section(
|
||||
t(`${ns}.serverSection`),
|
||||
<>
|
||||
<span style={{ fontSize: 12, color: c.amber, lineHeight: 1.5, marginTop: -6 }}>
|
||||
{t(`${ns}.restartHint`)}
|
||||
</span>
|
||||
{numberField('port', t(`${ns}.port`), t(`${ns}.portHint`), { min: 1, max: 65535 })}
|
||||
{numberField('session_days', t(`${ns}.sessionDays`), t(`${ns}.sessionDaysHint`), {
|
||||
min: 1,
|
||||
})}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||
<Toggle on={draft.https_only} onClick={() => patch({ https_only: !draft.https_only })} />
|
||||
<span>
|
||||
<span style={{ display: 'block', fontSize: 13, fontWeight: 600 }}>
|
||||
{t(`${ns}.httpsOnly`)}
|
||||
</span>
|
||||
<span style={{ display: 'block', fontSize: 11, color: c.textFaint, marginTop: 3, lineHeight: 1.5 }}>
|
||||
{t(`${ns}.httpsOnlyHint`)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</>,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 22 }}>
|
||||
<button
|
||||
onClick={() => void onSave()}
|
||||
disabled={!dirty || busy}
|
||||
style={{
|
||||
...primaryBtn,
|
||||
padding: '10px 24px',
|
||||
background: dirty ? c.accent : '#1d232b',
|
||||
color: dirty ? c.accentInk : c.textMuted,
|
||||
border: dirty ? 'none' : '1px solid #262d35',
|
||||
cursor: dirty && !busy ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
{savedNote && !dirty && <span style={{ fontSize: 12, color: c.green }}>{t(`${ns}.saved`)}</span>}
|
||||
{err && <span style={{ fontSize: 12, color: c.red }}>{err}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<YamlEditor />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api, ApiError } from '../../api/client'
|
||||
import { ConfirmModal, type ConfirmState } from '../../components/ConfirmModal'
|
||||
import { Toggle } from '../../components/Toggle'
|
||||
import { useConfig } from '../../config/ConfigContext'
|
||||
import { c, ghostBtn, labelStyle, panelStyle, primaryBtn } from '../../theme'
|
||||
import { copyToClipboard } from '../../utils/clipboard'
|
||||
@@ -83,6 +84,48 @@ Fields available: pbs_state, next_run, last_run_status, last_run_time,
|
||||
}
|
||||
}
|
||||
|
||||
// Opt-in outbound release check. Toggling saves immediately (single boolean — no draft to
|
||||
// keep, same as the scheduler switch on the dashboard); the footer badge reacts to it.
|
||||
export function UpdateCheck() {
|
||||
const { t } = useTranslation()
|
||||
const { config, save } = useConfig()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const on = Boolean(config?.app.update_check)
|
||||
|
||||
async function toggle() {
|
||||
if (!config) return
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await save({ ...config, app: { ...config.app, update_check: !on } })
|
||||
} catch (e) {
|
||||
setErr(e instanceof ApiError ? e.message : t('common.saveFailed'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ ...panelStyle, padding: '24px 26px', maxWidth: 640, marginTop: 18 }}>
|
||||
<span style={{ display: 'block', fontSize: 16, fontWeight: 700, marginBottom: 5 }}>
|
||||
{t('settings.updates.title')}
|
||||
</span>
|
||||
<span style={{ display: 'block', fontSize: 13, color: c.textDim, lineHeight: 1.5, marginBottom: 18 }}>
|
||||
{t('settings.updates.subtitle')}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Toggle on={on} onClick={() => void (busy ? null : toggle())} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{t('settings.updates.toggle')}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: c.textDim, marginTop: 8 }}>
|
||||
{t('settings.updates.toggleHint')}
|
||||
</div>
|
||||
{err && <div style={{ fontSize: 12, color: c.red, marginTop: 8 }}>{err}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Integrations() {
|
||||
const { t } = useTranslation()
|
||||
const { config, reload } = useConfig()
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { yaml as yamlLang } from '@codemirror/lang-yaml'
|
||||
import { HighlightStyle, indentUnit, syntaxHighlighting } from '@codemirror/language'
|
||||
import { setDiagnostics } from '@codemirror/lint'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { tags as tg } from '@lezer/highlight'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api, ApiError } from '../../api/client'
|
||||
import { Spinner } from '../../components/Spinner'
|
||||
import { useConfig } from '../../config/ConfigContext'
|
||||
import { useRegisterDirty } from '../../shell/UnsavedGuard'
|
||||
import { c, ghostBtn, mono, panelStyle, primaryBtn } from '../../theme'
|
||||
import { copyToClipboard } from '../../utils/clipboard'
|
||||
|
||||
// This module is loaded lazily (see Advanced.tsx) so CodeMirror lands in its own chunk and
|
||||
// never weighs on the dashboard's first paint.
|
||||
|
||||
const theme = EditorView.theme(
|
||||
{
|
||||
'&': { height: '440px', fontSize: '13px', color: c.text },
|
||||
'&.cm-focused': { outline: `1px solid ${c.accent}` },
|
||||
'.cm-scroller': { fontFamily: mono, overflow: 'auto' },
|
||||
'.cm-content': { caretColor: c.accent },
|
||||
'.cm-cursor': { borderLeftColor: c.accent },
|
||||
'.cm-gutters': { background: c.panelAlt, color: c.textMuted, border: 'none' },
|
||||
'.cm-activeLine': { background: 'rgba(255,255,255,.03)' },
|
||||
'.cm-activeLineGutter': { background: 'transparent', color: c.textDim },
|
||||
'&.cm-focused .cm-selectionBackground, ::selection': { background: 'rgba(232,131,15,.25)' },
|
||||
},
|
||||
{ dark: true },
|
||||
)
|
||||
|
||||
const highlight = HighlightStyle.define([
|
||||
{ tag: [tg.propertyName, tg.definition(tg.propertyName)], color: c.accent },
|
||||
{ tag: tg.string, color: c.green },
|
||||
{ tag: [tg.number, tg.bool, tg.null], color: c.blue },
|
||||
{ tag: tg.comment, color: c.textMuted, fontStyle: 'italic' },
|
||||
{ tag: [tg.punctuation, tg.separator], color: c.textDim },
|
||||
])
|
||||
|
||||
export function YamlEditor() {
|
||||
const { t } = useTranslation()
|
||||
const { reload } = useConfig()
|
||||
const host = useRef<HTMLDivElement | null>(null)
|
||||
const view = useRef<EditorView | null>(null)
|
||||
|
||||
// `saved` is the last text the server accepted; `text` tracks the buffer so the Apply
|
||||
// button and the unsaved-changes guard know when they differ.
|
||||
const [saved, setSaved] = useState<string | null>(null)
|
||||
const [text, setText] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [note, setNote] = useState<'saved' | 'copied' | 'copyFailed' | null>(null)
|
||||
const [loadErr, setLoadErr] = useState<string | null>(null)
|
||||
|
||||
const dirty = saved !== null && text !== saved
|
||||
useRegisterDirty(dirty)
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getConfigYaml()
|
||||
.then(({ yaml }) => {
|
||||
setSaved(yaml)
|
||||
setText(yaml)
|
||||
})
|
||||
.catch((e) => setLoadErr(e instanceof ApiError ? e.message : String(e)))
|
||||
}, [])
|
||||
|
||||
// Mount CodeMirror once the initial document is in hand. The view is uncontrolled: React
|
||||
// never pushes `text` back into it, it only mirrors what the user types.
|
||||
useEffect(() => {
|
||||
if (saved === null || !host.current || view.current) return
|
||||
view.current = new EditorView({
|
||||
parent: host.current,
|
||||
state: EditorState.create({
|
||||
doc: saved,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
history(),
|
||||
// No indentWithTab: Tab keeps moving focus, so the editor stays keyboard-escapable.
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
indentUnit.of(' '),
|
||||
yamlLang(),
|
||||
syntaxHighlighting(highlight),
|
||||
theme,
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) {
|
||||
setText(u.state.doc.toString())
|
||||
setErr(null)
|
||||
setNote(null)
|
||||
}
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
return () => {
|
||||
view.current?.destroy()
|
||||
view.current = null
|
||||
}
|
||||
}, [saved])
|
||||
|
||||
function mark(message: string, line: number | null) {
|
||||
const v = view.current
|
||||
if (!v) return
|
||||
if (line === null) {
|
||||
v.dispatch(setDiagnostics(v.state, []))
|
||||
return
|
||||
}
|
||||
const l = v.state.doc.line(Math.min(Math.max(line, 1), v.state.doc.lines))
|
||||
v.dispatch(setDiagnostics(v.state, [{ from: l.from, to: l.to, severity: 'error', message }]))
|
||||
}
|
||||
|
||||
async function onApply() {
|
||||
const current = view.current?.state.doc.toString() ?? text
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await api.putConfigYaml(current)
|
||||
setSaved(current)
|
||||
setNote('saved')
|
||||
mark('', null)
|
||||
await reload() // keep the other tabs' shared config in sync
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
setErr(e.message)
|
||||
const line = (e.raw as { line?: number } | undefined)?.line
|
||||
mark(e.message, typeof line === 'number' ? line : null)
|
||||
} else {
|
||||
setErr(String(e))
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopy() {
|
||||
const ok = await copyToClipboard(view.current?.state.doc.toString() ?? text)
|
||||
setNote(ok ? 'copied' : 'copyFailed')
|
||||
}
|
||||
|
||||
const ns = 'settings.advanced'
|
||||
|
||||
return (
|
||||
<div style={{ ...panelStyle, padding: '24px 26px', maxWidth: 640, marginTop: 18 }}>
|
||||
<span style={{ display: 'block', fontSize: 16, fontWeight: 700, marginBottom: 5 }}>
|
||||
{t(`${ns}.yamlTitle`)}
|
||||
</span>
|
||||
<span style={{ display: 'block', fontSize: 13, color: c.textDim, lineHeight: 1.5, marginBottom: 16 }}>
|
||||
{t(`${ns}.yamlSubtitle`)}
|
||||
</span>
|
||||
|
||||
{loadErr && <div style={{ fontSize: 12, color: c.red }}>{loadErr}</div>}
|
||||
{saved === null && !loadErr && <Spinner />}
|
||||
|
||||
<div
|
||||
ref={host}
|
||||
style={{
|
||||
display: saved === null ? 'none' : 'block',
|
||||
background: c.inputBg,
|
||||
border: `1px solid ${c.inputBorder}`,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ fontSize: 11, color: c.textFaint, lineHeight: 1.5, marginTop: 8 }}>
|
||||
{t(`${ns}.yamlHint`)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
|
||||
<button
|
||||
onClick={() => void onApply()}
|
||||
disabled={!dirty || busy}
|
||||
style={{
|
||||
...primaryBtn,
|
||||
padding: '10px 24px',
|
||||
background: dirty ? c.accent : '#1d232b',
|
||||
color: dirty ? c.accentInk : c.textMuted,
|
||||
border: dirty ? 'none' : '1px solid #262d35',
|
||||
cursor: dirty && !busy ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
>
|
||||
{t(`${ns}.yamlApply`)}
|
||||
</button>
|
||||
<button onClick={() => void onCopy()} style={{ ...ghostBtn, padding: '10px 18px' }}>
|
||||
{t(`${ns}.copy`)}
|
||||
</button>
|
||||
{note === 'saved' && !dirty && (
|
||||
<span style={{ fontSize: 12, color: c.green }}>{t(`${ns}.yamlSaved`)}</span>
|
||||
)}
|
||||
{note === 'copied' && <span style={{ fontSize: 12, color: c.green }}>{t(`${ns}.copied`)}</span>}
|
||||
{note === 'copyFailed' && (
|
||||
<span style={{ fontSize: 12, color: c.red }}>{t(`${ns}.copyFailed`)}</span>
|
||||
)}
|
||||
</div>
|
||||
{err && (
|
||||
<pre
|
||||
role="alert"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: c.red,
|
||||
fontFamily: mono,
|
||||
whiteSpace: 'pre-wrap',
|
||||
margin: '10px 0 0',
|
||||
}}
|
||||
>
|
||||
{err}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default YamlEditor
|
||||
@@ -23,7 +23,7 @@ function ShellInner() {
|
||||
const { guard } = useUnsavedGuard()
|
||||
const [view, setView] = useState<View>('main')
|
||||
const [settingsTab, setSettingsTab] = useState<Tab>('localization')
|
||||
const [version, setVersion] = useState('')
|
||||
const [upd, setUpd] = useState<Awaited<ReturnType<typeof api.update>> | null>(null)
|
||||
|
||||
const openSettings = (tab: Tab) => {
|
||||
setSettingsTab(tab)
|
||||
@@ -35,13 +35,13 @@ function ShellInner() {
|
||||
// already in Settings configuring it.
|
||||
const notConfigured = view === 'main' && !!config && !isConfigured(config)
|
||||
|
||||
// Version is static per deploy — fetch the backend's once for the footer.
|
||||
// The running version for the footer, plus the newer-release badge when the user opted
|
||||
// into the update check (the backend caches it; disabled => no outbound call at all).
|
||||
// Re-runs when the toggle flips so the badge appears without a reload.
|
||||
const updateCheck = config?.app.update_check
|
||||
useEffect(() => {
|
||||
api
|
||||
.health()
|
||||
.then((h) => setVersion(h.version))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
api.update().then(setUpd).catch(() => {})
|
||||
}, [updateCheck])
|
||||
|
||||
return (
|
||||
<div className="jn-shell">
|
||||
@@ -126,7 +126,20 @@ function ShellInner() {
|
||||
color: c.textFaint,
|
||||
}}
|
||||
>
|
||||
Joulenap{version && ` v${version}`}
|
||||
Joulenap{upd && ` v${upd.current}`}
|
||||
{upd?.update_available && (
|
||||
<>
|
||||
{' · '}
|
||||
<a
|
||||
href={upd.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: c.accent, fontWeight: 600 }}
|
||||
>
|
||||
{t('settings.updates.available', { version: upd.latest.replace(/^v/, '') })}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user