feat(ui): frontend polish sweep — 8 UX fixes
- DeckyFleet: card click opens inspect side-drawer instead of auto-filtering (localSearch filter behavior removed) - Dashboard: LIVE FEED / DECKIES UNDER SIEGE / TOP ATTACKERS panels now have fixed max-height with overflow scroll instead of growing - parseEventBody: defensive RFC 5424 header strip so raw syslog lines from the collector render as k=v pills instead of raw text - Attackers: search placeholder updated; activity (Active/Passive/ Inactive) and country chip filters added on top of existing IP search - Credentials + Bounty: sortable column headers (click to asc/desc/clear) - SwarmHosts + RemoteUpdates: icon extracted from <h1> into flex div with violet-accent class, matching site-wide Identities pattern - Swarm.css: fix --panel-border undefined variable → --border so the title border-bottom line is visible on SwarmHosts and RemoteUpdates
This commit is contained in:
@@ -1,7 +1,24 @@
|
||||
.attackers-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ak-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ak-filter-row .chip {
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.ak-filter-row button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.attackers-root .controls-row {
|
||||
|
||||
@@ -63,6 +63,8 @@ const Attackers: React.FC = () => {
|
||||
const query = searchParams.get('q') || '';
|
||||
const sortBy = searchParams.get('sort_by') || 'recent';
|
||||
const serviceFilter = searchParams.get('service') || '';
|
||||
const activityFilter = searchParams.get('activity') || '';
|
||||
const countryFilter = searchParams.get('country') || '';
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
|
||||
const [attackers, setAttackers] = useState<AttackerEntry[]>([]);
|
||||
@@ -96,7 +98,10 @@ const Attackers: React.FC = () => {
|
||||
useEffect(() => { setSearchInput(query); }, [query]);
|
||||
|
||||
const _params = (overrides: Record<string, string> = {}) => {
|
||||
const base: Record<string, string> = { q: query, sort_by: sortBy, service: serviceFilter, page: '1' };
|
||||
const base: Record<string, string> = {
|
||||
q: query, sort_by: sortBy, service: serviceFilter,
|
||||
activity: activityFilter, country: countryFilter, page: '1',
|
||||
};
|
||||
return Object.fromEntries(Object.entries({ ...base, ...overrides }).filter(([, v]) => v !== ''));
|
||||
};
|
||||
|
||||
@@ -107,6 +112,8 @@ const Attackers: React.FC = () => {
|
||||
const setPage = (p: number) => setSearchParams(_params({ page: p.toString() }));
|
||||
const setSort = (s: string) => setSearchParams(_params({ sort_by: s }));
|
||||
const clearService = () => setSearchParams(_params({ service: '' }));
|
||||
const setActivity = (a: string) => setSearchParams(_params({ activity: activityFilter === a ? '' : a }));
|
||||
const setCountry = (c: string) => setSearchParams(_params({ country: countryFilter === c ? '' : c }));
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
@@ -115,6 +122,14 @@ const Attackers: React.FC = () => {
|
||||
{ active: 0, passive: 0, inactive: 0 } as Record<ActivityTier, number>,
|
||||
);
|
||||
|
||||
const countries = [...new Set(attackers.map(a => a.country_code).filter(Boolean))].sort() as string[];
|
||||
|
||||
const visibleAttackers = attackers.filter(a => {
|
||||
if (activityFilter && deriveActivity(a) !== activityFilter) return false;
|
||||
if (countryFilter && a.country_code !== countryFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="attackers-root">
|
||||
<div className="page-header">
|
||||
@@ -132,7 +147,7 @@ const Attackers: React.FC = () => {
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
placeholder="Search by IP..."
|
||||
placeholder="Search IP, ASN, country, org…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
/>
|
||||
@@ -144,6 +159,43 @@ const Attackers: React.FC = () => {
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<div className="ak-filter-row">
|
||||
{(['active', 'passive', 'inactive'] as ActivityTier[]).map(tier => (
|
||||
<button
|
||||
key={tier}
|
||||
type="button"
|
||||
className={`chip ${activityFilter === tier ? (tier === 'active' ? 'alert-chip' : tier === 'passive' ? 'violet' : 'matrix') : 'dim-chip'}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setActivity(tier)}
|
||||
>
|
||||
<span className={`dot status-dot ${tier === 'active' ? 'hot' : tier === 'passive' ? 'warn' : ''}`} style={{ marginRight: 4 }} />
|
||||
{tier.toUpperCase()} {activityCounts[tier] > 0 ? activityCounts[tier] : ''}
|
||||
</button>
|
||||
))}
|
||||
{countries.length > 0 && <span className="dim" style={{ fontSize: '0.65rem', letterSpacing: 1, opacity: 0.4, alignSelf: 'center' }}>|</span>}
|
||||
{countries.map(cc => (
|
||||
<button
|
||||
key={cc}
|
||||
type="button"
|
||||
className={`chip ${countryFilter === cc ? 'violet' : 'dim-chip'}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setCountry(cc)}
|
||||
>
|
||||
{cc}
|
||||
</button>
|
||||
))}
|
||||
{(activityFilter || countryFilter || serviceFilter) && (
|
||||
<button
|
||||
type="button"
|
||||
className="chip dim-chip"
|
||||
style={{ cursor: 'pointer', opacity: 0.6 }}
|
||||
onClick={() => setSearchParams(_params({ activity: '', country: '', service: '' }))}
|
||||
>
|
||||
CLEAR ×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<div className="section-title">
|
||||
@@ -182,7 +234,7 @@ const Attackers: React.FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<div className="ak-grid">
|
||||
{attackers.map(a => {
|
||||
{visibleAttackers.map(a => {
|
||||
const activity = deriveActivity(a);
|
||||
const lastCmd = a.commands.length > 0 ? a.commands[a.commands.length - 1] : null;
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Archive, Search, ChevronLeft, ChevronRight, Filter, Key, Package, ChevronRight as ChevR,
|
||||
@@ -61,6 +61,8 @@ const Bounty: React.FC = () => {
|
||||
const searchRef = useRef<HTMLInputElement | null>(null);
|
||||
useFocusSearch(searchRef);
|
||||
const [selected, setSelected] = useState<BountyEntry | null>(null);
|
||||
const [sortCol, setSortCol] = useState<string>('');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
const limit = 50;
|
||||
|
||||
@@ -97,6 +99,41 @@ const Bounty: React.FC = () => {
|
||||
const fpCount = bounties.filter(b => b.bounty_type === 'fingerprint').length;
|
||||
const artCount = bounties.filter(b => b.bounty_type === 'artifact').length;
|
||||
|
||||
const handleSortCol = (col: string) => {
|
||||
if (sortCol === col) {
|
||||
if (sortDir === 'asc') setSortDir('desc');
|
||||
else { setSortCol(''); setSortDir('asc'); }
|
||||
} else {
|
||||
setSortCol(col);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedBounties = useMemo(() => {
|
||||
if (!sortCol) return bounties;
|
||||
return [...bounties].sort((a, b) => {
|
||||
let av: string | number = '';
|
||||
let bv: string | number = '';
|
||||
if (sortCol === 'time') { av = a.timestamp; bv = b.timestamp; }
|
||||
else if (sortCol === 'decky') { av = a.decky; bv = b.decky; }
|
||||
else if (sortCol === 'svc') { av = a.service; bv = b.service; }
|
||||
else if (sortCol === 'attacker') { av = a.attacker_ip; bv = b.attacker_ip; }
|
||||
else if (sortCol === 'type') { av = a.bounty_type; bv = b.bounty_type; }
|
||||
const cmp = String(av).localeCompare(String(bv));
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [bounties, sortCol, sortDir]);
|
||||
|
||||
const SortTh: React.FC<{ col: string; children: React.ReactNode }> = ({ col, children }) => (
|
||||
<th
|
||||
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
|
||||
onClick={() => handleSortCol(col)}
|
||||
>
|
||||
{children}
|
||||
{sortCol === col ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''}
|
||||
</th>
|
||||
);
|
||||
|
||||
const SEGMENTS: [string, string][] = [
|
||||
['', 'ALL'],
|
||||
['credential', 'CREDENTIALS'],
|
||||
@@ -175,17 +212,17 @@ const Bounty: React.FC = () => {
|
||||
<table className="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>TIME</th>
|
||||
<th>DECKY</th>
|
||||
<th>SVC</th>
|
||||
<th>ATTACKER</th>
|
||||
<th>TYPE</th>
|
||||
<SortTh col="time">TIME</SortTh>
|
||||
<SortTh col="decky">DECKY</SortTh>
|
||||
<SortTh col="svc">SVC</SortTh>
|
||||
<SortTh col="attacker">ATTACKER</SortTh>
|
||||
<SortTh col="type">TYPE</SortTh>
|
||||
<th>DATA</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bounties.length > 0 ? bounties.map(b => {
|
||||
{sortedBounties.length > 0 ? sortedBounties.map(b => {
|
||||
const isCred = b.bounty_type === 'credential';
|
||||
const isFp = b.bounty_type === 'fingerprint';
|
||||
const isArt = b.bounty_type === 'artifact';
|
||||
|
||||
@@ -46,6 +46,8 @@ const Credentials: React.FC = () => {
|
||||
const [selectedCred, setSelectedCred] = useState<CredentialEntry | null>(null);
|
||||
const [selectedReuse, setSelectedReuse] = useState<CredentialReuseRow | null>(null);
|
||||
const [refreshTick, setRefreshTick] = useState(0);
|
||||
const [sortCol, setSortCol] = useState<string>('');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
// ── Fetch credentials (CREDS tab + always for badge totals)
|
||||
useEffect(() => {
|
||||
@@ -139,6 +141,45 @@ const Credentials: React.FC = () => {
|
||||
const plaintextCount = creds.filter(c => c.secret_kind === 'plaintext').length;
|
||||
const hashedCount = creds.length - plaintextCount;
|
||||
|
||||
const handleSortCol = (col: string) => {
|
||||
if (sortCol === col) {
|
||||
if (sortDir === 'asc') setSortDir('desc');
|
||||
else { setSortCol(''); setSortDir('asc'); }
|
||||
} else {
|
||||
setSortCol(col);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedCreds = useMemo(() => {
|
||||
if (!sortCol) return creds;
|
||||
return [...creds].sort((a, b) => {
|
||||
let av: string | number = '';
|
||||
let bv: string | number = '';
|
||||
if (sortCol === 'seen') { av = a.last_seen; bv = b.last_seen; }
|
||||
else if (sortCol === 'decky') { av = a.decky_name; bv = b.decky_name; }
|
||||
else if (sortCol === 'svc') { av = a.service; bv = b.service; }
|
||||
else if (sortCol === 'attacker') { av = a.attacker_ip; bv = b.attacker_ip; }
|
||||
else if (sortCol === 'principal') { av = a.principal ?? ''; bv = b.principal ?? ''; }
|
||||
else if (sortCol === 'kind') { av = a.secret_kind; bv = b.secret_kind; }
|
||||
else if (sortCol === 'hits') { av = a.attempt_count; bv = b.attempt_count; }
|
||||
const cmp = typeof av === 'number' && typeof bv === 'number'
|
||||
? av - bv
|
||||
: String(av).localeCompare(String(bv));
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [creds, sortCol, sortDir]);
|
||||
|
||||
const SortTh: React.FC<{ col: string; children: React.ReactNode }> = ({ col, children }) => (
|
||||
<th
|
||||
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
|
||||
onClick={() => handleSortCol(col)}
|
||||
>
|
||||
{children}
|
||||
{sortCol === col ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''}
|
||||
</th>
|
||||
);
|
||||
|
||||
const openReuseFromCred = async (key: string) => {
|
||||
const hit = reuseMap.get(key);
|
||||
if (!hit) return;
|
||||
@@ -248,20 +289,20 @@ const Credentials: React.FC = () => {
|
||||
<table className="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>LAST SEEN</th>
|
||||
<th>DECKY</th>
|
||||
<th>SVC</th>
|
||||
<th>ATTACKER</th>
|
||||
<th>PRINCIPAL</th>
|
||||
<SortTh col="seen">LAST SEEN</SortTh>
|
||||
<SortTh col="decky">DECKY</SortTh>
|
||||
<SortTh col="svc">SVC</SortTh>
|
||||
<SortTh col="attacker">ATTACKER</SortTh>
|
||||
<SortTh col="principal">PRINCIPAL</SortTh>
|
||||
<th>SECRET</th>
|
||||
<th>KIND</th>
|
||||
<th>HITS</th>
|
||||
<SortTh col="kind">KIND</SortTh>
|
||||
<SortTh col="hits">HITS</SortTh>
|
||||
<th>REUSE</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{creds.length > 0 ? creds.map(c => {
|
||||
{sortedCreds.length > 0 ? sortedCreds.map(c => {
|
||||
const isPlain = c.secret_kind === 'plaintext';
|
||||
const secretText = isPlain
|
||||
? (c.secret_printable ?? '—')
|
||||
|
||||
@@ -266,7 +266,8 @@
|
||||
|
||||
.dash-grid > .logs-section .logs-table-container {
|
||||
flex: 1;
|
||||
max-height: none;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dash-side {
|
||||
@@ -278,6 +279,13 @@
|
||||
|
||||
.dash-side > .logs-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dash-side > .logs-section .panel-body {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Attacker/siege rows */
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Cpu, Database, Globe, Monitor, Network, PlusCircle, PowerOff,
|
||||
RefreshCw, Server, Shield, Terminal, Plus, X,
|
||||
} from '../icons';
|
||||
import { useEscapeKey } from '../hooks/useEscapeKey';
|
||||
import api from '../utils/api';
|
||||
import { ARCHETYPES as FALLBACK_ARCHETYPES, DEFAULT_SERVICES } from './MazeNET/data';
|
||||
import { useToast } from './Toasts/useToast';
|
||||
@@ -125,6 +126,124 @@ const _stateColor = (state: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Decky inspect panel ─────────────────────────────────────────────────
|
||||
|
||||
interface DeckyInspectPanelProps {
|
||||
decky: Decky;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DeckyInspectPanel: React.FC<DeckyInspectPanelProps> = ({ decky, onClose }) => {
|
||||
useEscapeKey(onClose, true);
|
||||
const status = _dotFor(decky);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
|
||||
const fmtDate = (ts: number | string | null | undefined) => {
|
||||
if (!ts) return '—';
|
||||
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts);
|
||||
return isNaN(d.getTime()) ? String(ts) : d.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', justifyContent: 'flex-end',
|
||||
zIndex: 1200,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: 360,
|
||||
background: 'var(--secondary-color)',
|
||||
borderLeft: '1px solid var(--border)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
gap: 12,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span className={`status-dot ${status}`} />
|
||||
<span style={{ fontWeight: 700, letterSpacing: 3, fontSize: '0.95rem', color: 'var(--matrix)' }}>
|
||||
{decky.name}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--dim-color)', padding: 4 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{[
|
||||
['IP', decky.ip],
|
||||
['HOSTNAME', decky.hostname],
|
||||
['DISTRO', decky.distro],
|
||||
['ARCHETYPE', decky.archetype],
|
||||
['LAST MUTATED', fmtDate(decky.last_mutated)],
|
||||
['MUTATE INTERVAL', decky.mutate_interval != null ? `${decky.mutate_interval}s` : '—'],
|
||||
].map(([label, val]) => val ? (
|
||||
<div key={label} style={{ display: 'flex', gap: 10, fontSize: '0.78rem' }}>
|
||||
<span style={{ minWidth: 130, opacity: 0.45, letterSpacing: 1 }}>{label}</span>
|
||||
<span style={{ color: 'var(--matrix)', wordBreak: 'break-all' }}>{val}</span>
|
||||
</div>
|
||||
) : null)}
|
||||
</div>
|
||||
|
||||
{decky.services.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: '0.65rem', opacity: 0.45, letterSpacing: 1.5, marginBottom: 8 }}>SERVICES</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{decky.services.map(svc => (
|
||||
<span key={svc} className="chip violet" style={{ fontSize: '0.65rem' }}>{svc}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{decky.swarm && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 8, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: '0.65rem', opacity: 0.45, letterSpacing: 1.5, marginBottom: 2 }}>SWARM</div>
|
||||
{[
|
||||
['HOST', decky.swarm.host_name],
|
||||
['ADDRESS', decky.swarm.host_address],
|
||||
['STATE', decky.swarm.state],
|
||||
['LAST SEEN', fmtDate(decky.swarm.last_seen)],
|
||||
['ERROR', decky.swarm.last_error],
|
||||
].map(([label, val]) => val ? (
|
||||
<div key={label} style={{ display: 'flex', gap: 10, fontSize: '0.78rem' }}>
|
||||
<span style={{ minWidth: 130, opacity: 0.45, letterSpacing: 1 }}>{label}</span>
|
||||
<span style={{
|
||||
color: label === 'STATE' ? _stateColor(val) : label === 'ERROR' ? 'var(--alert)' : 'var(--matrix)',
|
||||
wordBreak: 'break-all',
|
||||
}}>{val}</span>
|
||||
</div>
|
||||
) : null)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Decky card ───────────────────────────────────────────────────────────
|
||||
|
||||
interface DeckyCardProps {
|
||||
@@ -1180,6 +1299,7 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
||||
const [archetypes, setArchetypes] = useState<Archetype[]>(FALLBACK_ARCHETYPES);
|
||||
const [localSearch, setLocalSearch] = useState<string>('');
|
||||
const [intervalEditor, setIntervalEditor] = useState<{ name: string; current: number | null } | null>(null);
|
||||
const [selectedDecky, setSelectedDecky] = useState<Decky | null>(null);
|
||||
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
const lastSearchPropRef = useRef<string>(searchQuery);
|
||||
@@ -1362,9 +1482,7 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
||||
};
|
||||
|
||||
const handleInspect = (d: Decky) => {
|
||||
window.dispatchEvent(new CustomEvent('decnet:cmd', {
|
||||
detail: { id: 'filter-decky', payload: d.name },
|
||||
}));
|
||||
setSelectedDecky(d);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1394,16 +1512,6 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
||||
void handleMutateAll();
|
||||
return;
|
||||
}
|
||||
if (detail.id === 'filter-decky' && typeof detail.payload === 'string') {
|
||||
const name = detail.payload;
|
||||
setLocalSearch(name);
|
||||
push({ text: `FILTERING · ${name.toUpperCase()}`, tone: 'violet', icon: 'crosshair' });
|
||||
// Defer so React renders filtered grid first.
|
||||
window.setTimeout(() => {
|
||||
const el = cardRefs.current.get(name);
|
||||
if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}, 80);
|
||||
}
|
||||
};
|
||||
window.addEventListener('decnet:cmd', onCmd);
|
||||
return () => window.removeEventListener('decnet:cmd', onCmd);
|
||||
@@ -1549,6 +1657,13 @@ const DeckyFleet: React.FC<FleetProps> = ({ searchQuery = '' }) => {
|
||||
onClose={() => setIntervalEditor(null)}
|
||||
onSave={handleIntervalSave}
|
||||
/>
|
||||
|
||||
{selectedDecky && (
|
||||
<DeckyInspectPanel
|
||||
decky={selectedDecky}
|
||||
onClose={() => setSelectedDecky(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -169,7 +169,10 @@ const RemoteUpdates: React.FC = () => {
|
||||
<div className="dashboard swarm-root">
|
||||
<div className="page-header">
|
||||
<div className="page-title-group">
|
||||
<h1><Package size={18} /> REMOTE UPDATES</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Package size={22} className="violet-accent" />
|
||||
<h1>REMOTE UPDATES</h1>
|
||||
</div>
|
||||
<span className="page-sub">
|
||||
push updater bundles to enrolled workers · {hosts.length} WORKER{hosts.length === 1 ? '' : 'S'}
|
||||
</span>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
gap: 24px;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.swarm-root .page-title-group {
|
||||
|
||||
@@ -413,7 +413,10 @@ const SwarmHosts: React.FC = () => {
|
||||
<div className="dashboard swarm-root">
|
||||
<div className="page-header">
|
||||
<div className="page-title-group">
|
||||
<h1><HardDrive size={18} /> SWARM HOSTS</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<HardDrive size={22} className="violet-accent" />
|
||||
<h1>SWARM HOSTS</h1>
|
||||
</div>
|
||||
<span className="page-sub">
|
||||
{loading ? 'LOADING…' : `${hosts.length} ENROLLED · ${online} ONLINE`}
|
||||
</span>
|
||||
|
||||
@@ -11,11 +11,16 @@ export interface ParsedBody {
|
||||
tail: string | null;
|
||||
}
|
||||
|
||||
// RFC 5424: <PRI>VERSION TIMESTAMP HOSTNAME APPNAME PROCID MSGID SD MSG
|
||||
// Matches both framed (<135>1 ...) and unframed (bare timestamp) variants.
|
||||
const rfc5424Re = /^(?:<\d+>\d\s+)?\d{4}-\d{2}-\d{2}T[\d:.+-]+\s+\S+\s+\S+\s+\S+\s+\S+\s+(?:-|\[.*?\])\s*/;
|
||||
|
||||
export function parseEventBody(msg: string | null | undefined): ParsedBody {
|
||||
const empty: ParsedBody = { head: null, fields: {}, tail: null };
|
||||
if (!msg) return empty;
|
||||
const body = msg.trim();
|
||||
let body = msg.trim();
|
||||
if (!body || body === '-') return empty;
|
||||
body = body.replace(rfc5424Re, '');
|
||||
|
||||
const keyRe = /([A-Za-z_][A-Za-z0-9_]*)=/g;
|
||||
const firstKv = body.search(/(^|\s)[A-Za-z_][A-Za-z0-9_]*=/);
|
||||
|
||||
Reference in New Issue
Block a user