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:
@@ -27,7 +27,7 @@
|
||||
<title>CVE Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/* Tactical Intelligence Dashboard Styles */
|
||||
/* IMPORTANT: This file MUST be imported in App.js */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||||
|
||||
* {
|
||||
font-family: 'Outfit', system-ui, sans-serif;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
/* Pulse animation for glowing dots - used by inline styles */
|
||||
@@ -18,21 +20,179 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Base Colors - Modern Slate Foundation */
|
||||
--intel-darkest: #0F172A;
|
||||
--intel-dark: #1E293B;
|
||||
--intel-medium: #334155;
|
||||
--intel-accent: #0EA5E9; /* Sky Blue - professional cyan */
|
||||
--intel-warning: #F59E0B; /* Amber - sophisticated warning */
|
||||
--intel-danger: #EF4444; /* Modern Red - urgent but refined */
|
||||
--intel-success: #10B981; /* Emerald - professional green */
|
||||
--intel-grid: rgba(14, 165, 233, 0.08);
|
||||
/* ── Color · Surfaces (modern slate foundation) ─────────────── */
|
||||
--intel-darkest: #0F172A; /* page background */
|
||||
--intel-dark: #1E293B; /* card / panel surface */
|
||||
--intel-medium: #334155; /* elevated surface, hover row */
|
||||
--intel-light: #475569; /* muted border, disabled chip */
|
||||
--intel-accent: #0EA5E9; /* Sky Blue - professional cyan */
|
||||
--intel-warning: #F59E0B; /* Amber - sophisticated warning */
|
||||
--intel-danger: #EF4444; /* Modern Red - urgent but refined */
|
||||
--intel-success: #10B981; /* Emerald - professional green */
|
||||
--intel-grid: rgba(14, 165, 233, 0.08); /* grid backdrop */
|
||||
|
||||
/* Text Colors with proper contrast */
|
||||
--text-primary: #F8FAFC;
|
||||
--text-secondary: #E2E8F0;
|
||||
--text-tertiary: #CBD5E1;
|
||||
--text-muted: #94A3B8;
|
||||
/* Surface aliases — friendlier names */
|
||||
--bg-page: var(--intel-darkest);
|
||||
--bg-surface: var(--intel-dark);
|
||||
--bg-elevated: var(--intel-medium);
|
||||
--bg-hover: var(--intel-light);
|
||||
--bg-input: rgba(30, 41, 59, 0.6);
|
||||
--bg-overlay: rgba(10, 14, 39, 0.97);
|
||||
|
||||
/* ── Color · Foreground ─────────────────────────────────────── */
|
||||
--text-primary: #F8FAFC;
|
||||
--text-secondary: #E2E8F0;
|
||||
--text-tertiary: #CBD5E1;
|
||||
--text-muted: #94A3B8;
|
||||
--text-disabled: #64748B;
|
||||
--text-faint: #475569;
|
||||
--text-on-accent: #0F172A;
|
||||
|
||||
/* Foreground aliases */
|
||||
--fg-1: var(--text-primary);
|
||||
--fg-2: var(--text-secondary);
|
||||
--fg-3: var(--text-tertiary);
|
||||
--fg-muted: var(--text-muted);
|
||||
--fg-disabled: var(--text-disabled);
|
||||
--fg-on-accent: var(--text-on-accent);
|
||||
|
||||
/* ── Color · Borders ────────────────────────────────────────── */
|
||||
--border-subtle: rgba(14, 165, 233, 0.15);
|
||||
--border-default: rgba(14, 165, 233, 0.25);
|
||||
--border-strong: rgba(14, 165, 233, 0.40);
|
||||
--border-focus: #0EA5E9;
|
||||
|
||||
--border-1: var(--border-subtle);
|
||||
--border-2: var(--border-default);
|
||||
--border-3: var(--border-strong);
|
||||
|
||||
/* ── Color · Brand accent (sky blue — primary signal) ───────── */
|
||||
--intel-accent-bright: #38BDF8; /* sky-400 — text on dark */
|
||||
--intel-accent-soft: #7DD3FC; /* sky-300 */
|
||||
--intel-accent-15: rgba(14, 165, 233, 0.15);
|
||||
--intel-accent-08: rgba(14, 165, 233, 0.08);
|
||||
|
||||
--accent: var(--intel-accent);
|
||||
--accent-bright: var(--intel-accent-bright);
|
||||
--accent-soft: var(--intel-accent-soft);
|
||||
--accent-wash: var(--intel-accent-08);
|
||||
--accent-hover: #0284C7; /* sky-600 — pressed/hover for filled buttons */
|
||||
|
||||
/* ── Color · Semantic / severity (FIXED — never remap) ──────── */
|
||||
--intel-info: #0EA5E9; /* Medium · Info · Standard */
|
||||
|
||||
--sev-critical: var(--intel-danger);
|
||||
--sev-high: var(--intel-warning);
|
||||
--sev-medium: var(--intel-info);
|
||||
--sev-low: var(--intel-success);
|
||||
|
||||
/* Severity text-on-dark (lighter; better contrast) */
|
||||
--sev-critical-text: #FCA5A5;
|
||||
--sev-high-text: #FCD34D;
|
||||
--sev-medium-text: #7DD3FC;
|
||||
--sev-low-text: #6EE7B7;
|
||||
|
||||
/* Severity fills */
|
||||
--sev-critical-bg: rgba(239, 68, 68, 0.20);
|
||||
--sev-high-bg: rgba(245, 158, 11, 0.20);
|
||||
--sev-medium-bg: rgba(14, 165, 233, 0.20);
|
||||
--sev-low-bg: rgba(16, 185, 129, 0.20);
|
||||
|
||||
/* ── Color · Group badges ───────────────────────────────────── */
|
||||
--group-admin: #EF4444;
|
||||
--group-standard: #38BDF8;
|
||||
--group-leadership: #F59E0B;
|
||||
--group-readonly: #94A3B8;
|
||||
|
||||
/* ── Type · Families ────────────────────────────────────────── */
|
||||
--font-ui: 'Outfit', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
|
||||
/* ── Type · Scale ───────────────────────────────────────────── */
|
||||
--fs-display: 28px;
|
||||
--fs-h1: 24px;
|
||||
--fs-h2: 18px;
|
||||
--fs-h3: 16px;
|
||||
--fs-body: 14px;
|
||||
--fs-sm: 13px;
|
||||
--fs-xs: 12px;
|
||||
--fs-tiny: 11px;
|
||||
|
||||
--lh-tight: 1.2;
|
||||
--lh-normal: 1.4;
|
||||
--lh-loose: 1.6;
|
||||
|
||||
--fw-regular: 400;
|
||||
--fw-medium: 500;
|
||||
--fw-semibold: 600;
|
||||
--fw-bold: 700;
|
||||
|
||||
--tracking-wide: 0.05em; /* mono buttons, badges */
|
||||
--tracking-wider: 0.10em; /* uppercase headings */
|
||||
|
||||
/* ── Spacing (4-px grid) ────────────────────────────────────── */
|
||||
--sp-1: 4px;
|
||||
--sp-2: 8px;
|
||||
--sp-3: 12px;
|
||||
--sp-4: 16px;
|
||||
--sp-5: 20px;
|
||||
--sp-6: 24px;
|
||||
--sp-8: 32px;
|
||||
--sp-10: 40px;
|
||||
--sp-12: 48px;
|
||||
|
||||
/* ── Radii ──────────────────────────────────────────────────── */
|
||||
--r-xs: 3px;
|
||||
--r-sm: 4px;
|
||||
--r-md: 6px;
|
||||
--r-lg: 8px;
|
||||
--r-xl: 12px;
|
||||
--r-pill: 999px;
|
||||
|
||||
/* ── Elevation (with sky-blue inner highlight) ──────────────── */
|
||||
--shadow-rest: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.10);
|
||||
--shadow-card-hover: 0 8px 24px rgba(14, 165, 233, 0.15),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.20),
|
||||
0 0 30px rgba(14, 165, 233, 0.10);
|
||||
--shadow-popover: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.6),
|
||||
0 10px 30px rgba(14, 165, 233, 0.10);
|
||||
--shadow-focus: 0 0 0 2px rgba(14, 165, 233, 0.15);
|
||||
|
||||
/* Severity glow (used by status badge dots) */
|
||||
--glow-danger: 0 0 12px rgba(239, 68, 68, 0.6),
|
||||
0 0 6px rgba(239, 68, 68, 0.4);
|
||||
--glow-warning: 0 0 12px rgba(245, 158, 11, 0.6),
|
||||
0 0 6px rgba(245, 158, 11, 0.4);
|
||||
--glow-info: 0 0 12px rgba(14, 165, 233, 0.6),
|
||||
0 0 6px rgba(14, 165, 233, 0.4);
|
||||
--glow-success: 0 0 12px rgba(16, 185, 129, 0.6),
|
||||
0 0 6px rgba(16, 185, 129, 0.4);
|
||||
|
||||
/* Heading text-shadow glow */
|
||||
--glow-heading: 0 0 16px rgba(14, 165, 233, 0.30),
|
||||
0 0 32px rgba(14, 165, 233, 0.15);
|
||||
|
||||
/* ── Motion ─────────────────────────────────────────────────── */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--dur-fast: 150ms;
|
||||
--dur-med: 200ms;
|
||||
--dur-slow: 300ms;
|
||||
|
||||
/* ── Layout ─────────────────────────────────────────────────── */
|
||||
--topbar-h: 64px;
|
||||
--drawer-w: 240px;
|
||||
--panel-w: 480px;
|
||||
--content-max: 1600px;
|
||||
--z-topbar: 50;
|
||||
--z-drawer: 60;
|
||||
--z-modal: 100;
|
||||
--z-tooltip: 120;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -92,6 +252,65 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Semantic type ───────────────────────────────────────────── */
|
||||
.t-display {
|
||||
font: var(--fw-bold) var(--fs-display)/var(--lh-tight) var(--font-mono);
|
||||
color: var(--intel-accent-bright);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-shadow: var(--glow-heading);
|
||||
}
|
||||
.t-h1 {
|
||||
font: var(--fw-bold) var(--fs-h1)/var(--lh-tight) var(--font-mono);
|
||||
color: var(--intel-accent-bright);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
.t-h2 {
|
||||
font: var(--fw-semibold) var(--fs-h2)/var(--lh-tight) var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.t-h3 {
|
||||
font: var(--fw-semibold) var(--fs-h3)/var(--lh-normal) var(--font-ui);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.t-body {
|
||||
font: var(--fw-regular) var(--fs-body)/var(--lh-normal) var(--font-ui);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.t-sm {
|
||||
font: var(--fw-regular) var(--fs-sm)/var(--lh-normal) var(--font-ui);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.t-meta {
|
||||
font: var(--fw-regular) var(--fs-xs)/var(--lh-normal) var(--font-ui);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.t-label {
|
||||
font: var(--fw-medium) var(--fs-xs)/var(--lh-normal) var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.t-mono {
|
||||
font: var(--fw-regular) var(--fs-sm)/var(--lh-normal) var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.t-mono-sm {
|
||||
font: var(--fw-regular) var(--fs-xs)/var(--lh-normal) var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.t-code {
|
||||
font: var(--fw-medium) var(--fs-sm)/var(--lh-normal) var(--font-mono);
|
||||
color: var(--intel-success);
|
||||
background: var(--intel-darkest);
|
||||
border: 1px solid var(--border-default);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
/* Scanning line animation */
|
||||
.scan-line {
|
||||
position: absolute;
|
||||
@@ -123,14 +342,11 @@ body {
|
||||
.intel-card {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%);
|
||||
border: 1.5px solid rgba(14, 165, 233, 0.3);
|
||||
border-radius: var(--r-lg);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.1),
|
||||
inset 0 -1px 0 rgba(14, 165, 233, 0.05);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.intel-card::after {
|
||||
@@ -152,11 +368,7 @@ body {
|
||||
.intel-card:hover {
|
||||
border-color: rgba(14, 165, 233, 0.5);
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(14, 165, 233, 0.15),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.2),
|
||||
0 0 30px rgba(14, 165, 233, 0.1);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
|
||||
.intel-card:hover::after {
|
||||
@@ -166,13 +378,13 @@ body {
|
||||
/* Status badges with STRONG glow and contrast */
|
||||
.status-badge {
|
||||
position: relative;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 0.375rem;
|
||||
border-radius: var(--r-md);
|
||||
border: 2px solid;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -239,13 +451,13 @@ body {
|
||||
/* Button styles with depth and glow */
|
||||
.intel-button {
|
||||
position: relative;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
border-radius: var(--r-md);
|
||||
transition: all 0.3s;
|
||||
border: 1px solid;
|
||||
overflow: hidden;
|
||||
@@ -322,11 +534,11 @@ body {
|
||||
|
||||
/* Input fields with better contrast */
|
||||
.intel-input {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border: 1px solid rgba(14, 165, 233, 0.25);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-subtle);
|
||||
color: #F8FAFC;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border-radius: var(--r-md);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.3s;
|
||||
@@ -337,11 +549,8 @@ body {
|
||||
|
||||
.intel-input:focus {
|
||||
outline: none;
|
||||
border-color: #0EA5E9;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(14, 165, 233, 0.15),
|
||||
inset 0 2px 4px rgba(0, 0, 0, 0.15),
|
||||
0 4px 12px rgba(14, 165, 233, 0.1);
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: var(--shadow-focus);
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
}
|
||||
|
||||
@@ -353,25 +562,18 @@ body {
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 100%);
|
||||
border: 1.5px solid rgba(14, 165, 233, 0.35);
|
||||
border-radius: 0.5rem;
|
||||
border-radius: var(--r-lg);
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
0 2px 6px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.15);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(14, 165, 233, 0.5);
|
||||
box-shadow:
|
||||
0 8px 20px rgba(14, 165, 233, 0.15),
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(14, 165, 233, 0.2),
|
||||
0 0 24px rgba(14, 165, 233, 0.1);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
@@ -388,16 +590,20 @@ body {
|
||||
|
||||
/* Modal overlay with proper backdrop */
|
||||
.modal-overlay {
|
||||
background: rgba(10, 14, 39, 0.97);
|
||||
background: var(--bg-overlay);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Modal card enhancements */
|
||||
.intel-card.modal-card {
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.6),
|
||||
0 10px 30px rgba(0, 217, 255, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
box-shadow: var(--shadow-modal);
|
||||
}
|
||||
|
||||
/* ── Focus ───────────────────────────────────────────────────── */
|
||||
*:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@@ -407,12 +613,12 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1E293B;
|
||||
background: var(--intel-dark);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(14, 165, 233, 0.3);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@@ -447,7 +653,7 @@ body {
|
||||
|
||||
/* Data table styling */
|
||||
.data-row {
|
||||
border-bottom: 1px solid rgba(0, 217, 255, 0.1);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
@@ -535,7 +741,7 @@ body {
|
||||
/* Loading spinner */
|
||||
.loading-spinner {
|
||||
border: 2px solid rgba(14, 165, 233, 0.1);
|
||||
border-top-color: #0EA5E9;
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@@ -575,8 +781,8 @@ body {
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #F8FAFC;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--fg-1);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.4),
|
||||
0 0 16px rgba(14, 165, 233, 0.15);
|
||||
@@ -620,8 +826,8 @@ h3.text-intel-accent {
|
||||
/* Vendor Cards - nested depth */
|
||||
.vendor-card {
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.9) 100%);
|
||||
border: 1.5px solid rgba(14, 165, 233, 0.25);
|
||||
border-radius: 0.5rem;
|
||||
border: 1.5px solid var(--border-default);
|
||||
border-radius: var(--r-lg);
|
||||
padding: 1rem;
|
||||
box-shadow:
|
||||
0 3px 10px rgba(0, 0, 0, 0.4),
|
||||
@@ -641,8 +847,8 @@ h3.text-intel-accent {
|
||||
/* Document items - recessed appearance */
|
||||
.document-item {
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 1) 0%, rgba(20, 28, 48, 0.98) 100%);
|
||||
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--r-md);
|
||||
padding: 0.75rem;
|
||||
box-shadow:
|
||||
inset 0 2px 4px rgba(0, 0, 0, 0.3),
|
||||
@@ -681,7 +887,7 @@ h3.text-intel-accent {
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid rgba(14, 165, 233, 0.3);
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -692,7 +898,7 @@ h3.text-intel-accent {
|
||||
color: #10B981;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.markdown-content h3 {
|
||||
@@ -701,7 +907,7 @@ h3.text-intel-accent {
|
||||
color: #F59E0B;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.markdown-content h4,
|
||||
@@ -747,7 +953,7 @@ h3.text-intel-accent {
|
||||
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
color: #10B981;
|
||||
}
|
||||
@@ -799,7 +1005,7 @@ h3.text-intel-accent {
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
color: #0EA5E9;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.markdown-content td {
|
||||
|
||||
@@ -32,110 +32,114 @@ const STYLES = {
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// Stat cards with refined borders
|
||||
// Stat cards with Card_Surface gradient and top-edge rail
|
||||
statCard: {
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 100%)',
|
||||
border: '2px solid #0EA5E9',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '1rem',
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(14, 165, 233, 0.15), inset 0 1px 0 rgba(14, 165, 233, 0.15)',
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
|
||||
border: '1.5px solid rgba(14,165,233,0.12)',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// Intel card with refined glowing border
|
||||
// Intel card with Card_Surface gradient
|
||||
intelCard: {
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
|
||||
border: '2px solid rgba(14, 165, 233, 0.4)',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15), inset 0 1px 0 rgba(14, 165, 233, 0.12)',
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
|
||||
border: '1.5px solid rgba(14,165,233,0.12)',
|
||||
borderRadius: 8,
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// Vendor card with depth
|
||||
// Vendor card — nested Card_Surface gradient matching VendorEntry
|
||||
vendorCard: {
|
||||
background: 'linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.9) 100%)',
|
||||
border: '1.5px solid rgba(14, 165, 233, 0.3)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '1rem',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(14, 165, 233, 0.08)',
|
||||
marginBottom: '0.75rem',
|
||||
background: 'linear-gradient(135deg, rgba(15,23,42,0.95) 0%, rgba(30,41,59,0.9) 100%)',
|
||||
border: '1.5px solid rgba(14,165,233,0.30)',
|
||||
borderRadius: 6,
|
||||
padding: 16,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4), inset 0 1px 0 rgba(14,165,233,0.08)',
|
||||
marginBottom: 12,
|
||||
},
|
||||
// CRITICAL severity badge - Modern red with refined glow
|
||||
// CRITICAL severity badge — matching SeverityBadge in HomePrimitives
|
||||
badgeCritical: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
background: 'linear-gradient(135deg, rgba(239, 68, 68, 0.25) 0%, rgba(239, 68, 68, 0.2) 100%)',
|
||||
gap: 6,
|
||||
background: 'linear-gradient(135deg, rgba(239,68,68,0.25), rgba(239,68,68,0.20))',
|
||||
border: '2px solid #EF4444',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.375rem 0.875rem',
|
||||
borderRadius: 6,
|
||||
padding: '4px 10px',
|
||||
color: '#FCA5A5',
|
||||
fontWeight: '700',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
fontSize: 11,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
textShadow: '0 0 8px rgba(239, 68, 68, 0.5)',
|
||||
boxShadow: '0 0 16px rgba(239, 68, 68, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4)',
|
||||
letterSpacing: '0.05em',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
textShadow: '0 0 8px rgba(239,68,68,0.5)',
|
||||
boxShadow: '0 0 16px rgba(239,68,68,0.25), 0 4px 8px rgba(0,0,0,0.4)',
|
||||
},
|
||||
// HIGH severity badge - Amber with refined glow
|
||||
// HIGH severity badge
|
||||
badgeHigh: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
background: 'linear-gradient(135deg, rgba(245, 158, 11, 0.25) 0%, rgba(245, 158, 11, 0.2) 100%)',
|
||||
gap: 6,
|
||||
background: 'linear-gradient(135deg, rgba(245,158,11,0.25), rgba(245,158,11,0.20))',
|
||||
border: '2px solid #F59E0B',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.375rem 0.875rem',
|
||||
borderRadius: 6,
|
||||
padding: '4px 10px',
|
||||
color: '#FCD34D',
|
||||
fontWeight: '700',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
fontSize: 11,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
textShadow: '0 0 8px rgba(245, 158, 11, 0.5)',
|
||||
boxShadow: '0 0 16px rgba(245, 158, 11, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4)',
|
||||
letterSpacing: '0.05em',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
textShadow: '0 0 8px rgba(245,158,11,0.5)',
|
||||
boxShadow: '0 0 16px rgba(245,158,11,0.25), 0 4px 8px rgba(0,0,0,0.4)',
|
||||
},
|
||||
// MEDIUM severity badge - Sky blue with refined glow
|
||||
// MEDIUM severity badge
|
||||
badgeMedium: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.25) 0%, rgba(14, 165, 233, 0.2) 100%)',
|
||||
gap: 6,
|
||||
background: 'linear-gradient(135deg, rgba(14,165,233,0.25), rgba(14,165,233,0.20))',
|
||||
border: '2px solid #0EA5E9',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.375rem 0.875rem',
|
||||
borderRadius: 6,
|
||||
padding: '4px 10px',
|
||||
color: '#7DD3FC',
|
||||
fontWeight: '700',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
fontSize: 11,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
textShadow: '0 0 8px rgba(14, 165, 233, 0.5)',
|
||||
boxShadow: '0 0 16px rgba(14, 165, 233, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4)',
|
||||
letterSpacing: '0.05em',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
textShadow: '0 0 8px rgba(14,165,233,0.5)',
|
||||
boxShadow: '0 0 16px rgba(14,165,233,0.25), 0 4px 8px rgba(0,0,0,0.4)',
|
||||
},
|
||||
// LOW severity badge - Emerald with refined glow
|
||||
// LOW severity badge
|
||||
badgeLow: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.25) 0%, rgba(16, 185, 129, 0.2) 100%)',
|
||||
gap: 6,
|
||||
background: 'linear-gradient(135deg, rgba(16,185,129,0.25), rgba(16,185,129,0.20))',
|
||||
border: '2px solid #10B981',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.375rem 0.875rem',
|
||||
borderRadius: 6,
|
||||
padding: '4px 10px',
|
||||
color: '#6EE7B7',
|
||||
fontWeight: '700',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
fontSize: 11,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
textShadow: '0 0 8px rgba(16, 185, 129, 0.5)',
|
||||
boxShadow: '0 0 16px rgba(16, 185, 129, 0.3), 0 4px 8px rgba(0, 0, 0, 0.4)',
|
||||
letterSpacing: '0.05em',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
textShadow: '0 0 8px rgba(16,185,129,0.5)',
|
||||
boxShadow: '0 0 16px rgba(16,185,129,0.25), 0 4px 8px rgba(0,0,0,0.4)',
|
||||
},
|
||||
// Glowing dot for badges
|
||||
// Glowing dot for badges — matching SeverityBadge dot
|
||||
glowDot: (color) => ({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
display: 'inline-block',
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
boxShadow: `0 0 12px ${color}, 0 0 6px ${color}`,
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
boxShadow: `0 0 8px ${color}`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -955,8 +959,98 @@ export default function App() {
|
||||
|
||||
const filteredGroupedCVEs = groupedCVEs;
|
||||
|
||||
// Navigation tab definitions for the top bar
|
||||
const navTabs = [
|
||||
{ id: 'home', label: 'Home' },
|
||||
{ id: 'triage', label: 'Reporting' },
|
||||
{ id: 'compliance', label: 'Compliance' },
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base' },
|
||||
{ id: 'exports', label: 'Exports' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-intel-darkest grid-bg p-6 relative overflow-hidden fade-in">
|
||||
<div className="min-h-screen bg-intel-darkest grid-bg relative overflow-hidden fade-in" style={{ paddingTop: 'var(--topbar-h)' }}>
|
||||
{/* Top Bar */}
|
||||
<header style={{
|
||||
height: 'var(--topbar-h)', position: 'fixed', top: 0, left: 0, right: 0, zIndex: 'var(--z-topbar)',
|
||||
background: 'linear-gradient(180deg, rgba(30, 41, 59, 0.95) 0%, rgba(15, 23, 42, 0.98) 100%)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
borderBottom: '1px solid var(--border-1)',
|
||||
display: 'flex', alignItems: 'center', padding: '0 20px', gap: 16,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setNavOpen(true)}
|
||||
style={{
|
||||
background: 'transparent', border: 'none', color: 'var(--fg-2)',
|
||||
cursor: 'pointer', padding: 6, display: 'flex', alignItems: 'center',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--accent)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--fg-2)'; }}
|
||||
title="Navigation"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
{/* Brand mark — typographic stack */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Shield size={22} style={{ color: 'var(--accent)' }} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: 15, color: 'var(--fg-1)', letterSpacing: '0.02em', lineHeight: 1 }}>STEAM</div>
|
||||
<div style={{ fontFamily: 'var(--font-ui)', fontWeight: 500, fontSize: 9, color: 'var(--fg-muted)', letterSpacing: '0.18em', marginTop: 2 }}>SECURITY</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation tabs */}
|
||||
<nav style={{ display: 'flex', gap: 2, marginLeft: 24 }}>
|
||||
{navTabs.map(tab => {
|
||||
const active = currentPage === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
if (tab.id === 'triage') { setCalendarFilter(null); setReportingExcFilter(null); }
|
||||
setCurrentPage(tab.id);
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--bg-elevated)'; }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
|
||||
style={{
|
||||
background: active ? 'rgba(14, 165, 233, 0.15)' : 'transparent',
|
||||
color: active ? 'var(--accent)' : 'var(--fg-2)',
|
||||
border: 'none', borderRadius: 6,
|
||||
padding: '8px 12px', fontFamily: 'var(--font-ui)', fontWeight: active ? 600 : 500, fontSize: 13,
|
||||
cursor: 'pointer', transition: 'background 150ms, color 150ms',
|
||||
}}
|
||||
>{tab.label}</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Action buttons + User menu */}
|
||||
<div className="flex items-center gap-3">
|
||||
{canWrite() && (
|
||||
<button
|
||||
onClick={() => setShowNvdSync(true)}
|
||||
className="intel-button intel-button-success relative z-10 flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
NVD Sync
|
||||
</button>
|
||||
)}
|
||||
{canWrite() && (
|
||||
<button
|
||||
onClick={() => setShowAddCVE(true)}
|
||||
className="intel-button intel-button-primary relative z-10 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Entry
|
||||
</button>
|
||||
)}
|
||||
<UserMenu onManageUsers={() => setShowUserManagement(true)} onAuditLog={() => setShowAuditLog(true)} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<NavDrawer
|
||||
isOpen={navOpen}
|
||||
onClose={() => setNavOpen(false)}
|
||||
@@ -970,71 +1064,31 @@ export default function App() {
|
||||
{/* Scanning line effect */}
|
||||
<div className="scan-line"></div>
|
||||
|
||||
<div className={`${currentPage === 'triage' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10`}>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<button
|
||||
onClick={() => setNavOpen(true)}
|
||||
style={{ background: 'none', border: '1px solid rgba(14, 165, 233, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#64748B', flexShrink: 0 }}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.6)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#64748B'; e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)'; }}
|
||||
title="Navigation"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-intel-accent mb-1 font-mono tracking-tight">
|
||||
STEAM Security Dashboard
|
||||
</h1>
|
||||
<p className="text-gray-400 text-sm font-sans">NTS Threat Intelligence and Metric Aggregation</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{canWrite() && (
|
||||
<button
|
||||
onClick={() => setShowNvdSync(true)}
|
||||
className="intel-button intel-button-success relative z-10 flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
NVD Sync
|
||||
</button>
|
||||
)}
|
||||
{canWrite() && (
|
||||
<button
|
||||
onClick={() => setShowAddCVE(true)}
|
||||
className="intel-button intel-button-primary relative z-10 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Entry
|
||||
</button>
|
||||
)}
|
||||
<UserMenu onManageUsers={() => setShowUserManagement(true)} onAuditLog={() => setShowAuditLog(true)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${currentPage === 'triage' ? 'w-full' : 'max-w-7xl mx-auto'} relative z-10 p-6`}>
|
||||
{/* Header — Stats Bar area */}
|
||||
<div className="mb-4">
|
||||
|
||||
{/* Stats Bar - only shown on Home page */}
|
||||
{currentPage === 'home' && <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div style={STYLES.statCard}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14, 165, 233, 0.5)' }}></div>
|
||||
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Total CVEs</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#0EA5E9', textShadow: '0 0 16px rgba(14, 165, 233, 0.4)' }}>{Object.keys(filteredGroupedCVEs).length}</div>
|
||||
<div style={{...STYLES.statCard, border: '2px solid #0EA5E9', boxShadow: '0 4px 16px rgba(0,0,0,0.5), 0 0 20px rgba(14,165,233,0.15), inset 0 1px 0 rgba(14,165,233,0.15)'}}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14,165,233,0.50)' }}></div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--fg-2)', marginBottom: 4 }}>Total CVEs</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, fontFamily: 'var(--font-mono)', color: '#0EA5E9', textShadow: '0 0 16px rgba(14,165,233,0.4)', lineHeight: 1 }}>{Object.keys(filteredGroupedCVEs).length}</div>
|
||||
</div>
|
||||
<div style={STYLES.statCard}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14, 165, 233, 0.5)' }}></div>
|
||||
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Vendor Entries</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#E2E8F0' }}>{cves.length}</div>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: 'linear-gradient(90deg, transparent, #0EA5E9, transparent)', boxShadow: '0 0 8px rgba(14,165,233,0.50)' }}></div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--fg-2)', marginBottom: 4 }}>Vendor Entries</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, fontFamily: 'var(--font-mono)', color: 'var(--fg-1)', lineHeight: 1 }}>{cves.length}</div>
|
||||
</div>
|
||||
<div style={{...STYLES.statCard, border: '2px solid #F59E0B', boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(245, 158, 11, 0.15), inset 0 1px 0 rgba(245, 158, 11, 0.15)'}}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #F59E0B, transparent)', boxShadow: '0 0 8px rgba(245, 158, 11, 0.5)' }}></div>
|
||||
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Open Tickets</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#F59E0B', textShadow: '0 0 16px rgba(245, 158, 11, 0.4)' }}>{jiraTickets.filter(t => t.status !== 'Closed').length}</div>
|
||||
<div style={{...STYLES.statCard, border: '2px solid #F59E0B', boxShadow: '0 4px 16px rgba(0,0,0,0.5), 0 0 20px rgba(245,158,11,0.15), inset 0 1px 0 rgba(245,158,11,0.15)'}}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: 'linear-gradient(90deg, transparent, #F59E0B, transparent)', boxShadow: '0 0 8px rgba(245,158,11,0.50)' }}></div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--fg-2)', marginBottom: 4 }}>Open Tickets</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, fontFamily: 'var(--font-mono)', color: '#F59E0B', textShadow: '0 0 16px rgba(245,158,11,0.4)', lineHeight: 1 }}>{jiraTickets.filter(t => t.status !== 'Closed').length}</div>
|
||||
</div>
|
||||
<div style={{...STYLES.statCard, border: '2px solid #EF4444', boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5), 0 0 20px rgba(239, 68, 68, 0.15), inset 0 1px 0 rgba(239, 68, 68, 0.15)'}}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: 'linear-gradient(90deg, transparent, #EF4444, transparent)', boxShadow: '0 0 8px rgba(239, 68, 68, 0.5)' }}></div>
|
||||
<div style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.1em', color: '#CBD5E1', marginBottom: '0.25rem' }}>Critical</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: '700', fontFamily: 'monospace', color: '#EF4444', textShadow: '0 0 16px rgba(239, 68, 68, 0.4)' }}>{cves.filter(c => c.severity === 'Critical').length}</div>
|
||||
<div style={{...STYLES.statCard, border: '2px solid #EF4444', boxShadow: '0 4px 16px rgba(0,0,0,0.5), 0 0 20px rgba(239,68,68,0.15), inset 0 1px 0 rgba(239,68,68,0.15)'}}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 2, background: 'linear-gradient(90deg, transparent, #EF4444, transparent)', boxShadow: '0 0 8px rgba(239,68,68,0.50)' }}></div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--fg-2)', marginBottom: 4 }}>Critical</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, fontFamily: 'var(--font-mono)', color: '#EF4444', textShadow: '0 0 16px rgba(239,68,68,0.4)', lineHeight: 1 }}>{cves.filter(c => c.severity === 'Critical').length}</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
@@ -1659,9 +1713,9 @@ export default function App() {
|
||||
<div className="col-span-12 lg:col-span-9 space-y-4">
|
||||
<>
|
||||
{/* Quick Check */}
|
||||
<div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg">
|
||||
<div style={{...STYLES.intelCard, padding: 24}} className="rounded-lg">
|
||||
<div className="scan-line"></div>
|
||||
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#0EA5E9', marginBottom: '0.75rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 16px rgba(14, 165, 233, 0.4)' }}>Quick CVE Lookup</h2>
|
||||
<h2 style={{ fontFamily: 'var(--font-mono)', fontSize: 24, fontWeight: 700, color: '#0EA5E9', marginBottom: 12, textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 16px rgba(14,165,233,0.4)' }}>Quick CVE Lookup</h2>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
@@ -1680,28 +1734,28 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{quickCheckResult && (
|
||||
<div className={`mt-4 p-4 rounded border ${quickCheckResult.exists ? 'bg-intel-success/10 border-intel-success/30' : 'bg-intel-warning/10 border-intel-warning/30'}`}>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{quickCheckResult.error ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<XCircle className="w-5 h-5 text-intel-danger mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-intel-danger font-mono">Error</p>
|
||||
<p className="text-sm text-gray-300">{quickCheckResult.error}</p>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: 16, borderRadius: 6, background: 'rgba(239,68,68,0.10)', border: '1px solid rgba(239,68,68,0.30)' }}>
|
||||
<XCircle style={{ width: 18, height: 18, color: '#EF4444', marginTop: 1, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600, color: '#EF4444' }}>Error</div>
|
||||
<p style={{ margin: '8px 0 0', fontSize: 13, color: 'var(--fg-1)', lineHeight: 1.5 }}>{quickCheckResult.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : quickCheckResult.exists ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-intel-success mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-intel-success font-mono">✓ CVE Addressed ({quickCheckResult.vendors.length} vendor{quickCheckResult.vendors.length > 1 ? 's' : ''})</p>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: 16, borderRadius: 6, background: 'rgba(16,185,129,0.10)', border: '1px solid rgba(16,185,129,0.30)' }}>
|
||||
<CheckCircle style={{ width: 18, height: 18, color: '#10B981', marginTop: 1, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600, color: '#10B981', marginBottom: 8 }}>✓ CVE Addressed ({quickCheckResult.vendors.length} vendor{quickCheckResult.vendors.length > 1 ? 's' : ''})</div>
|
||||
<div style={{ display: 'grid', gap: 10, marginTop: 8 }}>
|
||||
{quickCheckResult.vendors.map((vendorInfo, idx) => (
|
||||
<div key={idx} className="p-3 bg-intel-dark/70 rounded border border-intel-accent/30 shadow-lg">
|
||||
<p className="font-semibold text-white mb-2 font-sans">{vendorInfo.vendor}</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm text-gray-300 mb-2 font-mono">
|
||||
<p><strong className="text-white">Severity:</strong> {vendorInfo.severity}</p>
|
||||
<p><strong className="text-white">Status:</strong> {vendorInfo.status}</p>
|
||||
<p><strong className="text-white">Documents:</strong> {vendorInfo.total_documents} attached</p>
|
||||
<div key={idx} style={{ padding: 12, background: 'rgba(15,23,42,0.7)', border: '1px solid rgba(14,165,233,0.30)', borderRadius: 6, boxShadow: '0 4px 8px rgba(0,0,0,0.3)' }}>
|
||||
<p style={{ fontFamily: 'var(--font-ui)', fontSize: 13, fontWeight: 600, color: 'var(--fg-1)', marginBottom: 6 }}>{vendorInfo.vendor}</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-2)' }}>
|
||||
<span><strong style={{ color: 'var(--fg-1)' }}>Severity:</strong> {vendorInfo.severity}</span>
|
||||
<span><strong style={{ color: 'var(--fg-1)' }}>Status:</strong> {vendorInfo.status}</span>
|
||||
<span><strong style={{ color: 'var(--fg-1)' }}>Documents:</strong> {vendorInfo.total_documents} attached</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1709,11 +1763,11 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-intel-warning mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-intel-warning font-mono">Not Found</p>
|
||||
<p className="text-sm text-gray-300">This CVE has not been addressed yet. No entry exists in the database.</p>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: 16, borderRadius: 6, background: 'rgba(245,158,11,0.10)', border: '1px solid rgba(245,158,11,0.30)' }}>
|
||||
<AlertCircle style={{ width: 18, height: 18, color: '#F59E0B', marginTop: 1, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600, color: '#F59E0B' }}>Not Found</div>
|
||||
<p style={{ margin: '8px 0 0', fontSize: 13, color: 'var(--fg-1)', lineHeight: 1.5 }}>This CVE has not been addressed yet. No entry exists in the database.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1722,11 +1776,11 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div style={{...STYLES.intelCard, padding: '1.5rem'}} className="rounded-lg">
|
||||
<div style={{...STYLES.intelCard, padding: 24}} className="rounded-lg">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">
|
||||
<Search className="inline w-4 h-4 mr-1" />
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600, color: 'var(--fg-2)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>
|
||||
<Search className="inline w-4 h-4" />
|
||||
Search CVEs
|
||||
</label>
|
||||
<input
|
||||
@@ -1734,20 +1788,23 @@ export default function App() {
|
||||
placeholder="CVE ID or description..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="intel-input w-full"
|
||||
style={{ width: '100%', boxSizing: 'border-box', background: 'rgba(15,23,42,0.85)', border: '1px solid rgba(14,165,233,0.25)', borderRadius: 6, padding: '9px 12px', color: 'var(--fg-1)', fontFamily: 'var(--font-mono)', fontSize: 13, outline: 'none', transition: 'border-color 160ms ease' }}
|
||||
className="intel-input"
|
||||
onFocus={e => { e.target.style.borderColor = '#0EA5E9'; e.target.style.boxShadow = '0 0 0 3px rgba(14,165,233,0.15)'; }}
|
||||
onBlur={e => { e.target.style.borderColor = 'rgba(14,165,233,0.25)'; e.target.style.boxShadow = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">
|
||||
<Filter className="inline w-4 h-4 mr-1" />
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600, color: 'var(--fg-2)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>
|
||||
<Filter className="inline w-4 h-4" />
|
||||
Vendor
|
||||
</label>
|
||||
<select
|
||||
value={selectedVendor}
|
||||
onChange={(e) => setSelectedVendor(e.target.value)}
|
||||
className="intel-input w-full"
|
||||
style={{ width: '100%', boxSizing: 'border-box', background: 'rgba(15,23,42,0.85)', border: '1px solid rgba(14,165,233,0.25)', borderRadius: 6, padding: '9px 12px', color: 'var(--fg-1)', fontFamily: 'var(--font-mono)', fontSize: 13, outline: 'none', appearance: 'none', backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230EA5E9' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E")`, backgroundRepeat: 'no-repeat', backgroundPosition: 'right 12px center', paddingRight: 32 }}
|
||||
>
|
||||
{vendors.map(vendor => (
|
||||
<option key={vendor} value={vendor}>{vendor}</option>
|
||||
@@ -1756,14 +1813,14 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-300 mb-2 uppercase tracking-wider">
|
||||
<AlertCircle className="inline w-4 h-4 mr-1" />
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600, color: 'var(--fg-2)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>
|
||||
<AlertCircle className="inline w-4 h-4" />
|
||||
Severity
|
||||
</label>
|
||||
<select
|
||||
value={selectedSeverity}
|
||||
onChange={(e) => setSelectedSeverity(e.target.value)}
|
||||
className="intel-input w-full"
|
||||
style={{ width: '100%', boxSizing: 'border-box', background: 'rgba(15,23,42,0.85)', border: '1px solid rgba(14,165,233,0.25)', borderRadius: 6, padding: '9px 12px', color: 'var(--fg-1)', fontFamily: 'var(--font-mono)', fontSize: 13, outline: 'none', appearance: 'none', backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230EA5E9' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E")`, backgroundRepeat: 'no-repeat', backgroundPosition: 'right 12px center', paddingRight: 32 }}
|
||||
>
|
||||
{severityLevels.map(level => (
|
||||
<option key={level} value={level}>{level}</option>
|
||||
@@ -1824,36 +1881,37 @@ export default function App() {
|
||||
const overallStatuses = [...new Set(vendorEntries.map(e => e.status))];
|
||||
|
||||
return (
|
||||
<div key={cveId} style={STYLES.intelCard} className="rounded-lg">
|
||||
<div key={cveId} style={{...STYLES.intelCard, borderRadius: 8, transition: 'border-color 200ms ease'}} className="rounded-lg">
|
||||
{/* Clickable CVE Header */}
|
||||
<div
|
||||
style={{ padding: '1.5rem', cursor: 'pointer', transition: 'all 0.2s', userSelect: 'none' }}
|
||||
style={{ padding: 24, cursor: 'pointer', transition: 'all 0.2s', userSelect: 'none' }}
|
||||
onClick={() => toggleCVEExpand(cveId)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 6 }}>
|
||||
<ChevronDown
|
||||
className={`w-5 h-5 text-intel-accent transition-transform duration-200 flex-shrink-0 ${isCVEExpanded ? 'rotate-0' : '-rotate-90'}`}
|
||||
style={{ display: 'inline-block', transform: isCVEExpanded ? 'rotate(0)' : 'rotate(-90deg)', transition: 'transform 200ms ease', color: '#0EA5E9', flexShrink: 0 }}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
<h3 className="text-2xl font-bold text-intel-accent font-mono tracking-tight">{cveId}</h3>
|
||||
<h3 style={{ margin: 0, fontFamily: 'var(--font-mono)', fontSize: 22, fontWeight: 700, color: '#0EA5E9', letterSpacing: '-0.01em' }}>{cveId}</h3>
|
||||
</div>
|
||||
|
||||
{/* Collapsed: truncated description + summary row */}
|
||||
{!isCVEExpanded && (
|
||||
<div className="ml-8">
|
||||
<p style={{ color: '#E4E8F1', fontSize: '0.875rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginBottom: '0.5rem' }}>{vendorEntries[0].description}</p>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div style={{ marginLeft: 30 }}>
|
||||
<p style={{ margin: '0 0 8px 0', color: 'var(--fg-1)', fontSize: 13, lineHeight: 1.5, fontFamily: 'var(--font-ui)', display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden', textOverflow: 'ellipsis' }}>{vendorEntries[0].description}</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
||||
<span style={getSeverityBadgeStyle(highestSeverity)}>
|
||||
<span style={STYLES.glowDot(getSeverityDotColor(highestSeverity))}></span>
|
||||
{highestSeverity}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.75rem', color: '#E4E8F1', fontFamily: 'monospace' }}>{vendorEntries.length} vendor{vendorEntries.length > 1 ? 's' : ''}</span>
|
||||
<span style={{ fontSize: '0.75rem', color: '#E4E8F1', fontFamily: 'monospace', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-2)' }}>{vendorEntries.length} vendor{vendorEntries.length > 1 ? 's' : ''}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-2)', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<FileText className="w-3 h-3" />
|
||||
{totalDocCount} doc{totalDocCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.75rem', color: '#E4E8F1', fontFamily: 'monospace' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-2)' }}>
|
||||
{overallStatuses.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1898,15 +1956,15 @@ export default function App() {
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#FFFFFF' }}>{cve.vendor}</h4>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, color: 'var(--fg-1)', fontFamily: 'var(--font-ui)', margin: 0 }}>{cve.vendor}</h4>
|
||||
<span style={getSeverityBadgeStyle(cve.severity)}>
|
||||
<span style={STYLES.glowDot(getSeverityDotColor(cve.severity))}></span>
|
||||
{cve.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', fontSize: '0.875rem', color: '#E4E8F1', fontFamily: 'monospace' }}>
|
||||
<span>Status: <span style={{ fontWeight: '500', color: '#FFFFFF' }}>{cve.status}</span></span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg-2)' }}>
|
||||
<span>Status: <span style={{ fontWeight: 500, color: 'var(--fg-1)' }}>{cve.status}</span></span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<FileText className="w-4 h-4" />
|
||||
{cve.document_count} doc{cve.document_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
@@ -2032,7 +2090,7 @@ export default function App() {
|
||||
{vendorTickets.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{vendorTickets.map(ticket => (
|
||||
<div key={ticket.id} style={{ background: 'linear-gradient(135deg, rgba(19, 25, 55, 0.85) 0%, rgba(30, 39, 73, 0.75) 100%)', border: '1px solid rgba(255, 184, 0, 0.3)', borderRadius: '0.375rem', padding: '0.75rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.04)' }} className="flex items-center justify-between">
|
||||
<div key={ticket.id} style={{ background: 'linear-gradient(135deg, rgba(19,25,55,0.85), rgba(30,39,73,0.75))', border: '1px solid rgba(245,158,11,0.30)', borderRadius: 6, padding: 10, boxShadow: '0 2px 6px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.04)' }} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<a
|
||||
href={ticket.url || '#'}
|
||||
@@ -2042,7 +2100,7 @@ export default function App() {
|
||||
>
|
||||
{ticket.ticket_key}
|
||||
</a>
|
||||
{ticket.summary && <span style={{ fontSize: '0.875rem', color: '#E4E8F1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '20rem' }}>{ticket.summary}</span>}
|
||||
{ticket.summary && <span style={{ fontSize: 12, color: 'var(--fg-1)', fontFamily: 'var(--font-ui)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '20rem' }}>{ticket.summary}</span>}
|
||||
<span style={
|
||||
ticket.status === 'Open' ? STYLES.badgeCritical :
|
||||
ticket.status === 'In Progress' ? STYLES.badgeHigh :
|
||||
@@ -2137,8 +2195,8 @@ export default function App() {
|
||||
{/* RIGHT PANEL - Calendar & Open Tickets */}
|
||||
<div className="col-span-12 lg:col-span-3 space-y-4">
|
||||
{/* Calendar Widget */}
|
||||
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #0EA5E9'}} className="rounded-lg">
|
||||
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#0EA5E9', marginBottom: '1rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(14, 165, 233, 0.4)' }}>
|
||||
<div style={{...STYLES.intelCard, padding: 20, borderLeft: '3px solid #0EA5E9'}} className="rounded-lg">
|
||||
<h2 style={{ fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600, color: '#0EA5E9', marginBottom: 12, textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(14,165,233,0.4)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
Calendar
|
||||
</h2>
|
||||
|
||||
@@ -2151,9 +2209,9 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{/* Open Vendor Tickets */}
|
||||
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #F59E0B'}} className="rounded-lg">
|
||||
<div style={{...STYLES.intelCard, padding: 20, borderLeft: '3px solid #F59E0B'}} className="rounded-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#F59E0B', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(245, 158, 11, 0.4)' }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600, color: '#F59E0B', display: 'flex', alignItems: 'center', gap: 8, textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(245,158,11,0.4)' }}>
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
Open Tickets
|
||||
</h2>
|
||||
@@ -2166,15 +2224,15 @@ export default function App() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center mb-3">
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#F59E0B', textShadow: '0 0 16px rgba(245, 158, 11, 0.4)' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 12 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 32, fontWeight: 700, color: '#F59E0B', textShadow: '0 0 16px rgba(245,158,11,0.4)', lineHeight: 1 }}>
|
||||
{jiraTickets.filter(t => t.status !== 'Closed').length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wider">Active</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600, color: 'var(--fg-2)', textTransform: 'uppercase', letterSpacing: '0.12em', marginTop: 6 }}>Active</div>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{jiraTickets.filter(t => t.status !== 'Closed').slice(0, 10).map(ticket => (
|
||||
<div key={ticket.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(245, 158, 11, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
|
||||
<div key={ticket.id} style={{ background: 'linear-gradient(135deg, rgba(30,41,59,0.85), rgba(51,65,85,0.75))', border: '1px solid rgba(245,158,11,0.25)', borderRadius: 6, padding: 10, boxShadow: '0 2px 6px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.03)' }}>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<a
|
||||
href={ticket.url || '#'}
|
||||
@@ -2218,9 +2276,9 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{/* Archer Risk Acceptance Tickets */}
|
||||
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #8B5CF6'}} className="rounded-lg">
|
||||
<div style={{...STYLES.intelCard, padding: 20, borderLeft: '3px solid #8B5CF6'}} className="rounded-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#8B5CF6', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139, 92, 246, 0.4)' }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600, color: '#8B5CF6', display: 'flex', alignItems: 'center', gap: 8, textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139,92,246,0.4)' }}>
|
||||
<Shield className="w-5 h-5" />
|
||||
Archer Risk Tickets
|
||||
</h2>
|
||||
@@ -2233,15 +2291,15 @@ export default function App() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center mb-3">
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#8B5CF6', textShadow: '0 0 16px rgba(139, 92, 246, 0.4)' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 12 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 32, fontWeight: 700, color: '#8B5CF6', textShadow: '0 0 16px rgba(139,92,246,0.4)', lineHeight: 1 }}>
|
||||
{archerTickets.filter(t => t.status !== 'Accepted').length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wider">Active</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600, color: 'var(--fg-2)', textTransform: 'uppercase', letterSpacing: '0.12em', marginTop: 6 }}>Active</div>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{archerTickets.filter(t => t.status !== 'Accepted').slice(0, 10).map(ticket => (
|
||||
<div key={ticket.id} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(139, 92, 246, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
|
||||
<div key={ticket.id} style={{ background: 'linear-gradient(135deg, rgba(30,41,59,0.85), rgba(51,65,85,0.75))', border: '1px solid rgba(139,92,246,0.25)', borderRadius: 6, padding: 10, boxShadow: '0 2px 6px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.03)' }}>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<a
|
||||
href={ticket.archer_url || '#'}
|
||||
@@ -2291,9 +2349,9 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{/* Ivanti Workflows */}
|
||||
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #0D9488'}} className="rounded-lg">
|
||||
<div style={{...STYLES.intelCard, padding: 20, borderLeft: '3px solid #0D9488'}} className="rounded-lg">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#0D9488', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(13, 148, 136, 0.4)' }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600, color: '#0D9488', display: 'flex', alignItems: 'center', gap: 8, textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(13,148,136,0.4)' }}>
|
||||
<Activity className="w-5 h-5" />
|
||||
Ivanti Workflows
|
||||
</h2>
|
||||
@@ -2389,11 +2447,11 @@ export default function App() {
|
||||
</div>
|
||||
) : ivantiSyncStatus === 'error' ? (
|
||||
<>
|
||||
<div className="text-center mb-3">
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#0D9488', textShadow: '0 0 16px rgba(13, 148, 136, 0.4)' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 12 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 32, fontWeight: 700, color: '#0D9488', textShadow: '0 0 16px rgba(13,148,136,0.4)', lineHeight: 1 }}>
|
||||
{ivantiTotal ?? '—'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wider">Total Workflows</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600, color: 'var(--fg-2)', textTransform: 'uppercase', letterSpacing: '0.12em', marginTop: 6 }}>Total Workflows</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 p-2 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
|
||||
<AlertCircle className="w-4 h-4 text-intel-danger mt-0.5 shrink-0" />
|
||||
@@ -2402,15 +2460,15 @@ export default function App() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-center mb-3">
|
||||
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#0D9488', textShadow: '0 0 16px rgba(13, 148, 136, 0.4)' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 12 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 32, fontWeight: 700, color: '#0D9488', textShadow: '0 0 16px rgba(13,148,136,0.4)', lineHeight: 1 }}>
|
||||
{ivantiSyncStatus === 'never' ? '—' : (ivantiTotal ?? '—')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 uppercase tracking-wider">Total Workflows</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600, color: 'var(--fg-2)', textTransform: 'uppercase', letterSpacing: '0.12em', marginTop: 6 }}>Total Workflows</div>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{ivantiWorkflows.slice(0, 10).map((wf, idx) => (
|
||||
<div key={wf.uuid ?? idx} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(13, 148, 136, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
|
||||
<div key={wf.uuid ?? idx} style={{ background: 'linear-gradient(135deg, rgba(30,41,59,0.85), rgba(51,65,85,0.75))', border: '1px solid rgba(13,148,136,0.25)', borderRadius: 6, padding: 10, boxShadow: '0 2px 6px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.03)' }}>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<span className="font-mono text-xs font-semibold text-teal-300">
|
||||
{wf.id?.value || wf.uuid?.slice(0, 8)}
|
||||
|
||||
@@ -4,19 +4,19 @@ import { X, Loader, AlertCircle, ChevronLeft, ChevronRight, Search } from 'lucid
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
|
||||
const ACTION_BADGES = {
|
||||
login: { bg: 'bg-green-100', text: 'text-green-800' },
|
||||
logout: { bg: 'bg-gray-100', text: 'text-gray-800' },
|
||||
login_failed: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||
cve_create: { bg: 'bg-blue-100', text: 'text-blue-800' },
|
||||
cve_update_status: { bg: 'bg-yellow-100', text: 'text-yellow-800' },
|
||||
document_upload: { bg: 'bg-purple-100', text: 'text-purple-800' },
|
||||
document_delete: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||
user_create: { bg: 'bg-blue-100', text: 'text-blue-800' },
|
||||
user_update: { bg: 'bg-yellow-100', text: 'text-yellow-800' },
|
||||
user_delete: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||
cve_edit: { bg: 'bg-orange-100', text: 'text-orange-800' },
|
||||
cve_delete: { bg: 'bg-red-100', text: 'text-red-800' },
|
||||
cve_nvd_sync: { bg: 'bg-green-100', text: 'text-green-800' },
|
||||
login: { bg: 'rgba(16,185,129,0.15)', text: '#10B981' },
|
||||
logout: { bg: 'rgba(148,163,184,0.15)', text: '#94A3B8' },
|
||||
login_failed: { bg: 'rgba(239,68,68,0.15)', text: '#EF4444' },
|
||||
cve_create: { bg: 'rgba(14,165,233,0.15)', text: '#0EA5E9' },
|
||||
cve_update_status: { bg: 'rgba(245,158,11,0.15)', text: '#F59E0B' },
|
||||
document_upload: { bg: 'rgba(139,92,246,0.15)', text: '#8B5CF6' },
|
||||
document_delete: { bg: 'rgba(239,68,68,0.15)', text: '#EF4444' },
|
||||
user_create: { bg: 'rgba(14,165,233,0.15)', text: '#0EA5E9' },
|
||||
user_update: { bg: 'rgba(245,158,11,0.15)', text: '#F59E0B' },
|
||||
user_delete: { bg: 'rgba(239,68,68,0.15)', text: '#EF4444' },
|
||||
cve_edit: { bg: 'rgba(249,115,22,0.15)', text: '#F97316' },
|
||||
cve_delete: { bg: 'rgba(239,68,68,0.15)', text: '#EF4444' },
|
||||
cve_nvd_sync: { bg: 'rgba(16,185,129,0.15)', text: '#10B981' },
|
||||
};
|
||||
|
||||
const ENTITY_TYPES = ['auth', 'cve', 'document', 'user'];
|
||||
@@ -97,9 +97,9 @@ export default function AuditLog({ onClose }) {
|
||||
};
|
||||
|
||||
const getActionBadge = (action) => {
|
||||
const style = ACTION_BADGES[action] || { bg: 'bg-gray-100', text: 'text-gray-800' };
|
||||
const style = ACTION_BADGES[action] || { bg: 'rgba(148,163,184,0.15)', text: '#94A3B8' };
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${style.bg} ${style.text}`}>
|
||||
<span style={{ padding: '0.125rem 0.5rem', borderRadius: 'var(--r-sm)', fontSize: '0.75rem', fontWeight: '600', fontFamily: 'var(--font-mono)', background: style.bg, color: style.text, textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
|
||||
{action}
|
||||
</span>
|
||||
);
|
||||
@@ -119,44 +119,48 @@ export default function AuditLog({ onClose }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--bg-overlay)', backdropFilter: 'blur(12px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 'var(--z-modal)', padding: '1rem' }}>
|
||||
<div style={{ background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(51,65,85,0.9) 50%, rgba(30,41,59,0.95) 100%)', border: '1.5px solid rgba(14,165,233,0.3)', borderRadius: 'var(--r-xl)', boxShadow: 'var(--shadow-modal)', maxWidth: '72rem', width: '100%', maxHeight: '90vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 flex justify-between items-center">
|
||||
<div style={{ padding: '1.5rem', borderBottom: '1px solid var(--border-subtle)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Audit Log</h2>
|
||||
<p className="text-gray-600">Track all user actions across the system</p>
|
||||
<h2 style={{ fontFamily: 'var(--font-mono)', fontSize: '1.5rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wider)', textShadow: 'var(--glow-heading)' }}>Audit Log</h2>
|
||||
<p style={{ color: 'var(--fg-muted)', fontSize: '0.875rem' }}>Track all user actions across the system</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 p-2"
|
||||
style={{ color: 'var(--fg-disabled)', cursor: 'pointer', background: 'none', border: 'none', padding: '0.5rem' }}
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<form onSubmit={handleFilter} className="p-4 border-b border-gray-200 bg-gray-50">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<form onSubmit={handleFilter} style={{ padding: '1rem', borderBottom: '1px solid var(--border-subtle)', background: 'rgba(15,23,42,0.4)' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Username</label>
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-2 top-1/2 transform -translate-y-1/2" />
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: '500', color: 'var(--fg-muted)', marginBottom: '0.25rem', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Username</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Search className="w-4 h-4" style={{ position: 'absolute', left: '0.5rem', top: '50%', transform: 'translateY(-50%)', color: 'var(--fg-disabled)' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search user..."
|
||||
value={userFilter}
|
||||
onChange={(e) => setUserFilter(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||
style={{ width: '100%', paddingLeft: '2rem', paddingRight: '0.75rem', paddingTop: '0.375rem', paddingBottom: '0.375rem', fontSize: '0.875rem', background: 'var(--bg-input)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--r-md)', color: 'var(--fg-1)', fontFamily: 'var(--font-mono)', outline: 'none' }}
|
||||
onFocus={e => { e.target.style.borderColor = 'var(--border-focus)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border-subtle)'; e.target.style.boxShadow = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Action</label>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: '500', color: 'var(--fg-muted)', marginBottom: '0.25rem', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Action</label>
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||
style={{ width: '100%', padding: '0.375rem 0.75rem', fontSize: '0.875rem', background: 'var(--bg-input)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--r-md)', color: 'var(--fg-1)', fontFamily: 'var(--font-mono)', outline: 'none', cursor: 'pointer' }}
|
||||
onFocus={e => { e.target.style.borderColor = 'var(--border-focus)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border-subtle)'; e.target.style.boxShadow = 'none'; }}
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
{actions.map(a => (
|
||||
@@ -165,11 +169,13 @@ export default function AuditLog({ onClose }) {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Entity Type</label>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: '500', color: 'var(--fg-muted)', marginBottom: '0.25rem', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Entity Type</label>
|
||||
<select
|
||||
value={entityTypeFilter}
|
||||
onChange={(e) => setEntityTypeFilter(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||
style={{ width: '100%', padding: '0.375rem 0.75rem', fontSize: '0.875rem', background: 'var(--bg-input)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--r-md)', color: 'var(--fg-1)', fontFamily: 'var(--font-mono)', outline: 'none', cursor: 'pointer' }}
|
||||
onFocus={e => { e.target.style.borderColor = 'var(--border-focus)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border-subtle)'; e.target.style.boxShadow = 'none'; }}
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{ENTITY_TYPES.map(t => (
|
||||
@@ -178,35 +184,39 @@ export default function AuditLog({ onClose }) {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Start Date</label>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: '500', color: 'var(--fg-muted)', marginBottom: '0.25rem', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||
style={{ width: '100%', padding: '0.375rem 0.75rem', fontSize: '0.875rem', background: 'var(--bg-input)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--r-md)', color: 'var(--fg-1)', fontFamily: 'var(--font-mono)', outline: 'none' }}
|
||||
onFocus={e => { e.target.style.borderColor = 'var(--border-focus)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border-subtle)'; e.target.style.boxShadow = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">End Date</label>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: '500', color: 'var(--fg-muted)', marginBottom: '0.25rem', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||
style={{ width: '100%', padding: '0.375rem 0.75rem', fontSize: '0.875rem', background: 'var(--bg-input)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--r-md)', color: 'var(--fg-1)', fontFamily: 'var(--font-mono)', outline: 'none' }}
|
||||
onFocus={e => { e.target.style.borderColor = 'var(--border-focus)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border-subtle)'; e.target.style.boxShadow = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-1.5 text-sm bg-[#0476D9] text-white rounded hover:bg-[#0360B8] transition-colors"
|
||||
style={{ padding: '0.375rem 1rem', fontSize: '0.8rem', background: 'rgba(14,165,233,0.08)', border: '1px solid #0EA5E9', color: '#38BDF8', borderRadius: 'var(--r-md)', cursor: 'pointer', fontFamily: 'var(--font-mono)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="px-4 py-1.5 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300 transition-colors"
|
||||
style={{ padding: '0.375rem 1rem', fontSize: '0.8rem', background: 'rgba(148,163,184,0.08)', border: '1px solid rgba(148,163,184,0.3)', color: 'var(--fg-muted)', borderRadius: 'var(--r-md)', cursor: 'pointer', fontFamily: 'var(--font-mono)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
@@ -214,56 +224,59 @@ export default function AuditLog({ onClose }) {
|
||||
</form>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 overflow-y-auto flex-1">
|
||||
<div style={{ padding: '1rem', overflowY: 'auto', flex: 1 }}>
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<Loader className="w-8 h-8 text-[#0476D9] mx-auto animate-spin" />
|
||||
<p className="text-gray-600 mt-2">Loading audit logs...</p>
|
||||
<div style={{ textAlign: 'center', padding: '3rem' }}>
|
||||
<Loader className="w-8 h-8 mx-auto animate-spin" style={{ color: '#0EA5E9' }} />
|
||||
<p style={{ color: 'var(--fg-muted)', marginTop: '0.5rem' }}>Loading audit logs...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle className="w-8 h-8 text-red-500 mx-auto" />
|
||||
<p className="text-red-600 mt-2">{error}</p>
|
||||
<div style={{ textAlign: 'center', padding: '3rem' }}>
|
||||
<AlertCircle className="w-8 h-8 mx-auto" style={{ color: '#EF4444' }} />
|
||||
<p style={{ color: '#EF4444', marginTop: '0.5rem' }}>{error}</p>
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">No audit log entries found.</p>
|
||||
<div style={{ textAlign: 'center', padding: '3rem' }}>
|
||||
<p style={{ color: 'var(--fg-muted)' }}>No audit log entries found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', fontSize: '0.875rem' }}>
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">Time</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">User</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">Action</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">Entity</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">Details</th>
|
||||
<th className="text-left py-2 px-3 text-xs font-medium text-gray-600">IP Address</th>
|
||||
<tr style={{ borderBottom: '1px solid var(--border-subtle)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem', fontSize: '0.75rem', fontWeight: '600', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Time</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem', fontSize: '0.75rem', fontWeight: '600', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>User</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem', fontSize: '0.75rem', fontWeight: '600', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Action</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem', fontSize: '0.75rem', fontWeight: '600', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Entity</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem', fontSize: '0.75rem', fontWeight: '600', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Details</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem', fontSize: '0.75rem', fontWeight: '600', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>IP Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-2 px-3 text-gray-600 whitespace-nowrap">
|
||||
<tr key={log.id} style={{ borderBottom: '1px solid var(--border-subtle)', transition: 'background var(--dur-fast)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(14,165,233,0.06)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--fg-muted)', whiteSpace: 'nowrap', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
|
||||
{formatDate(log.created_at)}
|
||||
</td>
|
||||
<td className="py-2 px-3 font-medium text-gray-900">
|
||||
<td style={{ padding: '0.5rem 0.75rem', fontWeight: '500', color: 'var(--fg-2)' }}>
|
||||
{log.username}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<td style={{ padding: '0.5rem 0.75rem' }}>
|
||||
{getActionBadge(log.action)}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-gray-700">
|
||||
<span className="text-gray-500">{log.entity_type}</span>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--fg-3)' }}>
|
||||
<span style={{ color: 'var(--fg-muted)' }}>{log.entity_type}</span>
|
||||
{log.entity_id && (
|
||||
<span className="ml-1 text-gray-900">{log.entity_id}</span>
|
||||
<span style={{ marginLeft: '0.25rem', color: 'var(--fg-2)' }}>{log.entity_id}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-gray-600 max-w-xs truncate" title={formatDetails(log.details)}>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--fg-muted)', maxWidth: '20rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={formatDetails(log.details)}>
|
||||
{formatDetails(log.details)}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-gray-500 font-mono text-xs">
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem' }}>
|
||||
{log.ip_address || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -276,25 +289,25 @@ export default function AuditLog({ onClose }) {
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="p-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
<div style={{ padding: '1rem', borderTop: '1px solid var(--border-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)' }}>
|
||||
Showing {((pagination.page - 1) * pagination.limit) + 1} - {Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total} entries
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<button
|
||||
onClick={() => fetchLogs(pagination.page - 1)}
|
||||
disabled={pagination.page <= 1}
|
||||
className="p-2 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ padding: '0.5rem', borderRadius: 'var(--r-sm)', background: 'none', border: 'none', cursor: pagination.page <= 1 ? 'not-allowed' : 'pointer', opacity: pagination.page <= 1 ? 0.5 : 1, color: 'var(--fg-muted)' }}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm text-gray-700">
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--fg-3)', fontFamily: 'var(--font-mono)' }}>
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => fetchLogs(pagination.page + 1)}
|
||||
disabled={pagination.page >= pagination.totalPages}
|
||||
className="p-2 rounded hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ padding: '0.5rem', borderRadius: 'var(--r-sm)', background: 'none', border: 'none', cursor: pagination.page >= pagination.totalPages ? 'not-allowed' : 'pointer', opacity: pagination.page >= pagination.totalPages ? 0.5 : 1, color: 'var(--fg-muted)' }}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -73,22 +73,22 @@ export default function CalendarWidget({ onDateClick }) {
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.75rem' }}>
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
||||
style={{ background: 'none', border: '1px solid rgba(14,165,233,0.25)', cursor: 'pointer', color: 'var(--fg-disabled)', padding: '2px 4px', borderRadius: 'var(--r-sm)', lineHeight: 1, transition: 'color var(--dur-fast), border-color var(--dur-fast)' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.5)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--fg-disabled)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.25)'; }}
|
||||
>
|
||||
<ChevronLeft style={{ width: '14px', height: '14px' }} />
|
||||
</button>
|
||||
|
||||
<span style={{ color: '#E2E8F0', fontFamily: 'monospace', fontWeight: '600', fontSize: '0.85rem' }}>
|
||||
<span style={{ color: 'var(--fg-2)', fontFamily: 'var(--font-mono)', fontWeight: '600', fontSize: '0.85rem' }}>
|
||||
{MONTH_NAMES[calMonth]} {calYear}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748B', padding: '2px 4px', borderRadius: '4px', lineHeight: 1, transition: 'color 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#64748B'; }}
|
||||
style={{ background: 'none', border: '1px solid rgba(14,165,233,0.25)', cursor: 'pointer', color: 'var(--fg-disabled)', padding: '2px 4px', borderRadius: 'var(--r-sm)', lineHeight: 1, transition: 'color var(--dur-fast), border-color var(--dur-fast)' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.5)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--fg-disabled)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.25)'; }}
|
||||
>
|
||||
<ChevronRight style={{ width: '14px', height: '14px' }} />
|
||||
</button>
|
||||
@@ -97,7 +97,7 @@ export default function CalendarWidget({ onDateClick }) {
|
||||
{/* Day-of-week headers */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '2px', textAlign: 'center', marginBottom: '4px' }}>
|
||||
{DAY_NAMES.map((d) => (
|
||||
<div key={d} style={{ fontSize: '0.6rem', color: '#475569', fontFamily: 'monospace', fontWeight: '600', textTransform: 'uppercase' }}>
|
||||
<div key={d} style={{ fontSize: '0.6rem', color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)', fontWeight: '600', textTransform: 'uppercase' }}>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
@@ -121,18 +121,18 @@ export default function CalendarWidget({ onDateClick }) {
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: '2px', padding: '3px 1px',
|
||||
borderRadius: '4px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
background: isToday ? 'rgba(14,165,233,0.2)' : 'transparent',
|
||||
border: isToday ? '1px solid rgba(14,165,233,0.5)' : '1px solid transparent',
|
||||
cursor: hasDue ? 'pointer' : 'default',
|
||||
transition: hasDue ? 'background 0.15s' : undefined,
|
||||
transition: hasDue ? 'background var(--dur-fast)' : undefined,
|
||||
}}
|
||||
onMouseEnter={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.35)' : 'rgba(239,68,68,0.15)'; } : undefined}
|
||||
onMouseLeave={hasDue ? (e) => { e.currentTarget.style.background = isToday ? 'rgba(14,165,233,0.2)' : 'transparent'; } : undefined}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: '0.7rem', fontFamily: 'monospace', lineHeight: 1,
|
||||
color: isToday ? '#0EA5E9' : hasDue ? '#EF4444' : '#CBD5E1',
|
||||
fontSize: '0.7rem', fontFamily: 'var(--font-mono)', lineHeight: 1,
|
||||
color: isToday ? '#0EA5E9' : hasDue ? '#EF4444' : 'var(--fg-3)',
|
||||
fontWeight: (isToday || hasDue) ? '700' : '400',
|
||||
}}>
|
||||
{day}
|
||||
@@ -157,7 +157,7 @@ export default function CalendarWidget({ onDateClick }) {
|
||||
{hasDueDatesThisMonth && (
|
||||
<div style={{ marginTop: '0.75rem', paddingTop: '0.625rem', borderTop: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#EF4444', boxShadow: '0 0 4px rgba(239,68,68,0.5)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.62rem', color: '#64748B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
<span style={{ fontSize: '0.62rem', color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
|
||||
Ivanti finding due
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -182,23 +182,23 @@ export default function KnowledgeBaseModal({ onClose, onUpdate }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '700px' }}>
|
||||
<div className="modal-overlay" onClick={onClose} style={{ background: 'var(--bg-overlay)', backdropFilter: 'blur(12px)' }}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '700px', background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(51,65,85,0.9) 50%, rgba(30,41,59,0.95) 100%)', border: '1.5px solid rgba(14,165,233,0.3)', borderRadius: 'var(--r-xl)', boxShadow: 'var(--shadow-modal)' }}>
|
||||
{/* Header */}
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Knowledge Base</h2>
|
||||
<button onClick={onClose} className="modal-close">
|
||||
<div className="modal-header" style={{ borderBottom: '1px solid var(--border-subtle)', padding: '1.25rem 1.5rem' }}>
|
||||
<h2 className="modal-title" style={{ fontFamily: 'var(--font-mono)', fontWeight: '700', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wider)', color: '#0EA5E9', textShadow: 'var(--glow-heading)' }}>Knowledge Base</h2>
|
||||
<button onClick={onClose} className="modal-close" style={{ color: 'var(--fg-disabled)', background: 'none', border: 'none', cursor: 'pointer' }}>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="modal-body">
|
||||
<div className="modal-body" style={{ padding: '1.5rem' }}>
|
||||
{/* Idle Phase - Upload Form */}
|
||||
{phase === 'idle' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
@@ -206,33 +206,39 @@ export default function KnowledgeBaseModal({ onClose, onUpdate }) {
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g., Inventory Management Policy"
|
||||
className="intel-input w-full"
|
||||
style={{ width: '100%', background: 'var(--bg-input)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--r-md)', color: 'var(--fg-1)', padding: '0.625rem 1rem', fontFamily: 'var(--font-mono)', fontSize: '0.875rem', outline: 'none' }}
|
||||
onFocus={e => { e.target.style.borderColor = 'var(--border-focus)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border-subtle)'; e.target.style.boxShadow = 'none'; }}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this document..."
|
||||
className="intel-input w-full"
|
||||
style={{ width: '100%', background: 'var(--bg-input)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--r-md)', color: 'var(--fg-1)', padding: '0.625rem 1rem', fontFamily: 'var(--font-mono)', fontSize: '0.875rem', outline: 'none', resize: 'vertical' }}
|
||||
onFocus={e => { e.target.style.borderColor = 'var(--border-focus)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border-subtle)'; e.target.style.boxShadow = 'none'; }}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="intel-input w-full"
|
||||
style={{ width: '100%', background: 'var(--bg-input)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--r-md)', color: 'var(--fg-1)', padding: '0.625rem 1rem', fontFamily: 'var(--font-mono)', fontSize: '0.875rem', outline: 'none', cursor: 'pointer' }}
|
||||
onFocus={e => { e.target.style.borderColor = 'var(--border-focus)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border-subtle)'; e.target.style.boxShadow = 'none'; }}
|
||||
>
|
||||
<option value="General">General</option>
|
||||
<option value="Policy">Policy</option>
|
||||
@@ -243,14 +249,14 @@ export default function KnowledgeBaseModal({ onClose, onUpdate }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: '#94A3B8' }}>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
|
||||
Document File *
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.md,.txt,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.html,.json,.yaml,.yml"
|
||||
onChange={handleFileSelect}
|
||||
className="intel-input w-full"
|
||||
style={{ width: '100%', background: 'var(--bg-input)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--r-md)', color: 'var(--fg-1)', padding: '0.625rem 1rem', fontFamily: 'var(--font-mono)', fontSize: '0.875rem' }}
|
||||
/>
|
||||
{selectedFile && (
|
||||
<p className="mt-2 text-sm" style={{ color: '#10B981' }}>
|
||||
|
||||
@@ -152,29 +152,29 @@ export default function KnowledgeBaseViewer({ article, onClose }) {
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
|
||||
border: '2px solid rgba(14, 165, 233, 0.4)',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.6), 0 0 28px rgba(14, 165, 233, 0.15)',
|
||||
border: '1.5px solid rgba(14, 165, 233, 0.3)',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
padding: '1.5rem',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4 pb-4" style={{ borderBottom: '1px solid rgba(14, 165, 233, 0.2)' }}>
|
||||
<div className="flex items-start justify-between mb-4 pb-4" style={{ borderBottom: '1px solid var(--border-subtle)' }}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<FileText className="w-5 h-5" style={{ color: getCategoryColor(article.category) }} />
|
||||
<h2 className="text-xl font-semibold" style={{ color: '#E2E8F0', fontFamily: 'monospace' }}>
|
||||
<h2 className="text-xl font-semibold" style={{ color: 'var(--fg-2)', fontFamily: 'var(--font-mono)' }}>
|
||||
{article.title}
|
||||
</h2>
|
||||
</div>
|
||||
{article.description && (
|
||||
<p className="text-sm mb-2" style={{ color: '#94A3B8' }}>
|
||||
<p className="text-sm mb-2" style={{ color: 'var(--fg-muted)' }}>
|
||||
{article.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs" style={{ color: '#64748B' }}>
|
||||
<div className="flex items-center gap-3 text-xs" style={{ color: 'var(--fg-disabled)' }}>
|
||||
<span
|
||||
className="px-2 py-1 rounded"
|
||||
style={{
|
||||
@@ -214,7 +214,7 @@ export default function KnowledgeBaseViewer({ article, onClose }) {
|
||||
{loading && (
|
||||
<div className="text-center py-12">
|
||||
<Loader className="w-8 h-8 animate-spin mx-auto mb-4" style={{ color: '#0EA5E9' }} />
|
||||
<p style={{ color: '#94A3B8' }}>Loading document...</p>
|
||||
<p style={{ color: 'var(--fg-muted)' }}>Loading document...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -223,7 +223,7 @@ export default function KnowledgeBaseViewer({ article, onClose }) {
|
||||
<AlertCircle className="w-6 h-6 flex-shrink-0" style={{ color: '#EF4444' }} />
|
||||
<div>
|
||||
<p className="font-medium" style={{ color: '#FCA5A5' }}>Failed to Load Document</p>
|
||||
<p className="text-sm mt-1" style={{ color: '#94A3B8' }}>{error}</p>
|
||||
<p className="text-sm mt-1" style={{ color: 'var(--fg-muted)' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -244,7 +244,7 @@ export default function KnowledgeBaseViewer({ article, onClose }) {
|
||||
return (
|
||||
<code
|
||||
className={className}
|
||||
style={inline ? { background: 'rgba(14,165,233,0.15)', padding: '0.1rem 0.3rem', borderRadius: '0.25rem', fontFamily: 'monospace', fontSize: '0.85em' } : {}}
|
||||
style={inline ? { background: 'rgba(14,165,233,0.15)', padding: '0.1rem 0.3rem', borderRadius: 'var(--r-sm)', fontFamily: 'var(--font-mono)', fontSize: '0.85em' } : {}}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
@@ -263,9 +263,9 @@ export default function KnowledgeBaseViewer({ article, onClose }) {
|
||||
className="text-sm p-4 rounded overflow-auto"
|
||||
style={{
|
||||
background: 'rgba(15, 23, 42, 0.8)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
color: '#E2E8F0',
|
||||
fontFamily: 'monospace',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
color: 'var(--fg-2)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
maxHeight: '600px'
|
||||
|
||||
@@ -28,29 +28,29 @@ export default function LoginForm() {
|
||||
{/* Scanning line effect */}
|
||||
<div className="scan-line"></div>
|
||||
|
||||
<div className="intel-card rounded-lg shadow-2xl max-w-md w-full p-8 border-intel-accent relative z-10">
|
||||
<div style={{ background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(51,65,85,0.9) 50%, rgba(30,41,59,0.95) 100%)', border: '1.5px solid rgba(14,165,233,0.3)', borderRadius: 'var(--r-lg)', boxShadow: 'var(--shadow-modal)', padding: '2rem', position: 'relative', zIndex: 10, maxWidth: '28rem', width: '100%' }}>
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-intel-accent to-intel-accent-dim rounded-full flex items-center justify-center mx-auto mb-4 shadow-lg" style={{boxShadow: '0 0 30px rgba(0, 217, 255, 0.4)'}}>
|
||||
<Lock className="w-8 h-8 text-intel-darkest" />
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ background: 'linear-gradient(135deg, rgba(14,165,233,0.15) 0%, rgba(14,165,233,0.1) 100%)', boxShadow: '0 0 30px rgba(14, 165, 233, 0.4)' }}>
|
||||
<Lock className="w-8 h-8" style={{ color: 'var(--accent)' }} />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-intel-accent font-mono tracking-tight">CVE INTEL</h1>
|
||||
<p className="text-gray-400 mt-2 font-sans text-sm">Threat Intelligence Access Portal</p>
|
||||
<h1 style={{ fontSize: '1.875rem', fontWeight: '700', color: 'var(--accent)', fontFamily: 'var(--font-mono)', letterSpacing: 'var(--tracking-wider)', textTransform: 'uppercase', textShadow: 'var(--glow-heading)' }}>CVE INTEL</h1>
|
||||
<p style={{ color: 'var(--fg-muted)', marginTop: '0.5rem', fontFamily: 'var(--font-ui)', fontSize: '0.875rem' }}>Threat Intelligence Access Portal</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-intel-danger/10 border border-intel-danger/30 rounded flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-intel-danger flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-gray-300">{error}</p>
|
||||
<div className="mb-6 p-4 rounded flex items-start gap-3" style={{ background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)' }}>
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" style={{ color: '#EF4444' }} />
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--fg-3)' }}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-xs font-medium text-gray-400 mb-2 uppercase tracking-wider">
|
||||
<label htmlFor="username" style={{ display: 'block', fontSize: '0.75rem', fontWeight: '500', color: 'var(--fg-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wider)', fontFamily: 'var(--font-mono)' }}>
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="w-5 h-5 text-gray-500 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<User className="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2" style={{ color: 'var(--fg-disabled)' }} />
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
@@ -65,11 +65,11 @@ export default function LoginForm() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-xs font-medium text-gray-400 mb-2 uppercase tracking-wider">
|
||||
<label htmlFor="password" style={{ display: 'block', fontSize: '0.75rem', fontWeight: '500', color: 'var(--fg-muted)', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wider)', fontFamily: 'var(--font-mono)' }}>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="w-5 h-5 text-gray-500 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<Lock className="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2" style={{ color: 'var(--fg-disabled)' }} />
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -91,10 +91,10 @@ export default function LoginForm() {
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="loading-spinner w-5 h-5"></div>
|
||||
<span className="font-mono uppercase tracking-wider">Authenticating...</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wider)' }}>Authenticating...</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="font-mono uppercase tracking-wider">Access System</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wider)' }}>Access System</span>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -3,186 +3,110 @@ import { X, Home, BarChart2, BookOpen, Download, ShieldCheck, Settings, Ticket }
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' },
|
||||
{ id: 'triage', label: 'Vuln Triage', icon: BarChart2, color: '#F59E0B', description: 'Active findings & CVE triage' },
|
||||
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' },
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' },
|
||||
{ id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' },
|
||||
{ id: 'jira', label: 'Jira Tickets', icon: Ticket, color: '#6366F1', description: 'Jira issue tracking & sync' },
|
||||
{ id: 'home', label: 'Home', icon: Home },
|
||||
{ id: 'triage', label: 'Vuln Triage', icon: BarChart2 },
|
||||
{ id: 'compliance', label: 'Compliance', icon: ShieldCheck },
|
||||
{ id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen },
|
||||
{ id: 'exports', label: 'Exports', icon: Download },
|
||||
{ id: 'jira', label: 'Jira Tickets', icon: Ticket },
|
||||
];
|
||||
|
||||
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings, color: '#EF4444', description: 'User management & audit' };
|
||||
const ADMIN_ITEM = { id: 'admin', label: 'Admin Panel', icon: Settings };
|
||||
|
||||
export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) {
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const allItems = [
|
||||
...NAV_ITEMS,
|
||||
...(isAdmin() ? [ADMIN_ITEM] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{/* Overlay */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.65)',
|
||||
backdropFilter: 'blur(3px)',
|
||||
zIndex: 50
|
||||
background: 'var(--bg-overlay)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 60,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, bottom: 0, width: '280px',
|
||||
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||||
borderRight: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
boxShadow: '6px 0 32px rgba(0, 0, 0, 0.7)',
|
||||
zIndex: 51,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
padding: '1.5rem'
|
||||
<aside style={{
|
||||
position: 'fixed', left: 0, top: 0, bottom: 0,
|
||||
width: 'var(--drawer-w)',
|
||||
zIndex: 61,
|
||||
background: 'var(--bg-surface)',
|
||||
borderRight: '1px solid var(--border-1)',
|
||||
padding: 16,
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
}}>
|
||||
{/* Drawer header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '2rem' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.15em', textShadow: '0 0 12px rgba(14, 165, 233, 0.5)' }}>
|
||||
STEAM
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', textTransform: 'uppercase', letterSpacing: '0.1em', marginTop: '2px' }}>
|
||||
Security Dashboard
|
||||
</div>
|
||||
</div>
|
||||
{/* Navigation label + close button */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 8, padding: '4px 6px',
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 11,
|
||||
color: 'var(--fg-muted)', textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||
}}>Navigation</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ color: '#475569', padding: '0.25rem', background: 'none', border: 'none', cursor: 'pointer', lineHeight: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#475569'}
|
||||
style={{
|
||||
background: 'transparent', border: 'none',
|
||||
color: 'var(--fg-muted)', cursor: 'pointer', display: 'flex',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--fg-1)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--fg-muted)'; }}
|
||||
>
|
||||
<X style={{ width: '20px', height: '20px' }} />
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<nav style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
{NAV_ITEMS.map(({ id, label, icon: Icon, color, description }) => {
|
||||
const active = currentPage === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => { onNavigate(id); onClose(); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.875rem',
|
||||
padding: '0.75rem 0.875rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: active ? `1px solid ${color}50` : '1px solid transparent',
|
||||
background: active ? `${color}18` : 'transparent',
|
||||
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||
transition: 'background 0.15s, border-color 0.15s'
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{/* Icon box */}
|
||||
<div style={{
|
||||
width: '36px', height: '36px', flexShrink: 0,
|
||||
borderRadius: '0.375rem',
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}40`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}>
|
||||
<Icon style={{ width: '17px', height: '17px', color }} />
|
||||
</div>
|
||||
|
||||
{/* Label + description */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
|
||||
color: active ? color : '#CBD5E1',
|
||||
textTransform: 'uppercase', letterSpacing: '0.06em'
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.68rem', color: '#475569', marginTop: '1px' }}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active indicator dot */}
|
||||
{active && (
|
||||
<div style={{
|
||||
width: '6px', height: '6px', borderRadius: '50%',
|
||||
background: color, boxShadow: `0 0 8px ${color}`, flexShrink: 0
|
||||
}} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Admin panel link — visible only to Admin group */}
|
||||
{isAdmin() && (() => {
|
||||
const { id, label, icon: Icon, color, description } = ADMIN_ITEM;
|
||||
const active = currentPage === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => { onNavigate(id); onClose(); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.875rem',
|
||||
padding: '0.75rem 0.875rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: active ? `1px solid ${color}50` : '1px solid transparent',
|
||||
background: active ? `${color}18` : 'transparent',
|
||||
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||
marginTop: '0.5rem',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
paddingTop: '1rem',
|
||||
transition: 'background 0.15s, border-color 0.15s'
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{
|
||||
width: '36px', height: '36px', flexShrink: 0,
|
||||
borderRadius: '0.375rem',
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}40`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}>
|
||||
<Icon style={{ width: '17px', height: '17px', color }} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
|
||||
color: active ? color : '#CBD5E1',
|
||||
textTransform: 'uppercase', letterSpacing: '0.06em'
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.68rem', color: '#475569', marginTop: '1px' }}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<div style={{
|
||||
width: '6px', height: '6px', borderRadius: '50%',
|
||||
background: color, boxShadow: `0 0 8px ${color}`, flexShrink: 0
|
||||
}} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</nav>
|
||||
{allItems.map(({ id, label, icon: Icon }) => {
|
||||
const active = currentPage === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => { onNavigate(id); onClose(); }}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--bg-elevated)'; }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
background: active ? 'var(--accent-soft)' : 'transparent',
|
||||
color: active ? 'var(--accent)' : 'var(--fg-2)',
|
||||
border: 'none', borderRadius: 6, padding: '9px 10px',
|
||||
fontFamily: 'var(--font-ui)', fontWeight: active ? 600 : 500, fontSize: 13,
|
||||
cursor: 'pointer', textAlign: 'left', width: '100%',
|
||||
transition: 'background 150ms, color 150ms',
|
||||
}}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
marginTop: 'auto', paddingTop: '1rem',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
textAlign: 'center'
|
||||
borderTop: '1px solid var(--border-1)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: '0.6rem', color: '#1E293B', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||
<div style={{
|
||||
fontSize: '0.6rem', color: 'var(--fg-muted)',
|
||||
fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
}}>
|
||||
NTS Threat Intelligence
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,40 +217,40 @@ export default function NvdSyncModal({ onClose, onSyncComplete }) {
|
||||
const errorCount = Object.values(results).filter(r => r.status === 'error').length;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] flex flex-col">
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--bg-overlay)', backdropFilter: 'blur(12px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 'var(--z-modal)', padding: '1rem' }}>
|
||||
<div style={{ background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(51,65,85,0.9) 50%, rgba(30,41,59,0.95) 100%)', border: '1.5px solid rgba(14,165,233,0.3)', borderRadius: 'var(--r-xl)', boxShadow: 'var(--shadow-modal)', maxWidth: '64rem', width: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 flex justify-between items-center flex-shrink-0">
|
||||
<div style={{ padding: '1.5rem', borderBottom: '1px solid var(--border-subtle)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<RefreshCw className="w-6 h-6 text-green-600" />
|
||||
<h2 style={{ fontFamily: 'var(--font-mono)', fontSize: '1.5rem', fontWeight: '700', color: '#10B981', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wider)', textShadow: '0 0 16px rgba(16,185,129,0.3)', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<RefreshCw className="w-6 h-6" style={{ color: '#10B981' }} />
|
||||
Sync with NVD
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Update existing CVE entries with data from the National Vulnerability Database</p>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)', marginTop: '0.25rem' }}>Update existing CVE entries with data from the National Vulnerability Database</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<button onClick={onClose} style={{ color: 'var(--fg-disabled)', cursor: 'pointer', background: 'none', border: 'none' }}>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div style={{ padding: '1.5rem', overflowY: 'auto', flex: 1 }}>
|
||||
{/* Idle Phase */}
|
||||
{phase === 'idle' && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-lg text-gray-700 mb-2">
|
||||
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
|
||||
<p style={{ fontSize: '1rem', color: 'var(--fg-2)', marginBottom: '0.5rem' }}>
|
||||
{cveIds.length > 0
|
||||
? <><strong>{cveIds.length}</strong> unique CVE{cveIds.length !== 1 ? 's' : ''} in database</>
|
||||
: 'Loading CVE count...'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)', marginBottom: '1.5rem' }}>
|
||||
This will fetch data from NVD for each CVE and let you review changes before applying.
|
||||
Rate-limited to stay within NVD API limits.
|
||||
</p>
|
||||
<button
|
||||
onClick={fetchNvdData}
|
||||
disabled={cveIds.length === 0}
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-md disabled:opacity-50 flex items-center gap-2 mx-auto"
|
||||
style={{ padding: '0.75rem 1.5rem', background: 'rgba(16,185,129,0.08)', border: '1px solid #10B981', color: '#34D399', borderRadius: 'var(--r-md)', cursor: cveIds.length === 0 ? 'not-allowed' : 'pointer', fontFamily: 'var(--font-mono)', fontWeight: '600', fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', display: 'inline-flex', alignItems: 'center', gap: '0.5rem', opacity: cveIds.length === 0 ? 0.5 : 1 }}
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
Fetch NVD Data
|
||||
@@ -260,25 +260,24 @@ export default function NvdSyncModal({ onClose, onSyncComplete }) {
|
||||
|
||||
{/* Fetching Phase */}
|
||||
{phase === 'fetching' && (
|
||||
<div className="py-8">
|
||||
<div className="text-center mb-6">
|
||||
<Loader className="w-8 h-8 text-green-600 animate-spin mx-auto mb-3" />
|
||||
<p className="text-lg text-gray-700">
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
|
||||
<Loader className="w-8 h-8 animate-spin" style={{ color: '#10B981', margin: '0 auto 0.75rem' }} />
|
||||
<p style={{ fontSize: '1rem', color: 'var(--fg-2)' }}>
|
||||
Fetching CVE {fetchProgress.current} of {fetchProgress.total}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 font-mono mt-1">{fetchProgress.currentId}</p>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', marginTop: '0.25rem' }}>{fetchProgress.currentId}</p>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 mb-4">
|
||||
<div style={{ width: '100%', background: 'rgba(255,255,255,0.1)', borderRadius: 'var(--r-pill)', height: '0.75rem', marginBottom: '1rem' }}>
|
||||
<div
|
||||
className="bg-green-600 h-3 rounded-full transition-all duration-300"
|
||||
style={{ width: `${fetchProgress.total > 0 ? (fetchProgress.current / fetchProgress.total) * 100 : 0}%` }}
|
||||
style={{ background: '#10B981', height: '0.75rem', borderRadius: 'var(--r-pill)', transition: 'width 0.3s', width: `${fetchProgress.total > 0 ? (fetchProgress.current / fetchProgress.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button
|
||||
onClick={cancelFetch}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
style={{ padding: '0.5rem 1rem', background: 'rgba(148,163,184,0.08)', border: '1px solid rgba(148,163,184,0.3)', color: 'var(--fg-muted)', borderRadius: 'var(--r-md)', cursor: 'pointer', fontFamily: 'var(--font-mono)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -290,31 +289,31 @@ export default function NvdSyncModal({ onClose, onSyncComplete }) {
|
||||
{phase === 'review' && (
|
||||
<div>
|
||||
{/* Summary bar */}
|
||||
<div className="flex flex-wrap gap-3 mb-4 p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<span className="font-medium">Found: <span className="text-green-700">{foundCount}</span></span>
|
||||
<span>|</span>
|
||||
<span>Up to date: <span className="text-gray-600">{noChangeCount}</span></span>
|
||||
<span>|</span>
|
||||
<span>Changes: <span className="text-blue-700">{foundCount}</span></span>
|
||||
<span>|</span>
|
||||
<span>Not in NVD: <span className="text-gray-400">{notFoundCount}</span></span>
|
||||
<span>|</span>
|
||||
<span>Errors: <span className="text-red-600">{errorCount}</span></span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', marginBottom: '1rem', padding: '0.75rem', background: 'rgba(15,23,42,0.4)', borderRadius: 'var(--r-md)', fontSize: '0.875rem', fontFamily: 'var(--font-mono)', border: '1px solid var(--border-subtle)' }}>
|
||||
<span style={{ fontWeight: '500' }}>Found: <span style={{ color: '#10B981' }}>{foundCount}</span></span>
|
||||
<span style={{ color: 'var(--fg-disabled)' }}>|</span>
|
||||
<span>Up to date: <span style={{ color: 'var(--fg-muted)' }}>{noChangeCount}</span></span>
|
||||
<span style={{ color: 'var(--fg-disabled)' }}>|</span>
|
||||
<span>Changes: <span style={{ color: '#0EA5E9' }}>{foundCount}</span></span>
|
||||
<span style={{ color: 'var(--fg-disabled)' }}>|</span>
|
||||
<span>Not in NVD: <span style={{ color: 'var(--fg-disabled)' }}>{notFoundCount}</span></span>
|
||||
<span style={{ color: 'var(--fg-disabled)' }}>|</span>
|
||||
<span>Errors: <span style={{ color: '#EF4444' }}>{errorCount}</span></span>
|
||||
</div>
|
||||
|
||||
{/* Bulk controls */}
|
||||
{Object.keys(descriptionChoices).length > 0 && (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<span className="text-sm text-gray-600 self-center">Descriptions:</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)' }}>Descriptions:</span>
|
||||
<button
|
||||
onClick={() => setBulkDescriptionChoice('keep')}
|
||||
className="px-3 py-1 text-xs rounded border border-gray-300 hover:bg-gray-100 transition-colors"
|
||||
style={{ padding: '0.25rem 0.75rem', fontSize: '0.75rem', borderRadius: 'var(--r-sm)', border: '1px solid var(--border-subtle)', background: 'none', color: 'var(--fg-muted)', cursor: 'pointer', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}
|
||||
>
|
||||
Keep All Existing
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBulkDescriptionChoice('nvd')}
|
||||
className="px-3 py-1 text-xs rounded border border-green-300 text-green-700 hover:bg-green-50 transition-colors"
|
||||
style={{ padding: '0.25rem 0.75rem', fontSize: '0.75rem', borderRadius: 'var(--r-sm)', border: '1px solid rgba(16,185,129,0.3)', background: 'none', color: '#10B981', cursor: 'pointer', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}
|
||||
>
|
||||
Use All NVD
|
||||
</button>
|
||||
@@ -322,34 +321,34 @@ export default function NvdSyncModal({ onClose, onSyncComplete }) {
|
||||
)}
|
||||
|
||||
{/* Comparison table */}
|
||||
<div className="space-y-2">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{Object.entries(results).map(([cveId, r]) => {
|
||||
if (r.status === 'no_change') {
|
||||
return (
|
||||
<div key={cveId} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<span className="text-gray-400">—</span>
|
||||
<span className="font-mono font-medium text-gray-500">{cveId}</span>
|
||||
<span className="text-gray-400">No changes needed</span>
|
||||
<div key={cveId} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem', background: 'rgba(15,23,42,0.4)', borderRadius: 'var(--r-md)', fontSize: '0.875rem', border: '1px solid var(--border-subtle)' }}>
|
||||
<span style={{ color: 'var(--fg-disabled)' }}>—</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: '500', color: 'var(--fg-muted)' }}>{cveId}</span>
|
||||
<span style={{ color: 'var(--fg-disabled)' }}>No changes needed</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (r.status === 'not_found') {
|
||||
return (
|
||||
<div key={cveId} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<span className="text-gray-400">—</span>
|
||||
<span className="font-mono font-medium text-gray-400">{cveId}</span>
|
||||
<span className="text-gray-400 italic">Not found in NVD</span>
|
||||
<div key={cveId} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem', background: 'rgba(15,23,42,0.4)', borderRadius: 'var(--r-md)', fontSize: '0.875rem', border: '1px solid var(--border-subtle)' }}>
|
||||
<span style={{ color: 'var(--fg-disabled)' }}>—</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: '500', color: 'var(--fg-disabled)' }}>{cveId}</span>
|
||||
<span style={{ color: 'var(--fg-disabled)', fontStyle: 'italic' }}>Not found in NVD</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (r.status === 'error') {
|
||||
return (
|
||||
<div key={cveId} className="flex items-center gap-3 p-3 bg-red-50 rounded-lg text-sm">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
|
||||
<span className="font-mono font-medium text-gray-700">{cveId}</span>
|
||||
<span className="text-red-600">{r.error}</span>
|
||||
<div key={cveId} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem', background: 'rgba(239,68,68,0.08)', borderRadius: 'var(--r-md)', fontSize: '0.875rem', border: '1px solid rgba(239,68,68,0.2)' }}>
|
||||
<AlertCircle className="w-4 h-4" style={{ color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: '500', color: 'var(--fg-3)' }}>{cveId}</span>
|
||||
<span style={{ color: '#EF4444' }}>{r.error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -357,74 +356,72 @@ export default function NvdSyncModal({ onClose, onSyncComplete }) {
|
||||
// status === 'found' — show changes
|
||||
const isExpanded = expandedDesc[cveId];
|
||||
return (
|
||||
<div key={cveId} className="border border-gray-200 rounded-lg p-4 bg-white">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="w-4 h-4 text-green-500 mt-1 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||
<span className="font-mono font-bold text-gray-900">{cveId}</span>
|
||||
<div key={cveId} style={{ border: '1px solid var(--border-subtle)', borderRadius: 'var(--r-md)', padding: '1rem', background: 'rgba(15,23,42,0.4)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.75rem' }}>
|
||||
<CheckCircle className="w-4 h-4" style={{ color: '#10B981', marginTop: '0.25rem', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: '700', color: 'var(--fg-2)' }}>{cveId}</span>
|
||||
{r.sevChanged && (
|
||||
<span className="text-xs">
|
||||
Severity: <span className="text-red-600">{r.current.severity}</span>
|
||||
<span style={{ fontSize: '0.75rem' }}>
|
||||
Severity: <span style={{ color: '#EF4444' }}>{r.current.severity}</span>
|
||||
{' → '}
|
||||
<span className="text-green-700">{r.nvd.severity}</span>
|
||||
<span style={{ color: '#10B981' }}>{r.nvd.severity}</span>
|
||||
</span>
|
||||
)}
|
||||
{r.dateChanged && (
|
||||
<span className="text-xs">
|
||||
Date: <span className="text-red-600">{r.current.published_date || '(none)'}</span>
|
||||
<span style={{ fontSize: '0.75rem' }}>
|
||||
Date: <span style={{ color: '#EF4444' }}>{r.current.published_date || '(none)'}</span>
|
||||
{' → '}
|
||||
<span className="text-green-700">{r.nvd.published_date}</span>
|
||||
<span style={{ color: '#10B981' }}>{r.nvd.published_date}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{r.descChanged && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-medium text-gray-600">Description:</span>
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: '500', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)' }}>Description:</span>
|
||||
<button
|
||||
onClick={() => setExpandedDesc(prev => ({ ...prev, [cveId]: !prev[cveId] }))}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||
style={{ fontSize: '0.75rem', color: '#0EA5E9', background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.25rem' }}
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="p-2 bg-red-50 rounded border border-red-200">
|
||||
<span className="font-medium text-red-700">Current: </span>
|
||||
<span className="text-gray-700">{r.current.description || '(empty)'}</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', fontSize: '0.75rem' }}>
|
||||
<div style={{ padding: '0.5rem', background: 'rgba(239,68,68,0.08)', borderRadius: 'var(--r-sm)', border: '1px solid rgba(239,68,68,0.2)' }}>
|
||||
<span style={{ fontWeight: '500', color: '#EF4444' }}>Current: </span>
|
||||
<span style={{ color: 'var(--fg-3)' }}>{r.current.description || '(empty)'}</span>
|
||||
</div>
|
||||
<div className="p-2 bg-green-50 rounded border border-green-200">
|
||||
<span className="font-medium text-green-700">NVD: </span>
|
||||
<span className="text-gray-700">{r.nvd.description}</span>
|
||||
<div style={{ padding: '0.5rem', background: 'rgba(16,185,129,0.08)', borderRadius: 'var(--r-sm)', border: '1px solid rgba(16,185,129,0.2)' }}>
|
||||
<span style={{ fontWeight: '500', color: '#10B981' }}>NVD: </span>
|
||||
<span style={{ color: 'var(--fg-3)' }}>{r.nvd.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">{truncate(r.nvd.description)}</p>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--fg-muted)' }}>{truncate(r.nvd.description)}</p>
|
||||
)}
|
||||
|
||||
{/* Description choice */}
|
||||
<div className="flex gap-4 mt-2">
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '0.5rem' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem', cursor: 'pointer', color: 'var(--fg-3)' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`desc-${cveId}`}
|
||||
checked={descriptionChoices[cveId] === 'keep'}
|
||||
onChange={() => setDescriptionChoices(prev => ({ ...prev, [cveId]: 'keep' }))}
|
||||
className="text-blue-600"
|
||||
/>
|
||||
Keep existing
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.75rem', cursor: 'pointer', color: 'var(--fg-3)' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`desc-${cveId}`}
|
||||
checked={descriptionChoices[cveId] === 'nvd'}
|
||||
onChange={() => setDescriptionChoices(prev => ({ ...prev, [cveId]: 'nvd' }))}
|
||||
className="text-green-600"
|
||||
/>
|
||||
Use NVD
|
||||
</label>
|
||||
@@ -442,30 +439,30 @@ export default function NvdSyncModal({ onClose, onSyncComplete }) {
|
||||
|
||||
{/* Applying Phase */}
|
||||
{phase === 'applying' && (
|
||||
<div className="text-center py-12">
|
||||
<Loader className="w-10 h-10 text-green-600 animate-spin mx-auto mb-4" />
|
||||
<p className="text-lg text-gray-700">Applying changes...</p>
|
||||
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
||||
<Loader className="w-10 h-10 animate-spin" style={{ color: '#10B981', margin: '0 auto 1rem' }} />
|
||||
<p style={{ fontSize: '1rem', color: 'var(--fg-2)' }}>Applying changes...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Done Phase */}
|
||||
{phase === 'done' && applyResult && (
|
||||
<div className="text-center py-8">
|
||||
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
|
||||
{applyResult.error ? (
|
||||
<>
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-lg text-red-700 font-medium mb-2">Sync failed</p>
|
||||
<p className="text-sm text-gray-600">{applyResult.error}</p>
|
||||
<AlertCircle className="w-12 h-12" style={{ color: '#EF4444', margin: '0 auto 1rem' }} />
|
||||
<p style={{ fontSize: '1rem', color: '#EF4444', fontWeight: '500', marginBottom: '0.5rem' }}>Sync failed</p>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)' }}>{applyResult.error}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-4" />
|
||||
<p className="text-lg text-green-700 font-medium mb-2">Sync complete</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<CheckCircle className="w-12 h-12" style={{ color: '#10B981', margin: '0 auto 1rem' }} />
|
||||
<p style={{ fontSize: '1rem', color: '#10B981', fontWeight: '500', marginBottom: '0.5rem' }}>Sync complete</p>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)' }}>
|
||||
{applyResult.updated} row{applyResult.updated !== 1 ? 's' : ''} updated
|
||||
</p>
|
||||
{applyResult.errors && applyResult.errors.length > 0 && (
|
||||
<p className="text-sm text-amber-600 mt-2">
|
||||
<p style={{ fontSize: '0.875rem', color: '#F59E0B', marginTop: '0.5rem' }}>
|
||||
{applyResult.errors.length} error{applyResult.errors.length !== 1 ? 's' : ''} occurred
|
||||
</p>
|
||||
)}
|
||||
@@ -476,19 +473,19 @@ export default function NvdSyncModal({ onClose, onSyncComplete }) {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-200 flex justify-end gap-3 flex-shrink-0">
|
||||
<div style={{ padding: '1.5rem', borderTop: '1px solid var(--border-subtle)', display: 'flex', justifyContent: 'flex-end', gap: '0.75rem', flexShrink: 0 }}>
|
||||
{phase === 'review' && (
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
style={{ padding: '0.5rem 1rem', background: 'rgba(148,163,184,0.08)', border: '1px solid rgba(148,163,184,0.3)', color: 'var(--fg-muted)', borderRadius: 'var(--r-md)', cursor: 'pointer', fontFamily: 'var(--font-mono)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={applyChanges}
|
||||
disabled={getChangesCount() === 0}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium shadow-md disabled:opacity-50"
|
||||
style={{ padding: '0.5rem 1.5rem', background: 'rgba(16,185,129,0.08)', border: '1px solid #10B981', color: '#34D399', borderRadius: 'var(--r-md)', cursor: getChangesCount() === 0 ? 'not-allowed' : 'pointer', fontFamily: 'var(--font-mono)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', opacity: getChangesCount() === 0 ? 0.5 : 1 }}
|
||||
>
|
||||
Apply {getChangesCount()} Change{getChangesCount() !== 1 ? 's' : ''}
|
||||
</button>
|
||||
@@ -497,7 +494,7 @@ export default function NvdSyncModal({ onClose, onSyncComplete }) {
|
||||
{phase === 'done' && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors font-medium shadow-md"
|
||||
style={{ padding: '0.5rem 1.5rem', background: 'rgba(14,165,233,0.08)', border: '1px solid #0EA5E9', color: '#38BDF8', borderRadius: 'var(--r-md)', cursor: 'pointer', fontFamily: 'var(--font-mono)', fontWeight: '600', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
@@ -18,10 +18,10 @@ const GROUP_LABELS = {
|
||||
};
|
||||
|
||||
const GROUP_BADGE_STYLES = {
|
||||
Admin: { backgroundColor: '#FEE2E2', color: '#991B1B' },
|
||||
Standard_User: { backgroundColor: '#DBEAFE', color: '#1E40AF' },
|
||||
Leadership: { backgroundColor: '#F3E8FF', color: '#6B21A8' },
|
||||
Read_Only: { backgroundColor: '#F3F4F6', color: '#374151' }
|
||||
Admin: { backgroundColor: 'rgba(239,68,68,0.15)', color: 'var(--group-admin)', border: '1px solid rgba(239,68,68,0.3)' },
|
||||
Standard_User: { backgroundColor: 'rgba(56,189,248,0.15)', color: 'var(--group-standard)', border: '1px solid rgba(56,189,248,0.3)' },
|
||||
Leadership: { backgroundColor: 'rgba(245,158,11,0.15)', color: 'var(--group-leadership)', border: '1px solid rgba(245,158,11,0.3)' },
|
||||
Read_Only: { backgroundColor: 'rgba(148,163,184,0.15)', color: 'var(--group-readonly)', border: '1px solid rgba(148,163,184,0.3)' }
|
||||
};
|
||||
|
||||
export default function UserManagement({ onClose }) {
|
||||
@@ -196,22 +196,22 @@ export default function UserManagement({ onClose }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="p-6 border-b border-gray-200 flex justify-between items-center">
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--bg-overlay)', backdropFilter: 'blur(12px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 'var(--z-modal)', padding: '1rem' }}>
|
||||
<div style={{ background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(51,65,85,0.9) 50%, rgba(30,41,59,0.95) 100%)', border: '1.5px solid rgba(14,165,233,0.3)', borderRadius: 'var(--r-xl)', boxShadow: 'var(--shadow-modal)', maxWidth: '56rem', width: '100%', maxHeight: '90vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '1.5rem', borderBottom: '1px solid var(--border-subtle)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">User Management</h2>
|
||||
<p className="text-gray-600">Manage user accounts and permissions</p>
|
||||
<h2 style={{ fontFamily: 'var(--font-mono)', fontSize: '1.5rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wider)', textShadow: 'var(--glow-heading)' }}>User Management</h2>
|
||||
<p style={{ color: 'var(--fg-muted)', fontSize: '0.875rem' }}>Manage user accounts and permissions</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 p-2"
|
||||
style={{ color: 'var(--fg-disabled)', cursor: 'pointer', background: 'none', border: 'none', padding: '0.5rem' }}
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div style={{ padding: '1.5rem', overflowY: 'auto', flex: 1 }}>
|
||||
{!showAddUser && (
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -221,7 +221,7 @@ export default function UserManagement({ onClose }) {
|
||||
setFormError('');
|
||||
setFormSuccess('');
|
||||
}}
|
||||
className="mb-6 px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors flex items-center gap-2 shadow-md"
|
||||
style={{ marginBottom: '1.5rem', padding: '0.5rem 1rem', background: 'rgba(14,165,233,0.08)', border: '1px solid #0EA5E9', color: '#38BDF8', borderRadius: 'var(--r-md)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', fontWeight: '600', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add User
|
||||
@@ -229,61 +229,65 @@ export default function UserManagement({ onClose }) {
|
||||
)}
|
||||
|
||||
{showAddUser && (
|
||||
<div className="mb-6 p-6 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
<div style={{ marginBottom: '1.5rem', padding: '1.5rem', background: 'rgba(15,23,42,0.6)', borderRadius: 'var(--r-lg)', border: '1px solid var(--border-subtle)' }}>
|
||||
<h3 style={{ fontSize: '1rem', fontWeight: '600', marginBottom: '1rem', color: 'var(--fg-2)', fontFamily: 'var(--font-mono)' }}>
|
||||
{editingUser ? 'Edit User' : 'Add New User'}
|
||||
</h3>
|
||||
|
||||
{formError && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
<span className="text-sm text-red-700">{formError}</span>
|
||||
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 'var(--r-md)', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<AlertCircle className="w-5 h-5" style={{ color: '#EF4444' }} />
|
||||
<span style={{ fontSize: '0.875rem', color: '#FCA5A5' }}>{formError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formSuccess && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
<span className="text-sm text-green-700">{formSuccess}</span>
|
||||
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: 'rgba(16,185,129,0.08)', border: '1px solid rgba(16,185,129,0.3)', borderRadius: 'var(--r-md)', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<CheckCircle className="w-5 h-5" style={{ color: '#10B981' }} />
|
||||
<span style={{ fontSize: '0.875rem', color: '#6EE7B7' }}>{formSuccess}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: '500', color: 'var(--fg-muted)', marginBottom: '0.25rem', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
|
||||
Username *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<div style={{ position: 'relative' }}>
|
||||
<User className="w-5 h-5" style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', color: 'var(--fg-disabled)' }} />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||
style={{ width: '100%', paddingLeft: '2.5rem', paddingRight: '1rem', paddingTop: '0.5rem', paddingBottom: '0.5rem', background: 'var(--bg-input)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--r-md)', color: 'var(--fg-1)', fontFamily: 'var(--font-mono)', fontSize: '0.875rem', outline: 'none' }}
|
||||
onFocus={e => { e.target.style.borderColor = 'var(--border-focus)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border-subtle)'; e.target.style.boxShadow = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: '500', color: 'var(--fg-muted)', marginBottom: '0.25rem', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
|
||||
Email *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Mail className="w-5 h-5" style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', color: 'var(--fg-disabled)' }} />
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||
style={{ width: '100%', paddingLeft: '2.5rem', paddingRight: '1rem', paddingTop: '0.5rem', paddingBottom: '0.5rem', background: 'var(--bg-input)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--r-md)', color: 'var(--fg-1)', fontFamily: 'var(--font-mono)', fontSize: '0.875rem', outline: 'none' }}
|
||||
onFocus={e => { e.target.style.borderColor = 'var(--border-focus)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border-subtle)'; e.target.style.boxShadow = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: '500', color: 'var(--fg-muted)', marginBottom: '0.25rem', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
|
||||
Password {editingUser ? '(leave blank to keep current)' : '*'}
|
||||
</label>
|
||||
<input
|
||||
@@ -291,21 +295,25 @@ export default function UserManagement({ onClose }) {
|
||||
required={!editingUser}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent"
|
||||
style={{ width: '100%', padding: '0.5rem 1rem', background: 'var(--bg-input)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--r-md)', color: 'var(--fg-1)', fontFamily: 'var(--font-mono)', fontSize: '0.875rem', outline: 'none' }}
|
||||
onFocus={e => { e.target.style.borderColor = 'var(--border-focus)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border-subtle)'; e.target.style.boxShadow = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', fontWeight: '500', color: 'var(--fg-muted)', marginBottom: '0.25rem', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
|
||||
Group *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Shield className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Shield className="w-5 h-5" style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', color: 'var(--fg-disabled)' }} />
|
||||
<select
|
||||
value={formData.group}
|
||||
onChange={(e) => setFormData({ ...formData, group: e.target.value })}
|
||||
disabled={isGroupDropdownDisabled(editingUser)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#0476D9] focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ width: '100%', paddingLeft: '2.5rem', paddingRight: '1rem', paddingTop: '0.5rem', paddingBottom: '0.5rem', background: 'var(--bg-input)', border: '1px solid var(--border-subtle)', borderRadius: 'var(--r-md)', color: 'var(--fg-1)', fontFamily: 'var(--font-mono)', fontSize: '0.875rem', outline: 'none', cursor: isGroupDropdownDisabled(editingUser) ? 'not-allowed' : 'pointer', opacity: isGroupDropdownDisabled(editingUser) ? 0.5 : 1 }}
|
||||
onFocus={e => { e.target.style.borderColor = 'var(--border-focus)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}
|
||||
onBlur={e => { e.target.style.borderColor = 'var(--border-subtle)'; e.target.style.boxShadow = 'none'; }}
|
||||
title={isGroupDropdownDisabled(editingUser) ? 'Cannot change your own Admin group' : ''}
|
||||
>
|
||||
{VALID_GROUPS.map((g) => (
|
||||
@@ -313,16 +321,16 @@ export default function UserManagement({ onClose }) {
|
||||
))}
|
||||
</select>
|
||||
{isGroupDropdownDisabled(editingUser) && (
|
||||
<p className="text-xs text-amber-600 mt-1">You cannot change your own Admin group.</p>
|
||||
<p style={{ fontSize: '0.75rem', color: '#F59E0B', marginTop: '0.25rem' }}>You cannot change your own Admin group.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<div style={{ display: 'flex', gap: '0.75rem', paddingTop: '0.5rem' }}>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-[#0476D9] text-white rounded-lg hover:bg-[#0360B8] transition-colors"
|
||||
style={{ padding: '0.5rem 1rem', background: 'rgba(14,165,233,0.08)', border: '1px solid #0EA5E9', color: '#38BDF8', borderRadius: 'var(--r-md)', cursor: 'pointer', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', fontWeight: '600', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}
|
||||
>
|
||||
{editingUser ? 'Update User' : 'Create User'}
|
||||
</button>
|
||||
@@ -332,7 +340,7 @@ export default function UserManagement({ onClose }) {
|
||||
setShowAddUser(false);
|
||||
setEditingUser(null);
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
style={{ padding: '0.5rem 1rem', background: 'rgba(148,163,184,0.08)', border: '1px solid rgba(148,163,184,0.3)', color: 'var(--fg-muted)', borderRadius: 'var(--r-md)', cursor: 'pointer', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', fontWeight: '600', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -342,73 +350,86 @@ export default function UserManagement({ onClose }) {
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<Loader className="w-8 h-8 text-[#0476D9] mx-auto animate-spin" />
|
||||
<p className="text-gray-600 mt-2">Loading users...</p>
|
||||
<div style={{ textAlign: 'center', padding: '3rem' }}>
|
||||
<Loader className="w-8 h-8 mx-auto animate-spin" style={{ color: '#0EA5E9' }} />
|
||||
<p style={{ color: 'var(--fg-muted)', marginTop: '0.5rem' }}>Loading users...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle className="w-8 h-8 text-red-500 mx-auto" />
|
||||
<p className="text-red-600 mt-2">{error}</p>
|
||||
<div style={{ textAlign: 'center', padding: '3rem' }}>
|
||||
<AlertCircle className="w-8 h-8 mx-auto" style={{ color: '#EF4444' }} />
|
||||
<p style={{ color: '#EF4444', marginTop: '0.5rem' }}>{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">User</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Group</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Status</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Last Login</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-700">Actions</th>
|
||||
<tr style={{ borderBottom: '1px solid var(--border-subtle)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem 1rem', fontSize: '0.75rem', fontWeight: '600', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>User</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem 1rem', fontSize: '0.75rem', fontWeight: '600', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Group</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem 1rem', fontSize: '0.75rem', fontWeight: '600', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Status</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem 1rem', fontSize: '0.75rem', fontWeight: '600', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Last Login</th>
|
||||
<th style={{ textAlign: 'right', padding: '0.75rem 1rem', fontSize: '0.75rem', fontWeight: '600', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<tr key={user.id} style={{ borderBottom: '1px solid var(--border-subtle)', transition: 'background var(--dur-fast)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(14,165,233,0.06)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<td style={{ padding: '0.75rem 1rem' }}>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{user.username}</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
<p style={{ fontWeight: '500', color: 'var(--fg-2)' }}>{user.username}</p>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--fg-muted)' }}>{user.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<td style={{ padding: '0.75rem 1rem' }}>
|
||||
<span
|
||||
style={{
|
||||
...GROUP_BADGE_STYLES[user.group] || GROUP_BADGE_STYLES.Read_Only,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
display: 'inline-block'
|
||||
borderRadius: 'var(--r-pill)',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
display: 'inline-block',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
}}
|
||||
>
|
||||
{user.group ? user.group.replace('_', ' ') : 'Read Only'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<td style={{ padding: '0.75rem 1rem' }}>
|
||||
<button
|
||||
onClick={() => handleToggleActive(user)}
|
||||
disabled={user.id === currentUser.id}
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
user.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
} ${user.id === currentUser.id ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:opacity-80'}`}
|
||||
style={{
|
||||
padding: '0.125rem 0.5rem',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
border: 'none',
|
||||
cursor: user.id === currentUser.id ? 'not-allowed' : 'pointer',
|
||||
opacity: user.id === currentUser.id ? 0.5 : 1,
|
||||
background: user.is_active ? 'rgba(16,185,129,0.15)' : 'rgba(239,68,68,0.15)',
|
||||
color: user.is_active ? '#10B981' : '#EF4444',
|
||||
}}
|
||||
>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-500">
|
||||
<td style={{ padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)' }}>
|
||||
{user.last_login
|
||||
? new Date(user.last_login).toLocaleString()
|
||||
: 'Never'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<td style={{ padding: '0.75rem 1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||
<button
|
||||
onClick={() => handleEdit(user)}
|
||||
className="p-2 text-gray-600 hover:bg-gray-100 rounded"
|
||||
style={{ padding: '0.5rem', color: 'var(--fg-muted)', background: 'none', border: 'none', cursor: 'pointer', borderRadius: 'var(--r-sm)' }}
|
||||
title="Edit user"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
@@ -416,9 +437,7 @@ export default function UserManagement({ onClose }) {
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
disabled={user.id === currentUser.id}
|
||||
className={`p-2 text-red-600 hover:bg-red-50 rounded ${
|
||||
user.id === currentUser.id ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
style={{ padding: '0.5rem', color: '#EF4444', background: 'none', border: 'none', cursor: user.id === currentUser.id ? 'not-allowed' : 'pointer', opacity: user.id === currentUser.id ? 0.5 : 1, borderRadius: 'var(--r-sm)' }}
|
||||
title="Delete user"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAuth } from '../contexts/AuthContext';
|
||||
import UserProfilePanel from './UserProfilePanel';
|
||||
|
||||
// ============================================
|
||||
// INLINE STYLES — Dark theme matching DESIGN_SYSTEM.md
|
||||
// INLINE STYLES — Design-token based
|
||||
// ============================================
|
||||
const STYLES = {
|
||||
container: {
|
||||
@@ -13,47 +13,42 @@ const STYLES = {
|
||||
menuButton: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '0.5rem',
|
||||
gap: 10,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 6,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
border: '1px solid var(--border-1)',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
fontSize: 13,
|
||||
color: 'var(--fg-1)',
|
||||
transition: 'background 0.2s',
|
||||
},
|
||||
menuButtonHover: {
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
background: 'var(--bg-elevated)',
|
||||
},
|
||||
avatar: {
|
||||
width: '2rem',
|
||||
height: '2rem',
|
||||
backgroundColor: '#0476D9',
|
||||
borderRadius: '9999px',
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--accent-soft)',
|
||||
color: 'var(--accent)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarIcon: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
userInfo: {
|
||||
textAlign: 'left',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
fontWeight: 700,
|
||||
fontSize: 11,
|
||||
},
|
||||
username: {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
color: '#F8FAFC',
|
||||
margin: 0,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
groupLabel: {
|
||||
fontSize: '0.75rem',
|
||||
color: '#E2E8F0',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
fontSize: 13,
|
||||
color: 'var(--fg-1)',
|
||||
margin: 0,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
chevron: {
|
||||
color: '#E2E8F0',
|
||||
color: 'var(--fg-2)',
|
||||
transition: 'transform 0.2s',
|
||||
},
|
||||
chevronOpen: {
|
||||
@@ -63,40 +58,50 @@ const STYLES = {
|
||||
dropdown: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
marginTop: '0.5rem',
|
||||
width: '16rem',
|
||||
background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.95) 0%, rgba(51, 65, 85, 0.9) 50%, rgba(30, 41, 59, 0.95) 100%)',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.6), 0 10px 30px rgba(14, 165, 233, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.1)',
|
||||
border: '1.5px solid rgba(14, 165, 233, 0.3)',
|
||||
padding: '0.5rem 0',
|
||||
zIndex: 50,
|
||||
top: '110%',
|
||||
minWidth: 240,
|
||||
background: 'var(--bg-surface)',
|
||||
border: '1px solid var(--border-1)',
|
||||
borderRadius: 8,
|
||||
boxShadow: 'var(--shadow-popover)',
|
||||
padding: 8,
|
||||
zIndex: 60,
|
||||
},
|
||||
// Dropdown header section
|
||||
dropdownHeader: {
|
||||
padding: '0.75rem 1rem',
|
||||
borderBottom: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
padding: '8px 10px',
|
||||
borderBottom: '1px solid var(--border-1)',
|
||||
marginBottom: 6,
|
||||
},
|
||||
dropdownHeaderName: {
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '500',
|
||||
color: '#F8FAFC',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
color: 'var(--fg-1)',
|
||||
margin: 0,
|
||||
},
|
||||
dropdownHeaderEmail: {
|
||||
fontSize: '0.875rem',
|
||||
color: '#94A3B8',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 400,
|
||||
fontSize: 11,
|
||||
color: 'var(--fg-muted)',
|
||||
margin: 0,
|
||||
marginTop: 2,
|
||||
},
|
||||
groupBadgeSection: {
|
||||
marginTop: 8,
|
||||
},
|
||||
// Menu items
|
||||
menuItem: {
|
||||
width: '100%',
|
||||
padding: '0.5rem 1rem',
|
||||
padding: '7px 10px',
|
||||
textAlign: 'left',
|
||||
fontSize: '0.875rem',
|
||||
color: '#F8FAFC',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
fontSize: 13,
|
||||
color: 'var(--fg-2)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -104,17 +109,19 @@ const STYLES = {
|
||||
transition: 'background 0.15s',
|
||||
},
|
||||
menuItemHover: {
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
background: 'var(--bg-elevated)',
|
||||
},
|
||||
// Sign out item
|
||||
signOutItem: {
|
||||
width: '100%',
|
||||
padding: '0.5rem 1rem',
|
||||
padding: '7px 10px',
|
||||
textAlign: 'left',
|
||||
fontSize: '0.875rem',
|
||||
color: '#F87171',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
fontSize: 13,
|
||||
color: 'var(--sev-critical)',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -122,7 +129,7 @@ const STYLES = {
|
||||
transition: 'background 0.15s',
|
||||
},
|
||||
signOutItemHover: {
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
background: 'var(--bg-elevated)',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -140,7 +147,6 @@ function getGroupBadgeStyle(group) {
|
||||
const c = colors[group] || colors.Read_Only;
|
||||
return {
|
||||
display: 'inline-block',
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.125rem 0.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.75rem',
|
||||
@@ -151,6 +157,18 @@ function getGroupBadgeStyle(group) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user initials from username (up to 2 characters).
|
||||
*/
|
||||
function getUserInitials(username) {
|
||||
if (!username) return '';
|
||||
const parts = username.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return username.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export default function UserMenu({ onManageUsers, onAuditLog }) {
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -213,15 +231,13 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
|
||||
...(buttonHovered ? STYLES.menuButtonHover : {}),
|
||||
}}
|
||||
>
|
||||
{/* Circular avatar with initials */}
|
||||
<div style={STYLES.avatar}>
|
||||
<User size={16} style={STYLES.avatarIcon} />
|
||||
</div>
|
||||
<div style={STYLES.userInfo} className="hidden sm:block">
|
||||
<p style={STYLES.username}>{user.username}</p>
|
||||
<p style={STYLES.groupLabel}>{formatGroupName(user.group)}</p>
|
||||
{getUserInitials(user.username)}
|
||||
</div>
|
||||
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 13 }}>{user.username}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
size={14}
|
||||
style={{
|
||||
...STYLES.chevron,
|
||||
...(isOpen ? STYLES.chevronOpen : {}),
|
||||
@@ -234,9 +250,11 @@ export default function UserMenu({ onManageUsers, onAuditLog }) {
|
||||
<div style={STYLES.dropdownHeader}>
|
||||
<p style={STYLES.dropdownHeaderName}>{user.username}</p>
|
||||
<p style={STYLES.dropdownHeaderEmail}>{user.email}</p>
|
||||
<span style={getGroupBadgeStyle(user.group)}>
|
||||
{formatGroupName(user.group)}
|
||||
</span>
|
||||
<div style={STYLES.groupBadgeSection}>
|
||||
<span style={getGroupBadgeStyle(user.group)}>
|
||||
{formatGroupName(user.group)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -34,9 +34,9 @@ const ARCHER_STATUS_COLORS = {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared style tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
const AXIS_STYLE = { fontFamily: 'monospace', fontSize: '0.6rem', fill: '#475569' };
|
||||
const AXIS_STYLE = { fontFamily: 'var(--font-mono)', fontSize: '0.6rem', fill: '#475569' };
|
||||
const GRID_STYLE = { stroke: 'rgba(255,255,255,0.05)', strokeDasharray: '3 3' };
|
||||
const LEGEND_STYLE = { fontFamily: 'monospace', fontSize: '0.62rem', color: '#64748B' };
|
||||
const LEGEND_STYLE = { fontFamily: 'var(--font-mono)', fontSize: '0.62rem', color: '#64748B' };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom dark tooltip
|
||||
@@ -49,7 +49,7 @@ function DarkTooltip({ active, payload, label }) {
|
||||
border: '1px solid rgba(20,184,166,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
fontFamily: 'monospace',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
minWidth: '130px',
|
||||
}}>
|
||||
@@ -76,20 +76,20 @@ function DarkTooltip({ active, payload, label }) {
|
||||
function ChartCard({ title, subtitle, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||
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: '0.5rem',
|
||||
borderRadius: 8,
|
||||
padding: '1rem 1.125rem 0.875rem',
|
||||
}}>
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '700',
|
||||
color: '#CBD5E1', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--fg-1)', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div style={{ fontSize: '0.62rem', color: '#334155', marginTop: '0.2rem', fontFamily: 'monospace' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--fg-disabled)', marginTop: '0.2rem', fontFamily: 'var(--font-mono)' }}>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
@@ -106,10 +106,10 @@ function NoData({ msg }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '160px', color: '#334155',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||
height: '160px', color: 'var(--fg-disabled)',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
border: '1px dashed rgba(20,184,166,0.1)',
|
||||
borderRadius: '0.375rem',
|
||||
borderRadius: 6,
|
||||
}}>
|
||||
{msg || 'No data yet — upload compliance reports to populate this chart'}
|
||||
</div>
|
||||
@@ -347,8 +347,8 @@ export default function ComplianceChartsPanel() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<TrendingUp style={{ width: '14px', height: '14px', color: TEAL }} />
|
||||
<span style={{
|
||||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '700',
|
||||
color: '#334155', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
|
||||
color: 'var(--fg-disabled)', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
}}>
|
||||
Historical Trends
|
||||
</span>
|
||||
|
||||
@@ -30,8 +30,8 @@ function MetricChip({ metricId, category, status }) {
|
||||
padding: '0.2rem 0.5rem',
|
||||
background: `${color}18`,
|
||||
border: `1px solid ${color}50`,
|
||||
borderRadius: '0.25rem',
|
||||
color, fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
|
||||
borderRadius: 4,
|
||||
color, fontSize: '0.7rem', fontFamily: 'var(--font-mono)', fontWeight: '600',
|
||||
}}>
|
||||
{metricId}
|
||||
</span>
|
||||
@@ -126,8 +126,8 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
{/* Panel */}
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, right: 0, bottom: 0, width: '420px',
|
||||
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
||||
borderLeft: `1px solid ${TEAL}30`,
|
||||
background: 'var(--bg-surface)',
|
||||
borderLeft: '1px solid var(--border-subtle)',
|
||||
boxShadow: `-8px 0 32px rgba(0,0,0,0.6)`,
|
||||
zIndex: 41,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
@@ -136,29 +136,29 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '1.25rem 1.25rem 1rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ minWidth: 0, flex: 1, marginRight: '0.75rem' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#F8FAFC', wordBreak: 'break-all', lineHeight: 1.3 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.9rem', fontWeight: '700', color: '#F8FAFC', wordBreak: 'break-all', lineHeight: 1.3 }}>
|
||||
{hostname}
|
||||
</div>
|
||||
{detail && (
|
||||
<div style={{ marginTop: '0.4rem', display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
|
||||
{detail.ip_address && (
|
||||
<span style={{ fontSize: '0.72rem', fontFamily: 'monospace', color: '#64748B' }}>{detail.ip_address}</span>
|
||||
<span style={{ fontSize: '0.72rem', fontFamily: 'var(--font-mono)', color: 'var(--fg-2)' }}>{detail.ip_address}</span>
|
||||
)}
|
||||
{detail.device_type && (
|
||||
<span style={{ fontSize: '0.72rem', color: '#475569' }}>· {detail.device_type}</span>
|
||||
<span style={{ fontSize: '0.72rem', color: 'var(--fg-disabled)' }}>· {detail.device_type}</span>
|
||||
)}
|
||||
<span style={{ fontSize: '0.72rem', color: TEAL }}>· {detail.team}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', flexShrink: 0, padding: '0.25rem' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#475569'}>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--fg-disabled)', flexShrink: 0, padding: '0.25rem' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--fg-1)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--fg-disabled)'}>
|
||||
<X style={{ width: '18px', height: '18px' }} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -203,7 +203,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
{activeMetrics.map(m => (
|
||||
<div key={m.metric_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||
<MetricChip metricId={m.metric_id} category={m.category} />
|
||||
<div style={{ fontSize: '0.72rem', color: '#64748B', fontFamily: 'monospace', textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--fg-2)', fontFamily: 'var(--font-mono)', textAlign: 'right' }}>
|
||||
<span style={{ color: m.seen_count > 2 ? '#F59E0B' : '#94A3B8' }}>
|
||||
{m.seen_count}× seen
|
||||
</span>
|
||||
@@ -253,7 +253,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', flexShrink: 0, marginLeft: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ fontSize: '0.68rem', color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap' }}>
|
||||
{g.created_by && `${g.created_by} · `}{g.created_at?.slice(0, 10)}
|
||||
</span>
|
||||
<button
|
||||
@@ -284,7 +284,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
return (
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.35rem' }}>
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#475569' }}>
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--fg-disabled)' }}>
|
||||
Metrics
|
||||
</span>
|
||||
<button
|
||||
@@ -297,7 +297,7 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
}}
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
fontSize: '0.68rem', fontFamily: 'monospace',
|
||||
fontSize: '0.68rem', fontFamily: 'var(--font-mono)',
|
||||
color: TEAL, padding: 0,
|
||||
transition: 'opacity 0.15s',
|
||||
}}
|
||||
@@ -328,9 +328,9 @@ export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded,
|
||||
padding: '0.25rem 0.5rem',
|
||||
background: isSelected ? `${color}25` : `${color}08`,
|
||||
border: `1px solid ${isSelected ? `${color}90` : `${color}30`}`,
|
||||
borderRadius: '0.25rem',
|
||||
borderRadius: 4,
|
||||
color: isSelected ? color : `${color}90`,
|
||||
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: '600',
|
||||
fontSize: '0.7rem', fontFamily: 'var(--font-mono)', fontWeight: '600',
|
||||
cursor: (isSelected && selectedMetrics.length === 1) ? 'default' : 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
opacity: (isSelected && selectedMetrics.length === 1) ? 0.85 : 1,
|
||||
@@ -399,16 +399,16 @@ function Section({ title, icon, children, muted, grow }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '1rem 1.25rem',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
...(grow ? { flex: 1, display: 'flex', flexDirection: 'column' } : {}),
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
fontSize: '0.68rem', fontFamily: 'monospace', textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em', color: muted ? '#334155' : '#475569',
|
||||
fontSize: '0.68rem', fontFamily: 'var(--font-mono)', textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em', color: muted ? 'var(--fg-disabled)' : 'var(--fg-disabled)',
|
||||
marginBottom: '0.75rem',
|
||||
}}>
|
||||
{icon && <span style={{ color: muted ? '#334155' : TEAL }}>{icon}</span>}
|
||||
{icon && <span style={{ color: muted ? 'var(--fg-disabled)' : TEAL }}>{icon}</span>}
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
@@ -441,12 +441,12 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
||||
marginBottom: '0.625rem', padding: '0.625rem 0.75rem',
|
||||
background: resolved ? 'transparent' : `${color}08`,
|
||||
border: `1px solid ${color}25`,
|
||||
borderRadius: '0.375rem',
|
||||
borderRadius: 6,
|
||||
opacity: resolved ? 0.5 : 1,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: highlights.length ? '0.4rem' : 0 }}>
|
||||
<MetricChip metricId={metric.metric_id} category={metric.category} status={metric.status} />
|
||||
{resolved && <span style={{ fontSize: '0.68rem', color: '#334155', fontFamily: 'monospace' }}>resolved {metric.resolved_on || ''}</span>}
|
||||
{resolved && <span style={{ fontSize: '0.68rem', color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)' }}>resolved {metric.resolved_on || ''}</span>}
|
||||
</div>
|
||||
{metric.metric_desc && (
|
||||
<div style={{ fontSize: '0.72rem', color: '#475569', marginBottom: (highlights.length || ivantiId) ? '0.4rem' : 0, lineHeight: 1.4 }}>
|
||||
@@ -456,8 +456,8 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
||||
{ivantiId && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: highlights.length ? '0.25rem' : 0 }}>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', minWidth: 0 }}>
|
||||
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', flexShrink: 0 }}>Ivanti ID</span>
|
||||
<span style={{ fontSize: '0.68rem', color: '#94A3B8', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ivantiId}</span>
|
||||
<span style={{ fontSize: '0.68rem', color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)', flexShrink: 0 }}>Ivanti ID</span>
|
||||
<span style={{ fontSize: '0.68rem', color: 'var(--fg-2)', fontFamily: 'var(--font-mono)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ivantiId}</span>
|
||||
</div>
|
||||
{onNavigate && (
|
||||
<button
|
||||
@@ -468,7 +468,7 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
||||
border: '1px solid rgba(14,165,233,0.4)',
|
||||
borderRadius: '0.25rem',
|
||||
color: '#0EA5E9',
|
||||
fontSize: '0.65rem', fontFamily: 'monospace',
|
||||
fontSize: '0.65rem', fontFamily: 'var(--font-mono)',
|
||||
padding: '0.2rem 0.5rem',
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
transition: 'all 0.15s',
|
||||
@@ -483,8 +483,8 @@ function MetricRow({ metric, resolved, onNavigate }) {
|
||||
)}
|
||||
{highlights.map(h => (
|
||||
<div key={h.label} style={{ display: 'flex', gap: '0.4rem', marginTop: '0.25rem' }}>
|
||||
<span style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', minWidth: '48px' }}>{h.label}</span>
|
||||
<span style={{ fontSize: '0.68rem', color: '#94A3B8', fontFamily: 'monospace', wordBreak: 'break-all' }}>
|
||||
<span style={{ fontSize: '0.68rem', color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)', minWidth: '48px' }}>{h.label}</span>
|
||||
<span style={{ fontSize: '0.68rem', color: 'var(--fg-2)', fontFamily: 'var(--font-mono)', wordBreak: 'break-all' }}>
|
||||
{String(h.value).length > 80 ? String(h.value).slice(0, 80) + '…' : h.value}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -97,25 +97,25 @@ function VariantPill({ entry, label }) {
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
padding: '0.15rem 0.45rem',
|
||||
gap: 4,
|
||||
padding: '2px 7px',
|
||||
background: `${color}1F`,
|
||||
borderRadius: '0.2rem',
|
||||
border: `1px solid ${color}25`,
|
||||
fontSize: '0.62rem',
|
||||
fontFamily: 'monospace',
|
||||
color: '#CBD5E1',
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${color}40`,
|
||||
fontSize: 10,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--fg-2)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{!isOk && (
|
||||
<span style={{
|
||||
width: '4px', height: '4px', borderRadius: '50%',
|
||||
display: 'inline-block', width: 4, height: 4, borderRadius: '50%',
|
||||
background: color, flexShrink: 0,
|
||||
boxShadow: `0 0 5px ${color}`,
|
||||
}} />
|
||||
)}
|
||||
{label && <span style={{ color: '#94A3B8' }}>{label}</span>}
|
||||
<span style={{ color, fontWeight: '600' }}>{pctDisplay(entry.compliance_pct)}</span>
|
||||
{label && <span style={{ color: 'var(--fg-disabled)' }}>{label}</span>}
|
||||
<span style={{ color, fontWeight: 600 }}>{pctDisplay(entry.compliance_pct)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -128,18 +128,16 @@ function MetricHealthCard({ family, active, onClick, onInfoClick, definitionLook
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
position: 'relative', textAlign: 'left', cursor: 'pointer',
|
||||
background: active
|
||||
? `${color}18`
|
||||
: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||
? `${color}26`
|
||||
: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
|
||||
border: `1.5px solid ${active ? color : color + '40'}`,
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.875rem 1rem',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.15s',
|
||||
minWidth: '160px',
|
||||
borderRadius: 8,
|
||||
padding: '14px 16px',
|
||||
minWidth: 160,
|
||||
flex: '1 1 0',
|
||||
position: 'relative',
|
||||
transition: 'all 160ms ease',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.borderColor = color + '80'; }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.borderColor = active ? color : color + '40'; }}
|
||||
@@ -149,37 +147,43 @@ function MetricHealthCard({ family, active, onClick, onInfoClick, definitionLook
|
||||
onClick={(e) => { e.stopPropagation(); onInfoClick(family.metricId); }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0.5rem',
|
||||
right: '0.5rem',
|
||||
top: 8,
|
||||
right: 8,
|
||||
cursor: 'pointer',
|
||||
color: '#475569',
|
||||
display: 'flex',
|
||||
color: 'var(--fg-disabled)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0.15rem',
|
||||
borderRadius: '0.2rem',
|
||||
transition: 'color 0.15s',
|
||||
padding: 2,
|
||||
borderRadius: 3,
|
||||
transition: 'color 160ms ease',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = TEAL; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--fg-disabled)'; }}
|
||||
>
|
||||
<Info style={{ width: '13px', height: '13px' }} />
|
||||
<Info style={{ width: 13, height: 13 }} />
|
||||
</span>
|
||||
|
||||
{/* Metric ID */}
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.95rem', fontWeight: '700', color: active ? color : '#E2E8F0', marginBottom: '0.25rem', paddingRight: '1.25rem' }}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 15, fontWeight: 700,
|
||||
color: active ? color : 'var(--fg-1)', marginBottom: 4, paddingRight: 20,
|
||||
}}>
|
||||
{family.metricId}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div style={{ fontSize: '0.65rem', color: '#475569', marginBottom: '0.5rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
color: 'var(--fg-disabled)', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{family.category}
|
||||
</div>
|
||||
|
||||
{/* Variant pills */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem', marginBottom: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 8 }}>
|
||||
{family.entries.map((entry, i) => {
|
||||
// Only show a label when there are multiple variants to differentiate
|
||||
let label = null;
|
||||
if (family.entries.length > 1) {
|
||||
label = entry.priority || `#${i + 1}`;
|
||||
@@ -189,20 +193,24 @@ function MetricHealthCard({ family, active, onClick, onInfoClick, definitionLook
|
||||
</div>
|
||||
|
||||
{/* Target */}
|
||||
<div style={{ fontSize: '0.68rem', color: '#475569', fontFamily: 'monospace', marginBottom: '0.5rem' }}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
color: 'var(--fg-disabled)', marginBottom: 8,
|
||||
}}>
|
||||
target {pctDisplay(family.target)}
|
||||
</div>
|
||||
|
||||
{/* Status pill */}
|
||||
{/* Status ribbon */}
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||||
fontSize: '0.62rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
color, padding: '0.2rem 0.5rem',
|
||||
background: `${color}12`, borderRadius: '999px',
|
||||
border: `1px solid ${color}30`,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
color, padding: '3px 9px',
|
||||
background: `${color}1A`, borderRadius: 999,
|
||||
border: `1px solid ${color}4D`,
|
||||
}}>
|
||||
<span style={{
|
||||
width: '5px', height: '5px', borderRadius: '50%',
|
||||
display: 'inline-block', width: 5, height: 5, borderRadius: '50%',
|
||||
background: color, flexShrink: 0,
|
||||
...(isOk ? {} : { boxShadow: `0 0 6px ${color}` }),
|
||||
}} />
|
||||
@@ -217,10 +225,10 @@ function MetricBadge({ metricId, category }) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
padding: '0.15rem 0.45rem',
|
||||
background: `${color}15`, border: `1px solid ${color}40`,
|
||||
borderRadius: '0.2rem', color,
|
||||
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '600',
|
||||
padding: '2px 7px',
|
||||
background: `${color}1F`, border: `1px solid ${color}4D`,
|
||||
borderRadius: 3, color,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{metricId}
|
||||
@@ -232,10 +240,11 @@ function SeenBadge({ count }) {
|
||||
const color = count > 3 ? '#EF4444' : count > 1 ? '#F59E0B' : '#64748B';
|
||||
return (
|
||||
<span style={{
|
||||
fontSize: '0.65rem', fontFamily: 'monospace', fontWeight: '700',
|
||||
color, padding: '0.15rem 0.4rem',
|
||||
background: `${color}12`, borderRadius: '0.2rem',
|
||||
border: `1px solid ${color}30`, whiteSpace: 'nowrap',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
|
||||
color, padding: '2px 7px',
|
||||
background: `${color}1A`,
|
||||
border: `1px solid ${color}4D`,
|
||||
borderRadius: 3, whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{count}×
|
||||
</span>
|
||||
@@ -345,32 +354,33 @@ export default function CompliancePage({ onNavigate }) {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.5rem' }}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700',
|
||||
margin: '0 0 6px 0',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 24, fontWeight: 700,
|
||||
color: TEAL, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
textShadow: `0 0 16px ${TEAL}40`, marginBottom: '0.25rem',
|
||||
textShadow: `0 0 16px rgba(20,184,166,0.4)`,
|
||||
}}>
|
||||
AEO Compliance
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
{lastUpload ? (
|
||||
<>
|
||||
<span style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||
Last report: <span style={{ color: '#64748B' }}>{lastUpload.report_date || lastUpload.uploaded_at?.slice(0, 10)}</span>
|
||||
<span style={{ fontSize: '0.72rem', color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)' }}>
|
||||
Last report: <span style={{ color: 'var(--fg-2)' }}>{lastUpload.report_date || lastUpload.uploaded_at?.slice(0, 10)}</span>
|
||||
</span>
|
||||
{isAdmin() && (
|
||||
<button
|
||||
onClick={() => setRollbackConfirm(true)}
|
||||
title="Rollback last upload"
|
||||
style={{
|
||||
background: 'none', border: '1px solid rgba(239,68,68,0.25)',
|
||||
borderRadius: '0.25rem', padding: '0.15rem 0.4rem',
|
||||
cursor: 'pointer', color: '#64748B',
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
|
||||
fontSize: '0.62rem', fontFamily: 'monospace',
|
||||
transition: 'all 0.15s',
|
||||
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)',
|
||||
transition: 'all 160ms ease',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = '#EF4444'; e.currentTarget.style.borderColor = 'rgba(239,68,68,0.6)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#64748B'; e.currentTarget.style.borderColor = 'rgba(239,68,68,0.25)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--fg-2)'; e.currentTarget.style.borderColor = 'rgba(239,68,68,0.25)'; }}
|
||||
>
|
||||
<RotateCcw style={{ width: '10px', height: '10px' }} />
|
||||
Rollback
|
||||
@@ -378,15 +388,15 @@ export default function CompliancePage({ onNavigate }) {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.72rem', color: '#334155', fontFamily: 'monospace' }}>No reports uploaded</span>
|
||||
<span style={{ fontSize: '0.72rem', color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)' }}>No reports uploaded</span>
|
||||
)}
|
||||
{summary.overall_scores?.customer_network != null && (
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'var(--font-mono)', color: 'var(--fg-2)' }}>
|
||||
Network: <span style={{ color: TEAL }}>{pctDisplay(summary.overall_scores.customer_network)}</span>
|
||||
</span>
|
||||
)}
|
||||
{summary.overall_scores?.vertical != null && (
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'monospace', color: '#64748B' }}>
|
||||
<span style={{ fontSize: '0.68rem', fontFamily: 'var(--font-mono)', color: 'var(--fg-2)' }}>
|
||||
Vertical: <span style={{ color: TEAL }}>{pctDisplay(summary.overall_scores.vertical)}</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -395,21 +405,26 @@ export default function CompliancePage({ onNavigate }) {
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||
<button onClick={refresh} title="Refresh"
|
||||
style={{ background: 'none', border: '1px solid rgba(20,184,166,0.25)', borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#475569' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = TEAL; e.currentTarget.style.borderColor = `${TEAL}60`; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.25)'; }}>
|
||||
style={{
|
||||
background: 'transparent', border: `1px solid rgba(20,184,166,0.25)`,
|
||||
borderRadius: 6, padding: 8, cursor: 'pointer', color: 'var(--fg-2)',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 160ms ease',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = TEAL; e.currentTarget.style.borderColor = TEAL; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--fg-2)'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.25)'; }}>
|
||||
<RefreshCw style={{ width: '16px', height: '16px' }} />
|
||||
</button>
|
||||
{canWrite() && (
|
||||
<button onClick={() => setShowUpload(true)}
|
||||
className="intel-button"
|
||||
style={{
|
||||
background: `${TEAL}18`, border: `1px solid ${TEAL}`,
|
||||
color: TEAL, padding: '0.5rem 1rem',
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
background: 'rgba(20,184,166,0.18)', border: `1px solid ${TEAL}`,
|
||||
color: TEAL, padding: '8px 16px',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em', cursor: 'pointer',
|
||||
borderRadius: '0.375rem',
|
||||
borderRadius: 6, transition: 'all 160ms ease', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<Upload style={{ width: '14px', height: '14px' }} />
|
||||
Upload Report
|
||||
@@ -419,23 +434,23 @@ export default function CompliancePage({ onNavigate }) {
|
||||
</div>
|
||||
|
||||
{/* ── Team tabs ────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 24 }}>
|
||||
{TEAMS.map(team => {
|
||||
const isActive = activeTeam === team;
|
||||
return (
|
||||
<button key={team} onClick={() => setActiveTeam(team)}
|
||||
style={{
|
||||
padding: '0.5rem 1.25rem', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600',
|
||||
padding: '8px 18px', cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||
borderRadius: '0.375rem',
|
||||
border: isActive ? `1px solid ${TEAL}` : '1px solid rgba(20,184,166,0.2)',
|
||||
background: isActive ? `${TEAL}18` : 'transparent',
|
||||
color: isActive ? TEAL : '#475569',
|
||||
transition: 'all 0.15s',
|
||||
borderRadius: 6,
|
||||
border: isActive ? `1px solid ${TEAL}` : '1px solid rgba(20,184,166,0.20)',
|
||||
background: isActive ? 'rgba(20,184,166,0.18)' : 'transparent',
|
||||
color: isActive ? TEAL : 'var(--fg-disabled)',
|
||||
transition: 'all 160ms ease',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.4)'; }}}
|
||||
onMouseLeave={e => { if (!isActive) { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.2)'; }}}>
|
||||
onMouseLeave={e => { if (!isActive) { e.currentTarget.style.color = 'var(--fg-disabled)'; e.currentTarget.style.borderColor = 'rgba(20,184,166,0.20)'; }}}>
|
||||
{team}
|
||||
</button>
|
||||
);
|
||||
@@ -445,11 +460,11 @@ export default function CompliancePage({ onNavigate }) {
|
||||
{/* ── Metric health cards ──────────────────────────────────── */}
|
||||
{families.length > 0 ? (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.65rem', color: '#334155', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.625rem' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 10 }}>
|
||||
Metric Health — click to filter
|
||||
{metricFilter && (
|
||||
<button onClick={() => setMetricFilter(null)}
|
||||
style={{ marginLeft: '0.75rem', color: TEAL, background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace' }}>
|
||||
style={{ marginLeft: 12, color: TEAL, background: 'none', border: 'none', cursor: 'pointer', fontSize: 10, fontFamily: 'var(--font-mono)' }}>
|
||||
× clear filter
|
||||
</button>
|
||||
)}
|
||||
@@ -501,29 +516,29 @@ export default function CompliancePage({ onNavigate }) {
|
||||
top: tooltipTop,
|
||||
left: tooltipLeft,
|
||||
zIndex: 50,
|
||||
width: '300px',
|
||||
width: 300,
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||
border: '1px solid rgba(20,184,166,0.25)',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid rgba(20,184,166,0.30)',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
|
||||
padding: '0.75rem 0.875rem',
|
||||
padding: '12px 14px',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.82rem', fontWeight: '700', color: '#E2E8F0', marginBottom: '0.4rem', lineHeight: 1.3 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 700, color: 'var(--fg-1)', marginBottom: 6, lineHeight: 1.3 }}>
|
||||
{def ? def.metric_title : (family.entries[0]?.description || hoveredMetric)}
|
||||
</div>
|
||||
{def && def.business_justification && (
|
||||
<div style={{ fontSize: '0.72rem', color: '#94A3B8', marginBottom: '0.3rem', lineHeight: 1.4 }}>
|
||||
<div style={{ fontFamily: 'var(--font-display, var(--font-ui))', fontSize: 12, color: 'var(--fg-2)', marginBottom: 6, lineHeight: 1.4 }}>
|
||||
{def.business_justification}
|
||||
</div>
|
||||
)}
|
||||
{def && def.data_sources_required && (
|
||||
<div style={{ fontSize: '0.65rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg-disabled)' }}>
|
||||
Sources: {def.data_sources_required}
|
||||
</div>
|
||||
)}
|
||||
{!def && family.entries[0]?.description && (
|
||||
<div style={{ fontSize: '0.72rem', color: '#94A3B8', lineHeight: 1.4 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--fg-2)', lineHeight: 1.4 }}>
|
||||
{family.entries[0].description}
|
||||
</div>
|
||||
)}
|
||||
@@ -534,10 +549,10 @@ export default function CompliancePage({ onNavigate }) {
|
||||
) : lastUpload === null ? (
|
||||
<div style={{
|
||||
marginBottom: '1.5rem', padding: '2rem',
|
||||
border: '1px dashed rgba(20,184,166,0.2)', borderRadius: '0.5rem',
|
||||
border: '1px dashed rgba(20,184,166,0.2)', borderRadius: 8,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ color: '#334155', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
<div style={{ color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
No compliance data — upload a report to get started
|
||||
</div>
|
||||
</div>
|
||||
@@ -548,33 +563,33 @@ export default function CompliancePage({ onNavigate }) {
|
||||
|
||||
{/* ── Device table ─────────────────────────────────────────── */}
|
||||
<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: '0.5rem',
|
||||
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',
|
||||
}}>
|
||||
{/* Table toolbar */}
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '0.875rem 1rem', borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
padding: '14px 16px', borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
{/* Active / Resolved tabs */}
|
||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{['active', 'resolved'].map(tab => {
|
||||
const isActive = activeTab === tab;
|
||||
return (
|
||||
<button key={tab} onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: '0.35rem 0.875rem', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||||
padding: '6px 14px', cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
borderRadius: '0.25rem',
|
||||
border: isActive ? `1px solid ${TEAL}60` : '1px solid transparent',
|
||||
background: isActive ? `${TEAL}12` : 'transparent',
|
||||
color: isActive ? TEAL : '#475569',
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${isActive ? 'rgba(20,184,166,0.40)' : 'transparent'}`,
|
||||
background: isActive ? 'rgba(20,184,166,0.10)' : 'transparent',
|
||||
color: isActive ? TEAL : 'var(--fg-disabled)',
|
||||
}}>
|
||||
{tab}
|
||||
{isActive && (
|
||||
<span style={{ marginLeft: '0.4rem', color: '#64748B' }}>
|
||||
<span style={{ marginLeft: 6, color: 'var(--fg-2)' }}>
|
||||
({loading ? '…' : filteredDevices.length})
|
||||
</span>
|
||||
)}
|
||||
@@ -589,13 +604,14 @@ export default function CompliancePage({ onNavigate }) {
|
||||
onChange={e => setHostSearch(e.target.value)}
|
||||
placeholder="Search hostname…"
|
||||
style={{
|
||||
background: 'rgba(15,23,42,0.8)', border: '1px solid rgba(20,184,166,0.2)',
|
||||
borderRadius: '0.25rem', color: '#E2E8F0', outline: 'none',
|
||||
padding: '0.35rem 0.625rem', fontSize: '0.75rem', fontFamily: 'monospace',
|
||||
width: '220px',
|
||||
background: 'rgba(15,23,42,0.85)',
|
||||
border: '1px solid rgba(20,184,166,0.20)',
|
||||
borderRadius: 4, color: 'var(--fg-1)', outline: 'none',
|
||||
padding: '6px 12px', fontSize: 12, fontFamily: 'var(--font-mono)',
|
||||
width: 220, transition: 'border-color 160ms ease',
|
||||
}}
|
||||
onFocus={e => e.target.style.borderColor = `${TEAL}60`}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(20,184,166,0.2)'}
|
||||
onFocus={e => { e.target.style.borderColor = 'rgba(20,184,166,0.60)'; e.target.style.boxShadow = '0 0 0 3px rgba(20,184,166,0.10)'; }}
|
||||
onBlur={e => { e.target.style.borderColor = 'rgba(20,184,166,0.20)'; e.target.style.boxShadow = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -603,10 +619,10 @@ export default function CompliancePage({ onNavigate }) {
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2.5fr 1.1fr 1fr 2fr 0.5fr 0.4fr',
|
||||
padding: '0.5rem 1rem',
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
fontSize: '0.62rem', color: '#334155',
|
||||
fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 600,
|
||||
color: 'var(--fg-disabled)', textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
}}>
|
||||
<span>Hostname</span>
|
||||
<span>IP Address</span>
|
||||
@@ -626,7 +642,7 @@ export default function CompliancePage({ onNavigate }) {
|
||||
<AlertCircle style={{ width: '16px', height: '16px' }} />{error}
|
||||
</div>
|
||||
) : filteredDevices.length === 0 ? (
|
||||
<div style={{ padding: '3rem', textAlign: 'center', color: '#334155', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
<div style={{ padding: 48, textAlign: 'center', color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||
{lastUpload === null ? 'No reports uploaded yet' : activeTab === 'active' ? 'No non-compliant devices' : 'No resolved items'}
|
||||
</div>
|
||||
) : (
|
||||
@@ -673,68 +689,78 @@ export default function CompliancePage({ onNavigate }) {
|
||||
{rollbackConfirm && lastUpload && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 60,
|
||||
background: 'rgba(10, 14, 39, 0.95)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
background: 'rgba(10,14,39,0.92)', backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
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.3)',
|
||||
borderRadius: '0.75rem',
|
||||
border: '1px solid rgba(239,68,68,0.30)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.7)',
|
||||
width: '100%', maxWidth: '420px',
|
||||
padding: '2rem',
|
||||
width: '100%', maxWidth: 420,
|
||||
padding: 28,
|
||||
}}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: '#EF4444', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '1rem' }}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 15, fontWeight: 700,
|
||||
color: '#EF4444', textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
Rollback Upload
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#CBD5E1', lineHeight: '1.5', marginBottom: '0.5rem' }}>
|
||||
<div style={{
|
||||
fontSize: 13, color: 'var(--fg-1)', lineHeight: 1.5,
|
||||
marginBottom: 8, fontFamily: 'var(--font-display, var(--font-ui))',
|
||||
}}>
|
||||
This will reverse the most recent upload:
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', color: '#94A3B8',
|
||||
background: 'rgba(15,23,42,0.6)', borderRadius: '0.375rem',
|
||||
padding: '0.625rem 0.75rem', marginBottom: '1.25rem',
|
||||
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: '#64748B' }}>File:</span> {lastUpload.report_date || 'unknown date'}</div>
|
||||
<div style={{ marginTop: '0.25rem', fontSize: '0.68rem', color: '#475569' }}>
|
||||
<div><span style={{ color: 'var(--fg-disabled)' }}>File:</span> {lastUpload.report_date || 'unknown date'}</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: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button
|
||||
onClick={() => setRollbackConfirm(false)}
|
||||
style={{
|
||||
flex: 1, padding: '0.625rem', background: 'transparent',
|
||||
border: '1px solid rgba(100,116,139,0.4)', borderRadius: '0.375rem',
|
||||
color: '#64748B', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.8rem',
|
||||
flex: 1, padding: 10, background: 'transparent',
|
||||
border: '1px solid rgba(100,116,139,0.40)', borderRadius: 6,
|
||||
color: 'var(--fg-2)', cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 160ms ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)'}>
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.40)'}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRollback}
|
||||
disabled={rollbackLoading}
|
||||
style={{
|
||||
flex: 2, padding: '0.625rem',
|
||||
background: rollbackLoading ? 'rgba(239,68,68,0.05)' : 'rgba(239,68,68,0.1)',
|
||||
flex: 2, padding: 10,
|
||||
background: rollbackLoading ? 'rgba(239,68,68,0.05)' : 'rgba(239,68,68,0.10)',
|
||||
border: '1px solid #EF4444',
|
||||
borderRadius: '0.375rem',
|
||||
borderRadius: 6,
|
||||
color: '#EF4444', cursor: rollbackLoading ? 'wait' : 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.8rem',
|
||||
fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
opacity: rollbackLoading ? 0.6 : 1,
|
||||
transition: 'all 160ms ease',
|
||||
}}
|
||||
onMouseEnter={e => { if (!rollbackLoading) e.currentTarget.style.background = 'rgba(239,68,68,0.18)'; }}
|
||||
onMouseLeave={e => { if (!rollbackLoading) e.currentTarget.style.background = 'rgba(239,68,68,0.1)'; }}>
|
||||
onMouseLeave={e => { if (!rollbackLoading) e.currentTarget.style.background = 'rgba(239,68,68,0.10)'; }}>
|
||||
{rollbackLoading
|
||||
? <><Loader style={{ width: '14px', height: '14px', animation: 'spin 1s linear infinite' }} /> Rolling back…</>
|
||||
: <><RotateCcw style={{ width: '14px', height: '14px' }} /> Confirm Rollback</>
|
||||
? <><Loader style={{ width: 14, height: 14, animation: 'spin 1s linear infinite' }} /> Rolling back…</>
|
||||
: <><RotateCcw style={{ width: 13, height: 13 }} /> Confirm Rollback</>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@@ -745,34 +771,32 @@ export default function CompliancePage({ onNavigate }) {
|
||||
{/* ── Rollback result toast ────────────────────────────────── */}
|
||||
{rollbackResult && (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: '1.5rem', right: '1.5rem', zIndex: 70,
|
||||
background: rollbackResult.error
|
||||
? 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||
border: `1px solid ${rollbackResult.error ? 'rgba(239,68,68,0.4)' : 'rgba(16,185,129,0.4)'}`,
|
||||
borderRadius: '0.5rem',
|
||||
position: 'fixed', 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 ${rollbackResult.error ? 'rgba(239,68,68,0.40)' : 'rgba(16,185,129,0.40)'}`,
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
|
||||
padding: '0.875rem 1.25rem',
|
||||
maxWidth: '360px',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem',
|
||||
padding: '14px 20px',
|
||||
maxWidth: 360,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
color: rollbackResult.error ? '#F87171' : '#10B981',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setRollbackResult(null)}
|
||||
>
|
||||
{rollbackResult.error ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<AlertCircle style={{ width: '16px', height: '16px', flexShrink: 0 }} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<AlertCircle style={{ width: 14, height: 14, flexShrink: 0 }} />
|
||||
{rollbackResult.error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<RotateCcw style={{ width: '14px', height: '14px' }} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: rollbackResult.rolled_back ? 4 : 0 }}>
|
||||
<RotateCcw style={{ width: 14, height: 14 }} />
|
||||
{rollbackResult.message}
|
||||
</div>
|
||||
{rollbackResult.rolled_back && (
|
||||
<div style={{ fontSize: '0.68rem', color: '#64748B' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--fg-2)' }}>
|
||||
{rollbackResult.rolled_back.items_deleted} items deleted, {rollbackResult.rolled_back.items_reactivated} reactivated
|
||||
</div>
|
||||
)}
|
||||
@@ -791,34 +815,38 @@ function DeviceRow({ device, selected, onClick }) {
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2.5fr 1.1fr 1fr 2fr 0.5fr 0.4fr',
|
||||
padding: '0.625rem 1rem',
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.04)',
|
||||
cursor: 'pointer',
|
||||
background: selected ? `${TEAL}08` : 'transparent',
|
||||
background: selected ? 'rgba(20,184,166,0.08)' : 'transparent',
|
||||
borderLeft: selected ? `2px solid ${TEAL}` : '2px solid transparent',
|
||||
transition: 'all 0.15s',
|
||||
transition: 'all 160ms ease',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onMouseEnter={e => { if (!selected) e.currentTarget.style.background = 'rgba(255,255,255,0.025)'; }}
|
||||
onMouseLeave={e => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{/* Hostname */}
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.78rem', color: selected ? TEAL : '#E2E8F0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
color: selected ? TEAL : 'var(--fg-1)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{device.hostname}
|
||||
</div>
|
||||
|
||||
{/* IP */}
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#64748B' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-2)' }}>
|
||||
{device.ip_address || '—'}
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div style={{ fontSize: '0.7rem', color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--fg-disabled)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{device.device_type || '—'}
|
||||
</div>
|
||||
|
||||
{/* Failing metrics */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{device.failing_metrics.map(m => (
|
||||
<MetricBadge key={m.metric_id} metricId={m.metric_id} category={m.category} />
|
||||
))}
|
||||
@@ -832,7 +860,7 @@ function DeviceRow({ device, selected, onClick }) {
|
||||
{/* Notes indicator */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
{device.has_notes && (
|
||||
<MessageSquare style={{ width: '13px', height: '13px', color: TEAL, opacity: 0.7 }} />
|
||||
<MessageSquare style={{ width: 13, height: 13, color: 'rgba(20,184,166,0.7)' }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -264,7 +264,7 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 60,
|
||||
background: 'rgba(10, 14, 39, 0.97)',
|
||||
background: 'var(--bg-overlay)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
@@ -272,8 +272,8 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.98) 0%, rgba(15,23,42,0.99) 100%)',
|
||||
border: `1px solid ${TEAL}40`,
|
||||
borderRadius: '0.75rem',
|
||||
boxShadow: `0 20px 60px rgba(0,0,0,0.7), 0 0 40px ${TEAL}15`,
|
||||
borderRadius: 12,
|
||||
boxShadow: 'var(--shadow-modal)',
|
||||
width: '100%', maxWidth: phase === 'drift-review' ? '560px' : '480px',
|
||||
maxHeight: 'calc(100vh - 2rem)',
|
||||
overflowY: 'auto',
|
||||
@@ -283,14 +283,14 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.75rem' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '700', color: TEAL, textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '1rem', fontWeight: '700', color: TEAL, textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
||||
Upload Report
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: '#475569', marginTop: '2px' }}>NTS_AEO xlsx compliance report</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--fg-disabled)', marginTop: '2px' }}>NTS_AEO xlsx compliance report</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#475569', padding: '0.25rem' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#E2E8F0'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#475569'}>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--fg-disabled)', padding: '0.25rem' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--fg-1)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--fg-disabled)'}>
|
||||
<X style={{ width: '20px', height: '20px' }} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -332,7 +332,7 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
{(phase === 'uploading' || phase === 'committing') && (
|
||||
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
|
||||
<Loader style={{ width: '40px', height: '40px', color: TEAL, margin: '0 auto 1rem', animation: 'spin 1s linear infinite' }} />
|
||||
<div style={{ color: '#CBD5E1', fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||||
<div style={{ color: '#CBD5E1', fontFamily: 'var(--font-mono)', fontSize: '0.875rem' }}>
|
||||
{phase === 'uploading' ? 'Parsing report…' : 'Committing upload…'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,8 +342,8 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
{phase === 'drift-review' && driftReport && (
|
||||
<>
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontSize: '0.8rem', color: '#64748B',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.8rem', color: 'var(--fg-disabled)',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
marginBottom: '1rem',
|
||||
}}>
|
||||
@@ -369,11 +369,11 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
{/* Status message */}
|
||||
{driftReport.breaking && driftReport.breaking.length > 0 && (
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem', color: '#EF4444',
|
||||
background: 'rgba(239,68,68,0.08)',
|
||||
border: '1px solid rgba(239,68,68,0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
borderRadius: 6,
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginBottom: '1rem',
|
||||
lineHeight: '1.4',
|
||||
@@ -386,11 +386,11 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
{(!driftReport.breaking || driftReport.breaking.length === 0) &&
|
||||
driftReport.silent_miss && driftReport.silent_miss.length > 0 && (
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem', color: '#F59E0B',
|
||||
background: 'rgba(245,158,11,0.08)',
|
||||
border: '1px solid rgba(245,158,11,0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
borderRadius: 6,
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginBottom: '1rem',
|
||||
lineHeight: '1.4',
|
||||
@@ -403,11 +403,11 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
{/* Reconcile changes summary (shown after a successful reconcile) */}
|
||||
{reconcileChanges && reconcileChanges.length > 0 && (
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem', color: '#10B981',
|
||||
background: 'rgba(16,185,129,0.08)',
|
||||
border: '1px solid rgba(16,185,129,0.25)',
|
||||
borderRadius: '0.375rem',
|
||||
borderRadius: 6,
|
||||
padding: '0.5rem 0.75rem',
|
||||
marginBottom: '1rem',
|
||||
lineHeight: '1.6',
|
||||
@@ -429,9 +429,10 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
<button onClick={() => { setPhase('idle'); setPreviewData(null); setDriftReport(null); setReconcileChanges(null); setLastFile(null); setLastSchema(null); }}
|
||||
style={{
|
||||
flex: 1, minWidth: '80px', padding: '0.625rem', background: 'transparent',
|
||||
border: '1px solid rgba(100,116,139,0.4)', borderRadius: '0.375rem',
|
||||
color: '#64748B', cursor: 'pointer',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: '0.8rem',
|
||||
border: '1px solid rgba(100,116,139,0.4)', borderRadius: 6,
|
||||
color: 'var(--fg-2)', cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)', fontSize: '0.8rem',
|
||||
transition: 'all 160ms ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)'}>
|
||||
@@ -447,13 +448,14 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
flex: 2, minWidth: '140px', padding: '0.625rem',
|
||||
background: reconciling ? 'rgba(245,158,11,0.05)' : 'rgba(245,158,11,0.1)',
|
||||
border: '1px solid rgba(245,158,11,0.5)',
|
||||
borderRadius: '0.375rem',
|
||||
borderRadius: 6,
|
||||
color: '#F59E0B',
|
||||
cursor: reconciling ? 'wait' : 'pointer',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: '0.8rem',
|
||||
fontFamily: 'var(--font-mono)', fontSize: '0.8rem',
|
||||
fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: '0.4rem',
|
||||
opacity: reconciling ? 0.6 : 1,
|
||||
transition: 'all 160ms ease',
|
||||
}}
|
||||
onMouseEnter={e => { if (!reconciling) e.currentTarget.style.background = 'rgba(245,158,11,0.18)'; }}
|
||||
onMouseLeave={e => { if (!reconciling) e.currentTarget.style.background = 'rgba(245,158,11,0.1)'; }}>
|
||||
@@ -472,12 +474,13 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
? 'rgba(100,116,139,0.08)'
|
||||
: `${TEAL}18`,
|
||||
border: `1px solid ${(driftReport.breaking && driftReport.breaking.length > 0) ? 'rgba(100,116,139,0.3)' : TEAL}`,
|
||||
borderRadius: '0.375rem',
|
||||
borderRadius: 6,
|
||||
color: (driftReport.breaking && driftReport.breaking.length > 0) ? '#475569' : TEAL,
|
||||
cursor: (driftReport.breaking && driftReport.breaking.length > 0) ? 'not-allowed' : 'pointer',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: '0.8rem',
|
||||
fontFamily: 'var(--font-mono)', fontSize: '0.8rem',
|
||||
fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
opacity: (driftReport.breaking && driftReport.breaking.length > 0) ? 0.5 : 1,
|
||||
transition: 'all 160ms ease',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (!(driftReport.breaking && driftReport.breaking.length > 0)) {
|
||||
@@ -499,7 +502,7 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
{phase === 'preview' && previewData && (
|
||||
<>
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: '0.8rem', color: '#64748B', fontFamily: 'monospace', marginBottom: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--fg-disabled)', fontFamily: 'var(--font-mono)', marginBottom: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{previewData.filename}
|
||||
{previewData.report_date && <span style={{ color: TEAL, marginLeft: '0.75rem' }}>{previewData.report_date}</span>}
|
||||
</div>
|
||||
@@ -519,20 +522,20 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
<span style={{ color, marginRight: '0.5rem', fontWeight: '700' }}>{icon}</span>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ color, fontFamily: 'monospace', fontWeight: '700', fontSize: '1.1rem' }}>{count}</span>
|
||||
<span style={{ color, fontFamily: 'var(--font-mono)', fontWeight: '700', fontSize: '1.1rem' }}>{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<button onClick={() => { setPhase('idle'); setPreviewData(null); }}
|
||||
style={{ flex: 1, padding: '0.625rem', background: 'transparent', border: '1px solid rgba(100,116,139,0.4)', borderRadius: '0.375rem', color: '#64748B', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem' }}
|
||||
style={{ flex: 1, padding: '0.625rem', background: 'transparent', border: '1px solid rgba(100,116,139,0.4)', borderRadius: 6, color: 'var(--fg-2)', cursor: 'pointer', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', transition: 'all 160ms ease' }}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.8)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'rgba(100,116,139,0.4)'}>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleCommit}
|
||||
style={{ flex: 2, padding: '0.625rem', background: `${TEAL}18`, border: `1px solid ${TEAL}`, borderRadius: '0.375rem', color: TEAL, cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||
style={{ flex: 2, padding: '0.625rem', background: `${TEAL}18`, border: `1px solid ${TEAL}`, borderRadius: 6, color: TEAL, cursor: 'pointer', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', fontWeight: '600', textTransform: 'uppercase', letterSpacing: '0.05em', transition: 'all 160ms ease' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = `${TEAL}28`}
|
||||
onMouseLeave={e => e.currentTarget.style.background = `${TEAL}18`}>
|
||||
Confirm Upload
|
||||
@@ -545,7 +548,7 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
{phase === 'done' && (
|
||||
<div style={{ textAlign: 'center', padding: '1.5rem 0' }}>
|
||||
<CheckCircle style={{ width: '44px', height: '44px', color: '#10B981', margin: '0 auto 1rem' }} />
|
||||
<div style={{ color: '#10B981', fontFamily: 'monospace', fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
<div style={{ color: '#10B981', fontFamily: 'var(--font-mono)', fontSize: '0.875rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Upload committed
|
||||
</div>
|
||||
</div>
|
||||
@@ -557,7 +560,7 @@ export default function ComplianceUploadModal({ onClose, onUploadComplete }) {
|
||||
<AlertCircle style={{ width: '36px', height: '36px', color: '#EF4444', margin: '0 auto 0.75rem' }} />
|
||||
<div style={{ color: '#F87171', fontSize: '0.875rem', marginBottom: '1.25rem' }}>{error}</div>
|
||||
<button onClick={() => { setPhase('idle'); setError(null); }}
|
||||
style={{ padding: '0.5rem 1.25rem', background: 'rgba(239,68,68,0.1)', border: '1px solid #EF4444', borderRadius: '0.375rem', color: '#F87171', cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
style={{ padding: '0.5rem 1.25rem', background: 'rgba(239,68,68,0.1)', border: '1px solid #EF4444', borderRadius: 6, color: '#F87171', cursor: 'pointer', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', transition: 'all 160ms ease' }}>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -154,12 +154,12 @@ async function fetchAtlasAndFindings() {
|
||||
function ExportCard({ color, colorRgb, icon: Icon, title, description, children }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
||||
border: `1px solid rgba(${colorRgb},0.2)`,
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(51,65,85,0.9) 50%, rgba(30,41,59,0.95) 100%)',
|
||||
border: `1.5px solid rgba(14,165,233,0.20)`,
|
||||
borderLeft: `3px solid ${color}`,
|
||||
borderRadius: '0.5rem',
|
||||
borderRadius: 'var(--r-lg)',
|
||||
padding: '1.5rem',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
@@ -167,14 +167,14 @@ function ExportCard({ color, colorRgb, icon: Icon, title, description, children
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||
<Icon style={{ width: '18px', height: '18px', color, flexShrink: 0 }} />
|
||||
<h3 style={{
|
||||
fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '600',
|
||||
color, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
fontFamily: 'var(--font-mono)', fontSize: '0.9rem', fontWeight: '600',
|
||||
color, textTransform: 'uppercase', letterSpacing: 'var(--tracking-wider)',
|
||||
textShadow: `0 0 12px rgba(${colorRgb},0.4)`, margin: 0,
|
||||
}}>
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.72rem', color: '#475569', margin: 0, lineHeight: 1.6 }}>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--fg-disabled)', margin: 0, lineHeight: 1.6 }}>
|
||||
{description}
|
||||
</p>
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: '1rem' }}>
|
||||
@@ -195,13 +195,14 @@ function ExportBtn({ label, exportKey, loading, color, colorRgb, onClick, disabl
|
||||
padding: '0.45rem 0.875rem',
|
||||
background: `rgba(${colorRgb},0.08)`,
|
||||
border: `1px solid rgba(${colorRgb},0.25)`,
|
||||
borderRadius: '0.375rem',
|
||||
color: isLoading ? '#64748B' : color,
|
||||
borderRadius: 'var(--r-md)',
|
||||
color: isLoading ? 'var(--fg-disabled)' : color,
|
||||
cursor: (!!loading || disabled) ? 'not-allowed' : 'pointer',
|
||||
opacity: (!!loading && !isLoading) ? 0.45 : 1,
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600',
|
||||
letterSpacing: '0.05em',
|
||||
transition: 'opacity 0.15s, color 0.15s',
|
||||
fontFamily: 'var(--font-mono)', fontSize: '0.72rem', fontWeight: '600',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
textTransform: 'uppercase',
|
||||
transition: 'opacity var(--dur-fast), color var(--dur-fast)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
@@ -220,10 +221,10 @@ function Toggle({ label, checked, onChange, color, colorRgb }) {
|
||||
<div
|
||||
onClick={() => onChange(!checked)}
|
||||
style={{
|
||||
width: '32px', height: '18px', borderRadius: '9px',
|
||||
width: '32px', height: '18px', borderRadius: 'var(--r-pill)',
|
||||
background: checked ? color : 'rgba(255,255,255,0.1)',
|
||||
border: `1px solid rgba(${colorRgb},0.4)`,
|
||||
position: 'relative', transition: 'background 0.2s',
|
||||
position: 'relative', transition: 'background var(--dur-med)',
|
||||
cursor: 'pointer', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
@@ -231,11 +232,11 @@ function Toggle({ label, checked, onChange, color, colorRgb }) {
|
||||
position: 'absolute', top: '2px',
|
||||
left: checked ? '14px' : '2px',
|
||||
width: '12px', height: '12px', borderRadius: '50%',
|
||||
background: '#E2E8F0',
|
||||
transition: 'left 0.2s',
|
||||
background: 'var(--fg-2)',
|
||||
transition: 'left var(--dur-med)',
|
||||
}} />
|
||||
</div>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#64748B' }}>{label}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--fg-disabled)' }}>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -427,9 +428,9 @@ export default function ExportsPage() {
|
||||
|
||||
if (!canExport()) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '4rem 1rem', color: '#94A3B8' }}>
|
||||
<div style={{ textAlign: 'center', padding: '4rem 1rem', color: 'var(--fg-muted)' }}>
|
||||
<Shield style={{ width: '48px', height: '48px', margin: '0 auto 1rem', opacity: 0.5 }} />
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.9rem' }}>You do not have permission to export data.</p>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', fontSize: '0.9rem' }}>You do not have permission to export data.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -439,8 +440,8 @@ export default function ExportsPage() {
|
||||
|
||||
{/* Page header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||
<Download style={{ width: '20px', height: '20px', color: '#8B5CF6' }} />
|
||||
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#8B5CF6', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(139,92,246,0.4)', margin: 0 }}>
|
||||
<Download style={{ width: '20px', height: '20px', color: '#0EA5E9' }} />
|
||||
<h2 style={{ fontFamily: 'var(--font-mono)', fontSize: '1.5rem', fontWeight: '700', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wider)', textShadow: 'var(--glow-heading)', margin: 0 }}>
|
||||
Exports
|
||||
</h2>
|
||||
</div>
|
||||
@@ -451,10 +452,10 @@ export default function ExportsPage() {
|
||||
display: 'flex', alignItems: 'center', gap: '0.625rem',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
borderRadius: 'var(--r-md)',
|
||||
}}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#EF4444', flex: 1 }}>{error}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: '#EF4444', flex: 1 }}>{error}</span>
|
||||
<button onClick={() => setError(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#EF4444', padding: 0 }}>
|
||||
<X style={{ width: '14px', height: '14px' }} />
|
||||
</button>
|
||||
@@ -477,7 +478,7 @@ export default function ExportsPage() {
|
||||
<ExportBtn label="Overdue SLA" exportKey="ivanti-overdue" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportOverdue} />
|
||||
<ExportBtn label="By Business Unit" exportKey="ivanti-bu" loading={loading} color="#F59E0B" colorRgb="245,158,11" onClick={exportByBU} />
|
||||
</div>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: 'var(--fg-disabled)', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
|
||||
"By Business Unit" creates one sheet per BU in a single workbook.
|
||||
</p>
|
||||
</ExportCard>
|
||||
@@ -501,15 +502,15 @@ export default function ExportsPage() {
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em', whiteSpace: 'nowrap' }}>Status</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.68rem', color: 'var(--fg-disabled)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', whiteSpace: 'nowrap' }}>Status</span>
|
||||
<select
|
||||
value={cveStatus}
|
||||
onChange={e => setCveStatus(e.target.value)}
|
||||
disabled={!!loading}
|
||||
style={{
|
||||
background: 'rgba(34,197,94,0.06)', border: '1px solid rgba(34,197,94,0.2)',
|
||||
borderRadius: '0.25rem', color: '#CBD5E1', padding: '0.25rem 0.5rem',
|
||||
fontFamily: 'monospace', fontSize: '0.72rem', cursor: 'pointer', outline: 'none',
|
||||
background: 'var(--bg-input)', border: '1px solid var(--border-subtle)',
|
||||
borderRadius: 'var(--r-sm)', color: 'var(--fg-3)', padding: '0.25rem 0.5rem',
|
||||
fontFamily: 'var(--font-mono)', fontSize: '0.72rem', cursor: 'pointer', outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
@@ -569,7 +570,7 @@ export default function ExportsPage() {
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<ExportBtn label="Full Report (multi-sheet)" exportKey="atlas-full" loading={loading} color="#A855F7" colorRgb="168,85,247" onClick={exportAtlasFull} />
|
||||
</div>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#334155', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: 'var(--fg-disabled)', margin: '0.75rem 0 0', lineHeight: 1.5 }}>
|
||||
"Full Report" creates three sheets: Active Plans, No Plan, and History (overridden plans).
|
||||
</p>
|
||||
</ExportCard>
|
||||
|
||||
@@ -2,42 +2,37 @@
|
||||
// Full-page knowledge base library — browse, search, filter, and read
|
||||
// articles inline. Upload and delete require editor/admin role.
|
||||
// Reuses existing KnowledgeBaseViewer and KnowledgeBaseModal components.
|
||||
// Layout: list-based rows inside a single Card container (design kit v2).
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
BookOpen, Search, Upload, RefreshCw, Loader,
|
||||
AlertCircle, Trash2, X, // FileText and File available if needed later
|
||||
BookOpen, Search, RefreshCw, Loader,
|
||||
AlertCircle, Trash2, X, Download, FilePlus,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import KnowledgeBaseModal from '../KnowledgeBaseModal';
|
||||
import KnowledgeBaseViewer from '../KnowledgeBaseViewer';
|
||||
import ConfirmModal from '../ConfirmModal'; // ⚠️ CONVENTION: ConfirmModal is imported but never used — either integrate it into handleDelete or remove this import
|
||||
import ConfirmModal from '../ConfirmModal';
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||
const GREEN = '#10B981';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static config
|
||||
// ---------------------------------------------------------------------------
|
||||
const CATEGORY_COLORS = {
|
||||
General: '#94A3B8',
|
||||
Policy: '#0EA5E9',
|
||||
Procedure: GREEN,
|
||||
Guide: '#F59E0B',
|
||||
Reference: '#8B5CF6',
|
||||
};
|
||||
|
||||
const FILE_EXT_COLORS = {
|
||||
pdf: '#EF4444',
|
||||
md: '#10B981',
|
||||
md: '#38BDF8',
|
||||
txt: '#94A3B8',
|
||||
doc: '#0EA5E9',
|
||||
docx: '#0EA5E9',
|
||||
doc: '#7DD3FC',
|
||||
docx: '#7DD3FC',
|
||||
xls: '#10B981',
|
||||
xlsx: '#10B981',
|
||||
ppt: '#F97316',
|
||||
pptx: '#F97316',
|
||||
ppt: '#F59E0B',
|
||||
pptx: '#F59E0B',
|
||||
html: '#8B5CF6',
|
||||
json: '#94A3B8',
|
||||
yaml: '#94A3B8',
|
||||
yml: '#94A3B8',
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER = ['Procedure', 'Guide', 'Policy', 'Reference', 'General'];
|
||||
@@ -50,7 +45,7 @@ function extOf(filename) {
|
||||
}
|
||||
|
||||
function extColor(filename) {
|
||||
return FILE_EXT_COLORS[extOf(filename)] || '#64748B';
|
||||
return FILE_EXT_COLORS[extOf(filename)] || '#94A3B8';
|
||||
}
|
||||
|
||||
function fmtSize(bytes) {
|
||||
@@ -65,138 +60,215 @@ function fmtDate(str) {
|
||||
return new Date(str).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function catColor(cat) {
|
||||
return CATEGORY_COLORS[cat] || '#94A3B8';
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileTypeChip — 36x36 square showing file extension
|
||||
// ---------------------------------------------------------------------------
|
||||
function FileTypeChip({ filename }) {
|
||||
const ext = extOf(filename);
|
||||
const color = extColor(filename);
|
||||
const label = ext.toUpperCase();
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 36, height: 36, borderRadius: 6,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: `1px solid ${color}`,
|
||||
color: color,
|
||||
fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: 10,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ArticleCard
|
||||
// ArticleRow — horizontal row for each article
|
||||
// ---------------------------------------------------------------------------
|
||||
function ArticleCard({ article, selected, onSelect, onDelete, canDelete }) {
|
||||
const color = catColor(article.category);
|
||||
const fileColor = extColor(article.file_name);
|
||||
const ext = extOf(article.file_name).toUpperCase();
|
||||
function ArticleRow({ article, selected, onSelect, onDelete, canDelete }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(article)}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '14px 18px',
|
||||
background: selected
|
||||
? `linear-gradient(135deg,rgba(16,185,129,0.1) 0%,rgba(15,23,42,0.98) 100%)`
|
||||
: 'linear-gradient(135deg,rgba(30,41,59,0.95) 0%,rgba(15,23,42,0.98) 100%)',
|
||||
border: `1.5px solid ${selected ? GREEN : 'rgba(16,185,129,0.12)'}`,
|
||||
borderRadius: '0.5rem',
|
||||
padding: '1rem',
|
||||
? 'linear-gradient(90deg, rgba(14,165,233,0.14) 0%, rgba(14,165,233,0.06) 100%)'
|
||||
: hover
|
||||
? 'linear-gradient(90deg, rgba(14,165,233,0.10) 0%, rgba(14,165,233,0.04) 100%)'
|
||||
: 'transparent',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
boxShadow: selected
|
||||
? 'inset 3px 0 0 var(--intel-accent)'
|
||||
: hover
|
||||
? 'inset 3px 0 0 var(--intel-accent)'
|
||||
: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
transition: 'all 200ms cubic-bezier(0.4,0,0.2,1)',
|
||||
}}
|
||||
onMouseEnter={e => { if (!selected) e.currentTarget.style.borderColor = 'rgba(16,185,129,0.35)'; }}
|
||||
onMouseLeave={e => { if (!selected) e.currentTarget.style.borderColor = 'rgba(16,185,129,0.12)'; }}
|
||||
>
|
||||
{/* File type badge + delete button */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{
|
||||
fontFamily: 'monospace', fontSize: '0.58rem', fontWeight: '700',
|
||||
color: fileColor, padding: '0.15rem 0.4rem',
|
||||
background: `${fileColor}15`, borderRadius: '0.2rem',
|
||||
border: `1px solid ${fileColor}30`,
|
||||
{/* Col 1: File type chip */}
|
||||
<FileTypeChip filename={article.file_name} />
|
||||
|
||||
{/* Col 2: Title + Description */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
font: '600 14px var(--font-ui)', color: 'var(--fg-1)',
|
||||
marginBottom: 3,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{ext}
|
||||
{article.title}
|
||||
</div>
|
||||
{article.description && (
|
||||
<div style={{
|
||||
font: '400 12px var(--font-ui)', color: 'var(--fg-muted)',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{article.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Col 3: Category + Date */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'flex-end',
|
||||
gap: 4, minWidth: 110, flexShrink: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 4,
|
||||
background: 'var(--bg-elevated)',
|
||||
color: 'var(--fg-2)', font: '500 11px var(--font-ui)',
|
||||
}}>
|
||||
{article.category}
|
||||
</span>
|
||||
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--fg-muted)' }}>
|
||||
{fmtDate(article.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Col 4: Size + Author */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'flex-end',
|
||||
gap: 4, minWidth: 90, flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--fg-muted)' }}>
|
||||
{fmtSize(article.file_size)}
|
||||
</span>
|
||||
<span style={{ font: '400 11px var(--font-mono)', color: 'var(--fg-disabled)' }}>
|
||||
{article.created_by_name || article.created_by || ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Col 5: Action buttons */}
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
// Download via API
|
||||
window.open(`${API_BASE}/knowledge-base/${article.id}/download`, '_blank');
|
||||
}}
|
||||
title="Download"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border-1)',
|
||||
borderRadius: 6, padding: 7, cursor: 'pointer',
|
||||
color: 'var(--fg-2)', display: 'flex',
|
||||
transition: 'all 150ms',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--intel-accent)'; e.currentTarget.style.color = 'var(--intel-accent)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-1)'; e.currentTarget.style.color = 'var(--fg-2)'; }}
|
||||
>
|
||||
<Download style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onDelete(article); }}
|
||||
title="Delete article"
|
||||
style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: '#334155', padding: '0.15rem',
|
||||
borderRadius: '0.2rem', display: 'flex', alignItems: 'center',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border-1)',
|
||||
borderRadius: 6, padding: 7, cursor: 'pointer',
|
||||
color: 'var(--fg-2)', display: 'flex',
|
||||
transition: 'all 150ms',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = '#EF4444'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#334155'; }}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#EF4444'; e.currentTarget.style.color = '#EF4444'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-1)'; e.currentTarget.style.color = 'var(--fg-2)'; }}
|
||||
>
|
||||
<Trash2 style={{ width: '12px', height: '12px' }} />
|
||||
<Trash2 style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div style={{
|
||||
fontFamily: 'monospace', fontSize: '0.82rem', fontWeight: '700',
|
||||
color: selected ? GREEN : '#E2E8F0',
|
||||
lineHeight: 1.3,
|
||||
}}>
|
||||
{article.title}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{article.description && (
|
||||
<div style={{
|
||||
fontSize: '0.7rem', color: '#475569',
|
||||
lineHeight: 1.45, display: '-webkit-box',
|
||||
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{article.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer — category + date */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: '0.375rem', borderTop: '1px solid rgba(255,255,255,0.04)' }}>
|
||||
<span style={{
|
||||
fontFamily: 'monospace', fontSize: '0.6rem', fontWeight: '600',
|
||||
color, padding: '0.15rem 0.4rem',
|
||||
background: `${color}12`, borderRadius: '0.2rem',
|
||||
border: `1px solid ${color}25`,
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
}}>
|
||||
{article.category}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{article.file_size && (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#334155' }}>
|
||||
{fmtSize(article.file_size)}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', color: '#334155' }}>
|
||||
{fmtDate(article.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CategoryPill — filter tab matching design kit
|
||||
// ---------------------------------------------------------------------------
|
||||
function CategoryPill({ label, active, count, onClick }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 14px', borderRadius: 6,
|
||||
background: active
|
||||
? 'linear-gradient(135deg, rgba(14,165,233,0.20) 0%, rgba(14,165,233,0.12) 100%)'
|
||||
: (hover ? 'rgba(14,165,233,0.08)' : 'transparent'),
|
||||
color: active ? 'var(--intel-accent-bright)' : 'var(--fg-2)',
|
||||
border: `1px solid ${active ? 'var(--intel-accent)' : 'var(--border-default)'}`,
|
||||
fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: 11,
|
||||
textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||
textShadow: active ? '0 0 8px rgba(14,165,233,0.4)' : 'none',
|
||||
boxShadow: active ? '0 0 16px rgba(14,165,233,0.20)' : 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 200ms cubic-bezier(0.4,0,0.2,1)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<span style={{
|
||||
font: '700 10px var(--font-mono)',
|
||||
color: active ? 'var(--intel-accent-bright)' : 'var(--fg-muted)',
|
||||
padding: '1px 6px', borderRadius: 999,
|
||||
background: active ? 'rgba(14,165,233,0.15)' : 'rgba(148,163,184,0.10)',
|
||||
}}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty state
|
||||
// ---------------------------------------------------------------------------
|
||||
function EmptyState({ hasFilter, onClear }) {
|
||||
return (
|
||||
<div style={{
|
||||
gridColumn: '1 / -1',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: 'center', padding: '4rem 2rem',
|
||||
border: '1px dashed rgba(16,185,129,0.15)', borderRadius: '0.5rem',
|
||||
color: '#334155',
|
||||
color: 'var(--fg-disabled)',
|
||||
}}>
|
||||
<BookOpen style={{ width: '36px', height: '36px', marginBottom: '1rem', opacity: 0.4 }} />
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.8rem', marginBottom: '0.375rem' }}>
|
||||
<BookOpen style={{ width: 36, height: 36, marginBottom: '1rem', opacity: 0.4 }} />
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', marginBottom: '0.375rem' }}>
|
||||
{hasFilter ? 'No articles match your search' : 'No articles yet'}
|
||||
</div>
|
||||
{hasFilter ? (
|
||||
<button onClick={onClear} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: GREEN, fontFamily: 'monospace', fontSize: '0.72rem',
|
||||
color: '#0EA5E9', fontFamily: 'var(--font-mono)', fontSize: '0.72rem',
|
||||
marginTop: '0.375rem',
|
||||
}}>
|
||||
Clear filters
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--fg-disabled)' }}>
|
||||
Upload a document to get started
|
||||
</div>
|
||||
)}
|
||||
@@ -208,12 +280,13 @@ function EmptyState({ hasFilter, onClear }) {
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function KnowledgeBasePage() {
|
||||
const { canWrite } = useAuth();
|
||||
const { canWrite, canDelete: canDeleteResource } = useAuth();
|
||||
|
||||
const [articles, setArticles] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState('newest');
|
||||
const [activeCategory, setActiveCategory] = useState('All');
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
@@ -264,18 +337,33 @@ export default function KnowledgeBasePage() {
|
||||
}, [selected]);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Filtering
|
||||
// Filtering + sorting
|
||||
// -------------------------------------------------------------------------
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return articles.filter(a => {
|
||||
let result = articles.filter(a => {
|
||||
const matchesCat = activeCategory === 'All' || a.category === activeCategory;
|
||||
const matchesSearch = !q ||
|
||||
a.title.toLowerCase().includes(q) ||
|
||||
(a.description || '').toLowerCase().includes(q);
|
||||
return matchesCat && matchesSearch;
|
||||
});
|
||||
}, [articles, activeCategory, search]);
|
||||
|
||||
// Sort
|
||||
result = [...result].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'oldest':
|
||||
return new Date(a.created_at) - new Date(b.created_at);
|
||||
case 'title':
|
||||
return (a.title || '').localeCompare(b.title || '');
|
||||
case 'newest':
|
||||
default:
|
||||
return new Date(b.created_at) - new Date(a.created_at);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [articles, activeCategory, search, sortBy]);
|
||||
|
||||
// Category tab counts (always from full list, not filtered by search)
|
||||
const categoryCounts = useMemo(() => {
|
||||
@@ -296,128 +384,161 @@ export default function KnowledgeBasePage() {
|
||||
// Render
|
||||
// -------------------------------------------------------------------------
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem', paddingBottom: '2rem' }}>
|
||||
<div style={{ padding: '24px 24px 48px', maxWidth: 1280, margin: '0 auto' }}>
|
||||
|
||||
{/* ── Page header ─────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
fontFamily: 'monospace', fontSize: '1.5rem', fontWeight: '700',
|
||||
color: GREEN, textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
textShadow: `0 0 16px ${GREEN}40`, marginBottom: '0.25rem',
|
||||
<h1 style={{
|
||||
font: '700 24px var(--font-mono)',
|
||||
color: 'var(--intel-accent-bright)',
|
||||
margin: 0, textTransform: 'uppercase',
|
||||
letterSpacing: '0.10em',
|
||||
textShadow: 'var(--glow-heading)',
|
||||
}}>
|
||||
Knowledge Base
|
||||
</h2>
|
||||
<div style={{ fontSize: '0.72rem', color: '#475569', fontFamily: 'monospace' }}>
|
||||
{loading ? '…' : `${articles.length} article${articles.length !== 1 ? 's' : ''}`}
|
||||
{articles.length > 0 && activeCategory !== 'All' && (
|
||||
<span style={{ marginLeft: '0.5rem', color: '#334155' }}>
|
||||
· {categoryCounts[activeCategory] || 0} in {activeCategory}
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<div style={{
|
||||
font: '400 13px var(--font-ui)',
|
||||
color: 'var(--fg-muted)', marginTop: 6,
|
||||
}}>
|
||||
Internal reference material — runbooks, advisories, policies
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={fetchArticles}
|
||||
title="Refresh"
|
||||
style={{
|
||||
background: 'none', border: `1px solid rgba(16,185,129,0.25)`,
|
||||
borderRadius: '0.375rem', padding: '0.5rem', cursor: 'pointer', color: '#475569',
|
||||
background: 'none', border: '1px solid rgba(14,165,233,0.25)',
|
||||
borderRadius: 'var(--r-md)', padding: '0.5rem', cursor: 'pointer',
|
||||
color: 'var(--fg-disabled)', display: 'flex', alignItems: 'center',
|
||||
transition: 'all 150ms',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = GREEN; e.currentTarget.style.borderColor = `${GREEN}60`; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'rgba(16,185,129,0.25)'; }}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = '#0EA5E9'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.5)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--fg-disabled)'; e.currentTarget.style.borderColor = 'rgba(14,165,233,0.25)'; }}
|
||||
>
|
||||
<RefreshCw style={{ width: '16px', height: '16px' }} />
|
||||
<RefreshCw style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Export list as CSV
|
||||
const header = 'Title,Category,File,Size,Date,Author\n';
|
||||
const rows = filtered.map(a =>
|
||||
`"${a.title}","${a.category}","${a.file_name}","${fmtSize(a.file_size)}","${fmtDate(a.created_at)}","${a.created_by_name || a.created_by || ''}"`
|
||||
).join('\n');
|
||||
const blob = new Blob([header + rows], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'knowledge-base-export.csv';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(14,165,233,0.25)',
|
||||
color: 'var(--fg-2)', padding: '0.5rem 1rem',
|
||||
fontFamily: 'var(--font-mono)', fontSize: '0.75rem', fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
cursor: 'pointer', borderRadius: 'var(--r-md)',
|
||||
transition: 'all 150ms',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'rgba(14,165,233,0.5)'; e.currentTarget.style.color = '#0EA5E9'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'rgba(14,165,233,0.25)'; e.currentTarget.style.color = 'var(--fg-2)'; }}
|
||||
>
|
||||
<Download style={{ width: 14, height: 14 }} />
|
||||
Export List
|
||||
</button>
|
||||
{canWrite() && (
|
||||
<button
|
||||
onClick={() => setShowUpload(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
background: `${GREEN}18`, border: `1px solid ${GREEN}`,
|
||||
color: GREEN, padding: '0.5rem 1rem',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
cursor: 'pointer', borderRadius: '0.375rem',
|
||||
background: 'rgba(14,165,233,0.08)', border: '1px solid #0EA5E9',
|
||||
color: '#38BDF8', padding: '0.5rem 1rem',
|
||||
fontFamily: 'var(--font-mono)', fontSize: '0.75rem', fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)',
|
||||
cursor: 'pointer', borderRadius: 'var(--r-md)',
|
||||
transition: 'all 150ms',
|
||||
}}
|
||||
>
|
||||
<Upload style={{ width: '14px', height: '14px' }} />
|
||||
<FilePlus style={{ width: 14, height: 14 }} />
|
||||
Upload Article
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Search + category tabs ───────────────────────────────── */}
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
{/* ── Search + Sort row ────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
<div style={{ flex: 1, maxWidth: 420, position: 'relative' }}>
|
||||
<Search style={{
|
||||
position: 'absolute', left: '0.625rem', top: '50%', transform: 'translateY(-50%)',
|
||||
width: '13px', height: '13px', color: '#334155', pointerEvents: 'none',
|
||||
position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
|
||||
width: 14, height: 14, color: 'var(--fg-disabled)', pointerEvents: 'none',
|
||||
}} />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search articles…"
|
||||
placeholder="Search title or description…"
|
||||
style={{
|
||||
paddingLeft: '2rem', paddingRight: search ? '2rem' : '0.625rem',
|
||||
paddingTop: '0.4rem', paddingBottom: '0.4rem',
|
||||
background: 'rgba(15,23,42,0.8)',
|
||||
border: '1px solid rgba(16,185,129,0.2)',
|
||||
borderRadius: '0.375rem', color: '#E2E8F0',
|
||||
outline: 'none', fontFamily: 'monospace', fontSize: '0.75rem',
|
||||
width: '220px',
|
||||
width: '100%', boxSizing: 'border-box',
|
||||
paddingLeft: 34, paddingRight: search ? 32 : 10,
|
||||
paddingTop: 8, paddingBottom: 8,
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: 'var(--r-md)', color: 'var(--fg-2)',
|
||||
outline: 'none', fontFamily: 'var(--font-mono)', fontSize: 13,
|
||||
}}
|
||||
onFocus={e => e.target.style.borderColor = `${GREEN}60`}
|
||||
onBlur={e => e.target.style.borderColor = 'rgba(16,185,129,0.2)'}
|
||||
onFocus={e => e.target.style.borderColor = 'var(--border-focus)'}
|
||||
onBlur={e => e.target.style.borderColor = 'var(--border-subtle)'}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
style={{
|
||||
position: 'absolute', right: '0.5rem', top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'none', border: 'none', cursor: 'pointer', color: '#334155', padding: 0,
|
||||
position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--fg-disabled)', padding: 0, display: 'flex',
|
||||
}}
|
||||
>
|
||||
<X style={{ width: '12px', height: '12px' }} />
|
||||
<X style={{ width: 12, height: 12 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value)}
|
||||
style={{
|
||||
minWidth: 160, padding: '8px 12px',
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: 'var(--r-md)', color: 'var(--fg-2)',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 13,
|
||||
outline: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
<option value="title">Title A–Z</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap' }}>
|
||||
{activeTabs.map(cat => {
|
||||
const isActive = activeCategory === cat;
|
||||
const color = cat === 'All' ? GREEN : catColor(cat);
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
style={{
|
||||
padding: '0.35rem 0.75rem',
|
||||
fontFamily: 'monospace', fontSize: '0.7rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
cursor: 'pointer', borderRadius: '0.25rem',
|
||||
border: isActive ? `1px solid ${color}` : '1px solid transparent',
|
||||
background: isActive ? `${color}15` : 'transparent',
|
||||
color: isActive ? color : '#475569',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) { e.currentTarget.style.color = '#94A3B8'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; }}}
|
||||
onMouseLeave={e => { if (!isActive) { e.currentTarget.style.color = '#475569'; e.currentTarget.style.borderColor = 'transparent'; }}}
|
||||
>
|
||||
{cat}
|
||||
<span style={{ marginLeft: '0.35rem', opacity: 0.6, fontWeight: '400' }}>
|
||||
{categoryCounts[cat] ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* ── Category pills ───────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 16 }}>
|
||||
{activeTabs.map(cat => (
|
||||
<CategoryPill
|
||||
key={cat}
|
||||
label={cat}
|
||||
count={categoryCounts[cat] ?? 0}
|
||||
active={activeCategory === cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Error state ──────────────────────────────────────────── */}
|
||||
@@ -426,14 +547,19 @@ export default function KnowledgeBasePage() {
|
||||
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.875rem 1rem',
|
||||
background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.3)',
|
||||
borderRadius: '0.5rem', color: '#F87171',
|
||||
fontFamily: 'monospace', fontSize: '0.78rem',
|
||||
borderRadius: 'var(--r-lg)', color: '#F87171',
|
||||
fontFamily: 'var(--font-mono)', fontSize: '0.78rem',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<AlertCircle style={{ width: '15px', height: '15px', flexShrink: 0 }} />
|
||||
<AlertCircle style={{ width: 15, height: 15, flexShrink: 0 }} />
|
||||
{error}
|
||||
<button
|
||||
onClick={fetchArticles}
|
||||
style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: '#F87171', fontFamily: 'monospace', fontSize: '0.72rem' }}
|
||||
style={{
|
||||
marginLeft: 'auto', background: 'none', border: 'none',
|
||||
cursor: 'pointer', color: '#F87171',
|
||||
fontFamily: 'var(--font-mono)', fontSize: '0.72rem',
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -443,34 +569,51 @@ export default function KnowledgeBasePage() {
|
||||
{/* ── Loading state ────────────────────────────────────────── */}
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<Loader style={{ width: '28px', height: '28px', color: GREEN, animation: 'spin 1s linear infinite' }} />
|
||||
<Loader style={{ width: 28, height: 28, color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Article grid ─────────────────────────────────────────── */}
|
||||
{/* ── Article list (Card wrapper) ──────────────────────────── */}
|
||||
{!loading && !error && (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||
gap: '0.875rem',
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
|
||||
border: '1.5px solid rgba(14,165,233,0.30)',
|
||||
borderRadius: 8,
|
||||
padding: 0,
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState hasFilter={hasFilter} onClear={clearFilters} />
|
||||
) : (
|
||||
filtered.map(article => (
|
||||
<ArticleCard
|
||||
<ArticleRow
|
||||
key={article.id}
|
||||
article={article}
|
||||
selected={selected?.id === article.id}
|
||||
onSelect={a => setSelected(selected?.id === a.id ? null : a)}
|
||||
onDelete={handleDelete}
|
||||
canDelete={canWrite()}
|
||||
canDelete={canDeleteResource(article)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Footer count ─────────────────────────────────────────── */}
|
||||
{!loading && !error && (
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
font: '400 12px var(--font-mono)',
|
||||
color: 'var(--fg-muted)',
|
||||
}}>
|
||||
{filtered.length} article{filtered.length === 1 ? '' : 's'}
|
||||
{activeCategory !== 'All' && (
|
||||
<> · filtered to <span style={{ color: 'var(--accent)' }}>{activeCategory}</span></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Inline viewer ────────────────────────────────────────── */}
|
||||
{selected && (
|
||||
<div style={{ marginTop: '0.25rem' }}>
|
||||
@@ -489,7 +632,7 @@ export default function KnowledgeBasePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{/* ── Confirmation Modal ───────────────────────────────────── */}
|
||||
<ConfirmModal
|
||||
open={!!pendingConfirm}
|
||||
title={pendingConfirm?.title}
|
||||
|
||||
@@ -263,7 +263,7 @@ function StatusDonut({ open, closed, loading }) {
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data — click Sync to load</p>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: '#475569' }}>No data — click Sync to load</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -288,10 +288,10 @@ function StatusDonut({ open, closed, loading }) {
|
||||
/>
|
||||
))}
|
||||
{/* Center total */}
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
{total.toLocaleString()}
|
||||
</text>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
TOTAL
|
||||
</text>
|
||||
</svg>
|
||||
@@ -302,10 +302,10 @@ function StatusDonut({ open, closed, loading }) {
|
||||
<div key={seg.label} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
{seg.label}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', lineHeight: 1.2 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', lineHeight: 1.2 }}>
|
||||
{seg.count.toLocaleString()}
|
||||
<span style={{ fontSize: '0.68rem', fontWeight: '400', color: '#64748B', marginLeft: '0.4rem' }}>
|
||||
({((seg.count / total) * 100).toFixed(1)}%)
|
||||
@@ -346,7 +346,7 @@ function ActionCoverageDonut({ findings, activeSegment, onSegmentClick }) {
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data — click Sync to load</p>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: '#475569' }}>No data — click Sync to load</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -381,10 +381,10 @@ function ActionCoverageDonut({ findings, activeSegment, onSegmentClick }) {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
{total.toLocaleString()}
|
||||
</text>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
TOTAL
|
||||
</text>
|
||||
</svg>
|
||||
@@ -402,13 +402,13 @@ function ActionCoverageDonut({ findings, activeSegment, onSegmentClick }) {
|
||||
>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0, outline: isActive ? `2px solid ${seg.color}` : 'none', outlineOffset: '1px' }} />
|
||||
<div>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{seg.label}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||
{seg.count}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||
({total > 0 ? ((seg.count / total) * 100).toFixed(0) : 0}%)
|
||||
</span>
|
||||
</div>
|
||||
@@ -418,7 +418,7 @@ function ActionCoverageDonut({ findings, activeSegment, onSegmentClick }) {
|
||||
{hasActive && (
|
||||
<button
|
||||
onClick={() => onSegmentClick(null)}
|
||||
style={{ marginTop: '0.25rem', background: 'none', border: 'none', fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', cursor: 'pointer', textAlign: 'left', padding: 0, textDecoration: 'underline' }}
|
||||
style={{ marginTop: '0.25rem', background: 'none', border: 'none', fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: '#475569', cursor: 'pointer', textAlign: 'left', padding: 0, textDecoration: 'underline' }}
|
||||
>
|
||||
clear filter
|
||||
</button>
|
||||
@@ -451,7 +451,7 @@ function FPWorkflowDonut({ counts, total, centerLabel = 'FP TOTAL' }) {
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No FP workflows — click Sync to load</p>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: '#475569' }}>No FP workflows — click Sync to load</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -477,10 +477,10 @@ function FPWorkflowDonut({ counts, total, centerLabel = 'FP TOTAL' }) {
|
||||
opacity={0.88}
|
||||
/>
|
||||
))}
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
{total.toLocaleString()}
|
||||
</text>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
{centerLabel}
|
||||
</text>
|
||||
</svg>
|
||||
@@ -491,13 +491,13 @@ function FPWorkflowDonut({ counts, total, centerLabel = 'FP TOTAL' }) {
|
||||
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||||
<div>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{seg.label}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||
{seg.count}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||
({((seg.count / total) * 100).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
@@ -536,7 +536,7 @@ function AtlasCoverageDonut({ hostsWithPlans, hostsWithoutPlans, totalHosts }) {
|
||||
if (totalHosts === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data — run Atlas Sync</p>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: '#475569' }}>No data — run Atlas Sync</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -558,10 +558,10 @@ function AtlasCoverageDonut({ hostsWithPlans, hostsWithoutPlans, totalHosts }) {
|
||||
opacity={0.88}
|
||||
/>
|
||||
))}
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
{totalHosts.toLocaleString()}
|
||||
</text>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
HOSTS
|
||||
</text>
|
||||
</svg>
|
||||
@@ -572,10 +572,10 @@ function AtlasCoverageDonut({ hostsWithPlans, hostsWithoutPlans, totalHosts }) {
|
||||
<div key={seg.label} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
{seg.label}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', lineHeight: 1.2 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', lineHeight: 1.2 }}>
|
||||
{seg.count.toLocaleString()}
|
||||
<span style={{ fontSize: '0.68rem', fontWeight: '400', color: '#64748B', marginLeft: '0.4rem' }}>
|
||||
({((seg.count / totalHosts) * 100).toFixed(1)}%)
|
||||
@@ -599,7 +599,7 @@ function AtlasPlanTypeDonut({ plansByType, totalPlans }) {
|
||||
if (totalPlans === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No plans — run Atlas Sync</p>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: '#475569' }}>No plans — run Atlas Sync</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -625,10 +625,10 @@ function AtlasPlanTypeDonut({ plansByType, totalPlans }) {
|
||||
opacity={0.88}
|
||||
/>
|
||||
))}
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
{totalPlans.toLocaleString()}
|
||||
</text>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
PLANS
|
||||
</text>
|
||||
</svg>
|
||||
@@ -639,13 +639,13 @@ function AtlasPlanTypeDonut({ plansByType, totalPlans }) {
|
||||
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||||
<div>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{seg.label}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||
{seg.count}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||
({((seg.count / totalPlans) * 100).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
@@ -666,7 +666,7 @@ function AtlasPlanStatusDonut({ plansByStatus, totalPlans }) {
|
||||
if (totalPlans === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No plans — run Atlas Sync</p>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: '#475569' }}>No plans — run Atlas Sync</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -700,10 +700,10 @@ function AtlasPlanStatusDonut({ plansByStatus, totalPlans }) {
|
||||
opacity={0.88}
|
||||
/>
|
||||
))}
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||
{totalPlans.toLocaleString()}
|
||||
</text>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'var(--font-mono)', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||
STATUS
|
||||
</text>
|
||||
</svg>
|
||||
@@ -714,13 +714,13 @@ function AtlasPlanStatusDonut({ plansByStatus, totalPlans }) {
|
||||
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||||
<div>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{seg.label}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||
{seg.count}
|
||||
</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||
({((seg.count / totalPlans) * 100).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
@@ -952,13 +952,14 @@ function ColumnManager({ columnOrder, onChange }) {
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
background: open ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.07)',
|
||||
border: `1px solid rgba(14,165,233,${open ? '0.5' : '0.25'})`,
|
||||
borderRadius: '0.375rem',
|
||||
padding: '8px 14px',
|
||||
background: open ? 'rgba(14,165,233,0.16)' : 'rgba(14,165,233,0.08)',
|
||||
border: `1px solid rgba(14,165,233,${open ? '0.55' : '0.25'})`,
|
||||
borderRadius: 6,
|
||||
color: '#0EA5E9', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em'
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
transition: 'all 150ms cubic-bezier(0.4,0,0.2,1)',
|
||||
}}
|
||||
>
|
||||
<Settings2 style={{ width: '13px', height: '13px' }} />
|
||||
@@ -1195,9 +1196,10 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
|
||||
const sc = severityColor(finding.vrrGroup);
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', padding: '0.2rem 0.45rem', borderRadius: '0.25rem', background: sc.bg, border: `1px solid ${sc.border}40`, fontFamily: 'monospace', fontWeight: '700', color: sc.text, fontSize: '0.72rem' }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600, color: sc.text, letterSpacing: '0.04em' }}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: sc.border, boxShadow: `0 0 6px ${sc.border}99`, flexShrink: 0 }} />
|
||||
{finding.severity?.toFixed(2)}
|
||||
<span style={{ fontSize: '0.6rem', opacity: 0.75 }}>{finding.vrrGroup}</span>
|
||||
<span style={{ fontSize: 10, opacity: 0.75 }}>{finding.vrrGroup}</span>
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
@@ -1278,12 +1280,32 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case 'slaStatus':
|
||||
case 'slaStatus': {
|
||||
const slc = slaColor(finding.slaStatus);
|
||||
const slaMap = {
|
||||
'#EF4444': 'rgba(239,68,68,0.16)',
|
||||
'#F59E0B': 'rgba(245,158,11,0.16)',
|
||||
'#10B981': 'rgba(16,185,129,0.16)',
|
||||
'#64748B': 'rgba(100,116,139,0.16)',
|
||||
};
|
||||
return (
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: slaColor(finding.slaStatus) }}>
|
||||
{finding.slaStatus || '—'}
|
||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||
{finding.slaStatus ? (
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 999,
|
||||
background: slaMap[slc] || 'rgba(100,116,139,0.16)',
|
||||
color: slc,
|
||||
fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: 10,
|
||||
letterSpacing: '0.05em',
|
||||
}}>
|
||||
{finding.slaStatus.replace('_', ' ')}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#475569', fontFamily: 'var(--font-mono)', fontSize: 11 }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case 'buOwnership': {
|
||||
const bu = finding.buOwnership || '';
|
||||
const isSteam = bu.toUpperCase().includes('STEAM');
|
||||
@@ -1319,14 +1341,15 @@ function TableCell({ colKey, finding, canWrite, onCveMouseEnter, onCveMouseLeave
|
||||
title={`${wf.id} · ${wf.state || 'Unknown'} · ${wf.type || ''}`}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||||
padding: '0.15rem 0.45rem', borderRadius: '0.25rem',
|
||||
background: ws.bg, border: `1px solid ${ws.border}`,
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
padding: '2px 8px', borderRadius: 4,
|
||||
background: ws.bg, border: `1px solid ${ws.text}55`,
|
||||
fontFamily: 'var(--font-mono)', fontSize: 10, fontWeight: 700,
|
||||
color: ws.text, cursor: 'default',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{wf.id}
|
||||
<span style={{ fontSize: '0.58rem', opacity: 0.8, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
<span style={{ fontSize: 9, opacity: 0.8, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
{wf.state}
|
||||
</span>
|
||||
</span>
|
||||
@@ -3638,13 +3661,14 @@ function RowVisibilityManager({ hiddenRowIds, findings, onRestore, onRestoreAll
|
||||
onClick={() => setOpen(p => !p)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.35rem 0.75rem',
|
||||
background: open ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.08)',
|
||||
border: `1px solid rgba(14,165,233,${open ? '0.5' : '0.2'})`,
|
||||
borderRadius: '0.375rem',
|
||||
color: '#94a3b8', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600',
|
||||
padding: '8px 14px',
|
||||
background: open ? 'rgba(14,165,233,0.16)' : 'rgba(14,165,233,0.08)',
|
||||
border: `1px solid rgba(14,165,233,${open ? '0.55' : '0.25'})`,
|
||||
borderRadius: 6,
|
||||
color: '#0EA5E9', cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
transition: 'all 150ms cubic-bezier(0.4,0,0.2,1)',
|
||||
}}
|
||||
>
|
||||
<EyeOff style={{ width: '13px', height: '13px' }} />
|
||||
@@ -4946,16 +4970,15 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
Panel 1 — Metrics placeholder
|
||||
---------------------------------------------------------------- */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
||||
border: '1px solid rgba(245,158,11,0.2)',
|
||||
borderLeft: '3px solid #F59E0B',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
|
||||
border: '1.5px solid rgba(14,165,233,0.12)',
|
||||
borderRadius: 8,
|
||||
padding: '1.5rem',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '1rem' }}>
|
||||
<PieChart style={{ width: '20px', height: '20px', color: '#F59E0B' }} />
|
||||
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(245,158,11,0.4)', margin: 0 }}>
|
||||
<PieChart style={{ width: '20px', height: '20px', color: '#0EA5E9' }} />
|
||||
<h2 style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', margin: 0 }}>
|
||||
Metric Graphs
|
||||
</h2>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.25rem' }} role="tablist">
|
||||
@@ -4970,20 +4993,17 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
onClick={() => setMetricsTab(tab.key)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') setMetricsTab(tab.key); }}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: isActive ? '2px solid #F59E0B' : '2px solid transparent',
|
||||
color: isActive ? '#F59E0B' : '#64748B',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.7rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
padding: '0.375rem 0.75rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s, color 0.15s'
|
||||
padding: '6px 12px',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
cursor: 'pointer', borderRadius: 4,
|
||||
border: isActive ? '1px solid #0EA5E9' : '1px solid transparent',
|
||||
background: isActive ? 'rgba(14,165,233,0.15)' : 'transparent',
|
||||
color: isActive ? '#0EA5E9' : 'var(--fg-muted)',
|
||||
transition: 'all 120ms',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'rgba(245, 158, 11, 0.06)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
|
||||
onMouseEnter={(e) => { if (!isActive) { e.currentTarget.style.border = '1px solid rgba(255,255,255,0.10)'; e.currentTarget.style.color = '#94A3B8'; } }}
|
||||
onMouseLeave={(e) => { if (!isActive) { e.currentTarget.style.border = '1px solid transparent'; e.currentTarget.style.color = 'var(--fg-muted)'; } }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
@@ -4996,7 +5016,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
{/* Open vs Closed donut */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
Open vs Closed
|
||||
</div>
|
||||
<StatusDonut
|
||||
@@ -5011,7 +5031,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
|
||||
{/* Action Coverage donut */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
Action Coverage
|
||||
{actionFilter && <span style={{ marginLeft: '0.5rem', color: ACTION_DEFS.find(d => d.key === actionFilter)?.color, fontSize: '0.6rem' }}>● filtered</span>}
|
||||
</div>
|
||||
@@ -5030,7 +5050,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
|
||||
{/* FP Finding Status donut — # of findings per FP workflow state */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
FP Finding Status
|
||||
</div>
|
||||
<FPWorkflowDonut counts={fpCounts.findingCounts} total={fpCounts.findingTotal} centerLabel="FINDINGS" />
|
||||
@@ -5041,7 +5061,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
|
||||
{/* FP Workflow Status donut — # of unique FP# ticket IDs per state */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
FP Workflow Status
|
||||
</div>
|
||||
<FPWorkflowDonut counts={fpCounts.idCounts} total={fpCounts.idTotal} centerLabel="FP TICKETS" />
|
||||
@@ -5056,13 +5076,13 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
) : atlasMetricsError ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '2rem', gap: '0.375rem' }}>
|
||||
<AlertCircle style={{ width: '14px', height: '14px', color: '#EF4444', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#FCA5A5' }}>{atlasMetricsError}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: '#FCA5A5' }}>{atlasMetricsError}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||
{/* Host Coverage donut */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
Host Coverage
|
||||
</div>
|
||||
<AtlasCoverageDonut
|
||||
@@ -5077,7 +5097,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
|
||||
{/* Plan Types donut */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
Plan Types
|
||||
</div>
|
||||
<AtlasPlanTypeDonut
|
||||
@@ -5091,7 +5111,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
|
||||
{/* Plan Status donut */}
|
||||
<div style={{ flex: '0 0 auto' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
Plan Status
|
||||
</div>
|
||||
<AtlasPlanStatusDonut
|
||||
@@ -5115,20 +5135,19 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
Panel 2 — Findings table
|
||||
---------------------------------------------------------------- */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
||||
border: '1px solid rgba(14,165,233,0.2)',
|
||||
borderLeft: '3px solid #0EA5E9',
|
||||
borderRadius: '0.5rem',
|
||||
background: 'linear-gradient(135deg, rgba(30,41,59,0.95) 0%, rgba(15,23,42,0.98) 100%)',
|
||||
border: '1.5px solid rgba(14,165,233,0.12)',
|
||||
borderRadius: 8,
|
||||
padding: '1.5rem',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
|
||||
}}>
|
||||
{/* Panel header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
|
||||
<div>
|
||||
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(14,165,233,0.4)', margin: '0 0 4px 0' }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', margin: '0 0 4px 0' }}>
|
||||
Host Findings
|
||||
</h2>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: '#475569' }}>
|
||||
{syncedDisplay}
|
||||
{syncStatus === 'success' && total !== null && (
|
||||
<span style={{ marginLeft: '0.75rem', color: '#64748B' }}>
|
||||
@@ -5151,12 +5170,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
onClick={() => setExcFilter(null)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
padding: '6px 12px',
|
||||
background: 'rgba(245,158,11,0.08)',
|
||||
border: '1px solid rgba(245,158,11,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
border: '1px solid rgba(245,158,11,0.30)',
|
||||
borderRadius: 6,
|
||||
color: '#F59E0B', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
>
|
||||
@@ -5170,13 +5189,13 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
onClick={() => setActionFilter(null)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
padding: '6px 12px',
|
||||
background: actionFilter === 'fp' ? 'rgba(14,165,233,0.08)' : actionFilter === 'archer' ? 'rgba(245,158,11,0.08)' : 'rgba(239,68,68,0.08)',
|
||||
border: `1px solid ${actionFilter === 'fp' ? 'rgba(14,165,233,0.3)' : actionFilter === 'archer' ? 'rgba(245,158,11,0.3)' : 'rgba(239,68,68,0.3)'}`,
|
||||
borderRadius: '0.375rem',
|
||||
border: `1px solid ${actionFilter === 'fp' ? 'rgba(14,165,233,0.30)' : actionFilter === 'archer' ? 'rgba(245,158,11,0.30)' : 'rgba(239,68,68,0.30)'}`,
|
||||
borderRadius: 6,
|
||||
color: actionFilter === 'fp' ? '#0EA5E9' : actionFilter === 'archer' ? '#F59E0B' : '#EF4444',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
>
|
||||
@@ -5189,12 +5208,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
onClick={() => setColumnFilters({})}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
padding: '6px 12px',
|
||||
background: 'rgba(245,158,11,0.08)',
|
||||
border: '1px solid rgba(245,158,11,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
border: '1px solid rgba(245,158,11,0.30)',
|
||||
borderRadius: 6,
|
||||
color: '#F59E0B', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em'
|
||||
}}
|
||||
>
|
||||
@@ -5209,14 +5228,15 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
disabled={sorted.length === 0}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
background: 'rgba(16,185,129,0.08)',
|
||||
border: '1px solid rgba(16,185,129,0.3)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#10B981', cursor: sorted.length === 0 ? 'not-allowed' : 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
padding: '8px 14px',
|
||||
background: 'rgba(14,165,233,0.08)',
|
||||
border: '1px solid rgba(14,165,233,0.25)',
|
||||
borderRadius: 6,
|
||||
color: '#0EA5E9', cursor: sorted.length === 0 ? 'not-allowed' : 'pointer',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
opacity: sorted.length === 0 ? 0.4 : 1,
|
||||
opacity: sorted.length === 0 ? 0.5 : 1,
|
||||
transition: 'all 150ms cubic-bezier(0.4,0,0.2,1)',
|
||||
}}
|
||||
>
|
||||
<Download style={{ width: '11px', height: '11px' }} />
|
||||
@@ -5226,8 +5246,8 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
{exportMenuOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 200,
|
||||
background: 'rgb(12,22,40)', border: '1px solid rgba(16,185,129,0.3)',
|
||||
borderRadius: '0.375rem', overflow: 'hidden',
|
||||
background: 'rgb(12,22,40)', border: '1px solid rgba(14,165,233,0.25)',
|
||||
borderRadius: 6, overflow: 'hidden',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||
minWidth: '120px',
|
||||
}}>
|
||||
@@ -5242,12 +5262,12 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
display: 'block', width: '100%', textAlign: 'left',
|
||||
padding: '0.5rem 0.875rem',
|
||||
background: 'none', border: 'none',
|
||||
fontFamily: 'monospace', fontSize: '0.73rem', fontWeight: '600',
|
||||
color: '#10B981', cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
color: '#0EA5E9', cursor: 'pointer',
|
||||
textTransform: 'uppercase', letterSpacing: '0.04em',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(16,185,129,0.1)'}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.1)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'none'}
|
||||
>
|
||||
{label}
|
||||
@@ -5262,13 +5282,14 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
background: queueOpen ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.08)',
|
||||
border: `1px solid rgba(14,165,233,${queueOpen ? '0.5' : '0.25'})`,
|
||||
borderRadius: '0.375rem',
|
||||
padding: '8px 14px',
|
||||
background: queueOpen ? 'rgba(14,165,233,0.16)' : 'rgba(14,165,233,0.08)',
|
||||
border: `1px solid rgba(14,165,233,${queueOpen ? '0.55' : '0.25'})`,
|
||||
borderRadius: 6,
|
||||
color: '#0EA5E9', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
transition: 'all 150ms cubic-bezier(0.4,0,0.2,1)',
|
||||
}}
|
||||
>
|
||||
<ListTodo style={{ width: '13px', height: '13px' }} />
|
||||
@@ -5309,18 +5330,19 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
title={!canWrite() ? 'Insufficient permissions' : 'Sync Atlas action plan status'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.4rem',
|
||||
padding: '0.4rem 0.75rem',
|
||||
background: atlasSyncing ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.06)',
|
||||
border: '1px solid rgba(14,165,233,0.2)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '8px 14px',
|
||||
background: atlasSyncing ? 'rgba(14,165,233,0.08)' : 'rgba(14,165,233,0.08)',
|
||||
border: '1px solid rgba(14,165,233,0.25)',
|
||||
borderRadius: 6,
|
||||
color: atlasSyncing ? '#475569' : '#0EA5E9',
|
||||
fontSize: '0.72rem',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 600,
|
||||
cursor: atlasSyncing || !canWrite() ? 'not-allowed' : 'pointer',
|
||||
opacity: !canWrite() ? 0.5 : 1,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
transition: 'all 150ms cubic-bezier(0.4,0,0.2,1)',
|
||||
}}
|
||||
>
|
||||
{atlasSyncing
|
||||
@@ -5332,15 +5354,16 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
onClick={syncFindings}
|
||||
disabled={syncing || loading}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||
padding: '0.375rem 0.75rem',
|
||||
background: 'rgba(14,165,233,0.1)',
|
||||
border: '1px solid rgba(14,165,233,0.35)',
|
||||
borderRadius: '0.375rem',
|
||||
color: '#0EA5E9', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 14px',
|
||||
background: 'rgba(16,185,129,0.18)',
|
||||
border: '1px solid #10B981',
|
||||
borderRadius: 6,
|
||||
color: '#10B981', cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
opacity: (syncing || loading) ? 0.6 : 1
|
||||
opacity: (syncing || loading) ? 0.5 : 1,
|
||||
transition: 'all 150ms cubic-bezier(0.4,0,0.2,1)',
|
||||
}}
|
||||
>
|
||||
<RefreshCw style={{ width: '13px', height: '13px', animation: syncing ? 'spin 1s linear infinite' : 'none' }} />
|
||||
@@ -5351,15 +5374,15 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
|
||||
{/* Error banner */}
|
||||
{syncStatus === 'error' && syncError && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', padding: '0.625rem 0.875rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '0.375rem', marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '10px 14px', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: 8, marginBottom: '1rem' }}>
|
||||
<AlertCircle style={{ width: '15px', height: '15px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>{syncError}</span>
|
||||
<span style={{ fontSize: 12, color: '#FCA5A5', fontFamily: 'var(--font-mono)' }}>{syncError}</span>
|
||||
</div>
|
||||
)}
|
||||
{atlasError && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', padding: '0.625rem 0.875rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '0.375rem', marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '10px 14px', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: 8, marginBottom: '1rem' }}>
|
||||
<AlertCircle style={{ width: '15px', height: '15px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
|
||||
<span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>Atlas: {atlasError}</span>
|
||||
<span style={{ fontSize: 12, color: '#FCA5A5', fontFamily: 'var(--font-mono)' }}>Atlas: {atlasError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5399,14 +5422,14 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
)}
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
|
||||
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.15)' }}>
|
||||
{/* Fixed selection checkbox column — row visibility feature */}
|
||||
<th
|
||||
style={{
|
||||
width: '36px', minWidth: '36px', padding: '0.5rem 0.5rem',
|
||||
background: 'rgb(10, 20, 36)',
|
||||
position: 'sticky', top: 0, zIndex: 10,
|
||||
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
|
||||
boxShadow: '0 1px 0 rgba(14,165,233,0.15)',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
@@ -5428,7 +5451,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
width: '36px', minWidth: '36px', padding: '0.5rem 0.5rem',
|
||||
background: 'rgb(10, 20, 36)',
|
||||
position: 'sticky', top: 0, zIndex: 10,
|
||||
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
|
||||
boxShadow: '0 1px 0 rgba(14,165,233,0.15)',
|
||||
}}
|
||||
/>
|
||||
{/* Fixed checkbox column — not part of column manager */}
|
||||
@@ -5437,7 +5460,7 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
width: '36px', minWidth: '36px', padding: '0.5rem 0.5rem',
|
||||
background: 'rgb(10, 20, 36)',
|
||||
position: 'sticky', top: 0, zIndex: 10,
|
||||
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
|
||||
boxShadow: '0 1px 0 rgba(14,165,233,0.15)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
@@ -5473,15 +5496,15 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
onClick={def?.sortable ? () => toggleSort(col.key) : undefined}
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem', textAlign: 'left',
|
||||
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
||||
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700,
|
||||
color: active ? '#0EA5E9' : '#64748B',
|
||||
textTransform: 'uppercase', letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: def?.sortable ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
background: 'rgb(10, 20, 36)',
|
||||
position: 'sticky', top: 0, zIndex: 10,
|
||||
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
|
||||
boxShadow: '0 1px 0 rgba(14,165,233,0.15)',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
@@ -5520,9 +5543,9 @@ export default function VulnerabilityTriagePage({ filterDate, filterEXC }) {
|
||||
return (
|
||||
<tr
|
||||
key={finding.id}
|
||||
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', background: rowBg }}
|
||||
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'rgba(14,165,233,0.05)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; }}
|
||||
style={{ borderBottom: '1px solid rgba(14,165,233,0.15)', background: rowBg, transition: 'background 150ms, box-shadow 150ms' }}
|
||||
onMouseEnter={(e) => { if (!isSelected) { e.currentTarget.style.background = 'rgba(0,217,255,0.06)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,217,255,0.10)'; } }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = rowBg; e.currentTarget.style.boxShadow = 'none'; }}
|
||||
>
|
||||
{/* Selection checkbox cell — row visibility feature */}
|
||||
<td
|
||||
|
||||
Reference in New Issue
Block a user