feat(web): retrofit empty states to shared EmptyState primitive

Replace ad-hoc empty-state markup across Dashboard, TopologyList,
LiveLogs, Attackers, Bounty, AttackerDetail, SwarmHosts, RemoteUpdates
and CommandPalette with the new <EmptyState> component. Themed icons
+ hints improve discoverability; TopologyList and SwarmHosts gain
CTAs to their respective creation flows.
This commit is contained in:
2026-04-22 17:22:07 -04:00
parent de63a0ab5c
commit ecb813ad38
9 changed files with 82 additions and 50 deletions

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { Activity, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey, Radio, Timer, Paperclip } from 'lucide-react'; import { Activity, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey, Radio, Timer, Paperclip, Terminal, Package, FileText } from 'lucide-react';
import api from '../utils/api'; import api from '../utils/api';
import ArtifactDrawer from './ArtifactDrawer'; import ArtifactDrawer from './ArtifactDrawer';
import SessionDrawer from './SessionDrawer'; import SessionDrawer from './SessionDrawer';
import EmptyState from './EmptyState/EmptyState';
import './Dashboard.css'; import './Dashboard.css';
interface AttackerBehavior { interface AttackerBehavior {
@@ -964,9 +965,12 @@ const AttackerDetail: React.FC = () => {
</div> </div>
</div> </div>
) : ( ) : (
<div style={{ padding: '24px', textAlign: 'center', opacity: 0.5 }}> <EmptyState
NO BEHAVIORAL DATA YET PROFILER HAS NOT RUN FOR THIS ATTACKER icon={Activity}
</div> title="NO BEHAVIORAL DATA YET"
hint="profiler has not run for this attacker"
size="compact"
/>
)} )}
</Section> </Section>
@@ -1028,9 +1032,11 @@ const AttackerDetail: React.FC = () => {
</table> </table>
</div> </div>
) : ( ) : (
<div style={{ padding: '24px', textAlign: 'center', opacity: 0.5 }}> <EmptyState
{serviceFilter ? `NO ${serviceFilter.toUpperCase()} COMMANDS CAPTURED` : 'NO COMMANDS CAPTURED'} icon={Terminal}
</div> title={serviceFilter ? `NO ${serviceFilter.toUpperCase()} COMMANDS CAPTURED` : 'NO COMMANDS CAPTURED'}
size="compact"
/>
)} )}
</Section> </Section>
); );
@@ -1177,9 +1183,11 @@ const AttackerDetail: React.FC = () => {
</table> </table>
</div> </div>
) : ( ) : (
<div style={{ padding: '24px', textAlign: 'center', opacity: 0.5 }}> <EmptyState
NO ARTIFACTS CAPTURED FROM THIS ATTACKER icon={Package}
</div> title="NO ARTIFACTS CAPTURED"
size="compact"
/>
)} )}
</Section> </Section>
@@ -1258,9 +1266,11 @@ const AttackerDetail: React.FC = () => {
</table> </table>
</div> </div>
) : ( ) : (
<div style={{ padding: '24px', textAlign: 'center', opacity: 0.5 }}> <EmptyState
NO SESSION TRANSCRIPTS RECORDED FROM THIS ATTACKER icon={FileText}
</div> title="NO SESSION TRANSCRIPTS RECORDED"
size="compact"
/>
)} )}
</Section> </Section>

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { Search, ChevronLeft, ChevronRight, UserX } from 'lucide-react'; import { Search, ChevronLeft, ChevronRight, Users } from 'lucide-react';
import api from '../utils/api'; import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
import './Dashboard.css'; import './Dashboard.css';
import './Attackers.css'; import './Attackers.css';
@@ -163,14 +164,13 @@ const Attackers: React.FC = () => {
</div> </div>
{loading ? ( {loading ? (
<div className="empty-state"> <EmptyState icon={Users} title="SCANNING THREAT PROFILES…" />
<span className="type-label">SCANNING THREAT PROFILES...</span>
</div>
) : attackers.length === 0 ? ( ) : attackers.length === 0 ? (
<div className="empty-state"> <EmptyState
<UserX size={28} /> icon={Users}
<span className="type-label">NO ACTIVE THREATS PROFILED YET</span> title="NO ACTIVE THREATS PROFILED YET"
</div> hint="waiting on attacker traffic to correlate"
/>
) : ( ) : (
<div className="ak-grid"> <div className="ak-grid">
{attackers.map(a => { {attackers.map(a => {

View File

@@ -2,10 +2,11 @@ import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { import {
Archive, Search, ChevronLeft, ChevronRight, Filter, Key, Package, ChevronRight as ChevR, Archive, Search, ChevronLeft, ChevronRight, Filter, Key, Package, ChevronRight as ChevR,
ArchiveX, Target,
} from 'lucide-react'; } from 'lucide-react';
import api from '../utils/api'; import api from '../utils/api';
import BountyInspector from './BountyInspector'; import BountyInspector from './BountyInspector';
import EmptyState from './EmptyState/EmptyState';
import './Dashboard.css'; import './Dashboard.css';
import './Bounty.css'; import './Bounty.css';
@@ -220,12 +221,11 @@ const Bounty: React.FC = () => {
}) : ( }) : (
<tr> <tr>
<td colSpan={7}> <td colSpan={7}>
<div className="empty-state"> <EmptyState
<ArchiveX size={30} /> icon={Target}
<span className="type-label"> title={loading ? 'RETRIEVING ARTIFACTS…' : 'THE VAULT IS EMPTY'}
{loading ? 'RETRIEVING ARTIFACTS...' : 'THE VAULT IS EMPTY'} hint={loading ? undefined : 'attacker-dropped artifacts will land here'}
</span> />
</div>
</td> </td>
</tr> </tr>
)} )}

View File

@@ -2,7 +2,9 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
LayoutDashboard, Server, Network, Terminal, Archive, Crosshair, LayoutDashboard, Server, Network, Terminal, Archive, Crosshair,
PlusCircle, Pause, RefreshCw, Download, HardDrive, Package, UserPlus, Settings, PlusCircle, Pause, RefreshCw, Download, HardDrive, Package, UserPlus, Settings,
SearchX,
} from 'lucide-react'; } from 'lucide-react';
import EmptyState from '../EmptyState/EmptyState';
import './CommandPalette.css'; import './CommandPalette.css';
type IconComponent = React.ComponentType<{ size?: number; className?: string }>; type IconComponent = React.ComponentType<{ size?: number; className?: string }>;
@@ -141,7 +143,7 @@ const CommandPalette: React.FC<Props> = ({ open, onClose, onNav, onAction }) =>
</div> </div>
))} ))}
{filtered.length === 0 && ( {filtered.length === 0 && (
<div className="cmd-empty">NO COMMAND MATCHES</div> <EmptyState icon={SearchX} title="NO COMMAND MATCHES" size="compact" />
)} )}
</div> </div>
<div className="cmd-hint"> <div className="cmd-hint">

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import './Dashboard.css'; import './Dashboard.css';
import { Shield, Users, Activity, Clock, Paperclip, Crosshair, Flame, Archive } from 'lucide-react'; import { Shield, Users, Activity, Clock, Paperclip, Crosshair, Flame, Archive, ShieldOff, Server } from 'lucide-react';
import { parseEventBody } from '../utils/parseEventBody'; import { parseEventBody } from '../utils/parseEventBody';
import ArtifactDrawer from './ArtifactDrawer'; import ArtifactDrawer from './ArtifactDrawer';
import EmptyState from './EmptyState/EmptyState';
interface Stats { interface Stats {
total_logs: number; total_logs: number;
@@ -417,7 +418,13 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
); );
}) : ( }) : (
<tr> <tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px' }}>NO INTERACTION DETECTED</td> <td colSpan={6} style={{ padding: 0 }}>
<EmptyState
icon={Activity}
title="NO INTERACTION DETECTED"
hint="waiting for the first decky hit"
/>
</td>
</tr> </tr>
)} )}
</tbody> </tbody>
@@ -454,7 +461,7 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
))} ))}
</div> </div>
) : ( ) : (
<div className="panel-empty">NO ACTIVITY</div> <EmptyState icon={Server} title="NO ACTIVITY" hint="all deckies quiet" />
)} )}
</div> </div>
@@ -486,7 +493,7 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
))} ))}
</div> </div>
) : ( ) : (
<div className="panel-empty">NO ATTACKERS YET</div> <EmptyState icon={ShieldOff} title="NO ATTACKERS YET" hint="nothing on the radar" />
)} )}
</div> </div>
</div> </div>

View File

@@ -2,11 +2,12 @@ import React, { useEffect, useState, useRef, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
Terminal, Search, BarChart3, ChevronLeft, ChevronRight, Terminal, Search, BarChart3, ChevronLeft, ChevronRight,
Play, Pause, Paperclip, Download, SearchX, X as XIcon, Play, Pause, Paperclip, Download, Radio, X as XIcon,
} from 'lucide-react'; } from 'lucide-react';
import api from '../utils/api'; import api from '../utils/api';
import { parseEventBody } from '../utils/parseEventBody'; import { parseEventBody } from '../utils/parseEventBody';
import ArtifactDrawer from './ArtifactDrawer'; import ArtifactDrawer from './ArtifactDrawer';
import EmptyState from './EmptyState/EmptyState';
import './Dashboard.css'; import './Dashboard.css';
import './LiveLogs.css'; import './LiveLogs.css';
@@ -357,12 +358,11 @@ const LiveLogs: React.FC = () => {
}) : ( }) : (
<tr> <tr>
<td colSpan={5}> <td colSpan={5}>
<div className="empty-state"> <EmptyState
<SearchX size={28} /> icon={Radio}
<span className="type-label"> title={loading ? 'RETRIEVING DATA…' : 'NO LOGS MATCHING CRITERIA'}
{loading ? 'RETRIEVING DATA...' : 'NO LOGS MATCHING CRITERIA'} hint={loading ? undefined : 'adjust filters or wait for new events'}
</span> />
</div>
</td> </td>
</tr> </tr>
)} )}

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import api from '../utils/api'; import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
import './Dashboard.css'; import './Dashboard.css';
import { import {
Upload, RefreshCw, RotateCcw, Package, AlertTriangle, CheckCircle, Upload, RefreshCw, RotateCcw, Package, AlertTriangle, CheckCircle,
@@ -222,11 +223,11 @@ const RemoteUpdates: React.FC = () => {
)} )}
{hosts.length === 0 ? ( {hosts.length === 0 ? (
<div style={{ padding: '24px', color: 'var(--dim-color)' }}> <EmptyState
<Server size={16} style={{ verticalAlign: 'middle', marginRight: '8px' }} /> icon={Server}
No workers with an updater bundle are enrolled. Run{' '} title="NO UPDATER-ENABLED WORKERS"
<code>decnet swarm enroll --host &lt;name&gt; --updater</code> to add one. hint="run `decnet swarm enroll --host <name> --updater` to add one"
</div> />
) : ( ) : (
<div style={{ display: 'grid', gap: '16px' }}> <div style={{ display: 'grid', gap: '16px' }}>
{hosts.map((h) => { {hosts.map((h) => {

View File

@@ -1,8 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../utils/api'; import api from '../utils/api';
import EmptyState from './EmptyState/EmptyState';
import './Dashboard.css'; import './Dashboard.css';
import './Swarm.css'; import './Swarm.css';
import { HardDrive, PowerOff, RefreshCw, Trash2, Wifi, WifiOff } from 'lucide-react'; import { HardDrive, PowerOff, RefreshCw, Server, Trash2, Wifi, WifiOff } from 'lucide-react';
interface SwarmHost { interface SwarmHost {
uuid: string; uuid: string;
@@ -20,6 +22,7 @@ interface SwarmHost {
const shortFp = (fp: string): string => (fp ? fp.slice(0, 16) + '…' : '—'); const shortFp = (fp: string): string => (fp ? fp.slice(0, 16) + '…' : '—');
const SwarmHosts: React.FC = () => { const SwarmHosts: React.FC = () => {
const navigate = useNavigate();
const [hosts, setHosts] = useState<SwarmHost[]>([]); const [hosts, setHosts] = useState<SwarmHost[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [decommissioning, setDecommissioning] = useState<Set<string>>(new Set()); const [decommissioning, setDecommissioning] = useState<Set<string>>(new Set());
@@ -102,7 +105,12 @@ const SwarmHosts: React.FC = () => {
{loading ? ( {loading ? (
<p>Loading hosts</p> <p>Loading hosts</p>
) : hosts.length === 0 ? ( ) : hosts.length === 0 ? (
<p>No swarm hosts enrolled yet. Head to <strong>SWARM Agent Enrollment</strong> to onboard one.</p> <EmptyState
icon={Server}
title="NO SWARM HOSTS ENROLLED"
hint="onboard an agent to expand the fleet"
cta={{ label: 'ENROLL HOST', onClick: () => navigate('/swarm/enroll') }}
/>
) : ( ) : (
<table className="data-table"> <table className="data-table">
<thead> <thead>

View File

@@ -4,6 +4,7 @@ import { Network, Plus, Power, Trash2, UploadCloud, RefreshCw, Skull } from 'luc
import api from '../../utils/api'; import api from '../../utils/api';
import { clearLayout } from '../MazeNET/useMazeLayoutStore'; import { clearLayout } from '../MazeNET/useMazeLayoutStore';
import CreateTopologyWizard from './CreateTopologyWizard'; import CreateTopologyWizard from './CreateTopologyWizard';
import EmptyState from '../EmptyState/EmptyState';
import './TopologyList.css'; import './TopologyList.css';
interface TopologySummary { interface TopologySummary {
@@ -237,9 +238,12 @@ const TopologyList: React.FC = () => {
</div> </div>
))} ))}
{!loading && rows.length === 0 && ( {!loading && rows.length === 0 && (
<div className="tlist-empty"> <EmptyState
No topologies yet. Click NEW TOPOLOGY to create one. icon={Network}
</div> title="NO TOPOLOGIES YET"
hint="spin one up to deploy a honeynet"
cta={{ label: 'NEW TOPOLOGY', icon: Plus, onClick: () => setCreating(true) }}
/>
)} )}
</div> </div>
</div> </div>