source: lan-frontend/src/components/NetIntelViz.jsx@ a762898

Last change on this file since a762898 was a762898, checked in by istevanoska <ilinastevanoska@…>, 5 months ago

Added visualizations

  • Property mode set to 100644
File size: 21.4 KB
RevLine 
[a762898]1import React, { useEffect, useMemo, useState } from "react";
2import {
3 LineChart,
4 Line,
5 XAxis,
6 YAxis,
7 Tooltip,
8 Legend,
9 ResponsiveContainer,
10 CartesianGrid,
11} from "recharts";
12
13const VIZ = {
14 STATS: "stats",
15 COMPUTERS: "computers",
16 COMPUTER_DETAILS: "computer_details",
17 NET_GRAPH: "net_graph",
18};
19
20function fmtTs(ts) {
21 if (!ts) return "";
22 return String(ts).replace("T", " ").replace("Z", "");
23}
24
25async function fetchJson(url, { method = "GET", headers = {}, body } = {}) {
26 const res = await fetch(url, {
27 method,
28 credentials: "include",
29 headers: {
30 "Content-Type": "application/json",
31 ...headers,
32 },
33 body: body ? JSON.stringify(body) : undefined,
34 });
35
36 const text = await res.text();
37 let data;
38 try {
39 data = text ? JSON.parse(text) : null;
40 } catch {
41 data = { raw: text };
42 }
43 if (!res.ok) {
44 const msg = data?.error || data?.message || res.statusText;
45 throw new Error(msg);
46 }
47 return data;
48}
49
50/** ✅ Use existing theme.css look: panel / btn / select / input / pill */
51function Panel({ title, right, children, subtitle }) {
52 return (
53 <div className="panel" style={{ marginBottom: 14 }}>
54 <div className="panel-head">
55 <div>
56 <div className="panel-title">{title}</div>
57 {subtitle ? (
58 <div style={{ color: "var(--dim)", fontSize: 12, marginTop: 4 }}>{subtitle}</div>
59 ) : null}
60 </div>
61 {right}
62 </div>
63 <div className="panel-body">{children}</div>
64 </div>
65 );
66}
67
68function Select({ label, value, onChange, options, disabled }) {
69 return (
70 <label style={{ display: "grid", gap: 6 }}>
71 <div style={{ fontSize: 12, color: "var(--dim)" }}>{label}</div>
72 <select
73 className="select"
74 value={value}
75 disabled={disabled}
76 onChange={(e) => onChange(e.target.value)}
77 >
78 {options.map((o) => (
79 <option key={o.value} value={o.value}>
80 {o.label}
81 </option>
82 ))}
83 </select>
84 </label>
85 );
86}
87
88function Pill({ children }) {
89 return <span className="pill">{children}</span>;
90}
91
92function Table({ columns, rows, emptyText = "Нема податоци." }) {
93 return (
94 <div style={{ overflow: "auto" }}>
95 <table className="table">
96 <thead>
97 <tr>
98 {columns.map((c) => (
99 <th key={c.key}>{c.title}</th>
100 ))}
101 </tr>
102 </thead>
103 <tbody>
104 {rows.length === 0 ? (
105 <tr>
106 <td colSpan={columns.length} style={{ color: "var(--dim)", padding: 12 }}>
107 {emptyText}
108 </td>
109 </tr>
110 ) : (
111 rows.map((r, idx) => (
112 <tr key={idx}>
113 {columns.map((c) => (
114 <td key={c.key}>{c.render ? c.render(r) : String(r[c.key] ?? "")}</td>
115 ))}
116 </tr>
117 ))
118 )}
119 </tbody>
120 </table>
121 </div>
122 );
123}
124
125export default function NetIntelViz({ baseUrl = "" }) {
126 const [loading, setLoading] = useState(false);
127 const [err, setErr] = useState("");
128
129 const [me, setMe] = useState(null);
130 const [envs, setEnvs] = useState(["default"]);
131 const [env, setEnv] = useState("default");
132
133 const [viz, setViz] = useState(VIZ.NET_GRAPH);
134
135 const [stats, setStats] = useState(null);
136 const [computers, setComputers] = useState([]);
137 const [computerName, setComputerName] = useState("");
138 const [computerDetails, setComputerDetails] = useState(null);
139
140 const [sinceHours, setSinceHours] = useState("24");
141 const [graph, setGraph] = useState(null);
142
143 const api = useMemo(() => (baseUrl || "").replace(/\/+$/, ""), [baseUrl]);
144
145 const envOptions = useMemo(() => {
146 const uniq = [...new Set(envs)];
147 return uniq.map((e) => ({ value: e, label: e }));
148 }, [envs]);
149
150 const computerOptions = useMemo(() => {
151 const opts = computers.map((c) => ({ value: c.name, label: c.name }));
152 return [{ value: "", label: "all" }, ...opts];
153 }, [computers]);
154
155 const vizOptions = [
156 { value: VIZ.STATS, label: "Stats (24h)" },
157 { value: VIZ.COMPUTERS, label: "Computers" },
158 { value: VIZ.COMPUTER_DETAILS, label: "Computer details" },
159 { value: VIZ.NET_GRAPH, label: "Network graph" },
160 ];
161
162 async function loadMe() {
163 const data = await fetchJson(`${api}/api/me`);
164 setMe(data?.user || null);
165 }
166
167 async function loadEnvs() {
168 try {
169 const data = await fetchJson(`${api}/api/admin/environments`);
170 const list = data?.environments?.length ? data.environments : ["default"];
171 setEnvs(list);
172 if (!list.includes(env)) setEnv(list[0] || "default");
173 } catch {
174 setEnvs(["default"]);
175 setEnv("default");
176 }
177 }
178
179 async function loadStats(selectedEnv) {
180 const data = await fetchJson(`${api}/api/stats`, {
181 headers: { "X-Env": selectedEnv || env || "default" },
182 });
183 setStats(data);
184 }
185
186 async function loadComputers(selectedEnv) {
187 const data = await fetchJson(`${api}/api/computers`, {
188 headers: { "X-Env": selectedEnv || env || "default" },
189 });
190 setComputers(Array.isArray(data) ? data : []);
191 }
192
193 async function loadComputerDetails(name) {
194 if (!name) {
195 setComputerDetails(null);
196 return;
197 }
198 const data = await fetchJson(`${api}/api/computer/${encodeURIComponent(name)}`, {
199 headers: { "X-Env": env || "default" },
200 });
201 setComputerDetails(data);
202 }
203
204 async function loadNetGraph() {
205 const qs = new URLSearchParams();
206 qs.set("env", env || "default");
207 qs.set("since_hours", String(parseInt(sinceHours || "24", 10) || 24));
208 if (computerName) qs.set("computer", computerName);
209
210 const data = await fetchJson(`${api}/api/graph/net?${qs.toString()}`, {
211 headers: { "X-Env": env || "default" },
212 });
213 setGraph(data);
214 }
215 function MiniBipartiteGraph({ nodes = [], edges = [] }) {
216 const comps = nodes.filter((n) => (n.subTitle || "").toLowerCase().includes("computer"));
217 const ips = nodes.filter((n) => (n.subTitle || "").toLowerCase().includes("remote"));
218
219 const width = 1100;
220 const height = 520;
221 const pad = 40;
222
223 const compPos = comps.map((n, i) => ({
224 ...n,
225 x: pad,
226 y: pad + (i * (height - 2 * pad)) / Math.max(1, comps.length - 1),
227 }));
228
229 const ipPos = ips.map((n, i) => ({
230 ...n,
231 x: width - pad,
232 y: pad + (i * (height - 2 * pad)) / Math.max(1, ips.length - 1),
233 }));
234
235 const pos = new Map();
236 [...compPos, ...ipPos].forEach((n) => pos.set(n.id, n));
237
238 const shownEdges = edges.slice(0, 120);
239
240 return (
241 <div style={{ overflow: "auto" }}>
242 <svg width={width} height={height} style={{ borderRadius: 16, background: "rgba(255,255,255,0.03)" }}>
243 {shownEdges.map((e, idx) => {
244 const s = pos.get(e.source);
245 const t = pos.get(e.target);
246 if (!s || !t) return null;
247 const v = Number(e.mainStat || 1);
248 const strokeWidth = Math.max(1, Math.min(6, Math.log2(v + 1)));
249 return (
250 <line
251 key={idx}
252 x1={s.x}
253 y1={s.y}
254 x2={t.x}
255 y2={t.y}
256 stroke="rgba(120,180,255,0.25)"
257 strokeWidth={strokeWidth}
258 />
259 );
260 })}
261
262 {compPos.map((n) => (
263 <g key={n.id}>
264 <circle cx={n.x} cy={n.y} r={6} fill="rgba(255,255,255,0.9)" />
265 <text x={n.x + 12} y={n.y + 4} fontSize="11" fill="rgba(255,255,255,0.85)">
266 {n.title}
267 </text>
268 </g>
269 ))}
270
271 {ipPos.map((n) => (
272 <g key={n.id}>
273 <circle cx={n.x} cy={n.y} r={6} fill="rgba(80,160,255,0.95)" />
274 <text x={n.x - 12} y={n.y + 4} textAnchor="end" fontSize="11" fill="rgba(255,255,255,0.85)">
275 {n.title}
276 </text>
277 </g>
278 ))}
279 </svg>
280
281 <div style={{ color: "var(--dim)", fontSize: 12, marginTop: 10 }}>
282 Tip: ако има премногу линии, намали Hours или избери конкретен Computer.
283 </div>
284 </div>
285 );
286}
287
288
289 async function refreshAll(nextViz = viz, nextEnv = env) {
290 setErr("");
291 setLoading(true);
292 try {
293 if (nextViz === VIZ.STATS) {
294 await loadStats(nextEnv);
295 } else if (nextViz === VIZ.COMPUTERS) {
296 await loadComputers(nextEnv);
297 } else if (nextViz === VIZ.COMPUTER_DETAILS) {
298 await loadComputers(nextEnv);
299 if (computerName) await loadComputerDetails(computerName);
300 } else if (nextViz === VIZ.NET_GRAPH) {
301 await loadComputers(nextEnv);
302 await loadNetGraph();
303 }
304 } catch (e) {
305 setErr(e.message || "Грешка при вчитување.");
306 } finally {
307 setLoading(false);
308 }
309 }
310
311 useEffect(() => {
312 (async () => {
313 setLoading(true);
314 setErr("");
315 try {
316 await loadMe();
317 await loadEnvs();
318 await refreshAll(VIZ.NET_GRAPH, env);
319 } catch (e) {
320 setErr(e.message || "Неуспешно поврзување.");
321 } finally {
322 setLoading(false);
323 }
324 })();
325 // eslint-disable-next-line react-hooks/exhaustive-deps
326 }, [api]);
327
328 const historySeries = useMemo(() => {
329 const hist = computerDetails?.history || [];
330 const rows = [...hist].reverse().slice(-80);
331 return rows.map((r) => ({
332 t: fmtTs(r.timestamp),
333 cpu: Number(r.cpu_usage ?? 0),
334 ram: Number(r.ram_usage ?? 0),
335 disk: Number(r.disk_usage ?? 0),
336 sent: Number(r.network_sent_mb ?? 0),
337 recv: Number(r.network_recv_mb ?? 0),
338 }));
339 }, [computerDetails]);
340
341 const nodes = graph?.nodes || [];
342 const edges = graph?.edges || [];
343 const meta = graph?.meta || {};
344
345 return (
346 <div>
347 {/* HEADER / CONTROLS */}
348 <Panel
349 title="Visualizations"
350 subtitle={me ? `Logged: ${me.email} • tenant: ${me.tenant_id} • role: ${me.role}` : "Not logged"}
351 right={
352 <div style={{ display: "flex", gap: 10, alignItems: "end", flexWrap: "wrap" }}>
353 <Select
354 label="Env"
355 value={env}
356 disabled={loading}
357 onChange={(v) => {
358 setEnv(v);
359 refreshAll(viz, v);
360 }}
361 options={envOptions}
362 />
363
364 <Select
365 label="View"
366 value={viz}
367 disabled={loading}
368 onChange={(v) => {
369 setViz(v);
370 refreshAll(v, env);
371 }}
372 options={vizOptions}
373 />
374
375 {(viz === VIZ.COMPUTER_DETAILS || viz === VIZ.NET_GRAPH) && (
376 <Select
377 label="Computer"
378 value={computerName}
379 disabled={loading}
380 onChange={(v) => {
381 setComputerName(v);
382 if (viz === VIZ.COMPUTER_DETAILS) loadComputerDetails(v).catch((e) => setErr(e.message));
383 if (viz === VIZ.NET_GRAPH) loadNetGraph().catch((e) => setErr(e.message));
384 }}
385 options={computerOptions}
386 />
387 )}
388
389 {viz === VIZ.NET_GRAPH && (
390 <Select
391 label="Hours"
392 value={sinceHours}
393 disabled={loading}
394 onChange={(v) => setSinceHours(v)}
395 options={[
396 { value: "1", label: "1" },
397 { value: "6", label: "6" },
398 { value: "12", label: "12" },
399 { value: "24", label: "24" },
400 { value: "48", label: "48" },
401 { value: "72", label: "72" },
402 ]}
403 />
404 )}
405
406 <button className="btn primary" disabled={loading} onClick={() => refreshAll(viz, env)}>
407 {loading ? "Loading…" : "Refresh"}
408 </button>
409 </div>
410 }
411 >
412 {err ? (
413 <div style={{ color: "#ffb4b4", background: "rgba(255,0,0,0.08)", padding: 10, borderRadius: 12 }}>
414 {err}
415 </div>
416 ) : (
417 <div style={{ color: "var(--dim)", fontSize: 12 }}>
418 Избери “View” за да смениш што се прикажува.
419 </div>
420 )}
421 </Panel>
422 <Panel title="Graph (SVG)" subtitle="Computers left, IPs right, lines = connections">
423 <MiniBipartiteGraph nodes={nodes} edges={edges} />
424</Panel>
425
426
427 {/* STATS */}
428 {viz === VIZ.STATS && (
429 <Panel title="Stats (24h)" subtitle="From /api/stats">
430 <div className="stats-grid" style={{ marginTop: 0 }}>
431 <div className="stat-card">
432 <div className="label">Sysmon Events</div>
433 <div className="value">{stats?.total_sysmon_events ?? "—"}</div>
434 <div className="sub">Last 24 hours</div>
435 </div>
436 <div className="stat-card">
437 <div className="label">Network Connections</div>
438 <div className="value">{stats?.total_network_connections ?? "—"}</div>
439 <div className="sub">Last 24 hours</div>
440 </div>
441 <div className="stat-card">
442 <div className="label">Server Time</div>
443 <div className="value" style={{ fontSize: 18 }}>
444 {fmtTs(stats?.server_time) || "—"}
445 </div>
446 <div className="sub">Now</div>
447 </div>
448 </div>
449 </Panel>
450 )}
451
452 {/* COMPUTERS */}
453 {viz === VIZ.COMPUTERS && (
454 <Panel title="Computers" subtitle="From /api/computers">
455 <Table
456 columns={[
457 { key: "name", title: "Name" },
458 { key: "user", title: "User" },
459 { key: "ip", title: "IP" },
460 { key: "os", title: "OS" },
461 { key: "status", title: "Status" },
462 { key: "avg_cpu", title: "Avg CPU" },
463 { key: "avg_ram", title: "Avg RAM" },
464 { key: "recent_logs", title: "Logs(24h)" },
465 { key: "recent_sysmon", title: "Sysmon(24h)" },
466 { key: "last_seen", title: "Last seen", render: (r) => fmtTs(r.last_seen) },
467 ]}
468 rows={computers}
469 emptyText="No computers for this env."
470 />
471 </Panel>
472 )}
473
474 {/* COMPUTER DETAILS */}
475 {viz === VIZ.COMPUTER_DETAILS && (
476 <>
477 <Panel
478 title="Computer details"
479 subtitle="From /api/computer/<name>"
480 right={
481 <button
482 className="btn"
483 disabled={loading || !computerName}
484 onClick={() => loadComputerDetails(computerName).catch((e) => setErr(e.message))}
485 >
486 Reload
487 </button>
488 }
489 >
490 {!computerName ? (
491 <div style={{ color: "var(--dim)" }}>Select a computer.</div>
492 ) : !computerDetails ? (
493 <div style={{ color: "var(--dim)" }}>Loading…</div>
494 ) : (
495 <div className="stats-grid" style={{ marginTop: 0 }}>
496 <div className="stat-card">
497 <div className="label">Name</div>
498 <div className="value">{computerDetails.computer?.name}</div>
499 <div className="sub">{computerDetails.computer?.ip} • {computerDetails.computer?.os}</div>
500 </div>
501 <div className="stat-card">
502 <div className="label">User</div>
503 <div className="value">{computerDetails.computer?.user || "—"}</div>
504 <div className="sub">
505 {fmtTs(computerDetails.computer?.first_seen)} → {fmtTs(computerDetails.computer?.last_seen)}
506 </div>
507 </div>
508 <div className="stat-card">
509 <div className="label">Total logs</div>
510 <div className="value">{computerDetails.computer?.total_logs ?? "—"}</div>
511 <div className="sub">Env: {computerDetails.computer?.env_name || env}</div>
512 </div>
513 </div>
514 )}
515 </Panel>
516
517 <Panel title="Performance charts" subtitle="Last ~80 history rows">
518 {historySeries.length === 0 ? (
519 <div style={{ color: "var(--dim)" }}>No history data.</div>
520 ) : (
521 <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
522 <div style={{ height: 280 }}>
523 <div style={{ marginBottom: 8, color: "var(--dim)", fontSize: 12 }}>CPU / RAM / Disk</div>
524 <ResponsiveContainer width="100%" height="100%">
525 <LineChart data={historySeries}>
526 <CartesianGrid strokeDasharray="3 3" />
527 <XAxis dataKey="t" hide />
528 <YAxis />
529 <Tooltip />
530 <Legend />
531 <Line type="monotone" dataKey="cpu" dot={false} />
532 <Line type="monotone" dataKey="ram" dot={false} />
533 <Line type="monotone" dataKey="disk" dot={false} />
534 </LineChart>
535 </ResponsiveContainer>
536 </div>
537
538 <div style={{ height: 280 }}>
539 <div style={{ marginBottom: 8, color: "var(--dim)", fontSize: 12 }}>Network MB sent/recv</div>
540 <ResponsiveContainer width="100%" height="100%">
541 <LineChart data={historySeries}>
542 <CartesianGrid strokeDasharray="3 3" />
543 <XAxis dataKey="t" hide />
544 <YAxis />
545 <Tooltip />
546 <Legend />
547 <Line type="monotone" dataKey="sent" dot={false} />
548 <Line type="monotone" dataKey="recv" dot={false} />
549 </LineChart>
550 </ResponsiveContainer>
551 </div>
552 </div>
553 )}
554 </Panel>
555
556 <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
557 <Panel title="Processes (current)" subtitle="computer_processes_current (limit 200)">
558 <Table
559 columns={[
560 { key: "pid", title: "PID" },
561 { key: "name", title: "Name" },
562 { key: "username", title: "User" },
563 { key: "cpu_percent", title: "CPU%" },
564 { key: "memory_mb", title: "MemMB" },
565 { key: "timestamp", title: "Time", render: (r) => fmtTs(r.timestamp) },
566 ]}
567 rows={computerDetails?.recent_processes || []}
568 />
569 </Panel>
570
571 <Panel title="Sysmon events" subtitle="sysmon_events (limit 200)">
572 <Table
573 columns={[
574 { key: "event_id", title: "Event" },
575 { key: "event_type", title: "Type" },
576 {
577 key: "message",
578 title: "Message",
579 render: (r) =>
580 (r.message || "").slice(0, 70) + ((r.message || "").length > 70 ? "…" : ""),
581 },
582 { key: "timestamp", title: "Time", render: (r) => fmtTs(r.timestamp) },
583 ]}
584 rows={computerDetails?.sysmon_events || []}
585 />
586 </Panel>
587 </div>
588
589 <Panel title="Network connections" subtitle="network_connections (limit 100)">
590 <Table
591 columns={[
592 { key: "pid", title: "PID" },
593 { key: "process_name", title: "Process" },
594 { key: "local_address", title: "Local" },
595 { key: "remote_address", title: "Remote" },
596 { key: "status", title: "Status" },
597 { key: "timestamp", title: "Time", render: (r) => fmtTs(r.timestamp) },
598 ]}
599 rows={computerDetails?.network_connections || []}
600 />
601 </Panel>
602 </>
603 )}
604
605 {/* NET GRAPH */}
606 {viz === VIZ.NET_GRAPH && (
607 <>
608 <Panel
609 title="Network graph (computer → remote IP)"
610 subtitle="From /api/graph/net"
611 right={
612 <button className="btn" disabled={loading} onClick={() => loadNetGraph().catch((e) => setErr(e.message))}>
613 Reload graph
614 </button>
615 }
616 >
617 <div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
618 <Pill>env: {meta?.env || env}</Pill>
619 <Pill>hours: {meta?.since_hours ?? sinceHours}</Pill>
620 <Pill>computer: {meta?.computer || "all"}</Pill>
621 <Pill>nodes: {nodes.length}</Pill>
622 <Pill>edges: {edges.length}</Pill>
623 </div>
624 </Panel>
625
626 <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
627 <Panel title="Top edges" subtitle="Each row = number of connections">
628 <Table
629 columns={[
630 { key: "source", title: "Source" },
631 { key: "target", title: "Target" },
632 { key: "mainStat", title: "Count" },
633 ]}
634 rows={edges.slice(0, 80)}
635 emptyText="No edges (maybe no traffic in that period)."
636 />
637 </Panel>
638
639 <Panel title="Nodes" subtitle="Computers + remote IP">
640 <Table
641 columns={[
642 { key: "title", title: "Title" },
643 { key: "subTitle", title: "Type" },
644 ]}
645 rows={nodes.slice(0, 120)}
646 />
647 </Panel>
648 </div>
649 </>
650 )}
651 </div>
652 );
653}
Note: See TracBrowser for help on using the repository browser.