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:
1 parent
fdf56ee7eb
commit
12ad2a8e97
18 files changed
+2574
-1262
No files matched your search
Vendored
+53
-32
@@ -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') {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user