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:
parent
3b4f6ce8a6
commit
0b2035a793
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue