feat(frontend): build dashboard, threats, and models pages
Dashboard: stat cards, severity bar, live alert feed, top IPs/paths. Threats: filtered paginated table with detail slide-in panel. Models: status banner, model cards with metrics, retrain button.
This commit is contained in:
parent
0b2035a793
commit
cda830a4e5
|
|
@ -6,147 +6,178 @@
|
||||||
@use '@/styles' as *;
|
@use '@/styles' as *;
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: calc(100vh - 56px);
|
|
||||||
padding: $space-6;
|
padding: $space-6;
|
||||||
background-color: $bg-default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
margin-bottom: $space-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: $font-size-2xl;
|
|
||||||
font-weight: $font-weight-semibold;
|
|
||||||
color: $text-default;
|
|
||||||
margin-bottom: $space-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: $font-size-sm;
|
|
||||||
color: $text-lighter;
|
|
||||||
}
|
|
||||||
|
|
||||||
.userCard {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
gap: $space-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
@include flex-center;
|
||||||
|
height: 300px;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: $space-4;
|
gap: $space-4;
|
||||||
|
|
||||||
|
@include breakpoint-down('lg') {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint-down('sm') {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.severitySection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-3;
|
||||||
padding: $space-5;
|
padding: $space-5;
|
||||||
background: $bg-surface-100;
|
background: $bg-surface-100;
|
||||||
border: 1px solid $border-default;
|
border: 1px solid $border-muted;
|
||||||
border-radius: $radius-lg;
|
border-radius: $radius-lg;
|
||||||
margin-bottom: $space-8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.severityBar {
|
||||||
width: 56px;
|
display: flex;
|
||||||
height: 56px;
|
height: 12px;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $bg-surface-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severityBarEmpty {
|
||||||
|
height: 12px;
|
||||||
border-radius: $radius-full;
|
border-radius: $radius-full;
|
||||||
background: $bg-surface-300;
|
background: $bg-surface-300;
|
||||||
color: $text-default;
|
|
||||||
font-size: $font-size-xl;
|
|
||||||
font-weight: $font-weight-semibold;
|
|
||||||
@include flex-center;
|
@include flex-center;
|
||||||
flex-shrink: 0;
|
font-size: $font-size-2xs;
|
||||||
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.userInfo {
|
.severityHigh {
|
||||||
@include flex-column;
|
background: $severity-high;
|
||||||
gap: $space-1;
|
transition: width $duration-slow $ease-out;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.userName {
|
.severityMedium {
|
||||||
font-size: $font-size-base;
|
background: $severity-medium;
|
||||||
font-weight: $font-weight-medium;
|
transition: width $duration-slow $ease-out;
|
||||||
color: $text-default;
|
|
||||||
@include truncate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.userEmail {
|
.severityLow {
|
||||||
font-size: $font-size-sm;
|
background: $severity-low;
|
||||||
color: $text-light;
|
transition: width $duration-slow $ease-out;
|
||||||
@include truncate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.userRole {
|
.legend {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-self: flex-start;
|
gap: $space-5;
|
||||||
padding: $space-0-5 $space-2;
|
}
|
||||||
background: $bg-surface-200;
|
|
||||||
border-radius: $radius-full;
|
.legendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-2;
|
||||||
font-size: $font-size-xs;
|
font-size: $font-size-xs;
|
||||||
color: $text-lighter;
|
color: $text-lighter;
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: $tracking-wide;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.legendDot {
|
||||||
margin-bottom: $space-8;
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: $radius-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.dotHigh {
|
||||||
font-size: $font-size-sm;
|
background: $severity-high;
|
||||||
font-weight: $font-weight-medium;
|
|
||||||
color: $text-light;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: $tracking-wide;
|
|
||||||
margin-bottom: $space-4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.dotMedium {
|
||||||
|
background: $severity-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dotLow {
|
||||||
|
background: $severity-low;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: $space-4;
|
gap: $space-4;
|
||||||
|
|
||||||
@include breakpoint-up('md') {
|
@include breakpoint-down('lg') {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.lists {
|
||||||
padding: $space-4;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankedList {
|
||||||
|
padding: $space-5;
|
||||||
background: $bg-surface-100;
|
background: $bg-surface-100;
|
||||||
border: 1px solid $border-default;
|
border: 1px solid $border-muted;
|
||||||
border-radius: $radius-lg;
|
border-radius: $radius-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hookName {
|
.rankedTitle {
|
||||||
display: inline-block;
|
|
||||||
font-size: $font-size-sm;
|
font-size: $font-size-sm;
|
||||||
font-weight: $font-weight-medium;
|
font-weight: $font-weight-medium;
|
||||||
color: $text-default;
|
color: $text-default;
|
||||||
background: $bg-surface-200;
|
margin: 0 0 $space-3;
|
||||||
padding: $space-1 $space-2;
|
|
||||||
border-radius: $radius-sm;
|
|
||||||
margin-bottom: $space-2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.rankedItems {
|
||||||
font-size: $font-size-sm;
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankedItem {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: $space-1-5 0;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rankedLabel {
|
||||||
color: $text-light;
|
color: $text-light;
|
||||||
margin-bottom: $space-2;
|
font-family: monospace;
|
||||||
line-height: $line-height-relaxed;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file {
|
.rankedCount {
|
||||||
|
color: $text-lighter;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: $space-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
font-size: $font-size-xs;
|
font-size: $font-size-xs;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
font-family: $font-mono;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.wsError {
|
||||||
@include flex-column;
|
padding: $space-3 $space-4;
|
||||||
gap: $space-2;
|
background: $severity-high-bg;
|
||||||
padding-left: $space-5;
|
border: 1px solid $severity-high;
|
||||||
list-style: disc;
|
border-radius: $radius-md;
|
||||||
|
font-size: $font-size-xs;
|
||||||
li {
|
color: $severity-high;
|
||||||
font-size: $font-size-sm;
|
|
||||||
color: $text-light;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,166 @@
|
||||||
// index.tsx
|
// index.tsx
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
|
import { useStats, useModelStatus, useAlerts } from '@/api/hooks'
|
||||||
|
import { StatCard, AlertFeed } from '@/components'
|
||||||
|
import styles from './dashboard.module.scss'
|
||||||
|
|
||||||
|
function SeverityBar({
|
||||||
|
high,
|
||||||
|
medium,
|
||||||
|
low,
|
||||||
|
}: {
|
||||||
|
high: number
|
||||||
|
medium: number
|
||||||
|
low: number
|
||||||
|
}): React.ReactElement {
|
||||||
|
const total = high + medium + low
|
||||||
|
if (total === 0) {
|
||||||
|
return <div className={styles.severityBarEmpty}>No threats detected</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.severityBar}>
|
||||||
|
<div
|
||||||
|
className={styles.severityHigh}
|
||||||
|
style={{ width: `${(high / total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={styles.severityMedium}
|
||||||
|
style={{ width: `${(medium / total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={styles.severityLow}
|
||||||
|
style={{ width: `${(low / total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeverityLegend({
|
||||||
|
high,
|
||||||
|
medium,
|
||||||
|
low,
|
||||||
|
}: {
|
||||||
|
high: number
|
||||||
|
medium: number
|
||||||
|
low: number
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={styles.legend}>
|
||||||
|
<span className={styles.legendItem}>
|
||||||
|
<span className={`${styles.legendDot} ${styles.dotHigh}`} />
|
||||||
|
High: {high}
|
||||||
|
</span>
|
||||||
|
<span className={styles.legendItem}>
|
||||||
|
<span className={`${styles.legendDot} ${styles.dotMedium}`} />
|
||||||
|
Medium: {medium}
|
||||||
|
</span>
|
||||||
|
<span className={styles.legendItem}>
|
||||||
|
<span className={`${styles.legendDot} ${styles.dotLow}`} />
|
||||||
|
Low: {low}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RankedList({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
items: { label: string; count: number }[]
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={styles.rankedList}>
|
||||||
|
<h3 className={styles.rankedTitle}>{title}</h3>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<span className={styles.emptyText}>None</span>
|
||||||
|
) : (
|
||||||
|
<ol className={styles.rankedItems}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.label} className={styles.rankedItem}>
|
||||||
|
<span className={styles.rankedLabel}>{item.label}</span>
|
||||||
|
<span className={styles.rankedCount}>{item.count}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Component(): React.ReactElement {
|
export function Component(): React.ReactElement {
|
||||||
return <div>Dashboard</div>
|
const { data: stats, isLoading: statsLoading } = useStats()
|
||||||
|
const { data: modelStatus } = useModelStatus()
|
||||||
|
const { alerts, isConnected, connectionError } = useAlerts()
|
||||||
|
|
||||||
|
if (statsLoading || !stats) {
|
||||||
|
return <div className={styles.loading}>Loading dashboard...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const { severity_breakdown: sb } = stats
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<div className={styles.statRow}>
|
||||||
|
<StatCard
|
||||||
|
label="Threats Detected"
|
||||||
|
value={stats.threats_detected}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Threats Stored"
|
||||||
|
value={stats.threats_stored}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="High Severity"
|
||||||
|
value={sb.high}
|
||||||
|
sublabel={`of ${stats.threats_detected} total`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Detection Mode"
|
||||||
|
value={modelStatus?.detection_mode ?? '...'}
|
||||||
|
sublabel={
|
||||||
|
modelStatus?.models_loaded ? 'Models loaded' : 'Rules only'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.severitySection}>
|
||||||
|
<SeverityBar high={sb.high} medium={sb.medium} low={sb.low} />
|
||||||
|
<SeverityLegend high={sb.high} medium={sb.medium} low={sb.low} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.bottomRow}>
|
||||||
|
<AlertFeed
|
||||||
|
alerts={alerts}
|
||||||
|
isConnected={isConnected}
|
||||||
|
maxHeight="360px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.lists}>
|
||||||
|
<RankedList
|
||||||
|
title="Top Source IPs"
|
||||||
|
items={stats.top_source_ips.map((ip) => ({
|
||||||
|
label: ip.source_ip,
|
||||||
|
count: ip.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<RankedList
|
||||||
|
title="Top Attacked Paths"
|
||||||
|
items={stats.top_attacked_paths.map((p) => ({
|
||||||
|
label: p.path,
|
||||||
|
count: p.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connectionError && (
|
||||||
|
<div className={styles.wsError}>{connectionError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.displayName = 'DashboardPage'
|
Component.displayName = 'DashboardPage'
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,100 @@
|
||||||
// index.tsx
|
// index.tsx
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
|
import { LuRefreshCw } from 'react-icons/lu'
|
||||||
|
import { useModelStatus, useRetrain } from '@/api/hooks'
|
||||||
|
import type { ActiveModel } from '@/api/types'
|
||||||
|
import styles from './models.module.scss'
|
||||||
|
|
||||||
|
function ModelCard({ model }: { model: ActiveModel }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={styles.modelCard}>
|
||||||
|
<div className={styles.modelHeader}>
|
||||||
|
<span className={styles.modelType}>{model.model_type}</span>
|
||||||
|
<span className={styles.modelVersion}>v{model.version}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.modelStats}>
|
||||||
|
<div className={styles.stat}>
|
||||||
|
<span className={styles.statLabel}>Training Samples</span>
|
||||||
|
<span className={styles.statValue}>
|
||||||
|
{model.training_samples.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{model.threshold !== null && (
|
||||||
|
<div className={styles.stat}>
|
||||||
|
<span className={styles.statLabel}>Threshold</span>
|
||||||
|
<span className={styles.statValue}>
|
||||||
|
{model.threshold.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(model.metrics).length > 0 && (
|
||||||
|
<div className={styles.metrics}>
|
||||||
|
<span className={styles.metricsTitle}>Metrics</span>
|
||||||
|
{Object.entries(model.metrics).map(([key, val]) => (
|
||||||
|
<div key={key} className={styles.metricRow}>
|
||||||
|
<span className={styles.metricKey}>{key}</span>
|
||||||
|
<span className={styles.metricVal}>{val.toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Component(): React.ReactElement {
|
export function Component(): React.ReactElement {
|
||||||
return <div>Models</div>
|
const { data: status, isLoading } = useModelStatus()
|
||||||
|
const retrain = useRetrain()
|
||||||
|
|
||||||
|
if (isLoading || !status) {
|
||||||
|
return <div className={styles.loading}>Loading model status...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = status.models_loaded
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<div
|
||||||
|
className={`${styles.banner} ${loaded ? styles.bannerLoaded : styles.bannerNotLoaded}`}
|
||||||
|
>
|
||||||
|
<span className={styles.bannerText}>
|
||||||
|
{loaded
|
||||||
|
? `Models Loaded — ${status.detection_mode} mode`
|
||||||
|
: `Models Not Loaded — ${status.detection_mode} mode`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.retrainBtn}
|
||||||
|
disabled={retrain.isPending}
|
||||||
|
onClick={() => retrain.mutate()}
|
||||||
|
>
|
||||||
|
<LuRefreshCw
|
||||||
|
className={retrain.isPending ? styles.spinning : ''}
|
||||||
|
/>
|
||||||
|
{retrain.isPending ? 'Retraining...' : 'Retrain Models'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.active_models.length === 0 ? (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
No trained models found. Click Retrain to start training.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{status.active_models.map((model) => (
|
||||||
|
<ModelCard key={model.model_type} model={model} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.displayName = 'ModelsPage'
|
Component.displayName = 'ModelsPage'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2026
|
||||||
|
// models.module.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@use '@/styles' as *;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: $space-6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
@include flex-center;
|
||||||
|
height: 300px;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: $space-4 $space-5;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerLoaded {
|
||||||
|
background: $severity-low-bg;
|
||||||
|
border: 1px solid $severity-low;
|
||||||
|
color: $severity-low;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerNotLoaded {
|
||||||
|
background: $severity-medium-bg;
|
||||||
|
border: 1px solid $severity-medium;
|
||||||
|
color: $severity-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bannerText {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retrainBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-2;
|
||||||
|
padding: $space-2-5 $space-4;
|
||||||
|
background: $accent-muted;
|
||||||
|
border: 1px solid $accent;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
color: $accent;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
cursor: pointer;
|
||||||
|
@include transition-fast;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include hover {
|
||||||
|
background: $accent;
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinning {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
@include flex-center;
|
||||||
|
height: 200px;
|
||||||
|
background: $bg-surface-100;
|
||||||
|
border: 1px solid $border-muted;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
color: $text-muted;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: $space-4;
|
||||||
|
|
||||||
|
@include breakpoint-down('lg') {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint-down('sm') {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelCard {
|
||||||
|
padding: $space-5;
|
||||||
|
background: $bg-surface-100;
|
||||||
|
border: 1px solid $border-muted;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelType {
|
||||||
|
font-size: $font-size-base;
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
color: $text-default;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelVersion {
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $text-lighter;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelStats {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-0-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: $font-size-2xs;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $text-default;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-1-5;
|
||||||
|
padding-top: $space-3;
|
||||||
|
border-top: 1px solid $border-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricsTitle {
|
||||||
|
font-size: $font-size-2xs;
|
||||||
|
color: $text-muted;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
margin-bottom: $space-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricKey {
|
||||||
|
color: $text-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricVal {
|
||||||
|
color: $text-light;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,154 @@
|
||||||
// index.tsx
|
// index.tsx
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useThreats } from '@/api/hooks'
|
||||||
|
import type { ThreatEvent } from '@/api/types'
|
||||||
|
import { SeverityBadge, ThreatDetail } from '@/components'
|
||||||
|
import { PAGINATION } from '@/config'
|
||||||
|
import styles from './threats.module.scss'
|
||||||
|
|
||||||
|
type SeverityFilter = 'ALL' | 'HIGH' | 'MEDIUM' | 'LOW'
|
||||||
|
|
||||||
|
function formatTime(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
export function Component(): React.ReactElement {
|
export function Component(): React.ReactElement {
|
||||||
return <div>Threats</div>
|
const [offset, setOffset] = useState(0)
|
||||||
|
const [severity, setSeverity] = useState<SeverityFilter>('ALL')
|
||||||
|
const [sourceIp, setSourceIp] = useState('')
|
||||||
|
const [selectedThreat, setSelectedThreat] = useState<ThreatEvent | null>(null)
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
limit: PAGINATION.DEFAULT_LIMIT,
|
||||||
|
offset,
|
||||||
|
...(severity !== 'ALL' && { severity }),
|
||||||
|
...(sourceIp && { source_ip: sourceIp }),
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading } = useThreats(params)
|
||||||
|
|
||||||
|
const total = data?.total ?? 0
|
||||||
|
const items = data?.items ?? []
|
||||||
|
const showing = Math.min(offset + PAGINATION.DEFAULT_LIMIT, total)
|
||||||
|
const hasPrev = offset > 0
|
||||||
|
const hasNext = offset + PAGINATION.DEFAULT_LIMIT < total
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={severity}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSeverity(e.target.value as SeverityFilter)
|
||||||
|
setOffset(0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="ALL">All Severities</option>
|
||||||
|
<option value="HIGH">High</option>
|
||||||
|
<option value="MEDIUM">Medium</option>
|
||||||
|
<option value="LOW">Low</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter by source IP..."
|
||||||
|
value={sourceIp}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSourceIp(e.target.value)
|
||||||
|
setOffset(0)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Source IP</th>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>Path</th>
|
||||||
|
<th>Score</th>
|
||||||
|
<th>Severity</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{isLoading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className={styles.loadingCell}>
|
||||||
|
Loading threats...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className={styles.emptyCell}>
|
||||||
|
No threats found matching your filters
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
items.map((threat) => (
|
||||||
|
<tr
|
||||||
|
key={threat.id}
|
||||||
|
className={styles.row}
|
||||||
|
onClick={() => setSelectedThreat(threat)}
|
||||||
|
>
|
||||||
|
<td className={styles.timeCell}>
|
||||||
|
{formatTime(threat.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className={styles.monoCell}>{threat.source_ip}</td>
|
||||||
|
<td>{threat.request_method}</td>
|
||||||
|
<td className={styles.pathCell}>{threat.request_path}</td>
|
||||||
|
<td className={styles.scoreCell}>
|
||||||
|
{threat.threat_score.toFixed(3)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<SeverityBadge severity={threat.severity} />
|
||||||
|
</td>
|
||||||
|
<td>{threat.status_code}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<span className={styles.paginationInfo}>
|
||||||
|
{total === 0
|
||||||
|
? 'No results'
|
||||||
|
: `${offset + 1}–${showing} of ${total}`}
|
||||||
|
</span>
|
||||||
|
<div className={styles.paginationButtons}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.pageBtn}
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={() => setOffset(offset - PAGINATION.DEFAULT_LIMIT)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.pageBtn}
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={() => setOffset(offset + PAGINATION.DEFAULT_LIMIT)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ThreatDetail
|
||||||
|
threat={selectedThreat}
|
||||||
|
onClose={() => setSelectedThreat(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.displayName = 'ThreatsPage'
|
Component.displayName = 'ThreatsPage'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
// ===================
|
||||||
|
// © AngelaMos | 2026
|
||||||
|
// threats.module.scss
|
||||||
|
// ===================
|
||||||
|
|
||||||
|
@use '@/styles' as *;
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: $space-6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-3;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select,
|
||||||
|
.input {
|
||||||
|
padding: $space-2 $space-3;
|
||||||
|
background: $bg-surface-100;
|
||||||
|
border: 1px solid $border-default;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
color: $text-default;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
outline: none;
|
||||||
|
@include transition-fast;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $accent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: $bg-surface-100;
|
||||||
|
border: 1px solid $border-muted;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: $space-3 $space-4;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
color: $text-lighter;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: $tracking-wider;
|
||||||
|
border-bottom: 1px solid $border-default;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: $space-2-5 $space-4;
|
||||||
|
border-bottom: 1px solid $border-muted;
|
||||||
|
color: $text-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
cursor: pointer;
|
||||||
|
@include transition-fast;
|
||||||
|
|
||||||
|
@include hover {
|
||||||
|
background: $bg-surface-200;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeCell {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
color: $text-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monoCell {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pathCell {
|
||||||
|
max-width: 250px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: $font-size-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreCell {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingCell,
|
||||||
|
.emptyCell {
|
||||||
|
text-align: center;
|
||||||
|
padding: $space-10 $space-4;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationInfo {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $text-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paginationButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageBtn {
|
||||||
|
padding: $space-2 $space-4;
|
||||||
|
background: $bg-surface-100;
|
||||||
|
border: 1px solid $border-default;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
color: $text-light;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
@include transition-fast;
|
||||||
|
|
||||||
|
@include hover {
|
||||||
|
background: $bg-surface-200;
|
||||||
|
color: $text-default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $bg-surface-100;
|
||||||
|
color: $text-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue