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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user