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" \ 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..4fce87b57 --- /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 plugins:", err); + return { status: 500, body: "Failed to read plugins" }; + } +} + +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 6c70ded82..f8d0ab396 100644 --- a/dashboard/assets/dashboard.js +++ b/dashboard/assets/dashboard.js @@ -7,6 +7,88 @@ /** @type {Record void>} */ const panelRenderers = {}; +const PLUGIN_SESSION_STORAGE_KEY = "dashboard.selectedPlugin"; + +function getPersistedPluginSelection() { + try { + return window.sessionStorage.getItem(PLUGIN_SESSION_STORAGE_KEY) || ""; + } catch { + // Ignore unavailable sessionStorage and fall back to no selection. + return ""; + } +} + +function persistPluginSelection(plugin) { + if (!plugin) return; + try { + window.sessionStorage.setItem(PLUGIN_SESSION_STORAGE_KEY, plugin); + } catch { + // Ignore unavailable sessionStorage; the UI can still function locally. + } +} + +/** + * 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() { + 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; + + 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(); + }); +} + // ── Thresholds ────────────────────────────────────────────────────────────── /** Minimum passing rate for skill invocation tests (0–1). */ @@ -998,7 +1080,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) { @@ -1006,7 +1088,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(); @@ -1265,7 +1347,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 +1502,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 +1676,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"; @@ -1800,11 +1882,19 @@ async function init() { } } -document.addEventListener("DOMContentLoaded", function () { - 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/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..c153f720c 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -17,9 +17,9 @@ @@ -32,6 +32,15 @@

Repository Health Dashboard

+
+
+ +
+
+
@@ -90,7 +99,8 @@

Deploy Scenario Retries

Loading...

-
+

Integration Test Average Token Usage

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 fa05d6719..dd5169b0e 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 { apiUrl, getPersistedPluginSelection, pageUrl } from "../shared/apiUrl"; +import PluginSelector 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); @@ -258,7 +260,7 @@ function App() { }) .catch((err) => setError(err.message)) .finally(() => setLoadingDates(false)); - }, []); + }, [selectedPlugin]); // Fetch test results when a date is selected useEffect(() => { @@ -277,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…

; @@ -297,6 +299,11 @@ function App() {

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

+ +
{/* Left panel - date list */}
+ ); +} \ No newline at end of file diff --git a/dashboard/src/shared/apiUrl.ts b/dashboard/src/shared/apiUrl.ts index 6ca453577..64a3041ca 100644 --- a/dashboard/src/shared/apiUrl.ts +++ b/dashboard/src/shared/apiUrl.ts @@ -3,11 +3,15 @@ * 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 +27,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; } @@ -46,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/plugin-selector.css b/dashboard/src/shared/plugin-selector.css new file mode 100644 index 000000000..6ddf7c558 --- /dev/null +++ b/dashboard/src/shared/plugin-selector.css @@ -0,0 +1,58 @@ +.plugin-toolbar { + padding: 0.875rem 1.5rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); +} + +.plugin-toolbar__content { + display: flex; + align-items: center; + gap: 1rem; +} + +.plugin-toolbar__field { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.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; +} + +@media (max-width: 640px) { + .plugin-toolbar { + padding: 0.875rem 1rem; + } + + .plugin-toolbar__field { + width: 100%; + flex-direction: column; + align-items: stretch; + gap: 0.4rem; + } + + .plugin-toolbar__select { + min-width: 0; + width: 100%; + } +} \ No newline at end of file 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 144b13b34..d2f474981 100644 --- a/dashboard/src/skills/App.tsx +++ b/dashboard/src/skills/App.tsx @@ -7,7 +7,8 @@ import { Tooltip, ResponsiveContainer, } from "recharts"; -import { apiUrl } from "../shared/apiUrl"; +import { apiUrl, getPersistedPluginSelection } from "../shared/apiUrl"; +import PluginSelector from "../shared/PluginSelector"; import { issuesUrl } from "./issuesUrl"; import { buildDaySeries, @@ -17,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; @@ -194,6 +196,7 @@ function TestGraphs({ testName, rows }: { testName: string; rows: MetricsRow[] } } export default function App() { + const [selectedPlugin, setSelectedPlugin] = useState(getPersistedPluginSelection); const [skills, setSkills] = useState([]); const [selected, setSelected] = useState(""); const [rows, setRows] = useState([]); @@ -204,24 +207,42 @@ export default function App() { // Load the list of plugin skills and honour a ?skill= deep link. useEffect(() => { - 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); + let cancelled = false; + 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) => + 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 instanceof Error ? err.message : String(err)); + } finally { + if (!cancelled) setSkillsLoading(false); + } + }; + load(); + return () => { + cancelled = true; + }; + }, [selectedPlugin]); // Load per-test metrics for the selected skill (main branch only). useEffect(() => { @@ -267,96 +288,103 @@ export default function App() { }; return ( -
- +
+ + +
+ -
- {!selectedSkill && !skillsLoading && ( -

Select a skill to see details.

- )} - {selectedSkill && ( - <> -
-

{selectedSkill.name}

-

- {selectedSkill.description || No description.} -

-

- Description length: {selectedSkill.descriptionLength} characters -

-

- Files: {selectedSkill.fileCount} -

- {selectedSkillMdUrl && ( -

+

+ {!selectedSkill && !skillsLoading && ( +

Select a skill to see details.

+ )} + {selectedSkill && ( + <> +
+

{selectedSkill.name}

+

+ {selectedSkill.description || No description.} +

+

+ Description length: {selectedSkill.descriptionLength} characters +

+

+ Files: {selectedSkill.fileCount} +

+ {selectedSkillMdUrl && ( +

+ + View SKILL.md + +

+ )} +

- View SKILL.md + View open issues for {selectedSkill.name} ↗

- )} -

- - View open issues for {selectedSkill.name} ↗ - -

-

- - View telemetry ↗ - -

-
+

+ + View telemetry ↗ + +

+
-

- Tests — last {WINDOW_DAYS} days (main) -

- {rowsLoading &&

Loading metrics…

} - {rowsError &&

{rowsError}

} - {!rowsLoading && !rowsError && byTest.size === 0 && ( -

No test runs found for this skill.

- )} - {[...byTest.entries()].map(([testName, testRows]) => ( - - ))} - - )} -
+

+ Tests — last {WINDOW_DAYS} days (main) +

+ {rowsLoading &&

Loading metrics…

} + {rowsError &&

{rowsError}

} + {!rowsLoading && !rowsError && byTest.size === 0 && ( +

No test runs found for this skill.

+ )} + {[...byTest.entries()].map(([testName, testRows]) => ( + + ))} + + )} +
+ ); } 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( 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