Files
headlamp-polaris-plugin/src/components/NamespacesListView.tsx
T
Chris Farhood de5feb68a3 feat: add maximize/minimize buttons to namespace drawer
Add toolbar with maximize/minimize and close buttons to namespace detail
drawer, matching Headlamp's native drawer behavior.

Changes:
- Add maximize/minimize button (⊡/⊟) to toggle drawer width
- Maximized width: calc(100vw - 240px) to avoid covering sidebar
- Normal width: 1000px
- Add smooth transition animation (0.3s)
- Add hover effects using MUI theme variables
- Improve close button styling with hover state
- Add accessibility labels and tooltips

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-13 09:32:03 -05:00

429 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useEffect, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import {
computeScore,
countResultsForItems,
filterResultsByNamespace,
getNamespaces,
getPolarisProxyUrl,
Result,
ResultCounts,
} from '../api/polaris';
import { usePolarisDataContext } from '../api/PolarisDataContext';
function scoreStatus(score: number): 'success' | 'warning' | 'error' {
if (score >= 80) return 'success';
if (score >= 50) return 'warning';
return 'error';
}
interface NamespaceRow {
namespace: string;
score: number;
pass: number;
warning: number;
danger: number;
skipped: number;
}
function resourceCounts(result: Result): ResultCounts {
return countResultsForItems([result]);
}
interface NamespaceDetailPanelProps {
namespace: string;
onClose: () => void;
}
function NamespaceDetailPanel({ namespace, onClose }: NamespaceDetailPanelProps) {
const [isMaximized, setIsMaximized] = React.useState(false);
const { data, loading, error } = usePolarisDataContext();
if (loading) {
return (
<div style={{ padding: '20px' }}>
<Loader title={`Loading Polaris data for ${namespace}...`} />
</div>
);
}
if (error) {
return (
<div style={{ padding: '20px' }}>
<SectionBox title="Error">
<NameValueTable
rows={[
{
name: 'Status',
value: <StatusLabel status="error">{error}</StatusLabel>,
},
]}
/>
</SectionBox>
</div>
);
}
if (!data) {
return (
<div style={{ padding: '20px' }}>
<SectionBox title="No Data">
<NameValueTable rows={[{ name: 'Status', value: 'No Polaris audit results found.' }]} />
</SectionBox>
</div>
);
}
const results = filterResultsByNamespace(data, namespace);
const counts = countResultsForItems(results);
const score = computeScore(counts);
const status = scoreStatus(score);
const countsPerResource = new Map<string, ResultCounts>();
for (const r of results) {
countsPerResource.set(`${r.Namespace}/${r.Kind}/${r.Name}`, resourceCounts(r));
}
function getResourceCounts(row: Result): ResultCounts {
return countsPerResource.get(`${row.Namespace}/${row.Kind}/${row.Name}`) ?? resourceCounts(row);
}
// Generate a unique class name for this drawer to avoid conflicts
const drawerClass = `polaris-namespace-drawer-${namespace}`;
return (
<>
<style>
{`
.${drawerClass} {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: ${isMaximized ? 'calc(100vw - 240px)' : '1000px'};
background-color: var(--mui-palette-background-default, #fafafa);
color: var(--mui-palette-text-primary);
box-shadow: -2px 0 8px rgba(0,0,0,0.15);
overflow-y: auto;
z-index: 1200;
padding: 20px;
transition: width 0.3s ease;
}
`}
</style>
<div className={drawerClass}>
<div
style={{
marginBottom: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>
Polaris {namespace}
</h2>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
onClick={() => setIsMaximized(!isMaximized)}
style={{
border: 'none',
background: 'transparent',
fontSize: '20px',
cursor: 'pointer',
padding: '4px 8px',
color: 'var(--mui-palette-text-secondary, #666)',
borderRadius: '4px',
}}
onMouseEnter={e => {
e.currentTarget.style.backgroundColor =
'var(--mui-palette-action-hover, rgba(0, 0, 0, 0.04))';
}}
onMouseLeave={e => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
aria-label={isMaximized ? 'Minimize panel' : 'Maximize panel'}
title={isMaximized ? 'Minimize' : 'Maximize'}
>
{isMaximized ? '⊟' : '⊡'}
</button>
<button
onClick={onClose}
style={{
border: 'none',
background: 'transparent',
fontSize: '24px',
cursor: 'pointer',
padding: '4px 8px',
color: 'var(--mui-palette-text-secondary, #666)',
borderRadius: '4px',
}}
onMouseEnter={e => {
e.currentTarget.style.backgroundColor =
'var(--mui-palette-action-hover, rgba(0, 0, 0, 0.04))';
}}
onMouseLeave={e => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
aria-label="Close panel"
title="Close"
>
×
</button>
</div>
</div>
<SectionBox title="External">
<NameValueTable
rows={[
{
name: 'Polaris Dashboard',
value: (
<a href={getPolarisProxyUrl()} target="_blank" rel="noopener noreferrer">
View in Polaris Dashboard
</a>
),
},
]}
/>
</SectionBox>
<SectionBox title="Namespace Score">
<NameValueTable
rows={[
{
name: 'Score',
value: <StatusLabel status={status}>{score}%</StatusLabel>,
},
{ name: 'Total Checks', value: String(counts.total) },
{
name: 'Pass',
value: <StatusLabel status="success">{counts.pass}</StatusLabel>,
},
{
name: 'Warning',
value: <StatusLabel status="warning">{counts.warning}</StatusLabel>,
},
{
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>
<SectionBox title="Resources">
<SimpleTable
columns={[
{ label: 'Name', getter: (row: Result) => row.Name },
{ label: 'Kind', getter: (row: Result) => row.Kind },
{
label: 'Pass',
getter: (row: Result) => (
<StatusLabel status="success">{getResourceCounts(row).pass}</StatusLabel>
),
},
{
label: 'Warning',
getter: (row: Result) => (
<StatusLabel status="warning">{getResourceCounts(row).warning}</StatusLabel>
),
},
{
label: 'Danger',
getter: (row: Result) => (
<StatusLabel status="error">{getResourceCounts(row).danger}</StatusLabel>
),
},
]}
data={results}
emptyMessage={`No resources found in namespace "${namespace}".`}
/>
</SectionBox>
</div>
</>
);
}
export default function NamespacesListView() {
const location = useLocation();
const history = useHistory();
const { data, loading, error } = usePolarisDataContext();
// Initialize from URL hash
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(
location.hash.slice(1) || null
);
// Sync drawer state when URL hash changes (browser back/forward)
useEffect(() => {
const hashNs = location.hash.slice(1);
setSelectedNamespace(hashNs || null);
}, [location.hash]);
const openNamespace = (ns: string) => {
setSelectedNamespace(ns);
history.push(`${location.pathname}#${ns}`);
};
const closeNamespace = () => {
setSelectedNamespace(null);
history.push(location.pathname);
};
// Handle keyboard navigation (Escape key closes drawer)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && selectedNamespace) {
closeNamespace();
}
};
if (selectedNamespace) {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedNamespace]);
if (loading) {
return <Loader title="Loading Polaris audit data..." />;
}
if (error) {
return (
<>
<SectionHeader title="Polaris — Namespaces" />
<SectionBox title="Error">
<NameValueTable
rows={[
{
name: 'Status',
value: <StatusLabel status="error">{error}</StatusLabel>,
},
]}
/>
</SectionBox>
</>
);
}
if (!data) {
return (
<>
<SectionHeader title="Polaris — Namespaces" />
<SectionBox title="No Data">
<NameValueTable rows={[{ name: 'Status', value: 'No Polaris audit results found.' }]} />
</SectionBox>
</>
);
}
const namespaces = getNamespaces(data);
const rows: NamespaceRow[] = namespaces.map(ns => {
const results = filterResultsByNamespace(data, ns);
const counts = countResultsForItems(results);
const score = computeScore(counts);
return {
namespace: ns,
score,
pass: counts.pass,
warning: counts.warning,
danger: counts.danger,
skipped: counts.skipped,
};
});
return (
<>
<SectionHeader title="Polaris — Namespaces" />
<SectionBox>
<SimpleTable
columns={[
{
label: 'Namespace',
getter: (row: NamespaceRow) => (
<button
onClick={() => openNamespace(row.namespace)}
style={{
border: 'none',
background: 'transparent',
color: 'var(--link-color, #1976d2)',
cursor: 'pointer',
textDecoration: 'underline',
padding: 0,
font: 'inherit',
}}
>
{row.namespace}
</button>
),
},
{
label: 'Score',
getter: (row: NamespaceRow) => (
<StatusLabel status={scoreStatus(row.score)}>{row.score}%</StatusLabel>
),
},
{
label: 'Pass',
getter: (row: NamespaceRow) => <StatusLabel status="success">{row.pass}</StatusLabel>,
},
{
label: 'Warning',
getter: (row: NamespaceRow) => (
<StatusLabel status="warning">{row.warning}</StatusLabel>
),
},
{
label: 'Danger',
getter: (row: NamespaceRow) => <StatusLabel status="error">{row.danger}</StatusLabel>,
},
{
label: 'Skipped',
getter: (row: NamespaceRow) => String(row.skipped),
},
]}
data={rows}
emptyMessage="No namespaces found in Polaris audit data."
/>
</SectionBox>
{selectedNamespace && (
<>
<div
onClick={closeNamespace}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 1100,
}}
aria-label="Close panel backdrop"
/>
<NamespaceDetailPanel namespace={selectedNamespace} onClose={closeNamespace} />
</>
)}
</>
);
}