diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 1fa1daf..54aa9db 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -36,6 +36,7 @@ from app.schemas.domain import ( AuditLogRead, ClusterCreate, ClusterRead, + ClusterUpdate, FirewallApplyRequest, FirewallPreview, IpAddressRead, @@ -264,26 +265,28 @@ def resolve_firewall_preview(db: Session, cluster: Cluster, preview: FirewallPre ports = str(rule.get("ports", "any")) protocol = str(rule.get("protocol", "any")) + protocols = ["tcp", "udp"] if protocol == "tcp/udp" else [protocol] for remote_value in remote_values: - provider_rule = { - "type": "in" if direction == "ingress" else "out", - "action": proxmox_action(str(rule.get("action", "allow"))), - "enable": 1, - "comment": ( - f"NexaFabric policy={rule.get('policy_id')} version={rule.get('policy_version')} " - f"rule={rule_index} target={target_workload.name}" - ), - } - if protocol != "any": - provider_rule["proto"] = protocol - if ports != "any": - provider_rule["dport"] = ports - if remote_value: - provider_rule["source" if direction == "ingress" else "dest"] = remote_value - if rule.get("logging"): - provider_rule["log"] = "info" - mapped_rule = {**mapped, "provider_target": provider_target, "provider_rule": provider_rule} - generated_rules.append(mapped_rule) + for provider_protocol in protocols: + provider_rule = { + "type": "in" if direction == "ingress" else "out", + "action": proxmox_action(str(rule.get("action", "allow"))), + "enable": 1, + "comment": ( + f"NexaFabric policy={rule.get('policy_id')} version={rule.get('policy_version')} " + f"rule={rule_index} target={target_workload.name}" + ), + } + if provider_protocol != "any": + provider_rule["proto"] = provider_protocol + if ports != "any": + provider_rule["dport"] = ports + if remote_value: + provider_rule["source" if direction == "ingress" else "dest"] = remote_value + if rule.get("logging"): + provider_rule["log"] = "info" + mapped_rule = {**mapped, "provider_target": provider_target, "provider_rule": provider_rule} + generated_rules.append(mapped_rule) return FirewallPreview( policy_id=preview.policy_id, @@ -434,6 +437,68 @@ def create_cluster(payload: ClusterCreate, user: CurrentUser, db: Session = Depe return cluster +@api_router.patch("/clusters/{cluster_id}", response_model=ClusterRead) +def update_cluster(cluster_id: str, payload: ClusterUpdate, user: CurrentUser, db: Session = Depends(get_db)) -> Cluster: + cluster = db.get(Cluster, cluster_id) + if not cluster: + raise HTTPException(status_code=404, detail="Cluster not found") + old_values = { + "name": cluster.name, + "api_url": cluster.api_url, + "provider": cluster.provider, + "mode": cluster.mode, + "verify_tls": cluster.verify_tls, + } + cluster.name = payload.name + cluster.api_url = payload.api_url + cluster.provider = payload.provider + cluster.mode = payload.mode + cluster.verify_tls = payload.verify_tls + if payload.api_token: + cluster.token_ref = payload.api_token + commit_or_400(db) + db.refresh(cluster) + write_audit( + db, + action="cluster.updated", + object_type="cluster", + object_id=cluster.id, + user_id=user.id, + old_values=old_values, + new_values={**payload.model_dump(exclude={"api_token"}), "api_token_changed": bool(payload.api_token)}, + ) + return cluster + + +@api_router.delete("/clusters/{cluster_id}") +def delete_cluster(cluster_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]: + cluster = db.get(Cluster, cluster_id) + if not cluster: + raise HTTPException(status_code=404, detail="Cluster not found") + workload_ids = [row[0] for row in db.execute(select(Workload.id).where(Workload.cluster_id == cluster.id)).all()] + if workload_ids: + for address in db.scalars(select(IpAddress).where(IpAddress.workload_id.in_(workload_ids))).all(): + db.delete(address) + network_ids = [row[0] for row in db.execute(select(Network.id).where(Network.cluster_id == cluster.id)).all()] + if network_ids: + subnet_ids = [row[0] for row in db.execute(select(Subnet.id).where(Subnet.network_id.in_(network_ids))).all()] + if subnet_ids: + for address in db.scalars(select(IpAddress).where(IpAddress.subnet_id.in_(subnet_ids))).all(): + db.delete(address) + for subnet in db.scalars(select(Subnet).where(Subnet.id.in_(subnet_ids))).all(): + db.delete(subnet) + for network in db.scalars(select(Network).where(Network.id.in_(network_ids))).all(): + db.delete(network) + for workload in db.scalars(select(Workload).where(Workload.cluster_id == cluster.id)).all(): + db.delete(workload) + for node in db.scalars(select(Node).where(Node.cluster_id == cluster.id)).all(): + db.delete(node) + db.delete(cluster) + commit_or_400(db) + write_audit(db, action="cluster.deleted", object_type="cluster", object_id=cluster_id, user_id=user.id) + return {"status": "deleted", "id": cluster_id} + + @api_router.post("/clusters/{cluster_id}/test") async def test_cluster(cluster_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> dict: cluster = db.get(Cluster, cluster_id) diff --git a/backend/app/schemas/domain.py b/backend/app/schemas/domain.py index ebe5510..785dc3d 100644 --- a/backend/app/schemas/domain.py +++ b/backend/app/schemas/domain.py @@ -71,6 +71,15 @@ class ClusterCreate(BaseModel): verify_tls: bool = True +class ClusterUpdate(BaseModel): + name: str + api_url: str + api_token: str | None = Field(default=None, min_length=8) + provider: str = "proxmox" + mode: str = "read_only" + verify_tls: bool = True + + class TenantCreate(BaseModel): name: str description: str | None = None @@ -157,6 +166,7 @@ class ClusterRead(OrmModel): api_url: str provider: str mode: str + verify_tls: bool last_sync_at: datetime | None last_sync_status: str | None last_sync_error: str | None diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5b2edfa..8b451ef 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -22,6 +22,7 @@ export type Cluster = { api_url: string; provider: string; mode: string; + verify_tls: boolean; last_sync_status: string | null; }; @@ -104,6 +105,7 @@ export type SecurityRule = { export type Policy = { id: string; + project_id: string | null; name: string; version: number; enabled: boolean; diff --git a/frontend/src/components/FormControls.tsx b/frontend/src/components/FormControls.tsx index 008b12a..8779c08 100644 --- a/frontend/src/components/FormControls.tsx +++ b/frontend/src/components/FormControls.tsx @@ -18,4 +18,4 @@ export const inputClass = "h-10 w-full rounded-md border border-border bg-transp export const selectClass = inputClass; export const buttonClass = "inline-flex h-10 items-center justify-center gap-2 rounded-md bg-accent px-4 text-sm font-medium text-white disabled:opacity-50"; export const secondaryButtonClass = "inline-flex h-10 items-center justify-center gap-2 rounded-md border border-border px-4 text-sm hover:bg-slate-100 dark:hover:bg-slate-800"; - +export const iconButtonClass = "inline-flex h-9 w-9 items-center justify-center rounded-md border border-border text-slate-600 hover:bg-slate-100 hover:text-slate-950 disabled:opacity-50 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"; diff --git a/frontend/src/pages/Clusters.tsx b/frontend/src/pages/Clusters.tsx index ea56a4e..85a79d6 100644 --- a/frontend/src/pages/Clusters.tsx +++ b/frontend/src/pages/Clusters.tsx @@ -1,38 +1,71 @@ import { FormEvent, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Cable, Plus, RefreshCcw, Server } from "lucide-react"; +import { Cable, Pencil, Plus, RefreshCcw, Server, Trash2 } from "lucide-react"; import { api, Cluster } from "../api/client"; import { DataTable } from "../components/DataTable"; -import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls"; +import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls"; import { Modal } from "../components/Modal"; import { PageHeader } from "../components/PageHeader"; +const emptyClusterForm = { + name: "Demo Provider", + api_url: "https://demo.local:8006", + api_token: "PVEAPIToken=demo", + provider: "demo", + mode: "read_only", + verify_tls: true, +}; + export function Clusters() { const queryClient = useQueryClient(); const clusters = useQuery({ queryKey: ["clusters"], queryFn: () => api("/clusters") }); - const [form, setForm] = useState({ - name: "Demo Provider", - api_url: "https://demo.local:8006", - api_token: "PVEAPIToken=demo", - provider: "demo", - mode: "read_only", - verify_tls: true, - }); + const [form, setForm] = useState(emptyClusterForm); const [result, setResult] = useState(""); const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(null); - const create = useMutation({ - mutationFn: () => api("/clusters", { method: "POST", body: JSON.stringify(form) }), + const save = useMutation({ + mutationFn: () => { + const body = editing && !form.api_token ? { ...form, api_token: null } : form; + return api(editing ? `/clusters/${editing.id}` : "/clusters", { + method: editing ? "PATCH" : "POST", + body: JSON.stringify(body), + }); + }, onSuccess: () => { setOpen(false); + setEditing(null); queryClient.invalidateQueries({ queryKey: ["clusters"] }); }, }); + const remove = useMutation({ + mutationFn: (cluster: Cluster) => api(`/clusters/${cluster.id}`, { method: "DELETE" }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clusters"] }), + }); async function submit(event: FormEvent) { event.preventDefault(); - await create.mutateAsync(); + await save.mutateAsync(); + } + + function addCluster() { + setEditing(null); + setForm(emptyClusterForm); + setOpen(true); + } + + function editCluster(cluster: Cluster) { + setEditing(cluster); + setForm({ + name: cluster.name, + api_url: cluster.api_url, + api_token: "", + provider: cluster.provider, + mode: cluster.mode, + verify_tls: cluster.verify_tls, + }); + setOpen(true); } async function action(cluster: Cluster, kind: "test" | "sync") { @@ -41,18 +74,24 @@ export function Clusters() { await queryClient.invalidateQueries({ queryKey: ["clusters"] }); } + function deleteCluster(cluster: Cluster) { + if (window.confirm(`Delete cluster "${cluster.name}" and its imported inventory/IPAM data?`)) { + remove.mutate(cluster); + } + } + return ( <>
- - setOpen(false)}> + + setOpen(false)}>
Provider connection
setForm({ ...form, name: event.target.value })} /> setForm({ ...form, api_url: event.target.value })} /> - setForm({ ...form, api_token: event.target.value })} /> + setForm({ ...form, api_token: event.target.value })} /> setForm({ ...form, verify_tls: event.target.checked })} /> Verify TLS - +
@@ -81,16 +120,23 @@ export function Clusters() { { key: "provider", label: "Provider" }, { key: "mode", label: "Mode" }, { key: "last_sync_status", label: "Sync" }, + { + key: "actions", + label: "Actions", + render: (row) => { + const cluster = row as unknown as Cluster; + return ( +
+ + + + +
+ ); + }, + }, ]} /> -
- {(clusters.data ?? []).map((cluster) => ( -
- - -
- ))} -
{result || "No cluster action result yet."}
diff --git a/frontend/src/pages/Policies.tsx b/frontend/src/pages/Policies.tsx index 8cb2a5d..ccc631b 100644 --- a/frontend/src/pages/Policies.tsx +++ b/frontend/src/pages/Policies.tsx @@ -1,10 +1,10 @@ import { FormEvent, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { GitBranch, Play, Plus, Trash2 } from "lucide-react"; +import { Eye, GitBranch, Pencil, Play, Plus, Trash2 } from "lucide-react"; import { api, Policy, Project, ServiceCatalogItem } from "../api/client"; import { DataTable } from "../components/DataTable"; -import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls"; +import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls"; import { Modal } from "../components/Modal"; import { PageHeader } from "../components/PageHeader"; @@ -21,6 +21,21 @@ function policyService(policy: Policy) { return `${String(value.protocol ?? "")}/${String(value.ports ?? "")}`; } +const defaultPolicyForm = { + project_id: "", + name: "Web to DB", + source: "sg:Web Tier", + destination: "sg:Database", + service_id: "", + protocol: "tcp", + ports: "5432", + action: "allow", + direction: "egress", + enforcement_mode: "enforced", + logging: true, + description: "Allow application database traffic", +}; + export function Policies() { const queryClient = useQueryClient(); const policies = useQuery({ queryKey: ["policies"], queryFn: () => api("/policies") }); @@ -28,26 +43,14 @@ export function Policies() { const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api("/service-catalog") }); const [preview, setPreview] = useState(""); const [open, setOpen] = useState(false); - const [form, setForm] = useState({ - project_id: "", - name: "Web to DB", - source: "sg:Web Tier", - destination: "sg:Database", - service_id: "", - protocol: "tcp", - ports: "5432", - action: "allow", - direction: "egress", - enforcement_mode: "enforced", - logging: true, - description: "Allow application database traffic", - }); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(defaultPolicyForm); - const create = useMutation({ + const save = useMutation({ mutationFn: () => { const service = services.data?.find((item) => item.id === form.service_id); - return api("/policies", { - method: "POST", + return api(editing ? `/policies/${editing.id}` : "/policies", { + method: editing ? "PATCH" : "POST", body: JSON.stringify({ project_id: form.project_id || null, name: form.name, @@ -67,6 +70,7 @@ export function Policies() { }, onSuccess: () => { setOpen(false); + setEditing(null); queryClient.invalidateQueries({ queryKey: ["policies"] }); }, }); @@ -77,7 +81,7 @@ export function Policies() { async function submit(event: FormEvent) { event.preventDefault(); - await create.mutateAsync(); + await save.mutateAsync(); } async function compile(policy: Policy) { @@ -91,14 +95,56 @@ export function Policies() { setPreview(JSON.stringify(data, null, 2)); } + function addPolicy() { + setEditing(null); + setForm(defaultPolicyForm); + setOpen(true); + } + + function editPolicy(policy: Policy) { + const service = policy.definition?.service as { protocol?: string; ports?: string } | undefined; + setEditing(policy); + setForm({ + project_id: policy.project_id ?? "", + name: policy.name, + source: policyValue(policy, "source") || "any", + destination: policyValue(policy, "destination") || "any", + service_id: "", + protocol: service?.protocol ?? "tcp", + ports: service?.ports ?? "", + action: policyValue(policy, "action") || "allow", + direction: policyValue(policy, "direction") || "ingress", + enforcement_mode: policy.enforcement_mode || "enforced", + logging: Boolean(policy.definition?.logging), + description: policyValue(policy, "description"), + }); + setOpen(true); + } + + function chooseService(serviceId: string) { + const service = services.data?.find((item) => item.id === serviceId); + setForm({ + ...form, + service_id: serviceId, + protocol: service?.protocol ?? form.protocol, + ports: service?.ports ?? form.ports, + }); + } + + function deletePolicy(policy: Policy) { + if (window.confirm(`Delete policy "${policy.name}"?`)) { + remove.mutate(policy); + } + } + return ( <>
- - setOpen(false)}> + + setOpen(false)}>
-
Add Policy
+
{editing ? "Edit Policy" : "Add Policy"}
setForm({ ...form, name: event.target.value })} /> @@ -106,9 +152,15 @@ export function Policies() { setForm({ ...form, source: event.target.value })} /> setForm({ ...form, destination: event.target.value })} />
- +
- setForm({ ...form, protocol: event.target.value })} /> + + + setForm({ ...form, ports: event.target.value })} />
@@ -122,7 +174,7 @@ export function Policies() { setForm({ ...form, description: event.target.value })} /> - +
@@ -142,10 +194,11 @@ export function Policies() { render: (row) => { const policy = row as unknown as Policy; return ( -
- - - +
+ + + +
); }, diff --git a/frontend/src/pages/PolicyDesigner.tsx b/frontend/src/pages/PolicyDesigner.tsx index b32f855..308c95f 100644 --- a/frontend/src/pages/PolicyDesigner.tsx +++ b/frontend/src/pages/PolicyDesigner.tsx @@ -42,7 +42,6 @@ export function PolicyDesigner() { }, [networks.data, securityGroups.data, workloads.data]); function payload() { - const service = services.data?.find((item) => item.id === form.service_id); return { project_id: null, name: form.name.trim(), @@ -50,7 +49,7 @@ export function PolicyDesigner() { definition: { source: form.source, destination: form.destination, - service: { protocol: service?.protocol ?? form.protocol, ports: service?.ports ?? form.ports }, + service: { protocol: form.protocol, ports: form.ports }, action: form.action, direction: form.direction, enforcement_mode: form.enforcement_mode, @@ -89,6 +88,16 @@ export function PolicyDesigner() { }); } + function chooseService(serviceId: string) { + const service = services.data?.find((item) => item.id === serviceId); + setForm({ + ...form, + service_id: serviceId, + protocol: service?.protocol ?? form.protocol, + ports: service?.ports ?? form.ports, + }); + } + return ( <> @@ -116,7 +125,7 @@ export function PolicyDesigner() { - chooseService(event.target.value)}> {(services.data ?? []).map((service) => )} @@ -128,7 +137,13 @@ export function PolicyDesigner() { - setForm({ ...form, protocol: event.target.value })} /> + + + setForm({ ...form, ports: event.target.value })} />