Compare commits
47
Commits
150a69b60b
..
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 | ||
|
|
714aebfdc0 | ||
|
|
45baa6ae7a | ||
|
|
7818158a9b | ||
|
|
ffc69011c4 | ||
|
|
e67174a4ae | ||
|
|
88badf1f22 | ||
|
|
1ada59d3c7 | ||
|
|
7fa4bcaad1 | ||
|
|
b382d4362c | ||
|
|
701835e9f3 | ||
|
|
0aaaa12ba1 |
@@ -1,7 +1,6 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ NexaFabric is an open-source SDN-like network and security control plane for Pro
|
|||||||
- FastAPI backend with SQLAlchemy 2, Alembic-ready models, JWT auth, RBAC primitives, audit logging, and provider interfaces.
|
- FastAPI backend with SQLAlchemy 2, Alembic-ready models, JWT auth, RBAC primitives, audit logging, and provider interfaces.
|
||||||
- React + TypeScript frontend with Vite, Tailwind CSS, TanStack Query, React Router, Zustand, dark/light mode, and production-oriented pages.
|
- React + TypeScript frontend with Vite, Tailwind CSS, TanStack Query, React Router, Zustand, dark/light mode, and production-oriented pages.
|
||||||
- PostgreSQL, Redis, worker, API, frontend, and reverse proxy through Docker Compose.
|
- PostgreSQL, Redis, worker, API, frontend, and reverse proxy through Docker Compose.
|
||||||
- Demo seed data for clusters, nodes, workloads, networks, tenants, policies, IPAM, jobs, and audit events.
|
- Optional demo seed data for clusters, nodes, workloads, networks, tenants, policies, IPAM, jobs, and audit events.
|
||||||
- Tests, lint/type-check scripts, CI workflow, and operational documentation.
|
- Tests, lint/type-check scripts, CI workflow, and operational documentation.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -22,10 +22,205 @@ Then open:
|
|||||||
- Frontend: http://localhost:8080
|
- Frontend: http://localhost:8080
|
||||||
- API docs: http://localhost:8080/api/docs
|
- API docs: http://localhost:8080/api/docs
|
||||||
|
|
||||||
Demo login:
|
On a fresh database NexaFabric opens the setup wizard first. The wizard creates the first Super Admin user and registers the first Proxmox or demo provider.
|
||||||
|
|
||||||
- Email: `admin@nexafabric.local`
|
To start from scratch during testing:
|
||||||
- Password: `ChangeMe_UseEnvInstead`
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Demo data is disabled by default. Enable it only for lab screenshots or UI testing:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SEED_DEMO_DATA=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proxmox Preparation
|
||||||
|
|
||||||
|
NexaFabric can read inventory from the Proxmox VE API immediately after you add a cluster, but IPAM and flow visibility depend on what Proxmox and the guests expose. Use this checklist before expecting full data in the UI.
|
||||||
|
|
||||||
|
### 1. Create A Dedicated API Token
|
||||||
|
|
||||||
|
In Proxmox VE, create a dedicated user and API token instead of using a personal admin token.
|
||||||
|
|
||||||
|
Recommended UI path:
|
||||||
|
|
||||||
|
1. `Datacenter` -> `Permissions` -> `Users`
|
||||||
|
2. Create a user such as `nexafabric@pve` or another realm you manage.
|
||||||
|
3. `Datacenter` -> `Permissions` -> `API Tokens`
|
||||||
|
4. Add a token such as `nexafabric@pve!control-plane`.
|
||||||
|
5. Keep privilege separation enabled unless you intentionally want the token to inherit all user privileges.
|
||||||
|
|
||||||
|
NexaFabric accepts both token formats:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PVEAPIToken=nexafabric@pve!control-plane=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||||
|
nexafabric@pve!control-plane=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer stores the token with the cluster and starts in `read_only` mode by default.
|
||||||
|
|
||||||
|
### 2. Assign Minimum Read Permissions
|
||||||
|
|
||||||
|
For inventory, IP discovery, policy preview, and dashboard data, assign the token read access at the Datacenter level or the narrowest paths that contain your nodes and guests.
|
||||||
|
|
||||||
|
Minimum practical read privileges:
|
||||||
|
|
||||||
|
- `Sys.Audit` for cluster and node inventory.
|
||||||
|
- `VM.Audit` for VM and container inventory/config visibility.
|
||||||
|
- `SDN.Audit` if you use Proxmox SDN zones, VNets, EVPN, or related network objects.
|
||||||
|
|
||||||
|
For write-enabled firewall orchestration, create a separate token or role and do not reuse the read-only token. Only enable write mode after previews and audit logging have been verified in your environment.
|
||||||
|
|
||||||
|
Minimum practical write privileges for VM/LXC-level firewall rules:
|
||||||
|
|
||||||
|
- `VM.Audit` so NexaFabric can resolve guests and inspect existing rules.
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
### 3. Enable QEMU Guest Agent For VM IP Discovery
|
||||||
|
|
||||||
|
For QEMU VMs, Proxmox only exposes guest interface/IP details reliably when the QEMU Guest Agent is installed in the VM and enabled in Proxmox.
|
||||||
|
|
||||||
|
Per VM:
|
||||||
|
|
||||||
|
1. Install the guest agent inside the VM.
|
||||||
|
- Debian/Ubuntu: `apt install qemu-guest-agent`
|
||||||
|
- RHEL/Rocky/Alma: `dnf install qemu-guest-agent`
|
||||||
|
- Windows: install the VirtIO guest tools including the QEMU guest agent.
|
||||||
|
2. Enable and start the service in the guest.
|
||||||
|
- Linux: `systemctl enable --now qemu-guest-agent`
|
||||||
|
3. In Proxmox UI, open the VM:
|
||||||
|
- `Options` -> `QEMU Guest Agent` -> `Enabled`
|
||||||
|
4. Reboot the VM or fully stop/start it if Proxmox does not immediately report the agent.
|
||||||
|
|
||||||
|
NexaFabric uses the Proxmox guest-agent network interface endpoint to discover IPv4 addresses for IPAM. If the guest agent is missing, the VM can still appear in inventory, but IPAM may not learn its IP address.
|
||||||
|
|
||||||
|
### 4. LXC IP Discovery
|
||||||
|
|
||||||
|
For LXC containers, NexaFabric reads static IPs from the Proxmox container network config when available.
|
||||||
|
|
||||||
|
Works best when container interfaces are configured with explicit IPs, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
net0: name=eth0,bridge=vmbr0,ip=10.10.10.50/24,gw=10.10.10.1
|
||||||
|
```
|
||||||
|
|
||||||
|
If the container uses DHCP, Proxmox may not always have a stable IP value in config. In that case, use a DHCP lease source, static reservations, or a future NexaFabric node-agent/flow-source integration.
|
||||||
|
|
||||||
|
### 5. Sync Cluster Inventory
|
||||||
|
|
||||||
|
After adding the cluster in NexaFabric:
|
||||||
|
|
||||||
|
1. Open `Clusters`.
|
||||||
|
2. Click the cluster row/name.
|
||||||
|
3. Use `Test` to validate the token.
|
||||||
|
4. Use `Sync` to import nodes, VMs/LXCs, networks, and discoverable IP addresses.
|
||||||
|
5. Open `IPAM` -> `Discover from Proxmox` if you want to rerun IP discovery later.
|
||||||
|
|
||||||
|
Imported IPs are placed into an automatically created `discovered-ipam` network if NexaFabric cannot map them to an existing subnet.
|
||||||
|
|
||||||
|
### 6. Firewall And Policy Requirements
|
||||||
|
|
||||||
|
NexaFabric policy preview does not require Proxmox firewall writes. It compiles NexaFabric policies into provider-specific preview output and records audit events.
|
||||||
|
|
||||||
|
Before enabling real firewall apply workflows:
|
||||||
|
|
||||||
|
- Ensure Proxmox firewall is enabled intentionally at the Datacenter, node, and guest level where you want enforcement.
|
||||||
|
- Ensure the firewall checkbox is enabled on the relevant VM/LXC network interfaces, otherwise Proxmox may store rules without enforcing them for that interface.
|
||||||
|
- Keep NexaFabric clusters in `read_only` mode until previews are reviewed.
|
||||||
|
- Use `audit` mode policies first to see what would be allowed or blocked.
|
||||||
|
- Confirm that Proxmox API token permissions match the exact write operations you plan to allow.
|
||||||
|
- Keep backups of Proxmox firewall configuration before enabling automation.
|
||||||
|
|
||||||
|
Live apply currently supports VM/LXC-level rules where the enforcement side is a concrete workload:
|
||||||
|
|
||||||
|
- `ingress` policies apply to the destination VM/LXC.
|
||||||
|
- `egress` policies apply to the source VM/LXC.
|
||||||
|
- The opposite side can be `any`, an IP/CIDR, or another workload with an assigned IP in IPAM.
|
||||||
|
- Security group and network-wide targets remain preview-only until they can be expanded safely.
|
||||||
|
- `audit` mode does not write blocking Proxmox rules; it records the intended result without enforcement.
|
||||||
|
|
||||||
|
NexaFabric is designed to read first, simulate second, and only apply after explicit confirmation.
|
||||||
|
|
||||||
|
### 7. Network Flow Visibility
|
||||||
|
|
||||||
|
Proxmox inventory and guest agent data are enough for:
|
||||||
|
|
||||||
|
- Cluster, node, VM, LXC inventory.
|
||||||
|
- Network object visibility.
|
||||||
|
- IPAM discovery for VMs with QEMU Guest Agent.
|
||||||
|
- Static LXC IP discovery.
|
||||||
|
- Policy matching and firewall previews.
|
||||||
|
|
||||||
|
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 options:
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
After a Proxmox cluster sync has imported nodes:
|
||||||
|
|
||||||
|
1. Open `Nodes`.
|
||||||
|
2. Click the agent icon on the node row.
|
||||||
|
3. Copy the installer command.
|
||||||
|
4. Run it as `root` on the matching Proxmox node.
|
||||||
|
|
||||||
|
The installer creates:
|
||||||
|
|
||||||
|
- `/opt/nexafabric-agent/nexafabric-agent.py`
|
||||||
|
- `/etc/nexafabric-agent/config.json`
|
||||||
|
- `nexafabric-agent.service`
|
||||||
|
|
||||||
|
The agent sends a heartbeat every 30 seconds to NexaFabric. It uses a node-specific enrollment token generated by the UI and does not need your Proxmox API token.
|
||||||
|
|
||||||
|
Useful commands on the Proxmox node:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status nexafabric-agent
|
||||||
|
journalctl -u nexafabric-agent -f
|
||||||
|
systemctl restart nexafabric-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
`401 No ticket` or `Provider sync failed` usually means:
|
||||||
|
|
||||||
|
- The API token format is wrong.
|
||||||
|
- The token was copied without the secret value after `=`.
|
||||||
|
- The token lacks the required ACLs.
|
||||||
|
- Privilege separation is enabled but no permissions were assigned to the token.
|
||||||
|
- The wrong realm/user/token ID was used.
|
||||||
|
|
||||||
|
TLS errors usually mean:
|
||||||
|
|
||||||
|
- Proxmox uses a self-signed certificate.
|
||||||
|
- The hostname in `Cluster API URL` does not match the certificate.
|
||||||
|
- Disable `Verify TLS certificate` only for trusted lab systems, or install a valid certificate on Proxmox.
|
||||||
|
|
||||||
## Repository Layout
|
## Repository Layout
|
||||||
|
|
||||||
@@ -38,5 +233,4 @@ nginx/ Reverse proxy example
|
|||||||
|
|
||||||
## Safety Model
|
## Safety Model
|
||||||
|
|
||||||
NexaFabric never applies firewall changes without a preview, validation, and audit record. The included Proxmox provider is designed around read-only inventory first. Write-enabled orchestration is intentionally routed through explicit dry-run and apply workflows.
|
NexaFabric never applies firewall changes without a preview, validation, and audit record. The included Proxmox provider is designed around read-only inventory first, then explicit dry-run and apply workflows for concrete VM/LXC firewall rules.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
return Path(path).read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
return 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)
|
||||||
|
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 []:
|
||||||
|
name = item.name
|
||||||
|
if name == "lo":
|
||||||
|
continue
|
||||||
|
vm_match = VM_INTERFACE_RE.search(name)
|
||||||
|
interfaces.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"vmid": vm_match.group(1) if vm_match else None,
|
||||||
|
"operstate": read_text(f"/sys/class/net/{name}/operstate") or "unknown",
|
||||||
|
"rx_bytes": int(read_text(f"/sys/class/net/{name}/statistics/rx_bytes") or 0),
|
||||||
|
"tx_bytes": int(read_text(f"/sys/class/net/{name}/statistics/tx_bytes") or 0),
|
||||||
|
"rx_packets": int(read_text(f"/sys/class/net/{name}/statistics/rx_packets") or 0),
|
||||||
|
"tx_packets": int(read_text(f"/sys/class/net/{name}/statistics/tx_packets") or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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"}:
|
||||||
|
return None
|
||||||
|
protocol = parts[0]
|
||||||
|
state = None
|
||||||
|
if protocol == "tcp" and len(parts) > 3 and "=" not in parts[3]:
|
||||||
|
state = parts[3]
|
||||||
|
|
||||||
|
values: dict[str, list[str]] = {}
|
||||||
|
for part in parts:
|
||||||
|
if "=" not in part:
|
||||||
|
continue
|
||||||
|
key, value = part.split("=", 1)
|
||||||
|
values.setdefault(key, []).append(value)
|
||||||
|
|
||||||
|
src_values = values.get("src", [])
|
||||||
|
dst_values = values.get("dst", [])
|
||||||
|
if not src_values or not dst_values:
|
||||||
|
return None
|
||||||
|
|
||||||
|
packet_values = [int(value) for value in values.get("packets", []) if value.isdigit()]
|
||||||
|
byte_values = [int(value) for value in values.get("bytes", []) if value.isdigit()]
|
||||||
|
sport = values.get("sport", [None])[0]
|
||||||
|
dport = values.get("dport", [None])[0]
|
||||||
|
return {
|
||||||
|
"source_ip": src_values[0],
|
||||||
|
"destination_ip": dst_values[0],
|
||||||
|
"protocol": protocol,
|
||||||
|
"source_port": int(sport) if sport and sport.isdigit() else None,
|
||||||
|
"destination_port": int(dport) if dport and dport.isdigit() else None,
|
||||||
|
"packets": sum(packet_values),
|
||||||
|
"bytes": sum(byte_values),
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_flows(limit: int = 500) -> list[dict[str, Any]]:
|
||||||
|
code, output = run_command(["conntrack", "-L", "-o", "extended"], timeout=10)
|
||||||
|
if code != 0 or not output:
|
||||||
|
return []
|
||||||
|
flows = []
|
||||||
|
seen = set()
|
||||||
|
for line in output.splitlines():
|
||||||
|
flow = parse_conntrack_line(line)
|
||||||
|
if not flow:
|
||||||
|
continue
|
||||||
|
key = (
|
||||||
|
flow["source_ip"],
|
||||||
|
flow["destination_ip"],
|
||||||
|
flow["protocol"],
|
||||||
|
flow.get("source_port"),
|
||||||
|
flow.get("destination_port"),
|
||||||
|
)
|
||||||
|
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():
|
||||||
|
return {"count": int(output), "source": "conntrack"}
|
||||||
|
for path in ("/proc/net/nf_conntrack", "/proc/net/ip_conntrack"):
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8", errors="ignore") as handle:
|
||||||
|
return {"count": sum(1 for _ in handle), "source": path}
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
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"])
|
||||||
|
status["pve_firewall"] = output if code == 0 else "unknown"
|
||||||
|
code, output = run_command(["nft", "-j", "list", "ruleset"], timeout=10)
|
||||||
|
if code == 0 and output:
|
||||||
|
status["nft_ruleset_sha256"] = hashlib.sha256(output.encode("utf-8")).hexdigest()
|
||||||
|
else:
|
||||||
|
status["nft_ruleset_sha256"] = None
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
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"),
|
||||||
|
"collected_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"hostname": socket.gethostname(),
|
||||||
|
"kernel": platform.release(),
|
||||||
|
"uptime_seconds": float(uptime.split()[0]) if uptime else None,
|
||||||
|
"loadavg": list(os.getloadavg()) if hasattr(os, "getloadavg") else [],
|
||||||
|
"interfaces": interfaces,
|
||||||
|
"interface_traffic": collect_interface_traffic(interfaces),
|
||||||
|
"flows": flows,
|
||||||
|
"ebpf_flows": ebpf_flows,
|
||||||
|
"conntrack": conntrack,
|
||||||
|
"firewall": collect_firewall(),
|
||||||
|
"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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_api_url(config: dict[str, Any]) -> str:
|
||||||
|
api_url = str(config["api_url"]).rstrip("/")
|
||||||
|
while api_url.endswith("/api/v1/api/v1"):
|
||||||
|
api_url = api_url.removesuffix("/api/v1")
|
||||||
|
if not api_url.endswith("/api/v1"):
|
||||||
|
api_url = f"{api_url}/api/v1"
|
||||||
|
return api_url
|
||||||
|
|
||||||
|
|
||||||
|
def post_heartbeat(config: dict[str, Any], payload: dict[str, Any]) -> None:
|
||||||
|
api_url = normalized_api_url(config)
|
||||||
|
heartbeat_url = f"{api_url}/agents/heartbeat"
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
heartbeat_url,
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {config['token']}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": f"nexafabric-agent/{VERSION}",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
context = None
|
||||||
|
if not bool(config.get("verify_tls", True)):
|
||||||
|
context = ssl._create_unverified_context()
|
||||||
|
with urllib.request.urlopen(request, timeout=15, context=context) as response:
|
||||||
|
response.read()
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str) -> dict[str, Any]:
|
||||||
|
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="NexaFabric Proxmox node telemetry agent")
|
||||||
|
parser.add_argument("--config", default="/etc/nexafabric-agent/config.json")
|
||||||
|
parser.add_argument("--once", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
config = load_config(args.config)
|
||||||
|
interval = int(config.get("interval_seconds", 30))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
started_at = time.monotonic()
|
||||||
|
payload = collect_payload(config)
|
||||||
|
try:
|
||||||
|
post_heartbeat(config, payload)
|
||||||
|
print(f"heartbeat ok: {payload['collected_at']}", flush=True)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = exc.read().decode("utf-8", errors="replace")[:500]
|
||||||
|
print(f"heartbeat failed: HTTP {exc.code} {exc.reason} url={exc.url} body={body}", flush=True)
|
||||||
|
except (OSError, urllib.error.URLError) as exc:
|
||||||
|
print(f"heartbeat failed: {exc} api_url={normalized_api_url(config)}", flush=True)
|
||||||
|
if args.once:
|
||||||
|
return 0
|
||||||
|
elapsed = time.monotonic() - started_at
|
||||||
|
time.sleep(max(interval - elapsed, 1))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(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))
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import CurrentUser
|
from app.api.deps import CurrentUser
|
||||||
from app.core.security import create_access_token, create_refresh_token, verify_password
|
from app.core.security import create_access_token, create_refresh_token, decode_token, verify_password
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.domain import User
|
from app.models.domain import User
|
||||||
from app.schemas.domain import LoginRequest, TokenPair, UserRead
|
from app.schemas.domain import LoginRequest, TokenPair, UserRead
|
||||||
@@ -33,6 +33,27 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenPair:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenPair)
|
||||||
|
def refresh(payload: dict[str, str], db: Session = Depends(get_db)) -> TokenPair:
|
||||||
|
token = payload.get("refresh_token")
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token")
|
||||||
|
try:
|
||||||
|
claims = decode_token(token)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc
|
||||||
|
if claims.get("typ") != "refresh":
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
|
||||||
|
user = db.scalar(select(User).where(User.id == claims.get("sub"), User.is_active.is_(True)))
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or missing user")
|
||||||
|
permissions = sorted({permission for role in user.roles for permission in role.permissions})
|
||||||
|
return TokenPair(
|
||||||
|
access_token=create_access_token(user.id, permissions),
|
||||||
|
refresh_token=create_refresh_token(user.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserRead)
|
@router.get("/me", response_model=UserRead)
|
||||||
def me(user: CurrentUser) -> User:
|
def me(user: CurrentUser) -> User:
|
||||||
return user
|
return user
|
||||||
|
|||||||
+1577
-50
File diff suppressed because it is too large
Load Diff
+16
-1
@@ -1,5 +1,6 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.api.v1.router import api_router
|
from app.api.v1.router import api_router
|
||||||
from app.core.config import get_settings
|
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
|
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:
|
def create_app() -> FastAPI:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -27,6 +42,7 @@ def create_app() -> FastAPI:
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def startup() -> None:
|
def startup() -> None:
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
ensure_runtime_indexes()
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
seed_demo_data(db)
|
seed_demo_data(db)
|
||||||
|
|
||||||
@@ -39,4 +55,3 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
|||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import JSON, 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 sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
@@ -129,6 +129,18 @@ class Node(Base, TimestampMixin):
|
|||||||
cluster: Mapped[Cluster] = relationship()
|
cluster: Mapped[Cluster] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class NodeAgent(Base, TimestampMixin):
|
||||||
|
__tablename__ = "node_agents"
|
||||||
|
|
||||||
|
node_id: Mapped[str] = mapped_column(ForeignKey("nodes.id"), primary_key=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(100), default="not_installed")
|
||||||
|
version: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||||
|
last_payload: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
install_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
node: Mapped[Node] = relationship()
|
||||||
|
|
||||||
|
|
||||||
class Workload(Base, TimestampMixin):
|
class Workload(Base, TimestampMixin):
|
||||||
__tablename__ = "workloads"
|
__tablename__ = "workloads"
|
||||||
|
|
||||||
@@ -173,7 +185,10 @@ class Subnet(Base, TimestampMixin):
|
|||||||
|
|
||||||
class IpAddress(Base, TimestampMixin):
|
class IpAddress(Base, TimestampMixin):
|
||||||
__tablename__ = "ip_addresses"
|
__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)
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=new_id)
|
||||||
subnet_id: Mapped[str] = mapped_column(ForeignKey("subnets.id"), index=True)
|
subnet_id: Mapped[str] = mapped_column(ForeignKey("subnets.id"), index=True)
|
||||||
@@ -183,6 +198,30 @@ class IpAddress(Base, TimestampMixin):
|
|||||||
note: Mapped[str | None] = mapped_column(Text)
|
note: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
source_ip: Mapped[str] = mapped_column(String(100), index=True)
|
||||||
|
destination_ip: Mapped[str] = mapped_column(String(100), index=True)
|
||||||
|
protocol: Mapped[str] = mapped_column(String(20), default="unknown")
|
||||||
|
source_port: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
destination_port: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
bytes: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||||
|
packets: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||||
|
state: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
observed_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||||
|
raw: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
|
||||||
|
|
||||||
class SecurityGroup(Base, TimestampMixin):
|
class SecurityGroup(Base, TimestampMixin):
|
||||||
__tablename__ = "security_groups"
|
__tablename__ = "security_groups"
|
||||||
|
|
||||||
@@ -192,6 +231,17 @@ class SecurityGroup(Base, TimestampMixin):
|
|||||||
description: Mapped[str | None] = mapped_column(Text)
|
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):
|
class SecurityRule(Base, TimestampMixin):
|
||||||
__tablename__ = "security_rules"
|
__tablename__ = "security_rules"
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ class ClusterCreate(BaseModel):
|
|||||||
verify_tls: bool = True
|
verify_tls: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ClusterUpdate(BaseModel):
|
||||||
|
name: str
|
||||||
|
api_url: str
|
||||||
|
api_token: str | None = Field(default=None, min_length=8)
|
||||||
|
provider: str = "proxmox"
|
||||||
|
mode: str = "read_only"
|
||||||
|
verify_tls: bool = True
|
||||||
|
|
||||||
|
|
||||||
class TenantCreate(BaseModel):
|
class TenantCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
@@ -104,12 +113,24 @@ class SubnetCreate(BaseModel):
|
|||||||
dhcp_enabled: bool = False
|
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):
|
class SecurityGroupCreate(BaseModel):
|
||||||
project_id: str | None = None
|
project_id: str | None = None
|
||||||
name: str
|
name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityGroupMemberCreate(BaseModel):
|
||||||
|
workload_id: str
|
||||||
|
|
||||||
|
|
||||||
class SecurityRuleCreate(BaseModel):
|
class SecurityRuleCreate(BaseModel):
|
||||||
security_group_id: str
|
security_group_id: str
|
||||||
direction: str = "ingress"
|
direction: str = "ingress"
|
||||||
@@ -151,12 +172,34 @@ class FirewallApplyRequest(BaseModel):
|
|||||||
dry_run: bool = True
|
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):
|
class ClusterRead(OrmModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
api_url: str
|
api_url: str
|
||||||
provider: str
|
provider: str
|
||||||
mode: str
|
mode: str
|
||||||
|
verify_tls: bool
|
||||||
last_sync_at: datetime | None
|
last_sync_at: datetime | None
|
||||||
last_sync_status: str | None
|
last_sync_status: str | None
|
||||||
last_sync_error: str | None
|
last_sync_error: str | None
|
||||||
@@ -171,6 +214,36 @@ class NodeRead(OrmModel):
|
|||||||
memory_mb: int
|
memory_mb: int
|
||||||
|
|
||||||
|
|
||||||
|
class NodeAgentRead(BaseModel):
|
||||||
|
node_id: str
|
||||||
|
status: str
|
||||||
|
version: str | None = None
|
||||||
|
last_seen_at: datetime | None = None
|
||||||
|
install_count: int = 0
|
||||||
|
last_payload: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NodeWithAgentRead(NodeRead):
|
||||||
|
agent: NodeAgentRead | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentHeartbeat(BaseModel):
|
||||||
|
version: str
|
||||||
|
node_name: str | None = None
|
||||||
|
collected_at: datetime | None = None
|
||||||
|
hostname: str | None = None
|
||||||
|
kernel: str | None = None
|
||||||
|
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] = {}
|
||||||
|
|
||||||
|
|
||||||
class WorkloadRead(OrmModel):
|
class WorkloadRead(OrmModel):
|
||||||
id: str
|
id: str
|
||||||
cluster_id: str
|
cluster_id: str
|
||||||
@@ -210,9 +283,12 @@ class SubnetRead(OrmModel):
|
|||||||
class IpAddressRead(OrmModel):
|
class IpAddressRead(OrmModel):
|
||||||
id: str
|
id: str
|
||||||
subnet_id: str
|
subnet_id: str
|
||||||
|
subnet_cidr: str | None = None
|
||||||
address: str
|
address: str
|
||||||
status: str
|
status: str
|
||||||
workload_id: str | None
|
workload_id: str | None
|
||||||
|
workload_name: str | None = None
|
||||||
|
workload_external_id: str | None = None
|
||||||
note: str | None
|
note: str | None
|
||||||
|
|
||||||
|
|
||||||
@@ -234,6 +310,15 @@ class SecurityGroupRead(OrmModel):
|
|||||||
project_id: str | None
|
project_id: str | None
|
||||||
name: str
|
name: str
|
||||||
description: str | None
|
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):
|
class SecurityRuleRead(OrmModel):
|
||||||
@@ -259,11 +344,14 @@ class PolicyRead(OrmModel):
|
|||||||
enforcement_mode: str
|
enforcement_mode: str
|
||||||
definition: dict[str, Any]
|
definition: dict[str, Any]
|
||||||
last_compiled: dict[str, Any] | None
|
last_compiled: dict[str, Any] | None
|
||||||
|
deployment_status: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class WorkloadInsight(BaseModel):
|
class WorkloadInsight(BaseModel):
|
||||||
workload: WorkloadRead
|
workload: WorkloadRead
|
||||||
|
assigned_ips: list[IpAddressRead]
|
||||||
traffic: list[dict[str, Any]]
|
traffic: list[dict[str, Any]]
|
||||||
|
active_firewall_rules: list[dict[str, Any]] = []
|
||||||
matching_policies: list[PolicyRead]
|
matching_policies: list[PolicyRead]
|
||||||
effective_decision: str
|
effective_decision: str
|
||||||
audit_mode_notes: list[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
|
||||||
@@ -7,6 +7,17 @@ from app.services.providers.base import Provider, ProviderConnection
|
|||||||
|
|
||||||
class ProxmoxProvider(Provider):
|
class ProxmoxProvider(Provider):
|
||||||
name = "proxmox"
|
name = "proxmox"
|
||||||
|
ignored_guest_interface_prefixes = (
|
||||||
|
"br-",
|
||||||
|
"cali",
|
||||||
|
"cni",
|
||||||
|
"docker",
|
||||||
|
"flannel",
|
||||||
|
"kube",
|
||||||
|
"lo",
|
||||||
|
"veth",
|
||||||
|
"virbr",
|
||||||
|
)
|
||||||
|
|
||||||
def auth_header(self, token: str) -> str:
|
def auth_header(self, token: str) -> str:
|
||||||
token = token.strip()
|
token = token.strip()
|
||||||
@@ -67,6 +78,9 @@ class ProxmoxProvider(Provider):
|
|||||||
return
|
return
|
||||||
interfaces = response.json().get("data", {}).get("result", [])
|
interfaces = response.json().get("data", {}).get("result", [])
|
||||||
for interface in interfaces:
|
for interface in interfaces:
|
||||||
|
interface_name = str(interface.get("name") or "").lower()
|
||||||
|
if interface_name.startswith(self.ignored_guest_interface_prefixes):
|
||||||
|
continue
|
||||||
for address in interface.get("ip-addresses", []):
|
for address in interface.get("ip-addresses", []):
|
||||||
ip_address = address.get("ip-address")
|
ip_address = address.get("ip-address")
|
||||||
prefix = address.get("prefix")
|
prefix = address.get("prefix")
|
||||||
@@ -113,7 +127,262 @@ class ProxmoxProvider(Provider):
|
|||||||
"warnings": ["Preview only. No Proxmox firewall changes were sent."],
|
"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,
|
||||||
|
headers: dict[str, str],
|
||||||
|
rules_url: str,
|
||||||
|
marker: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
existing_response = await client.get(rules_url, headers=headers)
|
||||||
|
existing_response.raise_for_status()
|
||||||
|
existing_rules = existing_response.json().get("data", [])
|
||||||
|
deletions = []
|
||||||
|
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 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]:
|
async def apply_rules(self, connection: ProviderConnection, rules: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
if connection.read_only:
|
if connection.read_only:
|
||||||
return {"applied": False, "reason": "Cluster is read-only", "rules": rules}
|
return {"applied": False, "reason": "Cluster is read-only", "rules": rules}
|
||||||
return {"applied": False, "reason": "Apply adapter intentionally requires explicit implementation", "rules": rules}
|
missing_mapping = [rule for rule in rules if "provider_target" not in rule]
|
||||||
|
if missing_mapping:
|
||||||
|
return {
|
||||||
|
"applied": False,
|
||||||
|
"reason": "Live apply requires every rule to resolve to a concrete Proxmox VM/LXC target.",
|
||||||
|
"rules": missing_mapping,
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {"Authorization": self.auth_header(connection.token)}
|
||||||
|
grouped: dict[tuple[str, str, str, str], list[dict[str, Any]]] = {}
|
||||||
|
for rule in rules:
|
||||||
|
target = rule["provider_target"]
|
||||||
|
marker = self.policy_marker(rule)
|
||||||
|
key = (str(target["node"]), str(target["kind"]), str(target["vmid"]), marker)
|
||||||
|
grouped.setdefault(key, []).append(rule)
|
||||||
|
|
||||||
|
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():
|
||||||
|
target = target_rules[0]["provider_target"]
|
||||||
|
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"):
|
||||||
|
audit_only_rules.append(
|
||||||
|
{
|
||||||
|
"policy_id": rule.get("policy_id"),
|
||||||
|
"target": target,
|
||||||
|
"reason": "Audit mode does not enforce or write blocking Proxmox rules.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
provider_rule = rule.get("provider_rule")
|
||||||
|
if not provider_rule:
|
||||||
|
return {
|
||||||
|
"applied": False,
|
||||||
|
"reason": "Resolved rule is missing provider_rule payload.",
|
||||||
|
"rule": rule,
|
||||||
|
}
|
||||||
|
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:
|
def main() -> None:
|
||||||
print("NexaFabric worker started. Configure Celery queues for production job execution.", flush=True)
|
asyncio.run(loop())
|
||||||
while True:
|
|
||||||
time.sleep(30)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
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"
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.providers import proxmox
|
||||||
|
from app.services.providers.base import ProviderConnection
|
||||||
|
from app.services.providers.proxmox import ProxmoxProvider
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, data: object) -> None:
|
||||||
|
self._data = data
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return {"data": self._data}
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "FakeAsyncClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
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"},
|
||||||
|
{"pos": 1, "comment": "NexaFabric policy=policy-1 version=1 rule=1 target=web"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete(self, url: str, **__: object) -> FakeResponse:
|
||||||
|
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})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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(
|
||||||
|
ProviderConnection(api_url="https://pve.example:8006", token="user@pve!token=secret", read_only=False),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"policy_id": "policy-1",
|
||||||
|
"audit_only": False,
|
||||||
|
"provider_target": {"node": "pve1", "kind": "qemu", "vmid": "100"},
|
||||||
|
"provider_rule": {
|
||||||
|
"type": "in",
|
||||||
|
"action": "ACCEPT",
|
||||||
|
"enable": 1,
|
||||||
|
"proto": "tcp",
|
||||||
|
"dport": "443",
|
||||||
|
"comment": "NexaFabric policy=policy-1 version=2 rule=1 target=web",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
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",
|
||||||
|
"action": "ACCEPT",
|
||||||
|
"enable": 1,
|
||||||
|
"proto": "tcp",
|
||||||
|
"dport": "443",
|
||||||
|
"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 == []
|
||||||
@@ -11,14 +11,16 @@ import { Ipam } from "./pages/Ipam";
|
|||||||
import { ListPage } from "./pages/ListPage";
|
import { ListPage } from "./pages/ListPage";
|
||||||
import { Login } from "./pages/Login";
|
import { Login } from "./pages/Login";
|
||||||
import { Networks } from "./pages/Networks";
|
import { Networks } from "./pages/Networks";
|
||||||
|
import { Nodes } from "./pages/Nodes";
|
||||||
import { Policies } from "./pages/Policies";
|
import { Policies } from "./pages/Policies";
|
||||||
import { PolicyDesigner } from "./pages/PolicyDesigner";
|
import { PolicyDesigner } from "./pages/PolicyDesigner";
|
||||||
import { SecurityGroups } from "./pages/SecurityGroups";
|
import { SecurityGroups } from "./pages/SecurityGroups";
|
||||||
import { ServiceCatalog } from "./pages/ServiceCatalog";
|
import { ServiceCatalog } from "./pages/ServiceCatalog";
|
||||||
|
import { Settings } from "./pages/Settings";
|
||||||
import { SetupWizard } from "./pages/SetupWizard";
|
import { SetupWizard } from "./pages/SetupWizard";
|
||||||
import { TenantsProjects } from "./pages/TenantsProjects";
|
import { TenantsProjects } from "./pages/TenantsProjects";
|
||||||
import { UsersRoles } from "./pages/UsersRoles";
|
import { UsersRoles } from "./pages/UsersRoles";
|
||||||
import { Workloads } from "./pages/Workloads";
|
import { WorkloadDetail, WorkloadFlows, Workloads } from "./pages/Workloads";
|
||||||
import { useTheme } from "./stores/theme";
|
import { useTheme } from "./stores/theme";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -46,8 +48,10 @@ function AppRoutes() {
|
|||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="clusters" element={<Clusters />} />
|
<Route path="clusters" element={<Clusters />} />
|
||||||
<Route path="nodes" element={<ListPage title="Nodes" subtitle="Cluster nodes, capacity, and health." path="/nodes" columns={[{ key: "name", label: "Name" }, { key: "status", label: "Status" }, { key: "cpu_count", label: "CPU" }, { key: "memory_mb", label: "Memory MB" }]} />} />
|
<Route path="nodes" element={<Nodes />} />
|
||||||
<Route path="workloads" element={<Workloads />} />
|
<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="networks" element={<Networks />} />
|
||||||
<Route path="ipam" element={<Ipam />} />
|
<Route path="ipam" element={<Ipam />} />
|
||||||
<Route path="tenants" element={<TenantsProjects />} />
|
<Route path="tenants" element={<TenantsProjects />} />
|
||||||
@@ -59,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="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="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="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>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
+134
-5
@@ -6,8 +6,19 @@ export type Dashboard = {
|
|||||||
workloads: number;
|
workloads: number;
|
||||||
networks: number;
|
networks: number;
|
||||||
open_policy_violations: number;
|
open_policy_violations: number;
|
||||||
|
security_posture: string;
|
||||||
faulty_nodes: Array<{ id: string; name: string; status: 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 }>;
|
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 = {
|
export type SetupStatus = {
|
||||||
@@ -22,6 +33,7 @@ export type Cluster = {
|
|||||||
api_url: string;
|
api_url: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
|
verify_tls: boolean;
|
||||||
last_sync_status: string | null;
|
last_sync_status: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,14 +68,19 @@ export type Subnet = {
|
|||||||
network_id: string;
|
network_id: string;
|
||||||
cidr: string;
|
cidr: string;
|
||||||
gateway: string | null;
|
gateway: string | null;
|
||||||
|
dns: string[];
|
||||||
dhcp_enabled: boolean;
|
dhcp_enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IpAddress = {
|
export type IpAddress = {
|
||||||
id: string;
|
id: string;
|
||||||
subnet_id: string;
|
subnet_id: string;
|
||||||
|
subnet_cidr: string | null;
|
||||||
address: string;
|
address: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
workload_id: string | null;
|
||||||
|
workload_name: string | null;
|
||||||
|
workload_external_id: string | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,6 +99,15 @@ export type SecurityGroup = {
|
|||||||
project_id: string | null;
|
project_id: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
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 = {
|
export type SecurityRule = {
|
||||||
@@ -100,12 +126,14 @@ export type SecurityRule = {
|
|||||||
|
|
||||||
export type Policy = {
|
export type Policy = {
|
||||||
id: string;
|
id: string;
|
||||||
|
project_id: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
version: number;
|
version: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
enforcement_mode: string;
|
enforcement_mode: string;
|
||||||
definition: Record<string, unknown>;
|
definition: Record<string, unknown>;
|
||||||
last_compiled: Record<string, unknown> | null;
|
last_compiled: Record<string, unknown> | null;
|
||||||
|
deployment_status: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Workload = {
|
export type Workload = {
|
||||||
@@ -120,9 +148,49 @@ export type Workload = {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NodeAgent = {
|
||||||
|
node_id: string;
|
||||||
|
status: string;
|
||||||
|
version: string | null;
|
||||||
|
last_seen_at: string | null;
|
||||||
|
install_count: number;
|
||||||
|
last_payload: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Node = {
|
||||||
|
id: string;
|
||||||
|
cluster_id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
cpu_count: number;
|
||||||
|
memory_mb: number;
|
||||||
|
agent: NodeAgent | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentInstallInfo = {
|
||||||
|
node_id: string;
|
||||||
|
install_url: string;
|
||||||
|
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 = {
|
export type WorkloadInsight = {
|
||||||
workload: Workload;
|
workload: Workload;
|
||||||
|
assigned_ips: IpAddress[];
|
||||||
traffic: Array<Record<string, unknown>>;
|
traffic: Array<Record<string, unknown>>;
|
||||||
|
active_firewall_rules: Array<Record<string, unknown>>;
|
||||||
matching_policies: Policy[];
|
matching_policies: Policy[];
|
||||||
effective_decision: string;
|
effective_decision: string;
|
||||||
audit_mode_notes: string[];
|
audit_mode_notes: string[];
|
||||||
@@ -148,11 +216,41 @@ export function token() {
|
|||||||
return localStorage.getItem("nexafabric.token");
|
return localStorage.getItem("nexafabric.token");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setToken(value: string) {
|
export function refreshToken() {
|
||||||
localStorage.setItem("nexafabric.token", value);
|
return localStorage.getItem("nexafabric.refreshToken");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
export function setTokens(accessToken: string, nextRefreshToken: string) {
|
||||||
|
localStorage.setItem("nexafabric.token", accessToken);
|
||||||
|
localStorage.setItem("nexafabric.refreshToken", nextRefreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTokens() {
|
||||||
|
localStorage.removeItem("nexafabric.token");
|
||||||
|
localStorage.removeItem("nexafabric.refreshToken");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken() {
|
||||||
|
const currentRefreshToken = refreshToken();
|
||||||
|
if (!currentRefreshToken) {
|
||||||
|
clearTokens();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ refresh_token: currentRefreshToken }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
clearTokens();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { access_token: string; refresh_token: string };
|
||||||
|
setTokens(data.access_token, data.refresh_token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init: RequestInit, retry: boolean): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -161,12 +259,23 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|||||||
...init.headers,
|
...init.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (response.status === 401 && retry && (await refreshAccessToken())) {
|
||||||
|
return request<T>(path, init, false);
|
||||||
|
}
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearTokens();
|
||||||
|
window.dispatchEvent(new Event("nexafabric.authExpired"));
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await response.text());
|
throw new Error(await response.text());
|
||||||
}
|
}
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
return request<T>(path, init, true);
|
||||||
|
}
|
||||||
|
|
||||||
export async function publicApi<T>(path: string, init: RequestInit = {}): Promise<T> {
|
export async function publicApi<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
@@ -181,11 +290,31 @@ export async function publicApi<T>(path: string, init: RequestInit = {}): Promis
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function authorizedFetch(path: string, init: RequestInit = {}) {
|
||||||
|
const makeRequest = () =>
|
||||||
|
fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(token() ? { Authorization: `Bearer ${token()}` } : {}),
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let response = await makeRequest();
|
||||||
|
if (response.status === 401 && (await refreshAccessToken())) {
|
||||||
|
response = await makeRequest();
|
||||||
|
}
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearTokens();
|
||||||
|
window.dispatchEvent(new Event("nexafabric.authExpired"));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
export async function login(email: string, password: string) {
|
export async function login(email: string, password: string) {
|
||||||
const data = await api<{ access_token: string }>("/auth/login", {
|
const data = await publicApi<{ access_token: string; refresh_token: string }>("/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
setToken(data.access_token);
|
setTokens(data.access_token, data.refresh_token);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,4 @@ export const inputClass = "h-10 w-full rounded-md border border-border bg-transp
|
|||||||
export const selectClass = inputClass;
|
export const selectClass = inputClass;
|
||||||
export const buttonClass = "inline-flex h-10 items-center justify-center gap-2 rounded-md bg-accent px-4 text-sm font-medium text-white disabled:opacity-50";
|
export const buttonClass = "inline-flex h-10 items-center justify-center gap-2 rounded-md bg-accent px-4 text-sm font-medium text-white disabled:opacity-50";
|
||||||
export const secondaryButtonClass = "inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border px-4 text-sm hover:bg-slate-100 dark:hover:bg-slate-800";
|
export const secondaryButtonClass = "inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border px-4 text-sm hover:bg-slate-100 dark:hover:bg-slate-800";
|
||||||
|
export const iconButtonClass = "inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-slate-600 hover:bg-slate-100 hover:text-slate-950 disabled:opacity-50 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white";
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Flame,
|
Flame,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
Moon,
|
Moon,
|
||||||
Network,
|
Network,
|
||||||
@@ -18,74 +19,137 @@ import {
|
|||||||
SquareStack,
|
SquareStack,
|
||||||
Sun,
|
Sun,
|
||||||
Users,
|
Users,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { token } from "../api/client";
|
import { clearTokens, token } from "../api/client";
|
||||||
import { useTheme } from "../stores/theme";
|
import { useTheme } from "../stores/theme";
|
||||||
|
|
||||||
const nav = [
|
const navGroups = [
|
||||||
|
{
|
||||||
|
label: "Operate",
|
||||||
|
items: [
|
||||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
|
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||||
{ to: "/clusters", label: "Clusters", icon: Server },
|
{ to: "/clusters", label: "Clusters", icon: Server },
|
||||||
{ to: "/nodes", label: "Nodes", icon: Activity },
|
{ to: "/nodes", label: "Nodes", icon: Activity },
|
||||||
{ to: "/workloads", label: "VMs/LXCs", icon: Blocks },
|
{ to: "/workloads", label: "VMs/LXCs", icon: Blocks },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Network",
|
||||||
|
items: [
|
||||||
{ to: "/networks", label: "Networks", icon: Network },
|
{ to: "/networks", label: "Networks", icon: Network },
|
||||||
{ to: "/ipam", label: "IPAM", icon: Database },
|
{ to: "/ipam", label: "IPAM", icon: Database },
|
||||||
|
{ to: "/services", label: "Service Catalog", icon: SquareStack },
|
||||||
{ to: "/tenants", label: "Tenants", icon: BriefcaseBusiness },
|
{ to: "/tenants", label: "Tenants", icon: BriefcaseBusiness },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Security",
|
||||||
|
items: [
|
||||||
{ to: "/security-groups", label: "Security Groups", icon: Shield },
|
{ to: "/security-groups", label: "Security Groups", icon: Shield },
|
||||||
{ to: "/policies", label: "Policies", icon: GitBranch },
|
{ to: "/policies", label: "Policies", icon: GitBranch },
|
||||||
{ to: "/services", label: "Service Catalog", icon: SquareStack },
|
|
||||||
{ to: "/designer", label: "Policy Designer", icon: LockKeyhole },
|
{ to: "/designer", label: "Policy Designer", icon: LockKeyhole },
|
||||||
{ to: "/firewall", label: "Firewall Preview", icon: Flame },
|
{ to: "/firewall", label: "Firewall Preview", icon: Flame },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const systemNav = [
|
||||||
{ to: "/jobs", label: "Jobs", icon: ClipboardList },
|
{ to: "/jobs", label: "Jobs", icon: ClipboardList },
|
||||||
{ to: "/audit", label: "Audit Logs", icon: BookOpen },
|
{ to: "/audit", label: "Audit Logs", icon: BookOpen },
|
||||||
{ to: "/users", label: "Users", icon: Users },
|
{ to: "/users", label: "Users", icon: Users },
|
||||||
{ to: "/settings", label: "Settings", icon: Settings },
|
{ 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() {
|
export function Layout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { dark, toggle } = useTheme();
|
const { dark, toggle } = useTheme();
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearTokens();
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token()) navigate("/login");
|
if (!token()) navigate("/login");
|
||||||
|
function handleAuthExpired() {
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
window.addEventListener("nexafabric.authExpired", handleAuthExpired);
|
||||||
|
return () => window.removeEventListener("nexafabric.authExpired", handleAuthExpired);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas text-slate-900 dark:text-slate-100">
|
<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">
|
<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 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>
|
||||||
<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 className="text-xs text-slate-500 dark:text-slate-400">Control Plane</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="h-[calc(100vh-4rem)] overflow-y-auto p-3">
|
</div>
|
||||||
{nav.map((item) => {
|
<nav className="flex h-[calc(100vh-4rem)] flex-col overflow-y-auto p-3">
|
||||||
const Icon = item.icon;
|
<div className="space-y-5">
|
||||||
return (
|
{navGroups.map((group) => (
|
||||||
<NavLink
|
<section key={group.label}>
|
||||||
key={item.to}
|
<div className="mb-2 px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">{group.label}</div>
|
||||||
to={item.to}
|
<div className="space-y-1">
|
||||||
className={({ isActive }) =>
|
{group.items.map((item) => <SidebarLink key={item.to} item={item} />)}
|
||||||
`mb-1 flex h-10 items-center gap-3 rounded-md px-3 text-sm ${
|
</div>
|
||||||
isActive ? "bg-accent text-white" : "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
</section>
|
||||||
}`
|
))}
|
||||||
}
|
</div>
|
||||||
>
|
<div className="mt-auto border-t border-border pt-3">
|
||||||
<Icon size={18} />
|
<div className="mb-2 px-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">System</div>
|
||||||
<span>{item.label}</span>
|
<div className="space-y-1">
|
||||||
</NavLink>
|
{systemNav.map((item) => <SidebarLink key={item.to} item={item} />)}
|
||||||
);
|
</div>
|
||||||
})}
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
<main className="md:pl-64">
|
<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">
|
<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>
|
<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} />}
|
{dark ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
</button>
|
</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>
|
</header>
|
||||||
<div className="p-4 md:p-6">
|
<div className="p-4 md:p-6">
|
||||||
<Outlet />
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,58 +1,105 @@
|
|||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Cable, Plus, RefreshCcw, Server } from "lucide-react";
|
import { Cable, Pencil, Plus, RefreshCcw, Server, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import { api, Cluster } from "../api/client";
|
import { api, Cluster } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
|
||||||
|
import { LoadingOverlay } from "../components/LoadingOverlay";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
export function Clusters() {
|
const emptyClusterForm = {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const clusters = useQuery({ queryKey: ["clusters"], queryFn: () => api<Cluster[]>("/clusters") });
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
name: "Demo Provider",
|
name: "Demo Provider",
|
||||||
api_url: "https://demo.local:8006",
|
api_url: "https://demo.local:8006",
|
||||||
api_token: "PVEAPIToken=demo",
|
api_token: "PVEAPIToken=demo",
|
||||||
provider: "demo",
|
provider: "demo",
|
||||||
mode: "read_only",
|
mode: "read_only",
|
||||||
verify_tls: true,
|
verify_tls: true,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
export function Clusters() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const clusters = useQuery({ queryKey: ["clusters"], queryFn: () => api<Cluster[]>("/clusters") });
|
||||||
|
const [form, setForm] = useState(emptyClusterForm);
|
||||||
const [result, setResult] = useState("");
|
const [result, setResult] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Cluster | null>(null);
|
||||||
|
const [busyMessage, setBusyMessage] = useState("");
|
||||||
|
|
||||||
const create = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: () => api<Cluster>("/clusters", { method: "POST", body: JSON.stringify(form) }),
|
mutationFn: () => {
|
||||||
|
const body = editing && !form.api_token ? { ...form, api_token: null } : form;
|
||||||
|
return api<Cluster>(editing ? `/clusters/${editing.id}` : "/clusters", {
|
||||||
|
method: editing ? "PATCH" : "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setEditing(null);
|
||||||
queryClient.invalidateQueries({ queryKey: ["clusters"] });
|
queryClient.invalidateQueries({ queryKey: ["clusters"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (cluster: Cluster) => api(`/clusters/${cluster.id}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clusters"] }),
|
||||||
|
});
|
||||||
|
|
||||||
async function submit(event: FormEvent) {
|
async function submit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await create.mutateAsync();
|
await save.mutateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCluster() {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyClusterForm);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editCluster(cluster: Cluster) {
|
||||||
|
setEditing(cluster);
|
||||||
|
setForm({
|
||||||
|
name: cluster.name,
|
||||||
|
api_url: cluster.api_url,
|
||||||
|
api_token: "",
|
||||||
|
provider: cluster.provider,
|
||||||
|
mode: cluster.mode,
|
||||||
|
verify_tls: cluster.verify_tls,
|
||||||
|
});
|
||||||
|
setOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function action(cluster: Cluster, kind: "test" | "sync") {
|
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" });
|
const data = await api<Record<string, unknown>>(`/clusters/${cluster.id}/${kind}`, { method: "POST" });
|
||||||
setResult(JSON.stringify(data, null, 2));
|
setResult(JSON.stringify(data, null, 2));
|
||||||
await queryClient.invalidateQueries({ queryKey: ["clusters"] });
|
await queryClient.invalidateQueries({ queryKey: ["clusters"] });
|
||||||
|
} finally {
|
||||||
|
setBusyMessage("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteCluster(cluster: Cluster) {
|
||||||
|
if (window.confirm(`Delete cluster "${cluster.name}" and its imported inventory/IPAM data?`)) {
|
||||||
|
remove.mutate(cluster);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Clusters" subtitle="Register, test, and sync Proxmox or demo providers." />
|
<PageHeader title="Clusters" subtitle="Register, test, and sync Proxmox or demo providers." />
|
||||||
|
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button className={buttonClass} onClick={() => setOpen(true)}><Plus size={16} /> Add Cluster</button>
|
<button className={buttonClass} onClick={addCluster}><Plus size={16} /> Add Cluster</button>
|
||||||
<Modal title="Add Cluster" open={open} onClose={() => setOpen(false)}>
|
<Modal title={editing ? "Edit Cluster" : "Add Cluster"} open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<div className="mb-4 flex items-center gap-2 font-medium"><Server size={18} /> Provider connection</div>
|
<div className="mb-4 flex items-center gap-2 font-medium"><Server size={18} /> Provider connection</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
|
<Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
|
||||||
<Field label="API URL"><input className={inputClass} value={form.api_url} onChange={(event) => setForm({ ...form, api_url: event.target.value })} /></Field>
|
<Field label="API URL"><input className={inputClass} value={form.api_url} onChange={(event) => setForm({ ...form, api_url: event.target.value })} /></Field>
|
||||||
<Field label="API Token"><input className={inputClass} value={form.api_token} onChange={(event) => setForm({ ...form, api_token: event.target.value })} /></Field>
|
<Field label={editing ? "API Token (leave empty to keep current)" : "API Token"}><input className={inputClass} value={form.api_token} onChange={(event) => setForm({ ...form, api_token: event.target.value })} /></Field>
|
||||||
<Field label="Provider">
|
<Field label="Provider">
|
||||||
<select className={selectClass} value={form.provider} onChange={(event) => setForm({ ...form, provider: event.target.value })}>
|
<select className={selectClass} value={form.provider} onChange={(event) => setForm({ ...form, provider: event.target.value })}>
|
||||||
<option value="demo">demo</option>
|
<option value="demo">demo</option>
|
||||||
@@ -69,7 +116,7 @@ export function Clusters() {
|
|||||||
<input type="checkbox" checked={form.verify_tls} onChange={(event) => setForm({ ...form, verify_tls: event.target.checked })} />
|
<input type="checkbox" checked={form.verify_tls} onChange={(event) => setForm({ ...form, verify_tls: event.target.checked })} />
|
||||||
Verify TLS
|
Verify TLS
|
||||||
</label>
|
</label>
|
||||||
<button className={buttonClass} disabled={create.isPending}>Save Cluster</button>
|
<button className={buttonClass} disabled={save.isPending}>{editing ? "Update Cluster" : "Save Cluster"}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -81,16 +128,23 @@ export function Clusters() {
|
|||||||
{ key: "provider", label: "Provider" },
|
{ key: "provider", label: "Provider" },
|
||||||
{ key: "mode", label: "Mode" },
|
{ key: "mode", label: "Mode" },
|
||||||
{ key: "last_sync_status", label: "Sync" },
|
{ key: "last_sync_status", label: "Sync" },
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Actions",
|
||||||
|
render: (row) => {
|
||||||
|
const cluster = row as unknown as Cluster;
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button className={iconButtonClass} title="Test connection" aria-label={`Test ${cluster.name}`} onClick={() => action(cluster, "test")}><Cable size={16} /></button>
|
||||||
|
<button className={iconButtonClass} title="Sync inventory" aria-label={`Sync ${cluster.name}`} onClick={() => action(cluster, "sync")}><RefreshCcw size={16} /></button>
|
||||||
|
<button className={iconButtonClass} title="Edit cluster" aria-label={`Edit ${cluster.name}`} onClick={() => editCluster(cluster)}><Pencil size={16} /></button>
|
||||||
|
<button className={iconButtonClass} title="Delete cluster" aria-label={`Delete ${cluster.name}`} disabled={remove.isPending} onClick={() => deleteCluster(cluster)}><Trash2 size={16} /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(clusters.data ?? []).map((cluster) => (
|
|
||||||
<div key={cluster.id} className="flex gap-2">
|
|
||||||
<button className={secondaryButtonClass} onClick={() => action(cluster, "test")}><Cable size={16} /> {cluster.name}</button>
|
|
||||||
<button className={secondaryButtonClass} onClick={() => action(cluster, "sync")}><RefreshCcw size={16} /> Sync</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<pre className="min-h-24 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{result || "No cluster action result yet."}</pre>
|
<pre className="min-h-24 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{result || "No cluster action result yet."}</pre>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
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 { api, Dashboard as DashboardData } from "../api/client";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
@@ -8,16 +8,78 @@ const cards = [
|
|||||||
["clusters", "Clusters", Server],
|
["clusters", "Clusters", Server],
|
||||||
["nodes", "Nodes", Boxes],
|
["nodes", "Nodes", Boxes],
|
||||||
["workloads", "VMs/LXCs", Network],
|
["workloads", "VMs/LXCs", Network],
|
||||||
["networks", "Networks", Network],
|
["networks", "Networks", Wifi],
|
||||||
["open_policy_violations", "Policy Violations", ShieldAlert],
|
["open_policy_violations", "Signals", ShieldAlert],
|
||||||
] as const;
|
] 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() {
|
export function Dashboard() {
|
||||||
const { data } = useQuery({ queryKey: ["dashboard"], queryFn: () => api<DashboardData>("/dashboard") });
|
const { data } = useQuery({ queryKey: ["dashboard"], queryFn: () => api<DashboardData>("/dashboard") });
|
||||||
|
const suspicious = data?.suspicious_traffic ?? [];
|
||||||
|
const postureStable = data?.security_posture !== "attention";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Dashboard" subtitle="Operational overview across clusters, networks, policies, and sync health." />
|
<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">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
{cards.map(([key, label, Icon]) => (
|
{cards.map(([key, label, Icon]) => (
|
||||||
<div key={key} className="rounded-md border border-border bg-panel p-4">
|
<div key={key} className="rounded-md border border-border bg-panel p-4">
|
||||||
@@ -29,27 +91,63 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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">
|
<section className="rounded-md border border-border bg-panel p-4">
|
||||||
<div className="mb-3 flex items-center gap-2 font-medium">
|
<div className="mb-3 flex items-center gap-2 font-medium">
|
||||||
<AlertTriangle size={18} />
|
<AlertTriangle size={18} />
|
||||||
Faulty Nodes
|
Faulty Nodes
|
||||||
</div>
|
</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">
|
<div key={node.id} className="flex justify-between border-t border-border py-3 text-sm">
|
||||||
<span>{node.name}</span>
|
<span>{node.name}</span>
|
||||||
<span className="text-danger">{node.status}</span>
|
<span className="text-danger">{node.status}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)) : <div className="border-t border-border py-3 text-sm text-slate-500">All known nodes are online.</div>}
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-md border border-border bg-panel p-4">
|
<section className="rounded-md border border-border bg-panel p-4">
|
||||||
<div className="mb-3 font-medium">Top Talkers</div>
|
<div className="mb-3 font-medium">Recent Cluster Sync</div>
|
||||||
{(data?.top_talkers ?? []).length ? (data?.top_talkers ?? []).map((item) => (
|
{(data?.last_syncs ?? []).length ? (data?.last_syncs ?? []).map((cluster) => (
|
||||||
<div key={item.name} className="flex justify-between border-t border-border py-3 text-sm">
|
<div key={cluster.id} className="flex justify-between gap-3 border-t border-border py-3 text-sm">
|
||||||
<span>{item.name}</span>
|
<div>
|
||||||
<span>{Math.round(item.bytes / 1_000_000)} MB</span>
|
<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>
|
||||||
)) : <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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import { api, Cluster, Policy } from "../api/client";
|
import { api, Cluster, Policy } from "../api/client";
|
||||||
import { buttonClass, Field, secondaryButtonClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||||
|
import { LoadingOverlay } from "../components/LoadingOverlay";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
export function FirewallPreview() {
|
export function FirewallPreview() {
|
||||||
@@ -28,16 +29,26 @@ export function FirewallPreview() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const selectedPolicyId = policyId || policies.data?.[0]?.id || "";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Firewall Preview" subtitle="Compile policies into provider-specific rules before any apply operation." />
|
<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]">
|
<div className="grid gap-4 lg:grid-cols-[360px_1fr]">
|
||||||
<section className="rounded-md border border-border bg-panel p-4">
|
<section className="rounded-md border border-border bg-panel p-4">
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Field label="Policy">
|
<Field label="Policy">
|
||||||
<select className={selectClass} value={selectedPolicyId} onChange={(event) => setPolicyId(event.target.value)}>
|
<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>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Cluster">
|
<Field label="Cluster">
|
||||||
@@ -49,13 +60,20 @@ export function FirewallPreview() {
|
|||||||
<input type="checkbox" checked={dryRun} onChange={(event) => setDryRun(event.target.checked)} />
|
<input type="checkbox" checked={dryRun} onChange={(event) => setDryRun(event.target.checked)} />
|
||||||
Dry run
|
Dry run
|
||||||
</label>
|
</label>
|
||||||
|
<div className="rounded-md border border-border bg-canvas p-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{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>
|
||||||
<button className={secondaryButtonClass} disabled={!selectedPolicyId} onClick={() => preview.mutate(selectedPolicyId)}>
|
<button className={secondaryButtonClass} disabled={!selectedPolicyId} onClick={() => preview.mutate(selectedPolicyId)}>
|
||||||
<Play size={18} />
|
<Play size={18} />
|
||||||
Generate Preview
|
Generate Preview
|
||||||
</button>
|
</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} />
|
<ShieldCheck size={18} />
|
||||||
Apply Confirmed
|
{dryRun ? "Run Dry Apply" : auditMode ? "Audit Mode Only" : "Apply Confirmed"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+108
-20
@@ -1,32 +1,42 @@
|
|||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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, IpAddress, Network, Subnet, token } from "../api/client";
|
import { api, authorizedFetch, IpAddress, Network, RuntimeSettings, Subnet } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
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 { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
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() {
|
export function Ipam() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const networks = useQuery({ queryKey: ["networks"], queryFn: () => api<Network[]>("/networks") });
|
const networks = useQuery({ queryKey: ["networks"], queryFn: () => api<Network[]>("/networks") });
|
||||||
const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api<Subnet[]>("/ipam/subnets") });
|
const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api<Subnet[]>("/ipam/subnets") });
|
||||||
const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api<IpAddress[]>("/ipam/addresses") });
|
const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api<IpAddress[]>("/ipam/addresses") });
|
||||||
|
const settings = useQuery({ queryKey: ["settings"], queryFn: () => api<RuntimeSettings>("/settings") });
|
||||||
const [message, setMessage] = useState("");
|
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 [ipForm, setIpForm] = useState({ subnet_id: "", address: "10.50.0.20", status: "reserved", note: "" });
|
||||||
const [subnetOpen, setSubnetOpen] = useState(false);
|
const [subnetOpen, setSubnetOpen] = useState(false);
|
||||||
const [ipOpen, setIpOpen] = useState(false);
|
const [ipOpen, setIpOpen] = useState(false);
|
||||||
|
const [busyMessage, setBusyMessage] = useState("");
|
||||||
|
const [editingSubnet, setEditingSubnet] = useState<Subnet | null>(null);
|
||||||
|
|
||||||
const createSubnet = useMutation({
|
const saveSubnet = useMutation({
|
||||||
mutationFn: () => api<Subnet>("/ipam/subnets", { method: "POST", body: JSON.stringify({ ...subnetForm, network_id: subnetForm.network_id || networks.data?.[0]?.id }) }),
|
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: () => {
|
onSuccess: () => {
|
||||||
setMessage("Subnet created.");
|
setMessage(editingSubnet ? "Subnet updated." : "Subnet created.");
|
||||||
setSubnetOpen(false);
|
setSubnetOpen(false);
|
||||||
|
setEditingSubnet(null);
|
||||||
queryClient.invalidateQueries({ queryKey: ["subnets"] });
|
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({
|
const createIp = useMutation({
|
||||||
mutationFn: () => api<IpAddress>("/ipam/addresses", { method: "POST", body: JSON.stringify({ ...ipForm, subnet_id: ipForm.subnet_id || subnets.data?.[0]?.id }) }),
|
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."),
|
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) {
|
async function submitSubnet(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await createSubnet.mutateAsync();
|
await saveSubnet.mutateAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitIp(event: FormEvent) {
|
async function submitIp(event: FormEvent) {
|
||||||
@@ -49,9 +66,9 @@ export function Ipam() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exportCsv() {
|
async function exportCsv() {
|
||||||
const response = await fetch("/api/v1/ipam/export.csv", {
|
setBusyMessage("Preparing IPAM export...");
|
||||||
headers: token() ? { Authorization: `Bearer ${token()}` } : {},
|
try {
|
||||||
});
|
const response = await authorizedFetch("/ipam/export.csv");
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
@@ -59,33 +76,70 @@ export function Ipam() {
|
|||||||
link.download = "nexafabric-ipam.csv";
|
link.download = "nexafabric-ipam.csv";
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
} finally {
|
||||||
|
setBusyMessage("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discoverIpam() {
|
async function discoverIpam() {
|
||||||
|
setBusyMessage("Discovering IP addresses from Proxmox...");
|
||||||
try {
|
try {
|
||||||
const result = await api<{ imported: number; errors: Array<Record<string, unknown>> }>("/ipam/discover", { method: "POST" });
|
const result = await api<{ imported: number; removed: number; errors: Array<Record<string, unknown>> }>("/ipam/discover", { method: "POST" });
|
||||||
setMessage(`Discovery imported ${result.imported} IP addresses${result.errors.length ? " with errors" : ""}.`);
|
setMessage(`Discovery imported ${result.imported} IP addresses and removed ${result.removed} container bridge entries${result.errors.length ? " with errors" : ""}.`);
|
||||||
await queryClient.invalidateQueries({ queryKey: ["subnets"] });
|
await queryClient.invalidateQueries({ queryKey: ["subnets"] });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
await queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMessage(error instanceof Error ? error.message : "IPAM discovery failed.");
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="IPAM" subtitle="Manage subnets, reservations, assignments, conflicts, and export state." />
|
<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="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">
|
<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={buttonClass} onClick={() => setIpOpen(true)}><Plus size={16} /> Reserve IP</button>
|
||||||
<button className={secondaryButtonClass} onClick={discoverIpam}>Discover from Proxmox</button>
|
<button className={secondaryButtonClass} onClick={discoverIpam}>Discover from Proxmox</button>
|
||||||
<button className={secondaryButtonClass} onClick={exportCsv}><Download size={16} /> Export CSV</button>
|
<button className={secondaryButtonClass} onClick={exportCsv}><Download size={16} /> Export CSV</button>
|
||||||
</div>
|
</div>
|
||||||
{message ? <div className="rounded-md border border-border bg-panel p-3 text-sm">{message}</div> : null}
|
{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}>
|
<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">
|
<div className="grid gap-3">
|
||||||
<Field label="Network">
|
<Field label="Network">
|
||||||
<select className={selectClass} value={subnetForm.network_id} onChange={(event) => setSubnetForm({ ...subnetForm, network_id: event.target.value })}>
|
<select className={selectClass} value={subnetForm.network_id} onChange={(event) => setSubnetForm({ ...subnetForm, network_id: event.target.value })}>
|
||||||
@@ -95,7 +149,12 @@ export function Ipam() {
|
|||||||
</Field>
|
</Field>
|
||||||
<Field label="CIDR"><input className={inputClass} value={subnetForm.cidr} onChange={(event) => setSubnetForm({ ...subnetForm, cidr: event.target.value })} /></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>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -121,8 +180,37 @@ export function Ipam() {
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
<section className="space-y-4">
|
<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
|
||||||
<DataTable rows={(addresses.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "address", label: "Address" }, { key: "status", label: "Status" }, { key: "note", label: "Note" }]} />
|
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={[
|
||||||
|
{ key: "address", label: "Address" },
|
||||||
|
{ key: "subnet_cidr", label: "Subnet" },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "workload_name", label: "VM/LXC" },
|
||||||
|
{ key: "workload_external_id", label: "VMID" },
|
||||||
|
{ key: "note", label: "Note" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Moon, ShieldCheck, Sun } from "lucide-react";
|
import { Activity, LockKeyhole, Moon, Network, ShieldCheck, Sun } from "lucide-react";
|
||||||
|
|
||||||
import { login } from "../api/client";
|
import { login } from "../api/client";
|
||||||
import { useTheme } from "../stores/theme";
|
import { useTheme } from "../stores/theme";
|
||||||
|
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [email, setEmail] = useState("admin@nexafabric.local");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("ChangeMe_UseEnvInstead");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const { dark, toggle } = useTheme();
|
const { dark, toggle } = useTheme();
|
||||||
|
|
||||||
@@ -24,36 +24,93 @@ export function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen place-items-center bg-canvas px-4">
|
<div className="min-h-screen bg-canvas text-slate-900 dark:text-slate-100">
|
||||||
<button
|
<button
|
||||||
className="fixed right-4 top-4 rounded-md border border-border bg-panel p-2 text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
className="fixed right-4 top-4 z-10 rounded-md border border-border bg-panel p-2 text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Toggle theme"
|
aria-label="Toggle theme"
|
||||||
>
|
>
|
||||||
{dark ? <Sun size={18} /> : <Moon size={18} />}
|
{dark ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
</button>
|
</button>
|
||||||
<form onSubmit={submit} className="w-full max-w-sm rounded-md border border-border bg-panel p-6 shadow-sm">
|
<div className="grid min-h-screen lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
<div className="mb-6 flex items-center gap-3">
|
<section className="relative hidden overflow-hidden border-r border-border bg-panel lg:block">
|
||||||
<div className="rounded-md bg-accent p-2 text-white">
|
<div className="absolute inset-0 opacity-40">
|
||||||
<ShieldCheck size={22} />
|
<div className="absolute left-16 top-24 h-48 w-48 rounded-full border border-accent/40" />
|
||||||
|
<div className="absolute right-20 top-48 h-72 w-72 rounded-full border border-slate-400/30" />
|
||||||
|
<div className="absolute bottom-24 left-1/3 h-56 w-56 rounded-full border border-accent/30" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex h-full flex-col justify-between p-12">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-md bg-accent p-3 text-white">
|
||||||
|
<ShieldCheck size={26} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">NexaFabric</h1>
|
<div className="text-2xl font-semibold">NexaFabric</div>
|
||||||
<p className="text-sm text-slate-500">Sign in to the control plane</p>
|
<div className="text-sm text-slate-500 dark:text-slate-400">Network and security control plane</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="max-w-xl animate-[slideUp_520ms_ease-out]">
|
||||||
|
<h1 className="mb-4 text-5xl font-semibold leading-tight tracking-normal">
|
||||||
|
Operate Proxmox networks with intent.
|
||||||
|
</h1>
|
||||||
|
<p className="text-base leading-7 text-slate-500 dark:text-slate-400">
|
||||||
|
Centralize inventory, IPAM, segmentation policy, firewall preview, and audit history without patching Proxmox.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 grid gap-3">
|
||||||
|
{[
|
||||||
|
["Inventory sync", Network],
|
||||||
|
["Policy audit mode", Activity],
|
||||||
|
["Preview before apply", LockKeyhole],
|
||||||
|
].map(([label, Icon]) => {
|
||||||
|
const LucideIcon = Icon as typeof Network;
|
||||||
|
return (
|
||||||
|
<div key={String(label)} className="flex items-center gap-3 rounded-md border border-border bg-canvas/70 p-3 text-sm">
|
||||||
|
<LucideIcon size={18} className="text-accent" />
|
||||||
|
<span>{String(label)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">Read first. Simulate second. Apply only after confirmation.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="grid place-items-center px-4 py-12">
|
||||||
|
<form onSubmit={submit} className="w-full max-w-md animate-[fadeIn_420ms_ease-out] rounded-md border border-border bg-panel p-7 shadow-sm">
|
||||||
|
<div className="mb-7">
|
||||||
|
<div className="mb-4 inline-flex rounded-md bg-accent p-3 text-white lg:hidden">
|
||||||
|
<ShieldCheck size={24} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold">Sign in</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">Use the administrator account created during setup.</p>
|
||||||
|
</div>
|
||||||
<label className="mb-4 block text-sm">
|
<label className="mb-4 block text-sm">
|
||||||
Email
|
Email
|
||||||
<input className="mt-1 w-full rounded-md border border-border bg-transparent px-3 py-2" value={email} onChange={(event) => setEmail(event.target.value)} />
|
<input
|
||||||
|
className="mt-1 h-11 w-full rounded-md border border-border bg-transparent px-3 outline-none focus:border-accent"
|
||||||
|
value={email}
|
||||||
|
autoComplete="username"
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="mb-4 block text-sm">
|
<label className="mb-5 block text-sm">
|
||||||
Password
|
Password
|
||||||
<input className="mt-1 w-full rounded-md border border-border bg-transparent px-3 py-2" type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
<input
|
||||||
|
className="mt-1 h-11 w-full rounded-md border border-border bg-transparent px-3 outline-none focus:border-accent"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
autoComplete="current-password"
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
{error ? <div className="mb-4 rounded-md border border-danger px-3 py-2 text-sm text-danger">{error}</div> : null}
|
{error ? <div className="mb-4 rounded-md border border-danger px-3 py-2 text-sm text-danger">{error}</div> : null}
|
||||||
<button className="h-10 w-full rounded-md bg-accent text-sm font-medium text-white">Sign In</button>
|
<button className="h-11 w-full rounded-md bg-accent text-sm font-medium text-white disabled:opacity-50" disabled={!email || !password}>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Activity, Cpu, Copy, RadioTower, RefreshCcw, ScrollText } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
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`),
|
||||||
|
onSuccess: (_data, node) => {
|
||||||
|
setSelectedNode(node);
|
||||||
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(installInfo.data.command);
|
||||||
|
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 ? (
|
||||||
|
<DataTable
|
||||||
|
rows={(nodes.data ?? []) as unknown as Record<string, unknown>[]}
|
||||||
|
columns={[
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "cpu_count", label: "CPU" },
|
||||||
|
{ key: "memory_mb", label: "Memory MB" },
|
||||||
|
{
|
||||||
|
key: "agent",
|
||||||
|
label: "Agent",
|
||||||
|
render: (row) => {
|
||||||
|
const node = row as unknown as Node;
|
||||||
|
return node.agent ? `${node.agent.status}${node.agent.version ? ` · ${node.agent.version}` : ""}` : "not_installed";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Actions",
|
||||||
|
render: (row) => {
|
||||||
|
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"
|
||||||
|
aria-label={`Install agent on ${node.name}`}
|
||||||
|
onClick={() => installInfo.mutate(node)}
|
||||||
|
>
|
||||||
|
<RadioTower size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Modal title="Install Node Agent" open={Boolean(selectedNode)} onClose={() => setSelectedNode(null)}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 font-medium">
|
||||||
|
<Cpu size={18} />
|
||||||
|
{selectedNode?.name}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-canvas p-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Run this command as root on the Proxmox node. It installs the agent under <code>/opt/nexafabric-agent</code> and starts a systemd service.
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-48 overflow-auto rounded-md border border-border bg-canvas p-3 text-xs">
|
||||||
|
{installInfo.data?.command ?? "Generating installer..."}
|
||||||
|
</pre>
|
||||||
|
<div className="grid gap-2 text-xs">
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">Installer link</span>
|
||||||
|
<code className="break-all rounded-md border border-border bg-canvas p-3">{installInfo.data?.install_url ?? ""}</code>
|
||||||
|
</div>
|
||||||
|
<button className={secondaryButtonClass} disabled={!installInfo.data} onClick={copyCommand}>
|
||||||
|
<Copy size={16} />
|
||||||
|
{copied ? "Copied" : "Copy Command"}
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+206
-32
@@ -1,21 +1,101 @@
|
|||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { GitBranch, Play, Plus } 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 { DataTable } from "../components/DataTable";
|
||||||
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
|
||||||
|
import { LoadingOverlay } from "../components/LoadingOverlay";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
export function Policies() {
|
function policyValue(policy: Policy, key: string) {
|
||||||
const queryClient = useQueryClient();
|
return String(policy.definition?.[key] ?? "");
|
||||||
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") });
|
}
|
||||||
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
|
|
||||||
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
|
function endpointLabel(value: string, workloads: Workload[]) {
|
||||||
const [preview, setPreview] = useState("");
|
if (value.startsWith("workload:")) {
|
||||||
const [open, setOpen] = useState(false);
|
const workloadId = value.replace("workload:", "");
|
||||||
const [form, setForm] = useState({
|
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") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const value = service as { protocol?: unknown; ports?: unknown };
|
||||||
|
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: "",
|
project_id: "",
|
||||||
name: "Web to DB",
|
name: "Web to DB",
|
||||||
source: "sg:Web Tier",
|
source: "sg:Web Tier",
|
||||||
@@ -28,13 +108,25 @@ export function Policies() {
|
|||||||
enforcement_mode: "enforced",
|
enforcement_mode: "enforced",
|
||||||
logging: true,
|
logging: true,
|
||||||
description: "Allow application database traffic",
|
description: "Allow application database traffic",
|
||||||
});
|
};
|
||||||
|
|
||||||
const create = useMutation({
|
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: () => {
|
mutationFn: () => {
|
||||||
const service = services.data?.find((item) => item.id === form.service_id);
|
const service = services.data?.find((item) => item.id === form.service_id);
|
||||||
return api<Policy>("/policies", {
|
return api<Policy>(editing ? `/policies/${editing.id}` : "/policies", {
|
||||||
method: "POST",
|
method: editing ? "PATCH" : "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
project_id: form.project_id || null,
|
project_id: form.project_id || null,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
@@ -54,44 +146,108 @@ export function Policies() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
setEditing(null);
|
||||||
queryClient.invalidateQueries({ queryKey: ["policies"] });
|
queryClient.invalidateQueries({ queryKey: ["policies"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (policy: Policy) => api(`/policies/${policy.id}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["policies"] }),
|
||||||
|
});
|
||||||
|
|
||||||
async function submit(event: FormEvent) {
|
async function submit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await create.mutateAsync();
|
await save.mutateAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function compile(policy: Policy) {
|
async function compile(policy: Policy) {
|
||||||
|
setBusyMessage(`Compiling ${policy.name}...`);
|
||||||
|
try {
|
||||||
const data = await api<Policy>(`/policies/${policy.id}/compile`, { method: "POST" });
|
const data = await api<Policy>(`/policies/${policy.id}/compile`, { method: "POST" });
|
||||||
setPreview(JSON.stringify(data.last_compiled, null, 2));
|
setPreview(JSON.stringify(data.last_compiled, null, 2));
|
||||||
await queryClient.invalidateQueries({ queryKey: ["policies"] });
|
await queryClient.invalidateQueries({ queryKey: ["policies"] });
|
||||||
|
} finally {
|
||||||
|
setBusyMessage("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function firewallPreview(policy: Policy) {
|
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" });
|
const data = await api<Record<string, unknown>>(`/firewall/preview/${policy.id}`, { method: "POST" });
|
||||||
setPreview(JSON.stringify(data, null, 2));
|
setPreview(JSON.stringify(data, null, 2));
|
||||||
|
} finally {
|
||||||
|
setBusyMessage("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPolicy() {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(defaultPolicyForm);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editPolicy(policy: Policy) {
|
||||||
|
const service = policy.definition?.service as { protocol?: string; ports?: string } | undefined;
|
||||||
|
setEditing(policy);
|
||||||
|
setForm({
|
||||||
|
project_id: policy.project_id ?? "",
|
||||||
|
name: policy.name,
|
||||||
|
source: policyValue(policy, "source") || "any",
|
||||||
|
destination: policyValue(policy, "destination") || "any",
|
||||||
|
service_id: "",
|
||||||
|
protocol: service?.protocol ?? "tcp",
|
||||||
|
ports: service?.ports ?? "",
|
||||||
|
action: policyValue(policy, "action") || "allow",
|
||||||
|
direction: policyValue(policy, "direction") || "ingress",
|
||||||
|
enforcement_mode: policy.enforcement_mode || "enforced",
|
||||||
|
logging: Boolean(policy.definition?.logging),
|
||||||
|
description: policyValue(policy, "description"),
|
||||||
|
});
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseService(serviceId: string) {
|
||||||
|
const service = services.data?.find((item) => item.id === serviceId);
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
service_id: serviceId,
|
||||||
|
protocol: service?.protocol ?? form.protocol,
|
||||||
|
ports: service?.ports ?? form.ports,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePolicy(policy: Policy) {
|
||||||
|
if (window.confirm(`Delete policy "${policy.name}"?`)) {
|
||||||
|
remove.mutate(policy);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Policies" subtitle="Create versioned microsegmentation policies and compile firewall previews." />
|
<PageHeader title="Policies" subtitle="Create versioned microsegmentation policies and compile firewall previews." />
|
||||||
|
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button className={buttonClass} onClick={() => setOpen(true)}><Plus size={16} /> Add Policy</button>
|
<button className={buttonClass} onClick={addPolicy}><Plus size={16} /> Add Policy</button>
|
||||||
<Modal title="Add Policy" open={open} onClose={() => setOpen(false)}>
|
<Modal title={editing ? "Edit Policy" : "Add Policy"} open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<div className="mb-4 flex items-center gap-2 font-medium"><GitBranch size={18} /> Add Policy</div>
|
<div className="mb-4 flex items-center gap-2 font-medium"><GitBranch size={18} /> {editing ? "Edit Policy" : "Add Policy"}</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<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="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>
|
<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">
|
<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="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 })} /></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>
|
</div>
|
||||||
<Field label="Service"><select className={selectClass} value={form.service_id} onChange={(event) => setForm({ ...form, service_id: 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>
|
<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">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Field label="Protocol"><input className={inputClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })} /></Field>
|
<Field label="Protocol">
|
||||||
|
<select className={selectClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })}>
|
||||||
|
<option value="tcp">tcp</option>
|
||||||
|
<option value="udp">udp</option>
|
||||||
|
<option value="tcp/udp">tcp & udp</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
<Field label="Ports"><input className={inputClass} value={form.ports} onChange={(event) => setForm({ ...form, ports: event.target.value })} /></Field>
|
<Field label="Ports"><input className={inputClass} value={form.ports} onChange={(event) => setForm({ ...form, ports: event.target.value })} /></Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -105,20 +261,38 @@ export function Policies() {
|
|||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Description"><input className={inputClass} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
|
<Field label="Description"><input className={inputClass} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
|
||||||
<button className={buttonClass}><Plus size={16} /> Save Policy</button>
|
<button className={buttonClass}><Plus size={16} /> {editing ? "Update Policy" : "Save Policy"}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<DataTable rows={(policies.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Policy" }, { key: "version", label: "Version" }, { key: "enabled", label: "Enabled" }]} />
|
<DataTable
|
||||||
<div className="flex flex-wrap gap-2">
|
rows={(policies.data ?? []) as unknown as Record<string, unknown>[]}
|
||||||
{(policies.data ?? []).map((policy) => (
|
columns={[
|
||||||
<div key={policy.id} className="flex gap-2">
|
{ key: "name", label: "Policy" },
|
||||||
<button className={secondaryButtonClass} onClick={() => compile(policy)}><Play size={16} /> Compile {policy.name}</button>
|
{ key: "source", label: "Source", render: (row) => policyEndpoint(row as unknown as Policy, "source", workloads.data ?? []) },
|
||||||
<button className={secondaryButtonClass} onClick={() => firewallPreview(policy)}><Play size={16} /> Preview</button>
|
{ key: "destination", label: "Destination", render: (row) => policyEndpoint(row as unknown as Policy, "destination", workloads.data ?? []) },
|
||||||
</div>
|
{ 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",
|
||||||
|
label: "Actions",
|
||||||
|
render: (row) => {
|
||||||
|
const policy = row as unknown as Policy;
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button className={iconButtonClass} title="Compile policy" aria-label={`Compile ${policy.name}`} onClick={() => compile(policy)}><Play size={16} /></button>
|
||||||
|
<button className={iconButtonClass} title="Preview policy" aria-label={`Preview ${policy.name}`} onClick={() => firewallPreview(policy)}><Eye size={16} /></button>
|
||||||
|
<button className={iconButtonClass} title="Edit policy" aria-label={`Edit ${policy.name}`} onClick={() => editPolicy(policy)}><Pencil size={16} /></button>
|
||||||
|
<button className={iconButtonClass} title="Delete policy" aria-label={`Delete ${policy.name}`} disabled={remove.isPending} onClick={() => deletePolicy(policy)}><Trash2 size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<pre className="min-h-40 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{preview || "No policy output yet."}</pre>
|
<pre className="min-h-40 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{preview || "No policy output yet."}</pre>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,12 +5,23 @@ import { Save, Wand2 } from "lucide-react";
|
|||||||
import { api, Network, Policy, SecurityGroup, ServiceCatalogItem, Workload } from "../api/client";
|
import { api, Network, Policy, SecurityGroup, ServiceCatalogItem, Workload } from "../api/client";
|
||||||
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { SearchableSelect } from "../components/SearchableSelect";
|
||||||
|
|
||||||
type TargetOption = {
|
type TargetOption = {
|
||||||
label: string;
|
label: string;
|
||||||
value: 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() {
|
export function PolicyDesigner() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
|
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
|
||||||
@@ -19,7 +30,7 @@ export function PolicyDesigner() {
|
|||||||
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
|
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
|
||||||
const [preview, setPreview] = useState<Record<string, unknown> | null>(null);
|
const [preview, setPreview] = useState<Record<string, unknown> | null>(null);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: "Designed Policy",
|
name: "",
|
||||||
source: "any",
|
source: "any",
|
||||||
destination: "any",
|
destination: "any",
|
||||||
service_id: "",
|
service_id: "",
|
||||||
@@ -35,22 +46,26 @@ export function PolicyDesigner() {
|
|||||||
const targets = useMemo<TargetOption[]>(() => {
|
const targets = useMemo<TargetOption[]>(() => {
|
||||||
return [
|
return [
|
||||||
{ label: "Any", value: "any" },
|
{ label: "Any", value: "any" },
|
||||||
|
{ label: "Custom IP/CIDR", value: customTargetValue },
|
||||||
...(workloads.data ?? []).map((workload) => ({ label: `VM/LXC: ${workload.name}`, value: `workload:${workload.id}` })),
|
...(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 ?? []).map((network) => ({ label: `Network: ${network.name}`, value: `network:${network.name}` })),
|
||||||
];
|
];
|
||||||
}, [networks.data, securityGroups.data, workloads.data]);
|
}, [networks.data, securityGroups.data, workloads.data]);
|
||||||
|
|
||||||
function payload() {
|
function payload() {
|
||||||
const service = services.data?.find((item) => item.id === form.service_id);
|
|
||||||
return {
|
return {
|
||||||
project_id: null,
|
project_id: null,
|
||||||
name: form.name,
|
name: form.name.trim(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
definition: {
|
definition: {
|
||||||
source: form.source,
|
source: form.source,
|
||||||
destination: form.destination,
|
destination: form.destination,
|
||||||
service: { protocol: service?.protocol ?? form.protocol, ports: service?.ports ?? form.ports },
|
service: { protocol: form.protocol, ports: form.ports },
|
||||||
action: form.action,
|
action: form.action,
|
||||||
direction: form.direction,
|
direction: form.direction,
|
||||||
enforcement_mode: form.enforcement_mode,
|
enforcement_mode: form.enforcement_mode,
|
||||||
@@ -89,24 +104,68 @@ export function PolicyDesigner() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseService(serviceId: string) {
|
||||||
|
const service = services.data?.find((item) => item.id === serviceId);
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
service_id: serviceId,
|
||||||
|
protocol: service?.protocol ?? form.protocol,
|
||||||
|
ports: service?.ports ?? form.ports,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Policy Designer" subtitle="Build tenant-aware microsegmentation policies and inspect their intended impact." />
|
<PageHeader title="Policy Designer" subtitle="Build tenant-aware microsegmentation policies and inspect their intended impact." />
|
||||||
<div className="grid gap-4 lg:grid-cols-[1fr_420px]">
|
<div className="grid gap-4 lg:grid-cols-[1fr_420px]">
|
||||||
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
|
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Field label="Policy Name">
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||||
|
placeholder="DNS from VMs to resolver"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div />
|
||||||
<Field label="Source">
|
<Field label="Source">
|
||||||
<select className={selectClass} value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}>
|
<SearchableSelect
|
||||||
{targets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)}
|
options={targets}
|
||||||
</select>
|
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>
|
||||||
<Field label="Destination">
|
<Field label="Destination">
|
||||||
<select className={selectClass} value={form.destination} onChange={(event) => setForm({ ...form, destination: event.target.value })}>
|
<SearchableSelect
|
||||||
{targets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)}
|
options={targets}
|
||||||
</select>
|
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>
|
||||||
<Field label="Service">
|
<Field label="Service">
|
||||||
<select className={selectClass} value={form.service_id} onChange={(event) => setForm({ ...form, service_id: event.target.value })}>
|
<select className={selectClass} value={form.service_id} onChange={(event) => chooseService(event.target.value)}>
|
||||||
<option value="">Custom</option>
|
<option value="">Custom</option>
|
||||||
{(services.data ?? []).map((service) => <option key={service.id} value={service.id}>{service.name} {service.ports}</option>)}
|
{(services.data ?? []).map((service) => <option key={service.id} value={service.id}>{service.name} {service.ports}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -118,7 +177,13 @@ export function PolicyDesigner() {
|
|||||||
<option>reject</option>
|
<option>reject</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Protocol"><input className={inputClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })} /></Field>
|
<Field label="Protocol">
|
||||||
|
<select className={selectClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })}>
|
||||||
|
<option value="tcp">tcp</option>
|
||||||
|
<option value="udp">udp</option>
|
||||||
|
<option value="tcp/udp">tcp & udp</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
<Field label="Ports"><input className={inputClass} value={form.ports} onChange={(event) => setForm({ ...form, ports: event.target.value })} /></Field>
|
<Field label="Ports"><input className={inputClass} value={form.ports} onChange={(event) => setForm({ ...form, ports: event.target.value })} /></Field>
|
||||||
<Field label="Direction">
|
<Field label="Direction">
|
||||||
<select className={selectClass} value={form.direction} onChange={(event) => setForm({ ...form, direction: event.target.value })}>
|
<select className={selectClass} value={form.direction} onChange={(event) => setForm({ ...form, direction: event.target.value })}>
|
||||||
@@ -145,7 +210,7 @@ export function PolicyDesigner() {
|
|||||||
<Wand2 size={18} />
|
<Wand2 size={18} />
|
||||||
Dry Run
|
Dry Run
|
||||||
</button>
|
</button>
|
||||||
<button className={buttonClass}>
|
<button className={buttonClass} disabled={!form.name.trim() || save.isPending}>
|
||||||
<Save size={18} />
|
<Save size={18} />
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
import { FormEvent, useMemo, useState } from "react";
|
import { FormEvent, useMemo, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { 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 { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
import { SearchableSelect, SearchableOption } from "../components/SearchableSelect";
|
||||||
|
|
||||||
export function SecurityGroups() {
|
export function SecurityGroups() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
|
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
|
||||||
const groups = useQuery({ queryKey: ["security-groups"], queryFn: () => api<SecurityGroup[]>("/security-groups") });
|
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 [selectedGroupId, setSelectedGroupId] = useState("");
|
||||||
const selectedGroup = useMemo(() => selectedGroupId || groups.data?.[0]?.id || "", [groups.data, selectedGroupId]);
|
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({
|
const rules = useQuery({
|
||||||
queryKey: ["security-rules", selectedGroup],
|
queryKey: ["security-rules", selectedGroup],
|
||||||
queryFn: () => api<SecurityRule[]>(`/security-groups/${selectedGroup}/rules`),
|
queryFn: () => api<SecurityRule[]>(`/security-groups/${selectedGroup}/rules`),
|
||||||
@@ -33,6 +46,8 @@ export function SecurityGroups() {
|
|||||||
});
|
});
|
||||||
const [groupOpen, setGroupOpen] = useState(false);
|
const [groupOpen, setGroupOpen] = useState(false);
|
||||||
const [ruleOpen, setRuleOpen] = useState(false);
|
const [ruleOpen, setRuleOpen] = useState(false);
|
||||||
|
const [memberOpen, setMemberOpen] = useState(false);
|
||||||
|
const [memberWorkloadId, setMemberWorkloadId] = useState("");
|
||||||
|
|
||||||
const createGroup = useMutation({
|
const createGroup = useMutation({
|
||||||
mutationFn: () => api<SecurityGroup>("/security-groups", { method: "POST", body: JSON.stringify({ ...groupForm, project_id: groupForm.project_id || null }) }),
|
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] });
|
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) {
|
async function submitGroup(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -67,6 +94,7 @@ export function SecurityGroups() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button className={buttonClass} onClick={() => setGroupOpen(true)}><Plus size={16} /> Add Group</button>
|
<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={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>
|
</div>
|
||||||
<Modal title="Add Security Group" open={groupOpen} onClose={() => setGroupOpen(false)}>
|
<Modal title="Add Security Group" open={groupOpen} onClose={() => setGroupOpen(false)}>
|
||||||
<form onSubmit={submitGroup}>
|
<form onSubmit={submitGroup}>
|
||||||
@@ -84,6 +112,26 @@ export function SecurityGroups() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</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)}>
|
<Modal title="Add Security Rule" open={ruleOpen} onClose={() => setRuleOpen(false)}>
|
||||||
<form onSubmit={submitRule}>
|
<form onSubmit={submitRule}>
|
||||||
<div className="mb-4 font-medium">Add Rule</div>
|
<div className="mb-4 font-medium">Add Rule</div>
|
||||||
@@ -108,7 +156,66 @@ export function SecurityGroups() {
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
<section className="space-y-4">
|
<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" }]} />
|
<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>
|
</section>
|
||||||
</div>
|
</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 { useQuery } from "@tanstack/react-query";
|
||||||
import { Activity } 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 { api, Workload, WorkloadInsight } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
|
import { inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
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() {
|
export function Workloads() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
|
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
|
||||||
const [selectedId, setSelectedId] = useState("");
|
const [selectedId, setSelectedId] = useState("");
|
||||||
const selected = selectedId || workloads.data?.[0]?.id || "";
|
const selected = selectedId || workloads.data?.[0]?.id || "";
|
||||||
const insight = useQuery({
|
const insight = useQuery({
|
||||||
queryKey: ["workload-insight", selected],
|
queryKey: ["workload-insight", selected, "summary"],
|
||||||
queryFn: () => api<WorkloadInsight>(`/vms/${selected}/insights`),
|
queryFn: () => api<WorkloadInsight>(`/vms/${selected}/insights?traffic=summary&include_rules=false&include_flow_context=false`),
|
||||||
enabled: Boolean(selected),
|
enabled: Boolean(selected),
|
||||||
});
|
});
|
||||||
|
const traffic = useMemo(() => summarizeTraffic(insight.data?.traffic ?? []), [insight.data?.traffic]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="VMs/LXCs" subtitle="Inspect workloads, observed traffic, and matching policy decisions." />
|
<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">
|
<section className="space-y-3">
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={(workloads.data ?? []) as unknown as Record<string, unknown>[]}
|
rows={(workloads.data ?? []) as unknown as Record<string, unknown>[]}
|
||||||
@@ -28,43 +489,63 @@ export function Workloads() {
|
|||||||
onRowClick={(row) => setSelectedId(String(row.id))}
|
onRowClick={(row) => setSelectedId(String(row.id))}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<aside className="rounded-md border border-border bg-panel p-4">
|
<aside className="space-y-3 rounded-md border border-border bg-panel p-3">
|
||||||
<div className="mb-4 flex items-center gap-2 font-medium"><Activity size={18} /> Workload Detail</div>
|
<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 ? (
|
{insight.data ? (
|
||||||
<div className="space-y-4 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div>
|
<header>
|
||||||
<div className="text-lg font-semibold">{insight.data.workload.name}</div>
|
<div className="font-semibold">{insight.data.workload.name}</div>
|
||||||
<div className="text-slate-500">{insight.data.workload.kind} · {insight.data.workload.status} · decision {insight.data.effective_decision}</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>
|
</div>
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-2 font-medium">Traffic</div>
|
<div className="mb-1.5 flex items-center gap-2 text-sm font-medium"><Network size={15} /> Assigned IPs</div>
|
||||||
<div className="space-y-2">
|
{insight.data.assigned_ips.length ? (
|
||||||
{insight.data.traffic.length ? insight.data.traffic.map((flow, index) => (
|
<div className="flex flex-wrap gap-2">
|
||||||
<div key={index} className="rounded-md border border-border p-3">
|
{insight.data.assigned_ips.slice(0, 4).map((ip) => (
|
||||||
<div>{String(flow.source)} → {String(flow.destination)}</div>
|
<div key={ip.id} className="rounded-md border border-border px-2 py-1 text-xs">
|
||||||
<div className="text-xs text-slate-500">{String(flow.protocol)}:{String(flow.port)} · {String(flow.bytes)} bytes · {String(flow.decision)}</div>
|
<span className="font-medium">{ip.address}</span>
|
||||||
{Array.isArray(flow.ip_addresses) ? <div className="mt-1 text-xs text-slate-500">IPs: {flow.ip_addresses.join(", ")}</div> : null}
|
<span className="ml-2 text-slate-500">{ip.subnet_cidr ?? "unknown subnet"}</span>
|
||||||
</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>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<div className="mb-2 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">
|
|
||||||
<div>{policy.name}</div>
|
|
||||||
<div className="text-xs text-slate-500">v{policy.version} · {policy.enforcement_mode}</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
{insight.data.audit_mode_notes.length ? (
|
|
||||||
<section>
|
<section>
|
||||||
<div className="mb-2 font-medium">Audit Mode</div>
|
<div className="mb-1.5 text-sm font-medium">Top Traffic</div>
|
||||||
{insight.data.audit_mode_notes.map((note) => <div key={note} className="rounded-md border border-border p-3 text-xs">{note}</div>)}
|
<TrafficBars traffic={traffic} />
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div className="mb-1.5 text-sm font-medium">Top Flows</div>
|
||||||
|
<CompactFlowList traffic={traffic} />
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<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>
|
</section>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-slate-500">Select a workload.</div>
|
<div className="text-sm text-slate-500">Select a workload.</div>
|
||||||
@@ -74,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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+15
-3
@@ -1,12 +1,25 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
client_max_body_size 16m;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
client_max_body_size 16m;
|
||||||
proxy_pass http://api:8000/api/;
|
proxy_pass http://api:8000/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +29,6 @@ server {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend:80;
|
proxy_pass http://frontend:80;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $http_host;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user