This commit is contained in:
2026-07-13 13:12:49 +02:00
commit 0a1d2163e3
26 changed files with 1961 additions and 0 deletions

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
# Keep the build context (and image) clean.
.venv/
__pycache__/
**/__pycache__/
*.pyc
.git/
.gitignore
.env
.env.example
docker/
README.md
# Local dev runtime dirs
data/
backup/
# Dev copy of compiled CSS — rebuilt in the image
app/static/app.css
*.lock

34
.env.example Normal file
View File

@@ -0,0 +1,34 @@
# Copy to .env and adjust. Loaded automatically at startup (dev convenience).
# Real container env vars take precedence over this file.
# --- Dev mode ---
# Auto-off inside Docker, auto-on outside. Force it explicitly if you want.
# In dev: local ./data + ./backup, mock Dockhand (if no creds), SIMULATED backups.
DEV_MODE=true
# --- Flask ---
FLASK_SECRET_KEY=change-me-to-a-long-random-string
# PORT=8080
# --- Dockhand connection (daemon logs in with these) ---
# Leave empty in dev to use the built-in mock Dockhand with example stacks.
DOCKHAND_URL=
DOCKHAND_USER=
DOCKHAND_PASS=
DOCKHAND_PROVIDER=local
DOCKHAND_ENV_ID=1
DOCKHAND_VERIFY_TLS=true
# --- Timezone (display, filenames, cron) ---
TZ=Europe/Berlin
# --- Paths (usually only set inside Docker) ---
# BACKUP_ROOT=/backup
# DATA_DIR=/data
# --- Authentik OIDC for THIS tool's web UI (optional) ---
# Leave empty to keep the UI open (e.g. behind a reverse proxy).
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.venv/
.env
__pycache__/
*.pyc
# Local dev runtime dirs (created by DEV_MODE)
/data/
/backup/
# Compiled Tailwind (built during Docker image build)
/app/static/app.css
*.lock

97
README.md Normal file
View File

@@ -0,0 +1,97 @@
# Dockhand Backup
Kontrolliertes Backup-Tool für Docker-**Stacks**, die von [Dockhand](https://dockhand.pro/)
verwaltet werden. Es fährt jeden Stack **geordnet über die Dockhand-API** herunter (`down`),
sichert die Daten als **unkomprimiertes `.tar`** und fährt ihn sofort wieder hoch (`up`) —
im Gegensatz zum Unraid-eigenen Backup, das Container willkürlich stoppt und
Abhängigkeiten zerstört.
## Eigenschaften
- **Cold-Backup pro Stack**: immer nur EIN Stack ist unten (`down → tar → up`). Ein Stack
wird **nie** unten gelassen — `up` läuft in einem `finally`, auch wenn das Tarren scheitert.
- **Reihenfolge & Ausschluss pro Stack** (per Drag&Drop im Dashboard).
- **Auto-Discovery**: neue Stacks werden vor jedem geplanten Lauf automatisch erkannt und
aufgenommen.
- **Format immer `tar`** (nie `tar.gz`). Ablage unter `/backup/<stack>/<stack>_<ts>.tar`.
- **Retention**: die letzten *N* `.tar` pro Stack behalten.
- **Beliebige Pfade** zusätzlich sichern (z.B. das Dockhand-Stack-Verzeichnis mit
compose/env), global oder pro Stack.
- **JSON als „DB"** (`/data/config.json`) — keine echte Datenbank.
- **Flask + Tailwind** UI, geschützt per **Authentik OIDC** (optional).
- **Zeitplan** per Cron (APScheduler).
## Wie es sichert
Für jeden Stack werden getarrt:
1. die **Bind-Mount-Host-Pfade**, die die Dockhand-/Docker-API meldet
(nur `bind`, keine Named Volumes),
2. das **Dockhand-Stack-Verzeichnis** `<dockhand_stacks_base>/<stack>` (compose/env),
3. stack-eigene **Extra-Pfade**.
> Diese Pfade müssen im Backup-Container unter **demselben absoluten Pfad** gemountet sein,
> wie Dockhand sie zurückgibt (siehe `docker-compose.example.yml`, z.B. `/mnt/user:/mnt/user:ro`).
Archiv-Einträge werden ohne führendes `/` gespeichert. **Wiederherstellen** an den
Originalort:
```bash
tar xf media_20260713-030000.tar -C /
```
## Dev-Modus (lokal testen, ohne Docker)
Läuft die App **außerhalb** von Docker (kein `/.dockerenv`), schaltet sie automatisch in den
Dev-Modus:
- lokale Ordner `./data` und `./backup` statt `/data` und `/backup`,
- ohne konfigurierte Dockhand-Zugangsdaten ein **Mock-Dockhand** mit Beispiel-Stacks,
- **Simulation**: es werden weder echte Stacks gestoppt/gestartet noch echte Host-Pfade
getarrt — jeder Lauf schreibt nur ein winziges Platzhalter-`.tar`. So testest du die
Oberfläche und den Ablauf gefahrlos.
Erzwingen/abschalten via Env-Var `DEV_MODE=true|false`.
```bash
python -m venv .venv && .venv/Scripts/activate # Windows
pip install -r requirements.txt
python wsgi.py # http://localhost:8080 (Tailwind via Play-CDN)
```
Willst du im Dev-Modus gegen dein **echtes** Dockhand listen (aber weiter simuliert
sichern), setze `DOCKHAND_URL`/`DOCKHAND_USER`/`DOCKHAND_PASS`.
## Deployment (Docker / Unraid)
```bash
cp docker/docker-compose.example.yml docker-compose.yml
# Pfade, Secrets und OIDC anpassen
docker compose up -d --build
```
Die UI läuft auf Port `8080`. Wichtige Env-Vars: `DOCKHAND_URL`, `DOCKHAND_USER`,
`DOCKHAND_PASS`, `DOCKHAND_ENV_ID`, `FLASK_SECRET_KEY`, optional `OIDC_*`.
Der Build kompiliert Tailwind mit der Standalone-CLI in `app/static/app.css` — zur Laufzeit
wird **kein CDN** benötigt.
## Projektstruktur
```
app/ # Python-Package
settings.py # Env-basierte Settings (+ Dev-Modus)
store.py # JSON-"DB" (atomar, gelockt)
dockhand.py # Dockhand-API-Client (+ Mock)
backup.py # Backup-Engine
scheduler.py # APScheduler (Cron)
auth.py # Authentik-OIDC-Login
routes.py # Flask-Routes
templates/ # Jinja + Tailwind
static/ # src.css, app.css (build), vendor/sortable.min.js
docker/ # Dockerfile + compose-Beispiel (NICHT im Image)
wsgi.py # Entrypoint
```
## Hinweis
Gitea-Workflows werden vom Betreiber separat ergänzt.

49
app/__init__.py Normal file
View File

@@ -0,0 +1,49 @@
"""Flask application factory."""
import logging
from flask import Flask
from . import auth, scheduler, settings
from .routes import bp as main_bp
from .filters import register_filters
def create_app():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
app = Flask(__name__)
app.secret_key = settings.SECRET_KEY
register_filters(app)
auth.init_app(app) # OIDC (Authentik) — no-op if unconfigured
app.register_blueprint(main_bp)
@app.context_processor
def _inject_globals():
# In dev we pull Tailwind from the Play CDN for zero-build styling;
# in Docker the multi-stage build compiles static/app.css instead.
from . import store
try:
excluded_count = sum(
1 for s in store.load().get("stacks", []) if not s.get("enabled", True))
except Exception: # noqa: BLE001
excluded_count = 0
return {
"dev_mode": settings.DEV_MODE,
"tailwind_cdn": settings.DEV_MODE,
"oidc_enabled": settings.oidc_enabled(),
"excluded_count": excluded_count,
}
scheduler.start() # start the cron scheduler
app.logger.info(
"dockhand-backup started (dev_mode=%s, dockhand=%s, oidc=%s)",
settings.DEV_MODE,
"real" if settings.dockhand_configured() else "mock",
"on" if settings.oidc_enabled() else "off",
)
return app

74
app/auth.py Normal file
View File

@@ -0,0 +1,74 @@
"""Web-UI login via Authentik (OIDC / OpenID Connect), using Authlib.
If the OIDC_* env vars are not configured, authentication is disabled and the
UI is open (useful behind a reverse proxy that already enforces auth).
"""
import functools
from authlib.integrations.flask_client import OAuth
from flask import Blueprint, redirect, request, session, url_for
from . import settings
bp = Blueprint("auth", __name__)
_oauth = OAuth()
_client = None
def init_app(app):
"""Register the OIDC client on the Flask app (no-op if not configured)."""
global _client
_oauth.init_app(app)
if settings.oidc_enabled():
_client = _oauth.register(
name="authentik",
client_id=settings.OIDC_CLIENT_ID,
client_secret=settings.OIDC_CLIENT_SECRET,
server_metadata_url=settings.OIDC_ISSUER.rstrip("/")
+ "/.well-known/openid-configuration",
client_kwargs={"scope": "openid profile email"},
)
app.register_blueprint(bp)
def login_required(view):
@functools.wraps(view)
def wrapped(*args, **kwargs):
if not settings.oidc_enabled():
return view(*args, **kwargs) # auth disabled -> open
if not session.get("user"):
session["next"] = request.url
return redirect(url_for("auth.login"))
return view(*args, **kwargs)
return wrapped
def current_user():
return session.get("user")
@bp.route("/login")
def login():
if not settings.oidc_enabled():
return redirect(url_for("main.dashboard"))
redirect_uri = settings.OIDC_REDIRECT_URI or url_for("auth.callback", _external=True)
return _client.authorize_redirect(redirect_uri)
@bp.route("/callback")
def callback():
token = _client.authorize_access_token()
userinfo = token.get("userinfo") or _client.userinfo()
session["user"] = {
"name": userinfo.get("name") or userinfo.get("preferred_username"),
"email": userinfo.get("email"),
}
nxt = session.pop("next", None)
return redirect(nxt or url_for("main.dashboard"))
@bp.route("/logout")
def logout():
session.pop("user", None)
return redirect(url_for("main.dashboard"))

273
app/backup.py Normal file
View File

@@ -0,0 +1,273 @@
"""Backup engine: discover stacks, cold-backup each one (down -> tar -> up),
apply retention, and back up arbitrary custom paths.
Format is ALWAYS an uncompressed .tar (never .tar.gz), by requirement.
Host bind-mount paths are stored with their leading "/" stripped, so a backup
restores to the original location with: tar xf <file>.tar -C /
Compose + env are fetched from the Dockhand API and embedded under
dockhand-meta/<stack>/ (compose.yaml, env) as reference copies.
In DEV_MODE the down/up lifecycle and the tarring of real host paths are
SIMULATED (a tiny placeholder .tar is written); read-only calls (list, inspect,
compose/env) still run so the UI shows real data.
"""
import io
import logging
import os
import stat
import tarfile
import threading
from . import dockhand, settings, store, timeutil
log = logging.getLogger("backup")
_RUN_LOCK = threading.Lock()
def _now_stamp() -> str:
now = timeutil.now()
return now.strftime("%Y%m%d-%H%M%S") + f"-{now.microsecond // 1000:03d}"
def _iso_now() -> str:
return timeutil.now().isoformat(timespec="seconds")
# --- discovery ----------------------------------------------------------
def _live_map():
return {s["name"]: s for s in dockhand.client.list_stacks()}
def discover(persist: bool = True, live=None) -> dict:
"""Add any unknown live stacks to the config (enabled, appended)."""
names = set((live or _live_map()).keys())
def mutate(config):
if not config.get("auto_discover", True):
return
known = {s["name"] for s in config["stacks"]}
nxt = max((s.get("order", 0) for s in config["stacks"]), default=-1) + 1
for name in sorted(names - known):
config["stacks"].append(
{"name": name, "order": nxt, "enabled": True,
"extra_paths": [], "bind_paths": [], "disabled_binds": []})
nxt += 1
if persist:
return store.update(mutate)
config = store.load()
mutate(config)
return config
def _cache_bind_paths(name: str, paths: list):
def mutate(config):
s = store.get_stack(config, name)
if s is not None:
s["bind_paths"] = paths
store.update(mutate)
# --- tar helpers --------------------------------------------------------
def _write_tar(tar_path: str, paths: list, extra_files: dict = None) -> list:
"""Create an uncompressed tar of host paths + in-memory extra files.
Missing host paths are skipped and returned as warnings."""
warnings = []
os.makedirs(os.path.dirname(tar_path), exist_ok=True)
with tarfile.open(tar_path, "w") as tar: # "w" == uncompressed
for p in paths:
if not os.path.exists(p):
warnings.append(f"missing: {p}")
log.warning("path missing, skipped: %s", p)
continue
mode = os.stat(p).st_mode
if not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)):
warnings.append(f"special file skipped: {p}")
log.warning("special file skipped: %s", p)
continue
arcname = p.lstrip("/").lstrip("\\") or os.path.basename(p)
tar.add(p, arcname=arcname)
for arcname, content in (extra_files or {}).items():
data = content.encode() if isinstance(content, str) else content
info = tarfile.TarInfo(name=arcname)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
return warnings
def _write_placeholder_tar(tar_path: str, paths: list, extra_files: dict = None):
os.makedirs(os.path.dirname(tar_path), exist_ok=True)
body = ("DEV_MODE simulated backup\n\nWould archive host paths:\n"
+ "\n".join(f" - {p}" for p in paths)
+ "\n\nPlus embedded files:\n"
+ "\n".join(f" - {k}" for k in (extra_files or {})) + "\n").encode()
with tarfile.open(tar_path, "w") as tar:
info = tarfile.TarInfo(name="DEV_MODE_SIMULATED.txt")
info.size = len(body)
tar.addfile(info, io.BytesIO(body))
def _apply_retention(folder: str, keep_n: int) -> int:
if keep_n <= 0 or not os.path.isdir(folder):
return 0
tars = [os.path.join(folder, f) for f in os.listdir(folder) if f.endswith(".tar")]
tars.sort(key=lambda f: os.path.getmtime(f), reverse=True)
deleted = 0
for f in tars[keep_n:]:
try:
os.remove(f)
deleted += 1
except OSError as exc:
log.warning("could not delete old backup %s: %s", f, exc)
return deleted
# --- per-stack backup ---------------------------------------------------
def _gather(name: str, live_stack: dict, stack_cfg: dict, config: dict):
"""Return (host_paths, extra_files) for a stack and cache its bind paths."""
bind = dockhand.bind_paths_for_stack(dockhand.client, live_stack or {})
_cache_bind_paths(name, bind) # cache ALL detected binds (for the UI toggles)
# Drop binds the user switched off — silently, no "missing" warning.
disabled = set(stack_cfg.get("disabled_binds") or [])
paths = [b for b in bind if b not in disabled]
base = config.get("dockhand_stacks_base", "")
if base:
paths.append(os.path.join(base, name))
for p in stack_cfg.get("extra_paths") or []:
if p:
paths.append(p)
# de-dup
seen, uniq = set(), []
for p in paths:
if p not in seen:
seen.add(p)
uniq.append(p)
extra_files = {}
compose = dockhand.client.get_compose(name)
if compose:
extra_files[f"dockhand-meta/{name}/compose.yaml"] = compose
env = dockhand.client.get_env_raw(name)
if env:
extra_files[f"dockhand-meta/{name}/env"] = env
return uniq, extra_files
def backup_stack(name: str, live_stack: dict, stack_cfg: dict, config: dict) -> dict:
"""Cold-backup a single stack: down -> tar -> up. Always brought back up."""
folder = os.path.join(settings.BACKUP_ROOT, name)
tar_path = os.path.join(folder, f"{name}_{_now_stamp()}.tar")
paths, extra_files = _gather(name, live_stack, stack_cfg, config)
entry = {"stack": name, "ts": _iso_now(), "status": "ok",
"file": os.path.basename(tar_path), "size": 0, "error": "", "warnings": []}
try:
if settings.DEV_MODE:
log.info("[dev] simulating backup of %s (%d paths)", name, len(paths))
_write_placeholder_tar(tar_path, paths, extra_files)
entry["status"] = "simulated"
else:
dockhand.client.down(name)
try:
warnings = _write_tar(tar_path, paths, extra_files)
entry["warnings"] = warnings
host_missing = [w for w in warnings]
if paths and len(host_missing) == len(paths):
entry["status"] = "error"
entry["error"] = "all host paths missing"
elif warnings:
entry["status"] = "partial"
finally:
dockhand.client.start(name) # never leave a stack down
if os.path.exists(tar_path):
entry["size"] = os.path.getsize(tar_path)
except Exception as exc: # noqa: BLE001
log.exception("backup of %s failed", name)
entry["status"] = "error"
entry["error"] = str(exc)
deleted = _apply_retention(folder, config.get("retention_n", 7))
if deleted:
log.info("retention: deleted %d old backups for %s", deleted, name)
store.update(lambda c: store.add_history(c, entry))
return entry
def backup_custom_paths() -> list:
config = store.load()
results = []
for item in config.get("custom_paths", []):
if not item.get("enabled", True):
continue
label = item.get("label") or "path"
path = item.get("path")
folder = os.path.join(settings.BACKUP_ROOT, "_custom")
tar_path = os.path.join(folder, f"{label}_{_now_stamp()}.tar")
entry = {"stack": f"custom:{label}", "ts": _iso_now(), "status": "ok",
"file": os.path.basename(tar_path), "size": 0, "error": "", "warnings": []}
try:
if settings.DEV_MODE:
_write_placeholder_tar(tar_path, [path])
entry["status"] = "simulated"
else:
warnings = _write_tar(tar_path, [path])
entry["warnings"] = warnings
if warnings:
entry["status"] = "error"
entry["error"] = "; ".join(warnings)
if os.path.exists(tar_path):
entry["size"] = os.path.getsize(tar_path)
except Exception as exc: # noqa: BLE001
entry["status"] = "error"
entry["error"] = str(exc)
_apply_retention(folder, config.get("retention_n", 7))
store.update(lambda c, e=entry: store.add_history(c, e))
results.append(entry)
return results
# --- run orchestration --------------------------------------------------
def run_all() -> dict:
if not _RUN_LOCK.acquire(blocking=False):
log.warning("backup run already in progress; skipping")
return {"skipped": True}
try:
live = _live_map()
config = discover(persist=True, live=live)
results = []
for stack_cfg in store.stacks_by_order(config):
if stack_cfg.get("enabled", True):
name = stack_cfg["name"]
results.append(backup_stack(name, live.get(name, {}), stack_cfg, config))
custom = backup_custom_paths()
return {"stacks": results, "custom": custom}
finally:
_RUN_LOCK.release()
def run_one(stack_name: str) -> dict:
if not _RUN_LOCK.acquire(blocking=False):
return {"skipped": True}
try:
live = _live_map()
config = store.load()
stack_cfg = store.get_stack(config, stack_name) or {"name": stack_name, "extra_paths": []}
return backup_stack(stack_name, live.get(stack_name, {}), stack_cfg, config)
finally:
_RUN_LOCK.release()
def refresh_bind_paths() -> dict:
"""Update the cached bind paths for all known stacks (read-only inspects).
Used to populate dashboard counts without running a backup."""
live = _live_map()
config = store.load()
counts = {}
for stack_cfg in config.get("stacks", []):
name = stack_cfg["name"]
paths = dockhand.bind_paths_for_stack(dockhand.client, live.get(name, {}))
counts[name] = len(paths)
_cache_bind_paths(name, paths)
return counts

294
app/dockhand.py Normal file
View File

@@ -0,0 +1,294 @@
"""Dockhand REST API client.
Endpoints verified against the Dockhand MCP server source (real API):
GET /api/stacks?env=<id> -> list stacks
GET /api/stacks/{name}/compose?env=<id> -> compose content
GET /api/stacks/{name}/env/raw?env=<id> -> raw .env content
POST /api/stacks/{name}/start|stop|down?env= -> lifecycle (SSE stream)
GET /api/containers/{id}/inspect?env=<id> -> full inspect incl. Mounts
There is NO GET /api/stacks/{name} — mounts/bind paths come from container
inspect, compose/env from their own endpoints.
Auth is session-based: POST /api/auth/login sets an HttpOnly cookie stored by
requests.Session. On 401 we log in once and retry.
"""
import json
import logging
import threading
import urllib.parse
import requests
from . import settings
log = logging.getLogger("dockhand")
class DockhandError(Exception):
pass
def _q(name: str) -> str:
return urllib.parse.quote(name, safe="")
def _extract_text(data) -> str:
"""Compose/env endpoints may return raw text or a JSON wrapper."""
if isinstance(data, str):
return data
if isinstance(data, dict):
for key in ("content", "compose", "raw", "data", "env"):
val = data.get(key)
if isinstance(val, str):
return val
return ""
class DockhandClient:
def __init__(self):
self._session = requests.Session()
self._session.verify = settings.DOCKHAND_VERIFY_TLS
self._lock = threading.Lock()
self._logged_in = False
def _url(self, path: str) -> str:
return f"{settings.DOCKHAND_URL}{path}"
def _login(self):
with self._lock:
resp = self._session.post(
self._url("/api/auth/login"),
json={
"username": settings.DOCKHAND_USER,
"password": settings.DOCKHAND_PASS,
"provider": settings.DOCKHAND_PROVIDER,
},
timeout=30,
)
if resp.status_code >= 400:
raise DockhandError(
f"Dockhand login failed ({resp.status_code}): {resp.text[:200]}"
)
self._logged_in = True
def _request(self, method, path, *, stream=False, **kwargs):
params = kwargs.pop("params", {}) or {}
params.setdefault("env", settings.DOCKHAND_ENV_ID)
if not self._logged_in:
self._login()
resp = self._session.request(
method, self._url(path), params=params, stream=stream, timeout=120, **kwargs
)
if resp.status_code == 401:
self._logged_in = False
self._login()
resp = self._session.request(
method, self._url(path), params=params, stream=stream, timeout=120, **kwargs
)
return resp
def _get_json(self, path):
resp = self._request("GET", path)
if resp.status_code >= 400:
raise DockhandError(f"GET {path} -> HTTP {resp.status_code}: {resp.text[:200]}")
try:
return resp.json()
except ValueError:
return resp.text
def _run_sse(self, path, body=None) -> str:
resp = self._request("POST", path, stream=True, json=body,
headers={"Accept": "text/event-stream"})
if resp.status_code >= 400:
raise DockhandError(f"POST {path} -> HTTP {resp.status_code}: {resp.text[:200]}")
last = None
output = []
for raw in resp.iter_lines(decode_unicode=True):
if not raw:
continue
line = raw.strip()
if line.startswith("data:"):
line = line[len("data:"):].strip()
if not line:
continue
try:
obj = json.loads(line)
except ValueError:
output.append(line)
continue
last = obj
if isinstance(obj, dict) and obj.get("output"):
output.append(str(obj["output"]))
if isinstance(last, dict) and last.get("success") is False:
raise DockhandError(f"{path} failed: {last.get('error') or last}")
return "\n".join(output)
# -- public API ------------------------------------------------------
def list_stacks(self) -> list:
data = self._get_json("/api/stacks")
if isinstance(data, list):
return data
if isinstance(data, dict):
return data.get("stacks", data.get("data", []))
return []
def get_compose(self, name: str) -> str:
try:
return _extract_text(self._get_json(f"/api/stacks/{_q(name)}/compose"))
except DockhandError as exc:
log.warning("compose fetch failed for %s: %s", name, exc)
return ""
def get_env_raw(self, name: str) -> str:
try:
return _extract_text(self._get_json(f"/api/stacks/{_q(name)}/env/raw"))
except DockhandError as exc:
log.warning("env fetch failed for %s: %s", name, exc)
return ""
def inspect_container(self, container_id: str) -> dict:
data = self._get_json(f"/api/containers/{_q(container_id)}/inspect")
if isinstance(data, dict):
# Some responses wrap the inspect object under data/container.
for key in ("data", "container", "inspect"):
inner = data.get(key)
if isinstance(inner, dict) and ("Mounts" in inner or "mounts" in inner):
return inner
return data
return {}
def start(self, name: str) -> str:
return self._run_sse(f"/api/stacks/{_q(name)}/start")
def stop(self, name: str) -> str:
return self._run_sse(f"/api/stacks/{_q(name)}/stop")
def down(self, name: str) -> str:
return self._run_sse(f"/api/stacks/{_q(name)}/down", body={"removeVolumes": False})
# ------------------------------------------------------------------ mock
class MockDockhandClient:
"""Fake Dockhand for local UI testing when no credentials are set."""
def __init__(self):
self._stacks = {
"media": {"name": "media", "status": "running", "type": "internal",
"containerDetails": [{"id": "c_jelly", "name": "jellyfin", "state": "running"},
{"id": "c_sonarr", "name": "sonarr", "state": "running"}]},
"nextcloud": {"name": "nextcloud", "status": "running", "type": "internal",
"containerDetails": [{"id": "c_ncapp", "name": "app", "state": "running"},
{"id": "c_ncdb", "name": "db", "state": "running"}]},
"monitoring": {"name": "monitoring", "status": "stopped", "type": "git",
"containerDetails": [{"id": "c_graf", "name": "grafana", "state": "exited"}]},
}
self._inspect = {
"c_jelly": {"Mounts": [{"Type": "bind", "Source": "/mnt/user/appdata/jellyfin"},
{"Type": "bind", "Source": "/mnt/user/media"}]},
"c_sonarr": {"Mounts": [{"Type": "bind", "Source": "/mnt/user/appdata/sonarr"}]},
"c_ncapp": {"Mounts": [{"Type": "bind", "Source": "/mnt/user/appdata/nextcloud"}]},
"c_ncdb": {"Mounts": [{"Type": "bind", "Source": "/mnt/user/appdata/nextcloud-db"},
{"Type": "volume", "Source": "some-named-volume"}]},
"c_graf": {"Mounts": [{"Type": "bind", "Source": "/mnt/user/appdata/grafana"}]},
}
def list_stacks(self):
return list(self._stacks.values())
def get_compose(self, name):
return f"# mock compose for {name}\nservices:\n {name}:\n image: example\n"
def get_env_raw(self, name):
return f"# mock env for {name}\nEXAMPLE=1\n"
def inspect_container(self, container_id):
return self._inspect.get(container_id, {"Mounts": []})
def start(self, name):
if name in self._stacks:
self._stacks[name]["status"] = "running"
return f"[mock] started {name}"
def stop(self, name):
if name in self._stacks:
self._stacks[name]["status"] = "stopped"
return f"[mock] stopped {name}"
def down(self, name):
if name in self._stacks:
self._stacks[name]["status"] = "stopped"
return f"[mock] down {name}"
# --------------------------------------------------------------- helpers
def container_ids(stack: dict) -> list:
"""Container IDs for a stack, from containerDetails (or containers)."""
ids = []
for c in stack.get("containerDetails") or []:
if isinstance(c, dict):
cid = c.get("id") or c.get("Id") or c.get("containerId")
if cid:
ids.append(cid)
if not ids:
for c in stack.get("containers") or []:
if isinstance(c, str):
ids.append(c)
elif isinstance(c, dict):
cid = c.get("id") or c.get("Id")
if cid:
ids.append(cid)
return ids
# System/infra bind mounts that are noise (or impossible) to back up.
_EXCLUDE_EXACT = {
"/var/run/docker.sock", "/run/docker.sock",
"/etc/localtime", "/etc/timezone", "/etc/hosts",
"/etc/resolv.conf", "/etc/hostname",
}
_EXCLUDE_PREFIX = ("/proc", "/sys", "/dev")
def _is_noise_bind(source: str) -> bool:
s = str(source)
if s in _EXCLUDE_EXACT or s.endswith(".sock"):
return True
return any(s == p or s.startswith(p + "/") for p in _EXCLUDE_PREFIX)
def bind_paths_for_stack(cli, stack: dict) -> list:
"""Resolve a stack's host bind-mount paths via container inspect.
Only real bind mounts (Type == 'bind') are returned; named volumes and
system/socket mounts (docker.sock, /etc/localtime, /proc, ...) are skipped.
"""
paths = []
for cid in container_ids(stack):
try:
insp = cli.inspect_container(cid)
except Exception as exc: # noqa: BLE001
log.warning("inspect failed for %s: %s", cid, exc)
continue
mounts = insp.get("Mounts") or insp.get("mounts") or []
for m in mounts:
if not isinstance(m, dict):
continue
mtype = (m.get("Type") or m.get("type") or "").lower()
source = m.get("Source") or m.get("source")
if not source or _is_noise_bind(source):
continue
# bind = explicit bind, or (no type given) an absolute host path
if mtype == "bind" or (not mtype and str(source).startswith("/")):
if source not in paths:
paths.append(source)
return paths
def _make_client():
if settings.dockhand_configured():
return DockhandClient()
return MockDockhandClient()
client = _make_client()

24
app/filters.py Normal file
View File

@@ -0,0 +1,24 @@
"""Jinja template filters."""
def _human_size(num):
try:
num = float(num)
except (TypeError, ValueError):
return "-"
for unit in ("B", "KB", "MB", "GB", "TB"):
if abs(num) < 1024.0:
return f"{num:3.1f} {unit}"
num /= 1024.0
return f"{num:.1f} PB"
def _short_ts(iso):
if not iso:
return "never"
return iso.replace("T", " ")
def register_filters(app):
app.jinja_env.filters["human_size"] = _human_size
app.jinja_env.filters["short_ts"] = _short_ts

282
app/routes.py Normal file
View File

@@ -0,0 +1,282 @@
"""Web UI routes."""
import logging
import threading
from flask import (Blueprint, flash, redirect, render_template, request,
url_for)
from . import backup, dockhand, scheduler, settings, store, timeutil
from .auth import current_user, login_required
log = logging.getLogger("routes")
bp = Blueprint("main", __name__)
def _run_bg(target, *args):
"""Fire a backup in a background thread so the request returns quickly."""
threading.Thread(target=target, args=args, daemon=True).start()
def _live_stacks():
"""Map stack name -> live info from Dockhand; empty dict if unreachable."""
try:
return {s["name"]: s for s in dockhand.client.list_stacks()}
except Exception as exc: # noqa: BLE001
log.warning("could not list stacks from Dockhand: %s", exc)
return {}
def _last_backup(history, name):
for h in history:
if h.get("stack") == name:
return h
return None
def _stack_rows(config, live, history):
rows = []
for s in store.stacks_by_order(config):
info = live.get(s["name"], {})
binds = s.get("bind_paths", [])
disabled = s.get("disabled_binds", [])
rows.append({
"name": s["name"],
"order": s.get("order", 0),
"enabled": s.get("enabled", True),
"status": info.get("status", "unknown"),
"bind_paths": binds, # all detected (cached)
"disabled_binds": disabled, # user switched off
"bind_active": len([b for b in binds if b not in disabled]),
"bind_total": len(binds),
"extra_paths": s.get("extra_paths", []),
"last": _last_backup(history, s["name"]),
})
return rows
@bp.route("/")
@login_required
def dashboard():
config = store.load()
live = _live_stacks()
history = config.get("history", [])
rows = _stack_rows(config, live, history)
known = {s["name"] for s in config["stacks"]}
new_stacks = sorted(n for n in live if n not in known)
return render_template(
"dashboard.html",
stacks=[r for r in rows if r["enabled"]], # excluded live in their own tab
new_stacks=new_stacks,
config=config,
next_run=scheduler.next_run_time(),
dockhand_mode="real" if settings.dockhand_configured() else "mock",
dev_mode=settings.DEV_MODE,
user=current_user(),
)
@bp.route("/excluded")
@login_required
def excluded():
config = store.load()
live = _live_stacks()
history = config.get("history", [])
rows = [r for r in _stack_rows(config, live, history) if not r["enabled"]]
return render_template("excluded.html", stacks=rows, user=current_user())
@bp.route("/stacks/reorder", methods=["POST"])
@login_required
def reorder():
# Expects order[]=name1&order[]=name2...
ordered = request.form.getlist("order[]") or request.form.getlist("order")
def mutate(config):
pos = {name: i for i, name in enumerate(ordered)}
for s in config["stacks"]:
if s["name"] in pos:
s["order"] = pos[s["name"]]
store.update(mutate)
return ("", 204)
@bp.route("/stacks/<name>/toggle", methods=["POST"])
@login_required
def toggle(name):
def mutate(config):
s = store.get_stack(config, name)
if s:
s["enabled"] = not s.get("enabled", True)
store.update(mutate)
return redirect(url_for("main.dashboard"))
@bp.route("/stacks/<name>/paths", methods=["POST"])
@login_required
def save_paths(name):
raw = request.form.get("extra_paths", "")
extra = [p.strip() for p in raw.splitlines() if p.strip()]
# Checkboxes submit the ENABLED binds; anything in the known set that is
# not enabled becomes disabled (skipped silently on backup).
all_binds = request.form.getlist("all_bind")
enabled = set(request.form.getlist("enabled_bind"))
disabled = [b for b in all_binds if b not in enabled]
def mutate(config):
s = store.get_stack(config, name)
if s:
s["extra_paths"] = extra
s["disabled_binds"] = disabled
store.update(mutate)
flash(f"Pfade für {name} gespeichert.", "ok")
return redirect(url_for("main.dashboard"))
@bp.route("/stacks/<name>/backup", methods=["POST"])
@login_required
def backup_one(name):
_run_bg(backup.run_one, name)
flash(f"Backup für {name} gestartet.", "ok")
return redirect(url_for("main.dashboard"))
@bp.route("/backup/run", methods=["POST"])
@login_required
def backup_all():
_run_bg(backup.run_all)
flash("Backup aller Stacks gestartet.", "ok")
return redirect(url_for("main.dashboard"))
@bp.route("/discover", methods=["POST"])
@login_required
def discover():
backup.discover(persist=True)
try:
backup.refresh_bind_paths() # populate bind paths in the same click
except Exception as exc: # noqa: BLE001
flash(f"Stacks übernommen, aber Pfade nicht aktualisiert: {exc}", "err")
return redirect(url_for("main.dashboard"))
flash("Neue Stacks übernommen und Bind-Pfade aktualisiert.", "ok")
return redirect(url_for("main.dashboard"))
@bp.route("/refresh-paths", methods=["POST"])
@login_required
def refresh_paths():
try:
counts = backup.refresh_bind_paths()
flash(f"Bind-Pfade aktualisiert ({sum(counts.values())} gesamt).", "ok")
except Exception as exc: # noqa: BLE001
flash(f"Fehler beim Aktualisieren: {exc}", "err")
return redirect(url_for("main.dashboard"))
@bp.route("/debug/stack/<name>")
@login_required
def debug_stack(name):
"""Raw API shapes for one stack — helps verify the real JSON structure."""
from flask import jsonify
live = {s["name"]: s for s in dockhand.client.list_stacks()}
stack = live.get(name, {})
ids = dockhand.container_ids(stack)
inspects = {}
for cid in ids:
try:
inspects[cid] = dockhand.client.inspect_container(cid)
except Exception as exc: # noqa: BLE001
inspects[cid] = {"error": str(exc)}
return jsonify({
"list_entry": stack,
"container_ids": ids,
"inspects": inspects,
"bind_paths": dockhand.bind_paths_for_stack(dockhand.client, stack),
"compose_head": dockhand.client.get_compose(name)[:500],
"env_head": dockhand.client.get_env_raw(name)[:500],
})
@bp.route("/settings", methods=["GET", "POST"])
@login_required
def settings_view():
if request.method == "POST":
def mutate(config):
config["schedule"] = request.form.get("schedule", config["schedule"]).strip()
config["schedule_enabled"] = request.form.get("schedule_enabled") == "on"
config["auto_discover"] = request.form.get("auto_discover") == "on"
config["timezone"] = request.form.get("timezone", "").strip()
try:
config["retention_n"] = max(0, int(request.form.get("retention_n", 7)))
except ValueError:
pass
config["dockhand_stacks_base"] = request.form.get(
"dockhand_stacks_base", "").strip()
store.update(mutate)
scheduler.reschedule()
flash("Einstellungen gespeichert.", "ok")
return redirect(url_for("main.settings_view"))
config = store.load()
return render_template(
"settings.html",
config=config,
next_run=scheduler.next_run_time(),
tz_name=timeutil.tz_name(),
user=current_user(),
env={
"DOCKHAND_URL": settings.DOCKHAND_URL or "(nicht gesetzt)",
"DOCKHAND_ENV_ID": settings.DOCKHAND_ENV_ID,
"dockhand_mode": "real" if settings.dockhand_configured() else "mock",
"backup_root": settings.BACKUP_ROOT,
"data_dir": settings.DATA_DIR,
"dev_mode": settings.DEV_MODE,
"oidc": settings.oidc_enabled(),
},
)
@bp.route("/custom-paths/add", methods=["POST"])
@login_required
def custom_add():
label = request.form.get("label", "").strip()
path = request.form.get("path", "").strip()
if label and path:
def mutate(config):
config.setdefault("custom_paths", []).append(
{"label": label, "path": path, "enabled": True})
store.update(mutate)
flash("Custom-Pfad hinzugefügt.", "ok")
return redirect(url_for("main.settings_view"))
@bp.route("/custom-paths/<int:idx>/delete", methods=["POST"])
@login_required
def custom_delete(idx):
def mutate(config):
paths = config.get("custom_paths", [])
if 0 <= idx < len(paths):
paths.pop(idx)
store.update(mutate)
return redirect(url_for("main.settings_view"))
@bp.route("/custom-paths/run", methods=["POST"])
@login_required
def custom_run():
_run_bg(backup.backup_custom_paths)
flash("Backup der Custom-Pfade gestartet.", "ok")
return redirect(url_for("main.settings_view"))
@bp.route("/history")
@login_required
def history():
config = store.load()
return render_template("history.html", history=config.get("history", []),
user=current_user())

55
app/scheduler.py Normal file
View File

@@ -0,0 +1,55 @@
"""APScheduler wrapper: runs the full backup on a cron schedule read from the
JSON config. The schedule is (re)applied whenever settings change.
"""
import logging
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from . import backup, store, timeutil
log = logging.getLogger("scheduler")
_scheduler = BackgroundScheduler()
_JOB_ID = "nightly-backup"
def _job():
log.info("scheduled backup starting")
backup.run_all()
log.info("scheduled backup finished")
def start():
if not _scheduler.running:
_scheduler.start()
reschedule()
def reschedule():
"""Apply the cron expression from config, or remove the job if disabled."""
config = store.load()
try:
_scheduler.remove_job(_JOB_ID)
except Exception: # noqa: BLE001 - job may not exist yet
pass
if not config.get("schedule_enabled", True):
log.info("schedule disabled")
return
cron = config.get("schedule", "0 3 * * *")
tz = timeutil.get_tz()
try:
trigger = CronTrigger.from_crontab(cron, timezone=tz)
except ValueError as exc:
log.error("invalid cron '%s': %s — schedule not applied", cron, exc)
return
_scheduler.add_job(_job, trigger, id=_JOB_ID, max_instances=1,
coalesce=True, replace_existing=True)
log.info("scheduled backup with cron '%s' (tz=%s)", cron, timeutil.tz_name())
def next_run_time():
job = _scheduler.get_job(_JOB_ID)
return job.next_run_time if job else None

73
app/settings.py Normal file
View File

@@ -0,0 +1,73 @@
"""Environment-based settings (secrets & connection info).
These come from the container environment and are NOT stored in the JSON "DB".
Mutable, user-editable state lives in <DATA_DIR>/config.json (see store.py).
"""
import os
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.dirname(_APP_DIR)
# Load a .env at the project root if present (handy for local dev). Real
# container env vars always take precedence (override=False).
try:
from dotenv import load_dotenv
load_dotenv(os.path.join(_ROOT, ".env"), override=False)
except ImportError:
pass
def _bool(name: str, default: bool) -> bool:
val = os.environ.get(name)
if val is None:
return default
return val.strip().lower() in ("1", "true", "yes", "on")
# --- Dev mode ---
# When on, the app knows it is NOT inside Docker. The host bind paths reported
# by Dockhand won't exist locally, so the backup engine SIMULATES tar/up/down
# (writes a tiny placeholder .tar, never really stops your stacks) and uses
# local ./data and ./backup folders. Auto-detected via Docker's /.dockerenv.
DEV_MODE = _bool("DEV_MODE", not os.path.exists("/.dockerenv"))
# --- Flask ---
SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "change-me-in-production")
# Default timezone for display, filenames and cron. The container itself may
# run in UTC; the user-facing timezone is configurable (env is just the
# default — the JSON config can override it at runtime).
DEFAULT_TZ = os.environ.get("TZ", "UTC")
# --- Dockhand connection (daemon -> Dockhand) ---
DOCKHAND_URL = os.environ.get("DOCKHAND_URL", "").rstrip("/")
DOCKHAND_USER = os.environ.get("DOCKHAND_USER", "")
DOCKHAND_PASS = os.environ.get("DOCKHAND_PASS", "")
DOCKHAND_PROVIDER = os.environ.get("DOCKHAND_PROVIDER", "local")
DOCKHAND_ENV_ID = os.environ.get("DOCKHAND_ENV_ID", "1")
DOCKHAND_VERIFY_TLS = _bool("DOCKHAND_VERIFY_TLS", True)
def dockhand_configured() -> bool:
"""Real Dockhand client is used only when URL + credentials are present."""
return bool(DOCKHAND_URL and DOCKHAND_USER and DOCKHAND_PASS)
# --- Paths ---
# In Docker these are absolute mount points; in dev they fall back to local
# folders at the project root so nothing needs mounting.
_default_backup = os.path.join(_ROOT, "backup") if DEV_MODE else "/backup"
_default_data = os.path.join(_ROOT, "data") if DEV_MODE else "/data"
BACKUP_ROOT = os.environ.get("BACKUP_ROOT", _default_backup)
DATA_DIR = os.environ.get("DATA_DIR", _default_data)
CONFIG_PATH = os.path.join(DATA_DIR, "config.json")
# --- OIDC (Authentik) for the web UI ---
OIDC_ISSUER = os.environ.get("OIDC_ISSUER", "")
OIDC_CLIENT_ID = os.environ.get("OIDC_CLIENT_ID", "")
OIDC_CLIENT_SECRET = os.environ.get("OIDC_CLIENT_SECRET", "")
OIDC_REDIRECT_URI = os.environ.get("OIDC_REDIRECT_URI", "")
def oidc_enabled() -> bool:
"""OIDC login is active only when fully configured; otherwise UI is open."""
return bool(OIDC_ISSUER and OIDC_CLIENT_ID and OIDC_CLIENT_SECRET)

3
app/static/src.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

2
app/static/vendor/sortable.min.js vendored Normal file

File diff suppressed because one or more lines are too long

92
app/store.py Normal file
View File

@@ -0,0 +1,92 @@
"""JSON "DB" — the only persistence layer (no real database, by design).
All mutable, user-editable state lives in <DATA_DIR>/config.json. Writes are
atomic (temp file + os.replace) and guarded by a cross-process file lock so the
scheduler thread and the web UI never corrupt the file when writing together.
"""
import json
import os
import threading
from filelock import FileLock
from . import settings
_LOCK = threading.RLock()
_FILE_LOCK = FileLock(settings.CONFIG_PATH + ".lock")
DEFAULT_CONFIG = {
"schedule": "0 3 * * *", # cron: daily at 03:00 (in `timezone`)
"schedule_enabled": True,
"timezone": "", # IANA tz, e.g. "Europe/Berlin"; "" -> TZ env/UTC
"retention_n": 7, # keep last N .tar per stack
"auto_discover": True, # auto-add newly seen stacks before a run
"dockhand_stacks_base": "", # host dir where Dockhand stores compose/env
"stacks": [], # [{name, order, enabled, extra_paths:[]}]
"custom_paths": [], # [{label, path, enabled}]
"history": [], # [{stack, ts, status, file, size, error}]
}
MAX_HISTORY = 500
def _ensure_dir():
os.makedirs(settings.DATA_DIR, exist_ok=True)
def load() -> dict:
"""Load config, filling in any missing keys with defaults."""
with _LOCK:
_ensure_dir()
if not os.path.exists(settings.CONFIG_PATH):
save(DEFAULT_CONFIG)
return json.loads(json.dumps(DEFAULT_CONFIG))
with open(settings.CONFIG_PATH, "r", encoding="utf-8") as fh:
data = json.load(fh)
merged = json.loads(json.dumps(DEFAULT_CONFIG))
merged.update(data)
return merged
def save(config: dict) -> None:
"""Atomically persist the whole config."""
with _LOCK, _FILE_LOCK:
_ensure_dir()
tmp = settings.CONFIG_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(config, fh, indent=2, ensure_ascii=False)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, settings.CONFIG_PATH)
def update(mutator) -> dict:
"""Load, apply mutator(config) in place, save, return the new config.
Holds the lock across the whole read-modify-write so concurrent updates
from the UI and scheduler don't clobber each other.
"""
with _LOCK, _FILE_LOCK:
config = load()
mutator(config)
save(config)
return config
def stacks_by_order(config: dict):
"""Stacks sorted by their `order` field."""
return sorted(config.get("stacks", []), key=lambda s: s.get("order", 0))
def get_stack(config: dict, name: str):
for s in config.get("stacks", []):
if s["name"] == name:
return s
return None
def add_history(config: dict, entry: dict) -> None:
"""Prepend a run entry, trimming to MAX_HISTORY. Mutates config in place."""
hist = config.setdefault("history", [])
hist.insert(0, entry)
del hist[MAX_HISTORY:]

56
app/templates/base.html Normal file
View File

@@ -0,0 +1,56 @@
<!doctype html>
<html lang="de" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Dockhand Backup{% endblock %}</title>
{% if tailwind_cdn %}
<script src="https://cdn.tailwindcss.com"></script>
{% else %}
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
{% endif %}
<script src="{{ url_for('static', filename='vendor/sortable.min.js') }}" defer></script>
</head>
<body class="h-full bg-slate-950 text-slate-200">
<div class="min-h-full">
<header class="border-b border-slate-800 bg-slate-900/60 backdrop-blur">
<div class="mx-auto max-w-6xl px-4 py-3 flex items-center gap-6">
<a href="{{ url_for('main.dashboard') }}" class="flex items-center gap-2 font-semibold text-white">
<span class="text-sky-400">⚓</span> Dockhand&nbsp;Backup
</a>
<nav class="flex items-center gap-4 text-sm">
<a href="{{ url_for('main.dashboard') }}" class="hover:text-white {{ 'text-white' if request.endpoint=='main.dashboard' else 'text-slate-400' }}">Dashboard</a>
<a href="{{ url_for('main.excluded') }}" class="hover:text-white {{ 'text-white' if request.endpoint=='main.excluded' else 'text-slate-400' }}">
Ausgeschlossen{% if excluded_count %} <span class="rounded-full bg-slate-700 px-1.5 text-xs">{{ excluded_count }}</span>{% endif %}</a>
<a href="{{ url_for('main.history') }}" class="hover:text-white {{ 'text-white' if request.endpoint=='main.history' else 'text-slate-400' }}">Verlauf</a>
<a href="{{ url_for('main.settings_view') }}" class="hover:text-white {{ 'text-white' if request.endpoint=='main.settings_view' else 'text-slate-400' }}">Einstellungen</a>
</nav>
<div class="ml-auto flex items-center gap-3 text-xs">
{% if dev_mode %}<span class="rounded-full bg-amber-500/20 px-2 py-1 text-amber-300 font-medium">DEV — simuliert</span>{% endif %}
{% if oidc_enabled and user %}
<span class="text-slate-400">{{ user.name or user.email }}</span>
<a href="{{ url_for('auth.logout') }}" class="text-slate-400 hover:text-white">Logout</a>
{% endif %}
</div>
</div>
</header>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mx-auto max-w-6xl px-4 pt-4 space-y-2">
{% for cat, msg in messages %}
<div class="rounded-md border px-3 py-2 text-sm
{{ 'border-emerald-700 bg-emerald-900/40 text-emerald-200' if cat=='ok' else 'border-rose-700 bg-rose-900/40 text-rose-200' }}">
{{ msg }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<main class="mx-auto max-w-6xl px-4 py-6">
{% block content %}{% endblock %}
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,219 @@
{% extends "base.html" %}
{% block title %}Dashboard — Dockhand Backup{% endblock %}
{% macro status_badge(status) %}
{% set cls = {
'running': 'bg-emerald-500/20 text-emerald-300',
'stopped': 'bg-slate-500/20 text-slate-300',
'partial': 'bg-amber-500/20 text-amber-300',
'created': 'bg-sky-500/20 text-sky-300',
}.get(status, 'bg-slate-700/40 text-slate-400') %}
<span class="rounded-full px-2 py-0.5 text-xs font-medium {{ cls }}">{{ status }}</span>
{% endmacro %}
{% block content %}
<div class="flex flex-wrap items-center justify-between gap-3 mb-5">
<div>
<h1 class="text-xl font-semibold text-white">Stacks <span class="text-slate-500 text-base">({{ stacks|length }})</span></h1>
<p class="text-sm text-slate-400">
Dockhand: <span class="font-medium text-slate-300">{{ dockhand_mode }}</span>
· Nächster Lauf:
<span class="font-medium text-slate-300">{{ next_run.strftime('%Y-%m-%d %H:%M %Z') if next_run else 'deaktiviert' }}</span>
</p>
</div>
<div class="flex gap-2">
<button type="button" onclick="document.getElementById('reorder-modal').classList.remove('hidden')"
class="rounded-md border border-slate-700 px-3 py-2 text-sm hover:bg-slate-800">Reihenfolge</button>
<form method="post" action="{{ url_for('main.refresh_paths') }}">
<button class="rounded-md border border-slate-700 px-3 py-2 text-sm hover:bg-slate-800" title="Bind-Pfade per Container-Inspect neu ermitteln">Pfade aktualisieren</button>
</form>
<form method="post" action="{{ url_for('main.discover') }}">
<button class="rounded-md border border-slate-700 px-3 py-2 text-sm hover:bg-slate-800">Neue übernehmen</button>
</form>
<form method="post" action="{{ url_for('main.backup_all') }}"
onsubmit="return confirm('Jetzt ein Backup ALLER Stacks starten?');">
<button class="rounded-md bg-sky-600 px-3 py-2 text-sm font-medium text-white hover:bg-sky-500">Backup: alle</button>
</form>
</div>
</div>
{% if new_stacks %}
<div class="mb-5 rounded-md border border-sky-800 bg-sky-900/30 px-4 py-3 text-sm text-sky-200">
Neue Stacks erkannt: <strong>{{ new_stacks|join(', ') }}</strong> — werden beim nächsten Lauf automatisch übernommen.
</div>
{% endif %}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{% for s in stacks %}
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3 flex flex-col gap-2 {{ 'opacity-60' if not s.enabled }}">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="font-medium text-white truncate" title="{{ s.name }}">{{ s.name }}</div>
<div class="mt-1 flex flex-wrap items-center gap-1">
{{ status_badge(s.status) }}
{% if not s.enabled %}<span class="rounded-full bg-rose-500/20 px-2 py-0.5 text-xs text-rose-300">ausgeschlossen</span>{% endif %}
</div>
</div>
</div>
<div class="text-xs text-slate-400 leading-relaxed">
{% if s.bind_total %}{{ s.bind_active }}/{{ s.bind_total }} Binds aktiv{% endif %}
{% if s.extra_paths %}{% if s.bind_total %} · {% endif %}{{ s.extra_paths|length }} Extra{% endif %}
<br>
Letztes Backup:
{% if s.last %}
<span class="text-slate-300">{{ s.last.ts|short_ts }}</span>
<span class="{{ 'text-emerald-400' if s.last.status in ('ok','simulated') else 'text-amber-400' if s.last.status=='partial' else 'text-rose-400' }}">{{ s.last.status }}</span>
<span class="text-slate-500">({{ s.last.size|human_size }})</span>
{% else %}<span class="text-slate-500">nie</span>{% endif %}
</div>
<div class="mt-auto flex flex-wrap gap-1 pt-1">
<form method="post" action="{{ url_for('main.backup_one', name=s.name) }}"
onsubmit="return confirm('Jetzt ein Backup von „{{ s.name }}“ starten?');">
<button class="rounded-md bg-slate-700 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-slate-600">Backup</button>
</form>
<button type="button"
onclick="openPaths(this)"
data-name="{{ s.name }}"
data-binds='{{ s.bind_paths|tojson }}'
data-disabled='{{ s.disabled_binds|tojson }}'
data-extra="{{ s.extra_paths|join('\n') }}"
class="rounded-md border border-slate-700 px-2.5 py-1.5 text-xs hover:bg-slate-800">Pfade</button>
<form method="post" action="{{ url_for('main.toggle', name=s.name) }}">
<button class="rounded-md border border-slate-700 px-2.5 py-1.5 text-xs hover:bg-slate-800">
{{ 'Einschließen' if not s.enabled else 'Ausschließen' }}
</button>
</form>
</div>
</div>
{% else %}
<p class="text-slate-400 text-sm">Noch keine Stacks. „Neue übernehmen" klicken oder auf den nächsten Lauf warten.</p>
{% endfor %}
</div>
<!-- ===== Reorder modal ===== -->
<div id="reorder-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div class="w-full max-w-md rounded-xl border border-slate-700 bg-slate-900 p-4 shadow-xl">
<div class="flex items-center justify-between mb-3">
<h2 class="font-semibold text-white">Reihenfolge festlegen</h2>
<button type="button" onclick="document.getElementById('reorder-modal').classList.add('hidden')" class="text-slate-400 hover:text-white">✕</button>
</div>
<p class="text-xs text-slate-400 mb-2">Namen ziehen zum Sortieren. Diese Reihenfolge bestimmt, in welcher die Stacks gesichert werden.</p>
<ul id="reorder-list" class="max-h-[60vh] overflow-y-auto space-y-1">
{% for s in stacks %}
<li data-name="{{ s.name }}" class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-950 px-3 py-2 cursor-grab">
<span class="text-slate-600">⋮⋮</span>
<span class="text-sm text-slate-200">{{ s.name }}</span>
</li>
{% endfor %}
</ul>
<div class="mt-4 flex justify-end gap-2">
<button type="button" onclick="document.getElementById('reorder-modal').classList.add('hidden')"
class="rounded-md border border-slate-700 px-3 py-2 text-sm hover:bg-slate-800">Abbrechen</button>
<button type="button" onclick="saveOrder()"
class="rounded-md bg-sky-600 px-3 py-2 text-sm font-medium text-white hover:bg-sky-500">Speichern</button>
</div>
</div>
</div>
<!-- ===== Paths modal (bind toggles + extra paths) ===== -->
<div id="paths-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<form id="paths-form" method="post" class="w-full max-w-lg rounded-xl border border-slate-700 bg-slate-900 p-4 shadow-xl max-h-[85vh] overflow-y-auto">
<div class="flex items-center justify-between mb-3">
<h2 class="font-semibold text-white">Pfade — <span id="paths-stack"></span></h2>
<button type="button" onclick="document.getElementById('paths-modal').classList.add('hidden')" class="text-slate-400 hover:text-white">✕</button>
</div>
<div class="mb-2 flex items-center justify-between">
<label class="block text-xs text-slate-400">Erkannte Bind-Mounts — abgeschaltete werden <strong>ohne Warnung</strong> übersprungen:</label>
<div class="text-xs text-slate-500">
<button type="button" onclick="setAllBinds(true)" class="hover:text-white">alle an</button> ·
<button type="button" onclick="setAllBinds(false)" class="hover:text-white">alle aus</button>
</div>
</div>
<div id="paths-binds" class="mb-4 space-y-1 rounded-md border border-slate-800 bg-slate-950 p-2 text-sm">
<!-- checkboxes injected by JS -->
</div>
<label class="block text-xs text-slate-400 mb-1">Zusätzliche Pfade (ein Pfad pro Zeile):</label>
<textarea id="paths-textarea" name="extra_paths" rows="4"
class="w-full rounded-md border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm font-mono"
placeholder="/mnt/user/appdata/foo"></textarea>
<div class="mt-4 flex justify-end gap-2">
<button type="button" onclick="document.getElementById('paths-modal').classList.add('hidden')"
class="rounded-md border border-slate-700 px-3 py-2 text-sm hover:bg-slate-800">Abbrechen</button>
<button class="rounded-md bg-sky-600 px-3 py-2 text-sm font-medium text-white hover:bg-sky-500">Speichern</button>
</div>
</form>
</div>
<script>
var REORDER_URL = '{{ url_for("main.reorder") }}';
var PATHS_URL_TMPL = '{{ url_for("main.save_paths", name="__NAME__") }}';
document.addEventListener('DOMContentLoaded', function () {
var list = document.getElementById('reorder-list');
if (list && typeof Sortable !== 'undefined') {
Sortable.create(list, { animation: 150 });
}
});
function saveOrder() {
var list = document.getElementById('reorder-list');
var names = Array.from(list.querySelectorAll('li')).map(li => li.dataset.name);
var body = new URLSearchParams();
names.forEach(n => body.append('order[]', n));
fetch(REORDER_URL, { method: 'POST', body: body }).then(() => location.reload());
}
function openPaths(btn) {
var name = btn.dataset.name;
var binds = JSON.parse(btn.dataset.binds || '[]');
var disabled = JSON.parse(btn.dataset.disabled || '[]');
var extra = btn.dataset.extra || '';
document.getElementById('paths-stack').textContent = name;
document.getElementById('paths-textarea').value = extra;
var box = document.getElementById('paths-binds');
box.innerHTML = '';
if (!binds.length) {
box.innerHTML = '<p class="text-xs text-slate-500">Keine Bind-Mounts erkannt. „Pfade aktualisieren" auf dem Dashboard klicken.</p>';
}
binds.forEach(function (p) {
var on = disabled.indexOf(p) === -1;
var row = document.createElement('label');
row.className = 'flex items-center gap-2 py-0.5 cursor-pointer';
row.innerHTML =
'<input type="hidden" name="all_bind" value="' + escapeAttr(p) + '">' +
'<input type="checkbox" name="enabled_bind" value="' + escapeAttr(p) + '"' + (on ? ' checked' : '') + '>' +
'<span class="font-mono text-xs ' + (on ? 'text-slate-200' : 'text-slate-500 line-through') + '">' + escapeHtml(p) + '</span>';
var cb = row.querySelector('input[type=checkbox]');
var span = row.querySelector('span');
cb.addEventListener('change', function () {
span.className = 'font-mono text-xs ' + (cb.checked ? 'text-slate-200' : 'text-slate-500 line-through');
});
box.appendChild(row);
});
document.getElementById('paths-form').action = PATHS_URL_TMPL.replace('__NAME__', encodeURIComponent(name));
document.getElementById('paths-modal').classList.remove('hidden');
}
function setAllBinds(on) {
document.querySelectorAll('#paths-binds input[type=checkbox]').forEach(function (cb) {
cb.checked = on;
cb.dispatchEvent(new Event('change'));
});
}
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function escapeAttr(s) {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
}
</script>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Ausgeschlossen — Dockhand Backup{% endblock %}
{% block content %}
<h1 class="text-xl font-semibold text-white mb-1">Ausgeschlossene Stacks <span class="text-slate-500 text-base">({{ stacks|length }})</span></h1>
<p class="text-sm text-slate-400 mb-5">Diese Stacks werden <strong>nicht</strong> gesichert und erscheinen nicht im Dashboard.</p>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{% for s in stacks %}
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3 flex flex-col gap-2">
<div class="font-medium text-white truncate" title="{{ s.name }}">{{ s.name }}</div>
<div class="text-xs text-slate-400">
Letztes Backup:
{% if s.last %}<span class="text-slate-300">{{ s.last.ts|short_ts }}</span>{% else %}<span class="text-slate-500">nie</span>{% endif %}
</div>
<form method="post" action="{{ url_for('main.toggle', name=s.name) }}" class="mt-auto pt-1">
<button class="rounded-md border border-slate-700 px-2.5 py-1.5 text-xs hover:bg-slate-800">Wieder einschließen</button>
</form>
</div>
{% else %}
<p class="text-slate-400 text-sm">Keine ausgeschlossenen Stacks.</p>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Verlauf — Dockhand Backup{% endblock %}
{% block content %}
<h1 class="text-xl font-semibold text-white mb-5">Backup-Verlauf</h1>
<div class="overflow-x-auto rounded-lg border border-slate-800">
<table class="min-w-full text-sm">
<thead class="bg-slate-900/60 text-slate-400">
<tr>
<th class="text-left px-4 py-2 font-medium">Zeit</th>
<th class="text-left px-4 py-2 font-medium">Stack</th>
<th class="text-left px-4 py-2 font-medium">Status</th>
<th class="text-left px-4 py-2 font-medium">Datei</th>
<th class="text-right px-4 py-2 font-medium">Größe</th>
<th class="text-left px-4 py-2 font-medium">Hinweise</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for h in history %}
<tr class="hover:bg-slate-900/40">
<td class="px-4 py-2 whitespace-nowrap text-slate-300">{{ h.ts|short_ts }}</td>
<td class="px-4 py-2 text-slate-200">{{ h.stack }}</td>
<td class="px-4 py-2">
<span class="rounded-full px-2 py-0.5 text-xs font-medium
{{ 'bg-emerald-500/20 text-emerald-300' if h.status in ('ok','simulated')
else 'bg-amber-500/20 text-amber-300' if h.status=='partial'
else 'bg-rose-500/20 text-rose-300' }}">{{ h.status }}</span>
</td>
<td class="px-4 py-2 font-mono text-slate-400 truncate max-w-xs">{{ h.file }}</td>
<td class="px-4 py-2 text-right text-slate-300">{{ h.size|human_size }}</td>
<td class="px-4 py-2 text-slate-400">
{% if h.error %}<span class="text-rose-400">{{ h.error }}</span>{% endif %}
{% if h.warnings %}<span class="text-amber-400">{{ h.warnings|join('; ') }}</span>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="px-4 py-6 text-center text-slate-500">Noch keine Läufe.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

102
app/templates/settings.html Normal file
View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Einstellungen — Dockhand Backup{% endblock %}
{% block content %}
<h1 class="text-xl font-semibold text-white mb-5">Einstellungen</h1>
<div class="grid gap-6 md:grid-cols-2">
<!-- Schedule / retention -->
<section class="rounded-lg border border-slate-800 bg-slate-900/40 p-4">
<h2 class="font-medium text-white mb-3">Zeitplan &amp; Aufbewahrung</h2>
<form method="post" action="{{ url_for('main.settings_view') }}" class="space-y-3">
<div>
<label class="block text-xs text-slate-400 mb-1">Cron-Ausdruck</label>
<input name="schedule" value="{{ config.schedule }}"
class="w-full rounded-md border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm font-mono">
<p class="text-xs text-slate-500 mt-1">z.B. <code>0 3 * * *</code> = täglich 03:00 ({{ tz_name }}). Nächster Lauf:
{{ next_run.strftime('%Y-%m-%d %H:%M %Z') if next_run else 'deaktiviert' }}</p>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Zeitzone (IANA, für Anzeige, Dateinamen &amp; Cron)</label>
<input name="timezone" value="{{ config.timezone }}" placeholder="Europe/Berlin"
class="w-full rounded-md border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm font-mono">
<p class="text-xs text-slate-500 mt-1">Leer = <code>TZ</code>-Env-Var, sonst UTC. Aktiv: <span class="text-slate-300">{{ tz_name }}</span></p>
</div>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="schedule_enabled" {{ 'checked' if config.schedule_enabled }}>
Zeitplan aktiv
</label>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="auto_discover" {{ 'checked' if config.auto_discover }}>
Neue Stacks automatisch vor jedem Lauf übernehmen
</label>
<div>
<label class="block text-xs text-slate-400 mb-1">Aufbewahrung (letzte N .tar pro Stack)</label>
<input name="retention_n" type="number" min="0" value="{{ config.retention_n }}"
class="w-32 rounded-md border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm">
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Dockhand-Stacks-Verzeichnis (compose/env liegen hier)</label>
<input name="dockhand_stacks_base" value="{{ config.dockhand_stacks_base }}"
placeholder="/mnt/user/appdata/dockhand/stacks"
class="w-full rounded-md border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm font-mono">
</div>
<button class="rounded-md bg-sky-600 px-3 py-2 text-sm font-medium text-white hover:bg-sky-500">Speichern</button>
</form>
</section>
<!-- Environment (read-only) -->
<section class="rounded-lg border border-slate-800 bg-slate-900/40 p-4">
<h2 class="font-medium text-white mb-3">Umgebung (aus Env-Vars)</h2>
<dl class="text-sm space-y-2">
<div class="flex justify-between gap-4"><dt class="text-slate-400">Dockhand-URL</dt><dd class="font-mono text-slate-300 truncate">{{ env.DOCKHAND_URL }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-400">Env-ID</dt><dd class="font-mono text-slate-300">{{ env.DOCKHAND_ENV_ID }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-400">Dockhand-Client</dt><dd class="font-mono text-slate-300">{{ env.dockhand_mode }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-400">Backup-Ziel</dt><dd class="font-mono text-slate-300 truncate">{{ env.backup_root }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-400">Daten-Verzeichnis</dt><dd class="font-mono text-slate-300 truncate">{{ env.data_dir }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-400">Dev-Modus</dt><dd class="font-mono {{ 'text-amber-300' if env.dev_mode else 'text-slate-300' }}">{{ env.dev_mode }}</dd></div>
<div class="flex justify-between gap-4"><dt class="text-slate-400">OIDC-Login</dt><dd class="font-mono text-slate-300">{{ 'aktiv' if env.oidc else 'aus' }}</dd></div>
</dl>
<p class="text-xs text-slate-500 mt-3">Diese Werte werden über Container-Umgebungsvariablen gesetzt, nicht hier.</p>
</section>
</div>
<!-- Custom paths -->
<section class="mt-6 rounded-lg border border-slate-800 bg-slate-900/40 p-4">
<div class="flex items-center justify-between mb-3">
<h2 class="font-medium text-white">Beliebige Pfade sichern</h2>
<form method="post" action="{{ url_for('main.custom_run') }}"
onsubmit="return confirm('Jetzt ein Backup der Custom-Pfade starten?');">
<button class="rounded-md border border-slate-700 px-3 py-1.5 text-xs hover:bg-slate-800">Backup: Custom-Pfade</button>
</form>
</div>
{% if config.custom_paths %}
<ul class="divide-y divide-slate-800 mb-3">
{% for c in config.custom_paths %}
<li class="flex items-center gap-3 py-2 text-sm">
<span class="font-medium text-slate-200">{{ c.label }}</span>
<span class="font-mono text-slate-400 truncate">{{ c.path }}</span>
<form method="post" action="{{ url_for('main.custom_delete', idx=loop.index0) }}" class="ml-auto">
<button class="text-rose-400 hover:text-rose-300 text-xs">Entfernen</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-sm text-slate-500 mb-3">Noch keine Custom-Pfade. Nützlich z.B. für das Dockhand-Stack-Verzeichnis oder andere Ordner.</p>
{% endif %}
<form method="post" action="{{ url_for('main.custom_add') }}" class="flex flex-wrap gap-2 items-end">
<div>
<label class="block text-xs text-slate-400 mb-1">Label</label>
<input name="label" class="rounded-md border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm" placeholder="dockhand-stacks">
</div>
<div class="flex-1 min-w-[240px]">
<label class="block text-xs text-slate-400 mb-1">Pfad</label>
<input name="path" class="w-full rounded-md border border-slate-700 bg-slate-950 px-2 py-1.5 text-sm font-mono" placeholder="/mnt/user/appdata/dockhand">
</div>
<button class="rounded-md bg-sky-600 px-3 py-2 text-sm font-medium text-white hover:bg-sky-500">Hinzufügen</button>
</form>
</section>
{% endblock %}

31
app/timeutil.py Normal file
View File

@@ -0,0 +1,31 @@
"""Timezone handling.
The container may run in UTC, but timestamps (history, filenames) and the cron
schedule are evaluated in a user-configurable timezone stored in the JSON config
(`timezone`), defaulting to the TZ env var, then UTC.
"""
import datetime
import logging
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from . import settings, store
log = logging.getLogger("timeutil")
def tz_name() -> str:
return store.load().get("timezone") or settings.DEFAULT_TZ or "UTC"
def get_tz() -> ZoneInfo:
name = tz_name()
try:
return ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError, OSError):
log.warning("unknown timezone '%s', falling back to UTC", name)
return ZoneInfo("UTC")
def now() -> datetime.datetime:
"""Timezone-aware 'now' in the configured timezone."""
return datetime.datetime.now(get_tz())

32
docker/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1
# ---- Stage 1: compile Tailwind CSS with the standalone CLI (no Node) ----
FROM debian:bookworm-slim AS css
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL -o /usr/local/bin/tailwindcss \
https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.10/tailwindcss-linux-x64 \
&& chmod +x /usr/local/bin/tailwindcss
COPY tailwind.config.js ./
COPY app/templates ./app/templates
COPY app/static/src.css ./app/static/src.css
RUN tailwindcss -c tailwind.config.js -i app/static/src.css -o app/static/app.css --minify
# ---- Stage 2: runtime ----
FROM python:3.12-slim AS runtime
WORKDIR /app
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY wsgi.py ./
# Compiled CSS from the build stage (overwrites any dev copy).
COPY --from=css /build/app/static/app.css ./app/static/app.css
EXPOSE 8080
# Single worker so only ONE APScheduler runs; threads handle UI concurrency.
CMD ["gunicorn", "-w", "1", "--threads", "8", "-b", "0.0.0.0:8080", \
"--timeout", "600", "wsgi:app"]

View File

@@ -0,0 +1,44 @@
# Example deployment for Unraid / Docker.
# Copy to docker-compose.yml, adjust paths & secrets, then: docker compose up -d --build
services:
dockhand-backup:
build:
context: ..
dockerfile: docker/Dockerfile
image: dockhand-backup:latest
container_name: dockhand-backup
restart: unless-stopped
ports:
- "8080:8080"
environment:
# --- Dockhand connection (daemon logs in with these) ---
DOCKHAND_URL: "http://192.168.1.10:3000"
DOCKHAND_USER: "backup-service"
DOCKHAND_PASS: "change-me"
DOCKHAND_PROVIDER: "local" # local user/password login in Dockhand
DOCKHAND_ENV_ID: "1"
DOCKHAND_VERIFY_TLS: "true"
# --- Flask ---
FLASK_SECRET_KEY: "generate-a-long-random-string"
# --- Authentik OIDC for THIS tool's web UI (optional) ---
# Leave unset to run the UI open (e.g. behind a reverse proxy).
OIDC_ISSUER: "" # e.g. https://auth.example.com/application/o/dockhand-backup/
OIDC_CLIENT_ID: ""
OIDC_CLIENT_SECRET: ""
OIDC_REDIRECT_URI: "" # e.g. https://backup.example.com/callback
TZ: "Europe/Berlin"
# DEV_MODE auto-off inside Docker; force with DEV_MODE: "false" if needed.
volumes:
# Backup output — pick where the .tar files land on the host:
- /mnt/user/backups/stacks:/backup
# JSON "DB" / config persistence:
- /mnt/user/appdata/dockhand-backup:/data
# IMPORTANT: bind-mount host paths must exist INSIDE the container at the
# SAME absolute path Dockhand reports. Mount ONLY what you want backed up —
# anything not mounted (or switched off per bind in the UI) is skipped.
- /mnt/user:/mnt/user:ro

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
Flask==3.0.3
APScheduler==3.10.4
requests==2.32.3
Authlib==1.3.2
filelock==3.15.4
gunicorn==22.0.0
tzdata==2024.1
python-dotenv==1.0.1

8
tailwind.config.js Normal file
View File

@@ -0,0 +1,8 @@
/** Tailwind config — scanned by the standalone CLI during the Docker build. */
module.exports = {
content: ["./app/templates/**/*.html"],
theme: {
extend: {},
},
plugins: [],
};

15
wsgi.py Normal file
View File

@@ -0,0 +1,15 @@
"""WSGI/entry point.
Production (in Docker): gunicorn -w1 --threads 4 wsgi:app
Local dev: python wsgi.py (Flask dev server on :8080)
"""
from app import create_app
app = create_app()
if __name__ == "__main__":
import os
# use_reloader=False: the reloader would start a second APScheduler.
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8080)),
debug=True, use_reloader=False)