Show Odido account health (active/dead) in Scripts after login probe.
This commit is contained in:
parent
96117a4887
commit
e3601710f8
5 changed files with 180 additions and 8 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
const cached = sessions.get(username);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.cookies;
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue