Compare commits
36
Commits
714aebfdc0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2509c4fa28 | ||
|
|
757fecc686 | ||
|
|
3aa7ae0c65 | ||
|
|
e6c9a92ff0 | ||
|
|
af68949e07 | ||
|
|
b9b0d4ae39 | ||
|
|
0f4734c85c | ||
|
|
07cd534254 | ||
|
|
65bc7fad67 | ||
|
|
9af60945cf | ||
|
|
67eee0662a | ||
|
|
1531b7ea47 | ||
|
|
8c59ab32d5 | ||
|
|
d651a11472 | ||
|
|
0d07349de0 | ||
|
|
da60155710 | ||
|
|
35ffcb6768 | ||
|
|
68c11eba57 | ||
|
|
2f7da934ea | ||
|
|
1802c2cbee | ||
|
|
6d5dc310df | ||
|
|
b12ac38c6c | ||
|
|
1b81847fc6 | ||
|
|
5302a8bc82 | ||
|
|
571d1513e7 | ||
|
|
32906bca1e | ||
|
|
baa0d24eb4 | ||
|
|
a16b56614c | ||
|
|
10e9406510 | ||
|
|
4ceb4489c5 | ||
|
|
3bfd77a74a | ||
|
|
dbd7fc6f95 | ||
|
|
fc719800f9 | ||
|
|
8536014666 | ||
|
|
5040ac2f16 | ||
|
|
926a1b8165 |
@@ -1,7 +1,6 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -77,7 +77,8 @@ For write-enabled firewall orchestration, create a separate token or role and do
|
||||
Minimum practical write privileges for VM/LXC-level firewall rules:
|
||||
|
||||
- `VM.Audit` so NexaFabric can resolve guests and inspect existing rules.
|
||||
- `VM.Config.Network` on `/vms` or on the narrow VM/LXC paths you want NexaFabric to manage.
|
||||
- `VM.Config.Options` so NexaFabric can enable the guest firewall option before writing rules.
|
||||
- `VM.Config.Network` on `/vms` or on the narrow VM/LXC paths you want NexaFabric to manage, so NexaFabric can set `firewall=1` on guest network interfaces before writing rules.
|
||||
|
||||
NexaFabric writes only rules that carry a `NexaFabric policy=...` comment marker. During apply it removes and replaces its own marked rules for the selected policy, leaving manually created Proxmox firewall rules untouched.
|
||||
|
||||
@@ -156,16 +157,15 @@ Proxmox inventory and guest agent data are enough for:
|
||||
- Static LXC IP discovery.
|
||||
- Policy matching and firewall previews.
|
||||
|
||||
Actual traffic flow visibility, top talkers, byte counters, and per-workload traffic history require an additional telemetry source. Proxmox VE does not provide full flow telemetry for every VM through the basic inventory API.
|
||||
Actual traffic flow visibility, top talkers, byte counters, and per-workload traffic history require the NexaFabric node agent or another telemetry source. Proxmox VE does not provide full flow telemetry for every VM through the basic inventory API.
|
||||
|
||||
Supported or planned options:
|
||||
Supported options:
|
||||
|
||||
- NexaFabric node agent on Proxmox nodes to read host interface counters, VM/LXC interface hints, conntrack flows, nftables ruleset state, and pve-firewall status.
|
||||
- NexaFabric node agent on Proxmox nodes to read VM/LXC interface hints, host interface counters, real IPv4 TCP/UDP/ICMP flows from Linux VM interfaces, conntrack flows when available, nftables ruleset state, and pve-firewall status.
|
||||
- Open vSwitch with sFlow/NetFlow/IPFIX exported to a collector.
|
||||
- Router/firewall flow exports from pfSense, OPNsense, FRR/BGP edge devices, or physical switches.
|
||||
- eBPF or host-level telemetry in future agent builds.
|
||||
|
||||
Until such a source is configured, NexaFabric will show `No flow telemetry collected yet` instead of fake traffic.
|
||||
Until such a source is configured, NexaFabric will show `No flow telemetry collected yet` instead of fake traffic. If the node agent can see VM interface counters but no packet flows, NexaFabric displays the counters as an explicitly marked fallback.
|
||||
|
||||
### 8. Install The Node Agent
|
||||
|
||||
@@ -192,7 +192,19 @@ journalctl -u nexafabric-agent -f
|
||||
systemctl restart nexafabric-agent
|
||||
```
|
||||
|
||||
The agent reports host/interface counters, VMID hints from Proxmox interface names, conntrack flow records, pve-firewall status, and an nftables ruleset hash. NexaFabric maps flow source/destination IPs back to workloads through IPAM, so VM/LXC details can show observed traffic once guest IPs have been discovered. The agent does not enforce policies itself; Proxmox firewall rule apply remains API-driven through NexaFabric.
|
||||
Agent version `0.2.1` reports host/interface counters, VMID hints from Proxmox interface names, real packet-derived IPv4 TCP/UDP/ICMP flows from VM interfaces, conntrack flow records when available, recent kernel firewall log drops/rejects, pve-firewall status, and an nftables ruleset hash. NexaFabric maps flow source/destination IPs back to workloads through IPAM, so VM/LXC details can show observed and blocked traffic once guest IPs have been discovered. Blocked traffic visibility depends on Proxmox/kernel firewall logging being enabled for the rule or default drop that rejected the packet. The agent does not enforce policies itself; Proxmox firewall rule apply remains API-driven through NexaFabric.
|
||||
|
||||
The default agent config enables the packet flow collector:
|
||||
|
||||
```json
|
||||
{
|
||||
"packet_flow_collector": true,
|
||||
"packet_flow_window_seconds": 10,
|
||||
"flow_limit": 500
|
||||
}
|
||||
```
|
||||
|
||||
The collector runs as root through the Linux `AF_PACKET` interface and attaches to Proxmox VM interfaces such as `tap100i0` and `fwln100i0`. It aggregates locally before sending data to NexaFabric; packet payloads are not stored or uploaded.
|
||||
|
||||
### 9. Troubleshooting Proxmox Integration
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# NexaFabric eBPF helper contract
|
||||
|
||||
Agent 0.3.0 can call an optional helper binary at `/opt/nexafabric-agent/nexafabric-ebpf`.
|
||||
The repository includes a dependency-free Go helper source at `nexafabric-ebpf.go`.
|
||||
|
||||
The first implementation uses Linux raw packet sockets on the selected VM/LXC interfaces and prints the same JSON contract that a tc/eBPF implementation should print. This keeps the helper installable on Proxmox immediately while preserving the agent integration point for a later kernel eBPF loader.
|
||||
|
||||
The helper is invoked as:
|
||||
|
||||
```sh
|
||||
nexafabric-ebpf --json --limit 1500 --duration 10 --interfaces tap100i0,fwln100i0
|
||||
```
|
||||
|
||||
It must print JSON to stdout:
|
||||
|
||||
```json
|
||||
{
|
||||
"flows": [
|
||||
{
|
||||
"source_ip": "172.16.0.10",
|
||||
"destination_ip": "172.16.0.20",
|
||||
"protocol": "tcp",
|
||||
"source_port": 443,
|
||||
"destination_port": 53020,
|
||||
"packets": 10,
|
||||
"bytes": 14800,
|
||||
"vmid": "100",
|
||||
"interface": "tap100i0",
|
||||
"direction": "ingress",
|
||||
"state": "observed"
|
||||
}
|
||||
],
|
||||
"diagnostics": {
|
||||
"attach_mode": "af_packet_raw_socket",
|
||||
"interfaces_attached": ["tap100i0"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The Python agent merges these flows with firewall-log, packet, and conntrack fallback collectors.
|
||||
@@ -5,8 +5,10 @@ import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import select
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -16,8 +18,16 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
VERSION = "0.1.0"
|
||||
VERSION = "0.3.0"
|
||||
VM_INTERFACE_RE = re.compile(r"(?:tap|fwbr|fwln|fwpr)(\d+)")
|
||||
VM_INTERFACE_DETAIL_RE = re.compile(r"(?:tap|fwbr|fwln|fwpr)(\d+)i(\d+)")
|
||||
LOG_FIELD_RE = re.compile(r"\b([A-Z]+)=([^\s]+)")
|
||||
FIREWALL_LOG_TS_RE = re.compile(r"\b(\d{2}/[A-Za-z]{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4})\b")
|
||||
ETH_P_IP = 0x0800
|
||||
ETH_P_ALL = 0x0003
|
||||
ETH_P_8021Q = 0x8100
|
||||
ETH_P_8021AD = 0x88A8
|
||||
IP_PROTOCOLS = {1: "icmp", 6: "tcp", 17: "udp"}
|
||||
|
||||
|
||||
def read_text(path: str) -> str | None:
|
||||
@@ -30,11 +40,23 @@ def read_text(path: str) -> str | None:
|
||||
def run_command(args: list[str], timeout: int = 5) -> tuple[int, str]:
|
||||
try:
|
||||
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
|
||||
return result.returncode, result.stdout.strip()
|
||||
output = result.stdout.strip() or result.stderr.strip()
|
||||
return result.returncode, output
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return 127, ""
|
||||
|
||||
|
||||
def executable_exists(path: str) -> bool:
|
||||
return Path(path).exists() and os.access(path, os.X_OK)
|
||||
|
||||
|
||||
def flow_int(value: object, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value) if value not in (None, "") else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def collect_interfaces() -> list[dict[str, Any]]:
|
||||
interfaces = []
|
||||
for item in Path("/sys/class/net").iterdir() if Path("/sys/class/net").exists() else []:
|
||||
@@ -56,6 +78,209 @@ def collect_interfaces() -> list[dict[str, Any]]:
|
||||
return interfaces
|
||||
|
||||
|
||||
def interface_rank(name: str) -> int:
|
||||
if name.startswith("tap"):
|
||||
return 0
|
||||
if name.startswith("fwln"):
|
||||
return 1
|
||||
if name.startswith("fwpr"):
|
||||
return 2
|
||||
if name.startswith("fwbr"):
|
||||
return 3
|
||||
return 9
|
||||
|
||||
|
||||
def collect_interface_traffic(interfaces: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
selected: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for interface in interfaces:
|
||||
vmid = interface.get("vmid")
|
||||
if not vmid:
|
||||
continue
|
||||
detail_match = VM_INTERFACE_DETAIL_RE.search(str(interface.get("name") or ""))
|
||||
nic = detail_match.group(2) if detail_match else "0"
|
||||
key = (str(vmid), nic)
|
||||
current = selected.get(key)
|
||||
if current and interface_rank(str(current.get("name") or "")) <= interface_rank(str(interface.get("name") or "")):
|
||||
continue
|
||||
selected[key] = interface
|
||||
|
||||
traffic = []
|
||||
for (vmid, nic), interface in sorted(selected.items()):
|
||||
rx_bytes = int(interface.get("rx_bytes") or 0)
|
||||
tx_bytes = int(interface.get("tx_bytes") or 0)
|
||||
rx_packets = int(interface.get("rx_packets") or 0)
|
||||
tx_packets = int(interface.get("tx_packets") or 0)
|
||||
traffic.append(
|
||||
{
|
||||
"vmid": vmid,
|
||||
"nic": nic,
|
||||
"interface": interface.get("name"),
|
||||
"source": f"vm:{vmid}",
|
||||
"destination": "network",
|
||||
"protocol": "interface-counter",
|
||||
"rx_bytes": rx_bytes,
|
||||
"tx_bytes": tx_bytes,
|
||||
"bytes": rx_bytes + tx_bytes,
|
||||
"rx_packets": rx_packets,
|
||||
"tx_packets": tx_packets,
|
||||
"packets": rx_packets + tx_packets,
|
||||
"state": interface.get("operstate") or "unknown",
|
||||
}
|
||||
)
|
||||
return traffic
|
||||
|
||||
|
||||
def selected_flow_interfaces(interfaces: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
selected: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for interface in interfaces:
|
||||
name = str(interface.get("name") or "")
|
||||
vmid = interface.get("vmid")
|
||||
if not vmid or not name.startswith(("tap", "fwln")):
|
||||
continue
|
||||
detail_match = VM_INTERFACE_DETAIL_RE.search(name)
|
||||
nic = detail_match.group(2) if detail_match else "0"
|
||||
key = (str(vmid), nic)
|
||||
current = selected.get(key)
|
||||
if current and interface_rank(str(current.get("name") or "")) <= interface_rank(name):
|
||||
continue
|
||||
selected[key] = interface
|
||||
return list(selected.values())
|
||||
|
||||
|
||||
def ipv4_address(raw: bytes) -> str:
|
||||
return socket.inet_ntoa(raw)
|
||||
|
||||
|
||||
def parse_packet_flow(packet: bytes) -> dict[str, Any] | None:
|
||||
if len(packet) < 34:
|
||||
return None
|
||||
offset = 12
|
||||
eth_type = struct.unpack("!H", packet[offset:offset + 2])[0]
|
||||
offset = 14
|
||||
while eth_type in {ETH_P_8021Q, ETH_P_8021AD}:
|
||||
if len(packet) < offset + 4:
|
||||
return None
|
||||
eth_type = struct.unpack("!H", packet[offset + 2:offset + 4])[0]
|
||||
offset += 4
|
||||
if eth_type != ETH_P_IP or len(packet) < offset + 20:
|
||||
return None
|
||||
|
||||
version_ihl = packet[offset]
|
||||
version = version_ihl >> 4
|
||||
ihl = (version_ihl & 0x0F) * 4
|
||||
if version != 4 or ihl < 20 or len(packet) < offset + ihl:
|
||||
return None
|
||||
total_length = struct.unpack("!H", packet[offset + 2:offset + 4])[0]
|
||||
protocol_number = packet[offset + 9]
|
||||
protocol = IP_PROTOCOLS.get(protocol_number)
|
||||
if not protocol:
|
||||
return None
|
||||
source_ip = ipv4_address(packet[offset + 12:offset + 16])
|
||||
destination_ip = ipv4_address(packet[offset + 16:offset + 20])
|
||||
transport_offset = offset + ihl
|
||||
source_port = None
|
||||
destination_port = None
|
||||
if protocol in {"tcp", "udp"}:
|
||||
if len(packet) < transport_offset + 4:
|
||||
return None
|
||||
source_port, destination_port = struct.unpack("!HH", packet[transport_offset:transport_offset + 4])
|
||||
elif protocol == "icmp" and len(packet) >= transport_offset + 2:
|
||||
source_port = packet[transport_offset]
|
||||
destination_port = packet[transport_offset + 1]
|
||||
|
||||
return {
|
||||
"source_ip": source_ip,
|
||||
"destination_ip": destination_ip,
|
||||
"protocol": protocol,
|
||||
"source_port": source_port,
|
||||
"destination_port": destination_port,
|
||||
"packets": 1,
|
||||
"bytes": total_length if total_length else max(len(packet) - offset, 0),
|
||||
"state": "observed",
|
||||
}
|
||||
|
||||
|
||||
def collect_packet_flows(interfaces: list[dict[str, Any]], duration: int, limit: int = 500) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
flow_interfaces = selected_flow_interfaces(interfaces)
|
||||
sockets: dict[socket.socket, dict[str, Any]] = {}
|
||||
diagnostics: dict[str, Any] = {
|
||||
"collector": "linux-af-packet",
|
||||
"duration_seconds": duration,
|
||||
"interfaces_requested": [interface.get("name") for interface in flow_interfaces],
|
||||
"interfaces_opened": [],
|
||||
"errors": [],
|
||||
}
|
||||
for interface in flow_interfaces:
|
||||
name = str(interface.get("name") or "")
|
||||
try:
|
||||
packet_socket = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETH_P_ALL))
|
||||
packet_socket.bind((name, 0))
|
||||
packet_socket.setblocking(False)
|
||||
sockets[packet_socket] = interface
|
||||
diagnostics["interfaces_opened"].append(name)
|
||||
except OSError as exc:
|
||||
diagnostics["errors"].append({"interface": name, "error": str(exc)})
|
||||
|
||||
if not sockets:
|
||||
return [], diagnostics
|
||||
|
||||
flows: dict[tuple[object, ...], dict[str, Any]] = {}
|
||||
deadline = time.monotonic() + max(duration, 1)
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
timeout = min(1.0, max(deadline - time.monotonic(), 0.0))
|
||||
readable, _, _ = select.select(list(sockets), [], [], timeout)
|
||||
for packet_socket in readable:
|
||||
interface = sockets[packet_socket]
|
||||
try:
|
||||
packet = packet_socket.recv(65535)
|
||||
except OSError:
|
||||
continue
|
||||
flow = parse_packet_flow(packet)
|
||||
if not flow:
|
||||
continue
|
||||
name = str(interface.get("name") or "")
|
||||
vmid = str(interface.get("vmid") or "")
|
||||
detail_match = VM_INTERFACE_DETAIL_RE.search(name)
|
||||
nic = detail_match.group(2) if detail_match else "0"
|
||||
key = (
|
||||
vmid,
|
||||
nic,
|
||||
flow["source_ip"],
|
||||
flow["destination_ip"],
|
||||
flow["protocol"],
|
||||
flow.get("source_port"),
|
||||
flow.get("destination_port"),
|
||||
)
|
||||
current = flows.get(key)
|
||||
if current:
|
||||
current["packets"] += 1
|
||||
current["bytes"] += int(flow["bytes"])
|
||||
current["last_seen_at"] = datetime.now(timezone.utc).isoformat()
|
||||
continue
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
flow.update(
|
||||
{
|
||||
"vmid": vmid,
|
||||
"nic": nic,
|
||||
"interface": name,
|
||||
"collector": "linux-af-packet",
|
||||
"first_seen_at": now,
|
||||
"last_seen_at": now,
|
||||
"observed_at": now,
|
||||
}
|
||||
)
|
||||
flows[key] = flow
|
||||
if len(flows) >= limit:
|
||||
diagnostics["truncated"] = True
|
||||
return sorted(flows.values(), key=lambda item: int(item.get("bytes") or 0), reverse=True), diagnostics
|
||||
finally:
|
||||
for packet_socket in sockets:
|
||||
packet_socket.close()
|
||||
|
||||
return sorted(flows.values(), key=lambda item: int(item.get("bytes") or 0), reverse=True), diagnostics
|
||||
|
||||
|
||||
def parse_conntrack_line(line: str) -> dict[str, Any] | None:
|
||||
parts = line.split()
|
||||
if len(parts) < 5 or parts[0] not in {"tcp", "udp", "icmp"}:
|
||||
@@ -113,12 +338,235 @@ def collect_flows(limit: int = 500) -> list[dict[str, Any]]:
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
flow["first_seen_at"] = now
|
||||
flow["last_seen_at"] = now
|
||||
flow["observed_at"] = now
|
||||
flow["collector"] = flow.get("collector") or "conntrack"
|
||||
flows.append(flow)
|
||||
if len(flows) >= limit:
|
||||
break
|
||||
return flows
|
||||
|
||||
|
||||
def firewall_log_seen_at(line: str) -> str:
|
||||
match = FIREWALL_LOG_TS_RE.search(line)
|
||||
if match:
|
||||
try:
|
||||
return datetime.strptime(match.group(1), "%d/%b/%Y:%H:%M:%S %z").astimezone(timezone.utc).isoformat()
|
||||
except ValueError:
|
||||
pass
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def parse_firewall_log_line(line: str) -> dict[str, Any] | None:
|
||||
fields = {key: value for key, value in LOG_FIELD_RE.findall(line)}
|
||||
source_ip = fields.get("SRC")
|
||||
destination_ip = fields.get("DST")
|
||||
protocol = fields.get("PROTO", "").lower()
|
||||
if not source_ip or not destination_ip or protocol not in {"tcp", "udp", "icmp"}:
|
||||
return None
|
||||
|
||||
lowered = line.lower()
|
||||
decision = None
|
||||
if any(token in lowered for token in ("drop", "reject", "deny", "blocked")):
|
||||
decision = "blocked"
|
||||
elif any(token in lowered for token in ("accept", "allow")):
|
||||
decision = "allowed"
|
||||
if not decision:
|
||||
return None
|
||||
|
||||
source_port = fields.get("SPT")
|
||||
destination_port = fields.get("DPT")
|
||||
length = fields.get("LEN")
|
||||
seen_at = firewall_log_seen_at(line)
|
||||
return {
|
||||
"source_ip": source_ip,
|
||||
"destination_ip": destination_ip,
|
||||
"protocol": protocol,
|
||||
"source_port": int(source_port) if source_port and source_port.isdigit() else None,
|
||||
"destination_port": int(destination_port) if destination_port and destination_port.isdigit() else None,
|
||||
"packets": 1,
|
||||
"bytes": int(length) if length and length.isdigit() else 0,
|
||||
"state": decision,
|
||||
"decision": decision,
|
||||
"collector": "firewall-log",
|
||||
"log_excerpt": line[-180:],
|
||||
"first_seen_at": seen_at,
|
||||
"last_seen_at": seen_at,
|
||||
"observed_at": seen_at,
|
||||
}
|
||||
|
||||
|
||||
def firewall_log_lines_from_files(max_lines: int = 5000) -> tuple[list[str], list[dict[str, str]]]:
|
||||
lines: list[str] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for path in ("/var/log/pve-firewall.log", "/var/log/pve-firewall.log.1"):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as handle:
|
||||
file_lines = handle.readlines()[-max_lines:]
|
||||
lines.extend(line.rstrip("\n") for line in file_lines)
|
||||
except OSError as exc:
|
||||
errors.append({"path": path, "error": str(exc)})
|
||||
return lines[-max_lines:], errors
|
||||
|
||||
|
||||
def collect_firewall_log_flows(since_minutes: int = 5, limit: int = 500) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
diagnostics = {"collector": "journalctl-kernel+pve-firewall-log", "since_minutes": since_minutes, "errors": []}
|
||||
code, output = run_command(
|
||||
["journalctl", "-k", "--since", f"-{max(since_minutes, 1)} min", "--no-pager", "-o", "cat"],
|
||||
timeout=10,
|
||||
)
|
||||
log_lines: list[str] = []
|
||||
if code == 0 and output:
|
||||
log_lines.extend(output.splitlines())
|
||||
else:
|
||||
diagnostics["errors"].append(output or "journalctl returned no firewall log output")
|
||||
file_lines, file_errors = firewall_log_lines_from_files(max(limit * 2, 1000))
|
||||
log_lines.extend(file_lines)
|
||||
diagnostics["file_errors"] = file_errors
|
||||
diagnostics["lines_scanned"] = len(log_lines)
|
||||
if not log_lines:
|
||||
return [], diagnostics
|
||||
|
||||
flows: dict[tuple[object, ...], dict[str, Any]] = {}
|
||||
for line in log_lines:
|
||||
if "SRC=" not in line or "DST=" not in line:
|
||||
continue
|
||||
flow = parse_firewall_log_line(line)
|
||||
if not flow:
|
||||
continue
|
||||
key = (
|
||||
flow["source_ip"],
|
||||
flow["destination_ip"],
|
||||
flow["protocol"],
|
||||
flow.get("source_port"),
|
||||
flow.get("destination_port"),
|
||||
flow.get("decision"),
|
||||
)
|
||||
current = flows.get(key)
|
||||
if current:
|
||||
current["packets"] += 1
|
||||
current["bytes"] += int(flow.get("bytes") or 0)
|
||||
current["last_seen_at"] = flow.get("last_seen_at") or datetime.now(timezone.utc).isoformat()
|
||||
current["observed_at"] = current["last_seen_at"]
|
||||
continue
|
||||
flows[key] = flow
|
||||
if len(flows) >= limit:
|
||||
diagnostics["truncated"] = True
|
||||
break
|
||||
diagnostics["flow_count"] = len(flows)
|
||||
return sorted(flows.values(), key=lambda item: int(item.get("packets") or 0), reverse=True), diagnostics
|
||||
|
||||
|
||||
def merge_flow_sources(*sources: list[dict[str, Any]], limit: int = 500) -> list[dict[str, Any]]:
|
||||
flows: dict[tuple[object, ...], dict[str, Any]] = {}
|
||||
for source in sources:
|
||||
for flow in source:
|
||||
key = (
|
||||
flow.get("source_ip"),
|
||||
flow.get("destination_ip"),
|
||||
flow.get("protocol"),
|
||||
flow.get("source_port"),
|
||||
flow.get("destination_port"),
|
||||
flow.get("decision") or flow.get("state") or "observed",
|
||||
)
|
||||
current = flows.get(key)
|
||||
if current:
|
||||
current["packets"] = int(current.get("packets") or 0) + int(flow.get("packets") or 0)
|
||||
current["bytes"] = int(current.get("bytes") or 0) + int(flow.get("bytes") or 0)
|
||||
current_seen = str(current.get("last_seen_at") or current.get("observed_at") or "")
|
||||
flow_seen = str(flow.get("last_seen_at") or flow.get("observed_at") or "")
|
||||
if flow_seen > current_seen:
|
||||
current["last_seen_at"] = flow_seen
|
||||
current["observed_at"] = flow_seen
|
||||
continue
|
||||
flows[key] = dict(flow)
|
||||
return sorted(
|
||||
flows.values(),
|
||||
key=lambda item: (
|
||||
1 if str(item.get("decision") or item.get("state") or "").lower() in {"blocked", "drop", "dropped", "reject", "rejected", "deny", "denied"} else 0,
|
||||
1 if item.get("collector") == "firewall-log" else 0,
|
||||
int(item.get("bytes") or 0),
|
||||
int(item.get("packets") or 0),
|
||||
),
|
||||
reverse=True,
|
||||
)[:limit]
|
||||
|
||||
|
||||
def collect_ebpf_flows(config: dict[str, Any], interfaces: list[dict[str, Any]], limit: int) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
binary = str(config.get("ebpf_binary") or "/opt/nexafabric-agent/nexafabric-ebpf")
|
||||
diagnostics: dict[str, Any] = {
|
||||
"collector": "ebpf",
|
||||
"enabled": bool(config.get("ebpf_collector", False)),
|
||||
"binary": binary,
|
||||
"errors": [],
|
||||
}
|
||||
if not diagnostics["enabled"]:
|
||||
return [], diagnostics
|
||||
if not executable_exists(binary):
|
||||
diagnostics["errors"].append("eBPF helper binary is not installed or not executable")
|
||||
return [], diagnostics
|
||||
|
||||
flow_interfaces = [str(interface.get("name")) for interface in selected_flow_interfaces(interfaces)]
|
||||
if not flow_interfaces:
|
||||
diagnostics["errors"].append("no VM/LXC tap or firewall-link interfaces found")
|
||||
return [], diagnostics
|
||||
|
||||
args = [
|
||||
binary,
|
||||
"--json",
|
||||
"--limit",
|
||||
str(limit),
|
||||
"--duration",
|
||||
str(int(config.get("ebpf_window_seconds", config.get("packet_flow_window_seconds", 10)))),
|
||||
"--interfaces",
|
||||
",".join(flow_interfaces),
|
||||
]
|
||||
code, output = run_command(args, timeout=int(config.get("ebpf_timeout_seconds", 15)))
|
||||
diagnostics["exit_code"] = code
|
||||
if code != 0 or not output:
|
||||
diagnostics["errors"].append(output or "eBPF helper returned no output")
|
||||
return [], diagnostics
|
||||
try:
|
||||
payload = json.loads(output)
|
||||
except json.JSONDecodeError as exc:
|
||||
diagnostics["errors"].append(f"eBPF helper returned invalid JSON: {exc}")
|
||||
diagnostics["output_excerpt"] = output[:300]
|
||||
return [], diagnostics
|
||||
|
||||
flows = payload.get("flows", [])
|
||||
if not isinstance(flows, list):
|
||||
diagnostics["errors"].append("eBPF helper JSON has no flows array")
|
||||
return [], diagnostics
|
||||
normalized = []
|
||||
for flow in flows[:limit]:
|
||||
if not isinstance(flow, dict):
|
||||
continue
|
||||
source_ip = flow.get("source_ip")
|
||||
destination_ip = flow.get("destination_ip")
|
||||
if not source_ip or not destination_ip:
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
**flow,
|
||||
"source_ip": str(source_ip),
|
||||
"destination_ip": str(destination_ip),
|
||||
"protocol": str(flow.get("protocol") or "unknown").lower(),
|
||||
"source_port": flow_int(flow.get("source_port"), 0) or None,
|
||||
"destination_port": flow_int(flow.get("destination_port"), 0) or None,
|
||||
"packets": flow_int(flow.get("packets")),
|
||||
"bytes": flow_int(flow.get("bytes")),
|
||||
"state": str(flow.get("state") or "observed"),
|
||||
"collector": "ebpf",
|
||||
}
|
||||
)
|
||||
diagnostics["flow_count"] = len(normalized)
|
||||
if isinstance(payload.get("diagnostics"), dict):
|
||||
diagnostics["helper"] = payload["diagnostics"]
|
||||
return normalized, diagnostics
|
||||
|
||||
|
||||
def collect_conntrack() -> dict[str, Any]:
|
||||
code, output = run_command(["conntrack", "-C"])
|
||||
if code == 0 and output.isdigit():
|
||||
@@ -132,6 +580,29 @@ def collect_conntrack() -> dict[str, Any]:
|
||||
return {"count": None, "source": "unavailable"}
|
||||
|
||||
|
||||
def collect_flow_diagnostics(
|
||||
conntrack: dict[str, Any],
|
||||
flow_count: int,
|
||||
packet_diagnostics: dict[str, Any] | None = None,
|
||||
firewall_log_diagnostics: dict[str, Any] | None = None,
|
||||
ebpf_diagnostics: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
code, conntrack_path = run_command(["sh", "-c", "command -v conntrack"])
|
||||
return {
|
||||
"flow_count": flow_count,
|
||||
"ebpf_collector": ebpf_diagnostics,
|
||||
"packet_collector": packet_diagnostics,
|
||||
"firewall_log_collector": firewall_log_diagnostics,
|
||||
"conntrack_binary": conntrack_path if code == 0 else None,
|
||||
"conntrack_count": conntrack.get("count"),
|
||||
"conntrack_source": conntrack.get("source"),
|
||||
"bridge_nf_call_iptables": read_text("/proc/sys/net/bridge/bridge-nf-call-iptables"),
|
||||
"bridge_nf_call_ip6tables": read_text("/proc/sys/net/bridge/bridge-nf-call-ip6tables"),
|
||||
"nf_conntrack_max": read_text("/proc/sys/net/netfilter/nf_conntrack_max"),
|
||||
"nf_conntrack_count": read_text("/proc/sys/net/netfilter/nf_conntrack_count"),
|
||||
}
|
||||
|
||||
|
||||
def collect_firewall() -> dict[str, Any]:
|
||||
status = {}
|
||||
code, output = run_command(["systemctl", "is-active", "pve-firewall"])
|
||||
@@ -146,6 +617,29 @@ def collect_firewall() -> dict[str, Any]:
|
||||
|
||||
def collect_payload(config: dict[str, Any]) -> dict[str, Any]:
|
||||
uptime = read_text("/proc/uptime")
|
||||
interfaces = collect_interfaces()
|
||||
flow_limit = int(config.get("flow_limit", 1500))
|
||||
packet_flows: list[dict[str, Any]] = []
|
||||
packet_diagnostics: dict[str, Any] | None = None
|
||||
ebpf_flows: list[dict[str, Any]] = []
|
||||
ebpf_diagnostics: dict[str, Any] | None = None
|
||||
firewall_log_flows: list[dict[str, Any]] = []
|
||||
firewall_log_diagnostics: dict[str, Any] | None = None
|
||||
ebpf_flows, ebpf_diagnostics = collect_ebpf_flows(config, interfaces, flow_limit)
|
||||
if bool(config.get("packet_flow_collector", True)):
|
||||
packet_flows, packet_diagnostics = collect_packet_flows(
|
||||
interfaces,
|
||||
int(config.get("packet_flow_window_seconds", 10)),
|
||||
flow_limit,
|
||||
)
|
||||
if bool(config.get("firewall_log_collector", True)):
|
||||
firewall_log_flows, firewall_log_diagnostics = collect_firewall_log_flows(
|
||||
int(config.get("firewall_log_window_minutes", 5)),
|
||||
flow_limit,
|
||||
)
|
||||
fallback_flows = packet_flows or collect_flows(flow_limit)
|
||||
flows = merge_flow_sources(ebpf_flows, fallback_flows, firewall_log_flows, limit=flow_limit)
|
||||
conntrack = collect_conntrack()
|
||||
return {
|
||||
"version": VERSION,
|
||||
"node_name": config.get("node_name"),
|
||||
@@ -154,11 +648,23 @@ def collect_payload(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"kernel": platform.release(),
|
||||
"uptime_seconds": float(uptime.split()[0]) if uptime else None,
|
||||
"loadavg": list(os.getloadavg()) if hasattr(os, "getloadavg") else [],
|
||||
"interfaces": collect_interfaces(),
|
||||
"flows": collect_flows(int(config.get("flow_limit", 500))),
|
||||
"conntrack": collect_conntrack(),
|
||||
"interfaces": interfaces,
|
||||
"interface_traffic": collect_interface_traffic(interfaces),
|
||||
"flows": flows,
|
||||
"ebpf_flows": ebpf_flows,
|
||||
"conntrack": conntrack,
|
||||
"firewall": collect_firewall(),
|
||||
"extra": {"platform": platform.platform()},
|
||||
"extra": {
|
||||
"platform": platform.platform(),
|
||||
"flow_sources": {
|
||||
"ebpf": len(ebpf_flows),
|
||||
"packet": len(packet_flows),
|
||||
"conntrack_fallback": 0 if packet_flows else len(fallback_flows),
|
||||
"firewall_log": len(firewall_log_flows),
|
||||
"merged": len(flows),
|
||||
},
|
||||
"flow_diagnostics": collect_flow_diagnostics(conntrack, len(flows), packet_diagnostics, firewall_log_diagnostics, ebpf_diagnostics),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -205,6 +711,7 @@ def main() -> int:
|
||||
interval = int(config.get("interval_seconds", 30))
|
||||
|
||||
while True:
|
||||
started_at = time.monotonic()
|
||||
payload = collect_payload(config)
|
||||
try:
|
||||
post_heartbeat(config, payload)
|
||||
@@ -216,7 +723,8 @@ def main() -> int:
|
||||
print(f"heartbeat failed: {exc} api_url={normalized_api_url(config)}", flush=True)
|
||||
if args.once:
|
||||
return 0
|
||||
time.sleep(interval)
|
||||
elapsed = time.monotonic() - started_at
|
||||
time.sleep(max(interval - elapsed, 1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ethPAll = 0x0003
|
||||
ethPIP = 0x0800
|
||||
ethP8021Q = 0x8100
|
||||
ethP8021AD = 0x88A8
|
||||
packetOut = 4
|
||||
protoICMP = 1
|
||||
protoTCP = 6
|
||||
protoUDP = 17
|
||||
maxFrameSize = 65535
|
||||
)
|
||||
|
||||
var vmInterfaceRE = regexp.MustCompile(`(?:tap|fwbr|fwln|fwpr)(\d+)`)
|
||||
var vmInterfaceDetailRE = regexp.MustCompile(`(?:tap|fwbr|fwln|fwpr)(\d+)i(\d+)`)
|
||||
|
||||
type flowKey struct {
|
||||
VMID string
|
||||
NIC string
|
||||
Interface string
|
||||
Direction string
|
||||
SourceIP string
|
||||
DestinationIP string
|
||||
Protocol string
|
||||
SourcePort int
|
||||
DestinationPort int
|
||||
}
|
||||
|
||||
type flowValue struct {
|
||||
SourceIP string `json:"source_ip"`
|
||||
DestinationIP string `json:"destination_ip"`
|
||||
Protocol string `json:"protocol"`
|
||||
SourcePort *int `json:"source_port,omitempty"`
|
||||
DestinationPort *int `json:"destination_port,omitempty"`
|
||||
Packets uint64 `json:"packets"`
|
||||
Bytes uint64 `json:"bytes"`
|
||||
VMID string `json:"vmid,omitempty"`
|
||||
NIC string `json:"nic,omitempty"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
Direction string `json:"direction,omitempty"`
|
||||
State string `json:"state"`
|
||||
Collector string `json:"collector"`
|
||||
FirstSeenAt string `json:"first_seen_at"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
ObservedAt string `json:"observed_at"`
|
||||
}
|
||||
|
||||
type diagnostics struct {
|
||||
AttachMode string `json:"attach_mode"`
|
||||
InterfacesRequested []string `json:"interfaces_requested"`
|
||||
InterfacesAttached []string `json:"interfaces_attached"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
Flows []flowValue `json:"flows"`
|
||||
Diagnostics diagnostics `json:"diagnostics"`
|
||||
}
|
||||
|
||||
func htons(value uint16) uint16 {
|
||||
return (value<<8)&0xff00 | value>>8
|
||||
}
|
||||
|
||||
func htonsInt(value uint16) int {
|
||||
return int(htons(value))
|
||||
}
|
||||
|
||||
func intPtr(value int) *int {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func protocolName(value byte) string {
|
||||
switch value {
|
||||
case protoICMP:
|
||||
return "icmp"
|
||||
case protoTCP:
|
||||
return "tcp"
|
||||
case protoUDP:
|
||||
return "udp"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func parsePacket(packet []byte) (flowKey, int, bool) {
|
||||
var key flowKey
|
||||
if len(packet) < 34 {
|
||||
return key, 0, false
|
||||
}
|
||||
offset := 12
|
||||
ethType := binary.BigEndian.Uint16(packet[offset : offset+2])
|
||||
offset = 14
|
||||
for ethType == ethP8021Q || ethType == ethP8021AD {
|
||||
if len(packet) < offset+4 {
|
||||
return key, 0, false
|
||||
}
|
||||
ethType = binary.BigEndian.Uint16(packet[offset+2 : offset+4])
|
||||
offset += 4
|
||||
}
|
||||
if ethType != ethPIP || len(packet) < offset+20 {
|
||||
return key, 0, false
|
||||
}
|
||||
versionIHL := packet[offset]
|
||||
version := versionIHL >> 4
|
||||
ihl := int(versionIHL&0x0f) * 4
|
||||
if version != 4 || ihl < 20 || len(packet) < offset+ihl {
|
||||
return key, 0, false
|
||||
}
|
||||
totalLength := int(binary.BigEndian.Uint16(packet[offset+2 : offset+4]))
|
||||
protocol := protocolName(packet[offset+9])
|
||||
if protocol == "" {
|
||||
return key, 0, false
|
||||
}
|
||||
key.SourceIP = net.IP(packet[offset+12 : offset+16]).String()
|
||||
key.DestinationIP = net.IP(packet[offset+16 : offset+20]).String()
|
||||
key.Protocol = protocol
|
||||
transportOffset := offset + ihl
|
||||
if protocol == "tcp" || protocol == "udp" {
|
||||
if len(packet) < transportOffset+4 {
|
||||
return key, 0, false
|
||||
}
|
||||
key.SourcePort = int(binary.BigEndian.Uint16(packet[transportOffset : transportOffset+2]))
|
||||
key.DestinationPort = int(binary.BigEndian.Uint16(packet[transportOffset+2 : transportOffset+4]))
|
||||
} else if protocol == "icmp" && len(packet) >= transportOffset+2 {
|
||||
key.SourcePort = int(packet[transportOffset])
|
||||
key.DestinationPort = int(packet[transportOffset+1])
|
||||
}
|
||||
if totalLength <= 0 {
|
||||
totalLength = len(packet) - offset
|
||||
}
|
||||
return key, totalLength, true
|
||||
}
|
||||
|
||||
func interfaceMeta(name string) (string, string) {
|
||||
vmid := ""
|
||||
nic := "0"
|
||||
if match := vmInterfaceRE.FindStringSubmatch(name); len(match) > 1 {
|
||||
vmid = match[1]
|
||||
}
|
||||
if match := vmInterfaceDetailRE.FindStringSubmatch(name); len(match) > 2 {
|
||||
nic = match[2]
|
||||
}
|
||||
return vmid, nic
|
||||
}
|
||||
|
||||
func setFd(fd int, set *syscall.FdSet) {
|
||||
set.Bits[fd/64] |= 1 << uint(fd%64)
|
||||
}
|
||||
|
||||
func isSet(fd int, set *syscall.FdSet) bool {
|
||||
return set.Bits[fd/64]&(1<<uint(fd%64)) != 0
|
||||
}
|
||||
|
||||
func openSocket(interfaceName string) (int, error) {
|
||||
iface, err := net.InterfaceByName(interfaceName)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
fd, err := syscall.Socket(syscall.AF_PACKET, syscall.SOCK_RAW, htonsInt(ethPAll))
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
addr := &syscall.SockaddrLinklayer{Protocol: htons(ethPAll), Ifindex: iface.Index}
|
||||
if err := syscall.Bind(fd, addr); err != nil {
|
||||
_ = syscall.Close(fd)
|
||||
return -1, err
|
||||
}
|
||||
if err := syscall.SetNonblock(fd, true); err != nil {
|
||||
_ = syscall.Close(fd)
|
||||
return -1, err
|
||||
}
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
func collect(interfaceNames []string, duration time.Duration, limit int) payload {
|
||||
result := payload{
|
||||
Flows: []flowValue{},
|
||||
Diagnostics: diagnostics{
|
||||
AttachMode: "af_packet_raw_socket",
|
||||
InterfacesRequested: interfaceNames,
|
||||
InterfacesAttached: []string{},
|
||||
Errors: []string{},
|
||||
},
|
||||
}
|
||||
type socketInfo struct {
|
||||
name string
|
||||
fd int
|
||||
}
|
||||
sockets := []socketInfo{}
|
||||
for _, name := range interfaceNames {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
continue
|
||||
}
|
||||
fd, err := openSocket(strings.TrimSpace(name))
|
||||
if err != nil {
|
||||
result.Diagnostics.Errors = append(result.Diagnostics.Errors, fmt.Sprintf("%s: %v", name, err))
|
||||
continue
|
||||
}
|
||||
sockets = append(sockets, socketInfo{name: strings.TrimSpace(name), fd: fd})
|
||||
result.Diagnostics.InterfacesAttached = append(result.Diagnostics.InterfacesAttached, strings.TrimSpace(name))
|
||||
}
|
||||
defer func() {
|
||||
for _, socket := range sockets {
|
||||
_ = syscall.Close(socket.fd)
|
||||
}
|
||||
}()
|
||||
if len(sockets) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
fdToSocket := map[int]socketInfo{}
|
||||
maxFd := 0
|
||||
for _, socket := range sockets {
|
||||
fdToSocket[socket.fd] = socket
|
||||
if socket.fd > maxFd {
|
||||
maxFd = socket.fd
|
||||
}
|
||||
}
|
||||
flows := map[flowKey]*flowValue{}
|
||||
deadline := time.Now().Add(duration)
|
||||
buffer := make([]byte, maxFrameSize)
|
||||
for time.Now().Before(deadline) {
|
||||
var readfds syscall.FdSet
|
||||
for _, socket := range sockets {
|
||||
setFd(socket.fd, &readfds)
|
||||
}
|
||||
timeout := syscall.NsecToTimeval(int64(250 * time.Millisecond))
|
||||
_, err := syscall.Select(maxFd+1, &readfds, nil, nil, &timeout)
|
||||
if err != nil && err != syscall.EINTR {
|
||||
result.Diagnostics.Errors = append(result.Diagnostics.Errors, err.Error())
|
||||
break
|
||||
}
|
||||
for fd, socket := range fdToSocket {
|
||||
if !isSet(fd, &readfds) {
|
||||
continue
|
||||
}
|
||||
n, from, err := syscall.Recvfrom(fd, buffer, 0)
|
||||
if err != nil {
|
||||
if err != syscall.EAGAIN && err != syscall.EWOULDBLOCK {
|
||||
result.Diagnostics.Errors = append(result.Diagnostics.Errors, fmt.Sprintf("%s: %v", socket.name, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
key, bytes, ok := parsePacket(buffer[:n])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
key.Interface = socket.name
|
||||
key.VMID, key.NIC = interfaceMeta(socket.name)
|
||||
key.Direction = "ingress"
|
||||
if link, ok := from.(*syscall.SockaddrLinklayer); ok && link.Pkttype == packetOut {
|
||||
key.Direction = "egress"
|
||||
}
|
||||
current := flows[key]
|
||||
if current == nil {
|
||||
current = &flowValue{
|
||||
SourceIP: key.SourceIP,
|
||||
DestinationIP: key.DestinationIP,
|
||||
Protocol: key.Protocol,
|
||||
SourcePort: intPtr(key.SourcePort),
|
||||
DestinationPort: intPtr(key.DestinationPort),
|
||||
VMID: key.VMID,
|
||||
NIC: key.NIC,
|
||||
Interface: key.Interface,
|
||||
Direction: key.Direction,
|
||||
State: "observed",
|
||||
Collector: "ebpf-helper",
|
||||
FirstSeenAt: now,
|
||||
LastSeenAt: now,
|
||||
ObservedAt: now,
|
||||
}
|
||||
flows[key] = current
|
||||
}
|
||||
current.Packets++
|
||||
current.Bytes += uint64(bytes)
|
||||
current.LastSeenAt = now
|
||||
current.ObservedAt = now
|
||||
if len(flows) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(flows) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, flow := range flows {
|
||||
result.Flows = append(result.Flows, *flow)
|
||||
}
|
||||
sort.Slice(result.Flows, func(i, j int) bool {
|
||||
if result.Flows[i].Bytes == result.Flows[j].Bytes {
|
||||
return result.Flows[i].Packets > result.Flows[j].Packets
|
||||
}
|
||||
return result.Flows[i].Bytes > result.Flows[j].Bytes
|
||||
})
|
||||
if len(result.Flows) > limit {
|
||||
result.Flows = result.Flows[:limit]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func main() {
|
||||
jsonOutput := flag.Bool("json", false, "print JSON output")
|
||||
limit := flag.Int("limit", 1500, "maximum unique flows")
|
||||
durationSeconds := flag.Int("duration", 10, "collection duration in seconds")
|
||||
interfaces := flag.String("interfaces", "", "comma-separated interface names")
|
||||
flag.Parse()
|
||||
if !*jsonOutput {
|
||||
fmt.Fprintln(os.Stderr, "only --json output is supported")
|
||||
os.Exit(2)
|
||||
}
|
||||
if *limit <= 0 {
|
||||
*limit = 1500
|
||||
}
|
||||
if *durationSeconds <= 0 {
|
||||
*durationSeconds = 10
|
||||
}
|
||||
names := []string{}
|
||||
for _, name := range strings.Split(*interfaces, ",") {
|
||||
if strings.TrimSpace(name) != "" {
|
||||
names = append(names, strings.TrimSpace(name))
|
||||
}
|
||||
}
|
||||
result := collect(names, time.Duration(*durationSeconds)*time.Second, *limit)
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "json marshal failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
+1061
-51
File diff suppressed because it is too large
Load Diff
+16
-1
@@ -1,5 +1,6 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
from app.core.config import get_settings
|
||||
@@ -7,6 +8,20 @@ from app.db.session import Base, SessionLocal, engine
|
||||
from app.seed.demo import seed_demo_data
|
||||
|
||||
|
||||
def ensure_runtime_indexes() -> None:
|
||||
index_statements = [
|
||||
"CREATE INDEX IF NOT EXISTS ix_ip_addresses_address ON ip_addresses (address)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_traffic_flows_node_updated ON traffic_flows (node_id, updated_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_traffic_flows_source_updated ON traffic_flows (source_ip, updated_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_traffic_flows_destination_updated ON traffic_flows (destination_ip, updated_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_traffic_flows_destination_port_updated ON traffic_flows (destination_port, updated_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_traffic_flows_state_updated ON traffic_flows (state, updated_at)",
|
||||
]
|
||||
with engine.begin() as connection:
|
||||
for statement in index_statements:
|
||||
connection.execute(text(statement))
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
app = FastAPI(
|
||||
@@ -27,6 +42,7 @@ def create_app() -> FastAPI:
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
ensure_runtime_indexes()
|
||||
with SessionLocal() as db:
|
||||
seed_demo_data(db)
|
||||
|
||||
@@ -39,4 +55,3 @@ def create_app() -> FastAPI:
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, DateTime, Enum, ForeignKey, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.session import Base
|
||||
@@ -185,7 +185,10 @@ class Subnet(Base, TimestampMixin):
|
||||
|
||||
class IpAddress(Base, TimestampMixin):
|
||||
__tablename__ = "ip_addresses"
|
||||
__table_args__ = (UniqueConstraint("subnet_id", "address"),)
|
||||
__table_args__ = (
|
||||
UniqueConstraint("subnet_id", "address"),
|
||||
Index("ix_ip_addresses_address", "address"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
subnet_id: Mapped[str] = mapped_column(ForeignKey("subnets.id"), index=True)
|
||||
@@ -197,6 +200,13 @@ class IpAddress(Base, TimestampMixin):
|
||||
|
||||
class TrafficFlow(Base, TimestampMixin):
|
||||
__tablename__ = "traffic_flows"
|
||||
__table_args__ = (
|
||||
Index("ix_traffic_flows_node_updated", "node_id", "updated_at"),
|
||||
Index("ix_traffic_flows_source_updated", "source_ip", "updated_at"),
|
||||
Index("ix_traffic_flows_destination_updated", "destination_ip", "updated_at"),
|
||||
Index("ix_traffic_flows_destination_port_updated", "destination_port", "updated_at"),
|
||||
Index("ix_traffic_flows_state_updated", "state", "updated_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
node_id: Mapped[str] = mapped_column(ForeignKey("nodes.id"), index=True)
|
||||
@@ -221,6 +231,17 @@ class SecurityGroup(Base, TimestampMixin):
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class SecurityGroupMember(Base, TimestampMixin):
|
||||
__tablename__ = "security_group_members"
|
||||
__table_args__ = (UniqueConstraint("security_group_id", "workload_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||
security_group_id: Mapped[str] = mapped_column(ForeignKey("security_groups.id"), index=True)
|
||||
workload_id: Mapped[str] = mapped_column(ForeignKey("workloads.id"), index=True)
|
||||
security_group: Mapped[SecurityGroup] = relationship()
|
||||
workload: Mapped[Workload] = relationship()
|
||||
|
||||
|
||||
class SecurityRule(Base, TimestampMixin):
|
||||
__tablename__ = "security_rules"
|
||||
|
||||
|
||||
@@ -113,12 +113,24 @@ class SubnetCreate(BaseModel):
|
||||
dhcp_enabled: bool = False
|
||||
|
||||
|
||||
class SubnetUpdate(BaseModel):
|
||||
network_id: str | None = None
|
||||
cidr: str | None = None
|
||||
gateway: str | None = None
|
||||
dns: list[str] | None = None
|
||||
dhcp_enabled: bool | None = None
|
||||
|
||||
|
||||
class SecurityGroupCreate(BaseModel):
|
||||
project_id: str | None = None
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class SecurityGroupMemberCreate(BaseModel):
|
||||
workload_id: str
|
||||
|
||||
|
||||
class SecurityRuleCreate(BaseModel):
|
||||
security_group_id: str
|
||||
direction: str = "ingress"
|
||||
@@ -160,6 +172,27 @@ class FirewallApplyRequest(BaseModel):
|
||||
dry_run: bool = True
|
||||
|
||||
|
||||
class RuntimeSettingsRead(BaseModel):
|
||||
product: str = "NexaFabric"
|
||||
firewall_apply_requires_preview: bool = True
|
||||
agent_optional: bool = True
|
||||
flow_retention_hours: int = 24
|
||||
auto_node_sync_enabled: bool = False
|
||||
auto_node_sync_interval_minutes: int = 60
|
||||
auto_ipam_sync_enabled: bool = False
|
||||
auto_ipam_sync_interval_minutes: int = 60
|
||||
last_node_auto_sync_at: datetime | None = None
|
||||
last_ipam_auto_sync_at: datetime | None = None
|
||||
|
||||
|
||||
class RuntimeSettingsUpdate(BaseModel):
|
||||
flow_retention_hours: int | None = Field(default=None, ge=1, le=8760)
|
||||
auto_node_sync_enabled: bool | None = None
|
||||
auto_node_sync_interval_minutes: int | None = Field(default=None, ge=1, le=10080)
|
||||
auto_ipam_sync_enabled: bool | None = None
|
||||
auto_ipam_sync_interval_minutes: int | None = Field(default=None, ge=1, le=10080)
|
||||
|
||||
|
||||
class ClusterRead(OrmModel):
|
||||
id: str
|
||||
name: str
|
||||
@@ -203,7 +236,9 @@ class AgentHeartbeat(BaseModel):
|
||||
uptime_seconds: float | None = None
|
||||
loadavg: list[float] = []
|
||||
interfaces: list[dict[str, Any]] = []
|
||||
interface_traffic: list[dict[str, Any]] = []
|
||||
flows: list[dict[str, Any]] = []
|
||||
ebpf_flows: list[dict[str, Any]] = []
|
||||
conntrack: dict[str, Any] = {}
|
||||
firewall: dict[str, Any] = {}
|
||||
extra: dict[str, Any] = {}
|
||||
@@ -275,6 +310,15 @@ class SecurityGroupRead(OrmModel):
|
||||
project_id: str | None
|
||||
name: str
|
||||
description: str | None
|
||||
members: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
class SecurityGroupMemberRead(OrmModel):
|
||||
id: str
|
||||
security_group_id: str
|
||||
workload_id: str
|
||||
workload_name: str | None = None
|
||||
workload_external_id: str | None = None
|
||||
|
||||
|
||||
class SecurityRuleRead(OrmModel):
|
||||
@@ -300,12 +344,14 @@ class PolicyRead(OrmModel):
|
||||
enforcement_mode: str
|
||||
definition: dict[str, Any]
|
||||
last_compiled: dict[str, Any] | None
|
||||
deployment_status: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class WorkloadInsight(BaseModel):
|
||||
workload: WorkloadRead
|
||||
assigned_ips: list[IpAddressRead]
|
||||
traffic: list[dict[str, Any]]
|
||||
active_firewall_rules: list[dict[str, Any]] = []
|
||||
matching_policies: list[PolicyRead]
|
||||
effective_decision: str
|
||||
audit_mode_notes: list[str]
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
from datetime import datetime, timedelta
|
||||
from ipaddress import ip_interface
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.domain import Cluster, IpAddress, Job, Network, Node, Subnet, SystemSetting, Workload
|
||||
from app.services.providers.base import ProviderConnection
|
||||
from app.services.providers.registry import get_provider
|
||||
|
||||
|
||||
def runtime_setting(db: Session) -> SystemSetting:
|
||||
setting = db.get(SystemSetting, "runtime")
|
||||
if not setting:
|
||||
setting = SystemSetting(key="runtime", value={})
|
||||
db.add(setting)
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
return setting
|
||||
|
||||
|
||||
def parse_last_run(value: dict[str, Any], key: str) -> datetime | None:
|
||||
raw = value.get(key)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(str(raw))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def due(value: dict[str, Any], enabled_key: str, interval_key: str, last_key: str) -> bool:
|
||||
if not bool(value.get(enabled_key, False)):
|
||||
return False
|
||||
interval = int(value.get(interval_key) or 60)
|
||||
last_run = parse_last_run(value, last_key)
|
||||
return last_run is None or datetime.utcnow() - last_run >= timedelta(minutes=interval)
|
||||
|
||||
|
||||
def is_container_network(value: str) -> bool:
|
||||
try:
|
||||
interface = ip_interface(value)
|
||||
except ValueError:
|
||||
return False
|
||||
ip = interface.ip
|
||||
network = str(interface.network)
|
||||
if ip.is_loopback or ip.is_link_local:
|
||||
return True
|
||||
if ip.version == 4 and ip.packed[0] == 172 and 17 <= ip.packed[1] <= 31:
|
||||
return True
|
||||
return network.startswith(("10.42.", "10.43.", "10.244.", "10.245."))
|
||||
|
||||
|
||||
def cleanup_discovered_container_networks(db: Session) -> int:
|
||||
removed = 0
|
||||
discovered_networks = db.scalars(select(Network).where(Network.name == "discovered-ipam")).all()
|
||||
for network in discovered_networks:
|
||||
subnets = db.scalars(select(Subnet).where(Subnet.network_id == network.id)).all()
|
||||
for subnet in subnets:
|
||||
if is_container_network(subnet.cidr):
|
||||
addresses = db.scalars(select(IpAddress).where(IpAddress.subnet_id == subnet.id)).all()
|
||||
for address in addresses:
|
||||
db.delete(address)
|
||||
removed += 1
|
||||
db.delete(subnet)
|
||||
return removed
|
||||
|
||||
|
||||
def ensure_discovered_network(db: Session, cluster_id: str) -> Network:
|
||||
network = db.scalar(select(Network).where(Network.cluster_id == cluster_id, Network.name == "discovered-ipam"))
|
||||
if network:
|
||||
return network
|
||||
network = Network(
|
||||
cluster_id=cluster_id,
|
||||
name="discovered-ipam",
|
||||
kind="discovered",
|
||||
description="Automatically created for IP addresses discovered during Proxmox sync.",
|
||||
)
|
||||
db.add(network)
|
||||
db.flush()
|
||||
return network
|
||||
|
||||
|
||||
def import_discovered_ips(db: Session, cluster_id: str, workload: Workload, addresses: list[str]) -> int:
|
||||
imported = 0
|
||||
for value in addresses:
|
||||
try:
|
||||
interface = ip_interface(value)
|
||||
except ValueError:
|
||||
continue
|
||||
if is_container_network(value):
|
||||
continue
|
||||
network = ensure_discovered_network(db, cluster_id)
|
||||
subnet = db.scalar(select(Subnet).where(Subnet.network_id == network.id, Subnet.cidr == str(interface.network)))
|
||||
if not subnet:
|
||||
subnet = Subnet(network_id=network.id, cidr=str(interface.network))
|
||||
db.add(subnet)
|
||||
db.flush()
|
||||
address_value = str(interface.ip)
|
||||
existing = db.scalar(select(IpAddress).where(IpAddress.subnet_id == subnet.id, IpAddress.address == address_value))
|
||||
if existing:
|
||||
existing.workload_id = workload.id
|
||||
existing.status = "assigned"
|
||||
else:
|
||||
db.add(IpAddress(subnet_id=subnet.id, address=address_value, status="assigned", workload_id=workload.id))
|
||||
imported += 1
|
||||
return imported
|
||||
|
||||
|
||||
async def sync_cluster_inventory(db: Session, cluster: Cluster, job_kind: str = "proxmox.auto_sync") -> dict[str, Any]:
|
||||
provider = get_provider(cluster.provider)
|
||||
try:
|
||||
inventory = await provider.sync_inventory(
|
||||
ProviderConnection(
|
||||
api_url=cluster.api_url,
|
||||
token=cluster.token_ref or "",
|
||||
verify_tls=cluster.verify_tls,
|
||||
read_only=cluster.mode == "read_only",
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
cluster.last_sync_at = datetime.utcnow()
|
||||
cluster.last_sync_status = "failed"
|
||||
cluster.last_sync_error = str(exc)
|
||||
db.add(Job(kind=job_kind, status="failed", progress=100, logs=[f"Auto sync failed for {cluster.name}"], error=str(exc)))
|
||||
db.commit()
|
||||
return {"cluster": cluster.name, "status": "failed", "error": str(exc)}
|
||||
|
||||
cluster.last_sync_at = datetime.utcnow()
|
||||
cluster.last_sync_status = "success"
|
||||
cluster.last_sync_error = None
|
||||
node_by_name = {node.name: node for node in db.scalars(select(Node).where(Node.cluster_id == cluster.id)).all()}
|
||||
for raw_node in inventory.get("nodes", []):
|
||||
name = raw_node.get("node") or raw_node.get("name")
|
||||
if not name:
|
||||
continue
|
||||
node = node_by_name.get(name)
|
||||
if not node:
|
||||
node = Node(cluster_id=cluster.id, name=name)
|
||||
db.add(node)
|
||||
node_by_name[name] = node
|
||||
node.status = raw_node.get("status", node.status)
|
||||
node.cpu_count = int(raw_node.get("maxcpu") or raw_node.get("cpu_count") or node.cpu_count or 0)
|
||||
maxmem = raw_node.get("maxmem")
|
||||
node.memory_mb = int(maxmem / 1024 / 1024) if isinstance(maxmem, int | float) else int(raw_node.get("memory_mb") or node.memory_mb or 0)
|
||||
|
||||
db.flush()
|
||||
workloads = {workload.external_id: workload for workload in db.scalars(select(Workload).where(Workload.cluster_id == cluster.id)).all()}
|
||||
for raw_workload in inventory.get("workloads", []):
|
||||
external_id = str(raw_workload.get("vmid") or raw_workload.get("id") or "")
|
||||
if not external_id:
|
||||
continue
|
||||
node = node_by_name.get(raw_workload.get("node")) or next(iter(node_by_name.values()), None)
|
||||
if not node:
|
||||
continue
|
||||
workload = workloads.get(external_id)
|
||||
if not workload:
|
||||
workload = Workload(cluster_id=cluster.id, node_id=node.id, external_id=external_id, name=external_id, kind="qemu")
|
||||
db.add(workload)
|
||||
workloads[external_id] = workload
|
||||
workload.node_id = node.id
|
||||
workload.name = raw_workload.get("name") or workload.name
|
||||
workload.kind = raw_workload.get("type") or raw_workload.get("kind") or workload.kind
|
||||
workload.status = raw_workload.get("status") or workload.status
|
||||
import_discovered_ips(db, cluster.id, workload, raw_workload.get("ip_addresses", []))
|
||||
|
||||
networks = {network.name: network for network in db.scalars(select(Network).where(Network.cluster_id == cluster.id)).all()}
|
||||
for raw_network in inventory.get("networks", []):
|
||||
name = raw_network.get("name") or raw_network.get("iface") or raw_network.get("id")
|
||||
if not name:
|
||||
continue
|
||||
network = networks.get(name)
|
||||
if not network:
|
||||
network = Network(cluster_id=cluster.id, name=name, kind=raw_network.get("type") or "network")
|
||||
db.add(network)
|
||||
networks[name] = network
|
||||
network.kind = raw_network.get("type") or raw_network.get("kind") or network.kind
|
||||
vlan = raw_network.get("vlan") or raw_network.get("vlan_id")
|
||||
network.vlan_id = int(vlan) if vlan not in (None, "") else network.vlan_id
|
||||
|
||||
db.add(Job(kind=job_kind, status="success", progress=100, logs=[f"Auto synced {cluster.name}"]))
|
||||
db.commit()
|
||||
return {"cluster": cluster.name, "status": "success", "inventory_counts": {key: len(value) for key, value in inventory.items()}}
|
||||
|
||||
|
||||
async def discover_ipam(db: Session, job_kind: str = "ipam.auto_discover") -> dict[str, Any]:
|
||||
imported = 0
|
||||
removed = cleanup_discovered_container_networks(db)
|
||||
errors = []
|
||||
for cluster in db.scalars(select(Cluster).order_by(Cluster.name)).all():
|
||||
try:
|
||||
inventory = await get_provider(cluster.provider).sync_inventory(
|
||||
ProviderConnection(
|
||||
api_url=cluster.api_url,
|
||||
token=cluster.token_ref or "",
|
||||
verify_tls=cluster.verify_tls,
|
||||
read_only=True,
|
||||
)
|
||||
)
|
||||
workloads = {workload.external_id: workload for workload in db.scalars(select(Workload).where(Workload.cluster_id == cluster.id)).all()}
|
||||
for raw_workload in inventory.get("workloads", []):
|
||||
workload = workloads.get(str(raw_workload.get("vmid") or raw_workload.get("id") or ""))
|
||||
if workload:
|
||||
imported += import_discovered_ips(db, cluster.id, workload, raw_workload.get("ip_addresses", []))
|
||||
except Exception as exc:
|
||||
errors.append({"cluster": cluster.name, "error": str(exc)})
|
||||
db.add(
|
||||
Job(
|
||||
kind=job_kind,
|
||||
status="success" if not errors else "failed",
|
||||
progress=100,
|
||||
logs=[f"Imported {imported} IP addresses", f"Removed {removed} container bridge IPs"],
|
||||
error=str(errors) if errors else None,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return {"imported": imported, "removed": removed, "errors": errors}
|
||||
|
||||
|
||||
async def run_due_jobs(db: Session) -> list[dict[str, Any]]:
|
||||
setting = runtime_setting(db)
|
||||
value = dict(setting.value or {})
|
||||
results = []
|
||||
if due(value, "auto_node_sync_enabled", "auto_node_sync_interval_minutes", "last_node_auto_sync_at"):
|
||||
for cluster in db.scalars(select(Cluster).order_by(Cluster.name)).all():
|
||||
results.append(await sync_cluster_inventory(db, cluster))
|
||||
value["last_node_auto_sync_at"] = datetime.utcnow().isoformat()
|
||||
if due(value, "auto_ipam_sync_enabled", "auto_ipam_sync_interval_minutes", "last_ipam_auto_sync_at"):
|
||||
results.append(await discover_ipam(db))
|
||||
value["last_ipam_auto_sync_at"] = datetime.utcnow().isoformat()
|
||||
if results:
|
||||
setting.value = value
|
||||
db.commit()
|
||||
return results
|
||||
@@ -127,15 +127,61 @@ class ProxmoxProvider(Provider):
|
||||
"warnings": ["Preview only. No Proxmox firewall changes were sent."],
|
||||
}
|
||||
|
||||
async def list_firewall_rules(self, connection: ProviderConnection, target: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
headers = {"Authorization": self.auth_header(connection.token)}
|
||||
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=10) as client:
|
||||
response = await client.get(self.firewall_rules_url(connection, target), headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.json().get("data", [])
|
||||
|
||||
def firewall_rules_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str:
|
||||
kind = "lxc" if target.get("kind") == "lxc" else "qemu"
|
||||
node = target["node"]
|
||||
vmid = target["vmid"]
|
||||
return f"{connection.api_url.rstrip('/')}/api2/json/nodes/{node}/{kind}/{vmid}/firewall/rules"
|
||||
|
||||
def firewall_options_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str:
|
||||
kind = "lxc" if target.get("kind") == "lxc" else "qemu"
|
||||
node = target["node"]
|
||||
vmid = target["vmid"]
|
||||
return f"{connection.api_url.rstrip('/')}/api2/json/nodes/{node}/{kind}/{vmid}/firewall/options"
|
||||
|
||||
def workload_config_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str:
|
||||
kind = "lxc" if target.get("kind") == "lxc" else "qemu"
|
||||
node = target["node"]
|
||||
vmid = target["vmid"]
|
||||
return f"{connection.api_url.rstrip('/')}/api2/json/nodes/{node}/{kind}/{vmid}/config"
|
||||
|
||||
def cluster_firewall_options_url(self, connection: ProviderConnection) -> str:
|
||||
return f"{connection.api_url.rstrip('/')}/api2/json/cluster/firewall/options"
|
||||
|
||||
def node_firewall_options_url(self, connection: ProviderConnection, target: dict[str, Any]) -> str:
|
||||
node = target["node"]
|
||||
return f"{connection.api_url.rstrip('/')}/api2/json/nodes/{node}/firewall/options"
|
||||
|
||||
def policy_marker(self, rule: dict[str, Any]) -> str:
|
||||
return f"NexaFabric policy={rule.get('policy_id')}"
|
||||
|
||||
def policy_id_marker(self, policy_id: str) -> str:
|
||||
return f"NexaFabric policy={policy_id}"
|
||||
|
||||
def rule_comment_matches_marker(self, comment: str, marker: str) -> bool:
|
||||
return comment == marker or comment.startswith(f"{marker} ")
|
||||
|
||||
def network_firewall_enabled_value(self, value: str) -> str:
|
||||
parts = [part for part in value.split(",") if part]
|
||||
found = False
|
||||
updated = []
|
||||
for part in parts:
|
||||
if part.startswith("firewall="):
|
||||
updated.append("firewall=1")
|
||||
found = True
|
||||
else:
|
||||
updated.append(part)
|
||||
if not found:
|
||||
updated.append("firewall=1")
|
||||
return ",".join(updated)
|
||||
|
||||
async def delete_existing_policy_rules(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
@@ -150,12 +196,99 @@ class ProxmoxProvider(Provider):
|
||||
for existing_rule in sorted(existing_rules, key=lambda item: int(item.get("pos", 0)), reverse=True):
|
||||
comment = str(existing_rule.get("comment") or "")
|
||||
pos = existing_rule.get("pos")
|
||||
if marker in comment and pos is not None:
|
||||
if self.rule_comment_matches_marker(comment, marker) and pos is not None:
|
||||
delete_response = await client.delete(f"{rules_url}/{pos}", headers=headers)
|
||||
delete_response.raise_for_status()
|
||||
deletions.append({"pos": pos, "comment": comment})
|
||||
return deletions
|
||||
|
||||
async def enable_guest_firewall(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
connection: ProviderConnection,
|
||||
target: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
response = await client.put(self.firewall_options_url(connection, target), headers=headers, data={"enable": 1})
|
||||
response.raise_for_status()
|
||||
return response.json().get("data")
|
||||
|
||||
async def enable_guest_firewall_interfaces(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
connection: ProviderConnection,
|
||||
target: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
config_url = self.workload_config_url(connection, target)
|
||||
response = await client.get(config_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
config = response.json().get("data", {})
|
||||
changes: dict[str, str] = {}
|
||||
for key, value in config.items():
|
||||
if not key.startswith("net") or not isinstance(value, str):
|
||||
continue
|
||||
enabled_value = self.network_firewall_enabled_value(value)
|
||||
if enabled_value != value:
|
||||
changes[key] = enabled_value
|
||||
if changes:
|
||||
update_response = await client.put(config_url, headers=headers, data=changes)
|
||||
update_response.raise_for_status()
|
||||
return [{"interface": key, "firewall": 1} for key in sorted(changes)]
|
||||
|
||||
def config_interface_status(self, config: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
interfaces = []
|
||||
for key, value in config.items():
|
||||
if not key.startswith("net") or not isinstance(value, str):
|
||||
continue
|
||||
parts = {part.split("=", 1)[0]: part.split("=", 1)[1] for part in value.split(",") if "=" in part}
|
||||
interfaces.append(
|
||||
{
|
||||
"interface": key,
|
||||
"bridge": parts.get("bridge"),
|
||||
"firewall": parts.get("firewall") == "1",
|
||||
"raw": value,
|
||||
}
|
||||
)
|
||||
return interfaces
|
||||
|
||||
async def firewall_enforcement_status(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
headers: dict[str, str],
|
||||
connection: ProviderConnection,
|
||||
target: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
status: dict[str, Any] = {"target": target, "warnings": []}
|
||||
checks = [
|
||||
("datacenter", self.cluster_firewall_options_url(connection)),
|
||||
("node", self.node_firewall_options_url(connection, target)),
|
||||
("guest", self.firewall_options_url(connection, target)),
|
||||
("config", self.workload_config_url(connection, target)),
|
||||
]
|
||||
for name, url in checks:
|
||||
try:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json().get("data", {})
|
||||
except Exception as exc:
|
||||
status[name] = {"error": str(exc)}
|
||||
status["warnings"].append(f"Unable to read {name} firewall status: {exc}")
|
||||
continue
|
||||
if name == "config":
|
||||
interfaces = self.config_interface_status(data)
|
||||
status["interfaces"] = interfaces
|
||||
disabled = [interface["interface"] for interface in interfaces if not interface.get("firewall")]
|
||||
if disabled:
|
||||
status["warnings"].append(f"VM/LXC network firewall flag is disabled on: {', '.join(disabled)}")
|
||||
continue
|
||||
status[name] = data
|
||||
enabled = data.get("enable")
|
||||
if str(enabled) not in {"1", "True", "true"}:
|
||||
label = {"datacenter": "Datacenter", "node": "Node", "guest": "VM/LXC"}.get(name, name)
|
||||
status["warnings"].append(f"{label} firewall enable option is not active.")
|
||||
return status
|
||||
|
||||
async def apply_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
if connection.read_only:
|
||||
return {"applied": False, "reason": "Cluster is read-only", "rules": rules}
|
||||
@@ -177,6 +310,9 @@ class ProxmoxProvider(Provider):
|
||||
|
||||
applied_rules = []
|
||||
deleted_rules = []
|
||||
enabled_targets = []
|
||||
enabled_interfaces = []
|
||||
enforcement_status = []
|
||||
audit_only_rules = []
|
||||
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
|
||||
for target_rules in grouped.values():
|
||||
@@ -184,6 +320,9 @@ class ProxmoxProvider(Provider):
|
||||
rules_url = self.firewall_rules_url(connection, target)
|
||||
marker = self.policy_marker(target_rules[0])
|
||||
deleted_rules.extend(await self.delete_existing_policy_rules(client, headers, rules_url, marker))
|
||||
if any(not rule.get("audit_only") for rule in target_rules):
|
||||
enabled_targets.append({"target": target, "result": await self.enable_guest_firewall(client, headers, connection, target)})
|
||||
enabled_interfaces.append({"target": target, "interfaces": await self.enable_guest_firewall_interfaces(client, headers, connection, target)})
|
||||
|
||||
for rule in target_rules:
|
||||
if rule.get("audit_only"):
|
||||
@@ -205,11 +344,45 @@ class ProxmoxProvider(Provider):
|
||||
create_response = await client.post(rules_url, headers=headers, data=provider_rule)
|
||||
create_response.raise_for_status()
|
||||
applied_rules.append({"target": target, "rule": provider_rule, "result": create_response.json().get("data")})
|
||||
enforcement_status.append(await self.firewall_enforcement_status(client, headers, connection, target))
|
||||
|
||||
return {
|
||||
"applied": True,
|
||||
"rules_written": len(applied_rules),
|
||||
"rules_deleted": len(deleted_rules),
|
||||
"firewall_enabled": enabled_targets,
|
||||
"interfaces_enabled": enabled_interfaces,
|
||||
"enforcement_status": enforcement_status,
|
||||
"audit_only": audit_only_rules,
|
||||
"rules": applied_rules,
|
||||
}
|
||||
|
||||
async def delete_policy_rules(
|
||||
self,
|
||||
connection: ProviderConnection,
|
||||
targets: list[dict[str, Any]],
|
||||
policy_id: str,
|
||||
) -> dict[str, Any]:
|
||||
if connection.read_only:
|
||||
return {"applied": False, "reason": "Cluster is read-only", "rules_deleted": 0, "targets": targets}
|
||||
|
||||
headers = {"Authorization": self.auth_header(connection.token)}
|
||||
marker = self.policy_id_marker(policy_id)
|
||||
deleted_rules = []
|
||||
errors = []
|
||||
async with httpx.AsyncClient(verify=connection.verify_tls, timeout=20) as client:
|
||||
for target in targets:
|
||||
rules_url = self.firewall_rules_url(connection, target)
|
||||
try:
|
||||
deleted = await self.delete_existing_policy_rules(client, headers, rules_url, marker)
|
||||
except Exception as exc:
|
||||
errors.append({"target": target, "error": str(exc)})
|
||||
continue
|
||||
deleted_rules.extend({"target": target, **item} for item in deleted)
|
||||
|
||||
return {
|
||||
"applied": not errors,
|
||||
"rules_deleted": len(deleted_rules),
|
||||
"deleted_rules": deleted_rules,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import time
|
||||
import asyncio
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.services.auto_sync import run_due_jobs
|
||||
|
||||
|
||||
async def loop() -> None:
|
||||
print("NexaFabric worker started. Auto-sync scheduler is active.", flush=True)
|
||||
while True:
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
results = await run_due_jobs(db)
|
||||
for result in results:
|
||||
print(f"auto-sync: {result}", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"auto-sync failed: {exc}", flush=True)
|
||||
await asyncio.sleep(30)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("NexaFabric worker started. Configure Celery queues for production job execution.", flush=True)
|
||||
while True:
|
||||
time.sleep(30)
|
||||
asyncio.run(loop())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_agent_module():
|
||||
path = Path(__file__).resolve().parents[1] / "app" / "agent_assets" / "nexafabric-agent.py"
|
||||
spec = importlib.util.spec_from_file_location("nexafabric_agent", path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_parse_proxmox_reject_firewall_log_line():
|
||||
agent = load_agent_module()
|
||||
line = (
|
||||
"100 6 tap100i0-IN 09/Jul/2026:23:04:58 +0200 REJECT: IN=fwbr100i0 OUT=fwbr100i0 "
|
||||
"PHYSIN=fwln100i0 PHYSOUT=tap100i0 SRC=172.16.155.74 DST=172.16.0.100 LEN=52 "
|
||||
"TTL=128 ID=47107 PROTO=TCP SPT=58945 DPT=80"
|
||||
)
|
||||
|
||||
flow = agent.parse_firewall_log_line(line)
|
||||
|
||||
assert flow["source_ip"] == "172.16.155.74"
|
||||
assert flow["destination_ip"] == "172.16.0.100"
|
||||
assert flow["protocol"] == "tcp"
|
||||
assert flow["source_port"] == 58945
|
||||
assert flow["destination_port"] == 80
|
||||
assert flow["decision"] == "blocked"
|
||||
assert flow["state"] == "blocked"
|
||||
@@ -19,6 +19,8 @@ class FakeResponse:
|
||||
class FakeAsyncClient:
|
||||
deleted_urls: list[str] = []
|
||||
posted_payloads: list[dict] = []
|
||||
put_urls: list[str] = []
|
||||
put_payloads: list[dict] = []
|
||||
|
||||
def __init__(self, **_: object) -> None:
|
||||
return None
|
||||
@@ -29,7 +31,13 @@ class FakeAsyncClient:
|
||||
async def __aexit__(self, *_: object) -> None:
|
||||
return None
|
||||
|
||||
async def get(self, _: str, **__: object) -> FakeResponse:
|
||||
async def get(self, url: str, **__: object) -> FakeResponse:
|
||||
if url.endswith("/config"):
|
||||
return FakeResponse({"net0": "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,firewall=0", "name": "web"})
|
||||
if url.endswith("/cluster/firewall/options"):
|
||||
return FakeResponse({"enable": 1})
|
||||
if url.endswith("/firewall/options"):
|
||||
return FakeResponse({"enable": 1})
|
||||
return FakeResponse(
|
||||
[
|
||||
{"pos": 0, "comment": "manual rule"},
|
||||
@@ -41,6 +49,11 @@ class FakeAsyncClient:
|
||||
self.deleted_urls.append(url)
|
||||
return FakeResponse(None)
|
||||
|
||||
async def put(self, url: str, data: dict, **__: object) -> FakeResponse:
|
||||
self.put_urls.append(url)
|
||||
self.put_payloads.append(data)
|
||||
return FakeResponse(None)
|
||||
|
||||
async def post(self, _: str, data: dict, **__: object) -> FakeResponse:
|
||||
self.posted_payloads.append(data)
|
||||
return FakeResponse({"pos": 1})
|
||||
@@ -50,6 +63,8 @@ class FakeAsyncClient:
|
||||
async def test_apply_rules_replaces_only_marked_nexafabric_rules(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
FakeAsyncClient.deleted_urls = []
|
||||
FakeAsyncClient.posted_payloads = []
|
||||
FakeAsyncClient.put_urls = []
|
||||
FakeAsyncClient.put_payloads = []
|
||||
monkeypatch.setattr(proxmox.httpx, "AsyncClient", FakeAsyncClient)
|
||||
|
||||
result = await ProxmoxProvider().apply_rules(
|
||||
@@ -74,6 +89,14 @@ async def test_apply_rules_replaces_only_marked_nexafabric_rules(monkeypatch: py
|
||||
assert result["applied"] is True
|
||||
assert result["rules_deleted"] == 1
|
||||
assert FakeAsyncClient.deleted_urls == ["https://pve.example:8006/api2/json/nodes/pve1/qemu/100/firewall/rules/1"]
|
||||
assert FakeAsyncClient.put_urls == [
|
||||
"https://pve.example:8006/api2/json/nodes/pve1/qemu/100/firewall/options",
|
||||
"https://pve.example:8006/api2/json/nodes/pve1/qemu/100/config",
|
||||
]
|
||||
assert FakeAsyncClient.put_payloads == [
|
||||
{"enable": 1},
|
||||
{"net0": "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0,firewall=1"},
|
||||
]
|
||||
assert FakeAsyncClient.posted_payloads == [
|
||||
{
|
||||
"type": "in",
|
||||
@@ -84,3 +107,23 @@ async def test_apply_rules_replaces_only_marked_nexafabric_rules(monkeypatch: py
|
||||
"comment": "NexaFabric policy=policy-1 version=2 rule=1 target=web",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_policy_rules_removes_marked_rules(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
FakeAsyncClient.deleted_urls = []
|
||||
FakeAsyncClient.posted_payloads = []
|
||||
FakeAsyncClient.put_urls = []
|
||||
FakeAsyncClient.put_payloads = []
|
||||
monkeypatch.setattr(proxmox.httpx, "AsyncClient", FakeAsyncClient)
|
||||
|
||||
result = await ProxmoxProvider().delete_policy_rules(
|
||||
ProviderConnection(api_url="https://pve.example:8006", token="user@pve!token=secret", read_only=False),
|
||||
[{"node": "pve1", "kind": "qemu", "vmid": "100"}],
|
||||
"policy-1",
|
||||
)
|
||||
|
||||
assert result["applied"] is True
|
||||
assert result["rules_deleted"] == 1
|
||||
assert FakeAsyncClient.deleted_urls == ["https://pve.example:8006/api2/json/nodes/pve1/qemu/100/firewall/rules/1"]
|
||||
assert FakeAsyncClient.posted_payloads == []
|
||||
|
||||
@@ -16,10 +16,11 @@ import { Policies } from "./pages/Policies";
|
||||
import { PolicyDesigner } from "./pages/PolicyDesigner";
|
||||
import { SecurityGroups } from "./pages/SecurityGroups";
|
||||
import { ServiceCatalog } from "./pages/ServiceCatalog";
|
||||
import { Settings } from "./pages/Settings";
|
||||
import { SetupWizard } from "./pages/SetupWizard";
|
||||
import { TenantsProjects } from "./pages/TenantsProjects";
|
||||
import { UsersRoles } from "./pages/UsersRoles";
|
||||
import { Workloads } from "./pages/Workloads";
|
||||
import { WorkloadDetail, WorkloadFlows, Workloads } from "./pages/Workloads";
|
||||
import { useTheme } from "./stores/theme";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
@@ -49,6 +50,8 @@ function AppRoutes() {
|
||||
<Route path="clusters" element={<Clusters />} />
|
||||
<Route path="nodes" element={<Nodes />} />
|
||||
<Route path="workloads" element={<Workloads />} />
|
||||
<Route path="workloads/:workloadId" element={<WorkloadDetail />} />
|
||||
<Route path="workloads/:workloadId/flows" element={<WorkloadFlows />} />
|
||||
<Route path="networks" element={<Networks />} />
|
||||
<Route path="ipam" element={<Ipam />} />
|
||||
<Route path="tenants" element={<TenantsProjects />} />
|
||||
@@ -60,7 +63,7 @@ function AppRoutes() {
|
||||
<Route path="jobs" element={<ListPage title="Jobs" subtitle="Background task state and execution logs." path="/jobs" columns={[{ key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "progress", label: "Progress" }]} />} />
|
||||
<Route path="audit" element={<ListPage title="Audit Logs" subtitle="Security-relevant activity and change history." path="/audit" columns={[{ key: "created_at", label: "Time" }, { key: "action", label: "Action" }, { key: "object_type", label: "Object" }, { key: "result", label: "Result" }]} />} />
|
||||
<Route path="users" element={<UsersRoles />} />
|
||||
<Route path="settings" element={<ListPage title="Settings" subtitle="Runtime settings and safety defaults." path="/settings" columns={[{ key: "product", label: "Product" }, { key: "firewall_apply_requires_preview", label: "Preview Required" }, { key: "agent_optional", label: "Agent Optional" }]} />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,19 @@ export type Dashboard = {
|
||||
workloads: number;
|
||||
networks: number;
|
||||
open_policy_violations: number;
|
||||
security_posture: string;
|
||||
faulty_nodes: Array<{ id: string; name: string; status: string }>;
|
||||
last_syncs: Array<{ id: string; name: string; provider: string; status: string | null; error: string | null; at: string | null }>;
|
||||
top_talkers: Array<{ name: string; bytes: number }>;
|
||||
suspicious_traffic: Array<{
|
||||
source: string;
|
||||
destination: string;
|
||||
protocol: string;
|
||||
port: number;
|
||||
bytes: number;
|
||||
reason: string;
|
||||
severity: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SetupStatus = {
|
||||
@@ -57,6 +68,7 @@ export type Subnet = {
|
||||
network_id: string;
|
||||
cidr: string;
|
||||
gateway: string | null;
|
||||
dns: string[];
|
||||
dhcp_enabled: boolean;
|
||||
};
|
||||
|
||||
@@ -87,6 +99,15 @@ export type SecurityGroup = {
|
||||
project_id: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
members: SecurityGroupMember[];
|
||||
};
|
||||
|
||||
export type SecurityGroupMember = {
|
||||
id: string;
|
||||
security_group_id: string;
|
||||
workload_id: string;
|
||||
workload_name: string | null;
|
||||
workload_external_id: string | null;
|
||||
};
|
||||
|
||||
export type SecurityRule = {
|
||||
@@ -112,6 +133,7 @@ export type Policy = {
|
||||
enforcement_mode: string;
|
||||
definition: Record<string, unknown>;
|
||||
last_compiled: Record<string, unknown> | null;
|
||||
deployment_status: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type Workload = {
|
||||
@@ -151,10 +173,24 @@ export type AgentInstallInfo = {
|
||||
command: string;
|
||||
};
|
||||
|
||||
export type RuntimeSettings = {
|
||||
product: string;
|
||||
firewall_apply_requires_preview: boolean;
|
||||
agent_optional: boolean;
|
||||
flow_retention_hours: number;
|
||||
auto_node_sync_enabled: boolean;
|
||||
auto_node_sync_interval_minutes: number;
|
||||
auto_ipam_sync_enabled: boolean;
|
||||
auto_ipam_sync_interval_minutes: number;
|
||||
last_node_auto_sync_at: string | null;
|
||||
last_ipam_auto_sync_at: string | null;
|
||||
};
|
||||
|
||||
export type WorkloadInsight = {
|
||||
workload: Workload;
|
||||
assigned_ips: IpAddress[];
|
||||
traffic: Array<Record<string, unknown>>;
|
||||
active_firewall_rules: Array<Record<string, unknown>>;
|
||||
matching_policies: Policy[];
|
||||
effective_decision: string;
|
||||
audit_mode_notes: string[];
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Flame,
|
||||
GitBranch,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
LockKeyhole,
|
||||
Moon,
|
||||
Network,
|
||||
@@ -18,35 +19,85 @@ import {
|
||||
SquareStack,
|
||||
Sun,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { token } from "../api/client";
|
||||
import { clearTokens, token } from "../api/client";
|
||||
import { useTheme } from "../stores/theme";
|
||||
|
||||
const nav = [
|
||||
const navGroups = [
|
||||
{
|
||||
label: "Operate",
|
||||
items: [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ to: "/clusters", label: "Clusters", icon: Server },
|
||||
{ to: "/nodes", label: "Nodes", icon: Activity },
|
||||
{ to: "/workloads", label: "VMs/LXCs", icon: Blocks },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Network",
|
||||
items: [
|
||||
{ to: "/networks", label: "Networks", icon: Network },
|
||||
{ to: "/ipam", label: "IPAM", icon: Database },
|
||||
{ to: "/services", label: "Service Catalog", icon: SquareStack },
|
||||
{ to: "/tenants", label: "Tenants", icon: BriefcaseBusiness },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Security",
|
||||
items: [
|
||||
{ to: "/security-groups", label: "Security Groups", icon: Shield },
|
||||
{ to: "/policies", label: "Policies", icon: GitBranch },
|
||||
{ to: "/services", label: "Service Catalog", icon: SquareStack },
|
||||
{ to: "/designer", label: "Policy Designer", icon: LockKeyhole },
|
||||
{ to: "/firewall", label: "Firewall Preview", icon: Flame },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const systemNav = [
|
||||
{ to: "/jobs", label: "Jobs", icon: ClipboardList },
|
||||
{ to: "/audit", label: "Audit Logs", icon: BookOpen },
|
||||
{ to: "/users", label: "Users", icon: Users },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
function SidebarLink({ item }: { item: { to: string; label: string; icon: LucideIcon } }) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
className={({ isActive }) =>
|
||||
`group relative flex h-9 items-center gap-3 rounded-md px-3 text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-accent/10 text-accent dark:bg-accent/15"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-800/80 dark:hover:text-white"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className={`absolute left-0 h-5 w-0.5 rounded-r-full ${isActive ? "bg-accent" : "bg-transparent"}`} />
|
||||
<Icon size={17} className={isActive ? "text-accent" : "text-slate-400 group-hover:text-slate-700 dark:group-hover:text-slate-200"} />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const { dark, toggle } = useTheme();
|
||||
|
||||
function logout() {
|
||||
clearTokens();
|
||||
navigate("/login");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!token()) navigate("/login");
|
||||
function handleAuthExpired() {
|
||||
@@ -60,37 +111,45 @@ export function Layout() {
|
||||
<div className="min-h-screen bg-canvas text-slate-900 dark:text-slate-100">
|
||||
<aside className="fixed inset-y-0 left-0 hidden w-64 border-r border-border bg-panel md:block">
|
||||
<div className="flex h-16 items-center border-b border-border px-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-9 w-9 place-items-center rounded-md bg-accent text-sm font-semibold text-white">NF</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold">NexaFabric</div>
|
||||
<div className="text-base font-semibold leading-5">NexaFabric</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Control Plane</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="h-[calc(100vh-4rem)] overflow-y-auto p-3">
|
||||
{nav.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`mb-1 flex h-10 items-center gap-3 rounded-md px-3 text-sm ${
|
||||
isActive ? "bg-accent text-white" : "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<nav className="flex h-[calc(100vh-4rem)] flex-col overflow-y-auto p-3">
|
||||
<div className="space-y-5">
|
||||
{navGroups.map((group) => (
|
||||
<section key={group.label}>
|
||||
<div className="mb-2 px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">{group.label}</div>
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => <SidebarLink key={item.to} item={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto border-t border-border pt-3">
|
||||
<div className="mb-2 px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">System</div>
|
||||
<div className="space-y-1">
|
||||
{systemNav.map((item) => <SidebarLink key={item.to} item={item} />)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="md:pl-64">
|
||||
<header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b border-border bg-panel px-4 md:px-6">
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">SDN-like network and security operations</div>
|
||||
<button className="rounded-md border border-border p-2 hover:bg-slate-100 dark:hover:bg-slate-800" onClick={toggle} aria-label="Toggle theme">
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-border hover:bg-slate-100 dark:hover:bg-slate-800" onClick={toggle} aria-label="Toggle theme">
|
||||
{dark ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<button className="inline-flex h-9 items-center gap-2 rounded-md border border-border px-3 text-sm hover:bg-slate-100 dark:hover:bg-slate-800" onClick={logout}>
|
||||
<LogOut size={16} />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-4 md:p-6">
|
||||
<Outlet />
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { LoaderCircle } from "lucide-react";
|
||||
|
||||
type LoadingOverlayProps = {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function LoadingOverlay({ open, title = "Working...", message = "Loading data..." }: LoadingOverlayProps) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/35 px-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm rounded-md border border-border bg-panel p-5 shadow-2xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="grid h-11 w-11 shrink-0 place-items-center rounded-md bg-accent/10 text-accent">
|
||||
<LoaderCircle className="animate-spin" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { inputClass } from "./FormControls";
|
||||
|
||||
export type SearchableOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
type SearchableSelectProps = {
|
||||
options: SearchableOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export function SearchableSelect({ options, value, onChange, placeholder = "Search..." }: SearchableSelectProps) {
|
||||
const selected = options.find((option) => option.value === value);
|
||||
const [query, setQuery] = useState(selected?.label ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized || selected?.label === query) {
|
||||
return options.slice(0, 12);
|
||||
}
|
||||
return options
|
||||
.filter((option) => `${option.label} ${option.detail ?? ""}`.toLowerCase().includes(normalized))
|
||||
.slice(0, 12);
|
||||
}, [options, query, selected?.label]);
|
||||
|
||||
function choose(option: SearchableOption) {
|
||||
onChange(option.value);
|
||||
setQuery(option.label);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={open ? query : selected?.label ?? query}
|
||||
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setQuery(selected?.label ?? "");
|
||||
setOpen(true);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
{open ? (
|
||||
<div className="absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border border-border bg-panel shadow-lg">
|
||||
{filtered.length ? filtered.map((option) => (
|
||||
<button
|
||||
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
key={option.value}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
choose(option);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="block font-medium">{option.label}</span>
|
||||
{option.detail ? <span className="block text-xs text-slate-500">{option.detail}</span> : null}
|
||||
</button>
|
||||
)) : <div className="px-3 py-2 text-sm text-slate-500">No matches.</div>}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Cable, Pencil, Plus, RefreshCcw, Server, Trash2 } from "lucide-react";
|
||||
import { api, Cluster } from "../api/client";
|
||||
import { DataTable } from "../components/DataTable";
|
||||
import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
|
||||
import { LoadingOverlay } from "../components/LoadingOverlay";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
@@ -24,6 +25,7 @@ export function Clusters() {
|
||||
const [result, setResult] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Cluster | null>(null);
|
||||
const [busyMessage, setBusyMessage] = useState("");
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
@@ -69,9 +71,14 @@ export function Clusters() {
|
||||
}
|
||||
|
||||
async function action(cluster: Cluster, kind: "test" | "sync") {
|
||||
setBusyMessage(kind === "sync" ? `Syncing inventory for ${cluster.name}...` : `Testing connection to ${cluster.name}...`);
|
||||
try {
|
||||
const data = await api<Record<string, unknown>>(`/clusters/${cluster.id}/${kind}`, { method: "POST" });
|
||||
setResult(JSON.stringify(data, null, 2));
|
||||
await queryClient.invalidateQueries({ queryKey: ["clusters"] });
|
||||
} finally {
|
||||
setBusyMessage("");
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCluster(cluster: Cluster) {
|
||||
@@ -83,6 +90,7 @@ export function Clusters() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Clusters" subtitle="Register, test, and sync Proxmox or demo providers." />
|
||||
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
|
||||
<div className="space-y-4">
|
||||
<button className={buttonClass} onClick={addCluster}><Plus size={16} /> Add Cluster</button>
|
||||
<Modal title={editing ? "Edit Cluster" : "Add Cluster"} open={open} onClose={() => setOpen(false)}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Boxes, Network, Server, ShieldAlert } from "lucide-react";
|
||||
import { Activity, AlertTriangle, Boxes, Network, Radar, Server, ShieldAlert, ShieldCheck, Wifi } from "lucide-react";
|
||||
|
||||
import { api, Dashboard as DashboardData } from "../api/client";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
@@ -8,16 +8,78 @@ const cards = [
|
||||
["clusters", "Clusters", Server],
|
||||
["nodes", "Nodes", Boxes],
|
||||
["workloads", "VMs/LXCs", Network],
|
||||
["networks", "Networks", Network],
|
||||
["open_policy_violations", "Policy Violations", ShieldAlert],
|
||||
["networks", "Networks", Wifi],
|
||||
["open_policy_violations", "Signals", ShieldAlert],
|
||||
] as const;
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (!value) {
|
||||
return "0 B";
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||
return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function BarList({ items }: { items: Array<{ name: string; bytes: number }> }) {
|
||||
const max = Math.max(...items.map((item) => item.bytes), 1);
|
||||
if (!items.length) {
|
||||
return <div className="border-t border-border py-4 text-sm text-slate-500">No flow telemetry collected yet.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3 border-t border-border pt-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.name} className="grid gap-1">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="truncate font-medium">{item.name}</span>
|
||||
<span className="shrink-0 text-slate-500">{formatBytes(item.bytes)}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
|
||||
<div className="h-full rounded-full bg-accent" style={{ width: `${Math.max((item.bytes / max) * 100, 4)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { data } = useQuery({ queryKey: ["dashboard"], queryFn: () => api<DashboardData>("/dashboard") });
|
||||
const suspicious = data?.suspicious_traffic ?? [];
|
||||
const postureStable = data?.security_posture !== "attention";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Dashboard" subtitle="Operational overview across clusters, networks, policies, and sync health." />
|
||||
<section className="mb-5 rounded-md border border-border bg-panel p-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`grid h-11 w-11 place-items-center rounded-md ${postureStable ? "bg-accent/10 text-accent" : "bg-danger/10 text-danger"}`}>
|
||||
{postureStable ? <ShieldCheck size={22} /> : <Radar size={22} />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold">{postureStable ? "Control plane stable" : "Attention required"}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{postureStable ? "No suspicious traffic signals or faulty nodes detected." : `${suspicious.length} suspicious traffic signal${suspicious.length === 1 ? "" : "s"} require review.`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div className="rounded-md border border-border px-3 py-2">
|
||||
<div className="text-xs text-slate-500">Telemetry</div>
|
||||
<div className="font-medium">{(data?.top_talkers ?? []).length ? "Active" : "Waiting"}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border px-3 py-2">
|
||||
<div className="text-xs text-slate-500">Faulty Nodes</div>
|
||||
<div className="font-medium">{data?.faulty_nodes.length ?? 0}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border px-3 py-2">
|
||||
<div className="text-xs text-slate-500">Signals</div>
|
||||
<div className="font-medium">{suspicious.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{cards.map(([key, label, Icon]) => (
|
||||
<div key={key} className="rounded-md border border-border bg-panel p-4">
|
||||
@@ -29,27 +91,63 @@ export function Dashboard() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<div className="mt-5 grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 flex items-center gap-2 font-medium">
|
||||
<Radar size={18} />
|
||||
Suspicious Traffic
|
||||
</div>
|
||||
{suspicious.length ? (
|
||||
<div className="divide-y divide-border border-t border-border">
|
||||
{suspicious.map((item) => (
|
||||
<div key={`${item.source}-${item.destination}-${item.port}`} className="grid gap-2 py-3 md:grid-cols-[1fr_auto] md:items-center">
|
||||
<div>
|
||||
<div className="font-medium">{item.source} -> {item.destination}</div>
|
||||
<div className="text-xs text-slate-500">{item.protocol}:{item.port} · {item.reason}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className={`rounded-md px-2 py-1 text-xs ${item.severity === "high" ? "bg-danger/10 text-danger" : "bg-amber-500/10 text-amber-500"}`}>{item.severity}</span>
|
||||
<span className="text-slate-500">{formatBytes(item.bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t border-border py-4 text-sm text-slate-500">No suspicious traffic detected from current flow telemetry.</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 flex items-center gap-2 font-medium">
|
||||
<Activity size={18} />
|
||||
Top Talkers
|
||||
</div>
|
||||
<BarList items={data?.top_talkers ?? []} />
|
||||
</section>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 flex items-center gap-2 font-medium">
|
||||
<AlertTriangle size={18} />
|
||||
Faulty Nodes
|
||||
</div>
|
||||
{(data?.faulty_nodes ?? []).map((node) => (
|
||||
{(data?.faulty_nodes ?? []).length ? (data?.faulty_nodes ?? []).map((node) => (
|
||||
<div key={node.id} className="flex justify-between border-t border-border py-3 text-sm">
|
||||
<span>{node.name}</span>
|
||||
<span className="text-danger">{node.status}</span>
|
||||
</div>
|
||||
))}
|
||||
)) : <div className="border-t border-border py-3 text-sm text-slate-500">All known nodes are online.</div>}
|
||||
</section>
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 font-medium">Top Talkers</div>
|
||||
{(data?.top_talkers ?? []).length ? (data?.top_talkers ?? []).map((item) => (
|
||||
<div key={item.name} className="flex justify-between border-t border-border py-3 text-sm">
|
||||
<span>{item.name}</span>
|
||||
<span>{Math.round(item.bytes / 1_000_000)} MB</span>
|
||||
<div className="mb-3 font-medium">Recent Cluster Sync</div>
|
||||
{(data?.last_syncs ?? []).length ? (data?.last_syncs ?? []).map((cluster) => (
|
||||
<div key={cluster.id} className="flex justify-between gap-3 border-t border-border py-3 text-sm">
|
||||
<div>
|
||||
<div className="font-medium">{cluster.name}</div>
|
||||
<div className="text-xs text-slate-500">{cluster.provider} · {cluster.at ? new Date(cluster.at).toLocaleString() : "never synced"}</div>
|
||||
</div>
|
||||
)) : <div className="border-t border-border py-3 text-sm text-slate-500">No flow telemetry collected yet.</div>}
|
||||
<span className={cluster.status === "failed" ? "text-danger" : "text-accent"}>{cluster.status ?? "unknown"}</span>
|
||||
</div>
|
||||
)) : <div className="border-t border-border py-3 text-sm text-slate-500">No cluster sync history yet.</div>}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
|
||||
import { api, Cluster, Policy } from "../api/client";
|
||||
import { buttonClass, Field, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||
import { LoadingOverlay } from "../components/LoadingOverlay";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
export function FirewallPreview() {
|
||||
@@ -28,16 +29,26 @@ export function FirewallPreview() {
|
||||
}),
|
||||
});
|
||||
const selectedPolicyId = policyId || policies.data?.[0]?.id || "";
|
||||
const selectedPolicy = (policies.data ?? []).find((policy) => policy.id === selectedPolicyId);
|
||||
const auditMode = selectedPolicy?.enforcement_mode === "audit";
|
||||
const busyMessage = preview.isPending
|
||||
? "Generating firewall preview..."
|
||||
: apply.isPending
|
||||
? dryRun
|
||||
? "Running dry apply..."
|
||||
: "Applying firewall rules..."
|
||||
: "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Firewall Preview" subtitle="Compile policies into provider-specific rules before any apply operation." />
|
||||
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
|
||||
<div className="grid gap-4 lg:grid-cols-[360px_1fr]">
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="grid gap-3">
|
||||
<Field label="Policy">
|
||||
<select className={selectClass} value={selectedPolicyId} onChange={(event) => setPolicyId(event.target.value)}>
|
||||
{(policies.data ?? []).map((policy) => <option key={policy.id} value={policy.id}>{policy.name}</option>)}
|
||||
{(policies.data ?? []).map((policy) => <option key={policy.id} value={policy.id}>{policy.name} · {policy.enforcement_mode} · v{policy.version}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Cluster">
|
||||
@@ -50,7 +61,9 @@ export function FirewallPreview() {
|
||||
Dry run
|
||||
</label>
|
||||
<div className="rounded-md border border-border bg-canvas p-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
{dryRun
|
||||
{auditMode
|
||||
? "This policy is in audit mode. Preview and dry apply are allowed, but live apply will not write Proxmox firewall rules."
|
||||
: dryRun
|
||||
? "Simulation only. NexaFabric will generate the same provider rules, but nothing is written to Proxmox."
|
||||
: "Live apply. NexaFabric will send the generated rules to the selected write-enabled cluster."}
|
||||
</div>
|
||||
@@ -58,9 +71,9 @@ export function FirewallPreview() {
|
||||
<Play size={18} />
|
||||
Generate Preview
|
||||
</button>
|
||||
<button className={buttonClass} disabled={!selectedPolicyId || apply.isPending} onClick={() => apply.mutate()}>
|
||||
<button className={buttonClass} disabled={!selectedPolicyId || apply.isPending || (auditMode && !dryRun)} onClick={() => apply.mutate()}>
|
||||
<ShieldCheck size={18} />
|
||||
{dryRun ? "Run Dry Apply" : "Apply Confirmed"}
|
||||
{dryRun ? "Run Dry Apply" : auditMode ? "Audit Mode Only" : "Apply Confirmed"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+94
-14
@@ -1,32 +1,42 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Database, Download, Plus } from "lucide-react";
|
||||
import { Database, Download, Pencil, Plus, RefreshCcw } from "lucide-react";
|
||||
|
||||
import { api, authorizedFetch, IpAddress, Network, Subnet } from "../api/client";
|
||||
import { api, authorizedFetch, IpAddress, Network, RuntimeSettings, Subnet } from "../api/client";
|
||||
import { DataTable } from "../components/DataTable";
|
||||
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||
import { buttonClass, Field, iconButtonClass, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||
import { LoadingOverlay } from "../components/LoadingOverlay";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
const emptySubnetForm = { network_id: "", cidr: "10.50.0.0/24", gateway: "10.50.0.1", dns: ["10.50.0.10"], dhcp_enabled: false };
|
||||
|
||||
export function Ipam() {
|
||||
const queryClient = useQueryClient();
|
||||
const networks = useQuery({ queryKey: ["networks"], queryFn: () => api<Network[]>("/networks") });
|
||||
const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api<Subnet[]>("/ipam/subnets") });
|
||||
const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api<IpAddress[]>("/ipam/addresses") });
|
||||
const settings = useQuery({ queryKey: ["settings"], queryFn: () => api<RuntimeSettings>("/settings") });
|
||||
const [message, setMessage] = useState("");
|
||||
const [subnetForm, setSubnetForm] = useState({ network_id: "", cidr: "10.50.0.0/24", gateway: "10.50.0.1", dns: ["10.50.0.10"], dhcp_enabled: false });
|
||||
const [subnetForm, setSubnetForm] = useState(emptySubnetForm);
|
||||
const [ipForm, setIpForm] = useState({ subnet_id: "", address: "10.50.0.20", status: "reserved", note: "" });
|
||||
const [subnetOpen, setSubnetOpen] = useState(false);
|
||||
const [ipOpen, setIpOpen] = useState(false);
|
||||
const [busyMessage, setBusyMessage] = useState("");
|
||||
const [editingSubnet, setEditingSubnet] = useState<Subnet | null>(null);
|
||||
|
||||
const createSubnet = useMutation({
|
||||
mutationFn: () => api<Subnet>("/ipam/subnets", { method: "POST", body: JSON.stringify({ ...subnetForm, network_id: subnetForm.network_id || networks.data?.[0]?.id }) }),
|
||||
const saveSubnet = useMutation({
|
||||
mutationFn: () => api<Subnet>(editingSubnet ? `/ipam/subnets/${editingSubnet.id}` : "/ipam/subnets", {
|
||||
method: editingSubnet ? "PATCH" : "POST",
|
||||
body: JSON.stringify({ ...subnetForm, network_id: subnetForm.network_id || networks.data?.[0]?.id }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setMessage("Subnet created.");
|
||||
setMessage(editingSubnet ? "Subnet updated." : "Subnet created.");
|
||||
setSubnetOpen(false);
|
||||
setEditingSubnet(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["subnets"] });
|
||||
},
|
||||
onError: (error) => setMessage(error instanceof Error ? error.message : "Subnet creation failed."),
|
||||
onError: (error) => setMessage(error instanceof Error ? error.message : "Subnet save failed."),
|
||||
});
|
||||
const createIp = useMutation({
|
||||
mutationFn: () => api<IpAddress>("/ipam/addresses", { method: "POST", body: JSON.stringify({ ...ipForm, subnet_id: ipForm.subnet_id || subnets.data?.[0]?.id }) }),
|
||||
@@ -37,10 +47,17 @@ export function Ipam() {
|
||||
},
|
||||
onError: (error) => setMessage(error instanceof Error ? error.message : "IP reservation failed."),
|
||||
});
|
||||
const toggleAutoDiscover = useMutation({
|
||||
mutationFn: () => api<RuntimeSettings>("/settings", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ auto_ipam_sync_enabled: !settings.data?.auto_ipam_sync_enabled }),
|
||||
}),
|
||||
onSuccess: () => settings.refetch(),
|
||||
});
|
||||
|
||||
async function submitSubnet(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await createSubnet.mutateAsync();
|
||||
await saveSubnet.mutateAsync();
|
||||
}
|
||||
|
||||
async function submitIp(event: FormEvent) {
|
||||
@@ -49,6 +66,8 @@ export function Ipam() {
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
setBusyMessage("Preparing IPAM export...");
|
||||
try {
|
||||
const response = await authorizedFetch("/ipam/export.csv");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -57,9 +76,13 @@ export function Ipam() {
|
||||
link.download = "nexafabric-ipam.csv";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setBusyMessage("");
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverIpam() {
|
||||
setBusyMessage("Discovering IP addresses from Proxmox...");
|
||||
try {
|
||||
const result = await api<{ imported: number; removed: number; errors: Array<Record<string, unknown>> }>("/ipam/discover", { method: "POST" });
|
||||
setMessage(`Discovery imported ${result.imported} IP addresses and removed ${result.removed} container bridge entries${result.errors.length ? " with errors" : ""}.`);
|
||||
@@ -67,23 +90,56 @@ export function Ipam() {
|
||||
await queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "IPAM discovery failed.");
|
||||
} finally {
|
||||
setBusyMessage("");
|
||||
}
|
||||
}
|
||||
|
||||
function addSubnet() {
|
||||
setEditingSubnet(null);
|
||||
setSubnetForm(emptySubnetForm);
|
||||
setSubnetOpen(true);
|
||||
}
|
||||
|
||||
function editSubnet(subnet: Subnet) {
|
||||
setEditingSubnet(subnet);
|
||||
setSubnetForm({
|
||||
network_id: subnet.network_id,
|
||||
cidr: subnet.cidr,
|
||||
gateway: subnet.gateway ?? "",
|
||||
dns: subnet.dns ?? [],
|
||||
dhcp_enabled: subnet.dhcp_enabled,
|
||||
});
|
||||
setSubnetOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="IPAM" subtitle="Manage subnets, reservations, assignments, conflicts, and export state." />
|
||||
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-panel p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Automatic IPAM discovery</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{settings.data?.auto_ipam_sync_enabled ? `Enabled every ${settings.data.auto_ipam_sync_interval_minutes} minutes` : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<button className={secondaryButtonClass} disabled={toggleAutoDiscover.isPending || !settings.data} onClick={() => toggleAutoDiscover.mutate()}>
|
||||
<RefreshCcw size={16} />
|
||||
{settings.data?.auto_ipam_sync_enabled ? "Disable Auto Discover" : "Enable Auto Discover"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className={buttonClass} onClick={() => setSubnetOpen(true)}><Plus size={16} /> Add Subnet</button>
|
||||
<button className={buttonClass} onClick={addSubnet}><Plus size={16} /> Add Subnet</button>
|
||||
<button className={buttonClass} onClick={() => setIpOpen(true)}><Plus size={16} /> Reserve IP</button>
|
||||
<button className={secondaryButtonClass} onClick={discoverIpam}>Discover from Proxmox</button>
|
||||
<button className={secondaryButtonClass} onClick={exportCsv}><Download size={16} /> Export CSV</button>
|
||||
</div>
|
||||
{message ? <div className="rounded-md border border-border bg-panel p-3 text-sm">{message}</div> : null}
|
||||
<Modal title="Add Subnet" open={subnetOpen} onClose={() => setSubnetOpen(false)}>
|
||||
<Modal title={editingSubnet ? "Edit Subnet" : "Add Subnet"} open={subnetOpen} onClose={() => setSubnetOpen(false)}>
|
||||
<form onSubmit={submitSubnet}>
|
||||
<div className="mb-4 flex items-center gap-2 font-medium"><Database size={18} /> Add Subnet</div>
|
||||
<div className="mb-4 flex items-center gap-2 font-medium"><Database size={18} /> {editingSubnet ? "Edit Subnet" : "Add Subnet"}</div>
|
||||
<div className="grid gap-3">
|
||||
<Field label="Network">
|
||||
<select className={selectClass} value={subnetForm.network_id} onChange={(event) => setSubnetForm({ ...subnetForm, network_id: event.target.value })}>
|
||||
@@ -93,7 +149,12 @@ export function Ipam() {
|
||||
</Field>
|
||||
<Field label="CIDR"><input className={inputClass} value={subnetForm.cidr} onChange={(event) => setSubnetForm({ ...subnetForm, cidr: event.target.value })} /></Field>
|
||||
<Field label="Gateway"><input className={inputClass} value={subnetForm.gateway} onChange={(event) => setSubnetForm({ ...subnetForm, gateway: event.target.value })} /></Field>
|
||||
<button className={buttonClass}><Plus size={16} /> Add Subnet</button>
|
||||
<Field label="DNS Servers"><input className={inputClass} value={subnetForm.dns.join(", ")} onChange={(event) => setSubnetForm({ ...subnetForm, dns: event.target.value.split(",").map((item) => item.trim()).filter(Boolean) })} /></Field>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={subnetForm.dhcp_enabled} onChange={(event) => setSubnetForm({ ...subnetForm, dhcp_enabled: event.target.checked })} />
|
||||
DHCP enabled
|
||||
</label>
|
||||
<button className={buttonClass} disabled={saveSubnet.isPending}><Plus size={16} /> {editingSubnet ? "Update Subnet" : "Add Subnet"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
@@ -119,7 +180,26 @@ export function Ipam() {
|
||||
</form>
|
||||
</Modal>
|
||||
<section className="space-y-4">
|
||||
<DataTable rows={(subnets.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "cidr", label: "Subnet" }, { key: "gateway", label: "Gateway" }, { key: "dhcp_enabled", label: "DHCP" }]} />
|
||||
<DataTable
|
||||
rows={(subnets.data ?? []) as unknown as Record<string, unknown>[]}
|
||||
columns={[
|
||||
{ key: "cidr", label: "Subnet" },
|
||||
{ key: "gateway", label: "Gateway" },
|
||||
{ key: "dhcp_enabled", label: "DHCP" },
|
||||
{
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
render: (row) => {
|
||||
const subnet = row as unknown as Subnet;
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<button className={iconButtonClass} title="Edit subnet" aria-label={`Edit subnet ${subnet.cidr}`} onClick={() => editSubnet(subnet)}><Pencil size={16} /></button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DataTable
|
||||
rows={(addresses.data ?? []) as unknown as Record<string, unknown>[]}
|
||||
columns={[
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Cpu, Copy, RadioTower } from "lucide-react";
|
||||
import { Activity, Cpu, Copy, RadioTower, RefreshCcw, ScrollText } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { AgentInstallInfo, api, Node } from "../api/client";
|
||||
import { AgentInstallInfo, api, Node, RuntimeSettings } from "../api/client";
|
||||
import { DataTable } from "../components/DataTable";
|
||||
import { iconButtonClass, secondaryButtonClass } from "../components/FormControls";
|
||||
import { LoadingOverlay } from "../components/LoadingOverlay";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
export function Nodes() {
|
||||
const nodes = useQuery({ queryKey: ["nodes-agents"], queryFn: () => api<Node[]>("/nodes/agents") });
|
||||
const settings = useQuery({ queryKey: ["settings"], queryFn: () => api<RuntimeSettings>("/settings") });
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const [detailNode, setDetailNode] = useState<Node | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const installInfo = useMutation({
|
||||
mutationFn: (node: Node) => api<AgentInstallInfo>(`/nodes/${node.id}/agent/install-info`),
|
||||
@@ -19,6 +22,13 @@ export function Nodes() {
|
||||
setCopied(false);
|
||||
},
|
||||
});
|
||||
const toggleAutoSync = useMutation({
|
||||
mutationFn: () => api<RuntimeSettings>("/settings", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ auto_node_sync_enabled: !settings.data?.auto_node_sync_enabled }),
|
||||
}),
|
||||
onSuccess: () => settings.refetch(),
|
||||
});
|
||||
|
||||
async function copyCommand() {
|
||||
if (!installInfo.data) {
|
||||
@@ -28,9 +38,49 @@ export function Nodes() {
|
||||
setCopied(true);
|
||||
}
|
||||
|
||||
function agentFlows(node: Node) {
|
||||
const flows = node.agent?.last_payload?.flows;
|
||||
const interfaceTraffic = node.agent?.last_payload?.interface_traffic;
|
||||
if (Array.isArray(flows) && flows.length) {
|
||||
return flows;
|
||||
}
|
||||
return Array.isArray(interfaceTraffic) ? interfaceTraffic : [];
|
||||
}
|
||||
|
||||
function agentFlowCount(node: Node) {
|
||||
const count = node.agent?.last_payload?.flow_count;
|
||||
if (typeof count === "number") {
|
||||
return count;
|
||||
}
|
||||
return agentFlows(node).length;
|
||||
}
|
||||
|
||||
function agentInterfaces(node: Node) {
|
||||
const interfaces = node.agent?.last_payload?.interfaces;
|
||||
return Array.isArray(interfaces) ? interfaces : [];
|
||||
}
|
||||
|
||||
function agentConntrack(node: Node) {
|
||||
const conntrack = node.agent?.last_payload?.conntrack;
|
||||
return conntrack && typeof conntrack === "object" ? conntrack as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Nodes" subtitle="Cluster nodes, capacity, health, and NexaFabric agent enrollment." />
|
||||
<LoadingOverlay open={installInfo.isPending} message="Generating node agent installer..." />
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-panel p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Automatic node sync</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{settings.data?.auto_node_sync_enabled ? `Enabled every ${settings.data.auto_node_sync_interval_minutes} minutes` : "Disabled"}
|
||||
</div>
|
||||
</div>
|
||||
<button className={secondaryButtonClass} disabled={toggleAutoSync.isPending || !settings.data} onClick={() => toggleAutoSync.mutate()}>
|
||||
<RefreshCcw size={16} />
|
||||
{settings.data?.auto_node_sync_enabled ? "Disable Auto Sync" : "Enable Auto Sync"}
|
||||
</button>
|
||||
</div>
|
||||
{nodes.isLoading ? <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading...</div> : null}
|
||||
{nodes.error ? <div className="rounded-md border border-danger p-4 text-sm text-danger">Failed to load nodes.</div> : null}
|
||||
{!nodes.isLoading && !nodes.error ? (
|
||||
@@ -56,6 +106,15 @@ export function Nodes() {
|
||||
const node = row as unknown as Node;
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
className={iconButtonClass}
|
||||
title="View agent data"
|
||||
aria-label={`View agent data for ${node.name}`}
|
||||
disabled={!node.agent?.last_payload}
|
||||
onClick={() => setDetailNode(node)}
|
||||
>
|
||||
<ScrollText size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={iconButtonClass}
|
||||
title="Install node agent"
|
||||
@@ -93,6 +152,78 @@ export function Nodes() {
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal title="Agent Data" open={Boolean(detailNode)} onClose={() => setDetailNode(null)}>
|
||||
{detailNode ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<Activity size={18} />
|
||||
{detailNode.name}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-md border border-border bg-canvas p-3">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Status</div>
|
||||
<div className="mt-1 text-sm font-medium">{detailNode.agent?.status ?? "not_installed"}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-canvas p-3">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Flows</div>
|
||||
<div className="mt-1 text-sm font-medium">{agentFlowCount(detailNode)}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-canvas p-3">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Conntrack</div>
|
||||
<div className="mt-1 text-sm font-medium">{String(agentConntrack(detailNode).count ?? "unknown")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<section>
|
||||
<div className="mb-2 text-sm font-medium">Interfaces</div>
|
||||
<div className="max-h-48 overflow-auto rounded-md border border-border">
|
||||
{agentInterfaces(detailNode).length ? agentInterfaces(detailNode).map((item, index) => {
|
||||
const iface = item as Record<string, unknown>;
|
||||
return (
|
||||
<div key={`${String(iface.name)}-${index}`} className="grid grid-cols-[1fr_auto] gap-3 border-b border-border px-3 py-2 text-xs last:border-0">
|
||||
<div>
|
||||
<div className="font-medium">{String(iface.name)}</div>
|
||||
<div className="text-slate-500">state {String(iface.operstate ?? "unknown")} {iface.vmid ? `· VMID ${String(iface.vmid)}` : ""}</div>
|
||||
</div>
|
||||
<div className="text-right text-slate-500">
|
||||
<div>rx {String(iface.rx_bytes ?? 0)}</div>
|
||||
<div>tx {String(iface.tx_bytes ?? 0)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : <div className="p-3 text-xs text-slate-500">No interface telemetry in the last heartbeat.</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<div className="mb-2 text-sm font-medium">Flows</div>
|
||||
<div className="max-h-48 overflow-auto rounded-md border border-border">
|
||||
{agentFlows(detailNode).length ? agentFlows(detailNode).slice(0, 50).map((item, index) => {
|
||||
const flow = item as Record<string, unknown>;
|
||||
const isInterfaceCounter = flow.protocol === "interface-counter";
|
||||
return (
|
||||
<div key={index} className="border-b border-border px-3 py-2 text-xs last:border-0">
|
||||
<div className="font-medium">
|
||||
{isInterfaceCounter
|
||||
? `VMID ${String(flow.vmid)} ${String(flow.interface ?? "")}`
|
||||
: `${String(flow.source_ip)}:${String(flow.source_port ?? "")} -> ${String(flow.destination_ip)}:${String(flow.destination_port ?? "")}`}
|
||||
</div>
|
||||
<div className="text-slate-500">
|
||||
{String(flow.protocol ?? "unknown")} · {String(flow.bytes ?? 0)} bytes · {String(flow.packets ?? 0)} packets · {String(flow.state ?? "unknown")}
|
||||
</div>
|
||||
{isInterfaceCounter ? <div className="text-slate-500">rx {String(flow.rx_bytes ?? 0)} · tx {String(flow.tx_bytes ?? 0)}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}) : <div className="p-3 text-xs text-slate-500">No conntrack flows or interface counters were reported in the last heartbeat.</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<div className="mb-2 text-sm font-medium">Raw Payload</div>
|
||||
<pre className="max-h-60 overflow-auto rounded-md border border-border bg-canvas p-3 text-xs">
|
||||
{JSON.stringify(detailNode.agent?.last_payload ?? {}, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Eye, GitBranch, Pencil, Play, Plus, Trash2 } from "lucide-react";
|
||||
import { AlertTriangle, CheckCircle2, Clock3, Eye, GitBranch, Pencil, Play, Plus, Shield, Trash2 } from "lucide-react";
|
||||
|
||||
import { api, Policy, Project, ServiceCatalogItem } from "../api/client";
|
||||
import { api, Policy, Project, ServiceCatalogItem, Workload } from "../api/client";
|
||||
import { DataTable } from "../components/DataTable";
|
||||
import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
|
||||
import { LoadingOverlay } from "../components/LoadingOverlay";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
@@ -12,6 +13,25 @@ function policyValue(policy: Policy, key: string) {
|
||||
return String(policy.definition?.[key] ?? "");
|
||||
}
|
||||
|
||||
function endpointLabel(value: string, workloads: Workload[]) {
|
||||
if (value.startsWith("workload:")) {
|
||||
const workloadId = value.replace("workload:", "");
|
||||
const workload = workloads.find((item) => item.id === workloadId);
|
||||
return workload ? `VM/LXC: ${workload.name}` : "VM/LXC: unknown";
|
||||
}
|
||||
if (value.startsWith("network:")) {
|
||||
return `Network: ${value.replace("network:", "")}`;
|
||||
}
|
||||
if (value.startsWith("sg:")) {
|
||||
return `Security Group: ${value.replace("sg:", "")}`;
|
||||
}
|
||||
return value || "any";
|
||||
}
|
||||
|
||||
function policyEndpoint(policy: Policy, key: string, workloads: Workload[]) {
|
||||
return endpointLabel(policyValue(policy, key), workloads);
|
||||
}
|
||||
|
||||
function policyService(policy: Policy) {
|
||||
const service = policy.definition?.service;
|
||||
if (!service || typeof service !== "object") {
|
||||
@@ -21,6 +41,60 @@ function policyService(policy: Policy) {
|
||||
return `${String(value.protocol ?? "")}/${String(value.ports ?? "")}`;
|
||||
}
|
||||
|
||||
function policyStatus(policy: Policy) {
|
||||
const status = policy.deployment_status ?? {};
|
||||
return {
|
||||
state: String(status.state ?? "unknown"),
|
||||
label: String(status.label ?? "Unknown"),
|
||||
expectedRules: Number(status.expected_rules ?? 0),
|
||||
activeRules: Number(status.active_rules ?? 0),
|
||||
staleRules: Number(status.stale_rules ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function statusClass(state: string) {
|
||||
if (state === "active") {
|
||||
return "border-accent/40 bg-accent/10 text-accent";
|
||||
}
|
||||
if (state === "audit") {
|
||||
return "border-amber-400/40 bg-amber-400/10 text-amber-300";
|
||||
}
|
||||
if (["stale", "partial", "unresolved"].includes(state)) {
|
||||
return "border-danger/40 bg-danger/10 text-danger";
|
||||
}
|
||||
return "border-border bg-canvas text-slate-500";
|
||||
}
|
||||
|
||||
function StatusIcon({ state }: { state: string }) {
|
||||
if (state === "active") {
|
||||
return <CheckCircle2 size={15} />;
|
||||
}
|
||||
if (state === "audit") {
|
||||
return <Shield size={15} />;
|
||||
}
|
||||
if (["stale", "partial", "unresolved"].includes(state)) {
|
||||
return <AlertTriangle size={15} />;
|
||||
}
|
||||
return <Clock3 size={15} />;
|
||||
}
|
||||
|
||||
function PolicyDeploymentStatus({ policy }: { policy: Policy }) {
|
||||
const status = policyStatus(policy);
|
||||
const detail =
|
||||
status.state === "audit"
|
||||
? "No live firewall write"
|
||||
: `${status.activeRules}/${status.expectedRules} active${status.staleRules ? ` · ${status.staleRules} stale` : ""}`;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={`inline-flex w-fit items-center gap-1.5 rounded-md border px-2 py-1 text-xs ${statusClass(status.state)}`}>
|
||||
<StatusIcon state={status.state} />
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{detail}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultPolicyForm = {
|
||||
project_id: "",
|
||||
name: "Web to DB",
|
||||
@@ -40,11 +114,13 @@ export function Policies() {
|
||||
const queryClient = useQueryClient();
|
||||
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") });
|
||||
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
|
||||
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
|
||||
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
|
||||
const [preview, setPreview] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Policy | null>(null);
|
||||
const [form, setForm] = useState(defaultPolicyForm);
|
||||
const [busyMessage, setBusyMessage] = useState("");
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
@@ -85,14 +161,24 @@ export function Policies() {
|
||||
}
|
||||
|
||||
async function compile(policy: Policy) {
|
||||
setBusyMessage(`Compiling ${policy.name}...`);
|
||||
try {
|
||||
const data = await api<Policy>(`/policies/${policy.id}/compile`, { method: "POST" });
|
||||
setPreview(JSON.stringify(data.last_compiled, null, 2));
|
||||
await queryClient.invalidateQueries({ queryKey: ["policies"] });
|
||||
} finally {
|
||||
setBusyMessage("");
|
||||
}
|
||||
}
|
||||
|
||||
async function firewallPreview(policy: Policy) {
|
||||
setBusyMessage(`Generating preview for ${policy.name}...`);
|
||||
try {
|
||||
const data = await api<Record<string, unknown>>(`/firewall/preview/${policy.id}`, { method: "POST" });
|
||||
setPreview(JSON.stringify(data, null, 2));
|
||||
} finally {
|
||||
setBusyMessage("");
|
||||
}
|
||||
}
|
||||
|
||||
function addPolicy() {
|
||||
@@ -140,6 +226,7 @@ export function Policies() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Policies" subtitle="Create versioned microsegmentation policies and compile firewall previews." />
|
||||
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
|
||||
<div className="space-y-4">
|
||||
<button className={buttonClass} onClick={addPolicy}><Plus size={16} /> Add Policy</button>
|
||||
<Modal title={editing ? "Edit Policy" : "Add Policy"} open={open} onClose={() => setOpen(false)}>
|
||||
@@ -149,8 +236,8 @@ export function Policies() {
|
||||
<Field label="Project"><select className={selectClass} value={form.project_id} onChange={(event) => setForm({ ...form, project_id: event.target.value })}><option value="">Global</option>{(projects.data ?? []).map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}</select></Field>
|
||||
<Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Source"><input className={inputClass} value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })} /></Field>
|
||||
<Field label="Destination"><input className={inputClass} value={form.destination} onChange={(event) => setForm({ ...form, destination: event.target.value })} /></Field>
|
||||
<Field label="Source"><input className={inputClass} value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })} placeholder="any, workload:<id>, 172.16.0.50 or 172.16.0.0/16" /></Field>
|
||||
<Field label="Destination"><input className={inputClass} value={form.destination} onChange={(event) => setForm({ ...form, destination: event.target.value })} placeholder="any, workload:<id>, 172.16.0.53 or 172.16.10.0/24" /></Field>
|
||||
</div>
|
||||
<Field label="Service"><select className={selectClass} value={form.service_id} onChange={(event) => chooseService(event.target.value)}><option value="">Custom</option>{(services.data ?? []).map((service) => <option key={service.id} value={service.id}>{service.name} {service.ports}</option>)}</select></Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -183,10 +270,11 @@ export function Policies() {
|
||||
rows={(policies.data ?? []) as unknown as Record<string, unknown>[]}
|
||||
columns={[
|
||||
{ key: "name", label: "Policy" },
|
||||
{ key: "source", label: "Source", render: (row) => policyValue(row as unknown as Policy, "source") },
|
||||
{ key: "destination", label: "Destination", render: (row) => policyValue(row as unknown as Policy, "destination") },
|
||||
{ key: "source", label: "Source", render: (row) => policyEndpoint(row as unknown as Policy, "source", workloads.data ?? []) },
|
||||
{ key: "destination", label: "Destination", render: (row) => policyEndpoint(row as unknown as Policy, "destination", workloads.data ?? []) },
|
||||
{ key: "service", label: "Service", render: (row) => policyService(row as unknown as Policy) },
|
||||
{ key: "enforcement_mode", label: "Mode" },
|
||||
{ key: "deployment_status", label: "Status", render: (row) => <PolicyDeploymentStatus policy={row as unknown as Policy} /> },
|
||||
{ key: "version", label: "Version" },
|
||||
{
|
||||
key: "actions",
|
||||
|
||||
@@ -5,12 +5,23 @@ import { Save, Wand2 } from "lucide-react";
|
||||
import { api, Network, Policy, SecurityGroup, ServiceCatalogItem, Workload } from "../api/client";
|
||||
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { SearchableSelect } from "../components/SearchableSelect";
|
||||
|
||||
type TargetOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const customTargetValue = "__custom_ip_cidr__";
|
||||
|
||||
function endpointSelectValue(value: string, targets: TargetOption[]) {
|
||||
return targets.some((target) => target.value === value) ? value : customTargetValue;
|
||||
}
|
||||
|
||||
function isCustomEndpoint(value: string, targets: TargetOption[]) {
|
||||
return endpointSelectValue(value, targets) === customTargetValue;
|
||||
}
|
||||
|
||||
export function PolicyDesigner() {
|
||||
const queryClient = useQueryClient();
|
||||
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
|
||||
@@ -35,8 +46,13 @@ export function PolicyDesigner() {
|
||||
const targets = useMemo<TargetOption[]>(() => {
|
||||
return [
|
||||
{ label: "Any", value: "any" },
|
||||
{ label: "Custom IP/CIDR", value: customTargetValue },
|
||||
...(workloads.data ?? []).map((workload) => ({ label: `VM/LXC: ${workload.name}`, value: `workload:${workload.id}` })),
|
||||
...(securityGroups.data ?? []).map((group) => ({ label: `Security Group: ${group.name}`, value: `sg:${group.name}` })),
|
||||
...(securityGroups.data ?? []).map((group) => ({
|
||||
label: `Security Group: ${group.name}`,
|
||||
value: `sg:${group.id}`,
|
||||
detail: `${group.members?.length ?? 0} member${group.members?.length === 1 ? "" : "s"}`,
|
||||
})),
|
||||
...(networks.data ?? []).map((network) => ({ label: `Network: ${network.name}`, value: `network:${network.name}` })),
|
||||
];
|
||||
}, [networks.data, securityGroups.data, workloads.data]);
|
||||
@@ -115,14 +131,38 @@ export function PolicyDesigner() {
|
||||
</Field>
|
||||
<div />
|
||||
<Field label="Source">
|
||||
<select className={selectClass} value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}>
|
||||
{targets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
options={targets}
|
||||
value={endpointSelectValue(form.source, targets)}
|
||||
onChange={(value) => setForm({ ...form, source: value === customTargetValue ? "" : value })}
|
||||
placeholder="Search source..."
|
||||
/>
|
||||
{isCustomEndpoint(form.source, targets) ? (
|
||||
<input
|
||||
className={`${inputClass} mt-2`}
|
||||
value={form.source}
|
||||
onChange={(event) => setForm({ ...form, source: event.target.value.trim() })}
|
||||
placeholder="172.16.0.50 or 172.16.0.0/16"
|
||||
required
|
||||
/>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field label="Destination">
|
||||
<select className={selectClass} value={form.destination} onChange={(event) => setForm({ ...form, destination: event.target.value })}>
|
||||
{targets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
options={targets}
|
||||
value={endpointSelectValue(form.destination, targets)}
|
||||
onChange={(value) => setForm({ ...form, destination: value === customTargetValue ? "" : value })}
|
||||
placeholder="Search destination..."
|
||||
/>
|
||||
{isCustomEndpoint(form.destination, targets) ? (
|
||||
<input
|
||||
className={`${inputClass} mt-2`}
|
||||
value={form.destination}
|
||||
onChange={(event) => setForm({ ...form, destination: event.target.value.trim() })}
|
||||
placeholder="172.16.0.53 or 172.16.10.0/24"
|
||||
required
|
||||
/>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field label="Service">
|
||||
<select className={selectClass} value={form.service_id} onChange={(event) => chooseService(event.target.value)}>
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Shield } from "lucide-react";
|
||||
import { Plus, Shield, Trash2, UserPlus } from "lucide-react";
|
||||
|
||||
import { api, Project, SecurityGroup, SecurityRule } from "../api/client";
|
||||
import { api, Project, SecurityGroup, SecurityGroupMember, SecurityRule, Workload } from "../api/client";
|
||||
import { DataTable } from "../components/DataTable";
|
||||
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
|
||||
import { buttonClass, Field, iconButtonClass, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { SearchableSelect, SearchableOption } from "../components/SearchableSelect";
|
||||
|
||||
export function SecurityGroups() {
|
||||
const queryClient = useQueryClient();
|
||||
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
|
||||
const groups = useQuery({ queryKey: ["security-groups"], queryFn: () => api<SecurityGroup[]>("/security-groups") });
|
||||
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
|
||||
const [selectedGroupId, setSelectedGroupId] = useState("");
|
||||
const selectedGroup = useMemo(() => selectedGroupId || groups.data?.[0]?.id || "", [groups.data, selectedGroupId]);
|
||||
const selectedGroupRecord = useMemo(() => (groups.data ?? []).find((group) => group.id === selectedGroup), [groups.data, selectedGroup]);
|
||||
const memberOptions = useMemo<SearchableOption[]>(() => {
|
||||
const existing = new Set((selectedGroupRecord?.members ?? []).map((member) => member.workload_id));
|
||||
return (workloads.data ?? [])
|
||||
.filter((workload) => !existing.has(workload.id))
|
||||
.map((workload) => ({
|
||||
label: workload.name,
|
||||
value: workload.id,
|
||||
detail: `${workload.kind} · VMID ${workload.external_id} · ${workload.status}`,
|
||||
}));
|
||||
}, [selectedGroupRecord?.members, workloads.data]);
|
||||
const rules = useQuery({
|
||||
queryKey: ["security-rules", selectedGroup],
|
||||
queryFn: () => api<SecurityRule[]>(`/security-groups/${selectedGroup}/rules`),
|
||||
@@ -33,6 +46,8 @@ export function SecurityGroups() {
|
||||
});
|
||||
const [groupOpen, setGroupOpen] = useState(false);
|
||||
const [ruleOpen, setRuleOpen] = useState(false);
|
||||
const [memberOpen, setMemberOpen] = useState(false);
|
||||
const [memberWorkloadId, setMemberWorkloadId] = useState("");
|
||||
|
||||
const createGroup = useMutation({
|
||||
mutationFn: () => api<SecurityGroup>("/security-groups", { method: "POST", body: JSON.stringify({ ...groupForm, project_id: groupForm.project_id || null }) }),
|
||||
@@ -48,6 +63,18 @@ export function SecurityGroups() {
|
||||
queryClient.invalidateQueries({ queryKey: ["security-rules", selectedGroup] });
|
||||
},
|
||||
});
|
||||
const addMember = useMutation({
|
||||
mutationFn: () => api<SecurityGroupMember>(`/security-groups/${selectedGroup}/members`, { method: "POST", body: JSON.stringify({ workload_id: memberWorkloadId }) }),
|
||||
onSuccess: () => {
|
||||
setMemberOpen(false);
|
||||
setMemberWorkloadId("");
|
||||
queryClient.invalidateQueries({ queryKey: ["security-groups"] });
|
||||
},
|
||||
});
|
||||
const removeMember = useMutation({
|
||||
mutationFn: (member: SecurityGroupMember) => api<{ status: string }>(`/security-groups/${member.security_group_id}/members/${member.id}`, { method: "DELETE" }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["security-groups"] }),
|
||||
});
|
||||
|
||||
async function submitGroup(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
@@ -67,6 +94,7 @@ export function SecurityGroups() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className={buttonClass} onClick={() => setGroupOpen(true)}><Plus size={16} /> Add Group</button>
|
||||
<button className={buttonClass} onClick={() => setRuleOpen(true)} disabled={!selectedGroup}><Plus size={16} /> Add Rule</button>
|
||||
<button className={secondaryButtonClass} onClick={() => setMemberOpen(true)} disabled={!selectedGroup || !memberOptions.length}><UserPlus size={16} /> Add Member</button>
|
||||
</div>
|
||||
<Modal title="Add Security Group" open={groupOpen} onClose={() => setGroupOpen(false)}>
|
||||
<form onSubmit={submitGroup}>
|
||||
@@ -84,6 +112,26 @@ export function SecurityGroups() {
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal title="Add Group Member" open={memberOpen} onClose={() => setMemberOpen(false)}>
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
await addMember.mutateAsync();
|
||||
}}
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-2 font-medium"><UserPlus size={18} /> Add Member</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="rounded-md border border-border bg-canvas p-3 text-sm">
|
||||
<div className="text-xs text-slate-500">Security Group</div>
|
||||
<div className="mt-1 font-medium">{selectedGroupRecord?.name ?? "No group selected"}</div>
|
||||
</div>
|
||||
<Field label="VM/LXC">
|
||||
<SearchableSelect options={memberOptions} value={memberWorkloadId} onChange={setMemberWorkloadId} placeholder="Search VM/LXC..." />
|
||||
</Field>
|
||||
<button className={buttonClass} disabled={!memberWorkloadId || addMember.isPending}><Plus size={16} /> Add Member</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal title="Add Security Rule" open={ruleOpen} onClose={() => setRuleOpen(false)}>
|
||||
<form onSubmit={submitRule}>
|
||||
<div className="mb-4 font-medium">Add Rule</div>
|
||||
@@ -108,7 +156,66 @@ export function SecurityGroups() {
|
||||
</form>
|
||||
</Modal>
|
||||
<section className="space-y-4">
|
||||
<DataTable rows={(groups.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Group" }, { key: "description", label: "Description" }]} />
|
||||
<DataTable
|
||||
rows={(groups.data ?? []) as unknown as Record<string, unknown>[]}
|
||||
selectedId={selectedGroup}
|
||||
onRowClick={(row) => setSelectedGroupId(String(row.id))}
|
||||
columns={[
|
||||
{ key: "name", label: "Group" },
|
||||
{ key: "description", label: "Description" },
|
||||
{
|
||||
key: "members",
|
||||
label: "Members",
|
||||
render: (row) => {
|
||||
const group = row as unknown as SecurityGroup;
|
||||
const members = group.members ?? [];
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{members.slice(0, 5).map((member) => (
|
||||
<span key={member.id} className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-slate-500">
|
||||
{member.workload_name ?? member.workload_id}
|
||||
<button
|
||||
className="text-slate-400 hover:text-danger"
|
||||
disabled={removeMember.isPending}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeMember.mutate(member);
|
||||
}}
|
||||
title="Remove member"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{members.length > 5 ? <span className="rounded-md border border-border px-2 py-1 text-xs text-slate-500">+{members.length - 5}</span> : null}
|
||||
{!members.length ? <span className="text-xs text-slate-500">No members</span> : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
render: (row) => (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className={iconButtonClass}
|
||||
title="Add member"
|
||||
aria-label="Add member"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setSelectedGroupId(String(row.id));
|
||||
setMemberOpen(true);
|
||||
}}
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DataTable rows={(rules.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "priority", label: "Priority" }, { key: "direction", label: "Direction" }, { key: "action", label: "Action" }, { key: "protocol", label: "Protocol" }, { key: "port", label: "Port" }]} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Database, RefreshCcw, Save, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { api, RuntimeSettings } from "../api/client";
|
||||
import { buttonClass, Field, inputClass } from "../components/FormControls";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
export function Settings() {
|
||||
const queryClient = useQueryClient();
|
||||
const settings = useQuery({ queryKey: ["settings"], queryFn: () => api<RuntimeSettings>("/settings") });
|
||||
const [retentionHours, setRetentionHours] = useState("24");
|
||||
const [nodeAutoSync, setNodeAutoSync] = useState(false);
|
||||
const [nodeInterval, setNodeInterval] = useState("60");
|
||||
const [ipamAutoSync, setIpamAutoSync] = useState(false);
|
||||
const [ipamInterval, setIpamInterval] = useState("60");
|
||||
const update = useMutation({
|
||||
mutationFn: () => api<RuntimeSettings>("/settings", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
flow_retention_hours: Number(retentionHours),
|
||||
auto_node_sync_enabled: nodeAutoSync,
|
||||
auto_node_sync_interval_minutes: Number(nodeInterval),
|
||||
auto_ipam_sync_enabled: ipamAutoSync,
|
||||
auto_ipam_sync_interval_minutes: Number(ipamInterval),
|
||||
}),
|
||||
}),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.data) {
|
||||
setRetentionHours(String(settings.data.flow_retention_hours));
|
||||
setNodeAutoSync(settings.data.auto_node_sync_enabled);
|
||||
setNodeInterval(String(settings.data.auto_node_sync_interval_minutes));
|
||||
setIpamAutoSync(settings.data.auto_ipam_sync_enabled);
|
||||
setIpamInterval(String(settings.data.auto_ipam_sync_interval_minutes));
|
||||
}
|
||||
}, [settings.data]);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
await update.mutateAsync();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Settings" subtitle="Runtime settings and safety defaults." />
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,560px)_1fr]">
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-4 flex items-center gap-2 font-medium"><Database size={18} /> Flow Retention</div>
|
||||
<Field label="Keep flow telemetry for">
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<input
|
||||
className={inputClass}
|
||||
min={1}
|
||||
max={8760}
|
||||
type="number"
|
||||
value={retentionHours}
|
||||
onChange={(event) => setRetentionHours(event.target.value)}
|
||||
/>
|
||||
<span className="inline-flex h-10 items-center rounded-md border border-border px-3 text-sm text-slate-500">hours</span>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="mt-3 rounded-md border border-border bg-canvas p-3 text-sm text-slate-500">
|
||||
New heartbeats update existing flows and remove entries older than this retention window.
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-4 flex items-center gap-2 font-medium"><ShieldCheck size={18} /> Safety Defaults</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-md border border-border bg-canvas p-3">
|
||||
<div className="text-xs text-slate-500">Product</div>
|
||||
<div className="mt-1 font-medium">{settings.data?.product ?? "NexaFabric"}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-canvas p-3">
|
||||
<div className="text-xs text-slate-500">Preview Required</div>
|
||||
<div className="mt-1 font-medium">{String(settings.data?.firewall_apply_requires_preview ?? true)}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-canvas p-3">
|
||||
<div className="text-xs text-slate-500">Agent Optional</div>
|
||||
<div className="mt-1 font-medium">{String(settings.data?.agent_optional ?? true)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-4 flex items-center gap-2 font-medium"><RefreshCcw size={18} /> Nodes Auto Sync</div>
|
||||
<label className="mb-3 flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={nodeAutoSync} onChange={(event) => setNodeAutoSync(event.target.checked)} />
|
||||
Enable automatic cluster inventory sync
|
||||
</label>
|
||||
<Field label="Interval">
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<input className={inputClass} min={1} max={10080} type="number" value={nodeInterval} onChange={(event) => setNodeInterval(event.target.value)} />
|
||||
<span className="inline-flex h-10 items-center rounded-md border border-border px-3 text-sm text-slate-500">minutes</span>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="mt-3 text-xs text-slate-500">Last run: {settings.data?.last_node_auto_sync_at ? new Date(settings.data.last_node_auto_sync_at).toLocaleString() : "never"}</div>
|
||||
</section>
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-4 flex items-center gap-2 font-medium"><RefreshCcw size={18} /> IPAM Auto Discover</div>
|
||||
<label className="mb-3 flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={ipamAutoSync} onChange={(event) => setIpamAutoSync(event.target.checked)} />
|
||||
Enable automatic IPAM discovery from Proxmox
|
||||
</label>
|
||||
<Field label="Interval">
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<input className={inputClass} min={1} max={10080} type="number" value={ipamInterval} onChange={(event) => setIpamInterval(event.target.value)} />
|
||||
<span className="inline-flex h-10 items-center rounded-md border border-border px-3 text-sm text-slate-500">minutes</span>
|
||||
</div>
|
||||
</Field>
|
||||
<div className="mt-3 text-xs text-slate-500">Last run: {settings.data?.last_ipam_auto_sync_at ? new Date(settings.data.last_ipam_auto_sync_at).toLocaleString() : "never"}</div>
|
||||
</section>
|
||||
</div>
|
||||
{update.error ? <div className="rounded-md border border-danger p-3 text-sm text-danger">Settings could not be saved. Super Admin permission is required.</div> : null}
|
||||
<button className={buttonClass} disabled={update.isPending || !retentionHours || !nodeInterval || !ipamInterval}>
|
||||
<Save size={16} />
|
||||
Save Settings
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,486 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Activity, CircuitBoard, Hash, Network, ShieldCheck } from "lucide-react";
|
||||
import { Activity, ArrowRight, BarChart3, CircuitBoard, Filter, Hash, Network, Search, Shield, ShieldCheck } from "lucide-react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { api, Workload, WorkloadInsight } from "../api/client";
|
||||
import { DataTable } from "../components/DataTable";
|
||||
import { inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
type TrafficSummary = {
|
||||
key: string;
|
||||
source: string;
|
||||
destination: string;
|
||||
sourceLabel: string;
|
||||
destinationLabel: string;
|
||||
sourceIp: string;
|
||||
destinationIp: string;
|
||||
protocol: string;
|
||||
port: string;
|
||||
sourcePort: string;
|
||||
bytes: number;
|
||||
packets: number;
|
||||
count: number;
|
||||
decision: string;
|
||||
interfaceName: string;
|
||||
note: string;
|
||||
collector: string;
|
||||
observedAt: string;
|
||||
ipAddresses: string[];
|
||||
matchingFirewallRules: Array<Record<string, unknown>>;
|
||||
matchingAuditPolicies: Array<Record<string, unknown>>;
|
||||
matchingPolicies: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
function records(value: unknown) {
|
||||
return Array.isArray(value) ? (value.filter((item) => item && typeof item === "object") as Array<Record<string, unknown>>) : [];
|
||||
}
|
||||
|
||||
function mergeRecords(left: Array<Record<string, unknown>>, right: Array<Record<string, unknown>>) {
|
||||
const seen = new Set<string>();
|
||||
const merged: Array<Record<string, unknown>> = [];
|
||||
for (const item of [...left, ...right]) {
|
||||
const key = String(item.id ?? item.pos ?? item.comment ?? JSON.stringify(item));
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
merged.push(item);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function formatBytes(value: unknown) {
|
||||
const bytes = Number(value ?? 0);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return "0 B";
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function summarizeTraffic(traffic: Array<Record<string, unknown>>) {
|
||||
const summaries = new Map<string, TrafficSummary>();
|
||||
for (const flow of traffic) {
|
||||
const source = String(flow.source ?? flow.source_ip ?? "external");
|
||||
const destination = String(flow.destination ?? flow.destination_ip ?? "external");
|
||||
const sourceIp = String(flow.source_ip ?? "");
|
||||
const destinationIp = String(flow.destination_ip ?? "");
|
||||
const sourceLabel = String(flow.source_label ?? flow.source_ip ?? source);
|
||||
const destinationLabel = String(flow.destination_label ?? flow.destination_ip ?? destination);
|
||||
const protocol = String(flow.protocol ?? "unknown");
|
||||
const port = String(flow.port ?? flow.destination_port ?? "");
|
||||
const sourcePort = String(flow.source_port ?? "");
|
||||
const key = [sourceIp || source, destinationIp || destination, protocol, sourcePort, port, String(flow.interface ?? ""), String(flow.decision ?? "")].join("|");
|
||||
const existing = summaries.get(key);
|
||||
const bytes = Number(flow.bytes ?? 0);
|
||||
const packets = Number(flow.packets ?? 0);
|
||||
const ipAddresses = Array.isArray(flow.ip_addresses) ? flow.ip_addresses.map(String) : [];
|
||||
const matchingFirewallRules = records(flow.matching_firewall_rules);
|
||||
const matchingAuditPolicies = records(flow.matching_audit_policies);
|
||||
const matchingPolicies = records(flow.matching_policies);
|
||||
if (existing) {
|
||||
existing.bytes += Number.isFinite(bytes) ? bytes : 0;
|
||||
existing.packets += Number.isFinite(packets) ? packets : 0;
|
||||
existing.count += 1;
|
||||
existing.ipAddresses = Array.from(new Set([...existing.ipAddresses, ...ipAddresses]));
|
||||
existing.matchingFirewallRules = mergeRecords(existing.matchingFirewallRules, matchingFirewallRules);
|
||||
existing.matchingAuditPolicies = mergeRecords(existing.matchingAuditPolicies, matchingAuditPolicies);
|
||||
existing.matchingPolicies = mergeRecords(existing.matchingPolicies, matchingPolicies);
|
||||
if (existing.decision === "observed" && String(flow.decision ?? "observed") !== "observed") {
|
||||
existing.decision = String(flow.decision ?? "observed");
|
||||
}
|
||||
if (!existing.observedAt && flow.observed_at) {
|
||||
existing.observedAt = String(flow.observed_at);
|
||||
} else if (flow.observed_at) {
|
||||
const existingTime = Date.parse(existing.observedAt || "");
|
||||
const flowTime = Date.parse(String(flow.observed_at));
|
||||
if (Number.isFinite(flowTime) && (!Number.isFinite(existingTime) || flowTime > existingTime)) {
|
||||
existing.observedAt = String(flow.observed_at);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
summaries.set(key, {
|
||||
key,
|
||||
source,
|
||||
destination,
|
||||
sourceLabel,
|
||||
destinationLabel,
|
||||
sourceIp,
|
||||
destinationIp,
|
||||
protocol,
|
||||
port,
|
||||
sourcePort,
|
||||
bytes: Number.isFinite(bytes) ? bytes : 0,
|
||||
packets: Number.isFinite(packets) ? packets : 0,
|
||||
count: 1,
|
||||
decision: String(flow.decision ?? "observed"),
|
||||
interfaceName: String(flow.interface ?? ""),
|
||||
note: String(flow.note ?? ""),
|
||||
collector: String(flow.collector ?? ""),
|
||||
observedAt: String(flow.observed_at ?? ""),
|
||||
ipAddresses,
|
||||
matchingFirewallRules,
|
||||
matchingAuditPolicies,
|
||||
matchingPolicies,
|
||||
});
|
||||
}
|
||||
return Array.from(summaries.values()).sort((left, right) => {
|
||||
const leftTime = Date.parse(left.observedAt || "");
|
||||
const rightTime = Date.parse(right.observedAt || "");
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return rightTime - leftTime;
|
||||
}
|
||||
if (Number.isFinite(rightTime) && !Number.isFinite(leftTime)) {
|
||||
return 1;
|
||||
}
|
||||
if (Number.isFinite(leftTime) && !Number.isFinite(rightTime)) {
|
||||
return -1;
|
||||
}
|
||||
return right.bytes - left.bytes;
|
||||
});
|
||||
}
|
||||
|
||||
function endpointText(flow: TrafficSummary) {
|
||||
return `${flow.sourceLabel} -> ${flow.destinationLabel}`;
|
||||
}
|
||||
|
||||
function totalBytes(traffic: TrafficSummary[]) {
|
||||
return traffic.reduce((sum, flow) => sum + flow.bytes, 0);
|
||||
}
|
||||
|
||||
function uniqueValues(values: string[]) {
|
||||
return Array.from(new Set(values.filter(Boolean))).sort();
|
||||
}
|
||||
|
||||
function TrafficBars({ traffic }: { traffic: TrafficSummary[] }) {
|
||||
const top = [...traffic].sort((left, right) => right.bytes - left.bytes).slice(0, 5);
|
||||
const max = Math.max(...top.map((flow) => flow.bytes), 1);
|
||||
if (!top.length) {
|
||||
return <div className="rounded-md border border-border p-3 text-xs text-slate-500">No traffic data yet.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{top.map((flow) => (
|
||||
<div key={flow.key} className="grid gap-1">
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="truncate">{endpointText(flow)}</span>
|
||||
<span className="shrink-0 text-slate-500">{formatBytes(flow.bytes)}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
|
||||
<div className="h-full rounded-full bg-accent" style={{ width: `${Math.max((flow.bytes / max) * 100, 4)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: Math.max(5 - top.length, 0) }).map((_, index) => (
|
||||
<div key={`empty-${index}`} className="grid gap-1 opacity-40">
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-slate-500">
|
||||
<span>No additional flow</span>
|
||||
<span>0 B</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-slate-200 dark:bg-slate-800" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactFlowList({ traffic }: { traffic: TrafficSummary[] }) {
|
||||
const top = traffic.filter((flow) => flow.protocol !== "interface-counter").slice(0, 3);
|
||||
if (!top.length) {
|
||||
const fallback = traffic.find((flow) => flow.protocol === "interface-counter");
|
||||
return (
|
||||
<div className="rounded-md border border-border p-2 text-xs text-slate-500">
|
||||
{fallback ? `Interface counter fallback: ${formatBytes(fallback.bytes)} observed.` : "No flow telemetry collected yet."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{top.map((flow) => (
|
||||
<div key={flow.key} className="grid grid-cols-[1fr_auto] gap-3 px-3 py-2 text-xs">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{endpointText(flow)}</div>
|
||||
<div className="truncate text-slate-500">{flow.protocol}{flow.port ? `:${flow.port}` : ""} · {flow.decision}</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-slate-500">{formatBytes(flow.bytes)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ruleLabel(rule: Record<string, unknown>) {
|
||||
if (rule.error) {
|
||||
return String(rule.error);
|
||||
}
|
||||
const type = String(rule.type ?? "rule");
|
||||
const action = String(rule.action ?? "unknown");
|
||||
const proto = rule.proto ? String(rule.proto) : "any";
|
||||
const port = rule.dport || rule.sport ? `:${String(rule.dport ?? rule.sport)}` : "";
|
||||
return `${type} ${action} ${proto}${port}`;
|
||||
}
|
||||
|
||||
function policyLabel(policy: Record<string, unknown>) {
|
||||
const name = String(policy.name ?? "Policy");
|
||||
const mode = String(policy.enforcement_mode ?? "enforced");
|
||||
const decision = String(policy.decision ?? "observed").replace("_", " ");
|
||||
const protocol = String(policy.protocol ?? "any");
|
||||
const ports = policy.ports ? `:${String(policy.ports)}` : "";
|
||||
return `${name} · ${mode} · ${decision} · ${protocol}${ports}`;
|
||||
}
|
||||
|
||||
function decisionClass(decision: string) {
|
||||
if (decision.includes("would")) {
|
||||
return "border-amber-400/40 bg-amber-400/10 text-amber-300";
|
||||
}
|
||||
if (decision.includes("block")) {
|
||||
return "border-danger/40 bg-danger/10 text-danger";
|
||||
}
|
||||
if (decision.includes("allow")) {
|
||||
return "border-accent/40 bg-accent/10 text-accent";
|
||||
}
|
||||
return "border-border bg-canvas text-slate-500";
|
||||
}
|
||||
|
||||
function FlowRuleContext({ flow }: { flow: TrafficSummary }) {
|
||||
const activeRules = flow.matchingFirewallRules.slice(0, 2);
|
||||
const auditPolicies = flow.matchingAuditPolicies.slice(0, 2);
|
||||
const hasContext = activeRules.length || auditPolicies.length;
|
||||
if (!hasContext) {
|
||||
return <div className="mt-1 text-xs text-slate-500">No matching active or audit rule.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 grid gap-1.5 text-xs">
|
||||
{activeRules.map((rule, index) => (
|
||||
<div key={`rule-${flow.key}-${index}`} className="rounded-md border border-border bg-canvas px-2 py-1">
|
||||
<span className="font-medium">Rule:</span> {ruleLabel(rule)}
|
||||
<span className={`ml-2 rounded border px-1.5 py-0.5 ${decisionClass(String(rule.decision ?? "observed"))}`}>{String(rule.decision ?? "observed")}</span>
|
||||
</div>
|
||||
))}
|
||||
{auditPolicies.map((policy, index) => (
|
||||
<div key={`audit-${flow.key}-${index}`} className="rounded-md border border-amber-400/30 bg-amber-400/10 px-2 py-1 text-amber-200">
|
||||
<span className="font-medium">Audit:</span> {policyLabel(policy)}
|
||||
</div>
|
||||
))}
|
||||
{flow.matchingFirewallRules.length + flow.matchingAuditPolicies.length > activeRules.length + auditPolicies.length ? (
|
||||
<div className="text-slate-500">
|
||||
{flow.matchingFirewallRules.length + flow.matchingAuditPolicies.length - activeRules.length - auditPolicies.length} more match
|
||||
{flow.matchingFirewallRules.length + flow.matchingAuditPolicies.length - activeRules.length - auditPolicies.length === 1 ? "" : "es"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveRulesList({ rules, compact = false }: { rules: Array<Record<string, unknown>>; compact?: boolean }) {
|
||||
const visibleRules = compact ? rules.slice(0, 3) : rules;
|
||||
if (!rules.length) {
|
||||
return <div className="rounded-md border border-border p-2 text-xs text-slate-500">No active firewall rules were read for this workload.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{visibleRules.map((rule, index) => (
|
||||
<div key={`${String(rule.pos ?? index)}-${index}`} className="grid grid-cols-[1fr_auto] gap-3 px-3 py-2 text-xs">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{ruleLabel(rule)}</div>
|
||||
<div className="truncate text-slate-500">{String(rule.comment ?? (rule.managed_by_nexafabric ? "NexaFabric managed" : "manual or provider rule"))}</div>
|
||||
</div>
|
||||
<div className={rule.enable === 0 ? "text-slate-500" : "text-accent"}>{rule.enable === 0 ? "off" : "on"}</div>
|
||||
</div>
|
||||
))}
|
||||
{compact && rules.length > visibleRules.length ? (
|
||||
<div className="px-3 py-2 text-xs text-slate-500">{rules.length - visibleRules.length} more rule{rules.length - visibleRules.length === 1 ? "" : "s"} on detail page.</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtocolChart({ traffic }: { traffic: TrafficSummary[] }) {
|
||||
const protocolTotals = Array.from(
|
||||
traffic.reduce((map, flow) => map.set(flow.protocol, (map.get(flow.protocol) ?? 0) + flow.bytes), new Map<string, number>()),
|
||||
).sort((left, right) => right[1] - left[1]);
|
||||
const total = protocolTotals.reduce((sum, [, bytes]) => sum + bytes, 0);
|
||||
const palette = ["#2dd4bf", "#60a5fa", "#f59e0b", "#f472b6", "#a78bfa"];
|
||||
|
||||
if (!total) {
|
||||
return <div className="rounded-md border border-border p-3 text-xs text-slate-500">No protocol split available.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex h-3 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
|
||||
{protocolTotals.map(([protocol, bytes], index) => {
|
||||
const width = (bytes / total) * 100;
|
||||
return <div key={protocol} title={protocol} style={{ width: `${width}%`, backgroundColor: palette[index % palette.length] }} />;
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{protocolTotals.map(([protocol, bytes], index) => (
|
||||
<span key={protocol} className="inline-flex items-center gap-2 rounded-md border border-border px-2 py-1">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: palette[index % palette.length] }} />
|
||||
{protocol} {formatBytes(bytes)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkloadFacts({ insight }: { insight: WorkloadInsight }) {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-md border border-border bg-panel p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500"><CircuitBoard size={14} /> Type</div>
|
||||
<div className="mt-1 font-medium">{insight.workload.kind}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-panel p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500"><Activity size={14} /> Status</div>
|
||||
<div className="mt-1 font-medium">{insight.workload.status}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-panel p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500"><Hash size={14} /> VMID</div>
|
||||
<div className="mt-1 font-medium">{insight.workload.external_id}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-panel p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500"><ShieldCheck size={14} /> Decision</div>
|
||||
<div className="mt-1 font-medium">{insight.effective_decision}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrafficTable({ traffic, dense = false }: { traffic: TrafficSummary[]; dense?: boolean }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-border">
|
||||
<div className={dense ? "overflow-auto" : "max-h-[460px] overflow-auto"}>
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="sticky top-0 bg-panel text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">Flow</th>
|
||||
<th className="px-3 py-2 font-medium">Protocol</th>
|
||||
<th className="px-3 py-2 font-medium">Decision</th>
|
||||
<th className="px-3 py-2 font-medium">Traffic</th>
|
||||
<th className="px-3 py-2 font-medium">Packets</th>
|
||||
<th className="px-3 py-2 font-medium">Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{traffic.map((flow) => (
|
||||
<tr key={flow.key} className="border-t border-border align-top hover:bg-slate-50 dark:hover:bg-slate-900/50">
|
||||
<td className="min-w-[420px] px-3 py-3">
|
||||
<div className="font-medium">{endpointText(flow)}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-xs text-slate-500">
|
||||
<span>{flow.sourceIp || flow.source}</span>
|
||||
<span>-></span>
|
||||
<span>{flow.destinationIp || flow.destination}</span>
|
||||
{flow.collector ? <span className="rounded border border-border px-1.5">{flow.collector}</span> : null}
|
||||
</div>
|
||||
<FlowRuleContext flow={flow} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3">
|
||||
<div>{flow.protocol}{flow.port ? `:${flow.port}` : ""}</div>
|
||||
{flow.sourcePort ? <div className="text-xs text-slate-500">source {flow.sourcePort}</div> : null}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<span className={`inline-flex rounded-md border px-2 py-1 text-xs ${decisionClass(flow.decision)}`}>{flow.decision.replace("_", " ")}</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 font-medium">{formatBytes(flow.bytes)}</td>
|
||||
<td className="whitespace-nowrap px-3 py-3">{flow.packets}</td>
|
||||
<td className="whitespace-nowrap px-3 py-3">
|
||||
<div>{flow.count} sample{flow.count === 1 ? "" : "s"}</div>
|
||||
{flow.observedAt ? <div className="text-xs text-slate-500">{new Date(flow.observedAt).toLocaleString()}</div> : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowStatCards({ traffic }: { traffic: TrafficSummary[] }) {
|
||||
const blocked = traffic.filter((flow) => flow.decision.includes("block")).length;
|
||||
const allowed = traffic.filter((flow) => flow.decision.includes("allow")).length;
|
||||
const protocols = uniqueValues(traffic.map((flow) => flow.protocol)).length;
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="rounded-md border border-border bg-panel p-3">
|
||||
<div className="text-xs text-slate-500">Total Traffic</div>
|
||||
<div className="mt-1 text-xl font-semibold">{formatBytes(totalBytes(traffic))}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-panel p-3">
|
||||
<div className="text-xs text-slate-500">Flows</div>
|
||||
<div className="mt-1 text-xl font-semibold">{traffic.length}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-panel p-3">
|
||||
<div className="text-xs text-slate-500">Allowed / Blocked</div>
|
||||
<div className="mt-1 text-xl font-semibold">{allowed} / {blocked}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-panel p-3">
|
||||
<div className="text-xs text-slate-500">Protocols</div>
|
||||
<div className="mt-1 text-xl font-semibold">{protocols}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopFlowChart({ title, items }: { title: string; items: Array<{ name: string; value: number; suffix?: string }> }) {
|
||||
const top = items.slice(0, 8);
|
||||
const max = Math.max(...top.map((item) => item.value), 1);
|
||||
return (
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 flex items-center gap-2 font-medium"><BarChart3 size={17} /> {title}</div>
|
||||
<div className="space-y-2">
|
||||
{top.length ? top.map((item) => (
|
||||
<div key={item.name} className="grid gap-1">
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="truncate">{item.name}</span>
|
||||
<span className="shrink-0 text-slate-500">{item.suffix ?? formatBytes(item.value)}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
|
||||
<div className="h-full rounded-full bg-accent" style={{ width: `${Math.max((item.value / max) * 100, 3)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)) : <div className="text-sm text-slate-500">No data for this filter.</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateBy(traffic: TrafficSummary[], label: (flow: TrafficSummary) => string, value: (flow: TrafficSummary) => number) {
|
||||
const totals = new Map<string, number>();
|
||||
for (const flow of traffic) {
|
||||
const key = label(flow);
|
||||
totals.set(key, (totals.get(key) ?? 0) + value(flow));
|
||||
}
|
||||
return Array.from(totals.entries()).map(([name, total]) => ({ name, value: total })).sort((left, right) => right.value - left.value);
|
||||
}
|
||||
|
||||
export function Workloads() {
|
||||
const navigate = useNavigate();
|
||||
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const selected = selectedId || workloads.data?.[0]?.id || "";
|
||||
const insight = useQuery({
|
||||
queryKey: ["workload-insight", selected],
|
||||
queryFn: () => api<WorkloadInsight>(`/vms/${selected}/insights`),
|
||||
queryKey: ["workload-insight", selected, "summary"],
|
||||
queryFn: () => api<WorkloadInsight>(`/vms/${selected}/insights?traffic=summary&include_rules=false&include_flow_context=false`),
|
||||
enabled: Boolean(selected),
|
||||
});
|
||||
const traffic = useMemo(() => summarizeTraffic(insight.data?.traffic ?? []), [insight.data?.traffic]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="VMs/LXCs" subtitle="Inspect workloads, observed traffic, and matching policy decisions." />
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_440px]">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<section className="space-y-3">
|
||||
<DataTable
|
||||
rows={(workloads.data ?? []) as unknown as Record<string, unknown>[]}
|
||||
@@ -28,63 +489,63 @@ export function Workloads() {
|
||||
onRowClick={(row) => setSelectedId(String(row.id))}
|
||||
/>
|
||||
</section>
|
||||
<aside className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-4 flex items-center gap-2 font-medium"><Activity size={18} /> Workload Detail</div>
|
||||
{insight.data ? (
|
||||
<div className="space-y-4 text-sm">
|
||||
<header className="rounded-md border border-border bg-canvas p-4">
|
||||
<div className="mb-3 text-lg font-semibold">{insight.data.workload.name}</div>
|
||||
<div className="grid gap-2 text-xs text-slate-500 sm:grid-cols-2">
|
||||
<div className="flex items-center gap-2"><CircuitBoard size={14} /> Type: {insight.data.workload.kind}</div>
|
||||
<div className="flex items-center gap-2"><Activity size={14} /> Status: {insight.data.workload.status}</div>
|
||||
<div className="flex items-center gap-2"><Hash size={14} /> VMID: {insight.data.workload.external_id}</div>
|
||||
<div className="flex items-center gap-2"><ShieldCheck size={14} /> Decision: {insight.data.effective_decision}</div>
|
||||
<aside className="space-y-3 rounded-md border border-border bg-panel p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 font-medium"><Activity size={18} /> Workload Summary</div>
|
||||
{selected ? (
|
||||
<button className={`${secondaryButtonClass} h-9 px-3`} onClick={() => navigate(`/workloads/${selected}`)}>
|
||||
<ArrowRight size={16} />
|
||||
Details
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{insight.data ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<header>
|
||||
<div className="font-semibold">{insight.data.workload.name}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{insight.data.workload.kind} · {insight.data.workload.status} · VMID {insight.data.workload.external_id}</div>
|
||||
</header>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="rounded-md border border-border bg-canvas p-2">
|
||||
<div className="text-xs text-slate-500">Traffic</div>
|
||||
<div className="mt-1 font-medium">{formatBytes(totalBytes(traffic))}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-canvas p-2">
|
||||
<div className="text-xs text-slate-500">Flows</div>
|
||||
<div className="mt-1 font-medium">{traffic.length}</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-canvas p-2">
|
||||
<div className="text-xs text-slate-500">IPs</div>
|
||||
<div className="mt-1 font-medium">{insight.data.assigned_ips.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
<section>
|
||||
<div className="mb-2 flex items-center gap-2 font-medium"><Network size={16} /> Assigned IPs</div>
|
||||
<div className="mb-1.5 flex items-center gap-2 text-sm font-medium"><Network size={15} /> Assigned IPs</div>
|
||||
{insight.data.assigned_ips.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{insight.data.assigned_ips.map((ip) => (
|
||||
<div key={ip.id} className="rounded-md border border-border px-3 py-2 text-xs">
|
||||
<div className="font-medium">{ip.address}</div>
|
||||
<div className="text-slate-500">{ip.subnet_cidr ?? "unknown subnet"}</div>
|
||||
{insight.data.assigned_ips.slice(0, 4).map((ip) => (
|
||||
<div key={ip.id} className="rounded-md border border-border px-2 py-1 text-xs">
|
||||
<span className="font-medium">{ip.address}</span>
|
||||
<span className="ml-2 text-slate-500">{ip.subnet_cidr ?? "unknown subnet"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-border p-3 text-xs text-slate-500">No assigned IP address was discovered for this workload yet.</div>
|
||||
<div className="rounded-md border border-border p-2 text-xs text-slate-500">No assigned IP address was discovered yet.</div>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<div className="mb-2 font-medium">Traffic</div>
|
||||
<div className="space-y-2">
|
||||
{insight.data.traffic.length ? insight.data.traffic.map((flow, index) => (
|
||||
<div key={index} className="rounded-md border border-border p-3">
|
||||
<div>{String(flow.source)} → {String(flow.destination)}</div>
|
||||
<div className="text-xs text-slate-500">{String(flow.protocol)}:{String(flow.port)} · {String(flow.bytes)} bytes · {String(flow.decision)}</div>
|
||||
{Array.isArray(flow.ip_addresses) ? <div className="mt-1 text-xs text-slate-500">IPs: {flow.ip_addresses.join(", ")}</div> : null}
|
||||
</div>
|
||||
)) : <div className="rounded-md border border-border p-3 text-xs text-slate-500">No real traffic telemetry has been collected yet. Install the node agent or enable a flow source to populate this section.</div>}
|
||||
</div>
|
||||
<div className="mb-1.5 text-sm font-medium">Top Traffic</div>
|
||||
<TrafficBars traffic={traffic} />
|
||||
</section>
|
||||
<section>
|
||||
<div className="mb-2 font-medium">Matching Policies</div>
|
||||
<div className="space-y-2">
|
||||
{insight.data.matching_policies.length ? insight.data.matching_policies.map((policy) => (
|
||||
<div key={policy.id} className="rounded-md border border-border p-3">
|
||||
<div>{policy.name}</div>
|
||||
<div className="text-xs text-slate-500">v{policy.version} · {policy.enforcement_mode}</div>
|
||||
</div>
|
||||
)) : <div className="rounded-md border border-border p-3 text-xs text-slate-500">No matching policy for this workload yet.</div>}
|
||||
</div>
|
||||
<div className="mb-1.5 text-sm font-medium">Top Flows</div>
|
||||
<CompactFlowList traffic={traffic} />
|
||||
</section>
|
||||
{insight.data.audit_mode_notes.length ? (
|
||||
<section>
|
||||
<div className="mb-2 font-medium">Audit Mode</div>
|
||||
{insight.data.audit_mode_notes.map((note) => <div key={note} className="rounded-md border border-border p-3 text-xs">{note}</div>)}
|
||||
<div className="mb-1.5 flex items-center gap-2 text-sm font-medium"><Shield size={15} /> Active Rules</div>
|
||||
<ActiveRulesList rules={insight.data.active_firewall_rules ?? []} compact />
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-slate-500">Select a workload.</div>
|
||||
@@ -94,3 +555,224 @@ export function Workloads() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkloadDetail() {
|
||||
const { workloadId } = useParams();
|
||||
const insight = useQuery({
|
||||
queryKey: ["workload-insight", workloadId, "summary", "rules"],
|
||||
queryFn: () => api<WorkloadInsight>(`/vms/${workloadId}/insights?traffic=summary&include_rules=true&include_flow_context=true`),
|
||||
enabled: Boolean(workloadId),
|
||||
});
|
||||
const traffic = useMemo(() => summarizeTraffic(insight.data?.traffic ?? []), [insight.data?.traffic]);
|
||||
|
||||
if (insight.isLoading) {
|
||||
return <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading workload...</div>;
|
||||
}
|
||||
|
||||
if (!insight.data) {
|
||||
return <div className="rounded-md border border-danger p-4 text-sm text-danger">Workload details could not be loaded.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={insight.data.workload.name} subtitle="Detailed workload traffic, addressing, and policy context." />
|
||||
<div className="mb-4">
|
||||
<Link className={secondaryButtonClass} to="/workloads">Back to VMs/LXCs</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<WorkloadFacts insight={insight.data} />
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_360px]">
|
||||
<section className="space-y-4">
|
||||
<div className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="font-medium">Traffic Distribution</div>
|
||||
<div className="text-xs text-slate-500">{traffic.length} aggregated flows · {formatBytes(totalBytes(traffic))}</div>
|
||||
</div>
|
||||
<TrafficBars traffic={traffic} />
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="font-medium">Flow Table</div>
|
||||
<Link className={`${secondaryButtonClass} h-9 px-3`} to={`/workloads/${insight.data.workload.id}/flows`}>
|
||||
<BarChart3 size={16} />
|
||||
Flow Analytics
|
||||
</Link>
|
||||
</div>
|
||||
{traffic.length ? <TrafficTable traffic={traffic.slice(0, 12)} /> : <div className="rounded-md border border-border p-3 text-xs text-slate-500">No flow telemetry collected yet.</div>}
|
||||
</div>
|
||||
</section>
|
||||
<aside className="space-y-4">
|
||||
<div className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 font-medium">Protocol Split</div>
|
||||
<ProtocolChart traffic={traffic} />
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 flex items-center gap-2 font-medium"><Network size={16} /> Assigned IPs</div>
|
||||
<div className="space-y-2">
|
||||
{insight.data.assigned_ips.map((ip) => (
|
||||
<div key={ip.id} className="rounded-md border border-border px-3 py-2 text-xs">
|
||||
<div className="font-medium">{ip.address}</div>
|
||||
<div className="text-slate-500">{ip.subnet_cidr ?? "unknown subnet"} · {ip.status}</div>
|
||||
</div>
|
||||
))}
|
||||
{!insight.data.assigned_ips.length ? <div className="text-xs text-slate-500">No assigned IPs discovered.</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 flex items-center gap-2 font-medium"><Shield size={16} /> Active Firewall Rules</div>
|
||||
<ActiveRulesList rules={insight.data.active_firewall_rules ?? []} />
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 font-medium">Matching Policies</div>
|
||||
<div className="space-y-2">
|
||||
{insight.data.matching_policies.map((policy) => (
|
||||
<div key={policy.id} className="rounded-md border border-border p-3 text-sm">
|
||||
<div className="font-medium">{policy.name}</div>
|
||||
<div className="text-xs text-slate-500">v{policy.version} · {policy.enforcement_mode}</div>
|
||||
</div>
|
||||
))}
|
||||
{!insight.data.matching_policies.length ? <div className="text-xs text-slate-500">No matching policies.</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{insight.data.audit_mode_notes.length ? (
|
||||
<div className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 font-medium">Audit Mode</div>
|
||||
<div className="space-y-2">
|
||||
{insight.data.audit_mode_notes.map((note) => <div key={note} className="rounded-md border border-border p-3 text-xs">{note}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkloadFlows() {
|
||||
const { workloadId } = useParams();
|
||||
const [query, setQuery] = useState("");
|
||||
const [protocol, setProtocol] = useState("all");
|
||||
const [decision, setDecision] = useState("all");
|
||||
const [port, setPort] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const insight = useQuery({
|
||||
queryKey: ["workload-insight", workloadId, "full"],
|
||||
queryFn: () => api<WorkloadInsight>(`/vms/${workloadId}/insights?traffic=full&include_rules=false&include_flow_context=false`),
|
||||
enabled: Boolean(workloadId),
|
||||
});
|
||||
const traffic = useMemo(() => summarizeTraffic(insight.data?.traffic ?? []), [insight.data?.traffic]);
|
||||
const protocols = useMemo(() => uniqueValues(traffic.map((flow) => flow.protocol)), [traffic]);
|
||||
const decisions = useMemo(() => uniqueValues(traffic.map((flow) => flow.decision)), [traffic]);
|
||||
const filteredTraffic = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const normalizedPort = port.trim();
|
||||
return traffic.filter((flow) => {
|
||||
const haystack = [
|
||||
flow.source,
|
||||
flow.destination,
|
||||
flow.sourceLabel,
|
||||
flow.destinationLabel,
|
||||
flow.sourceIp,
|
||||
flow.destinationIp,
|
||||
flow.protocol,
|
||||
flow.port,
|
||||
flow.sourcePort,
|
||||
flow.decision,
|
||||
flow.collector,
|
||||
].join(" ").toLowerCase();
|
||||
if (normalizedQuery && !haystack.includes(normalizedQuery)) {
|
||||
return false;
|
||||
}
|
||||
if (protocol !== "all" && flow.protocol !== protocol) {
|
||||
return false;
|
||||
}
|
||||
if (decision !== "all" && flow.decision !== decision) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedPort && flow.port !== normalizedPort && flow.sourcePort !== normalizedPort) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [decision, port, protocol, query, traffic]);
|
||||
const pageSize = 25;
|
||||
const totalPages = Math.max(Math.ceil(filteredTraffic.length / pageSize), 1);
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pagedTraffic = filteredTraffic.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [decision, port, protocol, query]);
|
||||
|
||||
if (insight.isLoading) {
|
||||
return <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading flow analytics...</div>;
|
||||
}
|
||||
|
||||
if (!insight.data) {
|
||||
return <div className="rounded-md border border-danger p-4 text-sm text-danger">Flow analytics could not be loaded.</div>;
|
||||
}
|
||||
|
||||
const endpointTotals = aggregateBy(filteredTraffic, (flow) => endpointText(flow), (flow) => flow.bytes);
|
||||
const destinationTotals = aggregateBy(filteredTraffic, (flow) => flow.destinationLabel, (flow) => flow.bytes);
|
||||
const protocolTotals = aggregateBy(filteredTraffic, (flow) => flow.protocol, (flow) => flow.bytes);
|
||||
const packetTotals = aggregateBy(filteredTraffic, (flow) => endpointText(flow), (flow) => flow.packets).map((item) => ({ ...item, suffix: `${item.value} packets` }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={`${insight.data.workload.name} Flow Analytics`} subtitle="Search, filter, and inspect workload traffic decisions." />
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Link className={secondaryButtonClass} to={`/workloads/${insight.data.workload.id}`}>Back to Workload</Link>
|
||||
<Link className={secondaryButtonClass} to="/workloads">Back to VMs/LXCs</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<FlowStatCards traffic={filteredTraffic} />
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 flex items-center gap-2 font-medium"><Filter size={17} /> Filters</div>
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_180px_180px_150px]">
|
||||
<label className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-2.5 text-slate-500" size={16} />
|
||||
<input className={`${inputClass} pl-9`} value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search IP, workload, collector, protocol..." />
|
||||
</label>
|
||||
<select className={selectClass} value={protocol} onChange={(event) => setProtocol(event.target.value)}>
|
||||
<option value="all">All protocols</option>
|
||||
{protocols.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
<select className={selectClass} value={decision} onChange={(event) => setDecision(event.target.value)}>
|
||||
<option value="all">All decisions</option>
|
||||
{decisions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
<input className={inputClass} value={port} onChange={(event) => setPort(event.target.value)} placeholder="Port" />
|
||||
</div>
|
||||
</section>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<TopFlowChart title="Top Conversations" items={endpointTotals} />
|
||||
<TopFlowChart title="Top Destinations" items={destinationTotals} />
|
||||
<TopFlowChart title="Protocol Traffic" items={protocolTotals} />
|
||||
<TopFlowChart title="Packet Volume" items={packetTotals} />
|
||||
</div>
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="font-medium">All Flows</div>
|
||||
<div className="text-xs text-slate-500">{filteredTraffic.length} of {traffic.length} flows · {formatBytes(totalBytes(filteredTraffic))}</div>
|
||||
</div>
|
||||
{filteredTraffic.length ? (
|
||||
<>
|
||||
<TrafficTable traffic={pagedTraffic} dense />
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-sm">
|
||||
<div className="text-slate-500">
|
||||
Showing {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, filteredTraffic.length)} of {filteredTraffic.length}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className={secondaryButtonClass} disabled={currentPage === 1} onClick={() => setPage((value) => Math.max(value - 1, 1))}>Previous</button>
|
||||
<span className="text-slate-500">Page {currentPage} / {totalPages}</span>
|
||||
<button className={secondaryButtonClass} disabled={currentPage === totalPages} onClick={() => setPage((value) => Math.min(value + 1, totalPages))}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : <div className="rounded-md border border-border p-4 text-sm text-slate-500">No flows match the current filters.</div>}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 16m;
|
||||
|
||||
location /api/ {
|
||||
client_max_body_size 16m;
|
||||
proxy_pass http://api:8000/api/;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -12,6 +14,7 @@ server {
|
||||
}
|
||||
|
||||
location /agents/ {
|
||||
client_max_body_size 16m;
|
||||
proxy_pass http://api:8000/api/v1/agents/;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
Reference in New Issue
Block a user