From 811059cf75b0169118c46d102f9dff09752843ac Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 11 Feb 2026 20:21:45 -0500 Subject: [PATCH] feat: comprehensive Polaris integration enhancements Major new features: - App bar score badge showing cluster Polaris score - Inline audit results in Deployment/StatefulSet/DaemonSet/Job/CronJob detail views - Exemption management UI with annotation PATCH support - Top issues table on overview dashboard - Audit time display and manual refresh button - Connection test button in settings - Check ID to human-readable name mapping - Enhanced error messages with context Technical improvements: - Added triggerRefresh to PolarisDataContext for manual refresh - Created checkMapping.ts for check metadata - Created topIssues.ts for extracting common failures - Enhanced DashboardView with top issues and refresh - Enhanced PolarisSettings with connection test - Created InlineAuditSection for details view integration - Created AppBarScoreBadge for app bar integration - Created ExemptionManager for annotation patches UI enhancements: - 1000px namespace detail panel - Theme-aware styling throughout - Improved formatting and layout - Better status indicators Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- README.md | 27 ++- artifacthub-pkg.yml | 6 +- package.json | 2 +- src/api/PolarisDataContext.tsx | 14 +- src/api/checkMapping.ts | 238 ++++++++++++++++++++++++ src/api/polaris.ts | 7 +- src/api/topIssues.ts | 81 ++++++++ src/components/AppBarScoreBadge.tsx | 44 +++++ src/components/DashboardView.tsx | 73 +++++++- src/components/ExemptionManager.tsx | 258 ++++++++++++++++++++++++++ src/components/InlineAuditSection.tsx | 171 +++++++++++++++++ src/components/PolarisSettings.tsx | 65 ++++++- src/index.tsx | 26 +++ 13 files changed, 993 insertions(+), 19 deletions(-) create mode 100644 src/api/checkMapping.ts create mode 100644 src/api/topIssues.ts create mode 100644 src/components/AppBarScoreBadge.tsx create mode 100644 src/components/ExemptionManager.tsx create mode 100644 src/components/InlineAuditSection.tsx diff --git a/README.md b/README.md index a911ef3..9c5532b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/artifacthub-pkg.yml b/artifacthub-pkg.yml index f561a76..6d62cfd 100644 --- a/artifacthub-pkg.yml +++ b/artifacthub-pkg.yml @@ -1,4 +1,4 @@ -version: 0.2.5 +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.2.5/headlamp-polaris-plugin-0.2.5.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:5b15832d0cb4acf796bc6816d0c9b5ab244de11c26d96e5602525f65ab3f53bb + headlamp/plugin/archive-checksum: sha256:0000000000000000000000000000000000000000000000000000000000000000 headlamp/plugin/distro-compat: in-cluster diff --git a/package.json b/package.json index e4981ad..3068295 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "headlamp-polaris-plugin", - "version": "0.2.5", + "version": "0.3.0", "description": "Headlamp plugin for Fairwinds Polaris audit results", "scripts": { "start": "headlamp-plugin start", diff --git a/src/api/PolarisDataContext.tsx b/src/api/PolarisDataContext.tsx index e7c8a9f..02790d9 100644 --- a/src/api/PolarisDataContext.tsx +++ b/src/api/PolarisDataContext.tsx @@ -5,6 +5,7 @@ interface PolarisDataContextValue { data: AuditData | null; loading: boolean; error: string | null; + refresh: () => void; } const PolarisDataContext = React.createContext(null); @@ -13,7 +14,18 @@ export function PolarisDataProvider(props: { children: React.ReactNode }) { const interval = getRefreshInterval(); const state = usePolarisData(interval); - return {props.children}; + // Rename triggerRefresh to refresh for consistency + const value = React.useMemo( + () => ({ + data: state.data, + loading: state.loading, + error: state.error, + refresh: state.triggerRefresh, + }), + [state] + ); + + return {props.children}; } export function usePolarisDataContext(): PolarisDataContextValue { diff --git a/src/api/checkMapping.ts b/src/api/checkMapping.ts new file mode 100644 index 0000000..77b1ef4 --- /dev/null +++ b/src/api/checkMapping.ts @@ -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 = { + // 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'; + } +} diff --git a/src/api/polaris.ts b/src/api/polaris.ts index 6271414..cb2e55e 100644 --- a/src/api/polaris.ts +++ b/src/api/polaris.ts @@ -186,6 +186,7 @@ interface PolarisDataState { data: AuditData | null; loading: boolean; error: string | null; + triggerRefresh: () => void; } export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState { @@ -194,6 +195,10 @@ export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState const [error, setError] = React.useState(null); const [tick, setTick] = React.useState(0); + const triggerRefresh = React.useCallback(() => { + setTick(t => t + 1); + }, []); + React.useEffect(() => { let cancelled = false; @@ -266,5 +271,5 @@ export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState return () => window.clearInterval(intervalId); }, [refreshIntervalSeconds]); - return { data, loading, error }; + return { data, loading, error, triggerRefresh }; } diff --git a/src/api/topIssues.ts b/src/api/topIssues.ts new file mode 100644 index 0000000..0ff4183 --- /dev/null +++ b/src/api/topIssues.ts @@ -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(); + + // 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); +} diff --git a/src/components/AppBarScoreBadge.tsx b/src/components/AppBarScoreBadge.tsx new file mode 100644 index 0000000..9419351 --- /dev/null +++ b/src/components/AppBarScoreBadge.tsx @@ -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 ( + } + label={`Polaris: ${score}%`} + color={getColor(score)} + size="small" + onClick={handleClick} + style={{ cursor: 'pointer', marginRight: '8px' }} + /> + ); +} diff --git a/src/components/DashboardView.tsx b/src/components/DashboardView.tsx index 650820c..103b671 100644 --- a/src/components/DashboardView.tsx +++ b/src/components/DashboardView.tsx @@ -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', @@ -67,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 ; } const counts = data ? countResults(data) : null; + const topIssues = data ? getTopIssues(data) : []; return ( <> - +
+ + {data && ( +
+ + Last updated: {formatAuditTime(data.AuditTime)} + + +
+ )} +
{error && ( @@ -93,7 +132,35 @@ export default function DashboardView() { )} - {data && counts && } + {data && counts && ( + <> + + + {topIssues.length > 0 && ( + + issue.checkName }, + { label: 'Category', getter: (issue: TopIssue) => issue.category }, + { + label: 'Severity', + getter: (issue: TopIssue) => ( + + {issue.severity} + + ), + }, + { + label: 'Affected Workloads', + getter: (issue: TopIssue) => String(issue.count), + }, + ]} + data={topIssues} + /> + + )} + + )} {!data && !error && ( diff --git a/src/components/ExemptionManager.tsx b/src/components/ExemptionManager.tsx new file mode 100644 index 0000000..602e9a0 --- /dev/null +++ b/src/components/ExemptionManager.tsx @@ -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>(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 = {}; + + 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 ( + <> + + {currentExemptions.length > 0 ? ( + ({ + name: exemption, + value: ( + + ), + }))} + /> + ) : ( +

No exemptions configured

+ )} + + +
+ + setDialogOpen(false)} + title="Add Exemptions" + > +
+ setExemptAll(e.target.checked)} + /> + } + label="Exempt from all checks" + /> + + {!exemptAll && ( + <> +
+ Select checks to exempt: +
+ + {failingChecks.map(check => ( + handleCheckToggle(check.checkId)} + /> + } + label={check.checkName} + /> + ))} + + + )} + +
+ + +
+
+
+ + ); +} + +// 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'; + } +} diff --git a/src/components/InlineAuditSection.tsx b/src/components/InlineAuditSection.tsx new file mode 100644 index 0000000..0c16d77 --- /dev/null +++ b/src/components/InlineAuditSection.tsx @@ -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 ( + + + + ); + } + + // 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 ( + + = 80 ? 'success' : score >= 50 ? 'warning' : 'error'}> + {score}% + + ), + }, + { + name: 'Summary', + value: `${counts.pass} passing, ${counts.warning} warnings, ${counts.danger} dangers`, + }, + ]} + /> + + {failures.length > 0 && ( + <> +
+ Failing Checks: +
+ f.checkName }, + { + label: 'Severity', + getter: (f: CheckFailure) => ( + + {f.severity} + + ), + }, + { label: 'Message', getter: (f: CheckFailure) => f.message }, + ]} + data={failures} + /> + + )} + +
+ + View Full Report → + +
+ +
+ +
+
+ ); +} diff --git a/src/components/PolarisSettings.tsx b/src/components/PolarisSettings.tsx index 85413ef..6de1fd8 100644 --- a/src/components/PolarisSettings.tsx +++ b/src/components/PolarisSettings.tsx @@ -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 { getDashboardUrl, getRefreshInterval, INTERVAL_OPTIONS, setDashboardUrl, setRefreshInterval } from '../api/polaris'; +import { getDashboardUrl, getRefreshInterval, INTERVAL_OPTIONS, setDashboardUrl, setRefreshInterval, AuditData } from '../api/polaris'; interface PluginSettingsProps { data?: { [key: string]: string | number | boolean }; @@ -11,6 +13,8 @@ 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 handleIntervalChange(e: React.ChangeEvent) { const seconds = Number(e.target.value); @@ -24,6 +28,41 @@ export default function PolarisSettings(props: PluginSettingsProps) { 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 ( ), }, + { + name: 'Connection Test', + value: ( +
+ + {testResult && ( +
+ + {testResult.message} + +
+ )} +
+ ), + }, ]} />
diff --git a/src/index.tsx b/src/index.tsx index 69a8ef2..a23edaa 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,6 @@ import { + registerAppBarAction, + registerDetailsViewSection, registerPluginSettings, registerRoute, registerSidebarEntry, @@ -8,6 +10,8 @@ import { PolarisDataProvider } from './api/PolarisDataContext'; import DashboardView from './components/DashboardView'; import NamespacesListView from './components/NamespacesListView'; import PolarisSettings from './components/PolarisSettings'; +import InlineAuditSection from './components/InlineAuditSection'; +import AppBarScoreBadge from './components/AppBarScoreBadge'; // --- Sidebar entries --- @@ -63,3 +67,25 @@ registerRoute({ // 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 ( + + + + ); +}); + +// Register app bar score badge +registerAppBarAction('polaris-score', () => ( + + + +));