Compare commits

...

10 Commits

Author SHA1 Message Date
github-actions[bot] a18710ccb1 ci: update artifact hub metadata for v0.3.0 2026-02-12 01:24:24 +00:00
Chris Farhood 811059cf75 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 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-11 20:21:45 -05:00
github-actions[bot] a404c075d6 ci: update artifact hub metadata for v0.2.5 2026-02-12 00:25:14 +00:00
Chris Farhood db17a08d26 fix: improve theming and settings visibility
- Fix namespace detail panel to follow system dark/light theme
- Use proper CSS custom properties for background and text colors
- Fix plugin settings registration (remove deprecated third parameter)
- Ensure close button and headers respect theme colors
- Version bump to 0.2.5

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-11 19:24:33 -05:00
github-actions[bot] e52670dee4 ci: update artifact hub metadata for v0.2.4 2026-02-11 23:07:13 +00:00
Chris Farhood 8d219a9c6e ui: increase namespace detail panel width to 1000px
- Expand side panel from 800px to 1000px for better content viewing
- Version bump to 0.2.4

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-11 18:06:29 -05:00
github-actions[bot] b2cbce16c1 ci: update artifact hub metadata for v0.2.3 2026-02-11 18:36:20 +00:00
Chris Farhood c95aab3ca3 feat: add full URL support for custom Polaris dashboards
- Add isFullUrl() helper to detect full vs proxy URLs
- Support both K8s proxy URLs and direct HTTP/HTTPS URLs
- Use fetch() for full URLs, ApiProxy for K8s proxy URLs
- Improve error messages with context-specific guidance
- Update settings with examples for both URL types
- Version bump to 0.2.3

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-11 13:35:35 -05:00
github-actions[bot] 604106c688 ci: update artifact hub metadata for v0.2.2 2026-02-11 18:32:26 +00:00
Chris Farhood 44a0016a4d feat: add configurable Polaris dashboard URL setting
- Add getDashboardUrl() and setDashboardUrl() functions to polaris.ts
- Update PolarisSettings with dashboard URL input field
- Replace hardcoded POLARIS_DASHBOARD_PROXY with configurable getPolarisProxyUrl()
- Increase namespace detail panel width to 800px
- Remove unused 'Skipped' field from overview dashboard
- Version bump to 0.2.2

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-11 13:31:40 -05:00
16 changed files with 1106 additions and 57 deletions
+19 -8
View File
@@ -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
+3 -3
View File
@@ -1,4 +1,4 @@
version: 0.2.1
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.1/headlamp-polaris-plugin-0.2.1.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:0ee98511e23590699e623fc597c56aef6619793b0fe7dfa4bb4c21ca28b6fdaf
headlamp/plugin/archive-checksum: sha256:fbe29c07478f28433f5859f452880929717f5ee1d5baebe7e9dbd8880ba483d1
headlamp/plugin/distro-compat: in-cluster
+2 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "headlamp-polaris-plugin",
"version": "0.2.1",
"version": "0.3.0",
"description": "Headlamp plugin for Fairwinds Polaris audit results",
"scripts": {
"start": "headlamp-plugin start",
+13 -1
View File
@@ -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 {
+238
View File
@@ -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 -18
View File
@@ -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:80/proxy/';
export function getPolarisProxyUrl(): string {
return getDashboardUrl();
}
// --- Score computation ---
@@ -157,13 +173,20 @@ export function computeScore(counts: ResultCounts): number {
// --- Data fetching hook ---
const POLARIS_API_PATH =
'/api/v1/namespaces/polaris/services/polaris-dashboard:80/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 {
@@ -172,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);
@@ -185,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);
}
@@ -216,5 +271,5 @@ export function usePolarisData(refreshIntervalSeconds: number): PolarisDataState
return () => window.clearInterval(intervalId);
}, [refreshIntervalSeconds]);
return { data, loading, error };
return { data, loading, error, triggerRefresh };
}
+81
View File
@@ -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);
}
+44
View File
@@ -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' }}
/>
);
}
+70 -12
View File
@@ -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,14 +55,6 @@ function OverviewSection(props: { data: AuditData; counts: ResultCounts }) {
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>
@@ -76,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">
@@ -102,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">
+258
View File
@@ -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';
}
}
+171
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>
),
+6 -5
View File
@@ -13,7 +13,7 @@ import {
countResultsForItems,
filterResultsByNamespace,
getNamespaces,
POLARIS_DASHBOARD_PROXY,
getPolarisProxyUrl,
Result,
ResultCounts,
} from '../api/polaris';
@@ -102,8 +102,8 @@ function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps)
right: 0,
top: 0,
bottom: 0,
width: '600px',
backgroundColor: 'var(--background-paper, #fff)',
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,
@@ -118,7 +118,7 @@ function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps)
alignItems: 'center',
}}
>
<h2 style={{ margin: 0 }}>Polaris {namespace}</h2>
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary, var(--text-primary, #000))' }}>Polaris {namespace}</h2>
<button
onClick={onClose}
style={{
@@ -127,6 +127,7 @@ function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps)
fontSize: '24px',
cursor: 'pointer',
padding: '0 8px',
color: 'var(--mui-palette-text-primary, var(--text-primary, #000))',
}}
aria-label="Close panel"
>
@@ -140,7 +141,7 @@ function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps)
{
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>
),
+97 -4
View File
@@ -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>
+28 -1
View File
@@ -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 ---
@@ -61,4 +65,27 @@ registerRoute({
),
});
registerPluginSettings('polaris', PolarisSettings, true);
// 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>
<InlineAuditSection resource={resource} />
</PolarisDataProvider>
);
});
// Register app bar score badge
registerAppBarAction('polaris-score', () => (
<PolarisDataProvider>
<AppBarScoreBadge />
</PolarisDataProvider>
));