feat(web): neues Menü mit Gruppen, Statusampel, Handy-Menü und Hilfe-Links

Formulare zeigen nur passende Felder; Jinja-maskierte Attribute behoben;
Web-Fixtures nach conftest.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-11 00:28:59 +02:00
co-authored by Claude Opus 5.5
parent a59ce46933
commit 88f5ad3a38
17 changed files with 543 additions and 126 deletions
+28 -1
View File
@@ -8,6 +8,32 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`.
## [Unreleased] ## [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/<name>` und `/healthz/rule/<nummer>` (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 (`=&#34;`) und wirkten nicht; Test prüft alle Seiten.
## [0.2.1] – 2026-10-11 ## [0.2.1] – 2026-10-11
### Behoben ### Behoben
@@ -165,7 +191,8 @@ Erste Version.
- Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den - Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den
Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt. 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.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.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 [0.1.1]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.1.0...v0.1.1
+30 -1
View File
@@ -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 | 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 | 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 | | 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 ## 2026-10-10 · 23:31–23:58 · Schlüsselverwaltung, Update-Skript, Releases 0.2.0/0.2.1
**Erledigt** **Erledigt**
+16 -2
View File
@@ -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 Typischer Einsatz: Ein Synology-NAS nimmt Scans entgegen, NetMover schiebt sie alle
30 Sekunden in den Eingangsordner von Paperless-ngx. 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) - [Wie NetMover Datenverlust verhindert](#wie-netmover-datenverlust-verhindert)
- [Installation](#installation) · [Erster Start](#erster-start) - [Installation](#installation) · [Erster Start](#erster-start)
- [Beispiel: Synology → Paperless](#beispiel-synology--paperless) - [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}` | | Gotify | `https://gotify.lan/message?token=…` | `application/json` | `{"title": "$title", "message": "$message", "priority": 5}` |
| eigener Empfänger | beliebig | `application/json` | Standardvorlage | | eigener Empfänger | beliebig | `application/json` | Standardvorlage |
`GET /healthz` liefert `200` oder `503` mit der Liste der Probleme (ohne Anmeldung), **Überwachung:** `GET /healthz` liefert ohne Anmeldung `200` oder `503` mit allen
z. B. für Uptime Kuma. Einzelprüfungen; `/healthz/mount/<name>` und `/healthz/rule/<nummer>` 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 ## Fehlersuche
@@ -418,6 +430,8 @@ Aufbau:
| `mover/` | Scan, Stabilität, atomares Schreiben, Kollisionen, Buchführung, Regellauf | | `mover/` | Scan, Stabilität, atomares Schreiben, Kollisionen, Buchführung, Regellauf |
| `runs.py`, `scheduler.py`, `health.py`, `notify.py` | Protokoll, Zeitsteuerung, Gesundheit, Webhook | | `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 | | `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 Die gesamte Mover-Logik ist mit dem `FakeMountBackend` ohne root testbar. SFTP
wird gegen einen echten SFTP-Server im Testprozess getestet. wird gegen einen echten SFTP-Server im Testprozess getestet.
+1 -1
View File
@@ -4,7 +4,7 @@
services: services:
netmover: netmover:
build: . build: .
image: netmover:0.2.1 image: netmover:0.3.0
container_name: netmover container_name: netmover
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
+1 -1
View File
@@ -1,3 +1,3 @@
"""NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben.""" """NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben."""
__version__ = "0.2.1" __version__ = "0.3.0"
+176 -7
View File
@@ -12,15 +12,19 @@ import asyncio
import dataclasses import dataclasses
import json import json
import math import math
import re
from collections.abc import AsyncIterator, Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from functools import lru_cache
from importlib.resources import files from importlib.resources import files
from pathlib import Path
from typing import Any from typing import Any
import markdown
from fastapi import APIRouter, Depends, FastAPI, Request 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.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import func, or_, select 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.config import Settings, get_settings
from netmover.db import session_scope from netmover.db import session_scope
from netmover.keys import delete_key, generate_key, import_key, list_keys, rename_key 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.logging_setup import event, get_logger
from netmover.models import ( from netmover.models import (
CollisionPolicy, CollisionPolicy,
Credential,
CredentialKind, CredentialKind,
Mount, Mount,
MountState, MountState,
@@ -159,8 +165,59 @@ def flash(request: Request, message: str, kind: str = "ok") -> None:
request.session["flash"] = [*request.session.get("flash", []), [kind, message]] 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: 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( return TEMPLATES.TemplateResponse(
request, request,
name, name,
@@ -209,11 +266,45 @@ public = APIRouter()
@public.get("/healthz") @public.get("/healthz")
async def healthz(request: Request) -> JSONResponse: async def healthz(request: Request) -> JSONResponse:
health = await run_in_threadpool(nm(request).health) """Für Uptime Kuma & Co.: 200 = alles in Ordnung, 503 = mindestens ein Problem.
body = {"status": "ok" if health.ok else "error", "problems": health.problems} 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) 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) @public.get("/setup", response_class=HTMLResponse)
async def setup_form(request: Request) -> Response: async def setup_form(request: Request) -> Response:
if auth.password_is_set(nm(request).session_factory): 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: with nm(request).session_factory() as s:
keys = list_keys(s) keys = list_keys(s)
return [("new", "neuer Schlüssel nur für diese Anbindung")] + [ 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]}…)") (str(k.id), f"{k.name} ({k.key_type.removeprefix('ssh-')})") for k in keys
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) 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 ------------------------------------------------------------------------------ # -- 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) @protected.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request) -> Response: async def settings_page(request: Request) -> Response:
cfg = load_settings(nm(request).session_factory) 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) @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://")): if url and not url.startswith(("http://", "https://")):
errors["webhook_url"] = "URL muss mit http:// oder https:// beginnen." errors["webhook_url"] = "URL muss mit http:// oder https:// beginnen."
cfg.webhook_url = url 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.notify_recovery = form.get("notify_recovery") == "on"
cfg.webhook_template = form.get("webhook_template") or cfg.webhook_template cfg.webhook_template = form.get("webhook_template") or cfg.webhook_template
cfg.webhook_content_type = (form.get("webhook_content_type") or "application/json").strip() 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('<p class="msg ok">Testnachricht gesendet.</p>') return HTMLResponse('<p class="msg ok">Testnachricht gesendet.</p>')
@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('<p class="msg error">Keine Push-URL gespeichert.</p>')
ok = await run_in_threadpool(kuma_push, url, await run_in_threadpool(state.health))
if not ok:
return HTMLResponse('<p class="msg error">Kuma nicht erreichbar oder URL falsch.</p>')
return HTMLResponse(
'<p class="msg ok">Gemeldet – in Kuma sollte der Monitor jetzt grün/rot sein.</p>'
)
@protected.post("/settings/password", response_class=HTMLResponse) @protected.post("/settings/password", response_class=HTMLResponse)
async def settings_password(request: Request) -> Response: async def settings_password(request: Request) -> Response:
state = nm(request) state = nm(request)
+75 -9
View File
@@ -19,14 +19,67 @@ a { color: var(--accent); }
code, pre, .mono { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: .9em; } 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; } pre { white-space: pre-wrap; word-break: break-word; margin: 0; }
header.top { background: var(--card); border-bottom: 1px solid var(--line); } /* --- Layout: Kopfzeile + Seitenleiste (Desktop), aufklappbares Menü (Handy) --- */
header.top .inner { max-width: 1200px; margin: 0 auto; display: flex; align-items: center; :root { --sidebar: 228px; --topbar: 52px; }
gap: var(--gap); padding: 8px 16px; flex-wrap: wrap; } .topbar { position: sticky; top: 0; z-index: 20; height: var(--topbar); display: flex; align-items: center;
header.top .brand { font-weight: 700; text-decoration: none; color: var(--fg); margin-right: 8px; } gap: 12px; padding: 0 16px; background: var(--card); border-bottom: 1px solid var(--line); }
nav.main { display: flex; gap: 4px; flex-wrap: wrap; flex: 1; } .topbar .brand { font-weight: 700; font-size: 1.05rem; text-decoration: none; color: var(--fg); }
nav.main a { padding: 6px 10px; border-radius: var(--radius); text-decoration: none; color: var(--fg); } .topbar .spacer { flex: 1; }
nav.main a.active, nav.main a:hover { background: var(--bg); } .health { display: inline-flex; align-items: center; gap: 7px; font-size: .85em; font-weight: 600;
main { max-width: 1200px; margin: 0 auto; padding: 16px; } 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; } h1 { font-size: 1.4rem; margin: 4px 0 16px; }
h2 { font-size: 1.1rem; margin: 24px 0 8px; } h2 { font-size: 1.1rem; margin: 24px 0 8px; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
@@ -95,7 +148,7 @@ details > summary { cursor: pointer; }
[hidden] { display: none !important; } [hidden] { display: none !important; }
@media (max-width: 640px) { @media (max-width: 640px) {
main { padding: 10px; } main { padding: 12px 12px 32px; }
th, td { padding: 6px; } th, td { padding: 6px; }
table.responsive thead { display: none; } table.responsive thead { display: none; }
table.responsive tr { display: block; border-bottom: 1px solid var(--line); padding: 6px 0; } 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; } ul.tight { margin: 4px 0 0; padding-left: 20px; }
.foot { text-align: center; padding: 16px; } .foot { text-align: center; padding: 16px; }
meter.fill { width: 100%; height: 8px; margin: 6px 0 2px; display: block; } 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; }
+28 -6
View File
@@ -1,6 +1,27 @@
// NetMover – kleine Helfer. Keine Inline-Skripte (CSP: script-src 'self'). // NetMover – kleine Helfer. Keine Inline-Skripte (CSP: script-src 'self').
(function () { (function () {
"use strict"; "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 meta = document.querySelector('meta[name="csrf-token"]');
var csrf = meta ? meta.getAttribute("content") : ""; var csrf = meta ? meta.getAttribute("content") : "";
@@ -17,13 +38,14 @@
// Felder je nach Auswahl ein-/ausblenden: data-show-for="select-name:wert1 wert2" // Felder je nach Auswahl ein-/ausblenden: data-show-for="select-name:wert1 wert2"
function refresh(form) { function refresh(form) {
// data-show-for="feld:wert1 wert2" – mehrere Bedingungen mit „|“ (ODER)
form.querySelectorAll("[data-show-for]").forEach(function (el) { form.querySelectorAll("[data-show-for]").forEach(function (el) {
var spec = el.getAttribute("data-show-for").split(":"); el.hidden = !el.getAttribute("data-show-for").split("|").some(function (cond) {
var ctl = form.querySelector('[name="' + spec[0] + '"]:checked') || var spec = cond.split(":");
form.querySelector('select[name="' + spec[0] + '"]'); var ctl = form.querySelector('[name="' + spec[0] + '"]:checked') ||
var value = ctl ? ctl.value : ""; form.querySelector('select[name="' + spec[0] + '"]');
var show = spec[1].split(" ").indexOf(value) !== -1; return spec[1].split(" ").indexOf(ctl ? ctl.value : "") !== -1;
el.hidden = !show; });
}); });
} }
document.querySelectorAll("form[data-dynamic]").forEach(function (form) { document.querySelectorAll("form[data-dynamic]").forEach(function (form) {
+43 -16
View File
@@ -1,3 +1,4 @@
{% from "icons.html" import icon %}
<!doctype html> <!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
@@ -10,28 +11,54 @@
<script src="/static/htmx.min.js?v=2.0.4" defer></script> <script src="/static/htmx.min.js?v=2.0.4" defer></script>
<script src="/static/app.js?v={{ version }}" defer></script> <script src="/static/app.js?v={{ version }}" defer></script>
</head> </head>
<body> <body class="{{ 'with-nav' if logged_in else 'no-nav' }}">
{% if logged_in %} {% if logged_in %}
<header class="top"><div class="inner"> {% macro item(key, href, label, badge='', badge_cls='') -%}
<a href="{{ href }}" class="{{ 'active' if active == key }}"{% if active == key %} aria-current="page"{% endif %}>
{{ icon(key) }}<span>{{ label }}</span>{% if badge %}<span class="count {{ badge_cls }}">{{ badge }}</span>{% endif %}</a>
{%- endmacro %}
<header class="topbar">
<button type="button" class="menu-toggle" aria-controls="sidebar" aria-expanded="false">{{ icon('menu') }}<span>Menü</span></button>
<a class="brand" href="/">NetMover</a> <a class="brand" href="/">NetMover</a>
<nav class="main"> {% if nav %}
<a href="/" class="{{ 'active' if active == 'status' }}">Status</a> <a class="health {{ 'ok' if nav.healthy else 'bad' }}" href="/" title="Gesundheitszustand">
<a href="/mounts" class="{{ 'active' if active == 'mounts' }}">Anbindungen</a> <span class="dot"></span>{{ 'Alles in Ordnung' if nav.healthy else (nav.problems ~ (' Problem' if nav.problems == 1 else ' Probleme')) }}</a>
<a href="/keys" class="{{ 'active' if active == 'keys' }}">Schlüssel</a> {% endif %}
<a href="/rules" class="{{ 'active' if active == 'rules' }}">Regeln</a> <span class="spacer"></span>
<a href="/log" class="{{ 'active' if active == 'log' }}">Protokoll</a> {% if help_anchor %}<a class="help-link" href="/help#{{ help_anchor }}">{{ icon('help') }}<span>Hilfe zu dieser Seite</span></a>{% endif %}
<a href="/settings" class="{{ 'active' if active == 'settings' }}">Einstellungen</a> </header>
</nav> <nav id="sidebar" class="sidebar" aria-label="Hauptmenü">
<form class="inline" method="post" action="/logout"> <div class="nav-group">
<input type="hidden" name="csrf_token" value="{{ csrf }}"> <div class="nav-title">Übersicht</div>
<button class="small">Abmelden</button> {{ item('status', '/', 'Status') }}
</form> </div>
</div></header> <div class="nav-group">
<div class="nav-title">Verbindungen</div>
{{ item('mounts', '/mounts', 'Anbindungen', (nav.mounts_bad ~ ' gestört') if nav and nav.mounts_bad else (nav.mounts if nav and nav.mounts else ''), 'bad' if nav and nav.mounts_bad else '') }}
{{ item('keys', '/keys', 'Schlüssel', nav.keys if nav and nav.keys else '') }}
</div>
<div class="nav-group">
<div class="nav-title">Automatisierung</div>
{{ item('rules', '/rules', 'Regeln', (nav.rules_active ~ '/' ~ nav.rules ~ ' aktiv') if nav and nav.rules else '') }}
{{ item('log', '/log', 'Protokoll') }}
</div>
<div class="nav-group">
<div class="nav-title">System</div>
{{ item('settings', '/settings', 'Einstellungen') }}
{{ item('help', '/help', 'Hilfe') }}
</div>
<div class="nav-foot">
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<button class="nav-button">{{ icon('logout') }}<span>Abmelden</span></button>
</form>
<div class="version">NetMover {{ version }}</div>
</div>
</nav>
{% endif %} {% endif %}
<main> <main>
{% for kind, text in flashes %}<p class="msg {{ kind }}">{{ text }}</p>{% endfor %} {% for kind, text in flashes %}<p class="msg {{ kind }}">{{ text }}</p>{% endfor %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<footer class="foot muted small">NetMover {{ version }}</footer>
</body> </body>
</html> </html>
+15
View File
@@ -0,0 +1,15 @@
{# Symbole als Inline-SVG (keine Fremd-Ressourcen, CSP-verträglich). Pfade im Stil von Feather/Lucide. #}
{% macro icon(name) -%}
<svg class="ico" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
{%- if name == 'status' -%}<path d="M3 12h4l3-8 4 16 3-8h4"/>
{%- elif name == 'mounts' -%}<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>
{%- elif name == 'keys' -%}<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>
{%- elif name == 'rules' -%}<path d="M4 7h13l-3-3M20 17H7l3 3"/>
{%- elif name == 'log' -%}<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
{%- elif name == 'settings' -%}<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>
{%- elif name == 'help' -%}<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.7.3-1 .9-1 1.7M12 17h.01"/>
{%- elif name == 'logout' -%}<path d="M15 4h4v16h-4M10 8l-4 4 4 4M6 12h11"/>
{%- elif name == 'menu' -%}<path d="M4 6h16M4 12h16M4 18h16"/>
{%- endif -%}
</svg>
{%- endmacro %}
+7 -6
View File
@@ -2,7 +2,7 @@
{% from "macros.html" import csrf_field %} {% from "macros.html" import csrf_field %}
{% block title %}Schlüssel{% endblock %} {% block title %}Schlüssel{% endblock %}
{% block content %} {% block content %}
<h1>SSH-Schlüssel</h1> <h1>Schlüssel</h1>
<p class="muted small">Gespeicherte Schlüssel für SFTP-Anbindungen. Ein Schlüssel kann von mehreren <p class="muted small">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 Anbindungen benutzt werden; den Benutzernamen legt jede Anbindung selbst fest. Private Schlüssel
liegen verschlüsselt in der Datenbank und werden nie angezeigt.</p> liegen verschlüsselt in der Datenbank und werden nie angezeigt.</p>
@@ -15,11 +15,12 @@
<div><strong>{{ k.name }}</strong> <span class="badge">{{ k.key_type.removeprefix('ssh-') }}</span> <div><strong>{{ k.name }}</strong> <span class="badge">{{ k.key_type.removeprefix('ssh-') }}</span>
{% if k.used_by %}<span class="badge ok">benutzt von {{ k.used_by|join(', ') }}</span>{% else %}<span class="badge">unbenutzt</span>{% endif %}</div> {% if k.used_by %}<span class="badge ok">benutzt von {{ k.used_by|join(', ') }}</span>{% else %}<span class="badge">unbenutzt</span>{% endif %}</div>
<div class="row"> <div class="row">
<form class="inline row" method="post" action="/keys/{{ k.id }}/rename">{{ csrf_field(csrf) }} <details class="rename"><summary class="btn small">Umbenennen</summary>
<input type="text" name="name" value="{{ k.name }}" aria-label="Neuer Name" required maxlength="100"> <form class="row" method="post" action="/keys/{{ k.id }}/rename">{{ csrf_field(csrf) }}
<button class="small">Umbenennen</button></form> <input type="text" name="name" value="{{ k.name }}" aria-label="Neuer Name" required maxlength="100">
<button class="small primary">Speichern</button></form></details>
<form class="inline" method="post" action="/keys/{{ k.id }}/delete" data-confirm="Schlüssel „{{ k.name }}“ löschen?">{{ csrf_field(csrf) }} <form class="inline" method="post" action="/keys/{{ k.id }}/delete" data-confirm="Schlüssel „{{ k.name }}“ löschen?">{{ csrf_field(csrf) }}
<button class="small danger" {{ 'disabled' if k.used_by }}>Löschen</button></form> <button class="small danger"{% if k.used_by %} disabled title="Wird noch benutzt"{% endif %}>Löschen</button></form>
</div> </div>
</div> </div>
<div class="small muted">Fingerabdruck <span class="mono">{{ k.fingerprint }}</span> · angelegt {{ k.created_at|localdt }}</div> <div class="small muted">Fingerabdruck <span class="mono">{{ k.fingerprint }}</span> · angelegt {{ k.created_at|localdt }}</div>
@@ -37,7 +38,7 @@
<h2>Neuen Schlüssel erzeugen</h2> <h2>Neuen Schlüssel erzeugen</h2>
<form class="form card row" method="post" action="/keys/generate">{{ csrf_field(csrf) }} <form class="form card row" method="post" action="/keys/generate">{{ csrf_field(csrf) }}
<input type="text" name="name" placeholder="Name, z. B. paperless-lxc" aria-label="Name" required maxlength="100"> <input class="w-auto" type="text" name="name" placeholder="Name, z. B. paperless-lxc" aria-label="Name" required maxlength="100">
<button class="primary">Ed25519-Schlüssel erzeugen</button> <button class="primary">Ed25519-Schlüssel erzeugen</button>
</form> </form>
+9 -9
View File
@@ -7,8 +7,8 @@
<form class="form" method="post" data-dynamic> <form class="form" method="post" data-dynamic>
<input type="hidden" name="csrf_token" value="{{ csrf }}"> <input type="hidden" name="csrf_token" value="{{ csrf }}">
{% if mount %}<input type="hidden" name="mount_id" value="{{ mount.id }}">{% endif %} {% if mount %}<input type="hidden" name="mount_id" value="{{ mount.id }}">{% endif %}
<fieldset><legend>Verbindung</legend><div class="fields"> <fieldset><legend>Verbindung <a class="legend-help" href="/help#anbindung-felder" title="Hilfe">?</a></legend><div class="fields">
{{ 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')) }} {{ 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) }} {{ 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') }} {{ field('server', 'Server', d.server, errors=errors, placeholder='192.168.1.10 oder nas.lan', attrs='required') }}
<div class="field{{ ' has-error' if errors.get('share') }}"> <div class="field{{ ' has-error' if errors.get('share') }}">
@@ -22,32 +22,32 @@
<div data-show-for="type:sftp">{{ field('port', 'SSH-Port', d.port or '', type='number', errors=errors, placeholder='22') }}</div> <div data-show-for="type:sftp">{{ field('port', 'SSH-Port', d.port or '', type='number', errors=errors, placeholder='22') }}</div>
</div></fieldset> </div></fieldset>
<fieldset data-show-for="type:cifs sftp"><legend>Anmeldung</legend><div class="fields"> <fieldset data-show-for="type:cifs sftp"><legend>Anmeldung <a class="legend-help" href="/help#anbindung-felder" title="Hilfe">?</a></legend><div class="fields">
<div data-show-for="type:sftp">{{ select('auth', 'Anmeldeart', [('ssh_key', 'SSH-Schlüssel'), ('password', 'Passwort')], d.auth.value, errors=errors) }}</div> <div data-show-for="type:sftp">{{ select('auth', 'Anmeldeart', [('ssh_key', 'SSH-Schlüssel'), ('password', 'Passwort')], d.auth.value, errors=errors) }}</div>
<div data-show-for="type:sftp"><div data-show-for="auth:ssh_key"> <div data-show-for="type:sftp"><div data-show-for="auth:ssh_key">
{{ select('key_id', 'Schlüssel', key_options, d.key_id, errors=errors, hint='Gespeicherte Schlüssel verwalten unter „Schlüssel“.') }} {{ select('key_id', 'Schlüssel', key_options, d.key_id, errors=errors, hint='Gespeicherte Schlüssel verwalten unter „Schlüssel“.') }}
</div></div> </div></div>
{{ 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"') }}
<div data-show-for="type:cifs">{{ field('domain', 'Domäne (optional)', d.domain, errors=errors) }}</div> <div data-show-for="type:cifs">{{ field('domain', 'Domäne (optional)', d.domain, errors=errors) }}</div>
<div class="field{{ ' has-error' if errors.get('password') }}" id="pw-field"> <div class="field{{ ' has-error' if errors.get('password') }}" id="pw-field" data-show-for="type:cifs|auth:password">
<label for="f-password">Passwort</label> <label for="f-password">Passwort</label>
<input id="f-password" type="password" name="password" autocomplete="new-password" placeholder="{{ '••••••' if has_secret }}"> <input id="f-password" type="password" name="password" autocomplete="new-password" placeholder="{{ '••••••' if has_secret }}">
{% if errors.get('password') %}<span class="error">{{ errors['password'] }}</span>{% endif %} {% if errors.get('password') %}<span class="error">{{ errors['password'] }}</span>{% endif %}
<span class="hint">{{ 'Leer lassen = unverändert. ' if has_secret }}Bei SFTP mit Schlüssel leer lassen.</span> <span class="hint">{{ 'Leer lassen = unverändert. ' if has_secret }}<span data-show-for="type:cifs">Benutzer und Passwort leer = Gastzugang.</span></span>
</div> </div>
</div> </div>
{% if public_key %}<div class="field wide"><label>Öffentlicher Schlüssel dieser Anbindung (in ~/.ssh/authorized_keys des Zielbenutzers eintragen)</label> {% if public_key %}<div class="field wide"><label>Öffentlicher Schlüssel dieser Anbindung (in ~/.ssh/authorized_keys des Zielbenutzers eintragen)</label>
<pre class="mono card" id="pubkey">{{ public_key }}</pre><button class="small" type="button" data-copy="pubkey">kopieren</button></div>{% endif %} <pre class="mono card" id="pubkey">{{ public_key }}</pre><button class="small" type="button" data-copy="pubkey">kopieren</button></div>{% endif %}
</fieldset> </fieldset>
<fieldset data-show-for="type:cifs"><legend>Eigentümer und Rechte (CIFS)</legend><div class="fields"> <fieldset data-show-for="type:cifs"><legend>Eigentümer und Rechte (CIFS) <a class="legend-help" href="/help#anbindung-felder" title="Hilfe">?</a></legend><div class="fields">
{{ field('uid', 'uid', d.uid, type='number', errors=errors, hint='Eigentümer aller Dateien im Mount') }} {{ 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('gid', 'gid', d.gid, type='number', errors=errors) }}
{{ field('file_mode', 'file_mode', d.file_mode, errors=errors, placeholder='0644') }} {{ field('file_mode', 'file_mode', d.file_mode, errors=errors, placeholder='0644') }}
{{ field('dir_mode', 'dir_mode', d.dir_mode, errors=errors, placeholder='0755') }} {{ field('dir_mode', 'dir_mode', d.dir_mode, errors=errors, placeholder='0755') }}
</div></fieldset> </div></fieldset>
<fieldset data-show-for="type:cifs nfs3 nfs4"><legend>Erweitert</legend><div class="fields"> <fieldset data-show-for="type:cifs nfs3 nfs4"><legend>Erweitert <a class="legend-help" href="/help#anbindung-felder" title="Hilfe">?</a></legend><div class="fields">
{{ 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.') }} {{ 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) }} {{ check('auto_mount', 'Beim Start automatisch einhängen', d.auto_mount) }}
</div></fieldset> </div></fieldset>
@@ -55,7 +55,7 @@
<div class="row"> <div class="row">
<button class="primary">Speichern</button> <button class="primary">Speichern</button>
<button type="button" hx-post="/mounts/test" hx-include="closest form" hx-target="#form-check" hx-swap="innerHTML"> <button type="button" title="Einhängen, lesen, schreiben – ohne zu speichern" hx-post="/mounts/test" hx-include="closest form" hx-target="#form-check" hx-swap="innerHTML">
Testen <span class="htmx-indicator">(läuft …)</span></button> Testen <span class="htmx-indicator">(läuft …)</span></button>
<a class="btn" href="/mounts">Abbrechen</a> <a class="btn" href="/mounts">Abbrechen</a>
</div> </div>
+5 -5
View File
@@ -7,14 +7,14 @@
{% if not src_mounts %}<p class="msg info">Zuerst eine SMB- oder NFS-Anbindung als Quelle <a href="/mounts/new">anlegen</a>.</p>{% endif %} {% if not src_mounts %}<p class="msg info">Zuerst eine SMB- oder NFS-Anbindung als Quelle <a href="/mounts/new">anlegen</a>.</p>{% endif %}
<form class="form" method="post" data-dynamic> <form class="form" method="post" data-dynamic>
<input type="hidden" name="csrf_token" value="{{ csrf }}"> <input type="hidden" name="csrf_token" value="{{ csrf }}">
<fieldset><legend>Allgemein</legend><div class="fields"> <fieldset><legend>Allgemein <a class="legend-help" href="/help#regeln" title="Hilfe">?</a></legend><div class="fields">
{{ field('name', 'Name', d.name, errors=errors, attrs='required maxlength="100"') }} {{ 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') }} {{ 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('enabled', 'Aktiv', d.enabled) }}
{{ check('dry_run', 'Nur Probelauf (nichts verändern)', d.dry_run) }} {{ check('dry_run', 'Nur Probelauf (nichts verändern)', d.dry_run) }}
</div></fieldset> </div></fieldset>
<fieldset><legend>Quelle</legend><div class="fields"> <fieldset><legend>Quelle <a class="legend-help" href="/help#regel-quelle-ziel" title="Hilfe">?</a></legend><div class="fields">
{{ select('src_mount_id', 'Anbindung', src_options, d.src_mount_id or '', errors=errors) }} {{ 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') }} {{ 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) }} {{ 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) }} {{ check('recursive', 'Unterordner einbeziehen (Struktur bleibt erhalten)', d.recursive) }}
</div></fieldset> </div></fieldset>
<fieldset><legend>Ziel</legend><div class="fields"> <fieldset><legend>Ziel <a class="legend-help" href="/help#regel-quelle-ziel" title="Hilfe">?</a></legend><div class="fields">
{{ select('dst_kind', 'Zielart', [('mount', 'Anbindung'), ('local', 'Lokaler Pfad')], d.dst_kind, errors=errors) }} {{ select('dst_kind', 'Zielart', [('mount', 'Anbindung'), ('local', 'Lokaler Pfad')], d.dst_kind, errors=errors) }}
<div data-show-for="dst_kind:mount">{{ select('dst_mount_id', 'Anbindung', dst_options, d.dst_mount_id or '', errors=errors) }}</div> <div data-show-for="dst_kind:mount">{{ select('dst_mount_id', 'Anbindung', dst_options, d.dst_mount_id or '', errors=errors) }}</div>
{{ field('dst_path', 'Zielordner', d.dst_path, errors=errors, hint='Anbindung: relativer Unterordner (wird angelegt). Lokal: absoluter Pfad, muss existieren.') }} {{ 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) }} {{ select('on_collision', 'Wenn Datei existiert', [('rename', 'umbenennen (scan-1.pdf)'), ('skip', 'überspringen'), ('overwrite', 'überschreiben')], d.on_collision.value, errors=errors) }}
</div></fieldset> </div></fieldset>
<fieldset><legend>Sicherheit und Grenzen</legend><div class="fields"> <fieldset><legend>Sicherheit und Grenzen <a class="legend-help" href="/help#regel-sicherheit" title="Hilfe">?</a></legend><div class="fields">
{{ 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('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('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('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"') }} {{ field('op_timeout_seconds', 'Zeitlimit ohne Fortschritt (s)', d.op_timeout_seconds, type='number', errors=errors, attrs='min="10"') }}
</div></fieldset> </div></fieldset>
<fieldset><legend>Rechte und Quarantäne</legend><div class="fields"> <fieldset><legend>Rechte und Quarantäne <a class="legend-help" href="/help#regel-rechte" title="Hilfe">?</a></legend><div class="fields">
{{ field('set_owner', 'Eigentümer setzen', d.set_owner, errors=errors, placeholder='1000:1000', hint='leer = unverändert; auf CIFS wirkungslos') }} {{ 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('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') }} {{ field('quarantine_path', 'Quarantäneordner', d.quarantine_path, errors=errors, placeholder='_quarantaene', hint='relativ zur Quell-Anbindung; leer = keine Quarantäne') }}
+13 -1
View File
@@ -8,7 +8,7 @@
<fieldset><legend>Protokoll</legend><div class="fields"> <fieldset><legend>Protokoll</legend><div class="fields">
{{ field('retention_days', 'Aufbewahrung (Tage)', cfg.retention_days, type='number', errors=errors, attrs='min="1" max="3650"') }} {{ field('retention_days', 'Aufbewahrung (Tage)', cfg.retention_days, type='number', errors=errors, attrs='min="1" max="3650"') }}
</div></fieldset> </div></fieldset>
<fieldset><legend>Benachrichtigung (Webhook)</legend><div class="fields"> <fieldset><legend>Benachrichtigung (Webhook) <a class="legend-help" href="/help#benachrichtigungen" title="Hilfe">?</a></legend><div class="fields">
{{ 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) }} {{ 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) }}
<div data-show-for="notify_mode:after_n">{{ field('notify_after', 'N', cfg.notify_after, type='number', errors=errors, attrs='min="1"') }}</div> <div data-show-for="notify_mode:after_n">{{ field('notify_after', 'N', cfg.notify_after, type='number', errors=errors, attrs='min="1"') }}</div>
{{ 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.') }} {{ 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 @@
</div> </div>
<div class="row"><button type="button" hx-post="/settings/webhook-test" hx-target="#hook-result">Test senden (gespeicherte Einstellungen)</button><span id="hook-result"></span></div> <div class="row"><button type="button" hx-post="/settings/webhook-test" hx-target="#hook-result">Test senden (gespeicherte Einstellungen)</button><span id="hook-result"></span></div>
</fieldset> </fieldset>
<fieldset><legend>Überwachung (Uptime Kuma) <a class="legend-help" href="/help#uptime-kuma" title="Hilfe">?</a></legend>
<div class="fields">
{{ 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.') }}
</div>
<div class="row"><button type="button" hx-post="/settings/kuma-test" hx-target="#kuma-result">Push jetzt testen (gespeicherte URL)</button><span id="kuma-result"></span></div>
<details class="small"><summary>Adressen für HTTP-Monitore (Kuma fragt NetMover ab)</summary>
<table class="small"><tbody>
{% for label, url in monitor_urls %}<tr><td>{{ label }}</td><td class="mono">{{ url }}</td></tr>{% endfor %}
</tbody></table>
<p class="muted small">200 = in Ordnung, 503 = Problem. Ohne Anmeldung erreichbar.</p>
</details>
</fieldset>
<button class="primary">Speichern</button> <button class="primary">Speichern</button>
</form> </form>
+42
View File
@@ -5,13 +5,18 @@ from pathlib import Path
import pytest import pytest
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from netmover.app_state import NetMover
from netmover.config import Settings
from netmover.crypto import SecretBox from netmover.crypto import SecretBox
from netmover.db import make_engine, make_session_factory, session_scope, upgrade from netmover.db import make_engine, make_session_factory, session_scope, upgrade
from netmover.models import Credential, CredentialKind, Mount, MountType from netmover.models import Credential, CredentialKind, Mount, MountType
from netmover.mounting.fake import FakeMountBackend from netmover.mounting.fake import FakeMountBackend
from netmover.mounting.manager import MountManager from netmover.mounting.manager import MountManager
from netmover.web.app import create_app
from tests.webutil import do_setup
@pytest.fixture @pytest.fixture
@@ -124,3 +129,40 @@ def make_mount(
return m.id return m.id
return factory 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
+21 -61
View File
@@ -4,7 +4,6 @@ from __future__ import annotations
import json import json
import re import re
from collections.abc import Iterator
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -17,66 +16,7 @@ from netmover.db import session_scope
from netmover.models import Credential, Mount, MountState, Rule, Run, RunStatus from netmover.models import Credential, Mount, MountState, Rule, Run, RunStatus
from netmover.mounting.fake import FakeMountBackend from netmover.mounting.fake import FakeMountBackend
from netmover.web.app import create_app from netmover.web.app import create_app
from tests.webutil import PASSWORD, csrf_of, do_setup, post
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]
# -- Einrichtung und Anmeldung -------------------------------------------------------------- # -- 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: def test_key_import_error_is_shown(client: TestClient) -> None:
post(client, "/keys/import", {"name": "x", "private_key": "kein schlüssel", "passphrase": ""}) post(client, "/keys/import", {"name": "x", "private_key": "kein schlüssel", "passphrase": ""})
assert "Kein lesbarer privater Schlüssel" in client.get("/keys").text 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 &#34; 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][^<>]*=&#34;[^<>]*>", html) # nur in Tags, nicht im Text
assert not broken, (url, broken[:2])
+33
View File
@@ -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)