commit 036cb668970edb4dcb15ffd5c7fb412efc88ae5f Author: Chris Farhood Date: Thu Feb 5 14:20:46 2026 -0500 feat: initial Polaris Headlamp plugin (v0.0.1) Sidebar page at /polaris that reads Fairwinds Polaris audit results from ConfigMap/polaris-dashboard in the polaris namespace. Displays cluster score, check summary (pass/warning/danger counts), and cluster info. Caches results with user-configurable refresh interval. Handles 403, 404, and malformed JSON error states. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70def5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.headlamp-plugin/ +.mcp.json diff --git a/claude.md b/claude.md new file mode 100644 index 0000000..970c438 --- /dev/null +++ b/claude.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Headlamp plugin that surfaces Fairwinds Polaris audit results inside the Headlamp UI. Reads from `ConfigMap/polaris-dashboard` in the `polaris` namespace (key: `dashboard.json`). Target Headlamp ≥ v0.26. + +## Build & Development Commands + +```bash +# Install dependencies +npm install + +# Build the plugin (standard Headlamp plugin build) +npx @kinvolk/headlamp-plugin build + +# Start development mode with hot reload +npx @kinvolk/headlamp-plugin start + +# Type-check without emitting +npx tsc --noEmit + +# Lint +npx eslint src/ +``` + +## Architecture + +``` +src/ +├── index.tsx # Entry point: registerSidebarEntry + registerRoute for /polaris +├── api/ +│ └── polaris.ts # Types (AuditData schema), usePolarisData hook, countResults utility, refresh settings +└── components/ + └── PolarisView.tsx # Main page: score badge, check summary, cluster info, error states, refresh interval selector +``` + +Single sidebar page at `/polaris`. Data is cached in React state and refreshed on a user-configurable interval (stored in localStorage under `polaris-plugin-refresh-interval`, default 5 minutes). The `usePolarisData` hook wraps `ConfigMap.useGet` with caching so stale data is shown while refreshing. + +## Key Constraints + +- **Data source**: `ConfigMap/polaris-dashboard` in `polaris` namespace, key `dashboard.json`. No CRDs, no external API calls, no cluster write operations. +- **UI components**: Use only Headlamp-provided components (`@kinvolk/headlamp-plugin/lib/CommonComponents`). Do not import raw MUI packages. No custom theming. +- **Error handling**: Must handle 403 (RBAC denied), 404 (Polaris not installed), malformed JSON, and loading states with distinct visual states. +- **TypeScript strictness**: No `any`, no implicit `unknown` casting, no dead code, no unused imports. +- **Packaging**: `@kinvolk/headlamp-plugin` is a peer dependency. Do not bundle React or MUI. + +## MCP Servers + +The project has MCP server integrations configured in `.mcp.json`: +- **Gitea** (git.farh.net): Source control via `gitea-mcp-server` +- **Kubernetes** (local): Cluster access via `kubernetes-mcp-server` +- **Flux** (local): Flux Operator access via `flux-operator-mcp` diff --git a/package.json b/package.json new file mode 100644 index 0000000..048c6e5 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "polaris-headlamp-plugin", + "version": "0.0.1", + "description": "Headlamp plugin for Fairwinds Polaris audit results", + "scripts": { + "start": "headlamp-plugin start", + "build": "headlamp-plugin build", + "tsc": "tsc --noEmit" + }, + "devDependencies": { + "@kinvolk/headlamp-plugin": "^0.13.0" + } +} diff --git a/src/api/polaris.ts b/src/api/polaris.ts new file mode 100644 index 0000000..1332df3 --- /dev/null +++ b/src/api/polaris.ts @@ -0,0 +1,207 @@ +import { K8s } from '@kinvolk/headlamp-plugin/lib'; +import React from 'react'; + +// --- Polaris AuditData schema (matches pkg/validator/output.go) --- + +type Severity = 'ignore' | 'warning' | 'danger'; + +interface ResultMessage { + ID: string; + Message: string; + Details: string[]; + Success: boolean; + Severity: Severity; + Category: string; +} + +type ResultSet = Record; + +interface ContainerResult { + Name: string; + Results: ResultSet; +} + +interface PodResult { + Name: string; + Results: ResultSet; + ContainerResults: ContainerResult[]; +} + +export interface Result { + Name: string; + Namespace: string; + Kind: string; + Results: ResultSet; + PodResult?: PodResult; + CreatedTime: string; +} + +interface ClusterInfo { + Version: string; + Nodes: number; + Pods: number; + Namespaces: number; + Controllers: number; +} + +export interface AuditData { + PolarisOutputVersion: string; + AuditTime: string; + SourceType: string; + SourceName: string; + DisplayName: string; + ClusterInfo: ClusterInfo; + Results: Result[]; + Score: number; +} + +// --- Result counting --- + +export interface ResultCounts { + total: number; + pass: number; + warning: number; + danger: number; +} + +function countResultSet(rs: ResultSet, counts: ResultCounts): void { + for (const key of Object.keys(rs)) { + const msg = rs[key]; + counts.total++; + if (msg.Success) { + counts.pass++; + } else if (msg.Severity === 'warning') { + counts.warning++; + } else if (msg.Severity === 'danger') { + counts.danger++; + } + } +} + +export function countResults(data: AuditData): ResultCounts { + const counts: ResultCounts = { total: 0, pass: 0, warning: 0, danger: 0 }; + for (const result of data.Results) { + countResultSet(result.Results, counts); + if (result.PodResult) { + countResultSet(result.PodResult.Results, counts); + for (const container of result.PodResult.ContainerResults) { + countResultSet(container.Results, counts); + } + } + } + return counts; +} + +// --- Settings --- + +const STORAGE_KEY = 'polaris-plugin-refresh-interval'; +const DEFAULT_INTERVAL_SECONDS = 300; // 5 minutes + +export function getRefreshInterval(): number { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored !== null) { + const parsed = parseInt(stored, 10); + if (!isNaN(parsed) && parsed > 0) { + return parsed; + } + } + return DEFAULT_INTERVAL_SECONDS; +} + +export function setRefreshInterval(seconds: number): void { + localStorage.setItem(STORAGE_KEY, String(seconds)); +} + +// --- Data fetching hook --- + +interface PolarisDataState { + data: AuditData | null; + loading: boolean; + error: string | null; +} + +export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState { + const [configMap, fetchError] = K8s.ResourceClasses.ConfigMap.useGet( + 'polaris-dashboard', + 'polaris' + ); + const [cachedData, setCachedData] = React.useState(null); + const [parseError, setParseError] = React.useState(null); + const [lastFetchTime, setLastFetchTime] = React.useState(0); + const [, setTick] = React.useState(0); + + // Parse ConfigMap data when it arrives + React.useEffect(() => { + if (!configMap) { + return; + } + const dataMap = configMap.data as Record | undefined; + const raw = dataMap?.['dashboard.json']; + if (!raw) { + setParseError('ConfigMap exists but dashboard.json key is missing.'); + return; + } + try { + const parsed: AuditData = JSON.parse(raw); + setCachedData(parsed); + setParseError(null); + setLastFetchTime(Date.now()); + } catch { + setParseError('Failed to parse dashboard.json: malformed JSON.'); + } + }, [configMap]); + + // Periodic refresh via re-render trigger + React.useEffect(() => { + if (refreshIntervalSeconds <= 0) { + return; + } + const intervalId = window.setInterval(() => { + setTick((t) => t + 1); + }, refreshIntervalSeconds * 1000); + return () => window.clearInterval(intervalId); + }, [refreshIntervalSeconds]); + + // Determine error state + if (fetchError) { + const status = (fetchError as { status?: number }).status; + if (status === 403) { + return { + data: cachedData, + loading: false, + error: + 'Access denied (403). Check that your RBAC permissions allow reading ConfigMaps in the polaris namespace.', + }; + } + if (status === 404) { + return { + data: cachedData, + loading: false, + error: + 'Polaris dashboard ConfigMap not found (404). Ensure Polaris is installed in the polaris namespace.', + }; + } + return { + data: cachedData, + loading: false, + error: `Failed to fetch Polaris data: ${String(fetchError)}`, + }; + } + + if (parseError) { + return { data: cachedData, loading: false, error: parseError }; + } + + const isLoading = !configMap && !fetchError; + + // Return cached data while loading if we have it + if (isLoading && cachedData && lastFetchTime > 0) { + return { data: cachedData, loading: false, error: null }; + } + + return { + data: cachedData, + loading: isLoading, + error: null, + }; +} diff --git a/src/components/PolarisView.tsx b/src/components/PolarisView.tsx new file mode 100644 index 0000000..bce8bd1 --- /dev/null +++ b/src/components/PolarisView.tsx @@ -0,0 +1,163 @@ +import { + Loader, + SectionBox, + SectionHeader, +} from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import React from 'react'; +import { + AuditData, + countResults, + getRefreshInterval, + ResultCounts, + setRefreshInterval, + usePolarisData, +} from '../api/polaris'; + +const INTERVAL_OPTIONS = [ + { label: '1 minute', value: 60 }, + { label: '5 minutes', value: 300 }, + { label: '10 minutes', value: 600 }, + { label: '30 minutes', value: 1800 }, +]; + +function RefreshSettings(props: { + interval: number; + onChange: (seconds: number) => void; +}) { + return ( +
+ + +
+ ); +} + +function StatCard(props: { label: string; value: number; color?: string }) { + return ( +
+
+ {props.value} +
+
{props.label}
+
+ ); +} + +function ScoreBadge(props: { score: number }) { + const color = props.score >= 80 ? '#4caf50' : props.score >= 50 ? '#ff9800' : '#f44336'; + return ( +
+
+ {props.score}% +
+
Cluster Score
+
+ ); +} + +function OverviewSection(props: { data: AuditData; counts: ResultCounts }) { + return ( + <> + + + + +
+ + + + +
+
+ +
+ + + + +
+
+ + ); +} + +export default function PolarisView() { + const [interval, setInterval] = React.useState(getRefreshInterval); + + function handleIntervalChange(seconds: number) { + setInterval(seconds); + setRefreshInterval(seconds); + } + + const { data, loading, error } = usePolarisData(interval); + + if (loading) { + return ; + } + + const counts = data ? countResults(data) : null; + + return ( + <> + , + ]} /> + + {error && ( + +
{error}
+
+ )} + + {data && counts && } + + {!data && !error && ( + +
+ No Polaris audit results found. +
+
+ )} + + ); +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..ccaf613 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,22 @@ +import { + registerRoute, + registerSidebarEntry, +} from '@kinvolk/headlamp-plugin/lib'; +import React from 'react'; +import PolarisView from './components/PolarisView'; + +registerSidebarEntry({ + parent: null, + name: 'polaris', + label: 'Polaris', + url: '/polaris', + icon: 'mdi:shield-check', +}); + +registerRoute({ + path: '/polaris', + sidebar: 'polaris', + name: 'polaris', + exact: true, + component: () => , +});