fix: comprehensive code quality, theming, and test coverage improvements

- Fix ExemptionManager apiVersion bug (apps/batch resources used wrong API path)
- Replace resource: any with proper KubeResource interface (strict TypeScript)
- Replace all var(--mui-palette-*) CSS variables with useTheme() + theme.palette.*
- Replace custom drawer with MUI Drawer component (proper a11y and theming)
- Replace alert() calls with StatusLabel-based inline feedback
- Add PolarisErrorBoundary wrapping all registered plugin components
- Export getPolarisApiPath/isFullUrl from polaris.ts, deduplicate in PolarisSettings
- Fix PolarisDataContext test mock missing triggerRefresh
- Fix DashboardView test SimpleTable mock using any
- Remove dead NamespaceDetailView (replaced by drawer), unused MockPolarisProvider,
  unused getSeverityColor export
- Add tests for InlineAuditSection, AppBarScoreBadge, topIssues, checkMapping (32 new)
- Update CLAUDE.md, CHANGELOG.md, README.md for v0.6.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DevContainer User
2026-03-04 16:59:50 +00:00
parent 6dd64e87ce
commit 514de78ba7
23 changed files with 1087 additions and 729 deletions
+30 -1
View File
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.6.0] - 2026-03-04
### Fixed
- **ExemptionManager apiVersion bug**: `apps` and `batch` resources now correctly use `/apis/{group}/v1/` instead of the broken `/api/v1/` path
- **Strict TypeScript**: Replaced `resource: any` in InlineAuditSection with proper `KubeResource` interface
- **PolarisDataContext test mock**: Added missing `triggerRefresh` to mock, preventing silent `undefined` for `refresh` in context
- **DashboardView test**: Fixed `SimpleTable` mock that used `Array<any>` and didn't exercise column getters
### Changed
- **Dark mode / theming**: Replaced all `var(--mui-palette-*)` CSS variables with `useTheme()` + `theme.palette.*` across all components (DashboardView, NamespacesListView, InlineAuditSection, ExemptionManager, PolarisSettings, AppBarScoreBadge)
- **Namespace drawer**: Replaced custom `<style>` block + positioned `<div>` with MUI `Drawer` component for proper accessibility (`role="dialog"`, `aria-modal`, Escape key handling via MUI)
- **AppBarScoreBadge**: Uses `theme.palette.success/warning/error` with proper `contrastText` instead of hardcoded hex colors
- **ExemptionManager feedback**: Replaced `alert()` calls with `StatusLabel`-based inline feedback; removed dead `getExemptions()` stub and unreachable remove-exemption UI
- **URL construction**: Exported `getPolarisApiPath` and `isFullUrl` from `polaris.ts`; PolarisSettings now reuses them instead of duplicating logic
### Added
- **Error boundaries**: All registered components (routes, detail sections, app bar action) wrapped in `PolarisErrorBoundary` for graceful error rendering
- **Tests for InlineAuditSection** (7 tests): loading, unsupported kind, not found, score/summary, failing checks, link, exemption manager
- **Tests for AppBarScoreBadge** (6 tests): loading, no data, score colors, navigation, aria-label
- **Tests for topIssues.ts** (8 tests): empty, all pass, controller/pod/container results, counting, ignore filter, sorting, max 10
- **Tests for checkMapping.ts** (11 tests): name/description/category/severity lookups, unknown checks, CHECK_MAPPING structure validation
### Removed
- **NamespaceDetailView.tsx**: Dead code with no registered route (replaced by drawer in NamespacesListView)
- **NamespaceDetailView.test.tsx**: Tests for removed component
- **MockPolarisProvider in test-utils.tsx**: Unused mock provider (tests use `vi.mock` instead)
- **`getSeverityColor` export in checkMapping.ts**: Dead export not imported anywhere
## [0.3.5] - 2026-02-12 ## [0.3.5] - 2026-02-12
### Fixed ### Fixed
@@ -242,7 +270,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Automated release workflow - Automated release workflow
- Basic CI/CD pipeline - Basic CI/CD pipeline
[Unreleased]: https://github.com/privilegedescalation/headlamp-polaris-plugin/compare/v0.3.5...HEAD [Unreleased]: https://github.com/privilegedescalation/headlamp-polaris-plugin/compare/v0.6.0...HEAD
[0.6.0]: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/tag/v0.6.0
[0.3.5]: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/tag/v0.3.5 [0.3.5]: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/tag/v0.3.5
[0.3.4]: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/tag/v0.3.4 [0.3.4]: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/tag/v0.3.4
[0.3.3]: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/tag/v0.3.3 [0.3.3]: https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/tag/v0.3.3
+10 -8
View File
@@ -35,17 +35,16 @@ All tests and `tsc` must pass before committing.
``` ```
src/ src/
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, registerDetailsViewSection, registerAppBarAction, registerPluginSettings ├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, registerDetailsViewSection, registerAppBarAction, registerPluginSettings; PolarisErrorBoundary
├── test-utils.tsx # Shared test utilities ├── test-utils.tsx # Shared test fixtures (makeResult, makeAuditData)
├── api/ ├── api/
│ ├── polaris.ts # Types (AuditData schema), countResults utilities, refresh settings │ ├── polaris.ts # Types (AuditData schema), countResults utilities, refresh settings, getPolarisApiPath, isFullUrl
│ ├── checkMapping.ts # Polaris check ID → human-readable name mapping │ ├── checkMapping.ts # Polaris check ID → human-readable name mapping
│ ├── topIssues.ts # Top failing checks aggregation logic │ ├── topIssues.ts # Top failing checks aggregation logic
│ └── PolarisDataContext.tsx # Shared React context provider (ApiProxy.request + configurable refresh) │ └── PolarisDataContext.tsx # Shared React context provider (ApiProxy.request + configurable refresh)
└── components/ └── components/
├── DashboardView.tsx # Overview page (score gauge, check distribution, top failing checks) ├── DashboardView.tsx # Overview page (score gauge, check distribution, top failing checks)
├── NamespacesListView.tsx # Namespace list with per-namespace scores ├── NamespacesListView.tsx # Namespace list with per-namespace scores + MUI Drawer detail panel
├── NamespaceDetailView.tsx # Per-namespace drill-down with resource table
├── InlineAuditSection.tsx # Injected into Deployment/StatefulSet/DaemonSet/Job/CronJob detail views ├── InlineAuditSection.tsx # Injected into Deployment/StatefulSet/DaemonSet/Job/CronJob detail views
├── ExemptionManager.tsx # Polaris exemption annotation management ├── ExemptionManager.tsx # Polaris exemption annotation management
├── AppBarScoreBadge.tsx # App bar cluster score chip ├── AppBarScoreBadge.tsx # App bar cluster score chip
@@ -60,12 +59,15 @@ Data is fetched via `ApiProxy.request` to the Polaris dashboard service proxy an
## Code conventions ## Code conventions
- Functional React components only — no class components - Functional React components only — class components only for error boundaries (PolarisErrorBoundary in index.tsx)
- All imports from `@kinvolk/headlamp-plugin/lib` and `@kinvolk/headlamp-plugin/lib/CommonComponents` - All imports from `@kinvolk/headlamp-plugin/lib` and `@kinvolk/headlamp-plugin/lib/CommonComponents`
- No additional UI libraries (no MUI direct imports, no Ant Design, etc.) - `@mui/material` is available as a shared external via Headlamp — use `useTheme` from `@mui/material/styles` for theming, MUI `Drawer`/`IconButton` etc. as needed. Do NOT add `@mui/material` to package.json dependencies.
- Use `useTheme()` + `theme.palette.*` for all theme-aware colors — never use `var(--mui-palette-*)` CSS variables
- No other UI libraries (no Ant Design, etc.)
- TypeScript strict mode — no `any`, use `unknown` + type guards at API boundaries - TypeScript strict mode — no `any`, use `unknown` + type guards at API boundaries
- Context provider (`PolarisDataProvider`) wraps each route component in `index.tsx` - Context provider (`PolarisDataProvider`) wraps each route component in `index.tsx`
- Tests: vitest + @testing-library/react, mock with `vi.mock('@kinvolk/headlamp-plugin/lib', ...)` - All registered components wrapped in `PolarisErrorBoundary` for graceful error handling
- Tests: vitest + @testing-library/react, mock with `vi.mock('@kinvolk/headlamp-plugin/lib', ...)` and `vi.mock('@mui/material/styles', ...)`
- `vitest.setup.ts` provides a spec-compliant `localStorage` shim for Node 22+ compatibility - `vitest.setup.ts` provides a spec-compliant `localStorage` shim for Node 22+ compatibility
## Testing ## Testing
+11 -7
View File
@@ -26,7 +26,7 @@ Adds a **Polaris** top-level sidebar section to Headlamp with comprehensive secu
- **Exemption Management** -- add or remove Polaris exemptions via annotation patches directly from the UI; supports per-check exemptions or exempt-all - **Exemption Management** -- add or remove Polaris exemptions via annotation patches directly from the UI; supports per-check exemptions or exempt-all
- **Configurable Dashboard URL** -- supports both Kubernetes service proxy URLs and full HTTP/HTTPS URLs for external Polaris deployments - **Configurable Dashboard URL** -- supports both Kubernetes service proxy URLs and full HTTP/HTTPS URLs for external Polaris deployments
- **Connection Testing** -- test button in settings to verify Polaris dashboard connectivity and show version info - **Connection Testing** -- test button in settings to verify Polaris dashboard connectivity and show version info
- **Dark Mode Support** -- full theme adaptation using MUI CSS variables; drawer, settings, and all UI elements respect system/Headlamp theme - **Dark Mode Support** -- full theme adaptation using MUI `useTheme()` API; drawer, settings, and all UI elements respect system/Headlamp theme
### Data & Refresh ### Data & Refresh
@@ -199,7 +199,7 @@ Quick reference:
| **Plugin not in sidebar** | Plugin not installed or needs browser refresh | Hard refresh browser (Cmd+Shift+R / Ctrl+Shift+F5) | | **Plugin not in sidebar** | Plugin not installed or needs browser refresh | Hard refresh browser (Cmd+Shift+R / Ctrl+Shift+F5) |
| **403 Access Denied** | Missing RBAC binding for `services/proxy` | Apply Role + RoleBinding from RBAC section | | **403 Access Denied** | Missing RBAC binding for `services/proxy` | Apply Role + RoleBinding from RBAC section |
| **404 or 503** | Polaris not installed, or dashboard disabled | Install Polaris with `dashboard.enabled: true` in `polaris` namespace | | **404 or 503** | Polaris not installed, or dashboard disabled | Install Polaris with `dashboard.enabled: true` in `polaris` namespace |
| **Dark mode white backgrounds** | Old plugin version | Upgrade to v0.3.5+ and hard refresh browser | | **Dark mode white backgrounds** | Old plugin version | Upgrade to v0.6.0+ and hard refresh browser |
| **Settings page empty** | Old plugin version | Upgrade to v0.3.3+ | | **Settings page empty** | Old plugin version | Upgrade to v0.3.3+ |
| **No data / infinite spinner** | Network policy or Polaris pod down | Check network policies and `kubectl get pods -n polaris` | | **No data / infinite spinner** | Network policy or Polaris pod down | Check network policies and `kubectl get pods -n polaris` |
@@ -253,17 +253,21 @@ For complete testing guide including CI/CD integration, see **[docs/TESTING.md](
``` ```
src/ src/
index.tsx -- Entry point. Registers sidebar entries and routes. index.tsx -- Entry point. Registers sidebar entries, routes, and error boundaries.
test-utils.tsx -- Shared test fixtures (makeResult, makeAuditData).
api/ api/
polaris.ts -- TypeScript types (AuditData schema), usePolarisData hook, polaris.ts -- TypeScript types (AuditData schema), usePolarisData hook,
countResults utilities, refresh interval settings. countResults utilities, refresh interval settings.
polaris.test.ts -- Unit tests for utility functions (vitest). checkMapping.ts -- Polaris check ID → human-readable name mapping.
topIssues.ts -- Top failing checks aggregation logic.
PolarisDataContext.tsx -- React context provider; shared data fetch across views. PolarisDataContext.tsx -- React context provider; shared data fetch across views.
components/ components/
DashboardView.tsx -- Overview page (score, check summary with skipped, cluster info). DashboardView.tsx -- Overview page (score, check summary with skipped, cluster info).
NamespacesListView.tsx -- Namespace list with scores and links to detail views. NamespacesListView.tsx -- Namespace list with scores; MUI Drawer detail panel.
NamespaceDetailView.tsx -- Per-namespace drill-down with resource table. InlineAuditSection.tsx -- Inline audit for Deployment/StatefulSet/DaemonSet/Job/CronJob detail views.
PolarisSettings.tsx -- Plugin settings page (refresh interval selector). ExemptionManager.tsx -- Polaris exemption annotation management.
AppBarScoreBadge.tsx -- App bar cluster score chip.
PolarisSettings.tsx -- Plugin settings page (refresh interval, dashboard URL).
vitest.config.mts -- Vitest configuration (jsdom environment). vitest.config.mts -- Vitest configuration (jsdom environment).
``` ```
+2
View File
@@ -16,6 +16,7 @@ vi.mock('./polaris', async importOriginal => {
data: makeAuditData([makeResult()]), data: makeAuditData([makeResult()]),
loading: false, loading: false,
error: null, error: null,
triggerRefresh: vi.fn(),
})), })),
}; };
}); });
@@ -44,5 +45,6 @@ describe('usePolarisDataContext', () => {
expect(result.current.data).not.toBeNull(); expect(result.current.data).not.toBeNull();
expect(result.current.loading).toBe(false); expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull(); expect(result.current.error).toBeNull();
expect(result.current.refresh).toBeDefined();
}); });
}); });
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import {
CHECK_MAPPING,
getCheckCategory,
getCheckDescription,
getCheckName,
getSeverityStatus,
} from './checkMapping';
describe('checkMapping', () => {
describe('getCheckName', () => {
it('returns human-readable name for known check IDs', () => {
expect(getCheckName('hostIPCSet')).toBe('Host IPC');
expect(getCheckName('cpuRequestsMissing')).toBe('CPU Requests');
expect(getCheckName('readinessProbeMissing')).toBe('Readiness Probe');
});
it('returns the raw check ID for unknown checks', () => {
expect(getCheckName('unknownCheck')).toBe('unknownCheck');
});
});
describe('getCheckDescription', () => {
it('returns description for known checks', () => {
expect(getCheckDescription('hostIPCSet')).toBe('Host IPC should not be configured');
});
it('returns "Unknown check" for unknown checks', () => {
expect(getCheckDescription('unknownCheck')).toBe('Unknown check');
});
});
describe('getCheckCategory', () => {
it('returns correct category for each type', () => {
expect(getCheckCategory('hostIPCSet')).toBe('Security');
expect(getCheckCategory('cpuRequestsMissing')).toBe('Efficiency');
expect(getCheckCategory('readinessProbeMissing')).toBe('Reliability');
});
it('defaults to Security for unknown checks', () => {
expect(getCheckCategory('unknownCheck')).toBe('Security');
});
});
describe('getSeverityStatus', () => {
it('maps danger to error', () => {
expect(getSeverityStatus('danger')).toBe('error');
});
it('maps warning to warning', () => {
expect(getSeverityStatus('warning')).toBe('warning');
});
it('defaults to success for other values', () => {
expect(getSeverityStatus('ignore')).toBe('success');
expect(getSeverityStatus('unknown')).toBe('success');
});
});
describe('CHECK_MAPPING', () => {
it('has entries for all expected categories', () => {
const categories = new Set(Object.values(CHECK_MAPPING).map(c => c.category));
expect(categories).toContain('Security');
expect(categories).toContain('Efficiency');
expect(categories).toContain('Reliability');
});
it('all entries have required fields', () => {
for (const [id, info] of Object.entries(CHECK_MAPPING)) {
expect(info.name, `${id} missing name`).toBeTruthy();
expect(info.description, `${id} missing description`).toBeTruthy();
expect(['Security', 'Efficiency', 'Reliability']).toContain(info.category);
expect(['danger', 'warning', 'ignore']).toContain(info.defaultSeverity);
}
});
});
});
-16
View File
@@ -207,22 +207,6 @@ export function getCheckCategory(checkId: string): 'Security' | 'Efficiency' | '
return CHECK_MAPPING[checkId]?.category || 'Security'; return CHECK_MAPPING[checkId]?.category || 'Security';
} }
/**
* Get color for severity
*/
export function getSeverityColor(severity: string): string {
switch (severity) {
case 'danger':
return '#f44336';
case 'warning':
return '#ff9800';
case 'ignore':
return '#9e9e9e';
default:
return '#9e9e9e';
}
}
/** /**
* Get status for StatusLabel component * Get status for StatusLabel component
*/ */
+2 -2
View File
@@ -300,7 +300,7 @@ export function computeScore(counts: ResultCounts): number {
* *
* @returns Full path to results.json endpoint * @returns Full path to results.json endpoint
*/ */
function getPolarisApiPath(): string { export function getPolarisApiPath(): string {
const baseUrl = getDashboardUrl(); const baseUrl = getDashboardUrl();
return baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`; return baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`;
} }
@@ -311,7 +311,7 @@ function getPolarisApiPath(): string {
* @param url - URL to check * @param url - URL to check
* @returns true if full URL, false if relative path * @returns true if full URL, false if relative path
*/ */
function isFullUrl(url: string): boolean { export function isFullUrl(url: string): boolean {
return url.startsWith('http://') || url.startsWith('https://'); return url.startsWith('http://') || url.startsWith('https://');
} }
+216
View File
@@ -0,0 +1,216 @@
import { describe, expect, it } from 'vitest';
import { makeAuditData, makeResult } from '../test-utils';
import { getTopIssues } from './topIssues';
describe('getTopIssues', () => {
it('returns empty array when no results', () => {
const data = makeAuditData([]);
expect(getTopIssues(data)).toEqual([]);
});
it('returns empty array when all checks pass', () => {
const data = makeAuditData([
makeResult({
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
},
}),
]);
expect(getTopIssues(data)).toEqual([]);
});
it('aggregates failing checks from controller-level results', () => {
const data = makeAuditData([
makeResult({
Results: {
cpuRequestsMissing: {
ID: 'cpuRequestsMissing',
Message: 'missing',
Details: [],
Success: false,
Severity: 'warning',
Category: 'Efficiency',
},
},
}),
]);
const issues = getTopIssues(data);
expect(issues).toHaveLength(1);
expect(issues[0].checkId).toBe('cpuRequestsMissing');
expect(issues[0].checkName).toBe('CPU Requests');
expect(issues[0].severity).toBe('warning');
expect(issues[0].count).toBe(1);
});
it('aggregates failing checks from pod and container results', () => {
const data = makeAuditData([
makeResult({
Results: {},
PodResult: {
Name: 'pod-1',
Results: {
hostIPCSet: {
ID: 'hostIPCSet',
Message: '',
Details: [],
Success: false,
Severity: 'danger',
Category: 'Security',
},
},
ContainerResults: [
{
Name: 'container-1',
Results: {
cpuLimitsMissing: {
ID: 'cpuLimitsMissing',
Message: '',
Details: [],
Success: false,
Severity: 'warning',
Category: 'Efficiency',
},
},
},
],
},
}),
]);
const issues = getTopIssues(data);
expect(issues).toHaveLength(2);
// Danger first
expect(issues[0].checkId).toBe('hostIPCSet');
expect(issues[0].severity).toBe('danger');
expect(issues[1].checkId).toBe('cpuLimitsMissing');
});
it('counts same check across multiple workloads', () => {
const data = makeAuditData([
makeResult({
Name: 'deploy-1',
Results: {
cpuRequestsMissing: {
ID: 'cpuRequestsMissing',
Message: '',
Details: [],
Success: false,
Severity: 'warning',
Category: 'Efficiency',
},
},
}),
makeResult({
Name: 'deploy-2',
Results: {
cpuRequestsMissing: {
ID: 'cpuRequestsMissing',
Message: '',
Details: [],
Success: false,
Severity: 'warning',
Category: 'Efficiency',
},
},
}),
]);
const issues = getTopIssues(data);
expect(issues).toHaveLength(1);
expect(issues[0].count).toBe(2);
});
it('ignores checks with severity "ignore"', () => {
const data = makeAuditData([
makeResult({
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: false,
Severity: 'ignore',
Category: 'X',
},
},
}),
]);
expect(getTopIssues(data)).toEqual([]);
});
it('sorts danger before warning, then by count descending', () => {
const data = makeAuditData([
makeResult({
Name: 'deploy-1',
Results: {
cpuRequestsMissing: {
ID: 'cpuRequestsMissing',
Message: '',
Details: [],
Success: false,
Severity: 'warning',
Category: 'Efficiency',
},
},
}),
makeResult({
Name: 'deploy-2',
Results: {
cpuRequestsMissing: {
ID: 'cpuRequestsMissing',
Message: '',
Details: [],
Success: false,
Severity: 'warning',
Category: 'Efficiency',
},
hostIPCSet: {
ID: 'hostIPCSet',
Message: '',
Details: [],
Success: false,
Severity: 'danger',
Category: 'Security',
},
},
}),
]);
const issues = getTopIssues(data);
// Danger first regardless of count
expect(issues[0].severity).toBe('danger');
expect(issues[1].severity).toBe('warning');
expect(issues[1].count).toBe(2);
});
it('returns at most 10 issues', () => {
const results: Record<
string,
{
ID: string;
Message: string;
Details: string[];
Success: boolean;
Severity: 'warning';
Category: string;
}
> = {};
for (let i = 0; i < 15; i++) {
results[`check${i}`] = {
ID: `check${i}`,
Message: '',
Details: [],
Success: false,
Severity: 'warning',
Category: 'X',
};
}
const data = makeAuditData([makeResult({ Results: results })]);
expect(getTopIssues(data)).toHaveLength(10);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { makeAuditData, makeResult } from '../test-utils';
// Mock Headlamp lib
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: vi.fn() },
}));
vi.mock('@mui/material/styles', () => ({
useTheme: () => ({
palette: {
success: { main: '#4caf50', contrastText: '#fff' },
warning: { main: '#ff9800', contrastText: '#000' },
error: { main: '#f44336', contrastText: '#fff' },
},
}),
}));
const mockPush = vi.fn();
vi.mock('react-router-dom', () => ({
useHistory: () => ({ push: mockPush }),
}));
const mockUsePolarisDataContext = vi.fn();
vi.mock('../api/PolarisDataContext', () => ({
usePolarisDataContext: () => mockUsePolarisDataContext(),
}));
import AppBarScoreBadge from './AppBarScoreBadge';
describe('AppBarScoreBadge', () => {
it('returns null when loading', () => {
mockUsePolarisDataContext.mockReturnValue({ data: null, loading: true });
const { container } = render(<AppBarScoreBadge />);
expect(container.innerHTML).toBe('');
});
it('returns null when no data', () => {
mockUsePolarisDataContext.mockReturnValue({ data: null, loading: false });
const { container } = render(<AppBarScoreBadge />);
expect(container.innerHTML).toBe('');
});
it('renders score with success color for high score', () => {
const data = makeAuditData([
makeResult({
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({ data, loading: false });
render(<AppBarScoreBadge />);
const button = screen.getByRole('button');
expect(button).toHaveTextContent('Polaris: 100%');
expect(button.style.backgroundColor).toBe('rgb(76, 175, 80)');
});
it('renders score with error color for low score', () => {
const data = makeAuditData([
makeResult({
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: false,
Severity: 'danger',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({ data, loading: false });
render(<AppBarScoreBadge />);
const button = screen.getByRole('button');
expect(button).toHaveTextContent('Polaris: 0%');
expect(button.style.backgroundColor).toBe('rgb(244, 67, 54)');
});
it('navigates to /polaris on click', async () => {
const user = userEvent.setup();
const data = makeAuditData([
makeResult({
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({ data, loading: false });
render(<AppBarScoreBadge />);
await user.click(screen.getByRole('button'));
expect(mockPush).toHaveBeenCalledWith('/polaris');
});
it('has correct aria-label', () => {
const data = makeAuditData([
makeResult({
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({ data, loading: false });
render(<AppBarScoreBadge />);
expect(screen.getByLabelText('Polaris cluster score: 100%')).toBeInTheDocument();
});
});
+14 -7
View File
@@ -1,3 +1,4 @@
import { useTheme } from '@mui/material/styles';
import React from 'react'; import React from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { computeScore, countResults } from '../api/polaris'; import { computeScore, countResults } from '../api/polaris';
@@ -8,6 +9,7 @@ import { usePolarisDataContext } from '../api/PolarisDataContext';
* Clicking navigates to the overview dashboard * Clicking navigates to the overview dashboard
*/ */
export default function AppBarScoreBadge() { export default function AppBarScoreBadge() {
const theme = useTheme();
const { data, loading } = usePolarisDataContext(); const { data, loading } = usePolarisDataContext();
const history = useHistory(); const history = useHistory();
@@ -18,11 +20,17 @@ export default function AppBarScoreBadge() {
const counts = countResults(data); const counts = countResults(data);
const score = computeScore(counts); const score = computeScore(counts);
// Color based on score // Color based on score using theme palette
const getColor = (score: number): string => { const getColor = (s: number): string => {
if (score >= 80) return '#4caf50'; // green if (s >= 80) return theme.palette.success.main;
if (score >= 50) return '#ff9800'; // orange if (s >= 50) return theme.palette.warning.main;
return '#f44336'; // red return theme.palette.error.main;
};
const getContrastColor = (s: number): string => {
if (s >= 80) return theme.palette.success.contrastText;
if (s >= 50) return theme.palette.warning.contrastText;
return theme.palette.error.contrastText;
}; };
const handleClick = () => { const handleClick = () => {
@@ -39,7 +47,7 @@ export default function AppBarScoreBadge() {
borderRadius: '16px', borderRadius: '16px',
border: 'none', border: 'none',
backgroundColor: getColor(score), backgroundColor: getColor(score),
color: 'white', color: getContrastColor(score),
fontSize: '13px', fontSize: '13px',
fontWeight: 500, fontWeight: 500,
display: 'inline-flex', display: 'inline-flex',
@@ -48,7 +56,6 @@ export default function AppBarScoreBadge() {
}} }}
aria-label={`Polaris cluster score: ${score}%`} aria-label={`Polaris cluster score: ${score}%`}
> >
<span>🛡</span>
<span>Polaris: {score}%</span> <span>Polaris: {score}%</span>
</button> </button>
); );
+28 -4
View File
@@ -8,6 +8,15 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: vi.fn() }, ApiProxy: { request: vi.fn() },
})); }));
vi.mock('@mui/material/styles', () => ({
useTheme: () => ({
palette: {
primary: { main: '#1976d2' },
text: { primary: '#000', secondary: '#666' },
},
}),
}));
// Mock Headlamp CommonComponents as thin pass-throughs // Mock Headlamp CommonComponents as thin pass-throughs
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>, Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
@@ -34,12 +43,27 @@ vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
</tbody> </tbody>
</table> </table>
), ),
SimpleTable: ({ data }: { data: Array<any> }) => ( SimpleTable: ({
columns,
data,
}: {
columns: Array<{ label: string; getter: (row: unknown) => React.ReactNode }>;
data: unknown[];
}) => (
<table data-testid="simple-table"> <table data-testid="simple-table">
<thead>
<tr>
{columns.map(col => (
<th key={col.label}>{col.label}</th>
))}
</tr>
</thead>
<tbody> <tbody>
{data.map((item, idx) => ( {data.map((row, i) => (
<tr key={idx}> <tr key={i}>
<td>{JSON.stringify(item)}</td> {columns.map(col => (
<td key={col.label}>{col.getter(row)}</td>
))}
</tr> </tr>
))} ))}
</tbody> </tbody>
+5 -3
View File
@@ -8,6 +8,7 @@ import {
SimpleTable, SimpleTable,
StatusLabel, StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents'; } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { useTheme } from '@mui/material/styles';
import React from 'react'; import React from 'react';
import { getSeverityStatus } from '../api/checkMapping'; import { getSeverityStatus } from '../api/checkMapping';
import { AuditData, computeScore, countResults, ResultCounts } from '../api/polaris'; import { AuditData, computeScore, countResults, ResultCounts } from '../api/polaris';
@@ -87,6 +88,7 @@ function formatAuditTime(auditTime: string): string {
} }
export default function DashboardView() { export default function DashboardView() {
const theme = useTheme();
const { data, loading, error, refresh } = usePolarisDataContext(); const { data, loading, error, refresh } = usePolarisDataContext();
if (loading) { if (loading) {
@@ -109,7 +111,7 @@ export default function DashboardView() {
<SectionHeader title="Polaris — Overview" /> <SectionHeader title="Polaris — Overview" />
{data && ( {data && (
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<span style={{ fontSize: '14px', color: 'var(--mui-palette-text-secondary, #666)' }}> <span style={{ fontSize: '14px', color: theme.palette.text.secondary }}>
Last updated: {formatAuditTime(data.AuditTime)} Last updated: {formatAuditTime(data.AuditTime)}
</span> </span>
<button <button
@@ -117,8 +119,8 @@ export default function DashboardView() {
style={{ style={{
padding: '6px 16px', padding: '6px 16px',
backgroundColor: 'transparent', backgroundColor: 'transparent',
color: 'var(--mui-palette-primary-main, #1976d2)', color: theme.palette.primary.main,
border: '1px solid var(--mui-palette-primary-main, #1976d2)', border: `1px solid ${theme.palette.primary.main}`,
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: 'pointer',
fontSize: '13px', fontSize: '13px',
+33 -62
View File
@@ -1,5 +1,6 @@
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib'; import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
import { Dialog, NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; import { Dialog, SectionBox, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { useTheme } from '@mui/material/styles';
import React from 'react'; import React from 'react';
import { getCheckName } from '../api/checkMapping'; import { getCheckName } from '../api/checkMapping';
import { Result } from '../api/polaris'; import { Result } from '../api/polaris';
@@ -26,17 +27,14 @@ export default function ExemptionManager({
kind, kind,
name, name,
}: ExemptionManagerProps) { }: ExemptionManagerProps) {
const theme = useTheme();
const [dialogOpen, setDialogOpen] = React.useState(false); const [dialogOpen, setDialogOpen] = React.useState(false);
const [selectedChecks, setSelectedChecks] = React.useState<Set<string>>(new Set()); const [selectedChecks, setSelectedChecks] = React.useState<Set<string>>(new Set());
const [exemptAll, setExemptAll] = React.useState(false); const [exemptAll, setExemptAll] = React.useState(false);
const [applying, setApplying] = React.useState(false); const [applying, setApplying] = React.useState(false);
const [feedback, setFeedback] = React.useState<{ success: boolean; message: string } | null>(
// Extract current exemptions from workload metadata null
const getExemptions = (): string[] => { );
// This would need to fetch the actual workload from K8s API
// For now, return empty array as placeholder
return [];
};
// Extract failing checks for this workload // Extract failing checks for this workload
const getFailingChecks = (): CheckFailure[] => { const getFailingChecks = (): CheckFailure[] => {
@@ -75,7 +73,6 @@ export default function ExemptionManager({
}; };
const failingChecks = getFailingChecks(); const failingChecks = getFailingChecks();
const currentExemptions = getExemptions();
const handleCheckToggle = (checkId: string) => { const handleCheckToggle = (checkId: string) => {
const newSelected = new Set(selectedChecks); const newSelected = new Set(selectedChecks);
@@ -89,15 +86,15 @@ export default function ExemptionManager({
const applyExemptions = async () => { const applyExemptions = async () => {
setApplying(true); setApplying(true);
setFeedback(null);
try { try {
// Construct the API path based on kind // Construct the API path based on kind
const apiGroup = getApiGroup(kind); const apiGroup = getApiGroup(kind);
const apiVersion = 'v1'; // This would need to be dynamic based on kind
const plural = getPlural(kind); const plural = getPlural(kind);
const patchPath = apiGroup const patchPath = apiGroup
? `/apis/${apiGroup}/${apiVersion}/namespaces/${namespace}/${plural}/${name}` ? `/apis/${apiGroup}/v1/namespaces/${namespace}/${plural}/${name}`
: `/api/v1/namespaces/${namespace}/${plural}/${name}`; : `/api/v1/namespaces/${namespace}/${plural}/${name}`;
// Build annotations patch // Build annotations patch
@@ -128,46 +125,27 @@ export default function ExemptionManager({
setDialogOpen(false); setDialogOpen(false);
setSelectedChecks(new Set()); setSelectedChecks(new Set());
setExemptAll(false); setExemptAll(false);
setFeedback({ success: true, message: 'Exemptions applied successfully' });
// Show success message (would need notistack integration)
alert('Exemptions applied successfully');
} catch (err) { } catch (err) {
alert(`Failed to apply exemptions: ${String(err)}`); setFeedback({ success: false, message: `Failed to apply exemptions: ${String(err)}` });
} finally { } finally {
setApplying(false); setApplying(false);
} }
}; };
const isDisabled = applying || (!exemptAll && selectedChecks.size === 0);
return ( return (
<> <>
<SectionBox title="Exemptions"> <SectionBox title="Exemptions">
{currentExemptions.length > 0 ? ( <p>No exemptions configured</p>
<NameValueTable
rows={currentExemptions.map(exemption => ({ {feedback && (
name: exemption, <div style={{ marginBottom: '8px' }}>
value: ( <StatusLabel status={feedback.success ? 'success' : 'error'}>
<button {feedback.message}
style={{ </StatusLabel>
padding: '4px 12px', </div>
backgroundColor: 'var(--mui-palette-error-main, #f44336)',
color: 'var(--mui-palette-error-contrastText, #fff)',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
}}
onClick={() => {
// Remove exemption logic
alert('Remove exemption: ' + exemption);
}}
>
Remove
</button>
),
}))}
/>
) : (
<p>No exemptions configured</p>
)} )}
<button <button
@@ -177,18 +155,14 @@ export default function ExemptionManager({
marginTop: '8px', marginTop: '8px',
padding: '6px 16px', padding: '6px 16px',
backgroundColor: backgroundColor:
failingChecks.length === 0 failingChecks.length === 0 ? theme.palette.action.disabledBackground : 'transparent',
? 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
: 'transparent',
color: color:
failingChecks.length === 0 failingChecks.length === 0
? 'var(--mui-palette-action-disabled, #9e9e9e)' ? theme.palette.action.disabled
: 'var(--mui-palette-primary-main, #1976d2)', : theme.palette.primary.main,
border: '1px solid', border: '1px solid',
borderColor: borderColor:
failingChecks.length === 0 failingChecks.length === 0 ? theme.palette.divider : theme.palette.primary.main,
? 'var(--mui-palette-divider, #e0e0e0)'
: 'var(--mui-palette-primary-main, #1976d2)',
borderRadius: '4px', borderRadius: '4px',
cursor: failingChecks.length === 0 ? 'not-allowed' : 'pointer', cursor: failingChecks.length === 0 ? 'not-allowed' : 'pointer',
fontSize: '13px', fontSize: '13px',
@@ -246,7 +220,7 @@ export default function ExemptionManager({
style={{ style={{
padding: '6px 16px', padding: '6px 16px',
backgroundColor: 'transparent', backgroundColor: 'transparent',
color: 'var(--mui-palette-primary-main, #1976d2)', color: theme.palette.primary.main,
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: 'pointer',
@@ -257,21 +231,18 @@ export default function ExemptionManager({
</button> </button>
<button <button
onClick={applyExemptions} onClick={applyExemptions}
disabled={applying || (!exemptAll && selectedChecks.size === 0)} disabled={isDisabled}
style={{ style={{
padding: '6px 16px', padding: '6px 16px',
backgroundColor: backgroundColor: isDisabled
applying || (!exemptAll && selectedChecks.size === 0) ? theme.palette.action.disabledBackground
? 'var(--mui-palette-action-disabledBackground, #e0e0e0)' : theme.palette.primary.main,
: 'var(--mui-palette-primary-main, #1976d2)', color: isDisabled
color: ? theme.palette.action.disabled
applying || (!exemptAll && selectedChecks.size === 0) : theme.palette.primary.contrastText,
? 'var(--mui-palette-action-disabled, #9e9e9e)'
: 'var(--mui-palette-primary-contrastText, #fff)',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: cursor: isDisabled ? 'not-allowed' : 'pointer',
applying || (!exemptAll && selectedChecks.size === 0) ? 'not-allowed' : 'pointer',
fontSize: '13px', fontSize: '13px',
fontWeight: 500, fontWeight: 500,
}} }}
+278
View File
@@ -0,0 +1,278 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { makeAuditData, makeResult } from '../test-utils';
// Mock Headlamp lib
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: vi.fn() },
}));
vi.mock('@mui/material/styles', () => ({
useTheme: () => ({
palette: {
primary: { main: '#1976d2' },
text: { primary: '#000', secondary: '#666' },
action: { disabledBackground: '#e0e0e0', disabled: '#9e9e9e' },
divider: '#e0e0e0',
error: { main: '#f44336', contrastText: '#fff' },
success: { main: '#4caf50' },
warning: { main: '#ff9800' },
},
}),
}));
// Mock react-router-dom
vi.mock('react-router-dom', () => ({
Link: ({ to, children, style }: { to: string; children: React.ReactNode; style?: object }) => (
<a href={to} style={style}>
{children}
</a>
),
}));
// Mock Headlamp CommonComponents
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
<div data-testid="section-box" data-title={title}>
{children}
</div>
),
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
<span data-testid="status-label" data-status={status}>
{children}
</span>
),
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => (
<table data-testid="name-value-table">
<tbody>
{rows.map(row => (
<tr key={row.name}>
<td>{row.name}</td>
<td>{row.value}</td>
</tr>
))}
</tbody>
</table>
),
SimpleTable: ({
columns,
data,
}: {
columns: Array<{ label: string; getter: (row: unknown) => React.ReactNode }>;
data: unknown[];
}) => (
<table data-testid="simple-table">
<thead>
<tr>
{columns.map(col => (
<th key={col.label}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{columns.map(col => (
<td key={col.label}>{col.getter(row)}</td>
))}
</tr>
))}
</tbody>
</table>
),
Dialog: () => null,
}));
// Mock ExemptionManager
vi.mock('./ExemptionManager', () => ({
default: () => <div data-testid="exemption-manager" />,
}));
const mockUsePolarisDataContext = vi.fn();
vi.mock('../api/PolarisDataContext', () => ({
usePolarisDataContext: () => mockUsePolarisDataContext(),
}));
import InlineAuditSection from './InlineAuditSection';
describe('InlineAuditSection', () => {
it('returns null when loading', () => {
mockUsePolarisDataContext.mockReturnValue({ data: null, loading: true, error: null });
const { container } = render(
<InlineAuditSection
resource={{ kind: 'Deployment', metadata: { name: 'x', namespace: 'y' } }}
/>
);
expect(container.innerHTML).toBe('');
});
it('returns null for unsupported kind', () => {
mockUsePolarisDataContext.mockReturnValue({
data: makeAuditData([]),
loading: false,
error: null,
});
const { container } = render(
<InlineAuditSection resource={{ kind: 'Service', metadata: { name: 'x', namespace: 'y' } }} />
);
expect(container.innerHTML).toBe('');
});
it('shows "not detected" when workload not found in audit data', () => {
mockUsePolarisDataContext.mockReturnValue({
data: makeAuditData([]),
loading: false,
error: null,
});
render(
<InlineAuditSection
resource={{ kind: 'Deployment', metadata: { name: 'my-app', namespace: 'default' } }}
/>
);
expect(screen.getByText(/Polaris dashboard not detected/)).toBeInTheDocument();
});
it('renders score and summary for a matching workload', () => {
const data = makeAuditData([
makeResult({
Name: 'my-app',
Namespace: 'default',
Kind: 'Deployment',
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
c2: {
ID: 'c2',
Message: '',
Details: [],
Success: false,
Severity: 'warning',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({ data, loading: false, error: null });
render(
<InlineAuditSection
resource={{ kind: 'Deployment', metadata: { name: 'my-app', namespace: 'default' } }}
/>
);
expect(screen.getByText('50%')).toBeInTheDocument();
expect(screen.getByText(/1 passing, 1 warnings, 0 dangers/)).toBeInTheDocument();
});
it('renders failing checks table with pod and container results', () => {
const data = makeAuditData([
makeResult({
Name: 'my-app',
Namespace: 'default',
Kind: 'Deployment',
Results: {},
PodResult: {
Name: 'pod',
Results: {
hostIPCSet: {
ID: 'hostIPCSet',
Message: 'Host IPC is set',
Details: [],
Success: false,
Severity: 'danger',
Category: 'Security',
},
},
ContainerResults: [
{
Name: 'container-1',
Results: {
cpuRequestsMissing: {
ID: 'cpuRequestsMissing',
Message: 'CPU requests missing',
Details: [],
Success: false,
Severity: 'warning',
Category: 'Efficiency',
},
},
},
],
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({ data, loading: false, error: null });
render(
<InlineAuditSection
resource={{ kind: 'Deployment', metadata: { name: 'my-app', namespace: 'default' } }}
/>
);
expect(screen.getByText('Host IPC')).toBeInTheDocument();
expect(screen.getByText('CPU Requests')).toBeInTheDocument();
expect(screen.getByText('Host IPC is set')).toBeInTheDocument();
});
it('renders link to full report', () => {
const data = makeAuditData([
makeResult({
Name: 'my-app',
Namespace: 'default',
Kind: 'Deployment',
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({ data, loading: false, error: null });
render(
<InlineAuditSection
resource={{ kind: 'Deployment', metadata: { name: 'my-app', namespace: 'default' } }}
/>
);
const link = screen.getByText('View Full Report →');
expect(link).toHaveAttribute('href', '/polaris/namespaces#default');
});
it('renders ExemptionManager', () => {
const data = makeAuditData([
makeResult({
Name: 'my-app',
Namespace: 'default',
Kind: 'Deployment',
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({ data, loading: false, error: null });
render(
<InlineAuditSection
resource={{ kind: 'Deployment', metadata: { name: 'my-app', namespace: 'default' } }}
/>
);
expect(screen.getByTestId('exemption-manager')).toBeInTheDocument();
});
});
+13 -5
View File
@@ -4,6 +4,7 @@ import {
SimpleTable, SimpleTable,
StatusLabel, StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents'; } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { useTheme } from '@mui/material/styles';
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { getCheckName, getSeverityStatus } from '../api/checkMapping'; import { getCheckName, getSeverityStatus } from '../api/checkMapping';
@@ -18,8 +19,17 @@ interface CheckFailure {
message: string; message: string;
} }
interface KubeResource {
kind: string;
metadata?: {
name?: string;
namespace?: string;
annotations?: Record<string, string>;
};
}
interface InlineAuditSectionProps { interface InlineAuditSectionProps {
resource: any; // KubeObject from Headlamp resource: KubeResource;
} }
/** /**
@@ -27,6 +37,7 @@ interface InlineAuditSectionProps {
* Shows a compact summary of Polaris findings for Deployments, StatefulSets, etc. * Shows a compact summary of Polaris findings for Deployments, StatefulSets, etc.
*/ */
export default function InlineAuditSection({ resource }: InlineAuditSectionProps) { export default function InlineAuditSection({ resource }: InlineAuditSectionProps) {
const theme = useTheme();
const { data, loading } = usePolarisDataContext(); const { data, loading } = usePolarisDataContext();
if (loading || !data) { if (loading || !data) {
@@ -156,10 +167,7 @@ export default function InlineAuditSection({ resource }: InlineAuditSectionProps
)} )}
<div style={{ marginTop: '16px' }}> <div style={{ marginTop: '16px' }}>
<Link <Link to={`/polaris/namespaces#${namespace}`} style={{ color: theme.palette.primary.main }}>
to={`/polaris/namespaces#${namespace}`}
style={{ color: 'var(--link-color, #1976d2)' }}
>
View Full Report View Full Report
</Link> </Link>
</div> </div>
-200
View File
@@ -1,200 +0,0 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { makeAuditData, makeResult } from '../test-utils';
// Mock Headlamp lib
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: vi.fn() },
}));
// Mock react-router-dom useParams
const mockNamespace = vi.fn(() => 'test-ns');
vi.mock('react-router-dom', () => ({
useParams: () => ({ namespace: mockNamespace() }),
}));
// Mock Headlamp CommonComponents
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
<div data-testid="section-box" data-title={title}>
{children}
</div>
),
SectionHeader: ({ title }: { title: string }) => <div data-testid="section-header">{title}</div>,
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
<span data-testid="status-label" data-status={status}>
{children}
</span>
),
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => (
<table data-testid="name-value-table">
<tbody>
{rows.map(row => (
<tr key={row.name}>
<td>{row.name}</td>
<td>{row.value}</td>
</tr>
))}
</tbody>
</table>
),
SimpleTable: ({
columns,
data,
emptyMessage,
}: {
columns: Array<{ label: string; getter: (row: unknown) => React.ReactNode }>;
data: unknown[];
emptyMessage?: string;
}) =>
data.length === 0 ? (
<div data-testid="simple-table-empty">{emptyMessage}</div>
) : (
<table data-testid="simple-table">
<thead>
<tr>
{columns.map(col => (
<th key={col.label}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{columns.map(col => (
<td key={col.label}>{col.getter(row)}</td>
))}
</tr>
))}
</tbody>
</table>
),
}));
const mockUsePolarisDataContext = vi.fn();
vi.mock('../api/PolarisDataContext', () => ({
usePolarisDataContext: () => mockUsePolarisDataContext(),
}));
import NamespaceDetailView from './NamespaceDetailView';
describe('NamespaceDetailView', () => {
it('renders loader when loading', () => {
mockUsePolarisDataContext.mockReturnValue({
data: null,
loading: true,
error: null,
});
render(<NamespaceDetailView />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading Polaris data for test-ns');
});
it('renders error message when error is set', () => {
mockUsePolarisDataContext.mockReturnValue({
data: null,
loading: false,
error: 'Access denied (403)',
});
render(<NamespaceDetailView />);
expect(screen.getByText('Access denied (403)')).toBeInTheDocument();
expect(screen.getByTestId('section-header')).toHaveTextContent('Polaris — test-ns');
});
it('renders "No Data" when no data and no error', () => {
mockUsePolarisDataContext.mockReturnValue({
data: null,
loading: false,
error: null,
});
render(<NamespaceDetailView />);
expect(screen.getByText('No Polaris audit results found.')).toBeInTheDocument();
});
it('renders namespace score and resource table with data', () => {
const data = makeAuditData([
makeResult({
Name: 'deploy-a',
Namespace: 'test-ns',
Kind: 'Deployment',
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
c2: {
ID: 'c2',
Message: '',
Details: [],
Success: false,
Severity: 'warning',
Category: 'X',
},
},
}),
makeResult({
Name: 'other',
Namespace: 'other-ns',
Kind: 'Deployment',
Results: {
c3: {
ID: 'c3',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({
data,
loading: false,
error: null,
});
render(<NamespaceDetailView />);
// Header
expect(screen.getByTestId('section-header')).toHaveTextContent('Polaris — test-ns');
// Score section: 50% (1 pass / 2 total)
expect(screen.getByText('50%')).toBeInTheDocument();
expect(screen.getByText('Total Checks')).toBeInTheDocument();
// Resource table shows only test-ns resources
expect(screen.getByText('deploy-a')).toBeInTheDocument();
expect(screen.queryByText('other')).not.toBeInTheDocument();
});
it('renders empty table message for namespace with no results', () => {
const data = makeAuditData([
makeResult({
Name: 'deploy-a',
Namespace: 'other-ns',
Results: {},
}),
]);
mockUsePolarisDataContext.mockReturnValue({
data,
loading: false,
error: null,
});
render(<NamespaceDetailView />);
expect(screen.getByTestId('simple-table-empty')).toHaveTextContent(
'No resources found in namespace "test-ns"'
);
});
});
-163
View File
@@ -1,163 +0,0 @@
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { useParams } from 'react-router-dom';
import {
computeScore,
countResultsForItems,
filterResultsByNamespace,
getPolarisProxyUrl,
Result,
ResultCounts,
} from '../api/polaris';
import { usePolarisDataContext } from '../api/PolarisDataContext';
function scoreStatus(score: number): 'success' | 'warning' | 'error' {
if (score >= 80) return 'success';
if (score >= 50) return 'warning';
return 'error';
}
function resourceCounts(result: Result): ResultCounts {
return countResultsForItems([result]);
}
export default function NamespaceDetailView() {
const { namespace } = useParams<{ namespace: string }>();
const { data, loading, error } = usePolarisDataContext();
if (loading) {
return <Loader title={`Loading Polaris data for ${namespace}...`} />;
}
if (error) {
return (
<>
<SectionHeader title={`Polaris — ${namespace}`} />
<SectionBox title="Error">
<NameValueTable
rows={[
{
name: 'Status',
value: <StatusLabel status="error">{error}</StatusLabel>,
},
]}
/>
</SectionBox>
</>
);
}
if (!data) {
return (
<>
<SectionHeader title={`Polaris — ${namespace}`} />
<SectionBox title="No Data">
<NameValueTable rows={[{ name: 'Status', value: 'No Polaris audit results found.' }]} />
</SectionBox>
</>
);
}
const results = filterResultsByNamespace(data, namespace);
const counts = countResultsForItems(results);
const score = computeScore(counts);
const status = scoreStatus(score);
const countsPerResource = new Map<string, ResultCounts>();
for (const r of results) {
countsPerResource.set(`${r.Namespace}/${r.Kind}/${r.Name}`, resourceCounts(r));
}
function getResourceCounts(row: Result): ResultCounts {
return countsPerResource.get(`${row.Namespace}/${row.Kind}/${row.Name}`) ?? resourceCounts(row);
}
return (
<>
<SectionHeader title={`Polaris — ${namespace}`} />
<SectionBox title="External">
<NameValueTable
rows={[
{
name: 'Polaris Dashboard',
value: (
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
View in Polaris Dashboard
</a>
),
},
]}
/>
</SectionBox>
<SectionBox title="Namespace Score">
<NameValueTable
rows={[
{
name: 'Score',
value: <StatusLabel status={status}>{score}%</StatusLabel>,
},
{ name: 'Total Checks', value: String(counts.total) },
{
name: 'Pass',
value: <StatusLabel status="success">{counts.pass}</StatusLabel>,
},
{
name: 'Warning',
value: <StatusLabel status="warning">{counts.warning}</StatusLabel>,
},
{
name: 'Danger',
value: <StatusLabel status="error">{counts.danger}</StatusLabel>,
},
{
name: 'Skipped',
value: (
<span title="Only counts checks with Severity=ignore. Annotation-based exemptions are not included.">
{counts.skipped}
</span>
),
},
]}
/>
</SectionBox>
<SectionBox title="Resources">
<SimpleTable
columns={[
{ label: 'Name', getter: (row: Result) => row.Name },
{ label: 'Kind', getter: (row: Result) => row.Kind },
{
label: 'Pass',
getter: (row: Result) => (
<StatusLabel status="success">{getResourceCounts(row).pass}</StatusLabel>
),
},
{
label: 'Warning',
getter: (row: Result) => (
<StatusLabel status="warning">{getResourceCounts(row).warning}</StatusLabel>
),
},
{
label: 'Danger',
getter: (row: Result) => (
<StatusLabel status="error">{getResourceCounts(row).danger}</StatusLabel>
),
},
]}
data={results}
emptyMessage={`No resources found in namespace "${namespace}".`}
/>
</SectionBox>
</>
);
}
@@ -14,6 +14,48 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
}, },
})); }));
// Mock MUI components
vi.mock('@mui/material/Drawer', () => ({
default: ({
open,
children,
}: {
open: boolean;
children: React.ReactNode;
onClose?: () => void;
anchor?: string;
}) => (open ? <div data-testid="mui-drawer">{children}</div> : null),
}));
vi.mock('@mui/material/IconButton', () => ({
default: ({
children,
onClick,
'aria-label': ariaLabel,
}: {
children: React.ReactNode;
onClick: () => void;
'aria-label': string;
title?: string;
size?: string;
}) => (
<button onClick={onClick} aria-label={ariaLabel}>
{children}
</button>
),
}));
vi.mock('@mui/material/styles', () => ({
useTheme: () => ({
palette: {
primary: { main: '#1976d2' },
text: { primary: '#000', secondary: '#666' },
action: { hover: 'rgba(0,0,0,0.04)' },
background: { default: '#fafafa' },
},
}),
}));
// Mock Headlamp CommonComponents // Mock Headlamp CommonComponents
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>, Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
+115 -192
View File
@@ -6,6 +6,9 @@ import {
SimpleTable, SimpleTable,
StatusLabel, StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents'; } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import Drawer from '@mui/material/Drawer';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import { import {
@@ -44,6 +47,7 @@ interface NamespaceDetailPanelProps {
} }
function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps) { function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps) {
const theme = useTheme();
const [isMaximized, setIsMaximized] = React.useState(false); const [isMaximized, setIsMaximized] = React.useState(false);
const { data, loading, error } = usePolarisDataContext(); const { data, loading, error } = usePolarisDataContext();
@@ -96,172 +100,119 @@ function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps)
return countsPerResource.get(`${row.Namespace}/${row.Kind}/${row.Name}`) ?? resourceCounts(row); return countsPerResource.get(`${row.Namespace}/${row.Kind}/${row.Name}`) ?? resourceCounts(row);
} }
// Generate a unique class name for this drawer to avoid conflicts
const drawerClass = `polaris-namespace-drawer-${namespace}`;
return ( return (
<> <div
<style> style={{
{` width: isMaximized ? 'calc(100vw - 240px)' : '1000px',
.${drawerClass} { padding: '20px',
position: fixed; transition: 'width 0.3s ease',
right: 0; }}
top: 0; >
bottom: 0; <div
width: ${isMaximized ? 'calc(100vw - 240px)' : '1000px'}; style={{
background-color: var(--mui-palette-background-default, #fafafa); marginBottom: '20px',
color: var(--mui-palette-text-primary); display: 'flex',
box-shadow: -2px 0 8px rgba(0,0,0,0.15); justifyContent: 'space-between',
overflow-y: auto; alignItems: 'center',
z-index: 1200; }}
padding: 20px; >
transition: width 0.3s ease; <h2 style={{ margin: 0, color: theme.palette.text.primary }}>Polaris {namespace}</h2>
} <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
`} <IconButton
</style> onClick={() => setIsMaximized(!isMaximized)}
<div className={drawerClass}> aria-label={isMaximized ? 'Minimize panel' : 'Maximize panel'}
<div title={isMaximized ? 'Minimize' : 'Maximize'}
style={{ size="small"
marginBottom: '20px', >
display: 'flex', {isMaximized ? '\u229F' : '\u22A1'}
justifyContent: 'space-between', </IconButton>
alignItems: 'center', <IconButton onClick={onClose} aria-label="Close panel" title="Close" size="small">
}} \u00D7
> </IconButton>
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>
Polaris {namespace}
</h2>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
onClick={() => setIsMaximized(!isMaximized)}
style={{
border: 'none',
background: 'transparent',
fontSize: '20px',
cursor: 'pointer',
padding: '4px 8px',
color: 'var(--mui-palette-text-secondary, #666)',
borderRadius: '4px',
}}
onMouseEnter={e => {
e.currentTarget.style.backgroundColor =
'var(--mui-palette-action-hover, rgba(0, 0, 0, 0.04))';
}}
onMouseLeave={e => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
aria-label={isMaximized ? 'Minimize panel' : 'Maximize panel'}
title={isMaximized ? 'Minimize' : 'Maximize'}
>
{isMaximized ? '⊟' : '⊡'}
</button>
<button
onClick={onClose}
style={{
border: 'none',
background: 'transparent',
fontSize: '24px',
cursor: 'pointer',
padding: '4px 8px',
color: 'var(--mui-palette-text-secondary, #666)',
borderRadius: '4px',
}}
onMouseEnter={e => {
e.currentTarget.style.backgroundColor =
'var(--mui-palette-action-hover, rgba(0, 0, 0, 0.04))';
}}
onMouseLeave={e => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
aria-label="Close panel"
title="Close"
>
×
</button>
</div>
</div> </div>
<SectionBox title="External">
<NameValueTable
rows={[
{
name: 'Polaris Dashboard',
value: (
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
View in Polaris Dashboard
</a>
),
},
]}
/>
</SectionBox>
<SectionBox title="Namespace Score">
<NameValueTable
rows={[
{
name: 'Score',
value: <StatusLabel status={status}>{score}%</StatusLabel>,
},
{ name: 'Total Checks', value: String(counts.total) },
{
name: 'Pass',
value: <StatusLabel status="success">{counts.pass}</StatusLabel>,
},
{
name: 'Warning',
value: <StatusLabel status="warning">{counts.warning}</StatusLabel>,
},
{
name: 'Danger',
value: <StatusLabel status="error">{counts.danger}</StatusLabel>,
},
{
name: 'Skipped',
value: (
<span title="Only counts checks with Severity=ignore. Annotation-based exemptions are not included.">
{counts.skipped}
</span>
),
},
]}
/>
</SectionBox>
<SectionBox title="Resources">
<SimpleTable
columns={[
{ label: 'Name', getter: (row: Result) => row.Name },
{ label: 'Kind', getter: (row: Result) => row.Kind },
{
label: 'Pass',
getter: (row: Result) => (
<StatusLabel status="success">{getResourceCounts(row).pass}</StatusLabel>
),
},
{
label: 'Warning',
getter: (row: Result) => (
<StatusLabel status="warning">{getResourceCounts(row).warning}</StatusLabel>
),
},
{
label: 'Danger',
getter: (row: Result) => (
<StatusLabel status="error">{getResourceCounts(row).danger}</StatusLabel>
),
},
]}
data={results}
emptyMessage={`No resources found in namespace "${namespace}".`}
/>
</SectionBox>
</div> </div>
</>
<SectionBox title="External">
<NameValueTable
rows={[
{
name: 'Polaris Dashboard',
value: (
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
View in Polaris Dashboard
</a>
),
},
]}
/>
</SectionBox>
<SectionBox title="Namespace Score">
<NameValueTable
rows={[
{
name: 'Score',
value: <StatusLabel status={status}>{score}%</StatusLabel>,
},
{ name: 'Total Checks', value: String(counts.total) },
{
name: 'Pass',
value: <StatusLabel status="success">{counts.pass}</StatusLabel>,
},
{
name: 'Warning',
value: <StatusLabel status="warning">{counts.warning}</StatusLabel>,
},
{
name: 'Danger',
value: <StatusLabel status="error">{counts.danger}</StatusLabel>,
},
{
name: 'Skipped',
value: (
<span title="Only counts checks with Severity=ignore. Annotation-based exemptions are not included.">
{counts.skipped}
</span>
),
},
]}
/>
</SectionBox>
<SectionBox title="Resources">
<SimpleTable
columns={[
{ label: 'Name', getter: (row: Result) => row.Name },
{ label: 'Kind', getter: (row: Result) => row.Kind },
{
label: 'Pass',
getter: (row: Result) => (
<StatusLabel status="success">{getResourceCounts(row).pass}</StatusLabel>
),
},
{
label: 'Warning',
getter: (row: Result) => (
<StatusLabel status="warning">{getResourceCounts(row).warning}</StatusLabel>
),
},
{
label: 'Danger',
getter: (row: Result) => (
<StatusLabel status="error">{getResourceCounts(row).danger}</StatusLabel>
),
},
]}
data={results}
emptyMessage={`No resources found in namespace "${namespace}".`}
/>
</SectionBox>
</div>
); );
} }
export default function NamespacesListView() { export default function NamespacesListView() {
const theme = useTheme();
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
const { data, loading, error } = usePolarisDataContext(); const { data, loading, error } = usePolarisDataContext();
@@ -287,21 +238,6 @@ export default function NamespacesListView() {
history.push(location.pathname); history.push(location.pathname);
}; };
// Handle keyboard navigation (Escape key closes drawer)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && selectedNamespace) {
closeNamespace();
}
};
if (selectedNamespace) {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedNamespace]);
if (loading) { if (loading) {
return <Loader title="Loading Polaris audit data..." />; return <Loader title="Loading Polaris audit data..." />;
} }
@@ -364,7 +300,7 @@ export default function NamespacesListView() {
style={{ style={{
border: 'none', border: 'none',
background: 'transparent', background: 'transparent',
color: 'var(--link-color, #1976d2)', color: theme.palette.primary.main,
cursor: 'pointer', cursor: 'pointer',
textDecoration: 'underline', textDecoration: 'underline',
padding: 0, padding: 0,
@@ -405,24 +341,11 @@ export default function NamespacesListView() {
/> />
</SectionBox> </SectionBox>
{selectedNamespace && ( <Drawer anchor="right" open={Boolean(selectedNamespace)} onClose={closeNamespace}>
<> {selectedNamespace && (
<div
onClick={closeNamespace}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1100,
}}
aria-label="Close panel backdrop"
/>
<NamespaceDetailPanel namespace={selectedNamespace} onClose={closeNamespace} /> <NamespaceDetailPanel namespace={selectedNamespace} onClose={closeNamespace} />
</> )}
)} </Drawer>
</> </>
); );
} }
+12
View File
@@ -8,6 +8,18 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: vi.fn() }, ApiProxy: { request: vi.fn() },
})); }));
vi.mock('@mui/material/styles', () => ({
useTheme: () => ({
palette: {
primary: { main: '#1976d2', contrastText: '#fff' },
text: { primary: '#000', secondary: '#666' },
action: { disabledBackground: '#e0e0e0', disabled: '#9e9e9e' },
divider: '#e0e0e0',
background: { paper: '#fff' },
},
}),
}));
// Mock Headlamp CommonComponents // Mock Headlamp CommonComponents
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => ( SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
+14 -12
View File
@@ -4,12 +4,15 @@ import {
SectionBox, SectionBox,
StatusLabel, StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents'; } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { useTheme } from '@mui/material/styles';
import React from 'react'; import React from 'react';
import { import {
AuditData, AuditData,
getDashboardUrl, getDashboardUrl,
getPolarisApiPath,
getRefreshInterval, getRefreshInterval,
INTERVAL_OPTIONS, INTERVAL_OPTIONS,
isFullUrl,
setDashboardUrl, setDashboardUrl,
setRefreshInterval, setRefreshInterval,
} from '../api/polaris'; } from '../api/polaris';
@@ -20,6 +23,7 @@ interface PluginSettingsProps {
} }
export default function PolarisSettings(props: PluginSettingsProps) { export default function PolarisSettings(props: PluginSettingsProps) {
const theme = useTheme();
const { data, onDataChange } = props; const { data, onDataChange } = props;
const currentInterval = (data?.refreshInterval as number) ?? getRefreshInterval(); const currentInterval = (data?.refreshInterval as number) ?? getRefreshInterval();
const currentUrl = (data?.dashboardUrl as string) ?? getDashboardUrl(); const currentUrl = (data?.dashboardUrl as string) ?? getDashboardUrl();
@@ -45,13 +49,11 @@ export default function PolarisSettings(props: PluginSettingsProps) {
setTestResult(null); setTestResult(null);
try { try {
const baseUrl = currentUrl; const apiPath = getPolarisApiPath();
const apiPath = baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`;
const isFullUrl = apiPath.startsWith('http://') || apiPath.startsWith('https://');
let result: AuditData; let result: AuditData;
if (isFullUrl) { if (isFullUrl(apiPath)) {
const response = await fetch(apiPath); const response = await fetch(apiPath);
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`);
@@ -107,17 +109,17 @@ export default function PolarisSettings(props: PluginSettingsProps) {
style={{ style={{
width: '100%', width: '100%',
padding: '4px 8px', padding: '4px 8px',
border: '1px solid var(--mui-palette-divider, #e0e0e0)', border: `1px solid ${theme.palette.divider}`,
borderRadius: '4px', borderRadius: '4px',
fontSize: '14px', fontSize: '14px',
backgroundColor: 'var(--mui-palette-background-paper, #fff)', backgroundColor: theme.palette.background.paper,
color: 'var(--mui-palette-text-primary, #000)', color: theme.palette.text.primary,
}} }}
/> />
<div <div
style={{ style={{
fontSize: '12px', fontSize: '12px',
color: 'var(--mui-palette-text-secondary, #666)', color: theme.palette.text.secondary,
marginTop: '4px', marginTop: '4px',
}} }}
> >
@@ -139,11 +141,11 @@ export default function PolarisSettings(props: PluginSettingsProps) {
style={{ style={{
padding: '6px 16px', padding: '6px 16px',
backgroundColor: testing backgroundColor: testing
? 'var(--mui-palette-action-disabledBackground, #e0e0e0)' ? theme.palette.action.disabledBackground
: 'var(--mui-palette-primary-main, #1976d2)', : theme.palette.primary.main,
color: testing color: testing
? 'var(--mui-palette-action-disabled, #9e9e9e)' ? theme.palette.action.disabled
: 'var(--mui-palette-primary-contrastText, #fff)', : theme.palette.primary.contrastText,
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: testing ? 'not-allowed' : 'pointer', cursor: testing ? 'not-allowed' : 'pointer',
+49 -12
View File
@@ -5,6 +5,7 @@ import {
registerRoute, registerRoute,
registerSidebarEntry, registerSidebarEntry,
} from '@kinvolk/headlamp-plugin/lib'; } from '@kinvolk/headlamp-plugin/lib';
import { SectionBox, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react'; import React from 'react';
import { PolarisDataProvider } from './api/PolarisDataContext'; import { PolarisDataProvider } from './api/PolarisDataContext';
import AppBarScoreBadge from './components/AppBarScoreBadge'; import AppBarScoreBadge from './components/AppBarScoreBadge';
@@ -13,6 +14,34 @@ import InlineAuditSection from './components/InlineAuditSection';
import NamespacesListView from './components/NamespacesListView'; import NamespacesListView from './components/NamespacesListView';
import PolarisSettings from './components/PolarisSettings'; import PolarisSettings from './components/PolarisSettings';
// --- Error boundary for plugin components ---
interface ErrorBoundaryState {
error: string | null;
}
class PolarisErrorBoundary extends React.Component<
{ children: React.ReactNode },
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error: error.message };
}
render() {
if (this.state.error) {
return (
<SectionBox title="Polaris Plugin Error">
<StatusLabel status="error">{this.state.error}</StatusLabel>
</SectionBox>
);
}
return this.props.children;
}
}
// --- Sidebar entries --- // --- Sidebar entries ---
registerSidebarEntry({ registerSidebarEntry({
@@ -47,9 +76,11 @@ registerRoute({
name: 'polaris', name: 'polaris',
exact: true, exact: true,
component: () => ( component: () => (
<PolarisDataProvider> <PolarisErrorBoundary>
<DashboardView /> <PolarisDataProvider>
</PolarisDataProvider> <DashboardView />
</PolarisDataProvider>
</PolarisErrorBoundary>
), ),
}); });
@@ -59,9 +90,11 @@ registerRoute({
name: 'polaris-namespaces', name: 'polaris-namespaces',
exact: true, exact: true,
component: () => ( component: () => (
<PolarisDataProvider> <PolarisErrorBoundary>
<NamespacesListView /> <PolarisDataProvider>
</PolarisDataProvider> <NamespacesListView />
</PolarisDataProvider>
</PolarisErrorBoundary>
), ),
}); });
@@ -77,15 +110,19 @@ registerDetailsViewSection(({ resource }) => {
} }
return ( return (
<PolarisDataProvider> <PolarisErrorBoundary>
<InlineAuditSection resource={resource} /> <PolarisDataProvider>
</PolarisDataProvider> <InlineAuditSection resource={resource} />
</PolarisDataProvider>
</PolarisErrorBoundary>
); );
}); });
// Register app bar score badge // Register app bar score badge
registerAppBarAction(() => ( registerAppBarAction(() => (
<PolarisDataProvider> <PolarisErrorBoundary>
<AppBarScoreBadge /> <PolarisDataProvider>
</PolarisDataProvider> <AppBarScoreBadge />
</PolarisDataProvider>
</PolarisErrorBoundary>
)); ));
-35
View File
@@ -1,4 +1,3 @@
import React from 'react';
import { AuditData, Result } from './api/polaris'; import { AuditData, Result } from './api/polaris';
// --- Fixtures --- // --- Fixtures ---
@@ -25,37 +24,3 @@ export function makeAuditData(results: Result[]): AuditData {
Results: results, Results: results,
}; };
} }
// --- Mock Polaris Context Provider ---
interface MockPolarisProviderProps {
data?: AuditData | null;
loading?: boolean;
error?: string | null;
children: React.ReactNode;
}
// We dynamically import PolarisDataContext to inject mock values.
// This avoids mocking the hook module — we supply real context with controlled values.
const PolarisDataContext = React.createContext<{
data: AuditData | null;
loading: boolean;
error: string | null;
} | null>(null);
export function MockPolarisProvider({
data = null,
loading = false,
error = null,
children,
}: MockPolarisProviderProps) {
return (
<PolarisDataContext.Provider value={{ data, loading, error }}>
{children}
</PolarisDataContext.Provider>
);
}
// The context reference used in test-utils must be the SAME object the components import.
// We achieve this by having component tests mock `usePolarisDataContext` to read from our context.
export { PolarisDataContext };