diff --git a/src/composables/__tests__/useDashboardSuggestions.spec.ts b/src/composables/__tests__/useDashboardSuggestions.spec.ts new file mode 100644 index 00000000..ab46decb --- /dev/null +++ b/src/composables/__tests__/useDashboardSuggestions.spec.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { effectScope, ref } from 'vue'; + +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + toastAdd: vi.fn(), +})); + +vi.mock('@/composables/axios', () => ({ + useDataApi: () => ({ + execute: mocks.execute, + isLoading: { value: false }, + error: { value: null }, + }), + decamelizeKeys: vi.fn((data: unknown) => JSON.stringify(data)), +})); + +vi.mock('primevue/usetoast', () => ({ + useToast: () => ({ add: mocks.toastAdd }), +})); + +import { useSuggestionRunPoller } from '../useDashboardSuggestions'; + +function response(status: string, completedCells: number, id = status) { + return { + data: { + value: { + data: { + id, + status, + plannedCalls: 2, + completedCells, + failedCells: 0, + }, + }, + }, + }; +} + +describe('useSuggestionRunPoller', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts polling active runs, stops on terminal status, and fires toast', async () => { + const onPoll = vi.fn(); + mocks.execute + .mockResolvedValueOnce(response('running', 1, 'run-1')) + .mockResolvedValueOnce(response('completed', 2, 'run-1')); + + const scope = effectScope(); + const poller = scope.run(() => + useSuggestionRunPoller(ref('ssp-1'), onPoll), + ); + + await poller?.pollLatest(); + expect(poller?.isPolling.value).toBe(true); + + await vi.advanceTimersByTimeAsync(3000); + + expect(mocks.execute).toHaveBeenCalledTimes(2); + expect(onPoll).toHaveBeenCalledTimes(2); + expect(poller?.isPolling.value).toBe(false); + expect(mocks.toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'success', + summary: 'Suggestions ready', + }), + ); + + scope.stop(); + }); + + it('keeps polling after a transient poll error', async () => { + mocks.execute + .mockRejectedValueOnce(new Error('temporary outage')) + .mockResolvedValueOnce(response('completed', 2, 'run-1')); + + const scope = effectScope(); + const poller = scope.run(() => useSuggestionRunPoller(ref('ssp-1'))); + + poller?.start(); + await vi.advanceTimersByTimeAsync(3000); + await vi.advanceTimersByTimeAsync(3000); + + expect(mocks.execute).toHaveBeenCalledTimes(2); + + scope.stop(); + }); +}); diff --git a/src/composables/axios/index.ts b/src/composables/axios/index.ts index 21db1cf0..756bcdad 100644 --- a/src/composables/axios/index.ts +++ b/src/composables/axios/index.ts @@ -14,6 +14,15 @@ import { useAxios } from '@vueuse/integrations/useAxios'; import camelcaseKeys from 'camelcase-keys'; import { default as _decamelizeKeys } from 'decamelize-keys'; +declare module 'axios' { + interface AxiosRequestConfig { + // Object paths whose child keys should be left untouched by the response + // camelcase conversion (e.g. arbitrary label maps that may use snake_case + // or `_`-prefixed keys we need to preserve verbatim). + camelcaseStopPaths?: readonly string[]; + } +} + const useAuthenticatedInstance = () => { const userStore = useUserStore(); const configStore = useConfigStore(); @@ -62,7 +71,11 @@ const useAuthenticatedInstance = () => { // Brute force camelcase conversion. OSCAL apis are all kebab-case so should be converted to // camel case, but any manually written APIs will be camel case and therefore won't change if (response.data) { - response.data = camelcaseKeys(response.data, { deep: true }); + const stopPaths = response.config?.camelcaseStopPaths; + response.data = camelcaseKeys(response.data, { + deep: true, + ...(stopPaths ? { stopPaths: [...stopPaths] } : {}), + }); } return response; }, diff --git a/src/composables/useDashboardSuggestions.ts b/src/composables/useDashboardSuggestions.ts new file mode 100644 index 00000000..42e0e191 --- /dev/null +++ b/src/composables/useDashboardSuggestions.ts @@ -0,0 +1,315 @@ +import { + computed, + onMounted, + onUnmounted, + ref, + shallowRef, + toValue, + watch, + type MaybeRef, + type Ref, +} from 'vue'; +import { useToast } from 'primevue/usetoast'; +import { decamelizeKeys, useDataApi } from '@/composables/axios'; +import { + buildAcceptDashboardSuggestionsEndpoint, + buildDashboardSuggestionEventsEndpoint, + buildDashboardSuggestionLabelSetsEndpoint, + buildDashboardSuggestionsEndpoint, + buildGenerateDashboardSuggestionsEndpoint, + buildLatestDashboardSuggestionRunEndpoint, + buildRejectDashboardSuggestionsEndpoint, + type DashboardSuggestion, + type DashboardSuggestionEvent, + type DashboardSuggestionLabelSet, + type GenerateDashboardSuggestionsPayload, + type SuggestionRun, + isRunActive, +} from '@/views/dashboard/partials/dashboard-suggestions'; + +export function useDashboardSuggestions( + sspId: Ref, + enabled: MaybeRef = true, +) { + const { + data: pendingSuggestions, + execute: fetchPendingSuggestions, + isLoading: pendingSuggestionsLoading, + } = useDataApi(null, {}, { immediate: false }); + const historySuggestions = shallowRef([]); + const { execute: fetchHistoryRequest, isLoading: historySuggestionsLoading } = + useDataApi(null, {}, { immediate: false }); + const { + data: labelSets, + execute: fetchLabelSets, + isLoading: labelSetsLoading, + } = useDataApi(null, {}, { immediate: false }); + + const { execute: generateRequest, isLoading: generating } = + useDataApi(null, {}, { immediate: false }); + const { execute: acceptRequest, isLoading: accepting } = useDataApi( + null, + {}, + { immediate: false }, + ); + const { execute: rejectRequest, isLoading: rejecting } = useDataApi( + null, + {}, + { immediate: false }, + ); + const { execute: eventsRequest, isLoading: loadingEvents } = useDataApi< + DashboardSuggestionEvent[] + >(null, {}, { immediate: false }); + + function canFetchSuggestions() { + return Boolean(toValue(enabled) && sspId.value); + } + + async function refreshPendingSuggestions() { + if (!canFetchSuggestions()) { + return; + } + + return fetchPendingSuggestions( + buildDashboardSuggestionsEndpoint(sspId.value, 'pending'), + { camelcaseStopPaths: ['data.labelSet'] }, + ); + } + + async function refreshLabelSets() { + if (!canFetchSuggestions()) { + return; + } + + return fetchLabelSets( + buildDashboardSuggestionLabelSetsEndpoint(sspId.value), + // Preserve raw label keys (e.g. `_agent`, `_plugin`) so the UI can filter + // out internal labels; otherwise camelcase conversion strips the `_`. + { camelcaseStopPaths: ['data.labels'] }, + ); + } + + async function generateSuggestions( + payload: GenerateDashboardSuggestionsPayload, + ) { + if (!canFetchSuggestions()) { + return; + } + + return generateRequest( + buildGenerateDashboardSuggestionsEndpoint(sspId.value), + { + method: 'POST', + data: payload, + transformRequest: [decamelizeKeys], + }, + ); + } + + async function acceptSuggestions(ids: string[]) { + if (!canFetchSuggestions()) { + return; + } + + await acceptRequest(buildAcceptDashboardSuggestionsEndpoint(sspId.value), { + method: 'POST', + data: { ids }, + transformRequest: [decamelizeKeys], + }); + await refreshPendingSuggestions(); + await refreshHistorySuggestions(); + } + + async function refreshHistorySuggestions() { + if (!canFetchSuggestions()) { + historySuggestions.value = []; + return; + } + + const statuses = ['accepted', 'rejected', 'superseded']; + const collected: DashboardSuggestion[] = []; + + for (const status of statuses) { + const response = await fetchHistoryRequest( + buildDashboardSuggestionsEndpoint(sspId.value, status), + { camelcaseStopPaths: ['data.labelSet'] }, + ); + collected.push(...(response?.data.value?.data ?? [])); + } + + historySuggestions.value = collected; + } + + async function rejectSuggestions(ids: string[], reason?: string) { + if (!canFetchSuggestions()) { + return; + } + + await rejectRequest(buildRejectDashboardSuggestionsEndpoint(sspId.value), { + method: 'POST', + data: { ids, reason }, + transformRequest: [decamelizeKeys], + }); + await refreshPendingSuggestions(); + await refreshHistorySuggestions(); + } + + async function fetchSuggestionEvents(suggestionId: string) { + if (!canFetchSuggestions()) { + return []; + } + + const response = await eventsRequest( + buildDashboardSuggestionEventsEndpoint(sspId.value, suggestionId), + ); + return response?.data.value?.data ?? []; + } + + onMounted(() => { + if (canFetchSuggestions()) { + void refreshHistorySuggestions(); + } + }); + + return { + pendingSuggestions, + historySuggestions, + labelSets, + pendingSuggestionsLoading, + historySuggestionsLoading, + labelSetsLoading, + generating, + accepting, + rejecting, + loadingEvents, + refreshPendingSuggestions, + refreshHistorySuggestions, + refreshLabelSets, + generateSuggestions, + acceptSuggestions, + rejectSuggestions, + fetchSuggestionEvents, + }; +} + +interface SuggestionRunPollerOptions { + stopOnPollError?: boolean; +} + +export function useSuggestionRunPoller( + sspId: Ref, + onPoll?: () => Promise | void, + options: SuggestionRunPollerOptions = {}, +) { + const toast = useToast(); + const run = ref(); + const timer = ref(); + const terminalToastShownFor = ref(); + + const { execute, isLoading, error } = useDataApi( + null, + {}, + { immediate: false }, + ); + + const progressPercent = computed(() => { + if (!run.value?.plannedCalls) { + return 0; + } + return Math.min( + 100, + Math.round((run.value.completedCells / run.value.plannedCalls) * 100), + ); + }); + + function stop() { + if (timer.value) { + window.clearInterval(timer.value); + timer.value = undefined; + } + } + + async function pollLatest() { + if (!sspId.value) { + return; + } + + try { + const response = await execute( + buildLatestDashboardSuggestionRunEndpoint(sspId.value), + ); + const latestRun = response?.data.value?.data; + if (!latestRun) { + return; + } + run.value = latestRun; + await onPoll?.(); + + if (isRunActive(latestRun)) { + start(); + return; + } + + stop(); + const runKey = latestRun.uuid ?? latestRun.id ?? latestRun.updatedAt; + if (runKey && terminalToastShownFor.value === runKey) { + return; + } + terminalToastShownFor.value = runKey; + + 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) { + stop(); + } + } + } + + function start() { + if (timer.value) { + return; + } + timer.value = window.setInterval(() => { + void pollLatest(); + }, 3000); + } + + watch( + run, + (nextRun) => { + if (isRunActive(nextRun)) { + start(); + } else { + stop(); + } + }, + { immediate: true }, + ); + + onUnmounted(stop); + + return { + run, + isPolling: computed(() => Boolean(timer.value)), + isLoading, + error, + progressPercent, + pollLatest, + start, + stop, + }; +} diff --git a/src/router/__tests__/index.spec.ts b/src/router/__tests__/index.spec.ts index 33cbe07f..1bf28353 100644 --- a/src/router/__tests__/index.spec.ts +++ b/src/router/__tests__/index.spec.ts @@ -36,4 +36,19 @@ describe('router', () => { }).path, ).toBe('/system/components/dashboards/component-1'); }); + + it('registers the dashboard suggestions review route behind auth meta', () => { + const route = router + .getRoutes() + .find((route) => route.name === 'dashboards.suggestions'); + + expect(route?.path).toBe('/dashboards/suggestions/:sspId'); + expect(route?.meta.requiresAuth).toBe(true); + expect( + router.resolve({ + name: 'dashboards.suggestions', + params: { sspId: 'ssp-1' }, + }).path, + ).toBe('/dashboards/suggestions/ssp-1'); + }); }); diff --git a/src/router/index.ts b/src/router/index.ts index 01b6cbb3..5c02a8e8 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -37,6 +37,14 @@ const authenticatedRoutes = [ requiresAuth: true, }, }, + { + path: '/dashboards/suggestions/:sspId', + name: 'dashboards.suggestions', + component: () => import('../views/dashboard/SuggestionsView.vue'), + meta: { + requiresAuth: true, + }, + }, { path: '/dashboards/:id', name: 'dashboards.view', diff --git a/src/stores/ai-config.ts b/src/stores/ai-config.ts new file mode 100644 index 00000000..c7382e62 --- /dev/null +++ b/src/stores/ai-config.ts @@ -0,0 +1,45 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import { useDataApi } from '@/composables/axios'; +import { buildDashboardSuggestionsConfigEndpoint } from '@/views/dashboard/partials/dashboard-suggestions'; + +interface DashboardSuggestionsConfig { + enabled: boolean; +} + +export const useAiConfigStore = defineStore('ai-config', () => { + const fetched = ref(false); + const enabled = ref(false); + + const { execute, isLoading, error } = useDataApi( + null, + null, + { immediate: false }, + ); + + async function fetchDashboardSuggestionsConfig() { + if (fetched.value) { + return enabled.value; + } + + try { + const response = await execute(buildDashboardSuggestionsConfigEndpoint()); + const config = response?.data.value?.data; + enabled.value = Boolean(config?.enabled); + } catch { + enabled.value = false; + } finally { + fetched.value = true; + } + + return enabled.value; + } + + return { + dashboardSuggestionsEnabled: computed(() => enabled.value), + dashboardSuggestionsConfigFetched: computed(() => fetched.value), + dashboardSuggestionsConfigLoading: isLoading, + dashboardSuggestionsConfigError: error, + fetchDashboardSuggestionsConfig, + }; +}); diff --git a/src/stores/filters.ts b/src/stores/filters.ts index 18512962..c6ce6d1f 100644 --- a/src/stores/filters.ts +++ b/src/stores/filters.ts @@ -5,6 +5,7 @@ export interface Dashboard { id?: string; uuid?: string; name: string; + sspId?: string | null; filter: Filter; controls: Control[]; components: SystemComponent[]; diff --git a/src/views/control-implementations/partials/ControlStatementImplementation.vue b/src/views/control-implementations/partials/ControlStatementImplementation.vue index c738f144..ed15a7d9 100644 --- a/src/views/control-implementations/partials/ControlStatementImplementation.vue +++ b/src/views/control-implementations/partials/ControlStatementImplementation.vue @@ -30,6 +30,7 @@ import SystemImplementationComponentCreateForm from '@/components/system-securit import DashboardEvidenceCounter from '@/views/control-implementations/partials/DashboardEvidenceCounter.vue'; import TooltipTitle from '@/components/TooltipTitle.vue'; import Message from '@/volt/Message.vue'; +import { useAiConfigStore } from '@/stores/ai-config'; import ControlStatementMetadata from './ControlStatementMetadata.vue'; import ControlStatementSuggestions from './ControlStatementSuggestions.vue'; import ControlStatementByComponents from './ControlStatementByComponents.vue'; @@ -57,6 +58,7 @@ const { system } = useSystemStore(); const toast = useToast(); const router = useRouter(); const axios = useAuthenticatedInstance(); +const aiConfig = useAiConfigStore(); const showCreateStatementModal = ref(false); const showEditStatementModal = ref(false); @@ -393,6 +395,8 @@ watch(selectedComponent, () => { }); onMounted(() => { + void aiConfig.fetchDashboardSuggestionsConfig(); + if (!system.securityPlan?.uuid) { return; } @@ -1199,6 +1203,17 @@ function viewDashboardEvidence(dashboard: DashboardWithControls) { } } +function openDashboardSuggestions() { + if (!resolvedSspId.value || !aiConfig.dashboardSuggestionsEnabled) { + return; + } + + router.push({ + name: 'dashboards.suggestions', + params: { sspId: resolvedSspId.value }, + }); +} + // Get unique titles for the dropdown (only show each title once) const uniqueEvidenceTitles = computed(() => { const titleMap = new Map(); @@ -1420,6 +1435,31 @@ async function submitEvidenceLinking() { @apply-suggestion="applySuggestedComponent" /> +
+ + AI dashboard suggestions + +
+ + + AI is not configured, so dashboard suggestions cannot be generated. + + Result Filter +
+ +