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 }; +}