From 114ee9508c458b3617ab1fbfb462cdf34d50017d Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Wed, 22 Jul 2026 15:08:45 -0700 Subject: [PATCH 1/8] plugin selection ui --- dashboard/assets/dashboard.js | 41 ++++- dashboard/assets/style.css | 60 ++++++- dashboard/index.html | 15 +- dashboard/src/integration-tests/App.tsx | 7 + dashboard/src/integration-tests/main.tsx | 1 + dashboard/src/nightly-runs/App.tsx | 7 + dashboard/src/nightly-runs/main.tsx | 1 + dashboard/src/performance-dashboard/App.tsx | 12 +- dashboard/src/performance-dashboard/main.tsx | 1 + dashboard/src/shared/PluginSelector.tsx | 67 +++++++ dashboard/src/shared/plugin-selector.css | 58 +++++++ dashboard/src/skills/App.tsx | 173 ++++++++++--------- dashboard/src/skills/main.tsx | 1 + 13 files changed, 354 insertions(+), 90 deletions(-) create mode 100644 dashboard/src/shared/PluginSelector.tsx create mode 100644 dashboard/src/shared/plugin-selector.css diff --git a/dashboard/assets/dashboard.js b/dashboard/assets/dashboard.js index 6c70ded82..635fb3f73 100644 --- a/dashboard/assets/dashboard.js +++ b/dashboard/assets/dashboard.js @@ -7,6 +7,40 @@ /** @type {Record void>} */ const panelRenderers = {}; +const AVAILABLE_PLUGINS = ["azure-skills", "cat"]; +const PLUGIN_SESSION_STORAGE_KEY = "dashboard.selectedPlugin"; + +function getPersistedPluginSelection() { + try { + const persisted = window.sessionStorage.getItem(PLUGIN_SESSION_STORAGE_KEY); + if (persisted && AVAILABLE_PLUGINS.includes(persisted)) { + return persisted; + } + } catch { + // Ignore unavailable sessionStorage and fall back to the default. + } + return AVAILABLE_PLUGINS[0]; +} + +function persistPluginSelection(plugin) { + if (!AVAILABLE_PLUGINS.includes(plugin)) return; + try { + window.sessionStorage.setItem(PLUGIN_SESSION_STORAGE_KEY, plugin); + } catch { + // Ignore unavailable sessionStorage; the UI can still function locally. + } +} + +function initPluginSelector() { + const select = document.getElementById("plugin-select"); + if (!select) return; + + select.value = getPersistedPluginSelection(); + select.addEventListener("change", function () { + persistPluginSelection(select.value); + }); +} + // ── Thresholds ────────────────────────────────────────────────────────────── /** Minimum passing rate for skill invocation tests (0–1). */ @@ -1265,7 +1299,7 @@ function renderE2EPassRatePanel( barFill.setAttribute( "aria-label", skill.skillName + ": " + pct + "% e2e pass rate (" + - (status === "pass" ? "above" : "below") + " " + E2E_THRESHOLD_PCT + "% threshold)", + (status === "pass" ? "above" : "below") + " " + E2E_THRESHOLD_PCT + "% threshold)", ); // Threshold marker — hidden from AT; sr-only sibling communicates the threshold @@ -1420,7 +1454,7 @@ function renderConfidenceLevelPanel( barFill.setAttribute( "aria-label", skill.skillName + ": " + pct + "% confidence level (" + - (status === "pass" ? "above" : "below") + " " + CONFIDENCE_THRESHOLD_PCT + "% threshold)", + (status === "pass" ? "above" : "below") + " " + CONFIDENCE_THRESHOLD_PCT + "% threshold)", ); const marker = el("div", "e2e-rate-threshold-marker"); @@ -1594,7 +1628,7 @@ function renderDeployRetriesPanel(section, rows, overallStatus, dateLabel) { ? "No data" : failing > 0 ? failing + " scenario" + (failing !== 1 ? "s" : "") + " failed (\u22653 retries)" - + (warning > 0 ? ", " + warning + " warned" : "") + + (warning > 0 ? ", " + warning + " warned" : "") : withRetries > 0 ? withRetries + " scenario" + (withRetries !== 1 ? "s" : "") + " needed retries" : "No retries \u2014 all scenarios passed first try"; @@ -1801,6 +1835,7 @@ async function init() { } document.addEventListener("DOMContentLoaded", function () { + initPluginSelector(); init(); loadSkillInvocationRates(); loadE2EPassRates(); diff --git a/dashboard/assets/style.css b/dashboard/assets/style.css index d163a7b5d..ba27a1738 100644 --- a/dashboard/assets/style.css +++ b/dashboard/assets/style.css @@ -241,6 +241,48 @@ button.header-status-pill { font-weight: 800; } +/* === Plugin Toolbar === */ +.plugin-toolbar { + padding: 14px 24px; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); +} + +.plugin-toolbar__content { + display: flex; + align-items: center; + gap: 16px; +} + +.plugin-toolbar__field { + display: flex; + align-items: center; + gap: 12px; +} + +.plugin-toolbar__label { + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.plugin-toolbar__select { + min-width: 220px; + padding: 0.45rem 0.7rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg); + color: var(--color-text); + font-size: 0.9rem; +} + +.plugin-toolbar__select:focus { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} + /* === Main Content === */ #main:has(.panel-grid) { padding: 24px 32px; @@ -1030,6 +1072,22 @@ button.header-status-pill { padding: 16px; } + .plugin-toolbar { + padding: 14px 16px; + } + + .plugin-toolbar__field { + width: 100%; + flex-direction: column; + align-items: stretch; + gap: 6px; + } + + .plugin-toolbar__select { + min-width: 0; + width: 100%; + } + main { padding: 16px; } @@ -1108,4 +1166,4 @@ button.header-status-pill { .panel-chevron { display: none; } -} +} \ No newline at end of file diff --git a/dashboard/index.html b/dashboard/index.html index 3fd308ac0..2075862ec 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -32,6 +32,18 @@

Repository Health Dashboard

+
+
+ +
+
+
@@ -90,7 +102,8 @@

Deploy Scenario Retries

Loading...

-
+

Integration Test Average Token Usage

diff --git a/dashboard/src/integration-tests/App.tsx b/dashboard/src/integration-tests/App.tsx index fa05d6719..5180abc91 100644 --- a/dashboard/src/integration-tests/App.tsx +++ b/dashboard/src/integration-tests/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import type { BlobTree, BlobTreeNode } from "../shared/blobTree"; import { apiUrl, pageUrl } from "../shared/apiUrl"; +import PluginSelector, { getPersistedPluginSelection } from "../shared/PluginSelector"; interface TestCase { testName: string; @@ -235,6 +236,7 @@ function formatTestName(name: string): string { } function App() { + const [selectedPlugin, setSelectedPlugin] = useState(getPersistedPluginSelection); const [dates, setDates] = useState([]); const [selectedDate, setSelectedDate] = useState(null); const [testResults, setTestResults] = useState(null); @@ -297,6 +299,11 @@ function App() {

Integration Tests{selectedDate ? ` \u2014 ${selectedDate}` : ""}

+ +
{/* Left panel - date list */}
+ ); } diff --git a/dashboard/src/skills/main.tsx b/dashboard/src/skills/main.tsx index 83cc2fb0f..462481ac0 100644 --- a/dashboard/src/skills/main.tsx +++ b/dashboard/src/skills/main.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import "./skills.css"; +import "../shared/plugin-selector.css"; ReactDOM.createRoot(document.getElementById("root")!).render( From 810854292bd56732cd6e3890c4ef0f5da61e7bb2 Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Thu, 23 Jul 2026 16:24:22 -0700 Subject: [PATCH 2/8] per plugin dashboard view --- dashboard/api/src/blobEnumerator.ts | 54 +++++++ dashboard/api/src/functions/getData.ts | 7 +- dashboard/api/src/functions/getPlugins.ts | 26 ++++ dashboard/api/src/functions/getReports.ts | 8 +- dashboard/api/src/functions/getTestResults.ts | 8 +- .../api/src/functions/getTestRunMetrics.ts | 33 +++++ dashboard/api/src/functions/getToolUsage.ts | 25 +++- dashboard/assets/dashboard.js | 110 +++++++++++--- dashboard/index.html | 7 +- dashboard/integration-tests.html | 2 +- dashboard/msbench-nightly-runs.html | 4 +- dashboard/nightly-runs.html | 2 +- dashboard/performance-dashboard.html | 7 +- dashboard/skills.html | 4 +- dashboard/src/integration-tests/App.tsx | 4 +- dashboard/src/nightly-runs/App.tsx | 4 +- dashboard/src/shared/PluginSelector.tsx | 140 +++++++++++++++--- dashboard/src/shared/apiUrl.ts | 18 ++- dashboard/src/skills/App.tsx | 39 +++-- dashboard/token-usage.html | 4 +- plugin-skills.json | 35 +++++ 21 files changed, 465 insertions(+), 76 deletions(-) create mode 100644 dashboard/api/src/functions/getPlugins.ts create mode 100644 plugin-skills.json diff --git a/dashboard/api/src/blobEnumerator.ts b/dashboard/api/src/blobEnumerator.ts index abb0e8e10..f56223a34 100644 --- a/dashboard/api/src/blobEnumerator.ts +++ b/dashboard/api/src/blobEnumerator.ts @@ -199,6 +199,7 @@ export function getPerSkillReports(root: BlobTree, date: string): Record { HEALTH_BLOB_PATH ); return raw ? JSON.parse(raw) : null; +} + +export type PluginSkills = { + plugins: Record; +}; + +export async function getPluginSkills(): Promise { + const raw = await downloadBlobContent( + getContainerClient(NON_INTEGRATION_CONTAINER), + PLUGIN_SKILLS_BLOB_PATH + ); + const data = raw ? (JSON.parse(raw) as PluginSkills) : null; + return data; +} + +/** + * Resolve a plugin name to the set of skill names it contains, using the + * plugin-skills map. Returns null when the plugin is unknown (or the map is + * unavailable), letting callers decide how to handle it. + */ +async function getSkillsForPlugin(plugin: string): Promise | null> { + const data = await getPluginSkills(); + const skills = data?.plugins?.[plugin]; + return skills ? new Set(skills) : null; +} + +/** + * Resolve the optional `plugin` query parameter into a skill filter: + * - `undefined`/empty plugin → `null` (no filtering; retain all skills). + * - known plugin → the set of skills belonging to it. + * - unknown plugin → an empty set (filters everything out). + */ +export async function resolveSkillFilter(plugin?: string): Promise | null> { + if (!plugin) { + return null; + } + return (await getSkillsForPlugin(plugin)) ?? new Set(); +} + +/** + * Remove skill subtrees not in the given set from a blob tree, in place. + * Tree shape: date -> runId -> skill -> ... + */ +export function filterBlobTreeBySkills(tree: BlobTree, skills: Set): void { + for (const dateNode of Object.values(tree)) { + for (const runNode of Object.values(dateNode.children)) { + for (const skillName of Object.keys(runNode.children)) { + if (!skills.has(skillName)) { + delete runNode.children[skillName]; + } + } + } + } } \ No newline at end of file diff --git a/dashboard/api/src/functions/getData.ts b/dashboard/api/src/functions/getData.ts index 883de4ffc..3d31e6d7d 100644 --- a/dashboard/api/src/functions/getData.ts +++ b/dashboard/api/src/functions/getData.ts @@ -1,5 +1,5 @@ import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions"; -import { enumerateBlobs } from "../blobEnumerator"; +import { enumerateBlobs, filterBlobTreeBySkills, resolveSkillFilter } from "../blobEnumerator"; import { logRequestIdentity } from "../requestIdentity"; /** @@ -46,6 +46,11 @@ async function getData(request: HttpRequest, context: InvocationContext): Promis const container = request.query.get("container") || undefined; const root = await enumerateBlobs(`${date}/`, container); + const skillFilter = await resolveSkillFilter(request.query.get("plugin") || undefined); + if (skillFilter) { + filterBlobTreeBySkills(root, skillFilter); + } + return { status: 200, jsonBody: root, diff --git a/dashboard/api/src/functions/getPlugins.ts b/dashboard/api/src/functions/getPlugins.ts new file mode 100644 index 000000000..31351799c --- /dev/null +++ b/dashboard/api/src/functions/getPlugins.ts @@ -0,0 +1,26 @@ +import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions"; +import { getPluginSkills } from "../blobEnumerator"; + +/** + * Returns the map of plugin names to their skill names, + * GET /api/plugins + */ +async function getPluginsHandler(request: HttpRequest, context: InvocationContext): Promise { + try { + const pluginSkills = await getPluginSkills(); + return { + status: 200, + jsonBody: pluginSkills, + }; + } catch (err) { + context.error("Failed to read plugin containers:", err); + return { status: 500, body: "Failed to read plugin containers" }; + } +} + +app.http("getPlugins", { + methods: ["GET"], + authLevel: "anonymous", + route: "plugins", + handler: getPluginsHandler, +}); diff --git a/dashboard/api/src/functions/getReports.ts b/dashboard/api/src/functions/getReports.ts index eb9d6b80a..a968d7535 100644 --- a/dashboard/api/src/functions/getReports.ts +++ b/dashboard/api/src/functions/getReports.ts @@ -1,5 +1,5 @@ import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions"; -import { enumerateBlobs, getBlobContent, } from "../blobEnumerator"; +import { enumerateBlobs, filterBlobTreeBySkills, getBlobContent, resolveSkillFilter } from "../blobEnumerator"; import { logRequestIdentity } from "../requestIdentity"; import { SKILL_REPORT_PATTERN } from "../skillReport"; import type { BlobTree, BlobTreeNode } from "../shared/blobTree"; @@ -34,6 +34,12 @@ async function getReports(request: HttpRequest, context: InvocationContext): Pro const container = request.query.get("container") || undefined; const tree: BlobTree = await enumerateBlobs(`${date}/`, container); + + const skillFilter = await resolveSkillFilter(request.query.get("plugin") || undefined); + if (skillFilter) { + filterBlobTreeBySkills(tree, skillFilter); + } + const dateNode = tree[date]; if (!dateNode) { return { status: 404, body: `No reports found for date: ${date}` }; diff --git a/dashboard/api/src/functions/getTestResults.ts b/dashboard/api/src/functions/getTestResults.ts index f41776782..a2f64aaef 100644 --- a/dashboard/api/src/functions/getTestResults.ts +++ b/dashboard/api/src/functions/getTestResults.ts @@ -1,5 +1,5 @@ import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions"; -import { enumerateBlobs, getBlobContent } from "../blobEnumerator"; +import { enumerateBlobs, getBlobContent, resolveSkillFilter } from "../blobEnumerator"; import { logRequestIdentity } from "../requestIdentity"; import { SKILL_REPORT_PATTERN } from "../skillReport"; import type { BlobTree, BlobTreeNode } from "../shared/blobTree"; @@ -350,6 +350,9 @@ async function getTestResults(request: HttpRequest, context: InvocationContext): return { status: 404, body: `No data found for date: ${date}` }; } + // When a plugin is specified, only retain skills belonging to that plugin. + const skillFilter = await resolveSkillFilter(request.query.get("plugin") || undefined); + // Collect testResults.json paths organized by skill name. // Structure: date -> runId -> skillName -> (files | children with testResults.json) const pathsBySkill = new Map(); @@ -360,6 +363,9 @@ async function getTestResults(request: HttpRequest, context: InvocationContext): for (const runNode of Object.values(dateNode.children)) { for (const [skillName, skillNode] of Object.entries(runNode.children)) { + if (skillFilter && !skillFilter.has(skillName)) { + continue; + } collectTestResultPaths(skillNode, skillName, pathsBySkill); collectSkillReportPaths(skillNode, skillName, reportPathsBySkill); collectTokenSummaryPaths(skillNode, skillName, tokenSummaryPathsBySkill); diff --git a/dashboard/api/src/functions/getTestRunMetrics.ts b/dashboard/api/src/functions/getTestRunMetrics.ts index 95557d45b..fef3a990c 100644 --- a/dashboard/api/src/functions/getTestRunMetrics.ts +++ b/dashboard/api/src/functions/getTestRunMetrics.ts @@ -2,6 +2,7 @@ import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/fu import { TableClient } from "@azure/data-tables"; import { AzureCliCredential, ManagedIdentityCredential } from "@azure/identity"; import { logRequestIdentity } from "../requestIdentity"; +import { resolveSkillFilter } from "../blobEnumerator"; const STORAGE_ACCOUNT_NAME = process.env.STORAGE_ACCOUNT_NAME; const TOKEN_USAGE_TABLE_NAME = process.env.TOKEN_USAGE_TABLE_NAME; @@ -28,6 +29,17 @@ function odataLiteral(value: string): string { return value.replace(/'/g, "''"); } +/** + * Build an OData clause that matches any skill in the set, e.g. + * `(skill eq 'a' or skill eq 'b')`. Returns undefined for an empty set. + */ +function skillSetClause(skills: Set): string | undefined { + if (skills.size === 0) { + return undefined; + } + return `(${[...skills].map((s) => `skill eq '${odataLiteral(s)}'`).join(" or ")})`; +} + /** * Returns integration-test run metrics rows from the table. * GET /api/test-run-metrics @@ -42,14 +54,30 @@ async function getTestRunMetrics(request: HttpRequest, context: InvocationContex const filterSkill = request.query.get("skill") || undefined; const filterTest = request.query.get("test") || undefined; const filterBranch = request.query.get("branch") || undefined; + const filterPlugin = request.query.get("plugin") || undefined; try { const tableClient = getTestRunMetricsTableClient(); + // When a plugin is specified, restrict to its skills. An unknown plugin + // resolves to an empty set, which means no rows can match. + const skillFilter = await resolveSkillFilter(filterPlugin); + if (skillFilter && skillFilter.size === 0) { + return { + status: 200, + headers: { "Content-Type": "application/json" }, + body: "[]", + }; + } + const filters: string[] = []; if (filterSkill) filters.push(`skill eq '${odataLiteral(filterSkill)}'`); if (filterTest) filters.push(`testName eq '${odataLiteral(filterTest)}'`); if (filterBranch) filters.push(`branch eq '${odataLiteral(filterBranch)}'`); + if (skillFilter) { + const clause = skillSetClause(skillFilter); + if (clause) filters.push(clause); + } const filter = filters.length > 0 ? filters.join(" and ") : undefined; const listOptions = filter ? { queryOptions: { filter } } : {}; @@ -98,6 +126,8 @@ async function getTestRunMetricsFilters(request: HttpRequest, context: Invocatio try { const tableClient = getTestRunMetricsTableClient(); + // When a plugin is specified, only surface filter values for its skills. + const skillFilter = await resolveSkillFilter(request.query.get("plugin") || undefined); const skills = new Set(); const tests = new Set(); const branches = new Set(); @@ -106,6 +136,9 @@ async function getTestRunMetricsFilters(request: HttpRequest, context: Invocatio for await (const entity of tableClient.listEntities({ queryOptions: { select: ["skill", "testName", "branch"] }, })) { + if (skillFilter && (!entity.skill || !skillFilter.has(entity.skill as string))) { + continue; + } if (entity.skill) skills.add(entity.skill as string); if (entity.testName) tests.add(entity.testName as string); if (entity.branch) branches.add(entity.branch as string); diff --git a/dashboard/api/src/functions/getToolUsage.ts b/dashboard/api/src/functions/getToolUsage.ts index 1580a83e3..581756557 100644 --- a/dashboard/api/src/functions/getToolUsage.ts +++ b/dashboard/api/src/functions/getToolUsage.ts @@ -2,6 +2,7 @@ import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/fu import { TableClient } from "@azure/data-tables"; import { AzureCliCredential, ManagedIdentityCredential } from "@azure/identity"; import { logRequestIdentity } from "../requestIdentity"; +import { resolveSkillFilter } from "../blobEnumerator"; const STORAGE_ACCOUNT_NAME = process.env.STORAGE_ACCOUNT_NAME; const TOOL_USAGE_TABLE_NAME = process.env.TOOL_USAGE_TABLE_NAME; @@ -71,21 +72,39 @@ async function getToolUsage(request: HttpRequest, context: InvocationContext): P runDate: request.query.get("runDate") || undefined, }); + // When a plugin is specified, restrict to its skills. An unknown plugin + // resolves to an empty set, which means no rows can match. + const skillFilter = await resolveSkillFilter(request.query.get("plugin") || undefined); + if (skillFilter && skillFilter.size === 0) { + return { + status: 200, + headers: { "Content-Type": "application/json" }, + body: "[]", + }; + } + const pluginClause = skillFilter + ? `(${[...skillFilter].map((s) => `skill eq '${odataLiteral(s)}'`).join(" or ")})` + : undefined; + + // Combine the base filters with the plugin clause. A plugin selection alone + // is enough to satisfy the "at least one filter" requirement below. + const combinedFilter = [filter, pluginClause].filter(Boolean).join(" and ") || undefined; + // Require at least one filter. An unfiltered scan of the one-row-per-tool-call // table can be very large and risks timeouts / excessive storage reads. - if (!filter) { + if (!combinedFilter) { return { status: 400, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - error: "At least one filter is required: skill, test, branch, runId, runToken, or runDate.", + error: "At least one filter is required: plugin, skill, test, branch, runId, runToken, or runDate.", }), }; } try { const tableClient = getToolUsageTableClient(); - const listOptions = { queryOptions: { filter } }; + const listOptions = { queryOptions: { filter: combinedFilter } }; const entities: Record[] = []; for await (const entity of tableClient.listEntities(listOptions)) { diff --git a/dashboard/assets/dashboard.js b/dashboard/assets/dashboard.js index 635fb3f73..fb5477b54 100644 --- a/dashboard/assets/dashboard.js +++ b/dashboard/assets/dashboard.js @@ -7,23 +7,20 @@ /** @type {Record void>} */ const panelRenderers = {}; -const AVAILABLE_PLUGINS = ["azure-skills", "cat"]; const PLUGIN_SESSION_STORAGE_KEY = "dashboard.selectedPlugin"; +const PLUGIN_SKILLS_SESSION_STORAGE_KEY = "dashboard.pluginSkills"; function getPersistedPluginSelection() { try { - const persisted = window.sessionStorage.getItem(PLUGIN_SESSION_STORAGE_KEY); - if (persisted && AVAILABLE_PLUGINS.includes(persisted)) { - return persisted; - } + return window.sessionStorage.getItem(PLUGIN_SESSION_STORAGE_KEY) || ""; } catch { - // Ignore unavailable sessionStorage and fall back to the default. + // Ignore unavailable sessionStorage and fall back to no selection. + return ""; } - return AVAILABLE_PLUGINS[0]; } function persistPluginSelection(plugin) { - if (!AVAILABLE_PLUGINS.includes(plugin)) return; + if (!plugin) return; try { window.sessionStorage.setItem(PLUGIN_SESSION_STORAGE_KEY, plugin); } catch { @@ -31,13 +28,79 @@ function persistPluginSelection(plugin) { } } -function initPluginSelector() { +function getCachedPluginSkills() { + try { + const raw = window.sessionStorage.getItem(PLUGIN_SKILLS_SESSION_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +/** + * Append the persisted plugin selection as a `plugin` query param so plugin-scoped + * endpoints filter their data to the selected plugin's skills. + * @param {string} path + * @returns {string} + */ +function withPlugin(path) { + const plugin = getPersistedPluginSelection(); + if (!plugin) return path; + const separator = path.includes("?") ? "&" : "?"; + return path + separator + "plugin=" + encodeURIComponent(plugin); +} + +async function fetchPluginSkills() { + const cached = getCachedPluginSkills(); + if (cached) return cached; + + try { + const res = await fetch("/api/plugins"); + if (!res.ok) return {}; + const data = await res.json(); + return data; + } catch { + return {}; + } +} + +/** + * Fetch the sorted list of available plugin directory names. + * @returns {Promise} + */ +async function fetchAvailablePlugins() { + const pluginSkills = await fetchPluginSkills(); + return Object.keys(pluginSkills.plugins).sort(); +} + +async function initPluginSelector() { const select = document.getElementById("plugin-select"); if (!select) return; - select.value = getPersistedPluginSelection(); + const plugins = await fetchAvailablePlugins(); + + select.textContent = ""; + for (const plugin of plugins) { + const option = document.createElement("option"); + option.value = plugin; + option.textContent = plugin; + select.appendChild(option); + } + + const persisted = getPersistedPluginSelection(); + if (persisted && plugins.includes(persisted)) { + select.value = persisted; + } else if (plugins.length > 0) { + select.value = plugins[0]; + persistPluginSelection(plugins[0]); + } + select.addEventListener("change", function () { persistPluginSelection(select.value); + // Reload so every panel re-fetches its data for the newly selected plugin. + window.location.reload(); }); } @@ -1032,7 +1095,7 @@ let _latestTestResultsPromise = null; function fetchLatestTestResults() { if (_latestTestResultsPromise) return _latestTestResultsPromise; _latestTestResultsPromise = (async () => { - const datesRes = await fetch("/api/dates"); + const datesRes = await fetch(withPlugin("/api/dates")); if (!datesRes.ok) throw new Error("HTTP " + datesRes.status); const dates = await datesRes.json(); if (!Array.isArray(dates) || dates.length === 0) { @@ -1040,7 +1103,7 @@ function fetchLatestTestResults() { } const latestDate = dates[0]; const resultsRes = await fetch( - "/api/test-results/" + encodeURIComponent(latestDate), + withPlugin("/api/test-results/" + encodeURIComponent(latestDate)), ); if (!resultsRes.ok) throw new Error("HTTP " + resultsRes.status); const skillResults = await resultsRes.json(); @@ -1834,12 +1897,19 @@ async function init() { } } -document.addEventListener("DOMContentLoaded", function () { - initPluginSelector(); - init(); - loadSkillInvocationRates(); - loadE2EPassRates(); - loadConfidenceLevelPerSkill(); - loadDeployScenarioRetries(); - loadIntegrationTestTokenUsage(); +document.addEventListener("DOMContentLoaded", async function () { + // Populate the plugin selector (and cache the plugin-container map) before + // kicking off the data loads, so their API calls can include the selected + // plugin's container fallback on the very first page load. + try { + await initPluginSelector(); + } catch { } + finally { + init(); + loadSkillInvocationRates(); + loadE2EPassRates(); + loadConfidenceLevelPerSkill(); + loadDeployScenarioRetries(); + loadIntegrationTestTokenUsage(); + } }); diff --git a/dashboard/index.html b/dashboard/index.html index 2075862ec..c153f720c 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -17,9 +17,9 @@ @@ -36,10 +36,7 @@

Repository Health Dashboard

diff --git a/dashboard/integration-tests.html b/dashboard/integration-tests.html index b5104f0e3..bee02fd48 100644 --- a/dashboard/integration-tests.html +++ b/dashboard/integration-tests.html @@ -16,9 +16,9 @@ diff --git a/dashboard/msbench-nightly-runs.html b/dashboard/msbench-nightly-runs.html index 2cbc8e2b2..1a523b59f 100644 --- a/dashboard/msbench-nightly-runs.html +++ b/dashboard/msbench-nightly-runs.html @@ -16,9 +16,9 @@ @@ -32,4 +32,4 @@ - + \ No newline at end of file diff --git a/dashboard/nightly-runs.html b/dashboard/nightly-runs.html index 505ebb46a..c4decf77d 100644 --- a/dashboard/nightly-runs.html +++ b/dashboard/nightly-runs.html @@ -16,9 +16,9 @@ diff --git a/dashboard/performance-dashboard.html b/dashboard/performance-dashboard.html index f7d7def70..38890f100 100644 --- a/dashboard/performance-dashboard.html +++ b/dashboard/performance-dashboard.html @@ -16,10 +16,11 @@ @@ -32,4 +33,4 @@ - + \ No newline at end of file diff --git a/dashboard/skills.html b/dashboard/skills.html index 2610b37f7..f2aeacd23 100644 --- a/dashboard/skills.html +++ b/dashboard/skills.html @@ -16,9 +16,9 @@ @@ -32,4 +32,4 @@ - + \ No newline at end of file diff --git a/dashboard/src/integration-tests/App.tsx b/dashboard/src/integration-tests/App.tsx index 5180abc91..897c1130d 100644 --- a/dashboard/src/integration-tests/App.tsx +++ b/dashboard/src/integration-tests/App.tsx @@ -260,7 +260,7 @@ function App() { }) .catch((err) => setError(err.message)) .finally(() => setLoadingDates(false)); - }, []); + }, [selectedPlugin]); // Fetch test results when a date is selected useEffect(() => { @@ -279,7 +279,7 @@ function App() { .then((data: SkillTestResults) => setTestResults(data)) .catch((err) => setError(err.message)) .finally(() => setLoadingResults(false)); - }, [selectedDate]); + }, [selectedDate, selectedPlugin]); if (loadingDates) { return

Loading…

; diff --git a/dashboard/src/nightly-runs/App.tsx b/dashboard/src/nightly-runs/App.tsx index 9a567088f..6154acf73 100644 --- a/dashboard/src/nightly-runs/App.tsx +++ b/dashboard/src/nightly-runs/App.tsx @@ -94,7 +94,7 @@ function Dashboard() { }) .catch((err) => setError(err.message)) .finally(() => setLoadingDates(false)); - }, []); + }, [selectedPlugin]); // Fetch data and reports when a date is selected useEffect(() => { @@ -128,7 +128,7 @@ function Dashboard() { .then((md) => setReportMarkdown(md)) .catch((err) => setReportMarkdown(`*Error loading reports: ${err.message}*`)) .finally(() => setLoadingReport(false)); - }, [selectedDate]); + }, [selectedDate, selectedPlugin]); const handleDownload = useCallback((blobName: string) => { const viewerUrl = pageUrl(`${window.location.pathname}?file=${encodeURIComponent(blobName)}`); diff --git a/dashboard/src/shared/PluginSelector.tsx b/dashboard/src/shared/PluginSelector.tsx index 289c1370f..75cbb0808 100644 --- a/dashboard/src/shared/PluginSelector.tsx +++ b/dashboard/src/shared/PluginSelector.tsx @@ -1,30 +1,96 @@ -import type { ChangeEvent } from "react"; +import { type PluginSkills } from "../../api/src/blobEnumerator"; +import { useEffect, useState, type ChangeEvent } from "react"; +import { apiUrl } from "./apiUrl"; -export const AVAILABLE_PLUGINS = ["azure-skills", "cat"]; export const PLUGIN_SESSION_STORAGE_KEY = "dashboard.selectedPlugin"; +export const PLUGIN_SKILLS_SESSION_STORAGE_KEY = "dashboard.pluginSkills"; -export function getPersistedPluginSelection(): string { +function getCachedPluginSkills(): PluginSkills | null { if (typeof window === "undefined") { - return AVAILABLE_PLUGINS[0]; + return null; } try { - const persisted = window.sessionStorage.getItem(PLUGIN_SESSION_STORAGE_KEY); - if (persisted && AVAILABLE_PLUGINS.includes(persisted)) { - return persisted; - } + const raw = window.sessionStorage.getItem(PLUGIN_SKILLS_SESSION_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + return parsed; } catch { - // Ignore unavailable sessionStorage and fall back to the default. + return null; } - - return AVAILABLE_PLUGINS[0]; } -export function persistPluginSelection(plugin: string): void { - if (typeof window === "undefined" || !AVAILABLE_PLUGINS.includes(plugin)) { +function cachePluginSkills(data: PluginSkills): void { + if (typeof window === "undefined") { return; } + try { + window.sessionStorage.setItem(PLUGIN_SKILLS_SESSION_STORAGE_KEY, JSON.stringify(data)); + } catch { + // Ignore unavailable sessionStorage; the map will simply be re-fetched. + } +} + +/** + * Concurrent callers (e.g. the selector and a skills view mounting together) + * share a single in-flight request so the underlying API is only hit once. + */ +let inFlightPluginSkills: Promise | null = null; + +async function fetchPluginSkills(): Promise { + const cached = getCachedPluginSkills(); + if (cached) { + return cached; + } + + if (inFlightPluginSkills) { + return inFlightPluginSkills; + } + + inFlightPluginSkills = (async () => { + const res = await fetch(apiUrl("/api/plugins")); + if (!res.ok) throw new Error(`API error: ${res.status}`); + const data = (await res.json()) as PluginSkills; + cachePluginSkills(data); + return data; + })(); + + try { + return await inFlightPluginSkills; + } finally { + inFlightPluginSkills = null; + } +} + +/** + * Fetch the sorted list of available plugin directory names (keys of the + * plugin-container map). + */ +export async function fetchAvailablePlugins(): Promise { + const pluginSkills = await fetchPluginSkills(); + return Object.keys(pluginSkills?.plugins ?? []).sort(); +} + +/** + * Fetch the list of skill names that belong to the given plugin. Returns an + * empty array when the plugin is unknown or no selection is provided. + */ +export async function fetchSkillsForPlugin(plugin: string): Promise { + const pluginSkills = await fetchPluginSkills(); + return pluginSkills?.plugins?.[plugin] ?? []; +} + +export function getPersistedPluginSelection(): string { + try { + return window.sessionStorage.getItem(PLUGIN_SESSION_STORAGE_KEY) ?? ""; + } catch { + // Ignore unavailable sessionStorage and fall back to no selection. + return ""; + } +} + +export function persistPluginSelection(plugin: string): void { try { window.sessionStorage.setItem(PLUGIN_SESSION_STORAGE_KEY, plugin); } catch { @@ -38,12 +104,45 @@ interface PluginSelectorProps { } export default function PluginSelector({ selectedPlugin, onChange }: PluginSelectorProps) { + const [plugins, setPlugins] = useState([]); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const list = await fetchAvailablePlugins(); + if (cancelled) return; + setPlugins(list); + // Ensure the active selection is valid for the fetched list, + // preferring the persisted value before the first plugin. + if (list.length > 0 && !list.includes(selectedPlugin)) { + const persisted = getPersistedPluginSelection(); + const next = list.includes(persisted) ? persisted : list[0]; + persistPluginSelection(next); + onChange(next); + } + } catch { + // Leave the list empty; the selector renders in a disabled state. + } + }; + load(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const handleChange = (event: ChangeEvent) => { const plugin = event.target.value; persistPluginSelection(plugin); onChange(plugin); }; + // While the list loads, keep the persisted selection selectable so the + // controlled diff --git a/dashboard/src/shared/apiUrl.ts b/dashboard/src/shared/apiUrl.ts index 6ca453577..b25a99c41 100644 --- a/dashboard/src/shared/apiUrl.ts +++ b/dashboard/src/shared/apiUrl.ts @@ -1,13 +1,19 @@ +import { getPersistedPluginSelection } from "./PluginSelector"; + /** * Builds an API URL by merging the page's current query parameters into the * given path. Path-level params take precedence over page-level params, so * explicit caller values are never overwritten. * + * The persisted plugin selection is appended as `plugin` (unless the caller or + * page already specified one) so plugin-scoped endpoints filter their data to + * the selected plugin's skills. + * * Example: page URL is /?container=abc, path is /api/dates - * → returns /api/dates?container=abc + * → returns /api/dates?container=abc&plugin=azure-skills * * Example: page URL is /?container=abc, path is /api/test-run-metrics?skill=foo - * → returns /api/test-run-metrics?container=abc&skill=foo + * → returns /api/test-run-metrics?container=abc&skill=foo&plugin=azure-skills */ export function apiUrl(path: string): string { const qIdx = path.indexOf("?"); @@ -23,6 +29,14 @@ export function apiUrl(path: string): string { } } + // Scope requests to the selected plugin unless one was already specified. + if (!params.has("plugin")) { + const plugin = getPersistedPluginSelection(); + if (plugin) { + params.set("plugin", plugin); + } + } + const qs = params.toString(); return qs ? `${base}?${qs}` : base; } diff --git a/dashboard/src/skills/App.tsx b/dashboard/src/skills/App.tsx index 28cf2a64a..028320116 100644 --- a/dashboard/src/skills/App.tsx +++ b/dashboard/src/skills/App.tsx @@ -8,7 +8,10 @@ import { ResponsiveContainer, } from "recharts"; import { apiUrl } from "../shared/apiUrl"; -import PluginSelector, { getPersistedPluginSelection } from "../shared/PluginSelector"; +import PluginSelector, { + getPersistedPluginSelection, + fetchSkillsForPlugin, +} from "../shared/PluginSelector"; import { issuesUrl } from "./issuesUrl"; import { buildDaySeries, @@ -206,24 +209,40 @@ export default function App() { // Load the list of plugin skills and honour a ?skill= deep link. useEffect(() => { - fetch(apiUrl("/api/static")) - .then((res) => { + let cancelled = false; + Promise.all([ + fetch(apiUrl("/api/static")).then((res) => { if (!res.ok) throw new Error(`API error: ${res.status}`); return res.json() as Promise; - }) - .then((data) => { - const list = skillsFromHealthData(data); + }), + fetchSkillsForPlugin(selectedPlugin), + ]) + .then(([data, pluginSkills]) => { + if (cancelled) return; + const allowed = new Set(pluginSkills); + const list = skillsFromHealthData(data).filter((s) => + allowed.has(s.name), + ); setSkills(list); const deepLink = new URLSearchParams(window.location.search).get("skill"); if (deepLink && list.some((s) => s.name === deepLink)) { setSelected(deepLink); } else if (list.length > 0) { setSelected(list[0].name); + } else { + setSelected(""); } }) - .catch((err) => setSkillsError(err.message)) - .finally(() => setSkillsLoading(false)); - }, []); + .catch((err) => { + if (!cancelled) setSkillsError(err.message); + }) + .finally(() => { + if (!cancelled) setSkillsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [selectedPlugin]); // Load per-test metrics for the selected skill (main branch only). useEffect(() => { @@ -249,7 +268,7 @@ export default function App() { if (!controller.signal.aborted) setRowsLoading(false); }); return () => controller.abort(); - }, [selected]); + }, [selected, selectedPlugin]); const selectedSkill = useMemo( () => skills.find((s) => s.name === selected), diff --git a/dashboard/token-usage.html b/dashboard/token-usage.html index 377e0cdf7..c082f5c0f 100644 --- a/dashboard/token-usage.html +++ b/dashboard/token-usage.html @@ -16,9 +16,9 @@ @@ -32,4 +32,4 @@ - + \ No newline at end of file diff --git a/plugin-skills.json b/plugin-skills.json new file mode 100644 index 000000000..c116d8847 --- /dev/null +++ b/plugin-skills.json @@ -0,0 +1,35 @@ +{ + "plugins": { + "azure-skills": [ + "airunway-aks-setup", + "appinsights-instrumentation", + "azure-ai", + "azure-aigateway", + "azure-cloud-migrate", + "azure-compliance", + "azure-compute", + "azure-cost", + "azure-deploy", + "azure-diagnostics", + "azure-enterprise-infra-planner", + "azure-kubernetes", + "azure-kusto", + "azure-messaging", + "azure-prepare", + "azure-quotas", + "azure-reliability", + "azure-resource-lookup", + "azure-resource-visualizer", + "azure-storage", + "azure-upgrade", + "azure-validate", + "entra-agent-id", + "entra-app-registration", + "microsoft-foundry", + "python-appservice-deploy" + ], + "test-plugin": [ + "entra-app-registration" + ] + } +} \ No newline at end of file From 14c7e891efa08e3d2589b703235966da1f39f822 Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Thu, 23 Jul 2026 16:37:47 -0700 Subject: [PATCH 3/8] remove plugin skills caching --- dashboard/assets/dashboard.js | 15 ----------- dashboard/src/shared/PluginSelector.tsx | 34 ------------------------- 2 files changed, 49 deletions(-) diff --git a/dashboard/assets/dashboard.js b/dashboard/assets/dashboard.js index fb5477b54..f8d0ab396 100644 --- a/dashboard/assets/dashboard.js +++ b/dashboard/assets/dashboard.js @@ -8,7 +8,6 @@ const panelRenderers = {}; const PLUGIN_SESSION_STORAGE_KEY = "dashboard.selectedPlugin"; -const PLUGIN_SKILLS_SESSION_STORAGE_KEY = "dashboard.pluginSkills"; function getPersistedPluginSelection() { try { @@ -28,17 +27,6 @@ function persistPluginSelection(plugin) { } } -function getCachedPluginSkills() { - try { - const raw = window.sessionStorage.getItem(PLUGIN_SKILLS_SESSION_STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed : null; - } catch { - return null; - } -} - /** * Append the persisted plugin selection as a `plugin` query param so plugin-scoped * endpoints filter their data to the selected plugin's skills. @@ -53,9 +41,6 @@ function withPlugin(path) { } async function fetchPluginSkills() { - const cached = getCachedPluginSkills(); - if (cached) return cached; - try { const res = await fetch("/api/plugins"); if (!res.ok) return {}; diff --git a/dashboard/src/shared/PluginSelector.tsx b/dashboard/src/shared/PluginSelector.tsx index 75cbb0808..820f8cde7 100644 --- a/dashboard/src/shared/PluginSelector.tsx +++ b/dashboard/src/shared/PluginSelector.tsx @@ -3,34 +3,6 @@ import { useEffect, useState, type ChangeEvent } from "react"; import { apiUrl } from "./apiUrl"; export const PLUGIN_SESSION_STORAGE_KEY = "dashboard.selectedPlugin"; -export const PLUGIN_SKILLS_SESSION_STORAGE_KEY = "dashboard.pluginSkills"; - -function getCachedPluginSkills(): PluginSkills | null { - if (typeof window === "undefined") { - return null; - } - - try { - const raw = window.sessionStorage.getItem(PLUGIN_SKILLS_SESSION_STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw); - return parsed; - } catch { - return null; - } -} - -function cachePluginSkills(data: PluginSkills): void { - if (typeof window === "undefined") { - return; - } - - try { - window.sessionStorage.setItem(PLUGIN_SKILLS_SESSION_STORAGE_KEY, JSON.stringify(data)); - } catch { - // Ignore unavailable sessionStorage; the map will simply be re-fetched. - } -} /** * Concurrent callers (e.g. the selector and a skills view mounting together) @@ -39,11 +11,6 @@ function cachePluginSkills(data: PluginSkills): void { let inFlightPluginSkills: Promise | null = null; async function fetchPluginSkills(): Promise { - const cached = getCachedPluginSkills(); - if (cached) { - return cached; - } - if (inFlightPluginSkills) { return inFlightPluginSkills; } @@ -52,7 +19,6 @@ async function fetchPluginSkills(): Promise { const res = await fetch(apiUrl("/api/plugins")); if (!res.ok) throw new Error(`API error: ${res.status}`); const data = (await res.json()) as PluginSkills; - cachePluginSkills(data); return data; })(); From f2718d19838b4086a299fa1954d0f48fd3158434 Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Thu, 23 Jul 2026 16:40:08 -0700 Subject: [PATCH 4/8] fix error message --- dashboard/api/src/functions/getPlugins.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dashboard/api/src/functions/getPlugins.ts b/dashboard/api/src/functions/getPlugins.ts index 31351799c..4fce87b57 100644 --- a/dashboard/api/src/functions/getPlugins.ts +++ b/dashboard/api/src/functions/getPlugins.ts @@ -13,8 +13,8 @@ async function getPluginsHandler(request: HttpRequest, context: InvocationContex jsonBody: pluginSkills, }; } catch (err) { - context.error("Failed to read plugin containers:", err); - return { status: 500, body: "Failed to read plugin containers" }; + context.error("Failed to read plugins:", err); + return { status: 500, body: "Failed to read plugins" }; } } From 59c420e66fdbc609f8e525b108542a7789904d2a Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Thu, 23 Jul 2026 16:44:19 -0700 Subject: [PATCH 5/8] refine async code style --- dashboard/src/skills/App.tsx | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/dashboard/src/skills/App.tsx b/dashboard/src/skills/App.tsx index 028320116..a493dfc81 100644 --- a/dashboard/src/skills/App.tsx +++ b/dashboard/src/skills/App.tsx @@ -210,14 +210,16 @@ export default function App() { // Load the list of plugin skills and honour a ?skill= deep link. useEffect(() => { let cancelled = false; - Promise.all([ - fetch(apiUrl("/api/static")).then((res) => { - if (!res.ok) throw new Error(`API error: ${res.status}`); - return res.json() as Promise; - }), - fetchSkillsForPlugin(selectedPlugin), - ]) - .then(([data, pluginSkills]) => { + let load = async () => { + try { + const [data, pluginSkills] = await Promise.all([ + fetch(apiUrl("/api/static")).then((res) => { + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json() as Promise; + }), + fetchSkillsForPlugin(selectedPlugin), + ]); + if (cancelled) return; const allowed = new Set(pluginSkills); const list = skillsFromHealthData(data).filter((s) => @@ -232,13 +234,13 @@ export default function App() { } else { setSelected(""); } - }) - .catch((err) => { - if (!cancelled) setSkillsError(err.message); - }) - .finally(() => { + } catch (err) { + if (!cancelled) setSkillsError(err instanceof Error ? err.message : String(err)); + } finally { if (!cancelled) setSkillsLoading(false); - }); + } + }; + load(); return () => { cancelled = true; }; From ef23b6ce75f2f3adcc95d437664ea141810236fc Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Fri, 24 Jul 2026 11:31:52 -0700 Subject: [PATCH 6/8] refactor to avoid cyclic module dependency --- dashboard/src/integration-tests/App.tsx | 4 +- dashboard/src/nightly-runs/App.tsx | 4 +- dashboard/src/performance-dashboard/App.tsx | 4 +- dashboard/src/shared/PluginSelector.tsx | 66 +-------------------- dashboard/src/shared/apiUrl.ts | 21 ++++++- dashboard/src/shared/plugins.ts | 45 ++++++++++++++ dashboard/src/skills/App.tsx | 8 +-- 7 files changed, 75 insertions(+), 77 deletions(-) create mode 100644 dashboard/src/shared/plugins.ts diff --git a/dashboard/src/integration-tests/App.tsx b/dashboard/src/integration-tests/App.tsx index 897c1130d..dd5169b0e 100644 --- a/dashboard/src/integration-tests/App.tsx +++ b/dashboard/src/integration-tests/App.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import type { BlobTree, BlobTreeNode } from "../shared/blobTree"; -import { apiUrl, pageUrl } from "../shared/apiUrl"; -import PluginSelector, { getPersistedPluginSelection } from "../shared/PluginSelector"; +import { apiUrl, getPersistedPluginSelection, pageUrl } from "../shared/apiUrl"; +import PluginSelector from "../shared/PluginSelector"; interface TestCase { testName: string; diff --git a/dashboard/src/nightly-runs/App.tsx b/dashboard/src/nightly-runs/App.tsx index 6154acf73..55fb051f1 100644 --- a/dashboard/src/nightly-runs/App.tsx +++ b/dashboard/src/nightly-runs/App.tsx @@ -3,8 +3,8 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import FileViewer from "./FileViewer"; import type { BlobEntry, BlobTree, BlobTreeNode } from "../shared/blobTree"; -import { apiUrl, pageUrl } from "../shared/apiUrl"; -import PluginSelector, { getPersistedPluginSelection } from "../shared/PluginSelector"; +import { apiUrl, getPersistedPluginSelection, pageUrl } from "../shared/apiUrl"; +import PluginSelector from "../shared/PluginSelector"; interface FileSection { label: string; diff --git a/dashboard/src/performance-dashboard/App.tsx b/dashboard/src/performance-dashboard/App.tsx index 709b43dac..57719f6ca 100644 --- a/dashboard/src/performance-dashboard/App.tsx +++ b/dashboard/src/performance-dashboard/App.tsx @@ -8,8 +8,8 @@ import { Tooltip, ResponsiveContainer, } from "recharts"; -import { apiUrl } from "../shared/apiUrl"; -import PluginSelector, { getPersistedPluginSelection } from "../shared/PluginSelector"; +import { apiUrl, getPersistedPluginSelection } from "../shared/apiUrl"; +import PluginSelector from "../shared/PluginSelector"; interface EvalMetricRow { date: string; diff --git a/dashboard/src/shared/PluginSelector.tsx b/dashboard/src/shared/PluginSelector.tsx index 820f8cde7..2cb2cdb54 100644 --- a/dashboard/src/shared/PluginSelector.tsx +++ b/dashboard/src/shared/PluginSelector.tsx @@ -1,68 +1,6 @@ -import { type PluginSkills } from "../../api/src/blobEnumerator"; import { useEffect, useState, type ChangeEvent } from "react"; -import { apiUrl } from "./apiUrl"; - -export const PLUGIN_SESSION_STORAGE_KEY = "dashboard.selectedPlugin"; - -/** - * Concurrent callers (e.g. the selector and a skills view mounting together) - * share a single in-flight request so the underlying API is only hit once. - */ -let inFlightPluginSkills: Promise | null = null; - -async function fetchPluginSkills(): Promise { - if (inFlightPluginSkills) { - return inFlightPluginSkills; - } - - inFlightPluginSkills = (async () => { - const res = await fetch(apiUrl("/api/plugins")); - if (!res.ok) throw new Error(`API error: ${res.status}`); - const data = (await res.json()) as PluginSkills; - return data; - })(); - - try { - return await inFlightPluginSkills; - } finally { - inFlightPluginSkills = null; - } -} - -/** - * Fetch the sorted list of available plugin directory names (keys of the - * plugin-container map). - */ -export async function fetchAvailablePlugins(): Promise { - const pluginSkills = await fetchPluginSkills(); - return Object.keys(pluginSkills?.plugins ?? []).sort(); -} - -/** - * Fetch the list of skill names that belong to the given plugin. Returns an - * empty array when the plugin is unknown or no selection is provided. - */ -export async function fetchSkillsForPlugin(plugin: string): Promise { - const pluginSkills = await fetchPluginSkills(); - return pluginSkills?.plugins?.[plugin] ?? []; -} - -export function getPersistedPluginSelection(): string { - try { - return window.sessionStorage.getItem(PLUGIN_SESSION_STORAGE_KEY) ?? ""; - } catch { - // Ignore unavailable sessionStorage and fall back to no selection. - return ""; - } -} - -export function persistPluginSelection(plugin: string): void { - try { - window.sessionStorage.setItem(PLUGIN_SESSION_STORAGE_KEY, plugin); - } catch { - // Ignore unavailable sessionStorage; the UI can still function locally. - } -} +import { fetchAvailablePlugins } from "./plugins"; +import { getPersistedPluginSelection, persistPluginSelection } from "./apiUrl"; interface PluginSelectorProps { selectedPlugin: string; diff --git a/dashboard/src/shared/apiUrl.ts b/dashboard/src/shared/apiUrl.ts index b25a99c41..64a3041ca 100644 --- a/dashboard/src/shared/apiUrl.ts +++ b/dashboard/src/shared/apiUrl.ts @@ -1,5 +1,3 @@ -import { getPersistedPluginSelection } from "./PluginSelector"; - /** * Builds an API URL by merging the page's current query parameters into the * given path. Path-level params take precedence over page-level params, so @@ -60,3 +58,22 @@ export function pageUrl(path: string): string { const separator = pathWithoutFragment.includes("?") ? "&" : "?"; return `${pathWithoutFragment}${separator}container=${encodeURIComponent(container)}${fragment}`; } + +export const PLUGIN_SESSION_STORAGE_KEY = "dashboard.selectedPlugin"; + +export function getPersistedPluginSelection(): string { + try { + return window.sessionStorage.getItem(PLUGIN_SESSION_STORAGE_KEY) ?? ""; + } catch { + // Ignore unavailable sessionStorage and fall back to no selection. + return ""; + } +} + +export function persistPluginSelection(plugin: string): void { + try { + window.sessionStorage.setItem(PLUGIN_SESSION_STORAGE_KEY, plugin); + } catch { + // Ignore unavailable sessionStorage; the UI can still function locally. + } +} diff --git a/dashboard/src/shared/plugins.ts b/dashboard/src/shared/plugins.ts new file mode 100644 index 000000000..4b47fbc6f --- /dev/null +++ b/dashboard/src/shared/plugins.ts @@ -0,0 +1,45 @@ +import { type PluginSkills } from "../../api/src/blobEnumerator"; +import { apiUrl } from "./apiUrl"; + +/** + * Concurrent callers (e.g. the selector and a skills view mounting together) + * share a single in-flight request so the underlying API is only hit once. + */ +let inFlightPluginSkills: Promise | null = null; + +async function fetchPluginSkills(): Promise { + if (inFlightPluginSkills) { + return inFlightPluginSkills; + } + + inFlightPluginSkills = (async () => { + const res = await fetch(apiUrl("/api/plugins")); + if (!res.ok) throw new Error(`API error: ${res.status}`); + const data = (await res.json()) as PluginSkills; + return data; + })(); + + try { + return await inFlightPluginSkills; + } finally { + inFlightPluginSkills = null; + } +} + +/** + * Fetch the sorted list of available plugin directory names (keys of the + * plugin-container map). + */ +export async function fetchAvailablePlugins(): Promise { + const pluginSkills = await fetchPluginSkills(); + return Object.keys(pluginSkills?.plugins ?? []).sort(); +} + +/** + * Fetch the list of skill names that belong to the given plugin. Returns an + * empty array when the plugin is unknown or no selection is provided. + */ +export async function fetchSkillsForPlugin(plugin: string): Promise { + const pluginSkills = await fetchPluginSkills(); + return pluginSkills?.plugins?.[plugin] ?? []; +} \ No newline at end of file diff --git a/dashboard/src/skills/App.tsx b/dashboard/src/skills/App.tsx index a493dfc81..6140ad687 100644 --- a/dashboard/src/skills/App.tsx +++ b/dashboard/src/skills/App.tsx @@ -7,11 +7,8 @@ import { Tooltip, ResponsiveContainer, } from "recharts"; -import { apiUrl } from "../shared/apiUrl"; -import PluginSelector, { - getPersistedPluginSelection, - fetchSkillsForPlugin, -} from "../shared/PluginSelector"; +import { apiUrl, getPersistedPluginSelection } from "../shared/apiUrl"; +import PluginSelector from "../shared/PluginSelector"; import { issuesUrl } from "./issuesUrl"; import { buildDaySeries, @@ -21,6 +18,7 @@ import { type MetricKey, type MetricsRow, } from "./metrics"; +import { fetchSkillsForPlugin } from "../shared/plugins"; /** Number of trailing days shown in every graph. */ const WINDOW_DAYS = 10; From e4aaf1f905b0f5dd7927fa5284716e354ec60da4 Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Fri, 24 Jul 2026 11:35:34 -0700 Subject: [PATCH 7/8] fix stale metrics request --- dashboard/src/skills/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/skills/App.tsx b/dashboard/src/skills/App.tsx index 6140ad687..d2f474981 100644 --- a/dashboard/src/skills/App.tsx +++ b/dashboard/src/skills/App.tsx @@ -268,7 +268,7 @@ export default function App() { if (!controller.signal.aborted) setRowsLoading(false); }); return () => controller.abort(); - }, [selected, selectedPlugin]); + }, [selected]); const selectedSkill = useMemo( () => skills.find((s) => s.name === selected), From 7b75e5aa28c1345443974e7ebdbe3732571650b9 Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Tue, 28 Jul 2026 15:05:48 -0700 Subject: [PATCH 8/8] chore: fix integration test result publishing --- .github/workflows/test-all-integration.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-all-integration.yml b/.github/workflows/test-all-integration.yml index fffd125d4..97e69df6e 100644 --- a/.github/workflows/test-all-integration.yml +++ b/.github/workflows/test-all-integration.yml @@ -320,7 +320,7 @@ jobs: fi - name: Generate quality report - if: always() && matrix.skill == 'microsoft-foundry' + if: always() && matrix.skill == 'azure-skills/microsoft-foundry' run: npm run quality-report || true # Note: The managed identity must have write permission to the target storage account @@ -335,7 +335,8 @@ jobs: SKILL: ${{ matrix.skill }} run: | DATE=$(date -u +%Y-%m-%d) - PREFIX="${DATE}/${RUN_ID}/${SKILL}/" + SKILL_NAME="${SKILL##*/}" + PREFIX="${DATE}/${RUN_ID}/${SKILL_NAME}/" echo "Uploading reports to ${STORAGE_ACCOUNT}/${STORAGE_CONTAINER}/${PREFIX}" az storage blob upload-batch \ --account-name "$STORAGE_ACCOUNT" \ @@ -358,7 +359,8 @@ jobs: SKILL: ${{ matrix.skill }} run: | DATE=$(date -u +%Y-%m-%d) - PREFIX="${DATE}/${RUN_ID}/${SKILL}/" + SKILL_NAME="${SKILL##*/}" + PREFIX="${DATE}/${RUN_ID}/${SKILL_NAME}/" echo "Uploading reports to ${STORAGE_ACCOUNT}/${STORAGE_CONTAINER}/${PREFIX}" az storage blob upload-batch \ --account-name "$STORAGE_ACCOUNT" \