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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.headlamp-plugin/
|
||||||
|
.mcp.json
|
||||||
@@ -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`
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, ResultMessage>;
|
||||||
|
|
||||||
|
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<AuditData | null>(null);
|
||||||
|
const [parseError, setParseError] = React.useState<string | null>(null);
|
||||||
|
const [lastFetchTime, setLastFetchTime] = React.useState<number>(0);
|
||||||
|
const [, setTick] = React.useState(0);
|
||||||
|
|
||||||
|
// Parse ConfigMap data when it arrives
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!configMap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dataMap = configMap.data as Record<string, string> | 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<label htmlFor="polaris-refresh-interval">Refresh interval:</label>
|
||||||
|
<select
|
||||||
|
id="polaris-refresh-interval"
|
||||||
|
value={props.interval}
|
||||||
|
onChange={(e) => props.onChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{INTERVAL_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard(props: { label: string; value: number; color?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '16px 24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
minWidth: '120px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '2rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: props.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.value}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{props.label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreBadge(props: { score: number }) {
|
||||||
|
const color = props.score >= 80 ? '#4caf50' : props.score >= 50 ? '#ff9800' : '#f44336';
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '16px' }}>
|
||||||
|
<div style={{ fontSize: '3rem', fontWeight: 'bold', color }}>
|
||||||
|
{props.score}%
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>Cluster Score</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewSection(props: { data: AuditData; counts: ResultCounts }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionBox title="Score">
|
||||||
|
<ScoreBadge score={props.data.Score} />
|
||||||
|
</SectionBox>
|
||||||
|
<SectionBox title="Check Summary">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatCard label="Total" value={props.counts.total} />
|
||||||
|
<StatCard label="Pass" value={props.counts.pass} color="#4caf50" />
|
||||||
|
<StatCard label="Warning" value={props.counts.warning} color="#ff9800" />
|
||||||
|
<StatCard label="Danger" value={props.counts.danger} color="#f44336" />
|
||||||
|
</div>
|
||||||
|
</SectionBox>
|
||||||
|
<SectionBox title="Cluster Info">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatCard label="Nodes" value={props.data.ClusterInfo.Nodes} />
|
||||||
|
<StatCard label="Pods" value={props.data.ClusterInfo.Pods} />
|
||||||
|
<StatCard label="Namespaces" value={props.data.ClusterInfo.Namespaces} />
|
||||||
|
<StatCard label="Controllers" value={props.data.ClusterInfo.Controllers} />
|
||||||
|
</div>
|
||||||
|
</SectionBox>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <Loader title="Loading Polaris audit data..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = data ? countResults(data) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader title="Polaris" actions={[
|
||||||
|
<RefreshSettings
|
||||||
|
key="refresh"
|
||||||
|
interval={interval}
|
||||||
|
onChange={handleIntervalChange}
|
||||||
|
/>,
|
||||||
|
]} />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<SectionBox title="Error">
|
||||||
|
<div style={{ padding: '16px', color: '#f44336' }}>{error}</div>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && counts && <OverviewSection data={data} counts={counts} />}
|
||||||
|
|
||||||
|
{!data && !error && (
|
||||||
|
<SectionBox title="No Data">
|
||||||
|
<div style={{ padding: '16px' }}>
|
||||||
|
No Polaris audit results found.
|
||||||
|
</div>
|
||||||
|
</SectionBox>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: () => <PolarisView />,
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user