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;
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue