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
18 changes: 11 additions & 7 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,7 @@ function modelGroups(calls) {
}

const loadingRegions = new Map();
function setPageLoading(active) {
function setPageLoading(active, error = null) {
for (const [element, {overlay, wrapper}] of loadingRegions) {
overlay.remove(); element.removeAttribute('aria-busy'); wrapper.classList.remove('region-loading'); wrapper.classList.remove('metric-loading');
}
Expand Down Expand Up @@ -821,9 +821,10 @@ function setPageLoading(active) {
const cell = document.createElement('td'); cell.colSpan = element.closest('table').querySelectorAll('thead th').length;
content = document.createElement('div'); content.className = 'region-loader'; cell.append(content); overlay.append(cell);
}
content.setAttribute('role','status');
content.setAttribute('role', error ? 'alert' : 'status');
const spinner = document.createElement('span'); spinner.className = 'page-loader-spinner'; spinner.setAttribute('aria-hidden','true');
const label = document.createElement('span'); label.textContent = t(state.dataMode === 'centralized' ? 'load.loadingCentralized' : 'load.loading');
spinner.hidden = Boolean(error);
const label = document.createElement('span'); label.textContent = error || t(state.dataMode === 'centralized' ? 'load.loadingCentralized' : 'load.loading');
content.append(spinner,label);
element.setAttribute('aria-busy','true'); wrapper.classList.add(metric ? 'metric-loading' : 'region-loading');
(table ? element : wrapper).append(overlay);
Expand Down Expand Up @@ -1886,7 +1887,9 @@ async function loadData(force = false, silent = false) {
const controller = new AbortController();
dataController = controller;
dataRequestKey = key;
if (!state.data) setPageLoading(true);
const missingPage = !quotaView && (!state.data || (state.data.pageOnly && state.data.requestKey !== key));
let pageError = null;
if (!state.data || missingPage) setPageLoading(true);

if (!silent) $("#refreshButton").classList.add("loading");
dataRequest = (async () => {
Expand All @@ -1902,26 +1905,27 @@ async function loadData(force = false, silent = false) {
if (controller.signal.aborted) return;
data.requestKey = key;
if (data.pageData?.view === "conversations") { state.page = data.pageData.page; data.requestKey = pageRequestKey(); }
pageCache.delete(key); pageCache.set(key, {data, etag:response.headers?.get("etag") || cached?.etag});
pageCache.delete(key); pageCache.set(key, {data, etag:response.headers?.get("etag") || (response.status === 304 ? cached?.etag : null)});
while (pageCache.size > 4) pageCache.delete(pageCache.keys().next().value);
applyUsageData(data);
}
if (force && !controller.signal.aborted) toast(t(source === "centralized" ? "refresh.doneCentralized" : "refresh.done"));
} catch (error) {
if (controller.signal.aborted) return;
if (missingPage) pageError = t("load.error", { error: error.message });
if (quotaView && state.data?.quotaOnly && !state.data.quotaDetail) {
$("#quotaChart").innerHTML = `<p role="alert">${escapeHtml(t("load.error", { error: error.message }))}</p>`;
}
if (state.view === "project-groups") {
$("#groupStatus").textContent = t("load.error", { error: error.message });
}
if (!silent || !state.data || state.view === "project-groups") {
if (!silent || !state.data || missingPage || state.view === "project-groups") {
$("#freshness").textContent = t("load.error", { error: error.message });
toast(t("load.errorToast"));
}
} finally {
if (dataController === controller) {
setPageLoading(false);
setPageLoading(Boolean(pageError), pageError);
$("#refreshButton").classList.remove("loading");
dataRequest = null;
dataRequestKey = null;
Expand Down
7 changes: 6 additions & 1 deletion sites-hub/app/api/page/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ export async function GET(request: Request) {
const filters=await readFilters(db(),owner);
for(const cwd of filters.folders) builder.add({cwd,models:filters.models,calls:[],turns:[]});
}
if((await quotaMetadataForOwner(owner)).revision !== metadata.revision) return json({error:'Les données ont changé pendant la lecture. Réessayez.'},409);
// Ingestion and quota updates can advance the revision during a long
// history read. The completed summaries remain useful, but must never
// be cached as an exact representation of the starting revision.
if((await quotaMetadataForOwner(owner)).revision !== metadata.revision) {
return Response.json(builder.finish(), {headers: {'Cache-Control':'private, no-store', Vary:headers.Vary}});
}
}
return Response.json(builder.finish(),{headers});
} catch(error) {
Expand Down
8 changes: 5 additions & 3 deletions sites-hub/lib/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ type SessionRow = { node_id: string; snapshot_json: string };
export async function quotaMetadataForOwner(ownerId: string, includeFirstSession = false) {
const database = db();
if (includeFirstSession) await migrateLegacySessions(database, ownerId);
const nodeResult = await database.prepare("SELECT id, alias, enrolled_at, last_seen, revoked_at, privacy_json, short_quota_json, quota_json, quota_history_json, analyzer_version, last_generated_at, last_payload_hash FROM mesh_nodes WHERE owner_id = ? ORDER BY alias").bind(ownerId).all<NodeRow>();
const [nodeResult, count, preferences] = await Promise.all([
database.prepare("SELECT id, alias, enrolled_at, last_seen, revoked_at, privacy_json, short_quota_json, quota_json, quota_history_json, analyzer_version, last_generated_at, last_payload_hash FROM mesh_nodes WHERE owner_id = ? ORDER BY alias").bind(ownerId).all<NodeRow>(),
database.prepare(`SELECT COUNT(*) AS total${includeFirstSession ? ", MIN(s.started_at) AS first_session_at" : ""} FROM mesh_sessions s JOIN mesh_nodes n ON n.id = s.node_id WHERE n.owner_id = ? AND n.revoked_at IS NULL`).bind(ownerId).first<{ total: number; first_session_at: string | null }>(),
database.prepare("SELECT theme FROM dashboard_preferences WHERE owner_id = ?").bind(ownerId).first<{ theme: string }>(),
]);
const nodes = nodeResult.results || [];
const active = nodes.filter((node) => !node.revoked_at);
const count = await database.prepare(`SELECT COUNT(*) AS total${includeFirstSession ? ", MIN(s.started_at) AS first_session_at" : ""} FROM mesh_sessions s JOIN mesh_nodes n ON n.id = s.node_id WHERE n.owner_id = ? AND n.revoked_at IS NULL`).bind(ownerId).first<{ total: number; first_session_at: string | null }>();
const quotas = active.filter((node) => node.quota_json).map((node) => ({ ...JSON.parse(node.quota_json!), nodeId: node.id, nodeAlias: node.alias, receivedAt: node.last_seen }));
quotas.sort((a, b) => String(b.observedAt || b.receivedAt).localeCompare(String(a.observedAt || a.receivedAt)));
const shortQuotas = active.filter((node) => node.short_quota_json).map((node) => ({ ...JSON.parse(node.short_quota_json!), nodeId: node.id, nodeAlias: node.alias, receivedAt: node.last_seen }));
Expand All @@ -21,7 +24,6 @@ export async function quotaMetadataForOwner(ownerId: string, includeFirstSession
: []);
quotaHistory.sort((a, b) => String(b.observedAt || b.receivedAt).localeCompare(String(a.observedAt || a.receivedAt)));
const uniqueQuotaHistory = normalizeQuotaPeriods({ weeklyQuotaHistory: quotaHistory, weeklyQuota: quotas[0] });
const preferences = await database.prepare("SELECT theme FROM dashboard_preferences WHERE owner_id = ?").bind(ownerId).first<{ theme: string }>();
const metadata = {
theme: preferences?.theme || null,
apiVersion: 1,
Expand Down
18 changes: 15 additions & 3 deletions sites-hub/tests/page-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ test("page API aggregates bounded batches, isolates owners, paginates and loads
const globalsKey = `pageApiTest${process.pid}`;
let sessionQueries = 0;
let materializedRows=0;
let onSessionRead = () => {};
globalThis[globalsKey] = { prepare(sql) {
if (sql.includes("AS snapshot_json")) sessionQueries++;
const statement = database.prepare(sql);
return { bind(...values) { return {
first: async () => statement.get(...values),
run: async () => ({meta:statement.run(...values)}),
all: async () => { const results=statement.all(...values); if(sql.includes("AS snapshot_json")) materializedRows+=results.length; return {results}; },
all: async () => { const results=statement.all(...values); if(sql.includes("AS snapshot_json")) { materializedRows+=results.length; onSessionRead(); } return {results}; },
}; } };
} };
const load = async (path, prefix) => {
Expand Down Expand Up @@ -63,16 +64,27 @@ test("page API aggregates bounded batches, isolates owners, paginates and loads
assert.equal((await route.GET(request(query))).status,503);
assert.equal((await route.GET(request(query))).status,503);
const response=await route.GET(request(query));assert.equal(response.status,200);
const data=await response.json();assert.equal(data.pageData.totals.count,501);assert.equal(data.sessions.length,6);assert.equal(sessionQueries,4);assert.equal(materializedRows,501);
const data=await response.json();assert.equal(data.pageData.totals.count,501);assert.equal(data.sessions.length,6);assert.equal(sessionQueries,1);assert.equal(materializedRows,501);
assert.ok(data.sessions.every(s=>s.calls.length===0 && s.turns.length===0));
const unchanged=await route.GET(request(query,{"If-None-Match":`W/${response.headers.get("etag")}`}));assert.equal(unchanged.status,304);assert.equal(sessionQueries,4);
const unchanged=await route.GET(request(query,{"If-None-Match":`W/${response.headers.get("etag")}`}));assert.equal(unchanged.status,304);assert.equal(sessionQueries,1);
const list=await (await route.GET(request({...query,view:"conversations",page:2,pageSize:20}))).json();
assert.equal(list.sessions.length,20);assert.equal(list.pageData.total,501);assert.equal(list.pageData.page,2);assert.ok(list.pageData.filters.models.includes("old-model"));
const detail=await (await route.GET(request({...query,view:"detail",id:"a:0001"}))).json();assert.equal(detail.sessions.length,1);assert.equal(detail.sessions[0].calls.length,1);
for(const id of ["b:excluded","c:excluded"]) {
const blocked=await (await route.GET(request({...query,view:"detail",id}))).json();assert.equal(blocked.sessions.length,0);
}
assert.equal((await route.GET(request({view:"invalid"}))).status,400);
// All-history reads must finish even when live quota/ingestion metadata
// changes between batches, without caching them under a stale revision.
onSessionRead = () => database.prepare("UPDATE mesh_nodes SET last_payload_hash=? WHERE id='a'").run(String(sessionQueries));
const allResponse = await route.GET(request({view:'overview',start:'1970-01-01T00:00:00.000Z'}));
assert.equal(allResponse.status,200);
assert.equal(allResponse.headers.get('etag'),null);
assert.equal(allResponse.headers.get('cache-control'),'private, no-store');
const allData = await allResponse.json();
assert.equal(allData.pageData.totals.count,1503);
assert.equal(allData.pageData.matched,1002);
onSessionRead = () => {};
// Date bounds must track edits and new snapshots, including malformed dates.
database.prepare("UPDATE mesh_sessions SET snapshot_json=? WHERE node_id='a' AND session_id='0001'").run(JSON.stringify({calls:[{...value,timestamp:'2020-01-01'}, {...value,timestamp:'invalid'}]}));
const indexed=database.prepare("SELECT first_call_day,last_call_day,invalid_call_dates FROM mesh_sessions WHERE node_id='a' AND session_id='0001'").get();
Expand Down
14 changes: 10 additions & 4 deletions src/storage/relational-reader.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ export async function readSessionSlices(
: `json_patch(s.snapshot_json,json_object('models',json(${models}),'calls',json(${calls}),'turns',json(${turns})))`;
let cursorNode = "",
cursorSession = "";
// Summary pages transfer compact aggregates, so use fewer D1 round trips.
// Raw detail/export reads keep the smaller memory-bounded batches.
const batchSize = options.aggregate ? 1024 : 128;
for (;;) {
const values = model ? (callsOnly ? [model] : [model, model]) : [];
values.push(owner, cursorNode, cursorSession);
Expand Down Expand Up @@ -153,16 +156,19 @@ export async function readSessionSlices(
}
const result = await database
.prepare(
`SELECT s.node_id,s.session_id,${projection} AS snapshot_json
`WITH page AS MATERIALIZED (
SELECT s.node_id,s.session_id,s.snapshot_json
FROM mesh_sessions s JOIN mesh_nodes n ON n.id=s.node_id
WHERE n.owner_id=? AND n.revoked_at IS NULL AND (s.node_id,s.session_id)>(?,?)${where}
ORDER BY s.node_id,s.session_id LIMIT 128`,
ORDER BY s.node_id,s.session_id LIMIT ${batchSize})
SELECT s.node_id,s.session_id,${projection} AS snapshot_json FROM page s
ORDER BY s.node_id,s.session_id`,
)
.bind(...values)
.bind(...(model ? [...values.slice(callsOnly ? 1 : 2), ...values.slice(0, callsOnly ? 1 : 2)] : values))
.all();
const rows = result.results || [];
for (const row of rows) await consume(row);
if (rows.length < 128) break;
if (rows.length < batchSize) break;
cursorNode = rows.at(-1).node_id;
cursorSession = rows.at(-1).session_id;
}
Expand Down
39 changes: 35 additions & 4 deletions test/dashboard-refresh.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,29 +18,30 @@ function dashboard({ mode = "centralized", view = "overview", data = { generated
}
const requests = [];
const renders = [];
const loadingStates = [];
const timers = [];
const listeners = new Map();
const state = { data, dataMode: mode, view };
const document = { hidden: false, addEventListener: (event, callback) => listeners.set(event, callback) };
const context = vm.createContext({
state, document, Date: ClockDate, URL, hydratePage: (data) => data, clearTimeout() {}, pageQuery: () => ({view:state.view}), pageRequestKey: () => `${state.dataMode}:${state.view}`, URLSearchParams, AbortController, weeklyQuotaPeriods, sameQuotaReset,
state, document, Date: ClockDate, URL, hydratePage: (data) => data, clearTimeout() {}, pageQuery: () => ({view:state.view, period:state.period}), pageRequestKey: () => `${state.dataMode}:${state.view}:${state.period}`, URLSearchParams, AbortController, weeklyQuotaPeriods, sameQuotaReset,
fetch: async (url, options) => {
const normalized = new URL(url, "http://localhost"); normalized.searchParams.delete("query"); requests.push(normalized.pathname + normalized.search);
return fetchImpl ? fetchImpl(url, requests.length, options) : { ok: true, json: async () => ({ generatedAt: String(now) }) };
},
setInterval: (callback, interval) => timers.push({ callback, interval, next: now + interval }),
$: () => ({ classList: { add() {}, remove() {} }, textContent: "" }),
loadUsageCache: () => null, saveUsageCache() {},
populateNodes() {}, populateModels() {}, populateFolders() {}, syncQuotaClock() {}, setPageLoading() {},
populateNodes() {}, populateModels() {}, populateFolders() {}, syncQuotaClock() {}, setPageLoading: (active, error) => loadingStates.push({active,error}),
renderQuotaNav() {}, renderFreshness() {}, escapeHtml: String,
render: () => renders.push(state.data.generatedAt), toast() {}, t: (key) => key,
});
context.fetchDashboardUsage = (url, options) => fetchUsage(url, options, {fetchImpl:context.fetch,delayMs:0});
vm.runInContext(`${constants}\n${loading}\n${polling}`, context);
const flush = () => new Promise((resolve) => setImmediate(resolve));
return {
state, requests, renders,
load: (force = false) => vm.runInContext(`loadData(${force})`, context),
state, requests, renders, loadingStates,
load: (force = false, silent = false) => vm.runInContext(`loadData(${force},${silent})`, context),
poll: () => vm.runInContext("pollForNewData()", context),
async advance(ms) {
const end = now + ms;
Expand Down Expand Up @@ -239,3 +240,33 @@ test("HTTP, network and invalid JSON failures retain the snapshot and release th
}
}
});

test("Today to All failure hides stale values, then a retry displays all history", async () => {
let fail = true;
const ui = dashboard({fetch: async (url) => {
const {period} = JSON.parse(new URL(url, 'http://localhost').searchParams.get('query'));
if (period === 'all' && fail) return Response.json({error:'Unavailable'}, {status:500});
return Response.json({generatedAt:period, pageOnly:true, pageData:{view:'overview', totals:{count:period === 'all' ? 100 : 3}}});
}});
ui.state.period = 'today';
await ui.load();
ui.state.period = 'all';
await ui.load(false, true);
assert.equal(ui.state.data.pageData.totals.count, 3);
assert.equal(ui.loadingStates.at(-1).active, true, 'Old period must remain hidden after failure');
assert.ok(ui.loadingStates.at(-1).error, 'A filter failure must be visible even on a silent load');
fail = false;
await ui.load();
assert.equal(ui.state.data.pageData.totals.count, 100);
assert.equal(ui.loadingStates.at(-1).active, false);
});

test("an uncached response drops the previous ETag after concurrent ingestion", async () => {
const headers = [];
const ui = dashboard({fetch:async (url, count, options) => {
headers.push(options.headers['If-None-Match']);
return Response.json({generatedAt:String(count),pageOnly:true}, {headers: count === 1 ? {ETag:'"old"'} : {'Cache-Control':'private, no-store'}});
}});
await ui.load(); await ui.load(); await ui.load();
assert.deepEqual(headers, [undefined, '"old"', undefined]);
});
41 changes: 41 additions & 0 deletions test/history-batches.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { openSqlite } from '../src/storage/sqlite.mjs';
import { LocalRepository } from '../src/storage/local-repository.mjs';
import { readSessionSlices } from '../src/storage/relational-reader.mjs';

test('large history uses fewer round trips while preserving every session and event', async () => {
const database = openSqlite();
try {
const repository = new LocalRepository(database);
const sessions = Array.from({length:2050}, (_,i)=>({
id:String(i).padStart(5,'0'), models:['gpt-5'],
calls:[{timestamp:'2026-09-01T12:00:00Z',model:'gpt-5',usage:{inputTokens:100,outputTokens:10,totalTokens:110}}],turns:[],
}));
repository.saveUsage({sessions,generatedAt:'2026-09-16'},'test','2026-09-16');
for (const aggregate of [false,true]) {
let queries = 0, largestBatch = 0;
const adapter = {prepare(sql) {
const statement = database.prepare(sql);
return {bind(...values) {
const bound = statement.bind(...values);
return {...bound, async all() {
const result = await bound.all();
if(sql.includes('AS snapshot_json')) { queries++; largestBatch=Math.max(largestBatch,result.results.length); }
return result;
}};
}};
}};
const ids = new Set(); let count=0;
await readSessionSlices(adapter,'local','1970-01-01','9999-12-31',false,null,row=>{
assert.ok(!ids.has(row.session_id),'No duplicate across page boundaries');
ids.add(row.session_id);
count+=JSON.parse(row.snapshot_json).calls.reduce((n,c)=>n+(c._count ?? 1),0);
},false,{aggregate});
assert.equal(ids.size,2050);
assert.equal(count,2050);
assert.equal(queries,aggregate?3:17);
assert.equal(largestBatch,aggregate?1024:128);
}
} finally { database.close(); }
});