feat: Weboberfläche mit Anmeldung, CSRF und vier Seiten

FastAPI + Jinja2 + lokal eingebundenes htmx, strikte CSP, Ersteinrichtung
mit Einrichtungscode aus dem Log, Argon2, Sperre nach Fehlversuchen,
Status-, Anbindungs-, Regel-, Protokoll- und Einstellungsseite,
/healthz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-10 19:33:00 +02:00
co-authored by Claude Opus 5.5
parent e2587551b0
commit 2b0b039aeb
24 changed files with 2364 additions and 2 deletions
+10
View File
@@ -25,6 +25,12 @@ class Settings(BaseSettings):
host: str = "127.0.0.1"
port: int = Field(default=8080, ge=1, le=65535)
# Hinter einem Reverse Proxy mit TLS auf true setzen (Cookie nur per HTTPS).
cookie_secure: bool = False
# IPs, deren X-Forwarded-For/-Proto übernommen wird (Reverse Proxy).
forwarded_allow_ips: str = "127.0.0.1"
session_hours: int = Field(default=12, ge=1, le=24 * 30)
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
log_format: Literal["logfmt", "json"] = "logfmt"
@@ -37,6 +43,10 @@ class Settings(BaseSettings):
def db_path(self) -> Path:
return self.data_dir / "netmover.db"
@property
def session_key_path(self) -> Path:
return self.data_dir / "session.key"
@property
def secret_key_path(self) -> Path:
return self.data_dir / "secret.key"
+4 -2
View File
@@ -572,7 +572,7 @@ class MountManager:
self._notify("mount.error", m.name, msg)
raise MountUnavailable(msg)
def usage(self, mount_id: int) -> tuple[int, int] | None:
def usage(self, mount_id: int, timeout: float | None = None) -> tuple[int, int] | None:
"""(gesamt, frei) in Bytes – nur bei eingehängten Kernel-Mounts."""
m, _ = self._load(mount_id)
if not m.type.is_kernel_mount:
@@ -581,7 +581,9 @@ class MountManager:
if not self.backend.is_mounted(mp):
return None
try:
st = run_with_timeout(lambda: os.statvfs(mp), self.probe_timeout, f"statvfs:{m.name}")
st = run_with_timeout(
lambda: os.statvfs(mp), timeout or self.probe_timeout, f"statvfs:{m.name}"
)
except (OSError, OperationTimeout):
return None
return st.f_blocks * st.f_frsize, st.f_bavail * st.f_frsize
+1
View File
@@ -0,0 +1 @@
"""Weboberfläche: FastAPI + Jinja2 + HTMX, serverseitig gerendert."""
+957
View File
@@ -0,0 +1,957 @@
"""FastAPI-Anwendung: Middleware, Anmeldung, Seiten.
Seiten werden serverseitig gerendert (Jinja2). HTMX nur dort, wo es echten
Nutzen bringt: Testergebnisse, Probelauf-Tabelle, aufklappbare Protokolle und
das automatische Aktualisieren der Statusseite. Alle Aktionen funktionieren
auch als normale Formulare (Post/Redirect/Get).
"""
from __future__ import annotations
import asyncio
import dataclasses
import json
import math
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import UTC, datetime
from importlib.resources import files
from typing import Any
from fastapi import APIRouter, Depends, FastAPI, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy import func, or_, select
from starlette.concurrency import run_in_threadpool
from starlette.datastructures import UploadFile
from starlette.middleware.sessions import SessionMiddleware
from netmover import __version__
from netmover.admin import (
AdminError,
MountInput,
RuleInput,
credential_for_test,
delete_mount,
mount_input_from_model,
parse_mount_form,
parse_rule_form,
rule_input_from_model,
save_mount,
save_rule,
)
from netmover.app_state import NetMover
from netmover.config import Settings, get_settings
from netmover.db import session_scope
from netmover.logging_setup import event, get_logger
from netmover.models import (
CollisionPolicy,
CredentialKind,
Mount,
MountState,
MountType,
PatternType,
Rule,
RuleAction,
Run,
RunItem,
RunStatus,
utcnow,
)
from netmover.mounting.backend import MountError
from netmover.mounting.report import CheckReport
from netmover.notify import Notification
from netmover.settings_store import AppSettings, NotifyMode, load_settings, save_settings
from netmover.transfer import apply_imported_settings, export_config, import_config
from netmover.web import auth
log = get_logger(__name__)
PAGE_SIZE = 50
STATE_LABELS = {
MountState.MOUNTED: "eingehängt",
MountState.UNMOUNTED: "ausgehängt",
MountState.DEGRADED: "gestört",
MountState.ERROR: "Fehler",
}
SFTP_STATE_LABELS = {
MountState.MOUNTED: "erreichbar",
MountState.UNMOUNTED: "nicht geprüft",
MountState.DEGRADED: "gestört",
MountState.ERROR: "nicht erreichbar",
}
RUN_LABELS = {
RunStatus.OK: "OK",
RunStatus.PARTIAL: "teilweise",
RunStatus.ERROR: "Fehler",
RunStatus.SKIPPED: "übersprungen",
RunStatus.RUNNING: "läuft",
}
# -- Jinja-Filter -----------------------------------------------------------------------
def _ago(value: datetime | None, now: datetime | None = None) -> str:
if value is None:
return "–"
seconds = ((now or datetime.now(UTC)) - value).total_seconds()
future = seconds < 0
seconds = abs(seconds)
if seconds < 60:
text = f"{int(seconds)} s"
elif seconds < 3600:
text = f"{int(seconds // 60)} min"
elif seconds < 86400:
text = f"{seconds / 3600:.1f} h".replace(".", ",")
else:
text = f"{int(seconds // 86400)} Tg."
return f"in {text}" if future else f"vor {text}"
def _localdt(value: datetime | None) -> str:
return value.astimezone().strftime("%d.%m.%Y %H:%M:%S") if value else "–"
def _bytes(value: int | None) -> str:
if value is None:
return "–"
units = ["B", "KB", "MB", "GB", "TB", "PB"]
n = float(value)
i = 0
while n >= 1024 and i < len(units) - 1:
n /= 1024
i += 1
return f"{n:.1f} {units[i]}".replace(".", ",") if i else f"{int(n)} B"
def _duration(start: datetime | None, end: datetime | None) -> str:
if start is None or end is None:
return "–"
s = (end - start).total_seconds()
return f"{s:.1f} s".replace(".", ",") if s < 60 else f"{int(s // 60)} min {int(s % 60)} s"
TEMPLATES = Jinja2Templates(directory=str(files("netmover.web") / "templates"))
TEMPLATES.env.filters.update(ago=_ago, localdt=_localdt, bytes=_bytes)
TEMPLATES.env.globals.update(
version=__version__,
duration=_duration,
STATE_LABELS=STATE_LABELS,
SFTP_STATE_LABELS=SFTP_STATE_LABELS,
RUN_LABELS=RUN_LABELS,
MountType=MountType,
MountState=MountState,
RunStatus=RunStatus,
PatternType=PatternType,
RuleAction=RuleAction,
CollisionPolicy=CollisionPolicy,
CredentialKind=CredentialKind,
NotifyMode=NotifyMode,
)
def flash(request: Request, message: str, kind: str = "ok") -> None:
# Neu zuweisen statt anhängen: die Session erkennt nur Zuweisungen als Änderung.
request.session["flash"] = [*request.session.get("flash", []), [kind, message]]
def render(request: Request, name: str, status_code: int = 200, **ctx: Any) -> HTMLResponse:
flashes = request.session.pop("flash", []) if not name.startswith("partials/") else []
return TEMPLATES.TemplateResponse(
request,
name,
{
"csrf": auth.csrf_token(request),
"flashes": flashes,
"logged_in": auth.is_logged_in(request),
"active": ctx.pop("active", ""),
**ctx,
},
status_code=status_code,
)
def redirect(url: str) -> RedirectResponse:
return RedirectResponse(url, status_code=303)
def nm(request: Request) -> NetMover:
app_state: NetMover = request.app.state.nm
return app_state
def _form_dict(form: Any) -> dict[str, str]:
return {k: v for k, v in form.items() if isinstance(v, str)}
# -- Schutz für alle geschützten Routen ---------------------------------------------------
async def guard(request: Request) -> None:
state = nm(request)
if not auth.password_is_set(state.session_factory):
raise auth.LoginRequired(setup=True)
if not auth.is_logged_in(request):
raise auth.LoginRequired()
await auth.check_csrf(request)
protected = APIRouter(dependencies=[Depends(guard)])
public = APIRouter()
# -- Öffentlich: Health, Einrichtung, Anmeldung --------------------------------------------
@public.get("/healthz")
async def healthz(request: Request) -> JSONResponse:
health = await run_in_threadpool(nm(request).health)
body = {"status": "ok" if health.ok else "error", "problems": health.problems}
return JSONResponse(body, status_code=200 if health.ok else 503)
@public.get("/setup", response_class=HTMLResponse)
async def setup_form(request: Request) -> Response:
if auth.password_is_set(nm(request).session_factory):
return redirect("/login")
return render(request, "setup.html")
@public.post("/setup", response_class=HTMLResponse)
async def setup_submit(request: Request) -> Response:
await auth.check_csrf(request)
state = nm(request)
if auth.password_is_set(state.session_factory):
return redirect("/login")
form = await request.form()
password = str(form.get("password") or "")
problem = auth.password_problem(password, str(form.get("confirm") or ""))
if not request.app.state.setup_code.matches(str(form.get("code") or "")):
problem = (
"Einrichtungscode stimmt nicht – er steht im Log des Dienstes (journalctl -u netmover)."
)
if problem:
return render(request, "setup.html", status_code=400, error=problem)
await run_in_threadpool(auth.set_password, state.session_factory, password)
auth.login_session(request)
event(log, "auth.setup_done")
flash(request, "Passwort gesetzt. Willkommen bei NetMover!")
return redirect("/")
@public.get("/login", response_class=HTMLResponse)
async def login_form(request: Request) -> Response:
if not auth.password_is_set(nm(request).session_factory):
return redirect("/setup")
return render(request, "login.html")
@public.post("/login", response_class=HTMLResponse)
async def login_submit(request: Request) -> Response:
await auth.check_csrf(request)
throttle: auth.LoginThrottle = request.app.state.throttle
client = request.client.host if request.client else "?"
wait = throttle.locked_for(client)
if wait:
return render(
request,
"login.html",
status_code=429,
error=f"Zu viele Fehlversuche – bitte {math.ceil(wait / 60)} min warten.",
)
form = await request.form()
ok = await run_in_threadpool(
auth.verify_password, nm(request).session_factory, str(form.get("password") or "")
)
if not ok:
throttle.failure(client)
event(log, "auth.login_failed", level=30, client=client)
return render(request, "login.html", status_code=401, error="Passwort falsch.")
throttle.success(client)
auth.login_session(request)
event(log, "auth.login", client=client)
target = request.query_params.get("next", "/")
return redirect(target if target.startswith("/") and not target.startswith("//") else "/")
@protected.post("/logout")
async def logout(request: Request) -> Response:
request.session.clear()
return redirect("/login")
# -- Status -----------------------------------------------------------------------------------
@dataclass
class MountView:
m: Mount
usage: tuple[int, int] | None
@dataclass
class RuleView:
r: Rule
last_run: Run | None
next_run: datetime | None
running_since: datetime | None
stale: bool
target: str
def _rule_target(r: Rule) -> str:
src = f"{r.src_mount.name}:/{r.src_subpath}" if r.src_subpath else f"{r.src_mount.name}:/"
if r.dst_mount is None:
dst = r.dst_path
else:
dst = f"{r.dst_mount.name}:/{r.dst_path}" if r.dst_path else f"{r.dst_mount.name}:/"
return f"{src} → {dst}"
async def _status_context(request: Request) -> dict[str, Any]:
state = nm(request)
health = await run_in_threadpool(state.health)
with state.session_factory() as s:
mounts = s.scalars(select(Mount).order_by(Mount.name)).all()
rules = s.scalars(select(Rule).order_by(Rule.name)).all()
last_runs = {
run.rule_id: run
for run in s.scalars(
select(Run).where(
Run.id.in_(
select(func.max(Run.id))
.where(Run.status != RunStatus.SKIPPED)
.group_by(Run.rule_id)
)
)
)
}
rule_views = [
RuleView(
r,
last_runs.get(r.id),
state.scheduler.next_run(r.id),
state.runner.running_since(r.id),
r.id in health.stale_rules,
_rule_target(r),
)
for r in rules
]
for m in mounts:
s.expunge(m)
async def usage(m: Mount) -> tuple[int, int] | None:
if m.state is not MountState.MOUNTED or not m.type.is_kernel_mount:
return None
return await run_in_threadpool(state.mounts.usage, m.id, 2.0)
usages = await asyncio.gather(*(usage(m) for m in mounts))
return {
"health": health,
"mounts": [MountView(m, u) for m, u in zip(mounts, usages, strict=True)],
"rules": rule_views,
"now": utcnow(),
}
@protected.get("/", response_class=HTMLResponse)
async def status_page(request: Request) -> Response:
return render(request, "status.html", active="status", **await _status_context(request))
@protected.get("/partials/status", response_class=HTMLResponse)
async def status_partial(request: Request) -> Response:
return render(request, "partials/status_body.html", **await _status_context(request))
@protected.post("/rules/{rule_id}/run")
async def rule_run_now(request: Request, rule_id: int) -> Response:
state = nm(request)
task = asyncio.create_task(state.scheduler.run_now(rule_id))
request.app.state.tasks.add(task)
task.add_done_callback(request.app.state.tasks.discard)
flash(request, "Lauf gestartet – das Ergebnis erscheint gleich im Protokoll.")
return redirect(request.headers.get("referer") or "/")
# -- Anbindungen ----------------------------------------------------------------------------
@protected.get("/mounts", response_class=HTMLResponse)
async def mounts_page(request: Request) -> Response:
with nm(request).session_factory() as s:
mounts = s.scalars(select(Mount).order_by(Mount.name)).all()
reports = {
m.id: CheckReport(**_report_data(m.last_test_result))
for m in mounts
if m.last_test_result
}
pubkeys = {
m.id: m.credential.public_key
for m in mounts
if m.credential is not None and m.credential.public_key
}
return render(
request, "mounts.html", active="mounts", mounts=mounts, reports=reports, pubkeys=pubkeys
)
def _report_data(raw: str | None) -> dict[str, Any]:
from netmover.mounting.report import CheckStep
data: dict[str, Any] = json.loads(raw or "{}")
data.pop("roles", None)
data.pop("ok", None)
data["steps"] = [CheckStep(**st) for st in data.get("steps", [])]
return data
def _mount_form(
request: Request,
data: MountInput,
errors: dict[str, str],
mount: Mount | None = None,
status_code: int = 200,
) -> Response:
pub = mount.credential.public_key if mount is not None and mount.credential else None
return render(
request,
"mount_form.html",
status_code=status_code,
active="mounts",
d=data,
errors=errors,
mount=mount,
public_key=pub,
has_secret=bool(mount is not None and mount.credential is not None),
)
@protected.get("/mounts/new", response_class=HTMLResponse)
async def mount_new(request: Request) -> Response:
return _mount_form(request, MountInput(), {})
@protected.get("/mounts/{mount_id}/edit", response_class=HTMLResponse)
async def mount_edit(request: Request, mount_id: int) -> Response:
with nm(request).session_factory() as s:
m = s.get(Mount, mount_id)
if m is None:
return redirect("/mounts")
return _mount_form(request, mount_input_from_model(m), {}, m)
@protected.post("/mounts/new", response_class=HTMLResponse)
@protected.post("/mounts/{mount_id}/edit", response_class=HTMLResponse)
async def mount_save(request: Request, mount_id: int | None = None) -> Response:
state = nm(request)
data, errors = parse_mount_form(_form_dict(await request.form()))
with session_scope(state.session_factory) as s:
existing = s.get(Mount, mount_id) if mount_id is not None else None
if not errors:
try:
m, errors = save_mount(s, state.secrets, state.mounts, data, mount_id)
except AdminError as exc:
errors = {"_": str(exc)}
m = None
else:
m = None
if errors or m is None:
s.rollback()
existing = s.get(Mount, mount_id) if mount_id is not None else None
return _mount_form(request, data, errors, existing, status_code=400)
saved_id, name = m.id, m.name
is_sftp_key = m.type is MountType.SFTP and data.auth is CredentialKind.SSH_KEY
flash(request, f"Anbindung „{name}“ gespeichert.")
if is_sftp_key:
flash(
request,
"Öffentlichen Schlüssel beim Zielserver in authorized_keys eintragen, dann „Testen“.",
"info",
)
state.scheduler.sync()
return redirect(f"/mounts#mount-{saved_id}")
@protected.post("/mounts/test", response_class=HTMLResponse)
async def mount_test_form(request: Request) -> Response:
"""„Testen“ im Formular – mit den (noch nicht gespeicherten) Eingaben."""
state = nm(request)
form = _form_dict(await request.form())
data, errors = parse_mount_form(form)
if errors:
return render(request, "partials/check_result.html", errors=errors, report=None)
existing_id = int(form["mount_id"]) if form.get("mount_id", "").isdigit() else None
with state.session_factory() as s:
existing = s.get(Mount, existing_id) if existing_id else None
cred = credential_for_test(data, existing, state.secrets)
cfg = data.config()
if data.type is MountType.SFTP and existing is not None:
cfg = dataclasses.replace(cfg, host_key=existing.host_key)
report = await run_in_threadpool(state.mounts.check, cfg, cred, None)
return render(request, "partials/check_result.html", report=report, errors={})
@protected.post("/mounts/{mount_id}/test", response_class=HTMLResponse)
async def mount_test(request: Request, mount_id: int) -> Response:
state = nm(request)
with state.session_factory() as s:
m = s.get(Mount, mount_id)
if m is None:
return HTMLResponse("Anbindung existiert nicht.", status_code=404)
data = mount_input_from_model(m)
cred = credential_for_test(data, m, state.secrets)
from netmover.mounting.manager import MountConfig
cfg = MountConfig.from_model(m)
report = await run_in_threadpool(state.mounts.check, cfg, cred, mount_id)
return render(request, "partials/check_result.html", report=report, errors={})
async def _mount_action(
request: Request, mount_id: int, fn: Callable[[int], None], ok: str
) -> Response:
try:
await run_in_threadpool(fn, mount_id)
flash(request, ok)
except (MountError, LookupError) as exc:
flash(request, str(exc), "error")
return redirect(f"/mounts#mount-{mount_id}")
@protected.post("/mounts/{mount_id}/mount")
async def mount_mount(request: Request, mount_id: int) -> Response:
return await _mount_action(request, mount_id, nm(request).mounts.mount, "Eingehängt.")
@protected.post("/mounts/{mount_id}/unmount")
async def mount_unmount(request: Request, mount_id: int) -> Response:
return await _mount_action(request, mount_id, nm(request).mounts.unmount, "Ausgehängt.")
@protected.post("/mounts/{mount_id}/delete")
async def mount_delete(request: Request, mount_id: int) -> Response:
state = nm(request)
def work() -> None:
with session_scope(state.session_factory) as s:
delete_mount(s, state.mounts, mount_id)
try:
await run_in_threadpool(work)
flash(request, "Anbindung gelöscht.")
except (AdminError, MountError) as exc:
flash(request, str(exc), "error")
return redirect("/mounts")
@protected.post("/mounts/{mount_id}/reset-hostkey")
async def mount_reset_hostkey(request: Request, mount_id: int) -> Response:
with session_scope(nm(request).session_factory) as s:
m = s.get(Mount, mount_id)
if m is not None:
m.host_key = None
m.state = MountState.UNMOUNTED
flash(
request,
"Hostschlüssel zurückgesetzt – beim nächsten „Testen“ wird er neu festgehalten.",
"info",
)
return redirect(f"/mounts#mount-{mount_id}")
# -- Regeln -----------------------------------------------------------------------------------
def _rule_form(
request: Request,
d: RuleInput,
errors: dict[str, str],
rule_id: int | None,
status_code: int = 200,
) -> Response:
with nm(request).session_factory() as s:
mounts = s.scalars(select(Mount).order_by(Mount.name)).all()
for m in mounts:
s.expunge(m)
return render(
request,
"rule_form.html",
status_code=status_code,
active="rules",
d=d,
errors=errors,
rule_id=rule_id,
src_mounts=[m for m in mounts if m.type.is_kernel_mount],
src_options=[("", "– wählen –")]
+ [(m.id, m.name) for m in mounts if m.type.is_kernel_mount],
dst_options=[("", "– wählen –")] + [(m.id, f"{m.name} ({m.type.value})") for m in mounts],
)
@protected.get("/rules", response_class=HTMLResponse)
async def rules_page(request: Request) -> Response:
ctx = await _status_context(request)
return render(request, "rules.html", active="rules", rules=ctx["rules"], now=ctx["now"])
@protected.get("/rules/new", response_class=HTMLResponse)
async def rule_new(request: Request) -> Response:
return _rule_form(request, RuleInput(), {}, None)
@protected.get("/rules/{rule_id}/edit", response_class=HTMLResponse)
async def rule_edit(request: Request, rule_id: int) -> Response:
with nm(request).session_factory() as s:
r = s.get(Rule, rule_id)
if r is None:
return redirect("/rules")
d = rule_input_from_model(r)
return _rule_form(request, d, {}, rule_id)
@protected.post("/rules/new", response_class=HTMLResponse)
@protected.post("/rules/{rule_id}/edit", response_class=HTMLResponse)
async def rule_save(request: Request, rule_id: int | None = None) -> Response:
state = nm(request)
form = _form_dict(await request.form())
with session_scope(state.session_factory) as s:
d, errors = parse_rule_form(s, form)
r = None
if not errors:
try:
r, errors = save_rule(s, d, rule_id)
except AdminError as exc:
errors = {"_": str(exc)}
if errors or r is None:
s.rollback()
return _rule_form(request, d, errors, rule_id, status_code=400)
name = r.name
state.scheduler.sync()
flash(request, f"Regel „{name}“ gespeichert.")
return redirect("/rules")
@protected.post("/rules/{rule_id}/toggle")
async def rule_toggle(request: Request, rule_id: int) -> Response:
state = nm(request)
with session_scope(state.session_factory) as s:
r = s.get(Rule, rule_id)
if r is not None:
r.enabled = not r.enabled
flash(request, f"Regel „{r.name}“ {'aktiviert' if r.enabled else 'deaktiviert'}.")
state.scheduler.sync()
return redirect("/rules")
@protected.post("/rules/{rule_id}/delete")
async def rule_delete(request: Request, rule_id: int) -> Response:
state = nm(request)
with session_scope(state.session_factory) as s:
r = s.get(Rule, rule_id)
if r is not None:
s.delete(r)
flash(request, f"Regel „{r.name}“ gelöscht.")
state.scheduler.sync()
return redirect("/rules")
@protected.post("/rules/{rule_id}/dryrun", response_class=HTMLResponse)
async def rule_dryrun(request: Request, rule_id: int) -> Response:
state = nm(request)
run_id = await state.scheduler.run_now(rule_id, dry_run=True)
with state.session_factory() as s:
run = s.get(Run, run_id) if run_id else None
items = s.scalars(select(RunItem).where(RunItem.run_id == run_id)).all() if run_id else []
return render(request, "partials/dryrun.html", run=run, items=items)
# -- Protokoll --------------------------------------------------------------------------------
@protected.get("/log", response_class=HTMLResponse)
async def log_page(request: Request) -> Response:
q = request.query_params
rule_id = int(q["rule"]) if q.get("rule", "").isdigit() else None
status = q.get("status") or ""
active_only = q.get("all") != "1"
page = max(1, int(q["page"])) if q.get("page", "").isdigit() else 1
with nm(request).session_factory() as s:
stmt = select(Run, Rule.name).join(Rule, Rule.id == Run.rule_id)
if rule_id:
stmt = stmt.where(Run.rule_id == rule_id)
if status in {st.value for st in RunStatus}:
stmt = stmt.where(Run.status == RunStatus(status))
if active_only:
stmt = stmt.where(
or_(Run.seen > 0, Run.status != RunStatus.OK, Run.message.is_not(None))
)
total = s.scalar(select(func.count()).select_from(stmt.subquery())) or 0
rows = s.execute(
stmt.order_by(Run.started_at.desc(), Run.id.desc())
.offset((page - 1) * PAGE_SIZE)
.limit(PAGE_SIZE)
).all()
rules = s.execute(select(Rule.id, Rule.name).order_by(Rule.name)).all()
return render(
request,
"log.html",
active="log",
rows=rows,
rules=rules,
rule_id=rule_id,
status=status,
active_only=active_only,
page=page,
pages=max(1, math.ceil(total / PAGE_SIZE)),
total=total,
)
@protected.get("/log/{run_id}/items", response_class=HTMLResponse)
async def log_items(request: Request, run_id: int) -> Response:
with nm(request).session_factory() as s:
items = s.scalars(
select(RunItem).where(RunItem.run_id == run_id).order_by(RunItem.id)
).all()
run = s.get(Run, run_id)
return render(request, "partials/run_items.html", items=items, run=run)
# -- Einstellungen ------------------------------------------------------------------------------
@protected.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request) -> Response:
cfg = load_settings(nm(request).session_factory)
return render(request, "settings.html", active="settings", cfg=cfg, errors={})
@protected.post("/settings", response_class=HTMLResponse)
async def settings_save(request: Request) -> Response:
state = nm(request)
form = _form_dict(await request.form())
cfg = load_settings(state.session_factory)
errors: dict[str, str] = {}
try:
cfg.retention_days = int(form.get("retention_days") or 30)
if not 1 <= cfg.retention_days <= 3650:
errors["retention_days"] = "Zwischen 1 und 3650 Tagen."
cfg.notify_after = int(form.get("notify_after") or 3)
if cfg.notify_after < 1:
errors["notify_after"] = "Mindestens 1."
except ValueError:
errors["retention_days"] = "Bitte ganze Zahlen angeben."
try:
cfg.notify_mode = NotifyMode(form.get("notify_mode") or "off")
except ValueError:
errors["notify_mode"] = "Ungültig."
url = (form.get("webhook_url") or "").strip()
if url != auth_mask(cfg.webhook_url): # Maske unverändert = URL behalten
if url and not url.startswith(("http://", "https://")):
errors["webhook_url"] = "URL muss mit http:// oder https:// beginnen."
cfg.webhook_url = url
cfg.notify_recovery = form.get("notify_recovery") == "on"
cfg.webhook_template = form.get("webhook_template") or cfg.webhook_template
cfg.webhook_content_type = (form.get("webhook_content_type") or "application/json").strip()
if errors:
return render(
request, "settings.html", status_code=400, active="settings", cfg=cfg, errors=errors
)
save_settings(state.session_factory, cfg)
flash(request, "Einstellungen gespeichert.")
return redirect("/settings")
def auth_mask(url: str) -> str:
"""Webhook-URL nur gekürzt anzeigen – sie enthält oft ein Token."""
if not url:
return ""
head, _, _rest = url.partition("://")
host = _rest.split("/", 1)[0]
return f"{head}://{host}/••••••"
TEMPLATES.env.globals["mask_url"] = auth_mask
@protected.post("/settings/webhook-test", response_class=HTMLResponse)
async def settings_webhook_test(request: Request) -> Response:
state = nm(request)
cfg = load_settings(state.session_factory)
if not cfg.webhook_url:
return HTMLResponse('<p class="msg error">Keine Webhook-URL gespeichert.</p>')
from netmover.notify import http_sender
from netmover.notify import render as render_body
body = render_body(
cfg.webhook_template,
cfg.webhook_content_type,
Notification("test", "NetMover: Testnachricht", "Webhook funktioniert."),
)
try:
await run_in_threadpool(http_sender, cfg.webhook_url, cfg.webhook_content_type, body)
except Exception as exc:
return HTMLResponse(f'<p class="msg error">Fehlgeschlagen: {type(exc).__name__}</p>')
return HTMLResponse('<p class="msg ok">Testnachricht gesendet.</p>')
@protected.post("/settings/password", response_class=HTMLResponse)
async def settings_password(request: Request) -> Response:
state = nm(request)
form = _form_dict(await request.form())
if not await run_in_threadpool(
auth.verify_password, state.session_factory, form.get("current", "")
):
flash(request, "Aktuelles Passwort falsch.", "error")
return redirect("/settings")
problem = auth.password_problem(form.get("password", ""), form.get("confirm", ""))
if problem:
flash(request, problem, "error")
return redirect("/settings")
await run_in_threadpool(auth.set_password, state.session_factory, form["password"])
auth.login_session(request)
flash(request, "Passwort geändert.")
return redirect("/settings")
@protected.get("/settings/export")
async def settings_export(request: Request) -> Response:
state = nm(request)
with state.session_factory() as s:
data = export_config(s, load_settings(state.session_factory))
stamp = datetime.now().strftime("%Y%m%d-%H%M")
return Response(
json.dumps(data, ensure_ascii=False, indent=2),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="netmover-{stamp}.json"'},
)
@protected.post("/settings/import")
async def settings_import(request: Request) -> Response:
state = nm(request)
form = await request.form()
upload = form.get("file")
if not isinstance(upload, UploadFile):
flash(request, "Keine Datei ausgewählt.", "error")
return redirect("/settings")
raw = await upload.read(2 * 1024 * 1024 + 1)
if len(raw) > 2 * 1024 * 1024:
flash(request, "Datei zu groß.", "error")
return redirect("/settings")
try:
data = json.loads(raw)
except ValueError:
flash(request, "Keine gültige JSON-Datei.", "error")
return redirect("/settings")
with session_scope(state.session_factory) as s:
report = import_config(s, state.mounts, data)
if report.settings:
save_settings(
state.session_factory,
apply_imported_settings(load_settings(state.session_factory), report.settings),
)
state.scheduler.sync()
if report.created:
flash(request, "Importiert: " + ", ".join(report.created))
if report.skipped:
flash(request, "Übersprungen: " + ", ".join(report.skipped), "info")
for err in report.errors:
flash(request, err, "error")
if report.created:
flash(
request,
"Zugangsdaten bitte bei den Anbindungen neu eingeben; importierte Regeln "
"sind deaktiviert.",
"info",
)
return redirect("/settings")
# -- Anwendung ----------------------------------------------------------------------------------
SECURITY_HEADERS = {
"Content-Security-Policy": (
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
"frame-ancestors 'none'; form-action 'self'; base-uri 'none'"
),
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "same-origin",
}
def create_app(settings: Settings | None = None, state: NetMover | None = None) -> FastAPI:
settings = settings or get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app_state = state or NetMover(settings)
app.state.nm = app_state
app.state.setup_code = auth.SetupCode()
app.state.throttle = auth.LoginThrottle()
app.state.tasks = set()
if not auth.password_is_set(app_state.session_factory):
event(
log,
"setup.required",
level=30,
code=app.state.setup_code.code,
hint="Diesen Code bei der Ersteinrichtung im Browser eingeben",
)
app_state.start()
try:
yield
finally:
app_state.stop()
app = FastAPI(
title="NetMover",
version=__version__,
lifespan=lifespan,
docs_url=None,
redoc_url=None,
openapi_url=None,
)
@app.middleware("http")
async def security_headers(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
response = await call_next(request)
for k, v in SECURITY_HEADERS.items():
response.headers.setdefault(k, v)
if request.url.path not in ("/healthz",) and not request.url.path.startswith("/static/"):
response.headers.setdefault("Cache-Control", "no-store")
return response
app.add_middleware(
SessionMiddleware,
secret_key=auth.load_or_create_session_key(settings.session_key_path),
session_cookie="netmover_session",
max_age=settings.session_hours * 3600,
same_site="lax",
https_only=settings.cookie_secure,
)
@app.exception_handler(auth.LoginRequired)
async def login_required(request: Request, exc: auth.LoginRequired) -> Response:
target = "/setup" if exc.setup else f"/login?next={request.url.path}"
if request.headers.get("hx-request"):
return Response(status_code=204, headers={"HX-Redirect": target})
return redirect(target)
app.mount(
"/static", StaticFiles(directory=str(files("netmover.web") / "static")), name="static"
)
app.include_router(public)
app.include_router(protected)
return app
__all__ = ["AppSettings", "create_app"]
+163
View File
@@ -0,0 +1,163 @@
"""Anmeldung, Einrichtung beim ersten Start, CSRF und Sperre bei Fehlversuchen.
* Ein einziges Administratorkonto; Passwort-Hash (Argon2) in ``settings``.
* Erster Start: Ohne Passwort führt jede Seite zur Einrichtung. Dafür wird ein
einmaliger Einrichtungscode ins Log geschrieben – so kann nicht irgendwer im
LAN, der die Seite zuerst öffnet, das Passwort festlegen.
* Session-Cookie ``HttpOnly``, ``SameSite=Lax`` (Starlette-SessionMiddleware,
signiert). CSRF-Token pro Session, Pflicht bei jedem POST – als Formularfeld
``csrf_token`` oder Header ``X-CSRF-Token`` (HTMX).
"""
from __future__ import annotations
import hmac
import secrets
import threading
import time
from collections import defaultdict, deque
from pathlib import Path
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from fastapi import HTTPException, Request
from sqlalchemy.orm import Session, sessionmaker
from netmover.settings_store import get_value, set_value
PASSWORD_KEY = "auth.admin_password_hash" # noqa: S105 – Schlüsselname
MIN_PASSWORD_LENGTH = 10
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_hasher = PasswordHasher()
class LoginRequired(Exception):
def __init__(self, setup: bool = False) -> None:
self.setup = setup
def load_or_create_session_key(path: Path) -> str:
import os
path.parent.mkdir(parents=True, exist_ok=True)
try:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
return path.read_text().strip()
key = secrets.token_urlsafe(48)
with os.fdopen(fd, "w") as fh:
fh.write(key + "\n")
return key
def password_is_set(factory: sessionmaker[Session]) -> bool:
return bool(get_value(factory, PASSWORD_KEY))
def set_password(factory: sessionmaker[Session], password: str) -> None:
set_value(factory, PASSWORD_KEY, _hasher.hash(password))
def verify_password(factory: sessionmaker[Session], password: str) -> bool:
stored = get_value(factory, PASSWORD_KEY)
if not stored:
return False
try:
_hasher.verify(stored, password)
except (VerificationError, InvalidHashError):
return False
if _hasher.check_needs_rehash(stored):
set_value(factory, PASSWORD_KEY, _hasher.hash(password))
return True
def password_problem(password: str, confirm: str) -> str | None:
if len(password) < MIN_PASSWORD_LENGTH:
return f"Mindestens {MIN_PASSWORD_LENGTH} Zeichen."
if password != confirm:
return "Die Passwörter stimmen nicht überein."
return None
# -- Session / CSRF -------------------------------------------------------------------
def csrf_token(request: Request) -> str:
token = request.session.get("csrf")
if not isinstance(token, str):
token = secrets.token_urlsafe(32)
request.session["csrf"] = token
return token
def login_session(request: Request) -> None:
"""Neue Session nach erfolgreicher Anmeldung (Schutz vor Session-Fixation)."""
request.session.clear()
request.session["user"] = "admin"
request.session["csrf"] = secrets.token_urlsafe(32)
request.session["since"] = int(time.time())
def is_logged_in(request: Request) -> bool:
return request.session.get("user") == "admin"
async def check_csrf(request: Request) -> None:
if request.method in SAFE_METHODS:
return
expected = request.session.get("csrf")
sent = request.headers.get("x-csrf-token")
if sent is None:
form = await request.form()
value = form.get("csrf_token")
sent = value if isinstance(value, str) else None
if not expected or not sent or not hmac.compare_digest(str(expected), sent):
raise HTTPException(status_code=403, detail="CSRF-Token fehlt oder ist ungültig.")
# -- Sperre bei Fehlversuchen --------------------------------------------------------------
class LoginThrottle:
"""Nach ``limit`` Fehlversuchen innerhalb von ``window`` Sekunden wird die
Client-IP für ``lockout`` Sekunden gesperrt."""
def __init__(self, limit: int = 5, window: float = 900, lockout: float = 300) -> None:
self.limit = limit
self.window = window
self.lockout = lockout
self._fails: dict[str, deque[float]] = defaultdict(deque)
self._locked: dict[str, float] = {}
self._lock = threading.Lock()
def locked_for(self, client: str) -> float:
with self._lock:
until = self._locked.get(client, 0)
return max(0.0, until - time.monotonic())
def failure(self, client: str) -> None:
now = time.monotonic()
with self._lock:
q = self._fails[client]
q.append(now)
while q and now - q[0] > self.window:
q.popleft()
if len(q) >= self.limit:
self._locked[client] = now + self.lockout
q.clear()
def success(self, client: str) -> None:
with self._lock:
self._fails.pop(client, None)
self._locked.pop(client, None)
class SetupCode:
"""Einmaliger Code für die Ersteinrichtung (steht im Log)."""
def __init__(self) -> None:
self.code = "-".join(secrets.token_hex(2) for _ in range(3))
def matches(self, value: str) -> bool:
return hmac.compare_digest(self.code, value.strip().lower())
+111
View File
@@ -0,0 +1,111 @@
/* NetMover – handgeschrieben, ohne Build-Schritt. Hell/Dunkel nach Systemeinstellung. */
:root {
--bg: #f6f7f9; --card: #fff; --fg: #1d2330; --muted: #5d6676; --line: #dfe3ea;
--accent: #2f6fde; --accent-fg: #fff; --ok: #1e8e3e; --warn: #b26a00; --err: #c62828;
--ok-bg: #e6f4ea; --warn-bg: #fff4e0; --err-bg: #fdecea; --info-bg: #e8f0fe;
--radius: 8px; --gap: 12px;
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #12151b; --card: #1b2028; --fg: #e6e9ef; --muted: #9aa3b2; --line: #2c333e;
--accent: #6b9bff; --accent-fg: #0b1020; --ok: #57c27a; --warn: #f0a63a; --err: #ff6b6b;
--ok-bg: #16301f; --warn-bg: #36280f; --err-bg: #3a1717; --info-bg: #182540;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); }
a { color: var(--accent); }
code, pre, .mono { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: .9em; }
pre { white-space: pre-wrap; word-break: break-word; margin: 0; }
header.top { background: var(--card); border-bottom: 1px solid var(--line); }
header.top .inner { max-width: 1200px; margin: 0 auto; display: flex; align-items: center;
gap: var(--gap); padding: 8px 16px; flex-wrap: wrap; }
header.top .brand { font-weight: 700; text-decoration: none; color: var(--fg); margin-right: 8px; }
nav.main { display: flex; gap: 4px; flex-wrap: wrap; flex: 1; }
nav.main a { padding: 6px 10px; border-radius: var(--radius); text-decoration: none; color: var(--fg); }
nav.main a.active, nav.main a:hover { background: var(--bg); }
main { max-width: 1200px; margin: 0 auto; padding: 16px; }
h1 { font-size: 1.4rem; margin: 4px 0 16px; }
h2 { font-size: 1.1rem; margin: 24px 0 8px; }
.muted { color: var(--muted); }
.small { font-size: .85em; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; }
.grid { display: grid; gap: var(--gap); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.spread { justify-content: space-between; }
.stack > * + * { margin-top: 8px; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 99px; font-size: .8em; font-weight: 600;
background: var(--bg); border: 1px solid var(--line); white-space: nowrap; }
.badge.ok { background: var(--ok-bg); color: var(--ok); border-color: transparent; }
.badge.warn { background: var(--warn-bg); color: var(--warn); border-color: transparent; }
.badge.err { background: var(--err-bg); color: var(--err); border-color: transparent; }
.msg { padding: 8px 12px; border-radius: var(--radius); margin: 0 0 10px; }
.msg.ok { background: var(--ok-bg); } .msg.error { background: var(--err-bg); }
.msg.info { background: var(--info-bg); } .msg.warn { background: var(--warn-bg); }
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line);
border-radius: var(--radius); overflow: hidden; }
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { font-size: .8em; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); background: var(--bg); }
tr:last-child td { border-bottom: 0; }
tr.warn td { background: var(--warn-bg); }
tr.err td { background: var(--err-bg); }
.table-wrap { overflow-x: auto; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
button, .btn { font: inherit; padding: 6px 12px; border-radius: var(--radius); border: 1px solid var(--line);
background: var(--card); color: var(--fg); cursor: pointer; text-decoration: none; display: inline-block; }
button.primary, .btn.primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
button.danger { color: var(--err); }
button.small, .btn.small { padding: 3px 9px; font-size: .85em; }
button:disabled { opacity: .5; }
form.inline { display: inline; margin: 0; }
form.form { max-width: 760px; }
fieldset { border: 1px solid var(--line); border-radius: var(--radius); margin: 0 0 14px; padding: 10px 14px 14px; background: var(--card); }
legend { font-weight: 600; padding: 0 6px; }
.fields { display: grid; gap: 10px 14px; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
.field { display: flex; flex-direction: column; gap: 3px; }
.field.wide { grid-column: 1 / -1; }
.field label { font-size: .85em; font-weight: 600; }
.field .hint { font-size: .8em; color: var(--muted); }
.field .error { font-size: .85em; color: var(--err); }
input[type=text], input[type=password], input[type=number], input[type=url], select, textarea {
font: inherit; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); width: 100%; }
textarea { min-height: 70px; }
.check { flex-direction: row; align-items: center; gap: 6px; }
.has-error input, .has-error select, .has-error textarea { border-color: var(--err); }
.steps { list-style: none; padding: 0; margin: 0; }
.steps li { padding: 3px 0; }
.steps .ico { display: inline-block; width: 1.4em; font-weight: 700; }
.steps .ok { color: var(--ok); } .steps .fail { color: var(--err); } .steps .skip { color: var(--muted); }
details > summary { cursor: pointer; }
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline; }
.login { max-width: 380px; margin: 10vh auto; }
[hidden] { display: none !important; }
@media (max-width: 640px) {
main { padding: 10px; }
th, td { padding: 6px; }
table.responsive thead { display: none; }
table.responsive tr { display: block; border-bottom: 1px solid var(--line); padding: 6px 0; }
table.responsive td { display: flex; justify-content: space-between; gap: 10px; border: 0; padding: 3px 8px; }
table.responsive td::before { content: attr(data-label); color: var(--muted); font-size: .85em; }
table.responsive td.num { text-align: left; }
}
.text-err { color: var(--err); } .text-warn { color: var(--warn); } .text-ok { color: var(--ok); }
pre.text-err { margin-top: 6px; }
ul.tight { margin: 4px 0 0; padding-left: 20px; }
.foot { text-align: center; padding: 16px; }
meter.fill { width: 100%; height: 8px; margin: 6px 0 2px; display: block; }
+45
View File
@@ -0,0 +1,45 @@
// NetMover – kleine Helfer. Keine Inline-Skripte (CSP: script-src 'self').
(function () {
"use strict";
var meta = document.querySelector('meta[name="csrf-token"]');
var csrf = meta ? meta.getAttribute("content") : "";
// CSRF-Token für alle HTMX-Anfragen
document.addEventListener("htmx:configRequest", function (e) {
e.detail.headers["X-CSRF-Token"] = csrf;
});
// Rückfrage vor gefährlichen Aktionen: <form data-confirm="…">
document.addEventListener("submit", function (e) {
var msg = e.target.getAttribute && e.target.getAttribute("data-confirm");
if (msg && !window.confirm(msg)) { e.preventDefault(); }
}, true);
// Felder je nach Auswahl ein-/ausblenden: data-show-for="select-name:wert1 wert2"
function refresh(form) {
form.querySelectorAll("[data-show-for]").forEach(function (el) {
var spec = el.getAttribute("data-show-for").split(":");
var ctl = form.querySelector('[name="' + spec[0] + '"]:checked') ||
form.querySelector('select[name="' + spec[0] + '"]');
var value = ctl ? ctl.value : "";
var show = spec[1].split(" ").indexOf(value) !== -1;
el.hidden = !show;
});
}
document.querySelectorAll("form[data-dynamic]").forEach(function (form) {
form.addEventListener("change", function () { refresh(form); });
refresh(form);
});
// Öffentlichen Schlüssel kopieren
document.addEventListener("click", function (e) {
var btn = e.target.closest && e.target.closest("[data-copy]");
if (!btn) return;
var src = document.getElementById(btn.getAttribute("data-copy"));
if (src && navigator.clipboard) {
navigator.clipboard.writeText(src.textContent.trim()).then(function () {
btn.textContent = "kopiert ✓";
});
}
});
})();
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf }}">
<meta name="htmx-config" content='{"includeIndicatorStyles": false, "allowEval": false}'>
<title>{% block title %}NetMover{% endblock %} · NetMover</title>
<link rel="stylesheet" href="/static/app.css?v={{ version }}">
<script src="/static/htmx.min.js?v=2.0.4" defer></script>
<script src="/static/app.js?v={{ version }}" defer></script>
</head>
<body>
{% if logged_in %}
<header class="top"><div class="inner">
<a class="brand" href="/">NetMover</a>
<nav class="main">
<a href="/" class="{{ 'active' if active == 'status' }}">Status</a>
<a href="/mounts" class="{{ 'active' if active == 'mounts' }}">Anbindungen</a>
<a href="/rules" class="{{ 'active' if active == 'rules' }}">Regeln</a>
<a href="/log" class="{{ 'active' if active == 'log' }}">Protokoll</a>
<a href="/settings" class="{{ 'active' if active == 'settings' }}">Einstellungen</a>
</nav>
<form class="inline" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<button class="small">Abmelden</button>
</form>
</div></header>
{% endif %}
<main>
{% for kind, text in flashes %}<p class="msg {{ kind }}">{{ text }}</p>{% endfor %}
{% block content %}{% endblock %}
</main>
<footer class="foot muted small">NetMover {{ version }}</footer>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% from "macros.html" import run_badge %}
{% block title %}Protokoll{% endblock %}
{% block content %}
<h1>Protokoll</h1>
<form class="row card" method="get">
<select name="rule"><option value="">alle Regeln</option>
{% for id, name in rules %}<option value="{{ id }}" {{ 'selected' if id == rule_id }}>{{ name }}</option>{% endfor %}</select>
<select name="status"><option value="">jeder Status</option>
{% for st in [RunStatus.OK, RunStatus.PARTIAL, RunStatus.ERROR, RunStatus.SKIPPED] %}
<option value="{{ st.value }}" {{ 'selected' if st.value == status }}>{{ RUN_LABELS[st] }}</option>{% endfor %}</select>
<label class="row small"><input type="checkbox" name="all" value="1" {{ 'checked' if not active_only }}> auch leere Läufe</label>
<button>Filtern</button>
<span class="muted small">{{ total }} Läufe</span>
</form>
<div class="table-wrap"><table class="responsive">
<thead><tr><th>Zeitpunkt</th><th>Regel</th><th>Status</th><th class="num">Dauer</th><th class="num">gesehen</th><th class="num">übertragen</th><th class="num">übersprungen</th><th class="num">Fehler</th></tr></thead>
<tbody>
{% for run, rule_name in rows %}
<tr class="{{ 'err' if run.status == RunStatus.ERROR }}">
<td data-label="Zeitpunkt">{{ run.started_at|localdt }}{% if run.dry_run %} <span class="badge">Probe</span>{% endif %}{% if run.trigger.value == 'manual' %} <span class="badge">manuell</span>{% endif %}</td>
<td data-label="Regel">{{ rule_name }}</td>
<td data-label="Status">{{ run_badge(run.status) }}</td>
<td data-label="Dauer" class="num">{{ duration(run.started_at, run.finished_at) }}</td>
<td data-label="gesehen" class="num">{{ run.seen }}</td>
<td data-label="übertragen" class="num">{{ run.moved }}</td>
<td data-label="übersprungen" class="num">{{ run.skipped }}</td>
<td data-label="Fehler" class="num">{{ run.failed }}</td>
</tr>
{% if run.message or run.moved or run.skipped or run.failed %}
<tr><td colspan="8">
{% if run.message %}<div class="small {{ 'text-err' if run.status == RunStatus.ERROR }}">{{ run.message }}</div>{% endif %}
{% if run.moved or run.skipped or run.failed %}
<details hx-get="/log/{{ run.id }}/items" hx-trigger="toggle once" hx-target="find .items" hx-swap="innerHTML">
<summary class="small">Einzeldateien</summary><div class="items muted small">lädt …</div></details>
{% endif %}
</td></tr>
{% endif %}
{% else %}
<tr><td colspan="8" class="muted">Keine Läufe.</td></tr>
{% endfor %}
</tbody></table></div>
{% if pages > 1 %}
<div class="row">
{% set base = '?rule=' ~ (rule_id or '') ~ '&status=' ~ status ~ ('&all=1' if not active_only else '') %}
{% if page > 1 %}<a class="btn small" href="{{ base }}&page={{ page - 1 }}">← neuer</a>{% endif %}
<span class="small muted">Seite {{ page }} / {{ pages }}</span>
{% if page < pages %}<a class="btn small" href="{{ base }}&page={{ page + 1 }}">älter →</a>{% endif %}
</div>
{% endif %}
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Anmelden{% endblock %}
{% block content %}
<div class="login card">
<h1>NetMover</h1>
{% if error %}<p class="msg error">{{ error }}</p>{% endif %}
<form method="post" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<div class="field">
<label for="pw">Passwort</label>
<input id="pw" type="password" name="password" autocomplete="current-password" autofocus required>
</div>
<button class="primary">Anmelden</button>
</form>
</div>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% macro field(name, label, value='', type='text', errors={}, hint='', wide=false, placeholder='', attrs='') -%}
<div class="field{{ ' wide' if wide }}{{ ' has-error' if errors.get(name) }}">
<label for="f-{{ name }}">{{ label }}</label>
<input id="f-{{ name }}" type="{{ type }}" name="{{ name }}" value="{{ '' if value is none else value }}"
placeholder="{{ placeholder }}" {{ attrs|safe }}>
{% if errors.get(name) %}<span class="error">{{ errors[name] }}</span>{% endif %}
{% if hint %}<span class="hint">{{ hint }}</span>{% endif %}
</div>
{%- endmacro %}
{% macro select(name, label, options, current, errors={}, hint='') -%}
<div class="field{{ ' has-error' if errors.get(name) }}">
<label for="f-{{ name }}">{{ label }}</label>
<select id="f-{{ name }}" name="{{ name }}">
{% for value, text in options %}
<option value="{{ value }}" {{ 'selected' if value|string == current|string }}>{{ text }}</option>
{% endfor %}
</select>
{% if errors.get(name) %}<span class="error">{{ errors[name] }}</span>{% endif %}
{% if hint %}<span class="hint">{{ hint }}</span>{% endif %}
</div>
{%- endmacro %}
{% macro check(name, label, checked, hint='') -%}
<div class="field check">
<input id="f-{{ name }}" type="checkbox" name="{{ name }}" {{ 'checked' if checked }}>
<label for="f-{{ name }}">{{ label }}</label>
{% if hint %}<span class="hint">{{ hint }}</span>{% endif %}
</div>
{%- endmacro %}
{% macro csrf_field(csrf) %}<input type="hidden" name="csrf_token" value="{{ csrf }}">{% endmacro %}
{% macro state_badge(m) -%}
{% set labels = SFTP_STATE_LABELS if m.type == MountType.SFTP else STATE_LABELS %}
{% set cls = {'mounted': 'ok', 'degraded': 'warn', 'error': 'err'}.get(m.state.value, '') %}
<span class="badge {{ cls }}">{{ labels[m.state] }}</span>
{%- endmacro %}
{% macro run_badge(status) -%}
{% set cls = {'ok': 'ok', 'partial': 'warn', 'error': 'err'}.get(status.value, '') %}
<span class="badge {{ cls }}">{{ RUN_LABELS[status] }}</span>
{%- endmacro %}
@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% from "macros.html" import field, select, check %}
{% block title %}{{ 'Anbindung bearbeiten' if mount else 'Neue Anbindung' }}{% endblock %}
{% block content %}
<h1>{{ 'Anbindung „' ~ mount.name ~ '“ bearbeiten' if mount else 'Neue Anbindung' }}</h1>
{% if errors.get('_') %}<p class="msg error">{{ errors['_'] }}</p>{% endif %}
<form class="form" method="post" data-dynamic>
<input type="hidden" name="csrf_token" value="{{ csrf }}">
{% if mount %}<input type="hidden" name="mount_id" value="{{ mount.id }}">{% endif %}
<fieldset><legend>Verbindung</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')) }}
{{ 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') }}
<div class="field{{ ' has-error' if errors.get('share') }}">
<label for="f-share">Freigabe / Pfad</label>
<input id="f-share" type="text" name="share" value="{{ d.share }}" required>
{% if errors.get('share') %}<span class="error">{{ errors['share'] }}</span>{% endif %}
<span class="hint" data-show-for="type:cifs">Freigabename, z. B. <code>scans</code></span>
<span class="hint" data-show-for="type:nfs3 nfs4">Exportpfad, z. B. <code>/volume1/scans</code></span>
<span class="hint" data-show-for="type:sftp">Absoluter Basisordner, z. B. <code>/opt/paperless/consume</code></span>
</div>
<div data-show-for="type:sftp">{{ field('port', 'SSH-Port', d.port or '', type='number', errors=errors, placeholder='22') }}</div>
</div></fieldset>
<fieldset data-show-for="type:cifs sftp"><legend>Anmeldung</legend><div class="fields">
<div data-show-for="type:sftp">{{ select('auth', 'Anmeldeart', [('ssh_key', 'SSH-Schlüssel (NetMover erzeugt ihn)'), ('password', 'Passwort')], d.auth.value, errors=errors) }}</div>
{{ field('username', 'Benutzername', d.username, errors=errors, attrs='autocomplete="off"', hint='Bei CIFS leer lassen für Gastzugang') }}
<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">
<label for="f-password">Passwort</label>
<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 %}
<span class="hint">{{ 'Leer lassen = unverändert. ' if has_secret }}Bei SFTP mit Schlüssel leer lassen.</span>
</div>
</div>
{% if public_key %}<div class="field wide"><label>Öffentlicher Schlüssel (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 %}
</fieldset>
<fieldset data-show-for="type:cifs"><legend>Eigentümer und Rechte (CIFS)</legend><div class="fields">
{{ field('uid', 'uid', d.uid, type='number', errors=errors, hint='Eigentümer aller Dateien im Mount') }}
{{ field('gid', 'gid', d.gid, type='number', errors=errors) }}
{{ field('file_mode', 'file_mode', d.file_mode, errors=errors, placeholder='0644') }}
{{ field('dir_mode', 'dir_mode', d.dir_mode, errors=errors, placeholder='0755') }}
</div></fieldset>
<fieldset data-show-for="type:cifs nfs3 nfs4"><legend>Erweitert</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.') }}
{{ check('auto_mount', 'Beim Start automatisch einhängen', d.auto_mount) }}
</div></fieldset>
{{ check('enabled', 'Aktiv', d.enabled) }}
<div class="row">
<button class="primary">Speichern</button>
<button type="button" hx-post="/mounts/test" hx-include="closest form" hx-target="#form-check" hx-swap="innerHTML">
Testen <span class="htmx-indicator">(läuft …)</span></button>
<a class="btn" href="/mounts">Abbrechen</a>
</div>
<div id="form-check"></div>
</form>
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% from "macros.html" import state_badge, csrf_field %}
{% block title %}Anbindungen{% endblock %}
{% block content %}
<div class="row spread"><h1>Anbindungen</h1><a class="btn primary" href="/mounts/new">+ Neue Anbindung</a></div>
{% if not mounts %}<p class="muted">Noch keine Anbindungen.</p>{% endif %}
<div class="stack">
{% for m in mounts %}
<div class="card" id="mount-{{ m.id }}">
<div class="row spread">
<div><strong>{{ m.name }}</strong> {{ state_badge(m) }}
{% if not m.enabled %}<span class="badge">deaktiviert</span>{% endif %}
{% if m.auto_mount %}<span class="badge">auto</span>{% endif %}</div>
<div class="row">
<button class="small" hx-post="/mounts/{{ m.id }}/test" hx-target="#check-{{ m.id }}" hx-swap="innerHTML">
Testen <span class="htmx-indicator">…</span></button>
{% if m.type != MountType.SFTP %}
{% if m.state == MountState.MOUNTED or m.state == MountState.DEGRADED %}
<form class="inline" method="post" action="/mounts/{{ m.id }}/unmount" data-confirm="„{{ m.name }}“ aushängen?">{{ csrf_field(csrf) }}<button class="small">Aushängen</button></form>
{% else %}
<form class="inline" method="post" action="/mounts/{{ m.id }}/mount">{{ csrf_field(csrf) }}<button class="small">Einhängen</button></form>
{% endif %}
{% endif %}
<a class="btn small" href="/mounts/{{ m.id }}/edit">Bearbeiten</a>
<form class="inline" method="post" action="/mounts/{{ m.id }}/delete" data-confirm="„{{ m.name }}“ wirklich löschen?">{{ csrf_field(csrf) }}<button class="small danger">Löschen</button></form>
</div>
</div>
<div class="muted small mono">{{ m.type.value }} · {% if m.type == MountType.CIFS %}//{{ m.server }}/{{ m.share }}{% elif m.type == MountType.SFTP %}{{ m.server }}:{{ m.port or 22 }}{{ m.share }}{% else %}{{ m.server }}:{{ m.share }}{% endif %}
{% if m.mountpoint %} → {{ m.mountpoint }}{% endif %}</div>
{% if m.last_error %}<pre class="small text-err">{{ m.last_error }}</pre>{% endif %}
{% if m.type == MountType.SFTP %}
<div class="small">Hostschlüssel: {% if m.host_key %}<span class="mono">{{ m.host_key.split()[0] }}</span> festgehalten
<form class="inline" method="post" action="/mounts/{{ m.id }}/reset-hostkey" data-confirm="Hostschlüssel zurücksetzen? Nur tun, wenn der Server neu aufgesetzt wurde.">{{ csrf_field(csrf) }}<button class="small">zurücksetzen</button></form>
{% else %}<span class="text-warn">noch nicht bestätigt – bitte „Testen“</span>{% endif %}</div>
{% if pubkeys.get(m.id) %}
<details class="small"><summary>Öffentlicher Schlüssel für authorized_keys</summary>
<pre class="mono" id="pub-{{ m.id }}">{{ pubkeys[m.id] }}</pre>
<button class="small" type="button" data-copy="pub-{{ m.id }}">kopieren</button></details>
{% endif %}
{% endif %}
<div id="check-{{ m.id }}">
{% if reports.get(m.id) %}<details class="small"><summary class="muted">Letzter Test {{ m.last_test_at|ago }}</summary>
{% with report = reports[m.id], errors = {} %}{% include "partials/check_result.html" %}{% endwith %}</details>{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,20 @@
{% set step_names = {'mount': 'Einhängen', 'read': 'Lesen', 'write': 'Schreiben', 'connect': 'Verbinden'} %}
<div class="card stack">
{% if errors %}
<p class="msg error">Eingaben unvollständig:</p>
<ul class="tight">{% for k, v in errors.items() %}<li><strong>{{ k }}</strong>: {{ v }}</li>{% endfor %}</ul>
{% elif report %}
<ul class="steps">
{% for st in report.steps %}
<li><span class="ico {{ 'ok' if st.ok else 'skip' if st.ok is none else 'fail' }}">{{ '✓' if st.ok else '–' if st.ok is none else '✗' }}</span>
<strong>{{ step_names.get(st.name, st.name) }}</strong>{% if st.message %}: <span class="{{ 'text-err' if st.ok == false }}">{{ st.message }}</span>{% endif %}</li>
{% endfor %}
</ul>
<div class="small">Geeignet als:
{% for role in report.roles %}<span class="badge ok">{{ role.value }}</span> {% else %}<span class="badge err">nichts</span>{% endfor %}</div>
{% if report.owner %}<div class="small muted">Testdatei gehörte {{ report.owner }}{% if report.root_squash %} (Root-Squash){% endif %}</div>{% endif %}
{% if report.free_bytes is not none %}<div class="small muted">{{ report.free_bytes|bytes }} frei von {{ report.total_bytes|bytes }}</div>{% endif %}
{% if report.host_key_fingerprint %}<div class="small">Hostschlüssel: <span class="mono">{{ report.host_key_fingerprint }}</span></div>{% endif %}
{% for h in report.hints %}<p class="msg info small">{{ h }}</p>{% endfor %}
{% endif %}
</div>
@@ -0,0 +1,18 @@
{% from "macros.html" import run_badge %}
<div class="card stack">
{% if not run %}<p class="msg error">Probelauf nicht möglich (Regel gelöscht?).</p>
{% else %}
<div class="row"><strong>Probelauf</strong> {{ run_badge(run.status) }}
<span class="muted small">{{ run.seen }} gefunden · {{ run.moved }} würden übertragen · {{ run.skipped }} übersprungen</span></div>
{% if run.message %}<p class="msg {{ 'error' if run.status == RunStatus.ERROR else 'info' }} small">{{ run.message }}</p>{% endif %}
{% if items %}
<div class="table-wrap"><table class="responsive small">
<thead><tr><th>Datei</th><th>Ziel</th><th>Ergebnis</th></tr></thead>
<tbody>{% for i in items %}
<tr><td data-label="Datei" class="mono">{{ i.src }}</td><td data-label="Ziel" class="mono">{{ i.dst or '–' }}</td>
<td data-label="Ergebnis">{{ i.message or i.status.value }}</td></tr>
{% endfor %}</tbody></table></div>
{% elif run.status != RunStatus.ERROR %}<p class="muted small">Keine passenden Dateien.</p>{% endif %}
<p class="muted small">Es wurde nichts verändert.</p>
{% endif %}
</div>
@@ -0,0 +1,11 @@
{% set cls = {'moved': 'ok', 'copied': 'ok', 'planned': 'ok', 'failed': 'err', 'quarantined': 'warn'} %}
{% set labels = {'moved': 'verschoben', 'copied': 'kopiert', 'skipped': 'übersprungen', 'failed': 'Fehler', 'quarantined': 'Quarantäne', 'planned': 'geplant'} %}
<table class="responsive small">
<thead><tr><th>Datei</th><th>Ziel</th><th class="num">Größe</th><th>Ergebnis</th></tr></thead>
<tbody>{% for i in items %}
<tr><td data-label="Datei" class="mono">{{ i.src }}</td><td data-label="Ziel" class="mono">{{ i.dst or '–' }}</td>
<td data-label="Größe" class="num">{{ i.size|bytes }}</td>
<td data-label="Ergebnis"><span class="badge {{ cls.get(i.status.value, '') }}">{{ labels[i.status.value] }}</span>
{% if i.message %}<span class="{{ 'text-err' if i.status.value == 'failed' }}">{{ i.message }}</span>{% endif %}</td></tr>
{% else %}<tr><td colspan="4" class="muted">Keine Einzeldateien protokolliert.</td></tr>{% endfor %}
</tbody></table>
@@ -0,0 +1,54 @@
{% from "macros.html" import state_badge, run_badge %}
{% if not health.ok %}
<div class="msg error"><strong>Achtung:</strong>
<ul class="tight">{% for p in health.problems %}<li>{{ p }}</li>{% endfor %}</ul></div>
{% else %}
<p class="msg ok">Alles in Ordnung.</p>
{% endif %}
<h2>Anbindungen</h2>
{% if not mounts %}<p class="muted">Noch keine Anbindungen – <a href="/mounts/new">jetzt anlegen</a>.</p>{% endif %}
<div class="grid">
{% for v in mounts %}{% set m = v.m %}
<div class="card">
<div class="row spread"><strong><a href="/mounts#mount-{{ m.id }}">{{ m.name }}</a></strong>{{ state_badge(m) }}</div>
<div class="muted small mono">{{ m.type.value }} · {{ m.server }}{{ ':' if m.type != MountType.CIFS else '/' }}{{ m.share }}</div>
{% if m.state == MountState.MOUNTED and m.last_mounted_at and m.type != MountType.SFTP %}
<div class="small">eingehängt seit {{ m.last_mounted_at|localdt }}</div>
{% elif m.state in (MountState.DEGRADED, MountState.ERROR) %}
<div class="small">{{ 'gestört' if m.state == MountState.DEGRADED else 'Fehler' }} seit {{ m.state_since|localdt }} ({{ m.state_since|ago }})</div>
{% endif %}
{% if v.usage %}{% set used = (v.usage[0] - v.usage[1]) %}{% set pct = (100 * used / v.usage[0]) if v.usage[0] else 0 %}
<meter class="fill" min="0" max="100" low="85" high="95" optimum="0" value="{{ pct|round(1) }}">{{ pct|round(0)|int }} %</meter>
<div class="small muted">{{ v.usage[1]|bytes }} frei von {{ v.usage[0]|bytes }} ({{ pct|round(0)|int }} % belegt)</div>
{% endif %}
{% if m.last_error %}<pre class="small text-err">{{ m.last_error }}</pre>{% endif %}
</div>
{% endfor %}
</div>
<h2>Regeln</h2>
{% if not rules %}<p class="muted">Noch keine Regeln – <a href="/rules/new">jetzt anlegen</a>.</p>{% else %}
<div class="table-wrap"><table class="responsive">
<thead><tr><th>Regel</th><th>Letztes Ergebnis</th><th>Letzter Erfolg</th><th>Nächster Lauf</th><th></th></tr></thead>
<tbody>
{% for v in rules %}{% set r = v.r %}
<tr class="{{ 'warn' if v.stale }}">
<td data-label="Regel"><strong>{{ r.name }}</strong>{% if not r.enabled %} <span class="badge">aus</span>{% endif %}{% if r.dry_run %} <span class="badge warn">Probelauf</span>{% endif %}
<div class="muted small mono">{{ v.target }}</div></td>
<td data-label="Letztes Ergebnis">
{% if v.running_since %}<span class="badge warn">läuft seit {{ v.running_since|ago|replace('vor ', '') }}</span>
{% elif v.last_run %}{{ run_badge(v.last_run.status) }}
<span class="small muted">{{ v.last_run.finished_at|ago }}{% if v.last_run.seen %} · {{ v.last_run.moved }}/{{ v.last_run.seen }}{% endif %}</span>
{% if v.last_run.status in (RunStatus.ERROR, RunStatus.PARTIAL) and v.last_run.message %}<div class="small text-err">{{ v.last_run.message }}</div>{% endif %}
{% else %}<span class="muted">–</span>{% endif %}</td>
<td data-label="Letzter Erfolg">{{ r.last_success_at|ago }}{% if v.stale %}<div class="small text-warn">länger als 3 × Intervall!</div>{% endif %}
{% if r.consecutive_failures %}<div class="small text-err">{{ r.consecutive_failures }} Fehler in Folge</div>{% endif %}</td>
<td data-label="Nächster Lauf">{{ v.next_run|ago if v.next_run else '–' }}</td>
<td><form class="inline" method="post" action="/rules/{{ r.id }}/run">
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<button class="small" {{ 'disabled' if v.running_since }}>Jetzt ausführen</button></form></td>
</tr>
{% endfor %}
</tbody></table></div>
{% endif %}
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% from "macros.html" import field, select, check %}
{% block title %}{{ 'Regel bearbeiten' if rule_id else 'Neue Regel' }}{% endblock %}
{% block content %}
<h1>{{ 'Regel bearbeiten' if rule_id else 'Neue Regel' }}</h1>
{% if errors.get('_') %}<p class="msg error">{{ errors['_'] }}</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>
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<fieldset><legend>Allgemein</legend><div class="fields">
{{ field('name', 'Name', d.name, errors=errors, attrs='required maxlength="100"') }}
{{ field('interval_seconds', 'Intervall (Sekunden)', d.interval_seconds, type='number', errors=errors, attrs='min="10" required', hint='mindestens 10') }}
{{ check('enabled', 'Aktiv', d.enabled) }}
{{ check('dry_run', 'Nur Probelauf (nichts verändern)', d.dry_run) }}
</div></fieldset>
<fieldset><legend>Quelle</legend><div class="fields">
{{ select('src_mount_id', 'Anbindung', src_options, d.src_mount_id or '', errors=errors) }}
{{ field('src_subpath', 'Unterordner', d.src_subpath, errors=errors, placeholder='z. B. Scans/Max', hint='relativ zur Freigabe; leer = Wurzel') }}
{{ select('pattern_type', 'Filterart', [('glob', 'Glob'), ('regex', 'Regulärer Ausdruck')], d.pattern_type.value, errors=errors) }}
{{ field('pattern', 'Filter', d.pattern, errors=errors, hint='Glob: *.pdf;*.jpg (Dateiname, ohne Groß/klein). Regex: gegen relativen Pfad.') }}
{{ check('recursive', 'Unterordner einbeziehen (Struktur bleibt erhalten)', d.recursive) }}
</div></fieldset>
<fieldset><legend>Ziel</legend><div class="fields">
{{ 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>
{{ field('dst_path', 'Zielordner', d.dst_path, errors=errors, hint='Anbindung: relativer Unterordner (wird angelegt). Lokal: absoluter Pfad, muss existieren.') }}
{{ select('action', 'Aktion', [('move', 'verschieben'), ('copy', 'kopieren (Quelle bleibt)')], d.action.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>
<fieldset><legend>Sicherheit und Grenzen</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('min_age_seconds', 'Mindestalter (s)', d.min_age_seconds, type='number', errors=errors, attrs='min="0"') }}
{{ field('max_size_mb', 'Max. Größe (MB)', d.max_size_mb or '', type='number', errors=errors, hint='leer = unbegrenzt') }}
{{ field('op_timeout_seconds', 'Zeitlimit ohne Fortschritt (s)', d.op_timeout_seconds, type='number', errors=errors, attrs='min="10"') }}
</div></fieldset>
<fieldset><legend>Rechte und Quarantäne</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_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_after', 'nach Fehlversuchen', d.quarantine_after, type='number', errors=errors, attrs='min="1"') }}
</div></fieldset>
<div class="row"><button class="primary">Speichern</button><a class="btn" href="/rules">Abbrechen</a></div>
</form>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% from "macros.html" import run_badge, csrf_field %}
{% block title %}Regeln{% endblock %}
{% block content %}
<div class="row spread"><h1>Regeln</h1><a class="btn primary" href="/rules/new">+ Neue Regel</a></div>
{% if not rules %}<p class="muted">Noch keine Regeln.</p>{% endif %}
<div class="stack">
{% for v in rules %}{% set r = v.r %}
<div class="card" id="rule-{{ r.id }}">
<div class="row spread">
<div><strong>{{ r.name }}</strong>
{% if r.enabled %}<span class="badge ok">aktiv</span>{% else %}<span class="badge">aus</span>{% endif %}
{% if r.dry_run %}<span class="badge warn">nur Probelauf</span>{% endif %}
<span class="badge">{{ 'kopieren' if r.action == RuleAction.COPY else 'verschieben' }}</span>
<span class="badge">alle {{ r.interval_seconds }} s</span></div>
<div class="row">
<form class="inline" method="post" action="/rules/{{ r.id }}/run">{{ csrf_field(csrf) }}<button class="small" {{ 'disabled' if v.running_since }}>Jetzt ausführen</button></form>
<button class="small" hx-post="/rules/{{ r.id }}/dryrun" hx-target="#dry-{{ r.id }}" hx-swap="innerHTML">Probelauf <span class="htmx-indicator">…</span></button>
<form class="inline" method="post" action="/rules/{{ r.id }}/toggle">{{ csrf_field(csrf) }}<button class="small">{{ 'Deaktivieren' if r.enabled else 'Aktivieren' }}</button></form>
<a class="btn small" href="/rules/{{ r.id }}/edit">Bearbeiten</a>
<form class="inline" method="post" action="/rules/{{ r.id }}/delete" data-confirm="Regel „{{ r.name }}“ und ihr Protokoll löschen?">{{ csrf_field(csrf) }}<button class="small danger">Löschen</button></form>
</div>
</div>
<div class="muted small mono">{{ v.target }} · Filter {{ r.pattern }}{{ ' (rekursiv)' if r.recursive }}</div>
<div class="small">Letzter Lauf: {% if v.running_since %}<span class="badge warn">läuft seit {{ v.running_since|ago|replace('vor ', '') }}</span>{% elif v.last_run %}{{ run_badge(v.last_run.status) }} {{ v.last_run.finished_at|ago }}{% else %}–{% endif %}
· letzter Erfolg: <span class="{{ 'text-warn' if v.stale }}">{{ r.last_success_at|ago }}</span>
· nächster: {{ v.next_run|ago if v.next_run else '–' }}</div>
<div id="dry-{{ r.id }}"></div>
</div>
{% endfor %}
</div>
{% endblock %}
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% from "macros.html" import field, select, check, csrf_field %}
{% block title %}Einstellungen{% endblock %}
{% block content %}
<h1>Einstellungen</h1>
<form class="form" method="post" action="/settings" data-dynamic>
{{ csrf_field(csrf) }}
<fieldset><legend>Protokoll</legend><div class="fields">
{{ field('retention_days', 'Aufbewahrung (Tage)', cfg.retention_days, type='number', errors=errors, attrs='min="1" max="3650"') }}
</div></fieldset>
<fieldset><legend>Benachrichtigung (Webhook)</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) }}
<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_content_type', 'Content-Type', cfg.webhook_content_type, errors=errors) }}
{{ check('notify_recovery', 'Entwarnung senden, wenn es wieder läuft', cfg.notify_recovery) }}
<div class="field wide"><label for="f-tpl">Vorlage</label>
<textarea id="f-tpl" name="webhook_template" class="mono">{{ cfg.webhook_template }}</textarea>
<span class="hint">Platzhalter: $event $title $message $rule $time $host – bei JSON automatisch maskiert.</span></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>
</fieldset>
<button class="primary">Speichern</button>
</form>
<h2>Passwort ändern</h2>
<form class="form card stack" method="post" action="/settings/password">
{{ csrf_field(csrf) }}
<div class="fields">
<div class="field"><label for="cur">Aktuelles Passwort</label><input id="cur" type="password" name="current" autocomplete="current-password" required></div>
<div class="field"><label for="np">Neues Passwort</label><input id="np" type="password" name="password" autocomplete="new-password" minlength="10" required></div>
<div class="field"><label for="np2">Wiederholen</label><input id="np2" type="password" name="confirm" autocomplete="new-password" minlength="10" required></div>
</div>
<button>Passwort ändern</button>
</form>
<h2>Export / Import</h2>
<div class="card stack">
<p class="small muted">Export enthält Anbindungen, Regeln und Einstellungen – <strong>ohne</strong> Zugangsdaten und Webhook-URL.
Beim Import werden vorhandene Namen übersprungen; importierte Regeln sind zunächst deaktiviert.</p>
<div class="row"><a class="btn" href="/settings/export">Konfiguration exportieren</a></div>
<form class="row" method="post" action="/settings/import" enctype="multipart/form-data">
{{ csrf_field(csrf) }}<input type="file" name="file" accept="application/json,.json" required><button>Importieren</button>
</form>
</div>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Einrichtung{% endblock %}
{% block content %}
<div class="login card">
<h1>Ersteinrichtung</h1>
<p class="muted">Bitte ein Administratorpasswort festlegen (mindestens 10 Zeichen).
Den Einrichtungscode schreibt NetMover beim Start ins Log:
<code>journalctl -u netmover | grep setup</code></p>
{% if error %}<p class="msg error">{{ error }}</p>{% endif %}
<form method="post" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf }}">
<div class="field"><label for="code">Einrichtungscode</label>
<input id="code" type="text" name="code" autocomplete="off" placeholder="xxxx-xxxx-xxxx" required></div>
<div class="field"><label for="pw">Passwort</label>
<input id="pw" type="password" name="password" autocomplete="new-password" minlength="10" required></div>
<div class="field"><label for="pw2">Passwort wiederholen</label>
<input id="pw2" type="password" name="confirm" autocomplete="new-password" minlength="10" required></div>
<button class="primary">Passwort setzen</button>
</form>
</div>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Status{% endblock %}
{% block content %}
<div class="row spread"><h1>Status</h1>
<span class="muted small">aktualisiert sich alle 15 s</span></div>
<div id="status-body" hx-get="/partials/status" hx-trigger="every 15s" hx-swap="innerHTML">
{% include "partials/status_body.html" %}
</div>
{% endblock %}