reconcile: import current prod state from CT151 (media-cleanup, access admin, ops versioning, newer dashboard)

This commit is contained in:
agw
2026-06-15 12:27:24 +02:00
parent 8f73960fb4
commit d9d5a12df1
14 changed files with 1875 additions and 91 deletions
+119
View File
@@ -162,3 +162,122 @@ async def set_group_members(gpk, user_pks):
ops += [c.post(f"/api/v3/core/groups/{gpk}/remove_user/", json={"pk": int(p)}) for p in cur - desired] ops += [c.post(f"/api/v3/core/groups/{gpk}/remove_user/", json={"pk": int(p)}) for p in cur - desired]
await asyncio.gather(*ops) await asyncio.gather(*ops)
return True return True
async def admin_group_pk(name):
"""UUID for ADMIN_GROUP-navnet (til guardrails). Cachet via list_groups()."""
for g in await list_groups():
if g["name"] == name:
return g["pk"]
return None
async def user_by_username(username):
"""Slå nuværende bruger op (pk + gruppe-uuids) til guardrail-tjek."""
for u in await list_users():
if u["username"] == username:
return {"pk": str(u["pk"]), "groups": set(u["groups"])}
return None
# ---------------- per-app adgang (Authentik policy-bindings) ----------------
# Adgang til en app styres af bindings på app'en: ingen bindings = åben for alle
# autentificerede; ≥1 binding = kun de bundne grupper/brugere. Vi administrerer
# KUN simple (negate=false) gruppe-/bruger-bindings og lader policy-/negate-
# bindings være urørte (de styres bevidst i Authentik selv).
async def _all_app_bindings(c):
out, page = {}, 1
while True:
r = (await c.get("/api/v3/policies/bindings/",
params={"page_size": 100, "page": page})).json()
for b in r.get("results", []):
out.setdefault(b.get("target"), []).append(b)
if not (r.get("pagination") or {}).get("next"):
break
page += 1
return out
def _split_bindings(blist):
"""→ (gruppe-uuids, bruger-pk-strenge, antal andre bindings vi ikke rører)."""
groups, users, other = set(), set(), 0
for b in blist or []:
if b.get("negate") or b.get("policy"):
other += 1
elif b.get("group"):
groups.add(str(b["group"]))
elif b.get("user") is not None:
users.add(str(b["user"]))
else:
other += 1
return groups, users, other
async def list_apps_access():
"""Alle apps + adgangs-resumé (åben / antal principaler). Cachet 30s."""
hit = cache.get("access:list")
if hit is not None:
return hit
async with _client(20) as c:
r = await c.get("/api/v3/core/applications/",
params={"page_size": 100, "superuser_full_list": "true"})
apps = r.json().get("results", [])
bindings = await _all_app_bindings(c)
out = []
for a in apps:
if a.get("slug") == "weircon-portal":
continue
blist = bindings.get(a["pk"], [])
g, u, other = _split_bindings(blist)
out.append({"slug": a["slug"], "name": a.get("name"), "pk": a["pk"],
"group": a.get("group") or "", "icon": a.get("meta_icon") or "",
"open": not blist, "n_groups": len(g), "n_users": len(u),
"n_other": other})
out.sort(key=lambda x: ((x["group"] or "~~").lower(), (x["name"] or "").lower()))
cache.put("access:list", out, 30)
return out
async def app_access(slug):
"""Bundne grupper/brugere for én app (til redigering)."""
async with _client(20) as c:
r = (await c.get("/api/v3/core/applications/",
params={"slug": slug, "superuser_full_list": "true"})).json()
results = r.get("results", [])
if not results:
return None
app_ = results[0]
b = (await c.get("/api/v3/policies/bindings/",
params={"target": app_["pk"], "page_size": 100})).json().get("results", [])
g, u, other = _split_bindings(b)
return {"slug": app_["slug"], "name": app_.get("name"), "pk": app_["pk"],
"icon": app_.get("meta_icon") or "", "groups": g, "users": u, "n_other": other}
async def set_app_access(app_pk, want_groups, want_users):
"""Diff & anvend simple gruppe-/bruger-bindings for én app.
Returnerer (antal_tilføjet, antal_fjernet). Rører ikke policy-/negate-bindings."""
want_g = set(str(x) for x in want_groups)
want_u = set(str(x) for x in want_users)
async with _client(25) as c:
cur = (await c.get("/api/v3/policies/bindings/",
params={"target": app_pk, "page_size": 100})).json().get("results", [])
cur_g = {str(b["group"]): b["pk"] for b in cur if b.get("group") and not b.get("negate")}
cur_u = {str(b["user"]): b["pk"] for b in cur if b.get("user") is not None and not b.get("negate")}
orders = [b.get("order") or 0 for b in cur]
nxt = (max(orders) + 1) if orders else 0
ops = []
for gid in want_g - set(cur_g):
ops.append(c.post("/api/v3/policies/bindings/",
json={"target": app_pk, "group": gid, "order": nxt, "enabled": True}))
nxt += 1
for uid in want_u - set(cur_u):
ops.append(c.post("/api/v3/policies/bindings/",
json={"target": app_pk, "user": int(uid), "order": nxt, "enabled": True}))
nxt += 1
dels = [cur_g[g] for g in set(cur_g) - want_g] + [cur_u[u] for u in set(cur_u) - want_u]
for pk in dels:
ops.append(c.delete(f"/api/v3/policies/bindings/{pk}/"))
await asyncio.gather(*ops)
return len(want_g - set(cur_g)) + len(want_u - set(cur_u)), len(dels)
+33
View File
@@ -21,12 +21,41 @@ RP_BASE_URL = os.environ.get("RP_BASE_URL", "https://rp.weircon.dk").rstrip("/")
RP_API_KEY = os.environ.get("RP_API_KEY", "") RP_API_KEY = os.environ.get("RP_API_KEY", "")
ASSETS_VERSION_URL = os.environ.get("ASSETS_VERSION_URL", "https://assets.weircon.dk/VERSION") ASSETS_VERSION_URL = os.environ.get("ASSETS_VERSION_URL", "https://assets.weircon.dk/VERSION")
GEOIP_DB = os.environ.get("GEOIP_DB", str(DATA / "dbip-country.mmdb")) GEOIP_DB = os.environ.get("GEOIP_DB", str(DATA / "dbip-country.mmdb"))
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") # valgfri: hæver GitHub-rate-limit 60→5000/t
NTFY_URL = os.environ.get("NTFY_URL", "") # fx https://ntfy.weircon.dk
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "ops")
NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "") # tk_… publish-token
DISCO_BASE_URL = os.environ.get("DISCO_BASE_URL", "") # DiscoPanel Connect-RPC (CT137)
DISCO_API_TOKEN = os.environ.get("DISCO_API_TOKEN", "")
DISCO_PANEL_URL = os.environ.get("DISCO_PANEL_URL", "") # bruger-vendt panel-URL til forsiden
USERPREFS = DATA / "userprefs.json" USERPREFS = DATA / "userprefs.json"
IP_LABELS_LOCAL = DATA / "ip_labels.json" IP_LABELS_LOCAL = DATA / "ip_labels.json"
IP_LABELS_REMOTE = os.environ.get("IP_LABELS_REMOTE", "") IP_LABELS_REMOTE = os.environ.get("IP_LABELS_REMOTE", "")
SETTINGS_PATH = DATA / "settings.json" SETTINGS_PATH = DATA / "settings.json"
# ---- Medie-oprydning (Emby + Sonarr/Radarr) ----
EMBY_URL = os.environ.get("EMBY_URL", "").rstrip("/")
EMBY_API_KEY = os.environ.get("EMBY_API_KEY", "")
RADARR_URL = os.environ.get("RADARR_URL", "").rstrip("/")
RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "")
SONARR_URL = os.environ.get("SONARR_URL", "").rstrip("/")
SONARR_API_KEY = os.environ.get("SONARR_API_KEY", "")
NTFY_MEDIA_TOPIC = os.environ.get("NTFY_MEDIA_TOPIC", "media")
MEDIA_STATE_PATH = DATA / "media_state.json"
MEDIA_SETTINGS_PATH = DATA / "media_settings.json"
# Standardindstillinger — kan redigeres i /admin/media:
MEDIA_DEFAULTS = {
"enabled": False, # dry-run indtil du selv slår sletning til
"age_days": 90, # tilføjet for mindst så længe siden
"grace_days": 14, # "på vej ud"-vindue før sletning
"disk_throttle": True, # slet kun når lageret er over disk_high %
"disk_high": 85, # start sletning over denne fyldningsgrad
"disk_low": 80, # stop igen når under denne
"do_movies": True,
"do_series": True,
}
GROUP_ORDER = {n: i for i, n in enumerate( GROUP_ORDER = {n: i for i, n in enumerate(
["Home", "Familie", "Medie", "Social", "AI", "Office", "Finans", "dev", "game", "Admin"])} ["Home", "Familie", "Medie", "Social", "AI", "Office", "Finans", "dev", "game", "Admin"])}
@@ -45,4 +74,8 @@ ADMIN_MODULES = [
"desc": "Opret og administrér brugere + adgang (via grupper)."}, "desc": "Opret og administrér brugere + adgang (via grupper)."},
{"key": "groups", "title": "Grupper", "icon": "👥", {"key": "groups", "title": "Grupper", "icon": "👥",
"desc": "Grupper og medlemmer — styrer hvilke services brugere har adgang til."}, "desc": "Grupper og medlemmer — styrer hvilke services brugere har adgang til."},
{"key": "access", "title": "Adgang til services", "icon": "🔐",
"desc": "Hvilke brugere og grupper kan tilgå hver enkelt service."},
{"key": "media", "title": "Medie-oprydning", "icon": "🎬",
"desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."},
] ]
+207 -11
View File
@@ -7,11 +7,15 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
import time
import authentik as ak import authentik as ak
import cache import cache
import config import config
import mediaclean as mc
import services as svc import services as svc
import store import store
import versions
app = FastAPI(title="Weircon Portal") app = FastAPI(title="Weircon Portal")
app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=True, app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, https_only=True,
@@ -83,18 +87,22 @@ async def dashboard_content(request: Request):
user = current_user(request) user = current_user(request)
if not user: if not user:
raise HTTPException(401) raise HTTPException(401)
import asyncio
mc_task = asyncio.create_task(svc.minecraft_servers())
apps, error = [], None apps, error = [], None
try: try:
apps = await ak.fetch_user_apps(user["username"]) apps = await ak.fetch_user_apps(user["username"])
except Exception as e: except Exception as e:
error = f"Kunne ikke hente apps fra Authentik: {e}" error = f"Kunne ikke hente apps fra Authentik: {e}"
mc_servers = await mc_task
prefs = store.get_prefs(user["username"]) prefs = store.get_prefs(user["username"])
fav_set = set(prefs["favorites"]) fav_set = set(prefs["favorites"])
by_name = sorted(apps, key=lambda x: (x["name"] or "").lower()) by_name = sorted(apps, key=lambda x: (x["name"] or "").lower())
return templates.TemplateResponse(request, "dashboard_body.html", { return templates.TemplateResponse(request, "dashboard_body.html", {
"request": request, "error": error, "groups": ak.group_apps(apps), "apps_sorted": by_name, "request": request, "error": error, "groups": ak.group_apps(apps), "apps_sorted": by_name,
"fav_apps": [a for a in by_name if a["slug"] in fav_set], "fav_apps": [a for a in by_name if a["slug"] in fav_set],
"favorites": list(fav_set), "view": prefs["view"]}) "favorites": list(fav_set), "view": prefs["view"],
"mc_servers": mc_servers, "disco_url": config.DISCO_PANEL_URL})
# ---------------- admin ---------------- # ---------------- admin ----------------
@@ -160,10 +168,20 @@ async def admin_cookies_save(request: Request, cookie_banner: str = Form(""), co
@app.get("/admin/ops", response_class=HTMLResponse) @app.get("/admin/ops", response_class=HTMLResponse)
async def admin_ops(request: Request): async def admin_ops(request: Request):
# Hurtig shell uden data — indholdet hentes async fra /ops-content og pollers hvert 10s.
user = require_admin(request) user = require_admin(request)
return templates.TemplateResponse(request, "admin_ops.html", { return templates.TemplateResponse(request, "admin_ops.html", {
"request": request, "user": user, "is_admin": True, "request": request, "user": user, "is_admin": True})
"version": await svc.assets_version(), "rp": await svc.rp_status()})
@app.get("/ops-content", response_class=HTMLResponse)
async def ops_content(request: Request):
require_admin(request)
import asyncio
version, rp, vers = await asyncio.gather(
svc.assets_version(), svc.rp_status(), versions.all_versions())
return templates.TemplateResponse(request, "admin_ops_body.html", {
"request": request, "version": version, "rp": rp, "versions": vers})
@app.get("/admin/stats", response_class=HTMLResponse) @app.get("/admin/stats", response_class=HTMLResponse)
@@ -225,11 +243,19 @@ async def admin_user_edit(request: Request, pk: int):
@app.post("/admin/users/{pk}") @app.post("/admin/users/{pk}")
async def admin_user_save(request: Request, pk: int): async def admin_user_save(request: Request, pk: int):
require_admin(request) user = require_admin(request)
f = await request.form() f = await request.form()
await ak.update_user(pk, f.getlist("groups"), f.get("is_active") == "on", f.get("password") or "") groups = f.getlist("groups")
cache.clear("users", "apps") # Guardrail: du kan ikke fjerne dig selv fra admin-gruppen (andre kan godt).
return RedirectResponse("/admin/users?saved=Bruger gemt", status_code=303) me = await ak.user_by_username(user["username"])
admin_pk = await ak.admin_group_pk(config.ADMIN_GROUP)
note = ""
if me and str(pk) == me["pk"] and admin_pk in me["groups"] and admin_pk not in groups:
groups = groups + [admin_pk]
note = " (du kan ikke fjerne din egen admin-rolle — beholdt)"
await ak.update_user(pk, groups, f.get("is_active") == "on", f.get("password") or "")
cache.clear("users", "apps", "access")
return RedirectResponse(f"/admin/users?saved=Bruger gemt{note}", status_code=303)
@app.get("/admin/groups", response_class=HTMLResponse) @app.get("/admin/groups", response_class=HTMLResponse)
@@ -261,11 +287,179 @@ async def admin_group_edit(request: Request, gpk: str):
@app.post("/admin/groups/{gpk}") @app.post("/admin/groups/{gpk}")
async def admin_group_save(request: Request, gpk: str): async def admin_group_save(request: Request, gpk: str):
user = require_admin(request)
f = await request.form()
members = f.getlist("members")
# Guardrail: du kan ikke fjerne dig selv fra admin-gruppen (andre kan godt).
me = await ak.user_by_username(user["username"])
admin_pk = await ak.admin_group_pk(config.ADMIN_GROUP)
note = ""
if me and str(gpk) == str(admin_pk) and admin_pk in me["groups"] and me["pk"] not in members:
members = members + [me["pk"]]
note = " (du kan ikke fjerne dig selv fra admin-gruppen — beholdt)"
await ak.set_group_members(gpk, members)
cache.clear("groups", "users", "apps", "access")
return RedirectResponse(f"/admin/groups?saved=Medlemmer gemt{note}", status_code=303)
# ---------------- adgang til services (per-app policy-bindings) ----------------
@app.get("/admin/access", response_class=HTMLResponse)
async def admin_access(request: Request, saved: str = ""):
user = require_admin(request)
return templates.TemplateResponse(request, "admin_access.html", {
"request": request, "user": user, "is_admin": True,
"apps": await ak.list_apps_access(), "saved": saved})
@app.get("/admin/access/{slug}", response_class=HTMLResponse)
async def admin_access_edit(request: Request, slug: str):
user = require_admin(request)
app_ = await ak.app_access(slug)
if not app_:
raise HTTPException(404, "Ukendt app")
admin_pk = await ak.admin_group_pk(config.ADMIN_GROUP)
me = await ak.user_by_username(user["username"])
# "Følsom" udgangspunkt = adgang i dag KUN for admin-gruppen, eller KUN for den
# indloggede selv → udvidelse skal bekræftes (rød modal).
only_admins = app_["groups"] == {str(admin_pk)} and not app_["users"]
only_me = bool(me) and app_["users"] == {me["pk"]} and not app_["groups"]
sensitive = only_admins or only_me
return templates.TemplateResponse(request, "admin_access_edit.html", {
"request": request, "user": user, "is_admin": True, "app": app_,
"groups": await ak.list_groups(), "users": await ak.list_users(),
"admin_pk": str(admin_pk), "sensitive": sensitive,
"orig_groups": sorted(app_["groups"]), "orig_users": sorted(app_["users"])})
@app.post("/admin/access/{slug}")
async def admin_access_save(request: Request, slug: str):
user = require_admin(request)
app_ = await ak.app_access(slug)
if not app_:
raise HTTPException(404, "Ukendt app")
f = await request.form()
want_groups = set(f.getlist("groups"))
want_users = set(f.getlist("users"))
# Guardrail: du må ikke fjerne din egen adgang til en service.
me = await ak.user_by_username(user["username"])
if (want_groups or want_users) and me:
covered = bool(want_groups & me["groups"]) or me["pk"] in want_users
if not covered:
return RedirectResponse(
f"/admin/access/{slug}?saved=Afvist: du ville fjerne din egen adgang til '{app_['name']}'",
status_code=303)
added, removed = await ak.set_app_access(app_["pk"], want_groups, want_users)
cache.clear("access", "apps")
parts = []
if added:
parts.append(f"{added} tilføjet")
if removed:
parts.append(f"{removed} fjernet")
detail = " · ".join(parts) if parts else "ingen ændringer"
return RedirectResponse(f"/admin/access?saved=Adgang til '{app_['name']}' gemt — {detail}", status_code=303)
# ---------------- medie-oprydning ----------------
@app.get("/admin/media", response_class=HTMLResponse)
async def admin_media(request: Request, saved: str = ""):
user = require_admin(request)
ctx = {"request": request, "user": user, "is_admin": True, "saved": saved,
"settings": mc.get_settings(), "human": mc._human}
if not mc.configured():
ctx["unconfigured"] = True
return templates.TemplateResponse(request, "admin_media.html", ctx)
inv = cache.get("media:inv")
if inv is None:
inv = await mc.inventory(ctx["settings"])
cache.put("media:inv", inv, 120)
st = mc.get_state()
ctx.update({"unconfigured": False, "items": inv["items"], "watch_ok": inv["watch_ok"],
"collections": inv.get("collections", []),
"leaving": st["leaving"], "log": st["log"][:15], "disk": await mc.disk_usage(),
"now": time.time()})
return templates.TemplateResponse(request, "admin_media.html", ctx)
@app.post("/api/media/keep")
async def api_media_keep(request: Request):
require_admin(request)
body = await request.json()
key, on = str(body.get("key") or ""), bool(body.get("on"))
kind, _, sid = key.partition(":")
if kind not in ("movie", "series") or not sid.isdigit():
raise HTTPException(400, "Ugyldig nøgle")
st = mc.get_state()
if on:
st["keep"][key] = True
st["leaving"].pop(key, None)
else:
st["keep"].pop(key, None)
mc.save_state(st)
tag_ok, err = True, None
try:
await mc.set_keep_tag(kind, int(sid), on)
except Exception as e:
tag_ok, err = False, str(e)[:120]
cache.clear("media:")
return {"ok": True, "key": key, "on": on, "tag_ok": tag_ok, "error": err}
@app.post("/api/media/collection-keep")
async def api_media_collection_keep(request: Request):
require_admin(request)
body = await request.json()
tmdb, on = str(body.get("tmdb") or ""), bool(body.get("on"))
title = str(body.get("title") or "")
if not tmdb.isdigit():
raise HTTPException(400, "Ugyldig samling")
st = mc.get_state()
if on:
st["keep_collections"][tmdb] = title
else:
st["keep_collections"].pop(tmdb, None)
mc.save_state(st)
cache.clear("media:")
return {"ok": True, "tmdb": tmdb, "on": on}
@app.post("/admin/media/run")
async def admin_media_run(request: Request):
require_admin(request)
inv = await mc.inventory(mc.get_settings())
cache.put("media:inv", inv, 120)
if not inv["watch_ok"]:
return RedirectResponse("/admin/media?saved=Emby utilgængelig — kunne ikke beregne", status_code=303)
cands = [i for i in inv["items"] if i["status"] == "candidate"]
total = sum(i["size"] for i in cands)
s = mc.get_settings()
mode = "sletning AKTIV" if s["enabled"] else "dry-run (sletning slået fra)"
return RedirectResponse(
f"/admin/media?saved=Simulering: {len(cands)} kandidater · frigør {mc._human(total)} · {mode}",
status_code=303)
@app.post("/admin/media/settings")
async def admin_media_settings(request: Request):
require_admin(request) require_admin(request)
f = await request.form() f = await request.form()
await ak.set_group_members(gpk, f.getlist("members"))
cache.clear("groups", "users", "apps") def num(name, default, lo, hi):
return RedirectResponse("/admin/groups?saved=Medlemmer gemt", status_code=303) try:
return max(lo, min(hi, int(f.get(name))))
except (TypeError, ValueError):
return default
mc.save_settings({
"enabled": f.get("enabled") == "on",
"do_movies": f.get("do_movies") == "on",
"do_series": f.get("do_series") == "on",
"disk_throttle": f.get("disk_throttle") == "on",
"age_days": num("age_days", 90, 1, 3650),
"grace_days": num("grace_days", 14, 0, 365),
"disk_high": num("disk_high", 85, 5, 100),
"disk_low": num("disk_low", 80, 1, 99),
})
cache.clear("media:")
return RedirectResponse("/admin/media?saved=Indstillinger gemt", status_code=303)
# ---------------- api ---------------- # ---------------- api ----------------
@@ -309,10 +503,12 @@ async def search_index(request: Request):
{"type": "page", "title": "Admin-panel", "url": "/admin", "extra": "admin"}, {"type": "page", "title": "Admin-panel", "url": "/admin", "extra": "admin"},
{"type": "page", "title": "Brugere", "url": "/admin/users", "extra": "users brugere"}, {"type": "page", "title": "Brugere", "url": "/admin/users", "extra": "users brugere"},
{"type": "page", "title": "Grupper", "url": "/admin/groups", "extra": "groups grupper"}, {"type": "page", "title": "Grupper", "url": "/admin/groups", "extra": "groups grupper"},
{"type": "page", "title": "Adgang til services", "url": "/admin/access", "extra": "access adgang bindings rettigheder"},
{"type": "page", "title": "Drift / egress", "url": "/admin/ops", "extra": "proxy version"}, {"type": "page", "title": "Drift / egress", "url": "/admin/ops", "extra": "proxy version"},
{"type": "page", "title": "App-beskrivelser", "url": "/admin/apps", "extra": "apps"}, {"type": "page", "title": "App-beskrivelser", "url": "/admin/apps", "extra": "apps"},
{"type": "page", "title": "IP → label & land", "url": "/admin/ip-labels", "extra": "ip geo"}, {"type": "page", "title": "IP → label & land", "url": "/admin/ip-labels", "extra": "ip geo"},
{"type": "page", "title": "Statistik", "url": "/admin/stats", "extra": "matomo"}] {"type": "page", "title": "Statistik", "url": "/admin/stats", "extra": "matomo"},
{"type": "page", "title": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"}]
try: try:
for a in await ak.fetch_user_apps(user["username"]): for a in await ak.fetch_user_apps(user["username"]):
items.append({"type": "app", "title": a["name"], "url": a["url"], items.append({"type": "app", "title": a["name"], "url": a["url"],
+25
View File
@@ -0,0 +1,25 @@
"""Natlig medie-oprydningskørsel (systemd-timer). Genberegner kandidater, vedligeholder
karens-listen og sletter udløbne (hvis sletning er slået til i /admin/media). Sender ntfy."""
import asyncio
import mediaclean as mc
async def main():
if not mc.configured():
print("media_job: ikke konfigureret (mangler EMBY/RADARR/SONARR env) — springer over")
return
res = await mc.run_cycle(dry_run=False)
if res.get("error"):
print("media_job FEJL:", res["error"])
return
print(f"media_job: kandidater={res['candidates']} nye={len(res['new'])} "
f"fjernet={len(res['removed'])} slettet={len(res['deleted'])} "
f"frigjort={mc._human(res['freed'])} throttled={res['throttled']} "
f"sletning={'til' if res['enabled'] else 'fra(dry)'}")
for e in res.get("errors", []):
print(" fejl:", e)
if __name__ == "__main__":
asyncio.run(main())
+407
View File
@@ -0,0 +1,407 @@
"""Medie-oprydning: find aldrig-set + gammelt medie via Emby (se-data) + Sonarr/Radarr
(bibliotek + sletning), med karensperiode ("på vej ud"), behold-liste (3 kilder forenet
i portalen) og ntfy-notifikation. Bruges af både web-ruterne og den natlige timer.
Kandidatregel (konservativ): har fil AND tilføjet > age_days siden AND aldrig afspillet
af nogen Emby-bruger (heller ikke delvist) AND ikke beholdt.
Behold = forening af: Emby-favorit · 'keep'-tag i Sonarr/Radarr · portalens egen behold-liste.
Portalens behold-knap skriver 'keep'-tag i *arr (kanonisk) + noterer i portal-listen.
"""
import json
import time
import httpx
import config
ARR = {
"movie": (config.RADARR_URL, config.RADARR_API_KEY, "movie", "movieIds"),
"series": (config.SONARR_URL, config.SONARR_API_KEY, "series", "seriesIds"),
}
TICKS = 10_000_000 # Emby: 1 sek = 10^7 ticks
# ---------------- state + settings ----------------
def _load(path, default):
try:
return json.loads(path.read_text("utf-8"))
except Exception:
return default
def _save(path, data):
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), "utf-8")
def get_settings() -> dict:
s = dict(config.MEDIA_DEFAULTS)
s.update(_load(config.MEDIA_SETTINGS_PATH, {}) or {})
return s
def save_settings(patch: dict) -> dict:
s = get_settings()
s.update(patch)
_save(config.MEDIA_SETTINGS_PATH, s)
return s
def get_state() -> dict:
st = _load(config.MEDIA_STATE_PATH, {}) or {}
st.setdefault("leaving", {}) # key -> {type,arr_id,title,size,added,first_seen,delete_after}
st.setdefault("keep", {}) # key -> True (portalens egen behold-liste)
st.setdefault("keep_collections", {}) # collection-tmdb -> titel (behold hele samlingen, også fremtidige)
st.setdefault("log", []) # seneste sletninger/hændelser
return st
def save_state(st: dict):
_save(config.MEDIA_STATE_PATH, st)
def configured() -> bool:
return all([config.EMBY_URL, config.EMBY_API_KEY, config.RADARR_URL,
config.RADARR_API_KEY, config.SONARR_URL, config.SONARR_API_KEY])
# ---------------- Emby se-data ----------------
async def _emby_items(client, uid, item_type, fields, filters=""):
p = {"Recursive": "true", "IncludeItemTypes": item_type, "Fields": fields,
"api_key": config.EMBY_API_KEY}
if filters:
p["Filters"] = filters
r = await client.get(f"{config.EMBY_URL}/emby/Users/{uid}/Items", params=p)
r.raise_for_status()
return r.json().get("Items", [])
def _pid(item, *keys):
"""Hent provider-id uanset store/små bogstaver (Tmdb/TMDB, Tvdb/TVDB...)."""
prov = {k.lower(): v for k, v in (item.get("ProviderIds") or {}).items()}
for k in keys:
v = prov.get(k.lower())
if v:
return str(v)
return None
def _watched(ud: dict) -> bool:
ud = ud or {}
return bool(ud.get("Played") or (ud.get("PlayCount") or 0) > 0
or (ud.get("PlaybackPositionTicks") or 0) > 0
or (ud.get("PlayedPercentage") or 0) > 0)
async def emby_watch_data() -> dict:
"""Aggreger på tværs af ALLE Emby-brugere → sæt af set/favorit provider-id'er."""
out = {"watched_movie": set(), "fav_movie": set(),
"watched_series": set(), "fav_series": set(), "ok": False}
async with httpx.AsyncClient(timeout=30) as c:
r = await c.get(f"{config.EMBY_URL}/emby/Users", params={"api_key": config.EMBY_API_KEY})
r.raise_for_status()
users = [u["Id"] for u in r.json()]
emby_series_tvdb = {} # Emby SeriesId -> tvdb
for uid in users:
movies = await _emby_items(c, uid, "Movie", "ProviderIds")
for m in movies:
tmdb = _pid(m, "Tmdb")
if not tmdb:
continue
if (m.get("UserData") or {}).get("IsFavorite"):
out["fav_movie"].add(tmdb)
if _watched(m.get("UserData")):
out["watched_movie"].add(tmdb)
series = await _emby_items(c, uid, "Series", "ProviderIds")
for s in series:
tvdb = _pid(s, "Tvdb")
if not tvdb:
continue
emby_series_tvdb[s["Id"]] = tvdb
if (s.get("UserData") or {}).get("IsFavorite"):
out["fav_series"].add(tvdb)
if _watched(s.get("UserData")):
out["watched_series"].add(tvdb)
for filt in ("IsPlayed", "IsResumable"):
eps = await _emby_items(c, uid, "Episode", "SeriesId", filters=filt)
for e in eps:
sid = e.get("SeriesId")
if sid in emby_series_tvdb:
out["watched_series"].add(emby_series_tvdb[sid])
out["ok"] = True
return out
# ---------------- *arr bibliotek ----------------
async def _arr_get(client, kind, path):
url, key, _, _ = ARR[kind]
r = await client.get(f"{url}/api/v3/{path}", headers={"X-Api-Key": key})
r.raise_for_status()
return r.json()
async def radarr_movies(client):
return await _arr_get(client, "movie", "movie")
async def sonarr_series(client):
return await _arr_get(client, "series", "series")
async def radarr_collections(client):
return await _arr_get(client, "movie", "collection")
async def disk_usage() -> dict:
"""Slow-pool fra Radarr's diskspace (/media er bind-mountet fra /slow/shared)."""
try:
async with httpx.AsyncClient(timeout=10) as c:
ds = await _arr_get(c, "movie", "diskspace")
cand = [x for x in ds if x.get("path") == "/media"] or \
sorted(ds, key=lambda x: x.get("totalSpace", 0), reverse=True)
d = cand[0]
total, free = d.get("totalSpace", 0), d.get("freeSpace", 0)
pct = round(100 * (total - free) / total, 1) if total else 0
return {"path": d.get("path"), "total": total, "free": free, "used_pct": pct, "ok": True}
except Exception as e:
return {"ok": False, "error": str(e)[:200]}
async def _ensure_keep_tag(client, kind) -> int:
tags = await _arr_get(client, kind, "tag")
for t in tags:
if (t.get("label") or "").lower() == "keep":
return t["id"]
url, key, _, _ = ARR[kind]
r = await client.post(f"{url}/api/v3/tag", headers={"X-Api-Key": key}, json={"label": "keep"})
r.raise_for_status()
return r.json()["id"]
async def set_keep_tag(kind, arr_id: int, on: bool):
url, key, res, ids_field = ARR[kind]
async with httpx.AsyncClient(timeout=20) as c:
tag_id = await _ensure_keep_tag(c, kind)
r = await c.put(f"{url}/api/v3/{res}/editor", headers={"X-Api-Key": key},
json={ids_field: [arr_id], "tags": [tag_id],
"applyTags": "add" if on else "remove"})
r.raise_for_status()
async def _keep_tag_ids(client):
out = {}
for kind in ("movie", "series"):
tags = await _arr_get(client, kind, "tag")
out[kind] = next((t["id"] for t in tags if (t.get("label") or "").lower() == "keep"), None)
return out
async def delete_item(kind, arr_id: int):
url, key, res, _ = ARR[kind]
async with httpx.AsyncClient(timeout=60) as c:
r = await c.delete(f"{url}/api/v3/{res}/{arr_id}",
headers={"X-Api-Key": key},
params={"deleteFiles": "true", "addImportExclusion": "false"})
r.raise_for_status()
# ---------------- kandidat-beregning ----------------
def _epoch(iso: str) -> float:
try:
from datetime import datetime
return datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()
except Exception:
return 0.0
async def inventory(settings: dict) -> dict:
"""Byg fuld oversigt: hvert film/serie med status (kandidat / beholdt / set / for ny).
Returnerer items + watch-ok-flag, så UI kan advare hvis Emby er nede."""
st = get_state()
portal_keep = st.get("keep", {})
kept_colls = set((st.get("keep_collections") or {}).keys())
now = time.time()
cutoff = now - settings["age_days"] * 86400
watch = await emby_watch_data()
async with httpx.AsyncClient(timeout=30) as c:
keep_tags = await _keep_tag_ids(c)
movies = await radarr_movies(c) if settings["do_movies"] else []
series = await sonarr_series(c) if settings["do_series"] else []
colls = await radarr_collections(c) if settings["do_movies"] else []
items = []
def classify(key, kind, arr_id, title, size, added_iso, provider, watched, fav, has_tag,
coll=None, coll_kept=False):
kept_sources = []
if fav:
kept_sources.append("favorit")
if has_tag:
kept_sources.append("tag")
if portal_keep.get(key):
kept_sources.append("portal")
if coll_kept:
kept_sources.append("samling")
kept = bool(kept_sources)
added = _epoch(added_iso)
old = added and added < cutoff
leaving = st["leaving"].get(key)
# base_status = status uden hensyn til behold (så klienten kan toggle uden reload)
if watched:
base = "watched"
elif not old:
base = "tooNew"
elif provider is None:
base = "nofile" # ingen fil på disk → intet at slette
else:
base = "candidate"
items.append({
"key": key, "type": kind, "arr_id": arr_id, "title": title, "size": size or 0,
"added": added_iso, "provider": provider, "watched": watched, "coll": coll,
"kept": kept, "keep_sources": kept_sources,
"status": "kept" if kept else base, "base_status": base,
"delete_after": (leaving or {}).get("delete_after"),
})
coll_count = {}
for m in movies:
tmdb = str(m.get("tmdbId") or "") or None
coll_tmdb = (m.get("collection") or {}).get("tmdbId")
coll_tmdb = str(coll_tmdb) if coll_tmdb else None
if coll_tmdb:
coll_count[coll_tmdb] = coll_count.get(coll_tmdb, 0) + 1
has_tag = keep_tags["movie"] in (m.get("tags") or []) if keep_tags["movie"] else False
classify(f"movie:{m['id']}", "movie", m["id"], m.get("title", "?"),
m.get("sizeOnDisk", 0), m.get("added", ""), tmdb if m.get("hasFile") else None,
watched=tmdb in watch["watched_movie"], fav=tmdb in watch["fav_movie"],
has_tag=has_tag, coll=coll_tmdb, coll_kept=coll_tmdb in kept_colls)
for s in series:
tvdb = str(s.get("tvdbId") or "") or None
stt = s.get("statistics") or {}
has_files = (stt.get("episodeFileCount") or 0) > 0
has_tag = keep_tags["series"] in (s.get("tags") or []) if keep_tags["series"] else False
classify(f"series:{s['id']}", "series", s["id"], s.get("title", "?"),
stt.get("sizeOnDisk", 0), s.get("added", ""), tvdb if has_files else None,
watched=tvdb in watch["watched_series"], fav=tvdb in watch["fav_series"],
has_tag=has_tag)
items.sort(key=lambda x: (x["status"] != "candidate", -x["size"]))
# samlinger der faktisk har film i biblioteket (med titel + antal + behold-status)
collections = [{"tmdb": str(x["tmdbId"]), "title": x.get("title", "?"),
"count": coll_count.get(str(x["tmdbId"]), 0),
"kept": str(x["tmdbId"]) in kept_colls}
for x in colls if coll_count.get(str(x["tmdbId"]), 0) > 0]
collections.sort(key=lambda x: x["title"].lower())
return {"items": items, "collections": collections, "watch_ok": watch["ok"], "settings": settings}
# ---------------- kørsel (timer + dry-run) ----------------
def _human(n: int) -> str:
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024 or unit == "TB":
return f"{n:.1f} {unit}" if unit != "B" else f"{n} B"
n /= 1024
async def ntfy(title, message, click=None, tags=None):
if not config.NTFY_URL:
return
headers = {"Title": title}
if tags:
headers["Tags"] = tags
if click:
headers["Click"] = click
headers["Actions"] = f"view, Åbn admin, {click}"
if config.NTFY_TOKEN:
headers["Authorization"] = f"Bearer {config.NTFY_TOKEN}"
try:
async with httpx.AsyncClient(timeout=10) as c:
await c.post(f"{config.NTFY_URL}/{config.NTFY_MEDIA_TOPIC}",
content=message.encode("utf-8"), headers=headers)
except Exception:
pass
async def run_cycle(dry_run: bool = False) -> dict:
"""Genberegn kandidater, vedligehold karens-listen, slet udløbne (medmindre dry-run).
Returnerer resumé til UI/notifikation."""
settings = get_settings()
inv = await inventory(settings)
if not inv["watch_ok"]:
return {"error": "Emby utilgængelig — afbrød (intet slettet)", "dry_run": dry_run}
st = get_state()
now = time.time()
cand = {i["key"]: i for i in inv["items"] if i["status"] == "candidate"}
disk0 = await disk_usage()
# Master-kontakt: er sletning slået fra, er det ren forhåndsvisning — ingen karens,
# ingen notifikation, ingen tilstandsændring. Slå "Slet automatisk" til for at aktivere.
if not settings["enabled"]:
return {"dry_run": dry_run, "enabled": False, "candidates": len(cand),
"reclaim": sum(i["size"] for i in cand.values()), "new": [], "removed": [],
"deleted": [], "freed": 0, "errors": [], "throttled": False, "disk": disk0}
new, removed = [], []
# tilføj nye kandidater til karens-listen
for key, i in cand.items():
if key not in st["leaving"]:
st["leaving"][key] = {
"type": i["type"], "arr_id": i["arr_id"], "title": i["title"],
"size": i["size"], "added": i["added"], "first_seen": now,
"delete_after": now + settings["grace_days"] * 86400}
new.append(st["leaving"][key])
# fjern dem der ikke længere er kandidater (fx beholdt eller set)
for key in list(st["leaving"]):
if key not in cand:
removed.append(key)
st["leaving"].pop(key)
# disk-throttle gate
disk = disk0
throttled = False
if settings["disk_throttle"] and disk.get("ok"):
if disk["used_pct"] < settings["disk_high"]:
throttled = True
deleted, freed, errors = [], 0, []
if not dry_run and settings["enabled"] and not throttled:
# ældste delete_after først; stop ved disk_low hvis throttle er til
due = sorted([(k, v) for k, v in st["leaving"].items() if v["delete_after"] <= now],
key=lambda kv: kv[1]["delete_after"])
for key, v in due:
if settings["disk_throttle"] and disk.get("ok"):
used_now = 100 * (disk["total"] - (disk["free"] + freed)) / disk["total"]
if used_now < settings["disk_low"]:
break
try:
await delete_item(v["type"], v["arr_id"])
deleted.append(v)
freed += v["size"]
st["leaving"].pop(key)
st["log"].insert(0, {"ts": now, "action": "deleted", "title": v["title"],
"type": v["type"], "size": v["size"]})
except Exception as e:
errors.append(f"{v['title']}: {str(e)[:120]}")
st["log"] = st["log"][:200]
if not dry_run:
save_state(st)
# notifikation (kun ægte kørsel)
if not dry_run:
link = "https://weircon.dk/admin/media"
if new:
tot = sum(i["size"] for i in new)
await ntfy(f"{len(new)} titler på vej ud",
"Nye oprydningskandidater (frigør " + _human(tot) + "):\n"
+ "\n".join(f"· {i['title']} ({_human(i['size'])})" for i in new[:15])
+ (f"\n… +{len(new) - 15} flere" if len(new) > 15 else "")
+ f"\n\nBehold dem du vil beholde inden {settings['grace_days']} dage.",
click=link, tags="wastebasket")
if deleted:
await ntfy(f"Slettede {len(deleted)} titler — frigjorde {_human(freed)}",
"\n".join(f"· {i['title']} ({_human(i['size'])})" for i in deleted[:20]),
click=link, tags="white_check_mark")
return {"dry_run": dry_run, "candidates": len(cand), "new": new, "removed": removed,
"deleted": deleted, "freed": freed, "errors": errors, "throttled": throttled,
"enabled": settings["enabled"], "disk": disk}
+47 -3
View File
@@ -1,8 +1,9 @@
"""Eksterne tjenester: geoip, static-assets-version, random-proxy status, Matomo.""" """Eksterne tjenester: geoip, static-assets-version, random-proxy status, Matomo, DiscoPanel."""
import httpx import httpx
from config import (ASSETS_VERSION_URL, GEOIP_DB, MATOMO_BASE, MATOMO_SITE, import cache
MATOMO_TOKEN, RP_API_KEY, RP_BASE_URL) from config import (ASSETS_VERSION_URL, DISCO_API_TOKEN, DISCO_BASE_URL, GEOIP_DB,
MATOMO_BASE, MATOMO_SITE, MATOMO_TOKEN, RP_API_KEY, RP_BASE_URL)
_geoip_reader = None _geoip_reader = None
@@ -50,6 +51,49 @@ async def rp_status():
return {"error": str(exc)[:200]} return {"error": str(exc)[:200]}
_MC_STATUS = { # DiscoPanel ServerStatus → (dansk label, css-klasse)
"SERVER_STATUS_RUNNING": ("Kører", "run"),
"SERVER_STATUS_STARTING": ("Starter…", "warn"),
"SERVER_STATUS_RESTARTING": ("Genstarter…", "warn"),
"SERVER_STATUS_CREATING": ("Oprettes…", "warn"),
"SERVER_STATUS_STOPPING": ("Stopper…", "warn"),
"SERVER_STATUS_STOPPED": ("Stoppet", "off"),
"SERVER_STATUS_ERROR": ("Fejl", "err"),
"SERVER_STATUS_UNHEALTHY": ("Usund", "err"),
}
async def minecraft_servers():
"""Serverliste fra DiscoPanel (CT 137, Connect-RPC). None = ikke konfigureret/utilgængelig."""
if not DISCO_API_TOKEN:
return None
hit = cache.get("mc:servers")
if hit is not None:
return hit
try:
async with httpx.AsyncClient(timeout=6) as c:
r = await c.post(f"{DISCO_BASE_URL}/discopanel.v1.ServerService/ListServers",
json={}, headers={"Authorization": f"Bearer {DISCO_API_TOKEN}",
"Content-Type": "application/json"})
r.raise_for_status()
data = r.json()
servers = []
for s in data.get("servers", []):
label, css = _MC_STATUS.get(s.get("status"), ("Ukendt", "off"))
servers.append({
"name": s.get("name") or "?",
"status": label, "css": css,
"online": s.get("status") == "SERVER_STATUS_RUNNING",
"players": int(s.get("playersOnline") or 0),
"max_players": int(s.get("maxPlayers") or 0),
"version": s.get("serverVersion") or s.get("mcVersion") or ""})
servers.sort(key=lambda x: (not x["online"], x["name"].lower()))
cache.put("mc:servers", servers, 15)
return servers
except Exception:
return None
async def matomo_summary(): async def matomo_summary():
if not MATOMO_TOKEN: if not MATOMO_TOKEN:
return None return None
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Admin — adgang til services{% endblock %}
{% block body %}
<p><a href="/admin" class="muted">← Admin</a></p>
<h1>Adgang til services</h1>
<p class="lead">Styr hvilke brugere og grupper der kan tilgå hver enkelt service.
Klik på en service for at redigere adgangen.</p>
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
<div class="filter" style="max-width:22rem">
<span class="mag">⌕</span>
<input id="afilter" type="search" placeholder="Søg services…" autocomplete="off" aria-label="Søg services">
</div>
<div class="grid" id="acards">
{% for a in apps %}
<a class="card" href="/admin/access/{{ a.slug }}"
data-search="{{ ((a.name or '') ~ ' ' ~ a.slug ~ ' ' ~ (a.group or ''))|lower }}">
<div class="head">
<span class="ico">{% if a.icon %}<img src="{{ a.icon }}" alt="">{% else %}{{ (a.name or '?')[0]|upper }}{% endif %}</span>
<span class="name">{{ a.name }}</span>
</div>
<div class="sub" style="white-space:normal">
{% if a.open %}
<span class="tag open">🌐 åben for alle</span>
{% else %}
{% if a.n_groups %}<span class="tag">{{ a.n_groups }} gruppe{{ 'r' if a.n_groups != 1 }}</span>{% endif %}
{% if a.n_users %}<span class="tag">{{ a.n_users }} bruger{{ 'e' if a.n_users != 1 }}</span>{% endif %}
{% if a.n_other %}<span class="tag muted" title="policy-/negate-bindings styres i Authentik">+{{ a.n_other }} regel</span>{% endif %}
{% endif %}
</div>
<div class="go">Redigér adgang →</div>
</a>
{% endfor %}
</div>
<p id="anomatch" class="muted" style="display:none;margin-top:1rem">Ingen services matcher.</p>
<style>
.tag { display:inline-block; font-size:.74rem; border:1px solid var(--border); border-radius:999px;
padding:.08rem .55rem; margin:.15rem .25rem .15rem 0; color:var(--muted); }
.tag.open { color:#3ecf8e; border-color:color-mix(in srgb,#3ecf8e 40%,transparent);
background:color-mix(in srgb,#3ecf8e 10%,transparent); }
</style>
<script>
(function(){
var inp=document.getElementById('afilter'); if(!inp) return;
var cards=[].slice.call(document.querySelectorAll('#acards .card'));
var nm=document.getElementById('anomatch');
inp.addEventListener('input',function(){
var q=inp.value.trim().toLowerCase(), shown=0;
cards.forEach(function(c){ var hit=!q||c.getAttribute('data-search').indexOf(q)>-1;
c.classList.toggle('hidden',!hit); if(hit) shown++; });
nm.style.display=shown?'none':'block';
});
})();
</script>
{% endblock %}
+123
View File
@@ -0,0 +1,123 @@
{% extends "base.html" %}
{% block title %}Adgang — {{ app.name }}{% endblock %}
{% macro gcard(g) %}
<label class="ucard" data-search="{{ g.name|lower }}">
<input type="checkbox" name="groups" value="{{ g.pk }}" {% if g.pk|string in app.groups %}checked{% endif %}>
<span class="uini">👥</span>
<span class="ubody">
<span class="uname">{{ g.name }}{% if g.pk|string == admin_pk %} <span class="muted" style="font-weight:400">· admin</span>{% endif %}</span>
<span class="usub">{{ g.members }} medlem{{ 'mer' if g.members != 1 }}</span>
</span>
<span class="uchk">✓</span>
</label>
{% endmacro %}
{% macro pcard(u) %}
<label class="ucard" data-search="{{ (u.username ~ ' ' ~ (u.name or '') ~ ' ' ~ (u.email or ''))|lower }}">
<input type="checkbox" name="users" value="{{ u.pk }}" {% if u.pk|string in app.users %}checked{% endif %}>
<span class="uini">{{ (u.username or '?')[0]|upper }}</span>
<span class="ubody">
<span class="uname">{{ u.name or u.username }}</span>
<span class="usub" title="{{ u.username }}{% if u.email %} · {{ u.email }}{% endif %}">{{ u.username }}{% if u.email %} · {{ u.email }}{% endif %}</span>
</span>
<span class="uchk">✓</span>
</label>
{% endmacro %}
{% block body %}
<p><a href="/admin/access" class="muted">← Adgang til services</a></p>
<h1>{{ app.name }}</h1>
<p class="lead">Vælg de grupper og brugere der skal have adgang.
<strong>Ingen markering = åben for alle</strong> autentificerede brugere.</p>
{% if not app.groups and not app.users and not app.n_other %}
<div class="err">Denne service er i dag <strong>åben for alle</strong> der er logget ind.
Markerer du nogen her, bliver adgangen <strong>begrænset</strong> til kun de valgte.</div>
{% endif %}
{% if app.n_other %}
<div class="err">Bemærk: servicen har {{ app.n_other }} ekstra regel (policy/negate) som styres i Authentik
og ikke kan ændres her — de bevares uændret.</div>
{% endif %}
{% set people = users|rejectattr('type','in',['service_account','internal_service_account'])|list %}
{% set svcs = users|selectattr('type','in',['service_account','internal_service_account'])|list %}
<form method="post" action="/admin/access/{{ app.slug }}" id="accform"
data-sensitive="{{ '1' if sensitive else '0' }}"
data-orig="{{ (orig_groups + orig_users)|join(',') }}">
<div class="filter" style="max-width:22rem">
<span class="mag">⌕</span>
<input id="ufilter" type="search" placeholder="Søg grupper og brugere…" autocomplete="off" aria-label="Søg">
</div>
<h2>Grupper</h2>
<div class="ucards" data-pool>
{% for g in groups %}{{ gcard(g) }}{% endfor %}
</div>
<h2>Enkeltbrugere</h2>
<div class="ucards" data-pool>
{% for u in people %}{{ pcard(u) }}{% endfor %}
</div>
{% if svcs %}
<details style="margin-top:1rem">
<summary class="muted" style="cursor:pointer">Service-konti ({{ svcs|length }})</summary>
<div class="ucards" data-pool>{% for u in svcs %}{{ pcard(u) }}{% endfor %}</div>
</details>
{% endif %}
<p style="margin-top:1.4rem"><button class="btn" type="submit">Gem adgang</button>
<a href="/admin/access" class="btn secondary">Annullér</a></p>
</form>
<!-- bekræft-modal: vises kun når en følsom (kun-mig / kun-admins) service udvides -->
<div id="confirm" class="modal-overlay" hidden>
<div class="modal-box" role="dialog" aria-modal="true" aria-labelledby="cf-h">
<h3 id="cf-h" style="margin:.2rem 0 .6rem">Udvide adgangen?</h3>
<p class="muted" style="margin:0 0 1.2rem">Denne service er i dag begrænset til
{% if sensitive %}{{ 'kun administratorer' if app.groups else 'kun dig' }}{% endif %}.
Du er ved at give <strong>flere</strong> adgang. Er du sikker?</p>
<div style="display:flex;gap:.6rem;justify-content:flex-end">
<button type="button" class="btn secondary" id="cf-cancel">Annullér</button>
<button type="button" class="btn danger" id="cf-ok">Bekræft</button>
</div>
</div>
</div>
<style>
.btn.danger { background:#e5484d; color:#fff; }
.btn.danger:hover { filter:brightness(1.08); }
.modal-overlay { position:fixed; inset:0; z-index:120; display:flex; align-items:center; justify-content:center;
background:color-mix(in srgb,var(--bg) 55%,transparent); backdrop-filter:blur(4px); padding:1rem; }
.modal-overlay[hidden] { display:none; }
.modal-box { width:min(28rem,calc(100vw - 2rem)); background:var(--surface); border:1px solid var(--border);
border-radius:1rem; padding:1.4rem 1.5rem; box-shadow:0 24px 64px rgb(0 0 0 / .45); }
</style>
<script>
(function(){
var form=document.getElementById('accform'),
inp=document.getElementById('ufilter'),
modal=document.getElementById('confirm');
// filter
if(inp){ var cards=[].slice.call(document.querySelectorAll('[data-pool] .ucard'));
inp.addEventListener('input',function(){ var q=inp.value.trim().toLowerCase();
cards.forEach(function(c){ c.classList.toggle('hidden', !!q && c.getAttribute('data-search').indexOf(q)<0); }); }); }
// confirm-on-widen
var sensitive=form.getAttribute('data-sensitive')==='1';
var orig=(form.getAttribute('data-orig')||'').split(',').filter(Boolean);
var origSet={}; orig.forEach(function(v){ origSet[v]=1; });
function selected(){ return [].slice.call(form.querySelectorAll('input:checked')).map(function(i){return i.value;}); }
function widened(){ return selected().some(function(v){ return !origSet[v]; }); }
var confirmed=false;
form.addEventListener('submit',function(e){
if(confirmed || !sensitive || !widened()) return; // normal submit
e.preventDefault(); modal.hidden=false;
});
document.getElementById('cf-cancel').addEventListener('click',function(){ modal.hidden=true; });
modal.addEventListener('click',function(e){ if(e.target===modal) modal.hidden=true; });
document.getElementById('cf-ok').addEventListener('click',function(){
confirmed=true; modal.hidden=true; form.submit(); });
})();
</script>
{% endblock %}
+257
View File
@@ -0,0 +1,257 @@
{% extends "base.html" %}
{% block title %}Admin — medie-oprydning{% endblock %}
{% block body %}
<h1>Medie-oprydning</h1>
<p class="lead">Finder film & serier, der <strong>aldrig er set</strong> af nogen og blev tilføjet for længe siden,
og sletter dem automatisk efter en karensperiode — medmindre du beholder dem. Behold via Emby-favorit,
<code>keep</code>-tag i Sonarr/Radarr, hele samlinger eller knappen her; alle vises samlet.</p>
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
{% if unconfigured %}
<div class="err">Ikke konfigureret endnu. Sæt <code>EMBY_URL/EMBY_API_KEY</code>,
<code>RADARR_URL/RADARR_API_KEY</code> og <code>SONARR_URL/SONARR_API_KEY</code> i <code>/etc/portal.env</code>.</div>
{% else %}
{% if not watch_ok %}<div class="err">⚠ Emby svarede ikke — se-data mangler, så intet markeres som kandidat (sikkerhed).</div>{% endif %}
{# ---- topkort: disk + status ---- #}
{% set cands = items | selectattr('status','equalto','candidate') | list %}
{% set leavingrows = items | selectattr('delete_after') | list %}
{% set cand_size = cands | sum(attribute='size') %}
<div class="grid" style="grid-template-columns:repeat(auto-fill,minmax(15rem,1fr));margin-top:1.4rem">
<div class="stat">
<div class="muted" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.08em">Slow-lager</div>
{% if disk.ok %}
<div class="n">{{ disk.used_pct }}%</div>
<div class="bar"><span style="width:{{ disk.used_pct }}%;{% if disk.used_pct >= settings.disk_high %}background:#f87171{% endif %}"></span></div>
<div class="muted" style="font-size:.85rem;margin-top:.4rem">{{ human(disk.free) }} fri af {{ human(disk.total) }}</div>
{% else %}<div class="muted">utilgængelig</div>{% endif %}
</div>
<div class="stat"><div class="muted" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.08em">Kandidater nu</div>
<div class="n">{{ cands|length }}</div>
<div class="muted" style="font-size:.85rem;margin-top:.4rem">frigør {{ human(cand_size) }}</div></div>
<div class="stat"><div class="muted" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.08em">På vej ud</div>
<div class="n">{{ leavingrows|length }}</div>
<div class="muted" style="font-size:.85rem;margin-top:.4rem">i karens</div></div>
<div class="stat"><div class="muted" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.08em">Sletning</div>
<div class="n" style="font-size:1.4rem;margin-top:.4rem">{% if settings.enabled %}<span style="color:#3ecf8e">AKTIV</span>{% else %}<span style="color:#f5a623">dry-run</span>{% endif %}</div>
<div class="muted" style="font-size:.85rem;margin-top:.4rem">{% if settings.disk_throttle %}kun over {{ settings.disk_high }}%{% else %}altid efter karens{% endif %}</div></div>
</div>
{# ---- indstillinger (redigerbare defaults) ---- #}
<details style="margin-top:1.6rem" {% if not settings.enabled %}open{% endif %}>
<summary style="cursor:pointer;font-weight:600">⚙️ Indstillinger</summary>
<form method="post" action="/admin/media/settings" style="margin-top:1rem">
<div class="setgrid">
<label class="sw"><input type="checkbox" name="enabled" {% if settings.enabled %}checked{% endif %}> <span>Slet automatisk (ellers kun dry-run)</span></label>
<label class="sw"><input type="checkbox" name="disk_throttle" {% if settings.disk_throttle %}checked{% endif %}> <span>Slet kun når lageret er fyldt</span></label>
<label class="sw"><input type="checkbox" name="do_movies" {% if settings.do_movies %}checked{% endif %}> <span>Inkludér film</span></label>
<label class="sw"><input type="checkbox" name="do_series" {% if settings.do_series %}checked{% endif %}> <span>Inkludér serier</span></label>
</div>
<div class="setgrid" style="margin-top:.8rem">
<label class="fld"><span>Alder før kandidat (dage)</span><input type="number" name="age_days" min="1" max="3650" value="{{ settings.age_days }}"></label>
<label class="fld"><span>Karens før sletning (dage)</span><input type="number" name="grace_days" min="0" max="365" value="{{ settings.grace_days }}"></label>
<label class="fld"><span>Start sletning over (% fyldt)</span><input type="number" name="disk_high" min="5" max="100" value="{{ settings.disk_high }}"></label>
<label class="fld"><span>Stop sletning under (% fyldt)</span><input type="number" name="disk_low" min="1" max="99" value="{{ settings.disk_low }}"></label>
</div>
<p style="margin-top:1rem;display:flex;gap:.6rem;flex-wrap:wrap">
<button class="btn" type="submit">Gem indstillinger</button>
<button class="btn secondary" type="submit" formaction="/admin/media/run" formmethod="post">Kør simulering nu</button>
</p>
</form>
</details>
{# ---- samlinger: behold hele samlinger (også fremtidige film) ---- #}
{% set kept_colls = collections | selectattr('kept') | list %}
<details style="margin-top:1rem">
<summary style="cursor:pointer;font-weight:600">📦 Samlinger — behold hele samlinger{% if kept_colls %} ({{ kept_colls|length }} valgt){% endif %}</summary>
<p class="muted" style="margin:.6rem 0;font-size:.9rem">Markér en samling, så <em>alle</em> dens film — også dem du tilføjer senere — aldrig slettes.</p>
<div class="filter" style="max-width:22rem;margin:0 0 .4rem"><span class="mag">⌕</span><input id="cfilter" type="search" placeholder="Søg samling…" autocomplete="off"></div>
<div id="clist" class="clist"></div>
</details>
<script id="cdata" type="application/json">{{ collections|tojson }}</script>
{# ---- filter ---- #}
<div class="opstools" style="margin-top:1.8rem">
<div class="filter" style="margin:0;flex:1;min-width:11rem">
<span class="mag">⌕</span>
<input id="mfilter" type="search" placeholder="Søg titel…" autocomplete="off">
</div>
<div class="segmented" id="mmode" role="tablist">
<button type="button" data-f="candidate" class="on">Kandidater</button>
<button type="button" data-f="leaving">På vej ud</button>
<button type="button" data-f="kept">Beholdt</button>
<button type="button" data-f="all">Alle</button>
</div>
</div>
<table style="margin-top:1rem">
<thead><tr><th>Titel</th><th style="width:64px">Type</th><th style="width:90px">Størrelse</th><th style="width:104px">Tilføjet</th><th style="width:170px">Status</th><th style="width:96px"></th></tr></thead>
<tbody id="mtbody"></tbody>
</table>
<div id="mpager" class="pager"></div>
<p id="mnomatch" class="muted" style="display:none;margin-top:1rem">Ingen titler matcher.</p>
<script id="mdata" type="application/json">{{ items|tojson }}</script>
{% if log %}
<h2>Seneste sletninger</h2>
<table><tbody>
{% for l in log %}<tr><td>{{ l.title }}</td><td style="width:64px">{{ 'Film' if l.type=='movie' else 'Serie' }}</td><td style="width:90px">{{ human(l.size) }}</td></tr>{% endfor %}
</tbody></table>
{% endif %}
<style>
.bar { height:.5rem; background:var(--surface2); border-radius:999px; overflow:hidden; margin-top:.5rem; }
.bar span { display:block; height:100%; background:var(--accent); border-radius:999px; }
.setgrid { display:grid; gap:.7rem; grid-template-columns:repeat(auto-fill,minmax(15rem,1fr)); }
.sw { display:flex; align-items:center; gap:.55rem; cursor:pointer; }
.sw input { width:auto; }
.fld { display:flex; flex-direction:column; gap:.3rem; font-size:.85rem; color:var(--muted); }
.fld input { width:100%; }
.mbadge { display:inline-block; font-size:.74rem; font-weight:600; border-radius:999px; padding:.1rem .6rem; border:1px solid var(--border); white-space:nowrap; }
.mbadge.amber { color:#f5a623; border-color:color-mix(in srgb,#f5a623 50%,transparent); background:color-mix(in srgb,#f5a623 12%,transparent); }
.mbadge.red { color:#f87171; border-color:color-mix(in srgb,#f87171 55%,transparent); background:color-mix(in srgb,#f87171 14%,transparent); }
.mbadge.green { color:#3ecf8e; border-color:color-mix(in srgb,#3ecf8e 40%,transparent); background:color-mix(in srgb,#3ecf8e 10%,transparent); }
.mbadge.muted { color:var(--muted); }
.segmented { display:inline-flex; border:1px solid var(--border); border-radius:.7rem; overflow:hidden; }
.segmented button { background:transparent; color:var(--muted); border:0; padding:.5rem .9rem; cursor:pointer; font:inherit; font-size:.85rem; }
.segmented button.on { background:var(--surface2); color:var(--text); }
.opstools { display:flex; gap:.8rem; align-items:center; flex-wrap:wrap; }
.pager { display:flex; gap:.9rem; align-items:center; margin-top:1rem; }
.pager button { padding:.35rem .8rem; }
.pager button:disabled { opacity:.4; cursor:default; }
td .btn[disabled] { opacity:.6; cursor:default; }
.clist { display:flex; flex-direction:column; gap:.4rem; margin-top:.4rem; max-height:24rem; overflow:auto; }
.crow { display:flex; align-items:center; gap:.8rem; padding:.45rem .7rem; border:1px solid var(--border); border-radius:.7rem; background:color-mix(in srgb,var(--surface) 70%,transparent); }
.crow.on { border-color:color-mix(in srgb,#3ecf8e 45%,transparent); background:color-mix(in srgb,#3ecf8e 8%,transparent); }
.crow .cname { flex:1; font-weight:600; font-size:.9rem; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.crow .ccount { font-size:.8rem; flex:0 0 auto; }
</style>
<script>
(function(){
var all = JSON.parse(document.getElementById('mdata').textContent || '[]');
var colls = JSON.parse(document.getElementById('cdata').textContent || '[]');
var now = {{ now|default(0) }};
var PAGE = 25, st = { q:'', mode:'candidate', page:1 };
var tb = document.getElementById('mtbody'), pager = document.getElementById('mpager'),
nomatch = document.getElementById('mnomatch'), clist = document.getElementById('clist');
function esc(s){ return String(s).replace(/[&<>"]/g, function(c){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
function human(n){ var u=['B','KB','MB','GB','TB'], i=0; while(n>=1024 && i<4){ n/=1024; i++; }
return n.toFixed(1)+' '+u[i]; }
function leaving(it){ return it.delete_after != null; }
// ---- hovedtabel ----
function match(it){
var okq = !st.q || it.title.toLowerCase().indexOf(st.q) > -1;
var okm = st.mode==='all'
|| (st.mode==='candidate' && it.status==='candidate' && !leaving(it))
|| (st.mode==='leaving' && leaving(it))
|| (st.mode==='kept' && it.kept);
return okq && okm;
}
function badge(it){
if (leaving(it)) return '<span class="mbadge red">På vej ud · '+Math.ceil((it.delete_after-now)/86400)+'d</span>';
if (it.status==='candidate') return '<span class="mbadge amber">Kandidat</span>';
if (it.status==='kept') return '<span class="mbadge green">Beholdt'+((it.keep_sources&&it.keep_sources.length)?' · '+esc(it.keep_sources.join(', ')):'')+'</span>';
if (it.status==='watched') return '<span class="mbadge muted">Set</span>';
if (it.status==='tooNew') return '<span class="mbadge muted">For ny</span>';
return '<span class="mbadge muted">Ingen fil</span>';
}
function row(it){
var btn = it.kept
? '<button class="btn secondary keepbtn" data-key="'+esc(it.key)+'" data-on="0" style="padding:.35rem .7rem;font-size:.82rem">Frigiv</button>'
: '<button class="btn keepbtn" data-key="'+esc(it.key)+'" data-on="1" style="padding:.35rem .7rem;font-size:.82rem">Behold</button>';
return '<tr><td>'+esc(it.title)+'</td><td>'+(it.type==='movie'?'Film':'Serie')+'</td><td>'+human(it.size)+'</td>'+
'<td class="muted" style="font-size:.85rem">'+esc((it.added||'').slice(0,10))+'</td><td>'+badge(it)+'</td><td>'+btn+'</td></tr>';
}
function render(){
var list = all.filter(match);
var pages = Math.max(1, Math.ceil(list.length/PAGE));
if (st.page > pages) st.page = pages;
tb.innerHTML = list.slice((st.page-1)*PAGE, st.page*PAGE).map(row).join('');
nomatch.style.display = list.length ? 'none' : 'block';
pager.innerHTML = list.length > PAGE
? '<button class="btn secondary" data-pg="prev"'+(st.page<=1?' disabled':'')+'>‹</button>'+
'<span class="muted">Side '+st.page+' af '+pages+' · '+list.length+' titler</span>'+
'<button class="btn secondary" data-pg="next"'+(st.page>=pages?' disabled':'')+'>›</button>'
: (list.length ? '<span class="muted">'+list.length+' titler</span>' : '');
}
document.getElementById('mfilter').addEventListener('input', function(e){
st.q = e.target.value.trim().toLowerCase(); st.page = 1; render(); });
document.getElementById('mmode').addEventListener('click', function(e){
var b = e.target.closest('button[data-f]'); if (!b) return;
st.mode = b.getAttribute('data-f'); st.page = 1;
this.querySelectorAll('button').forEach(function(x){ x.classList.toggle('on', x===b); });
render();
});
pager.addEventListener('click', function(e){
var b = e.target.closest('button[data-pg]'); if (!b || b.disabled) return;
st.page += b.getAttribute('data-pg')==='next' ? 1 : -1; render();
});
tb.addEventListener('click', function(e){
var b = e.target.closest('.keepbtn'); if (!b) return;
var key = b.getAttribute('data-key'), on = b.getAttribute('data-on')==='1';
b.disabled = true; b.textContent = '…';
fetch('/api/media/keep', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ key:key, on:on }) })
.then(function(r){ return r.json(); })
.then(function(res){
if (!res || !res.ok) throw 0;
var it = all.filter(function(x){ return x.key===key; })[0]; if (!it) return;
var hadFav = (it.keep_sources||[]).indexOf('favorit') > -1;
var hadColl = (it.keep_sources||[]).indexOf('samling') > -1;
if (on){ var s=[]; if(hadFav)s.push('favorit'); if(hadColl)s.push('samling'); s.push('tag','portal');
it.keep_sources=s; it.kept=true; it.delete_after=null; }
else { var s2=[]; if(hadFav)s2.push('favorit'); if(hadColl)s2.push('samling');
it.keep_sources=s2; it.kept=s2.length>0; }
it.status = it.kept ? 'kept' : it.base_status;
render();
})
.catch(function(){ b.disabled = false; b.textContent = on?'Behold':'Frigiv';
alert('Kunne ikke gemme — prøv igen.'); });
});
// ---- samlinger ----
var cq = '';
function crow(c){
var btn = c.kept
? '<button class="btn secondary collbtn" data-tmdb="'+esc(c.tmdb)+'" data-title="'+esc(c.title)+'" data-on="0" style="padding:.3rem .7rem;font-size:.82rem">Beholdes</button>'
: '<button class="btn collbtn" data-tmdb="'+esc(c.tmdb)+'" data-title="'+esc(c.title)+'" data-on="1" style="padding:.3rem .7rem;font-size:.82rem">Behold</button>';
return '<div class="crow'+(c.kept?' on':'')+'"><span class="cname">'+esc(c.title)+'</span><span class="ccount muted">'+c.count+' film</span>'+btn+'</div>';
}
function crender(){
var list = colls.filter(function(c){ return !cq || c.title.toLowerCase().indexOf(cq) > -1; });
clist.innerHTML = list.length ? list.map(crow).join('') : '<p class="muted">Ingen samlinger.</p>';
}
document.getElementById('cfilter').addEventListener('input', function(e){
cq = e.target.value.trim().toLowerCase(); crender(); });
clist.addEventListener('click', function(e){
var b = e.target.closest('.collbtn'); if (!b) return;
var tmdb = b.getAttribute('data-tmdb'), title = b.getAttribute('data-title'), on = b.getAttribute('data-on')==='1';
b.disabled = true; b.textContent = '…';
fetch('/api/media/collection-keep', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ tmdb:tmdb, title:title, on:on }) })
.then(function(r){ return r.json(); })
.then(function(res){
if (!res || !res.ok) throw 0;
var c = colls.filter(function(x){ return x.tmdb===tmdb; })[0]; if (c) c.kept = on;
all.forEach(function(it){
if (it.coll !== tmdb) return;
var s = (it.keep_sources||[]).filter(function(x){ return x!=='samling'; });
if (on) s.push('samling');
it.keep_sources = s; it.kept = s.length>0; it.status = it.kept ? 'kept' : it.base_status;
if (it.kept) it.delete_after = null;
});
crender(); render();
})
.catch(function(){ b.disabled = false; b.textContent = on?'Behold':'Beholdes';
alert('Kunne ikke gemme — prøv igen.'); });
});
crender();
render();
})();
</script>
{% endif %}
{% endblock %}
+133 -42
View File
@@ -2,51 +2,142 @@
{% block title %}Admin — drift{% endblock %} {% block title %}Admin — drift{% endblock %}
{% block body %} {% block body %}
<h1>Drift / egress</h1> <h1>Drift / egress</h1>
<p class="wt-lead">Live driftsoverblik. Genindlæser automatisk hvert 20. sekund.</p> <p class="lead">Live driftsoverblik — versioner, opdateringer og random-proxy.
Opdaterer automatisk hvert 10. sekund i baggrunden.</p>
<h2>Static-assets</h2> <div class="opstools">
<div class="wt-stat" style="max-width:18rem"> <div class="filter" style="margin:0;flex:1;min-width:11rem">
<div class="wt-stat-n">{{ version if version else '—' }}</div> <span class="mag">⌕</span>
<div class="wt-muted">deployet version (assets.weircon.dk)</div> <input id="opsfilter" type="search" placeholder="Søg tjeneste…" autocomplete="off" aria-label="Søg tjeneste">
</div> </div>
<div class="segmented" id="opsmode" role="tablist" aria-label="Filtrér">
<h2 style="display:flex;align-items:baseline;gap:.8rem">Random-proxy egress <button type="button" data-mode="all" class="on">Alle</button>
{% if rp and not rp.error %}<span class="wt-muted" style="font-size:.8rem;letter-spacing:0;text-transform:none"> <button type="button" data-mode="update">Opdateringer</button>
{{ rp.available }}/{{ rp.proxies_total }} live · {{ rp.inflight_total }} inflight i alt</span>{% endif %} <button type="button" data-mode="eol">EOL</button>
</h2> </div>
{% if rp.error %} <div class="segmented" id="opslevel" role="tablist" aria-label="Niveau">
<div class="wt-flash wt-flash--error">Kunne ikke nå random-proxy: {{ rp.error }}</div> <button type="button" data-level="all" class="on">Alle niveauer</button>
{% else %} <button type="button" data-level="major">Major</button>
{% set rot = rp.rotation %} <button type="button" data-level="minor">Minor</button>
{% if rot %} <button type="button" data-level="patch">Bugfix</button>
<div class="wt-stat" style="margin-bottom:1rem">
<div class="wt-muted">Næste rollover</div>
<div style="margin-top:.3rem">
<strong>slot {{ rot.next_slot }}</strong>
— <span class="wt-mono">{{ rot.next_egress_ip }}</span>
{% if rot.geo and rot.geo.flag %}<span title="{{ rot.geo.name }}">{{ rot.geo.flag }} {{ rot.geo.iso }}</span>{% endif %}
ruller af om <strong>{{ '%d:%02d'|format(rot.eta_sec//60, rot.eta_sec%60) }}</strong>
<span class="wt-muted">(interval {{ rot.interval_sec }}s · sidst: slot {{ rot.last_slot }} for {{ rot.last_rotated_age_sec }}s siden)</span>
</div> </div>
</div> </div>
{% endif %}
<div class="wt-grid" style="grid-template-columns:repeat(auto-fill,minmax(190px,1fr))"> <div id="opscontent">
{% for p in rp.proxies %} <div class="grid" aria-hidden="true" style="margin-top:1.4rem">
<div class="slot{% if rot and p.id == rot.next_slot %} next{% endif %}"> {% for i in range(6) %}
<div class="slot-top"> <div class="stat" style="opacity:.5"><span class="sk" style="width:60%;height:1.6em;display:block;border-radius:6px"></span>
<span class="slot-id">#{{ p.id }}</span> <span class="sk" style="width:40%;height:.8em;display:block;margin-top:.6rem;border-radius:5px"></span></div>
<span class="slot-state {{ 'down' if not p.reachable else ('busy' if p.inflight else 'live') }}"> {% endfor %}
{{ '⚠ down' if not p.reachable else (p.state or 'live') }}</span>
</div>
<div class="wt-mono" style="font-size:.92rem;overflow:hidden;text-overflow:ellipsis">{{ p.egress_ip or '–' }}</div>
<div class="wt-muted" style="font-size:.84rem;margin-top:.2rem">
{% if p.geo and p.geo.flag %}{{ p.geo.flag }} {{ p.geo.iso }} · {% endif %}{{ p.inflight or 0 }} inflight
{% if p.latency_ms %}· {{ p.latency_ms }}ms{% endif %}
</div>
{% if rot and p.id == rot.next_slot %}<div class="slot-next">↻ ruller af næst</div>{% endif %}
</div> </div>
{% endfor %}
</div> </div>
{% endif %}
<script>setTimeout(function(){ location.reload(); }, 20000);</script> <style>
.sk { background:var(--surface2); }
.font-mono { font-family: ui-monospace, "Cascadia Code", Menlo, Consolas, monospace; }
.vtable td { vertical-align:middle; }
.vbadge { display:inline-block; font-size:.74rem; font-weight:600; border-radius:999px; padding:.1rem .6rem; border:1px solid var(--border); color:var(--muted); white-space:nowrap; }
.vbadge.ok { color:#3ecf8e; border-color:color-mix(in srgb,#3ecf8e 40%,transparent); background:color-mix(in srgb,#3ecf8e 10%,transparent); }
.vbadge.update { color:#f5a623; border-color:color-mix(in srgb,#f5a623 50%,transparent); background:color-mix(in srgb,#f5a623 12%,transparent); }
.vbadge.muted { color:var(--muted); }
.vbadge.soon { color:#f5a623; border-color:color-mix(in srgb,#f5a623 50%,transparent); background:color-mix(in srgb,#f5a623 12%,transparent); }
.vbadge.eol { color:#f87171; border-color:color-mix(in srgb,#f87171 55%,transparent); background:color-mix(in srgb,#f87171 14%,transparent); font-weight:700; }
/* opdaterings-niveau-farver */
.vbadge.lvl-major { color:#fb923c; border-color:color-mix(in srgb,#fb923c 50%,transparent); background:color-mix(in srgb,#fb923c 12%,transparent); }
.vbadge.lvl-patch { color:var(--muted); border-color:var(--border); background:transparent; }
/* EOL-celle på ÉN linje: kompakt badge + lille næste-major */
.vtable td:last-child { white-space:nowrap; }
.nextmajor { margin-left:.35rem; font-size:.72rem; color:var(--accent); white-space:nowrap; }
.opslead { display:flex; align-items:baseline; gap:.7rem; flex-wrap:wrap; }
/* filter-værktøjslinje (persistent i shell, overlever 10s-poll) */
.opstools { display:flex; gap:.8rem; align-items:center; flex-wrap:wrap; margin:1.2rem 0 .2rem; }
.segmented { display:inline-flex; border:1px solid var(--border); border-radius:.7rem; overflow:hidden; }
.segmented button { background:transparent; color:var(--muted); border:0; padding:.5rem .9rem; cursor:pointer; font:inherit; font-size:.85rem; }
.segmented button.on { background:var(--surface2); color:var(--text); }
/* opdaterings-/EOL-markering på rækker (desktop: tonet baggrund) */
.updrow td, .eolnear td { background:color-mix(in srgb,#f5a623 7%,transparent); }
.eolsoon td { background:color-mix(in srgb,#f5a623 14%,transparent); }
.eolrow td { background:color-mix(in srgb,#f87171 9%,transparent); }
.slot { background:color-mix(in srgb, var(--surface) 70%, transparent); border:1px solid var(--border); border-radius:.9rem; padding:.8rem .9rem; }
.slot.next { border-color:#f5a623; box-shadow:0 0 0 1px color-mix(in srgb,#f5a623 40%,transparent); }
.slot-top { display:flex; justify-content:space-between; align-items:center; margin-bottom:.35rem; }
.slot-id { font-weight:700; color:var(--muted); font-size:.85rem; }
.slot-state { font-size:.72rem; border-radius:999px; padding:.05rem .5rem; border:1px solid var(--border); color:var(--muted); }
.slot-state.live { color:#3ecf8e; border-color:color-mix(in srgb,#3ecf8e 40%,transparent); }
.slot-state.busy { color:#f5a623; border-color:color-mix(in srgb,#f5a623 40%,transparent); }
.slot-state.down { color:#f87171; border-color:color-mix(in srgb,#f87171 40%,transparent); }
.slot-next { color:#f5a623; font-size:.74rem; margin-top:.35rem; }
#opscontent.stale { opacity:.55; transition:opacity .3s; }
/* mobil: versionstabellen kollapser til kort (ét pr. tjeneste) */
@media (max-width: 640px) {
.vtable thead { display:none; }
.vtable, .vtable tbody, .vtable tr, .vtable td { display:block; width:100%; }
.vtable tr { border:1px solid var(--border); border-radius:.9rem; padding:.55rem .85rem;
margin-bottom:.7rem; background:color-mix(in srgb,var(--surface) 70%,transparent); }
.vtable tr.updrow, .vtable tr.eolnear { border-color:color-mix(in srgb,#f5a623 50%,transparent); }
/* EOL < 3 mdr: hele kortet gult */
.vtable tr.eolsoon { border-color:color-mix(in srgb,#f5a623 70%,transparent);
background:color-mix(in srgb,#f5a623 22%,transparent); }
.vtable tr.eolrow { border-color:color-mix(in srgb,#f87171 60%,transparent);
background:color-mix(in srgb,#f87171 18%,transparent); }
.vtable tr.eolsoon td:first-child, .vtable tr.eolrow td:first-child { border-bottom-color:color-mix(in srgb,var(--text) 18%,transparent); }
.vtable td { border:0; background:transparent; padding:.2rem 0; display:flex;
justify-content:space-between; gap:1rem; align-items:center; text-align:right; }
.vtable td:first-child { font-size:1.05rem; padding:.1rem 0 .45rem; margin-bottom:.35rem;
border-bottom:1px solid var(--border); }
.vtable td:not(:first-child)::before { content:attr(data-label); color:var(--muted);
font-size:.76rem; font-weight:600; text-align:left; letter-spacing:.02em; }
.vtable td .vbadge { white-space:normal; text-align:right; }
.vtable td:last-child { white-space:normal; }
}
</style>
<script>
(function(){
var box = document.getElementById('opscontent'), busy = false;
var fstate = { q:'', mode:'all', level:'all' };
// Filter ligger i shell (persistent) og genanvendes efter hver poll → fokus/værdi bevares.
function applyFilter(){
var rows = box.querySelectorAll('.vtable tbody tr'), shown = 0;
rows.forEach(function(tr){
var name = tr.getAttribute('data-name') || '',
st = tr.getAttribute('data-status'), lvl = tr.getAttribute('data-level'),
eol = tr.getAttribute('data-eol');
var okq = !fstate.q || name.indexOf(fstate.q) > -1;
var isEol = (eol === 'eol' || eol === 'soon');
var okm = fstate.mode === 'all'
|| (fstate.mode === 'update' && (st === 'update' || isEol))
|| (fstate.mode === 'eol' && isEol);
// niveau-filter rammer kun opdateringer (kun de har data-level)
var okl = fstate.level === 'all' || lvl === fstate.level;
var vis = okq && okm && okl; tr.style.display = vis ? '' : 'none'; if (vis) shown++;
});
var nm = box.querySelector('#opsnomatch');
if (nm) nm.style.display = (rows.length && !shown) ? 'block' : 'none';
}
var fi = document.getElementById('opsfilter');
if (fi) fi.addEventListener('input', function(){ fstate.q = fi.value.trim().toLowerCase(); applyFilter(); });
function wireSeg(id, key){
var bar = document.getElementById(id); if (!bar) return;
bar.addEventListener('click', function(e){
var b = e.target.closest('button[data-' + key + ']'); if (!b) return;
fstate[key] = b.getAttribute('data-' + key);
bar.querySelectorAll('button').forEach(function(x){ x.classList.toggle('on', x === b); });
applyFilter();
});
}
wireSeg('opsmode', 'mode');
wireSeg('opslevel', 'level');
function load(){
if (busy) return; busy = true;
fetch('/ops-content', {headers:{'X-Requested-With':'fetch'}})
.then(function(r){ if(!r.ok) throw 0; return r.text(); })
.then(function(html){ box.innerHTML = html; box.classList.remove('stale'); applyFilter(); })
.catch(function(){ box.classList.add('stale'); })
.finally(function(){ busy = false; });
}
load();
setInterval(load, 10000);
document.addEventListener('visibilitychange', function(){ if(!document.hidden) load(); });
})();
</script>
{% endblock %} {% endblock %}
+87
View File
@@ -0,0 +1,87 @@
{# fragment — injiceres i #opscontent og pollers hvert 10s fra admin_ops.html #}
{% set updates = versions|selectattr('status','equalto','update')|list %}
{% set eol_now = versions|selectattr('eol')|selectattr('eol.state','equalto','eol')|list %}
{% set eol_soon = versions|selectattr('eol')|selectattr('eol.state','equalto','soon')|list %}
<div class="opslead" style="margin-top:1.4rem">
<h2 style="margin:0">Versioner & opdateringer</h2>
{% if updates %}<span class="vbadge update">{{ updates|length }} opdatering{{ 'er' if updates|length != 1 }} klar</span>
{% else %}<span class="vbadge ok">alt opdateret</span>{% endif %}
{% if eol_now %}<span class="vbadge eol">{{ eol_now|length }} EOL</span>{% endif %}
{% if eol_soon %}<span class="vbadge soon">{{ eol_soon|length }} EOL snart</span>{% endif %}
</div>
<table class="vtable" style="margin-top:.7rem">
<thead><tr><th>Tjeneste</th><th>Din version</th><th>Nyeste</th><th>Status</th><th>Support (EOL)</th></tr></thead>
<tbody>
{% for v in versions %}
{% set ec = v.eol.state if v.eol else 'none' %}
{% set near3 = v.eol and v.eol.state == 'soon' and v.eol.days is defined and v.eol.days < 92 %}
{% set rowclass = 'eolrow' if ec == 'eol' else ('eolsoon' if near3 else ('eolnear' if ec == 'soon' else ('updrow' if v.status == 'update' else ''))) %}
<tr class="{{ rowclass }}" data-name="{{ (v.name or '')|lower }}" data-status="{{ v.status }}" data-level="{{ v.level or '' }}" data-eol="{{ ec }}">
<td data-label="Tjeneste"><strong>{{ v.name }}</strong></td>
<td class="font-mono" data-label="Din version">{{ v.current or '—' }}</td>
<td class="font-mono muted" data-label="Nyeste">{{ v.latest or '—' }}</td>
<td data-label="Status">
{% if v.status == 'ok' %}<span class="vbadge ok">✓ opdateret</span>
{% elif v.status == 'update' %}<span class="vbadge update lvl-{{ v.level }}" title="{{ v.level or 'opdatering' }}-opdatering">↑ {{ v.level or 'opdatering' }}: {{ v.latest }}</span>
{% elif v.status == 'latest-only' %}<span class="vbadge muted" title="kender kun nyeste udgivelse">nyeste: {{ v.latest or '—' }}</span>
{% else %}<span class="vbadge muted" title="ingen kilde til nyeste version">ukendt</span>{% endif %}
</td>
<td data-label="Support (EOL)">
{% if not v.eol %}<span class="muted">—</span>
{% elif v.eol.state == 'eol' %}<span class="vbadge eol" title="cyklus {{ v.eol.cycle }} er end-of-life">⚠ EOL{% if v.eol.date %} siden {{ v.eol.date }}{% endif %}</span>
{% elif v.eol.state == 'soon' %}<span class="vbadge soon" title="cyklus {{ v.eol.cycle }} udløber snart">EOL {{ v.eol.date }}{% if v.eol.days is defined %} ({{ v.eol.days }}d){% endif %}</span>
{% else %}<span class="vbadge muted" title="cyklus {{ v.eol.cycle }} supporteret">{% if v.eol.date %}✓ til {{ v.eol.date }}{% else %}✓ supporteret{% endif %}</span>{% endif %}
{% if v.eol and v.eol.state in ('eol','soon') and v.eol.next %}<small class="nextmajor" title="anbefalet opgradering: major {{ v.eol.next.cycle }}{% if v.eol.next.eol %} (EOL {{ v.eol.next.eol }}){% endif %}">→ <strong>{{ v.eol.next.cycle }}</strong>{% if v.eol.next.latest %} ({{ v.eol.next.latest }}){% endif %}</small>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<p id="opsnomatch" class="muted" style="display:none;margin:.4rem 0">Ingen tjenester matcher filteret.</p>
<h2>Static-assets</h2>
<div class="stat" style="max-width:18rem">
<div class="n">{{ version if version else '—' }}</div>
<div class="muted">deployet version (assets.weircon.dk)</div>
</div>
<h2 style="display:flex;align-items:baseline;gap:.8rem">Random-proxy egress
{% if rp and not rp.error %}<span class="muted" style="font-size:.8rem;letter-spacing:0;text-transform:none">
{{ rp.available }}/{{ rp.proxies_total }} live · {{ rp.inflight_total }} inflight i alt</span>{% endif %}
</h2>
{% if rp.error %}
<div class="err">Kunne ikke nå random-proxy: {{ rp.error }}</div>
{% else %}
{% set rot = rp.rotation %}
{% if rot %}
<div class="stat" style="margin-bottom:1rem">
<div class="muted">Næste rollover</div>
<div style="margin-top:.3rem">
<strong>slot {{ rot.next_slot }}</strong>
— <span class="font-mono">{{ rot.next_egress_ip }}</span>
{% if rot.geo and rot.geo.flag %}<span title="{{ rot.geo.name }}">{{ rot.geo.flag }} {{ rot.geo.iso }}</span>{% endif %}
ruller af om <strong>{{ '%d:%02d'|format(rot.eta_sec//60, rot.eta_sec%60) }}</strong>
<span class="muted">(interval {{ rot.interval_sec }}s · sidst: slot {{ rot.last_slot }} for {{ rot.last_rotated_age_sec }}s siden)</span>
</div>
</div>
{% endif %}
<div class="grid" style="grid-template-columns:repeat(auto-fill,minmax(190px,1fr))">
{% for p in rp.proxies %}
<div class="slot{% if rot and p.id == rot.next_slot %} next{% endif %}">
<div class="slot-top">
<span class="slot-id">#{{ p.id }}</span>
<span class="slot-state {{ 'down' if not p.reachable else ('busy' if p.inflight else 'live') }}">
{{ '⚠ down' if not p.reachable else (p.state or 'live') }}</span>
</div>
<div class="font-mono" style="font-size:.92rem;overflow:hidden;text-overflow:ellipsis">{{ p.egress_ip or '–' }}</div>
<div class="muted" style="font-size:.84rem;margin-top:.2rem">
{% if p.geo and p.geo.flag %}{{ p.geo.flag }} {{ p.geo.iso }} · {% endif %}{{ p.inflight or 0 }} inflight
{% if p.latency_ms %}· {{ p.latency_ms }}ms{% endif %}
</div>
{% if rot and p.id == rot.next_slot %}<div class="slot-next">↻ ruller af næst</div>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
+39 -16
View File
@@ -2,13 +2,13 @@
{% block title %}Weircon — forside{% endblock %} {% block title %}Weircon — forside{% endblock %}
{% block body %} {% block body %}
<h1>Hej, {{ user.name }}</h1> <h1>Hej, {{ user.name }}</h1>
<p class="wt-lead">Tjenester du har adgang til.</p> <p class="lead">Tjenester du har adgang til.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %} {% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
<div id="dash"> <div id="dash">
<div class="wt-grid" style="margin-top:1.6rem" aria-hidden="true"> <div class="grid" style="margin-top:1.6rem" aria-hidden="true">
{% for i in range(8) %} {% for i in range(8) %}
<div class="wt-tile skel"><div class="wt-tile-head"><span class="wt-tile-ico"></span><span class="sk" style="width:55%;height:1em"></span></div><span class="sk" style="width:80%;height:.8em;margin-top:.8rem"></span></div> <div class="card skel"><div class="head"><span class="ico"></span><span class="sk" style="width:55%;height:1em"></span></div><span class="sk" style="width:80%;height:.8em;margin-top:.8rem"></span></div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -16,23 +16,46 @@
{% if is_admin %} {% if is_admin %}
<section class="appgroup"> <section class="appgroup">
<h2>Administration</h2> <h2>Administration</h2>
<div class="wt-grid"> <div class="grid">
<a class="wt-tile" href="/admin"> <a class="card" href="/admin" style="position:relative">
<div class="wt-tile-head"><span class="wt-tile-ico">⚙️</span><span class="wt-tile-name">Admin-panel</span></div> <div class="head"><span class="ico">⚙️</span><span class="name">Admin-panel</span></div>
<div class="wt-tile-sub">Brugere, grupper, drift, ikoner m.m.</div> <div class="sub">Brugere, grupper, drift, ikoner m.m.</div>
<div class="wt-tile-go">Åbn →</div> <div class="go">Åbn →</div>
</a> </a>
</div> </div>
</section> </section>
{% endif %} {% endif %}
<style>
.toolbar { display:flex; gap:1rem; align-items:center; flex-wrap:wrap; margin:1.4rem 0 .2rem; }
.toolbar .filter { margin:0; flex:1; min-width:12rem; }
.viewtoggle { display:inline-flex; border:1px solid var(--border); border-radius:.7rem; overflow:hidden; }
.viewtoggle button { background:transparent; color:var(--muted); border:0; padding:.5rem .9rem; cursor:pointer; font:inherit; font-size:.88rem; }
.viewtoggle button.on { background:var(--surface2); color:var(--text); }
.card { position:relative; }
.card:hover { border-color:var(--accent); transform:translateY(-3px); background:color-mix(in srgb, var(--surface) 92%, transparent); }
.card-link { position:absolute; inset:0; z-index:1; border-radius:inherit; }
.fav { position:absolute; top:.55rem; right:.6rem; z-index:2; width:1.7rem; height:1.7rem; border-radius:.5rem;
border:0; background:transparent; color:var(--border); cursor:pointer; font-size:1.05rem; line-height:1; padding:0; transition:color .15s, transform .15s; }
.fav:hover { color:var(--muted); transform:scale(1.15); }
.fav.on { color:#f5c518; }
.mcdot { display:inline-block; width:.55rem; height:.55rem; border-radius:50%; margin-right:.4rem; }
.mcdot.run { background:#3fb950; box-shadow:0 0 6px rgba(63,185,80,.5); }
.mcdot.off { background:var(--muted); }
.mcdot.warn { background:#f5c518; }
.mcdot.err { background:#f85149; }
.card.skel { opacity:.5; pointer-events:none; }
.card.skel .ico { background:var(--surface2); }
.sk { display:inline-block; background:var(--surface2); border-radius:5px; }
[hidden] { display:none !important; }
</style>
<script> <script>
(function(){ (function(){
function setView(v){ function setView(v){
var g=document.getElementById('view-grouped'), a=document.getElementById('view-alpha'); var g=document.getElementById('view-grouped'), a=document.getElementById('view-alpha');
if(!g||!a) return; if(!g||!a) return;
g.hidden=(v==='alpha'); a.hidden=(v!=='alpha'); g.hidden=(v==='alpha'); a.hidden=(v!=='alpha');
document.querySelectorAll('.wt-segment button').forEach(function(b){ b.classList.toggle('is-on', b.getAttribute('data-view')===v); }); document.querySelectorAll('.viewtoggle button').forEach(function(b){ b.classList.toggle('on', b.getAttribute('data-view')===v); });
} }
function setFav(slug,on){ function setFav(slug,on){
document.querySelectorAll('.fav[data-slug="'+CSS.escape(slug)+'"]').forEach(function(b){ document.querySelectorAll('.fav[data-slug="'+CSS.escape(slug)+'"]').forEach(function(b){
@@ -40,9 +63,9 @@
}); });
var favgrid=document.getElementById('favgrid'), favsec=document.getElementById('favsec'); var favgrid=document.getElementById('favgrid'), favsec=document.getElementById('favsec');
if(!favgrid) return; if(!favgrid) return;
var has=favgrid.querySelector('.wt-tile[data-slug="'+CSS.escape(slug)+'"]'); var has=favgrid.querySelector('.card[data-slug="'+CSS.escape(slug)+'"]');
if(on && !has){ if(on && !has){
var src=document.querySelector('#view-grouped .wt-tile[data-slug="'+CSS.escape(slug)+'"]') || document.querySelector('.wt-tile[data-slug="'+CSS.escape(slug)+'"]'); var src=document.querySelector('#view-grouped .card[data-slug="'+CSS.escape(slug)+'"]') || document.querySelector('.card[data-slug="'+CSS.escape(slug)+'"]');
if(src){ favgrid.appendChild(src.cloneNode(true)); } if(src){ favgrid.appendChild(src.cloneNode(true)); }
} else if(!on && has){ has.remove(); } } else if(!on && has){ has.remove(); }
if(favsec) favsec.style.display = favgrid.children.length ? '' : 'none'; if(favsec) favsec.style.display = favgrid.children.length ? '' : 'none';
@@ -55,7 +78,7 @@
setFav(slug,on); setFav(slug,on);
fetch('/api/favorite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug:slug,on:on})}); fetch('/api/favorite',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug:slug,on:on})});
return; } return; }
var vb=e.target.closest && e.target.closest('.wt-segment button'); var vb=e.target.closest && e.target.closest('.viewtoggle button');
if(vb){ var v=vb.getAttribute('data-view'); setView(v); if(vb){ var v=vb.getAttribute('data-view'); setView(v);
fetch('/api/view',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({view:v})}); } fetch('/api/view',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({view:v})}); }
}); });
@@ -68,13 +91,13 @@
if(!inp || inp.__wired) return; inp.__wired=true; if(!inp || inp.__wired) return; inp.__wired=true;
inp.addEventListener('input',function(){ inp.addEventListener('input',function(){
var q=inp.value.trim().toLowerCase(), shown=0; var q=inp.value.trim().toLowerCase(), shown=0;
document.querySelectorAll('.wt-tile[data-search]').forEach(function(c){ document.querySelectorAll('.card[data-search]').forEach(function(c){
var hit=!q || c.getAttribute('data-search').indexOf(q)>-1; var hit=!q || c.getAttribute('data-search').indexOf(q)>-1;
c.classList.toggle('hidden',!hit); if(hit && c.offsetParent!==null) shown++; c.classList.toggle('hidden',!hit); if(hit && c.offsetParent!==null) shown++;
}); });
document.querySelectorAll('.appgroup').forEach(function(s){ document.querySelectorAll('.appgroup').forEach(function(s){
if(s.id==='favsec') return; if(s.id==='favsec') return;
var any=s.querySelector('.wt-tile:not(.hidden)'); s.style.display=any?'':'none'; var any=s.querySelector('.card:not(.hidden)'); s.style.display=any?'':'none';
}); });
var nm=document.getElementById('nomatch'); if(nm) nm.style.display=shown?'none':'block'; var nm=document.getElementById('nomatch'); if(nm) nm.style.display=shown?'none':'block';
}); });
@@ -82,7 +105,7 @@
fetch('/dashboard-content',{headers:{'X-Requested-With':'fetch'}}) fetch('/dashboard-content',{headers:{'X-Requested-With':'fetch'}})
.then(function(r){ if(!r.ok) throw 0; return r.text(); }) .then(function(r){ if(!r.ok) throw 0; return r.text(); })
.then(function(html){ document.getElementById('dash').innerHTML=html; initDash(); }) .then(function(html){ document.getElementById('dash').innerHTML=html; initDash(); })
.catch(function(){ document.getElementById('dash').innerHTML='<p class="wt-muted" style="margin-top:1.6rem">Kunne ikke hente tjenester — prøv at genindlæse siden.</p>'; }); .catch(function(){ document.getElementById('dash').innerHTML='<p class="muted" style="margin-top:1.6rem">Kunne ikke hente tjenester — prøv at genindlæse siden.</p>'; });
})(); })();
</script> </script>
{% endblock %} {% endblock %}
+38 -19
View File
@@ -1,39 +1,58 @@
{% macro card(a) %} {% macro card(a) %}
<div class="wt-tile" data-slug="{{ a.slug }}" data-search="{{ (a.name ~ ' ' ~ a.desc ~ ' ' ~ a.host ~ ' ' ~ a.group)|lower }}"> <div class="card" data-slug="{{ a.slug }}" data-search="{{ (a.name ~ ' ' ~ a.desc ~ ' ' ~ a.host ~ ' ' ~ a.group)|lower }}">
<a class="wt-tile-link" href="{{ a.url }}" aria-label="{{ a.name }}"></a> <a class="card-link" href="{{ a.url }}" aria-label="{{ a.name }}"></a>
<button type="button" class="fav{% if a.slug in favorites %} on{% endif %}" data-slug="{{ a.slug }}" <button type="button" class="fav{% if a.slug in favorites %} on{% endif %}" data-slug="{{ a.slug }}"
aria-pressed="{{ 'true' if a.slug in favorites else 'false' }}" title="Markér som favorit" aria-label="Favorit">★</button> aria-pressed="{{ 'true' if a.slug in favorites else 'false' }}" title="Markér som favorit" aria-label="Favorit">★</button>
<div class="wt-tile-head"> <div class="head">
<span class="wt-tile-ico">{% if a.icon and a.icon.startswith('http') %}<img src="{{ a.icon }}" alt="">{% else %}{{ (a.name or '?')[0]|upper }}{% endif %}</span> <span class="ico">{% if a.icon and a.icon.startswith('http') %}<img src="{{ a.icon }}" alt="">{% else %}{{ (a.name or '?')[0]|upper }}{% endif %}</span>
<span class="wt-tile-name">{{ a.name }}</span> <span class="name">{{ a.name }}</span>
</div> </div>
<div class="wt-tile-sub" title="{{ a.desc if a.desc else a.host }}">{{ a.desc if a.desc else a.host }}</div> <div class="sub" title="{{ a.desc if a.desc else a.host }}">{{ a.desc if a.desc else a.host }}</div>
<div class="wt-tile-go">Åbn →</div> <div class="go">Åbn →</div>
</div> </div>
{% endmacro %} {% endmacro %}
{% if error %}<div class="wt-flash wt-flash--error">{{ error }}</div>{% endif %} {% if error %}<div class="err">{{ error }}</div>{% endif %}
{% if groups %} {% if groups %}
<div class="wt-toolbar"> <div class="toolbar">
<div class="wt-field" style="flex:1;min-width:12rem"><span class="wt-field-ico">⌕</span> <div class="filter"><span class="mag">⌕</span>
<input id="appfilter" class="wt-input" type="search" placeholder="Filtrér…" autocomplete="off" aria-label="Filtrér tjenester"></div> <input id="appfilter" type="search" placeholder="Filtrér…" autocomplete="off" aria-label="Filtrér tjenester"></div>
<div class="wt-segment" role="group" aria-label="Visning"> <div class="viewtoggle" role="group" aria-label="Visning">
<button type="button" data-view="grouped" class="{{ 'is-on' if view != 'alpha' }}">Grupperet</button> <button type="button" data-view="grouped" class="{{ 'on' if view != 'alpha' }}">Grupperet</button>
<button type="button" data-view="alpha" class="{{ 'is-on' if view == 'alpha' }}">Alfabetisk</button> <button type="button" data-view="alpha" class="{{ 'on' if view == 'alpha' }}">Alfabetisk</button>
</div> </div>
</div> </div>
<section id="favsec" class="appgroup"{% if not fav_apps %} style="display:none"{% endif %}> <section id="favsec" class="appgroup"{% if not fav_apps %} style="display:none"{% endif %}>
<h2>★ Favoritter</h2> <h2>★ Favoritter</h2>
<div class="wt-grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div> <div class="grid" id="favgrid">{% for a in fav_apps %}{{ card(a) }}{% endfor %}</div>
</section> </section>
{% if mc_servers %}
<section class="appgroup" id="mcsec">
<h2>⛏️ Minecraft-servere</h2>
<div class="grid">
{% for s in mc_servers %}
<div class="card" data-search="minecraft {{ (s.name ~ ' ' ~ s.version ~ ' ' ~ s.status)|lower }}">
<a class="card-link" href="{{ disco_url }}" aria-label="{{ s.name }} i DiscoPanel"></a>
<div class="head"><span class="ico">⛏️</span><span class="name">{{ s.name }}</span></div>
<div class="sub" title="{{ s.version }}">
<span class="mcdot {{ s.css }}"></span>{{ s.status }}
{%- if s.online %} · {{ s.players }}/{{ s.max_players }} spillere{% endif %}
{%- if s.version %} · {{ s.version }}{% endif %}
</div>
<div class="go">Åbn DiscoPanel →</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
<div id="view-grouped"{% if view == 'alpha' %} hidden{% endif %}> <div id="view-grouped"{% if view == 'alpha' %} hidden{% endif %}>
{% for g in groups %} {% for g in groups %}
<section class="appgroup"><h2>{{ g.name }}</h2><div class="wt-grid">{% for a in g.apps %}{{ card(a) }}{% endfor %}</div></section> <section class="appgroup"><h2>{{ g.name }}</h2><div class="grid">{% for a in g.apps %}{{ card(a) }}{% endfor %}</div></section>
{% endfor %} {% endfor %}
</div> </div>
<div id="view-alpha"{% if view != 'alpha' %} hidden{% endif %}> <div id="view-alpha"{% if view != 'alpha' %} hidden{% endif %}>
<section class="appgroup"><h2>Alle tjenester</h2><div class="wt-grid">{% for a in apps_sorted %}{{ card(a) }}{% endfor %}</div></section> <section class="appgroup"><h2>Alle tjenester</h2><div class="grid">{% for a in apps_sorted %}{{ card(a) }}{% endfor %}</div></section>
</div> </div>
<p id="nomatch" class="wt-muted" style="display:none;margin-top:1rem">Ingen tjenester matcher.</p> <p id="nomatch" class="muted" style="display:none;margin-top:1rem">Ingen tjenester matcher.</p>
{% elif not error %} {% elif not error %}
<p class="wt-muted" style="margin-top:1.6rem">Ingen apps fundet for din konto endnu.</p> <p class="muted" style="margin-top:1.6rem">Ingen apps fundet for din konto endnu.</p>
{% endif %} {% endif %}
+303
View File
@@ -0,0 +1,303 @@
"""Version-/opdaterings-tjek for homelab-tjenester. Udvidbart register:
- 'kind' = hvor DIN kørende version hentes (authentik/gitea/matomo/assets/None).
- 'repo' = GitHub owner/repo → 'nyeste' release. None = ingen kilde.
Resultater caches 1 time (versioner ændrer sig sjældent; GitHub-rate-limit 60/t)."""
import asyncio
import json
import re
import httpx
import cache
from config import (API_TOKEN, AUTHENTIK, ASSETS_VERSION_URL, MATOMO_BASE, MATOMO_TOKEN,
DATA, GITHUB_TOKEN, NTFY_URL, NTFY_TOPIC, NTFY_TOKEN)
# Kørende versioner for LXC-tjenester portalen ikke selv kan nå — indsamlet på
# Proxmox-værten af portal-versions.py (cron) og pushet hertil. Nøgler = SERVICES-navne.
LOCAL_VERSIONS = DATA / "versions_local.json"
# Sidst-kendte-gode "nyeste"-tags pr. kilde, persisteret til disk. Et fetch-fejl
# (fx GitHub rate-limit 60/t eller en genstart) blanker dermed ALDRIG kolonnen —
# værdien opdateres kun ved et vellykket nyt opslag.
LATEST_STORE = DATA / "latest_versions.json"
def _load_local():
try:
return json.loads(LOCAL_VERSIONS.read_text("utf-8"))
except Exception:
return {}
def _load_latest_store():
try:
return json.loads(LATEST_STORE.read_text("utf-8"))
except Exception:
return {}
def _save_latest_store(d):
try:
LATEST_STORE.write_text(json.dumps(d, ensure_ascii=False, indent=2), "utf-8")
except Exception:
pass
# (navn, kind til current-version, github-repo til nyeste). Tilføj en linje = ny tjeneste.
SERVICES = [
("Authentik", "authentik", "goauthentik/authentik"),
("Gitea", "gitea", "go-gitea/gitea"),
("Gitea runner", None, "gitea:gitea.com/gitea/runner"),
("Matomo", "matomo", "matomo-org/matomo"),
("Static-assets", "assets", None),
("NPMplus", None, "ZoeyVid/NPMplus"),
("Immich", None, "immich-app/immich"),
("Mealie", None, "mealie-recipes/mealie"),
("Firefly III", None, "firefly-iii/firefly-iii"),
("Nextcloud", None, "nextcloud/server"),
("Sonarr", None, "Sonarr/Sonarr"),
("Radarr", None, "Radarr/Radarr"),
("Prowlarr", None, "Prowlarr/Prowlarr"),
("Bazarr", None, "morpheus65535/bazarr"),
("qBittorrent", None, "qbittorrent/qBittorrent"),
("Overleaf", None, "gitea-tag:gitea.weircon.dk/agw/overleaf-cep"), # cep-fork bruger tags (ikke releases)
("OpenProject", None, "opf/openproject"),
("SFTPGo", None, "drakkan/sftpgo"),
("Opengist", None, "thomiceli/opengist"),
("Home Assistant OS", None, "home-assistant/operating-system"),
("Comentario", None, "gitlab:comentario/comentario"),
("Emby", None, None),
("MariaDB", None, None),
("Proxmox VE", None, None),
]
def _norm(v):
if not v:
return ""
# behold dato-versioner (fx NPMplus 2026-04-21-r2) — strip kun v-præfiks + build-metadata
return re.sub(r"^[vV]", "", str(v).strip()).split("+")[0].strip().lower()
async def _latest(src, client, store):
"""Nyeste release-tag. src er enten 'owner/repo' (GitHub, bagud-kompat) eller
'gitlab:group/proj' / 'gitea:host/owner/repo'. `store` = sidst-kendte-gode-dict
(muteres in-place ved succes; bruges som fallback ved fetch-fejl)."""
if not src:
return None
provider, loc = src.split(":", 1) if ":" in src else ("github", src)
ck = f"ver:{provider}:{loc}"
hit = cache.get(ck)
if hit is not None:
return hit or store.get(src) or None
tag = None
try:
if provider == "github":
h = {"Accept": "application/vnd.github+json", "User-Agent": "weircon-portal"}
if GITHUB_TOKEN:
h["Authorization"] = f"Bearer {GITHUB_TOKEN}"
r = await client.get(f"https://api.github.com/repos/{loc}/releases/latest", headers=h)
tag = r.json().get("tag_name") if r.status_code < 300 else None
elif provider == "gitlab":
import urllib.parse
pid = urllib.parse.quote(loc, safe="")
r = await client.get(f"https://gitlab.com/api/v4/projects/{pid}/releases",
params={"per_page": 1}, headers={"User-Agent": "weircon-portal"})
j = r.json() if r.status_code < 300 else []
tag = j[0]["tag_name"] if isinstance(j, list) and j else None
elif provider == "gitea":
host, repo = loc.split("/", 1)
r = await client.get(f"https://{host}/api/v1/repos/{repo}/releases",
params={"limit": 1}, headers={"User-Agent": "weircon-portal"})
j = r.json() if r.status_code < 300 else []
tag = j[0]["tag_name"] if isinstance(j, list) and j else None
elif provider == "gitea-tag":
# repos der bruger tags i stedet for releases (fx overleaf-cep). Nyeste tag.
host, repo = loc.split("/", 1)
r = await client.get(f"https://{host}/api/v1/repos/{repo}/tags",
params={"limit": 1}, headers={"User-Agent": "weircon-portal"})
j = r.json() if r.status_code < 300 else []
tag = j[0]["name"] if isinstance(j, list) and j else None
except Exception:
tag = None
if tag:
store[src] = tag # opdatér sidst-kendte-gode
cache.put(ck, tag, 3600)
return tag
# fetch fejlede → fald tilbage til sidst-kendte-gode (overlever rate-limit/genstart)
cache.put(ck, "", 180) # prøv live igen snart
return store.get(src) or None
# endoflife.date-produkt pr. tjeneste (kun dem der faktisk er tracket der).
EOL_PRODUCTS = {"Proxmox VE": "proxmox-ve", "Nextcloud": "nextcloud",
"MariaDB": "mariadb", "Matomo": "matomo"}
async def _eol(name, cur, client):
"""Slår kørende version op i endoflife.date → {eol-dato, state}. None hvis ikke tracket."""
prod = EOL_PRODUCTS.get(name)
if not prod or not cur:
return None
ck = f"eol:{prod}"
data = cache.get(ck)
if data is None:
try:
r = await client.get(f"https://endoflife.date/api/{prod}.json",
headers={"User-Agent": "weircon-portal", "Accept": "application/json"})
data = r.json() if r.status_code < 300 else []
except Exception:
data = []
cache.put(ck, data, 43200) # 12t — EOL-data ændrer sig sjældent
v = _norm(cur)
best = None
for c in data:
cyc = str(c.get("cycle"))
if v == cyc or v.startswith(cyc + "."):
if best is None or len(cyc) > len(str(best.get("cycle"))):
best = c
if not best:
return None
cycle = str(best.get("cycle"))
# næste major = cyklussen lige over den kørende (endoflife lister nyeste først)
nxt = None
try:
idx = data.index(best)
if idx > 0:
n = data[idx - 1]
nxt = {"cycle": str(n.get("cycle")), "latest": n.get("latest"),
"eol": n.get("eol") if isinstance(n.get("eol"), str) else None}
except Exception:
nxt = None
out = {"cycle": cycle, "next": nxt}
eol = best.get("eol")
if eol is True:
out.update(date=None, state="eol")
return out
if eol is False or eol is None:
out.update(date=None, state="supported")
return out
try:
import datetime
days = (datetime.date.fromisoformat(str(eol)) - datetime.date.today()).days
out.update(date=str(eol), days=days,
state="eol" if days <= 0 else ("soon" if days <= 183 else "supported"))
return out
except Exception:
out.update(date=str(eol), state="supported")
return out
async def _current(kind, client):
try:
if kind == "authentik":
r = await client.get(f"{AUTHENTIK}/api/v3/admin/version/",
headers={"Authorization": f"Bearer {API_TOKEN}"})
d = r.json()
return d.get("version_current"), d.get("version_latest")
if kind == "gitea":
r = await client.get("https://gitea.weircon.dk/api/v1/version")
return r.json().get("version"), None
if kind == "matomo":
r = await client.get(f"{MATOMO_BASE}/index.php", params={
"module": "API", "method": "API.getMatomoVersion", "format": "JSON", "token_auth": MATOMO_TOKEN})
return r.json().get("value"), None
if kind == "assets":
r = await client.get(ASSETS_VERSION_URL)
return r.text.strip()[:40], None
except Exception:
pass
return None, None
def _level(cur, latest):
"""Klassificér en tilgængelig opdatering: major / minor / patch (bugfix)."""
pa = [int(x) for x in re.findall(r"\d+", _norm(cur))]
pb = [int(x) for x in re.findall(r"\d+", _norm(latest))]
n = max(len(pa), len(pb))
pa += [0] * (n - len(pa))
pb += [0] * (n - len(pb))
if not pa or not pb:
return None
if pa[0] != pb[0]:
return "major"
if n > 1 and pa[1] != pb[1]:
return "minor"
return "patch"
async def _one(name, kind, repo, client, local, store):
cur, lat_builtin = await _current(kind, client)
if not cur:
cur = local.get(name) # vært-indsamlet snapshot for LXC-tjenester
latest = lat_builtin or await _latest(repo, client, store)
eol = await _eol(name, cur, client)
level = None
if cur and latest:
if _norm(cur) == _norm(latest):
status = "ok"
else:
status = "update"
level = _level(cur, latest)
elif cur:
status = "unknown" # ingen 'nyeste'-kilde
else:
status = "latest-only" # kender kun nyeste, ikke din version
return {"name": name, "current": cur, "latest": latest,
"status": status, "level": level, "eol": eol}
ALERT_STATE = DATA / "alert_state.json"
async def _maybe_alert(res, client):
"""Push en ntfy-besked når en NY opdatering eller EOL dukker op (ikke ved hver poll).
Første kørsel seedes tavst, så man ikke får alle eksisterende på én gang."""
if not (NTFY_URL and NTFY_TOKEN):
return
cur, detail = set(), {}
for v in res:
if v["status"] == "update":
sig = f"upd:{v['name']}:{_norm(v['latest'])}"
cur.add(sig); detail[sig] = f"⬆ {v['name']} {v['current']} -> {v['latest']}"
e = v.get("eol")
if e and e.get("state") in ("eol", "soon"):
sig = f"eol:{v['name']}:{e.get('date')}"
cur.add(sig); detail[sig] = f"⏳ {v['name']} EOL {e.get('date')}"
try:
stored = set(json.loads(ALERT_STATE.read_text("utf-8")))
except Exception:
stored = None
if stored is None: # første kørsel: seed tavst
ALERT_STATE.write_text(json.dumps(sorted(cur)), "utf-8")
return
new = cur - stored
if new:
body = "\n".join(detail[s] for s in sorted(new))
try:
await client.post(f"{NTFY_URL}/{NTFY_TOPIC}",
headers={"Authorization": f"Bearer {NTFY_TOKEN}",
"Title": f"Weircon ops: {len(new)} nye",
"Tags": "package", "Click": "https://weircon.dk/admin/ops",
"Icon": "https://assets.weircon.dk/static/icons/icon.png"},
content=body.encode("utf-8"), timeout=8)
except Exception:
pass
ALERT_STATE.write_text(json.dumps(sorted(cur)), "utf-8")
async def all_versions():
hit = cache.get("ver:all")
if hit is not None:
return hit
local = _load_local()
store = _load_latest_store()
async with httpx.AsyncClient(timeout=8) as client:
res = await asyncio.gather(*[_one(n, k, r, client, local, store) for n, k, r in SERVICES])
await _maybe_alert(res, client)
_save_latest_store(store) # persistér evt. nye sidst-kendte-gode tags
# Hvis "nyeste" mangler for tjenester der HAR en kilde (typisk rate-limit-blip),
# cache kun kort så det prøver igen snart; ellers fuld 30-min cache.
expects = {n for n, k, r in SERVICES if r or k == "authentik"}
incomplete = any(v["name"] in expects and not v["latest"] for v in res)
cache.put("ver:all", res, 180 if incomplete else 1800)
return res