From 23f953ceab0995022cf4d9296b3d310178aceb3b Mon Sep 17 00:00:00 2001 From: Vladimir Sivak Date: Mon, 2 Nov 2026 22:55:44 +0200 Subject: [PATCH 1/5] style(layout): polish responsive shell and sidebar navigation --- web/src/App.css | 64 +++++++++++++++++++++++- web/src/components/Sidebar.css | 91 ++++++++++++++++++++++++++-------- web/src/components/Sidebar.tsx | 31 +++++++++--- 3 files changed, 158 insertions(+), 28 deletions(-) diff --git a/web/src/App.css b/web/src/App.css index d28a34c..0556df5 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -1,8 +1,70 @@ .app-layout { + --sd-bg: #f4f6f9; + --sd-border: #dde3eb; + --sd-text: #172b45; + --sd-muted: #526379; + --sd-blue: #2457c5; display: flex; - align-items: flex-start; + align-items: stretch; + min-height: 100vh; + background: var(--sd-bg); + color: var(--sd-text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 0.875rem; + line-height: 1.5; + -webkit-font-smoothing: antialiased; } .app-layout .dashboard { flex: 1; + min-width: 0; + width: 100%; + max-width: 1680px; + padding: 32px; +} + +.app-layout [id] { + scroll-margin-top: 24px; +} + +.app-layout :focus-visible { + outline: 3px solid #2457c5; + outline-offset: 3px; +} + +.app-layout ::selection { + background: #dbe7ff; + color: #172b45; +} + +.app-layout .form-label { + margin-bottom: 6px; + color: var(--sd-muted); + font-size: 0.75rem; + font-weight: 600; +} + +.app-layout .form-select, +.app-layout .form-control { + min-height: 40px; + border-color: #c4cedb; + border-radius: 6px; + color: var(--sd-text); + font-size: 0.875rem; +} + +@media (max-width: 991.98px) { + .app-layout { + flex-direction: column; + } + + .app-layout .dashboard { + padding: 24px; + } +} + +@media (max-width: 575.98px) { + .app-layout .dashboard { + padding: 20px 12px; + } } diff --git a/web/src/components/Sidebar.css b/web/src/components/Sidebar.css index 18e6382..640b9b1 100644 --- a/web/src/components/Sidebar.css +++ b/web/src/components/Sidebar.css @@ -1,51 +1,100 @@ .sidebar { + flex: 0 0 220px; width: 220px; - min-height: 100vh; - background: #1f2937; - color: #f9fafb; - padding: 20px 16px; + height: 100vh; position: sticky; top: 0; + align-self: flex-start; + overflow-y: auto; + background: #182536; + color: #f8fafc; + padding: 28px 16px; } -.sidebar-brand { +.sidebar .sidebar-brand { display: flex; align-items: center; - gap: 10px; - font-size: 1.1rem; + gap: 12px; + margin: 0 8px 36px; + font-size: 1.25rem; font-weight: 700; - margin-bottom: 20px; + letter-spacing: -0.025em; } -.sidebar-logo { - width: 39px; - height: 39px; +.sidebar .sidebar-logo { + width: 36px; + height: 36px; object-fit: contain; flex-shrink: 0; /* The artwork is a black silhouette; the sidebar is dark, so draw it white. */ filter: brightness(0) invert(1); } -.sidebar-nav { +.sidebar .sidebar-nav { display: flex; flex-direction: column; - gap: 10px; + gap: 6px; } -.sidebar-nav a { - color: #d1d5db; - text-decoration: none; - padding: 8px 10px; +.sidebar .sidebar-nav a { + display: flex; + align-items: center; + gap: 12px; + min-height: 44px; + padding: 10px 12px; border-radius: 6px; + color: #becbdc; + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; } -.sidebar-nav a:hover { - background: #374151; +.sidebar .sidebar-nav svg { + flex-shrink: 0; +} + +.sidebar .sidebar-nav a:hover { + background: #24364d; color: #fff; } -@media (max-width: 992px) { +.sidebar .sidebar-nav a[aria-current='location'] { + background: #2b4566; + color: #fff; +} + +.sidebar .sidebar-nav a:focus-visible { + outline: 2px solid #a8caff; + outline-offset: 2px; +} + +@media (max-width: 991.98px) { .sidebar { - display: none; + position: static; + flex: none; + width: 100%; + height: auto; + padding: 16px 24px; + } + + .sidebar .sidebar-brand { + margin: 0 0 16px; + } + + .sidebar .sidebar-nav { + flex-direction: row; + flex-wrap: wrap; + gap: 4px; + } +} + +@media (max-width: 575.98px) { + .sidebar { + padding: 16px 12px; + } + + .sidebar .sidebar-nav { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); } } diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index e81a03d..db28d79 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,8 +1,23 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import logoUrl from '../assets/logo.png'; import './Sidebar.css'; const Sidebar: React.FC = () => { + const [activeSection, setActiveSection] = useState(window.location.hash.split('/')[0] || '#overview'); + + useEffect(() => { + const updateSection = () => setActiveSection(window.location.hash.split('/')[0] || '#overview'); + window.addEventListener('hashchange', updateSection); + return () => window.removeEventListener('hashchange', updateSection); + }, []); + + const links = [ + { id: 'overview', label: 'Overview', path: 'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z' }, + { id: 'threats', label: 'Threat Map', path: 'M12 3 3 7v5c0 5 9 9 9 9s9-4 9-9V7l-9-4Z M7 12h3l2-4 2 8 2-4h2' }, + { id: 'alerts', label: 'Alerts', path: 'M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9 M10 21h4' }, + { id: 'containers', label: 'Containers', path: 'm12 3 9 5v9l-9 5-9-5V8l9-5Z M3 8l9 5 9-5 M12 13v9 M7.5 5.5l9 5' }, + ]; + return ( ); From df3a231343cb9cea0ecafda0a59041d51c5ad60e Mon Sep 17 00:00:00 2001 From: Vladimir Sivak Date: Mon, 2 Nov 2026 22:57:33 +0200 Subject: [PATCH 2/5] style(alerts): improve responsive table actions and pagination --- web/src/components/AlertPanel.css | 283 ++++++++++++++---- web/src/components/AlertPanel.tsx | 222 ++++++++------ .../components/__tests__/AlertPanel.test.tsx | 32 +- 3 files changed, 390 insertions(+), 147 deletions(-) diff --git a/web/src/components/AlertPanel.css b/web/src/components/AlertPanel.css index 518cb82..e58c1dd 100644 --- a/web/src/components/AlertPanel.css +++ b/web/src/components/AlertPanel.css @@ -1,103 +1,274 @@ .alert-panel { - border: none; + container: alerts / inline-size; + min-width: 0; + border: 1px solid #dde3eb; border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + background: #fff; height: 100%; + color: #172b45; } .alert-panel .card-header { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border-radius: 12px 12px 0 0 !important; - padding: 1rem 1.5rem; + background: #fff; + border-bottom: 1px solid #dde3eb; + border-radius: 12px 12px 0 0; + padding: 20px 24px; } .alert-panel .card-title { margin: 0; - font-size: 1.25rem; - font-weight: 600; + font-size: 1rem; + font-weight: 650; +} + +.alert-panel .card-body { + padding: 24px; } -.alert-stats { +.alert-panel .alert-toolbar { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 24px; + margin-bottom: 24px; +} + +.alert-panel .alert-stats { display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1rem; - padding: 1rem; - background: #f8f9fa; - border-radius: 8px; + flex: 1 1 360px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; } -.stat-item { +.alert-panel .stat-item { display: flex; flex-direction: column; - align-items: center; + gap: 4px; } -.stat-label { - font-size: 0.75rem; - color: #6c757d; +.alert-panel .stat-label { + font-size: 0.6875rem; + color: #526379; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.04em; + overflow-wrap: anywhere; } -.stat-value { +.alert-panel .stat-value { font-size: 1.5rem; - font-weight: 700; - color: #2c3e50; + line-height: 1.2; + font-weight: 650; + color: #172b45; + font-variant-numeric: tabular-nums; } -.stat-value.new { - color: #007bff; +.alert-panel .stat-value.new { color: #2457c5; } +.alert-panel .stat-value.acknowledged { color: #92400e; } +.alert-panel .stat-value.resolved { color: #047857; } + +.alert-panel .alert-filters { + display: flex; + flex: 1 1 300px; + gap: 12px; } -.stat-value.acknowledged { - color: #ffc107; +.alert-panel .filter-group { + flex: 1; + min-width: 0; } -.stat-value.resolved { - color: #28a745; +.alert-panel .alert-selection { margin-bottom: 12px; color: #526379; font-size: 0.75rem; } + +.alert-panel .bulk-actions { + padding: 12px; + background: #eff5ff; + border-radius: 6px; } -.alert-filters { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; +.alert-panel .table-responsive { + overflow: visible; } -.filter-group { - margin-bottom: 0 !important; +.alert-panel .table { + table-layout: fixed; + width: 100%; + margin: 0; + font-size: 0.8125rem; + --bs-table-color: #33465e; + --bs-table-border-color: #e5eaf0; + --bs-table-hover-bg: #f8fafc; } -.bulk-actions { - padding: 0.75rem; - background: #e7f3ff; - border-radius: 8px; - border-left: 4px solid #007bff; +.alert-panel .select-column { width: 32px; } +.alert-panel .severity-column { width: 76px; } +.alert-panel .type-column { width: 102px; } +.alert-panel .status-column { width: 116px; } +.alert-panel .time-column { width: 110px; } +.alert-panel .actions-column { width: 248px; } + +.alert-panel .table th { + padding: 12px 8px; + background: #f4f6f9; + color: #526379; + font-size: 0.6875rem; + font-weight: 650; + vertical-align: middle; +} + +.alert-panel .table td { + padding: 16px 8px; + vertical-align: middle; + overflow-wrap: anywhere; +} + +.alert-panel .alert-type { + font-size: 0.75rem; } -.table { - margin-bottom: 0; +.alert-panel .alert-message { + white-space: pre-wrap; + word-break: break-word; +} + +.alert-panel .alert-hash { + padding: 1px 3px; + border-radius: 3px; + background: #edf1f6; + color: #33465e; + font-size: 0.75rem; + word-break: break-all; +} + +.alert-panel .alert-time span { + display: block; + white-space: nowrap; + font-variant-numeric: tabular-nums; + font-size: 0.75rem; } -.table th { +.alert-panel .alert-time span + span { color: #526379; } + +.alert-panel .alert-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.alert-panel .alert-actions .btn { + padding: 5px 8px; + font-size: 0.6875rem; font-weight: 600; - color: #495057; - border-top: none; - padding: 0.75rem; + white-space: nowrap; } -.table td { - vertical-align: middle; - padding: 0.75rem; +.alert-panel .btn-outline-warning { + --bs-btn-color: #92400e; + --bs-btn-bg: #fef3c7; + --bs-btn-border-color: #f5d97d; + --bs-btn-hover-color: #78350f; + --bs-btn-hover-bg: #fde68a; + --bs-btn-hover-border-color: #d9ad31; + --bs-btn-active-color: #78350f; + --bs-btn-active-bg: #fde68a; } -/* Responsive adjustments */ -@media (max-width: 768px) { - .alert-stats { - grid-template-columns: repeat(2, 1fr); - } +.alert-panel .btn-outline-success { + --bs-btn-color: #065f46; + --bs-btn-bg: #ecfdf5; + --bs-btn-border-color: #a7d8c5; + --bs-btn-hover-bg: #065f46; + --bs-btn-hover-border-color: #065f46; +} + +.alert-panel .btn-outline-primary { + --bs-btn-color: #2457c5; + --bs-btn-bg: #eff5ff; + --bs-btn-border-color: #c9d9fa; + --bs-btn-hover-bg: #2457c5; + --bs-btn-hover-border-color: #2457c5; +} + +.alert-panel .badge { + font-size: 0.6875rem; + font-weight: 600; + padding: 5px 7px; + white-space: normal; +} + +.alert-panel .badge.bg-warning, +.alert-panel .badge.bg-info { color: #172b45; } - .alert-filters { - grid-template-columns: 1fr; +.alert-panel .alert-footer { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 16px 24px; + background: #fff; + border-color: #dde3eb; + border-radius: 0 0 12px 12px; +} + +.alert-panel .alert-page-summary, +.alert-panel .alert-empty { + color: #526379; + font-size: 0.75rem; +} + +.alert-panel .alert-empty { padding: 24px 0; margin: 0; } +.alert-panel .pagination { flex-wrap: wrap; gap: 4px; margin: 0; } +.alert-panel .page-link { border-radius: 4px; color: #2457c5; min-width: 30px; text-align: center; } +.alert-panel .page-item.active .page-link { background: #2457c5; border-color: #2457c5; color: #fff; } +.alert-panel .page-item.disabled .page-link { color: #637083; background: #f4f6f9; } + +@container alerts (max-width: 850px) { + .alert-panel .card-body { padding: 16px; } + .alert-panel .alert-stats { flex-basis: 100%; } + .alert-panel .alert-filters { flex-basis: 100%; } + .alert-panel .alert-footer { padding: 16px; } + .alert-panel .alerts-table, + .alert-panel .alerts-table tbody { display: block; } + .alert-panel .alerts-table colgroup { display: none; } + .alert-panel .alerts-table thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + } + .alert-panel .alerts-table tbody tr { + position: relative; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding: 16px; + border: 1px solid #dde3eb; + border-radius: 8px; + margin-bottom: 12px; + } + .alert-panel .alerts-table td { + display: block; + padding: 0; + border: 0; + box-shadow: none; + background: transparent; } + .alert-panel .alerts-table td::before { + content: attr(data-label); + display: block; + margin-bottom: 4px; + color: #526379; + font-size: 0.6875rem; + } + .alert-panel .alerts-table td:first-child { position: absolute; top: 16px; right: 16px; } + .alert-panel .alerts-table .alert-type { padding-right: 24px; } + .alert-panel .alerts-table .alert-message, + .alert-panel .alerts-table .alert-action-cell { grid-column: 1 / -1; } + .alert-panel .alert-actions .btn { min-height: 36px; padding: 6px 10px; font-size: 0.75rem; } +} + +@container alerts (max-width: 400px) { + .alert-panel .alert-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .alert-panel .alert-filters { flex-direction: column; } } diff --git a/web/src/components/AlertPanel.tsx b/web/src/components/AlertPanel.tsx index 005927d..a370eb9 100644 --- a/web/src/components/AlertPanel.tsx +++ b/web/src/components/AlertPanel.tsx @@ -232,59 +232,72 @@ const AlertPanel: React.FC = () => { )} - {/* Statistics */} - {stats && ( -
-
- Total - {stats.totalCount} -
-
- New - {stats.newCount} -
-
- Acknowledged - {stats.acknowledgedCount} -
-
- Resolved - {stats.resolvedCount} +
+ {/* Statistics */} + {stats && ( +
+
+ Total + {stats.totalCount} +
+
+ New + {stats.newCount} +
+
+ Acknowledged + {stats.acknowledgedCount} +
+
+ Resolved + {stats.resolvedCount} +
+ )} + + {/* Filters */} +
+ + Filter by severity + setFilter({ ...filter, severity: e.target.value ? [e.target.value as AlertSeverity] : undefined })} + > + + + + + + + + + + + Filter by status + setFilter({ ...filter, status: e.target.value ? [e.target.value as AlertStatus] : undefined })} + > + + + + + +
- )} - {/* Filters */} -
- - Filter by severity - setFilter({ ...filter, severity: e.target.value ? [e.target.value as AlertSeverity] : undefined })} - > - - - - - - - - - - - Filter by status - setFilter({ ...filter, status: e.target.value ? [e.target.value as AlertStatus] : undefined })} - > - - - - - -
+ {!loading && alerts.length > 0 && ( + + )} + {/* Bulk Actions */} {selectedAlerts.size > 0 && (
@@ -297,18 +310,25 @@ const AlertPanel: React.FC = () => { {/* Alerts Table */} {loading ? (
- + Loading alerts...
) : ( <> - +
+ + + + + + + + + + @@ -323,56 +343,78 @@ const AlertPanel: React.FC = () => { - - - - + + - - + ))}
Recent security alerts
- + Select Severity Type
handleSelectAlert(alert.id)} /> + {alert.severity} {alert.alertType}{alert.message} + {alert.alertType} + {alert.message.split(/((?:sha256:)?[a-f0-9]{64})/gi).map((part, index) => + /^(?:sha256:)?[a-f0-9]{64}$/i.test(part) + ? {part} + : part + )} + {alert.status} {new Date(alert.timestamp).toLocaleString()} - {alert.status === 'New' && ( - - )} - {alert.status !== 'Resolved' && ( + + + +
+ {alert.status === 'New' && ( + + )} + {alert.status !== 'Resolved' && ( + + )} - )} - +
- {/* Pagination */} - {totalPages > 1 && ( - + {alerts.length === 0 &&

No alerts match the selected filters.

} + + )} + + {!loading && alerts.length > 0 && ( + + + {(currentPage - 1) * ITEMS_PER_PAGE + 1}–{Math.min(currentPage * ITEMS_PER_PAGE, alerts.length)} of {alerts.length.toLocaleString()} alerts + + + {totalPages > 1 && ( + + )} + + )} {/* Alert Detail Modal */} diff --git a/web/src/components/__tests__/AlertPanel.test.tsx b/web/src/components/__tests__/AlertPanel.test.tsx index 99abcc8..9e09b04 100644 --- a/web/src/components/__tests__/AlertPanel.test.tsx +++ b/web/src/components/__tests__/AlertPanel.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import AlertPanel from '../AlertPanel'; -import { alertIdFromHash } from '../AlertPanel'; +import { alertIdFromHash, pageItems } from '../AlertPanel'; import apiService from '../../services/api'; import webSocketService from '../../services/websocket'; @@ -51,6 +51,25 @@ describe('AlertPanel Component', () => { expect(screen.getByText('Rule violation detected')).toBeInTheDocument(); }); + test('preserves full hash and log text while formatting the timestamp', async () => { + const hash = '6129f31276ac69cf51a5b0261646721da3ac8e83155c31f0c381bc37ce951b59'; + const message = `Container (${hash})\nLog: suspicious process detected`; + (apiService.getAlerts as jest.Mock).mockResolvedValue([{ ...mockAlerts[0], message }]); + const { container } = render(); + + expect(await screen.findByText(hash)).toBeInTheDocument(); + expect(container.querySelector('.alert-message')).toHaveTextContent(`Container (${hash}) Log: suspicious process detected`); + expect(container.querySelector('time')).toHaveAttribute('datetime', mockAlerts[0].timestamp); + }); + + test('shows a useful empty state when filters return no alerts', async () => { + (apiService.getAlerts as jest.Mock).mockResolvedValue([]); + render(); + + expect(await screen.findByText('No alerts match the selected filters.')).toBeInTheDocument(); + expect(screen.queryByRole('navigation', { name: 'Alert pages' })).not.toBeInTheDocument(); + }); + test('filters alerts by severity', async () => { render(); @@ -185,3 +204,14 @@ describe('alertIdFromHash', () => { expect(alertIdFromHash('')).toBeNull(); }); }); + +describe('pageItems', () => { + test.each([1, 4, 1300, 2698, 2700])('keeps pagination bounded on page %i of 2700', (current) => { + const items = pageItems(current, 2700); + expect(items.length).toBeLessThanOrEqual(9); + expect(items).toContain(1); + expect(items).toContain(current); + expect(items).toContain(2700); + expect(items.filter(item => item === 'ellipsis').length).toBeLessThanOrEqual(2); + }); +}); From 1dc7b9abac72b32af9515b7ed8bf002c6a2734b0 Mon Sep 17 00:00:00 2001 From: Vladimir Sivak Date: Mon, 2 Nov 2026 23:00:00 +0200 Subject: [PATCH 3/5] style(overview): unify metrics cards and threat visualizations --- web/src/components/Dashboard.css | 95 +++++----- web/src/components/Dashboard.tsx | 19 +- web/src/components/SecurityScore.css | 92 +++++----- web/src/components/SecurityScore.tsx | 10 +- web/src/components/ThreatMap.css | 260 ++++++++++++++++++--------- web/src/components/ThreatMap.tsx | 151 +++++++--------- 6 files changed, 336 insertions(+), 291 deletions(-) diff --git a/web/src/components/Dashboard.css b/web/src/components/Dashboard.css index a6cc495..cfbda1b 100644 --- a/web/src/components/Dashboard.css +++ b/web/src/components/Dashboard.css @@ -1,86 +1,81 @@ .dashboard { - padding: 20px; - max-width: 1400px; + padding: 32px; + max-width: 1680px; margin: 0 auto; + color: #172b45; } .dashboard-loading, .dashboard-error { display: flex; + flex: 1; justify-content: center; align-items: center; min-height: 400px; + padding: 24px; } -.dashboard-topbar { +.dashboard .dashboard-topbar { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 0.75rem; + margin-bottom: 8px; } -.dashboard-topbar-spacer { - flex: 1; -} - -.dashboard-actions-btn { - border: 1px solid #d1d5db; - background: #fff; - color: #374151; - border-radius: 8px; - padding: 4px 10px; - font-size: 1.25rem; - line-height: 1; - cursor: pointer; -} - -.dashboard-actions-btn:hover { - background: #f9fafb; +.dashboard .dashboard-topbar h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 650; + letter-spacing: -0.025em; } -.dashboard-subtitle { - color: #7f8c8d; - font-size: 1rem; +.dashboard .dashboard-subtitle { + margin: 0; + color: #526379; + font-size: 0.875rem; } -.stat-card { - border: none; +.dashboard .stat-card { + height: 100%; + min-height: 196px; + border: 1px solid #dde3eb; border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - transition: transform 0.2s; + background: #fff; } -.stat-card:hover { - transform: translateY(-2px); +.dashboard .stat-card .card-body { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 24px; } -.stat-card .card-title { - font-size: 0.875rem; - color: #7f8c8d; - margin-bottom: 0.5rem; +.dashboard .stat-card .card-title { + font-size: 0.8125rem; + font-weight: 500; + color: #526379; + margin-bottom: 24px; } -.stat-value { - font-size: 2.5rem; - font-weight: 700; - color: #2c3e50; +.dashboard .stat-card .stat-value { + font-size: clamp(2rem, 3vw, 2.5rem); + line-height: 1.2; + font-weight: 650; + letter-spacing: -0.025em; + font-variant-numeric: tabular-nums; + color: #172b45; margin: 0; } -.last-updated { - text-align: center; - color: #7f8c8d; - font-size: 0.875rem; - margin-top: 20px; +.dashboard .last-updated { + color: #526379; + font-size: 0.75rem; + margin: 0; } -/* Responsive adjustments */ -@media (max-width: 768px) { - .dashboard { - padding: 10px; - } +@media (max-width: 767.98px) { - .stat-value { - font-size: 2rem; + .dashboard .stat-card { + min-height: 140px; } } diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx index 42b27f2..7987fd7 100644 --- a/web/src/components/Dashboard.tsx +++ b/web/src/components/Dashboard.tsx @@ -76,12 +76,11 @@ const Dashboard: React.FC = () => { } return ( - +
-
- +

Overview

Real-time security monitoring for containers and Linux servers @@ -90,7 +89,7 @@ const Dashboard: React.FC = () => { {/* Security Score Card */} - + @@ -99,7 +98,7 @@ const Dashboard: React.FC = () => { Active Threats - {securityStatus?.activeThreats || 0} + {(securityStatus?.activeThreats || 0).toLocaleString()} @@ -109,7 +108,7 @@ const Dashboard: React.FC = () => { Quarantined - {securityStatus?.quarantinedContainers || 0} + {(securityStatus?.quarantinedContainers || 0).toLocaleString()} @@ -119,7 +118,7 @@ const Dashboard: React.FC = () => { New Alerts - {securityStatus?.alertsNew || 0} + {(securityStatus?.alertsNew || 0).toLocaleString()} @@ -134,11 +133,11 @@ const Dashboard: React.FC = () => { {/* Alerts and Containers */} - - + + - + diff --git a/web/src/components/SecurityScore.css b/web/src/components/SecurityScore.css index 1dcc1b8..c28c96c 100644 --- a/web/src/components/SecurityScore.css +++ b/web/src/components/SecurityScore.css @@ -1,85 +1,77 @@ .security-score-card { - border: none; + border: 1px solid #dde3eb; border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + background: #fff; height: 100%; + color: #172b45; +} + +.security-score-card .card-body { + padding: 24px; } .security-score-card .card-title { - font-size: 0.875rem; - color: #7f8c8d; - margin-bottom: 1rem; + font-size: 0.8125rem; + font-weight: 500; + color: #526379; + margin-bottom: 16px; } -.gauge-container { +.security-score-card .gauge-container { display: flex; justify-content: center; - align-items: center; - padding: 1rem 0; + padding: 4px 0; } -.gauge { +.security-score-card .gauge { position: relative; width: 150px; height: 75px; overflow: hidden; } -.gauge-background { +.security-score-card .gauge-background { position: absolute; width: 150px; height: 150px; border-radius: 50%; - background: conic-gradient( - from -90deg, - #e74c3c 0deg, - #e67e22 60deg, - #f39c12 120deg, - #27ae60 180deg, - #ecf0f1 180deg - ); - transform: rotate(-90deg); + background: conic-gradient(from 270deg, #b42318 0deg, #c2410c 60deg, #b7790b 120deg, #047857 180deg, transparent 180deg); } -.gauge-fill { +.security-score-card .gauge-fill { position: absolute; - width: 150px; - height: 150px; - border-radius: 50%; - background: conic-gradient( - from -90deg, - #27ae60 0deg, - #27ae60 var(--rotation, 0deg), - transparent var(--rotation, 0deg) - ); - transform-origin: center; - transition: transform 0.5s ease-out; + left: 73px; + bottom: 75px; + width: 4px; + height: 74px; + border-radius: 2px; + background: #172b45; + transform-origin: 50% 100%; } -.gauge-cover { +.security-score-card .gauge-cover { position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 120px; - height: 120px; + top: 18px; + left: 18px; + width: 114px; + height: 114px; border-radius: 50%; - background: white; - display: flex; - justify-content: center; - align-items: center; + background: #fff; + padding-top: 14px; + text-align: center; } -.gauge-value { - font-size: 2.5rem; - font-weight: 700; - color: #2c3e50; +.security-score-card .gauge-value { + font-size: 2.25rem; + line-height: 1.2; + font-weight: 650; + font-variant-numeric: tabular-nums; + color: #172b45; } -.score-label { +.security-score-card .score-label { text-align: center; - font-size: 1.25rem; - font-weight: 600; - margin-top: 0.5rem; - transition: color 0.3s; + font-size: 0.8125rem; + font-weight: 650; + margin-top: 8px; } diff --git a/web/src/components/SecurityScore.tsx b/web/src/components/SecurityScore.tsx index eb7d029..5bfc4f6 100644 --- a/web/src/components/SecurityScore.tsx +++ b/web/src/components/SecurityScore.tsx @@ -8,10 +8,10 @@ interface SecurityScoreProps { const SecurityScore: React.FC = ({ score }) => { const getScoreColor = (score: number): string => { - if (score >= 80) return '#27ae60'; // Green - if (score >= 60) return '#f39c12'; // Orange - if (score >= 40) return '#e67e22'; // Dark Orange - return '#e74c3c'; // Red + if (score >= 80) return '#047857'; // Green + if (score >= 60) return '#92400e'; // Orange + if (score >= 40) return '#c2410c'; // Dark Orange + return '#b42318'; // Red }; const getScoreLabel = (score: number): string => { @@ -28,7 +28,7 @@ const SecurityScore: React.FC = ({ score }) => { Security Score

-
+
diff --git a/web/src/components/ThreatMap.css b/web/src/components/ThreatMap.css index 32f05c9..051ad1f 100644 --- a/web/src/components/ThreatMap.css +++ b/web/src/components/ThreatMap.css @@ -1,169 +1,251 @@ .threat-map { - border: none; + container: threats / inline-size; + min-width: 0; + border: 1px solid #dde3eb; border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - margin-bottom: 2rem; + background: #fff; + color: #172b45; } .threat-map .card-header { - background: linear-gradient(135deg, #fc4a1a 0%, #f7b733 100%); - color: white; - border-radius: 12px 12px 0 0 !important; - padding: 1rem 1.5rem; + background: #fff; + border-bottom: 1px solid #dde3eb; + border-radius: 12px 12px 0 0; + padding: 20px 24px; } .threat-map .card-title { margin: 0; - font-size: 1.25rem; - font-weight: 600; + font-size: 1rem; + font-weight: 650; +} + +.threat-map .card-body { + padding: 24px; } -.threat-filters { +.threat-map .threat-toolbar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: end; + gap: 24px; + margin-bottom: 24px; +} + +.threat-map .threat-filters { display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 1rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + flex: 1 1 280px; + max-width: 420px; + gap: 12px; +} + +.threat-map .filter-group { + min-width: 0; } -.filter-group { - margin-bottom: 0 !important; +.threat-map .form-control { + max-width: 100%; } -.threat-summary { +.threat-map .threat-summary { display: flex; - justify-content: space-around; - padding: 1rem; - background: #f8f9fa; - border-radius: 8px; + flex-wrap: wrap; + gap: 32px; } -.summary-item { +.threat-map .summary-item { display: flex; flex-direction: column; - align-items: center; + gap: 4px; } -.summary-label { - font-size: 0.75rem; - color: #6c757d; +.threat-map .summary-label { + font-size: 0.6875rem; + color: #526379; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.04em; } -.summary-value { +.threat-map .summary-value { font-size: 1.5rem; - font-weight: 700; - color: #2c3e50; + font-weight: 650; + line-height: 1.2; + font-variant-numeric: tabular-nums; } -.summary-value.increasing { - color: #e74c3c; +.threat-map .summary-value.increasing { + color: #b42318; + font-size: 1rem; + line-height: 1.8; + text-transform: capitalize; } -.summary-value.decreasing { - color: #27ae60; +.threat-map .summary-value.decreasing { + color: #047857; + font-size: 1rem; + line-height: 1.8; + text-transform: capitalize; } -.charts-row { +.threat-map .summary-value.stable { + color: #526379; + font-size: 1rem; + line-height: 1.8; + text-transform: capitalize; +} + +.threat-map .charts-row { display: grid; - grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); - gap: 1.5rem; - margin-bottom: 1.5rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; + margin-bottom: 24px; } -.chart-container { - background: white; - padding: 1rem; - border-radius: 8px; - border: 1px solid #e9ecef; +.threat-map .chart-container { + min-width: 0; + padding-top: 20px; + border-top: 1px solid #e5eaf0; } -.chart-container.full-width { +.threat-map .chart-container.full-width { grid-column: 1 / -1; } -.chart-container h5 { - margin: 0 0 1rem 0; - font-size: 1rem; - color: #2c3e50; - text-align: center; +.threat-map .chart-container h5, + +.threat-map .recent-threats h5 { + margin: 0 0 16px; + font-size: 0.8125rem; + font-weight: 600; + color: #33465e; } -.recent-threats { - margin-top: 1.5rem; +.threat-map .recharts-default-tooltip { + border: 1px solid #dde3eb !important; + border-radius: 8px; + font-size: 0.75rem; + box-shadow: 0 4px 16px #172b4514; } -.recent-threats h5 { - margin: 0 0 1rem 0; - font-size: 1rem; - color: #2c3e50; +.threat-map .recharts-legend-item { + margin: 4px 8px !important; +} + +.threat-map .recent-threats { + margin-top: 24px; } -.threat-list { +.threat-map .threat-list { display: flex; flex-direction: column; - gap: 0.75rem; } -.threat-item { +.threat-map .threat-item { display: flex; justify-content: space-between; align-items: center; - padding: 0.75rem 1rem; - background: #f8f9fa; - border-radius: 8px; - border-left: 4px solid #e74c3c; - transition: transform 0.2s; -} - -.threat-item:hover { - transform: translateX(4px); + flex-wrap: wrap; + gap: 12px 24px; + padding: 16px 0; + border-top: 1px solid #e5eaf0; } -.threat-info { +.threat-map .threat-info { display: flex; + flex: 1 1 200px; + min-width: 0; flex-direction: column; - gap: 0.25rem; + gap: 4px; } -.threat-type { +.threat-map .threat-type { font-weight: 600; - color: #2c3e50; + overflow-wrap: anywhere; } -.threat-source { - font-size: 0.85rem; - color: #6c757d; +.threat-map .threat-source { + color: #526379; + font-family: monospace; + font-size: 0.75rem; + overflow-wrap: anywhere; } -.threat-meta { +.threat-map .threat-meta { display: flex; + flex-wrap: wrap; align-items: center; - gap: 0.75rem; + gap: 8px 16px; } -.threat-score { - font-size: 0.85rem; - color: #495057; +.threat-map .threat-score { + font-size: 0.75rem; + font-variant-numeric: tabular-nums; } -.threat-time { +.threat-map .threat-time { font-size: 0.75rem; - color: #6c757d; + color: #526379; +} + +.threat-map .threat-empty { + color: #526379; + font-size: 0.8125rem; +} + +.threat-map .threat-badge { + padding: 4px 8px; + border-radius: 4px; + font-size: 0.6875rem; + font-weight: 600; + color: #33465e; + background: #edf1f6; +} + +.threat-map .severity-critical { + color: #9f1239; + background: #ffe4e6; } -/* Responsive adjustments */ -@media (max-width: 768px) { - .charts-row { - grid-template-columns: 1fr; +.threat-map .severity-high { + color: #9a3412; + background: #ffedd5; +} + +.threat-map .severity-medium { + color: #92400e; + background: #fef3c7; +} + +.threat-map .severity-low { + color: #065f46; + background: #d1fae5; +} + +.threat-map .severity-info { + color: #1e40af; + background: #dbeafe; +} + +@container threats (max-width: 700px) { + + .threat-map .charts-row { + grid-template-columns: minmax(0, 1fr); + } + + .threat-map .threat-info { + flex-basis: 100%; } +} + +@container threats (max-width: 400px) { - .threat-item { - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; + .threat-map .card-body { + padding: 16px; } - .threat-meta { - flex-wrap: wrap; + .threat-map .threat-filters { + grid-template-columns: minmax(0, 1fr); } } diff --git a/web/src/components/ThreatMap.tsx b/web/src/components/ThreatMap.tsx index 83177c7..5b6aefe 100644 --- a/web/src/components/ThreatMap.tsx +++ b/web/src/components/ThreatMap.tsx @@ -5,7 +5,7 @@ import apiService from '../services/api'; import { Threat, ThreatStatistics } from '../types/security'; import './ThreatMap.css'; -const COLORS = ['#e74c3c', '#e67e22', '#f39c12', '#3498db', '#27ae60']; +const SEVERITY_COLORS: Record = { Info: '#3674b5', Low: '#148267', Medium: '#b7790b', High: '#c45a24', Critical: '#b83244' }; const ThreatMap: React.FC = () => { const [threats, setThreats] = useState([]); @@ -71,65 +71,63 @@ const ThreatMap: React.FC = () => { {/* Date Filter */} -
- - From - setDateFrom(e.target.value)} - aria-label="From" - /> - - - To - setDateTo(e.target.value)} - aria-label="To" - /> - +
+
+ + From + setDateFrom(e.target.value)} + aria-label="From" + /> + + + To + setDateTo(e.target.value)} + aria-label="To" + /> + +
+ + {/* Statistics Summary */} + {statistics && ( +
+
+ Total Threats + {statistics.totalThreats.toLocaleString()} +
+
+ Trend + + {statistics.trend} + +
+
+ )} +
{loading ? (
- + Loading threats...
) : ( <> - {/* Statistics Summary */} - {statistics && ( -
-
- Total Threats - {statistics.totalThreats} -
-
- Trend - - {statistics.trend === 'increasing' ? '📈' : statistics.trend === 'decreasing' ? '📉' : '➡️'} - -
-
- )} - {/* Charts Row 1 */}
Threat Type Distribution
- - - - - - - - {getTypeData().map((entry, index) => ( - - ))} - + + + Intl.NumberFormat(undefined, { notation: 'compact' }).format(value)} /> + value.length > 20 ? `${value.slice(0, 18)}…` : value} /> + +
@@ -141,18 +139,19 @@ const ThreatMap: React.FC = () => { `${name}: ${(percent * 100).toFixed(0)}%`} - outerRadius={100} - fill="#8884d8" + cy="43%" + innerRadius={48} + outerRadius={80} + paddingAngle={2} + nameKey="name" dataKey="value" > - {getSeverityData().map((entry, index) => ( - + {getSeverityData().map((entry) => ( + ))} +
@@ -163,13 +162,12 @@ const ThreatMap: React.FC = () => {
Threat Timeline
- - - - + + + + Intl.NumberFormat(undefined, { notation: 'compact' }).format(value)} /> - - +
@@ -179,6 +177,7 @@ const ThreatMap: React.FC = () => {
Recent Threats
+ {threats.length === 0 &&

No recent threats.

} {threats.slice(0, 5).map((threat) => (
@@ -188,7 +187,7 @@ const ThreatMap: React.FC = () => {
{threat.severity} Score: {threat.score} - {new Date(threat.timestamp).toLocaleString()} +
))} @@ -201,30 +200,8 @@ const ThreatMap: React.FC = () => { ); }; -// Simple Badge component for threats -const Badge: React.FC<{ severity: string; children: React.ReactNode }> = ({ severity, children }) => { - const colors: Record = { - Info: '#17a2b8', - Low: '#28a745', - Medium: '#ffc107', - High: '#fd7e14', - Critical: '#dc3545', - }; - - return ( - - {children} - - ); -}; +const Badge: React.FC<{ severity: string; children: React.ReactNode }> = ({ severity, children }) => ( + {children} +); export default ThreatMap; From 42b91bd9c9e39098e1d7ba2a1245ea145e32bc68 Mon Sep 17 00:00:00 2001 From: Vladimir Sivak Date: Mon, 2 Nov 2026 23:08:57 +0200 Subject: [PATCH 4/5] style(containers): polish status cards and security details dialogs --- web/src/components/AlertPanel.css | 163 +++++++++++--- web/src/components/AlertPanel.tsx | 40 ++-- web/src/components/ContainerList.css | 203 +++++++++++++----- web/src/components/ContainerList.tsx | 164 ++++++-------- web/src/components/DetailsModal.css | 122 +++++++++++ .../components/__tests__/AlertPanel.test.tsx | 26 ++- .../__tests__/ContainerList.test.tsx | 33 ++- 7 files changed, 549 insertions(+), 202 deletions(-) create mode 100644 web/src/components/DetailsModal.css diff --git a/web/src/components/AlertPanel.css b/web/src/components/AlertPanel.css index e58c1dd..563bfbf 100644 --- a/web/src/components/AlertPanel.css +++ b/web/src/components/AlertPanel.css @@ -62,9 +62,17 @@ font-variant-numeric: tabular-nums; } -.alert-panel .stat-value.new { color: #2457c5; } -.alert-panel .stat-value.acknowledged { color: #92400e; } -.alert-panel .stat-value.resolved { color: #047857; } +.alert-panel .stat-value.new { + color: #2457c5; +} + +.alert-panel .stat-value.acknowledged { + color: #92400e; +} + +.alert-panel .stat-value.resolved { + color: #047857; +} .alert-panel .alert-filters { display: flex; @@ -77,7 +85,11 @@ min-width: 0; } -.alert-panel .alert-selection { margin-bottom: 12px; color: #526379; font-size: 0.75rem; } +.alert-panel .alert-selection { + margin-bottom: 12px; + color: #526379; + font-size: 0.75rem; +} .alert-panel .bulk-actions { padding: 12px; @@ -99,12 +111,29 @@ --bs-table-hover-bg: #f8fafc; } -.alert-panel .select-column { width: 32px; } -.alert-panel .severity-column { width: 76px; } -.alert-panel .type-column { width: 102px; } -.alert-panel .status-column { width: 116px; } -.alert-panel .time-column { width: 110px; } -.alert-panel .actions-column { width: 248px; } +.alert-panel .select-column { + width: 32px; +} + +.alert-panel .severity-column { + width: 76px; +} + +.alert-panel .type-column { + width: 102px; +} + +.alert-panel .status-column { + width: 116px; +} + +.alert-panel .time-column { + width: 110px; +} + +.alert-panel .actions-column { + width: 280px; +} .alert-panel .table th { padding: 12px 8px; @@ -146,7 +175,9 @@ font-size: 0.75rem; } -.alert-panel .alert-time span + span { color: #526379; } +.alert-panel .alert-time span + span { + color: #526379; +} .alert-panel .alert-actions { display: flex; @@ -156,7 +187,7 @@ .alert-panel .alert-actions .btn { padding: 5px 8px; - font-size: 0.6875rem; + font-size: 0.75rem; font-weight: 600; white-space: nowrap; } @@ -196,7 +227,10 @@ } .alert-panel .badge.bg-warning, -.alert-panel .badge.bg-info { color: #172b45; } + +.alert-panel .badge.bg-info { + color: #172b45; +} .alert-panel .alert-footer { display: flex; @@ -216,20 +250,63 @@ font-size: 0.75rem; } -.alert-panel .alert-empty { padding: 24px 0; margin: 0; } -.alert-panel .pagination { flex-wrap: wrap; gap: 4px; margin: 0; } -.alert-panel .page-link { border-radius: 4px; color: #2457c5; min-width: 30px; text-align: center; } -.alert-panel .page-item.active .page-link { background: #2457c5; border-color: #2457c5; color: #fff; } -.alert-panel .page-item.disabled .page-link { color: #637083; background: #f4f6f9; } +.alert-panel .alert-empty { + padding: 24px 0; + margin: 0; +} + +.alert-panel .pagination { + flex-wrap: wrap; + gap: 4px; + margin: 0; +} + +.alert-panel .page-link { + border-radius: 4px; + color: #2457c5; + min-width: 30px; + text-align: center; +} + +.alert-panel .page-item.active .page-link { + background: #2457c5; + border-color: #2457c5; + color: #fff; +} + +.alert-panel .page-item.disabled .page-link { + color: #637083; + background: #f4f6f9; +} @container alerts (max-width: 850px) { - .alert-panel .card-body { padding: 16px; } - .alert-panel .alert-stats { flex-basis: 100%; } - .alert-panel .alert-filters { flex-basis: 100%; } - .alert-panel .alert-footer { padding: 16px; } + + .alert-panel .card-body { + padding: 16px; + } + + .alert-panel .alert-stats { + flex-basis: 100%; + } + + .alert-panel .alert-filters { + flex-basis: 100%; + } + + .alert-panel .alert-footer { + padding: 16px; + } + .alert-panel .alerts-table, - .alert-panel .alerts-table tbody { display: block; } - .alert-panel .alerts-table colgroup { display: none; } + + .alert-panel .alerts-table tbody { + display: block; + } + + .alert-panel .alerts-table colgroup { + display: none; + } + .alert-panel .alerts-table thead { position: absolute; width: 1px; @@ -237,6 +314,7 @@ overflow: hidden; clip-path: inset(50%); } + .alert-panel .alerts-table tbody tr { position: relative; display: grid; @@ -247,6 +325,7 @@ border-radius: 8px; margin-bottom: 12px; } + .alert-panel .alerts-table td { display: block; padding: 0; @@ -254,6 +333,7 @@ box-shadow: none; background: transparent; } + .alert-panel .alerts-table td::before { content: attr(data-label); display: block; @@ -261,14 +341,37 @@ color: #526379; font-size: 0.6875rem; } - .alert-panel .alerts-table td:first-child { position: absolute; top: 16px; right: 16px; } - .alert-panel .alerts-table .alert-type { padding-right: 24px; } + + .alert-panel .alerts-table td:first-child { + position: absolute; + top: 16px; + right: 16px; + } + + .alert-panel .alerts-table .alert-type { + padding-right: 24px; + } + .alert-panel .alerts-table .alert-message, - .alert-panel .alerts-table .alert-action-cell { grid-column: 1 / -1; } - .alert-panel .alert-actions .btn { min-height: 36px; padding: 6px 10px; font-size: 0.75rem; } + + .alert-panel .alerts-table .alert-action-cell { + grid-column: 1 / -1; + } + + .alert-panel .alert-actions .btn { + min-height: 36px; + padding: 6px 10px; + font-size: 0.75rem; + } } @container alerts (max-width: 400px) { - .alert-panel .alert-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .alert-panel .alert-filters { flex-direction: column; } + + .alert-panel .alert-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .alert-panel .alert-filters { + flex-direction: column; + } } diff --git a/web/src/components/AlertPanel.tsx b/web/src/components/AlertPanel.tsx index a370eb9..0df52eb 100644 --- a/web/src/components/AlertPanel.tsx +++ b/web/src/components/AlertPanel.tsx @@ -4,6 +4,7 @@ import apiService from '../services/api'; import webSocketService from '../services/websocket'; import { Alert, AlertSeverity, AlertStatus, AlertFilter, AlertStats } from '../types/alerts'; import './AlertPanel.css'; +import './DetailsModal.css'; const ITEMS_PER_PAGE = 10; @@ -238,19 +239,19 @@ const AlertPanel: React.FC = () => {
Total - {stats.totalCount} + {stats.totalCount.toLocaleString()}
New - {stats.newCount} + {stats.newCount.toLocaleString()}
Acknowledged - {stats.acknowledgedCount} + {stats.acknowledgedCount.toLocaleString()}
Resolved - {stats.resolvedCount} + {stats.resolvedCount.toLocaleString()}
)} @@ -351,7 +352,7 @@ const AlertPanel: React.FC = () => { {alert.severity} - {alert.alertType} + {alert.alertType.replace(/([a-z])([A-Z])/g, '$1 $2')} {alert.message.split(/((?:sha256:)?[a-f0-9]{64})/gi).map((part, index) => /^(?:sha256:)?[a-f0-9]{64}$/i.test(part) @@ -402,7 +403,7 @@ const AlertPanel: React.FC = () => { - {alerts.length === 0 &&

No alerts match the selected filters.

} + {!error && alerts.length === 0 &&

No alerts match the selected filters.

} )} @@ -443,25 +444,28 @@ const AlertPanel: React.FC = () => { )} {/* Alert Detail Modal */} - + - Alert Details + Alert Details {selectedAlert && ( -
-

Type: {selectedAlert.alertType}

-

Severity: {selectedAlert.severity}

-

Status: {selectedAlert.status}

-

Message: {selectedAlert.message}

-

Time: {new Date(selectedAlert.timestamp).toLocaleString()}

+ <> +
+
ID
{selectedAlert.id}
+
Type
{selectedAlert.alertType}
+
Severity
{selectedAlert.severity}
+
Status
{selectedAlert.status}
+
Time
+
Message
{selectedAlert.message}
+
{selectedAlert.metadata && ( -
- Metadata: +
+

Metadata

{JSON.stringify(selectedAlert.metadata, null, 2)}
-
+ )} -
+ )}
diff --git a/web/src/components/ContainerList.css b/web/src/components/ContainerList.css index 1439178..2c59dc1 100644 --- a/web/src/components/ContainerList.css +++ b/web/src/components/ContainerList.css @@ -1,94 +1,189 @@ .container-list { - border: none; + min-width: 0; + border: 1px solid #dde3eb; border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - height: 100%; + background: #fff; + color: #172b45; } .container-list .card-header { - background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); - color: white; - border-radius: 12px 12px 0 0 !important; - padding: 1rem 1.5rem; + background: #fff; + border-bottom: 1px solid #dde3eb; + border-radius: 12px 12px 0 0; + padding: 20px 24px; } .container-list .card-title { margin: 0; - font-size: 1.25rem; - font-weight: 600; + font-size: 1rem; + font-weight: 650; } -.container-items { - display: flex; - flex-direction: column; - gap: 1rem; - max-height: 600px; - overflow-y: auto; +.container-list .card-body { + padding: 24px; } -.container-item { - border: 1px solid #e9ecef; - border-radius: 8px; - padding: 1rem; - transition: transform 0.2s, box-shadow 0.2s; +.container-list .container-filter { + max-width: 240px; } -.container-item:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +.container-list .container-items { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 20px; } -.container-header { +.container-list .container-item { + display: flex; + flex-direction: column; + min-width: 0; + border: 1px solid #dde3eb; + border-radius: 8px; + padding: 20px; +} + +.container-list .container-header { display: flex; justify-content: space-between; - align-items: center; - margin-bottom: 0.75rem; + align-items: start; + gap: 12px; + margin-bottom: 16px; } -.container-header h5 { +.container-list .container-header h5 { margin: 0; - font-size: 1.1rem; - font-weight: 600; - color: #2c3e50; + font-size: 0.9375rem; + font-weight: 650; + overflow-wrap: anywhere; +} + +.container-list .container-details { + margin-bottom: 20px; } -.container-details { - margin-bottom: 1rem; +.container-list .container-image { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 16px; } -.container-details p { - margin: 0.25rem 0; - font-size: 0.9rem; - color: #495057; +.container-list .container-image span, + +.container-list .container-security > div > span:first-child { + display: block; + color: #526379; + font-size: 0.6875rem; + margin-bottom: 4px; } -.network-activity { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem; - background: #f8f9fa; - border-radius: 4px; - font-size: 0.85rem; +.container-list .container-image code { + color: #33465e; + font-size: 0.75rem; + overflow-wrap: anywhere; +} + +.container-list .container-security { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.container-list .container-security strong { + font-variant-numeric: tabular-nums; + font-size: 1rem; +} + +.container-list .network-activity { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin: 0 0 12px; + padding: 12px 0; + border-top: 1px solid #e5eaf0; + border-bottom: 1px solid #e5eaf0; } -.container-actions { +.container-list .network-activity dt { + color: #526379; + font-size: 0.6875rem; + font-weight: 500; +} + +.container-list .network-activity dd { + margin: 4px 0 0; + color: #33465e; + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +.container-list .container-actions { display: flex; - gap: 0.5rem; flex-wrap: wrap; + gap: 8px; + margin-top: auto; +} + +.container-list .container-actions .btn { + min-height: 36px; + font-size: 0.75rem; + font-weight: 600; + padding: 6px 12px; +} + +.container-list .badge { + font-size: 0.6875rem; + font-weight: 600; + padding: 5px 8px; + white-space: normal; +} + +.container-list .badge.bg-warning { + color: #172b45; +} + +.container-list .container-empty { + margin: 0; + color: #526379; +} + +.container-list .btn-outline-primary { + --bs-btn-color: #2457c5; + --bs-btn-bg: #eff5ff; + --bs-btn-border-color: #c9d9fa; + --bs-btn-hover-bg: #2457c5; + --bs-btn-hover-border-color: #2457c5; } -/* Responsive adjustments */ -@media (max-width: 768px) { - .container-items { - max-height: none; +.container-list .btn-outline-danger { + --bs-btn-color: #b42318; + --bs-btn-bg: #fff1f2; + --bs-btn-border-color: #f5c1c6; + --bs-btn-hover-bg: #b42318; + --bs-btn-hover-border-color: #b42318; +} + +.container-list .btn-outline-success { + --bs-btn-color: #065f46; + --bs-btn-bg: #ecfdf5; + --bs-btn-border-color: #a7d8c5; + --bs-btn-hover-bg: #065f46; + --bs-btn-hover-border-color: #065f46; +} + +@media (max-width: 575.98px) { + + .container-list .card-body { + padding: 16px; } - .container-actions { - flex-direction: column; + .container-list .container-filter { + max-width: none; } - .container-actions button { - width: 100%; + .container-list .container-item { + padding: 16px; } } diff --git a/web/src/components/ContainerList.tsx b/web/src/components/ContainerList.tsx index 42ff229..decd00b 100644 --- a/web/src/components/ContainerList.tsx +++ b/web/src/components/ContainerList.tsx @@ -3,6 +3,7 @@ import { Card, Button, Form, Badge, Modal, Spinner, Alert as BootstrapAlert } fr import apiService from '../services/api'; import { Container, ContainerStatus } from '../types/containers'; import './ContainerList.css'; +import './DetailsModal.css'; const ContainerList: React.FC = () => { const [containers, setContainers] = useState([]); @@ -21,7 +22,7 @@ const ContainerList: React.FC = () => { try { setLoading(true); const data = await apiService.getContainers(); - setContainers(filterStatus ? data.filter((c: Container) => c.status === filterStatus) : data); + setContainers(filterStatus ? data.filter((c: Container) => (c.securityStatus.state === 'Quarantined' ? 'Quarantined' : c.status) === filterStatus) : data); } catch (err) { console.error('Error loading containers:', err); } finally { @@ -73,9 +74,9 @@ const ContainerList: React.FC = () => { }; const getRiskColor = (score: number) => { - if (score < 30) return '#27ae60'; - if (score < 60) return '#f39c12'; - return '#e74c3c'; + if (score < 30) return '#047857'; + if (score < 60) return '#92400e'; + return '#b42318'; }; const formatCount = (value: number | null) => (value === null ? 'n/a' : value.toLocaleString()); @@ -96,7 +97,7 @@ const ContainerList: React.FC = () => { Containers - + Filter by status { {loading ? (
- + Loading containers...
) : (
- {containers.map((container) => ( -
- {(() => { - const isQuarantined = - container.status === 'Quarantined' || container.securityStatus.state === 'Quarantined'; - - return ( - <> -
-
{container.name}
- - {isQuarantined ? 'Quarantined' : container.status} - -
-
-

Image: {container.image}

-

- Security:{' '} - - {container.securityStatus.state} + {containers.length === 0 &&

No containers match the selected status.

} + {containers.map((container) => { + const isQuarantined = container.status === 'Quarantined' || container.securityStatus.state === 'Quarantined'; + + return ( +
+
+
{container.name}
+ + {isQuarantined ? 'Quarantined' : container.status} -

-

- Risk Score:{' '} - - {container.riskScore} - -

-
- - ⬇ {formatCount(container.networkActivity.receivedPackets)} pkts | - ⬆ {formatCount(container.networkActivity.transmittedPackets)} pkts | - 🚫 {formatCount(container.networkActivity.blockedConnections)} - - {container.networkActivity.suspiciousActivity && ( - Suspicious - )}
-
-
- - {!isQuarantined && container.status === 'Running' && ( - + {!isQuarantined && container.status === 'Running' && ( + - )} - {isQuarantined && ( - - )} -
- - ); - })()} -
- ))} + }}>Quarantine + )} + {isQuarantined && ( + + )} +
+ + ); + })}
)}
{/* Detail Modal */} - setShowModal(false)}> + setShowModal(false)}> - Container Details + Container Details {selectedContainer && ( -
-

Name: {selectedContainer.name}

-

ID: {selectedContainer.id}

-

Image: {selectedContainer.image}

-

Status: {selectedContainer.status}

-

Security: {selectedContainer.securityStatus.state}

-

Risk Score: {selectedContainer.riskScore}

-

Threats: {selectedContainer.securityStatus.threats}

-

Vulnerabilities: {selectedContainer.securityStatus.vulnerabilities ?? 'Unavailable'}

-

Last Scan: {formatDateTime(selectedContainer.securityStatus.lastScan)}

-

RX Traffic: {formatBytes(selectedContainer.networkActivity.receivedBytes)}

-

TX Traffic: {formatBytes(selectedContainer.networkActivity.transmittedBytes)}

-

RX Packets: {formatCount(selectedContainer.networkActivity.receivedPackets)}

-

TX Packets: {formatCount(selectedContainer.networkActivity.transmittedPackets)}

-

Blocked Connections: {formatCount(selectedContainer.networkActivity.blockedConnections)}

-
+
+
Name
{selectedContainer.name}
+
ID
{selectedContainer.id}
+
Image
{selectedContainer.image}
+
Status
{selectedContainer.securityStatus.state === 'Quarantined' ? 'Quarantined' : selectedContainer.status}
+
Security
{selectedContainer.securityStatus.state}
+
Risk Score
{selectedContainer.riskScore}
+
Threats
{selectedContainer.securityStatus.threats}
+
Vulnerabilities
{selectedContainer.securityStatus.vulnerabilities ?? 'Unavailable'}
+
Created
+
Last Scan
{formatDateTime(selectedContainer.securityStatus.lastScan)}
+
RX Traffic
{formatBytes(selectedContainer.networkActivity.receivedBytes)}
+
TX Traffic
{formatBytes(selectedContainer.networkActivity.transmittedBytes)}
+
RX Packets
{formatCount(selectedContainer.networkActivity.receivedPackets)}
+
TX Packets
{formatCount(selectedContainer.networkActivity.transmittedPackets)}
+
Blocked Connections
{formatCount(selectedContainer.networkActivity.blockedConnections)}
+
)}
@@ -229,13 +197,13 @@ const ContainerList: React.FC = () => {
{/* Quarantine Modal */} - setShowQuarantineModal(false)}> + setShowQuarantineModal(false)}> - Confirm Quarantine + Confirm Quarantine

Are you sure you want to quarantine this container?

- + Reason { expect(screen.queryByRole('navigation', { name: 'Alert pages' })).not.toBeInTheDocument(); }); + test('distinguishes a loading failure from a successful empty result', async () => { + (apiService.getAlerts as jest.Mock).mockRejectedValue(new Error('Network unavailable')); + render(); + + expect(await screen.findByText('Failed to load alerts')).toBeInTheDocument(); + expect(screen.queryByText('No alerts match the selected filters.')).not.toBeInTheDocument(); + }); + + test('opens full alert details and metadata, then restores the section link on close', async () => { + const metadata = { source: 'sha256:' + 'a'.repeat(64), log: 'First line\nSecond line' }; + (apiService.getAlerts as jest.Mock).mockResolvedValue([{ ...mockAlerts[0], metadata }]); + render(); + + fireEvent.click(await screen.findByText('Details')); + const dialog = await screen.findByRole('dialog', { name: 'Alert Details' }); + expect(within(dialog).getByText('alert-1')).toBeInTheDocument(); + expect(within(dialog).getByLabelText('Alert metadata')).toHaveTextContent(metadata.source); + expect(window.location.hash).toBe('#alerts/alert-1'); + + fireEvent.click(within(dialog).getByText('Close')); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(window.location.hash).toBe('#alerts'); + }); + test('filters alerts by severity', async () => { render(); diff --git a/web/src/components/__tests__/ContainerList.test.tsx b/web/src/components/__tests__/ContainerList.test.tsx index cebcd94..b6a7e95 100644 --- a/web/src/components/__tests__/ContainerList.test.tsx +++ b/web/src/components/__tests__/ContainerList.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; import ContainerList from '../ContainerList'; import apiService from '../../services/api'; @@ -160,6 +160,37 @@ describe('ContainerList Component', () => { expect(screen.queryByText('Quarantine')).not.toBeInTheDocument(); }); + test('keeps security-state quarantine visible in the Quarantined filter', async () => { + const quarantined = { + ...mockContainers[0], + securityStatus: { ...mockContainers[0].securityStatus, state: 'Quarantined' as const }, + }; + (apiService.getContainers as jest.Mock).mockResolvedValue([quarantined, mockContainers[1]]); + render(); + expect(await screen.findByText('web-server')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Filter by status'), { target: { value: 'Quarantined' } }); + expect(await screen.findByText('web-server')).toBeInTheDocument(); + expect(screen.queryByText('database')).not.toBeInTheDocument(); + expect(screen.getByText('Release')).toBeInTheDocument(); + }); + + test('shows complete identifiers, timestamps and unavailable metrics in the details dialog', async () => { + const id = 'a'.repeat(64); + (apiService.getContainers as jest.Mock).mockResolvedValue([{ ...mockContainers[0], id }]); + render(); + fireEvent.click(await screen.findByText('Details')); + + const dialog = await screen.findByRole('dialog', { name: 'Container Details' }); + expect(within(dialog).getByText(id)).toBeInTheDocument(); + expect(within(dialog).getByText('1.0 KB')).toBeInTheDocument(); + expect(within(dialog).getAllByText('Unavailable')).toHaveLength(2); + expect(dialog.querySelector('time')).toHaveAttribute('datetime', mockContainers[0].createdAt); + + fireEvent.click(within(dialog).getByText('Close')); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + test('filters by status', async () => { render(); From cd90e6ca62f54dec7cd765f3c0ce850634fffbac Mon Sep 17 00:00:00 2001 From: Vladimir Sivak Date: Mon, 2 Nov 2026 23:37:41 +0200 Subject: [PATCH 5/5] feat(ui): add sidebar collapse, scroll-spy navigation, and card alignment --- web/src/components/ContainerList.css | 7 + web/src/components/ContainerList.tsx | 11 +- web/src/components/Dashboard.tsx | 8 +- web/src/components/Sidebar.css | 60 ++++++++ web/src/components/Sidebar.tsx | 91 ++++++++++-- web/src/components/ThreatMap.css | 5 + web/src/components/__tests__/Sidebar.test.tsx | 137 ++++++++++++++++++ 7 files changed, 296 insertions(+), 23 deletions(-) create mode 100644 web/src/components/__tests__/Sidebar.test.tsx diff --git a/web/src/components/ContainerList.css b/web/src/components/ContainerList.css index 2c59dc1..c91951c 100644 --- a/web/src/components/ContainerList.css +++ b/web/src/components/ContainerList.css @@ -95,6 +95,13 @@ font-size: 1rem; } +.container-list .container-security-badges { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + .container-list .network-activity { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); diff --git a/web/src/components/ContainerList.tsx b/web/src/components/ContainerList.tsx index decd00b..60f4d20 100644 --- a/web/src/components/ContainerList.tsx +++ b/web/src/components/ContainerList.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Card, Button, Form, Badge, Modal, Spinner, Alert as BootstrapAlert } from 'react-bootstrap'; +import { Card, Button, Form, Badge, Modal, Spinner } from 'react-bootstrap'; import apiService from '../services/api'; import { Container, ContainerStatus } from '../types/containers'; import './ContainerList.css'; @@ -133,7 +133,13 @@ const ContainerList: React.FC = () => {

Image{container.image}

-
Security{container.securityStatus.state}
+
+ Security +
+ {container.securityStatus.state} + {container.networkActivity.suspiciousActivity && Suspicious} +
+
Risk Score{container.riskScore}
@@ -141,7 +147,6 @@ const ContainerList: React.FC = () => {
Sent
{formatCount(container.networkActivity.transmittedPackets)} pkts
Blocked
{formatCount(container.networkActivity.blockedConnections)}
- {container.networkActivity.suspiciousActivity && Suspicious}
); }; diff --git a/web/src/components/ThreatMap.css b/web/src/components/ThreatMap.css index 051ad1f..105790f 100644 --- a/web/src/components/ThreatMap.css +++ b/web/src/components/ThreatMap.css @@ -129,6 +129,11 @@ box-shadow: 0 4px 16px #172b4514; } +/* Hidden tooltips can retain coordinates from before the sidebar resized. */ +.threat-map .recharts-tooltip-wrapper[style*='visibility: hidden'] { + display: none; +} + .threat-map .recharts-legend-item { margin: 4px 8px !important; } diff --git a/web/src/components/__tests__/Sidebar.test.tsx b/web/src/components/__tests__/Sidebar.test.tsx new file mode 100644 index 0000000..ea9071e --- /dev/null +++ b/web/src/components/__tests__/Sidebar.test.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import Sidebar from '../Sidebar'; + +jest.mock('../../assets/logo.png', () => 'logo.png'); + +const originalResizeObserver = global.ResizeObserver; +const disconnect = jest.fn(); +let resizeCallback: ResizeObserverCallback; +let sectionTops: Record; + +const Sections = () => ( +
+
+
+
+
+
+); + +const activeLink = () => screen.getByRole('navigation', { name: 'Main navigation' }) + .querySelector('[aria-current="location"]'); + +describe('Sidebar navigation', () => { + beforeEach(() => { + window.history.replaceState(null, '', '/'); + sectionTops = { overview: 32, 'threat-map': 600, alerts: 1800, containers: 3000 }; + Object.defineProperty(window, 'scrollY', { configurable: true, value: 0, writable: true }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }); + Object.defineProperty(document.documentElement, 'scrollHeight', { configurable: true, value: 4800 }); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: jest.fn().mockReturnValue({ matches: false }), + }); + HTMLElement.prototype.scrollIntoView = jest.fn(); + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + return { top: sectionTops[this.id] ?? 0 } as DOMRect; + }); + global.ResizeObserver = jest.fn().mockImplementation((callback) => { + resizeCallback = callback; + return { observe: jest.fn(), unobserve: jest.fn(), disconnect }; + }); + disconnect.mockClear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + afterAll(() => { + global.ResizeObserver = originalResizeObserver; + }); + + test('tracks scrolling in both directions without replacing the URL fragment', () => { + render(<>); + expect(activeLink()).toHaveTextContent('Overview'); + + sectionTops = { overview: -1900, 'threat-map': -1300, alerts: -100, containers: 1100 }; + fireEvent.scroll(window); + expect(activeLink()).toHaveTextContent('Alerts'); + expect(window.location.hash).toBe(''); + + sectionTops = { overview: 32, 'threat-map': 600, alerts: 1800, containers: 3000 }; + fireEvent.scroll(window); + expect(activeLink()).toHaveTextContent('Overview'); + }); + + test('keeps a tall section active until the next section reaches the activation line', () => { + sectionTops = { overview: -1800, 'threat-map': -1200, alerts: 650, containers: 1800 }; + render(<>); + expect(activeLink()).toHaveTextContent('Threat Map'); + + sectionTops.alerts = 90; + fireEvent.scroll(window); + expect(activeLink()).toHaveTextContent('Alerts'); + }); + + test('activates the last section at the bottom of the page', () => { + sectionTops = { overview: -3900, 'threat-map': -3300, alerts: -2100, containers: 110 }; + window.scrollY = 4000; + render(<>); + expect(activeLink()).toHaveTextContent('Containers'); + }); + + test('smoothly navigates to the canonical section and activates its link', () => { + render(<>); + fireEvent.click(screen.getByRole('link', { name: 'Threat Map' })); + + expect(document.getElementById('threat-map')!.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }); + expect(window.location.hash).toBe('#threat-map'); + expect(activeLink()).toHaveTextContent('Threat Map'); + }); + + test('respects reduced motion when navigating', () => { + (window.matchMedia as jest.Mock).mockReturnValue({ matches: true }); + render(<>); + fireEvent.click(screen.getByRole('link', { name: 'Containers' })); + + expect(document.getElementById('containers')!.scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' }); + }); + + test('recognizes legacy threat links and alert detail fragments', () => { + render(<>); + window.history.replaceState(null, '', '#threats'); + fireEvent(window, new Event('hashchange')); + expect(activeLink()).toHaveTextContent('Threat Map'); + + window.history.replaceState(null, '', '#alerts/alert-1'); + fireEvent(window, new Event('hashchange')); + expect(activeLink()).toHaveTextContent('Alerts'); + }); + + test('recalculates after asynchronous sections mount and disconnects on unmount', () => { + const { rerender, unmount } = render(<>); + sectionTops = { overview: -1900, 'threat-map': -1300, alerts: 24, containers: 1100 }; + rerender(<>); + act(() => resizeCallback([], {} as ResizeObserver)); + expect(activeLink()).toHaveTextContent('Alerts'); + + unmount(); + expect(disconnect).toHaveBeenCalled(); + }); + + test('collapses and expands while preserving accessible navigation and native titles', () => { + render(); + const collapse = screen.getByRole('button', { name: 'Collapse sidebar' }); + expect(collapse).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(collapse); + + const expand = screen.getByRole('button', { name: 'Expand sidebar' }); + expect(expand).toHaveAttribute('aria-expanded', 'false'); + expect(screen.getByRole('link', { name: 'Threat Map' })).toHaveAttribute('title', 'Threat Map'); + expect(screen.getByRole('link', { name: 'Threat Map' })).toHaveAttribute('href', '#threat-map'); + fireEvent.click(expand); + expect(screen.getByRole('button', { name: 'Collapse sidebar' })).toHaveAttribute('aria-expanded', 'true'); + }); +});