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:
@@ -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 { 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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user