diff --git a/src/composables/__tests__/axios.spec.ts b/src/composables/__tests__/axios.spec.ts
new file mode 100644
index 00000000..8c072918
--- /dev/null
+++ b/src/composables/__tests__/axios.spec.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it, vi } from 'vitest';
+import { useAuthenticatedInstance } from '@/composables/axios';
+
+vi.mock('@/stores/config.ts', () => ({
+ useConfigStore: () => ({
+ getConfig: vi.fn(async () => ({ API_URL: 'http://api.test' })),
+ }),
+}));
+
+vi.mock('@/stores/auth', () => ({
+ useUserStore: () => ({
+ logout: vi.fn(),
+ }),
+}));
+
+vi.mock('vue-router', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ }),
+}));
+
+vi.mock('primevue/usetoast', () => ({
+ useToast: () => ({
+ add: vi.fn(),
+ }),
+}));
+
+describe('axios response conversion', () => {
+ it('preserves dashboard suggestion label-map keys inside DataResponse arrays', async () => {
+ const instance = useAuthenticatedInstance();
+
+ const response = await instance.get('/dashboard-suggestions', {
+ // This endpoint returns camelCase field names; camelcase-keys matches
+ // stopPaths against the original keys, so the stop path is camelCase too.
+ camelcaseStopPaths: ['data.proposedFilterLabelSet'],
+ adapter: async (config) => ({
+ data: {
+ data: [
+ {
+ id: 'suggestion-1',
+ proposedFilterLabelSet: {
+ _policy: 'x',
+ service_name: 'api',
+ },
+ },
+ ],
+ },
+ status: 200,
+ statusText: 'OK',
+ headers: {},
+ config,
+ }),
+ });
+
+ expect(response.data.data[0]).toEqual({
+ id: 'suggestion-1',
+ proposedFilterLabelSet: {
+ _policy: 'x',
+ service_name: 'api',
+ },
+ });
+ });
+});
diff --git a/src/composables/useDashboardSuggestions.ts b/src/composables/useDashboardSuggestions.ts
index bd7b8cd2..a5f84d82 100644
--- a/src/composables/useDashboardSuggestions.ts
+++ b/src/composables/useDashboardSuggestions.ts
@@ -22,6 +22,7 @@ import {
buildGeneralizeDashboardSuggestionsEndpoint,
buildLatestDashboardSuggestionRunEndpoint,
buildRejectDashboardSuggestionsEndpoint,
+ DASHBOARD_SUGGESTION_LABEL_STOP_PATHS,
type DashboardSuggestion,
type DashboardSuggestionEvent,
type DashboardSuggestionLabelKey,
@@ -93,13 +94,9 @@ export function useDashboardSuggestions(
// Preserve raw label keys (e.g. `_policy`) on the originating label set,
// the proposed filter, and the AI baseline used for the edit diff;
// otherwise camelcase conversion strips `_` and the diff shows phantom
- // changes.
+ // changes. See DASHBOARD_SUGGESTION_LABEL_STOP_PATHS for why these are camelCase.
{
- camelcaseStopPaths: [
- 'data.labelSet',
- 'data.proposedFilterLabelSet',
- 'data.originalProposedFilterLabelSet',
- ],
+ camelcaseStopPaths: DASHBOARD_SUGGESTION_LABEL_STOP_PATHS,
},
);
}
@@ -194,11 +191,7 @@ export function useDashboardSuggestions(
const response = await fetchHistoryRequest(
buildDashboardSuggestionsEndpoint(sspId.value, status),
{
- camelcaseStopPaths: [
- 'data.labelSet',
- 'data.proposedFilterLabelSet',
- 'data.originalProposedFilterLabelSet',
- ],
+ camelcaseStopPaths: DASHBOARD_SUGGESTION_LABEL_STOP_PATHS,
},
);
collected.push(...(response?.data.value?.data ?? []));
@@ -235,11 +228,7 @@ export function useDashboardSuggestions(
data: payload,
transformRequest: [decamelizeKeys],
// Preserve raw label keys (e.g. `_policy`) the user kept in the filter.
- camelcaseStopPaths: [
- 'data.labelSet',
- 'data.proposedFilterLabelSet',
- 'data.originalProposedFilterLabelSet',
- ],
+ camelcaseStopPaths: DASHBOARD_SUGGESTION_LABEL_STOP_PATHS,
},
);
await refreshPendingSuggestions();
diff --git a/src/views/control-implementations/IndexView.vue b/src/views/control-implementations/IndexView.vue
index ce998954..5e4da46c 100644
--- a/src/views/control-implementations/IndexView.vue
+++ b/src/views/control-implementations/IndexView.vue
@@ -103,11 +103,7 @@
? 'View implementation'
: 'No implementation yet'
"
- @click="
- openImplementationDrawer(
- controlImplementations[slotProps.node.data.id],
- )
- "
+ @click="openControlDrawer(slotProps.node.data.id)"
>
+
@@ -141,6 +142,15 @@
position="right"
class="w-full! md:w-1/2! lg:w-3/5!"
>
+
+
Components
uiStore.controlImplementationDrawerOpen,
@@ -259,6 +280,10 @@ const controlImplementations = ref<{ [key: string]: ImplementedRequirement }>(
{},
);
const selectedImplementedRequirement = ref();
+// The control whose drawer is open. Tracked separately from the implemented
+// requirement so the drawer (and its AI suggestions panel) can open for a
+// control that has pending suggestions but no implementation yet.
+const selectedControlId = ref();
const RISK_FETCH_LIMIT = 100;
const loadedSspRisksFor = ref(null);
const preparingBulkSuggestions = ref(false);
@@ -272,8 +297,15 @@ const { data: sspRisks, execute: loadSspRisks } = useDataApi(
{},
{ immediate: false },
);
+const pendingDashboardSuggestions = ref([]);
+const dashboardSuggestionControlResults = ref([]);
+const dashboardSuggestionStateLoading = ref(false);
const nodes = ref>([]);
+const loadedDashboardSuggestionStateFor = ref(null);
+const loadingDashboardSuggestionStateFor = ref(null);
+let dashboardSuggestionStateLoadPromise: Promise | null = null;
+const dashboardSuggestionStateRequestId = ref(0);
interface StatementSuggestionWorkItem {
requirement: ImplementedRequirement;
@@ -303,6 +335,53 @@ const bulkSuggestionsButtonLabel = computed(() => {
return 'Apply All Suggestions';
});
+const pendingDashboardSuggestionsByControl = computed(() => {
+ const grouped: Record = {};
+ for (const suggestion of pendingDashboardSuggestions.value ?? []) {
+ const key = normalizeId(suggestion.controlId);
+ if (!key) {
+ continue;
+ }
+ grouped[key] = grouped[key] ?? [];
+ grouped[key].push(suggestion);
+ }
+ return grouped;
+});
+
+const dashboardSuggestionResultsByControl = computed(() => {
+ const results: Record = {};
+ for (const result of dashboardSuggestionControlResults.value ?? []) {
+ const key = normalizeId(result.controlId);
+ if (!key) {
+ continue;
+ }
+ results[key] = result;
+ }
+ return results;
+});
+
+const selectedDrawerControlId = computed(
+ () =>
+ selectedControlId.value ?? selectedImplementedRequirement.value?.controlId,
+);
+
+const selectedControlDashboardSuggestions = computed(() => {
+ const key = normalizeId(selectedDrawerControlId.value);
+ return key ? (pendingDashboardSuggestionsByControl.value[key] ?? []) : [];
+});
+
+const selectedControlSuggestionResult = computed(() => {
+ const key = normalizeId(selectedDrawerControlId.value);
+ return key ? dashboardSuggestionResultsByControl.value[key] : undefined;
+});
+
+function controlSuggestionCount(controlId?: string): number {
+ const key = normalizeId(controlId);
+ return key
+ ? (pendingDashboardSuggestionsByControl.value[key]?.length ?? 0)
+ : 0;
+}
+
function normalizeId(value?: string): string {
return (value || '').trim().toLowerCase();
}
@@ -377,6 +456,89 @@ function controlHighestSeverity(
return stats.highestSeverity ?? 'high';
}
+async function loadDashboardSuggestionState() {
+ const sspId = systemStore.system.securityPlan?.uuid;
+ if (!sspId || !aiConfigStore.dashboardSuggestionsEnabled) {
+ pendingDashboardSuggestions.value = [];
+ dashboardSuggestionControlResults.value = [];
+ loadedDashboardSuggestionStateFor.value = null;
+ loadingDashboardSuggestionStateFor.value = null;
+ dashboardSuggestionStateLoadPromise = null;
+ dashboardSuggestionStateRequestId.value += 1;
+ dashboardSuggestionStateLoading.value = false;
+ return;
+ }
+
+ if (loadedDashboardSuggestionStateFor.value === sspId) {
+ return;
+ }
+
+ if (
+ loadingDashboardSuggestionStateFor.value === sspId &&
+ dashboardSuggestionStateLoadPromise
+ ) {
+ await dashboardSuggestionStateLoadPromise;
+ return;
+ }
+
+ loadingDashboardSuggestionStateFor.value = sspId;
+ dashboardSuggestionStateLoading.value = true;
+ const requestId = (dashboardSuggestionStateRequestId.value += 1);
+ const loadPromise = (async () => {
+ const [pendingResult, controlResultsResult] = await Promise.allSettled([
+ axios.get>(
+ buildDashboardSuggestionsEndpoint(sspId, 'pending'),
+ {
+ camelcaseStopPaths: DASHBOARD_SUGGESTION_LABEL_STOP_PATHS,
+ },
+ ),
+ axios.get>(
+ buildDashboardSuggestionControlResultsEndpoint(sspId),
+ ),
+ ]);
+
+ if (
+ dashboardSuggestionStateRequestId.value !== requestId ||
+ systemStore.system.securityPlan?.uuid !== sspId
+ ) {
+ return;
+ }
+
+ pendingDashboardSuggestions.value =
+ pendingResult.status === 'fulfilled' ? pendingResult.value.data.data : [];
+ dashboardSuggestionControlResults.value =
+ controlResultsResult.status === 'fulfilled'
+ ? controlResultsResult.value.data.data
+ : [];
+ loadedDashboardSuggestionStateFor.value = sspId;
+ })();
+ dashboardSuggestionStateLoadPromise = loadPromise;
+
+ try {
+ await loadPromise;
+ } finally {
+ if (
+ loadingDashboardSuggestionStateFor.value === sspId &&
+ dashboardSuggestionStateLoadPromise === loadPromise
+ ) {
+ loadingDashboardSuggestionStateFor.value = null;
+ dashboardSuggestionStateLoadPromise = null;
+ dashboardSuggestionStateLoading.value = false;
+ }
+ }
+}
+
+async function initializeDashboardSuggestionState() {
+ try {
+ await aiConfigStore.fetchDashboardSuggestionsConfig();
+ if (aiConfigStore.dashboardSuggestionsEnabled) {
+ await loadDashboardSuggestionState();
+ }
+ } catch {
+ // Dashboard suggestions are optional; do not block the core controls view.
+ }
+}
+
function openControlRisks(controlId?: string) {
if (!controlId) {
return;
@@ -443,6 +605,7 @@ async function loadControlImplementations() {
uiStore.controlImplementationDrawerOpen
) {
selectedImplementedRequirement.value = impl;
+ selectedControlId.value = impl.controlId;
selectedRequirementFound = true;
}
}
@@ -823,13 +986,21 @@ function hasControlImplementation(controlId?: string): boolean {
return !!(controlId && controlImplementations.value[controlId]);
}
-function openImplementationDrawer(req: ImplementedRequirement | undefined) {
- if (!req) {
+// Opens the implementation drawer for a control. The control need not have an
+// implementation: when it only has pending AI suggestions, the drawer still
+// opens (with an empty Components section) so the suggestions panel is reachable.
+function openControlDrawer(controlId?: string) {
+ if (!controlId) {
return;
}
+ const requirement = controlImplementations.value[controlId];
uiStore.setControlImplementationDrawerOpen(true);
- uiStore.setControlImplementationSelectedRequirementId(req.uuid);
- selectedImplementedRequirement.value = req;
+ uiStore.setControlImplementationSelectedRequirementId(
+ requirement?.uuid ?? null,
+ );
+ selectedControlId.value = controlId;
+ selectedImplementedRequirement.value = requirement;
+ void loadDashboardSuggestionState();
}
watch(
@@ -838,6 +1009,13 @@ watch(
if (!sspId) {
sspRisks.value = [];
loadedSspRisksFor.value = null;
+ pendingDashboardSuggestions.value = [];
+ dashboardSuggestionControlResults.value = [];
+ loadedDashboardSuggestionStateFor.value = null;
+ loadingDashboardSuggestionStateFor.value = null;
+ dashboardSuggestionStateLoadPromise = null;
+ dashboardSuggestionStateRequestId.value += 1;
+ dashboardSuggestionStateLoading.value = false;
return;
}
@@ -858,17 +1036,43 @@ watch(
{ immediate: true },
);
+watch(
+ () => [
+ systemStore.system.securityPlan?.uuid,
+ aiConfigStore.dashboardSuggestionsConfigFetched,
+ aiConfigStore.dashboardSuggestionsEnabled,
+ ],
+ ([sspId, configFetched, enabled]) => {
+ if (!sspId || !configFetched || !enabled) {
+ pendingDashboardSuggestions.value = [];
+ dashboardSuggestionControlResults.value = [];
+ loadedDashboardSuggestionStateFor.value = null;
+ loadingDashboardSuggestionStateFor.value = null;
+ dashboardSuggestionStateLoadPromise = null;
+ dashboardSuggestionStateRequestId.value += 1;
+ dashboardSuggestionStateLoading.value = false;
+ return;
+ }
+ void loadDashboardSuggestionState();
+ },
+);
+
watch(
() => uiStore.controlImplementationDrawerOpen,
(isOpen) => {
- if (!isOpen && uiStore.controlImplementationSelectedRequirementId) {
- uiStore.setControlImplementationSelectedRequirementId(null);
+ if (!isOpen) {
+ if (uiStore.controlImplementationSelectedRequirementId) {
+ uiStore.setControlImplementationSelectedRequirementId(null);
+ }
selectedImplementedRequirement.value = undefined;
+ selectedControlId.value = undefined;
}
},
);
onMounted(async () => {
+ void initializeDashboardSuggestionState();
+
try {
await loadProfileBindings();
if (profileBindings.value.length > 0) {
diff --git a/src/views/control-implementations/__tests__/IndexView.spec.ts b/src/views/control-implementations/__tests__/IndexView.spec.ts
index fa1bcb82..a66b5531 100644
--- a/src/views/control-implementations/__tests__/IndexView.spec.ts
+++ b/src/views/control-implementations/__tests__/IndexView.spec.ts
@@ -1,12 +1,29 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { mount } from '@vue/test-utils';
-import { defineComponent, h, ref } from 'vue';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { enableAutoUnmount, mount } from '@vue/test-utils';
+import { defineComponent, h, reactive, ref } from 'vue';
import IndexView from '../IndexView.vue';
+enableAutoUnmount(afterEach);
+
const listProfiles = vi.fn();
const axiosGet = vi.fn();
const loadRisks = vi.fn(async () => ({ data: { value: { data: [] } } }));
const fetchControlImplementations = vi.fn();
+const aiConfigState = reactive({
+ dashboardSuggestionsEnabled: false,
+ dashboardSuggestionsConfigFetched: false,
+});
+const systemStoreState = reactive({
+ system: { securityPlan: { uuid: 'ssp-1' } as { uuid: string } | null },
+});
+const fetchDashboardSuggestionsConfig = vi.fn(async () => {
+ aiConfigState.dashboardSuggestionsConfigFetched = true;
+ return aiConfigState.dashboardSuggestionsEnabled;
+});
+let pendingDashboardSuggestionsFixture: unknown[] = [];
+let controlResultsFixture: unknown[] = [];
+let pendingDashboardSuggestionsReject = false;
+let controlResultsReject = false;
const uiStore = {
controlImplementationDrawerOpen: false,
controlImplementationSelectedRequirementId: null as string | null,
@@ -25,9 +42,7 @@ const uiStore = {
};
vi.mock('@/stores/system.ts', () => ({
- useSystemStore: () => ({
- system: { securityPlan: { uuid: 'ssp-1' } },
- }),
+ useSystemStore: () => systemStoreState,
}));
vi.mock('@/stores/system-security-plans', () => ({
@@ -40,7 +55,24 @@ vi.mock('@/stores/ui.ts', () => ({
useUIStore: () => uiStore,
}));
+vi.mock('@/stores/ai-config', () => ({
+ useAiConfigStore: () => ({
+ get dashboardSuggestionsEnabled() {
+ return aiConfigState.dashboardSuggestionsEnabled;
+ },
+ get dashboardSuggestionsConfigFetched() {
+ return aiConfigState.dashboardSuggestionsConfigFetched;
+ },
+ fetchDashboardSuggestionsConfig,
+ }),
+}));
+
vi.mock('vue-router', () => ({
+ RouterLink: {
+ name: 'RouterLink',
+ props: ['to'],
+ template: '',
+ },
useRouter: () => ({ push: vi.fn() }),
}));
@@ -69,6 +101,14 @@ vi.mock('@/composables/axios', () => ({
execute: fetchControlImplementations,
};
}
+ if (url === null) {
+ return {
+ data: ref([]),
+ isLoading: ref(false),
+ error: ref(null),
+ execute: loadRisks,
+ };
+ }
return {
data: ref([]),
isLoading: ref(false),
@@ -94,6 +134,16 @@ function flushPromises() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
+function createDeferred() {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((promiseResolve, promiseReject) => {
+ resolve = promiseResolve;
+ reject = promiseReject;
+ });
+ return { promise, resolve, reject };
+}
+
async function waitForMountedControls() {
for (let index = 0; index < 5; index += 1) {
await flushPromises();
@@ -104,6 +154,7 @@ const stubs = {
RouterLink: { template: '' },
Message: { template: '
' },
Badge: { template: '{{ value }}', props: ['value'] },
+ Chip: { template: '{{ label }}', props: ['label'] },
Button: {
props: ['disabled', 'ariaLabel', 'title', 'label'],
emits: ['click'],
@@ -145,13 +196,34 @@ const stubs = {
describe('control implementations IndexView', () => {
beforeEach(() => {
vi.clearAllMocks();
+ aiConfigState.dashboardSuggestionsEnabled = false;
+ aiConfigState.dashboardSuggestionsConfigFetched = false;
+ systemStoreState.system.securityPlan = { uuid: 'ssp-1' };
+ pendingDashboardSuggestionsFixture = [];
+ controlResultsFixture = [];
+ pendingDashboardSuggestionsReject = false;
+ controlResultsReject = false;
uiStore.controlImplementationDrawerOpen = false;
uiStore.controlImplementationSelectedRequirementId = null;
uiStore.controlImplementationExpandedKeys = {};
listProfiles.mockResolvedValue({
data: [{ uuid: 'profile-1', title: 'Profile One' }],
});
- axiosGet.mockResolvedValue({ data: { data: { uuid: 'catalog-1' } } });
+ axiosGet.mockImplementation(async (url: string) => {
+ if (url.includes('/dashboard-suggestions?status=pending')) {
+ if (pendingDashboardSuggestionsReject) {
+ throw new Error('pending failed');
+ }
+ return { data: { data: pendingDashboardSuggestionsFixture } };
+ }
+ if (url.includes('/dashboard-suggestions/control-results')) {
+ if (controlResultsReject) {
+ throw new Error('results failed');
+ }
+ return { data: { data: controlResultsFixture } };
+ }
+ return { data: { data: { uuid: 'catalog-1' } } };
+ });
fetchControlImplementations.mockResolvedValue({
data: {
value: {
@@ -201,4 +273,310 @@ describe('control implementations IndexView', () => {
uiStore.setControlImplementationSelectedRequirementId,
).toHaveBeenCalledWith('req-1');
});
+
+ it('does not render the AI dashboard suggestions panel when the feature flag is disabled', async () => {
+ const wrapper = mount(IndexView, { global: { stubs } });
+ await waitForMountedControls();
+
+ const implementationButton = wrapper
+ .findAll('button')
+ .find((button) => button.attributes('title') === 'View implementation');
+ await implementationButton?.trigger('click');
+ await flushPromises();
+
+ expect(wrapper.text()).not.toContain('AI dashboard suggestions');
+ expect(axiosGet).not.toHaveBeenCalledWith(
+ expect.stringContaining('/dashboard-suggestions?status=pending'),
+ expect.anything(),
+ );
+ expect(axiosGet).not.toHaveBeenCalledWith(
+ expect.stringContaining('/dashboard-suggestions/control-results'),
+ );
+ });
+
+ it('loads core controls data before dashboard suggestions config resolves', async () => {
+ const configDeferred = createDeferred();
+ fetchDashboardSuggestionsConfig.mockImplementationOnce(
+ () => configDeferred.promise,
+ );
+
+ mount(IndexView, { global: { stubs } });
+ await waitForMountedControls();
+
+ expect(fetchDashboardSuggestionsConfig).toHaveBeenCalled();
+ expect(listProfiles).toHaveBeenCalled();
+ expect(fetchControlImplementations).toHaveBeenCalled();
+ expect(axiosGet).not.toHaveBeenCalledWith(
+ expect.stringContaining('/dashboard-suggestions?status=pending'),
+ expect.anything(),
+ );
+ });
+
+ it('matches pending suggestions to the selected control case-insensitively', async () => {
+ aiConfigState.dashboardSuggestionsEnabled = true;
+ pendingDashboardSuggestionsFixture = [
+ {
+ id: 'suggestion-1',
+ status: 'pending',
+ controlId: 'AC-1',
+ labelSetHash: 'hash-1',
+ proposedFilterName: 'Production evidence',
+ proposedFilterLabelSet: { env: 'prod' },
+ confidence: 0.8,
+ reasoning: 'Uppercase control id still matches.',
+ },
+ ];
+
+ const wrapper = mount(IndexView, { global: { stubs } });
+ await waitForMountedControls();
+
+ const implementationButton = wrapper
+ .findAll('button')
+ .find((button) => button.attributes('title') === 'View implementation');
+ await implementationButton?.trigger('click');
+ await flushPromises();
+
+ expect(wrapper.text()).toContain('AI dashboard suggestions');
+ expect(wrapper.text()).toContain('Production evidence');
+ expect(wrapper.text()).toContain('80% confidence');
+ expect(axiosGet).toHaveBeenCalledWith(
+ expect.stringContaining('/dashboard-suggestions?status=pending'),
+ expect.objectContaining({
+ camelcaseStopPaths: expect.arrayContaining([
+ 'data.proposedFilterLabelSet',
+ ]),
+ }),
+ );
+ expect(axiosGet).toHaveBeenCalledWith(
+ expect.stringContaining('/dashboard-suggestions/control-results'),
+ );
+ });
+
+ it('loads dashboard suggestion state once when config resolution enables suggestions on mount', async () => {
+ aiConfigState.dashboardSuggestionsEnabled = true;
+ const configDeferred = createDeferred();
+ const pendingDeferred = createDeferred<{
+ data: { data: unknown[] };
+ }>();
+ const controlResultsDeferred = createDeferred<{
+ data: { data: unknown[] };
+ }>();
+
+ fetchDashboardSuggestionsConfig.mockImplementationOnce(async () => {
+ await configDeferred.promise;
+ aiConfigState.dashboardSuggestionsConfigFetched = true;
+ return aiConfigState.dashboardSuggestionsEnabled;
+ });
+ axiosGet.mockImplementation((url: string) => {
+ if (url.includes('/dashboard-suggestions?status=pending')) {
+ return pendingDeferred.promise;
+ }
+ if (url.includes('/dashboard-suggestions/control-results')) {
+ return controlResultsDeferred.promise;
+ }
+ return Promise.resolve({ data: { data: { uuid: 'catalog-1' } } });
+ });
+
+ mount(IndexView, { global: { stubs } });
+ configDeferred.resolve();
+ await flushPromises();
+
+ const pendingCalls = axiosGet.mock.calls.filter(([url]) =>
+ String(url).includes('/dashboard-suggestions?status=pending'),
+ );
+ const controlResultsCalls = axiosGet.mock.calls.filter(([url]) =>
+ String(url).includes('/dashboard-suggestions/control-results'),
+ );
+ expect(pendingCalls).toHaveLength(1);
+ expect(axiosGet).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '/api/oscal/system-security-plans/ssp-1/dashboard-suggestions?status=pending',
+ ),
+ expect.any(Object),
+ );
+ expect(controlResultsCalls).toHaveLength(1);
+ expect(axiosGet).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '/api/oscal/system-security-plans/ssp-1/dashboard-suggestions/control-results',
+ ),
+ );
+
+ pendingDeferred.resolve({ data: { data: [] } });
+ controlResultsDeferred.resolve({ data: { data: [] } });
+ await waitForMountedControls();
+ });
+
+ it('ignores stale dashboard suggestion responses after switching SSPs', async () => {
+ aiConfigState.dashboardSuggestionsEnabled = true;
+ aiConfigState.dashboardSuggestionsConfigFetched = true;
+ const ssp1Pending = createDeferred<{ data: { data: unknown[] } }>();
+ const ssp1ControlResults = createDeferred<{ data: { data: unknown[] } }>();
+ const ssp2Pending = createDeferred<{ data: { data: unknown[] } }>();
+ const ssp2ControlResults = createDeferred<{ data: { data: unknown[] } }>();
+
+ axiosGet.mockImplementation((url: string) => {
+ if (url.includes('/ssp-1/dashboard-suggestions?status=pending')) {
+ return ssp1Pending.promise;
+ }
+ if (url.includes('/ssp-1/dashboard-suggestions/control-results')) {
+ return ssp1ControlResults.promise;
+ }
+ if (url.includes('/ssp-2/dashboard-suggestions?status=pending')) {
+ return ssp2Pending.promise;
+ }
+ if (url.includes('/ssp-2/dashboard-suggestions/control-results')) {
+ return ssp2ControlResults.promise;
+ }
+ return Promise.resolve({ data: { data: { uuid: 'catalog-1' } } });
+ });
+
+ const wrapper = mount(IndexView, { global: { stubs } });
+ await flushPromises();
+
+ systemStoreState.system.securityPlan = { uuid: 'ssp-2' };
+ await flushPromises();
+
+ ssp2Pending.resolve({
+ data: {
+ data: [
+ {
+ id: 'suggestion-2',
+ status: 'pending',
+ controlId: 'AC-1',
+ labelSetHash: 'hash-2',
+ proposedFilterName: 'Second SSP suggestion',
+ },
+ ],
+ },
+ });
+ ssp2ControlResults.resolve({ data: { data: [] } });
+ await flushPromises();
+
+ ssp1Pending.resolve({
+ data: {
+ data: [
+ {
+ id: 'suggestion-1',
+ status: 'pending',
+ controlId: 'AC-1',
+ labelSetHash: 'hash-1',
+ proposedFilterName: 'First SSP stale suggestion',
+ },
+ ],
+ },
+ });
+ ssp1ControlResults.resolve({ data: { data: [] } });
+ await waitForMountedControls();
+
+ const callsBeforeDrawerOpen = axiosGet.mock.calls.length;
+ const implementationButton = wrapper
+ .findAll('button')
+ .find((button) => button.attributes('title') === 'View implementation');
+ await implementationButton?.trigger('click');
+ await flushPromises();
+
+ expect(wrapper.text()).toContain('Second SSP suggestion');
+ expect(wrapper.text()).not.toContain('First SSP stale suggestion');
+ expect(axiosGet.mock.calls).toHaveLength(callsBeforeDrawerOpen);
+ });
+
+ it('keeps pending suggestions visible when control-results cannot be fetched', async () => {
+ aiConfigState.dashboardSuggestionsEnabled = true;
+ controlResultsReject = true;
+ pendingDashboardSuggestionsFixture = [
+ {
+ id: 'suggestion-1',
+ status: 'pending',
+ controlId: 'AC-1',
+ labelSetHash: 'hash-1',
+ proposedFilterName: 'Resilient suggestion',
+ },
+ ];
+
+ const wrapper = mount(IndexView, { global: { stubs } });
+ await waitForMountedControls();
+
+ const implementationButton = wrapper
+ .findAll('button')
+ .find((button) => button.attributes('title') === 'View implementation');
+ await implementationButton?.trigger('click');
+ await flushPromises();
+
+ expect(wrapper.text()).toContain('Resilient suggestion');
+ });
+
+ it('keeps no-match state visible when pending suggestions cannot be fetched', async () => {
+ aiConfigState.dashboardSuggestionsEnabled = true;
+ pendingDashboardSuggestionsReject = true;
+ controlResultsFixture = [
+ {
+ controlId: 'AC-1',
+ outcome: 'no_match',
+ evaluatedAt: '2026-06-18T10:30:00Z',
+ },
+ ];
+
+ const wrapper = mount(IndexView, { global: { stubs } });
+ await waitForMountedControls();
+
+ const implementationButton = wrapper
+ .findAll('button')
+ .find((button) => button.attributes('title') === 'View implementation');
+ await implementationButton?.trigger('click');
+ await flushPromises();
+
+ expect(wrapper.text()).toContain(
+ 'AI reviewed this control and found no matching dashboard filter',
+ );
+ });
+
+ it('surfaces a clickable suggestions badge that opens the drawer for a control with no implementation', async () => {
+ // ac-2 has no implemented requirement (only ac-1 does), so its eye button is
+ // disabled. A pending suggestion for ac-2 must still be reachable via a badge.
+ aiConfigState.dashboardSuggestionsEnabled = true;
+ pendingDashboardSuggestionsFixture = [
+ {
+ id: 'suggestion-2',
+ status: 'pending',
+ controlId: 'AC-2',
+ labelSetHash: 'hash-2',
+ proposedFilterName: 'Unimplemented control suggestion',
+ },
+ ];
+
+ const wrapper = mount(IndexView, { global: { stubs } });
+ await waitForMountedControls();
+
+ // The ac-2 eye button is disabled (no implementation to view).
+ const eyeButtons = wrapper
+ .findAll('button')
+ .filter((button) => button.attributes('title') !== undefined);
+ expect(
+ eyeButtons.some((button) => button.attributes('disabled') !== undefined),
+ ).toBe(true);
+
+ const badge = wrapper
+ .findAll('button')
+ .find((button) =>
+ button
+ .attributes('aria-label')
+ ?.includes('pending AI dashboard suggestion'),
+ );
+ expect(badge).toBeTruthy();
+ expect(badge?.text()).toContain('1');
+
+ // Before opening, the drawer's suggestions panel is not scoped to ac-2.
+ expect(wrapper.text()).not.toContain('Unimplemented control suggestion');
+
+ await badge?.trigger('click');
+ await flushPromises();
+
+ expect(uiStore.setControlImplementationDrawerOpen).toHaveBeenCalledWith(
+ true,
+ );
+ expect(
+ uiStore.setControlImplementationSelectedRequirementId,
+ ).toHaveBeenCalledWith(null);
+ expect(wrapper.text()).toContain('Unimplemented control suggestion');
+ });
});
diff --git a/src/views/control-implementations/partials/ControlImplementationSuggestions.vue b/src/views/control-implementations/partials/ControlImplementationSuggestions.vue
new file mode 100644
index 00000000..00ed6cb6
--- /dev/null
+++ b/src/views/control-implementations/partials/ControlImplementationSuggestions.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
+
AI dashboard suggestions
+
+ Review in dashboard suggestions
+
+
+
+
+
+
+ Loading AI dashboard suggestions...
+
+
+
+
+
+
+
+
+ {{ filterName(suggestion) }}
+
+
+ {{ confidenceLabel(suggestion.confidence) }}
+
+
+
+
+
+
+
+
+
+
+ {{ suggestion.reasoning }}
+
+
+
+
+
+
+
+ AI reviewed this control and found no matching dashboard filter
+ on {{ formatEvaluatedAt(result?.evaluatedAt) }}.
+
+
+
+ {{
+ matchedWithoutPending
+ ? 'No pending AI dashboard suggestions for this control.'
+ : "AI hasn't evaluated this control yet."
+ }}
+
+
+
diff --git a/src/views/control-implementations/partials/RiskIndicatorBadge.vue b/src/views/control-implementations/partials/RiskIndicatorBadge.vue
index f4c98896..b5782df4 100644
--- a/src/views/control-implementations/partials/RiskIndicatorBadge.vue
+++ b/src/views/control-implementations/partials/RiskIndicatorBadge.vue
@@ -7,8 +7,10 @@ const props = defineProps<{
highestSeverity?: 'high' | 'medium' | 'low';
clickable?: boolean;
}>();
+// Forward the native event so callers can use the `.stop` modifier without
+// `.stopPropagation()` being called on `undefined`.
const emit = defineEmits<{
- click: [];
+ click: [event: MouseEvent];
}>();
const displayCount = computed(() =>
@@ -36,11 +38,11 @@ const tooltipText = computed(() => {
return `${displayCount.value} risks associated`;
});
-function onClick() {
+function onClick(event: MouseEvent) {
if (!props.clickable) {
return;
}
- emit('click');
+ emit('click', event);
}
diff --git a/src/views/control-implementations/partials/SuggestionIndicatorBadge.vue b/src/views/control-implementations/partials/SuggestionIndicatorBadge.vue
new file mode 100644
index 00000000..8f9cdc0c
--- /dev/null
+++ b/src/views/control-implementations/partials/SuggestionIndicatorBadge.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
diff --git a/src/views/control-implementations/partials/__tests__/ControlImplementationSuggestions.spec.ts b/src/views/control-implementations/partials/__tests__/ControlImplementationSuggestions.spec.ts
new file mode 100644
index 00000000..1e029c66
--- /dev/null
+++ b/src/views/control-implementations/partials/__tests__/ControlImplementationSuggestions.spec.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { defineComponent } from 'vue';
+import ControlImplementationSuggestions from '../ControlImplementationSuggestions.vue';
+import type {
+ ControlSuggestionResult,
+ DashboardSuggestion,
+} from '@/views/dashboard/partials/dashboard-suggestions';
+
+function suggestion(
+ overrides: Partial = {},
+): DashboardSuggestion {
+ return {
+ id: 'suggestion-1',
+ status: 'pending',
+ controlId: 'AC-1',
+ labelSetHash: 'hash-1',
+ proposedFilterName: 'Production evidence',
+ proposedFilterLabelSet: {
+ env: 'prod',
+ service: 'api',
+ _policy: 'internal',
+ },
+ confidence: 0.91,
+ reasoning: 'The production API evidence maps cleanly to this control.',
+ ...overrides,
+ };
+}
+
+function mountComponent(props: {
+ suggestions?: DashboardSuggestion[];
+ result?: ControlSuggestionResult;
+ loading?: boolean;
+}) {
+ return mount(ControlImplementationSuggestions, {
+ props: {
+ controlId: 'ac-1',
+ sspId: 'ssp-1',
+ suggestions: [],
+ ...props,
+ },
+ global: {
+ stubs: {
+ Chip: {
+ props: ['label'],
+ template: '{{ label }}',
+ },
+ Message: {
+ template: '
',
+ },
+ RouterLink: defineComponent({
+ name: 'RouterLink',
+ props: ['to'],
+ template: '',
+ }),
+ },
+ },
+ });
+}
+
+describe('ControlImplementationSuggestions', () => {
+ it('renders a loading state while AI state is loading', () => {
+ expect(mountComponent({ suggestions: [], loading: true }).text()).toContain(
+ 'Loading AI dashboard suggestions...',
+ );
+ });
+
+ it('lists pending suggestions with labels, confidence, reasoning, and review link', () => {
+ const wrapper = mountComponent({ suggestions: [suggestion()] });
+
+ expect(wrapper.text()).toContain('AI dashboard suggestions');
+ expect(wrapper.text()).toContain('Production evidence');
+ expect(wrapper.text()).toContain('env=prod');
+ expect(wrapper.text()).toContain('service=api');
+ expect(wrapper.text()).not.toContain('_policy=internal');
+ expect(wrapper.text()).toContain('91% confidence');
+ expect(wrapper.text()).toContain(
+ 'The production API evidence maps cleanly to this control.',
+ );
+ expect(wrapper.find('[data-test="review-link"]').exists()).toBe(true);
+ expect(wrapper.findComponent({ name: 'RouterLink' }).props('to')).toEqual({
+ name: 'dashboards.suggestions',
+ params: { sspId: 'ssp-1' },
+ });
+ });
+
+ it('renders evaluated no-match state with the run date', () => {
+ const evaluatedAt = '2026-06-18T10:30:00Z';
+ const wrapper = mountComponent({
+ result: {
+ controlId: 'AC-2',
+ outcome: 'no_match',
+ evaluatedAt,
+ },
+ });
+
+ expect(wrapper.text()).toContain(
+ 'AI reviewed this control and found no matching dashboard filter',
+ );
+ expect(wrapper.text()).toContain(new Date(evaluatedAt).toLocaleString());
+ });
+
+ it('renders subtle empty states for not evaluated and matched-without-pending controls', () => {
+ expect(mountComponent({}).text()).toContain(
+ "AI hasn't evaluated this control yet.",
+ );
+ expect(
+ mountComponent({
+ result: {
+ controlId: 'AC-1',
+ outcome: 'matched',
+ },
+ }).text(),
+ ).toContain('No pending AI dashboard suggestions for this control.');
+ });
+});
diff --git a/src/views/control-implementations/partials/__tests__/RiskIndicatorBadge.spec.ts b/src/views/control-implementations/partials/__tests__/RiskIndicatorBadge.spec.ts
new file mode 100644
index 00000000..0c5e5ff8
--- /dev/null
+++ b/src/views/control-implementations/partials/__tests__/RiskIndicatorBadge.spec.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { defineComponent } from 'vue';
+import RiskIndicatorBadge from '../RiskIndicatorBadge.vue';
+
+describe('RiskIndicatorBadge', () => {
+ it('renders nothing when there are no risks', () => {
+ const wrapper = mount(RiskIndicatorBadge, {
+ props: { riskCount: 0 },
+ });
+ expect(wrapper.find('button').exists()).toBe(false);
+ expect(wrapper.find('span').exists()).toBe(false);
+ });
+
+ it('caps the displayed count at 99+ when capped', () => {
+ const wrapper = mount(RiskIndicatorBadge, {
+ props: { riskCount: 250, isCapped: true },
+ });
+ expect(wrapper.text()).toContain('99+');
+ });
+
+ it('does not emit when not clickable', async () => {
+ const wrapper = mount(RiskIndicatorBadge, {
+ props: { riskCount: 3 },
+ });
+ await wrapper.find('span').trigger('click');
+ expect(wrapper.emitted('click')).toBeUndefined();
+ });
+
+ // Regression: emitting `click` without the native event made a parent
+ // `@click.stop` handler call `.stopPropagation()` on `undefined` and throw.
+ it('can be clicked through a parent @click.stop handler without throwing', async () => {
+ let handled = false;
+ const Parent = defineComponent({
+ components: { RiskIndicatorBadge },
+ setup() {
+ return {
+ onClick: () => {
+ handled = true;
+ },
+ };
+ },
+ template: ``,
+ });
+
+ const wrapper = mount(Parent);
+ await wrapper.find('button').trigger('click');
+ expect(handled).toBe(true);
+ });
+});
diff --git a/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts b/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts
index 0dab0e8a..6a2dbad1 100644
--- a/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts
+++ b/src/views/dashboard/partials/__tests__/dashboard-suggestions.spec.ts
@@ -1,10 +1,19 @@
import { describe, expect, it } from 'vitest';
import {
+ buildDashboardSuggestionControlResultsEndpoint,
buildLabelFilter,
computeGroupEditDiff,
type DashboardSuggestion,
} from '../dashboard-suggestions';
+describe('buildDashboardSuggestionControlResultsEndpoint', () => {
+ it('builds the latest control-result endpoint for an SSP', () => {
+ expect(buildDashboardSuggestionControlResultsEndpoint('ssp 1')).toBe(
+ '/api/oscal/system-security-plans/ssp%201/dashboard-suggestions/control-results',
+ );
+ });
+});
+
describe('buildLabelFilter', () => {
it('returns undefined when there are no valid conditions', () => {
expect(buildLabelFilter([])).toBeUndefined();
diff --git a/src/views/dashboard/partials/dashboard-suggestions.ts b/src/views/dashboard/partials/dashboard-suggestions.ts
index f193acc9..048c699f 100644
--- a/src/views/dashboard/partials/dashboard-suggestions.ts
+++ b/src/views/dashboard/partials/dashboard-suggestions.ts
@@ -157,6 +157,17 @@ export interface DashboardSuggestion {
updatedAt?: string;
}
+export type ControlSuggestionOutcome = 'matched' | 'no_match';
+
+export interface ControlSuggestionResult {
+ controlId: string;
+ controlCatalogId?: string;
+ outcome: ControlSuggestionOutcome;
+ suggestionCount?: number;
+ runId?: string;
+ evaluatedAt?: string;
+}
+
export type LabelChipKind = 'unchanged' | 'added' | 'removed';
export interface LabelChip {
@@ -333,6 +344,12 @@ export function buildDashboardSuggestionsEndpoint(
return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions${query}`;
}
+export function buildDashboardSuggestionControlResultsEndpoint(
+ sspId: string,
+): string {
+ return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions/control-results`;
+}
+
export function buildAcceptDashboardSuggestionsEndpoint(sspId: string): string {
return `${dashboardSuggestionsBaseEndpoint(sspId)}/dashboard-suggestions/accept`;
}
@@ -359,6 +376,18 @@ export function buildControlKey(catalogId: string, controlId: string): string {
return `${catalogId}:${controlId}`;
}
+// Response paths whose nested objects are user-defined label maps (keys like
+// `_policy` or `service_name`). The dashboard-suggestion endpoints return these
+// field names already camelCased, and `camelcase-keys` matches stopPaths against
+// the response's own keys — so these must stay camelCase, and the leading `_`
+// and snake_case label keys inside the maps are preserved rather than mangled.
+// Defined once so the request sites that read suggestion label maps can't drift.
+export const DASHBOARD_SUGGESTION_LABEL_STOP_PATHS: readonly string[] = [
+ 'data.labelSet',
+ 'data.proposedFilterLabelSet',
+ 'data.originalProposedFilterLabelSet',
+];
+
export function formatLabelSet(labels: Record): string[] {
return Object.entries(labels).map(([key, value]) => `${key}=${value}`);
}