diff --git a/backend/app/agent_assets/nexafabric-agent.py b/backend/app/agent_assets/nexafabric-agent.py index deaceaa..203528c 100644 --- a/backend/app/agent_assets/nexafabric-agent.py +++ b/backend/app/agent_assets/nexafabric-agent.py @@ -18,7 +18,7 @@ from pathlib import Path from typing import Any -VERSION = "0.2.1" +VERSION = "0.2.2" 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]+)") @@ -362,18 +362,39 @@ def parse_firewall_log_line(line: str) -> dict[str, Any] | None: } +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", "since_minutes": since_minutes, "errors": []} + 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, ) - if code != 0 or not output: + 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() + 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 output.splitlines(): + for line in log_lines: if "SRC=" not in line or "DST=" not in line: continue flow = parse_firewall_log_line(line) @@ -418,7 +439,16 @@ def merge_flow_sources(*sources: list[dict[str, Any]], limit: int = 500) -> list current["bytes"] = int(current.get("bytes") or 0) + int(flow.get("bytes") or 0) continue flows[key] = dict(flow) - return sorted(flows.values(), key=lambda item: int(item.get("bytes") or 0), reverse=True)[:limit] + 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_conntrack() -> dict[str, Any]: @@ -470,7 +500,7 @@ def collect_firewall() -> dict[str, Any]: def collect_payload(config: dict[str, Any]) -> dict[str, Any]: uptime = read_text("/proc/uptime") interfaces = collect_interfaces() - flow_limit = int(config.get("flow_limit", 500)) + flow_limit = int(config.get("flow_limit", 2000)) packet_flows: list[dict[str, Any]] = [] packet_diagnostics: dict[str, Any] | None = None firewall_log_flows: list[dict[str, Any]] = [] diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index c671dbf..df5226c 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -107,7 +107,16 @@ def setup_setting(db: Session) -> SystemSetting: def runtime_setting(db: Session) -> SystemSetting: setting = db.get(SystemSetting, "runtime") if not setting: - setting = SystemSetting(key="runtime", value={"flow_retention_hours": 24}) + setting = SystemSetting( + key="runtime", + value={ + "flow_retention_hours": 24, + "auto_node_sync_enabled": False, + "auto_node_sync_interval_minutes": 60, + "auto_ipam_sync_enabled": False, + "auto_ipam_sync_interval_minutes": 60, + }, + ) db.add(setting) db.commit() db.refresh(setting) @@ -116,7 +125,15 @@ def runtime_setting(db: Session) -> SystemSetting: def runtime_settings_payload(db: Session) -> RuntimeSettingsRead: value = runtime_setting(db).value or {} - return RuntimeSettingsRead(flow_retention_hours=int(value.get("flow_retention_hours") or 24)) + return RuntimeSettingsRead( + flow_retention_hours=int(value.get("flow_retention_hours") or 24), + auto_node_sync_enabled=bool(value.get("auto_node_sync_enabled", False)), + auto_node_sync_interval_minutes=int(value.get("auto_node_sync_interval_minutes") or 60), + auto_ipam_sync_enabled=bool(value.get("auto_ipam_sync_enabled", False)), + auto_ipam_sync_interval_minutes=int(value.get("auto_ipam_sync_interval_minutes") or 60), + last_node_auto_sync_at=datetime.fromisoformat(value["last_node_auto_sync_at"]) if value.get("last_node_auto_sync_at") else None, + last_ipam_auto_sync_at=datetime.fromisoformat(value["last_ipam_auto_sync_at"]) if value.get("last_ipam_auto_sync_at") else None, + ) def require_super_admin(user: User) -> None: @@ -1313,7 +1330,7 @@ cat > "$CONFIG_DIR/config.json" <<'JSON' "node_id": "{node.id}", "node_name": "{node.name}", "interval_seconds": 30, - "flow_limit": 500, + "flow_limit": 2000, "packet_flow_collector": true, "packet_flow_window_seconds": 10, "firewall_log_collector": true, @@ -1448,7 +1465,7 @@ def agent_heartbeat(payload: AgentHeartbeat, authorization: str | None = Header( ): flow for flow in db.scalars(select(TrafficFlow).where(TrafficFlow.node_id == node.id)).all() } - for raw_flow in payload.flows[:1000]: + for raw_flow in payload.flows[:5000]: source_ip = str(raw_flow.get("source_ip") or "") destination_ip = str(raw_flow.get("destination_ip") or "") if not source_ip or not destination_ip: @@ -1517,7 +1534,7 @@ async def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depe select(TrafficFlow) .where((TrafficFlow.source_ip.in_(workload_ips)) | (TrafficFlow.destination_ip.in_(workload_ips))) .order_by((TrafficFlow.state == "blocked").desc(), TrafficFlow.updated_at.desc()) - .limit(50) + .limit(1000) ).all() for flow in flows: source_owner = ip_owners.get(flow.source_ip) @@ -2068,7 +2085,8 @@ def update_settings(payload: RuntimeSettingsUpdate, user: CurrentUser, db: Sessi require_super_admin(user) setting = runtime_setting(db) old_values = dict(setting.value or {}) - setting.value = {**old_values, "flow_retention_hours": payload.flow_retention_hours} + changes = payload.model_dump(exclude_unset=True, exclude_none=True) + setting.value = {**old_values, **changes} commit_or_400(db) write_audit( db, diff --git a/backend/app/schemas/domain.py b/backend/app/schemas/domain.py index 9f53aaf..088cd7a 100644 --- a/backend/app/schemas/domain.py +++ b/backend/app/schemas/domain.py @@ -173,10 +173,20 @@ class RuntimeSettingsRead(BaseModel): 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 = Field(ge=1, le=8760) + 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): diff --git a/backend/app/services/auto_sync.py b/backend/app/services/auto_sync.py new file mode 100644 index 0000000..ce42de5 --- /dev/null +++ b/backend/app/services/auto_sync.py @@ -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 diff --git a/backend/app/workers/worker.py b/backend/app/workers/worker.py index 4b62f2c..6a7cf57 100644 --- a/backend/app/workers/worker.py +++ b/backend/app/workers/worker.py @@ -1,12 +1,25 @@ -import time +import asyncio + +from app.db.session import SessionLocal +from app.services.auto_sync import run_due_jobs + + +async def loop() -> None: + print("NexaFabric worker started. Auto-sync scheduler is active.", flush=True) + while True: + try: + with SessionLocal() as db: + results = await run_due_jobs(db) + for result in results: + print(f"auto-sync: {result}", flush=True) + except Exception as exc: + print(f"auto-sync failed: {exc}", flush=True) + await asyncio.sleep(30) def main() -> None: - print("NexaFabric worker started. Configure Celery queues for production job execution.", flush=True) - while True: - time.sleep(30) + asyncio.run(loop()) if __name__ == "__main__": main() - diff --git a/backend/tests/test_agent_firewall_logs.py b/backend/tests/test_agent_firewall_logs.py new file mode 100644 index 0000000..d2205df --- /dev/null +++ b/backend/tests/test_agent_firewall_logs.py @@ -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" diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 31c5cfb..f4c5f40 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -169,6 +169,12 @@ export type RuntimeSettings = { 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 = { diff --git a/frontend/src/pages/Ipam.tsx b/frontend/src/pages/Ipam.tsx index 70bc8d8..d5eab41 100644 --- a/frontend/src/pages/Ipam.tsx +++ b/frontend/src/pages/Ipam.tsx @@ -1,8 +1,8 @@ import { FormEvent, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Database, Download, Pencil, Plus } from "lucide-react"; +import { Database, Download, Pencil, Plus, RefreshCcw } from "lucide-react"; -import { api, authorizedFetch, IpAddress, Network, Subnet } from "../api/client"; +import { api, authorizedFetch, IpAddress, Network, RuntimeSettings, Subnet } from "../api/client"; import { DataTable } from "../components/DataTable"; import { buttonClass, Field, iconButtonClass, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls"; import { LoadingOverlay } from "../components/LoadingOverlay"; @@ -16,6 +16,7 @@ export function Ipam() { const networks = useQuery({ queryKey: ["networks"], queryFn: () => api("/networks") }); const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api("/ipam/subnets") }); const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api("/ipam/addresses") }); + const settings = useQuery({ queryKey: ["settings"], queryFn: () => api("/settings") }); const [message, setMessage] = useState(""); const [subnetForm, setSubnetForm] = useState(emptySubnetForm); const [ipForm, setIpForm] = useState({ subnet_id: "", address: "10.50.0.20", status: "reserved", note: "" }); @@ -46,6 +47,13 @@ export function Ipam() { }, onError: (error) => setMessage(error instanceof Error ? error.message : "IP reservation failed."), }); + const toggleAutoDiscover = useMutation({ + mutationFn: () => api("/settings", { + method: "PATCH", + body: JSON.stringify({ auto_ipam_sync_enabled: !settings.data?.auto_ipam_sync_enabled }), + }), + onSuccess: () => settings.refetch(), + }); async function submitSubnet(event: FormEvent) { event.preventDefault(); @@ -110,6 +118,18 @@ export function Ipam() {
+
+
+
Automatic IPAM discovery
+
+ {settings.data?.auto_ipam_sync_enabled ? `Enabled every ${settings.data.auto_ipam_sync_interval_minutes} minutes` : "Disabled"} +
+
+ +
diff --git a/frontend/src/pages/Nodes.tsx b/frontend/src/pages/Nodes.tsx index 8ddb692..6f50332 100644 --- a/frontend/src/pages/Nodes.tsx +++ b/frontend/src/pages/Nodes.tsx @@ -1,8 +1,8 @@ import { useMutation, useQuery } from "@tanstack/react-query"; -import { Activity, Cpu, Copy, RadioTower, ScrollText } from "lucide-react"; +import { Activity, Cpu, Copy, RadioTower, RefreshCcw, ScrollText } from "lucide-react"; import { useState } from "react"; -import { AgentInstallInfo, api, Node } from "../api/client"; +import { AgentInstallInfo, api, Node, RuntimeSettings } from "../api/client"; import { DataTable } from "../components/DataTable"; import { iconButtonClass, secondaryButtonClass } from "../components/FormControls"; import { LoadingOverlay } from "../components/LoadingOverlay"; @@ -11,6 +11,7 @@ import { PageHeader } from "../components/PageHeader"; export function Nodes() { const nodes = useQuery({ queryKey: ["nodes-agents"], queryFn: () => api("/nodes/agents") }); + const settings = useQuery({ queryKey: ["settings"], queryFn: () => api("/settings") }); const [selectedNode, setSelectedNode] = useState(null); const [detailNode, setDetailNode] = useState(null); const [copied, setCopied] = useState(false); @@ -21,6 +22,13 @@ export function Nodes() { setCopied(false); }, }); + const toggleAutoSync = useMutation({ + mutationFn: () => api("/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) { @@ -53,6 +61,18 @@ export function Nodes() { <> +
+
+
Automatic node sync
+
+ {settings.data?.auto_node_sync_enabled ? `Enabled every ${settings.data.auto_node_sync_interval_minutes} minutes` : "Disabled"} +
+
+ +
{nodes.isLoading ?
Loading...
: null} {nodes.error ?
Failed to load nodes.
: null} {!nodes.isLoading && !nodes.error ? ( diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 31abe32..6480284 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,6 +1,6 @@ import { FormEvent, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Database, Save, ShieldCheck } from "lucide-react"; +import { Database, RefreshCcw, Save, ShieldCheck } from "lucide-react"; import { api, RuntimeSettings } from "../api/client"; import { buttonClass, Field, inputClass } from "../components/FormControls"; @@ -10,10 +10,20 @@ export function Settings() { const queryClient = useQueryClient(); const settings = useQuery({ queryKey: ["settings"], queryFn: () => api("/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("/settings", { method: "PATCH", - body: JSON.stringify({ flow_retention_hours: Number(retentionHours) }), + 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"] }), }); @@ -21,6 +31,10 @@ export function 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]); @@ -32,49 +46,81 @@ export function Settings() { return ( <> -
-
-
Flow Retention
- -
- setRetentionHours(event.target.value)} - /> - hours + +
+
+
Flow Retention
+ +
+ setRetentionHours(event.target.value)} + /> + hours +
+
+
+ New heartbeats update existing flows and remove entries older than this retention window.
- -
- New heartbeats update existing flows and remove entries older than this retention window. -
- {update.error ?
Settings could not be saved. Super Admin permission is required.
: null} - - -
-
Safety Defaults
-
-
-
Product
-
{settings.data?.product ?? "NexaFabric"}
+
+
+
Safety Defaults
+
+
+
Product
+
{settings.data?.product ?? "NexaFabric"}
+
+
+
Preview Required
+
{String(settings.data?.firewall_apply_requires_preview ?? true)}
+
+
+
Agent Optional
+
{String(settings.data?.agent_optional ?? true)}
+
-
-
Preview Required
-
{String(settings.data?.firewall_apply_requires_preview ?? true)}
-
-
-
Agent Optional
-
{String(settings.data?.agent_optional ?? true)}
-
-
- -
+ +
+
+
+
Nodes Auto Sync
+ + +
+ setNodeInterval(event.target.value)} /> + minutes +
+
+
Last run: {settings.data?.last_node_auto_sync_at ? new Date(settings.data.last_node_auto_sync_at).toLocaleString() : "never"}
+
+
+
IPAM Auto Discover
+ + +
+ setIpamInterval(event.target.value)} /> + minutes +
+
+
Last run: {settings.data?.last_ipam_auto_sync_at ? new Date(settings.data.last_ipam_auto_sync_at).toLocaleString() : "never"}
+
+
+ {update.error ?
Settings could not be saved. Super Admin permission is required.
: null} + + ); }