Show Odido account health (active/dead) in Scripts after login probe.

This commit is contained in:
Jos Vooges | STH 2026-09-22 00:57:47 +02:00
parent 96117a4887
commit e3601710f8
5 changed files with 180 additions and 8 deletions

View file

@ -21,6 +21,9 @@ type OdidoAccount = {
hasPin: boolean;
enabled: boolean;
channelCount: number;
healthStatus: "ok" | "dead" | "unknown";
healthCheckedAt: string | null;
healthError: string | null;
};
type OdidoSettings = {
@ -183,6 +186,71 @@ export default function ScriptsPage() {
}
}
async function probeAccounts(accountId?: string) {
setBusy(true);
setErr(null);
setMsg(null);
try {
const url = accountId
? `/api/v1/admin/scripts/odido/accounts/${accountId}/probe`
: "/api/v1/admin/scripts/odido/accounts/probe";
const r = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: accountId ? undefined : JSON.stringify({}),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error?.message ?? "Statuscheck mislukt");
applySettings(d);
const list: OdidoAccount[] = d.accounts ?? [];
const ok = list.filter((a) => a.healthStatus === "ok").length;
const dead = list.filter((a) => a.healthStatus === "dead").length;
setMsg(`Accountstatus: ${ok} actief · ${dead} dood`);
} catch (e) {
setErr(String((e as Error).message ?? e));
} finally {
setBusy(false);
}
}
function healthBadge(a: OdidoAccount) {
if (!a.enabled) {
return (
<span className="muted" title="Uitgeschakeld" style={{ fontSize: "0.85rem" }}>
uit
</span>
);
}
if (a.healthStatus === "ok") {
return (
<span
className="ok-banner"
title={a.healthCheckedAt ? `Gecontroleerd ${new Date(a.healthCheckedAt).toLocaleString("nl-NL")}` : "Actief"}
style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }}
>
actief
</span>
);
}
if (a.healthStatus === "dead") {
return (
<span
className="err-banner"
title={a.healthError || "Login/heartbeat mislukt"}
style={{ display: "inline", padding: "2px 8px", fontSize: "0.8rem" }}
>
dood
</span>
);
}
return (
<span className="muted" style={{ fontSize: "0.85rem" }} title="Nog niet gecontroleerd">
onbekend
</span>
);
}
async function uploadWvd(file: File | null) {
if (!file) return;
setBusy(true);
@ -298,7 +366,12 @@ export default function ScriptsPage() {
load-balanced; unieke zenders komen van het account dat ze wél heeft.
</p>
<h3 style={{ marginBottom: "0.5rem" }}>Accounts</h3>
<h3 style={{ marginBottom: "0.5rem", display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
Accounts
<button type="button" disabled={busy || accounts.length === 0} onClick={() => void probeAccounts()}>
Status controleren
</button>
</h3>
{accounts.length === 0 ? (
<p className="muted">Nog geen accounts — voeg hieronder je eerste klantnummer toe.</p>
) : (
@ -316,17 +389,20 @@ export default function ScriptsPage() {
borderBottom: "1px solid var(--border, #333)",
}}
>
<div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<strong>{a.label || a.username}</strong>
<span className="muted" style={{ marginLeft: 8, fontSize: "0.85rem" }}>
{healthBadge(a)}
<span className="muted" style={{ fontSize: "0.85rem" }}>
{a.username}
{a.id !== "legacy" ? ` · ${a.channelCount} zenders` : ""}
{!a.enabled ? " · uit" : ""}
</span>
</div>
<div style={{ display: "flex", gap: 6 }}>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{a.id !== "legacy" && (
<>
<button type="button" disabled={busy} onClick={() => void probeAccounts(a.id)}>
Check
</button>
<button type="button" disabled={busy} onClick={() => void toggleAccount(a)}>
{a.enabled ? "Uitschakelen" : "Inschakelen"}
</button>

View file

@ -1692,6 +1692,26 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
}
);
app.post(
"/api/v1/admin/scripts/odido/accounts/probe",
{ preHandler: requireAdmin },
async (request) => {
const body = (request.body ?? {}) as { accountId?: string };
const { probeOdidoAccounts } = await import("../odido/settings");
return probeOdidoAccounts(config.SESSION_SECRET, body.accountId?.trim() || undefined);
}
);
app.post(
"/api/v1/admin/scripts/odido/accounts/:accountId/probe",
{ preHandler: requireAdmin },
async (request) => {
const { accountId } = request.params as { accountId: string };
const { probeOdidoAccounts } = await import("../odido/settings");
return probeOdidoAccounts(config.SESSION_SECRET, accountId);
}
);
// --- Custom ClearKey/DASH live channel lists ---
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels");

View file

@ -1,12 +1,17 @@
import { randomUUID } from "node:crypto";
import { decryptSecret, encryptSecret } from "../security/crypto";
export type OdidoAccountHealth = "ok" | "dead" | "unknown";
export type OdidoAccountStored = {
id: string;
label: string;
username: string;
pinEnc: string;
enabled: boolean;
healthStatus?: OdidoAccountHealth;
healthCheckedAt?: string;
healthError?: string;
};
export type OdidoAccountPublic = {
@ -17,6 +22,9 @@ export type OdidoAccountPublic = {
enabled: boolean;
/** Aantal zenders waar dit account toegang toe heeft (na laatste sync). */
channelCount: number;
healthStatus: OdidoAccountHealth;
healthCheckedAt: string | null;
healthError: string | null;
};
export type OdidoAccountCreds = {
@ -47,6 +55,9 @@ export function accountToPublic(
hasPin: !!account.pinEnc,
enabled: account.enabled !== false,
channelCount,
healthStatus: account.healthStatus ?? "unknown",
healthCheckedAt: account.healthCheckedAt ?? null,
healthError: account.healthError ?? null,
};
}
@ -96,6 +107,13 @@ export function parseAccountsJson(raw: unknown): OdidoAccountStored[] {
username,
pinEnc,
enabled: row.enabled !== false,
healthStatus:
row.healthStatus === "ok" || row.healthStatus === "dead" || row.healthStatus === "unknown"
? row.healthStatus
: undefined,
healthCheckedAt:
typeof row.healthCheckedAt === "string" ? row.healthCheckedAt : undefined,
healthError: typeof row.healthError === "string" ? row.healthError : undefined,
});
}
return out;

View file

@ -146,10 +146,13 @@ async function loginWithDevice(
export async function login(
username: string,
password: string,
sessionCacheMs = DEFAULT_SESSION_CACHE_MS
sessionCacheMs = DEFAULT_SESSION_CACHE_MS,
opts?: { force?: boolean }
): Promise<OdidoCookies> {
if (!opts?.force) {
const cached = sessions.get(username);
if (cached && cached.expiresAt > Date.now()) return cached.cookies;
}
const cookies = await loginWithDevice(username, password, DEFAULT_DEVICE_ID);
const ttl = Number.isFinite(sessionCacheMs) && sessionCacheMs > 0
? sessionCacheMs
@ -158,6 +161,11 @@ export async function login(
return cookies;
}
export function clearOdidoLoginCache(username?: string) {
if (username) sessions.delete(username);
else sessions.clear();
}
export async function heartbeat(cookies: OdidoCookies): Promise<boolean> {
const { body } = await post("/VSP/V3/OnLineHeartbeat", "inMSAAccess", {}, cookies);
return isSuccess(body) && body.userValid !== false && body.userValid !== "0";

View file

@ -132,6 +132,9 @@ export async function getOdidoSettingsPublic() {
hasPin: !!row.passwordEnc,
enabled: true,
channelCount: 0,
healthStatus: "unknown" as const,
healthCheckedAt: null,
healthError: null,
},
]
: [];
@ -290,6 +293,53 @@ export async function removeOdidoAccount(sessionSecret: string, accountId: strin
return getOdidoSettingsPublic();
}
/** Login + heartbeat per account → healthStatus ok/dead opslaan. */
export async function probeOdidoAccounts(
sessionSecret: string,
accountId?: string
) {
const { login, heartbeat } = await import("./client");
const existing = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
const config = await ensureAccountsMigrated(sessionSecret, existing);
const targets = accountId
? config.accounts.filter((a) => a.id === accountId)
: config.accounts;
if (accountId && !targets.length) {
throw new AppError("NOT_FOUND", "Account niet gevonden", 404);
}
const now = new Date().toISOString();
for (const account of targets) {
if (account.enabled === false) {
account.healthStatus = "unknown";
account.healthCheckedAt = now;
account.healthError = "Uitgeschakeld";
continue;
}
try {
const creds = decryptAccount(account, sessionSecret);
const cookies = await login(creds.username, creds.pin, 60_000, { force: true });
const alive = await heartbeat(cookies);
if (!alive) {
account.healthStatus = "dead";
account.healthCheckedAt = now;
account.healthError = "Sessie ongeldig (heartbeat)";
} else {
account.healthStatus = "ok";
account.healthCheckedAt = now;
account.healthError = undefined;
}
} catch (err) {
account.healthStatus = "dead";
account.healthCheckedAt = now;
account.healthError = err instanceof Error ? err.message : String(err);
}
}
await persistAccounts(config, existing?.enabled ?? true);
return getOdidoSettingsPublic();
}
export async function saveOdidoWvdUpload(contentBase64: string) {
const cleaned = contentBase64.replace(/^data:.*?;base64,/, "").trim();
if (!cleaned) throw new AppError("INVALID_REQUEST", "Lege WVD-upload", 400);