Files
NexaFabric/frontend/src/pages/Workloads.tsx
T
nessi 6d5dc310df feat: add flow-level firewall rule and policy matching with decision classification and audit mode visualization
Add firewall_rule_matches_flow to check if active rules match traffic flows using protocol/port/IP/direction matching with enable status validation, implement policy_matches_flow to evaluate policy definitions against flows with workload/network endpoint resolution and protocol/port matching, add flow_policy_decision to determine final decision from active rules and policies with audit
2026-07-09 19:47:39 +02:00

537 lines
24 KiB
TypeScript

import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Activity, ArrowRight, CircuitBoard, Hash, Network, Shield, ShieldCheck } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { api, Workload, WorkloadInsight } from "../api/client";
import { DataTable } from "../components/DataTable";
import { secondaryButtonClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader";
type TrafficSummary = {
key: string;
source: string;
destination: string;
sourceLabel: string;
destinationLabel: string;
protocol: string;
port: string;
bytes: number;
packets: number;
count: number;
decision: string;
interfaceName: string;
note: string;
ipAddresses: string[];
matchingFirewallRules: Array<Record<string, unknown>>;
matchingAuditPolicies: Array<Record<string, unknown>>;
matchingPolicies: Array<Record<string, unknown>>;
};
function records(value: unknown) {
return Array.isArray(value) ? (value.filter((item) => item && typeof item === "object") as Array<Record<string, unknown>>) : [];
}
function mergeRecords(left: Array<Record<string, unknown>>, right: Array<Record<string, unknown>>) {
const seen = new Set<string>();
const merged: Array<Record<string, unknown>> = [];
for (const item of [...left, ...right]) {
const key = String(item.id ?? item.pos ?? item.comment ?? JSON.stringify(item));
if (seen.has(key)) {
continue;
}
seen.add(key);
merged.push(item);
}
return merged;
}
function formatBytes(value: unknown) {
const bytes = Number(value ?? 0);
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function summarizeTraffic(traffic: Array<Record<string, unknown>>) {
const summaries = new Map<string, TrafficSummary>();
for (const flow of traffic) {
const source = String(flow.source ?? flow.source_ip ?? "external");
const destination = String(flow.destination ?? flow.destination_ip ?? "external");
const sourceLabel = String(flow.source_label ?? flow.source_ip ?? source);
const destinationLabel = String(flow.destination_label ?? flow.destination_ip ?? destination);
const protocol = String(flow.protocol ?? "unknown");
const port = String(flow.port ?? flow.destination_port ?? "");
const key = [source, destination, protocol, port, String(flow.interface ?? "")].join("|");
const existing = summaries.get(key);
const bytes = Number(flow.bytes ?? 0);
const packets = Number(flow.packets ?? 0);
const ipAddresses = Array.isArray(flow.ip_addresses) ? flow.ip_addresses.map(String) : [];
const matchingFirewallRules = records(flow.matching_firewall_rules);
const matchingAuditPolicies = records(flow.matching_audit_policies);
const matchingPolicies = records(flow.matching_policies);
if (existing) {
existing.bytes += Number.isFinite(bytes) ? bytes : 0;
existing.packets += Number.isFinite(packets) ? packets : 0;
existing.count += 1;
existing.ipAddresses = Array.from(new Set([...existing.ipAddresses, ...ipAddresses]));
existing.matchingFirewallRules = mergeRecords(existing.matchingFirewallRules, matchingFirewallRules);
existing.matchingAuditPolicies = mergeRecords(existing.matchingAuditPolicies, matchingAuditPolicies);
existing.matchingPolicies = mergeRecords(existing.matchingPolicies, matchingPolicies);
if (existing.decision === "observed" && String(flow.decision ?? "observed") !== "observed") {
existing.decision = String(flow.decision ?? "observed");
}
continue;
}
summaries.set(key, {
key,
source,
destination,
sourceLabel,
destinationLabel,
protocol,
port,
bytes: Number.isFinite(bytes) ? bytes : 0,
packets: Number.isFinite(packets) ? packets : 0,
count: 1,
decision: String(flow.decision ?? "observed"),
interfaceName: String(flow.interface ?? ""),
note: String(flow.note ?? ""),
ipAddresses,
matchingFirewallRules,
matchingAuditPolicies,
matchingPolicies,
});
}
return Array.from(summaries.values()).sort((left, right) => right.bytes - left.bytes);
}
function endpointText(flow: TrafficSummary) {
return `${flow.sourceLabel} -> ${flow.destinationLabel}`;
}
function totalBytes(traffic: TrafficSummary[]) {
return traffic.reduce((sum, flow) => sum + flow.bytes, 0);
}
function TrafficBars({ traffic }: { traffic: TrafficSummary[] }) {
const top = traffic.slice(0, 5);
const max = Math.max(...top.map((flow) => flow.bytes), 1);
if (!top.length) {
return <div className="rounded-md border border-border p-3 text-xs text-slate-500">No traffic data yet.</div>;
}
return (
<div className="space-y-1.5">
{top.map((flow) => (
<div key={flow.key} className="grid gap-1">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="truncate">{endpointText(flow)}</span>
<span className="shrink-0 text-slate-500">{formatBytes(flow.bytes)}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
<div className="h-full rounded-full bg-accent" style={{ width: `${Math.max((flow.bytes / max) * 100, 4)}%` }} />
</div>
</div>
))}
{Array.from({ length: Math.max(5 - top.length, 0) }).map((_, index) => (
<div key={`empty-${index}`} className="grid gap-1 opacity-40">
<div className="flex items-center justify-between gap-3 text-xs text-slate-500">
<span>No additional flow</span>
<span>0 B</span>
</div>
<div className="h-2 rounded-full bg-slate-200 dark:bg-slate-800" />
</div>
))}
</div>
);
}
function CompactFlowList({ traffic }: { traffic: TrafficSummary[] }) {
const top = traffic.filter((flow) => flow.protocol !== "interface-counter").slice(0, 3);
if (!top.length) {
const fallback = traffic.find((flow) => flow.protocol === "interface-counter");
return (
<div className="rounded-md border border-border p-2 text-xs text-slate-500">
{fallback ? `Interface counter fallback: ${formatBytes(fallback.bytes)} observed.` : "No flow telemetry collected yet."}
</div>
);
}
return (
<div className="divide-y divide-border rounded-md border border-border">
{top.map((flow) => (
<div key={flow.key} className="grid grid-cols-[1fr_auto] gap-3 px-3 py-2 text-xs">
<div className="min-w-0">
<div className="truncate font-medium">{endpointText(flow)}</div>
<div className="truncate text-slate-500">{flow.protocol}{flow.port ? `:${flow.port}` : ""} · {flow.decision}</div>
</div>
<div className="shrink-0 text-right text-slate-500">{formatBytes(flow.bytes)}</div>
</div>
))}
</div>
);
}
function ruleLabel(rule: Record<string, unknown>) {
if (rule.error) {
return String(rule.error);
}
const type = String(rule.type ?? "rule");
const action = String(rule.action ?? "unknown");
const proto = rule.proto ? String(rule.proto) : "any";
const port = rule.dport || rule.sport ? `:${String(rule.dport ?? rule.sport)}` : "";
return `${type} ${action} ${proto}${port}`;
}
function policyLabel(policy: Record<string, unknown>) {
const name = String(policy.name ?? "Policy");
const mode = String(policy.enforcement_mode ?? "enforced");
const decision = String(policy.decision ?? "observed").replace("_", " ");
const protocol = String(policy.protocol ?? "any");
const ports = policy.ports ? `:${String(policy.ports)}` : "";
return `${name} · ${mode} · ${decision} · ${protocol}${ports}`;
}
function decisionClass(decision: string) {
if (decision.includes("would")) {
return "border-amber-400/40 bg-amber-400/10 text-amber-300";
}
if (decision.includes("block")) {
return "border-danger/40 bg-danger/10 text-danger";
}
if (decision.includes("allow")) {
return "border-accent/40 bg-accent/10 text-accent";
}
return "border-border bg-canvas text-slate-500";
}
function FlowRuleContext({ flow }: { flow: TrafficSummary }) {
const activeRules = flow.matchingFirewallRules.slice(0, 2);
const auditPolicies = flow.matchingAuditPolicies.slice(0, 2);
const hasContext = activeRules.length || auditPolicies.length;
if (!hasContext) {
return <div className="mt-1 text-xs text-slate-500">No matching active or audit rule.</div>;
}
return (
<div className="mt-2 grid gap-1.5 text-xs">
{activeRules.map((rule, index) => (
<div key={`rule-${flow.key}-${index}`} className="rounded-md border border-border bg-canvas px-2 py-1">
<span className="font-medium">Rule:</span> {ruleLabel(rule)}
<span className={`ml-2 rounded border px-1.5 py-0.5 ${decisionClass(String(rule.decision ?? "observed"))}`}>{String(rule.decision ?? "observed")}</span>
</div>
))}
{auditPolicies.map((policy, index) => (
<div key={`audit-${flow.key}-${index}`} className="rounded-md border border-amber-400/30 bg-amber-400/10 px-2 py-1 text-amber-200">
<span className="font-medium">Audit:</span> {policyLabel(policy)}
</div>
))}
{flow.matchingFirewallRules.length + flow.matchingAuditPolicies.length > activeRules.length + auditPolicies.length ? (
<div className="text-slate-500">
{flow.matchingFirewallRules.length + flow.matchingAuditPolicies.length - activeRules.length - auditPolicies.length} more match
{flow.matchingFirewallRules.length + flow.matchingAuditPolicies.length - activeRules.length - auditPolicies.length === 1 ? "" : "es"}
</div>
) : null}
</div>
);
}
function ActiveRulesList({ rules, compact = false }: { rules: Array<Record<string, unknown>>; compact?: boolean }) {
const visibleRules = compact ? rules.slice(0, 3) : rules;
if (!rules.length) {
return <div className="rounded-md border border-border p-2 text-xs text-slate-500">No active firewall rules were read for this workload.</div>;
}
return (
<div className="divide-y divide-border rounded-md border border-border">
{visibleRules.map((rule, index) => (
<div key={`${String(rule.pos ?? index)}-${index}`} className="grid grid-cols-[1fr_auto] gap-3 px-3 py-2 text-xs">
<div className="min-w-0">
<div className="truncate font-medium">{ruleLabel(rule)}</div>
<div className="truncate text-slate-500">{String(rule.comment ?? (rule.managed_by_nexafabric ? "NexaFabric managed" : "manual or provider rule"))}</div>
</div>
<div className={rule.enable === 0 ? "text-slate-500" : "text-accent"}>{rule.enable === 0 ? "off" : "on"}</div>
</div>
))}
{compact && rules.length > visibleRules.length ? (
<div className="px-3 py-2 text-xs text-slate-500">{rules.length - visibleRules.length} more rule{rules.length - visibleRules.length === 1 ? "" : "s"} on detail page.</div>
) : null}
</div>
);
}
function ProtocolChart({ traffic }: { traffic: TrafficSummary[] }) {
const protocolTotals = Array.from(
traffic.reduce((map, flow) => map.set(flow.protocol, (map.get(flow.protocol) ?? 0) + flow.bytes), new Map<string, number>()),
).sort((left, right) => right[1] - left[1]);
const total = protocolTotals.reduce((sum, [, bytes]) => sum + bytes, 0);
const palette = ["#2dd4bf", "#60a5fa", "#f59e0b", "#f472b6", "#a78bfa"];
if (!total) {
return <div className="rounded-md border border-border p-3 text-xs text-slate-500">No protocol split available.</div>;
}
return (
<div className="space-y-3">
<div className="flex h-3 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
{protocolTotals.map(([protocol, bytes], index) => {
const width = (bytes / total) * 100;
return <div key={protocol} title={protocol} style={{ width: `${width}%`, backgroundColor: palette[index % palette.length] }} />;
})}
</div>
<div className="flex flex-wrap gap-2 text-xs">
{protocolTotals.map(([protocol, bytes], index) => (
<span key={protocol} className="inline-flex items-center gap-2 rounded-md border border-border px-2 py-1">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: palette[index % palette.length] }} />
{protocol} {formatBytes(bytes)}
</span>
))}
</div>
</div>
);
}
function WorkloadFacts({ insight }: { insight: WorkloadInsight }) {
return (
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-md border border-border bg-panel p-3">
<div className="flex items-center gap-2 text-xs text-slate-500"><CircuitBoard size={14} /> Type</div>
<div className="mt-1 font-medium">{insight.workload.kind}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="flex items-center gap-2 text-xs text-slate-500"><Activity size={14} /> Status</div>
<div className="mt-1 font-medium">{insight.workload.status}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="flex items-center gap-2 text-xs text-slate-500"><Hash size={14} /> VMID</div>
<div className="mt-1 font-medium">{insight.workload.external_id}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="flex items-center gap-2 text-xs text-slate-500"><ShieldCheck size={14} /> Decision</div>
<div className="mt-1 font-medium">{insight.effective_decision}</div>
</div>
</div>
);
}
function TrafficTable({ traffic }: { traffic: TrafficSummary[] }) {
return (
<div className="overflow-hidden rounded-md border border-border">
<div className="max-h-[460px] overflow-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-panel text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 font-medium">Flow</th>
<th className="px-3 py-2 font-medium">Protocol</th>
<th className="px-3 py-2 font-medium">Decision</th>
<th className="px-3 py-2 font-medium">Traffic</th>
<th className="px-3 py-2 font-medium">Packets</th>
<th className="px-3 py-2 font-medium">Seen</th>
</tr>
</thead>
<tbody>
{traffic.map((flow) => (
<tr key={flow.key} className="border-t border-border">
<td className="px-3 py-3">
<div className="font-medium">{endpointText(flow)}</div>
<div className="text-xs text-slate-500">{flow.ipAddresses.join(", ") || flow.interfaceName || "no endpoint metadata"}</div>
<FlowRuleContext flow={flow} />
</td>
<td className="px-3 py-3">{flow.protocol}{flow.port ? `:${flow.port}` : ""}</td>
<td className="px-3 py-3">
<span className={`inline-flex rounded-md border px-2 py-1 text-xs ${decisionClass(flow.decision)}`}>{flow.decision.replace("_", " ")}</span>
</td>
<td className="px-3 py-3">{formatBytes(flow.bytes)}</td>
<td className="px-3 py-3">{flow.packets}</td>
<td className="px-3 py-3">{flow.count} sample{flow.count === 1 ? "" : "s"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export function Workloads() {
const navigate = useNavigate();
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
const [selectedId, setSelectedId] = useState("");
const selected = selectedId || workloads.data?.[0]?.id || "";
const insight = useQuery({
queryKey: ["workload-insight", selected],
queryFn: () => api<WorkloadInsight>(`/vms/${selected}/insights`),
enabled: Boolean(selected),
});
const traffic = useMemo(() => summarizeTraffic(insight.data?.traffic ?? []), [insight.data?.traffic]);
return (
<>
<PageHeader title="VMs/LXCs" subtitle="Inspect workloads, observed traffic, and matching policy decisions." />
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_420px]">
<section className="space-y-3">
<DataTable
rows={(workloads.data ?? []) as unknown as Record<string, unknown>[]}
columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "external_id", label: "VMID" }]}
selectedId={selected}
onRowClick={(row) => setSelectedId(String(row.id))}
/>
</section>
<aside className="space-y-3 rounded-md border border-border bg-panel p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium"><Activity size={18} /> Workload Summary</div>
{selected ? (
<button className={`${secondaryButtonClass} h-9 px-3`} onClick={() => navigate(`/workloads/${selected}`)}>
<ArrowRight size={16} />
Details
</button>
) : null}
</div>
{insight.data ? (
<div className="space-y-3 text-sm">
<header>
<div className="font-semibold">{insight.data.workload.name}</div>
<div className="mt-1 text-xs text-slate-500">{insight.data.workload.kind} · {insight.data.workload.status} · VMID {insight.data.workload.external_id}</div>
</header>
<div className="grid grid-cols-3 gap-2">
<div className="rounded-md border border-border bg-canvas p-2">
<div className="text-xs text-slate-500">Traffic</div>
<div className="mt-1 font-medium">{formatBytes(totalBytes(traffic))}</div>
</div>
<div className="rounded-md border border-border bg-canvas p-2">
<div className="text-xs text-slate-500">Flows</div>
<div className="mt-1 font-medium">{traffic.length}</div>
</div>
<div className="rounded-md border border-border bg-canvas p-2">
<div className="text-xs text-slate-500">IPs</div>
<div className="mt-1 font-medium">{insight.data.assigned_ips.length}</div>
</div>
</div>
<section>
<div className="mb-1.5 flex items-center gap-2 text-sm font-medium"><Network size={15} /> Assigned IPs</div>
{insight.data.assigned_ips.length ? (
<div className="flex flex-wrap gap-2">
{insight.data.assigned_ips.slice(0, 4).map((ip) => (
<div key={ip.id} className="rounded-md border border-border px-2 py-1 text-xs">
<span className="font-medium">{ip.address}</span>
<span className="ml-2 text-slate-500">{ip.subnet_cidr ?? "unknown subnet"}</span>
</div>
))}
</div>
) : (
<div className="rounded-md border border-border p-2 text-xs text-slate-500">No assigned IP address was discovered yet.</div>
)}
</section>
<section>
<div className="mb-1.5 text-sm font-medium">Top Traffic</div>
<TrafficBars traffic={traffic} />
</section>
<section>
<div className="mb-1.5 text-sm font-medium">Top Flows</div>
<CompactFlowList traffic={traffic} />
</section>
<section>
<div className="mb-1.5 flex items-center gap-2 text-sm font-medium"><Shield size={15} /> Active Rules</div>
<ActiveRulesList rules={insight.data.active_firewall_rules ?? []} compact />
</section>
</div>
) : (
<div className="text-sm text-slate-500">Select a workload.</div>
)}
</aside>
</div>
</>
);
}
export function WorkloadDetail() {
const { workloadId } = useParams();
const insight = useQuery({
queryKey: ["workload-insight", workloadId],
queryFn: () => api<WorkloadInsight>(`/vms/${workloadId}/insights`),
enabled: Boolean(workloadId),
});
const traffic = useMemo(() => summarizeTraffic(insight.data?.traffic ?? []), [insight.data?.traffic]);
if (insight.isLoading) {
return <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading workload...</div>;
}
if (!insight.data) {
return <div className="rounded-md border border-danger p-4 text-sm text-danger">Workload details could not be loaded.</div>;
}
return (
<>
<PageHeader title={insight.data.workload.name} subtitle="Detailed workload traffic, addressing, and policy context." />
<div className="mb-4">
<Link className={secondaryButtonClass} to="/workloads">Back to VMs/LXCs</Link>
</div>
<div className="space-y-4">
<WorkloadFacts insight={insight.data} />
<div className="grid gap-4 xl:grid-cols-[1fr_360px]">
<section className="space-y-4">
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center justify-between">
<div className="font-medium">Traffic Distribution</div>
<div className="text-xs text-slate-500">{traffic.length} aggregated flows · {formatBytes(totalBytes(traffic))}</div>
</div>
<TrafficBars traffic={traffic} />
</div>
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Flow Table</div>
{traffic.length ? <TrafficTable traffic={traffic} /> : <div className="rounded-md border border-border p-3 text-xs text-slate-500">No flow telemetry collected yet.</div>}
</div>
</section>
<aside className="space-y-4">
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Protocol Split</div>
<ProtocolChart traffic={traffic} />
</div>
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium"><Network size={16} /> Assigned IPs</div>
<div className="space-y-2">
{insight.data.assigned_ips.map((ip) => (
<div key={ip.id} className="rounded-md border border-border px-3 py-2 text-xs">
<div className="font-medium">{ip.address}</div>
<div className="text-slate-500">{ip.subnet_cidr ?? "unknown subnet"} · {ip.status}</div>
</div>
))}
{!insight.data.assigned_ips.length ? <div className="text-xs text-slate-500">No assigned IPs discovered.</div> : null}
</div>
</div>
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium"><Shield size={16} /> Active Firewall Rules</div>
<ActiveRulesList rules={insight.data.active_firewall_rules ?? []} />
</div>
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Matching Policies</div>
<div className="space-y-2">
{insight.data.matching_policies.map((policy) => (
<div key={policy.id} className="rounded-md border border-border p-3 text-sm">
<div className="font-medium">{policy.name}</div>
<div className="text-xs text-slate-500">v{policy.version} · {policy.enforcement_mode}</div>
</div>
))}
{!insight.data.matching_policies.length ? <div className="text-xs text-slate-500">No matching policies.</div> : null}
</div>
</div>
{insight.data.audit_mode_notes.length ? (
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Audit Mode</div>
<div className="space-y-2">
{insight.data.audit_mode_notes.map((note) => <div key={note} className="rounded-md border border-border p-3 text-xs">{note}</div>)}
</div>
</div>
) : null}
</aside>
</div>
</div>
</>
);
}