Files
hiveops-aria/frontend/src/components/ThreatEvents.svelte
T
johannes b151bf112c
CD - Develop / build-and-deploy (push) Failing after 10m44s
style: standardize ThreatEvents filter chip CSS to canonical template
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:16:37 -04:00

445 lines
18 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { ariaApi, type ThreatEvent, type ThreatSeverity } from '../lib/api';
import { addToast } from '../lib/stores';
import Pagination from './common/Pagination.svelte';
export let initialSeverity: ThreatSeverity | '' = '';
let events: ThreatEvent[] = [];
let loading = false;
let totalElements = 0;
let totalPages = 0;
let page = 0;
let size = 50;
let severityFilter: ThreatSeverity | '' = initialSeverity;
let deviceSearch = '';
let deviceSearchInput = '';
let filterSidebarCollapsed = true;
let searchTimer: ReturnType<typeof setTimeout>;
$: hasActiveFilters = !!severityFilter || !!deviceSearch;
const SEVERITIES: ThreatSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
const SEV_COLOR: Record<string, string> = {
CRITICAL: '#dc2626',
HIGH: '#d97706',
MEDIUM: '#ca8a04',
LOW: '#16a34a',
INFO: '#6b7280',
};
const PHASE_LABEL: Record<string, string> = {
PHYSICAL_ACCESS: 'Physical Access',
MALWARE_STAGING: 'Staging',
MALWARE_EXECUTION: 'Execution',
PERSISTENCE: 'Persistence',
CLEANUP: 'Cleanup',
};
function fmt(ts: string) {
return new Date(ts).toLocaleString('en-US', { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
}
function fmtSignal(key: string) {
return key.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
}
async function load() {
loading = true;
try {
const res = await ariaApi.getEvents({
severity: severityFilter || undefined,
deviceAgentId: deviceSearch || undefined,
page,
size,
});
events = res.data.content;
totalElements = res.data.page.totalElements;
totalPages = res.data.page.totalPages;
} catch {
addToast('error', 'Load Failed', 'Could not load threat events');
} finally {
loading = false;
}
}
function handleSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
deviceSearch = deviceSearchInput.trim();
page = 0;
load();
}, 350);
}
function clearAllFilters() {
severityFilter = '';
deviceSearch = '';
deviceSearchInput = '';
page = 0;
load();
}
// Auto-refresh
const REFRESH_SECONDS = 60;
const CIRCUMFERENCE = 2 * Math.PI * 14;
let secondsLeft = REFRESH_SECONDS;
let refreshing = false;
let autoRefreshEnabled = true;
let tickInterval: ReturnType<typeof setInterval>;
$: dashOffset = CIRCUMFERENCE * (1 - secondsLeft / REFRESH_SECONDS);
function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled;
if (autoRefreshEnabled) secondsLeft = REFRESH_SECONDS;
}
async function doRefresh() {
refreshing = true;
await load();
refreshing = false;
secondsLeft = REFRESH_SECONDS;
}
onMount(() => {
load();
tickInterval = setInterval(() => {
if (!autoRefreshEnabled) return;
if (secondsLeft > 1) {
secondsLeft -= 1;
} else {
secondsLeft = REFRESH_SECONDS;
doRefresh();
}
}, 1000);
});
onDestroy(() => clearInterval(tickInterval));
</script>
<div class="page-wrap">
<div class="header">
<div class="header-text">
<h1>Threat Events</h1>
<p>IOC match events detected across the fleet</p>
</div>
<div class="header-controls">
<button class="btn-manual-refresh" on:click={doRefresh} disabled={refreshing}
title="Refresh now" aria-label="Refresh">{refreshing ? '⟳' : '↻'}</button>
<button class="refresh-countdown" class:paused={!autoRefreshEnabled}
on:click={toggleAutoRefresh}
title={autoRefreshEnabled ? `Auto-refresh in ${secondsLeft}s click to pause` : 'Auto-refresh paused — click to resume'}
aria-label="Toggle auto-refresh">
<svg width="72" height="72" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>
{#if autoRefreshEnabled}
<circle cx="18" cy="18" r="14" fill="none"
stroke={secondsLeft <= 5 ? '#f87171' : 'rgba(255,255,255,0.85)'}
stroke-width="3" stroke-dasharray={CIRCUMFERENCE} stroke-dashoffset={dashOffset}
stroke-linecap="round" transform="rotate(-90 18 18)" class:pulsing={secondsLeft <= 5}/>
{:else}
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(255,255,255,0.3)"
stroke-width="3" stroke-dasharray="4 3" transform="rotate(-90 18 18)"/>
{/if}
{#if refreshing}
<text x="18" y="22" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.9)" font-family="sans-serif"></text>
{:else if autoRefreshEnabled}
<text x="18" y="22" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.9)" font-family="sans-serif">{secondsLeft}</text>
{:else}
<text x="18" y="22" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.5)" font-family="sans-serif"></text>
{/if}
</svg>
</button>
</div>
</div>
<div class="page-body">
<!-- Filter sidebar -->
<div class="filter-sidebar" class:sidebar-collapsed={filterSidebarCollapsed}>
<div class="filter-sidebar-toggle-bar" class:bar-filters-active={hasActiveFilters}>
{#if !filterSidebarCollapsed}
<span class="filter-sidebar-title">Filters</span>
<div class="toggle-bar-right">
<span class="sidebar-stat-pill">{totalElements}</span>
<button class="sidebar-toggle-btn"
on:click={() => filterSidebarCollapsed = true}
title="Collapse filters"></button>
</div>
{:else}
<button class="sidebar-toggle-btn"
on:click={() => filterSidebarCollapsed = false}
title="Expand filters"></button>
{/if}
</div>
{#if !filterSidebarCollapsed}
<div class="filter-sidebar-content">
{#if hasActiveFilters}
<button class="sidebar-clear-all-btn" on:click={clearAllFilters}> Clear all</button>
{/if}
<div class="filter-section">
<label class="filter-section-label">Device ID</label>
<input type="text" placeholder="Agent ID…" bind:value={deviceSearchInput}
on:input={handleSearchInput} class="search-input" />
</div>
<div class="filter-section">
<label class="filter-section-label">Severity</label>
<select class="filter-select" bind:value={severityFilter}
on:change={() => { page = 0; load(); }}>
<option value="">All Severities</option>
{#each SEVERITIES as sev}
<option value={sev}>{sev}</option>
{/each}
</select>
</div>
</div>
{/if}
</div>
<!-- Content -->
<div class="page-main">
{#if hasActiveFilters}
<div class="active-filters">
<span class="active-filters-label">Filters:</span>
{#if severityFilter}
<span class="filter-tag" style="border-color:{SEV_COLOR[severityFilter]}">
<span class="filter-tag-dot" style="background:{SEV_COLOR[severityFilter]}"></span>
Severity: {severityFilter}
<button class="filter-tag-remove" on:click={() => { severityFilter = ''; page = 0; load(); }}>&times;</button>
</span>
{/if}
{#if deviceSearch}
<span class="filter-tag" style="border-color:#6b7280">
<span class="filter-tag-dot" style="background:#6b7280"></span>
Device: {deviceSearch}
<button class="filter-tag-remove" on:click={() => { deviceSearch = ''; deviceSearchInput = ''; page = 0; load(); }}>&times;</button>
</span>
{/if}
</div>
{/if}
<div class="table-card">
<Pagination {page} {totalPages} {totalElements} {size}
on:pageChange={e => { page = e.detail.page; load(); }}
on:pageSizeChange={e => { size = e.detail.size; page = 0; load(); }} />
<div class="table-container">
{#if loading}
<div class="table-loading">Loading…</div>
{:else if events.length === 0}
<div class="table-empty">No threat events found{hasActiveFilters ? ' matching current filters' : ''}.</div>
{:else}
<table>
<thead>
<tr>
<th>Severity</th>
<th>Signal</th>
<th>Device</th>
<th>Attack Phase</th>
<th>IOC Match</th>
<th>Detected</th>
</tr>
</thead>
<tbody>
{#each events as ev (ev.id)}
<tr>
<td>
<span class="sev-badge" style="background:{SEV_COLOR[ev.severity]}20;color:{SEV_COLOR[ev.severity]};border:1px solid {SEV_COLOR[ev.severity]}40">
{ev.severity}
</span>
</td>
<td class="signal-cell" title={ev.signalKey}>{fmtSignal(ev.signalKey)}</td>
<td class="mono-cell">{ev.deviceAgentId ?? '—'}</td>
<td>{ev.attackPhase ? PHASE_LABEL[ev.attackPhase] ?? ev.attackPhase : '—'}</td>
<td class="ioc-cell" title={ev.matchedIoc?.value ?? ''}>
{#if ev.matchedIoc}
<span class="ioc-type-badge">{ev.matchedIoc.iocType}</span>
<span class="ioc-val">{ev.matchedIoc.value}</span>
{:else}
<span class="muted"></span>
{/if}
</td>
<td class="date-cell">{fmt(ev.detectedAt)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</div>
</div>
</div>
<style>
.page-wrap {
display: flex; flex-direction: column;
height: 100%; overflow: hidden;
padding: 24px 24px 0;
}
.header-controls { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.btn-manual-refresh {
background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3);
color: white; border-radius: 50%; width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem; cursor: pointer; transition: background 0.15s; flex-shrink: 0;
}
.btn-manual-refresh:hover:not(:disabled) { background: rgba(255,255,255,0.28); }
.btn-manual-refresh:disabled { opacity: 0.5; cursor: not-allowed; }
.refresh-countdown {
flex-shrink: 0; background: none; border: none; cursor: pointer; padding: 0;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
opacity: 0.9; transition: opacity 0.15s, transform 0.15s;
}
.refresh-countdown:hover { opacity: 1; transform: scale(1.08); }
.refresh-countdown.paused { opacity: 0.5; }
.refresh-countdown.paused:hover { opacity: 0.8; }
@keyframes countdown-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
:global(.pulsing) { animation: countdown-pulse 0.8s ease-in-out infinite; }
.page-body {
display: flex; flex: 1; overflow: hidden; min-height: 0;
}
/* Filter sidebar */
.filter-sidebar {
width: 220px; min-width: 220px;
background: #f8f9fa;
border-right: 1px solid #dee2e6;
display: flex; flex-direction: column;
overflow: hidden;
transition: width 0.2s ease, min-width 0.2s ease;
flex-shrink: 0;
}
.filter-sidebar.sidebar-collapsed { width: 40px; min-width: 40px; }
.filter-sidebar-toggle-bar {
display: flex; align-items: center; justify-content: space-between;
padding: 0.55rem 0.65rem;
border-bottom: 1px solid #dee2e6;
background: #eef2f7;
flex-shrink: 0; min-height: 36px;
}
.filter-sidebar-title { font-size: var(--font-size-label); font-weight: 700; color: #374151; text-transform: uppercase; letter-spacing: 0.5px; }
.toggle-bar-right { display: flex; align-items: center; gap: 6px; }
.sidebar-stat-pill { font-size: var(--font-size-tiny); background: #e0e7ff; border: 1px solid #c7d2fe; color: #3730a3; border-radius: 10px; padding: 1px 8px; white-space: nowrap; }
.sidebar-toggle-btn { background: none; border: 1px solid #d1d5db; border-radius: 4px; padding: 1px 6px; cursor: pointer; font-size: 0.82rem; color: #6b7280; line-height: 1.4; flex-shrink: 0; margin-left: auto; }
.sidebar-toggle-btn:hover { background: #e5e7eb; border-color: #9ca3af; }
.filter-sidebar-content { display: flex; flex-direction: column; gap: 0.75rem; padding: 0.75rem 0.65rem; overflow-y: auto; flex: 1; }
.filter-section { display: flex; flex-direction: column; gap: 0.3rem; }
.filter-section-label { font-size: var(--font-size-tiny); font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; }
.search-input { padding: 0.4rem 0.6rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: var(--font-size-body-sm); width: 100%; background: white; }
.search-input:focus { outline: none; border-color: #3b82f6; }
.filter-select { padding: 0.4rem 0.6rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: var(--font-size-body-sm); width: 100%; background: white; color: #374151; }
.sidebar-clear-all-btn { border: none; background: #fee2e2; color: #dc2626; border-radius: 4px; padding: 0.3rem 0.6rem; cursor: pointer; font-size: var(--font-size-tiny); font-weight: 600; text-align: left; }
.sidebar-clear-all-btn:hover { background: #fecaca; }
/* Active filters */
.active-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-bottom: 8px; padding: 0.5rem 0.75rem; flex-shrink: 0; }
.active-filters-label { font-size: var(--font-size-tiny); font-weight: 600; color: #555; margin-right: 0.25rem; }
.filter-tag { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.6rem; background: white; border: 1px solid #ddd; border-left-width: 3px; border-radius: 4px; font-size: var(--font-size-tiny); font-weight: 500; color: #333; }
.filter-tag-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.filter-tag-remove { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border: none; background: #e5e7eb; border-radius: 50%; cursor: pointer; font-size: var(--font-size-tiny); color: #666; line-height: 1; padding: 0; margin-left: 0.15rem; }
.filter-tag-remove:hover { background: #f44336; color: white; }
/* Content */
.page-main { flex: 1; overflow-y: auto; padding: 16px 24px 24px; min-width: 0; display: flex; flex-direction: column; }
.table-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.table-container { flex: 1; overflow: auto; }
.table-loading, .table-empty { padding: 2rem; text-align: center; color: #6b7280; font-size: var(--font-size-body-sm); }
table { width: 100%; border-collapse: collapse; }
thead { background: #f9fafb; position: sticky; top: 0; z-index: 5; }
th {
color: #374151;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.3px;
font-weight: 600;
border-bottom: 2px solid #e5e7eb;
padding: 0.6rem 0.75rem;
text-align: left;
}
td {
border-bottom: 1px solid #f3f4f6;
color: #1f2937;
padding: 0.55rem 0.75rem;
font-size: var(--font-size-label);
}
tbody tr:hover { background: #eff6ff; }
tbody tr:last-child td { border-bottom: none; }
.sev-badge {
display: inline-block;
font-size: 0.72rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap;
}
.signal-cell { max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mono-cell { font-family: monospace; font-size: 0.78rem; color: #374151; }
.date-cell { white-space: nowrap; color: #6b7280; }
.muted { color: #9ca3af; }
.ioc-cell { display: flex; align-items: center; gap: 6px; max-width: 220px; }
.ioc-type-badge {
display: inline-block;
font-size: 0.65rem;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
background: #dbeafe;
color: #1e40af;
font-family: monospace;
white-space: nowrap;
flex-shrink: 0;
}
.ioc-val { font-family: monospace; font-size: 0.78rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Dark mode */
:global(.dark-mode) .filter-sidebar { background: #1f2937; border-right-color: #374151; }
:global(.dark-mode) .filter-sidebar-toggle-bar { background: #111827; border-bottom-color: #374151; }
:global(.dark-mode) .filter-sidebar-title { color: #9ca3af; }
:global(.dark-mode) .sidebar-toggle-btn { border-color: #4b5563; color: #9ca3af; }
:global(.dark-mode) .sidebar-toggle-btn:hover { background: #374151; }
:global(.dark-mode) .search-input { background: #283040; border-color: #4b5563; color: #e2e8f0; }
:global(.dark-mode) .filter-select { background: #283040; border-color: #4b5563; color: #e2e8f0; }
:global(.dark-mode) .table-card { background: #1f2937; }
:global(.dark-mode) thead { background: #111827; border-bottom-color: #374151; }
:global(.dark-mode) th { background: #111827; color: #9ca3af; border-bottom-color: #374151; }
:global(.dark-mode) td { color: #e5e7eb; border-bottom-color: #374151; }
:global(.dark-mode) tbody tr { background: #1f2937; }
:global(.dark-mode) tbody tr:hover { background: #374151; }
:global(.dark-mode) .mono-cell { color: #e5e7eb; }
:global(.dark-mode) .date-cell { color: #9ca3af; }
:global(.dark-mode) .table-loading, :global(.dark-mode) .table-empty { color: #9ca3af; }
:global(.dark-mode) .ioc-type-badge { background: #1e3a5f; color: #93c5fd; }
:global(.dark-mode) .active-filters { background: #111827; border-color: #374151; }
:global(.dark-mode) .active-filters-label { color: #9ca3af; }
:global(.dark-mode) .filter-tag { background: #1f2937; border-color: #374151; color: #e5e5e5; }
:global(.dark-mode) .filter-tag-remove { background: #374151; color: #e5e7eb; }
</style>