feat(web-ui): event-body parser and dashboard/live-logs polish

Frontend now handles syslog lines from producers that don't use
structured-data (notably the SSH PROMPT_COMMAND hook, which emits
'CMD uid=0 user=root src=IP pwd=… cmd=…' as a plain logger message).
A new parseEventBody utility splits the body into head + key/value
pairs and preserves the final value verbatim so commands stay intact.

Dashboard and LiveLogs use this parser to render consistent pills
whether the structure came from SD params or from the MSG body.
This commit is contained in:
2026-04-18 05:37:31 -04:00
parent 2bf886e18e
commit 47a0480994
3 changed files with 178 additions and 22 deletions

View File

@@ -1,6 +1,8 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import './Dashboard.css'; import './Dashboard.css';
import { Shield, Users, Activity, Clock } from 'lucide-react'; import { Shield, Users, Activity, Clock, Paperclip } from 'lucide-react';
import { parseEventBody } from '../utils/parseEventBody';
import ArtifactDrawer from './ArtifactDrawer';
interface Stats { interface Stats {
total_logs: number; total_logs: number;
@@ -29,6 +31,7 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
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 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);
@@ -127,6 +130,17 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
} }
} }
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 ( return (
<tr key={log.id}> <tr key={log.id}>
<td className="dim">{new Date(log.timestamp).toLocaleString()}</td> <td className="dim">{new Date(log.timestamp).toLocaleString()}</td>
@@ -136,20 +150,53 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
<td> <td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontWeight: 'bold', color: 'var(--text-color)' }}> <div style={{ fontWeight: 'bold', color: 'var(--text-color)' }}>
{log.event_type} {log.msg && log.msg !== '-' && <span style={{ fontWeight: 'normal', opacity: 0.8 }}> {log.msg}</span>} {(() => {
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> </div>
{Object.keys(parsedFields).length > 0 && ( {(Object.keys(parsedFields).length > 0 || parsedFields.stored_as) && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{Object.entries(parsedFields).map(([k, v]) => ( {parsedFields.stored_as && (
<span key={k} style={{ <button
fontSize: '0.7rem', onClick={() => setArtifact({
backgroundColor: 'rgba(0, 255, 65, 0.1)', decky: log.decky,
padding: '2px 8px', storedAs: String(parsedFields.stored_as),
borderRadius: '4px', 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)', border: '1px solid rgba(0, 255, 65, 0.3)',
wordBreak: 'break-all' wordBreak: 'break-all'
}}> }}>
<span style={{ opacity: 0.6 }}>{k}:</span> {v} <span style={{ opacity: 0.6 }}>{k}:</span> {typeof v === 'object' ? JSON.stringify(v) : v}
</span> </span>
))} ))}
</div> </div>
@@ -167,6 +214,14 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
</table> </table>
</div> </div>
</div> </div>
{artifact && (
<ArtifactDrawer
decky={artifact.decky}
storedAs={artifact.storedAs}
fields={artifact.fields}
onClose={() => setArtifact(null)}
/>
)}
</div> </div>
); );
}; };

View File

@@ -1,13 +1,15 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
Terminal, Search, Activity, Terminal, Search, Activity,
ChevronLeft, ChevronRight, Play, Pause ChevronLeft, ChevronRight, Play, Pause, Paperclip
} from 'lucide-react'; } from 'lucide-react';
import { import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell
} from 'recharts'; } from 'recharts';
import api from '../utils/api'; import api from '../utils/api';
import { parseEventBody } from '../utils/parseEventBody';
import ArtifactDrawer from './ArtifactDrawer';
import './Dashboard.css'; import './Dashboard.css';
interface LogEntry { interface LogEntry {
@@ -47,6 +49,9 @@ const LiveLogs: React.FC = () => {
const eventSourceRef = useRef<EventSource | null>(null); const eventSourceRef = useRef<EventSource | null>(null);
const limit = 50; const limit = 50;
// Open artifact drawer when a log row with stored_as is clicked.
const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record<string, any> } | null>(null);
// Sync search input if URL changes (e.g. back button) // Sync search input if URL changes (e.g. back button)
useEffect(() => { useEffect(() => {
setSearchInput(query); setSearchInput(query);
@@ -295,6 +300,17 @@ const LiveLogs: React.FC = () => {
} catch (e) {} } catch (e) {}
} }
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 ( return (
<tr key={log.id}> <tr key={log.id}>
<td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>{new Date(log.timestamp).toLocaleString()}</td> <td className="dim" style={{ fontSize: '0.75rem', whiteSpace: 'nowrap' }}>{new Date(log.timestamp).toLocaleString()}</td>
@@ -304,16 +320,49 @@ const LiveLogs: React.FC = () => {
<td> <td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ fontWeight: 'bold', color: 'var(--text-color)', fontSize: '0.9rem' }}> <div style={{ fontWeight: 'bold', color: 'var(--text-color)', fontSize: '0.9rem' }}>
{log.event_type} {log.msg && log.msg !== '-' && <span style={{ fontWeight: 'normal', opacity: 0.8 }}> {log.msg}</span>} {(() => {
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> </div>
{Object.keys(parsedFields).length > 0 && ( {(Object.keys(parsedFields).length > 0 || parsedFields.stored_as) && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{Object.entries(parsedFields).map(([k, v]) => ( {parsedFields.stored_as && (
<span key={k} style={{ <button
fontSize: '0.7rem', onClick={() => setArtifact({
backgroundColor: 'rgba(0, 255, 65, 0.1)', decky: log.decky,
padding: '2px 8px', storedAs: String(parsedFields.stored_as),
borderRadius: '4px', 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)', border: '1px solid rgba(0, 255, 65, 0.3)',
wordBreak: 'break-all' wordBreak: 'break-all'
}}> }}>
@@ -337,6 +386,14 @@ const LiveLogs: React.FC = () => {
</table> </table>
</div> </div>
</div> </div>
{artifact && (
<ArtifactDrawer
decky={artifact.decky}
storedAs={artifact.storedAs}
fields={artifact.fields}
onClose={() => setArtifact(null)}
/>
)}
</div> </div>
); );
}; };

View File

@@ -0,0 +1,44 @@
// Some producers (notably the SSH PROMPT_COMMAND hook via rsyslog) emit
// k=v pairs inside the syslog MSG body instead of RFC5424 structured-data.
// When the backend's `fields` is empty we salvage those pairs here so the
// UI renders consistent pills regardless of where the structure was set.
//
// A leading non-"key=" token is returned as `head` (e.g. "CMD"). The final
// key consumes the rest of the line so values like `cmd=ls -lah` stay intact.
export interface ParsedBody {
head: string | null;
fields: Record<string, string>;
tail: string | null;
}
export function parseEventBody(msg: string | null | undefined): ParsedBody {
const empty: ParsedBody = { head: null, fields: {}, tail: null };
if (!msg) return empty;
const body = msg.trim();
if (!body || body === '-') return empty;
const keyRe = /([A-Za-z_][A-Za-z0-9_]*)=/g;
const firstKv = body.search(/(^|\s)[A-Za-z_][A-Za-z0-9_]*=/);
if (firstKv < 0) return { head: null, fields: {}, tail: body };
const headEnd = firstKv === 0 ? 0 : firstKv;
const head = headEnd > 0 ? body.slice(0, headEnd).trim() : null;
const rest = body.slice(headEnd).replace(/^\s+/, '');
const pairs: Array<{ key: string; valueStart: number }> = [];
let m: RegExpExecArray | null;
while ((m = keyRe.exec(rest)) !== null) {
pairs.push({ key: m[1], valueStart: m.index + m[0].length });
}
const fields: Record<string, string> = {};
for (let i = 0; i < pairs.length; i++) {
const { key, valueStart } = pairs[i];
const end = i + 1 < pairs.length
? pairs[i + 1].valueStart - pairs[i + 1].key.length - 1
: rest.length;
fields[key] = rest.slice(valueStart, end).trim();
}
return { head: head && head !== '-' ? head : null, fields, tail: null };
}