Compare commits

..

20 Commits

Author SHA1 Message Date
gitea-actions[bot] 957c5fe791 ci: update artifact hub metadata for v0.1.4 2026-02-08 00:20:15 +00:00
Chris Farhood 380e34e652 chore: bump version to 0.1.4
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-07 19:18:47 -05:00
Chris Farhood b1e50d7416 Merge pull request 'feat: E2E smoke tests + fix empty namespace crash' (#19) from feat/e2e-tests-and-empty-ns-fix into main
Reviewed-on: farhoodliquor/headlamp-polaris-plugin#19
2026-02-07 19:17:11 -05:00
Chris Farhood 2298de9edd style: format polaris.ts for prettier
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-07 19:13:08 -05:00
Chris Farhood 39d85a3596 fix: drop :80 port suffix from service proxy URL for RBAC compatibility
When the proxy URL includes `:80`, Kubernetes checks the RBAC
resourceName as `polaris-dashboard:80` which doesn't match the
Role's resourceNames `["polaris-dashboard"]`. Dropping the port
suffix uses the service's default port and matches the RBAC correctly.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-07 19:11:03 -05:00
Chris Farhood 1421a159dd fix: remove unused import flagged by ESLint in PolarisSettings test
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-07 19:01:09 -05:00
Chris Farhood 186f9ef380 feat: add Playwright E2E smoke tests and fix empty namespace crash
Fix getNamespaces() to skip cluster-scoped resources (Namespace: "")
that caused Router.createRouteURL to throw TypeError on the Namespaces
page. Add Playwright E2E smoke tests with Authentik OIDC auth for CI
and K8s token fallback for local dev. Add Gitea Actions E2E workflow,
vitest unit test infrastructure, and test-utils fixtures.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-07 18:53:40 -05:00
gitea-actions[bot] 2a85f2a3d1 ci: update artifact hub metadata for v0.1.3 2026-02-07 19:51:35 +00:00
Chris Farhood c4e3c20a41 chore: bump version to 0.1.3
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:49:58 -05:00
Chris Farhood 50caae256d fix: skipped display, namespace link crash, overview redesign
- Fix skipped count showing empty by rendering as plain text instead
  of StatusLabel with empty status (which renders near-invisible)
- Fix namespace link crash by using Router.createRouteURL to generate
  cluster-prefixed URLs with react-router-dom Link, instead of
  Headlamp's Link component which crashes on plugin-registered routes
- Redesign overview page with PercentageCircle score chart and
  PercentageBar check distribution for a better visual experience

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:45:39 -05:00
Chris Farhood 3784b9b1c8 docs: update README for consolidated dashboard and current architecture
Remove references to deleted Full Audit page and DynamicSidebarRegistrar.
Add Namespaces page, skipped checks, test commands, and NamespacesListView
to project structure. Fix stale version numbers in install examples.
Consolidate CI/release docs to match single Gitea Actions workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:35:30 -05:00
gitea-actions[bot] 6760841b22 ci: update artifact hub metadata for v0.1.2 2026-02-07 19:21:46 +00:00
Chris Farhood ce32783fe6 chore: bump version to 0.1.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:20:45 -05:00
Chris Farhood 3b0287bf19 Merge pull request 'feat: consolidate dashboard pages, fix namespace links, add tests' (#18) from feat/consolidate-dashboard-fix-namespace-links into main
Reviewed-on: farhoodliquor/headlamp-polaris-plugin#18
2026-02-07 14:20:00 -05:00
Chris Farhood 101b663867 style: fix prettier formatting in NamespacesListView
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:17:38 -05:00
Chris Farhood 6281dbfa5e feat: consolidate dashboard pages, fix namespace links, add tests
Merge Overview and Full Audit into a single dashboard page that always
shows the skipped check count. Fix namespace link 404s by using
Headlamp's Link component (which generates cluster-prefixed URLs)
instead of raw react-router-dom Link. Add vitest unit tests for all
polaris.ts utility functions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 14:10:08 -05:00
gitea-actions[bot] 48c8ca04c0 ci: update artifact hub metadata for v0.1.1 2026-02-07 17:22:17 +00:00
Chris Farhood cc280034f6 chore: bump version to 0.1.1 and update architecture docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:21:22 -05:00
Chris Farhood a2cbd8b496 Merge pull request 'feat: replace dynamic sidebar with namespaces list page' (#17) from feat/namespaces-list-view into main
Reviewed-on: farhoodliquor/headlamp-polaris-plugin#17
2026-02-07 12:19:50 -05:00
Chris Farhood b815ce165d feat: replace dynamic sidebar with namespaces list page
Headlamp's sidebar Collapse only opens when an item is selected via
route matching, so 3-level nesting (Polaris > Namespaces > ns) never
expanded. Replace the DynamicSidebarRegistrar with a dedicated
/polaris/namespaces route that shows a table of namespaces with
scores and clickable links to the detail views.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 12:15:04 -05:00
27 changed files with 1741 additions and 151 deletions
+28
View File
@@ -0,0 +1,28 @@
name: E2E
on:
push:
branches:
- main
pull_request:
jobs:
e2e:
runs-on: ubuntu-latest
container: node:20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Install Chromium
run: npx playwright install --with-deps chromium
- name: Run E2E smoke tests
env:
HEADLAMP_URL: https://headlamp.animaniacs.farh.net
AUTHENTIK_USERNAME: ${{ secrets.AUTHENTIK_USERNAME }}
AUTHENTIK_PASSWORD: ${{ secrets.AUTHENTIK_PASSWORD }}
run: npx playwright test
+3
View File
@@ -3,3 +3,6 @@ dist/
.headlamp-plugin/
.mcp.json
*.tar.gz
e2e/.auth/
test-results/
.playwright-mcp/
+29 -34
View File
@@ -8,12 +8,12 @@ A [Headlamp](https://headlamp.dev/) plugin that surfaces [Fairwinds Polaris](htt
Adds a **Polaris** top-level sidebar section to Headlamp with the following views:
- **Overview** -- cluster score as a percentage (color-coded green/amber/red), check summary (pass/warning/danger counts), and cluster info (nodes, pods, namespaces, controllers)
- **Full Audit** -- same as overview but includes skipped checks in the totals
- **Namespace drill-down** -- per-namespace score, check counts, and a resource table showing pass/warning/danger per workload. Namespace entries appear dynamically in the sidebar based on live audit data.
- **External link** -- quick jump to the native Polaris dashboard via the Kubernetes service proxy
- **Overview** -- cluster score as a percentage (color-coded green/amber/red), check summary (pass/warning/danger/skipped counts), and cluster info (nodes, pods, namespaces, controllers)
- **Namespaces** -- table of all namespaces with per-namespace score, pass/warning/danger/skipped counts; click a namespace to drill down
- **Namespace detail** -- per-namespace score, check counts, and a resource table showing pass/warning/danger per workload
- **External link** -- quick jump to the native Polaris dashboard via the Kubernetes service proxy (from namespace detail view)
Data is fetched from the Polaris dashboard API through the Kubernetes service proxy (`/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json`). The plugin is read-only -- it never writes to the cluster.
Data is fetched from the Polaris dashboard API through the Kubernetes service proxy (`/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/results.json`). The plugin is read-only -- it never writes to the cluster.
Results are refreshed on a user-configurable interval (1 / 5 / 10 / 30 minutes, default 5). The setting is available in **Settings > Plugins > Polaris** and persists in the browser's localStorage.
@@ -52,7 +52,7 @@ Add it as an init container in your Headlamp Helm values:
```yaml
initContainers:
- name: polaris-plugin
image: git.farh.net/farhoodliquor/headlamp-polaris-plugin:v0.0.1
image: git.farh.net/farhoodliquor/headlamp-polaris-plugin:latest
command: ["sh", "-c", "cp -r /plugins/* /headlamp/plugins/"]
volumeMounts:
- name: plugins
@@ -72,7 +72,7 @@ volumeMounts:
Download the `.tar.gz` from the [GitHub releases page](https://github.com/cpfarhood/headlamp-polaris-plugin/releases) or the [Gitea releases page](https://git.farh.net/farhoodliquor/headlamp-polaris-plugin/releases), then extract into Headlamp's plugin directory:
```bash
tar xzf headlamp-polaris-plugin-0.0.1.tar.gz -C /headlamp/plugins/
tar xzf headlamp-polaris-plugin-<version>.tar.gz -C /headlamp/plugins/
```
### Option 4: Build from source
@@ -172,10 +172,13 @@ npm run build # outputs dist/main.js
npm run package # creates headlamp-polaris-plugin-<version>.tar.gz
```
### Type-check
### Type-check, lint, format, and test
```bash
npm run tsc
npm run tsc # type-check without emitting
npm run lint # eslint
npm run format:check # prettier check
npm test # vitest unit tests
```
## Project Structure
@@ -186,12 +189,14 @@ src/
api/
polaris.ts -- TypeScript types (AuditData schema), usePolarisData hook,
countResults utilities, refresh interval settings.
polaris.test.ts -- Unit tests for utility functions (vitest).
PolarisDataContext.tsx -- React context provider; shared data fetch across views.
components/
DashboardView.tsx -- Overview / Full Audit page (score, check summary, cluster info).
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.
DynamicSidebarRegistrar.tsx -- Registers sidebar entries dynamically from audit namespaces.
PolarisSettings.tsx -- Plugin settings page (refresh interval selector).
vitest.config.mts -- Vitest configuration (jsdom environment).
```
## Data Source
@@ -199,7 +204,7 @@ src/
The plugin fetches live audit results from the Polaris dashboard HTTP API via the Kubernetes service proxy:
```
GET /api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json
GET /api/v1/namespaces/polaris/services/polaris-dashboard/proxy/results.json
```
This endpoint is served by the `polaris-dashboard` ClusterIP service, which is created by the Polaris Helm chart when `dashboard.enabled: true`. The JSON response matches Polaris's `AuditData` schema (`pkg/validator/output.go`):
@@ -215,48 +220,38 @@ AuditData
Results{} -- container-level check results
```
Each check in a `ResultSet` has `Success` (bool) and `Severity` (`"warning"`, `"danger"`, or `"ignore"`). The cluster score is computed client-side as `pass / total * 100`.
Each check in a `ResultSet` has `Success` (bool) and `Severity` (`"warning"`, `"danger"`, or `"ignore"`). Checks with `Severity: "ignore"` and `Success: false` are counted as skipped. The cluster score is computed client-side as `pass / total * 100`.
## Releasing
Releases are automated via CI. To cut a release:
```bash
# Bump version in package.json and artifacthub-pkg.yml, then:
git add package.json package-lock.json artifacthub-pkg.yml
git commit -m "chore: bump version to 0.0.2"
git tag v0.0.2
git push origin main v0.0.2
# Bump version in package.json and artifacthub-pkg.yml (version + archive-url), then:
git add package.json artifacthub-pkg.yml
git commit -m "chore: bump version to X.Y.Z"
git tag vX.Y.Z
git push origin main vX.Y.Z
```
This triggers two CI pipelines:
**Gitea Actions** (`.gitea/workflows/release.yaml`):
This triggers the **Gitea Actions** release workflow (`.gitea/workflows/release.yaml`):
1. Build the plugin in a `node:20` container
2. Package a `.tar.gz` tarball
3. Build and push a Docker image to `git.farh.net/farhoodliquor/headlamp-polaris-plugin:{tag}` and `:latest`
4. Create a Gitea release with the tarball attached
5. Create a GitHub release with the same tarball (for Artifact Hub)
6. Update `artifacthub-pkg.yml` checksum on main and force-move the tag to match
**GitHub Actions** (`.github/workflows/release.yml`):
1. Build and package the plugin
2. Create a GitHub release with the tarball attached (required for Artifact Hub)
The Gitea repo push-mirrors to GitHub automatically, so both pipelines trigger from a single `git push`.
A guard step prevents infinite loops: if the release tarball checksum already matches the metadata, the build is skipped.
### CI secrets
| Secret | Where | Purpose |
|---|---|---|
| `REGISTRY_TOKEN` | Gitea | Personal access token with `package:write` scope for Docker image push |
| `GH_PAT` | Gitea | GitHub personal access token for creating GitHub releases |
The Gitea release uses the built-in `github.token`. The GitHub release uses the default `GITHUB_TOKEN` with `contents: write` permission.
### Updating Artifact Hub
When releasing a new version, update `artifacthub-pkg.yml`:
- `version` field
- `headlamp/plugin/archive-url` annotation (update the version in the download URL)
- `headlamp/plugin/archive-checksum` annotation (SHA256 of the new tarball, printed by the CI build)
The Gitea release uses the built-in `github.token`. The `archive-checksum` in `artifacthub-pkg.yml` is updated automatically by the release workflow.
## Links
+3 -3
View File
@@ -1,4 +1,4 @@
version: 0.1.0
version: 0.1.4
name: headlamp-polaris-plugin
displayName: Polaris
createdAt: "2026-02-05T19:00:00Z"
@@ -28,7 +28,7 @@ maintainers:
- name: cpfarhood
email: "chris@farhood.org"
annotations:
headlamp/plugin/archive-url: "https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/v0.1.0/headlamp-polaris-plugin-0.1.0.tar.gz"
headlamp/plugin/archive-url: "https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/v0.1.4/headlamp-polaris-plugin-0.1.4.tar.gz"
headlamp/plugin/version-compat: ">=0.26"
headlamp/plugin/archive-checksum: sha256:c720f4386a8581560412be43a796316812a5850173d4428a7d0f289d7a04c1a3
headlamp/plugin/archive-checksum: sha256:932e875310e1d3cd51a2b87ceff457582f6877395d2c521b75501849ac4af0ae
headlamp/plugin/distro-compat: in-cluster
+11 -5
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Headlamp plugin that surfaces Fairwinds Polaris audit results inside the Headlamp UI. Queries the Polaris dashboard API via the Kubernetes service proxy (`/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json`). Target Headlamp ≥ v0.26.
Headlamp plugin that surfaces Fairwinds Polaris audit results inside the Headlamp UI. Queries the Polaris dashboard API via the Kubernetes service proxy (`/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/results.json`). Target Headlamp ≥ v0.26.
## Build & Development Commands
@@ -23,6 +23,9 @@ npx tsc --noEmit
# Lint
npx eslint src/
# Run tests
npm test
```
## Architecture
@@ -32,19 +35,22 @@ src/
├── index.tsx # Entry point: registers sidebar entries + routes
├── api/
│ ├── polaris.ts # Types (AuditData schema), usePolarisData hook, countResults utilities, refresh settings
│ ├── polaris.test.ts # Unit tests for utility functions (vitest)
│ └── PolarisDataContext.tsx # React context provider for shared data fetch
└── components/
├── DashboardView.tsx # Overview / Full Audit page (score, check summary, cluster info)
├── DashboardView.tsx # Overview page (score, check summary with skipped count, cluster info)
├── NamespacesListView.tsx # Namespace list with scores and links to detail views
├── NamespaceDetailView.tsx # Per-namespace drill-down with resource table
├── DynamicSidebarRegistrar.tsx # Registers namespace sidebar entries from live audit data
└── PolarisSettings.tsx # Plugin settings (refresh interval selector)
```
Top-level sidebar section at `/polaris` with sub-routes for full audit (`/polaris/full-audit`) and per-namespace views (`/polaris/ns/:namespace`). Data is fetched via `ApiProxy.request` to the Polaris dashboard service proxy and refreshed on a user-configurable interval (stored in localStorage under `polaris-plugin-refresh-interval`, default 5 minutes). Score is computed from result counts (pass/total).
Top-level sidebar section at `/polaris` with sub-routes for namespaces list (`/polaris/namespaces`) and per-namespace views (`/polaris/ns/:namespace`). Data is fetched via `ApiProxy.request` to the Polaris dashboard service proxy and refreshed on a user-configurable interval (stored in localStorage under `polaris-plugin-refresh-interval`, default 5 minutes). Score is computed from result counts (pass/total). Skipped checks are always displayed in summaries.
**Sidebar limitation**: Headlamp's sidebar only supports 2-level nesting (parent → children). The `Collapse` component is driven by route-based selection, not click-to-toggle, so 3-level hierarchies don't expand properly. Namespace navigation is handled via the in-content table on the Namespaces page instead.
## Security / RBAC Requirements
The plugin reaches Polaris through the Kubernetes API server's service proxy sub-resource (`/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/...`). The Headlamp service account (or the user's bearer token when Headlamp runs in token-auth mode) must be granted:
The plugin reaches Polaris through the Kubernetes API server's service proxy sub-resource (`/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/...`). The Headlamp service account (or the user's bearer token when Headlamp runs in token-auth mode) must be granted:
| Verb | API Group | Resource | Resource Name | Namespace |
|------|-----------|----------|---------------|-----------|
+58
View File
@@ -0,0 +1,58 @@
# E2E Smoke Tests
Playwright-based smoke tests that validate the Polaris plugin against a live Headlamp deployment.
## CI
E2E tests run automatically in Gitea Actions on pushes to `main` and pull requests. The workflow (`.gitea/workflows/e2e.yaml`) uses Authentik OIDC for authentication via repo secrets.
### Required Gitea secrets
| Secret | Description |
| -------------------- | -------------------------------------------------------------- |
| `AUTHENTIK_USERNAME` | Authentik email or username for a CI user with Headlamp access |
| `AUTHENTIK_PASSWORD` | Password for that user |
## Running Locally
### Option 1: OIDC via Authentik (same as CI)
```bash
AUTHENTIK_USERNAME=you@example.com AUTHENTIK_PASSWORD=... npm run e2e
```
The default base URL is `https://headlamp.animaniacs.farh.net`. Override with `HEADLAMP_URL` if needed.
### Option 2: K8s bearer token (port-forward)
```bash
kubectl port-forward -n kube-system svc/headlamp 4466:80
export HEADLAMP_TOKEN=$(kubectl create token headlamp -n kube-system)
HEADLAMP_URL=http://localhost:4466 npm run e2e
```
Or in headed mode (opens a browser window):
```bash
HEADLAMP_URL=http://localhost:4466 npm run e2e:headed
```
## Environment Variables
| Variable | Required | Default | Description |
| -------------------- | -------- | -------------------------------------- | --------------------------------------- |
| `HEADLAMP_URL` | No | `https://headlamp.animaniacs.farh.net` | Base URL of the Headlamp instance |
| `AUTHENTIK_USERNAME` | OIDC | — | Authentik email/username |
| `AUTHENTIK_PASSWORD` | OIDC | — | Authentik password |
| `HEADLAMP_TOKEN` | Token | — | Kubernetes bearer token (fallback auth) |
Set either `AUTHENTIK_USERNAME` + `AUTHENTIK_PASSWORD` or `HEADLAMP_TOKEN`. OIDC takes priority if both are set.
## What the Tests Validate
- **Sidebar entry** — The Polaris sidebar item appears after login
- **Overview page** — Cluster score and check distribution render correctly
- **Namespaces page** — Table of namespaces loads with clickable links
- **Namespace detail** — Clicking a namespace shows its score and resource table
These are smoke tests against real cluster data. They verify the plugin loads and renders without errors, not specific data values.
+67
View File
@@ -0,0 +1,67 @@
import { test as setup, expect, Page } from '@playwright/test';
const AUTH_STATE_PATH = 'e2e/.auth/state.json';
async function authenticateWithOIDC(page: Page, username: string, password: string): Promise<void> {
// Navigate to login — Headlamp redirects / to /c/main/login
await page.goto('/');
await page.waitForURL('**/login');
// Click "Sign In" and capture the Authentik popup
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: /sign in/i }).click();
const popup = await popupPromise;
// Authentik step 1: fill username
await popup.getByRole('textbox', { name: /email or username/i }).fill(username);
await popup.getByRole('button', { name: /log in/i }).click();
// Authentik step 2: fill password
await popup.getByRole('textbox', { name: /password/i }).fill(password);
await popup.getByRole('button', { name: /continue|log in/i }).click();
// Wait for the popup to close (Authentik redirects back, Headlamp processes callback)
await popup.waitForEvent('close', { timeout: 15_000 });
// Original page should now be authenticated — wait for sidebar
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
timeout: 15_000,
});
}
async function authenticateWithToken(page: Page, token: string): Promise<void> {
// Navigate to login — Headlamp redirects / to /c/main/login
await page.goto('/');
await page.waitForURL('**/login');
// Click the token auth option
await page.getByRole('button', { name: /use a token/i }).click();
await page.waitForURL('**/token');
// Fill the "ID token" field and submit
await page.getByRole('textbox', { name: /id token/i }).fill(token);
await page.getByRole('button', { name: /authenticate/i }).click();
// Wait for the main UI to load
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
timeout: 15_000,
});
}
setup('authenticate with Headlamp', async ({ page }) => {
const username = process.env.AUTHENTIK_USERNAME;
const password = process.env.AUTHENTIK_PASSWORD;
const token = process.env.HEADLAMP_TOKEN;
if (username && password) {
await authenticateWithOIDC(page, username, password);
} else if (token) {
await authenticateWithToken(page, token);
} else {
throw new Error(
'Set AUTHENTIK_USERNAME + AUTHENTIK_PASSWORD for OIDC auth, or HEADLAMP_TOKEN for token auth'
);
}
await page.context().storageState({ path: AUTH_STATE_PATH });
});
+61
View File
@@ -0,0 +1,61 @@
import { test, expect } from '@playwright/test';
test.describe('Polaris plugin smoke tests', () => {
test('sidebar contains Polaris entry', async ({ page }) => {
await page.goto('/');
// The sidebar is the "Navigation" nav element (not "Appbar Tools")
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
await expect(sidebar).toBeVisible({ timeout: 15_000 });
await expect(sidebar.getByRole('button', { name: 'Polaris' })).toBeVisible();
});
test('overview page renders cluster score', async ({ page }) => {
await page.goto('/c/main/polaris');
// SectionHeader renders a heading
await expect(page.getByRole('heading', { name: 'Polaris \u2014 Overview' })).toBeVisible();
// "Cluster Score" section exists with a percentage
await expect(page.getByText('Cluster Score')).toBeVisible();
await expect(page.getByText(/%/)).toBeVisible();
});
test('namespaces page renders table with links', async ({ page }) => {
await page.goto('/c/main/polaris/namespaces');
await expect(page.getByRole('heading', { name: 'Polaris \u2014 Namespaces' })).toBeVisible();
// Table should have at least one row with a namespace link
const table = page.locator('table');
await expect(table).toBeVisible();
const rows = table.locator('tbody tr');
await expect(rows.first()).toBeVisible();
// Each namespace row should contain a link
const firstLink = rows.first().locator('a');
await expect(firstLink).toBeVisible();
});
test('namespace detail page renders from table link', async ({ page }) => {
await page.goto('/c/main/polaris/namespaces');
// Click the first namespace link in the table
const table = page.locator('table');
await expect(table).toBeVisible();
const firstLink = table.locator('tbody tr').first().locator('a');
const namespaceName = await firstLink.textContent();
await firstLink.click();
// Detail page should show the namespace name in the heading
await expect(
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
).toBeVisible();
// "Namespace Score" section should be present
await expect(page.getByText('Namespace Score')).toBeVisible();
// Resources table should exist
await expect(page.getByText('Resources')).toBeVisible();
await expect(page.locator('table')).toBeVisible();
});
});
+67 -3
View File
@@ -1,14 +1,15 @@
{
"name": "headlamp-polaris-plugin",
"version": "0.1.0",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "headlamp-polaris-plugin",
"version": "0.1.0",
"version": "0.1.3",
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0"
"@kinvolk/headlamp-plugin": "^0.13.0",
"@playwright/test": "^1.58.2"
}
},
"node_modules/@adobe/css-tools": {
@@ -2469,6 +2470,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -13554,6 +13571,53 @@
"node": ">=8"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+8 -3
View File
@@ -1,6 +1,6 @@
{
"name": "headlamp-polaris-plugin",
"version": "0.1.0",
"version": "0.1.4",
"description": "Headlamp plugin for Fairwinds Polaris audit results",
"scripts": {
"start": "headlamp-plugin start",
@@ -10,9 +10,14 @@
"lint": "eslint --ext .ts,.tsx src/",
"lint:fix": "eslint --ext .ts,.tsx --fix src/",
"format": "prettier --write src/",
"format:check": "prettier --check src/"
"format:check": "prettier --check src/",
"test": "vitest run",
"test:watch": "vitest",
"e2e": "playwright test",
"e2e:headed": "playwright test --headed"
},
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0"
"@kinvolk/headlamp-plugin": "^0.13.0",
"@playwright/test": "^1.58.2"
}
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: 'list',
use: {
baseURL: process.env.HEADLAMP_URL || 'https://headlamp.animaniacs.farh.net',
trace: 'on-first-retry',
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/state.json',
},
dependencies: ['setup'],
},
],
});
+48
View File
@@ -0,0 +1,48 @@
import { renderHook } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { makeAuditData, makeResult } from '../test-utils';
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: vi.fn() },
}));
// Mock usePolarisData so PolarisDataProvider doesn't make real API calls
vi.mock('./polaris', async importOriginal => {
const actual = await importOriginal<typeof import('./polaris')>();
return {
...actual,
usePolarisData: vi.fn(() => ({
data: makeAuditData([makeResult()]),
loading: false,
error: null,
})),
};
});
import { PolarisDataProvider, usePolarisDataContext } from './PolarisDataContext';
describe('usePolarisDataContext', () => {
it('throws when used outside PolarisDataProvider', () => {
// Suppress console.error from React during expected error
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => usePolarisDataContext());
}).toThrow('usePolarisDataContext must be used within a PolarisDataProvider');
spy.mockRestore();
});
it('returns context value when inside PolarisDataProvider', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<PolarisDataProvider>{children}</PolarisDataProvider>
);
const { result } = renderHook(() => usePolarisDataContext(), { wrapper });
expect(result.current.data).not.toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
});
+390
View File
@@ -0,0 +1,390 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { makeAuditData, makeResult } from '../test-utils';
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: vi.fn() },
}));
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
import {
computeScore,
countResults,
countResultsForItems,
filterResultsByNamespace,
getNamespaces,
getRefreshInterval,
Result,
ResultCounts,
setRefreshInterval,
usePolarisData,
} from './polaris';
// --- computeScore ---
describe('computeScore', () => {
it('returns 0 when total is 0', () => {
const counts: ResultCounts = { total: 0, pass: 0, warning: 0, danger: 0, skipped: 0 };
expect(computeScore(counts)).toBe(0);
});
it('returns 100 when all checks pass', () => {
const counts: ResultCounts = { total: 10, pass: 10, warning: 0, danger: 0, skipped: 0 };
expect(computeScore(counts)).toBe(100);
});
it('rounds to nearest integer', () => {
const counts: ResultCounts = { total: 3, pass: 1, warning: 1, danger: 1, skipped: 0 };
expect(computeScore(counts)).toBe(33);
});
it('includes skipped in total denominator', () => {
const counts: ResultCounts = { total: 10, pass: 5, warning: 2, danger: 1, skipped: 2 };
expect(computeScore(counts)).toBe(50);
});
});
// --- countResults / countResultsForItems ---
describe('countResults', () => {
it('returns zero counts for empty results', () => {
const data = makeAuditData([]);
const counts = countResults(data);
expect(counts).toEqual({ total: 0, pass: 0, warning: 0, danger: 0, skipped: 0 });
});
it('counts top-level result set entries', () => {
const result = makeResult({
Results: {
check1: {
ID: 'check1',
Message: 'ok',
Details: [],
Success: true,
Severity: 'warning',
Category: 'Security',
},
check2: {
ID: 'check2',
Message: 'bad',
Details: [],
Success: false,
Severity: 'danger',
Category: 'Security',
},
},
});
const counts = countResults(makeAuditData([result]));
expect(counts.total).toBe(2);
expect(counts.pass).toBe(1);
expect(counts.danger).toBe(1);
expect(counts.warning).toBe(0);
expect(counts.skipped).toBe(0);
});
it('counts skipped (severity=ignore, success=false) entries', () => {
const result = makeResult({
Results: {
skipped1: {
ID: 'skipped1',
Message: 'skipped',
Details: [],
Success: false,
Severity: 'ignore',
Category: 'Security',
},
},
});
const counts = countResults(makeAuditData([result]));
expect(counts.total).toBe(1);
expect(counts.skipped).toBe(1);
expect(counts.pass).toBe(0);
});
it('counts PodResult and ContainerResults', () => {
const result = makeResult({
Results: {
top: {
ID: 'top',
Message: 'ok',
Details: [],
Success: true,
Severity: 'warning',
Category: 'Reliability',
},
},
PodResult: {
Name: 'pod-1',
Results: {
podCheck: {
ID: 'podCheck',
Message: 'warn',
Details: [],
Success: false,
Severity: 'warning',
Category: 'Reliability',
},
},
ContainerResults: [
{
Name: 'container-1',
Results: {
containerCheck: {
ID: 'containerCheck',
Message: 'danger',
Details: [],
Success: false,
Severity: 'danger',
Category: 'Security',
},
},
},
],
},
});
const counts = countResults(makeAuditData([result]));
expect(counts.total).toBe(3);
expect(counts.pass).toBe(1);
expect(counts.warning).toBe(1);
expect(counts.danger).toBe(1);
});
it('aggregates across multiple results', () => {
const r1 = makeResult({
Name: 'deploy-a',
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
},
});
const r2 = makeResult({
Name: 'deploy-b',
Results: {
c2: {
ID: 'c2',
Message: '',
Details: [],
Success: false,
Severity: 'warning',
Category: 'X',
},
},
});
const counts = countResults(makeAuditData([r1, r2]));
expect(counts.total).toBe(2);
expect(counts.pass).toBe(1);
expect(counts.warning).toBe(1);
});
});
describe('countResultsForItems', () => {
it('works on a subset of results', () => {
const results: Result[] = [
makeResult({
Results: {
a: {
ID: 'a',
Message: '',
Details: [],
Success: false,
Severity: 'danger',
Category: 'X',
},
},
}),
];
const counts = countResultsForItems(results);
expect(counts.danger).toBe(1);
expect(counts.total).toBe(1);
});
});
// --- getNamespaces ---
describe('getNamespaces', () => {
it('returns empty array for no results', () => {
expect(getNamespaces(makeAuditData([]))).toEqual([]);
});
it('returns sorted unique namespaces', () => {
const data = makeAuditData([
makeResult({ Namespace: 'beta' }),
makeResult({ Namespace: 'alpha' }),
makeResult({ Namespace: 'beta' }),
makeResult({ Namespace: 'gamma' }),
]);
expect(getNamespaces(data)).toEqual(['alpha', 'beta', 'gamma']);
});
it('excludes results with empty namespace (cluster-scoped resources)', () => {
const data = makeAuditData([
makeResult({ Namespace: '' }),
makeResult({ Namespace: 'alpha' }),
makeResult({ Namespace: '' }),
]);
expect(getNamespaces(data)).toEqual(['alpha']);
});
});
// --- filterResultsByNamespace ---
describe('filterResultsByNamespace', () => {
it('returns only results matching the namespace', () => {
const data = makeAuditData([
makeResult({ Name: 'a', Namespace: 'ns1' }),
makeResult({ Name: 'b', Namespace: 'ns2' }),
makeResult({ Name: 'c', Namespace: 'ns1' }),
]);
const filtered = filterResultsByNamespace(data, 'ns1');
expect(filtered).toHaveLength(2);
expect(filtered.map(r => r.Name)).toEqual(['a', 'c']);
});
it('returns empty array for non-existent namespace', () => {
const data = makeAuditData([makeResult({ Namespace: 'ns1' })]);
expect(filterResultsByNamespace(data, 'ns-missing')).toEqual([]);
});
});
// --- getRefreshInterval / setRefreshInterval ---
describe('getRefreshInterval', () => {
beforeEach(() => {
window.localStorage.removeItem('polaris-plugin-refresh-interval');
});
it('returns default (300) when nothing stored', () => {
expect(getRefreshInterval()).toBe(300);
});
it('returns stored value when valid', () => {
localStorage.setItem('polaris-plugin-refresh-interval', '60');
expect(getRefreshInterval()).toBe(60);
});
it('returns default for non-numeric stored value', () => {
localStorage.setItem('polaris-plugin-refresh-interval', 'abc');
expect(getRefreshInterval()).toBe(300);
});
it('returns default for zero stored value', () => {
localStorage.setItem('polaris-plugin-refresh-interval', '0');
expect(getRefreshInterval()).toBe(300);
});
it('returns default for negative stored value', () => {
localStorage.setItem('polaris-plugin-refresh-interval', '-10');
expect(getRefreshInterval()).toBe(300);
});
});
describe('setRefreshInterval', () => {
beforeEach(() => {
window.localStorage.removeItem('polaris-plugin-refresh-interval');
});
it('stores value that getRefreshInterval reads back', () => {
setRefreshInterval(1800);
expect(getRefreshInterval()).toBe(1800);
});
});
// --- usePolarisData ---
describe('usePolarisData', () => {
const mockRequest = ApiProxy.request as ReturnType<typeof vi.fn>;
beforeEach(() => {
mockRequest.mockReset();
});
it('returns data on successful fetch', async () => {
const auditData = makeAuditData([makeResult()]);
mockRequest.mockResolvedValue(auditData);
const { result } = renderHook(() => usePolarisData(300));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(auditData);
expect(result.current.error).toBeNull();
});
it('returns RBAC error on 403', async () => {
mockRequest.mockRejectedValue({ status: 403 });
const { result } = renderHook(() => usePolarisData(300));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeNull();
expect(result.current.error).toContain('403');
expect(result.current.error).toContain('RBAC');
});
it('returns not-installed error on 404', async () => {
mockRequest.mockRejectedValue({ status: 404 });
const { result } = renderHook(() => usePolarisData(300));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toContain('not reachable');
});
it('returns not-installed error on 503', async () => {
mockRequest.mockRejectedValue({ status: 503 });
const { result } = renderHook(() => usePolarisData(300));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toContain('not reachable');
});
it('returns generic error for other failures', async () => {
mockRequest.mockRejectedValue(new Error('network down'));
const { result } = renderHook(() => usePolarisData(300));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toContain('Failed to fetch');
expect(result.current.error).toContain('network down');
});
it('does not update state after unmount', async () => {
let resolveFetch: (value: unknown) => void = () => {};
mockRequest.mockReturnValue(
new Promise(resolve => {
resolveFetch = resolve;
})
);
const { result, unmount } = renderHook(() => usePolarisData(300));
expect(result.current.loading).toBe(true);
unmount();
// Resolve after unmount — should not throw or update state
await act(async () => {
resolveFetch(makeAuditData([]));
});
});
});
+5 -4
View File
@@ -105,7 +105,9 @@ export function countResultsForItems(results: Result[]): ResultCounts {
export function getNamespaces(data: AuditData): string[] {
const namespaces = new Set<string>();
for (const result of data.Results) {
namespaces.add(result.Namespace);
if (result.Namespace) {
namespaces.add(result.Namespace);
}
}
return Array.from(namespaces).sort();
}
@@ -144,7 +146,7 @@ export function setRefreshInterval(seconds: number): void {
// --- Polaris dashboard proxy URL ---
export const POLARIS_DASHBOARD_PROXY =
'/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/';
'/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/';
// --- Score computation ---
@@ -155,8 +157,7 @@ export function computeScore(counts: ResultCounts): number {
// --- Data fetching hook ---
const POLARIS_API_PATH =
'/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json';
const POLARIS_API_PATH = '/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/results.json';
interface PolarisDataState {
data: AuditData | null;
+134
View File
@@ -0,0 +1,134 @@
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 Headlamp CommonComponents as thin pass-throughs
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>
),
PercentageCircle: ({ label }: { label: string }) => (
<div data-testid="percentage-circle">{label}</div>
),
PercentageBar: () => <div data-testid="percentage-bar" />,
}));
// Mock the context hook — we'll override per test via mockReturnValue
const mockUsePolarisDataContext = vi.fn();
vi.mock('../api/PolarisDataContext', () => ({
usePolarisDataContext: () => mockUsePolarisDataContext(),
}));
import DashboardView from './DashboardView';
describe('DashboardView', () => {
it('renders loader when loading', () => {
mockUsePolarisDataContext.mockReturnValue({
data: null,
loading: true,
error: null,
});
render(<DashboardView />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading Polaris audit data');
});
it('renders error message when error is set', () => {
mockUsePolarisDataContext.mockReturnValue({
data: null,
loading: false,
error: 'Access denied (403)',
});
render(<DashboardView />);
expect(screen.getByText('Access denied (403)')).toBeInTheDocument();
});
it('renders score, check distribution, and cluster info with data', () => {
const data = makeAuditData([
makeResult({
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
c2: {
ID: 'c2',
Message: '',
Details: [],
Success: false,
Severity: 'danger',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({
data,
loading: false,
error: null,
});
render(<DashboardView />);
// Score circle shows 50%
expect(screen.getByTestId('percentage-circle')).toHaveTextContent('50%');
// Check distribution values
expect(screen.getByText('Total Checks')).toBeInTheDocument();
// Cluster info section (title is in data-title attr of SectionBox)
const sectionBoxes = screen.getAllByTestId('section-box');
const clusterInfoBox = sectionBoxes.find(
el => el.getAttribute('data-title') === 'Cluster Info'
);
expect(clusterInfoBox).toBeDefined();
// Cluster info values
expect(screen.getByText('Nodes')).toBeInTheDocument();
expect(screen.getByText('Pods')).toBeInTheDocument();
});
it('renders "No Data" when no data and no error', () => {
mockUsePolarisDataContext.mockReturnValue({
data: null,
loading: false,
error: null,
});
render(<DashboardView />);
expect(screen.getByText('No Polaris audit results found.')).toBeInTheDocument();
});
});
+37 -50
View File
@@ -1,70 +1,60 @@
import {
Loader,
NameValueTable,
PercentageBar,
PercentageCircle,
SectionBox,
SectionHeader,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { AuditData, countResults, ResultCounts } from '../api/polaris';
import { AuditData, computeScore, countResults, ResultCounts } from '../api/polaris';
import { usePolarisDataContext } from '../api/PolarisDataContext';
function scoreStatus(score: number): 'success' | 'warning' | 'error' {
if (score >= 80) return 'success';
if (score >= 50) return 'warning';
return 'error';
}
const COLORS = {
pass: '#4caf50',
warning: '#ff9800',
danger: '#f44336',
skipped: '#9e9e9e',
};
function OverviewSection(props: {
data: AuditData;
counts: ResultCounts;
includeSkipped: boolean;
}) {
const { counts, includeSkipped } = props;
function OverviewSection(props: { data: AuditData; counts: ResultCounts }) {
const { counts } = props;
const score = computeScore(counts);
const displayTotal = includeSkipped ? counts.total : counts.total - counts.skipped;
const displayPass = counts.pass;
const score = displayTotal === 0 ? 0 : Math.round((displayPass / displayTotal) * 100);
const status = scoreStatus(score);
const summaryRows: { name: string; value: React.ReactNode }[] = [
{ name: 'Total Checks', value: String(displayTotal) },
{
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>,
},
const chartData = [
{ name: 'Pass', value: counts.pass, fill: COLORS.pass },
{ name: 'Warning', value: counts.warning, fill: COLORS.warning },
{ name: 'Danger', value: counts.danger, fill: COLORS.danger },
{ name: 'Skipped', value: counts.skipped, fill: COLORS.skipped },
];
if (includeSkipped) {
summaryRows.push({
name: 'Skipped',
value: <StatusLabel status="">{counts.skipped}</StatusLabel>,
});
}
return (
<>
<SectionBox title="Score">
<SectionBox title="Cluster Score">
<PercentageCircle data={chartData} total={counts.total} label={`${score}%`} />
</SectionBox>
<SectionBox title="Check Distribution">
<PercentageBar data={chartData} total={counts.total} />
<NameValueTable
rows={[
{ name: 'Total Checks', value: String(counts.total) },
{
name: 'Cluster Score',
value: <StatusLabel status={status}>{score}%</StatusLabel>,
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: String(counts.skipped) },
]}
/>
</SectionBox>
<SectionBox title="Check Summary">
<NameValueTable rows={summaryRows} />
</SectionBox>
<SectionBox title="Cluster Info">
<NameValueTable
rows={[
@@ -79,9 +69,8 @@ function OverviewSection(props: {
);
}
export default function DashboardView(props: { includeSkipped: boolean }) {
export default function DashboardView() {
const { data, loading, error } = usePolarisDataContext();
const title = props.includeSkipped ? 'Polaris — Full Audit' : 'Polaris — Overview';
if (loading) {
return <Loader title="Loading Polaris audit data..." />;
@@ -91,7 +80,7 @@ export default function DashboardView(props: { includeSkipped: boolean }) {
return (
<>
<SectionHeader title={title} />
<SectionHeader title="Polaris — Overview" />
{error && (
<SectionBox title="Error">
@@ -106,9 +95,7 @@ export default function DashboardView(props: { includeSkipped: boolean }) {
</SectionBox>
)}
{data && counts && (
<OverviewSection data={data} counts={counts} includeSkipped={props.includeSkipped} />
)}
{data && counts && <OverviewSection data={data} counts={counts} />}
{!data && !error && (
<SectionBox title="No Data">
@@ -1,29 +0,0 @@
import { registerSidebarEntry } from '@kinvolk/headlamp-plugin/lib';
import React from 'react';
import { getNamespaces } from '../api/polaris';
import { usePolarisDataContext } from '../api/PolarisDataContext';
const registeredNamespaces = new Set<string>();
export default function DynamicSidebarRegistrar() {
const { data } = usePolarisDataContext();
React.useEffect(() => {
if (!data) return;
const namespaces = getNamespaces(data);
for (const ns of namespaces) {
if (registeredNamespaces.has(ns)) continue;
registeredNamespaces.add(ns);
registerSidebarEntry({
parent: 'polaris-namespaces',
name: `polaris-ns-${ns}`,
label: ns,
url: `/polaris/ns/${ns}`,
icon: 'mdi:folder-outline',
});
}
}, [data]);
return null;
}
+200
View File
@@ -0,0 +1,200 @@
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"'
);
});
});
+4
View File
@@ -118,6 +118,10 @@ export default function NamespaceDetailView() {
name: 'Danger',
value: <StatusLabel status="error">{counts.danger}</StatusLabel>,
},
{
name: 'Skipped',
value: String(counts.skipped),
},
]}
/>
</SectionBox>
+219
View File
@@ -0,0 +1,219 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
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() },
Router: {
createRouteURL: (name: string, params: Record<string, string>) =>
`/polaris/ns/${params.namespace}`,
},
}));
// 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 NamespacesListView from './NamespacesListView';
function renderWithRouter(ui: React.ReactElement) {
return render(<MemoryRouter>{ui}</MemoryRouter>);
}
describe('NamespacesListView', () => {
it('renders loader when loading', () => {
mockUsePolarisDataContext.mockReturnValue({
data: null,
loading: true,
error: null,
});
renderWithRouter(<NamespacesListView />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading Polaris audit data');
});
it('renders error message when error is set', () => {
mockUsePolarisDataContext.mockReturnValue({
data: null,
loading: false,
error: 'Polaris dashboard not reachable',
});
renderWithRouter(<NamespacesListView />);
expect(screen.getByText('Polaris dashboard not reachable')).toBeInTheDocument();
});
it('renders "No Data" when no data and no error', () => {
mockUsePolarisDataContext.mockReturnValue({
data: null,
loading: false,
error: null,
});
renderWithRouter(<NamespacesListView />);
expect(screen.getByText('No Polaris audit results found.')).toBeInTheDocument();
});
it('renders namespace rows with correct scores and links', () => {
const data = makeAuditData([
makeResult({
Name: 'deploy-a',
Namespace: 'alpha',
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
},
}),
makeResult({
Name: 'deploy-b',
Namespace: 'beta',
Results: {
c2: {
ID: 'c2',
Message: '',
Details: [],
Success: false,
Severity: 'danger',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({
data,
loading: false,
error: null,
});
renderWithRouter(<NamespacesListView />);
// Namespace links
const alphaLink = screen.getByText('alpha');
expect(alphaLink.closest('a')).toHaveAttribute('href', '/polaris/ns/alpha');
const betaLink = screen.getByText('beta');
expect(betaLink.closest('a')).toHaveAttribute('href', '/polaris/ns/beta');
});
it('uses correct scoreStatus: >=80 success, >=50 warning, <50 error', () => {
// Create a namespace with 100% score (1 pass) and one with 0% (1 danger)
const data = makeAuditData([
makeResult({
Name: 'perfect',
Namespace: 'good-ns',
Results: {
c1: {
ID: 'c1',
Message: '',
Details: [],
Success: true,
Severity: 'warning',
Category: 'X',
},
},
}),
makeResult({
Name: 'bad',
Namespace: 'bad-ns',
Results: {
c2: {
ID: 'c2',
Message: '',
Details: [],
Success: false,
Severity: 'danger',
Category: 'X',
},
},
}),
]);
mockUsePolarisDataContext.mockReturnValue({
data,
loading: false,
error: null,
});
renderWithRouter(<NamespacesListView />);
// Find score StatusLabels - good-ns has 100% (success), bad-ns has 0% (error)
const statusLabels = screen.getAllByTestId('status-label');
const scoreLabels = statusLabels.filter(el => el.textContent?.includes('%'));
const successScore = scoreLabels.find(el => el.textContent === '100%');
expect(successScore).toHaveAttribute('data-status', 'success');
const errorScore = scoreLabels.find(el => el.textContent === '0%');
expect(errorScore).toHaveAttribute('data-status', 'error');
});
});
+135
View File
@@ -0,0 +1,135 @@
import { Router } from '@kinvolk/headlamp-plugin/lib';
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { Link } from 'react-router-dom';
import {
computeScore,
countResultsForItems,
filterResultsByNamespace,
getNamespaces,
} 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';
}
interface NamespaceRow {
namespace: string;
score: number;
pass: number;
warning: number;
danger: number;
skipped: number;
}
export default function NamespacesListView() {
const { data, loading, error } = usePolarisDataContext();
if (loading) {
return <Loader title="Loading Polaris audit data..." />;
}
if (error) {
return (
<>
<SectionHeader title="Polaris — Namespaces" />
<SectionBox title="Error">
<NameValueTable
rows={[
{
name: 'Status',
value: <StatusLabel status="error">{error}</StatusLabel>,
},
]}
/>
</SectionBox>
</>
);
}
if (!data) {
return (
<>
<SectionHeader title="Polaris — Namespaces" />
<SectionBox title="No Data">
<NameValueTable rows={[{ name: 'Status', value: 'No Polaris audit results found.' }]} />
</SectionBox>
</>
);
}
const namespaces = getNamespaces(data);
const rows: NamespaceRow[] = namespaces.map(ns => {
const results = filterResultsByNamespace(data, ns);
const counts = countResultsForItems(results);
const score = computeScore(counts);
return {
namespace: ns,
score,
pass: counts.pass,
warning: counts.warning,
danger: counts.danger,
skipped: counts.skipped,
};
});
return (
<>
<SectionHeader title="Polaris — Namespaces" />
<SectionBox>
<SimpleTable
columns={[
{
label: 'Namespace',
getter: (row: NamespaceRow) => (
<Link
to={Router.createRouteURL('polaris-namespace', {
namespace: row.namespace,
})}
>
{row.namespace}
</Link>
),
},
{
label: 'Score',
getter: (row: NamespaceRow) => (
<StatusLabel status={scoreStatus(row.score)}>{row.score}%</StatusLabel>
),
},
{
label: 'Pass',
getter: (row: NamespaceRow) => <StatusLabel status="success">{row.pass}</StatusLabel>,
},
{
label: 'Warning',
getter: (row: NamespaceRow) => (
<StatusLabel status="warning">{row.warning}</StatusLabel>
),
},
{
label: 'Danger',
getter: (row: NamespaceRow) => <StatusLabel status="error">{row.danger}</StatusLabel>,
},
{
label: 'Skipped',
getter: (row: NamespaceRow) => String(row.skipped),
},
]}
data={rows}
emptyMessage="No namespaces found in Polaris audit data."
/>
</SectionBox>
</>
);
}
+82
View File
@@ -0,0 +1,82 @@
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';
// Mock Headlamp lib
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: vi.fn() },
}));
// 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>
),
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: React.ReactNode }> }) => (
<div data-testid="name-value-table">
{rows.map(row => (
<div key={row.name}>
<span>{row.name}</span>
<span>{row.value}</span>
</div>
))}
</div>
),
}));
import PolarisSettings from './PolarisSettings';
describe('PolarisSettings', () => {
it('renders with interval from props.data', () => {
render(<PolarisSettings data={{ refreshInterval: 60 }} />);
const select = screen.getByRole('combobox');
expect(select).toHaveValue('60');
});
it('falls back to getRefreshInterval when no prop data', () => {
// Default is 300 (5 minutes)
render(<PolarisSettings />);
const select = screen.getByRole('combobox');
expect(select).toHaveValue('300');
});
it('renders all interval options', () => {
render(<PolarisSettings />);
const options = screen.getAllByRole('option');
expect(options).toHaveLength(4);
expect(options[0]).toHaveTextContent('1 minute');
expect(options[1]).toHaveTextContent('5 minutes');
expect(options[2]).toHaveTextContent('10 minutes');
expect(options[3]).toHaveTextContent('30 minutes');
});
it('calls setRefreshInterval and onDataChange when selection changes', async () => {
const onDataChange = vi.fn();
render(<PolarisSettings data={{ refreshInterval: 300 }} onDataChange={onDataChange} />);
const select = screen.getByRole('combobox');
await userEvent.selectOptions(select, '1800');
// Check localStorage was updated
expect(localStorage.getItem('polaris-plugin-refresh-interval')).toBe('1800');
// Check callback was called with merged data
expect(onDataChange).toHaveBeenCalledWith({ refreshInterval: 1800 });
});
it('works without onDataChange callback', async () => {
render(<PolarisSettings data={{ refreshInterval: 300 }} />);
const select = screen.getByRole('combobox');
// Should not throw even without onDataChange
await userEvent.selectOptions(select, '60');
expect(localStorage.getItem('polaris-plugin-refresh-interval')).toBe('60');
});
});
+9 -20
View File
@@ -6,8 +6,8 @@ import {
import React from 'react';
import { PolarisDataProvider } from './api/PolarisDataContext';
import DashboardView from './components/DashboardView';
import DynamicSidebarRegistrar from './components/DynamicSidebarRegistrar';
import NamespaceDetailView from './components/NamespaceDetailView';
import NamespacesListView from './components/NamespacesListView';
import PolarisSettings from './components/PolarisSettings';
// --- Sidebar entries ---
@@ -28,19 +28,11 @@ registerSidebarEntry({
icon: 'mdi:view-dashboard',
});
registerSidebarEntry({
parent: 'polaris',
name: 'polaris-full',
label: 'Full Audit',
url: '/polaris/full-audit',
icon: 'mdi:clipboard-text-search',
});
registerSidebarEntry({
parent: 'polaris',
name: 'polaris-namespaces',
label: 'Namespaces',
url: '/polaris',
url: '/polaris/namespaces',
icon: 'mdi:dns',
});
@@ -48,38 +40,35 @@ registerSidebarEntry({
registerRoute({
path: '/polaris',
sidebar: 'polaris',
sidebar: 'polaris-overview',
name: 'polaris',
exact: true,
component: () => (
<PolarisDataProvider>
<DynamicSidebarRegistrar />
<DashboardView includeSkipped={false} />
<DashboardView />
</PolarisDataProvider>
),
});
registerRoute({
path: '/polaris/full-audit',
sidebar: 'polaris-full',
name: 'polaris-full-audit',
path: '/polaris/namespaces',
sidebar: 'polaris-namespaces',
name: 'polaris-namespaces',
exact: true,
component: () => (
<PolarisDataProvider>
<DynamicSidebarRegistrar />
<DashboardView includeSkipped />
<NamespacesListView />
</PolarisDataProvider>
),
});
registerRoute({
path: '/polaris/ns/:namespace',
sidebar: 'polaris',
sidebar: 'polaris-namespaces',
name: 'polaris-namespace',
exact: true,
component: () => (
<PolarisDataProvider>
<DynamicSidebarRegistrar />
<NamespaceDetailView />
</PolarisDataProvider>
),
+61
View File
@@ -0,0 +1,61 @@
import React from 'react';
import { AuditData, Result } from './api/polaris';
// --- Fixtures ---
export function makeResult(overrides: Partial<Result> = {}): Result {
return {
Name: 'my-deploy',
Namespace: 'default',
Kind: 'Deployment',
Results: {},
CreatedTime: '2025-01-01T00:00:00Z',
...overrides,
};
}
export function makeAuditData(results: Result[]): AuditData {
return {
PolarisOutputVersion: '1.0',
AuditTime: '2025-01-01T00:00:00Z',
SourceType: 'Cluster',
SourceName: 'test',
DisplayName: 'test',
ClusterInfo: { Version: '1.28', Nodes: 3, Pods: 10, Namespaces: 2, Controllers: 5 },
Results: results,
};
}
// --- 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 };
+3
View File
@@ -1,4 +1,7 @@
{
"extends": "@kinvolk/headlamp-plugin/config/plugins-tsconfig.json",
"compilerOptions": {
"types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals", "lodash", "@testing-library/jest-dom"]
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
exclude: ['e2e/**', 'node_modules/**'],
},
});
+43
View File
@@ -0,0 +1,43 @@
import '@testing-library/jest-dom';
// Node 22+ ships a minimal built-in `localStorage` global (property-bag only,
// no getItem/setItem/removeItem/clear) that shadows jsdom's Web Storage
// implementation. Provide a spec-compliant shim so code under test works.
if (typeof localStorage !== 'undefined' && typeof localStorage.getItem !== 'function') {
const store = new Map<string, string>();
const storage = {
getItem(key: string): string | null {
return store.get(key) ?? null;
},
setItem(key: string, value: string): void {
store.set(key, String(value));
},
removeItem(key: string): void {
store.delete(key);
},
clear(): void {
store.clear();
},
get length(): number {
return store.size;
},
key(index: number): string | null {
return [...store.keys()][index] ?? null;
},
};
Object.defineProperty(globalThis, 'localStorage', {
value: storage,
writable: true,
configurable: true,
});
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'localStorage', {
value: storage,
writable: true,
configurable: true,
});
}
}