initial
This commit is contained in:
17
.dockerignore
Normal file
17
.dockerignore
Normal 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
34
.env.example
Normal 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
10
.gitignore
vendored
Normal 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
97
README.md
Normal 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
49
app/__init__.py
Normal 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
74
app/auth.py
Normal 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
273
app/backup.py
Normal 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
294
app/dockhand.py
Normal 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
24
app/filters.py
Normal 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
282
app/routes.py
Normal 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
55
app/scheduler.py
Normal 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
73
app/settings.py
Normal 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
3
app/static/src.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
2
app/static/vendor/sortable.min.js
vendored
Normal file
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
92
app/store.py
Normal 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
56
app/templates/base.html
Normal 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 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>
|
||||
219
app/templates/dashboard.html
Normal file
219
app/templates/dashboard.html
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
function escapeAttr(s) {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
24
app/templates/excluded.html
Normal file
24
app/templates/excluded.html
Normal 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 %}
|
||||
43
app/templates/history.html
Normal file
43
app/templates/history.html
Normal 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
102
app/templates/settings.html
Normal 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 & 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 & 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
31
app/timeutil.py
Normal 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
32
docker/Dockerfile
Normal 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"]
|
||||
44
docker/docker-compose.example.yml
Normal file
44
docker/docker-compose.example.yml
Normal 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
8
requirements.txt
Normal 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
8
tailwind.config.js
Normal 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
15
wsgi.py
Normal 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)
|
||||
Reference in New Issue
Block a user