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:
+30
-1
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
@@ -242,7 +270,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Automated release workflow
|
||||
- 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.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
|
||||
|
||||
@@ -35,17 +35,16 @@ All tests and `tsc` must pass before committing.
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, registerDetailsViewSection, registerAppBarAction, registerPluginSettings
|
||||
├── test-utils.tsx # Shared test utilities
|
||||
├── index.tsx # Plugin entry: registerRoute, registerSidebarEntry, registerDetailsViewSection, registerAppBarAction, registerPluginSettings; PolarisErrorBoundary
|
||||
├── test-utils.tsx # Shared test fixtures (makeResult, makeAuditData)
|
||||
├── 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
|
||||
│ ├── topIssues.ts # Top failing checks aggregation logic
|
||||
│ └── PolarisDataContext.tsx # Shared React context provider (ApiProxy.request + configurable refresh)
|
||||
└── components/
|
||||
├── DashboardView.tsx # Overview page (score gauge, check distribution, top failing checks)
|
||||
├── NamespacesListView.tsx # Namespace list with per-namespace scores
|
||||
├── NamespaceDetailView.tsx # Per-namespace drill-down with resource table
|
||||
├── NamespacesListView.tsx # Namespace list with per-namespace scores + MUI Drawer detail panel
|
||||
├── InlineAuditSection.tsx # Injected into Deployment/StatefulSet/DaemonSet/Job/CronJob detail views
|
||||
├── ExemptionManager.tsx # Polaris exemption annotation management
|
||||
├── 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
|
||||
|
||||
- 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`
|
||||
- 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
|
||||
- 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
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -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
|
||||
- **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
|
||||
- **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
|
||||
|
||||
@@ -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) |
|
||||
| **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 |
|
||||
| **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+ |
|
||||
| **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/
|
||||
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/
|
||||
polaris.ts -- TypeScript types (AuditData schema), usePolarisData hook,
|
||||
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.
|
||||
components/
|
||||
DashboardView.tsx -- Overview page (score, check summary with skipped, cluster info).
|
||||
NamespacesListView.tsx -- Namespace list with scores and links to detail views.
|
||||
NamespaceDetailView.tsx -- Per-namespace drill-down with resource table.
|
||||
PolarisSettings.tsx -- Plugin settings page (refresh interval selector).
|
||||
NamespacesListView.tsx -- Namespace list with scores; MUI Drawer detail panel.
|
||||
InlineAuditSection.tsx -- Inline audit for Deployment/StatefulSet/DaemonSet/Job/CronJob detail views.
|
||||
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).
|
||||
```
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ vi.mock('./polaris', async importOriginal => {
|
||||
data: makeAuditData([makeResult()]),
|
||||
loading: false,
|
||||
error: null,
|
||||
triggerRefresh: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -44,5 +45,6 @@ describe('usePolarisDataContext', () => {
|
||||
expect(result.current.data).not.toBeNull();
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.refresh).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -207,22 +207,6 @@ export function getCheckCategory(checkId: string): 'Security' | 'Efficiency' | '
|
||||
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
|
||||
*/
|
||||
|
||||
+2
-2
@@ -300,7 +300,7 @@ export function computeScore(counts: ResultCounts): number {
|
||||
*
|
||||
* @returns Full path to results.json endpoint
|
||||
*/
|
||||
function getPolarisApiPath(): string {
|
||||
export function getPolarisApiPath(): string {
|
||||
const baseUrl = getDashboardUrl();
|
||||
return baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`;
|
||||
}
|
||||
@@ -311,7 +311,7 @@ function getPolarisApiPath(): string {
|
||||
* @param url - URL to check
|
||||
* @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://');
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { computeScore, countResults } from '../api/polaris';
|
||||
@@ -8,6 +9,7 @@ import { usePolarisDataContext } from '../api/PolarisDataContext';
|
||||
* Clicking navigates to the overview dashboard
|
||||
*/
|
||||
export default function AppBarScoreBadge() {
|
||||
const theme = useTheme();
|
||||
const { data, loading } = usePolarisDataContext();
|
||||
const history = useHistory();
|
||||
|
||||
@@ -18,11 +20,17 @@ export default function AppBarScoreBadge() {
|
||||
const counts = countResults(data);
|
||||
const score = computeScore(counts);
|
||||
|
||||
// Color based on score
|
||||
const getColor = (score: number): string => {
|
||||
if (score >= 80) return '#4caf50'; // green
|
||||
if (score >= 50) return '#ff9800'; // orange
|
||||
return '#f44336'; // red
|
||||
// Color based on score using theme palette
|
||||
const getColor = (s: number): string => {
|
||||
if (s >= 80) return theme.palette.success.main;
|
||||
if (s >= 50) return theme.palette.warning.main;
|
||||
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 = () => {
|
||||
@@ -39,7 +47,7 @@ export default function AppBarScoreBadge() {
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
backgroundColor: getColor(score),
|
||||
color: 'white',
|
||||
color: getContrastColor(score),
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
display: 'inline-flex',
|
||||
@@ -48,7 +56,6 @@ export default function AppBarScoreBadge() {
|
||||
}}
|
||||
aria-label={`Polaris cluster score: ${score}%`}
|
||||
>
|
||||
<span>🛡️</span>
|
||||
<span>Polaris: {score}%</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,15 @@ 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' },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Headlamp CommonComponents as thin pass-throughs
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
|
||||
@@ -34,12 +43,27 @@ vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
</tbody>
|
||||
</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">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(col => (
|
||||
<th key={col.label}>{col.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>{JSON.stringify(item)}</td>
|
||||
{data.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map(col => (
|
||||
<td key={col.label}>{col.getter(row)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { getSeverityStatus } from '../api/checkMapping';
|
||||
import { AuditData, computeScore, countResults, ResultCounts } from '../api/polaris';
|
||||
@@ -87,6 +88,7 @@ function formatAuditTime(auditTime: string): string {
|
||||
}
|
||||
|
||||
export default function DashboardView() {
|
||||
const theme = useTheme();
|
||||
const { data, loading, error, refresh } = usePolarisDataContext();
|
||||
|
||||
if (loading) {
|
||||
@@ -109,7 +111,7 @@ export default function DashboardView() {
|
||||
<SectionHeader title="Polaris — Overview" />
|
||||
{data && (
|
||||
<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)}
|
||||
</span>
|
||||
<button
|
||||
@@ -117,8 +119,8 @@ export default function DashboardView() {
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
border: '1px solid var(--mui-palette-primary-main, #1976d2)',
|
||||
color: theme.palette.primary.main,
|
||||
border: `1px solid ${theme.palette.primary.main}`,
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { getCheckName } from '../api/checkMapping';
|
||||
import { Result } from '../api/polaris';
|
||||
@@ -26,17 +27,14 @@ export default function ExemptionManager({
|
||||
kind,
|
||||
name,
|
||||
}: ExemptionManagerProps) {
|
||||
const theme = useTheme();
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [selectedChecks, setSelectedChecks] = React.useState<Set<string>>(new Set());
|
||||
const [exemptAll, setExemptAll] = React.useState(false);
|
||||
const [applying, setApplying] = React.useState(false);
|
||||
|
||||
// Extract current exemptions from workload metadata
|
||||
const getExemptions = (): string[] => {
|
||||
// This would need to fetch the actual workload from K8s API
|
||||
// For now, return empty array as placeholder
|
||||
return [];
|
||||
};
|
||||
const [feedback, setFeedback] = React.useState<{ success: boolean; message: string } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Extract failing checks for this workload
|
||||
const getFailingChecks = (): CheckFailure[] => {
|
||||
@@ -75,7 +73,6 @@ export default function ExemptionManager({
|
||||
};
|
||||
|
||||
const failingChecks = getFailingChecks();
|
||||
const currentExemptions = getExemptions();
|
||||
|
||||
const handleCheckToggle = (checkId: string) => {
|
||||
const newSelected = new Set(selectedChecks);
|
||||
@@ -89,15 +86,15 @@ export default function ExemptionManager({
|
||||
|
||||
const applyExemptions = async () => {
|
||||
setApplying(true);
|
||||
setFeedback(null);
|
||||
|
||||
try {
|
||||
// Construct the API path based on kind
|
||||
const apiGroup = getApiGroup(kind);
|
||||
const apiVersion = 'v1'; // This would need to be dynamic based on kind
|
||||
const plural = getPlural(kind);
|
||||
|
||||
const patchPath = apiGroup
|
||||
? `/apis/${apiGroup}/${apiVersion}/namespaces/${namespace}/${plural}/${name}`
|
||||
? `/apis/${apiGroup}/v1/namespaces/${namespace}/${plural}/${name}`
|
||||
: `/api/v1/namespaces/${namespace}/${plural}/${name}`;
|
||||
|
||||
// Build annotations patch
|
||||
@@ -128,46 +125,27 @@ export default function ExemptionManager({
|
||||
setDialogOpen(false);
|
||||
setSelectedChecks(new Set());
|
||||
setExemptAll(false);
|
||||
|
||||
// Show success message (would need notistack integration)
|
||||
alert('Exemptions applied successfully');
|
||||
setFeedback({ success: true, message: 'Exemptions applied successfully' });
|
||||
} catch (err) {
|
||||
alert(`Failed to apply exemptions: ${String(err)}`);
|
||||
setFeedback({ success: false, message: `Failed to apply exemptions: ${String(err)}` });
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = applying || (!exemptAll && selectedChecks.size === 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionBox title="Exemptions">
|
||||
{currentExemptions.length > 0 ? (
|
||||
<NameValueTable
|
||||
rows={currentExemptions.map(exemption => ({
|
||||
name: exemption,
|
||||
value: (
|
||||
<button
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
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>
|
||||
<p>No exemptions configured</p>
|
||||
|
||||
{feedback && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<StatusLabel status={feedback.success ? 'success' : 'error'}>
|
||||
{feedback.message}
|
||||
</StatusLabel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -177,18 +155,14 @@ export default function ExemptionManager({
|
||||
marginTop: '8px',
|
||||
padding: '6px 16px',
|
||||
backgroundColor:
|
||||
failingChecks.length === 0
|
||||
? 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
|
||||
: 'transparent',
|
||||
failingChecks.length === 0 ? theme.palette.action.disabledBackground : 'transparent',
|
||||
color:
|
||||
failingChecks.length === 0
|
||||
? 'var(--mui-palette-action-disabled, #9e9e9e)'
|
||||
: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
? theme.palette.action.disabled
|
||||
: theme.palette.primary.main,
|
||||
border: '1px solid',
|
||||
borderColor:
|
||||
failingChecks.length === 0
|
||||
? 'var(--mui-palette-divider, #e0e0e0)'
|
||||
: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
failingChecks.length === 0 ? theme.palette.divider : theme.palette.primary.main,
|
||||
borderRadius: '4px',
|
||||
cursor: failingChecks.length === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
@@ -246,7 +220,7 @@ export default function ExemptionManager({
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
color: theme.palette.primary.main,
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
@@ -257,21 +231,18 @@ export default function ExemptionManager({
|
||||
</button>
|
||||
<button
|
||||
onClick={applyExemptions}
|
||||
disabled={applying || (!exemptAll && selectedChecks.size === 0)}
|
||||
disabled={isDisabled}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor:
|
||||
applying || (!exemptAll && selectedChecks.size === 0)
|
||||
? 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
|
||||
: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
color:
|
||||
applying || (!exemptAll && selectedChecks.size === 0)
|
||||
? 'var(--mui-palette-action-disabled, #9e9e9e)'
|
||||
: 'var(--mui-palette-primary-contrastText, #fff)',
|
||||
backgroundColor: isDisabled
|
||||
? theme.palette.action.disabledBackground
|
||||
: theme.palette.primary.main,
|
||||
color: isDisabled
|
||||
? theme.palette.action.disabled
|
||||
: theme.palette.primary.contrastText,
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor:
|
||||
applying || (!exemptAll && selectedChecks.size === 0) ? 'not-allowed' : 'pointer',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getCheckName, getSeverityStatus } from '../api/checkMapping';
|
||||
@@ -18,8 +19,17 @@ interface CheckFailure {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface KubeResource {
|
||||
kind: string;
|
||||
metadata?: {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
annotations?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
export default function InlineAuditSection({ resource }: InlineAuditSectionProps) {
|
||||
const theme = useTheme();
|
||||
const { data, loading } = usePolarisDataContext();
|
||||
|
||||
if (loading || !data) {
|
||||
@@ -156,10 +167,7 @@ export default function InlineAuditSection({ resource }: InlineAuditSectionProps
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<Link
|
||||
to={`/polaris/namespaces#${namespace}`}
|
||||
style={{ color: 'var(--link-color, #1976d2)' }}
|
||||
>
|
||||
<Link to={`/polaris/namespaces#${namespace}`} style={{ color: theme.palette.primary.main }}>
|
||||
View Full Report →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -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"'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
Loader: ({ title }: { title: string }) => <div data-testid="loader">{title}</div>,
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} 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 { useHistory, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
@@ -44,6 +47,7 @@ interface NamespaceDetailPanelProps {
|
||||
}
|
||||
|
||||
function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps) {
|
||||
const theme = useTheme();
|
||||
const [isMaximized, setIsMaximized] = React.useState(false);
|
||||
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);
|
||||
}
|
||||
|
||||
// Generate a unique class name for this drawer to avoid conflicts
|
||||
const drawerClass = `polaris-namespace-drawer-${namespace}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.${drawerClass} {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: ${isMaximized ? 'calc(100vw - 240px)' : '1000px'};
|
||||
background-color: var(--mui-palette-background-default, #fafafa);
|
||||
color: var(--mui-palette-text-primary);
|
||||
box-shadow: -2px 0 8px rgba(0,0,0,0.15);
|
||||
overflow-y: auto;
|
||||
z-index: 1200;
|
||||
padding: 20px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div className={drawerClass}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<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
|
||||
style={{
|
||||
width: isMaximized ? 'calc(100vw - 240px)' : '1000px',
|
||||
padding: '20px',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, color: theme.palette.text.primary }}>Polaris — {namespace}</h2>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<IconButton
|
||||
onClick={() => setIsMaximized(!isMaximized)}
|
||||
aria-label={isMaximized ? 'Minimize panel' : 'Maximize panel'}
|
||||
title={isMaximized ? 'Minimize' : 'Maximize'}
|
||||
size="small"
|
||||
>
|
||||
{isMaximized ? '\u229F' : '\u22A1'}
|
||||
</IconButton>
|
||||
<IconButton onClick={onClose} aria-label="Close panel" title="Close" size="small">
|
||||
\u00D7
|
||||
</IconButton>
|
||||
</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>
|
||||
</>
|
||||
|
||||
<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() {
|
||||
const theme = useTheme();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const { data, loading, error } = usePolarisDataContext();
|
||||
@@ -287,21 +238,6 @@ export default function NamespacesListView() {
|
||||
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) {
|
||||
return <Loader title="Loading Polaris audit data..." />;
|
||||
}
|
||||
@@ -364,7 +300,7 @@ export default function NamespacesListView() {
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--link-color, #1976d2)',
|
||||
color: theme.palette.primary.main,
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
padding: 0,
|
||||
@@ -405,24 +341,11 @@ export default function NamespacesListView() {
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
{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"
|
||||
/>
|
||||
<Drawer anchor="right" open={Boolean(selectedNamespace)} onClose={closeNamespace}>
|
||||
{selectedNamespace && (
|
||||
<NamespaceDetailPanel namespace={selectedNamespace} onClose={closeNamespace} />
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,18 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
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
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
|
||||
|
||||
@@ -4,12 +4,15 @@ import {
|
||||
SectionBox,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import {
|
||||
AuditData,
|
||||
getDashboardUrl,
|
||||
getPolarisApiPath,
|
||||
getRefreshInterval,
|
||||
INTERVAL_OPTIONS,
|
||||
isFullUrl,
|
||||
setDashboardUrl,
|
||||
setRefreshInterval,
|
||||
} from '../api/polaris';
|
||||
@@ -20,6 +23,7 @@ interface PluginSettingsProps {
|
||||
}
|
||||
|
||||
export default function PolarisSettings(props: PluginSettingsProps) {
|
||||
const theme = useTheme();
|
||||
const { data, onDataChange } = props;
|
||||
const currentInterval = (data?.refreshInterval as number) ?? getRefreshInterval();
|
||||
const currentUrl = (data?.dashboardUrl as string) ?? getDashboardUrl();
|
||||
@@ -45,13 +49,11 @@ export default function PolarisSettings(props: PluginSettingsProps) {
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const baseUrl = currentUrl;
|
||||
const apiPath = baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`;
|
||||
const isFullUrl = apiPath.startsWith('http://') || apiPath.startsWith('https://');
|
||||
const apiPath = getPolarisApiPath();
|
||||
|
||||
let result: AuditData;
|
||||
|
||||
if (isFullUrl) {
|
||||
if (isFullUrl(apiPath)) {
|
||||
const response = await fetch(apiPath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
@@ -107,17 +109,17 @@ export default function PolarisSettings(props: PluginSettingsProps) {
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid var(--mui-palette-divider, #e0e0e0)',
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||
color: 'var(--mui-palette-text-primary, #000)',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--mui-palette-text-secondary, #666)',
|
||||
color: theme.palette.text.secondary,
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
@@ -139,11 +141,11 @@ export default function PolarisSettings(props: PluginSettingsProps) {
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: testing
|
||||
? 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
|
||||
: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
? theme.palette.action.disabledBackground
|
||||
: theme.palette.primary.main,
|
||||
color: testing
|
||||
? 'var(--mui-palette-action-disabled, #9e9e9e)'
|
||||
: 'var(--mui-palette-primary-contrastText, #fff)',
|
||||
? theme.palette.action.disabled
|
||||
: theme.palette.primary.contrastText,
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: testing ? 'not-allowed' : 'pointer',
|
||||
|
||||
+49
-12
@@ -5,6 +5,7 @@ import {
|
||||
registerRoute,
|
||||
registerSidebarEntry,
|
||||
} from '@kinvolk/headlamp-plugin/lib';
|
||||
import { SectionBox, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { PolarisDataProvider } from './api/PolarisDataContext';
|
||||
import AppBarScoreBadge from './components/AppBarScoreBadge';
|
||||
@@ -13,6 +14,34 @@ import InlineAuditSection from './components/InlineAuditSection';
|
||||
import NamespacesListView from './components/NamespacesListView';
|
||||
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 ---
|
||||
|
||||
registerSidebarEntry({
|
||||
@@ -47,9 +76,11 @@ registerRoute({
|
||||
name: 'polaris',
|
||||
exact: true,
|
||||
component: () => (
|
||||
<PolarisDataProvider>
|
||||
<DashboardView />
|
||||
</PolarisDataProvider>
|
||||
<PolarisErrorBoundary>
|
||||
<PolarisDataProvider>
|
||||
<DashboardView />
|
||||
</PolarisDataProvider>
|
||||
</PolarisErrorBoundary>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -59,9 +90,11 @@ registerRoute({
|
||||
name: 'polaris-namespaces',
|
||||
exact: true,
|
||||
component: () => (
|
||||
<PolarisDataProvider>
|
||||
<NamespacesListView />
|
||||
</PolarisDataProvider>
|
||||
<PolarisErrorBoundary>
|
||||
<PolarisDataProvider>
|
||||
<NamespacesListView />
|
||||
</PolarisDataProvider>
|
||||
</PolarisErrorBoundary>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -77,15 +110,19 @@ registerDetailsViewSection(({ resource }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<PolarisDataProvider>
|
||||
<InlineAuditSection resource={resource} />
|
||||
</PolarisDataProvider>
|
||||
<PolarisErrorBoundary>
|
||||
<PolarisDataProvider>
|
||||
<InlineAuditSection resource={resource} />
|
||||
</PolarisDataProvider>
|
||||
</PolarisErrorBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
// Register app bar score badge
|
||||
registerAppBarAction(() => (
|
||||
<PolarisDataProvider>
|
||||
<AppBarScoreBadge />
|
||||
</PolarisDataProvider>
|
||||
<PolarisErrorBoundary>
|
||||
<PolarisDataProvider>
|
||||
<AppBarScoreBadge />
|
||||
</PolarisDataProvider>
|
||||
</PolarisErrorBoundary>
|
||||
));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { AuditData, Result } from './api/polaris';
|
||||
|
||||
// --- Fixtures ---
|
||||
@@ -25,37 +24,3 @@ export function makeAuditData(results: Result[]): AuditData {
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user