feat: add configurable flow retention settings with super admin controls and pagination for workload flows

Add RuntimeSettingsRead/RuntimeSettingsUpdate schemas with flow_retention_hours field (1-8760 hours), implement runtime_setting helper to initialize/fetch runtime system setting with 24h default, add require_super_admin guard to validate * permission, update agent_heartbeat to use configurable retention_cutoff from runtime settings instead of hardcoded 24h, replace /settings GET endpoint to return RuntimeSettingsRead with flow_retention_hours,
This commit is contained in:
2026-07-09 22:59:31 +02:00
parent 8c59ab32d5
commit 1531b7ea47
6 changed files with 172 additions and 7 deletions
+47 -4
View File
@@ -57,6 +57,8 @@ from app.schemas.domain import (
ProjectRead, ProjectRead,
RoleCreate, RoleCreate,
RoleRead, RoleRead,
RuntimeSettingsRead,
RuntimeSettingsUpdate,
SecurityRuleCreate, SecurityRuleCreate,
SecurityRuleRead, SecurityRuleRead,
ServiceCatalogCreate, ServiceCatalogCreate,
@@ -102,6 +104,27 @@ def setup_setting(db: Session) -> SystemSetting:
return setting 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: 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")) network = db.scalar(select(Network).where(Network.cluster_id == cluster_id, Network.name == "discovered-ipam"))
if network: if network:
@@ -1409,7 +1432,8 @@ def agent_heartbeat(payload: AgentHeartbeat, authorization: str | None = Header(
agent.version = payload.version agent.version = payload.version
agent.last_seen_at = datetime.utcnow() agent.last_seen_at = datetime.utcnow()
agent.last_payload = payload.model_dump(mode="json") 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(): for old_flow in db.scalars(select(TrafficFlow).where(TrafficFlow.node_id == node.id, TrafficFlow.updated_at < retention_cutoff)).all():
db.delete(old_flow) db.delete(old_flow)
existing_flows = { 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() return db.scalars(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(200)).all()
@api_router.get("/settings") @api_router.get("/settings", response_model=RuntimeSettingsRead)
def settings(_: CurrentUser) -> dict: def settings(_: CurrentUser, db: Session = Depends(get_db)) -> RuntimeSettingsRead:
return {"product": "NexaFabric", "firewall_apply_requires_preview": True, "agent_optional": True} 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)
+11
View File
@@ -168,6 +168,17 @@ 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
class RuntimeSettingsUpdate(BaseModel):
flow_retention_hours: int = Field(ge=1, le=8760)
class ClusterRead(OrmModel): class ClusterRead(OrmModel):
id: str id: str
name: str name: str
+2 -1
View File
@@ -16,6 +16,7 @@ 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";
@@ -62,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>
); );
+7
View File
@@ -164,6 +164,13 @@ export type AgentInstallInfo = {
command: string; command: string;
}; };
export type RuntimeSettings = {
product: string;
firewall_apply_requires_preview: boolean;
agent_optional: boolean;
flow_retention_hours: number;
};
export type WorkloadInsight = { export type WorkloadInsight = {
workload: Workload; workload: Workload;
assigned_ips: IpAddress[]; assigned_ips: IpAddress[];
+80
View File
@@ -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<RuntimeSettings>("/settings") });
const [retentionHours, setRetentionHours] = useState("24");
const update = useMutation({
mutationFn: () => api<RuntimeSettings>("/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 (
<>
<PageHeader title="Settings" subtitle="Runtime settings and safety defaults." />
<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">
<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>
{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}
<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">
<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>
</>
);
}
+25 -2
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Activity, ArrowRight, BarChart3, CircuitBoard, Filter, Hash, Network, Search, Shield, ShieldCheck } from "lucide-react"; import { Activity, ArrowRight, BarChart3, CircuitBoard, Filter, Hash, Network, Search, Shield, ShieldCheck } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
@@ -636,6 +636,7 @@ export function WorkloadFlows() {
const [protocol, setProtocol] = useState("all"); const [protocol, setProtocol] = useState("all");
const [decision, setDecision] = useState("all"); const [decision, setDecision] = useState("all");
const [port, setPort] = useState(""); const [port, setPort] = useState("");
const [page, setPage] = useState(1);
const insight = useQuery({ const insight = useQuery({
queryKey: ["workload-insight", workloadId], queryKey: ["workload-insight", workloadId],
queryFn: () => api<WorkloadInsight>(`/vms/${workloadId}/insights`), queryFn: () => api<WorkloadInsight>(`/vms/${workloadId}/insights`),
@@ -676,6 +677,14 @@ export function WorkloadFlows() {
return true; return true;
}); });
}, [decision, port, protocol, query, traffic]); }, [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) { if (insight.isLoading) {
return <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading flow analytics...</div>; return <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading flow analytics...</div>;
@@ -728,7 +737,21 @@ export function WorkloadFlows() {
<div className="font-medium">All Flows</div> <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 className="text-xs text-slate-500">{filteredTraffic.length} of {traffic.length} flows · {formatBytes(totalBytes(filteredTraffic))}</div>
</div> </div>
{filteredTraffic.length ? <TrafficTable traffic={filteredTraffic} dense /> : <div className="rounded-md border border-border p-4 text-sm text-slate-500">No flows match the current filters.</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> </section>
</div> </div>
</> </>