feat(usage): redesign request details as a compact event log

- Add grouped token, cache, and latency details
- Tighten column spacing and preserve custom widths
- Move pagination to the footer and add CSV export
- Support dark mode and responsive layouts
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent fdf56ee7eb
commit 12ad2a8e97
18 files changed
+2574 -1262

No files matched your search

+53 -32
View File
@@ -42,6 +42,38 @@ const timeline = Array.from({ length: 4 }, (_, index) => ({
})),
}));
const records = Array.from({ length: 400 }, (_, index) => ({
id: `record-${index + 1}`,
row_id: String(index + 1),
timestamp: new Date(now.getTime() - index * 1000).toISOString(),
latency_ms: 2000,
ttft_ms: 200,
source: index % 2 ? 'secondary-source' : 'test-source',
source_display: index % 2 ? 'Secondary source' : 'Test source',
failed: index % 4 === 0,
canceled: false,
failure_status: index % 4 === 0 ? 429 : 0,
failure_body: index % 4 === 0 ? 'Rate limit exceeded' : '',
provider: 'test-provider',
model: index % 3 ? 'test-model' : 'secondary-model',
alias: '',
reasoning_effort: 'high',
endpoint: '/v1/responses',
api_key_hash: 'test-key',
api_key_display: 'sk-test',
api_key_remark: 'Test key, "local"',
tokens: {
input_tokens: 1000,
output_tokens: 200,
reasoning_tokens: 100,
cache_read_tokens: 400,
cache_creation_tokens: 50,
total_tokens: 1200,
},
}));
const category = (key: string, label = key) => ({ key, label, requests: 200, failures: 25, tokens: 24_000 });
mockIPC(async (cmd, args) => {
if (cmd === 'plugin:event|listen') return 1;
if (cmd === 'plugin:event|unlisten' || cmd === 'set_app_locale') return null;
@@ -49,43 +81,32 @@ mockIPC(async (cmd, args) => {
return { state: 'collecting', message: '', lastCollectedAt: now.toISOString(), totalRecords: 54 };
}
if (cmd === 'get_usage_analysis') {
return { models: [], providers: [], sources: [], apiKeys: [] };
return {
models: [category('test-model'), category('secondary-model')],
providers: [category('test-provider')],
sources: [category('test-source', 'Test source'), category('secondary-source', 'Secondary source')],
apiKeys: [category('test-key', 'Test key')],
};
}
if (cmd === 'get_usage_events') {
const query = args?.query as { page: number; page_size: number };
const query = args?.query as {
page: number; page_size: number; model?: string; source?: string;
provider?: string; api_key_hash?: string; failed?: boolean; canceled?: boolean;
};
const filtered = records.filter(record =>
(!query.model || record.model === query.model)
&& (!query.source || record.source === query.source)
&& (!query.provider || record.provider === query.provider)
&& (!query.api_key_hash || record.api_key_hash === query.api_key_hash)
&& (query.failed === undefined || record.failed === query.failed)
&& (query.canceled === undefined || record.canceled === query.canceled));
const start = (query.page - 1) * query.page_size;
return {
page: query.page,
pageSize: query.page_size,
total: 400,
totalPages: Math.ceil(400 / query.page_size),
items: Array.from({ length: query.page_size }, (_, index) => ({
id: `record-${query.page}-${index}`,
timestamp: now.toISOString(),
latency_ms: 2000,
ttft_ms: 200,
source: 'test-source',
source_display: 'Test source',
failed: index % 4 === 0,
canceled: false,
failure_status: index % 4 === 0 ? 429 : 0,
failure_body: index % 4 === 0 ? 'Rate limit exceeded' : '',
provider: 'test-provider',
model: 'test-model',
alias: '',
reasoning_effort: 'high',
endpoint: '/v1/responses',
api_key_hash: 'test-key',
api_key_display: 'sk-test',
api_key_remark: 'Test key',
tokens: {
input_tokens: 1000,
output_tokens: 200,
reasoning_tokens: 100,
cache_read_tokens: 400,
cache_creation_tokens: 0,
total_tokens: 1600,
},
})),
total: filtered.length,
totalPages: Math.max(1, Math.ceil(filtered.length / query.page_size)),
items: filtered.slice(start, start + query.page_size),
};
}
if (cmd === 'get_usage_overview') {
+13 -3
View File
@@ -72,26 +72,36 @@ const viewports = [
const assertRequestToolbar = async label => {
const metrics = await page.locator('.usage-events-panel').evaluate(panel => {
const summary = panel.querySelector('.usage-events-summary');
const table = panel.querySelector('.usage-table-wrap');
const scrollbar = panel.querySelector('.usage-table-top-scrollbar');
const footer = panel.querySelector('.usage-events-footer');
const pagination = panel.querySelector('.usage-pagination-controls');
const summaryRect = summary.getBoundingClientRect();
const tableRect = table.getBoundingClientRect();
const scrollbarRect = scrollbar.getBoundingClientRect();
const footerRect = footer.getBoundingClientRect();
const paginationRect = pagination.getBoundingClientRect();
const controls = [...pagination.querySelectorAll('button, select, .usage-pagination-info')];
return {
summaryBottom: summaryRect.bottom,
tableTop: tableRect.top,
tableBottom: tableRect.bottom,
scrollbarTop: scrollbarRect.top,
scrollbarBottom: scrollbarRect.bottom,
footerTop: footerRect.top,
controlsContained: controls.every(control => {
const rect = control.getBoundingClientRect();
return rect.width > 0 && rect.height > 0
&& rect.left >= paginationRect.left - 1 && rect.right <= paginationRect.right + 1
&& rect.top >= paginationRect.top - 1 && rect.bottom <= paginationRect.bottom + 1
&& rect.bottom <= summaryRect.bottom + 1;
&& rect.bottom <= footerRect.bottom + 1;
}),
};
});
assert.ok(metrics.summaryBottom <= metrics.scrollbarTop + 1, `${label}: request summary must not overlap the horizontal scrollbar`);
assert.equal(metrics.controlsContained, true, `${label}: pagination controls must fit completely inside their toolbar`);
assert.ok(metrics.summaryBottom <= metrics.tableTop + 1, `${label}: request heading and filters must remain above the table`);
assert.ok(metrics.tableBottom <= metrics.scrollbarTop + 1, `${label}: the horizontal scrollbar must remain below the table`);
assert.ok(metrics.scrollbarBottom <= metrics.footerTop + 1, `${label}: the scrollbar must not overlap the pagination footer`);
assert.equal(metrics.controlsContained, true, `${label}: pagination controls must fit completely inside their footer`);
for (const control of await page.locator('.usage-pagination-controls button, .usage-page-size-select').all()) {
await assertReachable(control, `${label} pagination`);
}
+19 -15
View File
@@ -36,16 +36,12 @@ function componentFiles(directory: string): string[] {
describe('UI localization boundaries', () => {
it('preserves the raw reasoning effort in usage text and tooltips', () => {
const file = join(sourceRoot, 'pages', 'UsageRecordsPage.tsx');
const file = join(sourceRoot, 'pages', 'UsageEventsView.tsx');
const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
let effortCell: ts.JsxElement | undefined;
const findEffortCell = (node: ts.Node) => {
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(source) === 'small') effortCell = node;
ts.forEachChild(node, findEffortCell);
};
let effortCase: ts.CaseClause | undefined;
const visit = (node: ts.Node) => {
if (ts.isCaseClause(node) && ts.isStringLiteral(node.expression) && node.expression.text === 'model') {
findEffortCell(node);
if (ts.isCaseClause(node) && ts.isStringLiteral(node.expression) && node.expression.text === 'effort') {
effortCase = node;
} else {
ts.forEachChild(node, visit);
}
@@ -53,13 +49,21 @@ describe('UI localization boundaries', () => {
const component = source.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'UsageEventCell');
expect(component).toBeDefined();
if (component) visit(component);
const text = effortCell?.children.find(ts.isJsxExpression);
const title = effortCell?.openingElement.attributes.properties.find((attribute) =>
ts.isJsxAttribute(attribute) && attribute.name.getText(source) === 'title',
);
expect(text?.expression?.getText(source)).toBe("record.reasoning_effort || 'auto'");
expect(title && ts.isJsxAttribute(title) && title.initializer?.getText(source))
.toBe("{record.reasoning_effort || 'auto'}");
const displayedExpressions: string[] = [];
const titleExpressions: string[] = [];
const inspectEffort = (node: ts.Node) => {
if (ts.isJsxExpression(node) && !ts.isJsxAttribute(node.parent) && node.expression) {
displayedExpressions.push(node.expression.getText(source));
}
if (ts.isJsxAttribute(node) && node.name.getText(source) === 'title' && node.initializer) {
titleExpressions.push(node.initializer.getText(source));
}
ts.forEachChild(node, inspectEffort);
};
expect(effortCase).toBeDefined();
if (effortCase) inspectEffort(effortCase);
expect(displayedExpressions).toContain("record.reasoning_effort || 'auto'");
expect(titleExpressions).toContain("{record.reasoning_effort || 'auto'}");
});
it('routes visible prose and accessibility labels through translations', () => {
+99 -11
View File
@@ -14,11 +14,22 @@ const base = 'http://127.0.0.1:1421';
const statInfo1213 = await page.evaluate(() => {
const cards = Array.from(document.querySelectorAll('.usage-stat-card'));
const metas = Array.from(document.querySelectorAll('.usage-stat-card-meta'));
const tokenPanel = document.querySelector('.usage-health-panel');
const trendPanel = document.querySelector('.usage-trend-panel');
if (!(tokenPanel instanceof HTMLElement) || !(trendPanel instanceof HTMLElement)) throw new Error('Overview panels did not render');
const tokenRect = tokenPanel.getBoundingClientRect();
const trendRect = trendPanel.getBoundingClientRect();
const tpsCard = cards.find((card) => card.querySelector('.usage-stat-card-label')?.textContent?.trim() === 'TPS');
const tpsValue = tpsCard?.querySelector('.usage-stat-card-value')?.textContent?.trim() ?? '';
return {
cardCount: cards.length,
metaCount: metas.length,
detailPanelCount: document.querySelectorAll('.usage-stat-details-panel').length,
tokensBesideTrend: tokenRect.left > trendRect.right && Math.abs(tokenRect.top - trendRect.top) < 1,
tokenCount: tokenPanel.querySelectorAll('.usage-token-row').length,
cacheShare: tokenPanel.querySelector('.usage-token-donut-label strong')?.textContent,
contextText: tokenPanel.querySelector('.usage-token-context')?.textContent,
tokenContentInsidePanel: tokenPanel.querySelector('.usage-token-context').getBoundingClientRect().bottom <= tokenRect.bottom,
cardRows: new Set(cards.map((card) => Math.round(card.getBoundingClientRect().top))).size,
tpsValue,
};
@@ -26,12 +37,19 @@ const base = 'http://127.0.0.1:1421';
assert.equal(statInfo1213.cardCount, 6, 'There are 6 stat cards');
assert.equal(statInfo1213.cardRows, 1, 'At 1213px width, all 6 cards fit into a single row');
assert.equal(statInfo1213.metaCount, 6, 'Each stat card shows its supporting detail');
assert.equal(statInfo1213.metaCount, 0, 'The top stat cards show only their labels and primary values');
assert.equal(statInfo1213.detailPanelCount, 0, 'The statistics details panel is removed');
assert.equal(statInfo1213.tokensBesideTrend, true, 'Token composition sits beside the trend without an empty slot');
assert.equal(statInfo1213.tokenCount, 4, 'Token composition separates uncached input, cache reads, cache writes and output');
assert.equal(statInfo1213.cacheShare, '21.1%', 'The ring shows cache reads as a share of input plus output without double counting');
assert.equal(statInfo1213.tokenContentInsidePanel, true, 'Supporting details remain inside the Token card');
for (const value of ['50', '4', '0', '54', 'RPM', '0.23', '2.84 s']) {
assert.ok(statInfo1213.contextText.includes(value), `The Token card retains supporting request, performance and pricing details: ${value}`);
}
assert.ok(!statInfo1213.tpsValue.includes('TPS'), `TPS value should not contain TPS unit: ${statInfo1213.tpsValue}`);
assert.ok(/^\d+(\.\d+)?$/.test(statInfo1213.tpsValue), `TPS value should be numeric: ${statInfo1213.tpsValue}`);
// Now test constrained width where cards wrap and ensure vertical scrolling is preserved
await page.setViewportSize({ width: 750, height: 600 });
await page.setViewportSize({ width: 950, height: 600 });
await page.waitForTimeout(100);
const geometry = await page.evaluate(() => {
@@ -39,11 +57,14 @@ const base = 'http://127.0.0.1:1421';
const panel = document.querySelector('.usage-trend-panel');
const plot = document.querySelector('.usage-trend-plot');
const xAxis = document.querySelector('.usage-trend-x-axis');
const tokenPanel = document.querySelector('.usage-health-panel');
const cards = Array.from(document.querySelectorAll('.usage-stat-card'));
if (!(layout instanceof HTMLElement) || !(panel instanceof HTMLElement)
|| !(plot instanceof HTMLElement) || !(xAxis instanceof HTMLElement)) throw new Error('Fixture did not render');
|| !(plot instanceof HTMLElement) || !(xAxis instanceof HTMLElement)
|| !(tokenPanel instanceof HTMLElement)) throw new Error('Fixture did not render');
const panelRect = panel.getBoundingClientRect();
const xAxisRect = xAxis.getBoundingClientRect();
const tokenRect = tokenPanel.getBoundingClientRect();
return {
layoutClientHeight: layout.clientHeight,
layoutScrollHeight: layout.scrollHeight,
@@ -51,28 +72,41 @@ const base = 'http://127.0.0.1:1421';
layoutScrollWidth: layout.scrollWidth,
plotHeight: plot.getBoundingClientRect().height,
xAxisInsidePanel: xAxisRect.bottom <= panelRect.bottom,
tokensBesideTrend: tokenRect.left > panelRect.right,
panelsAligned: Math.abs(tokenRect.top - panelRect.top) < 1 && Math.abs(tokenRect.bottom - panelRect.bottom) < 1,
scale: Number(getComputedStyle(document.querySelector('.usage-overview-panels')).getPropertyValue('--usage-overview-scale')),
tokenContentInsidePanel: tokenPanel.querySelector('.usage-token-context').getBoundingClientRect().bottom <= tokenRect.bottom,
cardRows: new Set(cards.map((card) => Math.round(card.getBoundingClientRect().top))).size,
};
});
assert.ok(geometry.cardRows >= 2, 'The constrained layout reproduces wrapped summary cards');
assert.ok(geometry.layoutScrollHeight > geometry.layoutClientHeight, 'The overview becomes vertically scrollable');
assert.equal(geometry.cardRows, 1, 'Summary cards stay compact to leave room for the charts');
assert.equal(geometry.layoutScrollHeight, geometry.layoutClientHeight, 'The overview fits vertically without adding a scrollbar');
assert.equal(geometry.layoutScrollWidth, geometry.layoutClientWidth, 'The overview does not introduce horizontal scrolling');
assert.ok(geometry.plotHeight >= 140, 'The trend plot retains its minimum drawing height');
assert.ok(geometry.scale > 0 && geometry.scale < 1, 'The panels scale down for a constrained window');
assert.ok(geometry.plotHeight >= 140 * geometry.scale - 1, 'The trend plot scales with the rest of the panels');
assert.equal(geometry.xAxisInsidePanel, true, 'The X axis remains inside the trend panel instead of being clipped');
assert.equal(geometry.tokensBesideTrend, true, 'The scaled panels remain beside each other');
assert.equal(geometry.panelsAligned, true, 'The scaled panels keep equal heights');
assert.equal(geometry.tokenContentInsidePanel, true, 'The scaled Token card contains all supporting details');
await page.locator('.usage-overview-layout').evaluate((layout) => { layout.scrollTop = layout.scrollHeight; });
assert.ok(await page.locator('.usage-trend-x-axis').evaluate((axis) => {
const layout = axis.closest('.usage-overview-layout');
if (!layout) return false;
return axis.getBoundingClientRect().bottom <= layout.getBoundingClientRect().bottom + 1;
}), 'The complete X axis is reachable by scrolling the overview');
}), 'The complete X axis is visible without scrolling the overview');
await page.locator('.usage-trend-plot').hover();
await page.locator('.usage-trend-tooltip').waitFor();
await page.locator('.usage-trend-plot').press('Home');
assert.ok(await page.locator('.usage-trend-tooltip-total').textContent(), 'The scaled chart still supports pointer and keyboard inspection');
await page.locator('.usage-trend-plot').press('Escape');
const narrowAxis = await page.evaluate(() => ({
width: document.querySelector('.usage-trend-plot')?.getBoundingClientRect().width ?? 0,
ticks: document.querySelectorAll('.usage-trend-x-axis span').length,
}));
await page.setViewportSize({ width: 1500, height: 600 });
await page.setViewportSize({ width: 2400, height: 600 });
await page.waitForFunction((previous) => {
const plot = document.querySelector('.usage-trend-plot');
const ticks = document.querySelectorAll('.usage-trend-x-axis span').length;
@@ -85,7 +119,61 @@ const base = 'http://127.0.0.1:1421';
assert.ok(wideAxis.width > narrowAxis.width, 'The trend plot follows the wider window');
assert.ok(wideAxis.ticks > narrowAxis.ticks, 'The X axis adds readable ticks when more width is available');
console.log('PASS: usage overview layout and responsive trend axis passed.');
for (const [width, height] of [[1213, 1000], [1600, 1000], [1213, 600], [1280, 540], [950, 600]]) {
await page.setViewportSize({ width, height });
await page.waitForTimeout(100);
const panels = await page.evaluate(() => {
const layout = document.querySelector('.usage-overview-layout');
const trend = document.querySelector('.usage-trend-panel').getBoundingClientRect();
const tokens = document.querySelector('.usage-health-panel').getBoundingClientRect();
const context = document.querySelector('.usage-token-context').getBoundingClientRect();
const composition = document.querySelector('.usage-token-composition').getBoundingClientRect();
const donut = document.querySelector('.usage-token-donut').getBoundingClientRect();
const breakdown = document.querySelector('.usage-token-breakdown').getBoundingClientRect();
return {
topDifference: Math.abs(trend.top - tokens.top),
bottomDifference: Math.abs(trend.bottom - tokens.bottom),
contentInside: context.bottom <= tokens.bottom,
windowBottomGap: window.innerHeight - tokens.bottom,
windowRightGap: window.innerWidth - tokens.right,
verticalOverflow: layout.scrollHeight - layout.clientHeight,
horizontalOverflow: layout.scrollWidth - layout.clientWidth,
chartInside: donut.top >= composition.top && donut.bottom <= composition.bottom
&& breakdown.top >= composition.top && breakdown.bottom <= composition.bottom
&& Array.from(document.querySelectorAll('.usage-token-row')).every((row) => {
const bounds = row.getBoundingClientRect();
return bounds.top >= composition.top && bounds.bottom <= composition.bottom;
}),
};
});
assert.ok(panels.topDifference < 1 && panels.bottomDifference < 1, `The two Token panels have equal heights at ${width}×${height}`);
assert.equal(panels.contentInside, true, 'Equal-height panels preserve all supporting details');
assert.equal(panels.chartInside, true, 'The ring and legend shrink to fit their chart area');
assert.equal(panels.verticalOverflow, 0, 'Resizing does not introduce vertical scrolling');
assert.equal(panels.horizontalOverflow, 0, 'Resizing does not introduce horizontal scrolling');
assert.ok(panels.windowBottomGap >= 0 && panels.windowBottomGap <= 20, 'The panels use the available height with a compact bottom margin');
assert.ok(panels.windowRightGap >= 0 && panels.windowRightGap <= 20, 'The overview keeps a compact margin at the window edge');
}
const filterGroup = page.locator('.usage-filter-group');
const filterBounds = await filterGroup.boundingBox();
assert.equal(await page.locator('.usage-filter-reset-btn').count(), 0, 'The overview does not show a reset filters button');
const modelFilter = page.locator('.usage-filter-item select').nth(1);
await modelFilter.selectOption('test-model');
await page.locator('.usage-trend-x-axis').waitFor();
assert.equal(await modelFilter.inputValue(), 'test-model', 'Model filtering remains available');
assert.equal(await page.locator('.usage-filter-reset-btn').count(), 0, 'Selecting a filter does not reveal a reset button');
assert.deepEqual(await filterGroup.boundingBox(), filterBounds, 'Selecting a filter does not shift or resize the filter row');
await modelFilter.selectOption('');
const rangeFilter = page.locator('.usage-filter-item select').first();
await rangeFilter.selectOption('custom');
await page.locator('.usage-custom-range').waitFor();
assert.equal(await page.locator('.usage-filter-reset-btn').count(), 0, 'Custom date filters do not reveal a reset button');
await rangeFilter.selectOption('4h');
await page.locator('.usage-trend-x-axis').waitFor();
assert.equal(await modelFilter.inputValue(), '', 'Filters can still be cleared through their own dropdowns');
console.log('PASS: usage overview layout, responsive trend axis, and stable filters without a reset button passed.');
} finally {
await browser.close();
}
+97 -1
View File
@@ -3,6 +3,26 @@ const assert = require('node:assert/strict');
const base = 'http://127.0.0.1:1421';
function parseCsv(text) {
const rows = [];
let row = [];
let field = '';
let quoted = false;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (character === '"') {
if (quoted && text[index + 1] === '"') { field += '"'; index += 1; }
else quoted = !quoted;
} else if (!quoted && (character === ',' || character === '\n')) {
row.push(field);
field = '';
if (character === '\n') { rows.push(row); row = []; }
} else if (quoted || character !== '\r') field += character;
}
if (field || row.length) { row.push(field); rows.push(row); }
return rows;
}
(async () => {
const browser = await chromium.launch({
channel: 'msedge', headless: true, args: ['--no-proxy-server'],
@@ -10,12 +30,28 @@ const base = 'http://127.0.0.1:1421';
});
try {
const page = await browser.newPage({ viewport: { width: 1100, height: 700 } });
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', (route) => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
await page.goto(`${base}/tests/fixtures/usage-layout.html?tab=events&locale=en`, { waitUntil: 'domcontentloaded' });
await page.locator('.usage-table-top-scrollbar:not(.is-hidden)').waitFor();
await page.locator('.usage-page-size-select').selectOption('200');
await page.waitForFunction(() => document.querySelectorAll('.usage-events-table tbody tr').length === 200);
assert.equal(await page.getByRole('heading', { name: 'Request Event Log' }).count(), 1);
const firstRow = page.locator('.usage-events-table tbody tr').first();
assert.equal(await firstRow.locator('.usage-td-total').getAttribute('title'), '1,200 tokens', 'The displayed total uses the recorded total instead of adding cache and reasoning again');
assert.equal(await firstRow.locator('.tone-input').getAttribute('aria-label'), 'Input: 1000');
assert.equal(await firstRow.locator('.tone-output').getAttribute('aria-label'), 'Output: 200');
assert.equal(await firstRow.locator('.tone-reasoning').getAttribute('aria-label'), 'Reasoning: 100');
assert.equal(parseFloat(await firstRow.locator('.usage-td-cache > strong').textContent()), 40);
assert.equal(await firstRow.locator('.tone-cache-read').textContent(), '400');
assert.equal(await firstRow.locator('.tone-cache-write').textContent(), '50');
assert.equal(await firstRow.locator('.usage-td-time small').count(), 1, 'The request date remains visible below its time');
assert.ok((await firstRow.locator('.usage-td-latency small').textContent()).includes('200'), 'The latency cell includes first-token latency');
assert.equal(await page.locator('.usage-events-summary .usage-page-size-select').count(), 0);
assert.equal(await page.locator('.usage-events-footer .usage-page-size-select').count(), 1, 'Pagination remains in the bottom footer');
// Multiple input updates can arrive before the next animation frame. None
// may be dropped, and queued programmatic scroll events must not echo back.
const rapidScroll = await page.evaluate(async () => {
@@ -125,7 +161,30 @@ const base = 'http://127.0.0.1:1421';
for (let index = 2; index < await checkboxes.count(); index += 1) await checkboxes.nth(index).uncheck();
await page.locator('.usage-column-dialog-actions .primary-button').click();
await page.waitForFunction(() => document.querySelector('.usage-table-top-scrollbar').classList.contains('is-hidden'));
assert.equal(await page.locator('.usage-events-table th').count(), 2, 'Column visibility applies to the header and every request row');
assert.equal(await firstRow.locator('td').count(), 2);
assert.equal(await page.locator('.usage-table-wrap').evaluate((element) => element.scrollLeft), 0);
// Export carries complete request data even when most display columns are hidden.
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export Page CSV' }).click();
const download = await downloadPromise;
assert.match(download.suggestedFilename(), /^usage-events-page-1-\d{4}-\d{2}-\d{2}\.csv$/);
const stream = await download.createReadStream();
const chunks = [];
for await (const chunk of stream) chunks.push(chunk);
const exported = parseCsv(Buffer.concat(chunks).toString('utf8').replace(/^\uFEFF/, ''));
const [headers, ...records] = exported;
assert.equal(records.length, 200, 'CSV contains exactly the current page');
const values = Object.fromEntries(headers.map((header, index) => [header, records[0][index]]));
assert.deepEqual(Object.fromEntries(['input_tokens', 'output_tokens', 'reasoning_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'total_tokens'].map(key => [key, values[key]])), {
input_tokens: '1000', output_tokens: '200', reasoning_tokens: '100', cache_read_tokens: '400', cache_creation_tokens: '50', total_tokens: '1200',
});
assert.equal(values.api_key_remark, 'Test key, "local"', 'CSV correctly escapes commas and quotes');
assert.equal(values.endpoint, '/v1/responses');
assert.equal(values.row_id, '1');
assert.equal(records.at(-1)[headers.indexOf('row_id')], '200');
await page.locator('.usage-col-settings-btn').click();
await page.locator('.usage-column-select-all').click();
await page.locator('.usage-column-dialog-actions .primary-button').click();
@@ -133,7 +192,44 @@ const base = 'http://127.0.0.1:1421';
await bar.evaluate((element) => { element.scrollLeft = 250; });
await page.waitForFunction(() => document.querySelector('.usage-table-wrap').scrollLeft === 250);
console.log('PASS: rapid two-way scrolling, delayed echoes, refresh, window/column resizing, column visibility, and native dragging stay synchronized with 200 rows.');
const timeResize = page.locator('.usage-th-time .usage-col-resizer');
await timeResize.focus();
const beforeWidth = Number(await timeResize.getAttribute('aria-valuenow'));
await page.keyboard.press('ArrowRight');
assert.equal(Number(await timeResize.getAttribute('aria-valuenow')), beforeWidth + 10, 'Keyboard resizing remains available');
await page.keyboard.press('Home');
assert.equal(Number(await timeResize.getAttribute('aria-valuenow')), 84);
await page.locator('.usage-page-size-select').selectOption('20');
await page.waitForFunction(() => document.querySelector('.usage-pagination-info').textContent.trim() === '1 / 20');
const originalTime = await firstRow.locator('.usage-td-time strong').textContent();
await page.getByRole('button', { name: 'Next', exact: true }).click();
await page.waitForFunction(() => document.querySelector('.usage-pagination-info').textContent.trim() === '2 / 20');
assert.notEqual(await firstRow.locator('.usage-td-time strong').textContent(), originalTime, 'Bottom pagination loads a different request page');
assert.equal(await page.locator('.usage-pagination-summary').textContent(), 'Showing 21 - 40 of 400');
assert.equal(await page.getByRole('button', { name: 'Reset Filters', exact: true }).count(), 1, 'The request log keeps a clear filters control visible');
assert.equal(await page.getByRole('button', { name: 'Reset Filters', exact: true }).isDisabled(), true, 'The clear filters control is disabled without active filters');
await page.getByRole('combobox', { name: 'Model', exact: true }).selectOption('test-model');
await page.waitForFunction(() => document.querySelector('.usage-pagination-summary').textContent === 'Showing 1 - 20 of 266');
assert.equal(await page.locator('.usage-events-table tbody strong[title="test-model"]').count(), 20, 'Changing a filter resets pagination and shows matching models');
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('test-source');
await page.waitForFunction(() => document.querySelector('.usage-pagination-summary').textContent === 'Showing 1 - 20 of 133');
assert.ok((await page.locator('.usage-td-source').allTextContents()).every(text => text === 'Test source'));
await page.getByRole('combobox', { name: 'Request result', exact: true }).selectOption('failed');
await page.waitForFunction(() => document.querySelector('.usage-pagination-summary').textContent === 'Showing 1 - 20 of 66');
assert.equal(await page.locator('.usage-events-table tbody .usage-result.failed').count(), 20);
assert.equal(await page.getByRole('button', { name: 'Reset Filters', exact: true }).isDisabled(), false, 'Active filters enable the clear filters control');
await page.getByRole('combobox', { name: 'Model', exact: true }).selectOption('');
await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('');
await page.getByRole('combobox', { name: 'Request result', exact: true }).selectOption('all');
await page.waitForFunction(() => document.querySelector('.usage-pagination-summary').textContent === 'Showing 1 - 20 of 400');
assert.equal(await page.getByRole('combobox', { name: 'Model', exact: true }).inputValue(), '');
assert.equal(await page.getByRole('combobox', { name: 'Source', exact: true }).inputValue(), '');
assert.equal(await page.getByRole('combobox', { name: 'Request result', exact: true }).inputValue(), 'all');
assert.deepEqual(errors, [], 'Request log interactions do not produce runtime errors');
console.log('PASS: request values, CSV export, filters, bottom pagination, column visibility/resizing, and synchronized horizontal scrolling with 200 rows.');
} finally {
await browser.close();
}
+39
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
import {
calculateCacheReadRate,
calculateGenerationSpeed,
calculateTokenComposition,
formatCacheReadRate,
formatGenerationSpeed,
} from '../src/services/usageMetrics';
@@ -45,3 +46,41 @@ describe('cache read rate', () => {
expect(formatCacheReadRate(input)).toBe('—');
});
});
describe('token composition', () => {
test('splits cached input without double counting cache or reasoning', () => {
const tokens = {
inputTokens: 1_000,
outputTokens: 200,
cacheReadTokens: 600,
cacheCreationTokens: 100,
reasoningTokens: 80,
};
const composition = calculateTokenComposition(tokens);
expect(composition.total).toBe(1_200);
expect(composition.segments.map((segment) => segment.value)).toEqual([300, 600, 100, 200]);
expect(composition.cacheShare).toBe(50);
expect(composition.segments.reduce((total, segment) => total + segment.percent, 0)).toBeCloseTo(100);
expect(composition.segments[1].offset).toBe(25);
});
test('keeps an empty chart finite and free of colored segments', () => {
const composition = calculateTokenComposition({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 });
expect(composition.total).toBe(0);
expect(composition.cacheShare).toBe(0);
expect(composition.segments.every((segment) => segment.value === 0 && segment.percent === 0 && segment.offset === 0)).toBe(true);
});
test('avoids negative uncached input when legacy cache counts exceed input', () => {
const composition = calculateTokenComposition({ inputTokens: 100, outputTokens: 0, cacheReadTokens: 600, cacheCreationTokens: 0 });
expect(composition.total).toBe(600);
expect(composition.segments[0].value).toBe(0);
expect(composition.cacheShare).toBe(100);
});
test('normalizes invalid counts before calculating chart proportions', () => {
const composition = calculateTokenComposition({ inputTokens: Number.NaN, outputTokens: 100, cacheReadTokens: -10, cacheCreationTokens: Number.POSITIVE_INFINITY });
expect(composition.total).toBe(100);
expect(composition.segments.map((segment) => segment.percent)).toEqual([0, 0, 0, 100]);
});
});