diff --git a/docs/benchmarking.md b/docs/benchmarking.md index bad939394..d635f37f6 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -93,7 +93,16 @@ Add benchmarks only if they: After adding a new benchmark, regenerate the baseline as described above. +### Dashboard + +The Settings → Updates → Diagnostics panel reads from the runtime benchmark +database through `/api/benchmarks/*` endpoints. It shows: + +- p50/p95 timing by stage for the selected window +- per-plugin timing averages +- recent refresh rows +- stage drill-down for a selected refresh + ### Roadmap (next) -- Add `/api/benchmarks/*` endpoints and simple dashboard. - SSE progress stream and lightweight UI indicator. diff --git a/src/blueprints/settings/_benchmarks.py b/src/blueprints/settings/_benchmarks.py index 2e38fcec5..637fdfaa2 100644 --- a/src/blueprints/settings/_benchmarks.py +++ b/src/blueprints/settings/_benchmarks.py @@ -9,6 +9,23 @@ from utils.messages import BENCHMARKS_API_DISABLED_ERROR +def _clamp_benchmark_limit(raw_limit: str | None) -> int: + try: + return max(1, min(200, int(raw_limit or "50"))) + except (TypeError, ValueError): + return 50 + + +def _parse_benchmark_cursor(raw_cursor: str | None) -> int | None: + if not raw_cursor: + return None + try: + cursor = int(raw_cursor) + except (TypeError, ValueError): + return None + return cursor if cursor > 0 else None + + @_mod.settings_bp.route("/api/benchmarks/summary", methods=["GET"]) # type: ignore def benchmarks_summary() -> tuple[object, int] | Response: if not _mod._benchmarks_enabled(): @@ -66,8 +83,8 @@ def benchmarks_refreshes() -> tuple[object, int] | Response: return json_error(BENCHMARKS_API_DISABLED_ERROR, status=404) conn = None try: - limit = max(1, min(200, int(request.args.get("limit", "50")))) - cursor = request.args.get("cursor") + limit = _clamp_benchmark_limit(request.args.get("limit")) + cursor = _parse_benchmark_cursor(request.args.get("cursor")) since = _mod._window_since_seconds(request.args.get("window", "24h")) conn = sqlite3.connect(_mod._get_bench_db_path()) conn.row_factory = sqlite3.Row @@ -82,7 +99,7 @@ def benchmarks_refreshes() -> tuple[object, int] | Response: ORDER BY id DESC LIMIT ? """, - (since, int(cursor), limit), + (since, cursor, limit), ).fetchall() else: rows = conn.execute( diff --git a/src/schemas/endpoint_map.py b/src/schemas/endpoint_map.py index 876d5f9ea..3afb8c5bd 100644 --- a/src/schemas/endpoint_map.py +++ b/src/schemas/endpoint_map.py @@ -15,6 +15,8 @@ from schemas.responses import ( BenchmarksPluginsResponse, + BenchmarksRefreshesResponse, + BenchmarksStagesResponse, BenchmarksSummaryResponse, DiagnosticsResponse, HealthPluginsResponse, @@ -54,6 +56,8 @@ "settings.start_rollback": RollbackControlResponse, "settings.benchmarks_summary": BenchmarksSummaryResponse, "settings.benchmarks_plugins": BenchmarksPluginsResponse, + "settings.benchmarks_refreshes": BenchmarksRefreshesResponse, + "settings.benchmarks_stages": BenchmarksStagesResponse, "settings.safe_reset": SuccessMessageResponse, "settings.save_api_keys": SaveApiKeysResponse, "settings.delete_api_key": SuccessMessageResponse, diff --git a/src/schemas/responses.py b/src/schemas/responses.py index 830cf8a0c..79635684e 100644 --- a/src/schemas/responses.py +++ b/src/schemas/responses.py @@ -310,6 +310,49 @@ class BenchmarksPluginsResponse(TypedDict, total=False): items: list[BenchmarksPluginEntry] +class BenchmarksRefreshEntry(TypedDict): + """One recent refresh row from ``GET /api/benchmarks/refreshes``.""" + + id: int + ts: float + refresh_id: str + plugin_id: str | None + instance: str | None + playlist: str | None + used_cached: int | None + request_ms: int | None + generate_ms: int | None + preprocess_ms: int | None + display_ms: int | None + + +class BenchmarksRefreshesResponse(TypedDict, total=False): + """Response body for ``GET /api/benchmarks/refreshes``.""" + + success: bool + request_id: str + items: list[BenchmarksRefreshEntry] + next_cursor: str | None + + +class BenchmarksStageEntry(TypedDict): + """One stage event from ``GET /api/benchmarks/stages``.""" + + id: int + ts: float + stage: str + duration_ms: int | None + extra_json: str | None + + +class BenchmarksStagesResponse(TypedDict, total=False): + """Response body for ``GET /api/benchmarks/stages``.""" + + success: bool + request_id: str + items: list[BenchmarksStageEntry] + + class JobStatusResult(TypedDict, total=False): """Async ``/update_now`` job result payload returned by ``/api/job/``.""" diff --git a/src/static/openapi.json b/src/static/openapi.json index c22563c4c..be7a5c114 100644 --- a/src/static/openapi.json +++ b/src/static/openapi.json @@ -650,6 +650,103 @@ } } }, + "/api/benchmarks/refreshes": { + "get": { + "tags": [ + "Health" + ], + "summary": "Recent benchmark refreshes", + "description": "Returns recent refresh timing rows for the diagnostics dashboard.", + "operationId": "benchmarksRefreshes", + "parameters": [ + { + "name": "window", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "24h" + }, + "description": "Look-back window (for example 1h, 24h, 7d)." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + }, + "description": "Maximum rows to return." + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Previous refresh_events id for pagination." + } + ], + "responses": { + "200": { + "description": "Recent benchmark refresh rows", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BenchmarksRefreshesResponse" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/benchmarks/stages": { + "get": { + "tags": [ + "Health" + ], + "summary": "Benchmark stage events", + "description": "Returns stage timing rows for a single refresh_id.", + "operationId": "benchmarksStages", + "parameters": [ + { + "name": "refresh_id", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Refresh id from /api/benchmarks/refreshes." + } + ], + "responses": { + "200": { + "description": "Benchmark stage rows", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BenchmarksStagesResponse" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "422": { + "$ref": "#/components/responses/BadRequest" + } + } + } + }, "/api/diagnostics": { "get": { "tags": [ @@ -2096,6 +2193,141 @@ } } }, + "BenchmarksRefreshEntry": { + "type": "object", + "required": [ + "id", + "ts", + "refresh_id", + "plugin_id", + "instance", + "playlist", + "used_cached", + "request_ms", + "generate_ms", + "preprocess_ms", + "display_ms" + ], + "properties": { + "id": { + "type": "integer" + }, + "ts": { + "type": "number" + }, + "refresh_id": { + "type": "string" + }, + "plugin_id": { + "type": "string", + "nullable": true + }, + "instance": { + "type": "string", + "nullable": true + }, + "playlist": { + "type": "string", + "nullable": true + }, + "used_cached": { + "type": "integer", + "nullable": true + }, + "request_ms": { + "type": "integer", + "nullable": true + }, + "generate_ms": { + "type": "integer", + "nullable": true + }, + "preprocess_ms": { + "type": "integer", + "nullable": true + }, + "display_ms": { + "type": "integer", + "nullable": true + } + } + }, + "BenchmarksRefreshesResponse": { + "type": "object", + "required": [ + "success", + "items", + "next_cursor" + ], + "properties": { + "success": { + "type": "boolean" + }, + "request_id": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BenchmarksRefreshEntry" + } + }, + "next_cursor": { + "type": "string", + "nullable": true + } + } + }, + "BenchmarksStageEntry": { + "type": "object", + "required": [ + "id", + "ts", + "stage", + "duration_ms", + "extra_json" + ], + "properties": { + "id": { + "type": "integer" + }, + "ts": { + "type": "number" + }, + "stage": { + "type": "string" + }, + "duration_ms": { + "type": "integer", + "nullable": true + }, + "extra_json": { + "type": "string", + "nullable": true + } + } + }, + "BenchmarksStagesResponse": { + "type": "object", + "required": [ + "success", + "items" + ], + "properties": { + "success": { + "type": "boolean" + }, + "request_id": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BenchmarksStageEntry" + } + } + } + }, "DiagnosticsMemory": { "type": "object", "required": [ diff --git a/src/static/scripts/settings/diagnostics.js b/src/static/scripts/settings/diagnostics.js index 446f06660..9deb9883a 100644 --- a/src/static/scripts/settings/diagnostics.js +++ b/src/static/scripts/settings/diagnostics.js @@ -22,6 +22,22 @@ return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`; } + function formatTimestamp(val) { + if (val === null || val === undefined || Number.isNaN(Number(val))) { + return "—"; + } + try { + return new Date(Number(val) * 1000).toLocaleString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch (_e) { + return "—"; + } + } + function formatPercent(val) { if (val === null || val === undefined || Number.isNaN(Number(val))) { return "—"; @@ -114,6 +130,68 @@ return table; } + function buildRefreshesTable(items, onSelectRefresh) { + if (!items.length) { + const msg = document.createElement("div"); + msg.className = "bench-empty"; + msg.textContent = "No recent refresh timings for this window."; + return msg; + } + + const table = buildTable([ + "Time", + "Plugin", + "Request", + "Generate", + "Display", + "Stages", + ]); + table.className = "bench-table bench-refresh-table"; + const tbody = document.createElement("tbody"); + for (const item of items.slice(0, 12)) { + const row = document.createElement("tr"); + appendCell(row, formatTimestamp(item.ts)); + appendCell(row, item.instance || item.plugin_id || "—"); + appendCell(row, formatMs(item.request_ms)); + appendCell(row, formatMs(item.generate_ms)); + appendCell(row, formatMs(item.display_ms)); + + const actionCell = document.createElement("td"); + const button = document.createElement("button"); + button.type = "button"; + button.className = "benchmark-stage-button"; + button.textContent = "View"; + button.disabled = !item.refresh_id; + button.addEventListener("click", () => onSelectRefresh(item)); + actionCell.appendChild(button); + row.appendChild(actionCell); + tbody.appendChild(row); + } + table.appendChild(tbody); + return table; + } + + function buildStagesTable(items) { + if (!items.length) { + const msg = document.createElement("div"); + msg.className = "bench-empty"; + msg.textContent = "No stage events were recorded for this refresh."; + return msg; + } + const table = buildTable(["Stage", "Duration", "Recorded"]); + table.className = "bench-table"; + const tbody = document.createElement("tbody"); + for (const item of items) { + const row = document.createElement("tr"); + appendCell(row, item.stage || "—"); + appendCell(row, formatMs(item.duration_ms)); + appendCell(row, formatTimestamp(item.ts)); + tbody.appendChild(row); + } + table.appendChild(tbody); + return table; + } + function buildSystemHealthTable(systemData) { const table = buildTable(["Metric", "Value"]); table.className = "bench-table"; @@ -201,10 +279,24 @@ console.warn(message, error); const panel = document.getElementById(panelId); if (panel) { - panel.textContent = message; + const detail = error?.message ? `: ${error.message}` : ""; + panel.textContent = `${message}${detail}`; } } + function getResponseError(data, fallback) { + if (typeof data?.error === "string" && data.error.trim()) { + return data.error; + } + if (data?.error != null) { + return String(data.error); + } + if (typeof data?.message === "string" && data.message.trim()) { + return data.message; + } + return fallback; + } + function getIsolationFailureMessage(data, verb, pluginId) { const fallbackMsg = `Failed to ${verb} plugin`; let errorText = fallbackMsg; @@ -221,51 +313,154 @@ function createDiagnosticsModule({ ui }) { let progressES = null; + let benchmarkRequestSeq = 0; + let stageRequestSeq = 0; + + function getBenchmarkWindow() { + return document.getElementById("benchmarkWindow")?.value || "24h"; + } + + function setBenchmarkPanelsLoading(isLoading) { + for (const panelId of ["benchSummary", "benchPlugins", "benchRefreshes"]) { + ui.setPanelLoading?.(panelId, isLoading); + } + } + + function renderPanel(panelId, child) { + const panel = document.getElementById(panelId); + if (!panel) return null; + panel.textContent = ""; + if (child) panel.appendChild(child); + return panel; + } + + function resetStagesPanel(message = "Select a recent refresh to view recorded stages.") { + const panel = document.getElementById("benchStages"); + if (!panel) return; + panel.textContent = message; + ui.setPanelLoading?.("benchStages", false); + } + + async function refreshStages(refreshItem) { + const panel = document.getElementById("benchStages"); + if (!panel || !refreshItem?.refresh_id) return; + const requestSeq = ++stageRequestSeq; + ui.setPanelLoading?.("benchStages", true); + const refreshLabel = + refreshItem.instance || refreshItem.plugin_id || "refresh"; + panel.textContent = `Loading stages for ${refreshLabel}...`; + try { + const params = new URLSearchParams({ refresh_id: refreshItem.refresh_id }); + const resp = await fetch(`/api/benchmarks/stages?${params.toString()}`, { + cache: "no-store", + }); + const data = await resp.json(); + if (requestSeq !== stageRequestSeq) return; + if (!resp.ok || data?.success === false) { + throw new Error( + getResponseError(data, resp.statusText || `HTTP ${resp.status}`) + ); + } + panel.textContent = ""; + const header = document.createElement("div"); + header.className = "benchmark-stage-heading"; + header.textContent = `${refreshLabel} stages`; + panel.appendChild(header); + panel.appendChild( + buildStagesTable(Array.isArray(data?.items) ? data.items : []) + ); + } catch (e) { + if (requestSeq !== stageRequestSeq) return; + setPanelFailure("benchStages", "Failed to load refresh stages", e); + } finally { + if (requestSeq === stageRequestSeq) { + ui.setPanelLoading?.("benchStages", false); + } + } + } async function refreshBenchmarks() { - ui.setPanelLoading?.("benchSummary", true); + const requestSeq = ++benchmarkRequestSeq; + const windowValue = getBenchmarkWindow(); + setBenchmarkPanelsLoading(true); + resetStagesPanel(); try { - const [summaryResp, pluginsResp] = await Promise.all([ - fetch("/api/benchmarks/summary?window=24h", { cache: "no-store" }), - fetch("/api/benchmarks/plugins?window=24h", { cache: "no-store" }), + const [summaryResp, pluginsResp, refreshesResp] = await Promise.all([ + fetch( + `/api/benchmarks/summary?window=${encodeURIComponent(windowValue)}`, + { cache: "no-store" } + ), + fetch( + `/api/benchmarks/plugins?window=${encodeURIComponent(windowValue)}`, + { cache: "no-store" } + ), + fetch( + `/api/benchmarks/refreshes?window=${encodeURIComponent(windowValue)}&limit=12`, + { cache: "no-store" } + ), ]); const summary = await summaryResp.json(); const plugins = await pluginsResp.json(); + const refreshes = await refreshesResp.json(); + if (requestSeq !== benchmarkRequestSeq || windowValue !== getBenchmarkWindow()) { + return; + } - const panel = document.getElementById("benchSummary"); - if (!panel) return; - panel.textContent = ""; + const failures = [ + [summaryResp, summary, "benchSummary", "benchmark summary"], + [pluginsResp, plugins, "benchPlugins", "plugin timing"], + [refreshesResp, refreshes, "benchRefreshes", "recent refreshes"], + ].filter(([resp, body]) => !resp.ok || body?.success === false); + + if (failures.length > 0) { + for (const [, body, panelId, label] of failures) { + const message = getResponseError(body, `Failed to load ${label}`); + renderPanel(panelId, document.createTextNode(message)); + } + resetStagesPanel("Refresh stages are unavailable until recent refreshes load."); + return; + } const summaryData = summary.summary || {}; const hasData = Object.values(summaryData).some( (stage) => stage?.p50 !== null && stage?.p50 !== undefined ); const pluginItems = Array.isArray(plugins?.items) ? plugins.items : []; + const refreshItems = Array.isArray(refreshes?.items) + ? refreshes.items + : []; - if (!hasData && pluginItems.length === 0) { + if (!hasData && pluginItems.length === 0 && refreshItems.length === 0) { const emptyMsg = document.createElement("div"); emptyMsg.className = "bench-empty"; emptyMsg.textContent = - "No benchmark data recorded in the last 24 hours. Benchmarks are collected automatically on each display refresh."; - panel.appendChild(emptyMsg); + "No benchmark data recorded in this window. Benchmarks are collected automatically on each display refresh."; + renderPanel("benchSummary", emptyMsg); + renderPanel("benchPlugins", null); + renderPanel("benchRefreshes", null); + resetStagesPanel("No refreshes available for this window."); return; } + const summaryPanel = renderPanel("benchSummary", null); const heading1 = document.createElement("strong"); - heading1.textContent = "Benchmark Summary (24h)"; - panel.appendChild(heading1); - panel.appendChild(buildSummaryTable(summaryData)); + heading1.textContent = `Timing Summary (${windowValue})`; + summaryPanel?.appendChild(heading1); + summaryPanel?.appendChild(buildSummaryTable(summaryData)); - if (pluginItems.length > 0) { - const heading2 = document.createElement("strong"); - heading2.textContent = "Per-plugin Averages"; - panel.appendChild(heading2); - panel.appendChild(buildPluginsTable(pluginItems)); - } + renderPanel("benchPlugins", buildPluginsTable(pluginItems)); + renderPanel("benchRefreshes", buildRefreshesTable(refreshItems, refreshStages)); + resetStagesPanel(); } catch (e) { + if (requestSeq !== benchmarkRequestSeq) return; setPanelFailure("benchSummary", "Failed to load benchmark summary", e); + setPanelFailure("benchPlugins", "Failed to load plugin timing", e); + setPanelFailure("benchRefreshes", "Failed to load recent refreshes", e); + resetStagesPanel("Refresh stages are unavailable until recent refreshes load."); } finally { - ui.setPanelLoading?.("benchSummary", false); + if (requestSeq === benchmarkRequestSeq) { + setBenchmarkPanelsLoading(false); + } } } @@ -406,6 +601,9 @@ document .getElementById("refreshBenchmarksBtn") ?.addEventListener("click", refreshBenchmarks); + document + .getElementById("benchmarkWindow") + ?.addEventListener("change", refreshBenchmarks); document.getElementById("safeResetBtn")?.addEventListener("click", safeReset); document .getElementById("isolatePluginBtn") diff --git a/src/static/styles/main.css b/src/static/styles/main.css index 97692352d..4d30aad63 100644 --- a/src/static/styles/main.css +++ b/src/static/styles/main.css @@ -6893,6 +6893,18 @@ a.pl-add-row:hover { flex-wrap: wrap; } +.benchmark-control-row { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.benchmark-window-select { + min-width: 8rem; + width: auto; +} + .observability-section-title { display: grid; gap: 4px; @@ -7023,6 +7035,37 @@ a.pl-add-row:hover { letter-spacing: 0.03em; } +.bench-refresh-table { + min-width: 36rem; +} + +.benchmark-stage-button { + border: 1px solid var(--border-color, var(--divider)); + border-radius: 6px; + background: var(--panel-bg); + color: var(--text); + cursor: pointer; + font: inherit; + font-size: var(--text-xs); + line-height: 1.2; + padding: 4px 8px; +} + +.benchmark-stage-button:hover:not(:disabled), +.benchmark-stage-button:focus-visible { + border-color: var(--accent); +} + +.benchmark-stage-button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.benchmark-stage-heading { + font-weight: 700; + margin-bottom: 6px; +} + /* Mobile settings nav toggle */ .settings-mobile-toggle { display: none; diff --git a/src/static/styles/partials/_settings-console.css b/src/static/styles/partials/_settings-console.css index 21768b6cd..97ff52835 100644 --- a/src/static/styles/partials/_settings-console.css +++ b/src/static/styles/partials/_settings-console.css @@ -189,6 +189,18 @@ flex-wrap: wrap; } +.benchmark-control-row { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.benchmark-window-select { + min-width: 8rem; + width: auto; +} + .observability-section-title { display: grid; gap: 4px; @@ -319,6 +331,37 @@ letter-spacing: 0.03em; } +.bench-refresh-table { + min-width: 36rem; +} + +.benchmark-stage-button { + border: 1px solid var(--border-color, var(--divider)); + border-radius: 6px; + background: var(--panel-bg); + color: var(--text); + cursor: pointer; + font: inherit; + font-size: var(--text-xs); + line-height: 1.2; + padding: 4px 8px; +} + +.benchmark-stage-button:hover:not(:disabled), +.benchmark-stage-button:focus-visible { + border-color: var(--accent); +} + +.benchmark-stage-button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.benchmark-stage-heading { + font-weight: 700; + margin-bottom: 6px; +} + /* Mobile settings nav toggle */ .settings-mobile-toggle { display: none; diff --git a/src/templates/settings.html b/src/templates/settings.html index f170eb4af..35032dbc0 100644 --- a/src/templates/settings.html +++ b/src/templates/settings.html @@ -365,7 +365,15 @@

Backup & restore

Diagnostics

Snapshot render health, benchmark timings, and temporary plugin isolation controls.

- +
+ + + +
@@ -379,7 +387,7 @@

Diagnostics

- Benchmark Summary + Timing Summary
Benchmark summary loading...
@@ -387,6 +395,20 @@

Diagnostics

Health summary loading...
+
+
+ Plugin Timing +
Plugin timing loading...
+
+
+ Recent Refreshes +
Recent refreshes loading...
+
+
+
+ Refresh Stages +
Select a recent refresh to view recorded stages.
+
Isolation Summary
Isolation list loading...
diff --git a/tests/contract/test_frontend_api_contract_coverage.py b/tests/contract/test_frontend_api_contract_coverage.py index 89a5ef4dc..af79af87b 100644 --- a/tests/contract/test_frontend_api_contract_coverage.py +++ b/tests/contract/test_frontend_api_contract_coverage.py @@ -20,6 +20,8 @@ ("GET", "/api/health/plugins"), ("GET", "/api/benchmarks/summary"), ("GET", "/api/benchmarks/plugins"), + ("GET", "/api/benchmarks/refreshes"), + ("GET", "/api/benchmarks/stages"), ("GET", "/api/diagnostics"), ("GET", "/api/job/"), ("GET", "/api/version/info"), @@ -55,6 +57,8 @@ ("GET", "/api/health/plugins"), ("GET", "/api/benchmarks/summary"), ("GET", "/api/benchmarks/plugins"), + ("GET", "/api/benchmarks/refreshes"), + ("GET", "/api/benchmarks/stages"), ("GET", "/api/diagnostics"), ("GET", "/api/job/{job_id}"), ("GET", "/api/version/info"), diff --git a/tests/contract/test_response_shapes.py b/tests/contract/test_response_shapes.py index 59fa1755e..1dfa80114 100644 --- a/tests/contract/test_response_shapes.py +++ b/tests/contract/test_response_shapes.py @@ -18,6 +18,8 @@ from __future__ import annotations +import sqlite3 +import time from typing import Any import pytest @@ -25,8 +27,11 @@ # Import schemas via the ``src.*`` path which ``tests/conftest.py`` puts on # sys.path via SRC_ABS. TypedDict subclasses from ``typing`` expose their # total-ness on the class itself. +from benchmarks.benchmark_storage import _ensure_schema from schemas.responses import ( # noqa: E402 (sys.path set up by conftest) BenchmarksPluginsResponse, + BenchmarksRefreshesResponse, + BenchmarksStagesResponse, BenchmarksSummaryResponse, DiagnosticsResponse, HealthPluginsResponse, @@ -232,6 +237,55 @@ def test_benchmarks_plugins_shape(client, device_config_dev, tmp_path): assert isinstance(body.get("items"), list) +def test_benchmarks_refreshes_shape(client, device_config_dev, tmp_path): + db_path = tmp_path / "contract_benchmarks_refreshes.db" + device_config_dev.update_value("enable_benchmarks", True, write=False) + device_config_dev.update_value("benchmarks_db_path", str(db_path), write=True) + + conn = sqlite3.connect(db_path) + _ensure_schema(conn) + conn.execute( + """ + INSERT INTO refresh_events ( + refresh_id, ts, plugin_id, instance, playlist, used_cached, + request_ms, generate_ms, preprocess_ms, display_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ("shape-refresh-1", time.time(), "clock", "Clock", "Default", 0, 10, 20, 3, 4), + ) + conn.commit() + conn.close() + + body = _get_json(client, "/api/benchmarks/refreshes?window=24h&limit=5") + assert_shape(body, BenchmarksRefreshesResponse) + assert body.get("success") is True + assert isinstance(body.get("items"), list) + + +def test_benchmarks_stages_shape(client, device_config_dev, tmp_path): + db_path = tmp_path / "contract_benchmarks_stages.db" + device_config_dev.update_value("enable_benchmarks", True, write=False) + device_config_dev.update_value("benchmarks_db_path", str(db_path), write=True) + + refresh_id = "shape-refresh-2" + conn = sqlite3.connect(db_path) + _ensure_schema(conn) + conn.execute( + """ + INSERT INTO stage_events (refresh_id, ts, stage, duration_ms, extra_json) + VALUES (?, ?, ?, ?, ?) + """, + (refresh_id, time.time(), "generate_image", 123, "{}"), + ) + conn.commit() + conn.close() + + body = _get_json(client, f"/api/benchmarks/stages?refresh_id={refresh_id}") + assert_shape(body, BenchmarksStagesResponse) + assert body.get("success") is True + assert isinstance(body.get("items"), list) + + def test_diagnostics_shape(client): body = _get_json(client, "/api/diagnostics") assert_shape(body, DiagnosticsResponse) diff --git a/tests/static/test_benchmark_display.py b/tests/static/test_benchmark_display.py index adb0e2f90..d8be83bce 100644 --- a/tests/static/test_benchmark_display.py +++ b/tests/static/test_benchmark_display.py @@ -40,6 +40,29 @@ def test_build_plugins_table_defined(self): js = _read_js() assert "function buildPluginsTable" in js + def test_build_refreshes_table_defined(self): + js = _read_js() + assert "function buildRefreshesTable" in js + + def test_build_stages_table_defined(self): + js = _read_js() + assert "function buildStagesTable" in js + + def test_benchmark_refreshes_ignore_stale_responses(self): + js = _read_js() + assert "benchmarkRequestSeq" in js + assert "windowValue !== getBenchmarkWindow()" in js + + def test_benchmark_errors_check_response_status(self): + js = _read_js() + assert "!resp.ok || body?.success === false" in js + assert "!resp.ok || data?.success === false" in js + + def test_stage_panel_reset_helper_defined(self): + js = _read_js() + assert "function resetStagesPanel" in js + assert "No refreshes available for this window." in js + def test_stage_labels_defined(self): """Human-readable labels must replace raw keys like 'generate_ms'.""" js = _read_js() @@ -63,3 +86,9 @@ def test_bench_table_class_in_css(self): def test_bench_table_class_used_in_js(self): js = _read_js() assert '"bench-table"' in js + + def test_benchmark_window_control_exists(self): + html = Path("src/templates/settings.html").read_text() + assert 'id="benchmarkWindow"' in html + assert 'id="benchRefreshes"' in html + assert 'id="benchStages"' in html diff --git a/tests/unit/test_settings_blueprint.py b/tests/unit/test_settings_blueprint.py index b745c7c67..60b33b095 100644 --- a/tests/unit/test_settings_blueprint.py +++ b/tests/unit/test_settings_blueprint.py @@ -360,6 +360,20 @@ def test_benchmarks_refreshes_with_cursor(self, client, monkeypatch, tmp_path): resp = client.get("/api/benchmarks/refreshes?cursor=999") assert resp.status_code == 200 + def test_benchmarks_refreshes_invalid_cursor_is_ignored( + self, client, monkeypatch, tmp_path + ): + import blueprints.settings as mod + + monkeypatch.setattr(mod, "_benchmarks_enabled", lambda: True) + db_path = str(tmp_path / "bench.db") + monkeypatch.setattr(mod, "_get_bench_db_path", lambda: db_path) + resp = client.get("/api/benchmarks/refreshes?cursor=not-an-int&limit=nope") + assert resp.status_code == 200 + data = resp.get_json() + assert data["success"] is True + assert isinstance(data["items"], list) + # --------------------------------------------------------------------------- # Health API endpoints