diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index f5b84bf..582c628 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -111,54 +111,6 @@ jobs: "${API_URL}/releases/${RELEASE_ID}/assets?name=${TARBALL}" echo "Gitea release updated" - - name: Create GitHub release - 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 \ - -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))") - 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 - curl -sf -X POST \ - -H "Authorization: token ${{ secrets.GH_PAT }}" \ - -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" - name: Update metadata and align tag run: | @@ -187,15 +139,5 @@ jobs: # that the release checksum already matches and skip the build. git tag -f ${GITHUB_REF_NAME} git push -f origin ${GITHUB_REF_NAME} - # 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 - 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" + echo "Note: GitHub sync handled by Gitea mirror configuration" diff --git a/artifacthub-pkg.yml b/artifacthub-pkg.yml index a6fb662..8f122c4 100644 --- a/artifacthub-pkg.yml +++ b/artifacthub-pkg.yml @@ -1,9 +1,11 @@ -version: 0.1.7 +version: 0.2.0-dev.5 name: headlamp-polaris-plugin displayName: Polaris createdAt: "2026-02-05T19:00:00Z" +prerelease: true description: >- - Surfaces Fairwinds Polaris audit results inside the Headlamp UI. + [DEV PREVIEW] Surfaces Fairwinds Polaris audit results inside the Headlamp UI + with a new drawer-based namespace navigation pattern. 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. @@ -28,7 +30,7 @@ maintainers: - name: cpfarhood email: "chris@farhood.org" annotations: - 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/archive-url: "https://github.com/cpfarhood/headlamp-polaris-plugin/releases/download/v0.2.0-dev.5/headlamp-polaris-plugin-0.2.0-dev.5.tar.gz" headlamp/plugin/version-compat: ">=0.26" - headlamp/plugin/archive-checksum: sha256:0000000000000000000000000000000000000000000000000000000000000000 + headlamp/plugin/archive-checksum: sha256:cb8d03f52022590fce5565b4f08a3fb99d0e264f3ff6a1c99ab59bf48b33ef79 headlamp/plugin/distro-compat: in-cluster diff --git a/e2e/polaris.spec.ts b/e2e/polaris.spec.ts index 29e1877..f43dc7f 100644 --- a/e2e/polaris.spec.ts +++ b/e2e/polaris.spec.ts @@ -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(); }); }); diff --git a/package.json b/package.json index 1ceae59..2aa2ea9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "headlamp-polaris-plugin", - "version": "0.1.7", + "version": "0.2.0-dev.5", "description": "Headlamp plugin for Fairwinds Polaris audit results", "scripts": { "start": "headlamp-plugin start", diff --git a/src/components/NamespacesListView.test.tsx b/src/components/NamespacesListView.test.tsx index d0bbc46..9ae4d7a 100644 --- a/src/components/NamespacesListView.test.tsx +++ b/src/components/NamespacesListView.test.tsx @@ -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(); - // 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(); + + // 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( + + + + ); + + // Drawer should be open with the namespace from hash + expect(screen.getByText(/Polaris — test-ns/)).toBeInTheDocument(); + }); }); diff --git a/src/components/NamespacesListView.tsx b/src/components/NamespacesListView.tsx index 2dfbe3f..2b6c91e 100644 --- a/src/components/NamespacesListView.tsx +++ b/src/components/NamespacesListView.tsx @@ -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, + POLARIS_DASHBOARD_PROXY, + Result, + ResultCounts, } from '../api/polaris'; import { usePolarisDataContext } from '../api/PolarisDataContext'; @@ -32,9 +34,226 @@ 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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+ + {error}, + }, + ]} + /> + +
+ ); + } + + if (!data) { + return ( +
+ + + +
+ ); + } + + const results = filterResultsByNamespace(data, namespace); + const counts = countResultsForItems(results); + const score = computeScore(counts); + const status = scoreStatus(score); + + const countsPerResource = new Map(); + 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 ( +
+
+

Polaris — {namespace}

+ +
+ + + + View in Polaris Dashboard + + ), + }, + ]} + /> + + + + {score}%, + }, + { name: 'Total Checks', value: String(counts.total) }, + { + name: 'Pass', + value: {counts.pass}, + }, + { + name: 'Warning', + value: {counts.warning}, + }, + { + name: 'Danger', + value: {counts.danger}, + }, + { + name: 'Skipped', + value: ( + + {counts.skipped} + + ), + }, + ]} + /> + + + + row.Name }, + { label: 'Kind', getter: (row: Result) => row.Kind }, + { + label: 'Pass', + getter: (row: Result) => ( + {getResourceCounts(row).pass} + ), + }, + { + label: 'Warning', + getter: (row: Result) => ( + {getResourceCounts(row).warning} + ), + }, + { + label: 'Danger', + getter: (row: Result) => ( + {getResourceCounts(row).danger} + ), + }, + ]} + data={results} + emptyMessage={`No resources found in namespace "${namespace}".`} + /> + +
+ ); +} + +export default function NamespacesListView() { + const location = useLocation(); + const history = useHistory(); + const { data, loading, error } = usePolarisDataContext(); + + // Initialize from URL hash + const [selectedNamespace, setSelectedNamespace] = useState( + 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 ; } @@ -92,13 +311,20 @@ export default function NamespacesListView() { { label: 'Namespace', getter: (row: NamespaceRow) => ( - openNamespace(row.namespace)} + style={{ + border: 'none', + background: 'transparent', + color: 'var(--link-color, #1976d2)', + cursor: 'pointer', + textDecoration: 'underline', + padding: 0, + font: 'inherit', + }} > {row.namespace} - + ), }, { @@ -130,6 +356,25 @@ export default function NamespacesListView() { emptyMessage="No namespaces found in Polaris audit data." /> + + {selectedNamespace && ( + <> +
+ + + )} ); } diff --git a/src/index.tsx b/src/index.tsx index 8c6a538..1b91add 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,7 +6,6 @@ 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'; @@ -62,16 +61,4 @@ registerRoute({ ), }); -registerRoute({ - path: '/polaris/ns/:namespace', - sidebar: 'polaris-namespaces', - name: 'polaris-namespace', - exact: true, - component: () => ( - - - - ), -}); - registerPluginSettings('polaris', PolarisSettings, true);