WIP: Dashboard redesign — design system overhaul and component updates
Frontend redesign in progress: updated styles, layout, and components across all pages to align with new design system. Includes Jira API compliance specs, property tests, and load test script.
This commit is contained in:
@@ -0,0 +1,618 @@
|
||||
// CompPrimitives.jsx — primitives for the Compliance page kit.
|
||||
// Lifted directly from frontend/src/components/pages/CompliancePage.js.
|
||||
// Identity color is teal (#14B8A6); status colors map green/amber/red onto
|
||||
// "Meets/Exceeds Target", "Within 15% of Target", and "Below 15% of Target".
|
||||
|
||||
const { useState: useCompState, useRef: useCompRef } = React;
|
||||
|
||||
/* ── Tokens ──────────────────────────────────────────────────────
|
||||
Two layers:
|
||||
• Status — drives every percentage display + the worst-status
|
||||
ribbon on metric cards. Always one of three.
|
||||
• Category — owns the colored MetricBadge that flags which
|
||||
program a failing metric belongs to. */
|
||||
const C_COLORS = {
|
||||
teal: '#14B8A6',
|
||||
tealMid: '#5EEAD4',
|
||||
green: '#10B981',
|
||||
amber: '#F59E0B',
|
||||
red: '#EF4444',
|
||||
sky: '#0EA5E9',
|
||||
purple: '#8B5CF6',
|
||||
orange: '#F97316',
|
||||
slate: '#64748B',
|
||||
};
|
||||
|
||||
const STATUS_COLOR = {
|
||||
'Meets/Exceeds Target': C_COLORS.green,
|
||||
'Within 15% of Target': C_COLORS.amber,
|
||||
'Below 15% of Target': C_COLORS.red,
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS = {
|
||||
'Vulnerability Management': C_COLORS.red,
|
||||
'Access & MFA': C_COLORS.amber,
|
||||
'Logging & Monitoring': C_COLORS.purple,
|
||||
'End-of-Life OS': C_COLORS.orange,
|
||||
'Decommissioned Assets': C_COLORS.slate,
|
||||
'Asset Data Quality': C_COLORS.slate,
|
||||
'Application Security': C_COLORS.sky,
|
||||
'Disaster Recovery': C_COLORS.teal,
|
||||
'Endpoint Protection': C_COLORS.orange,
|
||||
};
|
||||
|
||||
const statusColor = s => STATUS_COLOR[s] || C_COLORS.red;
|
||||
const pctDisplay = p => `${Math.round(p * 100)}%`;
|
||||
const cAlpha = (hex, a) => {
|
||||
const h = hex.replace('#', '');
|
||||
return `rgba(${parseInt(h.slice(0,2),16)},${parseInt(h.slice(2,4),16)},${parseInt(h.slice(4,6),16)},${a})`;
|
||||
};
|
||||
|
||||
/* ── PageHeader ──────────────────────────────────────────────────
|
||||
AEO Compliance — title in teal w/ glow, last-report meta beneath,
|
||||
refresh + upload-report on the right. Mirrors the KB / Reporting
|
||||
header pattern but with teal instead of green. */
|
||||
function CompPageHeader({ title = 'AEO Compliance', lastReport, networkScore, verticalScore, onRefresh, onUpload, onRollback, isAdmin }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24, gap: 16 }}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
margin: '0 0 6px 0',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 24, fontWeight: 700,
|
||||
color: C_COLORS.teal, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
textShadow: `0 0 16px ${cAlpha(C_COLORS.teal, 0.4)}`,
|
||||
}}>{title}</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||
{lastReport ? (
|
||||
<>
|
||||
<span style={{ color: 'var(--fg-disabled)' }}>
|
||||
Last report: <span style={{ color: 'var(--fg-2)' }}>{lastReport}</span>
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<button onClick={onRollback} style={{
|
||||
background: 'transparent', border: '1px solid rgba(239,68,68,0.25)',
|
||||
borderRadius: 4, padding: '2px 6px', cursor: 'pointer',
|
||||
color: 'var(--fg-2)', display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 10, fontFamily: 'var(--font-mono)',
|
||||
}}>
|
||||
<CompIcon name="rotate" size={10} color="currentColor" /> Rollback
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: 'var(--fg-disabled)' }}>No reports uploaded</span>
|
||||
)}
|
||||
{networkScore != null && (
|
||||
<span style={{ color: 'var(--fg-2)' }}>Network: <span style={{ color: C_COLORS.teal }}>{networkScore}</span></span>
|
||||
)}
|
||||
{verticalScore != null && (
|
||||
<span style={{ color: 'var(--fg-2)' }}>Vertical: <span style={{ color: C_COLORS.teal }}>{verticalScore}</span></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<CompIconButton icon="refresh" onClick={onRefresh} />
|
||||
<CompButton variant="primary" icon="upload" onClick={onUpload}>Upload Report</CompButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Buttons ───────────────────────────────────────────────────── */
|
||||
function CompButton({ variant = 'neutral', icon, size = 'md', children, ...rest }) {
|
||||
const [hover, setHover] = useCompState(false);
|
||||
const v = {
|
||||
primary: { bg: hover ? cAlpha(C_COLORS.teal, 0.28) : cAlpha(C_COLORS.teal, 0.18), bd: C_COLORS.teal, fg: C_COLORS.teal },
|
||||
neutral: { bg: hover ? cAlpha(C_COLORS.teal, 0.10) : 'transparent', bd: cAlpha(C_COLORS.teal, 0.30), fg: C_COLORS.teal },
|
||||
danger: { bg: hover ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.10)', bd: C_COLORS.red, fg: C_COLORS.red },
|
||||
ghost: { bg: hover ? 'rgba(255,255,255,0.04)' : 'transparent', bd: 'rgba(100,116,139,0.40)', fg: 'var(--fg-2)' },
|
||||
}[variant];
|
||||
const padX = size === 'sm' ? 10 : 16;
|
||||
const padY = size === 'sm' ? 4 : 8;
|
||||
const fs = size === 'sm' ? 11 : 12;
|
||||
return (
|
||||
<button
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: `${padY}px ${padX}px`, borderRadius: 6,
|
||||
background: v.bg, border: `1px solid ${v.bd}`, color: v.fg,
|
||||
fontFamily: 'var(--font-mono)', fontSize: fs, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
cursor: 'pointer', transition: 'all 160ms ease', whiteSpace: 'nowrap',
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{icon && <CompIcon name={icon} size={fs + 2} color={v.fg} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CompIconButton({ icon, onClick, color = C_COLORS.teal }) {
|
||||
const [hover, setHover] = useCompState(false);
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
background: hover ? cAlpha(color, 0.10) : 'transparent',
|
||||
border: `1px solid ${hover ? color : cAlpha(color, 0.25)}`,
|
||||
borderRadius: 6, padding: 8, cursor: 'pointer',
|
||||
color: hover ? color : 'var(--fg-2)',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 160ms ease',
|
||||
}}>
|
||||
<CompIcon name={icon} size={16} color="currentColor" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── TeamTabs ──────────────────────────────────────────────────── */
|
||||
function TeamTabs({ teams, active, onChange }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 24 }}>
|
||||
{teams.map(team => {
|
||||
const on = active === team;
|
||||
return (
|
||||
<button key={team} onClick={() => onChange(team)} style={{
|
||||
padding: '8px 18px', cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${on ? C_COLORS.teal : cAlpha(C_COLORS.teal, 0.20)}`,
|
||||
background: on ? cAlpha(C_COLORS.teal, 0.18) : 'transparent',
|
||||
color: on ? C_COLORS.teal : 'var(--fg-disabled)',
|
||||
transition: 'all 160ms ease',
|
||||
}}>{team}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── VariantPill ─────────────────────────────────────────────────
|
||||
The compliance % pill that lives inside MetricHealthCard. One per
|
||||
priority/variant within a metric family. Dot only shown when the
|
||||
variant isn't already meeting target — green pills stay quiet. */
|
||||
function VariantPill({ status, pct, label }) {
|
||||
const color = statusColor(status);
|
||||
const isOk = status === 'Meets/Exceeds Target';
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 7px',
|
||||
background: cAlpha(color, 0.12),
|
||||
border: `1px solid ${cAlpha(color, 0.25)}`,
|
||||
borderRadius: 3,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
color: 'var(--fg-2)', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{!isOk && (
|
||||
<span style={{
|
||||
display: 'inline-block', width: 4, height: 4, borderRadius: '50%',
|
||||
background: color, boxShadow: `0 0 5px ${color}`,
|
||||
}} />
|
||||
)}
|
||||
{label && <span style={{ color: 'var(--fg-disabled)' }}>{label}</span>}
|
||||
<span style={{ color, fontWeight: 600 }}>{pctDisplay(pct)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── StatusRibbon ────────────────────────────────────────────────
|
||||
The lozenge at the bottom of MetricHealthCard. "OK" when meeting,
|
||||
abbreviated status text otherwise. */
|
||||
function StatusRibbon({ status }) {
|
||||
const color = statusColor(status);
|
||||
const isOk = status === 'Meets/Exceeds Target';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
color, padding: '3px 9px',
|
||||
background: cAlpha(color, 0.10),
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${cAlpha(color, 0.30)}`,
|
||||
}}>
|
||||
<span style={{
|
||||
display: 'inline-block', width: 5, height: 5, borderRadius: '50%',
|
||||
background: color, boxShadow: isOk ? 'none' : `0 0 6px ${color}`,
|
||||
}} />
|
||||
{isOk ? 'OK' : status.replace(' of Target', '')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── MetricHealthCard ────────────────────────────────────────────
|
||||
The big clickable cards in the metric strip. Click to filter the
|
||||
device table; click the info "i" to open the metric definition
|
||||
panel. Border + ID color shift when active. */
|
||||
function MetricHealthCard({ family, active, onClick, onInfoClick, onHover, onLeave }) {
|
||||
const [h, setH] = useCompState(false);
|
||||
const color = statusColor(family.worstStatus);
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
onMouseEnter={(e) => { setH(true); onHover && onHover(e.currentTarget); }}
|
||||
onMouseLeave={() => { setH(false); onLeave && onLeave(); }}
|
||||
style={{
|
||||
position: 'relative', textAlign: 'left', cursor: 'pointer',
|
||||
background: active
|
||||
? cAlpha(color, 0.15)
|
||||
: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
|
||||
border: `1.5px solid ${active ? color : (h ? cAlpha(color, 0.50) : cAlpha(color, 0.25))}`,
|
||||
borderRadius: 8,
|
||||
padding: '14px 16px',
|
||||
minWidth: 160, flex: '1 1 0',
|
||||
transition: 'all 160ms ease',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); onInfoClick && onInfoClick(family.metricId); }}
|
||||
style={{
|
||||
position: 'absolute', top: 8, right: 8,
|
||||
display: 'inline-flex', cursor: 'pointer', padding: 2,
|
||||
color: 'var(--fg-disabled)', borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<CompIcon name="info" size={13} color="currentColor" />
|
||||
</span>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 15, fontWeight: 700,
|
||||
color: active ? color : 'var(--fg-1)', marginBottom: 4, paddingRight: 20,
|
||||
}}>{family.metricId}</div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
color: 'var(--fg-disabled)', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
marginBottom: 8,
|
||||
}}>{family.category}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 8 }}>
|
||||
{family.entries.map((e, i) => (
|
||||
<VariantPill
|
||||
key={e.metric_id + '-' + i}
|
||||
status={e.status} pct={e.compliance_pct}
|
||||
label={family.entries.length > 1 ? (e.priority || `#${i + 1}`) : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
color: 'var(--fg-disabled)', marginBottom: 8,
|
||||
}}>target {pctDisplay(family.target)}</div>
|
||||
<StatusRibbon status={family.worstStatus} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── MetricBadge ─────────────────────────────────────────────────
|
||||
Compact category-tinted ID chip used in device-row "Failing Metrics"
|
||||
columns and inside detail panels. */
|
||||
function MetricBadge({ metricId, category }) {
|
||||
const color = CATEGORY_COLORS[category] || C_COLORS.slate;
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
padding: '2px 7px',
|
||||
background: cAlpha(color, 0.12),
|
||||
border: `1px solid ${cAlpha(color, 0.30)}`,
|
||||
borderRadius: 3, color,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>{metricId}</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── SeenBadge ───────────────────────────────────────────────────
|
||||
"1×" / "3×" / "5×" — how many cycles a host has been failing the
|
||||
same set of metrics. Color escalates: slate → amber → red. */
|
||||
function SeenBadge({ count }) {
|
||||
const color = count > 3 ? C_COLORS.red : count > 1 ? C_COLORS.amber : C_COLORS.slate;
|
||||
return (
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
|
||||
color, padding: '2px 7px',
|
||||
background: cAlpha(color, 0.10),
|
||||
border: `1px solid ${cAlpha(color, 0.30)}`,
|
||||
borderRadius: 3, whiteSpace: 'nowrap',
|
||||
}}>{count}×</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── DeviceTable + DeviceRow ─────────────────────────────────────
|
||||
The non-compliant host list. Toolbar has Active/Resolved tabs +
|
||||
hostname search. Rows show hostname, IP, type, failing metric
|
||||
badges, seen count, and a notes indicator. */
|
||||
function DeviceTable({ children }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
|
||||
border: '1px solid rgba(20,184,166,0.15)',
|
||||
borderRadius: 8, overflow: 'hidden',
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceTableToolbar({ tab, onTabChange, count, search, onSearchChange }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '14px 16px', borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{['active', 'resolved'].map(t => {
|
||||
const on = tab === t;
|
||||
return (
|
||||
<button key={t} onClick={() => onTabChange(t)} style={{
|
||||
padding: '6px 14px', cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${on ? cAlpha(C_COLORS.teal, 0.40) : 'transparent'}`,
|
||||
background: on ? cAlpha(C_COLORS.teal, 0.10) : 'transparent',
|
||||
color: on ? C_COLORS.teal : 'var(--fg-disabled)',
|
||||
}}>
|
||||
{t}
|
||||
{on && <span style={{ marginLeft: 6, color: 'var(--fg-2)' }}>({count})</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<CompSearchInput value={search} onChange={onSearchChange} placeholder="Search hostname…" width={220} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompSearchInput({ value, onChange, placeholder, width = 240 }) {
|
||||
const [focus, setFocus] = useCompState(false);
|
||||
return (
|
||||
<input
|
||||
value={value} onChange={onChange} placeholder={placeholder}
|
||||
onFocus={() => setFocus(true)} onBlur={() => setFocus(false)}
|
||||
style={{
|
||||
background: 'rgba(15,23,42,0.85)',
|
||||
border: `1px solid ${focus ? cAlpha(C_COLORS.teal, 0.60) : cAlpha(C_COLORS.teal, 0.20)}`,
|
||||
borderRadius: 4, color: 'var(--fg-1)', outline: 'none',
|
||||
padding: '6px 12px', fontSize: 12, fontFamily: 'var(--font-mono)',
|
||||
width, transition: 'border-color 160ms ease',
|
||||
boxShadow: focus ? `0 0 0 3px ${cAlpha(C_COLORS.teal, 0.10)}` : 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const DEVICE_GRID = '2.5fr 1.1fr 1fr 2fr 0.5fr 0.4fr';
|
||||
|
||||
function DeviceTableHeader() {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: DEVICE_GRID,
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600,
|
||||
color: 'var(--fg-disabled)', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
}}>
|
||||
<span>Hostname</span><span>IP Address</span><span>Type</span>
|
||||
<span>Failing Metrics</span><span>Seen</span><span></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceRow({ hostname, ip, type, failingMetrics, seenCount, hasNotes, selected, onClick }) {
|
||||
const [hover, setHover] = useCompState(false);
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: 'grid', gridTemplateColumns: DEVICE_GRID,
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
cursor: 'pointer',
|
||||
background: selected ? cAlpha(C_COLORS.teal, 0.08) : (hover ? 'rgba(255,255,255,0.025)' : 'transparent'),
|
||||
borderLeft: selected ? `2px solid ${C_COLORS.teal}` : '2px solid transparent',
|
||||
transition: 'all 160ms ease', alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
color: selected ? C_COLORS.teal : 'var(--fg-1)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>{hostname}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-2)' }}>{ip || '—'}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--fg-disabled)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{type || '—'}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{failingMetrics.map(m => <MetricBadge key={m.metric_id} metricId={m.metric_id} category={m.category} />)}
|
||||
</div>
|
||||
<div><SeenBadge count={seenCount} /></div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{hasNotes && <CompIcon name="message" size={13} color={cAlpha(C_COLORS.teal, 0.7)} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── EmptyState — for table body when there's nothing to show. ── */
|
||||
function CompEmpty({ children }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: 48, textAlign: 'center',
|
||||
color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
}}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── ChartCard — wrapper around any of the 6 charts on the page. ── */
|
||||
function ChartCard({ title, subtitle, children, height = 240 }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
|
||||
border: '1px solid rgba(20,184,166,0.15)',
|
||||
borderRadius: 8, padding: 16,
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--fg-1)', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
marginBottom: subtitle ? 2 : 12,
|
||||
}}>{title}</div>
|
||||
{subtitle && (
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
color: 'var(--fg-disabled)', marginBottom: 12,
|
||||
}}>{subtitle}</div>
|
||||
)}
|
||||
<div style={{ height }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── ChartLegend — shared legend row used at the top of stacked charts. ── */
|
||||
function ChartLegend({ items }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 8 }}>
|
||||
{items.map(it => (
|
||||
<span key={it.label} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg-2)',
|
||||
}}>
|
||||
<span style={{
|
||||
display: 'inline-block', width: 10, height: 10, borderRadius: 2,
|
||||
background: it.color,
|
||||
}} />
|
||||
{it.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── DefinitionTooltip ───────────────────────────────────────────
|
||||
The hover popover that surfaces a metric's title + business
|
||||
justification + data sources. */
|
||||
function DefinitionTooltip({ title, justification, sources }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: 300,
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||
border: `1px solid ${cAlpha(C_COLORS.teal, 0.30)}`,
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
|
||||
padding: '12px 14px',
|
||||
}}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 700, color: 'var(--fg-1)', marginBottom: 6, lineHeight: 1.3 }}>{title}</div>
|
||||
{justification && (
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 12, color: 'var(--fg-2)', lineHeight: 1.4, marginBottom: 6 }}>{justification}</div>
|
||||
)}
|
||||
{sources && (
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg-disabled)' }}>Sources: {sources}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── RollbackDialog ──────────────────────────────────────────────
|
||||
Centered modal w/ red identity. "Reverses the most recent upload"
|
||||
message + danger confirm. */
|
||||
function RollbackDialog({ reportLabel, onCancel, onConfirm, loading }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 60,
|
||||
background: 'rgba(10,14,39,0.92)', backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||
border: '1px solid rgba(239,68,68,0.30)', borderRadius: 12,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.7)',
|
||||
width: '100%', maxWidth: 420, padding: 28,
|
||||
}}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 15, fontWeight: 700, color: C_COLORS.red, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 12 }}>
|
||||
Rollback Upload
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--fg-1)', lineHeight: 1.5, marginBottom: 8, fontFamily: 'var(--font-display)' }}>
|
||||
This will reverse the most recent upload:
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg-2)',
|
||||
background: 'rgba(15,23,42,0.6)', borderRadius: 6,
|
||||
padding: '10px 12px', marginBottom: 18,
|
||||
border: '1px solid rgba(239,68,68,0.15)',
|
||||
}}>
|
||||
<div><span style={{ color: 'var(--fg-disabled)' }}>File:</span> {reportLabel}</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, color: 'var(--fg-disabled)' }}>
|
||||
New items will be deleted, resolved items will be reactivated, and the upload record will be removed.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<CompButton variant="ghost" onClick={onCancel} style={{ flex: 1, justifyContent: 'center' }}>Cancel</CompButton>
|
||||
<button onClick={onConfirm} disabled={loading} style={{
|
||||
flex: 2, padding: 10,
|
||||
background: loading ? 'rgba(239,68,68,0.05)' : 'rgba(239,68,68,0.10)',
|
||||
border: `1px solid ${C_COLORS.red}`, borderRadius: 6,
|
||||
color: C_COLORS.red, cursor: loading ? 'wait' : 'pointer',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
opacity: loading ? 0.6 : 1,
|
||||
}}>
|
||||
<CompIcon name="rotate" size={13} color="currentColor" />
|
||||
{loading ? 'Rolling back…' : 'Confirm Rollback'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── RollbackToast — bottom-right confirmation/error toast. ── */
|
||||
function RollbackToast({ tone = 'success', message, detail, onDismiss }) {
|
||||
const c = tone === 'error' ? C_COLORS.red : C_COLORS.green;
|
||||
return (
|
||||
<div onClick={onDismiss} style={{
|
||||
position: 'absolute', bottom: 24, right: 24, zIndex: 70,
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||
border: `1px solid ${cAlpha(c, 0.40)}`, borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
|
||||
padding: '14px 20px', maxWidth: 360,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, color: c, cursor: 'pointer',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: detail ? 4 : 0 }}>
|
||||
<CompIcon name={tone === 'error' ? 'alert' : 'rotate'} size={14} color="currentColor" />
|
||||
{message}
|
||||
</div>
|
||||
{detail && <div style={{ fontSize: 10, color: 'var(--fg-2)' }}>{detail}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── CompIcon — every icon used by the compliance page. ── */
|
||||
function CompIcon({ name, size = 16, color = 'currentColor' }) {
|
||||
const p = {
|
||||
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
|
||||
stroke: color, strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||
style: { display: 'inline-block', verticalAlign: 'middle' },
|
||||
};
|
||||
switch (name) {
|
||||
case 'upload': return <svg {...p}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
|
||||
case 'refresh': return <svg {...p}><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/><path d="M3 21v-5h5"/></svg>;
|
||||
case 'rotate': return <svg {...p}><path d="M3 12a9 9 0 1 0 9-9"/><polyline points="3 4 3 9 8 9"/></svg>;
|
||||
case 'message': return <svg {...p}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>;
|
||||
case 'info': return <svg {...p}><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>;
|
||||
case 'alert': return <svg {...p}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>;
|
||||
case 'check': return <svg {...p}><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>;
|
||||
case 'loader': return <svg {...p}><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"/><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"/></svg>;
|
||||
case 'x': return <svg {...p}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>;
|
||||
default: return <svg {...p}><circle cx="12" cy="12" r="10"/></svg>;
|
||||
}
|
||||
}
|
||||
|
||||
window.COMP = {
|
||||
COLORS: C_COLORS, STATUS_COLOR, CATEGORY_COLORS,
|
||||
statusColor, pctDisplay, cAlpha,
|
||||
CompPageHeader, CompButton, CompIconButton, TeamTabs,
|
||||
VariantPill, StatusRibbon, MetricHealthCard, MetricBadge, SeenBadge,
|
||||
DeviceTable, DeviceTableToolbar, DeviceTableHeader, DeviceRow, CompSearchInput, CompEmpty,
|
||||
ChartCard, ChartLegend,
|
||||
DefinitionTooltip, RollbackDialog, RollbackToast,
|
||||
CompIcon,
|
||||
};
|
||||
Reference in New Issue
Block a user