diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index 144dd2393..aee81a1de 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -1324,7 +1324,7 @@ body { .filter-input { flex: 1; - max-width: 420px; + min-width: min(400px, 100%); padding: 8px 12px; background: var(--bg-surface); border: 1px solid var(--border); @@ -1334,12 +1334,33 @@ body { outline: none; } -.filter-input:focus { - border-color: var(--accent); +.filter-input-wrap { + position: relative; + display: flex; + flex: 1; + min-width: min(400px, 100%); + width: 100%; +} + +.filter-input-wrap .filter-input { + min-width: 0; + width: 100%; + padding-left: 34px; +} + +.filter-input-icon { + position: absolute; + left: 12px; + top: 50%; + width: 14px; + height: 14px; + color: var(--text-muted); + pointer-events: none; + transform: translateY(-50%); } -.models-filter-input { - max-width: 840px; +.filter-input:focus { + border-color: var(--accent); } .table-wrapper { @@ -2216,7 +2237,7 @@ td.col-price { margin-bottom: 16px; } -.usage-log-toolbar .filter-input { +.usage-log-toolbar .filter-input-wrap { flex: 1; max-width: none; } @@ -2284,7 +2305,7 @@ td.col-price { min-width: 0; } -.audit-filter-row-search .filter-input { +.audit-filter-row-search .filter-input-wrap { grid-column: 1 / -1; max-width: none; } @@ -2472,13 +2493,14 @@ td.col-price { .audit-request-response { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; + overflow-x: auto; } .audit-entry-metadata { display: flex; - align-items: flex-start; + align-items: center; gap: 10px; margin-top: 12px; padding-top: 12px; @@ -2505,6 +2527,7 @@ td.col-price { border: 1px solid var(--border); border-radius: 8px; background: var(--bg); + min-width: 0; padding: 12px; } @@ -2527,30 +2550,76 @@ td.col-price { text-transform: uppercase; letter-spacing: 0.4px; color: var(--text-muted); - margin-bottom: 6px; +} + +.audit-pane-block { + min-width: 0; } .audit-pane-block + .audit-pane-block { margin-top: 10px; } +.audit-pane-block > h5 { + margin-bottom: 6px; +} + +.audit-pane-block-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.audit-copy-btn { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + padding: 4px 8px; + background-color: var(--bg-surface); + border: 1px solid color-mix(in srgb, var(--border) 70%, var(--text) 30%); + border-radius: 6px; + color: var(--text); + cursor: pointer; + font-family: inherit; + font-size: 12px; + transition: + background-color 0.15s, + border-color 0.15s, + color 0.15s; +} + +.audit-copy-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--bg-surface) 80%, var(--text) 20%); + border-color: color-mix(in srgb, var(--border) 45%, var(--text) 55%); +} + +.audit-copy-btn.copy-feedback-btn-copied { + background: color-mix(in srgb, var(--success) 18%, var(--bg-surface)); +} + .audit-json { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 6px; + box-sizing: border-box; font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12px; line-height: 1.45; + max-width: 100%; padding: 10px; max-height: 220px; - overflow: auto; + overflow-x: auto; + overflow-y: auto; white-space: pre; color: var(--text); } .audit-json-body { - white-space: pre-wrap; - overflow-wrap: anywhere; + white-space: pre; + overflow-wrap: normal; } .conversation-body-highlight { @@ -3666,6 +3735,7 @@ body.conversation-drawer-open { .audit-filter-row { grid-template-columns: 1fr; } + .audit-filter-row .filter-input-wrap, .audit-filter-row .filter-input, .audit-filter-select, .audit-filter-row .pagination-btn { @@ -3681,10 +3751,11 @@ body.conversation-drawer-open { } .audit-entry-metadata { flex-direction: column; + align-items: flex-start; gap: 8px; } .audit-request-response { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); } .conversation-drawer { width: 100%; @@ -3914,6 +3985,7 @@ body.conversation-drawer-open { align-items: center; width: 100%; min-width: 0; + overflow-x: auto; } /* ─── Connectors ─── */ diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index 40ce52ac0..c176975cc 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -604,6 +604,11 @@ function dashboard() { ); }, + runtimeRefreshSteps() { + const steps = this.runtimeRefreshReport && this.runtimeRefreshReport.steps; + return Array.isArray(steps) ? steps : []; + }, + runtimeRefreshStepLabel(step) { const name = String((step && step.name) || "").replace(/_/g, " "); const status = String((step && step.status) || "").trim(); diff --git a/internal/admin/dashboard/static/js/modules/audit-list.js b/internal/admin/dashboard/static/js/modules/audit-list.js index ec96f89ea..864bcc12b 100644 --- a/internal/admin/dashboard/static/js/modules/audit-list.js +++ b/internal/admin/dashboard/static/js/modules/audit-list.js @@ -174,6 +174,7 @@ return { title: 'Request', entry, + copyHeaders: data && data.request_headers, copyBody: data && data.request_body, showErrorMessage: false, errorMessage: null, @@ -194,6 +195,7 @@ return { title: 'Response', entry, + copyHeaders: data && data.response_headers, copyBody: data && data.response_body, showErrorMessage: !!(data && data.error_message), errorMessage: data && data.error_message, @@ -213,25 +215,34 @@ const renderBody = typeof this.renderBodyWithConversationHighlights === 'function' ? this.renderBodyWithConversationHighlights.bind(this) : (_entry, body) => formatJSON(body); + const createCopyState = () => clipboard + ? clipboard.createClipboardButtonState({ + logPrefix: 'Failed to copy audit payload:' + }) + : { + copied: false, + error: false, + copy() { + return Promise.resolve(); + } + }; + const copyBodyState = createCopyState(); + const copyHeadersState = createCopyState(); return { pane, formattedHeaders: pane && pane.showHeaders ? formatJSON(pane.headers) : '', renderedBody: pane && pane.showBody ? renderBody(pane.entry, pane.body) : '', - copyState: clipboard - ? clipboard.createClipboardButtonState({ - logPrefix: 'Failed to copy audit payload:' - }) - : { - copied: false, - error: false, - copy() { - return Promise.resolve(); - } - }, + copyBodyState, + copyHeadersState, + copyState: copyBodyState, copyBody() { - return this.copyState.copy(this.pane.copyBody, formatJSON); + return this.copyBodyState.copy(this.pane.copyBody, formatJSON); + }, + + copyHeaders() { + return this.copyHeadersState.copy(this.pane.copyHeaders, formatJSON); } }; } diff --git a/internal/admin/dashboard/static/js/modules/audit-list.test.js b/internal/admin/dashboard/static/js/modules/audit-list.test.js index fc891771a..36474c18c 100644 --- a/internal/admin/dashboard/static/js/modules/audit-list.test.js +++ b/internal/admin/dashboard/static/js/modules/audit-list.test.js @@ -40,6 +40,7 @@ test('auditRequestPane returns the shared request-pane contract', () => { assert.equal(pane.title, 'Request'); assert.equal(pane.entry, entry); + assert.equal(JSON.stringify(pane.copyHeaders), JSON.stringify(entry.data.request_headers)); assert.equal(JSON.stringify(pane.copyBody), JSON.stringify(entry.data.request_body)); assert.equal(pane.showErrorMessage, false); assert.equal(pane.errorMessage, null); @@ -68,6 +69,7 @@ test('auditResponsePane returns the shared response-pane contract', () => { assert.equal(pane.title, 'Response'); assert.equal(pane.entry, entry); + assert.equal(JSON.stringify(pane.copyHeaders), JSON.stringify(entry.data.response_headers)); assert.equal(JSON.stringify(pane.copyBody), JSON.stringify(entry.data.response_body)); assert.equal(pane.showErrorMessage, true); assert.equal(pane.errorMessage, 'provider timeout'); @@ -254,14 +256,47 @@ test('auditPaneState copies the formatted body and resets success feedback', asy await paneState.copyBody(); assert.deepEqual(writes, ['{\n "model": "gpt-5",\n "stream": false\n}']); - assert.equal(paneState.copyState.copied, true); - assert.equal(paneState.copyState.error, false); + assert.equal(paneState.copyBodyState.copied, true); + assert.equal(paneState.copyBodyState.error, false); assert.equal(typeof resetCallback, 'function'); resetCallback(); - assert.equal(paneState.copyState.copied, false); - assert.equal(paneState.copyState.error, false); + assert.equal(paneState.copyBodyState.copied, false); + assert.equal(paneState.copyBodyState.error, false); +}); + +test('auditPaneState copies the formatted headers independently from body feedback', async () => { + const writes = []; + const module = createAuditListModule({ + setTimeout() { + return 1; + }, + clearTimeout() {}, + window: { + navigator: { + clipboard: { + writeText(value) { + writes.push(value); + return Promise.resolve(); + } + } + } + } + }); + + const paneState = module.auditPaneState({ + copyHeaders: { 'x-request-id': 'req-123' }, + copyBody: { id: 'body-1' } + }); + + await paneState.copyHeaders(); + + assert.deepEqual(writes, ['{\n "x-request-id": "req-123"\n}']); + assert.equal(paneState.copyHeadersState.copied, true); + assert.equal(paneState.copyHeadersState.error, false); + assert.equal(paneState.copyBodyState.copied, false); + assert.equal(paneState.copyBodyState.error, false); }); test('auditPaneState marks copy failures and clears the error after reset', async () => { @@ -292,12 +327,12 @@ test('auditPaneState marks copy failures and clears the error after reset', asyn await paneState.copyBody(); - assert.equal(paneState.copyState.copied, false); - assert.equal(paneState.copyState.error, true); + assert.equal(paneState.copyBodyState.copied, false); + assert.equal(paneState.copyBodyState.error, true); assert.equal(typeof resetCallback, 'function'); resetCallback(); - assert.equal(paneState.copyState.copied, false); - assert.equal(paneState.copyState.error, false); + assert.equal(paneState.copyBodyState.copied, false); + assert.equal(paneState.copyBodyState.error, false); }); diff --git a/internal/admin/dashboard/static/js/modules/charts.js b/internal/admin/dashboard/static/js/modules/charts.js index bc5f2bd7a..56e0b994f 100644 --- a/internal/admin/dashboard/static/js/modules/charts.js +++ b/internal/admin/dashboard/static/js/modules/charts.js @@ -282,6 +282,18 @@ return this._usageRowsBySelectedValue(this.userPathUsage || []); }, + userPathUsageChartVisible() { + const rows = Array.isArray(this.userPathUsage) ? this.userPathUsage : []; + if (rows.length === 0) { + return false; + } + if (rows.length !== 1) { + return true; + } + const onlyPath = String(rows[0] && rows[0].user_path || '').trim(); + return onlyPath !== '' && onlyPath !== '/'; + }, + _barData() { return this._barDataFrom(this.modelUsage, (m) => typeof this.qualifiedModelDisplay === 'function' ? this.qualifiedModelDisplay(m) @@ -351,7 +363,7 @@ renderUserPathChart(retries) { if (retries === undefined) retries = 3; this.$nextTick(() => { - if (!this.userPathUsage || this.userPathUsage.length === 0 || this.page !== 'usage' || (this.userPathUsageView || 'chart') !== 'chart') { + if (!this.userPathUsageChartVisible() || this.page !== 'usage' || (this.userPathUsageView || 'chart') !== 'chart') { if (this.usageUserPathChart) { this.usageUserPathChart.destroy(); this.usageUserPathChart = null; diff --git a/internal/admin/dashboard/static/js/modules/charts.test.js b/internal/admin/dashboard/static/js/modules/charts.test.js index 27e0bf216..f044d0d70 100644 --- a/internal/admin/dashboard/static/js/modules/charts.test.js +++ b/internal/admin/dashboard/static/js/modules/charts.test.js @@ -174,6 +174,32 @@ test('renderUserPathChart recreates the user path chart and uses usage mode valu assert.equal(JSON.stringify(module.usageUserPathChart.data.datasets[0].data), JSON.stringify([0.03])); }); +test('user path usage chart hides when the only path is root', () => { + FakeChart.instances = []; + const { module } = createChartsContext(); + module.page = 'usage'; + module.usageUserPathChart = null; + module.userPathUsage = [ + { user_path: '/', input_tokens: 21, output_tokens: 34, total_tokens: 55, total_cost: 0.03 } + ]; + + assert.equal(module.userPathUsageChartVisible(), false); + module.renderUserPathChart(); + + assert.equal(FakeChart.instances.length, 0); + assert.equal(module.usageUserPathChart, null); + + module.userPathUsage = [ + { user_path: '/team/alpha', input_tokens: 21, output_tokens: 34, total_tokens: 55, total_cost: 0.03 } + ]; + + assert.equal(module.userPathUsageChartVisible(), true); + module.renderUserPathChart(); + + assert.equal(FakeChart.instances.length, 1); + assert.notEqual(module.usageUserPathChart, null); +}); + test('usage chart table rows use the same selected metric ordering as charts', () => { const { module } = createChartsContext(); module.usageMode = 'tokens'; diff --git a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js index ec87d64a7..7079e50e3 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js +++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js @@ -366,6 +366,7 @@ test('audit toolbar uses a full-width search row above the select row with a rig indexTemplate, /id="audit-filter-search"[^>]*placeholder="Search by request ID, model, provider, path, user path, or error\.\.\."/ ); + assert.match(indexTemplate, /class="audit-filter-row audit-filter-row-search"[\s\S]*data-lucide="search" class="filter-input-icon"[\s\S]*id="audit-filter-search"/); assert.match(indexTemplate, /id="audit-filter-status"[\s\S]*