// The user's diagnostics level and the browser log it lets out. Only touches core's req and // beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`. import type { LogEvent } from "../log"; import type { ServerLogRow } from "./admin"; import { beacon, identityHeaders, req } from "./core"; /** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */ export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE"; export interface DiagnosticsState { level: DiagnosticsLevel; /** When a raised level lapses back to INFO (ISO); null at INFO. */ until: string | null; /** How many days this instance keeps stored log lines — the consent text states it. */ retention_days: number; /** How long a debug session records at most — the start dialog's consent text states it. */ session_minutes: number; } /** Where batches go. The shipper leaves the log lines ABOUT this request out of what it sends, or * every batch would carry the previous batch's request line forever. */ export const DIAGNOSTICS_EVENTS_PATH = "/api/diagnostics/events"; export const diagnosticsApi = { setDiagnosticsLevel: ( level: DiagnosticsLevel, ): Promise<{ ok: true; diagnostics: DiagnosticsState }> => req("/api/me/diagnostics", { method: "PUT", body: JSON.stringify({ level }) }), // Quiet: a lost batch is not the user's problem to be told about in a dialog. sendLogEvents: (events: LogEvent[]): Promise<{ ok: true; stored: number }> => req( DIAGNOSTICS_EVENTS_PATH, { method: "POST", body: JSON.stringify({ events }) }, { quiet: true }, ), /** The last batch of a closing tab (keepalive, fire-and-forget). */ sendLogEventsOnUnload: (events: LogEvent[]): void => beacon(DIAGNOSTICS_EVENTS_PATH, { events }), }; // --- debug sessions ---------------------------------------------------------------------- /** The two levels a recording can run at — it always records more than the base. */ export type DebugSessionLevel = "DEBUG" | "TRACE"; /** One recording: Start → reproduce → Stop. `until` is the server's cap; past it the session is * over whether or not it was stopped. */ export interface DebugSession { id: string; level: DebugSessionLevel; started_at: string; until: string; ended_at: string | null; ended_reason: "stopped" | "expired" | null; /** The issue it was sent as, once sent. */ issue_url: string | null; /** Files a send that stopped half-way has still to deliver (retrying sends only these). */ pending_files: string[]; /** Whether the issue's body has still to be edited to show the screenshots it already carries. */ images_pending: boolean; /** Whether a send is under way RIGHT NOW. Every step of a send commits its progress, so without * this a reload or a second tab reads a running send as a failed one. */ sending: boolean; screenshots: number; /** The issue being written from it (the composer saves as it goes); null until it first does. */ draft: DebugDraft | null; } /** A saved draft: the rendered text the export and the issue carry, plus the composer's own fields * (`form`, lib/issueDraft `DraftForm`) to reopen it as it was. */ export interface DebugDraft { title: string; body: string; excluded: number[]; form: Record; } export interface DebugScreenshot { id: number; mime: string; size: number; created_at: string; } const SESSIONS = "/api/debug-sessions"; export const debugSessionsApi = { startDebugSession: (level: DebugSessionLevel): Promise<{ ok: true; session: DebugSession }> => req(SESSIONS, { method: "POST", body: JSON.stringify({ level }) }), stopDebugSession: (id: string): Promise<{ ok: true; session: DebugSession }> => req(`${SESSIONS}/${id}/stop`, { method: "POST" }), debugSessions: (): Promise<{ items: DebugSession[] }> => req(SESSIONS), debugSession: (id: string): Promise => req(`${SESSIONS}/${id}`), /** The rows an issue from this session would carry, browser and server, in time order. */ debugSessionEvents: (id: string): Promise<{ items: ServerLogRow[]; total: number }> => req(`${SESSIONS}/${id}/events`), debugScreenshots: (id: string): Promise<{ items: DebugScreenshot[] }> => req(`${SESSIONS}/${id}/screenshots`), /** One image as the raw body — the server checks what it really is, whatever `type` says. */ addDebugScreenshot: ( id: string, image: Blob, ): Promise<{ ok: true; screenshot: DebugScreenshot }> => req(`${SESSIONS}/${id}/screenshots`, { method: "POST", headers: { "Content-Type": image.type || "application/octet-stream", ...identityHeaders() }, body: image, }), saveDebugDraft: (id: string, draft: DebugDraft): Promise<{ ok: true; session: DebugSession }> => req(`${SESSIONS}/${id}/draft`, { method: "PUT", body: JSON.stringify(draft) }), /** The report as a zip, built from the SAVED draft — a request, not a navigation, so a refusal * (the export's own pace) reaches the user as a message instead of a saved error page. */ debugExport: async (id: string): Promise<{ blob: Blob; name: string }> => { const r = await req(`${SESSIONS}/${id}/export`, {}, { raw: true }); const disposition = r.headers.get("content-disposition") ?? ""; const name = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `siftlode-debug-${id}.zip`; return { blob: await r.blob(), name }; }, /** Send the SAVED draft to the issue tracker, or finish a send that stopped half-way. */ sendDebugIssue: (id: string): Promise<{ ok: true; session: DebugSession }> => req(`${SESSIONS}/${id}/issue`, { method: "POST" }), /** The body as the tracker renders it (sanitised HTML — shown in a sandboxed frame only). */ previewDebugIssue: (id: string, text: string): Promise<{ ok: true; html: string }> => req(`${SESSIONS}/${id}/preview`, { method: "POST", body: JSON.stringify({ text }) }), removeDebugScreenshot: (id: string, shotId: number): Promise<{ ok: true; deleted: number }> => req(`${SESSIONS}/${id}/screenshots/${shotId}`, { method: "DELETE" }), /** Where an `` loads a screenshot from. */ debugScreenshotUrl: (id: string, shotId: number): string => `${SESSIONS}/${id}/screenshots/${shotId}`, };