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 *;
|
||||
|
||||
.page {
|
||||
min-height: calc(100vh - 56px);
|
||||
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;
|
||||
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;
|
||||
|
||||
@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;
|
||||
background: $bg-surface-100;
|
||||
border: 1px solid $border-default;
|
||||
border: 1px solid $border-muted;
|
||||
border-radius: $radius-lg;
|
||||
margin-bottom: $space-8;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
.severityBar {
|
||||
display: flex;
|
||||
height: 12px;
|
||||
border-radius: $radius-full;
|
||||
overflow: hidden;
|
||||
background: $bg-surface-300;
|
||||
}
|
||||
|
||||
.severityBarEmpty {
|
||||
height: 12px;
|
||||
border-radius: $radius-full;
|
||||
background: $bg-surface-300;
|
||||
color: $text-default;
|
||||
font-size: $font-size-xl;
|
||||
font-weight: $font-weight-semibold;
|
||||
@include flex-center;
|
||||
flex-shrink: 0;
|
||||
font-size: $font-size-2xs;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
@include flex-column;
|
||||
gap: $space-1;
|
||||
min-width: 0;
|
||||
.severityHigh {
|
||||
background: $severity-high;
|
||||
transition: width $duration-slow $ease-out;
|
||||
}
|
||||
|
||||
.userName {
|
||||
font-size: $font-size-base;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-default;
|
||||
@include truncate;
|
||||
.severityMedium {
|
||||
background: $severity-medium;
|
||||
transition: width $duration-slow $ease-out;
|
||||
}
|
||||
|
||||
.userEmail {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-light;
|
||||
@include truncate;
|
||||
.severityLow {
|
||||
background: $severity-low;
|
||||
transition: width $duration-slow $ease-out;
|
||||
}
|
||||
|
||||
.userRole {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
padding: $space-0-5 $space-2;
|
||||
background: $bg-surface-200;
|
||||
border-radius: $radius-full;
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: $space-5;
|
||||
}
|
||||
|
||||
.legendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
font-size: $font-size-xs;
|
||||
color: $text-lighter;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: $tracking-wide;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: $space-8;
|
||||
.legendDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: $radius-full;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-light;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: $tracking-wide;
|
||||
margin-bottom: $space-4;
|
||||
.dotHigh {
|
||||
background: $severity-high;
|
||||
}
|
||||
|
||||
.grid {
|
||||
.dotMedium {
|
||||
background: $severity-medium;
|
||||
}
|
||||
|
||||
.dotLow {
|
||||
background: $severity-low;
|
||||
}
|
||||
|
||||
.bottomRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $space-4;
|
||||
|
||||
@include breakpoint-up('md') {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@include breakpoint-down('lg') {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: $space-4;
|
||||
.lists {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-4;
|
||||
}
|
||||
|
||||
.rankedList {
|
||||
padding: $space-5;
|
||||
background: $bg-surface-100;
|
||||
border: 1px solid $border-default;
|
||||
border: 1px solid $border-muted;
|
||||
border-radius: $radius-lg;
|
||||
}
|
||||
|
||||
.hookName {
|
||||
display: inline-block;
|
||||
.rankedTitle {
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $text-default;
|
||||
background: $bg-surface-200;
|
||||
padding: $space-1 $space-2;
|
||||
border-radius: $radius-sm;
|
||||
margin-bottom: $space-2;
|
||||
margin: 0 0 $space-3;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: $font-size-sm;
|
||||
.rankedItems {
|
||||
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;
|
||||
margin-bottom: $space-2;
|
||||
line-height: $line-height-relaxed;
|
||||
font-family: monospace;
|
||||
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;
|
||||
color: $text-muted;
|
||||
font-family: $font-mono;
|
||||
}
|
||||
|
||||
.list {
|
||||
@include flex-column;
|
||||
gap: $space-2;
|
||||
padding-left: $space-5;
|
||||
list-style: disc;
|
||||
|
||||
li {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-light;
|
||||
}
|
||||
.wsError {
|
||||
padding: $space-3 $space-4;
|
||||
background: $severity-high-bg;
|
||||
border: 1px solid $severity-high;
|
||||
border-radius: $radius-md;
|
||||
font-size: $font-size-xs;
|
||||
color: $severity-high;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,166 @@
|
|||
// 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 {
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -3,8 +3,100 @@
|
|||
// 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 {
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ===================
|
||||
|
||||
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 {
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -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