merge testing->tomerge/main #7
@@ -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,11 +150,44 @@ 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 && (
|
||||||
|
<button
|
||||||
|
onClick={() => 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paperclip size={11} /> ARTIFACT
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{Object.entries(parsedFields)
|
||||||
|
.filter(([k]) => k !== 'meta_json_b64')
|
||||||
|
.map(([k, v]) => (
|
||||||
<span key={k} style={{
|
<span key={k} style={{
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
backgroundColor: 'rgba(0, 255, 65, 0.1)',
|
backgroundColor: 'rgba(0, 255, 65, 0.1)',
|
||||||
@@ -149,7 +196,7 @@ const Dashboard: React.FC<DashboardProps> = ({ searchQuery }) => {
|
|||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ 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,11 +320,44 @@ 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 && (
|
||||||
|
<button
|
||||||
|
onClick={() => 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paperclip size={11} /> ARTIFACT
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{Object.entries(parsedFields)
|
||||||
|
.filter(([k]) => k !== 'meta_json_b64')
|
||||||
|
.map(([k, v]) => (
|
||||||
<span key={k} style={{
|
<span key={k} style={{
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
backgroundColor: 'rgba(0, 255, 65, 0.1)',
|
backgroundColor: 'rgba(0, 255, 65, 0.1)',
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
44
decnet_web/src/utils/parseEventBody.ts
Normal file
44
decnet_web/src/utils/parseEventBody.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user