diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 3f5f670..c671dbf 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -57,6 +57,8 @@ from app.schemas.domain import ( ProjectRead, RoleCreate, RoleRead, + RuntimeSettingsRead, + RuntimeSettingsUpdate, SecurityRuleCreate, SecurityRuleRead, ServiceCatalogCreate, @@ -102,6 +104,27 @@ def setup_setting(db: Session) -> SystemSetting: return setting +def runtime_setting(db: Session) -> SystemSetting: + setting = db.get(SystemSetting, "runtime") + if not setting: + setting = SystemSetting(key="runtime", value={"flow_retention_hours": 24}) + db.add(setting) + db.commit() + db.refresh(setting) + return setting + + +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)) + + +def require_super_admin(user: User) -> None: + permissions = {permission for role in user.roles for permission in role.permissions} + if "*" not in permissions: + raise HTTPException(status_code=403, detail="Super Admin permission required") + + 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: @@ -1409,7 +1432,8 @@ def agent_heartbeat(payload: AgentHeartbeat, authorization: str | None = Header( agent.version = payload.version agent.last_seen_at = datetime.utcnow() agent.last_payload = payload.model_dump(mode="json") - retention_cutoff = datetime.utcnow() - timedelta(hours=24) + retention_hours = runtime_settings_payload(db).flow_retention_hours + retention_cutoff = datetime.utcnow() - timedelta(hours=retention_hours) for old_flow in db.scalars(select(TrafficFlow).where(TrafficFlow.node_id == node.id, TrafficFlow.updated_at < retention_cutoff)).all(): db.delete(old_flow) existing_flows = { @@ -2034,6 +2058,25 @@ def audit(_: CurrentUser, db: Session = Depends(get_db)) -> list[AuditLog]: return db.scalars(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(200)).all() -@api_router.get("/settings") -def settings(_: CurrentUser) -> dict: - return {"product": "NexaFabric", "firewall_apply_requires_preview": True, "agent_optional": True} +@api_router.get("/settings", response_model=RuntimeSettingsRead) +def settings(_: CurrentUser, db: Session = Depends(get_db)) -> RuntimeSettingsRead: + return runtime_settings_payload(db) + + +@api_router.patch("/settings", response_model=RuntimeSettingsRead) +def update_settings(payload: RuntimeSettingsUpdate, user: CurrentUser, db: Session = Depends(get_db)) -> RuntimeSettingsRead: + 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} + commit_or_400(db) + write_audit( + db, + action="settings.updated", + object_type="system", + object_id="runtime", + user_id=user.id, + old_values=old_values, + new_values=setting.value, + ) + return runtime_settings_payload(db) diff --git a/backend/app/schemas/domain.py b/backend/app/schemas/domain.py index 6bb8304..9f53aaf 100644 --- a/backend/app/schemas/domain.py +++ b/backend/app/schemas/domain.py @@ -168,6 +168,17 @@ class FirewallApplyRequest(BaseModel): dry_run: bool = True +class RuntimeSettingsRead(BaseModel): + product: str = "NexaFabric" + firewall_apply_requires_preview: bool = True + agent_optional: bool = True + flow_retention_hours: int = 24 + + +class RuntimeSettingsUpdate(BaseModel): + flow_retention_hours: int = Field(ge=1, le=8760) + + class ClusterRead(OrmModel): id: str name: str diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 45680dc..8269253 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import { Policies } from "./pages/Policies"; import { PolicyDesigner } from "./pages/PolicyDesigner"; import { SecurityGroups } from "./pages/SecurityGroups"; import { ServiceCatalog } from "./pages/ServiceCatalog"; +import { Settings } from "./pages/Settings"; import { SetupWizard } from "./pages/SetupWizard"; import { TenantsProjects } from "./pages/TenantsProjects"; import { UsersRoles } from "./pages/UsersRoles"; @@ -62,7 +63,7 @@ function AppRoutes() { } /> } /> } /> - } /> + } /> ); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6714d70..31c5cfb 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -164,6 +164,13 @@ export type AgentInstallInfo = { command: string; }; +export type RuntimeSettings = { + product: string; + firewall_apply_requires_preview: boolean; + agent_optional: boolean; + flow_retention_hours: number; +}; + export type WorkloadInsight = { workload: Workload; assigned_ips: IpAddress[]; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..31abe32 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,80 @@ +import { FormEvent, useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Database, 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("/settings") }); + const [retentionHours, setRetentionHours] = useState("24"); + const update = useMutation({ + mutationFn: () => api("/settings", { + method: "PATCH", + body: JSON.stringify({ flow_retention_hours: Number(retentionHours) }), + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }), + }); + + useEffect(() => { + if (settings.data) { + setRetentionHours(String(settings.data.flow_retention_hours)); + } + }, [settings.data]); + + async function submit(event: FormEvent) { + event.preventDefault(); + await update.mutateAsync(); + } + + return ( + <> + +
+
+
Flow Retention
+ +
+ setRetentionHours(event.target.value)} + /> + hours +
+
+
+ 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"}
+
+
+
Preview Required
+
{String(settings.data?.firewall_apply_requires_preview ?? true)}
+
+
+
Agent Optional
+
{String(settings.data?.agent_optional ?? true)}
+
+
+
+
+ + ); +} diff --git a/frontend/src/pages/Workloads.tsx b/frontend/src/pages/Workloads.tsx index e559585..f658e1a 100644 --- a/frontend/src/pages/Workloads.tsx +++ b/frontend/src/pages/Workloads.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Activity, ArrowRight, BarChart3, CircuitBoard, Filter, Hash, Network, Search, Shield, ShieldCheck } from "lucide-react"; import { Link, useNavigate, useParams } from "react-router-dom"; @@ -636,6 +636,7 @@ export function WorkloadFlows() { 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], queryFn: () => api(`/vms/${workloadId}/insights`), @@ -676,6 +677,14 @@ export function WorkloadFlows() { 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
Loading flow analytics...
; @@ -728,7 +737,21 @@ export function WorkloadFlows() {
All Flows
{filteredTraffic.length} of {traffic.length} flows ยท {formatBytes(totalBytes(filteredTraffic))}
- {filteredTraffic.length ? :
No flows match the current filters.
} + {filteredTraffic.length ? ( + <> + +
+
+ Showing {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, filteredTraffic.length)} of {filteredTraffic.length} +
+
+ + Page {currentPage} / {totalPages} + +
+
+ + ) :
No flows match the current filters.
}