reconcile: import current prod state from CT151 (media-cleanup, access admin, ops versioning, newer dashboard)
This commit is contained in:
+119
@@ -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)
|
||||||
|
|||||||
@@ -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."},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 {'&':'&','<':'<','>':'>','"':'"'}[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
@@ -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 %}
|
||||||
|
|||||||
@@ -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
@@ -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 %}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
Reference in New Issue
Block a user