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:
CarterPerez-dev 2026-02-28 12:13:38 -05:00
parent 0b2035a793
commit cda830a4e5
6 changed files with 880 additions and 97 deletions

View File

@ -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;
}

View File

@ -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'

View File

@ -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'

View File

@ -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;
}

View File

@ -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'

View File

@ -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;
}
}
}