portal: improve request status UX and jellyfin sync

This commit is contained in:
Brad Stein 2026-01-02 04:27:44 -03:00
parent 8edc680503
commit 1cb12dd6c6
5 changed files with 76 additions and 3 deletions

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import socket
import time import time
from typing import Any from typing import Any
@ -11,6 +12,16 @@ from ..keycloak import admin_client, require_auth, require_account_access
from ..utils import random_password from ..utils import random_password
def _tcp_check(host: str, port: int, timeout_sec: float) -> bool:
if not host or port <= 0:
return False
try:
with socket.create_connection((host, port), timeout=timeout_sec):
return True
except OSError:
return False
def register(app) -> None: def register(app) -> None:
@app.route("/api/account/overview", methods=["GET"]) @app.route("/api/account/overview", methods=["GET"])
@require_auth @require_auth
@ -24,10 +35,14 @@ def register(app) -> None:
mailu_app_password = "" mailu_app_password = ""
mailu_status = "ready" mailu_status = "ready"
jellyfin_status = "ready" jellyfin_status = "ready"
jellyfin_sync_status = "unknown"
jellyfin_sync_detail = ""
if not admin_client().ready(): if not admin_client().ready():
mailu_status = "server not configured" mailu_status = "server not configured"
jellyfin_status = "server not configured" jellyfin_status = "server not configured"
jellyfin_sync_status = "unknown"
jellyfin_sync_detail = "keycloak admin not configured"
elif username: elif username:
try: try:
user = admin_client().find_user(username) or {} user = admin_client().find_user(username) or {}
@ -58,6 +73,8 @@ def register(app) -> None:
except Exception: except Exception:
mailu_status = "keycloak admin error" mailu_status = "keycloak admin error"
jellyfin_status = "keycloak admin error" jellyfin_status = "keycloak admin error"
jellyfin_sync_status = "unknown"
jellyfin_sync_detail = "keycloak admin error"
mailu_username = "" mailu_username = ""
if keycloak_email and keycloak_email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"): if keycloak_email and keycloak_email.lower().endswith(f"@{settings.MAILU_DOMAIN.lower()}"):
@ -65,11 +82,28 @@ def register(app) -> None:
elif username: elif username:
mailu_username = f"{username}@{settings.MAILU_DOMAIN}" mailu_username = f"{username}@{settings.MAILU_DOMAIN}"
if jellyfin_status == "ready":
if _tcp_check(
settings.JELLYFIN_LDAP_HOST,
settings.JELLYFIN_LDAP_PORT,
settings.JELLYFIN_LDAP_CHECK_TIMEOUT_SEC,
):
jellyfin_sync_status = "ok"
jellyfin_sync_detail = "LDAP reachable"
else:
jellyfin_sync_status = "degraded"
jellyfin_sync_detail = "LDAP unreachable"
return jsonify( return jsonify(
{ {
"user": {"username": username, "email": keycloak_email, "groups": g.keycloak_groups}, "user": {"username": username, "email": keycloak_email, "groups": g.keycloak_groups},
"mailu": {"status": mailu_status, "username": mailu_username, "app_password": mailu_app_password}, "mailu": {"status": mailu_status, "username": mailu_username, "app_password": mailu_app_password},
"jellyfin": {"status": jellyfin_status, "username": username}, "jellyfin": {
"status": jellyfin_status,
"username": username,
"sync_status": jellyfin_sync_status,
"sync_detail": jellyfin_sync_detail,
},
} }
) )

View File

@ -77,3 +77,6 @@ MAILU_SYNC_URL = os.getenv(
).rstrip("/") ).rstrip("/")
JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/") JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/")
JELLYFIN_LDAP_HOST = os.getenv("JELLYFIN_LDAP_HOST", "openldap.sso.svc.cluster.local").strip()
JELLYFIN_LDAP_PORT = int(os.getenv("JELLYFIN_LDAP_PORT", "389"))
JELLYFIN_LDAP_CHECK_TIMEOUT_SEC = float(os.getenv("JELLYFIN_LDAP_CHECK_TIMEOUT_SEC", "1"))

View File

@ -29,6 +29,16 @@
font-size: 13px; font-size: 13px;
} }
.pill-ok {
border-color: rgba(120, 255, 160, 0.35);
color: rgba(170, 255, 215, 0.92);
}
.pill-warn {
border-color: rgba(255, 220, 120, 0.35);
color: rgba(255, 230, 170, 0.92);
}
.card { .card {
background: var(--bg-panel); background: var(--bg-panel);
border: 1px solid var(--border); border: 1px solid var(--border);

View File

@ -85,7 +85,18 @@
<div class="card module"> <div class="card module">
<div class="module-head"> <div class="module-head">
<h2>Jellyfin</h2> <h2>Jellyfin</h2>
<span class="pill mono">{{ jellyfin.status }}</span> <span
class="pill mono"
:class="jellyfin.syncStatus === 'ok' ? 'pill-ok' : jellyfin.syncStatus === 'degraded' ? 'pill-warn' : ''"
>
{{
jellyfin.syncStatus === "ok"
? "in sync"
: jellyfin.syncStatus === "degraded"
? "check sync"
: jellyfin.status
}}
</span>
</div> </div>
<p class="muted"> <p class="muted">
Jellyfin authentication is backed by LDAP (Keycloak is the source of truth). Use your Keycloak username and Jellyfin authentication is backed by LDAP (Keycloak is the source of truth). Use your Keycloak username and
@ -101,6 +112,7 @@
<span class="v mono">{{ jellyfin.username }}</span> <span class="v mono">{{ jellyfin.username }}</span>
</div> </div>
</div> </div>
<div v-if="jellyfin.syncDetail" class="hint mono">{{ jellyfin.syncDetail }}</div>
<div v-if="jellyfin.error" class="error-box"> <div v-if="jellyfin.error" class="error-box">
<div class="mono">{{ jellyfin.error }}</div> <div class="mono">{{ jellyfin.error }}</div>
</div> </div>
@ -173,6 +185,8 @@ const mailu = reactive({
const jellyfin = reactive({ const jellyfin = reactive({
status: "loading", status: "loading",
username: "", username: "",
syncStatus: "",
syncDetail: "",
error: "", error: "",
}); });
@ -230,9 +244,13 @@ async function refreshOverview() {
mailu.currentPassword = data.mailu?.app_password || ""; mailu.currentPassword = data.mailu?.app_password || "";
jellyfin.status = data.jellyfin?.status || "ready"; jellyfin.status = data.jellyfin?.status || "ready";
jellyfin.username = data.jellyfin?.username || auth.username; jellyfin.username = data.jellyfin?.username || auth.username;
jellyfin.syncStatus = data.jellyfin?.sync_status || "";
jellyfin.syncDetail = data.jellyfin?.sync_detail || "";
} catch (err) { } catch (err) {
mailu.status = "unavailable"; mailu.status = "unavailable";
jellyfin.status = "unavailable"; jellyfin.status = "unavailable";
jellyfin.syncStatus = "";
jellyfin.syncDetail = "";
mailu.error = "Failed to load account status."; mailu.error = "Failed to load account status.";
jellyfin.error = "Failed to load account status."; jellyfin.error = "Failed to load account status.";
} }

View File

@ -196,13 +196,21 @@ async function copyRequestCode() {
async function checkStatus() { async function checkStatus() {
if (checking.value) return; if (checking.value) return;
error.value = ""; error.value = "";
const trimmed = statusForm.request_code.trim();
if (!trimmed) return;
if (!trimmed.includes("~")) {
error.value = "Request code should look like username~XXXXXXXXXX. Copy it from the submit step.";
status.value = "unknown";
onboardingUrl.value = "";
return;
}
checking.value = true; checking.value = true;
try { try {
const resp = await fetch("/api/access/request/status", { const resp = await fetch("/api/access/request/status", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
cache: "no-store", cache: "no-store",
body: JSON.stringify({ request_code: statusForm.request_code.trim() }), body: JSON.stringify({ request_code: trimmed }),
}); });
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`); if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);