Compare commits

..

50 Commits

Author SHA1 Message Date
Chris Farhood b9e9484bf0 Merge pull request 'chore: bump version to 0.1.7' (#24) from release/0.1.7 into main
Reviewed-on: farhoodliquor/headlamp-polaris-plugin#24
2026-02-09 21:01:24 -05:00
Chris Farhood 22d88cfca4 Merge pull request 'docs: remove incorrect dev installation instructions' (#23) from docs/remove-sidecar-instructions into main
Reviewed-on: farhoodliquor/headlamp-polaris-plugin#23
2026-02-09 21:01:18 -05:00
Chris Farhood 48dcb214b9 chore: bump version to 0.1.7 2026-02-09 20:58:41 -05:00
Chris Farhood 762056e46c docs: remove incorrect dev installation instructions 2026-02-09 17:50:08 -05:00
gitea-actions[bot] f2a2176eb6 ci: update artifact hub metadata for v0.2.0-dev.5 2026-02-09 18:48:56 +00:00
Chris Farhood 73939e66ad chore: update workflow to use 'dev' branch name
Updated GITEA_BRANCH reference from 'dev/namespace-drawer' to 'dev'
to match the renamed long-lived development branch.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-09 13:46:47 -05:00
Chris Farhood 2c26d49bf9 docs: add dev/preview version installation instructions
Documents how to install dev preview versions using direct URLs since
they are not published to ArtifactHub. Includes sidecar pattern example
and manual download instructions.
2026-02-09 13:41:30 -05:00
Chris Farhood 679be5dedc fix: only update GitHub main for stable releases 2026-02-09 13:03:07 -05:00
Chris Farhood c67bcb1804 chore: bump version to 0.1.6 2026-02-07 22:13:19 -05:00
Chris Farhood c19bb2fa87 fix: use "Polaris" as plugin settings display name
Changed from "headlamp-polaris-plugin" to "Polaris" in the
registerPluginSettings call. This makes the plugin name appear
cleanly in Settings > Plugins.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-07 22:12:55 -05:00
Chris Farhood 253d1277d9 docs: document skipped count limitation
Added "Known Limitations" section explaining that the skipped count
only reflects Severity=ignore checks and does not include
annotation-based exemptions.

Explains why (exempted checks omitted from results.json) and what
would be required to support exemption counting (direct K8s resource
queries with broader RBAC).

Points users to the "View in Polaris Dashboard" link as a workaround.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-07 22:12:55 -05:00
Chris Farhood f69c91acf9 feat: add tooltip to skipped count explaining limitation
The skipped count only reflects checks with Severity=ignore from
the Polaris API. Annotation-based exemptions (e.g.,
polaris.fairwinds.com/*-exempt) are not included because:

1. Exempted checks are completely omitted from results.json
2. The Polaris dashboard UI counts exemptions client-side by
   querying Kubernetes resources for annotations
3. Our plugin only has access to the processed audit results

Added HTML title tooltip to explain this limitation to users.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-07 22:12:55 -05:00
Chris Farhood 5659026959 fix: add :80 port to dashboard proxy constant
The POLARIS_DASHBOARD_PROXY constant was missing :80, causing
dashboard links in the UI to fail with "no endpoints available".
This matches the fix already applied to POLARIS_API_PATH.

Fixes external dashboard link in namespace detail view.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-07 22:12:55 -05:00
gitea-actions[bot] 6ae632f577 ci: update artifact hub metadata for v0.1.5 2026-02-08 01:48:09 +00:00
Chris Farhood e0cfb4e808 chore: bump version to 0.1.5 2026-02-07 20:46:32 -05:00
Chris Farhood c4c43cef40 fix: restore :80 port in service proxy URL
The removal of :80 in commit 39d85a3 broke service proxy requests.
Kubernetes API requires explicit port specification when services
have named ports. Without it, the API server returns "no endpoints
available" even though endpoints exist.

Root cause: polaris-dashboard service defines port as named
"http-dashboard" on port 80. The proxy sub-resource requires
either :80 or :http-dashboard suffix to resolve correctly.

Fixes the "Polaris dashboard not reachable" error on v0.1.4.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-07 20:46:13 -05:00
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
gitea-actions[bot] 4126439e52 ci: update artifact hub metadata for v0.1.0 2026-02-07 16:37:15 +00:00
Chris Farhood 49ed3ea7ff chore: bump version to 0.1.0 and fix release workflow race condition
Fetch latest main before pushing metadata update to prevent
non-fast-forward rejections when main moves during the release run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 11:36:13 -05:00
Chris Farhood deafe68893 chore: bump version to 0.0.11
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 10:47:43 -05:00
Chris Farhood a6f30bf681 ci: update artifact hub metadata for v0.0.10
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 10:46:42 -05:00
Chris Farhood 9df30c3943 docs: address review feedback on dashboard.enabled wording
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 10:35:51 -05:00
Chris Farhood cc3cc81af9 docs: add service proxy RBAC/security requirements and update outdated docs
The README, CLAUDE.md, and artifacthub-pkg.yml all referenced the old
ConfigMap-based data source. Updated to document the actual Kubernetes
service proxy path, the correct RBAC Role/RoleBinding (services/proxy
get), and added sections for token-auth mode, NetworkPolicy, audit
logging, and troubleshooting. Also updated the project structure and
feature descriptions to match the current codebase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 10:25:59 -05:00
Chris Farhood d5ab99116c chore: bump version to 0.0.10
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 09:53:22 -05:00
Chris Farhood 5e330798e1 Merge pull request 'feat: restructure sidebar hierarchy and add full audit view' (#15) from feat/sidebar-restructure-dashboard-views into main
Reviewed-on: farhoodliquor/headlamp-polaris-plugin#15
2026-02-07 09:52:49 -05:00
Chris Farhood 1559a9ffcd feat: restructure sidebar hierarchy and add full audit view
Reorganize the sidebar into a proper hierarchy (Overview, Full Audit,
Namespaces) and add a Full Audit dashboard view that includes skipped
checks. Namespace routes move to /polaris/ns/:namespace to avoid
path collisions, and namespace detail pages now link out to the
Polaris dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 09:50:28 -05:00
gitea-actions[bot] bea7eaa67b ci: update artifact hub metadata for v0.0.9 2026-02-07 13:46:45 +00:00
Chris Farhood 9a1d7f961f chore: bump version to 0.0.9
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 08:45:43 -05:00
Chris Farhood e6e2aa63a5 Merge pull request 'docs: update ArtifactHub URLs in README' (#14) from fix/readme-artifacthub-urls into main
Reviewed-on: farhoodliquor/headlamp-polaris-plugin#14
Reviewed-by: Chris Farhood <cpfarhood@noreply.git.farh.net>
2026-02-07 08:45:14 -05:00
Chris Farhood dab068a963 refactor: rename npm package to headlamp-polaris-plugin
Aligns npm package name with the repo and ArtifactHub package name.
Updates all references: package.json, registerPluginSettings, Dockerfile,
release workflow tarball URLs, artifacthub-pkg.yml archive-url, and README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 08:43:55 -05:00
Chris Farhood 244714316f docs: update ArtifactHub URLs to match new package name
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 08:43:43 -05:00
29 changed files with 1981 additions and 159 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
+25 -8
View File
@@ -18,7 +18,7 @@ jobs:
- name: Check if release is already finalized
run: |
VERSION=${GITHUB_REF_NAME#v}
TARBALL_URL="https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/${GITHUB_REF_NAME}/polaris-headlamp-plugin-${VERSION}.tar.gz"
TARBALL_URL="https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/${GITHUB_REF_NAME}/headlamp-polaris-plugin-${VERSION}.tar.gz"
HTTP_CODE=$(curl -sL -o /tmp/release.tar.gz -w "%{http_code}" "$TARBALL_URL" 2>/dev/null)
if [ "$HTTP_CODE" = "200" ]; then
ACTUAL="sha256:$(sha256sum /tmp/release.tar.gz | awk '{print $1}')"
@@ -115,6 +115,9 @@ jobs:
continue-on-error: true
run: |
[ "$SKIP_BUILD" = "true" ] && exit 0
# Push tag to GitHub first so it exists before creating the release
git remote add github-release https://x-access-token:${{ secrets.GH_PAT }}@github.com/cpfarhood/headlamp-polaris-plugin.git 2>/dev/null || true
git push -f github-release ${GITHUB_REF_NAME} 2>/dev/null || true
GH_API="https://api.github.com/repos/cpfarhood/headlamp-polaris-plugin"
# Create release or fetch existing one
BODY=$(curl -s -X POST \
@@ -161,24 +164,38 @@ jobs:
run: |
[ "$SKIP_BUILD" = "true" ] && exit 0
VERSION=${GITHUB_REF_NAME#v}
git checkout main
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: sha256:${CHECKSUM}|" artifacthub-pkg.yml
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/${GITHUB_REF_NAME}/polaris-headlamp-plugin-${VERSION}.tar.gz\"|" artifacthub-pkg.yml
sed -i "s|^version:.*|version: ${VERSION}|" artifacthub-pkg.yml
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@git.farh.net"
# Determine which Gitea branch to update based on version suffix
if [[ "$VERSION" == *"-dev."* ]]; then
GITEA_BRANCH="dev"
else
GITEA_BRANCH="main"
fi
git fetch origin ${GITEA_BRANCH}
git checkout origin/${GITEA_BRANCH} -B temp-update
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: sha256:${CHECKSUM}|" artifacthub-pkg.yml
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/${GITHUB_REF_NAME}/headlamp-polaris-plugin-${VERSION}.tar.gz\"|" artifacthub-pkg.yml
sed -i "s|^version:.*|version: ${VERSION}|" artifacthub-pkg.yml
git add artifacthub-pkg.yml
git diff --cached --quiet || {
git commit -m "ci: update artifact hub metadata for ${GITHUB_REF_NAME}"
git push origin main
git push origin temp-update:${GITEA_BRANCH}
}
# Force-move tag to the commit with correct checksum.
# This triggers a new CI run, but the guard step will detect
# that the release checksum already matches and skip the build.
git tag -f ${GITHUB_REF_NAME}
git push -f origin ${GITHUB_REF_NAME}
# Also push to GitHub directly to avoid waiting for mirror sync
# Only push to GitHub main branch for STABLE releases
# Dev releases only create GitHub releases, don't update main branch
# This keeps GitHub main branch at latest stable for ArtifactHub
git remote add github https://x-access-token:${{ secrets.GH_PAT }}@github.com/cpfarhood/headlamp-polaris-plugin.git 2>/dev/null || true
git push github main 2>/dev/null || true
if [[ "$VERSION" != *"-dev."* ]]; then
echo "Stable release detected - pushing to GitHub main branch"
git push github temp-update:main 2>/dev/null || true
else
echo "Dev release detected - skipping GitHub main branch update"
fi
git push -f github ${GITHUB_REF_NAME} 2>/dev/null || true
echo "Tag ${GITHUB_REF_NAME} aligned with updated metadata"
+3
View File
@@ -3,3 +3,6 @@ dist/
.headlamp-plugin/
.mcp.json
*.tar.gz
e2e/.auth/
test-results/
.playwright-mcp/
+2 -2
View File
@@ -6,5 +6,5 @@ COPY src/ src/
RUN npx @kinvolk/headlamp-plugin build
FROM alpine:3.20
COPY --from=build /app/dist/ /plugins/polaris-headlamp-plugin/
COPY --from=build /app/package.json /plugins/polaris-headlamp-plugin/
COPY --from=build /app/dist/ /plugins/headlamp-polaris-plugin/
COPY --from=build /app/package.json /plugins/headlamp-polaris-plugin/
+128 -68
View File
@@ -1,39 +1,44 @@
# polaris-headlamp-plugin
# headlamp-polaris-plugin
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/polaris-headlamp-plugin)](https://artifacthub.io/packages/headlamp/polaris-headlamp-plugin/polaris-headlamp-plugin)
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/polaris)](https://artifacthub.io/packages/headlamp/polaris/headlamp-polaris-plugin)
A [Headlamp](https://headlamp.dev/) plugin that surfaces [Fairwinds Polaris](https://polaris.docs.fairwinds.com/) audit results directly in the Headlamp UI.
## What It Does
Adds a **Polaris** sidebar entry to Headlamp that displays:
Adds a **Polaris** top-level sidebar section to Headlamp with the following views:
- **Cluster Score** -- overall Polaris score as a percentage (color-coded green/amber/red)
- **Check Summary** -- total, pass, warning, and danger counts across all workloads
- **Cluster Info** -- node, pod, namespace, and controller counts
- **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 read from the `ConfigMap/polaris-dashboard` in the `polaris` namespace (key: `dashboard.json`), which is created by the standard Polaris Helm chart. 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 cached and refreshed on a user-configurable interval (1 / 5 / 10 / 30 minutes, default 5). The setting persists in the browser's localStorage.
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.
Error states are handled explicitly: RBAC denied (403), Polaris not installed (404), malformed JSON, and loading.
Error states are handled explicitly: RBAC denied (403), Polaris not installed (404/503), malformed JSON, and loading.
## Prerequisites
- **Headlamp** >= v0.26 deployed in your cluster
- **Polaris** installed via the [official Helm chart](https://github.com/FairwindsOps/polaris) with the dashboard component enabled
- The Headlamp service account must have RBAC permission to `get` ConfigMaps in the `polaris` namespace
| Requirement | Minimum version |
|-------------|----------------|
| Headlamp | v0.26+ |
| Polaris (with dashboard enabled) | Any recent release |
| Kubernetes | v1.24+ |
Polaris must be deployed in the `polaris` namespace with the dashboard component enabled (`dashboard.enabled: true` in the Helm chart, which is the default). The plugin reads from the `polaris-dashboard` ClusterIP service on port 80.
## Installing
### Option 1: Artifact Hub + Headlamp plugin manager (recommended)
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/polaris-headlamp-plugin/polaris-headlamp-plugin). Configure Headlamp's `pluginsManager` in your Helm values to install it automatically:
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/polaris/headlamp-polaris-plugin). Configure Headlamp's `pluginsManager` in your Helm values to install it automatically:
```yaml
pluginsManager:
sources:
- url: https://artifacthub.io/packages/headlamp/polaris-headlamp-plugin/polaris-headlamp-plugin
- url: https://artifacthub.io/packages/headlamp/polaris/headlamp-polaris-plugin
```
Headlamp will fetch and install the plugin on startup.
@@ -47,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
@@ -67,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 polaris-headlamp-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
@@ -78,42 +83,81 @@ npm run build
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
```
## RBAC
## Installing Dev/Preview Versions
The plugin reads a single ConfigMap. Minimum RBAC required for the Headlamp service account:
Dev preview versions are **not currently available** through the Headlamp plugin manager. Stable versions can be installed from ArtifactHub via the plugin manager UI.
## RBAC / Security Setup
The plugin fetches audit data through the Kubernetes API server's **service proxy** sub-resource. The identity making the request (Headlamp's service account, or the user's own token in token-auth mode) must be granted:
| Verb | API Group | Resource | Resource Name | Namespace |
|------|-----------|----------|---------------|-----------|
| `get` | `""` (core) | `services/proxy` | `polaris-dashboard` | `polaris` |
### Minimal RBAC manifests
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
kind: Role
metadata:
name: headlamp-polaris-reader
name: polaris-proxy-reader
namespace: polaris
rules:
- apiGroups: [""]
resources: ["configmaps"]
resources: ["services/proxy"]
resourceNames: ["polaris-dashboard"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
kind: RoleBinding
metadata:
name: headlamp-polaris-reader
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: headlamp-polaris-reader
name: headlamp-polaris-proxy
namespace: polaris
subjects:
- kind: ServiceAccount
name: headlamp
namespace: headlamp
name: headlamp # adjust to match your Headlamp service account
namespace: kube-system # adjust to match the namespace Headlamp runs in
roleRef:
kind: Role
name: polaris-proxy-reader
apiGroup: rbac.authorization.k8s.io
```
Apply with `kubectl apply -f polaris-rbac.yaml`.
### Token-auth mode
When Headlamp is configured for user-supplied tokens (rather than a fixed service account), **each user** must have the RoleBinding above attached to their own identity. A 403 error in the plugin means the currently logged-in user lacks this binding.
### NetworkPolicy
If the `polaris` namespace enforces network policies, ensure ingress is allowed from the Kubernetes API server (which performs the proxy hop) to `polaris-dashboard` on port 80.
### Read-only access
The plugin only performs `GET` requests through the service proxy. No `create`, `update`, `delete`, or `patch` verbs are required. Do not grant broader access than `get` on `services/proxy`.
### Audit logging
Every proxied request is recorded in Kubernetes API audit logs as a `get` on `services/proxy` in the `polaris` namespace. If the auto-refresh interval generates more audit volume than desired, increase the refresh interval in the plugin settings or adjust your audit policy.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|-------------|-----|
| **403 Access Denied** | Missing RBAC binding for `services/proxy` | Apply the Role + RoleBinding from the RBAC section above |
| **404 or 503** | Polaris not installed, or dashboard disabled | Install Polaris with `dashboard.enabled: true` in the `polaris` namespace |
| **No data** | Polaris running but no workloads scanned yet | Wait for the next Polaris audit cycle or restart the Polaris pod |
| **Stale data** | Refresh interval too long | Lower the interval in **Settings > Plugins > Polaris** |
## Development
### Setup
```bash
git clone https://github.com/cpfarhood/headlamp-polaris-plugin.git
cd polaris-headlamp-plugin
cd headlamp-polaris-plugin
npm install
```
@@ -129,42 +173,48 @@ This starts the Headlamp plugin dev server. Point a running Headlamp instance at
```bash
npm run build # outputs dist/main.js
npm run package # creates polaris-headlamp-plugin-<version>.tar.gz
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
```
src/
index.tsx -- Entry point. Registers sidebar entry and route at /polaris.
index.tsx -- Entry point. Registers sidebar entries and routes.
api/
polaris.ts -- TypeScript types matching the Polaris AuditData schema,
usePolarisData() hook with caching, countResults() utility,
and refresh interval settings (localStorage).
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/
PolarisView.tsx -- Main page component. Score badge, check summary cards,
cluster info, error states, refresh interval selector.
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).
vitest.config.mts -- Vitest configuration (jsdom environment).
```
## Data Source
The plugin reads from:
The plugin fetches live audit results from the Polaris dashboard HTTP API via the Kubernetes service proxy:
- **ConfigMap**: `polaris-dashboard`
- **Namespace**: `polaris`
- **Key**: `dashboard.json`
```
GET /api/v1/namespaces/polaris/services/polaris-dashboard/proxy/results.json
```
This ConfigMap is created automatically when Polaris is installed with the dashboard enabled. The JSON structure matches Polaris's `AuditData` schema (`pkg/validator/output.go`):
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`):
```
AuditData
Score -- cluster score (0-100)
ClusterInfo -- nodes, pods, namespaces, controllers
Results[] -- per-workload results
Results{} -- top-level check results (ResultSet)
@@ -174,52 +224,62 @@ AuditData
Results{} -- container-level check results
```
Each check in a `ResultSet` has `Success` (bool) and `Severity` (`"warning"` or `"danger"`).
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`.
## Known Limitations
### Skipped Count and Annotation-Based Exemptions
The **Skipped** count shown in the plugin only reflects checks with `Severity: "ignore"` in the Polaris API response. It does **not** include annotation-based exemptions (e.g., `polaris.fairwinds.com/privilegeEscalationAllowed-exempt: "true"`).
**Why?** Polaris completely omits exempted checks from the `results.json` endpoint. The native Polaris dashboard UI computes the "skipped" count client-side by:
1. Querying Kubernetes resources (Deployments, DaemonSets, StatefulSets, Pods) directly
2. Parsing their annotations for `polaris.fairwinds.com/*-exempt` keys
3. Counting how many checks were exempted
This plugin only has access to the processed audit results via the service proxy and does not query raw Kubernetes resources. To show accurate exemption counts, the plugin would need to:
- Request cluster-wide read access to all workload types (requires additional RBAC grants beyond `services/proxy`)
- Parse annotations on every workload in every namespace
- Cross-reference with the Polaris check catalog to count exemptions
This is a significant architectural change and is not currently implemented. Hover over the "Skipped" count in the UI to see a tooltip explaining this limitation.
**Workaround:** Use the "View in Polaris Dashboard" link from any namespace detail view to see the full exemption count in the native dashboard.
## 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
- [Artifact Hub](https://artifacthub.io/packages/headlamp/polaris-headlamp-plugin/polaris-headlamp-plugin)
- [Artifact Hub](https://artifacthub.io/packages/headlamp/polaris/headlamp-polaris-plugin)
- [GitHub (mirror)](https://github.com/cpfarhood/headlamp-polaris-plugin)
- [Gitea (source of truth)](https://git.farh.net/farhoodliquor/headlamp-polaris-plugin)
- [Headlamp](https://headlamp.dev/)
+10 -4
View File
@@ -1,8 +1,14 @@
version: 0.0.8
version: 0.1.7
name: headlamp-polaris-plugin
displayName: Polaris
createdAt: "2026-02-05T19:00:00Z"
description: Surfaces Fairwinds Polaris audit results inside the Headlamp UI.
description: >-
Surfaces Fairwinds Polaris audit results inside the Headlamp UI.
Shows cluster score, check summary, and per-namespace drill-downs
with per-resource pass/warning/danger breakdowns. Data is fetched
read-only via the Kubernetes service proxy to the Polaris dashboard.
Requires a Role granting `get` on `services/proxy` for the
`polaris-dashboard` service in the `polaris` namespace.
license: MIT
homeURL: "https://github.com/cpfarhood/headlamp-polaris-plugin"
category: security
@@ -22,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.0.8/polaris-headlamp-plugin-0.0.8.tar.gz"
headlamp/plugin/archive-url: "https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/v0.1.7/headlamp-polaris-plugin-0.1.7.tar.gz"
headlamp/plugin/version-compat: ">=0.26"
headlamp/plugin/archive-checksum: sha256:425082de35c4c59363c8cfe5859145781f76dc2423084bd8b4585b28bec66784
headlamp/plugin/archive-checksum: sha256:0000000000000000000000000000000000000000000000000000000000000000
headlamp/plugin/distro-compat: in-cluster
+60 -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,20 +23,75 @@ npx tsc --noEmit
# Lint
npx eslint src/
# Run tests
npm test
```
## Architecture
```
src/
├── index.tsx # Entry point: registerSidebarEntry + registerRoute for /polaris
├── index.tsx # Entry point: registers sidebar entries + routes
├── api/
── polaris.ts # Types (AuditData schema), usePolarisData hook, countResults utility, refresh settings
── 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/
── PolarisView.tsx # Main page: score badge, check summary, cluster info, error states, refresh interval selector
── 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
└── PolarisSettings.tsx # Plugin settings (refresh interval selector)
```
Single sidebar page at `/polaris`. 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/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 |
|------|-----------|----------|---------------|-----------|
| `get` | `""` (core) | `services/proxy` | `polaris-dashboard` | `polaris` |
Minimal RBAC example:
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: polaris-proxy-reader
namespace: polaris
rules:
- apiGroups: [""]
resources: ["services/proxy"]
resourceNames: ["polaris-dashboard"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: headlamp-polaris-proxy
namespace: polaris
subjects:
- kind: ServiceAccount
name: headlamp # adjust to match your Headlamp SA
namespace: kube-system
roleRef:
kind: Role
name: polaris-proxy-reader
apiGroup: rbac.authorization.k8s.io
```
Additional considerations:
- **NetworkPolicy**: If the `polaris` namespace enforces network policies, allow ingress from the Headlamp pod (or the API server, since it performs the proxy hop) to `polaris-dashboard` on port 80.
- **Polaris dashboard listen address**: The Polaris Helm chart exposes the dashboard on a ClusterIP service (`polaris-dashboard:80`). If the chart is installed with `dashboard.enabled: false`, the service will not be created, resulting in a 404 error for proxy requests.
- **No write operations**: The plugin only performs `GET` requests through the proxy. No `create`, `update`, or `delete` verbs are required. Do not grant broader service proxy access than `get`.
- **Token-auth mode**: When Headlamp is configured for user-supplied tokens (rather than a fixed service account), each user's own RBAC bindings must include the role above. A 403 from the plugin means the logged-in user lacks the binding.
- **Audit logging**: Kubernetes API audit logs will record every proxied request as a `get` on `services/proxy` in the `polaris` namespace. Set an appropriate audit policy level if request volume from the auto-refresh interval is a concern.
## Key Constraints
+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();
});
});
+69 -5
View File
@@ -1,14 +1,15 @@
{
"name": "polaris-headlamp-plugin",
"version": "0.0.8",
"name": "headlamp-polaris-plugin",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "polaris-headlamp-plugin",
"version": "0.0.8",
"name": "headlamp-polaris-plugin",
"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",
+9 -4
View File
@@ -1,6 +1,6 @@
{
"name": "polaris-headlamp-plugin",
"version": "0.0.8",
"name": "headlamp-polaris-plugin",
"version": "0.1.7",
"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([]));
});
});
});
+12 -2
View File
@@ -61,6 +61,7 @@ export interface ResultCounts {
pass: number;
warning: number;
danger: number;
skipped: number;
}
function countResultSet(rs: ResultSet, counts: ResultCounts): void {
@@ -69,6 +70,8 @@ function countResultSet(rs: ResultSet, counts: ResultCounts): void {
counts.total++;
if (msg.Success) {
counts.pass++;
} else if (msg.Severity === 'ignore') {
counts.skipped++;
} else if (msg.Severity === 'warning') {
counts.warning++;
} else if (msg.Severity === 'danger') {
@@ -78,7 +81,7 @@ function countResultSet(rs: ResultSet, counts: ResultCounts): void {
}
function countResultItems(results: Result[]): ResultCounts {
const counts: ResultCounts = { total: 0, pass: 0, warning: 0, danger: 0 };
const counts: ResultCounts = { total: 0, pass: 0, warning: 0, danger: 0, skipped: 0 };
for (const result of results) {
countResultSet(result.Results, counts);
if (result.PodResult) {
@@ -102,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();
}
@@ -138,6 +143,11 @@ export function setRefreshInterval(seconds: number): void {
localStorage.setItem(STORAGE_KEY, String(seconds));
}
// --- Polaris dashboard proxy URL ---
export const POLARIS_DASHBOARD_PROXY =
'/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/';
// --- Score computation ---
export function computeScore(counts: ResultCounts): number {
+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();
});
});
@@ -1,6 +1,8 @@
import {
Loader,
NameValueTable,
PercentageBar,
PercentageCircle,
SectionBox,
SectionHeader,
StatusLabel,
@@ -9,43 +11,53 @@ import React from 'react';
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 }) {
const score = computeScore(props.counts);
const status = scoreStatus(score);
const { counts } = props;
const score = computeScore(counts);
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 },
];
return (
<>
<SectionBox title="Score">
<NameValueTable
rows={[
{
name: 'Cluster Score',
value: <StatusLabel status={status}>{score}%</StatusLabel>,
},
]}
/>
<SectionBox title="Cluster Score">
<PercentageCircle data={chartData} total={counts.total} label={`${score}%`} />
</SectionBox>
<SectionBox title="Check Summary">
<SectionBox title="Check Distribution">
<PercentageBar data={chartData} total={counts.total} />
<NameValueTable
rows={[
{ name: 'Total Checks', value: String(props.counts.total) },
{ name: 'Total Checks', value: String(counts.total) },
{
name: 'Pass',
value: <StatusLabel status="success">{props.counts.pass}</StatusLabel>,
value: <StatusLabel status="success">{counts.pass}</StatusLabel>,
},
{
name: 'Warning',
value: <StatusLabel status="warning">{props.counts.warning}</StatusLabel>,
value: <StatusLabel status="warning">{counts.warning}</StatusLabel>,
},
{
name: 'Danger',
value: <StatusLabel status="error">{props.counts.danger}</StatusLabel>,
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>
),
},
]}
/>
@@ -64,7 +76,7 @@ function OverviewSection(props: { data: AuditData; counts: ResultCounts }) {
);
}
export default function PolarisView() {
export default function DashboardView() {
const { data, loading, error } = usePolarisDataContext();
if (loading) {
@@ -75,7 +87,7 @@ export default function PolarisView() {
return (
<>
<SectionHeader title="Polaris" />
<SectionHeader title="Polaris — Overview" />
{error && (
<SectionBox title="Error">
@@ -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',
name: `polaris-ns-${ns}`,
label: ns,
url: `/polaris/${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"'
);
});
});
+24
View File
@@ -12,6 +12,7 @@ import {
computeScore,
countResultsForItems,
filterResultsByNamespace,
POLARIS_DASHBOARD_PROXY,
Result,
ResultCounts,
} from '../api/polaris';
@@ -82,6 +83,21 @@ export default function NamespaceDetailView() {
<>
<SectionHeader title={`Polaris — ${namespace}`} />
<SectionBox title="External">
<NameValueTable
rows={[
{
name: 'Polaris Dashboard',
value: (
<a href={POLARIS_DASHBOARD_PROXY} target="_blank" rel="noopener noreferrer">
View in Polaris Dashboard
</a>
),
},
]}
/>
</SectionBox>
<SectionBox title="Namespace Score">
<NameValueTable
rows={[
@@ -102,6 +118,14 @@ export default function NamespaceDetailView() {
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>
+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');
});
});
+39 -9
View File
@@ -5,10 +5,12 @@ import {
} from '@kinvolk/headlamp-plugin/lib';
import React from 'react';
import { PolarisDataProvider } from './api/PolarisDataContext';
import DynamicSidebarRegistrar from './components/DynamicSidebarRegistrar';
import DashboardView from './components/DashboardView';
import NamespaceDetailView from './components/NamespaceDetailView';
import NamespacesListView from './components/NamespacesListView';
import PolarisSettings from './components/PolarisSettings';
import PolarisView from './components/PolarisView';
// --- Sidebar entries ---
registerSidebarEntry({
parent: null,
@@ -18,30 +20,58 @@ registerSidebarEntry({
icon: 'mdi:shield-check',
});
registerSidebarEntry({
parent: 'polaris',
name: 'polaris-overview',
label: 'Overview',
url: '/polaris',
icon: 'mdi:view-dashboard',
});
registerSidebarEntry({
parent: 'polaris',
name: 'polaris-namespaces',
label: 'Namespaces',
url: '/polaris/namespaces',
icon: 'mdi:dns',
});
// --- Routes ---
registerRoute({
path: '/polaris',
sidebar: 'polaris',
sidebar: 'polaris-overview',
name: 'polaris',
exact: true,
component: () => (
<PolarisDataProvider>
<DynamicSidebarRegistrar />
<PolarisView />
<DashboardView />
</PolarisDataProvider>
),
});
registerRoute({
path: '/polaris/:namespace',
sidebar: 'polaris',
path: '/polaris/namespaces',
sidebar: 'polaris-namespaces',
name: 'polaris-namespaces',
exact: true,
component: () => (
<PolarisDataProvider>
<NamespacesListView />
</PolarisDataProvider>
),
});
registerRoute({
path: '/polaris/ns/:namespace',
sidebar: 'polaris-namespaces',
name: 'polaris-namespace',
exact: true,
component: () => (
<PolarisDataProvider>
<DynamicSidebarRegistrar />
<NamespaceDetailView />
</PolarisDataProvider>
),
});
registerPluginSettings('polaris-headlamp-plugin', PolarisSettings, true);
registerPluginSettings('polaris', PolarisSettings, true);
+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,
});
}
}