feat(aria): add Svelte SPA frontend and threat events API
CD - Develop / build-and-deploy (push) Failing after 54s
CD - Develop / build-and-deploy (push) Failing after 54s
- frontend/: full aria.bcos.dev SPA (port 5187) with Dashboard, Threat Events, and IOC Management views; Phase 2-3 nav items disabled as placeholders - ThreatEventController: GET /api/aria/events (paginated, severity/device filter) and GET /api/aria/stats (total/critical/high/activeIoc counts) - Deployed to bcos.dev: hiveiq-aria-frontend container + aria.bcos.dev NPM proxy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.svelte-kit/
|
||||
.env
|
||||
.env.local
|
||||
*.local
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g serve
|
||||
|
||||
COPY --from=builder /build/dist ./dist
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
EXPOSE 5187
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["serve", "-s", "dist", "-l", "5187"]
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Inject runtime config from environment variables into the built SPA.
|
||||
# This file is written to /app/dist/config.js and loaded by index.html
|
||||
# BEFORE the Vite bundle — so window.__APP_CONFIG__ is available at startup.
|
||||
#
|
||||
# Add new config values here and in src/lib/api.ts Window interface.
|
||||
|
||||
cat > /app/dist/config.js <<EOF
|
||||
window.__APP_CONFIG__ = {
|
||||
apiUrl: '${VITE_API_URL:-http://localhost:8080}',
|
||||
authUrl: '${VITE_AUTH_URL:-http://localhost:8082}'
|
||||
};
|
||||
EOF
|
||||
|
||||
echo "config.js: apiUrl=${VITE_API_URL:-http://localhost:8080}"
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ARIA | HiveIQ</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- Runtime config injected by entrypoint.sh — sets window.__APP_CONFIG__ -->
|
||||
<script src="/config.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2176
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "hiveops-aria-frontend",
|
||||
"version": "1.0.1-dev",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
"svelte": "^4.0.0",
|
||||
"svelte-check": "^3.0.0",
|
||||
"svelte-preprocess": "^5.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,255 @@
|
||||
<script lang="ts">
|
||||
import Dashboard from './components/Dashboard.svelte';
|
||||
import ThreatEvents from './components/ThreatEvents.svelte';
|
||||
import IocManagement from './components/IocManagement.svelte';
|
||||
import Toast from './components/common/Toast.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { authApi } from './lib/api';
|
||||
import type { ThreatSeverity } from './lib/api';
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
const appVersion: string = __APP_VERSION__;
|
||||
|
||||
let userInfo: { name?: string; email?: string; role?: string } | null = null;
|
||||
let sidebarCollapsed = localStorage.getItem('ariaSidebarCollapsed') === 'true';
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed = !sidebarCollapsed;
|
||||
localStorage.setItem('ariaSidebarCollapsed', String(sidebarCollapsed));
|
||||
}
|
||||
|
||||
type View = 'dashboard' | 'events' | 'ioc';
|
||||
let currentView: View = 'dashboard';
|
||||
let eventsInitialSeverity: ThreatSeverity | '' = '';
|
||||
|
||||
function navigate(view: View, severity?: ThreatSeverity | '') {
|
||||
eventsInitialSeverity = severity ?? '';
|
||||
currentView = view;
|
||||
}
|
||||
|
||||
function handleDashboardNav(e: CustomEvent<{ view: string }>) {
|
||||
if (e.detail.view === 'events-critical') navigate('events', 'CRITICAL');
|
||||
else if (e.detail.view === 'events-high') navigate('events', 'HIGH');
|
||||
else if (e.detail.view === 'ioc') navigate('ioc');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.documentElement.classList.add('dark-mode');
|
||||
|
||||
authApi.get('/api/auth/users/me').then((res: any) => {
|
||||
userInfo = { name: res.data.name, email: res.data.email, role: res.data.role };
|
||||
}).catch(() => {});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
<aside class="sidebar" class:collapsed={sidebarCollapsed}>
|
||||
<div class="sidebar-header">
|
||||
<div class="aria-brand">
|
||||
<span class="aria-logo">🛡️</span>
|
||||
{#if !sidebarCollapsed}
|
||||
<div class="aria-brand-text">
|
||||
<span class="aria-name">ARIA</span>
|
||||
<span class="aria-sub">Threat Intelligence</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !sidebarCollapsed}
|
||||
<span class="sidebar-version">v{appVersion}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-group">
|
||||
<button class="nav-btn" class:active={currentView === 'dashboard'}
|
||||
on:click={() => navigate('dashboard')} title="Dashboard">
|
||||
<span class="nav-icon">📊</span>
|
||||
{#if !sidebarCollapsed}<span>Dashboard</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-group">
|
||||
<button class="nav-btn" class:active={currentView === 'events'}
|
||||
on:click={() => navigate('events', '')} title="Threat Events">
|
||||
<span class="nav-icon">⚡</span>
|
||||
{#if !sidebarCollapsed}<span>Threat Events</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-group">
|
||||
<button class="nav-btn" class:active={currentView === 'ioc'}
|
||||
on:click={() => navigate('ioc')} title="IOC Management">
|
||||
<span class="nav-icon">🎯</span>
|
||||
{#if !sidebarCollapsed}<span>IOC Management</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !sidebarCollapsed}
|
||||
<div class="nav-section-header">Phase 2–3</div>
|
||||
{/if}
|
||||
|
||||
<div class="nav-group">
|
||||
<button class="nav-btn nav-disabled" disabled title="Precursor Alerts (Phase 2)">
|
||||
<span class="nav-icon">🔔</span>
|
||||
{#if !sidebarCollapsed}<span>Precursor Alerts</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-group">
|
||||
<button class="nav-btn nav-disabled" disabled title="Device Posture (Phase 3)">
|
||||
<span class="nav-icon">🔒</span>
|
||||
{#if !sidebarCollapsed}<span>Device Posture</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{#if !sidebarCollapsed && userInfo}
|
||||
<div class="sidebar-user">
|
||||
<div class="user-avatar">{(userInfo.name ?? userInfo.email ?? '?')[0].toUpperCase()}</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{userInfo.name ?? ''}</div>
|
||||
<div class="user-role">{userInfo.role ?? ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<button class="sidebar-toggle" on:click={toggleSidebar} title={sidebarCollapsed ? 'Expand' : 'Collapse'}>
|
||||
{sidebarCollapsed ? '>>' : '<<'}
|
||||
</button>
|
||||
|
||||
<main class="main-content">
|
||||
{#if currentView === 'dashboard'}
|
||||
<Dashboard on:navigate={handleDashboardNav} />
|
||||
{:else if currentView === 'events'}
|
||||
<ThreatEvents initialSeverity={eventsInitialSeverity} />
|
||||
{:else if currentView === 'ioc'}
|
||||
<IocManagement />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Toast />
|
||||
|
||||
<style>
|
||||
:global(html), :global(body) {
|
||||
margin: 0; padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
font-size: 14px; height: 100%; overflow: hidden;
|
||||
}
|
||||
:global(#app) { height: 100%; }
|
||||
:global(*) { box-sizing: border-box; }
|
||||
:global(:root) {
|
||||
--font-size-page-title: 1.4rem; --font-size-body: 0.95rem;
|
||||
--font-size-body-sm: 0.9rem; --font-size-label: 0.85rem;
|
||||
--font-size-caption: 0.8rem; --font-size-tiny: 0.75rem;
|
||||
--font-size-subtitle: 0.85rem; --font-size-icon: 1.1rem;
|
||||
--font-size-stat-value: 2rem; --color-primary: #2563eb;
|
||||
}
|
||||
|
||||
.app { display: flex; height: 100vh; overflow: hidden; }
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: linear-gradient(180deg, #081651 0%, #1c49b8 100%);
|
||||
color: white; display: flex; flex-direction: column;
|
||||
height: 100vh; overflow-y: auto;
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
|
||||
flex-shrink: 0; transition: width 0.25s ease;
|
||||
}
|
||||
.sidebar.collapsed { width: 56px; }
|
||||
|
||||
.sidebar-toggle {
|
||||
width: 18px; background: #0a1f6e; border: none; cursor: pointer;
|
||||
color: rgba(255,255,255,0.6); display: flex; align-items: center; justify-content: center;
|
||||
font-size: 8px; font-weight: 700; flex-shrink: 0; padding: 0; height: 100vh;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.sidebar-toggle:hover { background: #1635c0; color: white; }
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.25rem 1rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
flex-shrink: 0; display: flex; flex-direction: column; gap: 0.25rem;
|
||||
}
|
||||
|
||||
.aria-brand { display: flex; align-items: center; gap: 10px; }
|
||||
.aria-logo { font-size: 1.6rem; flex-shrink: 0; }
|
||||
.aria-brand-text { display: flex; flex-direction: column; }
|
||||
.aria-name { font-size: 1.1rem; font-weight: 800; letter-spacing: 2px; color: white; }
|
||||
.aria-sub { font-size: 0.65rem; color: rgba(255,255,255,0.55); letter-spacing: 0.3px; margin-top: 1px; }
|
||||
.sidebar-version { font-size: 0.7rem; color: rgba(255,255,255,0.4); padding-left: 2px; }
|
||||
|
||||
.sidebar-nav { display: flex; flex-direction: column; padding: 0.75rem 0; flex: 1; }
|
||||
|
||||
.nav-section-header {
|
||||
font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px;
|
||||
color: rgba(255,255,255,0.3); padding: 0.75rem 1.25rem 0.2rem; margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-group { display: flex; flex-direction: column; }
|
||||
|
||||
.nav-btn {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: transparent; border: none;
|
||||
color: rgba(255,255,255,0.8); cursor: pointer;
|
||||
font-size: 0.9rem; font-weight: 500;
|
||||
text-align: left; transition: all 0.2s;
|
||||
border-left: 3px solid transparent; width: 100%;
|
||||
}
|
||||
.nav-btn:hover:not(:disabled) { background: rgba(255,255,255,0.1); color: white; }
|
||||
.nav-btn.active { background: rgba(255,255,255,0.15); color: white; border-left-color: white; }
|
||||
.nav-disabled { opacity: 0.38; cursor: not-allowed !important; }
|
||||
|
||||
.nav-icon { font-size: 1.05rem; width: 24px; text-align: center; flex-shrink: 0; }
|
||||
|
||||
.sidebar-user {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex; align-items: center; gap: 0.65rem; flex-shrink: 0;
|
||||
}
|
||||
.user-avatar {
|
||||
width: 30px; height: 30px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 0.85rem; flex-shrink: 0;
|
||||
}
|
||||
.user-info { display: flex; flex-direction: column; min-width: 0; }
|
||||
.user-name { font-size: 0.8rem; font-weight: 600; color: white; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.user-role { font-size: 0.65rem; color: rgba(255,255,255,0.5); }
|
||||
|
||||
.main-content { flex: 1; overflow-y: auto; background: #f5f5f5; height: 100vh; }
|
||||
|
||||
:global(.header) {
|
||||
background: linear-gradient(180deg, #081651 0%, #1c49b8 100%) !important;
|
||||
padding: 24px !important; border-radius: 12px !important; margin-bottom: 24px !important;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08) !important;
|
||||
border-left: 4px solid rgba(255,255,255,0.5) !important;
|
||||
color: white !important; display: flex !important;
|
||||
justify-content: space-between !important; align-items: center !important;
|
||||
min-height: 80px !important;
|
||||
}
|
||||
:global(.header-text) { flex: 1; min-width: 0; }
|
||||
:global(.header h1), :global(.header-text h1) {
|
||||
margin: 0 0 4px 0 !important; color: white !important;
|
||||
font-size: 1.4rem !important; font-weight: 700 !important;
|
||||
}
|
||||
:global(.header p), :global(.header-text p) {
|
||||
margin: 0 !important; color: rgba(255,255,255,0.8) !important; font-size: 0.85rem !important;
|
||||
}
|
||||
|
||||
:global(.btn) { padding: 8px 16px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.9rem; font-weight: 500; transition: background 0.15s; }
|
||||
:global(.btn:disabled) { opacity: 0.6; cursor: not-allowed; }
|
||||
:global(.btn-primary) { background: #2563eb; color: white; }
|
||||
:global(.btn-primary:hover:not(:disabled)) { background: #1d4ed8; }
|
||||
:global(.btn-secondary) { background: #f1f5f9; color: #374151; border: 1px solid #e2e8f0; }
|
||||
:global(.btn-secondary:hover:not(:disabled)) { background: #e2e8f0; }
|
||||
:global(.btn-sm) { padding: 4px 10px; font-size: 0.8rem; }
|
||||
|
||||
:global(.dark-mode) { color-scheme: dark; }
|
||||
:global(.dark-mode) .sidebar { background: linear-gradient(180deg, #050e2e 0%, #0f2460 100%); }
|
||||
:global(.dark-mode) .main-content { background: #0f172a; }
|
||||
:global(.dark-mode) :global(.header) { background: linear-gradient(180deg, #050e2e 0%, #0f2460 100%) !important; }
|
||||
:global(.dark-mode) :global(.btn-secondary) { background: #1e293b; color: #e2e8f0; border-color: #334155; }
|
||||
:global(.dark-mode) :global(.btn-secondary:hover:not(:disabled)) { background: #334155; }
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
/* ─────────────────────────────────────────
|
||||
HiveOps Frontend Design System
|
||||
app.css — loaded by main.ts on every page
|
||||
───────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* ── Typography scale ───────────────────
|
||||
Use these variables for ALL font sizes.
|
||||
Never hard-code rem values in components.
|
||||
*/
|
||||
--font-size-page-title: 1.4rem; /* main page h1 */
|
||||
--font-size-section-title: 1.1rem; /* card/section headings */
|
||||
--font-size-card-title: 0.95rem;
|
||||
--font-size-body: 0.95rem; /* default text, nav buttons */
|
||||
--font-size-body-sm: 0.9rem; /* secondary body text */
|
||||
--font-size-label: 0.85rem; /* form labels, table text */
|
||||
--font-size-caption: 0.8rem; /* btn-sm, minor labels */
|
||||
--font-size-tiny: 0.75rem; /* expand arrows, badges */
|
||||
--font-size-subtitle: 0.85rem; /* header subtitle / description */
|
||||
--font-size-stat-value: 2rem; /* dashboard KPI numbers */
|
||||
--font-size-icon: 1.1rem; /* nav icons (24px slot) */
|
||||
--font-size-icon-sm: 0.9rem; /* submenu icons (20px slot) */
|
||||
|
||||
/* ── Color system ───────────────────────
|
||||
Primary palette for buttons/links/active states.
|
||||
*/
|
||||
--color-primary: #2563eb;
|
||||
--color-primary-hover: #1d4ed8;
|
||||
--color-danger: #d32f2f;
|
||||
--color-success: #2e7d32;
|
||||
--color-warning: #b45309;
|
||||
--color-border: #e0e0e0;
|
||||
--color-text-primary: #1a1a1a;
|
||||
--color-text-secondary:#666;
|
||||
--color-bg-light: #fafbfc;
|
||||
|
||||
/* ── Sidebar gradient ───────────────────
|
||||
Both the sidebar background and .header blocks use this gradient.
|
||||
Do not override per-app — consistency is the point.
|
||||
*/
|
||||
--sidebar-gradient: linear-gradient(180deg, #081651 0%, #1c49b8 100%);
|
||||
--sidebar-gradient-dark: linear-gradient(180deg, #050e2e 0%, #0f2460 100%);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import { onMount, createEventDispatcher } from 'svelte';
|
||||
import { ariaApi, type AriaStats } from '../lib/api';
|
||||
import { addToast } from '../lib/stores';
|
||||
|
||||
const dispatch = createEventDispatcher<{ navigate: { view: string } }>();
|
||||
|
||||
let stats: AriaStats | null = null;
|
||||
let loading = true;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await ariaApi.getStats();
|
||||
stats = res.data;
|
||||
} catch {
|
||||
addToast('error', 'Load Failed', 'Could not load ARIA stats');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="page-wrap">
|
||||
<div class="header">
|
||||
<div class="header-text">
|
||||
<h1>ARIA Dashboard</h1>
|
||||
<p>ATM Runtime Integrity Analysis — threat event overview</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-row">Loading…</div>
|
||||
{:else if stats}
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon neutral">🛡️</div>
|
||||
<div class="stat-value">{stats.totalEvents.toLocaleString()}</div>
|
||||
<div class="stat-label">Total Events</div>
|
||||
</div>
|
||||
|
||||
<button class="stat-card clickable critical" on:click={() => dispatch('navigate', { view: 'events-critical' })}>
|
||||
<div class="stat-icon critical-icon">🔴</div>
|
||||
<div class="stat-value critical-val">{stats.criticalCount.toLocaleString()}</div>
|
||||
<div class="stat-label">Critical</div>
|
||||
<div class="stat-hint">View events →</div>
|
||||
</button>
|
||||
|
||||
<button class="stat-card clickable high" on:click={() => dispatch('navigate', { view: 'events-high' })}>
|
||||
<div class="stat-icon high-icon">🟠</div>
|
||||
<div class="stat-value high-val">{stats.highCount.toLocaleString()}</div>
|
||||
<div class="stat-label">High</div>
|
||||
<div class="stat-hint">View events →</div>
|
||||
</button>
|
||||
|
||||
<button class="stat-card clickable ioc" on:click={() => dispatch('navigate', { view: 'ioc' })}>
|
||||
<div class="stat-icon ioc-icon">🎯</div>
|
||||
<div class="stat-value ioc-val">{stats.activeIocCount.toLocaleString()}</div>
|
||||
<div class="stat-label">Active IOCs</div>
|
||||
<div class="stat-hint">Manage IOCs →</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="section-title">Phase Coverage</div>
|
||||
<div class="phase-grid">
|
||||
{#each [
|
||||
{ phase: 'Physical Access', icon: '🔑', desc: 'Unauthorized cabinet/safe access detected via jackpot precursors' },
|
||||
{ phase: 'Malware Staging', icon: '📦', desc: 'Known staging filenames, directories, or registry keys matched' },
|
||||
{ phase: 'Malware Execution', icon: '💀', desc: 'Execution-phase IOC match — active threat in progress' },
|
||||
{ phase: 'Persistence', icon: '🔗', desc: 'Persistence mechanism (service, registry run key) installed' },
|
||||
{ phase: 'Cleanup', icon: '🧹', desc: 'Log or artifact deletion following an attack attempt' },
|
||||
] as p}
|
||||
<div class="phase-card">
|
||||
<span class="phase-icon">{p.icon}</span>
|
||||
<div class="phase-body">
|
||||
<div class="phase-name">{p.phase}</div>
|
||||
<div class="phase-desc">{p.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-wrap {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.loading-row {
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 24px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
button.stat-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, box-shadow 0.12s;
|
||||
font-family: inherit;
|
||||
}
|
||||
button.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.stat-icon { font-size: 1.8rem; }
|
||||
.stat-value { font-size: var(--font-size-stat-value, 2rem); font-weight: 700; color: #1f2937; line-height: 1; }
|
||||
.stat-label { font-size: var(--font-size-label, 0.85rem); color: #6b7280; font-weight: 500; }
|
||||
.stat-hint { font-size: 0.72rem; color: #9ca3af; margin-top: 2px; }
|
||||
|
||||
.critical-val { color: #dc2626; }
|
||||
.high-val { color: #d97706; }
|
||||
.ioc-val { color: #2563eb; }
|
||||
|
||||
.section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.phase-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.phase-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.phase-icon { font-size: 1.3rem; flex-shrink: 0; margin-top: 1px; }
|
||||
.phase-body { display: flex; flex-direction: column; gap: 2px; }
|
||||
.phase-name { font-weight: 600; color: #111827; font-size: var(--font-size-body, 0.95rem); }
|
||||
.phase-desc { font-size: var(--font-size-caption, 0.8rem); color: #6b7280; }
|
||||
|
||||
/* Dark mode */
|
||||
:global(.dark-mode) .stat-card { background: #1f2937; border-color: #374151; }
|
||||
:global(.dark-mode) .stat-value { color: #e5e7eb; }
|
||||
:global(.dark-mode) .phase-card { background: #1f2937; border-color: #374151; }
|
||||
:global(.dark-mode) .phase-name { color: #e5e7eb; }
|
||||
:global(.dark-mode) .phase-desc { color: #9ca3af; }
|
||||
:global(.dark-mode) .loading-row { color: #9ca3af; }
|
||||
</style>
|
||||
@@ -0,0 +1,468 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { ariaApi, type ThreatIoc, type IocType, type ConfidenceLevel } from '../lib/api';
|
||||
import { addToast } from '../lib/stores';
|
||||
|
||||
let iocs: ThreatIoc[] = [];
|
||||
let loading = false;
|
||||
let showInactive = false;
|
||||
let typeFilter: IocType | '' = '';
|
||||
|
||||
let showPanel = false;
|
||||
let editingIoc: ThreatIoc | null = null;
|
||||
let submitting = false;
|
||||
|
||||
// Form state
|
||||
let form = {
|
||||
iocType: '' as IocType | '',
|
||||
value: '',
|
||||
description: '',
|
||||
sourceRef: '',
|
||||
confidence: 'HIGH' as ConfidenceLevel,
|
||||
expiresAt: '',
|
||||
};
|
||||
|
||||
const IOC_TYPES: IocType[] = ['FILENAME','MD5','REGISTRY_KEY','DIRECTORY','SERVICE_NAME','IP','FILE_PATH'];
|
||||
|
||||
const CONF_COLOR: Record<string, string> = {
|
||||
HIGH: '#16a34a',
|
||||
MEDIUM: '#d97706',
|
||||
LOW: '#6b7280',
|
||||
};
|
||||
|
||||
const SRC_LABEL: Record<string, string> = {
|
||||
FBI_FLASH: 'FBI FLASH',
|
||||
FS_ISAC: 'FS-ISAC',
|
||||
MANUAL: 'Manual',
|
||||
};
|
||||
|
||||
function fmtDate(ts: string | undefined) {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await ariaApi.getIocs({
|
||||
type: typeFilter || undefined,
|
||||
activeOnly: !showInactive,
|
||||
});
|
||||
iocs = res.data;
|
||||
} catch {
|
||||
addToast('error', 'Load Failed', 'Could not load IOCs');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editingIoc = null;
|
||||
form = { iocType: '', value: '', description: '', sourceRef: '', confidence: 'HIGH', expiresAt: '' };
|
||||
showPanel = true;
|
||||
}
|
||||
|
||||
function openEdit(ioc: ThreatIoc) {
|
||||
editingIoc = ioc;
|
||||
form = {
|
||||
iocType: ioc.iocType,
|
||||
value: ioc.value,
|
||||
description: ioc.description ?? '',
|
||||
sourceRef: ioc.sourceRef ?? '',
|
||||
confidence: ioc.confidence,
|
||||
expiresAt: ioc.expiresAt ? ioc.expiresAt.split('T')[0] : '',
|
||||
};
|
||||
showPanel = true;
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
showPanel = false;
|
||||
editingIoc = null;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.iocType || !form.value.trim()) {
|
||||
addToast('warning', 'Validation', 'IOC Type and Value are required');
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
try {
|
||||
const payload = {
|
||||
iocType: form.iocType as IocType,
|
||||
value: form.value.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
sourceRef: form.sourceRef.trim() || undefined,
|
||||
confidence: form.confidence,
|
||||
expiresAt: form.expiresAt ? new Date(form.expiresAt).toISOString() : undefined,
|
||||
};
|
||||
if (editingIoc) {
|
||||
await ariaApi.updateIoc(editingIoc.id, payload);
|
||||
addToast('success', 'Saved', 'IOC updated');
|
||||
} else {
|
||||
await ariaApi.createIoc(payload);
|
||||
addToast('success', 'Created', 'IOC added to watchlist');
|
||||
}
|
||||
closePanel();
|
||||
load();
|
||||
} catch {
|
||||
addToast('error', 'Save Failed', 'Could not save IOC');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivate(ioc: ThreatIoc) {
|
||||
if (!confirm(`Deactivate IOC: ${ioc.value}?\n\nIt will no longer trigger detections.`)) return;
|
||||
try {
|
||||
await ariaApi.deactivateIoc(ioc.id);
|
||||
addToast('success', 'Deactivated', ioc.value);
|
||||
load();
|
||||
} catch {
|
||||
addToast('error', 'Failed', 'Could not deactivate IOC');
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<div class="page-wrap">
|
||||
<div class="header">
|
||||
<div class="header-text">
|
||||
<h1>IOC Management</h1>
|
||||
<p>Indicators of Compromise — FBI FLASH, FS-ISAC, and manual entries</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="count">{iocs.length} IOC{iocs.length !== 1 ? 's' : ''}</span>
|
||||
|
||||
<div class="type-filter">
|
||||
<select bind:value={typeFilter} on:change={() => { load(); }}>
|
||||
<option value="">All Types</option>
|
||||
{#each IOC_TYPES as t}
|
||||
<option value={t}>{t.replace(/_/g, ' ')}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label class="inactive-toggle">
|
||||
<input type="checkbox" bind:checked={showInactive} on:change={load} />
|
||||
Show inactive
|
||||
</label>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button class="btn btn-primary btn-sm" on:click={openAdd}>+ Add IOC</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
{#if loading}
|
||||
<div class="table-msg">Loading…</div>
|
||||
{:else if iocs.length === 0}
|
||||
<div class="table-msg">No IOCs found.</div>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
<th>Description</th>
|
||||
<th>Source</th>
|
||||
<th>Confidence</th>
|
||||
<th>Added</th>
|
||||
<th>Expires</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each iocs as ioc (ioc.id)}
|
||||
<tr class:inactive-row={!ioc.active}>
|
||||
<td>
|
||||
<span class="type-badge">{ioc.iocType.replace(/_/g,' ')}</span>
|
||||
</td>
|
||||
<td>
|
||||
<code class="ioc-value" title={ioc.value}>{ioc.value}</code>
|
||||
</td>
|
||||
<td class="desc-cell" title={ioc.description ?? ''}>{ioc.description ?? '—'}</td>
|
||||
<td class="src-cell">{SRC_LABEL[ioc.source] ?? ioc.source}</td>
|
||||
<td>
|
||||
<span class="conf-badge" style="color:{CONF_COLOR[ioc.confidence]}">{ioc.confidence}</span>
|
||||
</td>
|
||||
<td class="date-cell">{fmtDate(ioc.addedAt)}</td>
|
||||
<td class="date-cell">{fmtDate(ioc.expiresAt)}</td>
|
||||
<td>
|
||||
{#if ioc.active}
|
||||
<span class="badge badge-active">Active</span>
|
||||
{:else}
|
||||
<span class="badge badge-inactive">Inactive</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn-sm btn-edit" on:click={() => openEdit(ioc)}>Edit</button>
|
||||
{#if ioc.active}
|
||||
<button class="btn-sm btn-delete" on:click={() => deactivate(ioc)}>Deactivate</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide-in panel -->
|
||||
{#if showPanel}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="panel-overlay" on:click={closePanel}></div>
|
||||
<div class="panel" role="dialog" aria-modal="true">
|
||||
<div class="panel-header">
|
||||
<div class="panel-header-text">
|
||||
<div class="panel-title">{editingIoc ? 'Edit IOC' : 'Add IOC'}</div>
|
||||
<div class="panel-subtitle">IOC Management</div>
|
||||
</div>
|
||||
<button class="panel-close" on:click={closePanel} aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<div class="field">
|
||||
<label for="fi-type">IOC Type *</label>
|
||||
<select id="fi-type" bind:value={form.iocType} disabled={!!editingIoc}>
|
||||
<option value="">Select type…</option>
|
||||
{#each IOC_TYPES as t}
|
||||
<option value={t}>{t.replace(/_/g, ' ')}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="fi-value">Value *</label>
|
||||
<input id="fi-value" type="text" bind:value={form.value} placeholder="e.g. atmii.exe or C:/Temp/atr/" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="fi-desc">Description</label>
|
||||
<textarea id="fi-desc" bind:value={form.description} rows="2" placeholder="Describe the IOC"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="fi-ref">Source Reference</label>
|
||||
<input id="fi-ref" type="text" bind:value={form.sourceRef} placeholder="e.g. FBI FLASH AC-000265-TT" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="fi-conf">Confidence</label>
|
||||
<select id="fi-conf" bind:value={form.confidence}>
|
||||
<option value="HIGH">High</option>
|
||||
<option value="MEDIUM">Medium</option>
|
||||
<option value="LOW">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="fi-exp">Expires (optional)</label>
|
||||
<input id="fi-exp" type="date" bind:value={form.expiresAt} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
<button class="btn btn-secondary" on:click={closePanel}>Cancel</button>
|
||||
<button class="btn btn-primary" disabled={submitting} on:click={submit}>
|
||||
{submitting ? 'Saving…' : editingIoc ? 'Update' : 'Add IOC'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page-wrap {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 1rem; gap: 0.75rem;
|
||||
}
|
||||
.toolbar-left { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.toolbar-right { display: flex; gap: 0.5rem; }
|
||||
.count { color: #6b7280; font-size: var(--font-size-body-sm); white-space: nowrap; }
|
||||
|
||||
.type-filter select {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: var(--font-size-caption);
|
||||
background: white;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.inactive-toggle {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-size: var(--font-size-caption); color: #6b7280; cursor: pointer;
|
||||
}
|
||||
.inactive-toggle input { cursor: pointer; }
|
||||
|
||||
.table-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
|
||||
border: 1px solid #e5e7eb;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.table-msg { padding: 2rem; text-align: center; color: #6b7280; font-size: var(--font-size-body-sm); }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead { position: sticky; top: 0; z-index: 5; }
|
||||
th {
|
||||
background: #f9fafb; color: #374151;
|
||||
font-size: 0.78rem; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.3px;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
padding: 0.6rem 0.75rem; text-align: left;
|
||||
}
|
||||
td {
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
color: #1f2937; font-size: var(--font-size-label);
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
tbody tr:hover { background: #eff6ff; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
.inactive-row { opacity: 0.55; }
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.68rem; font-weight: 700;
|
||||
padding: 1px 6px; border-radius: 3px;
|
||||
background: #dbeafe; color: #1e40af;
|
||||
font-family: monospace; white-space: nowrap;
|
||||
}
|
||||
|
||||
.ioc-value {
|
||||
font-family: monospace; font-size: 0.78rem;
|
||||
color: #111827; display: block;
|
||||
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.desc-cell { color: #6b7280; max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.src-cell { color: #6b7280; white-space: nowrap; font-size: var(--font-size-caption); }
|
||||
.date-cell { color: #6b7280; white-space: nowrap; }
|
||||
.conf-badge { font-weight: 700; font-size: var(--font-size-caption); }
|
||||
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.badge-active { background: #dcfce7; color: #166534; }
|
||||
.badge-inactive { background: #f1f5f9; color: #6b7280; }
|
||||
|
||||
.actions { white-space: nowrap; display: flex; gap: 0.4rem; justify-content: flex-end; }
|
||||
.btn-sm { border: none; border-radius: 5px; padding: 0.25rem 0.6rem; cursor: pointer; font-size: 0.78rem; font-weight: 500; }
|
||||
.btn-edit { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }
|
||||
.btn-edit:hover { background: #e5e7eb; }
|
||||
.btn-delete { background: #fee2e2; color: #b91c1c; }
|
||||
.btn-delete:hover { background: #fecaca; }
|
||||
|
||||
/* Panel */
|
||||
.panel-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.35);
|
||||
z-index: 999; cursor: pointer;
|
||||
}
|
||||
.panel {
|
||||
position: fixed; top: 0; right: 0;
|
||||
width: 480px; height: 100vh;
|
||||
background: white; z-index: 1000;
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: -4px 0 20px rgba(0,0,0,0.15);
|
||||
animation: panelIn 0.25s ease; overflow: hidden;
|
||||
}
|
||||
@keyframes panelIn {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
.panel-header {
|
||||
background: linear-gradient(180deg, #081651 0%, #1c49b8 100%);
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex; align-items: center; justify-content: space-between; flex-shrink: 0;
|
||||
}
|
||||
.panel-title { color: white; font-size: 1rem; font-weight: 700; }
|
||||
.panel-subtitle { color: rgba(255,255,255,0.65); font-size: 0.78rem; margin-top: 2px; }
|
||||
.panel-close {
|
||||
background: rgba(255,255,255,0.15); border: none; color: white;
|
||||
width: 28px; height: 28px; border-radius: 50%; cursor: pointer;
|
||||
font-size: 0.85rem; display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; transition: background 0.15s;
|
||||
}
|
||||
.panel-close:hover { background: rgba(255,255,255,0.3); }
|
||||
|
||||
.panel-body {
|
||||
flex: 1; overflow-y: auto; padding: 1.5rem;
|
||||
display: flex; flex-direction: column; gap: 1rem;
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
border-top: 1px solid #e5e7eb; padding: 1rem 1.5rem;
|
||||
display: flex; justify-content: flex-end; gap: 0.75rem;
|
||||
flex-shrink: 0; background: #f9fafb;
|
||||
}
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.field label { font-size: var(--font-size-label); font-weight: 600; color: #374151; }
|
||||
.field input, .field select, .field textarea {
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid #d1d5db; border-radius: 6px;
|
||||
font-size: var(--font-size-body-sm); color: #111827;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field input:focus, .field select:focus, .field textarea:focus {
|
||||
outline: none; border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59,130,246,0.15);
|
||||
}
|
||||
.field textarea { resize: vertical; }
|
||||
.field select:disabled { background: #f3f4f6; color: #6b7280; }
|
||||
|
||||
/* Dark mode */
|
||||
:global(.dark-mode) .form-card { background: #1f2937; border-color: #374151; }
|
||||
:global(.dark-mode) .count { color: #9ca3af; }
|
||||
:global(.dark-mode) .type-filter select { background: #1e293b; border-color: #4b5563; color: #e2e8f0; }
|
||||
:global(.dark-mode) .inactive-toggle { color: #9ca3af; }
|
||||
: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) .ioc-value { color: #e5e7eb; }
|
||||
:global(.dark-mode) .desc-cell { color: #9ca3af; }
|
||||
:global(.dark-mode) .src-cell { color: #9ca3af; }
|
||||
:global(.dark-mode) .date-cell { color: #9ca3af; }
|
||||
:global(.dark-mode) .type-badge { background: #1e3a5f; color: #93c5fd; }
|
||||
:global(.dark-mode) .badge-inactive { background: #1e293b; color: #6b7280; }
|
||||
:global(.dark-mode) .btn-edit { background: #374151; color: #e5e7eb; border-color: #4b5563; }
|
||||
:global(.dark-mode) .btn-edit:hover { background: #4b5563; }
|
||||
:global(.dark-mode) .btn-delete { background: #3b1a1a; color: #f87171; }
|
||||
:global(.dark-mode) .btn-delete:hover { background: #4c1d1d; }
|
||||
:global(.dark-mode) .panel { background: #1e293b; }
|
||||
:global(.dark-mode) .panel-footer { background: #162032; border-color: #334155; }
|
||||
:global(.dark-mode) .field label { color: #e2e8f0; }
|
||||
:global(.dark-mode) .field input, :global(.dark-mode) .field select, :global(.dark-mode) .field textarea {
|
||||
background: #283040; border-color: #4b5563; color: #e2e8f0;
|
||||
}
|
||||
:global(.dark-mode) .table-msg { color: #9ca3af; }
|
||||
</style>
|
||||
@@ -0,0 +1,369 @@
|
||||
<script lang="ts">
|
||||
import { onMount } 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 = false;
|
||||
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();
|
||||
}
|
||||
|
||||
function setSeverity(sev: ThreatSeverity | '') {
|
||||
severityFilter = sev;
|
||||
page = 0;
|
||||
load();
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<div class="status-pills">
|
||||
{#each SEVERITIES as sev}
|
||||
<button
|
||||
class="status-pill"
|
||||
class:active={severityFilter === sev}
|
||||
style={severityFilter === sev ? `background:${SEV_COLOR[sev]};border-color:${SEV_COLOR[sev]}` : `border-left:3px solid ${SEV_COLOR[sev]}`}
|
||||
on:click={() => setSeverity(severityFilter === sev ? '' : sev)}
|
||||
>{sev}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</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>
|
||||
{severityFilter}
|
||||
<button class="filter-tag-remove" on:click={() => setSeverity('')}>×</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(); }}>×</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;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.status-pills { display: flex; flex-direction: column; gap: 4px; }
|
||||
.status-pill { padding: 5px 10px; border-radius: 6px; border: 1px solid #d1d5db; background: white; font-size: var(--font-size-caption); font-weight: 600; cursor: pointer; color: #374151; text-align: left; transition: all 0.15s; }
|
||||
.status-pill.active { color: white !important; }
|
||||
|
||||
.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; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.active-filters-label { font-size: var(--font-size-tiny); font-weight: 600; color: #6b7280; }
|
||||
.filter-tag { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 4px; border-left: 4px solid; background: white; font-size: var(--font-size-tiny); border: 1px solid; }
|
||||
.filter-tag-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
.filter-tag-remove { background: none; border: none; cursor: pointer; color: #9ca3af; font-size: 0.9rem; padding: 0 0 0 2px; line-height: 1; }
|
||||
.filter-tag-remove:hover { color: #374151; }
|
||||
|
||||
/* 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) .status-pill { background: #1e293b; border-color: #334155; color: #94a3b8; }
|
||||
|
||||
: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) .filter-tag { background: #1e293b; }
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
export let serviceName: string = 'HiveOps';
|
||||
export let menuItem: string = '';
|
||||
</script>
|
||||
|
||||
<div class="br-root">
|
||||
<div class="br-card">
|
||||
<div class="br-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="br-title">HiveOps Browser Required</h1>
|
||||
<p class="br-body">
|
||||
This page is only accessible through the HiveOps Browser desktop application.
|
||||
</p>
|
||||
{#if menuItem}
|
||||
<p class="br-hint">
|
||||
Open the app and navigate to <strong>{menuItem}</strong> from the main menu.
|
||||
</p>
|
||||
{/if}
|
||||
<a class="br-btn" href="https://cdn.bcos.cloud/downloads/"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
HiveOps Browser v2.0+
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.br-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: #0f172a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
.br-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 16px;
|
||||
padding: 3rem 2.5rem;
|
||||
max-width: 440px;
|
||||
width: calc(100% - 2rem);
|
||||
text-align: center;
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.br-icon { color: #3b82f6; margin-bottom: 0.25rem; }
|
||||
.br-title { margin: 0; font-size: 1.4rem; font-weight: 700; color: #f1f5f9; line-height: 1.3; }
|
||||
.br-body { margin: 0; font-size: 0.95rem; color: #94a3b8; line-height: 1.6; }
|
||||
.br-hint { margin: 0; font-size: 0.9rem; color: #64748b; line-height: 1.5; }
|
||||
.br-hint strong { color: #93c5fd; font-weight: 600; }
|
||||
.br-btn {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.65rem 1.5rem;
|
||||
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.15s;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
.br-btn:hover { opacity: 0.88; }
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script>
|
||||
export let message = 'Loading...';
|
||||
export let overlay = true;
|
||||
</script>
|
||||
|
||||
{#if overlay}
|
||||
<div class="loading-overlay">
|
||||
<div class="loading-card">
|
||||
<div class="spinner"></div>
|
||||
<span class="loading-message">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="loading-inline">
|
||||
<div class="spinner"></div>
|
||||
<span class="loading-message">{message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
z-index: 10;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.loading-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem 2.5rem;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.loading-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid #e0e0e0;
|
||||
border-top-color: var(--color-primary, #2563eb);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
.loading-message { font-size: var(--font-size-body, 0.95rem); color: #666; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
:global(.dark-mode) .loading-overlay { background: rgba(0, 0, 0, 0.5); }
|
||||
:global(.dark-mode) .loading-card { background: #2a2a2a; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); }
|
||||
:global(.dark-mode) .loading-message { color: #999; }
|
||||
:global(.dark-mode) .spinner { border-color: #444; border-top-color: #5b9aff; }
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let page = 0;
|
||||
export let totalPages = 0;
|
||||
export let totalElements = 0;
|
||||
export let size = 25;
|
||||
export let pageSizeOptions = [25, 50, 100, 200, 500];
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
pageChange: { page: number };
|
||||
pageSizeChange: { size: number };
|
||||
}>();
|
||||
|
||||
$: startItem = totalElements === 0 ? 0 : page * size + 1;
|
||||
$: endItem = Math.min((page + 1) * size, totalElements);
|
||||
|
||||
$: pages = (() => {
|
||||
const result: (number | '...')[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 0; i < totalPages; i++) result.push(i);
|
||||
} else {
|
||||
result.push(0);
|
||||
if (page > 2) result.push('...');
|
||||
for (let i = Math.max(1, page - 1); i <= Math.min(totalPages - 2, page + 1); i++) result.push(i);
|
||||
if (page < totalPages - 3) result.push('...');
|
||||
result.push(totalPages - 1);
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
function goToPage(p: number) {
|
||||
if (p >= 0 && p < totalPages && p !== page) dispatch('pageChange', { page: p });
|
||||
}
|
||||
|
||||
function handleSizeChange(e: Event) {
|
||||
dispatch('pageSizeChange', { size: parseInt((e.target as HTMLSelectElement).value) });
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if totalElements > 0}
|
||||
<div class="pagination">
|
||||
<div class="pagination-info">
|
||||
Showing {startItem}–{endItem} of {totalElements.toLocaleString()}
|
||||
</div>
|
||||
|
||||
<div class="pagination-controls">
|
||||
<button class="page-btn" disabled={page === 0} on:click={() => goToPage(0)} title="First">«</button>
|
||||
<button class="page-btn" disabled={page === 0} on:click={() => goToPage(page - 1)} title="Previous">‹</button>
|
||||
{#each pages as p}
|
||||
{#if p === '...'}
|
||||
<span class="page-ellipsis">...</span>
|
||||
{:else}
|
||||
<button class="page-btn" class:active={p === page} on:click={() => goToPage(Number(p))}>{Number(p) + 1}</button>
|
||||
{/if}
|
||||
{/each}
|
||||
<button class="page-btn" disabled={page >= totalPages - 1} on:click={() => goToPage(page + 1)} title="Next">›</button>
|
||||
<button class="page-btn" disabled={page >= totalPages - 1} on:click={() => goToPage(totalPages - 1)} title="Last">»</button>
|
||||
</div>
|
||||
|
||||
<div class="pagination-size">
|
||||
<label>
|
||||
Items per page:
|
||||
<select value={size} on:change={handleSizeChange}>
|
||||
{#each pageSizeOptions as opt}<option value={opt}>{opt}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: 0.85rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.page-btn:hover:not(:disabled):not(.active) { background: #f3f4f6; border-color: #9ca3af; }
|
||||
.page-btn.active { background: #2563eb; color: white; border-color: #2563eb; }
|
||||
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.page-ellipsis { padding: 0 4px; color: #9ca3af; font-size: 0.85rem; }
|
||||
|
||||
.pagination-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pagination-size label {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.pagination-size select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Dark mode ── */
|
||||
:global(.dark-mode) .pagination { background: #1a1a2e; border-bottom-color: #3f3f5a; }
|
||||
:global(.dark-mode) .pagination-info { color: #9ca3af; }
|
||||
:global(.dark-mode) .page-btn { background: #2a2a3e; border-color: #4b5563; color: #ccc; }
|
||||
:global(.dark-mode) .page-btn:hover:not(:disabled):not(.active) { background: #334155; border-color: #64748b; }
|
||||
:global(.dark-mode) .page-btn.active { background: #2563eb; border-color: #2563eb; color: white; }
|
||||
:global(.dark-mode) .page-ellipsis { color: #64748b; }
|
||||
:global(.dark-mode) .pagination-size label { color: #9ca3af; }
|
||||
:global(.dark-mode) .pagination-size select { background: #2a2a3e; border-color: #4b5563; color: #ccc; }
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let field: string;
|
||||
export let label: string;
|
||||
export let currentSort: string = '';
|
||||
export let currentDirection: 'asc' | 'desc' = 'asc';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
sortChange: { field: string; direction: 'asc' | 'desc' };
|
||||
}>();
|
||||
|
||||
$: isActive = currentSort === field;
|
||||
$: arrow = isActive ? (currentDirection === 'asc' ? '▲' : '▼') : '';
|
||||
|
||||
function handleClick() {
|
||||
dispatch('sortChange', {
|
||||
field,
|
||||
direction: isActive && currentDirection === 'asc' ? 'desc' : 'asc',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<th class="sortable-header" class:active={isActive} on:click={handleClick}>
|
||||
<span class="header-content">
|
||||
{label}
|
||||
<span class="sort-indicator" class:visible={isActive}>{arrow || '▴'}</span>
|
||||
</span>
|
||||
</th>
|
||||
|
||||
<style>
|
||||
.sortable-header { cursor: pointer; user-select: none; }
|
||||
.sortable-header:hover { background: rgba(0, 0, 0, 0.05); }
|
||||
.header-content { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.sort-indicator { font-size: 0.65em; opacity: 0; transition: opacity 0.15s; }
|
||||
.sort-indicator.visible { opacity: 1; }
|
||||
.sortable-header:hover .sort-indicator { opacity: 0.5; }
|
||||
.sortable-header:hover .sort-indicator.visible { opacity: 1; }
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Generic status badge. Pass a `status` string; it maps to a colour class.
|
||||
* Override `colorMap` if your app uses different statuses.
|
||||
*/
|
||||
export let status: string;
|
||||
export let colorMap: Record<string, string> = {};
|
||||
|
||||
const defaultMap: Record<string, string> = {
|
||||
// Generic lifecycle
|
||||
ACTIVE: 'badge-active',
|
||||
INACTIVE: 'badge-inactive',
|
||||
PENDING: 'badge-pending',
|
||||
FAILED: 'badge-failed',
|
||||
ERROR: 'badge-failed',
|
||||
// Fleet tasks
|
||||
COMPLETED: 'badge-active',
|
||||
RUNNING: 'badge-info',
|
||||
QUEUED: 'badge-pending',
|
||||
CANCELLED: 'badge-inactive',
|
||||
// Claims
|
||||
OPEN: 'badge-info',
|
||||
REVIEW: 'badge-review',
|
||||
RESOLVED: 'badge-active',
|
||||
};
|
||||
|
||||
$: cls = colorMap[status] ?? defaultMap[status] ?? 'badge-inactive';
|
||||
</script>
|
||||
|
||||
<span class="badge {cls}">{status}</span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: var(--font-size-tiny);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-active { background: #dcfce7; color: #166534; }
|
||||
.badge-inactive { background: #f1f5f9; color: #64748b; }
|
||||
.badge-pending { background: #fef9c3; color: #854d0e; }
|
||||
.badge-failed { background: #fee2e2; color: #991b1b; }
|
||||
.badge-info { background: #dbeafe; color: #1e40af; }
|
||||
.badge-review { background: #f3e8ff; color: #6b21a8; }
|
||||
|
||||
:global(.dark-mode) .badge-active { background: #14532d; color: #86efac; }
|
||||
:global(.dark-mode) .badge-inactive { background: #1e293b; color: #94a3b8; }
|
||||
:global(.dark-mode) .badge-pending { background: #422006; color: #fde68a; }
|
||||
:global(.dark-mode) .badge-failed { background: #450a0a; color: #fca5a5; }
|
||||
:global(.dark-mode) .badge-info { background: #1e3a5f; color: #93c5fd; }
|
||||
:global(.dark-mode) .badge-review { background: #3b0764; color: #d8b4fe; }
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { toasts } from '../../lib/stores';
|
||||
</script>
|
||||
|
||||
{#if $toasts.length > 0}
|
||||
<div class="toast-container">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div class="toast toast-{toast.type}">
|
||||
<div class="toast-title">{toast.title}</div>
|
||||
{#if toast.message}<div class="toast-message">{toast.message}</div>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||
animation: toastIn 0.2s ease;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@keyframes toastIn {
|
||||
from { transform: translateX(110%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.toast-title { font-size: var(--font-size-body-sm); font-weight: 600; }
|
||||
.toast-message { font-size: var(--font-size-caption); margin-top: 2px; opacity: 0.85; }
|
||||
|
||||
.toast-success { background: #166534; color: white; }
|
||||
.toast-error { background: #991b1b; color: white; }
|
||||
.toast-warning { background: #92400e; color: white; }
|
||||
.toast-info { background: #1e40af; color: white; }
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
import axios from 'axios';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__APP_CONFIG__?: {
|
||||
apiUrl?: string;
|
||||
authUrl?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const API_BASE = window.__APP_CONFIG__?.apiUrl || 'http://localhost:8017';
|
||||
const AUTH_BASE = window.__APP_CONFIG__?.authUrl || 'http://localhost:8082';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
export const authApi = axios.create({
|
||||
baseURL: AUTH_BASE,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
export default api;
|
||||
|
||||
// ── Enums ────────────────────────────────────────────────────────────
|
||||
export type ThreatSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'INFO';
|
||||
export type EventType = 'PHYSICAL' | 'OS_EVENT' | 'FILE_SYSTEM' | 'COMPOSITE';
|
||||
export type AttackPhase = 'PHYSICAL_ACCESS' | 'MALWARE_STAGING' | 'MALWARE_EXECUTION' | 'PERSISTENCE' | 'CLEANUP';
|
||||
export type IocType = 'FILENAME' | 'MD5' | 'REGISTRY_KEY' | 'DIRECTORY' | 'SERVICE_NAME' | 'IP' | 'FILE_PATH';
|
||||
export type IocSource = 'FBI_FLASH' | 'FS_ISAC' | 'MANUAL';
|
||||
export type ConfidenceLevel = 'HIGH' | 'MEDIUM' | 'LOW';
|
||||
|
||||
// ── Entities ─────────────────────────────────────────────────────────
|
||||
export interface ThreatIoc {
|
||||
id: number;
|
||||
iocType: IocType;
|
||||
value: string;
|
||||
description?: string;
|
||||
source: IocSource;
|
||||
sourceRef?: string;
|
||||
confidence: ConfidenceLevel;
|
||||
addedAt: string;
|
||||
expiresAt?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface ThreatEvent {
|
||||
id: number;
|
||||
deviceId?: number;
|
||||
deviceAgentId?: string;
|
||||
eventType: EventType;
|
||||
signalKey: string;
|
||||
matchedIoc?: ThreatIoc;
|
||||
severity: ThreatSeverity;
|
||||
detectedAt: string;
|
||||
attackPhase?: AttackPhase;
|
||||
institutionKey?: string;
|
||||
}
|
||||
|
||||
export interface AriaStats {
|
||||
totalEvents: number;
|
||||
criticalCount: number;
|
||||
highCount: number;
|
||||
activeIocCount: number;
|
||||
}
|
||||
|
||||
export interface PageResponse<T> {
|
||||
content: T[];
|
||||
page: {
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
size: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateIocRequest {
|
||||
iocType: IocType;
|
||||
value: string;
|
||||
description?: string;
|
||||
sourceRef?: string;
|
||||
confidence?: ConfidenceLevel;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
// ── API calls ────────────────────────────────────────────────────────
|
||||
export const ariaApi = {
|
||||
getStats: () =>
|
||||
api.get<AriaStats>('/api/aria/stats'),
|
||||
|
||||
getEvents: (params: { severity?: ThreatSeverity; deviceAgentId?: string; page?: number; size?: number }) =>
|
||||
api.get<PageResponse<ThreatEvent>>('/api/aria/events', { params }),
|
||||
|
||||
getIocs: (params?: { type?: IocType; activeOnly?: boolean }) =>
|
||||
api.get<ThreatIoc[]>('/api/aria/ioc', { params }),
|
||||
|
||||
createIoc: (data: CreateIocRequest) =>
|
||||
api.post<ThreatIoc>('/api/aria/ioc', data),
|
||||
|
||||
updateIoc: (id: number, data: Partial<CreateIocRequest>) =>
|
||||
api.put<ThreatIoc>(`/api/aria/ioc/${id}`, data),
|
||||
|
||||
deactivateIoc: (id: number) =>
|
||||
api.delete(`/api/aria/ioc/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// ── Pagination types (re-exported for components) ────────────────────
|
||||
export type { PageResponse } from './api';
|
||||
|
||||
export interface PaginatedState<T> {
|
||||
content: T[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
page: number;
|
||||
size: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function emptyPaginatedState<T>(): PaginatedState<T> {
|
||||
return { content: [], totalElements: 0, totalPages: 0, page: 0, size: 25, loading: false, error: null };
|
||||
}
|
||||
|
||||
// ── User page size (localStorage-backed) ─────────────────────────────
|
||||
function createUserPageSizeStore(key = 'hiveops_userPageSize') {
|
||||
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null;
|
||||
const { subscribe, set } = writable<number>(stored ? parseInt(stored, 10) : 25);
|
||||
return {
|
||||
subscribe,
|
||||
set: (value: number) => {
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(key, String(value));
|
||||
set(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
export const userPageSize = createUserPageSizeStore();
|
||||
|
||||
// ── Global async state ───────────────────────────────────────────────
|
||||
export const loading = writable(false);
|
||||
export const error = writable<string | null>(null);
|
||||
|
||||
// ── Debounce utility ─────────────────────────────────────────────────
|
||||
export function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
return ((...args: any[]) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
}) as T;
|
||||
}
|
||||
|
||||
// ── User info (populated in App.svelte onMount) ──────────────────────
|
||||
export const userInfo = writable<{ name?: string; email?: string; role?: string } | null>(null);
|
||||
|
||||
// ── Toast notifications ───────────────────────────────────────────────
|
||||
export interface Toast {
|
||||
id: number;
|
||||
type: 'success' | 'error' | 'info' | 'warning';
|
||||
title: string;
|
||||
message?: string;
|
||||
}
|
||||
export const toasts = writable<Toast[]>([]);
|
||||
let _toastId = 0;
|
||||
export function addToast(type: Toast['type'], title: string, message?: string, durationMs = 4000) {
|
||||
const id = ++_toastId;
|
||||
toasts.update(t => [...t, { id, type, title, message }]);
|
||||
setTimeout(() => toasts.update(t => t.filter(x => x.id !== id)), durationMs);
|
||||
}
|
||||
|
||||
// ── Add your domain stores below ─────────────────────────────────────
|
||||
@@ -0,0 +1,8 @@
|
||||
import './app.css'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app')!,
|
||||
})
|
||||
|
||||
export default app
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.svelte"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import sveltePreprocess from 'svelte-preprocess'
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
svelte({
|
||||
preprocess: sveltePreprocess({ typescript: true })
|
||||
})
|
||||
],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
server: {
|
||||
port: 5187,
|
||||
open: true,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.hiveops.aria.controller;
|
||||
|
||||
import com.hiveops.aria.entity.ThreatEvent;
|
||||
import com.hiveops.aria.repository.ThreatEventRepository;
|
||||
import com.hiveops.aria.repository.ThreatIocRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/aria")
|
||||
@RequiredArgsConstructor
|
||||
public class ThreatEventController {
|
||||
|
||||
private final ThreatEventRepository eventRepository;
|
||||
private final ThreatIocRepository iocRepository;
|
||||
|
||||
@GetMapping("/events")
|
||||
@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")
|
||||
public Page<ThreatEvent> getEvents(
|
||||
@RequestParam(required = false) ThreatEvent.ThreatSeverity severity,
|
||||
@RequestParam(required = false) String deviceAgentId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by("detectedAt").descending());
|
||||
|
||||
if (severity != null) {
|
||||
return eventRepository.findBySeverityOrderByDetectedAtDesc(severity, pageable);
|
||||
}
|
||||
if (deviceAgentId != null && !deviceAgentId.isBlank()) {
|
||||
return eventRepository.findByDeviceAgentIdOrderByDetectedAtDesc(deviceAgentId, pageable);
|
||||
}
|
||||
return eventRepository.findAllByOrderByDetectedAtDesc(pageable);
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")
|
||||
public ResponseEntity<Map<String, Object>> getStats() {
|
||||
Instant since24h = Instant.now().minus(24, ChronoUnit.HOURS);
|
||||
Instant since7d = Instant.now().minus(7, ChronoUnit.DAYS);
|
||||
|
||||
long totalEvents = eventRepository.count();
|
||||
long critical24h = eventRepository.findBySeverityOrderByDetectedAtDesc(
|
||||
ThreatEvent.ThreatSeverity.CRITICAL,
|
||||
PageRequest.of(0, Integer.MAX_VALUE)).getTotalElements();
|
||||
long high24h = eventRepository.findBySeverityOrderByDetectedAtDesc(
|
||||
ThreatEvent.ThreatSeverity.HIGH,
|
||||
PageRequest.of(0, Integer.MAX_VALUE)).getTotalElements();
|
||||
long activeIocs = iocRepository.findByActiveTrue().size();
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"totalEvents", totalEvents,
|
||||
"criticalCount", critical24h,
|
||||
"highCount", high24h,
|
||||
"activeIocCount", activeIocs
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user