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:
Catubba
2026-07-22 19:32:45 +02:00
committed by GitHub
25 changed files with 1294 additions and 26 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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"
+2
View File
@@ -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"]
+78 -1
View File
@@ -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."""
+77
View File
@@ -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__),
)
+3
View File
@@ -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 -1
View File
@@ -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"
+93
View File
@@ -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
+95
View File
@@ -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
+4
View File
@@ -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
+3
View File
@@ -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 |
+153 -2
View File
@@ -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"
}
}
}
+8 -1
View File
@@ -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",
+25 -3
View File
@@ -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'),
+2
View File
@@ -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
View File
@@ -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,
+50 -1
View File
@@ -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."
}
}
}
+50 -1
View File
@@ -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."
}
}
}
+18 -3
View File
@@ -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>
)
+250
View File
@@ -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()
+216
View File
@@ -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
+21 -8
View File
@@ -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>