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; hasPin: boolean;
enabled: boolean; enabled: boolean;
channelCount: number; channelCount: number;
healthStatus: "ok" | "dead" | "unknown";
healthCheckedAt: string | null;
healthError: string | null;
}; };
type OdidoSettings = { 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) { async function uploadWvd(file: File | null) {
if (!file) return; if (!file) return;
setBusy(true); setBusy(true);
@ -298,7 +366,12 @@ export default function ScriptsPage() {
load-balanced; unieke zenders komen van het account dat ze wél heeft. load-balanced; unieke zenders komen van het account dat ze wél heeft.
</p> </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 ? ( {accounts.length === 0 ? (
<p className="muted">Nog geen accounts — voeg hieronder je eerste klantnummer toe.</p> <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)", borderBottom: "1px solid var(--border, #333)",
}} }}
> >
<div> <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<strong>{a.label || a.username}</strong> <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.username}
{a.id !== "legacy" ? ` · ${a.channelCount} zenders` : ""} {a.id !== "legacy" ? ` · ${a.channelCount} zenders` : ""}
{!a.enabled ? " · uit" : ""}
</span> </span>
</div> </div>
<div style={{ display: "flex", gap: 6 }}> <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{a.id !== "legacy" && ( {a.id !== "legacy" && (
<> <>
<button type="button" disabled={busy} onClick={() => void probeAccounts(a.id)}>
Check
</button>
<button type="button" disabled={busy} onClick={() => void toggleAccount(a)}> <button type="button" disabled={busy} onClick={() => void toggleAccount(a)}>
{a.enabled ? "Uitschakelen" : "Inschakelen"} {a.enabled ? "Uitschakelen" : "Inschakelen"}
</button> </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 --- // --- Custom ClearKey/DASH live channel lists ---
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => { app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels"); const { listLiveChannelListsAdmin } = await import("../viewer/live-channels");

View file

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

View file

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

View file

@ -132,6 +132,9 @@ export async function getOdidoSettingsPublic() {
hasPin: !!row.passwordEnc, hasPin: !!row.passwordEnc,
enabled: true, enabled: true,
channelCount: 0, channelCount: 0,
healthStatus: "unknown" as const,
healthCheckedAt: null,
healthError: null,
}, },
] ]
: []; : [];
@ -290,6 +293,53 @@ export async function removeOdidoAccount(sessionSecret: string, accountId: strin
return getOdidoSettingsPublic(); 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) { export async function saveOdidoWvdUpload(contentBase64: string) {
const cleaned = contentBase64.replace(/^data:.*?;base64,/, "").trim(); const cleaned = contentBase64.replace(/^data:.*?;base64,/, "").trim();
if (!cleaned) throw new AppError("INVALID_REQUEST", "Lege WVD-upload", 400); if (!cleaned) throw new AppError("INVALID_REQUEST", "Lege WVD-upload", 400);