Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 0 additions & 68 deletions internal/admin/dashboard/static/dist/assets/index-G3uz-AWc.js

This file was deleted.

67 changes: 67 additions & 0 deletions internal/admin/dashboard/static/dist/assets/index-uqP7q8EU.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/admin/dashboard/static/dist/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion web/dashboard/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,14 @@ provider/model display) · `dateKeys.js` (UTC "YYYY-MM-DD" day-key math shared
by the timezone store, the reporting window and the contribution calendar) ·
`chartTheme.js` (theme colors + the shared Chart.js style fragments) ·
`clipboard.svelte.js` · `debounce.js` · `storage.js` (localStorage can be
absent or blocked — never touch it directly) · `api/paths.js` (`gomodelPath`).
absent or blocked — never touch it directly) · `api/paths.js` (`gomodelPath`) ·
`attachments.js` (`{@attach ...}` behaviours: `dismissOnOutside`,
`autofocusWithin`).

For DOM behaviour tied to one element, prefer an attachment over `bind:this` +
`$effect`: the element arrives as the argument, the returned teardown runs with
the element, and a falsy expression (`{@attach open ? x(…) : undefined}`)
switches it off.

## Page skeleton

Expand Down
80 changes: 80 additions & 0 deletions web/dashboard/src/lib/api/eventStream.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Client for the gateway's SSE-framed admin streams (GET /admin/live/logs).
//
// Two consumers read that endpoint — the audit-log live stream
// (pages/audit-logs/live-logs-logic.js) and the overview's token-throughput
// signal stream (pages/overview/liveTokensState.svelte.js) — and each used to
// carry its own copy of the framing, the `data:` parsing and the reconnect
// backoff. The transport lives here once; the consumers keep their own
// policy (what to do with an event, when reconnecting is allowed).
//
// No imports, no runes: `live-logs-logic.js` is loaded directly by node --test,
// which cannot resolve the `$lib` alias, so it imports this module by relative
// path while bundled code uses `$lib/api/eventStream.js`.

/**
* Ceiling for the attempt counter, NOT a retry limit: the consumers keep
* reconnecting indefinitely, and once the counter pins here the delay stops
* growing (see nextReconnect).
*/
export const MAX_RECONNECT_ATTEMPTS = 6;

/**
* Read an SSE body to completion, decoding each frame's `data:` lines and
* handing the parsed JSON to `onEvent`. Frames that are not valid JSON are
* skipped; a trailing frame with no blank-line terminator is still flushed.
*
* @param {{read: () => Promise<{done: boolean, value?: Uint8Array}>}} reader
* @param {(event: unknown) => void} onEvent
*/
export async function consumeEventStream(reader, onEvent) {
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let delimiter;
while ((delimiter = buffer.match(/\r?\n\r?\n/))) {
const splitAt = delimiter.index;
const frame = buffer.slice(0, splitAt);
buffer = buffer.slice(splitAt + delimiter[0].length);
emitFrame(frame, onEvent);
}
}
buffer += decoder.decode();
if (buffer.trim()) {
emitFrame(buffer, onEvent);
}
}

function emitFrame(frame, onEvent) {
const lines = String(frame || "").split(/\r?\n/);
const data = [];
for (const line of lines) {
if (line.indexOf("data:") === 0) {
data.push(line.slice(5).trimStart());
}
}
if (data.length === 0) return;
let event;
try {
event = JSON.parse(data.join("\n"));
} catch {
return;
}
onEvent(event);
}

/**
* Next attempt number and its exponential backoff delay: 500ms doubling per
* attempt, so the delay tops out at 16s once `attempt` reaches
* MAX_RECONNECT_ATTEMPTS (the 30s ceiling below is a guard the attempt cap
* keeps unreachable). `attempts` is the count of consecutive failures so far.
*
* @param {number} attempts
* @returns {{attempt: number, delay: number}}
*/
export function nextReconnect(attempts) {
const attempt = Math.min(Number(attempts || 0) + 1, MAX_RECONNECT_ATTEMPTS);
return { attempt, delay: Math.min(30000, 500 * Math.pow(2, attempt - 1)) };
}
17 changes: 6 additions & 11 deletions web/dashboard/src/lib/components/atoms/Modal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
// lock (via the modals store), and autofocus of [data-modal-autofocus].
// Children render inside the shell; give the top-level child the dialog
// role/class (e.g. <section class="model-editor" role="dialog">).
import { tick, untrack } from "svelte";
import { untrack } from "svelte";
import { modals } from "$lib/stores/ui.svelte.js";
import { autofocusWithin } from "$lib/utils/attachments.js";

let {
open = false,
Expand All @@ -23,19 +24,11 @@
variant === "auth" ? "auth-dialog-shell" : "editor-modal-shell",
);

let shellEl = $state(null);

$effect(() => {
if (!open) return;
// untrack: opened() reads AND writes modals.stack; tracking that read
// would make the effect invalidate itself and loop (effect_update_depth).
const token = untrack(() => modals.opened());
tick().then(() => {
const target = shellEl && shellEl.querySelector("[data-modal-autofocus]");
if (target && typeof target.focus === "function") {
target.focus();
}
});
const onKeydown = (event) => {
// Only the topmost dialog reacts to Escape (stacked dialogs, e.g. the
// auth dialog over an editor, must not both close).
Expand All @@ -50,8 +43,10 @@
};
});

// Only a click on the shell itself is a backdrop click; clicks inside the
// dialog bubble up to the same handler with a deeper target.
function onShellClick(event) {
if (closeOnBackdrop && event.target === shellEl) {
if (closeOnBackdrop && event.target === event.currentTarget) {
onclose?.();
}
}
Expand All @@ -60,7 +55,7 @@
{#if open}
<div class={backdropClass} aria-hidden="true"></div>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class={shellClass} bind:this={shellEl} onclick={onShellClick}>
<div class={shellClass} onclick={onShellClick} {@attach autofocusWithin()}>
{@render children?.()}
</div>
{/if}
Expand Down
37 changes: 10 additions & 27 deletions web/dashboard/src/lib/components/molecules/ChartCanvas.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,23 @@
// Chart.js wrapper. Rebuilds the chart when the config or theme changes —
// theme rebuilds are required because colors are read from CSS variables at
// build time. Pass a `build()` function returning a Chart.js config (type,
// data, options, plugins); it runs inside an effect so reactive reads are
// tracked automatically.
// data, options, plugins); it runs inside the canvas attachment, so reactive
// reads are tracked automatically and the chart is destroyed (and rebuilt)
// with the attachment.
import Chart from "chart.js/auto";
import { themeStore } from "$lib/stores/ui.svelte.js";

let { build, class: className = "", ariaLabel = "" } = $props();

let canvas = $state(null);
let chart = null;

$effect(() => {
function chart(canvas) {
// Track theme changes so charts pick up the new palette.
void themeStore.tick;
if (!canvas || typeof build !== "function") return;
if (typeof build !== "function") return;
const config = build();
if (!config) {
if (chart) {
chart.destroy();
chart = null;
}
return;
}
if (chart) {
chart.destroy();
chart = null;
}
chart = new Chart(canvas.getContext("2d"), config);
return () => {
if (chart) {
chart.destroy();
chart = null;
}
};
});
if (!config) return;
const instance = new Chart(canvas.getContext("2d"), config);
return () => instance.destroy();
}
</script>

<canvas bind:this={canvas} class={className} aria-label={ariaLabel}></canvas>
<canvas class={className} aria-label={ariaLabel} {@attach chart}></canvas>
22 changes: 4 additions & 18 deletions web/dashboard/src/lib/components/molecules/DatePicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { timezone } from "$lib/stores/timezone.svelte.js";
import DatePickerCalendar from "./DatePickerCalendar.svelte";
import { shiftMonth } from "./datePickerLogic.js";
import { dismissOnOutside } from "$lib/utils/attachments.js";

let { onchange } = $props();

Expand All @@ -15,7 +16,6 @@
let selectingDate = $state("start");
let calendarMonth = $state(new Date());
let cursorHint = $state({ show: false, x: 0, y: 0 });
let rootEl = $state(null);

function toggle() {
open = !open;
Expand All @@ -33,22 +33,6 @@
cursorHint = { show: false, x: 0, y: 0 };
}

$effect(() => {
if (!open) return;
const onDocClick = (event) => {
if (rootEl && !rootEl.contains(event.target)) close();
};
const onKeydown = (event) => {
if (event.key === "Escape") close();
};
document.addEventListener("click", onDocClick, true);
window.addEventListener("keydown", onKeydown);
return () => {
document.removeEventListener("click", onDocClick, true);
window.removeEventListener("keydown", onKeydown);
};
});

// A day rollover slides a today-following window without any click, so the
// host page has to refetch just as it would after a manual pick.
let seenSyncTick = dateRange.syncTick;
Expand Down Expand Up @@ -93,7 +77,9 @@
}
</script>

<div class="date-picker" bind:this={rootEl}>
<!-- The dismiss attachment sits on the root (trigger included), so clicking the
trigger is an inside click and `toggle` keeps owning that case. -->
<div class="date-picker" {@attach open ? dismissOnOutside(close) : undefined}>
<button
class="date-picker-trigger"
title={dateRange.dateRangeSpanLabel()}
Expand Down
49 changes: 49 additions & 0 deletions web/dashboard/src/lib/utils/attachments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Reusable Svelte attachments (`{@attach ...}`), for the small DOM behaviours
// the dashboard repeats. An attachment is a function that receives the element
// it is placed on and may return a teardown function; it runs in an effect, so
// reactive reads inside it are tracked and teardown runs when the element goes
// away. Prefer these over `bind:this` + `$effect` — there is no element ref to
// hold and no null guard to write.
//
// Attach a falsy value to disable one: `{@attach open ? dismissOnOutside(close) : undefined}`.

/**
* Dismiss a popup when a click lands outside the element or Escape is pressed.
* Place it on the popup's outermost element (the one that also contains its
* trigger, so clicking the trigger stays an inside click and the trigger's own
* toggle keeps working).
*
* @param {() => void} ondismiss
*/
export function dismissOnOutside(ondismiss) {
return (/** @type {Element} */ node) => {
const onDocClick = (/** @type {MouseEvent} */ event) => {
if (!node.contains(/** @type {Node} */ (event.target))) ondismiss();
};
const onKeydown = (/** @type {KeyboardEvent} */ event) => {
if (event.key === "Escape") ondismiss();
};
// Capture phase: see the click even when something inside the page stops
// it from bubbling.
document.addEventListener("click", onDocClick, true);
window.addEventListener("keydown", onKeydown);
return () => {
document.removeEventListener("click", onDocClick, true);
window.removeEventListener("keydown", onKeydown);
};
};
}

/**
* Focus the first descendant matching `selector` once the element is mounted.
*
* @param {string} [selector]
*/
export function autofocusWithin(selector = "[data-modal-autofocus]") {
return (/** @type {Element} */ node) => {
const target = /** @type {HTMLElement | null} */ (
node.querySelector(selector)
);
if (target && typeof target.focus === "function") target.focus();
};
}
42 changes: 5 additions & 37 deletions web/dashboard/src/pages/audit-logs/live-logs-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
// fetchUsage, fetchAuditLog, isAuditEntryExpanded, refreshLiveConversation,
// noteLiveTokenUsage, fetchAuditEntryDetail
//
// No Svelte runes and no imports here: node --test runs this file directly.
// No Svelte runes here, and the single import is by relative path: node --test
// runs this file directly and cannot resolve the `$lib` alias.

import { consumeEventStream } from "../../lib/api/eventStream.js";

const LIVE_LOGS_STREAM_PATH = "/admin/live/logs?types=audit,usage";

Expand Down Expand Up @@ -52,42 +55,7 @@ export function liveLogsStreamPath(lastSeq) {
export function liveLogsMethods() {
return {
async consumeLiveLogsBody(reader) {
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let delimiter;
while ((delimiter = buffer.match(/\r?\n\r?\n/))) {
const splitAt = delimiter.index;
const frame = buffer.slice(0, splitAt);
buffer = buffer.slice(splitAt + delimiter[0].length);
this.handleLiveLogsFrame(frame);
}
}
buffer += decoder.decode();
if (buffer.trim()) {
this.handleLiveLogsFrame(buffer);
}
},

handleLiveLogsFrame(frame) {
const lines = String(frame || '').split(/\r?\n/);
const data = [];
for (const line of lines) {
if (line.indexOf('data:') === 0) {
data.push(line.slice(5).trimStart());
}
}
if (data.length === 0) return;
let event;
try {
event = JSON.parse(data.join('\n'));
} catch {
return;
}
this.applyLiveLogEvent(event);
await consumeEventStream(reader, (event) => this.applyLiveLogEvent(event));
},

applyLiveLogEvent(event) {
Expand Down
10 changes: 6 additions & 4 deletions web/dashboard/src/pages/audit-logs/liveLogs.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@
// Transport: the stream is fetch + ReadableStream (NOT EventSource) so the
// Authorization bearer header can be sent; apiFetch preserves that. The
// stream uses SSE framing (data: lines, CRLF frames), a replay cursor
// (&cursor=lastSeq), exponential reconnect backoff (500ms * 2^n, capped at
// 30s, attempt cap 6) and heartbeat handling.
// (&cursor=lastSeq), exponential reconnect backoff (500ms * 2^n, attempt
// counter capped at 6 so the delay tops out at 16s; reconnects continue
// indefinitely) and heartbeat handling. Framing and backoff are shared with
// the overview's usage-signal stream via $lib/api/eventStream.js.

import { untrack } from "svelte";
import { apiFetch, getJSON, isAbortError } from "$lib/api/client.js";
import { nextReconnect } from "$lib/api/eventStream.js";
import { readStored } from "$lib/utils/storage.js";
import { auth } from "$lib/stores/auth.svelte.js";
import { runtimeConfig } from "$lib/stores/runtimeConfig.svelte.js";
Expand Down Expand Up @@ -196,9 +199,8 @@ class LiveLogsStore {
this.liveLogsStreaming = false;
if (!this.liveLogsEnabled()) return;
if (this.liveLogsReconnectTimer) return;
const attempt = Math.min(this.liveLogsReconnectAttempts + 1, 6);
const { attempt, delay } = nextReconnect(this.liveLogsReconnectAttempts);
this.liveLogsReconnectAttempts = attempt;
const delay = Math.min(30000, 500 * Math.pow(2, attempt - 1));
this.liveLogsReconnectTimer = setTimeout(() => {
this.liveLogsReconnectTimer = null;
void this.startLiveLogs();
Expand Down
Loading