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:
co-authored by
Claude Opus 5.5
parent
e2587551b0
commit
2b0b039aeb
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Weboberfläche: FastAPI + Jinja2 + HTMX, serverseitig gerendert."""
|
||||
@@ -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"]
|
||||
@@ -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())
|
||||
@@ -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; }
|
||||
@@ -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 ✓";
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user