feat(frontend): add shared components and dotted grid background

Severity badge, stat card, alert feed, threat detail panel. Add
severity/accent color tokens and dotted grid pattern on content area.
This commit is contained in:
CarterPerez-dev 2026-02-28 11:36:49 -05:00
parent 3b4f6ce8a6
commit 0b2035a793
11 changed files with 636 additions and 4 deletions

View File

@ -0,0 +1,93 @@
// ===================
// © AngelaMos | 2026
// alert-feed.module.scss
// ===================
@use '@/styles/tokens' as *;
.feed {
display: flex;
flex-direction: column;
background: $bg-surface-100;
border: 1px solid $border-muted;
border-radius: $radius-lg;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-4 $space-5;
border-bottom: 1px solid $border-muted;
}
.title {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $text-default;
margin: 0;
}
.status {
width: 8px;
height: 8px;
border-radius: $radius-full;
}
.connected {
background: $severity-low;
}
.disconnected {
background: $severity-high;
}
.list {
overflow-y: auto;
max-height: 400px;
}
.row {
display: grid;
grid-template-columns: 80px 130px 1fr auto 60px;
gap: $space-3;
align-items: center;
padding: $space-2-5 $space-5;
border-bottom: 1px solid $border-muted;
font-size: $font-size-xs;
&:last-child {
border-bottom: none;
}
}
.time {
color: $text-lighter;
font-variant-numeric: tabular-nums;
}
.ip {
color: $text-light;
font-family: monospace;
}
.path {
color: $text-light;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.score {
color: $text-lighter;
text-align: right;
font-variant-numeric: tabular-nums;
}
.empty {
padding: $space-10;
text-align: center;
color: $text-muted;
font-size: $font-size-sm;
}

View File

@ -0,0 +1,70 @@
// ===================
// © AngelaMos | 2026
// alert-feed.tsx
// ===================
import { useEffect, useRef } from 'react'
import type { WebSocketAlert } from '@/api/types'
import { SeverityBadge } from './severity-badge'
import styles from './alert-feed.module.scss'
interface AlertFeedProps {
alerts: WebSocketAlert[]
isConnected: boolean
maxHeight?: string
}
function formatTime(timestamp: string): string {
return new Date(timestamp).toLocaleTimeString()
}
export function AlertFeed({
alerts,
isConnected,
maxHeight,
}: AlertFeedProps): React.ReactElement {
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTop = 0
}
}, [alerts.length])
return (
<div className={styles.feed}>
<div className={styles.header}>
<h3 className={styles.title}>Live Alerts</h3>
<span
className={`${styles.status} ${isConnected ? styles.connected : styles.disconnected}`}
/>
</div>
<div
ref={listRef}
className={styles.list}
style={maxHeight ? { maxHeight } : undefined}
>
{alerts.length === 0 ? (
<div className={styles.empty}>Waiting for alerts...</div>
) : (
alerts.map((alert, i) => (
<div key={`${alert.timestamp}-${i}`} className={styles.row}>
<span className={styles.time}>
{formatTime(alert.timestamp)}
</span>
<span className={styles.ip}>{alert.source_ip}</span>
<span className={styles.path}>{alert.request_path}</span>
<SeverityBadge
severity={alert.severity as 'HIGH' | 'MEDIUM' | 'LOW'}
/>
<span className={styles.score}>
{alert.threat_score.toFixed(2)}
</span>
</div>
))
)}
</div>
</div>
)
}

View File

@ -1,4 +1,9 @@
/**
* ©AngelaMos | 2026
* index.tsx
*/
// ===================
// © AngelaMos | 2026
// index.tsx
// ===================
export { SeverityBadge } from './severity-badge'
export { StatCard } from './stat-card'
export { AlertFeed } from './alert-feed'
export { ThreatDetail } from './threat-detail'

View File

@ -0,0 +1,33 @@
// ===================
// © AngelaMos | 2026
// severity-badge.module.scss
// ===================
@use '@/styles/tokens' as *;
.badge {
display: inline-flex;
align-items: center;
padding: $space-0-5 $space-2;
border-radius: $radius-full;
font-size: $font-size-2xs;
font-weight: $font-weight-semibold;
letter-spacing: $tracking-wider;
line-height: $line-height-none;
text-transform: uppercase;
}
.high {
color: $severity-high;
background: $severity-high-bg;
}
.medium {
color: $severity-medium;
background: $severity-medium-bg;
}
.low {
color: $severity-low;
background: $severity-low-bg;
}

View File

@ -0,0 +1,20 @@
// ===================
// © AngelaMos | 2026
// severity-badge.tsx
// ===================
import styles from './severity-badge.module.scss'
interface SeverityBadgeProps {
severity: 'HIGH' | 'MEDIUM' | 'LOW'
}
export function SeverityBadge({
severity,
}: SeverityBadgeProps): React.ReactElement {
return (
<span className={`${styles.badge} ${styles[severity.toLowerCase()]}`}>
{severity}
</span>
)
}

View File

@ -0,0 +1,34 @@
// ===================
// © AngelaMos | 2026
// stat-card.module.scss
// ===================
@use '@/styles/tokens' as *;
.card {
display: flex;
flex-direction: column;
gap: $space-1;
padding: $space-5;
background: $bg-surface-100;
border: 1px solid $border-muted;
border-radius: $radius-lg;
}
.value {
font-size: $font-size-3xl;
font-weight: $font-weight-semibold;
color: $text-default;
line-height: $line-height-none;
letter-spacing: $tracking-tight;
}
.label {
font-size: $font-size-sm;
color: $text-lighter;
}
.sublabel {
font-size: $font-size-xs;
color: $text-muted;
}

View File

@ -0,0 +1,26 @@
// ===================
// © AngelaMos | 2026
// stat-card.tsx
// ===================
import styles from './stat-card.module.scss'
interface StatCardProps {
label: string
value: string | number
sublabel?: string
}
export function StatCard({
label,
value,
sublabel,
}: StatCardProps): React.ReactElement {
return (
<div className={styles.card}>
<span className={styles.value}>{value}</span>
<span className={styles.label}>{label}</span>
{sublabel && <span className={styles.sublabel}>{sublabel}</span>}
</div>
)
}

View File

@ -0,0 +1,170 @@
// ===================
// © AngelaMos | 2026
// threat-detail.module.scss
// ===================
@use '@/styles/tokens' as *;
.overlay {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 60%);
z-index: $z-modal;
display: flex;
justify-content: flex-end;
}
.panel {
width: 100%;
max-width: 520px;
height: 100%;
background: $bg-surface-100;
border-left: 1px solid $border-default;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-5 $space-6;
border-bottom: 1px solid $border-muted;
position: sticky;
top: 0;
background: $bg-surface-100;
z-index: 1;
}
.title {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $text-default;
margin: 0;
}
.closeBtn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: none;
border: 1px solid $border-default;
border-radius: $radius-md;
color: $text-light;
cursor: pointer;
transition: background $duration-fast $ease-out;
&:hover {
background: $bg-surface-200;
}
}
.body {
padding: $space-6;
display: flex;
flex-direction: column;
gap: $space-6;
}
.section {
display: flex;
flex-direction: column;
gap: $space-3;
}
.sectionTitle {
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: $text-lighter;
text-transform: uppercase;
letter-spacing: $tracking-wider;
margin: 0;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $space-3;
}
.field {
display: flex;
flex-direction: column;
gap: $space-1;
}
.fieldLabel {
font-size: $font-size-2xs;
color: $text-muted;
}
.fieldValue {
font-size: $font-size-sm;
color: $text-default;
}
.mono {
font-size: $font-size-xs;
color: $text-light;
font-family: monospace;
word-break: break-all;
}
.scores {
display: flex;
flex-direction: column;
gap: $space-2;
}
.scoreRow {
display: grid;
grid-template-columns: 40px 1fr 50px;
gap: $space-3;
align-items: center;
}
.scoreLabel {
font-size: $font-size-xs;
color: $text-lighter;
text-transform: uppercase;
font-weight: $font-weight-medium;
}
.scoreBar {
height: 6px;
background: $bg-surface-300;
border-radius: $radius-full;
overflow: hidden;
}
.scoreFill {
height: 100%;
background: $accent;
border-radius: $radius-full;
transition: width $duration-slow $ease-out;
}
.scoreValue {
font-size: $font-size-xs;
color: $text-light;
text-align: right;
font-variant-numeric: tabular-nums;
}
.rules {
display: flex;
flex-wrap: wrap;
gap: $space-2;
}
.rule {
padding: $space-1 $space-2-5;
background: $accent-muted;
color: $accent;
border-radius: $radius-sm;
font-size: $font-size-2xs;
font-family: monospace;
}

View File

@ -0,0 +1,166 @@
// ===================
// © AngelaMos | 2026
// threat-detail.tsx
// ===================
import { LuX } from 'react-icons/lu'
import type { ThreatEvent } from '@/api/types'
import { SeverityBadge } from './severity-badge'
import styles from './threat-detail.module.scss'
interface ThreatDetailProps {
threat: ThreatEvent | null
onClose: () => void
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString()
}
export function ThreatDetail({
threat,
onClose,
}: ThreatDetailProps): React.ReactElement | null {
if (!threat) return null
return (
<div className={styles.overlay} onClick={onClose} onKeyDown={() => {}}>
<div
className={styles.panel}
onClick={(e) => e.stopPropagation()}
onKeyDown={() => {}}
>
<div className={styles.header}>
<h2 className={styles.title}>Threat Details</h2>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
aria-label="Close"
>
<LuX />
</button>
</div>
<div className={styles.body}>
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Overview</h3>
<div className={styles.grid}>
<div className={styles.field}>
<span className={styles.fieldLabel}>Severity</span>
<SeverityBadge severity={threat.severity} />
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>Threat Score</span>
<span className={styles.fieldValue}>
{threat.threat_score.toFixed(4)}
</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>Detected</span>
<span className={styles.fieldValue}>
{formatDate(threat.created_at)}
</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>Reviewed</span>
<span className={styles.fieldValue}>
{threat.reviewed ? 'Yes' : 'No'}
</span>
</div>
</div>
</section>
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Request</h3>
<div className={styles.grid}>
<div className={styles.field}>
<span className={styles.fieldLabel}>Source IP</span>
<span className={styles.mono}>{threat.source_ip}</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>Method</span>
<span className={styles.fieldValue}>
{threat.request_method}
</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>Path</span>
<span className={styles.mono}>{threat.request_path}</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>Status</span>
<span className={styles.fieldValue}>
{threat.status_code}
</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>Response Size</span>
<span className={styles.fieldValue}>
{threat.response_size} B
</span>
</div>
<div className={styles.field}>
<span className={styles.fieldLabel}>User Agent</span>
<span className={styles.mono}>{threat.user_agent}</span>
</div>
</div>
</section>
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Component Scores</h3>
<div className={styles.scores}>
{Object.entries(threat.component_scores).map(([key, val]) => (
<div key={key} className={styles.scoreRow}>
<span className={styles.scoreLabel}>{key}</span>
<div className={styles.scoreBar}>
<div
className={styles.scoreFill}
style={{ width: `${Math.min(val * 100, 100)}%` }}
/>
</div>
<span className={styles.scoreValue}>{val.toFixed(3)}</span>
</div>
))}
</div>
</section>
{threat.geo.country && (
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Geolocation</h3>
<div className={styles.grid}>
<div className={styles.field}>
<span className={styles.fieldLabel}>Country</span>
<span className={styles.fieldValue}>
{threat.geo.country}
</span>
</div>
{threat.geo.city && (
<div className={styles.field}>
<span className={styles.fieldLabel}>City</span>
<span className={styles.fieldValue}>
{threat.geo.city}
</span>
</div>
)}
</div>
</section>
)}
{threat.matched_rules && threat.matched_rules.length > 0 && (
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Matched Rules</h3>
<div className={styles.rules}>
{threat.matched_rules.map((rule) => (
<span key={rule} className={styles.rule}>
{rule}
</span>
))}
</div>
</section>
)}
</div>
</div>
</div>
)
}

View File

@ -299,6 +299,8 @@ $header-height: 56px;
.content {
flex: 1;
overflow-y: auto;
background-image: radial-gradient(circle, $border-muted 1px, transparent 1px);
background-size: 24px 24px;
}
.loading {

View File

@ -109,6 +109,19 @@ $text-muted: hsl(0, 0%, 30.2%);
$error-default: hsl(0, 72%, 51%);
$error-light: hsl(0, 72%, 65%);
$severity-high: hsl(0, 72%, 51%);
$severity-high-bg: hsl(0, 72%, 15%);
$severity-medium: hsl(38, 92%, 50%);
$severity-medium-bg: hsl(38, 92%, 15%);
$severity-low: hsl(142, 71%, 45%);
$severity-low-bg: hsl(142, 71%, 15%);
$accent: hsl(217, 91%, 60%);
$accent-muted: hsl(217, 91%, 20%);
$success: hsl(142, 71%, 45%);
$success-bg: hsl(142, 71%, 15%);
// ============================================================================
// BORDER RADIUS
// ============================================================================