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
+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 { 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<WorkloadInsight>(`/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 <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="text-xs text-slate-500">{filteredTraffic.length} of {traffic.length} flows · {formatBytes(totalBytes(filteredTraffic))}</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>
</div>
</>