Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a18710ccb1 | |||
| 811059cf75 | |||
| a404c075d6 | |||
| db17a08d26 | |||
| e52670dee4 | |||
| 8d219a9c6e | |||
| b2cbce16c1 | |||
| c95aab3ca3 | |||
| 604106c688 | |||
| 44a0016a4d | |||
| 03d7379e13 | |||
| 861dff6901 | |||
| 03b75a836b | |||
| 83a5342011 | |||
| 3daa1cbc14 | |||
| 9c03d912df | |||
| 00d4b224eb | |||
| c1248ec3c4 | |||
| 7ac5d0a494 | |||
| 59c1d4e844 | |||
| a507ba1d4a | |||
| d03fb81cd5 | |||
| d4d593cf74 | |||
| 2facb1b22b | |||
| 104a7fb2ba | |||
| b9e9484bf0 | |||
| 22d88cfca4 | |||
| 48dcb214b9 | |||
| c0681162e7 | |||
| 762056e46c | |||
| ab1f028fe0 | |||
| f2a2176eb6 | |||
| fe2e5d53e7 | |||
| 73939e66ad | |||
| 4378ad39f3 | |||
| 93bfb9e1bb | |||
| 2c26d49bf9 | |||
| 679be5dedc | |||
| a95f132413 | |||
| d3203b1890 | |||
| cd69cef2af | |||
| 0461ee8f23 | |||
| 14e323200c | |||
| a8e7dfca6d | |||
| 66903ca5e5 | |||
| f274203092 | |||
| 1273f94ae5 | |||
| 9d4b2e17aa | |||
| 82261a1c19 | |||
| 863889eca4 | |||
| 99bac773cc | |||
| 9fdb7c04cd | |||
| 0bd90ca317 | |||
| 975a31d1f3 | |||
| e54630410e | |||
| 088c74323b | |||
| d837987916 | |||
| 1b082a24db | |||
| 4544284df0 | |||
| 4838b22a02 | |||
| c67bcb1804 | |||
| c19bb2fa87 | |||
| 253d1277d9 | |||
| f69c91acf9 | |||
| 5659026959 | |||
| 6ae632f577 | |||
| e0cfb4e808 | |||
| c4c43cef40 |
@@ -112,50 +112,35 @@ jobs:
|
||||
echo "Gitea release updated"
|
||||
|
||||
- name: Create GitHub release
|
||||
continue-on-error: true
|
||||
run: |
|
||||
[ "$SKIP_BUILD" = "true" ] && exit 0
|
||||
GH_API="https://api.github.com/repos/cpfarhood/headlamp-polaris-plugin"
|
||||
# Create release or fetch existing one
|
||||
BODY=$(curl -s -X POST \
|
||||
-H "Authorization: token ${{ secrets.GH_PAT }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"${GH_API}/releases" \
|
||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"generate_release_notes\":true}")
|
||||
RELEASE_ID=$(echo "$BODY" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).id))")
|
||||
if [ "$RELEASE_ID" = "undefined" ]; then
|
||||
echo "Release already exists, fetching it..."
|
||||
BODY=$(curl -sf \
|
||||
-H "Authorization: token ${{ secrets.GH_PAT }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"${GH_API}/releases/tags/${GITHUB_REF_NAME}")
|
||||
RELEASE_ID=$(echo "$BODY" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).id))")
|
||||
# GitHub API to create/update release
|
||||
GITHUB_API="https://api.github.com/repos/cpfarhood/headlamp-polaris-plugin"
|
||||
# Check if release exists
|
||||
RELEASE_DATA=$(curl -sf \
|
||||
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
||||
"${GITHUB_API}/releases/tags/${GITHUB_REF_NAME}" || echo "{}")
|
||||
RELEASE_ID=$(echo "$RELEASE_DATA" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).id||''))")
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
# Create new release
|
||||
RELEASE_DATA=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${GITHUB_API}/releases" \
|
||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"draft\":false,\"prerelease\":false}")
|
||||
RELEASE_ID=$(echo "$RELEASE_DATA" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.parse(d).id))")
|
||||
fi
|
||||
|
||||
echo "GitHub Release ID: $RELEASE_ID"
|
||||
# Delete existing assets with the same name
|
||||
ASSETS=$(curl -sf \
|
||||
-H "Authorization: token ${{ secrets.GH_PAT }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"${GH_API}/releases/${RELEASE_ID}/assets")
|
||||
echo "$ASSETS" | node -e "
|
||||
process.stdin.resume();let d='';
|
||||
process.stdin.on('data',c=>d+=c);
|
||||
process.stdin.on('end',()=>{
|
||||
const assets=JSON.parse(d);
|
||||
assets.filter(a=>a.name==='${TARBALL}').forEach(a=>console.log(a.id));
|
||||
})" | while read -r ASSET_ID; do
|
||||
echo "Deleting existing asset $ASSET_ID..."
|
||||
curl -sf -X DELETE \
|
||||
-H "Authorization: token ${{ secrets.GH_PAT }}" \
|
||||
"${GH_API}/releases/assets/${ASSET_ID}"
|
||||
done
|
||||
# Upload tarball
|
||||
# Upload tarball to GitHub
|
||||
UPLOAD_URL=$(echo "$RELEASE_DATA" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const r=JSON.parse(d);console.log(r.upload_url||'https://uploads.github.com/repos/cpfarhood/headlamp-polaris-plugin/releases/${RELEASE_ID}/assets')})" | sed 's/{.*}//')
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.GH_PAT }}" \
|
||||
-H "Authorization: token ${{ secrets.GH_TOKEN }}" \
|
||||
-H "Content-Type: application/gzip" \
|
||||
"https://uploads.github.com/repos/cpfarhood/headlamp-polaris-plugin/releases/${RELEASE_ID}/assets?name=${TARBALL}" \
|
||||
--data-binary "@${TARBALL}"
|
||||
echo "GitHub release updated with same tarball"
|
||||
--data-binary "@${TARBALL}" \
|
||||
"${UPLOAD_URL}?name=${TARBALL}"
|
||||
echo "GitHub release updated"
|
||||
|
||||
- name: Update metadata and align tag
|
||||
run: |
|
||||
@@ -163,23 +148,26 @@ jobs:
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@git.farh.net"
|
||||
git fetch origin main
|
||||
git checkout origin/main -B main
|
||||
# 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
|
||||
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
|
||||
git push -f github ${GITHUB_REF_NAME} 2>/dev/null || true
|
||||
echo "Tag ${GITHUB_REF_NAME} aligned with updated metadata"
|
||||
echo "Note: GitHub sync handled by Gitea mirror configuration"
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if release is already finalized
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
TARBALL_URL="https://github.com/${{ github.repository }}/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}')"
|
||||
EXPECTED=$(grep 'archive-checksum' artifacthub-pkg.yml | awk '{print $2}')
|
||||
echo "Release tarball checksum: $ACTUAL"
|
||||
echo "Metadata checksum: $EXPECTED"
|
||||
if [ "$ACTUAL" = "$EXPECTED" ]; then
|
||||
echo "SKIP_BUILD=true" >> $GITHUB_ENV
|
||||
echo "Checksums match - release is finalized, nothing to do"
|
||||
fi
|
||||
else
|
||||
echo "No existing release (HTTP $HTTP_CODE) - will build"
|
||||
fi
|
||||
rm -f /tmp/release.tar.gz
|
||||
|
||||
- name: Setup Node.js
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Build plugin
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
run: npx @kinvolk/headlamp-plugin build
|
||||
|
||||
- name: Package tarball
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
run: npx @kinvolk/headlamp-plugin package
|
||||
|
||||
- name: Compute tarball checksum
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
run: |
|
||||
TARBALL=$(ls *.tar.gz)
|
||||
CHECKSUM=$(sha256sum "$TARBALL" | awk '{print $1}')
|
||||
echo "TARBALL=$TARBALL" >> $GITHUB_ENV
|
||||
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_ENV
|
||||
echo "Tarball: $TARBALL"
|
||||
echo "Checksum: sha256:$CHECKSUM"
|
||||
|
||||
- name: Create GitHub release and upload tarball
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: ${{ env.TARBALL }}
|
||||
fail_on_unmatched_files: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update metadata and align tag
|
||||
if: env.SKIP_BUILD != 'true'
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Update metadata
|
||||
git fetch origin main
|
||||
git checkout origin/main -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/${{ github.repository }}/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
|
||||
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "ci: update artifact hub metadata for ${GITHUB_REF_NAME}"
|
||||
git push origin temp-update:main
|
||||
fi
|
||||
|
||||
# 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}
|
||||
echo "Tag ${GITHUB_REF_NAME} aligned with updated metadata"
|
||||
@@ -6,18 +6,29 @@ A [Headlamp](https://headlamp.dev/) plugin that surfaces [Fairwinds Polaris](htt
|
||||
|
||||
## What It Does
|
||||
|
||||
Adds a **Polaris** top-level sidebar section to Headlamp with the following views:
|
||||
Adds a **Polaris** top-level sidebar section to Headlamp with comprehensive security, reliability, and efficiency audit integration:
|
||||
|
||||
- **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)
|
||||
### Main Views
|
||||
|
||||
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.
|
||||
- **Overview Dashboard** -- cluster score with percentage gauge, check distribution charts, top 10 most common failing checks across the cluster, cluster statistics, and last audit time with manual refresh button
|
||||
- **Namespaces** -- table of all namespaces with per-namespace score and check counts; click a namespace to open a detailed side panel (1000px wide, theme-aware)
|
||||
- **Namespace Detail Panel** -- per-namespace score, check counts, resource-level audit results, external Polaris dashboard link, and exemption management
|
||||
|
||||
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.
|
||||
### Integrated Features
|
||||
|
||||
Error states are handled explicitly: RBAC denied (403), Polaris not installed (404/503), malformed JSON, and loading.
|
||||
- **App Bar Score Badge** -- cluster Polaris score displayed as a colored chip in the top navigation bar (green ≥80%, yellow ≥50%, red <50%); click to navigate to overview
|
||||
- **Inline Resource Audits** -- Polaris audit results automatically injected into detail views for Deployments, StatefulSets, DaemonSets, Jobs, and CronJobs; shows compact score, failing checks table, and link to full report
|
||||
- **Exemption Management** -- add or remove Polaris exemptions via annotation patches directly from the UI; supports per-check exemptions or exempt-all
|
||||
- **Configurable Dashboard URL** -- supports both Kubernetes service proxy URLs and full HTTP/HTTPS URLs for external Polaris deployments
|
||||
- **Connection Testing** -- test button in settings to verify Polaris dashboard connectivity and show version info
|
||||
|
||||
### Data & Refresh
|
||||
|
||||
Data is fetched from the Polaris dashboard API through the Kubernetes service proxy (`/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/results.json`) or custom URLs. The plugin is primarily read-only; it only writes when explicitly applying exemption annotations.
|
||||
|
||||
Results are refreshed on a user-configurable interval (1 / 5 / 10 / 30 minutes, default 5). Settings are available in **Settings > Plugins > Polaris** and persist in browser localStorage.
|
||||
|
||||
Error states are handled explicitly with context-specific messages: RBAC denied (403), Polaris not installed (404/503), malformed JSON, network failures, and CORS issues.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -83,6 +94,10 @@ npm run build
|
||||
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
|
||||
```
|
||||
|
||||
## Installing Dev/Preview Versions
|
||||
|
||||
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:
|
||||
@@ -222,6 +237,26 @@ AuditData
|
||||
|
||||
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:
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
version: 0.1.4
|
||||
version: 0.3.0
|
||||
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.4/headlamp-polaris-plugin-0.1.4.tar.gz"
|
||||
headlamp/plugin/archive-url: "https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/v0.3.0/headlamp-polaris-plugin-0.3.0.tar.gz"
|
||||
headlamp/plugin/version-compat: ">=0.26"
|
||||
headlamp/plugin/archive-checksum: sha256:932e875310e1d3cd51a2b87ceff457582f6877395d2c521b75501849ac4af0ae
|
||||
headlamp/plugin/archive-checksum: sha256:fbe29c07478f28433f5859f452880929717f5ee1d5baebe7e9dbd8880ba483d1
|
||||
headlamp/plugin/distro-compat: in-cluster
|
||||
|
||||
+64
-15
@@ -20,42 +20,91 @@ test.describe('Polaris plugin smoke tests', () => {
|
||||
await expect(page.getByText(/%/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('namespaces page renders table with links', async ({ page }) => {
|
||||
test('namespaces page renders table with namespace buttons', 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
|
||||
// Table should have at least one row with a namespace button
|
||||
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();
|
||||
// Each namespace row should contain a button (now buttons instead of links for drawer)
|
||||
const firstButton = rows.first().locator('button');
|
||||
await expect(firstButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('namespace detail page renders from table link', async ({ page }) => {
|
||||
test('namespace detail drawer opens from table button', async ({ page }) => {
|
||||
await page.goto('/c/main/polaris/namespaces');
|
||||
|
||||
// Click the first namespace link in the table
|
||||
// Click the first namespace button 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();
|
||||
const firstButton = table.locator('tbody tr').first().locator('button');
|
||||
const namespaceName = await firstButton.textContent();
|
||||
await firstButton.click();
|
||||
|
||||
// Detail page should show the namespace name in the heading
|
||||
// Drawer should open and show the namespace name in the heading
|
||||
await expect(
|
||||
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||
).toBeVisible();
|
||||
|
||||
// "Namespace Score" section should be present in drawer
|
||||
await expect(page.getByText('Namespace Score')).toBeVisible();
|
||||
|
||||
// Resources table should exist in drawer
|
||||
await expect(page.getByText('Resources')).toBeVisible();
|
||||
|
||||
// URL hash should be updated with namespace name
|
||||
await expect(page).toHaveURL(/\/polaris\/namespaces#/);
|
||||
});
|
||||
|
||||
test('namespace detail drawer closes with Escape key', async ({ page }) => {
|
||||
await page.goto('/c/main/polaris/namespaces');
|
||||
|
||||
// Open the drawer by clicking a namespace button
|
||||
const table = page.locator('table');
|
||||
await expect(table).toBeVisible();
|
||||
const firstButton = table.locator('tbody tr').first().locator('button');
|
||||
const namespaceName = await firstButton.textContent();
|
||||
await firstButton.click();
|
||||
|
||||
// Verify drawer is open
|
||||
await expect(
|
||||
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||
).toBeVisible();
|
||||
|
||||
// Press Escape key
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Drawer should close (heading should not be visible anymore)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: `Polaris \u2014 ${namespaceName}` })
|
||||
).not.toBeVisible();
|
||||
|
||||
// URL hash should be cleared
|
||||
await expect(page).toHaveURL(/\/polaris\/namespaces$/);
|
||||
});
|
||||
|
||||
test('namespace detail drawer opens from URL hash', async ({ page }) => {
|
||||
// Get a namespace name first
|
||||
await page.goto('/c/main/polaris/namespaces');
|
||||
const table = page.locator('table');
|
||||
await expect(table).toBeVisible();
|
||||
const firstButton = table.locator('tbody tr').first().locator('button');
|
||||
const namespaceName = await firstButton.textContent();
|
||||
|
||||
// Navigate directly to URL with hash
|
||||
await page.goto(`/c/main/polaris/namespaces#${namespaceName}`);
|
||||
|
||||
// Drawer should automatically open with the namespace details
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "headlamp-polaris-plugin",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "headlamp-polaris-plugin",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"devDependencies": {
|
||||
"@kinvolk/headlamp-plugin": "^0.13.0",
|
||||
"@playwright/test": "^1.58.2"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "headlamp-polaris-plugin",
|
||||
"version": "0.1.4",
|
||||
"version": "0.3.0",
|
||||
"description": "Headlamp plugin for Fairwinds Polaris audit results",
|
||||
"scripts": {
|
||||
"start": "headlamp-plugin start",
|
||||
|
||||
@@ -5,6 +5,7 @@ interface PolarisDataContextValue {
|
||||
data: AuditData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const PolarisDataContext = React.createContext<PolarisDataContextValue | null>(null);
|
||||
@@ -13,7 +14,18 @@ export function PolarisDataProvider(props: { children: React.ReactNode }) {
|
||||
const interval = getRefreshInterval();
|
||||
const state = usePolarisData(interval);
|
||||
|
||||
return <PolarisDataContext.Provider value={state}>{props.children}</PolarisDataContext.Provider>;
|
||||
// Rename triggerRefresh to refresh for consistency
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
data: state.data,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
refresh: state.triggerRefresh,
|
||||
}),
|
||||
[state]
|
||||
);
|
||||
|
||||
return <PolarisDataContext.Provider value={value}>{props.children}</PolarisDataContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePolarisDataContext(): PolarisDataContextValue {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Mapping of Polaris check IDs to human-readable names and descriptions
|
||||
* Sourced from Polaris documentation
|
||||
*/
|
||||
|
||||
export interface CheckInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
category: 'Security' | 'Efficiency' | 'Reliability';
|
||||
defaultSeverity: 'danger' | 'warning' | 'ignore';
|
||||
}
|
||||
|
||||
export const CHECK_MAPPING: Record<string, CheckInfo> = {
|
||||
// Security checks
|
||||
hostIPCSet: {
|
||||
name: 'Host IPC',
|
||||
description: 'Host IPC should not be configured',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
hostPIDSet: {
|
||||
name: 'Host PID',
|
||||
description: 'Host PID should not be configured',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
hostNetworkSet: {
|
||||
name: 'Host Network',
|
||||
description: 'Host network should not be configured',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
hostPortSet: {
|
||||
name: 'Host Port',
|
||||
description: 'Host port should not be configured',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
runAsRootAllowed: {
|
||||
name: 'Run as Root',
|
||||
description: 'Should not be allowed to run as root',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
runAsPrivileged: {
|
||||
name: 'Privileged Container',
|
||||
description: 'Should not run as privileged',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
notReadOnlyRootFilesystem: {
|
||||
name: 'Read-Only Root Filesystem',
|
||||
description: 'Filesystem should be read-only',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
privilegeEscalationAllowed: {
|
||||
name: 'Privilege Escalation',
|
||||
description: 'Privilege escalation should not be allowed',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
dangerousCapabilities: {
|
||||
name: 'Dangerous Capabilities',
|
||||
description: 'Dangerous capabilities should not be allowed',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
insecureCapabilities: {
|
||||
name: 'Insecure Capabilities',
|
||||
description: 'Insecure capabilities should not be allowed',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
sensitiveContainerEnvVar: {
|
||||
name: 'Sensitive Environment Variables',
|
||||
description: 'Sensitive env vars detected',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
sensitiveConfigmapContent: {
|
||||
name: 'Sensitive ConfigMap',
|
||||
description: 'Sensitive ConfigMap content detected',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
automountServiceAccountToken: {
|
||||
name: 'Service Account Token Auto-mount',
|
||||
description: 'Service account token auto-mount',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
tlsSettingsMissing: {
|
||||
name: 'TLS Settings',
|
||||
description: 'TLS settings missing',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
missingNetworkPolicy: {
|
||||
name: 'Network Policy',
|
||||
description: 'Missing NetworkPolicy',
|
||||
category: 'Security',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
|
||||
// Reliability checks
|
||||
tagNotSpecified: {
|
||||
name: 'Image Tag',
|
||||
description: 'Image tag should be specified',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'danger',
|
||||
},
|
||||
pullPolicyNotAlways: {
|
||||
name: 'Pull Policy',
|
||||
description: 'Pull policy should be Always',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
readinessProbeMissing: {
|
||||
name: 'Readiness Probe',
|
||||
description: 'Readiness probe should be configured',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
livenessProbeMissing: {
|
||||
name: 'Liveness Probe',
|
||||
description: 'Liveness probe should be configured',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
deploymentMissingReplicas: {
|
||||
name: 'Deployment Replicas',
|
||||
description: 'Deployment should have multiple replicas',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
priorityClassNotSet: {
|
||||
name: 'Priority Class',
|
||||
description: 'Priority class should be set',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
metadataAndNameMismatched: {
|
||||
name: 'Metadata Mismatch',
|
||||
description: 'Metadata and name should match',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
missingPodDisruptionBudget: {
|
||||
name: 'Pod Disruption Budget',
|
||||
description: 'PodDisruptionBudget should exist',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
pdbDisruptionsIsZero: {
|
||||
name: 'PDB Disruptions',
|
||||
description: 'PDB maxUnavailable should not be zero',
|
||||
category: 'Reliability',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
|
||||
// Efficiency checks
|
||||
cpuRequestsMissing: {
|
||||
name: 'CPU Requests',
|
||||
description: 'CPU requests should be set',
|
||||
category: 'Efficiency',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
cpuLimitsMissing: {
|
||||
name: 'CPU Limits',
|
||||
description: 'CPU limits should be set',
|
||||
category: 'Efficiency',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
memoryRequestsMissing: {
|
||||
name: 'Memory Requests',
|
||||
description: 'Memory requests should be set',
|
||||
category: 'Efficiency',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
memoryLimitsMissing: {
|
||||
name: 'Memory Limits',
|
||||
description: 'Memory limits should be set',
|
||||
category: 'Efficiency',
|
||||
defaultSeverity: 'warning',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get human-readable name for a check ID
|
||||
*/
|
||||
export function getCheckName(checkId: string): string {
|
||||
return CHECK_MAPPING[checkId]?.name || checkId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get check description
|
||||
*/
|
||||
export function getCheckDescription(checkId: string): string {
|
||||
return CHECK_MAPPING[checkId]?.description || 'Unknown check';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get check category
|
||||
*/
|
||||
export function getCheckCategory(checkId: string): 'Security' | 'Efficiency' | 'Reliability' {
|
||||
return CHECK_MAPPING[checkId]?.category || 'Security';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for severity
|
||||
*/
|
||||
export function getSeverityColor(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'danger':
|
||||
return '#f44336';
|
||||
case 'warning':
|
||||
return '#ff9800';
|
||||
case 'ignore':
|
||||
return '#9e9e9e';
|
||||
default:
|
||||
return '#9e9e9e';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status for StatusLabel component
|
||||
*/
|
||||
export function getSeverityStatus(severity: string): 'error' | 'warning' | 'success' {
|
||||
switch (severity) {
|
||||
case 'danger':
|
||||
return 'error';
|
||||
case 'warning':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'success';
|
||||
}
|
||||
}
|
||||
+73
-17
@@ -125,11 +125,14 @@ export const INTERVAL_OPTIONS = [
|
||||
{ label: '30 minutes', value: 1800 },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'polaris-plugin-refresh-interval';
|
||||
const REFRESH_STORAGE_KEY = 'polaris-plugin-refresh-interval';
|
||||
const DEFAULT_INTERVAL_SECONDS = 300; // 5 minutes
|
||||
|
||||
const URL_STORAGE_KEY = 'polaris-plugin-dashboard-url';
|
||||
const DEFAULT_DASHBOARD_URL = '/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/';
|
||||
|
||||
export function getRefreshInterval(): number {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const stored = localStorage.getItem(REFRESH_STORAGE_KEY);
|
||||
if (stored !== null) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) {
|
||||
@@ -140,13 +143,26 @@ export function getRefreshInterval(): number {
|
||||
}
|
||||
|
||||
export function setRefreshInterval(seconds: number): void {
|
||||
localStorage.setItem(STORAGE_KEY, String(seconds));
|
||||
localStorage.setItem(REFRESH_STORAGE_KEY, String(seconds));
|
||||
}
|
||||
|
||||
export function getDashboardUrl(): string {
|
||||
const stored = localStorage.getItem(URL_STORAGE_KEY);
|
||||
if (stored !== null && stored.trim() !== '') {
|
||||
return stored.trim();
|
||||
}
|
||||
return DEFAULT_DASHBOARD_URL;
|
||||
}
|
||||
|
||||
export function setDashboardUrl(url: string): void {
|
||||
localStorage.setItem(URL_STORAGE_KEY, url.trim());
|
||||
}
|
||||
|
||||
// --- Polaris dashboard proxy URL ---
|
||||
|
||||
export const POLARIS_DASHBOARD_PROXY =
|
||||
'/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/';
|
||||
export function getPolarisProxyUrl(): string {
|
||||
return getDashboardUrl();
|
||||
}
|
||||
|
||||
// --- Score computation ---
|
||||
|
||||
@@ -157,12 +173,20 @@ export function computeScore(counts: ResultCounts): number {
|
||||
|
||||
// --- Data fetching hook ---
|
||||
|
||||
const POLARIS_API_PATH = '/api/v1/namespaces/polaris/services/polaris-dashboard/proxy/results.json';
|
||||
function getPolarisApiPath(): string {
|
||||
const baseUrl = getDashboardUrl();
|
||||
return baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`;
|
||||
}
|
||||
|
||||
function isFullUrl(url: string): boolean {
|
||||
return url.startsWith('http://') || url.startsWith('https://');
|
||||
}
|
||||
|
||||
interface PolarisDataState {
|
||||
data: AuditData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
triggerRefresh: () => void;
|
||||
}
|
||||
|
||||
export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState {
|
||||
@@ -171,12 +195,30 @@ export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [tick, setTick] = React.useState(0);
|
||||
|
||||
const triggerRefresh = React.useCallback(() => {
|
||||
setTick(t => t + 1);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const result: AuditData = await ApiProxy.request(POLARIS_API_PATH);
|
||||
const apiPath = getPolarisApiPath();
|
||||
let result: AuditData;
|
||||
|
||||
if (isFullUrl(apiPath)) {
|
||||
// Direct fetch for full URLs
|
||||
const response = await fetch(apiPath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
result = await response.json();
|
||||
} else {
|
||||
// Kubernetes proxy for relative URLs
|
||||
result = await ApiProxy.request(apiPath);
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setData(result);
|
||||
setError(null);
|
||||
@@ -184,17 +226,31 @@ export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return;
|
||||
const apiPath = getPolarisApiPath();
|
||||
const status = (err as { status?: number }).status;
|
||||
if (status === 403) {
|
||||
setError(
|
||||
'Access denied (403). Check that your RBAC permissions allow proxying to the Polaris service.'
|
||||
);
|
||||
} else if (status === 404 || status === 503) {
|
||||
setError(
|
||||
'Polaris dashboard not reachable. Ensure Polaris is installed in the polaris namespace.'
|
||||
);
|
||||
|
||||
if (isFullUrl(apiPath)) {
|
||||
// Full URL errors
|
||||
if (status === 403) {
|
||||
setError('Access denied (403). Check authentication and CORS configuration.');
|
||||
} else if (status === 404) {
|
||||
setError('Polaris dashboard not found (404). Verify the URL is correct.');
|
||||
} else {
|
||||
setError(`Failed to fetch from ${apiPath}: ${String(err)}`);
|
||||
}
|
||||
} else {
|
||||
setError(`Failed to fetch Polaris data: ${String(err)}`);
|
||||
// Kubernetes proxy errors
|
||||
if (status === 403) {
|
||||
setError(
|
||||
'Access denied (403). Check that your RBAC permissions allow proxying to the Polaris service.'
|
||||
);
|
||||
} else if (status === 404 || status === 503) {
|
||||
setError(
|
||||
'Polaris dashboard not reachable. Ensure Polaris is installed in the configured namespace.'
|
||||
);
|
||||
} else {
|
||||
setError(`Failed to fetch Polaris data: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -215,5 +271,5 @@ export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [refreshIntervalSeconds]);
|
||||
|
||||
return { data, loading, error };
|
||||
return { data, loading, error, triggerRefresh };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { AuditData } from './polaris';
|
||||
import { getCheckName, getCheckCategory } from './checkMapping';
|
||||
|
||||
export interface TopIssue {
|
||||
checkId: string;
|
||||
checkName: string;
|
||||
category: 'Security' | 'Efficiency' | 'Reliability';
|
||||
severity: 'danger' | 'warning';
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the most common failing checks across the cluster
|
||||
* Returns top 10 issues sorted by severity then count
|
||||
*/
|
||||
export function getTopIssues(data: AuditData): TopIssue[] {
|
||||
const issueCounts = new Map<string, { severity: 'danger' | 'warning'; count: number }>();
|
||||
|
||||
// Aggregate all failing checks
|
||||
for (const result of data.Results) {
|
||||
// Pod-level checks
|
||||
if (result.PodResult?.Results) {
|
||||
for (const [checkId, checkResult] of Object.entries(result.PodResult.Results)) {
|
||||
if (!checkResult.Success && checkResult.Severity !== 'ignore') {
|
||||
const existing = issueCounts.get(checkId);
|
||||
issueCounts.set(checkId, {
|
||||
severity: checkResult.Severity as 'danger' | 'warning',
|
||||
count: (existing?.count || 0) + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Container-level checks
|
||||
if (result.PodResult?.ContainerResults) {
|
||||
for (const container of result.PodResult.ContainerResults) {
|
||||
for (const [checkId, checkResult] of Object.entries(container.Results)) {
|
||||
if (!checkResult.Success && checkResult.Severity !== 'ignore') {
|
||||
const existing = issueCounts.get(checkId);
|
||||
issueCounts.set(checkId, {
|
||||
severity: checkResult.Severity as 'danger' | 'warning',
|
||||
count: (existing?.count || 0) + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Controller-level checks (if any)
|
||||
if (result.Results) {
|
||||
for (const [checkId, checkResult] of Object.entries(result.Results)) {
|
||||
if (!checkResult.Success && checkResult.Severity !== 'ignore') {
|
||||
const existing = issueCounts.get(checkId);
|
||||
issueCounts.set(checkId, {
|
||||
severity: checkResult.Severity as 'danger' | 'warning',
|
||||
count: (existing?.count || 0) + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and format
|
||||
const issues: TopIssue[] = Array.from(issueCounts.entries()).map(([checkId, data]) => ({
|
||||
checkId,
|
||||
checkName: getCheckName(checkId),
|
||||
category: getCheckCategory(checkId),
|
||||
severity: data.severity,
|
||||
count: data.count,
|
||||
}));
|
||||
|
||||
// Sort by severity (danger first) then by count (descending)
|
||||
issues.sort((a, b) => {
|
||||
if (a.severity === 'danger' && b.severity !== 'danger') return -1;
|
||||
if (a.severity !== 'danger' && b.severity === 'danger') return 1;
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
// Return top 10
|
||||
return issues.slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Chip } from '@mui/material';
|
||||
import { Shield as ShieldIcon } from '@mui/icons-material';
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
||||
import { computeScore, countResults } from '../api/polaris';
|
||||
|
||||
/**
|
||||
* App bar badge showing cluster Polaris score
|
||||
* Clicking navigates to the overview dashboard
|
||||
*/
|
||||
export default function AppBarScoreBadge() {
|
||||
const { data, loading } = usePolarisDataContext();
|
||||
const history = useHistory();
|
||||
|
||||
if (loading || !data) {
|
||||
return null; // Graceful degradation when Polaris unavailable
|
||||
}
|
||||
|
||||
const counts = countResults(data);
|
||||
const score = computeScore(counts);
|
||||
|
||||
// Color based on score
|
||||
const getColor = (score: number): 'success' | 'warning' | 'error' => {
|
||||
if (score >= 80) return 'success';
|
||||
if (score >= 50) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
history.push('/polaris');
|
||||
};
|
||||
|
||||
return (
|
||||
<Chip
|
||||
icon={<ShieldIcon />}
|
||||
label={`Polaris: ${score}%`}
|
||||
color={getColor(score)}
|
||||
size="small"
|
||||
onClick={handleClick}
|
||||
style={{ cursor: 'pointer', marginRight: '8px' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,16 @@ import {
|
||||
PercentageCircle,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { Button } from '@mui/material';
|
||||
import { Refresh as RefreshIcon } from '@mui/icons-material';
|
||||
import React from 'react';
|
||||
import { AuditData, computeScore, countResults, ResultCounts } from '../api/polaris';
|
||||
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
||||
import { getTopIssues, TopIssue } from '../api/topIssues';
|
||||
import { getSeverityStatus } from '../api/checkMapping';
|
||||
|
||||
const COLORS = {
|
||||
pass: '#4caf50',
|
||||
@@ -26,7 +31,6 @@ function OverviewSection(props: { data: AuditData; counts: ResultCounts }) {
|
||||
{ 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 (
|
||||
@@ -51,7 +55,6 @@ function OverviewSection(props: { data: AuditData; counts: ResultCounts }) {
|
||||
name: 'Danger',
|
||||
value: <StatusLabel status="error">{counts.danger}</StatusLabel>,
|
||||
},
|
||||
{ name: 'Skipped', value: String(counts.skipped) },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
@@ -69,18 +72,52 @@ function OverviewSection(props: { data: AuditData; counts: ResultCounts }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatAuditTime(auditTime: string): string {
|
||||
const date = new Date(auditTime);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
export default function DashboardView() {
|
||||
const { data, loading, error } = usePolarisDataContext();
|
||||
const { data, loading, error, refresh } = usePolarisDataContext();
|
||||
|
||||
if (loading) {
|
||||
return <Loader title="Loading Polaris audit data..." />;
|
||||
}
|
||||
|
||||
const counts = data ? countResults(data) : null;
|
||||
const topIssues = data ? getTopIssues(data) : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Polaris — Overview" />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<SectionHeader title="Polaris — Overview" />
|
||||
{data && (
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '14px', color: 'var(--mui-palette-text-secondary, #666)' }}>
|
||||
Last updated: {formatAuditTime(data.AuditTime)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={refresh}
|
||||
size="small"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<SectionBox title="Error">
|
||||
@@ -95,7 +132,35 @@ export default function DashboardView() {
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{data && counts && <OverviewSection data={data} counts={counts} />}
|
||||
{data && counts && (
|
||||
<>
|
||||
<OverviewSection data={data} counts={counts} />
|
||||
|
||||
{topIssues.length > 0 && (
|
||||
<SectionBox title="Top Issues">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Check', getter: (issue: TopIssue) => issue.checkName },
|
||||
{ label: 'Category', getter: (issue: TopIssue) => issue.category },
|
||||
{
|
||||
label: 'Severity',
|
||||
getter: (issue: TopIssue) => (
|
||||
<StatusLabel status={getSeverityStatus(issue.severity)}>
|
||||
{issue.severity}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Affected Workloads',
|
||||
getter: (issue: TopIssue) => String(issue.count),
|
||||
},
|
||||
]}
|
||||
data={topIssues}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!data && !error && (
|
||||
<SectionBox title="No Data">
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { NameValueTable, SectionBox, Dialog } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||
import { Button, Checkbox, FormControlLabel, FormGroup } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { Result } from '../api/polaris';
|
||||
import { getCheckName } from '../api/checkMapping';
|
||||
|
||||
interface ExemptionManagerProps {
|
||||
workloadResult: Result;
|
||||
namespace: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CheckFailure {
|
||||
checkId: string;
|
||||
checkName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exemption management UI for adding/removing Polaris exemptions
|
||||
* Uses annotation patches on the workload resource
|
||||
*/
|
||||
export default function ExemptionManager({ workloadResult, namespace, kind, name }: ExemptionManagerProps) {
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [selectedChecks, setSelectedChecks] = React.useState<Set<string>>(new Set());
|
||||
const [exemptAll, setExemptAll] = React.useState(false);
|
||||
const [applying, setApplying] = React.useState(false);
|
||||
|
||||
// Extract current exemptions from workload metadata
|
||||
const getExemptions = (): string[] => {
|
||||
// This would need to fetch the actual workload from K8s API
|
||||
// For now, return empty array as placeholder
|
||||
return [];
|
||||
};
|
||||
|
||||
// Extract failing checks for this workload
|
||||
const getFailingChecks = (): CheckFailure[] => {
|
||||
const failures: CheckFailure[] = [];
|
||||
|
||||
// Pod-level checks
|
||||
if (workloadResult.PodResult?.Results) {
|
||||
for (const [checkId, checkResult] of Object.entries(workloadResult.PodResult.Results)) {
|
||||
if (!checkResult.Success && checkResult.Severity !== 'ignore') {
|
||||
failures.push({
|
||||
checkId,
|
||||
checkName: getCheckName(checkId),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Container checks
|
||||
if (workloadResult.PodResult?.ContainerResults) {
|
||||
for (const container of workloadResult.PodResult.ContainerResults) {
|
||||
for (const [checkId, checkResult] of Object.entries(container.Results)) {
|
||||
if (!checkResult.Success && checkResult.Severity !== 'ignore') {
|
||||
// Avoid duplicates
|
||||
if (!failures.some(f => f.checkId === checkId)) {
|
||||
failures.push({
|
||||
checkId,
|
||||
checkName: getCheckName(checkId),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
};
|
||||
|
||||
const failingChecks = getFailingChecks();
|
||||
const currentExemptions = getExemptions();
|
||||
|
||||
const handleCheckToggle = (checkId: string) => {
|
||||
const newSelected = new Set(selectedChecks);
|
||||
if (newSelected.has(checkId)) {
|
||||
newSelected.delete(checkId);
|
||||
} else {
|
||||
newSelected.add(checkId);
|
||||
}
|
||||
setSelectedChecks(newSelected);
|
||||
};
|
||||
|
||||
const applyExemptions = async () => {
|
||||
setApplying(true);
|
||||
|
||||
try {
|
||||
// Construct the API path based on kind
|
||||
const apiGroup = getApiGroup(kind);
|
||||
const apiVersion = 'v1'; // This would need to be dynamic based on kind
|
||||
const plural = getPlural(kind);
|
||||
|
||||
const patchPath = apiGroup
|
||||
? `/apis/${apiGroup}/${apiVersion}/namespaces/${namespace}/${plural}/${name}`
|
||||
: `/api/v1/namespaces/${namespace}/${plural}/${name}`;
|
||||
|
||||
// Build annotations patch
|
||||
const annotations: Record<string, string> = {};
|
||||
|
||||
if (exemptAll) {
|
||||
annotations['polaris.fairwinds.com/exempt'] = 'true';
|
||||
} else {
|
||||
for (const checkId of selectedChecks) {
|
||||
annotations[`polaris.fairwinds.com/${checkId}-exempt`] = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
const patch = {
|
||||
metadata: {
|
||||
annotations,
|
||||
},
|
||||
};
|
||||
|
||||
await ApiProxy.request(patchPath, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/strategic-merge-patch+json',
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
|
||||
setDialogOpen(false);
|
||||
setSelectedChecks(new Set());
|
||||
setExemptAll(false);
|
||||
|
||||
// Show success message (would need notistack integration)
|
||||
alert('Exemptions applied successfully');
|
||||
} catch (err) {
|
||||
alert(`Failed to apply exemptions: ${String(err)}`);
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionBox title="Exemptions">
|
||||
{currentExemptions.length > 0 ? (
|
||||
<NameValueTable
|
||||
rows={currentExemptions.map(exemption => ({
|
||||
name: exemption,
|
||||
value: (
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => {
|
||||
// Remove exemption logic
|
||||
alert('Remove exemption: ' + exemption);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<p>No exemptions configured</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
disabled={failingChecks.length === 0}
|
||||
style={{ marginTop: '8px' }}
|
||||
>
|
||||
Add Exemption
|
||||
</Button>
|
||||
</SectionBox>
|
||||
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
title="Add Exemptions"
|
||||
>
|
||||
<div style={{ padding: '16px', minWidth: '400px' }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={exemptAll}
|
||||
onChange={(e) => setExemptAll(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Exempt from all checks"
|
||||
/>
|
||||
|
||||
{!exemptAll && (
|
||||
<>
|
||||
<div style={{ marginTop: '16px', marginBottom: '8px', fontWeight: 600 }}>
|
||||
Select checks to exempt:
|
||||
</div>
|
||||
<FormGroup>
|
||||
{failingChecks.map(check => (
|
||||
<FormControlLabel
|
||||
key={check.checkId}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selectedChecks.has(check.checkId)}
|
||||
onChange={() => handleCheckToggle(check.checkId)}
|
||||
/>
|
||||
}
|
||||
label={check.checkName}
|
||||
/>
|
||||
))}
|
||||
</FormGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '16px', display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={applyExemptions}
|
||||
disabled={applying || (!exemptAll && selectedChecks.size === 0)}
|
||||
>
|
||||
{applying ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions to get API info based on kind
|
||||
function getApiGroup(kind: string): string | null {
|
||||
switch (kind) {
|
||||
case 'Deployment':
|
||||
case 'StatefulSet':
|
||||
case 'DaemonSet':
|
||||
return 'apps';
|
||||
case 'Job':
|
||||
case 'CronJob':
|
||||
return 'batch';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getPlural(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'Deployment':
|
||||
return 'deployments';
|
||||
case 'StatefulSet':
|
||||
return 'statefulsets';
|
||||
case 'DaemonSet':
|
||||
return 'daemonsets';
|
||||
case 'Job':
|
||||
return 'jobs';
|
||||
case 'CronJob':
|
||||
return 'cronjobs';
|
||||
default:
|
||||
return kind.toLowerCase() + 's';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { NameValueTable, SectionBox, StatusLabel, SimpleTable } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
||||
import { computeScore, countResultsForItems, ResultCounts } from '../api/polaris';
|
||||
import { getCheckName, getSeverityStatus } from '../api/checkMapping';
|
||||
import ExemptionManager from './ExemptionManager';
|
||||
|
||||
interface CheckFailure {
|
||||
checkId: string;
|
||||
checkName: string;
|
||||
severity: 'danger' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface InlineAuditSectionProps {
|
||||
resource: any; // KubeObject from Headlamp
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline Polaris audit section for resource detail views
|
||||
* Shows a compact summary of Polaris findings for Deployments, StatefulSets, etc.
|
||||
*/
|
||||
export default function InlineAuditSection({ resource }: InlineAuditSectionProps) {
|
||||
const { data, loading } = usePolarisDataContext();
|
||||
|
||||
if (loading || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if this is a supported controller kind
|
||||
const supportedKinds = ['Deployment', 'StatefulSet', 'DaemonSet', 'Job', 'CronJob'];
|
||||
const kind = resource.kind;
|
||||
|
||||
if (!supportedKinds.includes(kind)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = resource.metadata?.name;
|
||||
const namespace = resource.metadata?.namespace;
|
||||
|
||||
if (!name || !namespace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find this workload in Polaris audit data
|
||||
const workloadResult = data.Results.find(
|
||||
r => r.Kind === kind && r.Name === name && r.Namespace === namespace
|
||||
);
|
||||
|
||||
if (!workloadResult) {
|
||||
return (
|
||||
<SectionBox title="Polaris Audit">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: 'Polaris dashboard not detected — install Polaris to see audit results',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate score and counts
|
||||
const counts = countResultsForItems([workloadResult]);
|
||||
const score = computeScore(counts);
|
||||
|
||||
// Extract failing checks
|
||||
const failures: CheckFailure[] = [];
|
||||
|
||||
// Pod-level checks
|
||||
if (workloadResult.PodResult?.Results) {
|
||||
for (const [checkId, checkResult] of Object.entries(workloadResult.PodResult.Results)) {
|
||||
if (!checkResult.Success && checkResult.Severity !== 'ignore') {
|
||||
failures.push({
|
||||
checkId,
|
||||
checkName: getCheckName(checkId),
|
||||
severity: checkResult.Severity as 'danger' | 'warning',
|
||||
message: checkResult.Message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Container checks
|
||||
if (workloadResult.PodResult?.ContainerResults) {
|
||||
for (const container of workloadResult.PodResult.ContainerResults) {
|
||||
for (const [checkId, checkResult] of Object.entries(container.Results)) {
|
||||
if (!checkResult.Success && checkResult.Severity !== 'ignore') {
|
||||
// Avoid duplicates
|
||||
if (!failures.some(f => f.checkId === checkId)) {
|
||||
failures.push({
|
||||
checkId,
|
||||
checkName: getCheckName(checkId),
|
||||
severity: checkResult.Severity as 'danger' | 'warning',
|
||||
message: checkResult.Message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by severity
|
||||
failures.sort((a, b) => {
|
||||
if (a.severity === 'danger' && b.severity !== 'danger') return -1;
|
||||
if (a.severity !== 'danger' && b.severity === 'danger') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionBox title="Polaris Audit">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Score',
|
||||
value: (
|
||||
<StatusLabel status={score >= 80 ? 'success' : score >= 50 ? 'warning' : 'error'}>
|
||||
{score}%
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Summary',
|
||||
value: `${counts.pass} passing, ${counts.warning} warnings, ${counts.danger} dangers`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{failures.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginTop: '16px', marginBottom: '8px', fontWeight: 600 }}>
|
||||
Failing Checks:
|
||||
</div>
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Check', getter: (f: CheckFailure) => f.checkName },
|
||||
{
|
||||
label: 'Severity',
|
||||
getter: (f: CheckFailure) => (
|
||||
<StatusLabel status={getSeverityStatus(f.severity)}>
|
||||
{f.severity}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Message', getter: (f: CheckFailure) => f.message },
|
||||
]}
|
||||
data={failures}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<Link to={`/polaris/namespaces#${namespace}`} style={{ color: 'var(--link-color, #1976d2)' }}>
|
||||
View Full Report →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<ExemptionManager
|
||||
workloadResult={workloadResult}
|
||||
namespace={namespace}
|
||||
kind={kind}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
computeScore,
|
||||
countResultsForItems,
|
||||
filterResultsByNamespace,
|
||||
POLARIS_DASHBOARD_PROXY,
|
||||
getPolarisProxyUrl,
|
||||
Result,
|
||||
ResultCounts,
|
||||
} from '../api/polaris';
|
||||
@@ -89,7 +89,7 @@ export default function NamespaceDetailView() {
|
||||
{
|
||||
name: 'Polaris Dashboard',
|
||||
value: (
|
||||
<a href={POLARIS_DASHBOARD_PROXY} target="_blank" rel="noopener noreferrer">
|
||||
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
|
||||
View in Polaris Dashboard
|
||||
</a>
|
||||
),
|
||||
@@ -120,7 +120,11 @@ export default function NamespaceDetailView() {
|
||||
},
|
||||
{
|
||||
name: 'Skipped',
|
||||
value: String(counts.skipped),
|
||||
value: (
|
||||
<span title="Only counts checks with Severity=ignore. Annotation-based exemptions are not included.">
|
||||
{counts.skipped}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
@@ -117,7 +118,7 @@ describe('NamespacesListView', () => {
|
||||
expect(screen.getByText('No Polaris audit results found.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders namespace rows with correct scores and links', () => {
|
||||
it('renders namespace rows with correct scores and buttons', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'deploy-a',
|
||||
@@ -157,12 +158,14 @@ describe('NamespacesListView', () => {
|
||||
|
||||
renderWithRouter(<NamespacesListView />);
|
||||
|
||||
// Namespace links
|
||||
const alphaLink = screen.getByText('alpha');
|
||||
expect(alphaLink.closest('a')).toHaveAttribute('href', '/polaris/ns/alpha');
|
||||
// Namespace buttons (now buttons instead of links for drawer)
|
||||
const alphaButton = screen.getByText('alpha');
|
||||
expect(alphaButton).toBeInTheDocument();
|
||||
expect(alphaButton.tagName).toBe('BUTTON');
|
||||
|
||||
const betaLink = screen.getByText('beta');
|
||||
expect(betaLink.closest('a')).toHaveAttribute('href', '/polaris/ns/beta');
|
||||
const betaButton = screen.getByText('beta');
|
||||
expect(betaButton).toBeInTheDocument();
|
||||
expect(betaButton.tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
it('uses correct scoreStatus: >=80 success, >=50 warning, <50 error', () => {
|
||||
@@ -216,4 +219,74 @@ describe('NamespacesListView', () => {
|
||||
const errorScore = scoreLabels.find(el => el.textContent === '0%');
|
||||
expect(errorScore).toHaveAttribute('data-status', 'error');
|
||||
});
|
||||
|
||||
it('opens drawer when namespace button is clicked and URL hash is updated', async () => {
|
||||
const user = userEvent.setup();
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'deploy-a',
|
||||
Namespace: 'alpha',
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderWithRouter(<NamespacesListView />);
|
||||
|
||||
// Click the namespace button
|
||||
const alphaButton = screen.getByText('alpha');
|
||||
await user.click(alphaButton);
|
||||
|
||||
// Drawer should open (check for the panel title)
|
||||
expect(screen.getByText(/Polaris — alpha/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes drawer from URL hash', () => {
|
||||
const data = makeAuditData([
|
||||
makeResult({
|
||||
Name: 'deploy-a',
|
||||
Namespace: 'test-ns',
|
||||
Results: {
|
||||
c1: {
|
||||
ID: 'c1',
|
||||
Message: '',
|
||||
Details: [],
|
||||
Success: true,
|
||||
Severity: 'warning',
|
||||
Category: 'X',
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
mockUsePolarisDataContext.mockReturnValue({
|
||||
data,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Render with initial hash in URL
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/polaris/namespaces#test-ns']}>
|
||||
<NamespacesListView />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Drawer should be open with the namespace from hash
|
||||
expect(screen.getByText(/Polaris — test-ns/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Router } from '@kinvolk/headlamp-plugin/lib';
|
||||
import {
|
||||
Loader,
|
||||
NameValueTable,
|
||||
@@ -7,13 +6,16 @@ import {
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
computeScore,
|
||||
countResultsForItems,
|
||||
filterResultsByNamespace,
|
||||
getNamespaces,
|
||||
getPolarisProxyUrl,
|
||||
Result,
|
||||
ResultCounts,
|
||||
} from '../api/polaris';
|
||||
import { usePolarisDataContext } from '../api/PolarisDataContext';
|
||||
|
||||
@@ -32,9 +34,227 @@ interface NamespaceRow {
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export default function NamespacesListView() {
|
||||
function resourceCounts(result: Result): ResultCounts {
|
||||
return countResultsForItems([result]);
|
||||
}
|
||||
|
||||
interface NamespaceDetailPanelProps {
|
||||
namespace: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps) {
|
||||
const { data, loading, error } = usePolarisDataContext();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<Loader title={`Loading Polaris data for ${namespace}...`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: <StatusLabel status="error">{error}</StatusLabel>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<SectionBox title="No Data">
|
||||
<NameValueTable rows={[{ name: 'Status', value: 'No Polaris audit results found.' }]} />
|
||||
</SectionBox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const results = filterResultsByNamespace(data, namespace);
|
||||
const counts = countResultsForItems(results);
|
||||
const score = computeScore(counts);
|
||||
const status = scoreStatus(score);
|
||||
|
||||
const countsPerResource = new Map<string, ResultCounts>();
|
||||
for (const r of results) {
|
||||
countsPerResource.set(`${r.Namespace}/${r.Kind}/${r.Name}`, resourceCounts(r));
|
||||
}
|
||||
|
||||
function getResourceCounts(row: Result): ResultCounts {
|
||||
return countsPerResource.get(`${row.Namespace}/${row.Kind}/${row.Name}`) ?? resourceCounts(row);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '1000px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper, var(--background-paper, #fff))',
|
||||
boxShadow: '-2px 0 8px rgba(0,0,0,0.15)',
|
||||
overflowY: 'auto',
|
||||
zIndex: 1200,
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary, var(--text-primary, #000))' }}>Polaris — {namespace}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
padding: '0 8px',
|
||||
color: 'var(--mui-palette-text-primary, var(--text-primary, #000))',
|
||||
}}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SectionBox title="External">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Polaris Dashboard',
|
||||
value: (
|
||||
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
|
||||
View in Polaris Dashboard
|
||||
</a>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<SectionBox title="Namespace Score">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Score',
|
||||
value: <StatusLabel status={status}>{score}%</StatusLabel>,
|
||||
},
|
||||
{ name: 'Total Checks', value: String(counts.total) },
|
||||
{
|
||||
name: 'Pass',
|
||||
value: <StatusLabel status="success">{counts.pass}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Warning',
|
||||
value: <StatusLabel status="warning">{counts.warning}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Danger',
|
||||
value: <StatusLabel status="error">{counts.danger}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Skipped',
|
||||
value: (
|
||||
<span title="Only counts checks with Severity=ignore. Annotation-based exemptions are not included.">
|
||||
{counts.skipped}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<SectionBox title="Resources">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (row: Result) => row.Name },
|
||||
{ label: 'Kind', getter: (row: Result) => row.Kind },
|
||||
{
|
||||
label: 'Pass',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="success">{getResourceCounts(row).pass}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Warning',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="warning">{getResourceCounts(row).warning}</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Danger',
|
||||
getter: (row: Result) => (
|
||||
<StatusLabel status="error">{getResourceCounts(row).danger}</StatusLabel>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={results}
|
||||
emptyMessage={`No resources found in namespace "${namespace}".`}
|
||||
/>
|
||||
</SectionBox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NamespacesListView() {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const { data, loading, error } = usePolarisDataContext();
|
||||
|
||||
// Initialize from URL hash
|
||||
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(
|
||||
location.hash.slice(1) || null
|
||||
);
|
||||
|
||||
// Sync drawer state when URL hash changes (browser back/forward)
|
||||
useEffect(() => {
|
||||
const hashNs = location.hash.slice(1);
|
||||
setSelectedNamespace(hashNs || null);
|
||||
}, [location.hash]);
|
||||
|
||||
const openNamespace = (ns: string) => {
|
||||
setSelectedNamespace(ns);
|
||||
history.push(`${location.pathname}#${ns}`);
|
||||
};
|
||||
|
||||
const closeNamespace = () => {
|
||||
setSelectedNamespace(null);
|
||||
history.push(location.pathname);
|
||||
};
|
||||
|
||||
// Handle keyboard navigation (Escape key closes drawer)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && selectedNamespace) {
|
||||
closeNamespace();
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedNamespace) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedNamespace]);
|
||||
|
||||
if (loading) {
|
||||
return <Loader title="Loading Polaris audit data..." />;
|
||||
}
|
||||
@@ -92,13 +312,20 @@ export default function NamespacesListView() {
|
||||
{
|
||||
label: 'Namespace',
|
||||
getter: (row: NamespaceRow) => (
|
||||
<Link
|
||||
to={Router.createRouteURL('polaris-namespace', {
|
||||
namespace: row.namespace,
|
||||
})}
|
||||
<button
|
||||
onClick={() => openNamespace(row.namespace)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--link-color, #1976d2)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{row.namespace}
|
||||
</Link>
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -130,6 +357,25 @@ export default function NamespacesListView() {
|
||||
emptyMessage="No namespaces found in Polaris audit data."
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
{selectedNamespace && (
|
||||
<>
|
||||
<div
|
||||
onClick={closeNamespace}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 1100,
|
||||
}}
|
||||
aria-label="Close panel backdrop"
|
||||
/>
|
||||
<NamespaceDetailPanel namespace={selectedNamespace} onClose={closeNamespace} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { NameValueTable, SectionBox, StatusLabel } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||
import { Button } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { getRefreshInterval, INTERVAL_OPTIONS, setRefreshInterval } from '../api/polaris';
|
||||
import { getDashboardUrl, getRefreshInterval, INTERVAL_OPTIONS, setDashboardUrl, setRefreshInterval, AuditData } from '../api/polaris';
|
||||
|
||||
interface PluginSettingsProps {
|
||||
data?: { [key: string]: string | number | boolean };
|
||||
@@ -10,13 +12,57 @@ interface PluginSettingsProps {
|
||||
export default function PolarisSettings(props: PluginSettingsProps) {
|
||||
const { data, onDataChange } = props;
|
||||
const currentInterval = (data?.refreshInterval as number) ?? getRefreshInterval();
|
||||
const currentUrl = (data?.dashboardUrl as string) ?? getDashboardUrl();
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
const [testResult, setTestResult] = React.useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
function handleIntervalChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const seconds = Number(e.target.value);
|
||||
setRefreshInterval(seconds);
|
||||
onDataChange?.({ ...data, refreshInterval: seconds });
|
||||
}
|
||||
|
||||
function handleUrlChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const url = e.target.value;
|
||||
setDashboardUrl(url);
|
||||
onDataChange?.({ ...data, dashboardUrl: url });
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const baseUrl = currentUrl;
|
||||
const apiPath = baseUrl.endsWith('/') ? `${baseUrl}results.json` : `${baseUrl}/results.json`;
|
||||
const isFullUrl = apiPath.startsWith('http://') || apiPath.startsWith('https://');
|
||||
|
||||
let result: AuditData;
|
||||
|
||||
if (isFullUrl) {
|
||||
const response = await fetch(apiPath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
result = await response.json();
|
||||
} else {
|
||||
result = await ApiProxy.request(apiPath);
|
||||
}
|
||||
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected successfully! Version: ${result.PolarisOutputVersion}, Last audit: ${new Date(result.AuditTime).toLocaleString()}`,
|
||||
});
|
||||
} catch (err) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: `Connection failed: ${String(err)}`,
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionBox title="Polaris Settings">
|
||||
<NameValueTable
|
||||
@@ -24,7 +70,7 @@ export default function PolarisSettings(props: PluginSettingsProps) {
|
||||
{
|
||||
name: 'Refresh Interval',
|
||||
value: (
|
||||
<select value={currentInterval} onChange={handleChange}>
|
||||
<select value={currentInterval} onChange={handleIntervalChange}>
|
||||
{INTERVAL_OPTIONS.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
@@ -33,6 +79,53 @@ export default function PolarisSettings(props: PluginSettingsProps) {
|
||||
</select>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Dashboard URL',
|
||||
value: (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={currentUrl}
|
||||
onChange={handleUrlChange}
|
||||
placeholder="/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||
Examples:<br />
|
||||
• K8s proxy: <code>/api/v1/namespaces/polaris/services/polaris-dashboard:80/proxy/</code><br />
|
||||
• Full URL: <code>https://my-polaris.example.com</code>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Connection Test',
|
||||
value: (
|
||||
<div>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={testConnection}
|
||||
disabled={testing}
|
||||
size="small"
|
||||
>
|
||||
{testing ? 'Testing...' : 'Test Connection'}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<StatusLabel status={testResult.success ? 'success' : 'error'}>
|
||||
{testResult.message}
|
||||
</StatusLabel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
+24
-10
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
registerAppBarAction,
|
||||
registerDetailsViewSection,
|
||||
registerPluginSettings,
|
||||
registerRoute,
|
||||
registerSidebarEntry,
|
||||
@@ -6,9 +8,10 @@ import {
|
||||
import React from 'react';
|
||||
import { PolarisDataProvider } from './api/PolarisDataContext';
|
||||
import DashboardView from './components/DashboardView';
|
||||
import NamespaceDetailView from './components/NamespaceDetailView';
|
||||
import NamespacesListView from './components/NamespacesListView';
|
||||
import PolarisSettings from './components/PolarisSettings';
|
||||
import InlineAuditSection from './components/InlineAuditSection';
|
||||
import AppBarScoreBadge from './components/AppBarScoreBadge';
|
||||
|
||||
// --- Sidebar entries ---
|
||||
|
||||
@@ -62,16 +65,27 @@ registerRoute({
|
||||
),
|
||||
});
|
||||
|
||||
registerRoute({
|
||||
path: '/polaris/ns/:namespace',
|
||||
sidebar: 'polaris-namespaces',
|
||||
name: 'polaris-namespace',
|
||||
exact: true,
|
||||
component: () => (
|
||||
// Register plugin settings
|
||||
registerPluginSettings('polaris', PolarisSettings);
|
||||
|
||||
// Register details view section for supported controller types
|
||||
registerDetailsViewSection('polaris-audit', ({ resource }) => {
|
||||
const supportedKinds = ['Deployment', 'StatefulSet', 'DaemonSet', 'Job', 'CronJob'];
|
||||
|
||||
if (!supportedKinds.includes(resource?.kind)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PolarisDataProvider>
|
||||
<NamespaceDetailView />
|
||||
<InlineAuditSection resource={resource} />
|
||||
</PolarisDataProvider>
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
registerPluginSettings('headlamp-polaris-plugin', PolarisSettings, true);
|
||||
// Register app bar score badge
|
||||
registerAppBarAction('polaris-score', () => (
|
||||
<PolarisDataProvider>
|
||||
<AppBarScoreBadge />
|
||||
</PolarisDataProvider>
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user