Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: internal
packages:
- "@typespec/spec-dashboard"
---

Add coverage overview component and emitter display name support
4 changes: 4 additions & 0 deletions packages/spec-dashboard/src/apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export interface CoverageFromAzureStorageOptions {
readonly tables?: TableDefinition[];
/** Optional tier config to filter scenarios by tier */
readonly tiers?: TierConfig;
/** Show coverage overview cards at the top of the dashboard */
readonly showOverview?: boolean;
/** Optional friendly display names for emitters. Key is the emitter package name, value is the display name. */
readonly emitterDisplayNames?: Readonly<Record<string, string>>;
}

export interface GeneratorCoverageSuiteReport extends CoverageReport {
Expand Down
182 changes: 182 additions & 0 deletions packages/spec-dashboard/src/components/coverage-overview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { Card, Text, tokens } from "@fluentui/react-components";
import { FunctionComponent, useMemo } from "react";
import { CoverageSummary } from "../apis.js";
import { GroupRatioColors, GroupRatios } from "../constants.js";

interface EmitterOverview {
name: string;
displayName: string;
coverageRatio: number;
}

export interface CoverageOverviewProps {
coverageSummaries: CoverageSummary[];
emitterDisplayNames?: Record<string, string>;
}

/**
* Extracts a display-friendly name from a full emitter package name.
* e.g. "@typespec/http-client-python" → "Python"
*/
function getEmitterDisplayName(
emitterName: string,
report: CoverageSummary["generatorReports"][string],
emitterDisplayNames?: Record<string, string>,
): string {
if (emitterDisplayNames?.[emitterName]) {
return emitterDisplayNames[emitterName];
}
if (report?.generatorMetadata?.name) {
return report.generatorMetadata.name;
}
// Strip common prefix patterns
const match = emitterName.match(/http-client-(\w+)$/);
if (match) {
return match[1].charAt(0).toUpperCase() + match[1].slice(1);
}
return emitterName;
}

/**
* Gets the accent color for a coverage ratio using the same thresholds as the coverage tables.
*/
function getOverviewColor(ratio: number): string {
for (const [key, threshold] of Object.entries(GroupRatios)) {
if (ratio >= threshold) {
return GroupRatioColors[key as keyof typeof GroupRatios];
}
}
return GroupRatioColors.zero;
}

/**
* Displays a section with a grid of cards showing per-emitter coverage overview.
*/
export const CoverageOverview: FunctionComponent<CoverageOverviewProps> = ({
coverageSummaries,
emitterDisplayNames,
}) => {
const emitterOverviews = useMemo(() => {
// Aggregate scenarios per emitter across all summaries
const emitterMap = new Map<
string,
{
totalScenarios: number;
coveredScenarios: number;
report: CoverageSummary["generatorReports"][string];
}
>();

for (const summary of coverageSummaries) {
for (const [emitterName, report] of Object.entries(summary.generatorReports)) {
if (!emitterMap.has(emitterName)) {
emitterMap.set(emitterName, { totalScenarios: 0, coveredScenarios: 0, report });
}
const entry = emitterMap.get(emitterName)!;
const scenarios = summary.manifest.scenarios;
entry.totalScenarios += scenarios.length;
if (report) {
for (const scenario of scenarios) {
const status = report.results[scenario.name];
if (status === "pass" || status === "not-applicable" || status === "not-supported") {
entry.coveredScenarios++;
}
}
}
}
}

const overviews: EmitterOverview[] = [];
for (const [emitterName, data] of emitterMap) {
overviews.push({
name: emitterName,
displayName: getEmitterDisplayName(emitterName, data.report, emitterDisplayNames),
coverageRatio: data.totalScenarios > 0 ? data.coveredScenarios / data.totalScenarios : 0,
});
}

return overviews;
}, [coverageSummaries, emitterDisplayNames]);

if (emitterOverviews.length === 0) {
return null;
}

return (
<section
css={{
marginBottom: 32,
padding: "20px 24px",
backgroundColor: tokens.colorNeutralBackground2,
borderRadius: tokens.borderRadiusXLarge,
border: `1px solid ${tokens.colorNeutralStroke2}`,
}}
>
<Text
as="h2"
weight="semibold"
size={500}
css={{
display: "block",
marginBottom: 16,
color: tokens.colorNeutralForeground1,
}}
>
Coverage Overview
</Text>
<div
css={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
gap: 16,
}}
>
{emitterOverviews.map((emitter) => (
<EmitterOverviewCard key={emitter.name} emitter={emitter} />
))}
</div>
</section>
);
};

interface EmitterOverviewCardProps {
emitter: EmitterOverview;
}

const EmitterOverviewCard: FunctionComponent<EmitterOverviewCardProps> = ({ emitter }) => {
const accentColor = getOverviewColor(emitter.coverageRatio);
const percentage = Math.floor(emitter.coverageRatio * 100);

return (
<Card
css={{
backgroundColor: tokens.colorNeutralBackground1,
borderTop: `3px solid ${accentColor}`,
padding: "16px 16px 20px",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 4,
minHeight: 100,
}}
>
<Text
weight="semibold"
size={300}
css={{
color: tokens.colorNeutralForeground2,
minHeight: `calc(${tokens.lineHeightBase300})`,
display: "flex",
alignItems: "center",
textAlign: "center",
}}
>
{emitter.displayName}
</Text>
<Text weight="bold" size={800} css={{ color: accentColor }}>
{percentage}%
</Text>
</Card>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export const DashboardFromAzureStorage = (props: DashboardFromAzureStorageProps)
<Dashboard
coverageSummaries={coverageSummaries}
scenarioTierConfig={props.options.tiers}
showOverview={props.options.showOverview}
emitterDisplayNames={props.options.emitterDisplayNames}
></Dashboard>
) : (
"Loading"
Expand Down
64 changes: 27 additions & 37 deletions packages/spec-dashboard/src/components/dashboard-table.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { css } from "@emotion/react";
import { Popover, PopoverSurface, PopoverTrigger, tokens } from "@fluentui/react-components";
import { CodeBlock16Filled, Print16Filled } from "@fluentui/react-icons";
import { ScenarioData, ScenarioManifest } from "@typespec/spec-coverage-sdk";
import { ScenarioManifest } from "@typespec/spec-coverage-sdk";
import { FunctionComponent, useCallback, useMemo, useState } from "react";
import { CoverageSummary, GeneratorCoverageSuiteReport } from "../apis.js";
import { Colors } from "../constants.js";
import { getCompletedRatio } from "../utils/coverage-utils.js";
import { GeneratorInformation } from "./generator-information.js";
import { ScenarioGroupRatioStatusBox } from "./scenario-group-status.js";
import { ScenarioStatusBox } from "./scenario-status.js";
Expand All @@ -13,6 +14,7 @@ import { ManifestTreeNode, TreeTableRow } from "./tree-table/types.js";

export interface DashboardTableProps {
coverageSummary: CoverageSummary;
emitterDisplayNames?: Record<string, string>;
}

function buildTreeRows(
Expand Down Expand Up @@ -52,7 +54,10 @@ function buildTreeRows(
return rows;
}

export const DashboardTable: FunctionComponent<DashboardTableProps> = ({ coverageSummary }) => {
export const DashboardTable: FunctionComponent<DashboardTableProps> = ({
coverageSummary,
emitterDisplayNames,
}) => {
const languages: string[] = Object.keys(coverageSummary.generatorReports) as any;
const tree = useMemo(() => createTree(coverageSummary.manifest), [coverageSummary.manifest]);

Expand All @@ -78,7 +83,10 @@ export const DashboardTable: FunctionComponent<DashboardTableProps> = ({ coverag
return (
<table css={TableStyles}>
<thead>
<DashboardHeaderRow coverageSummary={coverageSummary} />
<DashboardHeaderRow
coverageSummary={coverageSummary}
emitterDisplayNames={emitterDisplayNames}
/>
</thead>
<tbody>{rows}</tbody>
</table>
Expand Down Expand Up @@ -134,28 +142,15 @@ const ScenarioGroupStatusBox: FunctionComponent<ScenarioGroupStatusBoxProps> = (
return <ScenarioGroupRatioStatusBox ratio={ratio} />;
};

function getCompletedRatio(
scenarios: ScenarioData[],
report: GeneratorCoverageSuiteReport,
scope: string = "",
) {
const filtered = scenarios.filter((x) => x.name.startsWith(scope));
let coveredCount = 0;
for (const scenario of filtered) {
const status = report.results[scenario.name];
if (status === "pass" || status === "not-applicable" || status === "not-supported") {
coveredCount++;
}
}

return coveredCount / filtered.length;
}

interface DashboardHeaderRowProps {
coverageSummary: CoverageSummary;
emitterDisplayNames?: Record<string, string>;
}

const DashboardHeaderRow: FunctionComponent<DashboardHeaderRowProps> = ({ coverageSummary }) => {
const DashboardHeaderRow: FunctionComponent<DashboardHeaderRowProps> = ({
coverageSummary,
emitterDisplayNames,
}) => {
const data: [string, number, GeneratorCoverageSuiteReport | undefined][] = Object.entries(
coverageSummary.generatorReports,
).map(([language, report]) => {
Expand All @@ -171,7 +166,13 @@ const DashboardHeaderRow: FunctionComponent<DashboardHeaderRowProps> = ({ covera
<tr>
{tableHeader}
{data.map(([lang, status, report]) => (
<GeneratorHeaderCell key={lang} status={status} report={report} language={lang} />
<GeneratorHeaderCell
key={lang}
status={status}
report={report}
language={lang}
displayName={emitterDisplayNames?.[lang as string]}
/>
))}
</tr>
);
Expand Down Expand Up @@ -200,12 +201,14 @@ export interface GeneratorHeaderCellProps {
status: number;
report: GeneratorCoverageSuiteReport | undefined;
language: string;
displayName?: string;
}

export const GeneratorHeaderCell: FunctionComponent<GeneratorHeaderCellProps> = ({
status,
report,
language,
displayName,
}) => {
return (
<th css={{ padding: "0 !important" }}>
Expand Down Expand Up @@ -238,7 +241,7 @@ export const GeneratorHeaderCell: FunctionComponent<GeneratorHeaderCellProps> =
>
<Popover withArrow>
<PopoverTrigger>
<div>{report?.generatorMetadata?.name ?? language}</div>
<div>{displayName ?? report?.generatorMetadata?.name ?? language}</div>
</PopoverTrigger>
<PopoverSurface>
{report && <GeneratorInformation status={status} report={report} />}
Expand Down Expand Up @@ -320,18 +323,5 @@ function createTree(manifest: ScenarioManifest): ManifestTreeNode {
current.scenario = scenario;
}

return cutTillMultipleChildren(root);
}

function cutTillMultipleChildren(node: ManifestTreeNode): ManifestTreeNode {
let newRoot: ManifestTreeNode = node;
while (newRoot.children) {
if (Object.keys(newRoot.children).length === 1) {
newRoot = Object.values(newRoot.children)[0];
} else {
break;
}
}

return newRoot;
return root;
}
18 changes: 17 additions & 1 deletion packages/spec-dashboard/src/components/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,25 @@ import { FunctionComponent, useState } from "react";
import { CoverageSummary } from "../apis.js";
import { useTierFiltering } from "../hooks/use-tier-filtering.js";
import { TierConfig } from "../utils/tier-filtering-utils.js";
import { CoverageOverview } from "./coverage-overview.js";
import { DashboardTable } from "./dashboard-table.js";
import { InfoEntry, InfoReport } from "./info-table.js";
import { TierFilterTabs } from "./tier-filter.js";

export interface DashboardProps {
coverageSummaries: CoverageSummary[];
scenarioTierConfig?: TierConfig;
/** Show coverage overview cards at the top of the dashboard */
showOverview?: boolean;
/** Optional friendly display names for emitters. Key is the emitter package name. */
emitterDisplayNames?: Record<string, string>;
}

export const Dashboard: FunctionComponent<DashboardProps> = ({
coverageSummaries,
scenarioTierConfig,
showOverview,
emitterDisplayNames,
}) => {
const [selectedTier, setSelectedTier] = useState<string | undefined>(undefined);

Expand All @@ -28,7 +35,10 @@ export const Dashboard: FunctionComponent<DashboardProps> = ({
.filter((s) => !selectedTier || s.manifest.scenarios.length > 0)
.map((coverageSummary, i) => (
<div key={i} css={{ margin: 5 }}>
<DashboardTable coverageSummary={coverageSummary} />
<DashboardTable
coverageSummary={coverageSummary}
emitterDisplayNames={emitterDisplayNames}
/>
</div>
));

Expand All @@ -45,6 +55,12 @@ export const Dashboard: FunctionComponent<DashboardProps> = ({
selectedTier={selectedTier}
setSelectedTier={setSelectedTier}
/>
{showOverview && (
<CoverageOverview
coverageSummaries={filteredSummaries}
emitterDisplayNames={emitterDisplayNames}
/>
)}
<div css={{ display: "flex" }}>{specsCardTable}</div>
<div css={{ height: 30 }}></div>
{summaryTables}
Expand Down
Loading
Loading