The per-tab account pin, the X-Siftlode-Account header and ?account= go: the session cookie alone says who is asking. localStorage keys lose their account suffix; the browser records whose state it holds (siftlode.owner), BootGate hands it to the account the server answered as, and a different account starts from a clean slate (the public /watch keys stay). The first boot moves the account's old per-account copies onto the plain keys and drops other accounts' copies. Until the claim, and after another tab claimed the browser for someone else, the account helpers read and write nothing. The account panel lists no other accounts: "Add another account" becomes "Sign in as someone else" (Google's account chooser; the pick replaces this account, a cancel keeps it), shown where Google sign-in is configured.
130 lines
6.2 KiB
TypeScript
130 lines
6.2 KiB
TypeScript
// 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<string, unknown>;
|
|
}
|
|
|
|
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<DebugSession> => 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<Response>(`${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 `<img>` loads a screenshot from. */
|
|
debugScreenshotUrl: (id: string, shotId: number): string =>
|
|
`${SESSIONS}/${id}/screenshots/${shotId}`,
|
|
};
|