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
537 lines
24 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|