| 1 | // // src/pages/Visualizations.jsx
|
|---|
| 2 | // import { useState } from "react";
|
|---|
| 3 | // import SankeyPanel from "../components/SankeyPanel";
|
|---|
| 4 | //
|
|---|
| 5 | // export default function Visualizations() {
|
|---|
| 6 | // const [env, setEnv] = useState("office1");
|
|---|
| 7 | //
|
|---|
| 8 | // // ✅ All = празни низи
|
|---|
| 9 | // const [computers, setComputers] = useState([]);
|
|---|
| 10 | // const [ips, setIps] = useState([]);
|
|---|
| 11 | //
|
|---|
| 12 | // return (
|
|---|
| 13 | // <div>
|
|---|
| 14 | // <h2>Network Sankey Visualization</h2>
|
|---|
| 15 | //
|
|---|
| 16 | // <SankeyPanel
|
|---|
| 17 | // env={env}
|
|---|
| 18 | // computers={computers}
|
|---|
| 19 | // ips={ips}
|
|---|
| 20 | // from="now-2d"
|
|---|
| 21 | // to="now"
|
|---|
| 22 | // />
|
|---|
| 23 | // </div>
|
|---|
| 24 | // );
|
|---|
| 25 | // }
|
|---|
| 26 |
|
|---|
| 27 |
|
|---|
| 28 | import React, { useEffect, useMemo, useState } from "react";
|
|---|
| 29 | import SankeyViz from "../components/SankeyViz";
|
|---|
| 30 |
|
|---|
| 31 | function MultiSelect({ options, value, onChange, label }) {
|
|---|
| 32 | return (
|
|---|
| 33 | <div style={{ display: "grid", gap: 6 }}>
|
|---|
| 34 | <div style={{ fontSize: 12, color: "rgba(255,255,255,0.7)" }}>{label}</div>
|
|---|
| 35 | <select
|
|---|
| 36 | multiple
|
|---|
| 37 | value={value}
|
|---|
| 38 | onChange={(e) => {
|
|---|
| 39 | const vals = Array.from(e.target.selectedOptions).map((o) => o.value);
|
|---|
| 40 | onChange(vals);
|
|---|
| 41 | }}
|
|---|
| 42 | className="select"
|
|---|
| 43 | style={{ minHeight: 90 }}
|
|---|
| 44 | >
|
|---|
| 45 | {options.map((o) => (
|
|---|
| 46 | <option key={o} value={o}>
|
|---|
| 47 | {o}
|
|---|
| 48 | </option>
|
|---|
| 49 | ))}
|
|---|
| 50 | </select>
|
|---|
| 51 | <div style={{ fontSize: 11, color: "rgba(255,255,255,0.5)" }}>
|
|---|
| 52 | (држи Ctrl за multi-select)
|
|---|
| 53 | </div>
|
|---|
| 54 | </div>
|
|---|
| 55 | );
|
|---|
| 56 | }
|
|---|
| 57 |
|
|---|
| 58 | export default function Visualizations() {
|
|---|
| 59 | const [env, setEnv] = useState("office1");
|
|---|
| 60 |
|
|---|
| 61 | // 👇 ако сакаш env листа динамички, додај endpoint.
|
|---|
| 62 | const envOptions = ["default", "office1"];
|
|---|
| 63 |
|
|---|
| 64 | const [computerOptions, setComputerOptions] = useState(["All"]);
|
|---|
| 65 | const [ipOptions, setIpOptions] = useState(["All"]);
|
|---|
| 66 |
|
|---|
| 67 | const [computers, setComputers] = useState(["All"]);
|
|---|
| 68 | const [ips, setIps] = useState(["All"]);
|
|---|
| 69 |
|
|---|
| 70 | const [hours, setHours] = useState(48);
|
|---|
| 71 | const [limit, setLimit] = useState(200);
|
|---|
| 72 |
|
|---|
| 73 | const [vizType, setVizType] = useState("sankey"); // sankey | table | topips
|
|---|
| 74 | const [rows, setRows] = useState([]);
|
|---|
| 75 | const [loading, setLoading] = useState(false);
|
|---|
| 76 |
|
|---|
| 77 | // ✅ 1) load computers for env (користи твојот постоечки /api/computers)
|
|---|
| 78 | async function loadComputers() {
|
|---|
| 79 | const r = await fetch("/api/computers", {
|
|---|
| 80 | credentials: "include",
|
|---|
| 81 | headers: { "X-Env": env },
|
|---|
| 82 | });
|
|---|
| 83 | const data = await r.json();
|
|---|
| 84 | const names = (Array.isArray(data) ? data : []).map((c) => c.name).filter(Boolean);
|
|---|
| 85 | setComputerOptions(["All", ...names]);
|
|---|
| 86 | }
|
|---|
| 87 |
|
|---|
| 88 | // ✅ 2) load ips list (ОВА треба да го имаш на backend како /api/ips)
|
|---|
| 89 | async function loadIps() {
|
|---|
| 90 | const r = await fetch(`/api/ips?env=${encodeURIComponent(env)}&hours=${hours}&limit=${limit}`, {
|
|---|
| 91 | credentials: "include",
|
|---|
| 92 | });
|
|---|
| 93 | const data = await r.json();
|
|---|
| 94 | const list = Array.isArray(data?.ips) ? data.ips : Array.isArray(data) ? data : [];
|
|---|
| 95 | setIpOptions(["All", ...list]);
|
|---|
| 96 | }
|
|---|
| 97 |
|
|---|
| 98 | // ✅ 3) load sankey rows (ОВА треба да го имаш на backend како /api/sankey)
|
|---|
| 99 | async function loadSankey() {
|
|---|
| 100 | setLoading(true);
|
|---|
| 101 | try {
|
|---|
| 102 | const params = new URLSearchParams();
|
|---|
| 103 | params.set("env", env);
|
|---|
| 104 | params.set("hours", String(hours));
|
|---|
| 105 | params.set("limit", String(limit));
|
|---|
| 106 | params.set("computers", (computers?.length ? computers : ["All"]).join(","));
|
|---|
| 107 | params.set("ips", (ips?.length ? ips : ["All"]).join(","));
|
|---|
| 108 |
|
|---|
| 109 | const r = await fetch(`/api/sankey?${params.toString()}`, { credentials: "include" });
|
|---|
| 110 | const data = await r.json();
|
|---|
| 111 | setRows(Array.isArray(data) ? data : []);
|
|---|
| 112 | } finally {
|
|---|
| 113 | setLoading(false);
|
|---|
| 114 | }
|
|---|
| 115 | }
|
|---|
| 116 |
|
|---|
| 117 | useEffect(() => {
|
|---|
| 118 | loadComputers();
|
|---|
| 119 | loadIps();
|
|---|
| 120 | // eslint-disable-next-line react-hooks/exhaustive-deps
|
|---|
| 121 | }, [env, hours, limit]);
|
|---|
| 122 |
|
|---|
| 123 | useEffect(() => {
|
|---|
| 124 | loadSankey();
|
|---|
| 125 | // eslint-disable-next-line react-hooks/exhaustive-deps
|
|---|
| 126 | }, [env, hours, limit, computers, ips]);
|
|---|
| 127 |
|
|---|
| 128 | // Table view
|
|---|
| 129 | const tableRows = useMemo(() => rows.slice(0, 200), [rows]);
|
|---|
| 130 |
|
|---|
| 131 | // Top IPs view
|
|---|
| 132 | const topIps = useMemo(() => {
|
|---|
| 133 | const map = new Map();
|
|---|
| 134 | for (const r of rows) {
|
|---|
| 135 | map.set(r.target, (map.get(r.target) || 0) + Number(r.value || 0));
|
|---|
| 136 | }
|
|---|
| 137 | return Array.from(map.entries())
|
|---|
| 138 | .sort((a, b) => b[1] - a[1])
|
|---|
| 139 | .slice(0, 20);
|
|---|
| 140 | }, [rows]);
|
|---|
| 141 |
|
|---|
| 142 | return (
|
|---|
| 143 | <div className="panel" style={{ marginTop: 14 }}>
|
|---|
| 144 | <div className="panel-head">
|
|---|
| 145 | <div className="panel-title">Visualizations</div>
|
|---|
| 146 | <div style={{ color: "var(--dim)", fontSize: 12 }}>
|
|---|
| 147 | {loading ? "Loading…" : `${rows.length} links`}
|
|---|
| 148 | </div>
|
|---|
| 149 | </div>
|
|---|
| 150 |
|
|---|
| 151 | <div className="panel-body" style={{ display: "grid", gap: 14 }}>
|
|---|
| 152 | {/* Controls */}
|
|---|
| 153 | <div
|
|---|
| 154 | style={{
|
|---|
| 155 | display: "grid",
|
|---|
| 156 | gridTemplateColumns: "1fr 1fr 1fr",
|
|---|
| 157 | gap: 12,
|
|---|
| 158 | alignItems: "start",
|
|---|
| 159 | }}
|
|---|
| 160 | >
|
|---|
| 161 | <div style={{ display: "grid", gap: 8 }}>
|
|---|
| 162 | <div style={{ fontSize: 12, color: "rgba(255,255,255,0.7)" }}>Environment</div>
|
|---|
| 163 | <select className="select" value={env} onChange={(e) => setEnv(e.target.value)}>
|
|---|
| 164 | {envOptions.map((x) => (
|
|---|
| 165 | <option key={x} value={x}>
|
|---|
| 166 | {x}
|
|---|
| 167 | </option>
|
|---|
| 168 | ))}
|
|---|
| 169 | </select>
|
|---|
| 170 |
|
|---|
| 171 | <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
|
|---|
| 172 | <div>
|
|---|
| 173 | <div style={{ fontSize: 12, color: "rgba(255,255,255,0.7)" }}>Hours</div>
|
|---|
| 174 | <input
|
|---|
| 175 | className="input"
|
|---|
| 176 | type="number"
|
|---|
| 177 | value={hours}
|
|---|
| 178 | min={1}
|
|---|
| 179 | max={720}
|
|---|
| 180 | onChange={(e) => setHours(Number(e.target.value))}
|
|---|
| 181 | />
|
|---|
| 182 | </div>
|
|---|
| 183 | <div>
|
|---|
| 184 | <div style={{ fontSize: 12, color: "rgba(255,255,255,0.7)" }}>Limit</div>
|
|---|
| 185 | <input
|
|---|
| 186 | className="input"
|
|---|
| 187 | type="number"
|
|---|
| 188 | value={limit}
|
|---|
| 189 | min={10}
|
|---|
| 190 | max={2000}
|
|---|
| 191 | onChange={(e) => setLimit(Number(e.target.value))}
|
|---|
| 192 | />
|
|---|
| 193 | </div>
|
|---|
| 194 | </div>
|
|---|
| 195 |
|
|---|
| 196 | <div style={{ display: "grid", gap: 6 }}>
|
|---|
| 197 | <div style={{ fontSize: 12, color: "rgba(255,255,255,0.7)" }}>Visualization</div>
|
|---|
| 198 | <select className="select" value={vizType} onChange={(e) => setVizType(e.target.value)}>
|
|---|
| 199 | <option value="sankey">Sankey</option>
|
|---|
| 200 | <option value="table">Table</option>
|
|---|
| 201 | <option value="topips">Top IPs</option>
|
|---|
| 202 | </select>
|
|---|
| 203 | </div>
|
|---|
| 204 |
|
|---|
| 205 | <button
|
|---|
| 206 | className="btn"
|
|---|
| 207 | onClick={() => {
|
|---|
| 208 | setComputers(["All"]);
|
|---|
| 209 | setIps(["All"]);
|
|---|
| 210 | }}
|
|---|
| 211 | >
|
|---|
| 212 | Reset filters
|
|---|
| 213 | </button>
|
|---|
| 214 | </div>
|
|---|
| 215 |
|
|---|
| 216 | <MultiSelect
|
|---|
| 217 | label="Computers"
|
|---|
| 218 | options={computerOptions}
|
|---|
| 219 | value={computers}
|
|---|
| 220 | onChange={(vals) => setComputers(vals.length ? vals : ["All"])}
|
|---|
| 221 | />
|
|---|
| 222 |
|
|---|
| 223 | <MultiSelect
|
|---|
| 224 | label="IPs"
|
|---|
| 225 | options={ipOptions}
|
|---|
| 226 | value={ips}
|
|---|
| 227 | onChange={(vals) => setIps(vals.length ? vals : ["All"])}
|
|---|
| 228 | />
|
|---|
| 229 | </div>
|
|---|
| 230 |
|
|---|
| 231 | {/* Viz */}
|
|---|
| 232 | {vizType === "sankey" && <SankeyViz dataRows={rows} height={520} />}
|
|---|
| 233 |
|
|---|
| 234 | {vizType === "table" && (
|
|---|
| 235 | <div style={{ overflow: "auto", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 12 }}>
|
|---|
| 236 | <table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|---|
| 237 | <thead>
|
|---|
| 238 | <tr>
|
|---|
| 239 | <th style={{ textAlign: "left", padding: 10, color: "rgba(255,255,255,0.8)" }}>Source</th>
|
|---|
| 240 | <th style={{ textAlign: "left", padding: 10, color: "rgba(255,255,255,0.8)" }}>Target</th>
|
|---|
| 241 | <th style={{ textAlign: "right", padding: 10, color: "rgba(255,255,255,0.8)" }}>Value</th>
|
|---|
| 242 | </tr>
|
|---|
| 243 | </thead>
|
|---|
| 244 | <tbody>
|
|---|
| 245 | {tableRows.map((r, idx) => (
|
|---|
| 246 | <tr key={idx} style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }}>
|
|---|
| 247 | <td style={{ padding: 10, color: "rgba(255,255,255,0.85)" }}>{r.source}</td>
|
|---|
| 248 | <td style={{ padding: 10, color: "rgba(255,255,255,0.85)" }}>{r.target}</td>
|
|---|
| 249 | <td style={{ padding: 10, textAlign: "right", color: "rgba(255,255,255,0.85)" }}>{r.value}</td>
|
|---|
| 250 | </tr>
|
|---|
| 251 | ))}
|
|---|
| 252 | </tbody>
|
|---|
| 253 | </table>
|
|---|
| 254 | </div>
|
|---|
| 255 | )}
|
|---|
| 256 |
|
|---|
| 257 | {vizType === "topips" && (
|
|---|
| 258 | <div style={{ display: "grid", gap: 8 }}>
|
|---|
| 259 | {topIps.map(([ip, v]) => (
|
|---|
| 260 | <div
|
|---|
| 261 | key={ip}
|
|---|
| 262 | style={{
|
|---|
| 263 | display: "grid",
|
|---|
| 264 | gridTemplateColumns: "220px 1fr 80px",
|
|---|
| 265 | gap: 10,
|
|---|
| 266 | alignItems: "center",
|
|---|
| 267 | padding: "8px 10px",
|
|---|
| 268 | border: "1px solid rgba(255,255,255,0.08)",
|
|---|
| 269 | borderRadius: 12,
|
|---|
| 270 | }}
|
|---|
| 271 | >
|
|---|
| 272 | <div style={{ color: "rgba(255,255,255,0.85)" }}>{ip}</div>
|
|---|
| 273 | <div style={{ height: 10, background: "rgba(255,255,255,0.08)", borderRadius: 99, overflow: "hidden" }}>
|
|---|
| 274 | <div
|
|---|
| 275 | style={{
|
|---|
| 276 | height: "100%",
|
|---|
| 277 | width: `${Math.min(100, (v / (topIps[0]?.[1] || 1)) * 100)}%`,
|
|---|
| 278 | background: "rgba(80,160,255,0.7)",
|
|---|
| 279 | }}
|
|---|
| 280 | />
|
|---|
| 281 | </div>
|
|---|
| 282 | <div style={{ textAlign: "right", color: "rgba(255,255,255,0.8)" }}>{v}</div>
|
|---|
| 283 | </div>
|
|---|
| 284 | ))}
|
|---|
| 285 | </div>
|
|---|
| 286 | )}
|
|---|
| 287 | </div>
|
|---|
| 288 | </div>
|
|---|
| 289 | );
|
|---|
| 290 | }
|
|---|