| 1 | import React, { useEffect, useMemo, useState } from "react";
|
|---|
| 2 | import {
|
|---|
| 3 | LineChart,
|
|---|
| 4 | Line,
|
|---|
| 5 | XAxis,
|
|---|
| 6 | YAxis,
|
|---|
| 7 | Tooltip,
|
|---|
| 8 | Legend,
|
|---|
| 9 | ResponsiveContainer,
|
|---|
| 10 | CartesianGrid,
|
|---|
| 11 | } from "recharts";
|
|---|
| 12 |
|
|---|
| 13 | const VIZ = {
|
|---|
| 14 | STATS: "stats",
|
|---|
| 15 | COMPUTERS: "computers",
|
|---|
| 16 | COMPUTER_DETAILS: "computer_details",
|
|---|
| 17 | NET_GRAPH: "net_graph",
|
|---|
| 18 | };
|
|---|
| 19 |
|
|---|
| 20 | function fmtTs(ts) {
|
|---|
| 21 | if (!ts) return "";
|
|---|
| 22 | return String(ts).replace("T", " ").replace("Z", "");
|
|---|
| 23 | }
|
|---|
| 24 |
|
|---|
| 25 | async 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 */
|
|---|
| 51 | function 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 |
|
|---|
| 68 | function 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 |
|
|---|
| 88 | function Pill({ children }) {
|
|---|
| 89 | return <span className="pill">{children}</span>;
|
|---|
| 90 | }
|
|---|
| 91 |
|
|---|
| 92 | function 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 |
|
|---|
| 125 | export 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 | }
|
|---|