diff --git a/ui/src/components/suite-detail/RunsHeatmap.tsx b/ui/src/components/suite-detail/RunsHeatmap.tsx index 75e0ab630..5bc19c476 100644 --- a/ui/src/components/suite-detail/RunsHeatmap.tsx +++ b/ui/src/components/suite-detail/RunsHeatmap.tsx @@ -94,14 +94,14 @@ export type MetricMode = 'duration' | 'mgas' interface RunsHeatmapProps { runs: IndexEntry[] - /** When set, runs are grouped by this label key (or 'instance_id') before client grouping. */ - groupBy?: string + /** When set, runs are grouped by these label keys (or 'instance_id') before client grouping. Multiple keys form a composite group. */ + groupBy?: string[] /** Returns the URL for the per-group compare button, or undefined to render it inert. */ getCompareGroupHref?: (runs: IndexEntry[]) => string | undefined /** Returns the URL for comparing a client's latest successful run across groups. */ getCompareClientAcrossGroupsHref?: (client: string) => string | undefined - /** Returns the URL for the averaged group-vs-group comparison page. */ - getGroupCompareGroupHref?: (groupLabel: string, clients: string[]) => string | undefined + /** Returns the URL for the averaged group-vs-group comparison page. groupMetadata maps each grouped label key to this group's value (instance_id excluded, as it isn't a metadata filter). */ + getGroupCompareGroupHref?: (groupMetadata: Record, clients: string[]) => string | undefined /** Returns the URL for a client's averaged comparison across label groups. */ getGroupCompareClientAcrossGroupsHref?: (client: string) => string | undefined isDark: boolean @@ -117,6 +117,9 @@ interface RunsHeatmapProps { interface GroupSection { label: string + // Per-key values for the compare-URL builders (instance_id excluded — it is + // not a metadata filter). Empty when grouping only by instance_id. + metadata: Record clients: string[] clientRuns: Record clientDurationStats: Record @@ -131,6 +134,21 @@ interface TooltipData { y: number } +// runGroup computes a run's composite group across the selected keys: a display +// label of `key=value` pairs, plus a metadata map (instance_id excluded — it is +// not a metadata filter) for the group-compare URL builders. +function runGroup(run: IndexEntry, keys: string[]): { label: string; metadata: Record } { + const metadata: Record = {} + const parts = keys.map((key) => { + const value = key === 'instance_id' ? run.instance.id : (run.metadata?.[key] ?? '(none)') + if (key !== 'instance_id') metadata[key] = value + + return `${key}=${value}` + }) + + return { label: parts.join(', '), metadata } +} + export function RunsHeatmap({ runs, groupBy, @@ -255,18 +273,18 @@ export function RunsHeatmap({ // When groupBy is set, split runs into sections by label value const groupSections: GroupSection[] | null = useMemo(() => { - if (!groupBy) return null + if (!groupBy || groupBy.length === 0) return null - // Partition runs by group value + // Partition runs by their composite group value (across all selected keys). const grouped = new Map() + const groupMeta = new Map>() for (const run of runs) { - const value = groupBy === 'instance_id' - ? run.instance.id - : (run.metadata?.[groupBy] ?? '(none)') - let list = grouped.get(value) + const g = runGroup(run, groupBy) + let list = grouped.get(g.label) if (!list) { list = [] - grouped.set(value, list) + grouped.set(g.label, list) + groupMeta.set(g.label, g.metadata) } list.push(run) } @@ -321,6 +339,7 @@ export function RunsHeatmap({ sections.push({ label, + metadata: groupMeta.get(label) ?? {}, clients: Object.keys(sectionClientRuns).sort(), clientRuns: sectionClientRuns, clientDurationStats: sectionDurationStats, @@ -458,14 +477,12 @@ export function RunsHeatmap({ )} - {(groupSections ?? [{ label: '', clients, clientRuns, clientDurationStats, clientMgasStats, clientDurationScales, clientMgasScales }]).map((section, sectionIdx) => ( + {(groupSections ?? [{ label: '', metadata: {}, clients, clientRuns, clientDurationStats, clientMgasStats, clientDurationScales, clientMgasScales }]).map((section, sectionIdx) => (
0 && 'mt-4')}> {section.label && (
- {groupBy} - = {section.label} {getCompareGroupHref && ( @@ -479,7 +496,7 @@ export function RunsHeatmap({ )} {getGroupCompareGroupHref && ( diff --git a/ui/src/pages/SuiteDetailPage.tsx b/ui/src/pages/SuiteDetailPage.tsx index b8d700bf5..ae67dc8ea 100644 --- a/ui/src/pages/SuiteDetailPage.tsx +++ b/ui/src/pages/SuiteDetailPage.tsx @@ -59,6 +59,34 @@ function serializeStepFilter(steps: IndexStepType[]): string | undefined { return steps.join(',') } +// Group-by keys. The `groupBy` URL param is: unset = all label keys (the +// default), 'none' = no grouping, otherwise a comma-separated list of keys. +// allKeys is the set of available label keys (excludes the special +// 'instance_id' option, which is only ever present when explicitly selected). +function parseGroupByKeys(param: string | undefined, allKeys: string[]): string[] { + if (param === undefined) return allKeys + if (param === 'none') return [] + const valid = new Set([...allKeys, 'instance_id']) + return param.split(',').map((k) => k.trim()).filter((k) => valid.has(k)) +} + +// Serialize group-by keys to the URL param: undefined when it equals the +// all-labels default, 'none' when empty, otherwise the comma-joined keys. +function serializeGroupByKeys(keys: string[], allKeys: string[]): string | undefined { + if (keys.length === 0) return 'none' + const keySet = new Set(keys) + if (keys.length === allKeys.length && allKeys.every((k) => keySet.has(k))) { + return undefined + } + return keys.join(',') +} + +// groupValueForRun resolves a single group key to a run's value: the instance +// id for the special 'instance_id' key, otherwise the metadata label value. +function groupValueForRun(run: IndexEntry, key: string): string { + return key === 'instance_id' ? run.instance.id : (run.metadata?.[key] ?? '(none)') +} + // --------------------------------------------------------------------------- // ChartFilters — client toggles + label chip filters for the Run Charts // --------------------------------------------------------------------------- @@ -440,31 +468,28 @@ export function SuiteDetailPage() { return Array.from(keys).sort() }, [suiteRunsAll]) - // Effective group-by: when groupBy is unset in the URL, auto-default to the - // first available label key. The sentinel 'none' means the user explicitly - // chose no grouping. - const effectiveGroupBy = useMemo(() => { - if (groupBy === 'none') return undefined - if (groupBy !== undefined) return groupBy - return groupByLabelKeys[0] - }, [groupBy, groupByLabelKeys]) + // Effective group-by keys: when groupBy is unset in the URL, default to ALL + // available label keys; 'none' means the user chose no grouping. Filtered to + // keys actually present in the data. + const effectiveGroupByKeys = useMemo( + () => parseGroupByKeys(groupBy, groupByLabelKeys), + [groupBy, groupByLabelKeys], + ) - // Apply grouping: transforms client name to include the group suffix + // Apply grouping: transforms client name to include the composite group suffix + // (`key=value` per selected key, joined). const applyGrouping = useCallback((runs: IndexEntry[]): IndexEntry[] => { - if (!effectiveGroupBy) return runs + if (effectiveGroupByKeys.length === 0) return runs return runs.map((run) => { - let suffix: string - if (effectiveGroupBy === 'instance_id') { - suffix = run.instance.id - } else { - suffix = run.metadata?.[effectiveGroupBy] ?? '(none)' - } + const suffix = effectiveGroupByKeys + .map((key) => `${key}=${groupValueForRun(run, key)}`) + .join(', ') return { ...run, instance: { ...run.instance, client: `${run.instance.client} / ${suffix}` }, } }) - }, [effectiveGroupBy]) + }, [effectiveGroupByKeys]) const groupedRunsAll = useMemo(() => applyGrouping(suiteRunsAll), [suiteRunsAll, applyGrouping]) @@ -854,14 +879,22 @@ export function SuiteDetailPage() { }) } - const handleGroupByChange = (key: string | undefined) => { + const setGroupBy = (param: string | undefined) => { navigate({ to: '/suites/$suiteHash', params: { suiteHash }, - search: { tab, client, image, status, sortBy, sortDir, chartMode, chartPassingOnly: chartPassingOnlyParam, heatmapColor, steps: serializeStepFilter(stepFilter), labels: serializeLabelFilters(labelFilters), groupBy: key }, + search: { tab, client, image, status, sortBy, sortDir, chartMode, chartPassingOnly: chartPassingOnlyParam, heatmapColor, steps: serializeStepFilter(stepFilter), labels: serializeLabelFilters(labelFilters), groupBy: param }, }) } + // Toggle a single key in/out of the current group-by selection. + const handleGroupByToggle = (key: string) => { + const next = effectiveGroupByKeys.includes(key) + ? effectiveGroupByKeys.filter((k) => k !== key) + : [...effectiveGroupByKeys, key] + setGroupBy(serializeGroupByKeys(next, groupByLabelKeys)) + } + const handleStepFilterChange = (steps: IndexStepType[]) => { navigate({ to: '/suites/$suiteHash', @@ -1024,40 +1057,32 @@ export function SuiteDetailPage() { {groupByLabelKeys.length > 0 && (
Group by: -
+
- {groupByLabelKeys.map((key) => ( + {[...groupByLabelKeys, 'instance_id'].map((key) => ( ))} -
)} @@ -1117,8 +1142,8 @@ export function SuiteDetailPage() {
{ + groupBy={effectiveGroupByKeys} + getCompareGroupHref={effectiveGroupByKeys.length > 0 ? (groupRuns) => { const sorted = [...groupRuns].sort((a, b) => b.timestamp - a.timestamp) const seen = new Set() const ids: string[] = [] @@ -1136,8 +1161,8 @@ export function SuiteDetailPage() { if (ids.length < MIN_COMPARE_RUNS) return undefined return `/compare?runs=${encodeURIComponent(ids.join(','))}` } : undefined} - getCompareClientAcrossGroupsHref={effectiveGroupBy ? (client) => { - // Find the latest successful run for this client in each group + getCompareClientAcrossGroupsHref={effectiveGroupByKeys.length > 0 ? (client) => { + // Find the latest successful run for this client in each composite group const sorted = [...suiteRunsAll] .filter((r) => r.instance.client === client) .sort((a, b) => b.timestamp - a.timestamp) @@ -1146,9 +1171,7 @@ export function SuiteDetailPage() { for (const run of sorted) { // Skip live runs — can't compare in-progress runs. if (run.status === 'running') continue - const groupValue = effectiveGroupBy === 'instance_id' - ? run.instance.id - : (run.metadata?.[effectiveGroupBy] ?? '(none)') + const groupValue = effectiveGroupByKeys.map((k) => groupValueForRun(run, k)).join(', ') if (seenGroups.has(groupValue)) continue if (run.tests.tests_total > 0 && run.tests.tests_passed === run.tests.tests_total) { seenGroups.add(groupValue) @@ -1157,28 +1180,29 @@ export function SuiteDetailPage() { if (ids.length >= MAX_COMPARE_RUNS) break } if (ids.length < MIN_COMPARE_RUNS) return undefined - const labels = effectiveGroupBy === 'instance_id' ? 'instance-id' : `label:${effectiveGroupBy}` + // `labels` is a single-key run-label display mode on the compare page; + // use the first label key (or instance-id when that's all there is). + const labelKey = effectiveGroupByKeys.find((k) => k !== 'instance_id') + const labels = labelKey ? `label:${labelKey}` : 'instance-id' return `/compare?runs=${encodeURIComponent(ids.join(','))}&labels=${encodeURIComponent(labels)}` } : undefined} - getGroupCompareGroupHref={effectiveGroupBy ? (groupLabel, groupClients) => { - const labelFilter = effectiveGroupBy !== 'instance_id' ? `${effectiveGroupBy}=${groupLabel}` : '' - const groups = groupClients.map((c) => `${c}:${labelFilter}`).join(';') + getGroupCompareGroupHref={effectiveGroupByKeys.length > 0 ? (groupMetadata, groupClients) => { + // groupMetadata is the group's key=value pairs (instance_id excluded); + // empty (instance_id-only) yields a bare `client:` filter. + const filter = Object.entries(groupMetadata).map(([k, v]) => `${k}=${v}`).join(',') + const groups = groupClients.map((c) => `${c}:${filter}`).join(';') return `/compare/groups?suite=${encodeURIComponent(suiteHash)}&groups=${encodeURIComponent(groups)}` } : undefined} - getGroupCompareClientAcrossGroupsHref={effectiveGroupBy ? (client) => { - // Build one group per label-value for this client. - const labelValues = new Set() + getGroupCompareClientAcrossGroupsHref={effectiveGroupByKeys.length > 0 ? (client) => { + // Build one group per distinct label-value combination for this client + // (instance_id can't be expressed as a metadata filter, so it's excluded). + const metaKeys = effectiveGroupByKeys.filter((k) => k !== 'instance_id') + const filters = new Set() for (const run of suiteRunsAll) { if (run.instance.client !== client) continue - const val = effectiveGroupBy === 'instance_id' - ? run.instance.id - : (run.metadata?.[effectiveGroupBy] ?? '') - if (val) labelValues.add(val) + filters.add(metaKeys.map((k) => `${k}=${run.metadata?.[k] ?? '(none)'}`).join(',')) } - const groups = [...labelValues].sort().map((val) => { - if (effectiveGroupBy === 'instance_id') return `${client}:` - return `${client}:${effectiveGroupBy}=${val}` - }).join(';') + const groups = [...filters].sort().map((filter) => `${client}:${filter}`).join(';') return `/compare/groups?suite=${encodeURIComponent(suiteHash)}&groups=${encodeURIComponent(groups)}` } : undefined} isDark={isDark}