diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 941aa28..29413a7 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -526,6 +526,159 @@ def flow_policy_decision(active_matches: list[dict[str, object]], policy_matches return "observed" +def policy_read_payload(policy: Policy, deployment_status: dict[str, object] | None = None) -> dict[str, object]: + return { + "id": policy.id, + "project_id": policy.project_id, + "name": policy.name, + "version": policy.version, + "enabled": policy.enabled, + "enforcement_mode": policy.enforcement_mode, + "definition": policy.definition, + "last_compiled": policy.last_compiled, + "deployment_status": deployment_status, + } + + +def nexafabric_rule_version(rule: dict[str, object], policy_id: str) -> int | None: + comment = str(rule.get("comment") or "") + marker = f"NexaFabric policy={policy_id} version=" + if marker not in comment: + return None + try: + return int(comment.split(marker, 1)[1].split(" ", 1)[0]) + except (IndexError, ValueError): + return None + + +async def policy_deployment_status(db: Session, policy: Policy) -> dict[str, object]: + if not policy.enabled: + return {"state": "disabled", "label": "Disabled", "expected_rules": 0, "active_rules": 0, "stale_rules": 0, "clusters": []} + if policy.enforcement_mode == "audit": + return {"state": "audit", "label": "Audit mode", "expected_rules": 0, "active_rules": 0, "stale_rules": 0, "clusters": []} + + clusters = db.scalars(select(Cluster).order_by(Cluster.name)).all() + if not clusters: + return {"state": "unknown", "label": "No cluster", "expected_rules": 0, "active_rules": 0, "stale_rules": 0, "clusters": []} + + expected_rules = 0 + active_rules = 0 + stale_rules = 0 + unresolved = 0 + cluster_results: list[dict[str, object]] = [] + + for cluster in clusters: + try: + preview = resolve_firewall_preview(db, cluster, await FirewallOrchestrator().preview(cluster, policy)) + except Exception as exc: + cluster_results.append({"cluster_id": cluster.id, "cluster_name": cluster.name, "state": "error", "error": str(exc)}) + continue + + writable_rules = [ + rule for rule in preview.generated_rules if rule.get("provider_target") and rule.get("provider_rule") and not rule.get("audit_only") + ] + expected_rules += len(writable_rules) + unresolved += len(preview.conflicts) + + provider = get_provider(cluster.provider) + list_rules = getattr(provider, "list_firewall_rules", None) + if not list_rules: + cluster_results.append( + { + "cluster_id": cluster.id, + "cluster_name": cluster.name, + "state": "unknown", + "expected_rules": len(writable_rules), + "active_rules": 0, + "stale_rules": 0, + "reason": "Provider cannot list active firewall rules.", + } + ) + continue + + cluster_active = 0 + cluster_stale = 0 + seen_targets: dict[str, dict[str, object]] = {} + for rule in writable_rules: + target = rule.get("provider_target") + if isinstance(target, dict): + seen_targets[str(target)] = target + for target in seen_targets.values(): + try: + rules = await list_rules( + ProviderConnection( + api_url=cluster.api_url, + token=cluster.token_ref or "", + verify_tls=cluster.verify_tls, + read_only=True, + ), + target, + ) + except Exception as exc: + cluster_results.append({"cluster_id": cluster.id, "cluster_name": cluster.name, "state": "error", "error": str(exc)}) + continue + for active_rule in rules: + version = nexafabric_rule_version(active_rule, policy.id) + if version is None: + continue + if version == policy.version: + cluster_active += 1 + else: + cluster_stale += 1 + + active_rules += cluster_active + stale_rules += cluster_stale + if preview.conflicts: + cluster_state = "unresolved" + elif cluster_active >= len(writable_rules) and writable_rules: + cluster_state = "active" + elif cluster_active: + cluster_state = "partial" + elif cluster_stale: + cluster_state = "stale" + else: + cluster_state = "not_applied" + cluster_results.append( + { + "cluster_id": cluster.id, + "cluster_name": cluster.name, + "state": cluster_state, + "expected_rules": len(writable_rules), + "active_rules": cluster_active, + "stale_rules": cluster_stale, + "conflicts": preview.conflicts, + } + ) + + if unresolved: + state = "unresolved" + label = "Needs attention" + elif expected_rules and active_rules >= expected_rules: + state = "active" + label = "Active" + elif active_rules: + state = "partial" + label = "Partially active" + elif stale_rules: + state = "stale" + label = "Outdated" + elif expected_rules: + state = "not_applied" + label = "Not applied" + else: + state = "unknown" + label = "No resolved rules" + + return { + "state": state, + "label": label, + "expected_rules": expected_rules, + "active_rules": active_rules, + "stale_rules": stale_rules, + "clusters": cluster_results, + } + + def dashboard_top_talkers(db: Session) -> list[dict[str, int | str]]: totals: dict[str, int] = {} workloads = db.scalars(select(Workload)).all() @@ -1593,8 +1746,11 @@ def delete_security_rule(rule_id: str, user: CurrentUser, db: Session = Depends( @api_router.get("/policies", response_model=list[PolicyRead]) -def policies(_: CurrentUser, db: Session = Depends(get_db)) -> list[Policy]: - return db.scalars(select(Policy).order_by(Policy.name)).all() +async def policies(_: CurrentUser, db: Session = Depends(get_db)) -> list[dict[str, object]]: + return [ + policy_read_payload(policy, await policy_deployment_status(db, policy)) + for policy in db.scalars(select(Policy).order_by(Policy.name)).all() + ] @api_router.post("/policies", response_model=PolicyRead) diff --git a/backend/app/schemas/domain.py b/backend/app/schemas/domain.py index f53d513..6bb8304 100644 --- a/backend/app/schemas/domain.py +++ b/backend/app/schemas/domain.py @@ -309,6 +309,7 @@ class PolicyRead(OrmModel): enforcement_mode: str definition: dict[str, Any] last_compiled: dict[str, Any] | None + deployment_status: dict[str, Any] | None = None class WorkloadInsight(BaseModel): diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 78e18be..6714d70 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -124,6 +124,7 @@ export type Policy = { enforcement_mode: string; definition: Record; last_compiled: Record | null; + deployment_status: Record | null; }; export type Workload = { diff --git a/frontend/src/pages/Policies.tsx b/frontend/src/pages/Policies.tsx index 3208c84..048b15e 100644 --- a/frontend/src/pages/Policies.tsx +++ b/frontend/src/pages/Policies.tsx @@ -1,6 +1,6 @@ import { FormEvent, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Eye, GitBranch, Pencil, Play, Plus, Trash2 } from "lucide-react"; +import { AlertTriangle, CheckCircle2, Clock3, Eye, GitBranch, Pencil, Play, Plus, Shield, Trash2 } from "lucide-react"; import { api, Policy, Project, ServiceCatalogItem } from "../api/client"; import { DataTable } from "../components/DataTable"; @@ -22,6 +22,60 @@ function policyService(policy: Policy) { return `${String(value.protocol ?? "")}/${String(value.ports ?? "")}`; } +function policyStatus(policy: Policy) { + const status = policy.deployment_status ?? {}; + return { + state: String(status.state ?? "unknown"), + label: String(status.label ?? "Unknown"), + expectedRules: Number(status.expected_rules ?? 0), + activeRules: Number(status.active_rules ?? 0), + staleRules: Number(status.stale_rules ?? 0), + }; +} + +function statusClass(state: string) { + if (state === "active") { + return "border-accent/40 bg-accent/10 text-accent"; + } + if (state === "audit") { + return "border-amber-400/40 bg-amber-400/10 text-amber-300"; + } + if (["stale", "partial", "unresolved"].includes(state)) { + return "border-danger/40 bg-danger/10 text-danger"; + } + return "border-border bg-canvas text-slate-500"; +} + +function StatusIcon({ state }: { state: string }) { + if (state === "active") { + return ; + } + if (state === "audit") { + return ; + } + if (["stale", "partial", "unresolved"].includes(state)) { + return ; + } + return ; +} + +function PolicyDeploymentStatus({ policy }: { policy: Policy }) { + const status = policyStatus(policy); + const detail = + status.state === "audit" + ? "No live firewall write" + : `${status.activeRules}/${status.expectedRules} active${status.staleRules ? ` ยท ${status.staleRules} stale` : ""}`; + return ( +
+ + + {status.label} + + {detail} +
+ ); +} + const defaultPolicyForm = { project_id: "", name: "Web to DB", @@ -200,6 +254,7 @@ export function Policies() { { key: "destination", label: "Destination", render: (row) => policyValue(row as unknown as Policy, "destination") }, { key: "service", label: "Service", render: (row) => policyService(row as unknown as Policy) }, { key: "enforcement_mode", label: "Mode" }, + { key: "deployment_status", label: "Status", render: (row) => }, { key: "version", label: "Version" }, { key: "actions",