diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e01dd8..af3cf23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,32 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`. ## [Unreleased] +## [0.3.0] – 2026-10-11 + +### Hinzugefügt +- **Anleitung** (`docs/ANLEITUNG.md`) mit Screenshots, zugleich in der Oberfläche + unter *Hilfe*; „Hilfe zu dieser Seite“ in der Kopfzeile und „?“-Links an den + Formularabschnitten führen direkt zum passenden Abschnitt. +- **Roadmap** (`ROADMAP.md`). +- **Uptime Kuma:** `/healthz` mit Version, Scheduler-Herzschlag und Einzelprüfungen; + Einzel-Endpunkte `/healthz/mount/` und `/healthz/rule/` (200/503/404); + Push an einen Kuma-Push-Monitor jede Minute (*Einstellungen → Überwachung*, URL + maskiert, Test-Knopf, fertige Adressen für HTTP-Monitore). + +### Geändert +- **Menü neu aufgebaut:** Seitenleiste mit Gruppen in der Reihenfolge der + Einrichtung (Übersicht · Verbindungen · Automatisierung · System), Symbole, + Zähler (z. B. „2 gestört“, „1/2 aktiv“), Statusampel in der Kopfzeile; auf dem + Handy ausklappbares Menü. +- SFTP-Formular zeigt nur noch passende Felder und Hinweise (kein Passwortfeld bei + Schlüssel-Anmeldung, keine SMB-Hinweise); kompaktere Schlüsselseite. +- Gesundheit: Eine aktive Regel mit drei fehlgeschlagenen Läufen in Folge gilt jetzt + ebenfalls als Problem (nicht nur „zu lange kein Erfolg“). + +### Behoben +- `aria-current` im Menü und der gesperrte Löschknopf auf der Schlüsselseite + wurden von Jinja maskiert (`="`) und wirkten nicht; Test prüft alle Seiten. + ## [0.2.1] – 2026-10-11 ### Behoben @@ -165,7 +191,8 @@ Erste Version. - Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt. -[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.1...HEAD +[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.3.0...HEAD +[0.3.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.1...v0.3.0 [0.2.1]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.0...v0.2.1 [0.2.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.1...v0.2.0 [0.1.1]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.0...v0.1.1 diff --git a/DEVLOG.md b/DEVLOG.md index 46861fb..95a61a8 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -16,10 +16,39 @@ Zeiten in Ortszeit (Europe/Berlin). | 2026-10-10 | 21:36 | 22:05 | 0:29 | – | Praxistest auf VM, Release 0.1.1 | | 2026-10-10 | 22:45 | 23:00 | 0:15 | – | Echtes NAS/Paperless anbinden | | 2026-10-10 | 23:31 | 23:58 | 0:27 | – | Schlüsselverwaltung, Update-Skript, 0.2.x | -| **Summe** | | | **2:17** | | | +| 2026-10-11 | 00:13 | 00:32 | 0:19 | – | Menü, Anleitung, Roadmap, Uptime Kuma (0.3.0) | +| **Summe** | | | **2:36** | | | --- +## 2026-10-11 · 00:13–00:32 · Menü, Anleitung, Roadmap, Uptime Kuma (0.3.0) + +**Erledigt** +- Seitenleiste mit Gruppen, Symbolen (Inline-SVG), Zählern und Statusampel; + Handy-Menü per Knopf (ohne JS sichtbar). +- `docs/ANLEITUNG.md` (für Anwender) mit Sprungmarken; Hilfe-Seite rendert sie per + Python-Markdown, das Paket liefert `docs/` mit (hatch force-include). +- `ROADMAP.md`; Nutzerwünsche eingeplant: SFTP als Quelle, gespeicherte Zugangsdaten, + automatisches Pausieren/Fortsetzen, E-Mail (0.4.0). +- Uptime Kuma: ausführliches `/healthz`, Einzel-Endpunkte, Push-Job. +- 13 neue Tests (278 gesamt). + +**Wie geprüft** +- Erstmals echte Screenshots mit Playwright (Chromium, Scratchpad-venv) gegen eine + Demo-Instanz (Fake-Backend), Desktop 1366 px und Handy 390 px, hell und dunkel. + Daraus gefunden und behoben: überflüssiges Passwortfeld/SMB-Hinweise bei SFTP, + sperriges Umbenennen auf der Schlüsselseite, Tabellen in der Hilfe ragten auf + dem Handy über den Rand. Dieselben Screenshots sind jetzt in der Anleitung + (128-Farben-PNG, zusammen ~150 KB; Demo-Pfad per DOM durch `/mnt/netmover` ersetzt). +- Ein echter Fehler, den erst der Blick auf das HTML zeigte: `{{ 'attr="x"' if … }}` + wird von Jinja maskiert → `aria-current` und `disabled title` wirkten nicht. + +**Entscheidungen** +- Automatisches Pausieren: kein Deaktivieren (müsste nach jedem NAS-Neustart von + Hand rückgängig gemacht werden), sondern „wartet auf Anbindung“ mit + automatischem Fortsetzen – für 0.4.0 vorgeschlagen. +- Regel mit 3 Fehlschlägen in Folge zählt jetzt als Problem in `/healthz`. + ## 2026-10-10 · 23:31–23:58 · Schlüsselverwaltung, Update-Skript, Releases 0.2.0/0.2.1 **Erledigt** diff --git a/README.md b/README.md index 12913b0..3aa0255 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,15 @@ unabhängig voneinander per SMB, NFS, lokal oder (nur als Ziel) per SFTP angebun Typischer Einsatz: Ein Synology-NAS nimmt Scans entgegen, NetMover schiebt sie alle 30 Sekunden in den Eingangsordner von Paperless-ngx. +![Statusseite](docs/img/status.png) + +| Dokument | Inhalt | +|---|---| +| **[Anleitung](docs/ANLEITUNG.md)** | Bedienung: Anbindungen, Schlüssel, Regeln, Status, Protokoll, Uptime Kuma, Beispiele, Fragen – auch in der Oberfläche unter *Hilfe* | +| **[Roadmap](ROADMAP.md)** | Was in Arbeit und geplant ist | +| [Changelog](CHANGELOG.md) | Änderungen je Version | +| dieses README | Installation, Update, Betrieb, Fehlersuche, Sicherheit | + - [Wie NetMover Datenverlust verhindert](#wie-netmover-datenverlust-verhindert) - [Installation](#installation) · [Erster Start](#erster-start) - [Beispiel: Synology → Paperless](#beispiel-synology--paperless) @@ -340,8 +349,11 @@ Webhook-Vorlagen (Platzhalter `$event $title $message $rule $time $host`): | Gotify | `https://gotify.lan/message?token=…` | `application/json` | `{"title": "$title", "message": "$message", "priority": 5}` | | eigener Empfänger | beliebig | `application/json` | Standardvorlage | -`GET /healthz` liefert `200` oder `503` mit der Liste der Probleme (ohne Anmeldung), -z. B. für Uptime Kuma. +**Überwachung:** `GET /healthz` liefert ohne Anmeldung `200` oder `503` mit allen +Einzelprüfungen; `/healthz/mount/` und `/healthz/rule/` prüfen eine +einzelne Anbindung oder Regel. Alternativ meldet NetMover jede Minute an einen +Uptime-Kuma-Push-Monitor (*Einstellungen → Überwachung*). Details: +[Anleitung → Uptime Kuma](docs/ANLEITUNG.md#uptime-kuma). ## Fehlersuche @@ -418,6 +430,8 @@ Aufbau: | `mover/` | Scan, Stabilität, atomares Schreiben, Kollisionen, Buchführung, Regellauf | | `runs.py`, `scheduler.py`, `health.py`, `notify.py` | Protokoll, Zeitsteuerung, Gesundheit, Webhook | | `admin.py`, `keys.py`, `transfer.py`, `web/` | Formulare, Schlüsselverwaltung, Export/Import, Oberfläche | +| `health.py`, `kuma.py` | Gesundheitsprüfung, Uptime-Kuma-Push | +| `docs/` | Anleitung und Bilder; wird als Hilfe-Seite mit ausgeliefert | Die gesamte Mover-Logik ist mit dem `FakeMountBackend` ohne root testbar. SFTP wird gegen einen echten SFTP-Server im Testprozess getestet. diff --git a/compose.yml b/compose.yml index e74a2e1..7688396 100644 --- a/compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ services: netmover: build: . - image: netmover:0.2.1 + image: netmover:0.3.0 container_name: netmover restart: unless-stopped cap_add: diff --git a/src/netmover/__init__.py b/src/netmover/__init__.py index 7b860e0..b2455a9 100644 --- a/src/netmover/__init__.py +++ b/src/netmover/__init__.py @@ -1,3 +1,3 @@ """NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben.""" -__version__ = "0.2.1" +__version__ = "0.3.0" diff --git a/src/netmover/web/app.py b/src/netmover/web/app.py index b329049..5ae04d1 100644 --- a/src/netmover/web/app.py +++ b/src/netmover/web/app.py @@ -12,15 +12,19 @@ import asyncio import dataclasses import json import math +import re from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager from dataclasses import dataclass from datetime import UTC, datetime +from functools import lru_cache from importlib.resources import files +from pathlib import Path from typing import Any +import markdown from fastapi import APIRouter, Depends, FastAPI, Request -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlalchemy import func, or_, select @@ -46,9 +50,11 @@ from netmover.app_state import NetMover from netmover.config import Settings, get_settings from netmover.db import session_scope from netmover.keys import delete_key, generate_key, import_key, list_keys, rename_key +from netmover.kuma import push as kuma_push from netmover.logging_setup import event, get_logger from netmover.models import ( CollisionPolicy, + Credential, CredentialKind, Mount, MountState, @@ -159,8 +165,59 @@ def flash(request: Request, message: str, kind: str = "ok") -> None: request.session["flash"] = [*request.session.get("flash", []), [kind, message]] +# Hilfe-Abschnitt passend zur Seite (Link „Hilfe zu dieser Seite“) +HELP_ANCHORS = { + "status": "status", + "mounts": "anbindungen", + "keys": "schluessel", + "rules": "regeln", + "log": "protokoll", + "settings": "einstellungen", +} + + +@dataclass +class NavState: + healthy: bool + problems: int + mounts: int + mounts_bad: int + keys: int + rules: int + rules_active: int + + +def _nav_state(request: Request) -> NavState | None: + if not auth.is_logged_in(request): + return None + state = nm(request) + health = state.health() + with state.session_factory() as s: + mounts = s.execute(select(Mount.state, Mount.enabled)).all() + rules = s.execute(select(Rule.enabled)).all() + keys = s.scalar( + select(func.count()).select_from(Credential).where(Credential.managed.is_(True)) + ) + return NavState( + healthy=health.ok, + problems=len(health.problems), + mounts=len(mounts), + mounts_bad=sum( + 1 for st, en in mounts if en and st in (MountState.ERROR, MountState.DEGRADED) + ), + keys=keys or 0, + rules=len(rules), + rules_active=sum(1 for (en,) in rules if en), + ) + + def render(request: Request, name: str, status_code: int = 200, **ctx: Any) -> HTMLResponse: - flashes = request.session.pop("flash", []) if not name.startswith("partials/") else [] + partial = name.startswith("partials/") + flashes = request.session.pop("flash", []) if not partial else [] + active = ctx.get("active", "") + if not partial: + ctx.setdefault("nav", _nav_state(request)) + ctx.setdefault("help_anchor", HELP_ANCHORS.get(active)) return TEMPLATES.TemplateResponse( request, name, @@ -209,11 +266,45 @@ public = APIRouter() @public.get("/healthz") async def healthz(request: Request) -> JSONResponse: - health = await run_in_threadpool(nm(request).health) - body = {"status": "ok" if health.ok else "error", "problems": health.problems} + """Für Uptime Kuma & Co.: 200 = alles in Ordnung, 503 = mindestens ein Problem. + Ohne Anmeldung erreichbar; enthält Namen und Fehlermeldungen, keine Geheimnisse.""" + state = nm(request) + health = await run_in_threadpool(state.health) + beat = state.scheduler.last_heartbeat + body = { + "status": "ok" if health.ok else "error", + "version": __version__, + "problems": health.problems, + "scheduler": { + "running": state.scheduler.running, + "last_heartbeat": beat.isoformat() if beat else None, + }, + "checks": [c.as_dict() for c in health.checks], + } return JSONResponse(body, status_code=200 if health.ok else 503) +async def _single_check(request: Request, kind: str, **key: Any) -> JSONResponse: + health = await run_in_threadpool(nm(request).health) + check = health.find(kind, **key) + if check is None: + return JSONResponse({"status": "unknown", "message": "nicht gefunden"}, status_code=404) + body = {"status": "ok" if check.ok else "error", "name": check.name, "message": check.message} + return JSONResponse(body, status_code=200 if check.ok else 503) + + +@public.get("/healthz/mount/{slug}") +async def healthz_mount(request: Request, slug: str) -> JSONResponse: + """Eigener Kuma-Monitor pro Anbindung.""" + return await _single_check(request, "mount", slug=slug) + + +@public.get("/healthz/rule/{rule_id}") +async def healthz_rule(request: Request, rule_id: int) -> JSONResponse: + """Eigener Kuma-Monitor pro Regel.""" + return await _single_check(request, "rule", id=rule_id) + + @public.get("/setup", response_class=HTMLResponse) async def setup_form(request: Request) -> Response: if auth.password_is_set(nm(request).session_factory): @@ -425,8 +516,7 @@ def _key_options(request: Request) -> list[tuple[str, str]]: with nm(request).session_factory() as s: keys = list_keys(s) return [("new", "neuer Schlüssel nur für diese Anbindung")] + [ - (str(k.id), f"{k.name} ({k.key_type.removeprefix('ssh-')}, {k.fingerprint[7:19]}…)") - for k in keys + (str(k.id), f"{k.name} ({k.key_type.removeprefix('ssh-')})") for k in keys ] @@ -817,13 +907,73 @@ async def log_items(request: Request, run_id: int) -> Response: return render(request, "partials/run_items.html", items=items, run=run) +# -- Hilfe -------------------------------------------------------------------------------------- + +REPO_URL = "https://git.wilde-server.de/florian.wilde/netmover" +_IMG_NAME = re.compile(r"^[a-z0-9_-]+\.png$") + + +def _help_dir() -> Path: + """Im installierten Paket mitgeliefert (siehe pyproject), sonst docs/ im Repo.""" + packaged = Path(str(files("netmover.web") / "help")) + if (packaged / "ANLEITUNG.md").is_file(): + return packaged + return Path(__file__).resolve().parents[3] / "docs" + + +@lru_cache(maxsize=1) +def _help_html() -> str: + text = (_help_dir() / "ANLEITUNG.md").read_text(encoding="utf-8") + html = markdown.markdown(text, extensions=["tables", "attr_list", "fenced_code"]) + html = html.replace('src="img/', 'src="/help/img/') + return html.replace( + 'href="../README.md"', f'href="{REPO_URL}#readme" target="_blank" rel="noopener"' + ) + + +@protected.get("/help", response_class=HTMLResponse) +async def help_page(request: Request) -> Response: + return render(request, "help.html", active="help", content=_help_html()) + + +@protected.get("/help/img/{name}") +async def help_image(request: Request, name: str) -> Response: + path = _help_dir() / "img" / name + if not _IMG_NAME.fullmatch(name) or not path.is_file(): + return Response(status_code=404) + return FileResponse(path, media_type="image/png") + + # -- Einstellungen ------------------------------------------------------------------------------ +def _monitor_urls(request: Request) -> list[tuple[str, str]]: + """Adressen für Uptime-Kuma-HTTP-Monitore (Gesamtzustand und Einzel-Endpunkte).""" + base = str(request.base_url).rstrip("/") + urls = [("Gesamtzustand", f"{base}/healthz")] + with nm(request).session_factory() as s: + urls += [ + (f"Anbindung „{n}“", f"{base}/healthz/mount/{slug}") + for n, slug in s.execute(select(Mount.name, Mount.slug).order_by(Mount.name)) + ] + urls += [ + (f"Regel „{n}“", f"{base}/healthz/rule/{rid}") + for rid, n in s.execute(select(Rule.id, Rule.name).order_by(Rule.name)) + ] + return urls + + @protected.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request) -> Response: cfg = load_settings(nm(request).session_factory) - return render(request, "settings.html", active="settings", cfg=cfg, errors={}) + return render( + request, + "settings.html", + active="settings", + cfg=cfg, + errors={}, + monitor_urls=_monitor_urls(request), + ) @protected.post("/settings", response_class=HTMLResponse) @@ -850,6 +1000,11 @@ async def settings_save(request: Request) -> Response: if url and not url.startswith(("http://", "https://")): errors["webhook_url"] = "URL muss mit http:// oder https:// beginnen." cfg.webhook_url = url + kuma = (form.get("kuma_push_url") or "").strip() + if kuma != auth_mask(cfg.kuma_push_url): + if kuma and not kuma.startswith(("http://", "https://")): + errors["kuma_push_url"] = "URL muss mit http:// oder https:// beginnen." + cfg.kuma_push_url = kuma cfg.notify_recovery = form.get("notify_recovery") == "on" cfg.webhook_template = form.get("webhook_template") or cfg.webhook_template cfg.webhook_content_type = (form.get("webhook_content_type") or "application/json").strip() @@ -895,6 +1050,20 @@ async def settings_webhook_test(request: Request) -> Response: return HTMLResponse('

Testnachricht gesendet.

') +@protected.post("/settings/kuma-test", response_class=HTMLResponse) +async def settings_kuma_test(request: Request) -> Response: + state = nm(request) + url = load_settings(state.session_factory).kuma_push_url + if not url: + return HTMLResponse('

Keine Push-URL gespeichert.

') + ok = await run_in_threadpool(kuma_push, url, await run_in_threadpool(state.health)) + if not ok: + return HTMLResponse('

Kuma nicht erreichbar oder URL falsch.

') + return HTMLResponse( + '

Gemeldet – in Kuma sollte der Monitor jetzt grün/rot sein.

' + ) + + @protected.post("/settings/password", response_class=HTMLResponse) async def settings_password(request: Request) -> Response: state = nm(request) diff --git a/src/netmover/web/static/app.css b/src/netmover/web/static/app.css index 1489183..b4594f1 100644 --- a/src/netmover/web/static/app.css +++ b/src/netmover/web/static/app.css @@ -19,14 +19,67 @@ a { color: var(--accent); } code, pre, .mono { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: .9em; } pre { white-space: pre-wrap; word-break: break-word; margin: 0; } -header.top { background: var(--card); border-bottom: 1px solid var(--line); } -header.top .inner { max-width: 1200px; margin: 0 auto; display: flex; align-items: center; - gap: var(--gap); padding: 8px 16px; flex-wrap: wrap; } -header.top .brand { font-weight: 700; text-decoration: none; color: var(--fg); margin-right: 8px; } -nav.main { display: flex; gap: 4px; flex-wrap: wrap; flex: 1; } -nav.main a { padding: 6px 10px; border-radius: var(--radius); text-decoration: none; color: var(--fg); } -nav.main a.active, nav.main a:hover { background: var(--bg); } -main { max-width: 1200px; margin: 0 auto; padding: 16px; } +/* --- Layout: Kopfzeile + Seitenleiste (Desktop), aufklappbares Menü (Handy) --- */ +:root { --sidebar: 228px; --topbar: 52px; } +.topbar { position: sticky; top: 0; z-index: 20; height: var(--topbar); display: flex; align-items: center; + gap: 12px; padding: 0 16px; background: var(--card); border-bottom: 1px solid var(--line); } +.topbar .brand { font-weight: 700; font-size: 1.05rem; text-decoration: none; color: var(--fg); } +.topbar .spacer { flex: 1; } +.health { display: inline-flex; align-items: center; gap: 7px; font-size: .85em; font-weight: 600; + padding: 4px 10px; border-radius: 99px; text-decoration: none; } +.health .dot { width: 9px; height: 9px; border-radius: 50%; background: currentColor; } +.health.ok { color: var(--ok); background: var(--ok-bg); } +.health.bad { color: var(--err); background: var(--err-bg); } +.help-link { display: inline-flex; align-items: center; gap: 6px; font-size: .9em; text-decoration: none; } +.menu-toggle { display: none; align-items: center; gap: 6px; } +.ico { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; + stroke-linecap: round; stroke-linejoin: round; } + +.sidebar { position: fixed; top: var(--topbar); left: 0; bottom: 0; width: var(--sidebar); overflow-y: auto; + padding: 14px 10px; background: var(--card); border-right: 1px solid var(--line); + display: flex; flex-direction: column; gap: 14px; } +.nav-title { font-size: .72em; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; + color: var(--muted); padding: 0 10px 4px; } +.sidebar a, .nav-button { display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 10px; + border-radius: var(--radius); color: var(--fg); text-decoration: none; font: inherit; + background: none; border: 0; cursor: pointer; text-align: left; } +.sidebar a:hover, .nav-button:hover { background: var(--bg); } +.sidebar a.active { background: var(--info-bg); color: var(--accent); font-weight: 600; } +.sidebar .count { margin-left: auto; font-size: .75em; color: var(--muted); background: var(--bg); + border-radius: 99px; padding: 1px 7px; white-space: nowrap; } +.sidebar .count.bad { color: var(--err); background: var(--err-bg); } +.nav-foot { margin-top: auto; border-top: 1px solid var(--line); padding-top: 10px; } +.nav-foot form { margin: 0; } +.nav-foot .version { font-size: .75em; color: var(--muted); padding: 6px 10px 0; } + +main { max-width: 1200px; margin: 0 auto; padding: 18px 20px 40px; } +body.with-nav main { margin-left: var(--sidebar); } +@media (min-width: 1430px) { body.with-nav main { margin-left: calc(var(--sidebar) + (100vw - var(--sidebar) - 1200px) / 2); } } + +@media (max-width: 900px) { + .menu-toggle { display: inline-flex; } + .help-link span { display: none; } + body.with-nav main { margin-left: 0; } + /* Ohne JavaScript bleibt das Menü sichtbar (oben, nicht fixiert). */ + .sidebar { position: static; width: auto; border-right: 0; border-bottom: 1px solid var(--line); } + .js .sidebar { position: fixed; width: min(280px, 85vw); transform: translateX(-105%); + transition: transform .18s ease; border-right: 1px solid var(--line); box-shadow: 0 8px 24px rgba(0,0,0,.18); z-index: 30; } + .js .sidebar.open { transform: none; } +} +@media (max-width: 480px) { .health { font-size: 0; padding: 6px; } .health .dot { width: 11px; height: 11px; } } + +/* --- Anleitung --- */ +.doc { max-width: 860px; line-height: 1.6; padding: 18px 26px 26px; } +.doc h1 { font-size: 1.6rem; margin-top: 4px; } +.doc h2 { font-size: 1.25rem; margin-top: 34px; padding-top: 12px; border-top: 1px solid var(--line); } +.doc h3 { font-size: 1.05rem; margin-top: 22px; } +.doc table { margin: 10px 0 14px; font-size: .93em; } +.doc img { max-width: 100%; border: 1px solid var(--line); border-radius: var(--radius); margin: 8px 0; } +.doc pre { background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 10px 12px; overflow-x: auto; } +.doc code { background: var(--bg); border-radius: 4px; padding: 0 4px; } +.doc pre code { background: none; padding: 0; } +.doc :target { scroll-margin-top: calc(var(--topbar) + 12px); } + h1 { font-size: 1.4rem; margin: 4px 0 16px; } h2 { font-size: 1.1rem; margin: 24px 0 8px; } .muted { color: var(--muted); } @@ -95,7 +148,7 @@ details > summary { cursor: pointer; } [hidden] { display: none !important; } @media (max-width: 640px) { - main { padding: 10px; } + main { padding: 12px 12px 32px; } th, td { padding: 6px; } table.responsive thead { display: none; } table.responsive tr { display: block; border-bottom: 1px solid var(--line); padding: 6px 0; } @@ -109,3 +162,16 @@ pre.text-err { margin-top: 6px; } ul.tight { margin: 4px 0 0; padding-left: 20px; } .foot { text-align: center; padding: 16px; } meter.fill { width: 100%; height: 8px; margin: 6px 0 2px; display: block; } + +.doc table { display: block; overflow-x: auto; max-width: 100%; } +.legend-help { display: inline-grid; place-items: center; width: 18px; height: 18px; margin-left: 4px; + border-radius: 50%; font-size: .75em; font-weight: 700; text-decoration: none; color: var(--muted); + border: 1px solid var(--line); vertical-align: 1px; } +.legend-help:hover { color: var(--accent); border-color: var(--accent); } +details.rename { display: inline-block; position: relative; } +details.rename > summary { list-style: none; } +details.rename > summary::-webkit-details-marker { display: none; } +details.rename[open] > form { position: absolute; right: 0; top: calc(100% + 4px); z-index: 5; width: 300px; + padding: 8px; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); + box-shadow: 0 6px 18px rgba(0,0,0,.12); flex-wrap: nowrap; } +input.w-auto { width: auto; flex: 1; min-width: 200px; max-width: 360px; } diff --git a/src/netmover/web/static/app.js b/src/netmover/web/static/app.js index 15daa15..5d76867 100644 --- a/src/netmover/web/static/app.js +++ b/src/netmover/web/static/app.js @@ -1,6 +1,27 @@ // NetMover – kleine Helfer. Keine Inline-Skripte (CSP: script-src 'self'). (function () { "use strict"; + document.documentElement.classList.add("js"); + + // Menü auf dem Handy auf-/zuklappen + var toggle = document.querySelector(".menu-toggle"); + var sidebar = document.getElementById("sidebar"); + if (toggle && sidebar) { + toggle.addEventListener("click", function (e) { + e.stopPropagation(); + var open = sidebar.classList.toggle("open"); + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + }); + document.addEventListener("click", function (e) { + if (sidebar.classList.contains("open") && !sidebar.contains(e.target)) { + sidebar.classList.remove("open"); + toggle.setAttribute("aria-expanded", "false"); + } + }); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape") { sidebar.classList.remove("open"); toggle.setAttribute("aria-expanded", "false"); } + }); + } var meta = document.querySelector('meta[name="csrf-token"]'); var csrf = meta ? meta.getAttribute("content") : ""; @@ -17,13 +38,14 @@ // Felder je nach Auswahl ein-/ausblenden: data-show-for="select-name:wert1 wert2" function refresh(form) { + // data-show-for="feld:wert1 wert2" – mehrere Bedingungen mit „|“ (ODER) form.querySelectorAll("[data-show-for]").forEach(function (el) { - var spec = el.getAttribute("data-show-for").split(":"); - var ctl = form.querySelector('[name="' + spec[0] + '"]:checked') || - form.querySelector('select[name="' + spec[0] + '"]'); - var value = ctl ? ctl.value : ""; - var show = spec[1].split(" ").indexOf(value) !== -1; - el.hidden = !show; + el.hidden = !el.getAttribute("data-show-for").split("|").some(function (cond) { + var spec = cond.split(":"); + var ctl = form.querySelector('[name="' + spec[0] + '"]:checked') || + form.querySelector('select[name="' + spec[0] + '"]'); + return spec[1].split(" ").indexOf(ctl ? ctl.value : "") !== -1; + }); }); } document.querySelectorAll("form[data-dynamic]").forEach(function (form) { diff --git a/src/netmover/web/templates/base.html b/src/netmover/web/templates/base.html index b371f0a..7863c24 100644 --- a/src/netmover/web/templates/base.html +++ b/src/netmover/web/templates/base.html @@ -1,3 +1,4 @@ +{% from "icons.html" import icon %} @@ -10,28 +11,54 @@ - + {% if logged_in %} -
+{% macro item(key, href, label, badge='', badge_cls='') -%} + + {{ icon(key) }}{{ label }}{% if badge %}{{ badge }}{% endif %} +{%- endmacro %} +
+ NetMover - -
- - -
-
+ {% if nav %} + + {{ 'Alles in Ordnung' if nav.healthy else (nav.problems ~ (' Problem' if nav.problems == 1 else ' Probleme')) }} + {% endif %} + + {% if help_anchor %}{{ icon('help') }}Hilfe zu dieser Seite{% endif %} + + {% endif %}
{% for kind, text in flashes %}

{{ text }}

{% endfor %} {% block content %}{% endblock %}
-
NetMover {{ version }}
diff --git a/src/netmover/web/templates/icons.html b/src/netmover/web/templates/icons.html new file mode 100644 index 0000000..795709f --- /dev/null +++ b/src/netmover/web/templates/icons.html @@ -0,0 +1,15 @@ +{# Symbole als Inline-SVG (keine Fremd-Ressourcen, CSP-verträglich). Pfade im Stil von Feather/Lucide. #} +{% macro icon(name) -%} + +{%- endmacro %} diff --git a/src/netmover/web/templates/keys.html b/src/netmover/web/templates/keys.html index e70e7b6..0493ebc 100644 --- a/src/netmover/web/templates/keys.html +++ b/src/netmover/web/templates/keys.html @@ -2,7 +2,7 @@ {% from "macros.html" import csrf_field %} {% block title %}Schlüssel{% endblock %} {% block content %} -

SSH-Schlüssel

+

Schlüssel

Gespeicherte Schlüssel für SFTP-Anbindungen. Ein Schlüssel kann von mehreren Anbindungen benutzt werden; den Benutzernamen legt jede Anbindung selbst fest. Private Schlüssel liegen verschlüsselt in der Datenbank und werden nie angezeigt.

@@ -15,11 +15,12 @@
{{ k.name }} {{ k.key_type.removeprefix('ssh-') }} {% if k.used_by %}benutzt von {{ k.used_by|join(', ') }}{% else %}unbenutzt{% endif %}
-
{{ csrf_field(csrf) }} - -
+
Umbenennen +
{{ csrf_field(csrf) }} + +
{{ csrf_field(csrf) }} -
+
Fingerabdruck {{ k.fingerprint }} · angelegt {{ k.created_at|localdt }}
@@ -37,7 +38,7 @@

Neuen Schlüssel erzeugen

{{ csrf_field(csrf) }} - +
diff --git a/src/netmover/web/templates/mount_form.html b/src/netmover/web/templates/mount_form.html index c66aa10..1cca88c 100644 --- a/src/netmover/web/templates/mount_form.html +++ b/src/netmover/web/templates/mount_form.html @@ -7,8 +7,8 @@
{% if mount %}{% endif %} -
Verbindung
- {{ field('name', 'Name', d.name, errors=errors, attrs='required maxlength="100"', hint=('Verzeichnis: ' ~ mount.mountpoint if mount and mount.mountpoint else 'Bestimmt das Verzeichnis unter /mnt/netmover')) }} +
Verbindung ?
+ {{ field('name', 'Name', d.name, errors=errors, attrs='required maxlength="100"', hint=('Verzeichnis: ' ~ mount.mountpoint if mount and mount.mountpoint else '')) }} {{ select('type', 'Typ', [('cifs', 'SMB/CIFS'), ('nfs4', 'NFS 4'), ('nfs3', 'NFS 3'), ('sftp', 'SFTP (nur Ziel)')], d.type.value, errors=errors) }} {{ field('server', 'Server', d.server, errors=errors, placeholder='192.168.1.10 oder nas.lan', attrs='required') }}
@@ -22,32 +22,32 @@
{{ field('port', 'SSH-Port', d.port or '', type='number', errors=errors, placeholder='22') }}
-
Anmeldung
+
Anmeldung ?
{{ select('auth', 'Anmeldeart', [('ssh_key', 'SSH-Schlüssel'), ('password', 'Passwort')], d.auth.value, errors=errors) }}
{{ select('key_id', 'Schlüssel', key_options, d.key_id, errors=errors, hint='Gespeicherte Schlüssel verwalten unter „Schlüssel“.') }}
- {{ field('username', 'Benutzername', d.username, errors=errors, attrs='autocomplete="off"', hint='Bei CIFS leer lassen für Gastzugang') }} + {{ field('username', 'Benutzername', d.username, errors=errors, attrs='autocomplete="off"') }}
{{ field('domain', 'Domäne (optional)', d.domain, errors=errors) }}
-
+
{% if errors.get('password') %}{{ errors['password'] }}{% endif %} - {{ 'Leer lassen = unverändert. ' if has_secret }}Bei SFTP mit Schlüssel leer lassen. + {{ 'Leer lassen = unverändert. ' if has_secret }}Benutzer und Passwort leer = Gastzugang.
{% if public_key %}
{{ public_key }}
{% endif %}
-
Eigentümer und Rechte (CIFS)
+
Eigentümer und Rechte (CIFS) ?
{{ field('uid', 'uid', d.uid, type='number', errors=errors, hint='Eigentümer aller Dateien im Mount') }} {{ field('gid', 'gid', d.gid, type='number', errors=errors) }} {{ field('file_mode', 'file_mode', d.file_mode, errors=errors, placeholder='0644') }} {{ field('dir_mode', 'dir_mode', d.dir_mode, errors=errors, placeholder='0755') }}
-
Erweitert
+
Erweitert ?
{{ field('extra_options', 'Zusatzoptionen', d.extra_options, errors=errors, wide=true, placeholder='vers=3.0,noserverino', hint='Kommagetrennt; nur erlaubte Optionen. Zugangsdaten setzt NetMover selbst.') }} {{ check('auto_mount', 'Beim Start automatisch einhängen', d.auto_mount) }}
@@ -55,7 +55,7 @@
- Abbrechen
diff --git a/src/netmover/web/templates/rule_form.html b/src/netmover/web/templates/rule_form.html index eece0cd..06e358e 100644 --- a/src/netmover/web/templates/rule_form.html +++ b/src/netmover/web/templates/rule_form.html @@ -7,14 +7,14 @@ {% if not src_mounts %}

Zuerst eine SMB- oder NFS-Anbindung als Quelle anlegen.

{% endif %} -
Allgemein
+
Allgemein ?
{{ field('name', 'Name', d.name, errors=errors, attrs='required maxlength="100"') }} {{ field('interval_seconds', 'Intervall (Sekunden)', d.interval_seconds, type='number', errors=errors, attrs='min="10" required', hint='mindestens 10') }} {{ check('enabled', 'Aktiv', d.enabled) }} {{ check('dry_run', 'Nur Probelauf (nichts verändern)', d.dry_run) }}
-
Quelle
+
Quelle ?
{{ select('src_mount_id', 'Anbindung', src_options, d.src_mount_id or '', errors=errors) }} {{ field('src_subpath', 'Unterordner', d.src_subpath, errors=errors, placeholder='z. B. Scans/Max', hint='relativ zur Freigabe; leer = Wurzel') }} {{ select('pattern_type', 'Filterart', [('glob', 'Glob'), ('regex', 'Regulärer Ausdruck')], d.pattern_type.value, errors=errors) }} @@ -22,7 +22,7 @@ {{ check('recursive', 'Unterordner einbeziehen (Struktur bleibt erhalten)', d.recursive) }}
-
Ziel
+
Ziel ?
{{ select('dst_kind', 'Zielart', [('mount', 'Anbindung'), ('local', 'Lokaler Pfad')], d.dst_kind, errors=errors) }}
{{ select('dst_mount_id', 'Anbindung', dst_options, d.dst_mount_id or '', errors=errors) }}
{{ field('dst_path', 'Zielordner', d.dst_path, errors=errors, hint='Anbindung: relativer Unterordner (wird angelegt). Lokal: absoluter Pfad, muss existieren.') }} @@ -30,14 +30,14 @@ {{ select('on_collision', 'Wenn Datei existiert', [('rename', 'umbenennen (scan-1.pdf)'), ('skip', 'überspringen'), ('overwrite', 'überschreiben')], d.on_collision.value, errors=errors) }}
-
Sicherheit und Grenzen
+
Sicherheit und Grenzen ?
{{ field('stability_seconds', 'Stabilitätsfenster (s)', d.stability_seconds, type='number', errors=errors, attrs='min="0"', hint='Größe und Zeit müssen so lange gleich bleiben') }} {{ field('min_age_seconds', 'Mindestalter (s)', d.min_age_seconds, type='number', errors=errors, attrs='min="0"') }} {{ field('max_size_mb', 'Max. Größe (MB)', d.max_size_mb or '', type='number', errors=errors, hint='leer = unbegrenzt') }} {{ field('op_timeout_seconds', 'Zeitlimit ohne Fortschritt (s)', d.op_timeout_seconds, type='number', errors=errors, attrs='min="10"') }}
-
Rechte und Quarantäne
+
Rechte und Quarantäne ?
{{ field('set_owner', 'Eigentümer setzen', d.set_owner, errors=errors, placeholder='1000:1000', hint='leer = unverändert; auf CIFS wirkungslos') }} {{ field('set_mode', 'Modus setzen', d.set_mode, errors=errors, placeholder='0644', hint='leer = wie Quelle') }} {{ field('quarantine_path', 'Quarantäneordner', d.quarantine_path, errors=errors, placeholder='_quarantaene', hint='relativ zur Quell-Anbindung; leer = keine Quarantäne') }} diff --git a/src/netmover/web/templates/settings.html b/src/netmover/web/templates/settings.html index cab8785..e4ba62f 100644 --- a/src/netmover/web/templates/settings.html +++ b/src/netmover/web/templates/settings.html @@ -8,7 +8,7 @@
Protokoll
{{ field('retention_days', 'Aufbewahrung (Tage)', cfg.retention_days, type='number', errors=errors, attrs='min="1" max="3650"') }}
-
Benachrichtigung (Webhook)
+
Benachrichtigung (Webhook) ?
{{ select('notify_mode', 'Benachrichtigen', [('off', 'nie'), ('every', 'bei jedem fehlgeschlagenen Lauf'), ('after_n', 'nach N Fehlschlägen in Folge')], cfg.notify_mode.value, errors=errors) }}
{{ field('notify_after', 'N', cfg.notify_after, type='number', errors=errors, attrs='min="1"') }}
{{ field('webhook_url', 'Webhook-URL', mask_url(cfg.webhook_url), errors=errors, wide=true, placeholder='https://ntfy.sh/mein-topic', hint='Wird nur gekürzt angezeigt; unverändert lassen = behalten, leeren = entfernen.') }} @@ -20,6 +20,18 @@
+
Überwachung (Uptime Kuma) ? +
+ {{ field('kuma_push_url', 'Push-URL (Kuma-Monitor vom Typ „Push“)', mask_url(cfg.kuma_push_url), errors=errors, wide=true, placeholder='https://kuma.lan/api/push/AbCdEf?status=up&msg=OK&ping=', hint='NetMover meldet jede Minute den Zustand. Leer = aus. Wird nur gekürzt angezeigt.') }} +
+
+
Adressen für HTTP-Monitore (Kuma fragt NetMover ab) + + {% for label, url in monitor_urls %}{% endfor %} +
{{ label }}{{ url }}
+

200 = in Ordnung, 503 = Problem. Ohne Anmeldung erreichbar.

+
+
diff --git a/tests/conftest.py b/tests/conftest.py index b44a359..630ce11 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,13 +5,18 @@ from pathlib import Path import pytest from cryptography.fernet import Fernet +from fastapi.testclient import TestClient from sqlalchemy.orm import Session, sessionmaker +from netmover.app_state import NetMover +from netmover.config import Settings from netmover.crypto import SecretBox from netmover.db import make_engine, make_session_factory, session_scope, upgrade from netmover.models import Credential, CredentialKind, Mount, MountType from netmover.mounting.fake import FakeMountBackend from netmover.mounting.manager import MountManager +from netmover.web.app import create_app +from tests.webutil import do_setup @pytest.fixture @@ -124,3 +129,40 @@ def make_mount( return m.id return factory + + +# -- Weboberfläche ----------------------------------------------------------------------------- + + +@pytest.fixture +def settings(tmp_path: Path) -> Settings: + return Settings( + data_dir=tmp_path / "lib", + run_dir=tmp_path / "run", + mount_base=tmp_path / "mnt", + mount_backend="fake", + ) + + +@pytest.fixture +def backend(tmp_path: Path) -> Iterator[FakeMountBackend]: + b = FakeMountBackend(tmp_path / "fake") + yield b + b.release_hangs() + + +@pytest.fixture +def state(settings: Settings, backend: FakeMountBackend) -> NetMover: + return NetMover(settings, backend=backend) + + +@pytest.fixture +def raw_client(settings: Settings, state: NetMover) -> Iterator[TestClient]: + with TestClient(create_app(settings, state), follow_redirects=False) as c: + yield c + + +@pytest.fixture +def client(raw_client: TestClient) -> TestClient: + raw_client.headers["X-Test-CSRF"] = do_setup(raw_client) + return raw_client diff --git a/tests/test_web.py b/tests/test_web.py index b7716ff..0fb431d 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -4,7 +4,6 @@ from __future__ import annotations import json import re -from collections.abc import Iterator from pathlib import Path import pytest @@ -17,66 +16,7 @@ from netmover.db import session_scope from netmover.models import Credential, Mount, MountState, Rule, Run, RunStatus from netmover.mounting.fake import FakeMountBackend from netmover.web.app import create_app - -PASSWORD = "sehr-geheim-123" - - -@pytest.fixture -def settings(tmp_path: Path) -> Settings: - return Settings( - data_dir=tmp_path / "lib", - run_dir=tmp_path / "run", - mount_base=tmp_path / "mnt", - mount_backend="fake", - ) - - -@pytest.fixture -def backend(tmp_path: Path) -> Iterator[FakeMountBackend]: - b = FakeMountBackend(tmp_path / "fake") - yield b - b.release_hangs() - - -@pytest.fixture -def state(settings: Settings, backend: FakeMountBackend) -> NetMover: - return NetMover(settings, backend=backend) - - -@pytest.fixture -def raw_client(settings: Settings, state: NetMover) -> Iterator[TestClient]: - with TestClient(create_app(settings, state), follow_redirects=False) as c: - yield c - - -def csrf_of(html: str) -> str: - m = re.search(r'name="csrf-token" content="([^"]+)"', html) - assert m, "kein CSRF-Token in der Seite" - return m.group(1) - - -def do_setup(c: TestClient) -> str: - page = c.get("/setup") - token = csrf_of(page.text) - code = c.app.state.setup_code.code # type: ignore[attr-defined] - r = c.post( - "/setup", - data={"csrf_token": token, "code": code, "password": PASSWORD, "confirm": PASSWORD}, - ) - assert r.status_code == 303, r.text - return csrf_of(c.get("/").text) - - -@pytest.fixture -def client(raw_client: TestClient) -> TestClient: - raw_client.headers["X-Test-CSRF"] = do_setup(raw_client) - return raw_client - - -def post(c: TestClient, url: str, data: dict[str, str] | None = None, **kw: object): # type: ignore[no-untyped-def] - payload = {"csrf_token": c.headers["X-Test-CSRF"], **(data or {})} - return c.post(url, data=payload, **kw) # type: ignore[arg-type] - +from tests.webutil import PASSWORD, csrf_of, do_setup, post # -- Einrichtung und Anmeldung -------------------------------------------------------------- @@ -621,3 +561,23 @@ def test_keys_page_generate_import_select_delete(client: TestClient, state: NetM def test_key_import_error_is_shown(client: TestClient) -> None: post(client, "/keys/import", {"name": "x", "private_key": "kein schlüssel", "passphrase": ""}) assert "Kein lesbarer privater Schlüssel" in client.get("/keys").text + + +def test_no_escaped_attributes_in_pages(client: TestClient, state: NetMover) -> None: + """Attribute in {{ … }} werden von Jinja maskiert – dann landet " im HTML.""" + + post(client, "/keys/generate", {"name": "k"}) + for url in ( + "/", + "/mounts", + "/mounts/new", + "/keys", + "/rules", + "/rules/new", + "/log", + "/settings", + "/help", + ): + html = client.get(url).text + broken = re.findall(r"<[a-z][^<>]*="[^<>]*>", html) # nur in Tags, nicht im Text + assert not broken, (url, broken[:2]) diff --git a/tests/webutil.py b/tests/webutil.py new file mode 100644 index 0000000..5c54d2e --- /dev/null +++ b/tests/webutil.py @@ -0,0 +1,33 @@ +"""Hilfsfunktionen für Tests der Weboberfläche.""" + +from __future__ import annotations + +import re +from typing import Any + +from fastapi.testclient import TestClient + +PASSWORD = "sehr-geheim-123" + + +def csrf_of(html: str) -> str: + m = re.search(r'name="csrf-token" content="([^"]+)"', html) + assert m, "kein CSRF-Token in der Seite" + return m.group(1) + + +def do_setup(c: TestClient) -> str: + page = c.get("/setup") + token = csrf_of(page.text) + code = c.app.state.setup_code.code # type: ignore[attr-defined] + r = c.post( + "/setup", + data={"csrf_token": token, "code": code, "password": PASSWORD, "confirm": PASSWORD}, + ) + assert r.status_code == 303, r.text + return csrf_of(c.get("/").text) + + +def post(c: TestClient, url: str, data: dict[str, str] | None = None, **kw: Any) -> Any: + payload = {"csrf_token": c.headers["X-Test-CSRF"], **(data or {})} + return c.post(url, data=payload, **kw)