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:
co-authored by
Claude Opus 5.5
parent
a59ce46933
commit
88f5ad3a38
+28
-1
@@ -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 (`="`) 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
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| 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
@@ -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,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
@@ -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)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 spec = cond.split(":");
|
||||||
var ctl = form.querySelector('[name="' + spec[0] + '"]:checked') ||
|
var ctl = form.querySelector('[name="' + spec[0] + '"]:checked') ||
|
||||||
form.querySelector('select[name="' + spec[0] + '"]');
|
form.querySelector('select[name="' + spec[0] + '"]');
|
||||||
var value = ctl ? ctl.value : "";
|
return spec[1].split(" ").indexOf(ctl ? ctl.value : "") !== -1;
|
||||||
var show = spec[1].split(" ").indexOf(value) !== -1;
|
});
|
||||||
el.hidden = !show;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.querySelectorAll("form[data-dynamic]").forEach(function (form) {
|
document.querySelectorAll("form[data-dynamic]").forEach(function (form) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<div class="nav-title">Übersicht</div>
|
||||||
|
{{ item('status', '/', 'Status') }}
|
||||||
|
</div>
|
||||||
|
<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 }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf }}">
|
||||||
<button class="small">Abmelden</button>
|
<button class="nav-button">{{ icon('logout') }}<span>Abmelden</span></button>
|
||||||
</form>
|
</form>
|
||||||
</div></header>
|
<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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
<form class="row" method="post" action="/keys/{{ k.id }}/rename">{{ csrf_field(csrf) }}
|
||||||
<input type="text" name="name" value="{{ k.name }}" aria-label="Neuer Name" required maxlength="100">
|
<input type="text" name="name" value="{{ k.name }}" aria-label="Neuer Name" required maxlength="100">
|
||||||
<button class="small">Umbenennen</button></form>
|
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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') }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 " 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])
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user