+ {lines.map((line, i) => {
+ if (!line) return
;
+ if (!re) {
+ return (
+
handleMouseUp(i, line)}
+ style={{ color: "#94a3b8", padding: "1px 0" }}>
+ {line}
+
+ );
+ }
+ const m = re.exec(line);
+ if (!m) {
+ return (
+
handleMouseUp(i, line)}
+ style={{ color: "#374151", borderLeft: "2px solid rgba(255,255,255,0.04)", paddingLeft: 8, padding: "1px 0 1px 8px" }}>
+ {line}
+
+ );
+ }
+ // Render matched line with TIME/ENTRY segments highlighted
+ const groups = m.groups || {};
+ const TIME = groups.TIME !== undefined ? groups.TIME : null;
+ const ENTRY = groups.ENTRY !== undefined ? groups.ENTRY : null;
+ const timeIdx = TIME != null ? line.indexOf(TIME) : -1;
+ const entryIdx = ENTRY != null ? line.indexOf(ENTRY, timeIdx >= 0 ? timeIdx + TIME.length : 0) : -1;
+
+ const parts = [];
+ let cur = 0;
+ const addPart = (end, color, label) => {
+ if (cur < end) {
+ parts.push({ text: line.slice(cur, end), color, label });
+ cur = end;
+ }
+ };
+ // Build parts in order
+ const segs = [];
+ if (TIME != null && timeIdx >= 0) segs.push({ start: timeIdx, end: timeIdx + TIME.length, color: "#f59e0b", label: "TIME" });
+ if (ENTRY != null && entryIdx >= 0) segs.push({ start: entryIdx, end: entryIdx + ENTRY.length, color: "#86efac", label: "ENTRY" });
+ segs.sort((a, b) => a.start - b.start);
+ for (const seg of segs) {
+ if (seg.start > cur) parts.push({ text: line.slice(cur, seg.start), color: "#4b5563", label: null });
+ parts.push({ text: line.slice(seg.start, seg.end), color: seg.color, label: seg.label });
+ cur = seg.end;
+ }
+ if (cur < line.length) parts.push({ text: line.slice(cur), color: "#4b5563", label: null });
+
+ return (
+
handleMouseUp(i, line)}
+ style={{ display: "flex", flexWrap: "wrap", gap: 0, alignItems: "baseline", padding: "1px 0" }}>
+ {parts.map((p, pi) => (
+ {p.text}
+ ))}
+
+ );
+ })}
+
+ );
+}
+
+// ββ LOG ENTRY EDITOR ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function LogEntryEditor({ entry, conn, index, onChange, onRemove, onDuplicate }) {
+ const [expanded, setExpanded] = useState(index === 0);
+ const [activeTab, setActiveTab] = useState("collect"); // "collect" | "activate" | "deactivate"
+
+ // Per-command terminal state
+ const [outputs, setOutputs] = useState({ collect: null, activate: null, deactivate: null });
+ const [errors, setErrors] = useState({ collect: "", activate: "", deactivate: "" });
+ const [running, setRunning] = useState(null); // null | "collect" | "activate" | "deactivate"
+
+ // Regex builder state
+ const [markMode, setMarkMode] = useState(null); // null | "TIME" | "ENTRY"
+ const [markedLine, setMarkedLine] = useState(null); // index of line used for regex building
+ const [timeSpan, setTimeSpan] = useState(null); // { start, end, text }
+ const [entrySpan, setEntrySpan] = useState(null); // { start, end, text }
+
+ // Local editable command strings (so the user can edit without committing on every keystroke)
+ const set = (k, v) => onChange({ ...entry, [k]: v });
+
+ const execCmd = async (tab) => {
+ const cmdMap = { collect: entry.log_file_cmd, activate: entry.log_activation_cmd, deactivate: entry.log_deactivation_cmd };
+ const cmd = cmdMap[tab];
+ if (!conn.ip_address || !conn.user) {
+ setErrors(p => ({ ...p, [tab]: "Fill in connection details first (Step 1)." }));
+ return;
+ }
+ if (!cmd || !cmd.trim()) {
+ setErrors(p => ({ ...p, [tab]: "No command entered." }));
+ return;
+ }
+ setRunning(tab);
+ setOutputs(p => ({ ...p, [tab]: null }));
+ setErrors(p => ({ ...p, [tab]: "" }));
+ try {
+ const payload = {
+ ip_address: conn.ip_address, port: Number(conn.port || 22),
+ user: conn.user, password: conn.password,
+ ssh_key_string: conn.ssh_key_string || "",
+ gateways: conn.gateways || [], command: cmd,
+ };
+ if (entry.custom_shell_prompt?.trim()) payload.custom_shell_prompt = entry.custom_shell_prompt.trim();
+ const res = await apiFetch("/api/devices/exec-command", { method: "POST", body: JSON.stringify(payload) });
+ const out = res.stdout || res.stderr || "(empty output)";
+ setOutputs(p => ({ ...p, [tab]: out }));
+ if (res.exit_code !== 0 && res.stderr) {
+ setErrors(p => ({ ...p, [tab]: `exit ${res.exit_code}: ${res.stderr.slice(0, 200)}` }));
+ }
+ } catch (e) {
+ setErrors(p => ({ ...p, [tab]: e.message }));
+ } finally {
+ setRunning(null);
+ }
+ };
+
+ // When user marks a span in the terminal, update timeSpan/entrySpan and rebuild regex
+ const handleMarkSpan = (lineIdx, start, end, text) => {
+ if (!markMode) return;
+ const span = { start, end, text };
+ let newTime = timeSpan, newEntry = entrySpan;
+ if (markMode === "TIME") { newTime = span; setTimeSpan(span); setMarkedLine(lineIdx); }
+ if (markMode === "ENTRY") { newEntry = span; setEntrySpan(span); setMarkedLine(lineIdx); }
+ setMarkMode(null);
+ // Rebuild regex from the line
+ const lines = (outputs.collect || "").split("\n").filter(l => l.trim());
+ const line = lines[markedLine != null && markMode === "ENTRY" ? markedLine : lineIdx] || "";
+ const built = buildRegexFromSpans(line, markMode === "TIME" ? span : newTime, markMode === "ENTRY" ? span : newEntry);
+ if (built) set("data_extraction_regex", built);
+ };
+
+ const clearSpans = () => { setTimeSpan(null); setEntrySpan(null); setMarkedLine(null); setMarkMode(null); };
+
+ // Regex validation
+ let reErr = "";
+ try { if (entry.data_extraction_regex) new RegExp(pyRegexToJs(entry.data_extraction_regex)); }
+ catch (e) { reErr = e.message; }
+
+ const collectLines = (outputs.collect || "").split("\n");
+ const matchCount = (() => {
+ if (!entry.data_extraction_regex || reErr) return null;
+ try {
+ const re = new RegExp(pyRegexToJs(entry.data_extraction_regex));
+ const nonEmpty = collectLines.filter(l => l.trim());
+ return { matched: nonEmpty.filter(l => re.test(l)).length, total: nonEmpty.length };
+ } catch { return null; }
+ })();
+
+ // Tab config
+ const TABS = [
+ { key: "collect", icon: "βΆ", label: "collect", cmdKey: "log_file_cmd", hint: "Command that fetches log data" },
+ { key: "activate", icon: "β‘", label: "activate", cmdKey: "log_activation_cmd", hint: "Runs before collection. Must exit 0 to enable. Use `true` to always enable." },
+ { key: "deactivate", icon: "β", label: "deactivate", cmdKey: "log_deactivation_cmd", hint: "Runs on collection stop to disable the log source. Optional." },
+ ];
+ const currentTab = TABS.find(t => t.key === activeTab);
+
+ // Styles
+ const S = {
+ terminal: {
+ background: "#0a0d12", border: "1px solid rgba(255,255,255,0.08)",
+ borderRadius: 8, overflow: "hidden",
+ },
+ termBar: {
+ background: "#111520", borderBottom: "1px solid rgba(255,255,255,0.07)",
+ display: "flex", alignItems: "center", padding: "0 0 0 14px", gap: 0,
+ },
+ termTab: (active, hasOutput, hasErr) => ({
+ display: "flex", alignItems: "center", gap: 5,
+ fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: active ? 700 : 400,
+ padding: "7px 14px", cursor: "pointer", border: "none",
+ borderBottom: active ? "2px solid var(--accent)" : "2px solid transparent",
+ background: active ? "rgba(129,140,248,0.08)" : "transparent",
+ color: active ? "var(--accent)" : (hasErr ? "#f87171" : hasOutput ? "#4ade80" : "var(--muted)"),
+ transition: "all 0.12s", whiteSpace: "nowrap",
+ }),
+ promptLine: {
+ display: "flex", alignItems: "center", gap: 8,
+ padding: "8px 14px", borderTop: "1px solid rgba(255,255,255,0.05)",
+ background: "rgba(0,0,0,0.2)",
+ },
+ runBtn: (busy) => ({
+ display: "flex", alignItems: "center", gap: 6,
+ background: busy ? "rgba(129,140,248,0.06)" : "rgba(129,140,248,0.12)",
+ border: "1px solid rgba(129,140,248,0.3)", borderRadius: 5,
+ color: "var(--accent)", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 700,
+ padding: "4px 12px", cursor: busy ? "not-allowed" : "pointer",
+ opacity: busy ? 0.6 : 1, flexShrink: 0, whiteSpace: "nowrap",
+ }),
+ cmdInput: {
+ flex: 1, background: "transparent", border: "none", outline: "none",
+ color: "#e2e8f0", fontFamily: "var(--font-mono)", fontSize: 11,
+ padding: 0, caretColor: "var(--accent)",
+ },
+ markBtn: (active, color) => ({
+ display: "flex", alignItems: "center", gap: 4,
+ background: active ? `${color}22` : "rgba(255,255,255,0.04)",
+ border: `1px solid ${active ? color : "rgba(255,255,255,0.1)"}`,
+ borderRadius: 4, color: active ? color : "var(--muted)",
+ fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 700,
+ padding: "3px 8px", cursor: "pointer", transition: "all 0.12s", whiteSpace: "nowrap",
+ }),
+ };
+
+ const logTypeColors = { text: "cyan", chart: "violet" };
+
+ return (
+
+ {/* ββ Header ββ */}
+
setExpanded(v => !v)}
+ style={{
+ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px",
+ cursor: "pointer", background: expanded ? "rgba(129,140,248,0.04)" : "transparent",
+ borderBottom: expanded ? "1px solid rgba(255,255,255,0.07)" : "none",
+ }}
+ >
+ {/* index pill */}
+ {String(index + 1).padStart(2, "0")}
+
+ {/* log name */}
+
+ {entry.log_name || unnamed entry }
+
+
+ {/* badges */}
+ {entry.log_type}
+ {outputs.collect && collect β }
+ {outputs.activate && activate β }
+ {outputs.deactivate && deactivate β }
+ {entry.data_extraction_regex && !reErr && matchCount && (
+ 0 ? "green" : "red"}>
+ {matchCount.matched}/{matchCount.total} lines
+
+ )}
+
+ { e.stopPropagation(); onDuplicate(); }} title="Duplicate"
+ style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", fontSize: 13, padding: "2px 6px" }}>β
+ { e.stopPropagation(); onRemove(); }} title="Remove"
+ style={{ background: "none", border: "none", cursor: "pointer", color: "#f87171", fontSize: 15, padding: "2px 6px" }}>Γ
+ {expanded ? "β²" : "βΌ"}
+
+
+ {expanded && (
+
+
+ {/* ββ Row 1: Log Name / Type / Data Unit ββ */}
+
+
+
Log Name *
+
set("log_name", e.target.value)}
+ placeholder="e.g. syslog, cpu_usage" style={inputStyle} />
+
+
+
Type
+
+ {["text", "chart"].map(t => (
+ set("log_type", t)} style={{
+ padding: "9px 14px", borderRadius: 7, cursor: "pointer", border: "1px solid",
+ fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 600,
+ background: entry.log_type === t ? (t === "text" ? "rgba(34,211,238,0.14)" : "rgba(167,139,250,0.14)") : "rgba(255,255,255,0.03)",
+ color: entry.log_type === t ? (t === "text" ? "#22d3ee" : "#a78bfa") : "var(--muted)",
+ borderColor: entry.log_type === t ? (t === "text" ? "rgba(34,211,238,0.4)" : "rgba(167,139,250,0.4)") : "var(--border)",
+ transition: "all 0.12s",
+ }}>{t === "text" ? "π text" : "π chart"}
+ ))}
+
+
+ {entry.log_type === "chart" && (
+
+
Unit
+
set("data_unit", e.target.value)}
+ placeholder="%, Β°C, msβ¦" style={{ ...inputStyle, width: 90 }} />
+
+ )}
+
+
+ {/* ββ Description ββ */}
+
+
+ {/* ββ Custom Shell Prompt (collapsed inline) ββ */}
+
+ shell prompt
+ set("custom_shell_prompt", e.target.value)}
+ placeholder="leave empty for standard exec Β· e.g. router# or $"
+ style={{ ...inputStyle, fontSize: 11, padding: "7px 12px", background: "rgba(255,255,255,0.02)" }} />
+
+
+ {/* ββ Terminal Panel ββ */}
+
+ {/* Tab bar */}
+
+ {/* Traffic lights */}
+
+ {["#f87171","#fbbf24","#4ade80"].map((c, i) => (
+
+ ))}
+
+ {TABS.map(tab => (
+
setActiveTab(tab.key)}
+ style={S.termTab(
+ activeTab === tab.key,
+ outputs[tab.key] != null,
+ !!errors[tab.key],
+ )}>
+ {tab.icon}
+ {tab.label}
+ {running === tab.key && (
+
+
+
+ )}
+ {outputs[tab.key] != null && running !== tab.key && (
+
+ {errors[tab.key] ? "β" : "β"}
+
+ )}
+
+ ))}
+
+
+ {conn.user && conn.ip_address ? `${conn.user}@${conn.ip_address}` : "not connected"}
+
+
+
+ {/* Command prompt input line */}
+
+ $
+ set(currentTab.cmdKey, e.target.value)}
+ onKeyDown={e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); execCmd(activeTab); } }}
+ placeholder={currentTab.hint}
+ style={S.cmdInput}
+ />
+ execCmd(activeTab)} disabled={!!running} style={S.runBtn(!!running)}>
+ {running === activeTab ? (
+ <>
+
+ running>
+ ) : "run β΅"}
+
+
+
+ {/* Error bar */}
+ {errors[activeTab] && (
+
β {errors[activeTab]}
+ )}
+
+ {/* Output area */}
+ {outputs[activeTab] != null ? (
+
+ ) : (
+
+ {running === activeTab
+ ? "executingβ¦"
+ : `press run β΅ to test ${activeTab} command`}
+
+ )}
+
+
+ {/* ββ Regex Builder (only shown when collect has output) ββ */}
+ {activeTab === "collect" && outputs.collect != null && (
+
+ {/* Header row */}
+
+
+ extraction regex
+
+ {matchCount && (
+
0 ? "#4ade80" : "#f87171",
+ background: matchCount.matched > 0 ? "rgba(74,222,128,0.08)" : "rgba(248,113,113,0.08)",
+ border: `1px solid ${matchCount.matched > 0 ? "rgba(74,222,128,0.2)" : "rgba(248,113,113,0.2)"}`,
+ borderRadius: 4, padding: "2px 7px",
+ }}>
+ {matchCount.matched > 0 ? `β ${matchCount.matched}/${matchCount.total} lines matched` : `β 0/${matchCount.total} matched`}
+
+ )}
+
+ {/* Mark-mode buttons */}
+
click to mark β
+
setMarkMode(markMode === "TIME" ? null : "TIME")}
+ style={S.markBtn(markMode === "TIME", "#f59e0b")}
+ >
+ {timeSpan ? `TIME: "${timeSpan.text.slice(0,16)}${timeSpan.text.length>16?"β¦":""}"` : "β± mark TIME"}
+
+
setMarkMode(markMode === "ENTRY" ? null : "ENTRY")}
+ style={S.markBtn(markMode === "ENTRY", "#86efac")}
+ >
+ {entrySpan ? `ENTRY: "${entrySpan.text.slice(0,16)}${entrySpan.text.length>16?"β¦":""}"` : "π mark ENTRY"}
+
+ {(timeSpan || entrySpan) && (
+
+ β clear
+
+ )}
+
+
+ {/* Mark-mode active hint */}
+ {markMode && (
+
+ β¦ Select the {markMode} portion in the terminal output above, then release.
+ {markMode === "TIME" ? " This will capture the timestamp." : " This will capture the log value/content."}
+
+ )}
+
+ {/* Regex input */}
+
{ set("data_extraction_regex", e.target.value); clearSpans(); }}
+ placeholder="^(?P
\\w+\\s+\\d+\\s+\\d+:\\d+:\\d+)\\s+(?P.*)"
+ style={{
+ ...inputStyle,
+ fontFamily: "var(--font-mono)", fontSize: 11,
+ background: "rgba(0,0,0,0.4)",
+ borderColor: reErr ? "rgba(248,113,113,0.5)" : "rgba(129,140,248,0.25)",
+ color: reErr ? "#f87171" : "#a5b4fc",
+ }}
+ />
+ {reErr && (
+
+ β {reErr}
+
+ )}
+
+ Named groups: (?P<TIME>β¦) for timestamp Β· (?P<ENTRY>β¦) for value
+
+
+ )}
+
+
+ )}
+
+ );
+}
+
+// ββ CONFIG BUILDER WIZARD ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function ConfigBuilderModal({ open, onClose, onSave }) {
+ const [step, setStep] = useState(1); // 1=connection, 2=log entries
+ const EMPTY_CONN = () => ({
+ device_name: "", ip_address: "", port: 22,
+ user: "pi", password: "", ssh_key_string: "", authMode: "password", collection_interval: 30,
+ gateways: [],
+ });
+ const [conn, setConn] = useState(EMPTY_CONN);
+ const [entries, setEntries] = useState([EMPTY_LOG_ENTRY()]);
+ const [connStatus, setConnStatus] = useState(null); // null | "testing" | {success, message}
+ const [saving, setSaving] = useState(false);
+
+ const EMPTY_PACKET_CAPTURE = () => ({
+ enabled: false,
+ capture_start_cmd: "tcpdump -i any",
+ capture_stop_cmd: "pkill -INT -f 'tcpdump -i any'",
+ capture_description: "Network packet capture",
+ max_pcap_size_mb: "",
+ });
+ const [packetCapture, setPacketCapture] = useState(EMPTY_PACKET_CAPTURE);
+ const setPC = (k, v) => setPacketCapture(prev => ({ ...prev, [k]: v }));
+
+ const setC = (k, v) => setConn(prev => ({ ...prev, [k]: v }));
+
+ const testConnection = async () => {
+ setConnStatus("testing");
+ try {
+ const res = await apiFetch("/api/devices/test-connection", {
+ method: "POST",
+ body: JSON.stringify({
+ ip_address: conn.ip_address,
+ port: Number(conn.port),
+ user: conn.user,
+ password: conn.password,
+ ssh_key_string: conn.ssh_key_string || "",
+ gateways: conn.gateways,
+ }),
+ });
+ setConnStatus(res);
+ } catch (e) {
+ setConnStatus({ success: false, message: e.message });
+ }
+ };
+
+ const addEntry = () => setEntries(prev => [...prev, EMPTY_LOG_ENTRY()]);
+
+ const updateEntry = (idx, updated) =>
+ setEntries(prev => prev.map((e, i) => i === idx ? updated : e));
+
+ const removeEntry = (idx) =>
+ setEntries(prev => prev.filter((_, i) => i !== idx));
+
+ const duplicateEntry = (idx) => {
+ const src = entries[idx];
+ const clone = { ...src, _id: Math.random().toString(36).slice(2), log_name: src.log_name + "_copy" };
+ setEntries(prev => [...prev.slice(0, idx + 1), clone, ...prev.slice(idx + 1)]);
+ };
+
+ const buildConfig = () => {
+ const log_file_configs = entries.map(({ _id, ...rest }) => {
+ // Drop optional keys that were left empty so they don't appear in the config
+ const entry = { ...rest };
+ if (!entry.log_activation_cmd) delete entry.log_activation_cmd;
+ if (!entry.log_deactivation_cmd) delete entry.log_deactivation_cmd;
+ if (!entry.custom_shell_prompt) delete entry.custom_shell_prompt;
+ return entry;
+ });
+ const config = {
+ device_name: conn.device_name || `device-${conn.ip_address}`,
+ ip_address: conn.ip_address,
+ port: Number(conn.port),
+ user: conn.user,
+ collection_interval: Number(conn.collection_interval),
+ log_file_configs,
+ };
+
+ // Include whichever auth method is populated; omit the other if empty
+ if (conn.ssh_key_string) config.ssh_key_string = conn.ssh_key_string;
+ if (conn.password) config.password = conn.password;
+
+ // Convert flat gateways[] β nested { gateway: { ..., gateway: { ... } } }
+ // Hops are ordered outermost-first, so fold right-to-left.
+ if (conn.gateways && conn.gateways.length > 0) {
+ const nested = [...conn.gateways]
+ .reverse()
+ .reduce((inner, hop) => {
+ const hopObj = {
+ ip_address: hop.ip_address,
+ port: Number(hop.port || 22),
+ user: hop.user,
+ };
+ if (hop.ssh_key_string) hopObj.ssh_key_string = hop.ssh_key_string;
+ if (hop.password) hopObj.password = hop.password;
+ if (inner) hopObj.gateway = inner;
+ return hopObj;
+ }, null);
+
+ config.gateway = nested;
+ }
+
+ if (packetCapture.enabled) {
+ const pcc = {
+ capture_start_cmd: packetCapture.capture_start_cmd,
+ capture_stop_cmd: packetCapture.capture_stop_cmd,
+ capture_description: packetCapture.capture_description || "Network packet capture",
+ };
+ // Omit when blank so the watchdog treats it as "unlimited" rather
+ // than a literal 0 MB cap.
+ if (packetCapture.max_pcap_size_mb !== "" && packetCapture.max_pcap_size_mb != null) {
+ pcc.max_pcap_size_mb = Number(packetCapture.max_pcap_size_mb);
+ }
+ config.packets_capture_config = pcc;
+ }
+
+ return config;
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ const config = buildConfig();
+ const b64 = btoa(JSON.stringify(config, null, 2));
+ await onSave(`data:application/json;base64,${b64}`);
+ onClose();
+ // Reset
+ setStep(1);
+ setConn(EMPTY_CONN());
+ setEntries([EMPTY_LOG_ENTRY()]);
+ setPacketCapture(EMPTY_PACKET_CAPTURE());
+ setConnStatus(null);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const downloadConfig = () => {
+ const config = buildConfig();
+ const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
+ const a = document.createElement("a");
+ const cfgBlobUrl = URL.createObjectURL(blob);
+ a.href = cfgBlobUrl;
+ a.download = `${conn.device_name || "device"}_config.json`;
+ a.click();
+ // FIX: revoke to release blob memory.
+ URL.revokeObjectURL(cfgBlobUrl);
+ };
+
+ if (!open) return null;
+
+ const step1Valid = conn.ip_address && conn.user && conn.port;
+ const packetCaptureValid = !packetCapture.enabled ||
+ (packetCapture.capture_start_cmd.trim() && packetCapture.capture_stop_cmd.trim());
+ const step2Valid = entries.length > 0 && entries.every(e => e.log_name && e.log_file_cmd) && packetCaptureValid;
+
+ const stepTabStyle = (s) => ({
+ display: "flex", alignItems: "center", gap: 9, padding: "12px 24px",
+ background: step === s ? "rgba(129,140,248,0.08)" : "transparent",
+ border: "none", borderBottom: `2px solid ${step === s ? "var(--accent)" : "transparent"}`,
+ color: step === s ? "var(--accent)" : step > s ? "#4ade80" : "var(--muted)",
+ fontFamily: "var(--font-display)", fontWeight: 700, fontSize: 13,
+ cursor: "pointer", transition: "all 0.15s", letterSpacing: "0.04em",
+ opacity: (s === 2 && !step1Valid) ? 0.4 : 1,
+ });
+
+ return (
+ { if (e.target === e.currentTarget) onClose(); }}>
+
+
+ {/* Header */}
+
+
+
+ π
+
+
+
Device Config Builder
+
Build and test your configuration interactively
+
+
+
e.currentTarget.style.color = "var(--text)"}
+ onMouseLeave={e => e.currentTarget.style.color = "var(--muted)"}
+ >Γ
+
+
+ {/* Step tabs */}
+
+ setStep(1)}>
+ 1 ? "rgba(74,222,128,0.2)" : "rgba(129,140,248,0.15)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 10, fontWeight: 800, color: step > 1 ? "#4ade80" : "var(--accent)" }}>
+ {step > 1 ? "β" : "1"}
+
+ Connection
+
+ step1Valid && setStep(2)}>
+ 2
+ Log Entries
+ ({entries.length})
+
+
+
+ {/* Body */}
+
+
+ {/* ββ STEP 1: Connection ββ */}
+ {step === 1 && (
+
+
+
Device Identity
+
+
+
Device Name *
+
setC("device_name", e.target.value)}
+ placeholder="Raspberry-PI-Zero" style={inputStyle} />
+
+
+
Collection Interval (sec)
+
setC("collection_interval", e.target.value)}
+ min={5} style={inputStyle} />
+
+
+
+
+
+
+
SSH Credentials
+ {/* Auth mode toggle */}
+
+ {["password", "key"].map(mode => (
+ setC("authMode", mode)} style={{
+ padding: "5px 14px", border: "none", cursor: "pointer",
+ fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 600,
+ background: conn.authMode === mode ? "rgba(129,140,248,0.18)" : "transparent",
+ color: conn.authMode === mode ? "var(--accent)" : "var(--muted)",
+ transition: "all 0.12s",
+ }}>
+ {mode === "password" ? "π Password" : "π SSH Key"}
+
+ ))}
+
+
+
+
+
IP Address *
+
setC("ip_address", e.target.value)}
+ placeholder="10.01.230.23" style={inputStyle} />
+
+
+
Port
+
setC("port", e.target.value)}
+ style={{ ...inputStyle, width: 80 }} />
+
+
+
+
+
Username *
+
setC("user", e.target.value)}
+ placeholder="pi" style={inputStyle} />
+
+ {conn.authMode === "password" ? (
+
+
Password
+
setC("password", e.target.value)}
+ placeholder="β’β’β’β’β’β’β’β’" style={inputStyle} />
+
+ ) : (
+
+
+ Paste the private key below
+
+
+ )}
+
+ {conn.authMode === "key" && (
+
+ )}
+
+ {/* Test connection */}
+
+
+ {connStatus === "testing" ? (
+ <>
+
+ Testingβ¦>
+ ) : "π Test Connection"}
+
+
+ {connStatus && connStatus !== "testing" && (
+
+ {connStatus.success ? "β" : "β"} {connStatus.message}
+
+ )}
+
+
+
+ {/* ββ Gateway Hops ββ */}
+
+
+
+
SSH Gateway Hops
+
+ Optional jump hosts. Hops are chained left-to-right: hop 1 β hop 2 β β¦ β target device.
+
+
+
setC("gateways", [...(conn.gateways || []), { _id: Math.random().toString(36).slice(2), ip_address: "", port: 22, user: "", password: "", ssh_key_string: "", authMode: "password" }])}
+ style={{ background: "rgba(129,140,248,0.1)", border: "1px solid rgba(129,140,248,0.3)", borderRadius: 7, color: "var(--accent)", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 700, padding: "5px 12px", cursor: "pointer", whiteSpace: "nowrap" }}
+ >
+ + Add Hop
+
+
+
+ {(!conn.gateways || conn.gateways.length === 0) && (
+
+ No gateway hops β direct connection to target device.
+
+ )}
+
+ {(conn.gateways || []).map((hop, hi) => {
+ const setHop = (k, v) => {
+ const updated = conn.gateways.map((h, i) => i === hi ? { ...h, [k]: v } : h);
+ setC("gateways", updated);
+ };
+ const removeHop = () => setC("gateways", conn.gateways.filter((_, i) => i !== hi));
+ const moveUp = () => { if (hi === 0) return; const g = [...conn.gateways]; [g[hi-1], g[hi]] = [g[hi], g[hi-1]]; setC("gateways", g); };
+ const moveDown = () => { if (hi === conn.gateways.length - 1) return; const g = [...conn.gateways]; [g[hi], g[hi+1]] = [g[hi+1], g[hi]]; setC("gateways", g); };
+ return (
+
+ {/* Hop header */}
+
+
+ HOP {hi + 1}
+
+ {hop.ip_address && (
+
+ {hop.user ? `${hop.user}@` : ""}{hop.ip_address}:{hop.port || 22}
+
+ )}
+
+ β²
+ βΌ
+ Γ
+
+
+ {/* Hop fields */}
+
+
+
IP Address
+
setHop("ip_address", e.target.value)} placeholder="10.0.1.1" style={inputStyle} />
+
+
+
Port
+
setHop("port", e.target.value)} style={inputStyle} />
+
+
+
Username
+
setHop("user", e.target.value)} placeholder="admin" style={inputStyle} />
+
+
+
+
Auth
+
+ {["password", "key"].map(mode => (
+ setHop("authMode", mode)} style={{
+ padding: "2px 9px", border: "none", cursor: "pointer",
+ fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 600,
+ background: hop.authMode === mode ? "rgba(129,140,248,0.18)" : "transparent",
+ color: hop.authMode === mode ? "var(--accent)" : "var(--muted)",
+ transition: "all 0.12s",
+ }}>{mode === "password" ? "pwd" : "key"}
+ ))}
+
+
+ {hop.authMode !== "key" ? (
+
setHop("password", e.target.value)} placeholder="β’β’β’β’β’β’" style={inputStyle} />
+ ) : (
+
+ )}
+
+
+ {hop.authMode === "key" && (
+
+ )}
+
+ );
+ })}
+
+ {(conn.gateways || []).length > 0 && (
+
+ β
+ {(conn.gateways || []).map((h, i) => (
+ {h.ip_address || `hop${i+1}`}{i < conn.gateways.length - 1 ? " β " : ""}
+ ))}
+ β {conn.ip_address || "target"}
+
+ )}
+
+
+ )}
+
+ {/* ββ STEP 2: Log Entries ββ */}
+ {step === 2 && (
+
+
+
+ {entries.length} log entr{entries.length === 1 ? "y" : "ies"} Β· Click βΆ Run on Device to test each command live
+
+
+ {
+ // Add a set of common Raspberry Pi log entries as a template
+ setEntries(prev => [...prev,
+ { _id: Math.random().toString(36).slice(2), log_name: "syslog", log_file_cmd: "sudo journalctl -n 200 --no-pager", data_extraction_regex: "^(?P\\w+\\s+\\d+\\s+\\d+:\\d+:\\d+)\\s+(?P.*)", log_activation_cmd: "ls -la", log_type: "text", data_unit: "" },
+ { _id: Math.random().toString(36).slice(2), log_name: "cpu_usage_percent", log_file_cmd: "echo $(date '+%Y-%m-%d %H:%M:%S'),$(top -bn1 | grep 'Cpu(s)' | awk '{print 100-$8}')", data_extraction_regex: "^(?P\\d+-\\d+-\\d+\\s\\d+:\\d+:\\d+),(?P.*)", log_activation_cmd: "true", log_type: "chart", data_unit: "%" },
+ ]);
+ }}
+ style={{ background: "rgba(255,255,255,0.04)", border: "1px solid var(--border)", borderRadius: 7, color: "var(--muted)", fontFamily: "var(--font-mono)", fontSize: 11, padding: "6px 12px", cursor: "pointer" }}
+ >
+ + Add Templates
+
+
+ + Add Entry
+
+
+
+
+ {entries.map((entry, idx) => (
+
updateEntry(idx, updated)}
+ onRemove={() => removeEntry(idx)}
+ onDuplicate={() => duplicateEntry(idx)}
+ />
+ ))}
+
+ {entries.length === 0 && (
+
+ No entries yet. Click "+ Add Entry" to get started.
+
+ )}
+
+ {/* ββ Network Packet Capture (optional) ββ */}
+
+
+
+
Network Packet Capture
+
+ Optional. Captures raw traffic via tcpdump/tshark over SSH alongside the log entries above.
+
+
+
+ Enabled
+ setPC("enabled", e.target.checked)}
+ style={{ accentColor: "var(--accent)", width: 15, height: 15 }}
+ />
+
+
+
+ {packetCapture.enabled && (
+ <>
+
+
+
Capture Start Command
+
setPC("capture_start_cmd", e.target.value)}
+ placeholder="tcpdump -i any"
+ style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
+ />
+
+ Remote command that writes pcap data to stdout. The capture pipeline appends the output redirection itself β don't add it here.
+
+
+
+
Capture Stop Command
+
setPC("capture_stop_cmd", e.target.value)}
+ placeholder="pkill -INT -f 'tcpdump -i any'"
+ style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
+ />
+
+ Remote command that signals the capture process to stop and flush cleanly.
+
+
+
+
+
+
Description
+
setPC("capture_description", e.target.value)}
+ placeholder="Network packet capture"
+ style={inputStyle}
+ />
+
+
+
Max PCAP Size (MB)
+
setPC("max_pcap_size_mb", e.target.value)}
+ placeholder="Unlimited"
+ style={inputStyle}
+ />
+
+ Capture stops automatically once reached. Blank = unlimited.
+
+
+
+ >
+ )}
+
+
+ )}
+
+
+ {/* Footer */}
+
+
+ {step === 2 && (
+
+ β¬ Download JSON
+
+ )}
+
+
+ {step === 1 && (
+ setStep(2)} disabled={!step1Valid}>
+ Next: Log Entries β
+
+ )}
+ {step === 2 && (
+ <>
+ setStep(1)}>β Back
+
+ {saving ? "Savingβ¦" : "πΎ Save Device"}
+
+ >
+ )}
+ Cancel
+
+
+
+
+ );
+}
+
+// ββ ADD DEVICE BUTTON + CHOICE MODAL ββββββββββββββββββββββββββββββββββββββββββ
+function AddDeviceBtn({ onUpload, onBuildConfig }) {
+ const [choiceOpen, setChoiceOpen] = useState(false);
+ const fileRef = useRef();
+
+ const handleFile = (e) => {
+ const files = Array.from(e.target.files || []);
+ if (files.length === 0) return;
+
+ Promise.all(
+ files.map(
+ (file) =>
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = (ev) => resolve(ev.target.result);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsDataURL(file);
+ })
+ )
+ ).then((contentsList) => {
+ onUpload(contentsList.length === 1 ? contentsList[0] : contentsList);
+ setChoiceOpen(false);
+ });
+
+ e.target.value = "";
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {/* Animated concentric rings + logo */}
+
+
+
+
+
+
+
+
+
+
+
+ {[0, 45, 90, 135, 180, 225, 270, 315].map((a, i) => {
+ const rad = (a * Math.PI) / 180;
+ return ;
+ })}
+
+
+
+
+ {/* Title */}
+
+ Stopping Collection
+
+
+ {/* Progress bar */}
+
+ {/* Bar track */}
+
+ {pct !== null ? (
+
+ ) : (
+ /* Indeterminate shimmer when no expected count */
+
+ )}
+
+
+
+ {/* Counts row */}
+
+
+ {saved > 0 ? (
+ <>{saved}
+ {expected > 0 ? ` / ${expected} snapshot${expected !== 1 ? "s" : ""} saved` : ` snapshot${saved !== 1 ? "s" : ""} saved`}>
+ ) : (
+ "Waiting for snapshotsβ¦"
+ )}
+
+ {pct !== null && (
+
+ {pct}%
+
+ )}
+
+
+
+ {/* Cycling status message */}
+
+ {STOP_MESSAGES[msgIdx]}
+
+
+ {/* Bouncing dots */}
+
+ {[0,1,2].map(i => (
+
+ ))}
+
+
+ {/* Subtle disclaimer */}
+
+ Waiting for device teardown β this may take up to 5 min.
+ Do not close the tab.
+
+
+
+ );
+}
+
+// ββ SESSION INFO ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function SessionInfo({ sessionId, textUrl, chartUrl, partial = false }) {
+ return (
+
+
+
+ {/* Concentric rings + logo */}
+
+
+
+
+
+
+
+
+
+
+
+ {[0, 45, 90, 135, 180, 225, 270, 315].map((a, i) => {
+ const rad = (a * Math.PI) / 180;
+ return ;
+ })}
+
+
+
+
+ {/* Title */}
+
+ Loading Logs
+
+
+ {/* Progress bar */}
+
+
+ {pct !== null ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Counts row */}
+
+
+ {done > 0 ? (
+ <>{done}
+ {total > 0 ? ` / ${total} snapshot${total !== 1 ? "s" : ""} loaded` : ` snapshot${done !== 1 ? "s" : ""} loaded`}>
+ ) : (
+ "Waiting for responseβ¦"
+ )}
+
+ {pct !== null && (
+
+ {pct}%
+
+ )}
+
+
+
+ {/* Cycling status message */}
+
+ {LOG_LOAD_MESSAGES[msgIdx]}
+
+
+ {/* Bouncing dots */}
+
+ {[0, 1, 2].map(i => (
+
+ ))}
+
+
+ );
+}
+
+// ββ MAIN APP ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+const PULSE_KF = `
+ @keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:0.4;transform:scale(1.6)} }
+ @keyframes spin { from{transform:rotate(0deg)} to{transform:rotate(360deg)} }
+`;
+
+export default function App() {
+ const auth = useAuth();
+
+ const [devices, setDevices] = useState([]);
+ const [devicesLoading, setDevicesLoading] = useState(true);
+ const [systemStats, setSystemStats] = useState(null);
+ const [selectedDevices, setSelectedDevices] = useState([]);
+ // Device groups: { id, name, deviceIds[] }
+ const [groups, setGroups] = useState(() => {
+ try { return JSON.parse(localStorage.getItem("lo_device_groups") || "[]"); } catch { return []; }
+ });
+ const [collapsedGroups, setCollapsedGroups] = useState(() => {
+ try { return new Set(JSON.parse(localStorage.getItem("lo_collapsed_groups") || "[]")); } catch { return new Set(); }
+ });
+ const [newGroupName, setNewGroupName] = useState("");
+ const [creatingGroup, setCreatingGroup] = useState(false);
+ const [snapshots, setSnapshots] = useState([]);
+ const [snapsTotal, setSnapsTotal] = useState(0);
+ const [snapsTotalPages, setSnapsTotalPages] = useState(1);
+ const [snapsPage, setSnapsPage] = useState(1);
+ const [pageSize, setPageSize] = useState(() => {
+ const stored = parseInt(localStorage.getItem("lo_page_size"), 10);
+ return (stored > 0 && stored <= 500) ? stored : 25;
+ });
+ const [snapsLoading, setSnapsLoading] = useState(true);
+ const [selectedSnaps, setSelectedSnaps] = useState([]);
+ const [isChart, setIsChart] = useState(false);
+ const [searchParam, setSearchParam] = useState("");
+ const [searchValue, setSearchValue] = useState("");
+ const [filterActive, setFilterActive] = useState(false);
+
+ // stop-collection loading overlay
+ const [stoppingCollection, setStoppingCollection] = useState(false);
+ const [stopProgress, setStopProgress] = useState({ saved: 0, expected: 0, sessionId: "" });
+
+ // modals
+ const [logModal, setLogModal] = useState(false);
+ const [logRows, setLogRows] = useState([]);
+ const [chartGroups, setChartGroups] = useState([]); // [{ snapInfo, rows }]
+ const [logRowsLoading, setLogRowsLoading] = useState(false);
+ const [viewingSnaps, setViewingSnaps] = useState([]); // snapshots currently open in the log modal
+ const [logLoadProgress, setLogLoadProgress] = useState({ done: 0, total: 0 });
+ const [colorMode, setColorMode] = useState(false);
+
+ // packet_capture "view packet details" modal
+ const [packetModal, setPacketModal] = useState(false);
+ const [packetModalData, setPacketModalData] = useState(null); // { packet_number, details }
+ const [packetModalLoading, setPacketModalLoading] = useState(false);
+ const [packetModalError, setPacketModalError] = useState("");
+ const [deviceModal, setDeviceModal] = useState(null);
+ const [sessionModal, setSessionModal] = useState(null);
+ const [apiModal, setApiModal] = useState(false);
+ const [builderModal, setBuilderModal] = useState(false);
+ const [loginModal, setLoginModal] = useState(false);
+ const [settingsModal, setSettingsModal] = useState(false);
+ const [scenarioModal, setScenarioModal] = useState(false);
+ const [scenarioInput, setScenarioInput] = useState("");
+ const [scenarioError, setScenarioError] = useState(false);
+ const [downloadingSnaps, setDownloadingSnaps] = useState(false);
+
+ // removal confirmation dialogs
+ const [confirmRemoveDevices, setConfirmRemoveDevices] = useState(false);
+ const [removingDevices, setRemovingDevices] = useState(false);
+ const [confirmRemoveSnaps, setConfirmRemoveSnaps] = useState(false);
+ const [removingSnaps, setRemovingSnaps] = useState(false);
+
+ // toasts
+ const [toasts, setToasts] = useState([]);
+ const addToast = useCallback((message, type = "error") => setToasts((prev) => [...prev, { id: Date.now(), message, type }]), []);
+ const dismissToast = useCallback((id) => setToasts((prev) => prev.filter((t) => t.id !== id)), []);
+
+ // ββ data fetching ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ const fetchDevices = useCallback(async () => {
+ try {
+ setDevices(await apiFetch("/api/devices"));
+ } catch (e) {
+ addToast(`Failed to load devices: ${e.message}`);
+ } finally {
+ setDevicesLoading(false);
+ }
+ }, [addToast]);
+
+ const fetchSystemStats = useCallback(async () => {
+ try {
+ setSystemStats(await apiFetch("/api/system/stats"));
+ } catch {
+ // Non-critical: leave the last known stats (or null) in place rather
+ // than spamming a toast every poll interval.
+ }
+ }, []);
+
+ const fetchSnapshots = useCallback(async (param, value, chart, page = 1, pSize) => {
+ setSnapsLoading(true);
+ try {
+ const ps = pSize ?? pageSize;
+ let url = `/api/snapshots?log_type=${chart ? "chart" : "text"}&page=${page}&page_size=${ps}`;
+ if (param && value) url += `&search_param=${encodeURIComponent(param)}&search_value=${encodeURIComponent(value)}`;
+ const data = await apiFetch(url);
+ setSnapshots(data.items ?? []);
+ setSnapsTotal(data.total ?? 0);
+ setSnapsTotalPages(data.total_pages ?? 1);
+ setSnapsPage(data.page ?? 1);
+ } catch (e) {
+ addToast(`Failed to load snapshots: ${e.message}`);
+ } finally {
+ setSnapsLoading(false);
+ }
+ }, [addToast, pageSize]);
+
+ useEffect(() => {
+ localStorage.setItem("lo_device_groups", JSON.stringify(groups));
+ }, [groups]);
+
+ useEffect(() => {
+ localStorage.setItem("lo_collapsed_groups", JSON.stringify([...collapsedGroups]));
+ }, [collapsedGroups]);
+
+ const toggleGroupCollapse = (groupId) => {
+ setCollapsedGroups(prev => {
+ const next = new Set(prev);
+ if (next.has(groupId)) next.delete(groupId);
+ else next.add(groupId);
+ return next;
+ });
+ };
+
+ // ββ group helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ const createGroup = () => {
+ const name = newGroupName.trim();
+ if (!name) return;
+ const taken = [
+ ...groups.map(g => g.name.toLowerCase()),
+ ...devices.map(d => d.name.toLowerCase()),
+ ];
+ if (taken.includes(name.toLowerCase())) {
+ addToast(`Name "${name}" is already in use by a device or group.`);
+ return;
+ }
+ setGroups(prev => [...prev, { id: Date.now().toString(36), name, deviceIds: [] }]);
+ setNewGroupName("");
+ setCreatingGroup(false);
+ };
+
+ const deleteGroup = (groupId) =>
+ setGroups(prev => prev.filter(g => g.id !== groupId));
+
+ const renameGroup = (groupId, name) => {
+ const nameLower = name.toLowerCase();
+ const taken = [
+ ...groups.filter(g => g.id !== groupId).map(g => g.name.toLowerCase()),
+ ...devices.map(d => d.name.toLowerCase()),
+ ];
+ if (taken.includes(nameLower)) {
+ addToast(`Name "${name}" is already in use by a device or group.`);
+ return false;
+ }
+ setGroups(prev => prev.map(g => g.id === groupId ? { ...g, name } : g));
+ return true;
+ };
+
+ // Move a device into a group (removes from other groups first)
+ const moveDeviceToGroup = (deviceId, targetGroupId) => {
+ setGroups(prev => prev.map(g => {
+ if (g.id === targetGroupId) {
+ return { ...g, deviceIds: g.deviceIds.includes(deviceId) ? g.deviceIds : [...g.deviceIds, deviceId] };
+ }
+ return { ...g, deviceIds: g.deviceIds.filter(id => id !== deviceId) };
+ }));
+ };
+
+ // Reorder a device within its group: move srcId to be placed before destId
+ const reorderDeviceInGroup = (groupId, srcId, destId) => {
+ setGroups(prev => prev.map(g => {
+ if (g.id !== groupId) return g;
+ const ids = g.deviceIds.filter(id => id !== srcId);
+ const destIdx = ids.indexOf(destId);
+ if (destIdx === -1) return { ...g, deviceIds: [...ids, srcId] };
+ ids.splice(destIdx, 0, srcId);
+ return { ...g, deviceIds: ids };
+ }));
+ };
+
+ // Remove a device from all groups
+ const removeDeviceFromGroups = (deviceId) =>
+ setGroups(prev => prev.map(g => ({ ...g, deviceIds: g.deviceIds.filter(id => id !== deviceId) })));
+
+ // Devices not in any group
+ const ungroupedDevices = devices.filter(d => !groups.some(g => g.deviceIds.includes(d.id)));
+
+ // Device names visible in non-collapsed groups (used to filter snapshots table)
+ const visibleDeviceNames = new Set(
+ devices
+ .filter(d => {
+ const ownerGroup = groups.find(g => g.deviceIds.includes(d.id));
+ // Ungrouped devices are always visible; grouped devices only if group not collapsed
+ return !ownerGroup || !collapsedGroups.has(ownerGroup.id);
+ })
+ .map(d => d.name)
+ );
+
+ useEffect(() => { fetchDevices(); }, [fetchDevices]);
+ useEffect(() => { fetchSnapshots("", "", false); }, [fetchSnapshots]);
+ useEffect(() => { fetchSystemStats(); }, [fetchSystemStats]);
+
+ useEffect(() => {
+ const id = setInterval(fetchDevices, 10000);
+ return () => clearInterval(id);
+ }, [fetchDevices]);
+
+ useEffect(() => {
+ const id = setInterval(fetchSystemStats, 5000);
+ return () => clearInterval(id);
+ }, [fetchSystemStats]);
+
+ // FIX: this effect previously had no dependency array, causing it to run
+ // after every render and rely on a manual ref comparison to detect changes.
+ // Using [isChart] as the dependency array is correct and idiomatic.
+ useEffect(() => {
+ fetchSnapshots(filterActive ? searchParam : "", filterActive ? searchValue : "", isChart);
+ }, [isChart]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ const p = new URLSearchParams(window.location.search);
+ const sp = p.get("search_param") || "";
+ const sv = p.get("search_value") || "";
+ const lt = p.get("log_type") === "chart";
+ if (sp || sv) {
+ setSearchParam(sp); setSearchValue(sv); setIsChart(lt); setFilterActive(true);
+ fetchSnapshots(sp, sv, lt);
+ }
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // ββ handlers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ const uploadOne = async (contents) => {
+ // Decode name from config to check for conflicts before hitting the API
+ const raw = contents.includes(",") ? contents.split(",")[1] : contents;
+ JSON.parse(atob(raw)); // throws if not valid base64 JSON
+ const { device } = await apiFetch("/api/devices", { method: "POST", body: JSON.stringify({ contents }) });
+ setDevices((prev) => [...prev, device]);
+ };
+
+ const handleUpload = async (contents) => {
+ // Single file: keep the original behaviour/messages unchanged.
+ if (!Array.isArray(contents)) {
+ try {
+ await uploadOne(contents);
+ addToast("Device added successfully.", "success");
+ } catch (e) {
+ addToast(e.status === 422 ? "Incorrect config file β could not parse device configuration." : `Upload failed: ${e.message}`);
+ }
+ return;
+ }
+
+ // Multiple files: upload each independently so one bad config doesn't
+ // block the rest, then report an aggregate result.
+ let succeeded = 0;
+ const failures = [];
+ for (let i = 0; i < contents.length; i++) {
+ try {
+ await uploadOne(contents[i]);
+ succeeded++;
+ } catch (e) {
+ failures.push(e.status === 422 ? `file ${i + 1}: invalid config` : `file ${i + 1}: ${e.message}`);
+ }
+ }
+
+ if (succeeded > 0) {
+ addToast(`Added ${succeeded} device${succeeded !== 1 ? "s" : ""} successfully.`, "success");
+ }
+ if (failures.length > 0) {
+ addToast(`Failed to add ${failures.length} device(s) β ${failures.join("; ")}`);
+ }
+ };
+
+ const toggleDevice = (id, checked) =>
+ setSelectedDevices((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
+
+ const selectAllInGroup = (ids, checked) =>
+ setSelectedDevices(prev =>
+ checked
+ ? [...new Set([...prev, ...ids])]
+ : prev.filter(x => !ids.includes(x))
+ );
+
+ const startCollection = () => {
+ setScenarioInput("");
+ setScenarioError(false);
+ setScenarioModal(true);
+ };
+
+ const confirmStartCollection = async () => {
+ if (!scenarioInput.trim()) { setScenarioError(true); return; }
+ const ids = devices.filter((d) => selectedDevices.includes(d.id)).map((d) => d.id);
+ setScenarioModal(false);
+ try {
+ await apiFetch("/api/start-logs-collection", {
+ method: "POST",
+ body: JSON.stringify({ selected_devices: ids, session_scenario: scenarioInput.trim() }),
+ });
+ addToast("Log collection started.", "success");
+ fetchDevices();
+ fetchSnapshots(filterActive ? searchParam : "", filterActive ? searchValue : "", isChart);
+ } catch (e) { addToast(`Failed to start collection: ${e.message}`); }
+ };
+
+ const stopCollection = async () => {
+ const selectedDevObjs = devices.filter((d) => selectedDevices.includes(d.id));
+ const ids = selectedDevObjs.map((d) => d.id);
+ const runningDev = selectedDevObjs.find((d) => d.collecting);
+ const session_id = runningDev?.config?.current_session_id || "";
+
+ // Estimate expected snapshots: sum of log_file_configs entries across selected collecting devices
+ const expected = selectedDevObjs.reduce((sum, d) => {
+ const cfgs = d.config?.log_file_configs;
+ return sum + (Array.isArray(cfgs) ? cfgs.length : 1);
+ }, 0);
+
+ setStopProgress({ saved: 0, expected, sessionId: session_id });
+ setStoppingCollection(true);
+
+ // Poll snapshot count for this session while backend tears down
+ let pollId = null;
+ const pollSaved = async () => {
+ try {
+ const url = session_id
+ ? `/api/snapshots?log_type=text&page_size=9999&search_param=Session%20ID&search_value=${session_id}`
+ : `/api/snapshots?log_type=text&page_size=9999`;
+ const snaps = await apiFetch(url);
+ // Count both text and chart snapshots produced so far
+ const chartUrl = session_id
+ ? `/api/snapshots?log_type=chart&page_size=9999&search_param=Session%20ID&search_value=${session_id}`
+ : `/api/snapshots?log_type=chart&page_size=9999`;
+ const chartSnaps = await apiFetch(chartUrl);
+ const total = (snaps?.total || 0) + (chartSnaps?.total || 0);
+ setStopProgress(prev => ({ ...prev, saved: total }));
+ return total;
+ } catch {
+ return null; // ignore poll errors
+ }
+ };
+ pollId = setInterval(pollSaved, 1500);
+
+ try {
+ const result = await apiFetch("/api/stop-logs-collection", { method: "POST", body: JSON.stringify({ selected_devices: ids, session_id }) });
+ clearInterval(pollId);
+ // Final poll to get accurate count
+ const saved = await pollSaved();
+ const partial = expected > 0 && saved != null && saved < expected;
+ setSessionModal({
+ sessionId: result.session_id,
+ textUrl: result.text_logs_url,
+ chartUrl: result.chart_logs_url,
+ partial,
+ });
+ fetchDevices();
+ fetchSnapshots(filterActive ? searchParam : "", filterActive ? searchValue : "", isChart);
+ } catch (e) {
+ clearInterval(pollId);
+ // Even if the stop request itself errored or timed out server-side
+ // (e.g. not all snapshots were saved before the teardown timeout),
+ // still surface whatever was collected so far rather than leaving
+ // the user with nothing but a toast.
+ if (session_id) {
+ await pollSaved();
+ const qs = `search_param=Session%20ID&search_value=${session_id}`;
+ setSessionModal({
+ sessionId: session_id,
+ textUrl: `${window.location.origin}/?${qs}&log_type=text`,
+ chartUrl: `${window.location.origin}/?${qs}&log_type=chart`,
+ partial: true,
+ });
+ fetchDevices();
+ fetchSnapshots(filterActive ? searchParam : "", filterActive ? searchValue : "", isChart);
+ }
+ addToast(`Stop collection: not all snapshots finished saving (${e.message})`);
+ } finally {
+ setStoppingCollection(false);
+ setStopProgress({ saved: 0, expected: 0, sessionId: "" });
+ }
+ };
+
+ const removeSelected = async () => {
+ setRemovingDevices(true);
+ await Promise.all(
+ selectedDevices.map((id) =>
+ apiFetch(`/api/devices/${id}`, { method: "DELETE" }).catch((e) => addToast(`Remove failed: ${e.message}`))
+ )
+ );
+ setSelectedDevices([]);
+ fetchDevices();
+ setRemovingDevices(false);
+ setConfirmRemoveDevices(false);
+ };
+
+ /**
+ * Deletes the currently selected snapshots (text or chart, depending on
+ * the active `isChart` toggle) via the backend, which removes each
+ * snapshot's underlying file through `LogSnapshot.remove_log_snapshot`.
+ * Called after the user confirms in the removal confirmation dialog.
+ */
+ const removeSelectedSnaps = async () => {
+ if (selectedSnaps.length === 0) return;
+ setRemovingSnaps(true);
+ try {
+ const result = await apiFetch("/api/snapshots", {
+ method: "DELETE",
+ body: JSON.stringify({ snapshot_ids: selectedSnaps, log_type: isChart ? "chart" : "text" }),
+ });
+ if (result?.not_found?.length) {
+ addToast(`${result.not_found.length} snapshot(s) could not be found`, "info");
+ }
+ addToast(`Removed ${result?.removed?.length || 0} snapshot(s)`, "success");
+ setSelectedSnaps([]);
+ fetchSnapshots(filterActive ? searchParam : "", filterActive ? searchValue : "", isChart);
+ } catch (e) {
+ addToast(`Remove failed: ${e.message}`);
+ } finally {
+ setRemovingSnaps(false);
+ setConfirmRemoveSnaps(false);
+ }
+ };
+
+ const toggleSnap = (id, checked) =>
+ setSelectedSnaps((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
+
+ /**
+ * Opens the log/chart modal.
+ * For chart mode: fetches each snapshot separately and builds chartGroups
+ * so each snapshot gets its own Plotly panel inside the modal.
+ * For text mode: merges all rows as before.
+ */
+ const openLogContent = async (snapsToView) => {
+ setLogModal(true);
+ setLogRowsLoading(true);
+ setLogRows([]);
+ setChartGroups([]);
+ setViewingSnaps(snapsToView);
+ setLogLoadProgress({ done: 0, total: snapsToView.length });
+
+ try {
+ let done = 0;
+ const results = await Promise.all(
+ snapsToView.map((s) =>
+ apiFetch(`/api/snapshots/${s.id}/content?log_type=${isChart ? "chart" : "text"}`).then((r) => {
+ done += 1;
+ setLogLoadProgress({ done, total: snapsToView.length });
+ return { snapInfo: s, rows: r.rows };
+ })
+ )
+ );
+
+ if (isChart) {
+ setChartGroups(results);
+ } else {
+ const merged = results.flatMap((r) =>
+ r.rows.map((row) => ({
+ ...row,
+ device_name: r.snapInfo.deviceName ?? r.snapInfo.device_name ?? "",
+ // Needed so packet_capture rows can call back to
+ // /api/snapshots/