From 61d7b8248ae8de1233dee2f030fcedcdd32638f2 Mon Sep 17 00:00:00 2001 From: onselakin Date: Fri, 20 Feb 2026 16:47:22 +0300 Subject: [PATCH 1/5] feat(profile): add compliance tab with dashboard-style progress view Add profile compliance route/tab, composable, typed models, and unit tests to render summary cards, progress bars, group breakdown, and control status details. --- src/composables/useProfileCompliance.ts | 53 ++++ src/router/index.ts | 8 + src/types/compliance.ts | 55 ++++ .../__tests__/ProfileComplianceView.spec.ts | 133 +++++++++ src/views/profile/ProfileComplianceView.vue | 273 ++++++++++++++++++ src/views/profile/ProfileView.vue | 1 + 6 files changed, 523 insertions(+) create mode 100644 src/composables/useProfileCompliance.ts create mode 100644 src/types/compliance.ts create mode 100644 src/views/__tests__/ProfileComplianceView.spec.ts create mode 100644 src/views/profile/ProfileComplianceView.vue diff --git a/src/composables/useProfileCompliance.ts b/src/composables/useProfileCompliance.ts new file mode 100644 index 00000000..0ac8932e --- /dev/null +++ b/src/composables/useProfileCompliance.ts @@ -0,0 +1,53 @@ +import { computed, toValue, type MaybeRefOrGetter } from 'vue'; +import { useDataApi } from '@/composables/axios'; +import type { ProfileComplianceProgress } from '@/types/compliance'; + +interface LoadComplianceOptions { + includeControls?: boolean; + sspId?: string; +} + +export function useProfileCompliance(profileId: MaybeRefOrGetter) { + const { + data, + isLoading, + error, + execute: executeCompliance, + } = useDataApi(null, null, { + immediate: false, + }); + + const progress = computed(() => data.value); + const summary = computed(() => progress.value?.summary); + const controls = computed(() => progress.value?.controls ?? []); + const groups = computed(() => progress.value?.groups ?? []); + + async function loadCompliance(options: LoadComplianceOptions = {}) { + const resolvedProfileID = toValue(profileId); + + const params = new URLSearchParams(); + if (typeof options.includeControls === 'boolean') { + params.set('includeControls', String(options.includeControls)); + } + if (options.sspId) { + params.set('sspId', options.sspId); + } + + const query = params.toString(); + const url = query.length + ? `/api/oscal/profiles/${resolvedProfileID}/compliance-progress?${query}` + : `/api/oscal/profiles/${resolvedProfileID}/compliance-progress`; + + await executeCompliance(url); + } + + return { + progress, + summary, + controls, + groups, + isLoading, + error, + loadCompliance, + }; +} diff --git a/src/router/index.ts b/src/router/index.ts index 2cb60a42..dd29d86a 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -323,6 +323,14 @@ const authenticatedRoutes = [ requiresAuth: true, }, }, + { + path: 'compliance', + name: 'profile:view-compliance', + component: () => import('../views/profile/ProfileComplianceView.vue'), + meta: { + requiresAuth: true, + }, + }, { path: 'json', name: 'profile:view-json', diff --git a/src/types/compliance.ts b/src/types/compliance.ts new file mode 100644 index 00000000..980bd4a2 --- /dev/null +++ b/src/types/compliance.ts @@ -0,0 +1,55 @@ +export interface ProfileComplianceStatusCount { + count: number; + status: string; +} + +export interface ProfileComplianceControl { + controlId: string; + catalogId: string; + title: string; + groupId?: string; + groupTitle?: string; + implemented?: boolean; + statusCounts: ProfileComplianceStatusCount[]; + computedStatus: string; +} + +export interface ProfileComplianceGroup { + id: string; + title: string; + totalControls: number; + satisfied: number; + notSatisfied: number; + unknown: number; + compliancePercent: number; +} + +export interface ProfileComplianceSummary { + totalControls: number; + satisfied: number; + notSatisfied: number; + unknown: number; + compliancePercent: number; + assessedPercent: number; + implementedControls?: number; +} + +export interface ProfileComplianceImplementation { + implementedControls: number; + implementationPercent: number; + unimplementedControls: number; +} + +export interface ProfileComplianceScope { + type: string; + id: string; + title: string; +} + +export interface ProfileComplianceProgress { + scope: ProfileComplianceScope; + summary: ProfileComplianceSummary; + implementation?: ProfileComplianceImplementation; + groups: ProfileComplianceGroup[]; + controls: ProfileComplianceControl[]; +} diff --git a/src/views/__tests__/ProfileComplianceView.spec.ts b/src/views/__tests__/ProfileComplianceView.spec.ts new file mode 100644 index 00000000..b807c36e --- /dev/null +++ b/src/views/__tests__/ProfileComplianceView.spec.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { reactive, ref } from 'vue'; +import { flushPromises, mount } from '@vue/test-utils'; +import ProfileComplianceView from '../profile/ProfileComplianceView.vue'; +import type { + ProfileComplianceControl, + ProfileComplianceGroup, + ProfileComplianceSummary, +} from '@/types/compliance'; + +const route = reactive({ params: { id: 'profile-1' } }); + +const mockSummary = ref(); +const mockControls = ref([]); +const mockGroups = ref([]); +const mockIsLoading = ref(false); +const mockError = ref(null); +const mockLoadCompliance = vi.fn(); +const mockToastAdd = vi.fn(); + +vi.mock('vue-router', () => ({ + useRoute: () => route, +})); + +vi.mock('primevue/usetoast', () => ({ + useToast: () => ({ + add: mockToastAdd, + }), +})); + +vi.mock('@/composables/useProfileCompliance', () => ({ + useProfileCompliance: () => ({ + summary: mockSummary, + controls: mockControls, + groups: mockGroups, + isLoading: mockIsLoading, + error: mockError, + loadCompliance: mockLoadCompliance, + }), +})); + +function mountComponent() { + return mount(ProfileComplianceView, { + global: { + stubs: { + PageHeader: { + template: '
', + }, + ResultStatusBadge: { + props: ['green', 'gray', 'red'], + template: + '
{{ green }} {{ gray }} {{ red }}
', + }, + }, + }, + }); +} + +describe('ProfileComplianceView', () => { + beforeEach(() => { + vi.clearAllMocks(); + route.params.id = 'profile-1'; + + mockSummary.value = { + totalControls: 3, + satisfied: 1, + notSatisfied: 1, + unknown: 1, + compliancePercent: 33, + assessedPercent: 67, + }; + + mockControls.value = [ + { + controlId: 'CTRL-SAT', + catalogId: 'catalog-1', + title: 'Satisfied Control', + statusCounts: [{ status: 'satisfied', count: 2 }], + computedStatus: 'satisfied', + }, + { + controlId: 'CTRL-NS', + catalogId: 'catalog-1', + title: 'Not Satisfied Control', + statusCounts: [{ status: 'not-satisfied', count: 1 }], + computedStatus: 'not-satisfied', + }, + { + controlId: 'CTRL-UNK', + catalogId: 'catalog-1', + title: 'Unknown Control', + statusCounts: [], + computedStatus: 'unknown', + }, + ]; + + mockGroups.value = [ + { + id: 'CC', + title: 'Common Criteria', + totalControls: 3, + satisfied: 1, + notSatisfied: 1, + unknown: 1, + compliancePercent: 33, + }, + ]; + + mockIsLoading.value = false; + mockError.value = null; + mockLoadCompliance.mockResolvedValue(undefined); + }); + + it('loads and renders compliance summary details', async () => { + const wrapper = mountComponent(); + await flushPromises(); + + expect(mockLoadCompliance).toHaveBeenCalledWith({ includeControls: true }); + expect(wrapper.text()).toContain('Overall Profile Progress'); + expect(wrapper.text()).toContain('33% compliant'); + expect(wrapper.text()).toContain('CTRL-SAT'); + expect(wrapper.text()).toContain('CTRL-NS'); + expect(wrapper.text()).toContain('CTRL-UNK'); + }); + + it('shows loading state', async () => { + mockIsLoading.value = true; + const wrapper = mountComponent(); + await flushPromises(); + + expect(wrapper.text()).toContain('Loading compliance progress...'); + }); +}); diff --git a/src/views/profile/ProfileComplianceView.vue b/src/views/profile/ProfileComplianceView.vue new file mode 100644 index 00000000..bb743d3d --- /dev/null +++ b/src/views/profile/ProfileComplianceView.vue @@ -0,0 +1,273 @@ + + + diff --git a/src/views/profile/ProfileView.vue b/src/views/profile/ProfileView.vue index 7b0f1545..3ba76fc1 100644 --- a/src/views/profile/ProfileView.vue +++ b/src/views/profile/ProfileView.vue @@ -67,6 +67,7 @@ watch(error, () => { const routes = ref([ { name: 'profile:view-controls', label: 'Controls' }, + { name: 'profile:view-compliance', label: 'Compliance' }, { name: 'profile:view-merge', label: 'Merge' }, { name: 'profile:view-json', label: 'JSON' }, ]); From a7a1bb433d20c9f3143d49b3eac7611e94a0a95f Mon Sep 17 00:00:00 2001 From: onselakin Date: Tue, 24 Feb 2026 13:25:38 +0300 Subject: [PATCH 2/5] feat(compliance): add no data message and improve compliance progress calculations --- .../__tests__/useProfileCompliance.spec.ts | 190 ++++++++++++++++++ src/views/profile/ProfileComplianceView.vue | 33 ++- 2 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 src/composables/__tests__/useProfileCompliance.spec.ts diff --git a/src/composables/__tests__/useProfileCompliance.spec.ts b/src/composables/__tests__/useProfileCompliance.spec.ts new file mode 100644 index 00000000..4ef9a6aa --- /dev/null +++ b/src/composables/__tests__/useProfileCompliance.spec.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref } from 'vue'; +import { useProfileCompliance } from '../useProfileCompliance'; +import type { ProfileComplianceProgress } from '@/types/compliance'; + +const mockExecute = vi.fn(); +const mockData: { value: ProfileComplianceProgress | undefined } = { + value: undefined, +}; + +vi.mock('@/composables/axios', () => ({ + useDataApi: vi.fn(() => ({ + data: mockData, + isLoading: ref(false), + error: ref(null), + execute: mockExecute, + })), +})); + +describe('useProfileCompliance', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockData.value = undefined; + }); + + describe('loadCompliance URL construction', () => { + it('builds a clean URL with no query params when no options given', async () => { + mockExecute.mockResolvedValue(undefined); + + const { loadCompliance } = useProfileCompliance('profile-abc'); + await loadCompliance(); + + expect(mockExecute).toHaveBeenCalledWith( + '/api/oscal/profiles/profile-abc/compliance-progress', + ); + }); + + it('appends includeControls=true when explicitly set', async () => { + mockExecute.mockResolvedValue(undefined); + + const { loadCompliance } = useProfileCompliance('profile-abc'); + await loadCompliance({ includeControls: true }); + + expect(mockExecute).toHaveBeenCalledWith( + '/api/oscal/profiles/profile-abc/compliance-progress?includeControls=true', + ); + }); + + it('appends includeControls=false when explicitly set', async () => { + mockExecute.mockResolvedValue(undefined); + + const { loadCompliance } = useProfileCompliance('profile-abc'); + await loadCompliance({ includeControls: false }); + + expect(mockExecute).toHaveBeenCalledWith( + '/api/oscal/profiles/profile-abc/compliance-progress?includeControls=false', + ); + }); + + it('does not append includeControls when omitted from options', async () => { + mockExecute.mockResolvedValue(undefined); + + const { loadCompliance } = useProfileCompliance('profile-abc'); + await loadCompliance({}); + + const calledUrl = mockExecute.mock.calls[0][0] as string; + expect(calledUrl).not.toContain('includeControls'); + }); + + it('appends sspId when provided', async () => { + mockExecute.mockResolvedValue(undefined); + + const { loadCompliance } = useProfileCompliance('profile-abc'); + await loadCompliance({ sspId: 'ssp-xyz' }); + + expect(mockExecute).toHaveBeenCalledWith( + '/api/oscal/profiles/profile-abc/compliance-progress?sspId=ssp-xyz', + ); + }); + + it('appends both includeControls and sspId when both provided', async () => { + mockExecute.mockResolvedValue(undefined); + + const { loadCompliance } = useProfileCompliance('profile-abc'); + await loadCompliance({ includeControls: false, sspId: 'ssp-xyz' }); + + const calledUrl = mockExecute.mock.calls[0][0] as string; + expect(calledUrl).toContain('includeControls=false'); + expect(calledUrl).toContain('sspId=ssp-xyz'); + }); + + it('does not append sspId when not provided', async () => { + mockExecute.mockResolvedValue(undefined); + + const { loadCompliance } = useProfileCompliance('profile-abc'); + await loadCompliance({ includeControls: true }); + + const calledUrl = mockExecute.mock.calls[0][0] as string; + expect(calledUrl).not.toContain('sspId'); + }); + + it('resolves the profileId from a ref', async () => { + mockExecute.mockResolvedValue(undefined); + + const profileIdRef = ref('profile-from-ref'); + const { loadCompliance } = useProfileCompliance(profileIdRef); + await loadCompliance(); + + expect(mockExecute).toHaveBeenCalledWith( + '/api/oscal/profiles/profile-from-ref/compliance-progress', + ); + }); + }); + + describe('derived computed refs', () => { + const sampleProgress: ProfileComplianceProgress = { + scope: { type: 'profile', id: 'profile-abc', title: 'Test Profile' }, + summary: { + totalControls: 3, + satisfied: 1, + notSatisfied: 1, + unknown: 1, + compliancePercent: 33, + assessedPercent: 67, + }, + groups: [ + { + id: 'GRP-1', + title: 'Group One', + totalControls: 3, + satisfied: 1, + notSatisfied: 1, + unknown: 1, + compliancePercent: 33, + }, + ], + controls: [ + { + controlId: 'CTRL-1', + catalogId: 'catalog-1', + title: 'Control One', + statusCounts: [{ status: 'satisfied', count: 2 }], + computedStatus: 'satisfied', + }, + ], + }; + + it('summary is undefined when data has not loaded', () => { + mockData.value = undefined; + const { summary } = useProfileCompliance('profile-abc'); + expect(summary.value).toBeUndefined(); + }); + + it('summary returns the loaded summary', () => { + mockData.value = sampleProgress; + const { summary } = useProfileCompliance('profile-abc'); + expect(summary.value).toEqual(sampleProgress.summary); + }); + + it('controls defaults to empty array when data has not loaded', () => { + mockData.value = undefined; + const { controls } = useProfileCompliance('profile-abc'); + expect(controls.value).toEqual([]); + }); + + it('controls returns the loaded controls array', () => { + mockData.value = sampleProgress; + const { controls } = useProfileCompliance('profile-abc'); + expect(controls.value).toEqual(sampleProgress.controls); + }); + + it('groups defaults to empty array when data has not loaded', () => { + mockData.value = undefined; + const { groups } = useProfileCompliance('profile-abc'); + expect(groups.value).toEqual([]); + }); + + it('groups returns the loaded groups array', () => { + mockData.value = sampleProgress; + const { groups } = useProfileCompliance('profile-abc'); + expect(groups.value).toEqual(sampleProgress.groups); + }); + + it('progress returns the full data object', () => { + mockData.value = sampleProgress; + const { progress } = useProfileCompliance('profile-abc'); + expect(progress.value).toEqual(sampleProgress); + }); + }); +}); diff --git a/src/views/profile/ProfileComplianceView.vue b/src/views/profile/ProfileComplianceView.vue index bb743d3d..e38654a1 100644 --- a/src/views/profile/ProfileComplianceView.vue +++ b/src/views/profile/ProfileComplianceView.vue @@ -171,6 +171,14 @@ + + @@ -217,16 +225,31 @@ watch(error, () => { }); }); -const satisfiedWidth = computed( - () => `${summary.value?.compliancePercent ?? 0}%`, -); +const satisfiedWidth = computed(() => { + if (!summary.value) return '0%'; + return `${percent(summary.value.satisfied, summary.value.totalControls)}%`; +}); const notSatisfiedWidth = computed(() => { if (!summary.value) return '0%'; - return `${percent(summary.value.notSatisfied, summary.value.totalControls)}%`; + const sat = percent(summary.value.satisfied, summary.value.totalControls); + const notSat = percent( + summary.value.notSatisfied, + summary.value.totalControls, + ); + // Fill remaining space to avoid rounding gaps + const remaining = 100 - sat; + const notSatCapped = Math.min(notSat, remaining); + return `${notSatCapped}%`; }); const unknownWidth = computed(() => { if (!summary.value) return '0%'; - return `${percent(summary.value.unknown, summary.value.totalControls)}%`; + const sat = percent(summary.value.satisfied, summary.value.totalControls); + const notSat = percent( + summary.value.notSatisfied, + summary.value.totalControls, + ); + const used = Math.min(sat + notSat, 100); + return `${100 - used}%`; }); function percent(part: number, total: number): number { From c15a1070f6d1e2def2413ec84ad6e73407fad9ea Mon Sep 17 00:00:00 2001 From: onselakin Date: Tue, 24 Feb 2026 15:48:24 +0300 Subject: [PATCH 3/5] feat(compliance): add compliance dashboard and related routing --- src/router/index.ts | 8 + .../dashboard/CompliancePostureWidget.vue | 255 +++++++++++ src/views/dashboard/IndexView.vue | 3 + .../SystemSecurityPlanComplianceView.vue | 408 ++++++++++++++++++ .../SystemSecurityPlanEditorView.vue | 41 +- 5 files changed, 706 insertions(+), 9 deletions(-) create mode 100644 src/views/dashboard/CompliancePostureWidget.vue create mode 100644 src/views/system-security-plans/SystemSecurityPlanComplianceView.vue diff --git a/src/router/index.ts b/src/router/index.ts index dd29d86a..ec718bea 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -630,6 +630,14 @@ const authenticatedRoutes = [ '../views/system-security-plans/SystemSecurityPlanControlImplementationView.vue' ), }, + { + path: 'compliance', + name: 'system-security-plan-compliance', + component: () => + import( + '../views/system-security-plans/SystemSecurityPlanComplianceView.vue' + ), + }, { path: 'json', name: 'system-security-plan-json', diff --git a/src/views/dashboard/CompliancePostureWidget.vue b/src/views/dashboard/CompliancePostureWidget.vue new file mode 100644 index 00000000..e5490560 --- /dev/null +++ b/src/views/dashboard/CompliancePostureWidget.vue @@ -0,0 +1,255 @@ + + + diff --git a/src/views/dashboard/IndexView.vue b/src/views/dashboard/IndexView.vue index 999f6c41..6419ca10 100644 --- a/src/views/dashboard/IndexView.vue +++ b/src/views/dashboard/IndexView.vue @@ -1,4 +1,6 @@ diff --git a/src/utils/compliance.spec.ts b/src/utils/compliance.spec.ts new file mode 100644 index 00000000..f0aaff79 --- /dev/null +++ b/src/utils/compliance.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { + computeComplianceWidths, + controlKey, + percent, + statusClass, + statusCount, + statusLabel, +} from './compliance'; + +describe('compliance utils', () => { + it('computes widths from summary values', () => { + expect( + computeComplianceWidths({ + totalControls: 3, + satisfied: 1, + notSatisfied: 1, + }), + ).toEqual({ + satisfied: 33, + notSatisfied: 33, + unknown: 34, + }); + }); + + it('returns zero widths when summary is missing', () => { + expect(computeComplianceWidths()).toEqual({ + satisfied: 0, + notSatisfied: 0, + unknown: 0, + }); + }); + + it('caps not-satisfied width to remaining percentage', () => { + expect( + computeComplianceWidths({ + totalControls: 1, + satisfied: 1, + notSatisfied: 1, + }), + ).toEqual({ + satisfied: 100, + notSatisfied: 0, + unknown: 0, + }); + }); + + it('computes percentages safely when total is zero', () => { + expect(percent(3, 0)).toBe(0); + }); + + it('builds a stable control key', () => { + expect( + controlKey({ + controlId: 'AC-1', + catalogId: 'NIST-800-53', + title: 'Access Control Policy and Procedures', + statusCounts: [], + computedStatus: 'unknown', + }), + ).toBe('NIST-800-53:AC-1'); + }); + + it('returns matching status count or zero fallback', () => { + const control = { + controlId: 'AC-2', + catalogId: 'NIST-800-53', + title: 'Account Management', + statusCounts: [ + { status: 'satisfied', count: 2 }, + { status: 'not-satisfied', count: 1 }, + ], + computedStatus: 'satisfied', + }; + + expect(statusCount(control, 'satisfied')).toBe(2); + expect(statusCount(control, 'unknown')).toBe(0); + }); + + it('maps status display classes and labels', () => { + expect(statusClass('satisfied')).toBe('bg-emerald-100 text-emerald-800'); + expect(statusClass('not-satisfied')).toBe('bg-red-100 text-red-800'); + expect(statusClass('anything-else')).toBe('bg-slate-100 text-slate-800'); + + expect(statusLabel('satisfied')).toBe('Satisfied'); + expect(statusLabel('not-satisfied')).toBe('Not Satisfied'); + expect(statusLabel('anything-else')).toBe('Unknown'); + }); +}); diff --git a/src/utils/compliance.ts b/src/utils/compliance.ts new file mode 100644 index 00000000..1f74c55f --- /dev/null +++ b/src/utils/compliance.ts @@ -0,0 +1,85 @@ +import type { + ProfileComplianceControl, + ProfileComplianceStatusCount, + ProfileComplianceSummary, +} from '@/types/compliance'; + +type ComplianceWidthSummary = Pick< + ProfileComplianceSummary, + 'totalControls' | 'satisfied' | 'notSatisfied' +>; + +export interface ComplianceWidths { + satisfied: number; + notSatisfied: number; + unknown: number; +} + +export function percent(part: number, total: number): number { + if (!total) return 0; + return Math.round((part / total) * 100); +} + +export function computeComplianceWidths( + summary?: ComplianceWidthSummary | null, +): ComplianceWidths { + if (!summary) { + return { + satisfied: 0, + notSatisfied: 0, + unknown: 0, + }; + } + + const satisfied = percent(summary.satisfied, summary.totalControls); + const notSatisfiedRaw = percent(summary.notSatisfied, summary.totalControls); + + const remaining = 100 - satisfied; + const notSatisfied = Math.min(notSatisfiedRaw, remaining); + + const used = Math.min(satisfied + notSatisfiedRaw, 100); + const unknown = 100 - used; + + return { + satisfied, + notSatisfied, + unknown, + }; +} + +export function controlKey(control: ProfileComplianceControl): string { + return `${control.catalogId}:${control.controlId}`; +} + +export function statusCount( + control: ProfileComplianceControl, + status: string, +): number { + return ( + control.statusCounts.find( + (item: ProfileComplianceStatusCount) => item.status === status, + )?.count || 0 + ); +} + +export function statusClass(status: string): string { + switch (status) { + case 'satisfied': + return 'bg-emerald-100 text-emerald-800'; + case 'not-satisfied': + return 'bg-red-100 text-red-800'; + default: + return 'bg-slate-100 text-slate-800'; + } +} + +export function statusLabel(status: string): string { + switch (status) { + case 'satisfied': + return 'Satisfied'; + case 'not-satisfied': + return 'Not Satisfied'; + default: + return 'Unknown'; + } +} diff --git a/src/views/dashboard/CompliancePostureWidget.vue b/src/views/dashboard/CompliancePostureWidget.vue index e5490560..3bbe07bc 100644 --- a/src/views/dashboard/CompliancePostureWidget.vue +++ b/src/views/dashboard/CompliancePostureWidget.vue @@ -137,6 +137,7 @@ import PageCard from '@/components/PageCard.vue'; import { useDataApi } from '@/composables/axios'; import type { SystemSecurityPlan, Profile } from '@/oscal'; import type { ProfileComplianceSummary } from '@/types/compliance'; +import { computeComplianceWidths } from '@/utils/compliance'; interface ComplianceItem { sspId: string; @@ -223,27 +224,16 @@ onMounted(async () => { } }); -function pct(part: number, total: number): number { - if (!total) return 0; - return Math.round((part / total) * 100); -} - function satisfiedWidth(summary: ProfileComplianceSummary): number { - return pct(summary.satisfied, summary.totalControls); + return computeComplianceWidths(summary).satisfied; } function notSatisfiedWidth(summary: ProfileComplianceSummary): number { - const sat = satisfiedWidth(summary); - const notSat = pct(summary.notSatisfied, summary.totalControls); - const remaining = 100 - sat; - return Math.min(notSat, remaining); + return computeComplianceWidths(summary).notSatisfied; } function unknownWidth(summary: ProfileComplianceSummary): number { - const sat = satisfiedWidth(summary); - const notSat = notSatisfiedWidth(summary); - const used = Math.min(sat + notSat, 100); - return 100 - used; + return computeComplianceWidths(summary).unknown; } function navigateToCompliance(sspId: string) { diff --git a/src/views/profile/ProfileComplianceView.vue b/src/views/profile/ProfileComplianceView.vue index e38654a1..8a259dbe 100644 --- a/src/views/profile/ProfileComplianceView.vue +++ b/src/views/profile/ProfileComplianceView.vue @@ -4,177 +4,17 @@ Loading compliance progress... - + - +