From 6281dbfa5ecc3d0d0596cf348de56dbc5e4a6a15 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Sat, 7 Feb 2026 14:10:08 -0500 Subject: [PATCH] feat: consolidate dashboard pages, fix namespace links, add tests Merge Overview and Full Audit into a single dashboard page that always shows the skipped check count. Fix namespace link 404s by using Headlamp's Link component (which generates cluster-prefixed URLs) instead of raw react-router-dom Link. Add vitest unit tests for all polaris.ts utility functions. Co-Authored-By: Claude Opus 4.6 --- claude.md | 12 +- package.json | 4 +- src/api/polaris.test.ts | 264 +++++++++++++++++++++++++ src/components/DashboardView.tsx | 69 +++---- src/components/NamespaceDetailView.tsx | 4 + src/components/NamespacesListView.tsx | 14 +- src/index.tsx | 22 +-- vitest.config.mts | 8 + 8 files changed, 329 insertions(+), 68 deletions(-) create mode 100644 src/api/polaris.test.ts create mode 100644 vitest.config.mts diff --git a/claude.md b/claude.md index 7554a92..333c417 100644 --- a/claude.md +++ b/claude.md @@ -23,6 +23,9 @@ npx tsc --noEmit # Lint npx eslint src/ + +# Run tests +npm test ``` ## Architecture @@ -32,15 +35,18 @@ src/ ├── index.tsx # Entry point: registers sidebar entries + routes ├── api/ │ ├── polaris.ts # Types (AuditData schema), usePolarisData hook, countResults utilities, refresh settings +│ ├── polaris.test.ts # Unit tests for utility functions (vitest) │ └── PolarisDataContext.tsx # React context provider for shared data fetch └── components/ - ├── DashboardView.tsx # Overview / Full Audit page (score, check summary, cluster info) + ├── DashboardView.tsx # Overview page (score, check summary with skipped count, cluster info) + ├── NamespacesListView.tsx # Namespace list with scores and links to detail views ├── NamespaceDetailView.tsx # Per-namespace drill-down with resource table - ├── DynamicSidebarRegistrar.tsx # Registers namespace sidebar entries from live audit data └── PolarisSettings.tsx # Plugin settings (refresh interval selector) ``` -Top-level sidebar section at `/polaris` with sub-routes for full audit (`/polaris/full-audit`) and per-namespace views (`/polaris/ns/:namespace`). Data is fetched via `ApiProxy.request` to the Polaris dashboard service proxy and refreshed on a user-configurable interval (stored in localStorage under `polaris-plugin-refresh-interval`, default 5 minutes). Score is computed from result counts (pass/total). +Top-level sidebar section at `/polaris` with sub-routes for namespaces list (`/polaris/namespaces`) and per-namespace views (`/polaris/ns/:namespace`). Data is fetched via `ApiProxy.request` to the Polaris dashboard service proxy and refreshed on a user-configurable interval (stored in localStorage under `polaris-plugin-refresh-interval`, default 5 minutes). Score is computed from result counts (pass/total). Skipped checks are always displayed in summaries. + +**Sidebar limitation**: Headlamp's sidebar only supports 2-level nesting (parent → children). The `Collapse` component is driven by route-based selection, not click-to-toggle, so 3-level hierarchies don't expand properly. Namespace navigation is handled via the in-content table on the Namespaces page instead. ## Security / RBAC Requirements diff --git a/package.json b/package.json index 9234ec7..5a80c74 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "lint": "eslint --ext .ts,.tsx src/", "lint:fix": "eslint --ext .ts,.tsx --fix src/", "format": "prettier --write src/", - "format:check": "prettier --check src/" + "format:check": "prettier --check src/", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { "@kinvolk/headlamp-plugin": "^0.13.0" diff --git a/src/api/polaris.test.ts b/src/api/polaris.test.ts new file mode 100644 index 0000000..bbc29e2 --- /dev/null +++ b/src/api/polaris.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + ApiProxy: { request: vi.fn() }, +})); + +import { + AuditData, + computeScore, + countResults, + countResultsForItems, + filterResultsByNamespace, + getNamespaces, + Result, + ResultCounts, +} from './polaris'; + +// --- Fixtures --- + +function makeResult(overrides: Partial = {}): Result { + return { + Name: 'my-deploy', + Namespace: 'default', + Kind: 'Deployment', + Results: {}, + CreatedTime: '2025-01-01T00:00:00Z', + ...overrides, + }; +} + +function makeAuditData(results: Result[]): AuditData { + return { + PolarisOutputVersion: '1.0', + AuditTime: '2025-01-01T00:00:00Z', + SourceType: 'Cluster', + SourceName: 'test', + DisplayName: 'test', + ClusterInfo: { Version: '1.28', Nodes: 3, Pods: 10, Namespaces: 2, Controllers: 5 }, + Results: results, + }; +} + +// --- computeScore --- + +describe('computeScore', () => { + it('returns 0 when total is 0', () => { + const counts: ResultCounts = { total: 0, pass: 0, warning: 0, danger: 0, skipped: 0 }; + expect(computeScore(counts)).toBe(0); + }); + + it('returns 100 when all checks pass', () => { + const counts: ResultCounts = { total: 10, pass: 10, warning: 0, danger: 0, skipped: 0 }; + expect(computeScore(counts)).toBe(100); + }); + + it('rounds to nearest integer', () => { + const counts: ResultCounts = { total: 3, pass: 1, warning: 1, danger: 1, skipped: 0 }; + expect(computeScore(counts)).toBe(33); + }); + + it('includes skipped in total denominator', () => { + const counts: ResultCounts = { total: 10, pass: 5, warning: 2, danger: 1, skipped: 2 }; + expect(computeScore(counts)).toBe(50); + }); +}); + +// --- countResults / countResultsForItems --- + +describe('countResults', () => { + it('returns zero counts for empty results', () => { + const data = makeAuditData([]); + const counts = countResults(data); + expect(counts).toEqual({ total: 0, pass: 0, warning: 0, danger: 0, skipped: 0 }); + }); + + it('counts top-level result set entries', () => { + const result = makeResult({ + Results: { + check1: { + ID: 'check1', + Message: 'ok', + Details: [], + Success: true, + Severity: 'warning', + Category: 'Security', + }, + check2: { + ID: 'check2', + Message: 'bad', + Details: [], + Success: false, + Severity: 'danger', + Category: 'Security', + }, + }, + }); + const counts = countResults(makeAuditData([result])); + expect(counts.total).toBe(2); + expect(counts.pass).toBe(1); + expect(counts.danger).toBe(1); + expect(counts.warning).toBe(0); + expect(counts.skipped).toBe(0); + }); + + it('counts skipped (severity=ignore, success=false) entries', () => { + const result = makeResult({ + Results: { + skipped1: { + ID: 'skipped1', + Message: 'skipped', + Details: [], + Success: false, + Severity: 'ignore', + Category: 'Security', + }, + }, + }); + const counts = countResults(makeAuditData([result])); + expect(counts.total).toBe(1); + expect(counts.skipped).toBe(1); + expect(counts.pass).toBe(0); + }); + + it('counts PodResult and ContainerResults', () => { + const result = makeResult({ + Results: { + top: { + ID: 'top', + Message: 'ok', + Details: [], + Success: true, + Severity: 'warning', + Category: 'Reliability', + }, + }, + PodResult: { + Name: 'pod-1', + Results: { + podCheck: { + ID: 'podCheck', + Message: 'warn', + Details: [], + Success: false, + Severity: 'warning', + Category: 'Reliability', + }, + }, + ContainerResults: [ + { + Name: 'container-1', + Results: { + containerCheck: { + ID: 'containerCheck', + Message: 'danger', + Details: [], + Success: false, + Severity: 'danger', + Category: 'Security', + }, + }, + }, + ], + }, + }); + const counts = countResults(makeAuditData([result])); + expect(counts.total).toBe(3); + expect(counts.pass).toBe(1); + expect(counts.warning).toBe(1); + expect(counts.danger).toBe(1); + }); + + it('aggregates across multiple results', () => { + const r1 = makeResult({ + Name: 'deploy-a', + Results: { + c1: { + ID: 'c1', + Message: '', + Details: [], + Success: true, + Severity: 'warning', + Category: 'X', + }, + }, + }); + const r2 = makeResult({ + Name: 'deploy-b', + Results: { + c2: { + ID: 'c2', + Message: '', + Details: [], + Success: false, + Severity: 'warning', + Category: 'X', + }, + }, + }); + const counts = countResults(makeAuditData([r1, r2])); + expect(counts.total).toBe(2); + expect(counts.pass).toBe(1); + expect(counts.warning).toBe(1); + }); +}); + +describe('countResultsForItems', () => { + it('works on a subset of results', () => { + const results: Result[] = [ + makeResult({ + Results: { + a: { + ID: 'a', + Message: '', + Details: [], + Success: false, + Severity: 'danger', + Category: 'X', + }, + }, + }), + ]; + const counts = countResultsForItems(results); + expect(counts.danger).toBe(1); + expect(counts.total).toBe(1); + }); +}); + +// --- getNamespaces --- + +describe('getNamespaces', () => { + it('returns empty array for no results', () => { + expect(getNamespaces(makeAuditData([]))).toEqual([]); + }); + + it('returns sorted unique namespaces', () => { + const data = makeAuditData([ + makeResult({ Namespace: 'beta' }), + makeResult({ Namespace: 'alpha' }), + makeResult({ Namespace: 'beta' }), + makeResult({ Namespace: 'gamma' }), + ]); + expect(getNamespaces(data)).toEqual(['alpha', 'beta', 'gamma']); + }); +}); + +// --- filterResultsByNamespace --- + +describe('filterResultsByNamespace', () => { + it('returns only results matching the namespace', () => { + const data = makeAuditData([ + makeResult({ Name: 'a', Namespace: 'ns1' }), + makeResult({ Name: 'b', Namespace: 'ns2' }), + makeResult({ Name: 'c', Namespace: 'ns1' }), + ]); + const filtered = filterResultsByNamespace(data, 'ns1'); + expect(filtered).toHaveLength(2); + expect(filtered.map(r => r.Name)).toEqual(['a', 'c']); + }); + + it('returns empty array for non-existent namespace', () => { + const data = makeAuditData([makeResult({ Namespace: 'ns1' })]); + expect(filterResultsByNamespace(data, 'ns-missing')).toEqual([]); + }); +}); diff --git a/src/components/DashboardView.tsx b/src/components/DashboardView.tsx index 24f56c6..a65a804 100644 --- a/src/components/DashboardView.tsx +++ b/src/components/DashboardView.tsx @@ -6,7 +6,7 @@ import { StatusLabel, } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; import React from 'react'; -import { AuditData, countResults, ResultCounts } from '../api/polaris'; +import { AuditData, computeScore, countResults, ResultCounts } from '../api/polaris'; import { usePolarisDataContext } from '../api/PolarisDataContext'; function scoreStatus(score: number): 'success' | 'warning' | 'error' { @@ -15,41 +15,11 @@ function scoreStatus(score: number): 'success' | 'warning' | 'error' { return 'error'; } -function OverviewSection(props: { - data: AuditData; - counts: ResultCounts; - includeSkipped: boolean; -}) { - const { counts, includeSkipped } = props; - - const displayTotal = includeSkipped ? counts.total : counts.total - counts.skipped; - const displayPass = counts.pass; - const score = displayTotal === 0 ? 0 : Math.round((displayPass / displayTotal) * 100); +function OverviewSection(props: { data: AuditData; counts: ResultCounts }) { + const { counts } = props; + const score = computeScore(counts); const status = scoreStatus(score); - const summaryRows: { name: string; value: React.ReactNode }[] = [ - { name: 'Total Checks', value: String(displayTotal) }, - { - name: 'Pass', - value: {counts.pass}, - }, - { - name: 'Warning', - value: {counts.warning}, - }, - { - name: 'Danger', - value: {counts.danger}, - }, - ]; - - if (includeSkipped) { - summaryRows.push({ - name: 'Skipped', - value: {counts.skipped}, - }); - } - return ( <> @@ -63,7 +33,27 @@ function OverviewSection(props: { /> - + {counts.pass}, + }, + { + name: 'Warning', + value: {counts.warning}, + }, + { + name: 'Danger', + value: {counts.danger}, + }, + { + name: 'Skipped', + value: {counts.skipped}, + }, + ]} + /> ; @@ -91,7 +80,7 @@ export default function DashboardView(props: { includeSkipped: boolean }) { return ( <> - + {error && ( @@ -106,9 +95,7 @@ export default function DashboardView(props: { includeSkipped: boolean }) { )} - {data && counts && ( - - )} + {data && counts && } {!data && !error && ( diff --git a/src/components/NamespaceDetailView.tsx b/src/components/NamespaceDetailView.tsx index 612724d..2edfd3b 100644 --- a/src/components/NamespaceDetailView.tsx +++ b/src/components/NamespaceDetailView.tsx @@ -118,6 +118,10 @@ export default function NamespaceDetailView() { name: 'Danger', value: {counts.danger}, }, + { + name: 'Skipped', + value: {counts.skipped}, + }, ]} /> diff --git a/src/components/NamespacesListView.tsx b/src/components/NamespacesListView.tsx index a580940..299deb4 100644 --- a/src/components/NamespacesListView.tsx +++ b/src/components/NamespacesListView.tsx @@ -1,4 +1,5 @@ import { + Link, Loader, NameValueTable, SectionBox, @@ -7,7 +8,6 @@ import { StatusLabel, } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; import React from 'react'; -import { Link } from 'react-router-dom'; import { computeScore, countResultsForItems, @@ -28,6 +28,7 @@ interface NamespaceRow { pass: number; warning: number; danger: number; + skipped: number; } export default function NamespacesListView() { @@ -77,6 +78,7 @@ export default function NamespacesListView() { pass: counts.pass, warning: counts.warning, danger: counts.danger, + skipped: counts.skipped, }; }); @@ -89,7 +91,9 @@ export default function NamespacesListView() { { label: 'Namespace', getter: (row: NamespaceRow) => ( - {row.namespace} + + {row.namespace} + ), }, { @@ -116,6 +120,12 @@ export default function NamespacesListView() { {row.danger} ), }, + { + label: 'Skipped', + getter: (row: NamespaceRow) => ( + {row.skipped} + ), + }, ]} data={rows} emptyMessage="No namespaces found in Polaris audit data." diff --git a/src/index.tsx b/src/index.tsx index db49107..0275edf 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -28,14 +28,6 @@ registerSidebarEntry({ icon: 'mdi:view-dashboard', }); -registerSidebarEntry({ - parent: 'polaris', - name: 'polaris-full', - label: 'Full Audit', - url: '/polaris/full-audit', - icon: 'mdi:clipboard-text-search', -}); - registerSidebarEntry({ parent: 'polaris', name: 'polaris-namespaces', @@ -53,19 +45,7 @@ registerRoute({ exact: true, component: () => ( - - - ), -}); - -registerRoute({ - path: '/polaris/full-audit', - sidebar: 'polaris-full', - name: 'polaris-full-audit', - exact: true, - component: () => ( - - + ), }); diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..5270893 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + }, +});