From 60d066ca1ad2775d4396fd719a4d0e2f3f240a25 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Wed, 17 Jun 2026 09:55:43 -0300 Subject: [PATCH] fix: dashboard fixes Signed-off-by: Gustavo Carvalho --- .../__tests__/useDashboardSuggestions.spec.ts | 37 ++++++ src/composables/useDashboardSuggestions.ts | 55 ++++++-- src/views/dashboard/SuggestionsView.vue | 119 ++++++++++++------ .../__tests__/SuggestionsView.spec.ts | 65 +++++++++- .../partials/SuggestionScopeDialog.vue | 5 +- .../__tests__/SuggestionScopeDialog.spec.ts | 17 +++ .../partials/dashboard-suggestions.ts | 35 ++++-- 7 files changed, 271 insertions(+), 62 deletions(-) diff --git a/src/composables/__tests__/useDashboardSuggestions.spec.ts b/src/composables/__tests__/useDashboardSuggestions.spec.ts index ab46decb..5e273f81 100644 --- a/src/composables/__tests__/useDashboardSuggestions.spec.ts +++ b/src/composables/__tests__/useDashboardSuggestions.spec.ts @@ -76,6 +76,43 @@ describe('useSuggestionRunPoller', () => { scope.stop(); }); + it('surfaces failure details when a completed run reports failed cells', async () => { + mocks.execute.mockResolvedValueOnce({ + data: { + value: { + data: { + id: 'run-1', + status: 'completed', + plannedCalls: 2, + completedCells: 1, + failedCells: 1, + stats: { + failedCells: [{ cellIndex: 2, error: 'model timed out' }], + }, + }, + }, + }, + }); + + const scope = effectScope(); + const poller = scope.run(() => useSuggestionRunPoller(ref('ssp-1'))); + + await poller?.pollLatest(); + + expect(mocks.toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'warn', + summary: '1 suggestion failed', + detail: 'Cell 2: model timed out', + }), + ); + expect(mocks.toastAdd).not.toHaveBeenCalledWith( + expect.objectContaining({ summary: 'Suggestions ready' }), + ); + + scope.stop(); + }); + it('keeps polling after a transient poll error', async () => { mocks.execute .mockRejectedValueOnce(new Error('temporary outage')) diff --git a/src/composables/useDashboardSuggestions.ts b/src/composables/useDashboardSuggestions.ts index 42e0e191..8fbe9231 100644 --- a/src/composables/useDashboardSuggestions.ts +++ b/src/composables/useDashboardSuggestions.ts @@ -25,6 +25,7 @@ import { type GenerateDashboardSuggestionsPayload, type SuggestionRun, isRunActive, + runCellFailures, } from '@/views/dashboard/partials/dashboard-suggestions'; export function useDashboardSuggestions( @@ -72,7 +73,11 @@ export function useDashboardSuggestions( return fetchPendingSuggestions( buildDashboardSuggestionsEndpoint(sspId.value, 'pending'), - { camelcaseStopPaths: ['data.labelSet'] }, + // Preserve raw label keys (e.g. `_policy`) on both the originating label + // set and the proposed filter; otherwise camelcase conversion strips `_`. + { + camelcaseStopPaths: ['data.labelSet', 'data.proposedFilterLabelSet'], + }, ); } @@ -132,7 +137,9 @@ export function useDashboardSuggestions( for (const status of statuses) { const response = await fetchHistoryRequest( buildDashboardSuggestionsEndpoint(sspId.value, status), - { camelcaseStopPaths: ['data.labelSet'] }, + { + camelcaseStopPaths: ['data.labelSet', 'data.proposedFilterLabelSet'], + }, ); collected.push(...(response?.data.value?.data ?? [])); } @@ -192,6 +199,28 @@ export function useDashboardSuggestions( }; } +// Builds a human-readable detail string for a run that reported failures, +// preferring per-cell error messages (from `stats.failedCells`) over the +// generic top-level error. +function formatRunFailureDetail(run: SuggestionRun): string { + const failures = runCellFailures(run); + if (failures.length === 0) { + return run.error ?? 'Dashboard suggestion generation failed.'; + } + + const shown = failures.slice(0, 3).map((failure) => { + const label = + failure.cellIndex === undefined ? 'Cell' : `Cell ${failure.cellIndex}`; + return `${label}: ${failure.error ?? 'Failed'}`; + }); + + const remaining = failures.length - shown.length; + if (remaining > 0) { + shown.push(`and ${remaining} more`); + } + return shown.join('; '); +} + interface SuggestionRunPollerOptions { stopOnPollError?: boolean; } @@ -257,20 +286,26 @@ export function useSuggestionRunPoller( } terminalToastShownFor.value = runKey; - if (latestRun.status === 'completed') { + const hasFailures = + latestRun.status === 'failed' || (latestRun.failedCells ?? 0) > 0; + + if (hasFailures) { + toast.add({ + severity: latestRun.status === 'failed' ? 'error' : 'warn', + summary: + latestRun.status === 'failed' + ? 'Generation failed' + : `${latestRun.failedCells} suggestion${latestRun.failedCells === 1 ? '' : 's'} failed`, + detail: formatRunFailureDetail(latestRun), + life: 8000, + }); + } else if (latestRun.status === 'completed') { toast.add({ severity: 'success', summary: 'Suggestions ready', detail: `${latestRun.completedCells} cells completed`, life: 3000, }); - } else if (latestRun.status === 'failed') { - toast.add({ - severity: 'error', - summary: 'Generation failed', - detail: latestRun.error ?? 'Dashboard suggestion generation failed.', - life: 5000, - }); } } catch { if (options.stopOnPollError) { diff --git a/src/views/dashboard/SuggestionsView.vue b/src/views/dashboard/SuggestionsView.vue index ae64b035..2e4ccc5a 100644 --- a/src/views/dashboard/SuggestionsView.vue +++ b/src/views/dashboard/SuggestionsView.vue @@ -61,16 +61,24 @@ -

{{ run.error ?? 'Suggestion generation failed.' }}

-
    -
  • - {{ failure.controlKey ?? 'Unknown control' }} / - {{ failure.labelSetHash ?? 'Unknown label set' }}: - {{ failure.message ?? 'Failed' }} +

    + {{ + run.status === 'failed' + ? (run.error ?? 'Suggestion generation failed.') + : `${run.failedCells} of ${run.plannedCalls} cells failed to generate.` + }} +

    +
      +
    • + Cell {{ failure.cellIndex ?? index }}: + {{ failure.error ?? 'Failed' }}
    @@ -199,20 +207,8 @@ class="mt-3 rounded-md bg-zinc-50 p-3 text-sm dark:bg-slate-800" >

    - Control fit: - {{ - suggestion.controlFitReasoning ?? - suggestion.reasoning ?? - 'No control-fit reasoning provided.' - }} -

    -

    - System relevance: - {{ - suggestion.systemRelevanceReasoning ?? - suggestion.reasoning ?? - 'No system-relevance reasoning provided.' - }} + Reasoning: + {{ suggestion.reasoning ?? 'No reasoning provided.' }}

    @@ -357,10 +353,10 @@ import Textarea from '@/volt/Textarea.vue'; import SuggestionScopeDialog from './partials/SuggestionScopeDialog.vue'; import { formatLabelSet, + runCellFailures, type DashboardSuggestion, type DashboardSuggestionEvent, type GenerateDashboardSuggestionsPayload, - type SuggestionRunFailure, } from './partials/dashboard-suggestions'; const route = useRoute(); @@ -487,23 +483,74 @@ const pendingGroups = computed(() => { >(); for (const suggestion of pendingSuggestions.value ?? []) { - const matched = labelSetByHash.value.get(suggestion.labelSetHash); - const group = groups.get(suggestion.labelSetHash) ?? { - hash: suggestion.labelSetHash, - labels: formatLabelSet( - matched?.labels ?? suggestion.labelSet ?? suggestion.labels ?? {}, - ), - evidenceCount: matched?.evidenceCount ?? 0, - sampleTitles: matched?.sampleTitles ?? [], - suggestions: [], - }; + const proposedFilter = suggestion.proposedFilterLabelSet; + const hasProposedFilter = + !!proposedFilter && Object.keys(proposedFilter).length > 0; + + // The proposed dashboard is defined by `proposedFilterLabelSet` (a subset of + // the originating evidence's labels), so group/label/link by that filter. + // Fall back to the full label set only when no proposed filter is present. + const key = hasProposedFilter + ? `filter:${proposedFilterKey(proposedFilter)}` + : suggestion.labelSetHash; + + let group = groups.get(key); + if (!group) { + const matched = labelSetByHash.value.get(suggestion.labelSetHash); + const evidence = hasProposedFilter + ? evidenceMatchingFilter(proposedFilter) + : { + count: matched?.evidenceCount ?? 0, + sampleTitles: matched?.sampleTitles ?? [], + }; + + group = { + hash: key, + labels: formatLabelSet( + hasProposedFilter + ? proposedFilter + : (matched?.labels ?? + suggestion.labelSet ?? + suggestion.labels ?? + {}), + ), + evidenceCount: evidence.count, + sampleTitles: evidence.sampleTitles, + suggestions: [], + }; + groups.set(key, group); + } group.suggestions.push(suggestion); - groups.set(suggestion.labelSetHash, group); } return Array.from(groups.values()); }); +// Stable identity for a proposed filter, independent of key insertion order. +function proposedFilterKey(filter: Record): string { + return Object.entries(filter) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('&'); +} + +// Evidence matching a proposed filter is every full label set that is a superset +// of the filter, since the dashboard filters on that subset of labels. +function evidenceMatchingFilter(filter: Record) { + const matching = (labelSets.value ?? []).filter((labelSet) => + Object.entries(filter).every( + ([key, value]) => labelSet.labels?.[key] === value, + ), + ); + return { + count: matching.reduce( + (total, labelSet) => total + (labelSet.evidenceCount ?? 0), + 0, + ), + sampleTitles: matching.flatMap((labelSet) => labelSet.sampleTitles ?? []), + }; +} + onMounted(async () => { await aiConfig.fetchDashboardSuggestionsConfig(); if (!aiConfig.dashboardSuggestionsEnabled) { @@ -769,10 +816,6 @@ function confidenceLabel(confidence: number | undefined) { return `${Math.round(confidence * 100)}% confidence`; } -function failureKey(failure: SuggestionRunFailure) { - return `${failure.controlKey ?? 'control'}-${failure.labelSetHash ?? 'label'}-${failure.message ?? 'failed'}`; -} - function toggleReasoning(id: string) { const next = new Set(expandedReasoning.value); if (next.has(id)) { diff --git a/src/views/dashboard/__tests__/SuggestionsView.spec.ts b/src/views/dashboard/__tests__/SuggestionsView.spec.ts index 8c4543b7..86cd3070 100644 --- a/src/views/dashboard/__tests__/SuggestionsView.spec.ts +++ b/src/views/dashboard/__tests__/SuggestionsView.spec.ts @@ -134,8 +134,7 @@ describe('SuggestionsView', () => { controlTitle: 'Access control policy', labelSetHash: 'hash-1', confidence: 0.91, - controlFitReasoning: 'Fits AC-1', - systemRelevanceReasoning: 'Relevant to payments', + reasoning: 'Fits AC-1 and is relevant to payments', action: 'create', proposedFilterName: 'Production access', }, @@ -146,8 +145,7 @@ describe('SuggestionsView', () => { controlTitle: 'Account management', labelSetHash: 'hash-1', confidence: 0.8, - controlFitReasoning: 'Fits AC-2', - systemRelevanceReasoning: 'Relevant to accounts', + reasoning: 'Fits AC-2 and is relevant to accounts', action: 'create', proposedFilterName: 'Production access', }, @@ -243,8 +241,63 @@ describe('SuggestionsView', () => { await reasoningButton?.trigger('click'); await nextTick(); - expect(wrapper.text()).toContain('Fits AC-1'); - expect(wrapper.text()).toContain('Relevant to payments'); + expect(wrapper.text()).toContain('Fits AC-1 and is relevant to payments'); + }); + + it('labels, links and counts groups by the proposed filter label set', async () => { + const policy = + 'compliance_framework.secret_scanning_push_protection_enabled'; + state.pendingSuggestions.value = [ + { + id: 'sug-1', + status: 'pending', + controlId: 'GD.Conf.C05', + controlTitle: 'Detect and Block Secret Leakage', + labelSetHash: 'full-hash-1', + labelSet: { _policy: policy, repository: 'todo-app', team: 'ccf' }, + proposedFilterLabelSet: { _policy: policy }, + confidence: 0.95, + action: 'create', + proposedFilterName: 'Secret scanning push protection enabled', + }, + ]; + state.labelSets.value = [ + { + hash: 'full-hash-1', + labels: { _policy: policy, repository: 'todo-app' }, + evidenceCount: 3, + sampleTitles: ['todo-app evidence'], + }, + { + hash: 'full-hash-2', + labels: { _policy: policy, repository: 'api' }, + evidenceCount: 4, + sampleTitles: ['api evidence'], + }, + { + hash: 'full-hash-3', + labels: { _policy: 'other.policy', repository: 'api' }, + evidenceCount: 9, + sampleTitles: ['unrelated evidence'], + }, + ]; + + const wrapper = mountView(); + + expect(wrapper.findAll('[data-testid^="suggestion-group-"]')).toHaveLength( + 1, + ); + // Chips and link use the proposed filter, not the full originating label set. + expect(wrapper.text()).toContain(`_policy=${policy}`); + expect(wrapper.text()).not.toContain('repository=todo-app'); + + const evidenceLink = wrapper.find('a[data-to]'); + // Sums evidence across every label set that is a superset of the filter. + expect(evidenceLink.text()).toContain('7 matched evidence'); + expect(JSON.parse(evidenceLink.attributes('data-to') ?? '{}')).toEqual({ + name: 'evidence:index', + query: { filter: `_policy=${policy}` }, + }); }); it('wires group accept and reject reason payloads', async () => { diff --git a/src/views/dashboard/partials/SuggestionScopeDialog.vue b/src/views/dashboard/partials/SuggestionScopeDialog.vue index 6838a414..14440311 100644 --- a/src/views/dashboard/partials/SuggestionScopeDialog.vue +++ b/src/views/dashboard/partials/SuggestionScopeDialog.vue @@ -147,7 +147,7 @@ import Chip from '@/volt/Chip.vue'; import Dialog from '@/volt/Dialog.vue'; import Message from '@/volt/Message.vue'; import MultiSelect from '@/volt/MultiSelect.vue'; -import { useAuthenticatedInstance } from '@/composables/axios'; +import { decamelizeKeys, useAuthenticatedInstance } from '@/composables/axios'; import type { DashboardSuggestionsPreview, DashboardSuggestionLabelSet, @@ -336,6 +336,9 @@ async function fetchPreview(requestId: number) { >( buildPreviewDashboardSuggestionsEndpoint(props.sspId), scope ? { scope } : {}, + // Backend expects kebab-case keys (e.g. `control-keys`); without this the + // scope keys go out as camelCase and the API reads an empty scope (422). + { transformRequest: [decamelizeKeys] }, ); if (requestId !== latestPreviewRequestId) { return; diff --git a/src/views/dashboard/partials/__tests__/SuggestionScopeDialog.spec.ts b/src/views/dashboard/partials/__tests__/SuggestionScopeDialog.spec.ts index 699fc531..cbdc663b 100644 --- a/src/views/dashboard/partials/__tests__/SuggestionScopeDialog.spec.ts +++ b/src/views/dashboard/partials/__tests__/SuggestionScopeDialog.spec.ts @@ -14,6 +14,7 @@ const state = vi.hoisted(() => ({ vi.mock('@/composables/axios', () => ({ useAuthenticatedInstance: () => ({ post: state.axiosPost }), + decamelizeKeys: (data: unknown) => data, })); function makeLabelSet(hash: string): DashboardSuggestionLabelSet { @@ -130,6 +131,22 @@ describe('SuggestionScopeDialog', () => { expect(wrapper.text()).toContain('2 AI calls covering 1 controls x 2'); }); + it('sends the preview scope through decamelizeKeys so backend keys are kebab-case', async () => { + const wrapper = mountDialog(); + await flushPreview(); + + // Deselect a control so a non-empty scope is sent. + await wrapper + .findAllComponents({ name: 'MultiSelect' })[0] + .vm.$emit('update:modelValue', ['AC-1']); + await flushPreview(); + + const lastCall = state.axiosPost.mock.calls.at(-1); + expect(lastCall?.[1]).toMatchObject({ scope: { controlKeys: ['AC-1'] } }); + // Without this transform the keys go out camelCase and the API 422s. + expect(lastCall?.[2]?.transformRequest).toBeDefined(); + }); + it('requires explicit confirmation for larger grids', async () => { state.preview = { plannedCalls: 30, diff --git a/src/views/dashboard/partials/dashboard-suggestions.ts b/src/views/dashboard/partials/dashboard-suggestions.ts index 4cc16854..3e40c9fa 100644 --- a/src/views/dashboard/partials/dashboard-suggestions.ts +++ b/src/views/dashboard/partials/dashboard-suggestions.ts @@ -34,10 +34,23 @@ export interface DashboardSuggestionsPreview { labelSetCount: number; } -export interface SuggestionRunFailure { - controlKey?: string; - labelSetHash?: string; - message?: string; +// A single failed cell, as reported in `run.stats.failedCells`. The API stores +// these under `stats.failed_cells` (camelCased on the way in). Note it carries +// only the cell index and error message — control/label-set detail is not +// included in the run summary response. +export interface SuggestionRunCellFailure { + cellIndex?: number; + error?: string; +} + +// Free-form run statistics (`stats` jsonb). Only the fields the UI reads are +// typed here; the backend may include additional keys. +export interface SuggestionRunStats { + cellsCompleted?: number; + cellsFailed?: number; + failedCells?: SuggestionRunCellFailure[]; + mappingsReturned?: number; + mappingsRejected?: number; } export interface SuggestionRun { @@ -49,11 +62,17 @@ export interface SuggestionRun { failedCells: number; scope?: DashboardSuggestionScope; error?: string; - failures?: SuggestionRunFailure[]; + stats?: SuggestionRunStats; createdAt?: string; updatedAt?: string; } +export function runCellFailures( + run: SuggestionRun | undefined, +): SuggestionRunCellFailure[] { + return run?.stats?.failedCells ?? []; +} + export interface DashboardSuggestion { id: string; uuid?: string; @@ -63,10 +82,12 @@ export interface DashboardSuggestion { labelSetHash: string; labelSet?: Record; labels?: Record; + // Subset of labels that defines the proposed dashboard filter. This is what + // the suggested dashboard actually filters on, and is usually a small subset + // of the originating evidence's full `labelSet`. + proposedFilterLabelSet?: Record; confidence?: number; reasoning?: string; - controlFitReasoning?: string; - systemRelevanceReasoning?: string; action?: 'create' | 'extend'; proposedFilterName?: string; targetFilterId?: string;