feat: add automatic cluster sync and IPAM discovery with configurable intervals, firewall log file parsing, and enhanced flow prioritization
Add RuntimeSettingsRead/RuntimeSettingsUpdate schemas with auto_node_sync_enabled/auto_node_sync_interval_minutes/auto_ipam_sync_enabled/auto_ipam_sync_interval_minutes/last_node_auto_sync_at/last_ipam_auto_sync_at fields, implement firewall_log_lines_from_files to parse /var/log/pve-firewall.log with max_lines limit and error collection, extend collect_firewall_log
This commit is contained in:
@@ -18,7 +18,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
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_RE = re.compile(r"(?:tap|fwbr|fwln|fwpr)(\d+)")
|
||||||
VM_INTERFACE_DETAIL_RE = re.compile(r"(?:tap|fwbr|fwln|fwpr)(\d+)i(\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]+)")
|
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]]:
|
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(
|
code, output = run_command(
|
||||||
["journalctl", "-k", "--since", f"-{max(since_minutes, 1)} min", "--no-pager", "-o", "cat"],
|
["journalctl", "-k", "--since", f"-{max(since_minutes, 1)} min", "--no-pager", "-o", "cat"],
|
||||||
timeout=10,
|
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")
|
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
|
return [], diagnostics
|
||||||
|
|
||||||
flows: dict[tuple[object, ...], dict[str, Any]] = {}
|
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:
|
if "SRC=" not in line or "DST=" not in line:
|
||||||
continue
|
continue
|
||||||
flow = parse_firewall_log_line(line)
|
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)
|
current["bytes"] = int(current.get("bytes") or 0) + int(flow.get("bytes") or 0)
|
||||||
continue
|
continue
|
||||||
flows[key] = dict(flow)
|
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]:
|
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]:
|
def collect_payload(config: dict[str, Any]) -> dict[str, Any]:
|
||||||
uptime = read_text("/proc/uptime")
|
uptime = read_text("/proc/uptime")
|
||||||
interfaces = collect_interfaces()
|
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_flows: list[dict[str, Any]] = []
|
||||||
packet_diagnostics: dict[str, Any] | None = None
|
packet_diagnostics: dict[str, Any] | None = None
|
||||||
firewall_log_flows: list[dict[str, Any]] = []
|
firewall_log_flows: list[dict[str, Any]] = []
|
||||||
|
|||||||
@@ -107,7 +107,16 @@ def setup_setting(db: Session) -> SystemSetting:
|
|||||||
def runtime_setting(db: Session) -> SystemSetting:
|
def runtime_setting(db: Session) -> SystemSetting:
|
||||||
setting = db.get(SystemSetting, "runtime")
|
setting = db.get(SystemSetting, "runtime")
|
||||||
if not setting:
|
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.add(setting)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(setting)
|
db.refresh(setting)
|
||||||
@@ -116,7 +125,15 @@ def runtime_setting(db: Session) -> SystemSetting:
|
|||||||
|
|
||||||
def runtime_settings_payload(db: Session) -> RuntimeSettingsRead:
|
def runtime_settings_payload(db: Session) -> RuntimeSettingsRead:
|
||||||
value = runtime_setting(db).value or {}
|
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:
|
def require_super_admin(user: User) -> None:
|
||||||
@@ -1313,7 +1330,7 @@ cat > "$CONFIG_DIR/config.json" <<'JSON'
|
|||||||
"node_id": "{node.id}",
|
"node_id": "{node.id}",
|
||||||
"node_name": "{node.name}",
|
"node_name": "{node.name}",
|
||||||
"interval_seconds": 30,
|
"interval_seconds": 30,
|
||||||
"flow_limit": 500,
|
"flow_limit": 2000,
|
||||||
"packet_flow_collector": true,
|
"packet_flow_collector": true,
|
||||||
"packet_flow_window_seconds": 10,
|
"packet_flow_window_seconds": 10,
|
||||||
"firewall_log_collector": true,
|
"firewall_log_collector": true,
|
||||||
@@ -1448,7 +1465,7 @@ def agent_heartbeat(payload: AgentHeartbeat, authorization: str | None = Header(
|
|||||||
): flow
|
): flow
|
||||||
for flow in db.scalars(select(TrafficFlow).where(TrafficFlow.node_id == node.id)).all()
|
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 "")
|
source_ip = str(raw_flow.get("source_ip") or "")
|
||||||
destination_ip = str(raw_flow.get("destination_ip") or "")
|
destination_ip = str(raw_flow.get("destination_ip") or "")
|
||||||
if not source_ip or not destination_ip:
|
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)
|
select(TrafficFlow)
|
||||||
.where((TrafficFlow.source_ip.in_(workload_ips)) | (TrafficFlow.destination_ip.in_(workload_ips)))
|
.where((TrafficFlow.source_ip.in_(workload_ips)) | (TrafficFlow.destination_ip.in_(workload_ips)))
|
||||||
.order_by((TrafficFlow.state == "blocked").desc(), TrafficFlow.updated_at.desc())
|
.order_by((TrafficFlow.state == "blocked").desc(), TrafficFlow.updated_at.desc())
|
||||||
.limit(50)
|
.limit(1000)
|
||||||
).all()
|
).all()
|
||||||
for flow in flows:
|
for flow in flows:
|
||||||
source_owner = ip_owners.get(flow.source_ip)
|
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)
|
require_super_admin(user)
|
||||||
setting = runtime_setting(db)
|
setting = runtime_setting(db)
|
||||||
old_values = dict(setting.value or {})
|
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)
|
commit_or_400(db)
|
||||||
write_audit(
|
write_audit(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -173,10 +173,20 @@ class RuntimeSettingsRead(BaseModel):
|
|||||||
firewall_apply_requires_preview: bool = True
|
firewall_apply_requires_preview: bool = True
|
||||||
agent_optional: bool = True
|
agent_optional: bool = True
|
||||||
flow_retention_hours: int = 24
|
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):
|
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):
|
class ClusterRead(OrmModel):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -169,6 +169,12 @@ export type RuntimeSettings = {
|
|||||||
firewall_apply_requires_preview: boolean;
|
firewall_apply_requires_preview: boolean;
|
||||||
agent_optional: boolean;
|
agent_optional: boolean;
|
||||||
flow_retention_hours: number;
|
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 = {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
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, 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 { DataTable } from "../components/DataTable";
|
||||||
import { buttonClass, Field, iconButtonClass, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, iconButtonClass, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||||
import { LoadingOverlay } from "../components/LoadingOverlay";
|
import { LoadingOverlay } from "../components/LoadingOverlay";
|
||||||
@@ -16,6 +16,7 @@ export function Ipam() {
|
|||||||
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(emptySubnetForm);
|
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: "" });
|
||||||
@@ -46,6 +47,13 @@ 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();
|
||||||
@@ -110,6 +118,18 @@ export function Ipam() {
|
|||||||
<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} />
|
<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={addSubnet}><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>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
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 { useState } from "react";
|
||||||
|
|
||||||
import { AgentInstallInfo, api, Node } from "../api/client";
|
import { AgentInstallInfo, api, Node, RuntimeSettings } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
import { iconButtonClass, secondaryButtonClass } from "../components/FormControls";
|
import { iconButtonClass, secondaryButtonClass } from "../components/FormControls";
|
||||||
import { LoadingOverlay } from "../components/LoadingOverlay";
|
import { LoadingOverlay } from "../components/LoadingOverlay";
|
||||||
@@ -11,6 +11,7 @@ import { PageHeader } from "../components/PageHeader";
|
|||||||
|
|
||||||
export function Nodes() {
|
export function Nodes() {
|
||||||
const nodes = useQuery({ queryKey: ["nodes-agents"], queryFn: () => api<Node[]>("/nodes/agents") });
|
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 [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||||
const [detailNode, setDetailNode] = useState<Node | null>(null);
|
const [detailNode, setDetailNode] = useState<Node | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -21,6 +22,13 @@ export function Nodes() {
|
|||||||
setCopied(false);
|
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() {
|
async function copyCommand() {
|
||||||
if (!installInfo.data) {
|
if (!installInfo.data) {
|
||||||
@@ -53,6 +61,18 @@ export function Nodes() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader title="Nodes" subtitle="Cluster nodes, capacity, health, and NexaFabric agent enrollment." />
|
<PageHeader title="Nodes" subtitle="Cluster nodes, capacity, health, and NexaFabric agent enrollment." />
|
||||||
<LoadingOverlay open={installInfo.isPending} message="Generating node agent installer..." />
|
<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.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.error ? <div className="rounded-md border border-danger p-4 text-sm text-danger">Failed to load nodes.</div> : null}
|
||||||
{!nodes.isLoading && !nodes.error ? (
|
{!nodes.isLoading && !nodes.error ? (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { api, RuntimeSettings } from "../api/client";
|
||||||
import { buttonClass, Field, inputClass } from "../components/FormControls";
|
import { buttonClass, Field, inputClass } from "../components/FormControls";
|
||||||
@@ -10,10 +10,20 @@ export function Settings() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const settings = useQuery({ queryKey: ["settings"], queryFn: () => api<RuntimeSettings>("/settings") });
|
const settings = useQuery({ queryKey: ["settings"], queryFn: () => api<RuntimeSettings>("/settings") });
|
||||||
const [retentionHours, setRetentionHours] = useState("24");
|
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({
|
const update = useMutation({
|
||||||
mutationFn: () => api<RuntimeSettings>("/settings", {
|
mutationFn: () => api<RuntimeSettings>("/settings", {
|
||||||
method: "PATCH",
|
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"] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
|
||||||
});
|
});
|
||||||
@@ -21,6 +31,10 @@ export function Settings() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings.data) {
|
if (settings.data) {
|
||||||
setRetentionHours(String(settings.data.flow_retention_hours));
|
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]);
|
}, [settings.data]);
|
||||||
|
|
||||||
@@ -32,8 +46,9 @@ export function Settings() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Settings" subtitle="Runtime settings and safety defaults." />
|
<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]">
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,560px)_1fr]">
|
||||||
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
|
<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>
|
<div className="mb-4 flex items-center gap-2 font-medium"><Database size={18} /> Flow Retention</div>
|
||||||
<Field label="Keep flow telemetry for">
|
<Field label="Keep flow telemetry for">
|
||||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||||
@@ -51,12 +66,7 @@ export function Settings() {
|
|||||||
<div className="mt-3 rounded-md border border-border bg-canvas p-3 text-sm text-slate-500">
|
<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.
|
New heartbeats update existing flows and remove entries older than this retention window.
|
||||||
</div>
|
</div>
|
||||||
{update.error ? <div className="mt-3 rounded-md border border-danger p-3 text-sm text-danger">Settings could not be saved. Super Admin permission is required.</div> : null}
|
</section>
|
||||||
<button className={`${buttonClass} mt-4`} disabled={update.isPending || !retentionHours}>
|
|
||||||
<Save size={16} />
|
|
||||||
Save Settings
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<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-4 flex items-center gap-2 font-medium"><ShieldCheck size={18} /> Safety Defaults</div>
|
<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="grid gap-3 md:grid-cols-3">
|
||||||
@@ -75,6 +85,42 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user