feat(web/dashboard): reskin with richer live-activity panels
Rewrites Dashboard.tsx around three stacked panels — live interactions, deckies-under-siege, and top-attackers — each with its own header, empty state, and status accents. Dashboard.css fills in the supporting grid + type system.
This commit is contained in:
@@ -1,52 +1,203 @@
|
|||||||
.dashboard {
|
.dashboard {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 32px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Page header */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 16px;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title-group h1 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-sub {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chips */
|
||||||
|
.chip {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-tint-10);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.violet {
|
||||||
|
border-color: var(--violet);
|
||||||
|
color: var(--violet);
|
||||||
|
background: var(--violet-tint-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.matrix {
|
||||||
|
border-color: var(--matrix);
|
||||||
|
color: var(--matrix);
|
||||||
|
background: var(--matrix-tint-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.dim-chip {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: rgba(0, 255, 65, 0.6);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.alert-chip {
|
||||||
|
border-color: var(--alert);
|
||||||
|
color: var(--alert);
|
||||||
|
background: rgba(255, 65, 65, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breach banner */
|
||||||
|
.breach-banner {
|
||||||
|
background: rgba(255, 65, 65, 0.1);
|
||||||
|
border: 1px solid var(--alert);
|
||||||
|
padding: 12px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: var(--alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breach-banner .pulse {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: var(--alert);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: decnet-pulse 0.7s infinite alternate;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breach-banner button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--alert);
|
||||||
|
color: var(--alert);
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breach-banner button:hover {
|
||||||
|
background: rgba(255, 65, 65, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 24px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background-color: var(--secondary-color);
|
background-color: var(--secondary-color);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
padding: 24px;
|
padding: 16px 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 10px;
|
||||||
transition: all 0.3s ease;
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card:hover {
|
||||||
border-color: var(--text-color);
|
border-color: var(--accent);
|
||||||
box-shadow: var(--matrix-green-glow);
|
box-shadow: var(--accent-glow);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon {
|
.stat-card.alert {
|
||||||
color: var(--accent-color);
|
border-color: rgba(255, 65, 65, 0.4);
|
||||||
filter: drop-shadow(var(--violet-glow));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-content {
|
.stat-card .row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.7rem;
|
font-size: 0.65rem;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-value .dim {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-delta {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
opacity: 0.8;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-delta.up {
|
||||||
|
color: var(--alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sparkline */
|
||||||
|
.spark {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
height: 22px;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spark span {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.5;
|
||||||
|
min-height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spark.alert span {
|
||||||
|
background: var(--alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section header (logs, panels) */
|
||||||
.logs-section {
|
.logs-section {
|
||||||
background-color: var(--secondary-color);
|
background-color: var(--secondary-color);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -55,20 +206,147 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
padding: 16px 24px;
|
padding: 14px 20px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.section-header h2 {
|
.section-header h2 {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dashboard grid */
|
||||||
|
.dash-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attacker/siege rows */
|
||||||
|
.attacker-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attacker-row:hover {
|
||||||
|
background: rgba(0, 255, 65, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attacker-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attacker-bar-wrap {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(48, 54, 61, 0.5);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attacker-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attacker-bar.hot {
|
||||||
|
background: var(--alert);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-empty {
|
||||||
|
padding: 20px 16px;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.4;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status dots (hot / warn / active) */
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.active {
|
||||||
|
background: var(--matrix);
|
||||||
|
box-shadow: 0 0 8px var(--matrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.warn {
|
||||||
|
background: #ffaa00;
|
||||||
|
box-shadow: 0 0 6px rgba(255, 170, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.hot {
|
||||||
|
background: var(--alert);
|
||||||
|
box-shadow: 0 0 8px var(--alert);
|
||||||
|
animation: decnet-pulse 1s infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row-enter animation */
|
||||||
|
@keyframes row-enter {
|
||||||
|
from {
|
||||||
|
background: rgba(0, 255, 65, 0.2);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-enter {
|
||||||
|
animation: row-enter 0.6s var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logs table (existing) */
|
||||||
.logs-table-container {
|
.logs-table-container {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logs-table {
|
.logs-table {
|
||||||
@@ -79,14 +357,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logs-table th {
|
.logs-table th {
|
||||||
padding: 12px 24px;
|
padding: 12px 20px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--secondary-color);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logs-table td {
|
.logs-table td {
|
||||||
padding: 12px 24px;
|
padding: 10px 20px;
|
||||||
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
|
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +410,7 @@
|
|||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Attacker Profiles */
|
/* Attacker Profiles (Attackers page) */
|
||||||
.attacker-grid {
|
.attacker-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
@@ -146,8 +428,8 @@
|
|||||||
|
|
||||||
.attacker-card:hover {
|
.attacker-card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
border-color: var(--text-color);
|
border-color: var(--accent);
|
||||||
box-shadow: var(--matrix-green-glow);
|
box-shadow: var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.traversal-badge {
|
.traversal-badge {
|
||||||
@@ -182,8 +464,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.back-button:hover {
|
.back-button:hover {
|
||||||
border-color: var(--text-color);
|
border-color: var(--accent);
|
||||||
box-shadow: var(--matrix-green-glow);
|
box-shadow: var(--accent-glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fingerprint cards */
|
/* Fingerprint cards */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
import { Shield, Users, Activity, Clock, Paperclip } from 'lucide-react';
|
import { Shield, Users, Activity, Clock, Paperclip, Crosshair, Flame, Archive } from 'lucide-react';
|
||||||
import { parseEventBody } from '../utils/parseEventBody';
|
import { parseEventBody } from '../utils/parseEventBody';
|
||||||
import ArtifactDrawer from './ArtifactDrawer';
|
import ArtifactDrawer from './ArtifactDrawer';
|
||||||
|
|
||||||
@@ -21,32 +22,81 @@ interface LogEntry {
|
|||||||
raw_line: string;
|
raw_line: string;
|
||||||
fields: string | null;
|
fields: string | null;
|
||||||
msg: string | null;
|
msg: string | null;
|
||||||
|
severity?: string;
|
||||||
|
is_bounty?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ThreatLevel = 'nominal' | 'elevated' | 'critical';
|
||||||
|
|
||||||
|
const SPARK_LEN = 12;
|
||||||
|
|
||||||
|
function Sparkline({ data, alert }: { data: number[]; alert?: boolean }) {
|
||||||
|
const max = Math.max(...data, 1);
|
||||||
|
return (
|
||||||
|
<div className={`spark ${alert ? 'alert' : ''}`}>
|
||||||
|
{data.map((v, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
height: `${(v / max) * 100}%`,
|
||||||
|
opacity: 0.4 + (v / max) * 0.6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rollWindow(prev: number[], next: number): number[] {
|
||||||
|
const out = prev.slice(-SPARK_LEN + 1);
|
||||||
|
out.push(next);
|
||||||
|
while (out.length < SPARK_LEN) out.unshift(0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeThreat(hits5m: number): ThreatLevel {
|
||||||
|
if (hits5m > 100) return 'critical';
|
||||||
|
if (hits5m > 50) return 'elevated';
|
||||||
|
return 'nominal';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSector(): string {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('decnet_tweaks');
|
||||||
|
if (!raw) return 'PRODUCTION';
|
||||||
|
const t = JSON.parse(raw);
|
||||||
|
return (t?.sector || 'PRODUCTION').toString().toUpperCase();
|
||||||
|
} catch {
|
||||||
|
return 'PRODUCTION';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
|
const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record<string, any> } | null>(null);
|
const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record<string, unknown> } | null>(null);
|
||||||
|
const [newestLogId, setNewestLogId] = useState<number | null>(null);
|
||||||
|
const [sparkTotal, setSparkTotal] = useState<number[]>(() => Array(SPARK_LEN).fill(0));
|
||||||
|
const [sparkAttackers, setSparkAttackers] = useState<number[]>(() => Array(SPARK_LEN).fill(0));
|
||||||
|
const [sparkBounties, setSparkBounties] = useState<number[]>(() => Array(SPARK_LEN).fill(0));
|
||||||
|
const lastStatsRef = useRef<{ total: number; uniq: number; bounties: number } | null>(null);
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (eventSourceRef.current) {
|
if (eventSourceRef.current) eventSourceRef.current.close();
|
||||||
eventSourceRef.current.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
|
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
|
||||||
let url = `${baseUrl}/stream?token=${token}`;
|
let url = `${baseUrl}/stream?token=${token}`;
|
||||||
if (searchQuery) {
|
if (searchQuery) url += `&search=${encodeURIComponent(searchQuery)}`;
|
||||||
url += `&search=${encodeURIComponent(searchQuery)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const es = new EventSource(url);
|
const es = new EventSource(url);
|
||||||
eventSourceRef.current = es;
|
eventSourceRef.current = es;
|
||||||
@@ -55,11 +105,14 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
|
|||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data);
|
const payload = JSON.parse(event.data);
|
||||||
if (payload.type === 'logs') {
|
if (payload.type === 'logs') {
|
||||||
setLogs(prev => [...payload.data, ...prev].slice(0, 100));
|
const incoming: LogEntry[] = payload.data;
|
||||||
|
if (incoming.length > 0) {
|
||||||
|
setNewestLogId(incoming[0].id);
|
||||||
|
}
|
||||||
|
setLogs(prev => [...incoming, ...prev].slice(0, 100));
|
||||||
} else if (payload.type === 'stats') {
|
} else if (payload.type === 'stats') {
|
||||||
setStats(payload.data);
|
setStats(payload.data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
window.dispatchEvent(new CustomEvent('decnet:stats', { detail: payload.data }));
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE payload', err);
|
console.error('Failed to parse SSE payload', err);
|
||||||
@@ -81,144 +134,369 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
|
|||||||
};
|
};
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Tick once a second so the 5-min rolling window stays accurate even
|
||||||
|
// when logs haven't arrived.
|
||||||
|
const [nowTick, setNowTick] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const iv = setInterval(() => setNowTick(Date.now()), 1000);
|
||||||
|
return () => clearInterval(iv);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Derived metrics from live log buffer
|
||||||
|
const { hits5m, alertCount, uniqueAttackers5m, bountiesCount, deckiesUnderSiege, topAttackers } = useMemo(() => {
|
||||||
|
const cutoff = nowTick - 5 * 60_000;
|
||||||
|
const recent = logs.filter(l => {
|
||||||
|
const t = Date.parse(l.timestamp);
|
||||||
|
return !isNaN(t) && t >= cutoff;
|
||||||
|
});
|
||||||
|
const alertN = recent.filter(l => l.severity === 'warn' || l.is_bounty).length;
|
||||||
|
const uniq = new Set(recent.map(l => l.attacker_ip)).size;
|
||||||
|
const bounties = logs.filter(l => l.is_bounty).length;
|
||||||
|
|
||||||
|
const deckyHits = new Map<string, number>();
|
||||||
|
for (const l of recent) deckyHits.set(l.decky, (deckyHits.get(l.decky) || 0) + 1);
|
||||||
|
const siege = Array.from(deckyHits.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([name, hits]) => ({
|
||||||
|
name,
|
||||||
|
hits,
|
||||||
|
status: hits > 30 ? 'hot' : hits > 10 ? 'warn' : 'active',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const attackerHits = new Map<string, number>();
|
||||||
|
for (const l of logs) attackerHits.set(l.attacker_ip, (attackerHits.get(l.attacker_ip) || 0) + 1);
|
||||||
|
const maxAttackerHits = Math.max(1, ...attackerHits.values());
|
||||||
|
const top = Array.from(attackerHits.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(([ip, hits]) => ({
|
||||||
|
ip,
|
||||||
|
hits,
|
||||||
|
pct: Math.min(100, (hits / maxAttackerHits) * 100),
|
||||||
|
hot: hits > maxAttackerHits * 0.6,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
hits5m: recent.length,
|
||||||
|
alertCount: alertN,
|
||||||
|
uniqueAttackers5m: uniq,
|
||||||
|
bountiesCount: bounties,
|
||||||
|
deckiesUnderSiege: siege,
|
||||||
|
topAttackers: top,
|
||||||
|
};
|
||||||
|
}, [logs, nowTick]);
|
||||||
|
|
||||||
|
const threat = computeThreat(hits5m);
|
||||||
|
|
||||||
|
// Broadcast stats + threat for Layout's listener
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stats) return;
|
||||||
|
window.dispatchEvent(new CustomEvent('decnet:stats', {
|
||||||
|
detail: { ...stats, threat, hits_5m: hits5m, alert_count: alertCount },
|
||||||
|
}));
|
||||||
|
}, [stats, threat, hits5m, alertCount]);
|
||||||
|
|
||||||
|
// Roll sparklines on each stats frame
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stats) return;
|
||||||
|
const total = stats.total_logs;
|
||||||
|
const uniq = stats.unique_attackers;
|
||||||
|
const last = lastStatsRef.current;
|
||||||
|
if (last) {
|
||||||
|
const dTotal = Math.max(0, total - last.total);
|
||||||
|
const dUniq = Math.max(0, uniq - last.uniq);
|
||||||
|
const dBounties = Math.max(0, bountiesCount - last.bounties);
|
||||||
|
setSparkTotal(prev => rollWindow(prev, dTotal));
|
||||||
|
setSparkAttackers(prev => rollWindow(prev, dUniq));
|
||||||
|
setSparkBounties(prev => rollWindow(prev, dBounties));
|
||||||
|
}
|
||||||
|
lastStatsRef.current = { total, uniq, bounties: bountiesCount };
|
||||||
|
}, [stats, bountiesCount]);
|
||||||
|
|
||||||
if (loading && !stats) return <div className="loader">INITIALIZING SENSORS...</div>;
|
if (loading && !stats) return <div className="loader">INITIALIZING SENSORS...</div>;
|
||||||
|
|
||||||
|
const sector = getSector();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
|
<div className="page-header">
|
||||||
|
<div className="page-title-group">
|
||||||
|
<h1>DASHBOARD</h1>
|
||||||
|
<span className="page-sub">SECTOR · {sector} · LIVE</span>
|
||||||
|
</div>
|
||||||
|
<div className="section-actions">
|
||||||
|
<span className="chip matrix fx-blink">
|
||||||
|
<span className="status-dot active" /> LIVE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{threat === 'critical' && (
|
||||||
|
<div className="breach-banner">
|
||||||
|
<span className="pulse" />
|
||||||
|
<span style={{ flex: 1 }}>
|
||||||
|
ACTIVE BREACH — {hits5m} hits in last 5 min · {uniqueAttackers5m} attackers
|
||||||
|
</span>
|
||||||
|
<button onClick={() => navigate('/live-logs')}>INSPECT SESSION</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="stats-grid">
|
<div className="stats-grid">
|
||||||
<StatCard
|
<div className="stat-card">
|
||||||
icon={<Activity size={32} />}
|
<div className="row">
|
||||||
label="TOTAL INTERACTIONS"
|
<span className="stat-label">TOTAL INTERACTIONS</span>
|
||||||
value={stats?.total_logs || 0}
|
<div className="stat-icon"><Activity size={18} /></div>
|
||||||
/>
|
</div>
|
||||||
<StatCard
|
<div className="stat-value">{(stats?.total_logs ?? 0).toLocaleString()}</div>
|
||||||
icon={<Users size={32} />}
|
<div className="row">
|
||||||
label="UNIQUE ATTACKERS"
|
<div className="stat-delta up">+{hits5m} in last 5m</div>
|
||||||
value={stats?.unique_attackers || 0}
|
<Sparkline data={sparkTotal} />
|
||||||
/>
|
</div>
|
||||||
<StatCard
|
</div>
|
||||||
icon={<Shield size={32} />}
|
|
||||||
label="ACTIVE DECKIES"
|
<div className="stat-card alert">
|
||||||
value={`${stats?.active_deckies || 0} / ${stats?.deployed_deckies || 0}`}
|
<div className="row">
|
||||||
/>
|
<span className="stat-label">UNIQUE ATTACKERS</span>
|
||||||
|
<div className="stat-icon"><Crosshair size={18} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-value">{(stats?.unique_attackers ?? 0).toLocaleString()}</div>
|
||||||
|
<div className="row">
|
||||||
|
<div className="stat-delta up">{uniqueAttackers5m} active in 5m</div>
|
||||||
|
<Sparkline data={sparkAttackers} alert />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="row">
|
||||||
|
<span className="stat-label">ACTIVE DECKIES</span>
|
||||||
|
<div className="stat-icon"><Shield size={18} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-value">
|
||||||
|
{stats?.active_deckies ?? 0}
|
||||||
|
<span className="dim" style={{ fontSize: '1rem' }}> / {stats?.deployed_deckies ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<div className="stat-delta">OF TOTAL FLEET</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="row">
|
||||||
|
<span className="stat-label">BOUNTIES CAPTURED</span>
|
||||||
|
<div className="stat-icon"><Archive size={18} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-value">{bountiesCount.toLocaleString()}</div>
|
||||||
|
<div className="row">
|
||||||
|
<div className="stat-delta">THIS SESSION</div>
|
||||||
|
<Sparkline data={sparkBounties} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="logs-section">
|
<div className="dash-grid">
|
||||||
<div className="section-header">
|
<div className="logs-section">
|
||||||
<Clock size={20} />
|
<div className="section-header">
|
||||||
<h2>LIVE INTERACTION LOG</h2>
|
<div className="section-title">
|
||||||
</div>
|
<Clock size={16} />
|
||||||
<div className="logs-table-container">
|
<span>LIVE INTERACTION FEED</span>
|
||||||
<table className="logs-table">
|
<span className="chip matrix fx-blink">
|
||||||
<thead>
|
<span className="status-dot active" /> LIVE
|
||||||
<tr>
|
</span>
|
||||||
<th>TIMESTAMP</th>
|
</div>
|
||||||
<th>DECKY</th>
|
<div className="section-actions">
|
||||||
<th>SERVICE</th>
|
<span>{logs.length} RECENT</span>
|
||||||
<th>ATTACKER IP</th>
|
</div>
|
||||||
<th>EVENT</th>
|
</div>
|
||||||
</tr>
|
<div className="logs-table-container">
|
||||||
</thead>
|
<table className="logs-table">
|
||||||
<tbody>
|
<thead>
|
||||||
{logs.length > 0 ? logs.map(log => {
|
|
||||||
let parsedFields: Record<string, string> = {};
|
|
||||||
if (log.fields) {
|
|
||||||
try {
|
|
||||||
parsedFields = JSON.parse(log.fields);
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore parsing errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let msgHead: string | null = null;
|
|
||||||
let msgTail: string | null = null;
|
|
||||||
if (Object.keys(parsedFields).length === 0) {
|
|
||||||
const parsed = parseEventBody(log.msg);
|
|
||||||
parsedFields = parsed.fields;
|
|
||||||
msgHead = parsed.head;
|
|
||||||
msgTail = parsed.tail;
|
|
||||||
} else if (log.msg && log.msg !== '-') {
|
|
||||||
msgTail = log.msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={log.id}>
|
|
||||||
<td className="dim">{new Date(log.timestamp).toLocaleString()}</td>
|
|
||||||
<td className="violet-accent">{log.decky}</td>
|
|
||||||
<td className="matrix-text">{log.service}</td>
|
|
||||||
<td>{log.attacker_ip}</td>
|
|
||||||
<td>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
||||||
<div style={{ fontWeight: 'bold', color: 'var(--text-color)' }}>
|
|
||||||
{(() => {
|
|
||||||
const et = log.event_type && log.event_type !== '-' ? log.event_type : null;
|
|
||||||
const parts = [et, msgHead].filter(Boolean) as string[];
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{parts.join(' · ')}
|
|
||||||
{msgTail && <span style={{ fontWeight: 'normal', opacity: 0.8 }}>{parts.length ? ' — ' : ''}{msgTail}</span>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
{(Object.keys(parsedFields).length > 0 || parsedFields.stored_as) && (
|
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
|
||||||
{parsedFields.stored_as && (
|
|
||||||
<button
|
|
||||||
onClick={() => setArtifact({
|
|
||||||
decky: log.decky,
|
|
||||||
storedAs: String(parsedFields.stored_as),
|
|
||||||
fields: parsedFields,
|
|
||||||
})}
|
|
||||||
title="Inspect captured artifact"
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: '6px',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
backgroundColor: 'rgba(255, 170, 0, 0.1)',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
border: '1px solid rgba(255, 170, 0, 0.5)',
|
|
||||||
color: '#ffaa00',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Paperclip size={11} /> ARTIFACT
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{Object.entries(parsedFields)
|
|
||||||
.filter(([k]) => k !== 'meta_json_b64')
|
|
||||||
.map(([k, v]) => (
|
|
||||||
<span key={k} style={{
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
backgroundColor: 'rgba(0, 255, 65, 0.1)',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
border: '1px solid rgba(0, 255, 65, 0.3)',
|
|
||||||
wordBreak: 'break-all'
|
|
||||||
}}>
|
|
||||||
<span style={{ opacity: 0.6 }}>{k}:</span> {typeof v === 'object' ? JSON.stringify(v) : v}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}) : (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} style={{textAlign: 'center', padding: '40px'}}>NO INTERACTION DETECTED</td>
|
<th>TIME</th>
|
||||||
|
<th></th>
|
||||||
|
<th>DECKY</th>
|
||||||
|
<th>SVC</th>
|
||||||
|
<th>ATTACKER</th>
|
||||||
|
<th>EVENT</th>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{logs.length > 0 ? logs.slice(0, 14).map(log => {
|
||||||
|
let parsedFields: Record<string, unknown> = {};
|
||||||
|
if (log.fields) {
|
||||||
|
try {
|
||||||
|
parsedFields = JSON.parse(log.fields);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let msgHead: string | null = null;
|
||||||
|
let msgTail: string | null = null;
|
||||||
|
if (Object.keys(parsedFields).length === 0) {
|
||||||
|
const parsed = parseEventBody(log.msg);
|
||||||
|
parsedFields = parsed.fields;
|
||||||
|
msgHead = parsed.head;
|
||||||
|
msgTail = parsed.tail;
|
||||||
|
} else if (log.msg && log.msg !== '-') {
|
||||||
|
msgTail = log.msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAlert = log.severity === 'warn' || log.is_bounty;
|
||||||
|
const isNew = log.id === newestLogId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={log.id} className={isNew ? 'row-enter' : ''}>
|
||||||
|
<td className="dim" style={{ fontSize: '0.7rem', whiteSpace: 'nowrap' }}>
|
||||||
|
{new Date(log.timestamp).toLocaleTimeString()}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{log.is_bounty
|
||||||
|
? <span className="chip violet"><Archive size={8} /> BOUNTY</span>
|
||||||
|
: <span className={`status-dot ${isAlert ? 'hot' : 'active'}`} />}
|
||||||
|
</td>
|
||||||
|
<td className="violet-accent">{log.decky}</td>
|
||||||
|
<td><span className="chip dim-chip">{log.service}</span></td>
|
||||||
|
<td className="matrix-text">{log.attacker_ip}</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '0.78rem', color: 'var(--text-color)' }}>
|
||||||
|
{(() => {
|
||||||
|
const et = log.event_type && log.event_type !== '-' ? log.event_type : null;
|
||||||
|
const parts = [et, msgHead].filter(Boolean) as string[];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.join(' · ')}
|
||||||
|
{msgTail && (
|
||||||
|
<span style={{ fontWeight: 'normal', opacity: 0.8 }}>
|
||||||
|
{parts.length ? ' — ' : ''}{msgTail}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
{Object.keys(parsedFields).length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{parsedFields.stored_as != null && (
|
||||||
|
<button
|
||||||
|
onClick={() => setArtifact({
|
||||||
|
decky: log.decky,
|
||||||
|
storedAs: String(parsedFields.stored_as),
|
||||||
|
fields: parsedFields,
|
||||||
|
})}
|
||||||
|
title="Inspect captured artifact"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
fontSize: '0.62rem',
|
||||||
|
backgroundColor: 'rgba(255, 170, 0, 0.1)',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid rgba(255, 170, 0, 0.5)',
|
||||||
|
color: '#ffaa00',
|
||||||
|
cursor: 'pointer',
|
||||||
|
letterSpacing: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paperclip size={10} /> ARTIFACT
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{Object.entries(parsedFields)
|
||||||
|
.filter(([k]) => k !== 'meta_json_b64' && k !== 'stored_as')
|
||||||
|
.map(([k, v]) => (
|
||||||
|
<span key={k} className="chip matrix" style={{ fontSize: '0.62rem' }}>
|
||||||
|
<span className="dim" style={{ marginRight: 3 }}>{k}:</span>
|
||||||
|
{typeof v === 'object' ? JSON.stringify(v) : String(v)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} style={{ textAlign: 'center', padding: '40px' }}>NO INTERACTION DETECTED</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dash-side">
|
||||||
|
<div className="logs-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<div className="section-title">
|
||||||
|
<Flame size={16} />
|
||||||
|
<span>DECKIES UNDER SIEGE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{deckiesUnderSiege.length > 0 ? (
|
||||||
|
<div className="panel-body">
|
||||||
|
{deckiesUnderSiege.map(d => (
|
||||||
|
<div
|
||||||
|
key={d.name}
|
||||||
|
className="attacker-row"
|
||||||
|
onClick={() => window.dispatchEvent(new CustomEvent('decnet:cmd', { detail: { id: 'filter-decky', payload: d.name } }))}
|
||||||
|
>
|
||||||
|
<span className={`status-dot ${d.status}`} />
|
||||||
|
<span className="violet-accent" style={{ width: 110, fontSize: '0.75rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.name}</span>
|
||||||
|
<div className="attacker-bar-wrap">
|
||||||
|
<div
|
||||||
|
className={`attacker-bar ${d.status === 'hot' ? 'hot' : ''}`}
|
||||||
|
style={{ width: `${Math.min(100, (d.hits / Math.max(1, deckiesUnderSiege[0].hits)) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="dim" style={{ fontSize: '0.7rem', width: 32, textAlign: 'right' }}>{d.hits}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="panel-empty">NO ACTIVITY</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="logs-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<div className="section-title">
|
||||||
|
<Users size={16} />
|
||||||
|
<span>TOP ATTACKERS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{topAttackers.length > 0 ? (
|
||||||
|
<div className="panel-body">
|
||||||
|
{topAttackers.map(a => (
|
||||||
|
<div
|
||||||
|
key={a.ip}
|
||||||
|
className="attacker-row"
|
||||||
|
onClick={() => window.dispatchEvent(new CustomEvent('decnet:cmd', { detail: { id: 'filter-attacker', payload: a.ip } }))}
|
||||||
|
>
|
||||||
|
<span className={`chip ${a.hot ? 'alert-chip' : 'dim-chip'}`} style={{ minWidth: 34, textAlign: 'center', justifyContent: 'center' }}>??</span>
|
||||||
|
<span className="matrix-text" style={{ flex: 1, fontSize: '0.7rem', fontVariantNumeric: 'tabular-nums', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.ip}</span>
|
||||||
|
<div className="attacker-bar-wrap">
|
||||||
|
<div
|
||||||
|
className={`attacker-bar ${a.hot ? 'hot' : ''}`}
|
||||||
|
style={{ width: `${a.pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="dim" style={{ fontSize: '0.7rem', width: 32, textAlign: 'right' }}>{a.hits}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="panel-empty">NO ATTACKERS YET</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{artifact && (
|
{artifact && (
|
||||||
<ArtifactDrawer
|
<ArtifactDrawer
|
||||||
decky={artifact.decky}
|
decky={artifact.decky}
|
||||||
storedAs={artifact.storedAs}
|
storedAs={artifact.storedAs}
|
||||||
fields={artifact.fields}
|
fields={artifact.fields as Record<string, string>}
|
||||||
onClose={() => setArtifact(null)}
|
onClose={() => setArtifact(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -226,20 +504,4 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface StatCardProps {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatCard: React.FC<StatCardProps> = ({ icon, label, value }) => (
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon">{icon}</div>
|
|
||||||
<div className="stat-content">
|
|
||||||
<span className="stat-label">{label}</span>
|
|
||||||
<span className="stat-value">{value.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Dashboard;
|
export default Dashboard;
|
||||||
|
|||||||
Reference in New Issue
Block a user