From 47a0480994cca018918e1285fc81ecc36fe7c4a5 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 18 Apr 2026 05:37:31 -0400 Subject: [PATCH] feat(web-ui): event-body parser and dashboard/live-logs polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- decnet_web/src/components/Dashboard.tsx | 75 ++++++++++++++++++++--- decnet_web/src/components/LiveLogs.tsx | 81 +++++++++++++++++++++---- decnet_web/src/utils/parseEventBody.ts | 44 ++++++++++++++ 3 files changed, 178 insertions(+), 22 deletions(-) create mode 100644 decnet_web/src/utils/parseEventBody.ts diff --git a/decnet_web/src/components/Dashboard.tsx b/decnet_web/src/components/Dashboard.tsx index 7f6c0f7..fd8319b 100644 --- a/decnet_web/src/components/Dashboard.tsx +++ b/decnet_web/src/components/Dashboard.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useState, useRef } from 'react'; 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 { total_logs: number; @@ -29,6 +31,7 @@ const Dashboard: React.FC = ({ searchQuery }) => { const [stats, setStats] = useState(null); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); + const [artifact, setArtifact] = useState<{ decky: string; storedAs: string; fields: Record } | null>(null); const eventSourceRef = useRef(null); const reconnectTimerRef = useRef | null>(null); @@ -127,6 +130,17 @@ const Dashboard: React.FC = ({ 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 ( {new Date(log.timestamp).toLocaleString()} @@ -136,20 +150,53 @@ const Dashboard: React.FC = ({ searchQuery }) => {
- {log.event_type} {log.msg && log.msg !== '-' && — {log.msg}} + {(() => { + const et = log.event_type && log.event_type !== '-' ? log.event_type : null; + const parts = [et, msgHead].filter(Boolean) as string[]; + return ( + <> + {parts.join(' · ')} + {msgTail && {parts.length ? ' — ' : ''}{msgTail}} + + ); + })()}
- {Object.keys(parsedFields).length > 0 && ( + {(Object.keys(parsedFields).length > 0 || parsedFields.stored_as) && (
- {Object.entries(parsedFields).map(([k, v]) => ( - 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', + }} + > + ARTIFACT + + )} + {Object.entries(parsedFields) + .filter(([k]) => k !== 'meta_json_b64') + .map(([k, v]) => ( + - {k}: {v} + {k}: {typeof v === 'object' ? JSON.stringify(v) : v} ))}
@@ -167,6 +214,14 @@ const Dashboard: React.FC = ({ searchQuery }) => {
+ {artifact && ( + setArtifact(null)} + /> + )} ); }; diff --git a/decnet_web/src/components/LiveLogs.tsx b/decnet_web/src/components/LiveLogs.tsx index 575e1f5..ae77db9 100644 --- a/decnet_web/src/components/LiveLogs.tsx +++ b/decnet_web/src/components/LiveLogs.tsx @@ -1,13 +1,15 @@ import React, { useEffect, useState, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { - Terminal, Search, Activity, - ChevronLeft, ChevronRight, Play, Pause +import { + Terminal, Search, Activity, + ChevronLeft, ChevronRight, Play, Pause, Paperclip } from 'lucide-react'; -import { +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'; import api from '../utils/api'; +import { parseEventBody } from '../utils/parseEventBody'; +import ArtifactDrawer from './ArtifactDrawer'; import './Dashboard.css'; interface LogEntry { @@ -47,6 +49,9 @@ const LiveLogs: React.FC = () => { const eventSourceRef = useRef(null); 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 } | null>(null); + // Sync search input if URL changes (e.g. back button) useEffect(() => { setSearchInput(query); @@ -295,6 +300,17 @@ const LiveLogs: React.FC = () => { } 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 ( {new Date(log.timestamp).toLocaleString()} @@ -304,16 +320,49 @@ const LiveLogs: React.FC = () => {
- {log.event_type} {log.msg && log.msg !== '-' && — {log.msg}} + {(() => { + const et = log.event_type && log.event_type !== '-' ? log.event_type : null; + const parts = [et, msgHead].filter(Boolean) as string[]; + return ( + <> + {parts.join(' · ')} + {msgTail && {parts.length ? ' — ' : ''}{msgTail}} + + ); + })()}
- {Object.keys(parsedFields).length > 0 && ( + {(Object.keys(parsedFields).length > 0 || parsedFields.stored_as) && (
- {Object.entries(parsedFields).map(([k, v]) => ( - 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', + }} + > + ARTIFACT + + )} + {Object.entries(parsedFields) + .filter(([k]) => k !== 'meta_json_b64') + .map(([k, v]) => ( + @@ -337,6 +386,14 @@ const LiveLogs: React.FC = () => {
+ {artifact && ( + setArtifact(null)} + /> + )} ); }; diff --git a/decnet_web/src/utils/parseEventBody.ts b/decnet_web/src/utils/parseEventBody.ts new file mode 100644 index 0000000..1a0e90a --- /dev/null +++ b/decnet_web/src/utils/parseEventBody.ts @@ -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; + 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 = {}; + 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 }; +}