From a911d36f3418160ab4f9d4d9145546cc1f4fccec Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 9 Jul 2026 12:33:36 +0200 Subject: [PATCH] feat: add comprehensive CRUD endpoints, cluster sync improvements, and firewall orchestration Add create endpoints for users, roles, tenants, projects, networks, subnets, and security rules with audit logging, implement commit_or_400 helper for IntegrityError handling with 409 responses, enhance cluster sync to populate nodes, workloads, and networks from provider inventory with last_sync_at tracking, add update/delete operations for IP addresses and policies with version tracking, implement IP --- backend/app/api/v1/router.py | 371 ++++++++++++++++++++++- backend/app/schemas/domain.py | 101 ++++++ frontend/src/App.tsx | 23 +- frontend/src/api/client.ts | 73 ++++- frontend/src/components/FormControls.tsx | 21 ++ frontend/src/components/Layout.tsx | 2 + frontend/src/pages/Clusters.tsx | 92 ++++++ frontend/src/pages/FirewallPreview.tsx | 65 +++- frontend/src/pages/Ipam.tsx | 94 ++++++ frontend/src/pages/Networks.tsx | 76 +++++ frontend/src/pages/Policies.tsx | 112 +++++++ frontend/src/pages/SecurityGroups.tsx | 101 ++++++ frontend/src/pages/ServiceCatalog.tsx | 42 +++ frontend/src/pages/TenantsProjects.tsx | 65 ++++ frontend/src/pages/UsersRoles.tsx | 65 ++++ 15 files changed, 1267 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/FormControls.tsx create mode 100644 frontend/src/pages/Clusters.tsx create mode 100644 frontend/src/pages/Ipam.tsx create mode 100644 frontend/src/pages/Networks.tsx create mode 100644 frontend/src/pages/Policies.tsx create mode 100644 frontend/src/pages/SecurityGroups.tsx create mode 100644 frontend/src/pages/ServiceCatalog.tsx create mode 100644 frontend/src/pages/TenantsProjects.tsx create mode 100644 frontend/src/pages/UsersRoles.tsx diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 7ef560c..77ac7be 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,19 +1,30 @@ +from datetime import datetime +import csv +import io + from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.api.deps import CurrentUser from app.api.v1 import auth +from app.core.security import hash_password from app.db.session import get_db from app.models.domain import ( AuditLog, Cluster, + IpAddress, Job, Network, Node, Policy, Project, + Role, SecurityGroup, + SecurityRule, + ServiceCatalogItem, Subnet, Tenant, User, @@ -23,19 +34,31 @@ from app.schemas.domain import ( AuditLogRead, ClusterCreate, ClusterRead, + FirewallApplyRequest, FirewallPreview, IpAddressRead, IpReservationCreate, JobRead, + NetworkCreate, NetworkRead, NodeRead, PolicyCreate, PolicyRead, + ProjectCreate, ProjectRead, + RoleCreate, + RoleRead, + SecurityRuleCreate, + SecurityRuleRead, + ServiceCatalogCreate, + ServiceCatalogRead, SecurityGroupCreate, SecurityGroupRead, + SubnetCreate, SubnetRead, + TenantCreate, TenantRead, + UserCreate, UserRead, WorkloadRead, ) @@ -48,6 +71,14 @@ api_router = APIRouter() api_router.include_router(auth.router) +def commit_or_400(db: Session) -> None: + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="Resource conflicts with an existing record") from exc + + @api_router.get("/dashboard") def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict: return { @@ -70,6 +101,37 @@ def users(_: CurrentUser, db: Session = Depends(get_db)) -> list[User]: return db.scalars(select(User).order_by(User.email)).all() +@api_router.post("/users", response_model=UserRead) +def create_user(payload: UserCreate, user: CurrentUser, db: Session = Depends(get_db)) -> User: + roles = db.scalars(select(Role).where(Role.id.in_(payload.role_ids))).all() if payload.role_ids else [] + new_user = User( + email=payload.email.strip().lower(), + display_name=payload.display_name, + password_hash=hash_password(payload.password), + roles=roles, + ) + db.add(new_user) + commit_or_400(db) + db.refresh(new_user) + write_audit(db, action="user.created", object_type="user", object_id=new_user.id, user_id=user.id) + return new_user + + +@api_router.get("/roles", response_model=list[RoleRead]) +def roles(_: CurrentUser, db: Session = Depends(get_db)) -> list[Role]: + return db.scalars(select(Role).order_by(Role.name)).all() + + +@api_router.post("/roles", response_model=RoleRead) +def create_role(payload: RoleCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Role: + role = Role(name=payload.name, permissions=payload.permissions) + db.add(role) + commit_or_400(db) + db.refresh(role) + write_audit(db, action="role.created", object_type="role", object_id=role.id, user_id=user.id) + return role + + @api_router.get("/clusters", response_model=list[ClusterRead]) def clusters(_: CurrentUser, db: Session = Depends(get_db)) -> list[Cluster]: return db.scalars(select(Cluster).order_by(Cluster.name)).all() @@ -80,23 +142,35 @@ def create_cluster(payload: ClusterCreate, user: CurrentUser, db: Session = Depe cluster = Cluster( name=payload.name, api_url=payload.api_url, + provider=payload.provider, token_ref=payload.api_token, mode=payload.mode, verify_tls=payload.verify_tls, ) db.add(cluster) - db.commit() + commit_or_400(db) db.refresh(cluster) write_audit(db, action="cluster.created", object_type="cluster", object_id=cluster.id, user_id=user.id) return cluster @api_router.post("/clusters/{cluster_id}/test") -def test_cluster(cluster_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> dict: +async def test_cluster(cluster_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> dict: cluster = db.get(Cluster, cluster_id) if not cluster: raise HTTPException(status_code=404, detail="Cluster not found") - return {"cluster_id": cluster.id, "status": "configured", "message": "Provider connection is ready for live token validation."} + try: + result = await get_provider(cluster.provider).test_connection( + ProviderConnection( + api_url=cluster.api_url, + token=cluster.token_ref or "", + verify_tls=cluster.verify_tls, + read_only=cluster.mode == "read_only", + ) + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Provider connection failed: {exc}") from exc + return {"cluster_id": cluster.id, "status": "ok", "provider": cluster.provider, "result": result} @api_router.post("/clusters/{cluster_id}/sync") @@ -113,10 +187,65 @@ async def sync_cluster(cluster_id: str, user: CurrentUser, db: Session = Depends read_only=cluster.mode == "read_only", ) ) + cluster.last_sync_at = datetime.utcnow() cluster.last_sync_status = "success" cluster.last_sync_error = None + node_by_name = {node.name: node for node in db.scalars(select(Node).where(Node.cluster_id == cluster.id)).all()} + for raw_node in inventory.get("nodes", []): + name = raw_node.get("node") or raw_node.get("name") + if not name: + continue + node = node_by_name.get(name) + if not node: + node = Node(cluster_id=cluster.id, name=name) + db.add(node) + node_by_name[name] = node + node.status = raw_node.get("status", node.status) + node.cpu_count = int(raw_node.get("maxcpu") or raw_node.get("cpu_count") or node.cpu_count or 0) + maxmem = raw_node.get("maxmem") + node.memory_mb = int(maxmem / 1024 / 1024) if isinstance(maxmem, int | float) else int(raw_node.get("memory_mb") or node.memory_mb or 0) + + db.flush() + workload_by_external_id = { + workload.external_id: workload + for workload in db.scalars(select(Workload).where(Workload.cluster_id == cluster.id)).all() + } + for raw_workload in inventory.get("workloads", []): + external_id = str(raw_workload.get("vmid") or raw_workload.get("id") or "") + if not external_id: + continue + node_name = raw_workload.get("node") + node = node_by_name.get(node_name) or next(iter(node_by_name.values()), None) + if not node: + continue + workload = workload_by_external_id.get(external_id) + if not workload: + workload = Workload(cluster_id=cluster.id, node_id=node.id, external_id=external_id, name=external_id, kind="qemu") + db.add(workload) + workload_by_external_id[external_id] = workload + workload.node_id = node.id + workload.name = raw_workload.get("name") or workload.name + workload.kind = raw_workload.get("type") or raw_workload.get("kind") or workload.kind + workload.status = raw_workload.get("status") or workload.status + + network_by_name = { + network.name: network + for network in db.scalars(select(Network).where(Network.cluster_id == cluster.id)).all() + } + for raw_network in inventory.get("networks", []): + name = raw_network.get("name") or raw_network.get("iface") or raw_network.get("id") + if not name: + continue + network = network_by_name.get(name) + if not network: + network = Network(cluster_id=cluster.id, name=name, kind=raw_network.get("type") or "network") + db.add(network) + network_by_name[name] = network + network.kind = raw_network.get("type") or raw_network.get("kind") or network.kind + vlan = raw_network.get("vlan") or raw_network.get("vlan_id") + network.vlan_id = int(vlan) if vlan not in (None, "") else network.vlan_id db.add(Job(kind="proxmox.sync", status="success", progress=100, logs=[f"Synced {cluster.name}"])) - db.commit() + commit_or_400(db) write_audit(db, action="cluster.sync", object_type="cluster", object_id=cluster.id, user_id=user.id) return {"cluster_id": cluster.id, "status": "success", "inventory_counts": {key: len(value) for key, value in inventory.items()}} @@ -136,40 +265,134 @@ def networks(_: CurrentUser, db: Session = Depends(get_db)) -> list[Network]: return db.scalars(select(Network).order_by(Network.name)).all() +@api_router.post("/networks", response_model=NetworkRead) +def create_network(payload: NetworkCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Network: + if not db.get(Cluster, payload.cluster_id): + raise HTTPException(status_code=404, detail="Cluster not found") + network = Network(**payload.model_dump()) + db.add(network) + commit_or_400(db) + db.refresh(network) + write_audit(db, action="network.created", object_type="network", object_id=network.id, user_id=user.id) + return network + + @api_router.get("/ipam/subnets", response_model=list[SubnetRead]) def subnets(_: CurrentUser, db: Session = Depends(get_db)) -> list[Subnet]: return db.scalars(select(Subnet).order_by(Subnet.cidr)).all() -@api_router.get("/ipam/addresses", response_model=list[IpAddressRead]) -def ipam_addresses(_: CurrentUser, db: Session = Depends(get_db)): - from app.models.domain import IpAddress +@api_router.post("/ipam/subnets", response_model=SubnetRead) +def create_subnet(payload: SubnetCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Subnet: + if not db.get(Network, payload.network_id): + raise HTTPException(status_code=404, detail="Network not found") + subnet = Subnet(**payload.model_dump()) + db.add(subnet) + commit_or_400(db) + db.refresh(subnet) + write_audit(db, action="ipam.subnet.created", object_type="subnet", object_id=subnet.id, user_id=user.id) + return subnet + +@api_router.get("/ipam/addresses", response_model=list[IpAddressRead]) +def ipam_addresses(_: CurrentUser, db: Session = Depends(get_db)) -> list[IpAddress]: return db.scalars(select(IpAddress).order_by(IpAddress.address)).all() @api_router.post("/ipam/addresses", response_model=IpAddressRead) -def reserve_ip(payload: IpReservationCreate, user: CurrentUser, db: Session = Depends(get_db)): - from app.models.domain import IpAddress - +def reserve_ip(payload: IpReservationCreate, user: CurrentUser, db: Session = Depends(get_db)) -> IpAddress: + if not db.get(Subnet, payload.subnet_id): + raise HTTPException(status_code=404, detail="Subnet not found") address = IpAddress(subnet_id=payload.subnet_id, address=payload.address, status=payload.status, note=payload.note) db.add(address) - db.commit() + commit_or_400(db) db.refresh(address) write_audit(db, action="ipam.address.created", object_type="ip_address", object_id=address.id, user_id=user.id) return address +@api_router.patch("/ipam/addresses/{address_id}", response_model=IpAddressRead) +def update_ip(address_id: str, payload: IpReservationCreate, user: CurrentUser, db: Session = Depends(get_db)) -> IpAddress: + address = db.get(IpAddress, address_id) + if not address: + raise HTTPException(status_code=404, detail="IP address not found") + old_values = {"address": address.address, "status": address.status, "note": address.note} + address.subnet_id = payload.subnet_id + address.address = payload.address + address.status = payload.status + address.note = payload.note + commit_or_400(db) + db.refresh(address) + write_audit( + db, + action="ipam.address.updated", + object_type="ip_address", + object_id=address.id, + user_id=user.id, + old_values=old_values, + new_values={"address": address.address, "status": address.status, "note": address.note}, + ) + return address + + +@api_router.delete("/ipam/addresses/{address_id}") +def delete_ip(address_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]: + address = db.get(IpAddress, address_id) + if not address: + raise HTTPException(status_code=404, detail="IP address not found") + db.delete(address) + commit_or_400(db) + write_audit(db, action="ipam.address.deleted", object_type="ip_address", object_id=address_id, user_id=user.id) + return {"status": "deleted", "id": address_id} + + +@api_router.get("/ipam/export.csv") +def export_ipam(_: CurrentUser, db: Session = Depends(get_db)) -> StreamingResponse: + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow(["subnet_id", "address", "status", "workload_id", "note"]) + for address in db.scalars(select(IpAddress).order_by(IpAddress.address)): + writer.writerow([address.subnet_id, address.address, address.status, address.workload_id or "", address.note or ""]) + buffer.seek(0) + return StreamingResponse( + iter([buffer.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=nexafabric-ipam.csv"}, + ) + + @api_router.get("/tenants", response_model=list[TenantRead]) def tenants(_: CurrentUser, db: Session = Depends(get_db)) -> list[Tenant]: return db.scalars(select(Tenant).order_by(Tenant.name)).all() +@api_router.post("/tenants", response_model=TenantRead) +def create_tenant(payload: TenantCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Tenant: + tenant = Tenant(**payload.model_dump()) + db.add(tenant) + commit_or_400(db) + db.refresh(tenant) + write_audit(db, action="tenant.created", object_type="tenant", object_id=tenant.id, user_id=user.id) + return tenant + + @api_router.get("/projects", response_model=list[ProjectRead]) def projects(_: CurrentUser, db: Session = Depends(get_db)) -> list[Project]: return db.scalars(select(Project).order_by(Project.name)).all() +@api_router.post("/projects", response_model=ProjectRead) +def create_project(payload: ProjectCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Project: + if not db.get(Tenant, payload.tenant_id): + raise HTTPException(status_code=404, detail="Tenant not found") + project = Project(**payload.model_dump()) + db.add(project) + commit_or_400(db) + db.refresh(project) + write_audit(db, action="project.created", object_type="project", object_id=project.id, user_id=user.id) + return project + + @api_router.get("/security-groups", response_model=list[SecurityGroupRead]) def security_groups(_: CurrentUser, db: Session = Depends(get_db)) -> list[SecurityGroup]: return db.scalars(select(SecurityGroup).order_by(SecurityGroup.name)).all() @@ -179,12 +402,44 @@ def security_groups(_: CurrentUser, db: Session = Depends(get_db)) -> list[Secur def create_security_group(payload: SecurityGroupCreate, user: CurrentUser, db: Session = Depends(get_db)) -> SecurityGroup: group = SecurityGroup(project_id=payload.project_id, name=payload.name, description=payload.description) db.add(group) - db.commit() + commit_or_400(db) db.refresh(group) write_audit(db, action="security_group.created", object_type="security_group", object_id=group.id, user_id=user.id) return group +@api_router.get("/security-groups/{group_id}/rules", response_model=list[SecurityRuleRead]) +def security_group_rules(group_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> list[SecurityRule]: + return db.scalars( + select(SecurityRule) + .where(SecurityRule.security_group_id == group_id) + .order_by(SecurityRule.priority, SecurityRule.created_at) + ).all() + + +@api_router.post("/security-rules", response_model=SecurityRuleRead) +def create_security_rule(payload: SecurityRuleCreate, user: CurrentUser, db: Session = Depends(get_db)) -> SecurityRule: + if not db.get(SecurityGroup, payload.security_group_id): + raise HTTPException(status_code=404, detail="Security group not found") + rule = SecurityRule(**payload.model_dump()) + db.add(rule) + commit_or_400(db) + db.refresh(rule) + write_audit(db, action="security_rule.created", object_type="security_rule", object_id=rule.id, user_id=user.id) + return rule + + +@api_router.delete("/security-rules/{rule_id}") +def delete_security_rule(rule_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> dict[str, str]: + rule = db.get(SecurityRule, rule_id) + if not rule: + raise HTTPException(status_code=404, detail="Security rule not found") + db.delete(rule) + commit_or_400(db) + write_audit(db, action="security_rule.deleted", object_type="security_rule", object_id=rule_id, user_id=user.id) + return {"status": "deleted", "id": rule_id} + + @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() @@ -194,12 +449,44 @@ def policies(_: CurrentUser, db: Session = Depends(get_db)) -> list[Policy]: def create_policy(payload: PolicyCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Policy: policy = Policy(project_id=payload.project_id, name=payload.name, enabled=payload.enabled, definition=payload.definition) db.add(policy) - db.commit() + commit_or_400(db) db.refresh(policy) write_audit(db, action="policy.created", object_type="policy", object_id=policy.id, user_id=user.id) return policy +@api_router.patch("/policies/{policy_id}", response_model=PolicyRead) +def update_policy(policy_id: str, payload: PolicyCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Policy: + policy = db.get(Policy, policy_id) + if not policy: + raise HTTPException(status_code=404, detail="Policy not found") + old_values = {"name": policy.name, "enabled": policy.enabled, "definition": policy.definition, "version": policy.version} + policy.project_id = payload.project_id + policy.name = payload.name + policy.enabled = payload.enabled + policy.definition = payload.definition + policy.version += 1 + commit_or_400(db) + db.refresh(policy) + write_audit(db, action="policy.updated", object_type="policy", object_id=policy.id, user_id=user.id, old_values=old_values, new_values=payload.model_dump()) + return policy + + +@api_router.post("/policies/{policy_id}/compile", response_model=PolicyRead) +def compile_policy(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> Policy: + from app.services.policy_engine import PolicyEngine + + policy = db.get(Policy, policy_id) + if not policy: + raise HTTPException(status_code=404, detail="Policy not found") + policy.last_compiled = PolicyEngine().compile(policy) + db.add(Job(kind="policy.compile", status="success", progress=100, logs=[f"Compiled policy {policy.name}"])) + commit_or_400(db) + db.refresh(policy) + write_audit(db, action="policy.compiled", object_type="policy", object_id=policy.id, user_id=user.id, new_values=policy.last_compiled) + return policy + + @api_router.post("/firewall/preview/{policy_id}", response_model=FirewallPreview) async def firewall_preview(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> FirewallPreview: policy = db.get(Policy, policy_id) @@ -211,6 +498,64 @@ async def firewall_preview(policy_id: str, user: CurrentUser, db: Session = Depe return preview +@api_router.post("/firewall/apply") +async def firewall_apply(payload: FirewallApplyRequest, user: CurrentUser, db: Session = Depends(get_db)) -> dict: + if not payload.confirm: + raise HTTPException(status_code=400, detail="Firewall apply requires confirm=true after preview review") + policy = db.get(Policy, payload.policy_id) + cluster = db.get(Cluster, payload.cluster_id) if payload.cluster_id else db.scalar(select(Cluster).order_by(Cluster.name).limit(1)) + if not policy or not cluster: + raise HTTPException(status_code=404, detail="Policy or cluster not found") + preview = await FirewallOrchestrator().preview(cluster, policy) + provider = get_provider(cluster.provider) + result = await provider.apply_rules( + ProviderConnection( + api_url=cluster.api_url, + token=cluster.token_ref or "", + verify_tls=cluster.verify_tls, + read_only=payload.dry_run or cluster.mode == "read_only", + ), + preview.generated_rules, + ) + job = Job( + kind="firewall.apply", + status="success" if result.get("applied") else "failed", + progress=100, + started_at=datetime.utcnow(), + finished_at=datetime.utcnow(), + logs=[f"Policy {policy.name}", f"Dry run: {payload.dry_run}", str(result)], + error=None if result.get("applied") else result.get("reason", "Provider did not apply rules"), + ) + db.add(job) + commit_or_400(db) + write_audit( + db, + action="firewall.apply", + object_type="policy", + object_id=policy.id, + user_id=user.id, + new_values={"request": payload.model_dump(), "result": result}, + result="success" if result.get("applied") else "blocked", + error_text=None if result.get("applied") else result.get("reason"), + ) + return {"job_id": job.id, "preview": preview.model_dump(), "provider_result": result} + + +@api_router.get("/service-catalog", response_model=list[ServiceCatalogRead]) +def service_catalog(_: CurrentUser, db: Session = Depends(get_db)) -> list[ServiceCatalogItem]: + return db.scalars(select(ServiceCatalogItem).order_by(ServiceCatalogItem.name)).all() + + +@api_router.post("/service-catalog", response_model=ServiceCatalogRead) +def create_service(payload: ServiceCatalogCreate, user: CurrentUser, db: Session = Depends(get_db)) -> ServiceCatalogItem: + service = ServiceCatalogItem(**payload.model_dump()) + db.add(service) + commit_or_400(db) + db.refresh(service) + write_audit(db, action="service.created", object_type="service_catalog", object_id=service.id, user_id=user.id) + return service + + @api_router.get("/jobs", response_model=list[JobRead]) def jobs(_: CurrentUser, db: Session = Depends(get_db)) -> list[Job]: return db.scalars(select(Job).order_by(Job.created_at.desc())).all() diff --git a/backend/app/schemas/domain.py b/backend/app/schemas/domain.py index f47ac2e..b96ed11 100644 --- a/backend/app/schemas/domain.py +++ b/backend/app/schemas/domain.py @@ -26,20 +26,85 @@ class UserRead(OrmModel): is_active: bool +class UserCreate(BaseModel): + email: str + display_name: str + password: str = Field(min_length=12) + role_ids: list[str] = [] + + +class RoleCreate(BaseModel): + name: str + permissions: list[str] = [] + + +class RoleRead(OrmModel): + id: str + name: str + permissions: list[str] + + class ClusterCreate(BaseModel): name: str api_url: str api_token: str = Field(min_length=8) + provider: str = "proxmox" mode: str = "read_only" verify_tls: bool = True +class TenantCreate(BaseModel): + name: str + description: str | None = None + + +class ProjectCreate(BaseModel): + tenant_id: str + name: str + description: str | None = None + + +class NetworkCreate(BaseModel): + cluster_id: str + project_id: str | None = None + name: str + kind: str = "bridge" + vlan_id: int | None = None + mtu: int = 1500 + gateway: str | None = None + dns: list[str] = [] + dhcp_enabled: bool = False + tags: list[str] = [] + description: str | None = None + + +class SubnetCreate(BaseModel): + network_id: str + cidr: str + gateway: str | None = None + dns: list[str] = [] + dhcp_enabled: bool = False + + class SecurityGroupCreate(BaseModel): project_id: str | None = None name: str description: str | None = None +class SecurityRuleCreate(BaseModel): + security_group_id: str + direction: str = "ingress" + action: str = "allow" + protocol: str = "tcp" + source: str = "any" + destination: str = "any" + port: str | None = None + priority: int = 1000 + logging: bool = False + description: str | None = None + + class PolicyCreate(BaseModel): project_id: str | None = None name: str @@ -54,6 +119,20 @@ class IpReservationCreate(BaseModel): note: str | None = None +class ServiceCatalogCreate(BaseModel): + name: str + protocol: str + ports: str + editable: bool = True + + +class FirewallApplyRequest(BaseModel): + policy_id: str + cluster_id: str | None = None + confirm: bool = False + dry_run: bool = True + + class ClusterRead(OrmModel): id: str name: str @@ -139,6 +218,20 @@ class SecurityGroupRead(OrmModel): description: str | None +class SecurityRuleRead(OrmModel): + id: str + security_group_id: str + direction: str + action: str + protocol: str + source: str + destination: str + port: str | None + priority: int + logging: bool + description: str | None + + class PolicyRead(OrmModel): id: str project_id: str | None @@ -149,6 +242,14 @@ class PolicyRead(OrmModel): last_compiled: dict[str, Any] | None +class ServiceCatalogRead(OrmModel): + id: str + name: str + protocol: str + ports: str + editable: bool + + class FirewallPreview(BaseModel): policy_id: str dry_run: bool = True diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c883352..5e8a7dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,9 +5,17 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import { Layout } from "./components/Layout"; import { Dashboard } from "./pages/Dashboard"; import { FirewallPreview } from "./pages/FirewallPreview"; +import { Clusters } from "./pages/Clusters"; +import { Ipam } from "./pages/Ipam"; import { ListPage } from "./pages/ListPage"; import { Login } from "./pages/Login"; +import { Networks } from "./pages/Networks"; +import { Policies } from "./pages/Policies"; import { PolicyDesigner } from "./pages/PolicyDesigner"; +import { SecurityGroups } from "./pages/SecurityGroups"; +import { ServiceCatalog } from "./pages/ServiceCatalog"; +import { TenantsProjects } from "./pages/TenantsProjects"; +import { UsersRoles } from "./pages/UsersRoles"; import { useTheme } from "./stores/theme"; const queryClient = new QueryClient(); @@ -26,19 +34,20 @@ export function App() { } /> }> } /> - } /> + } /> } /> } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 42f104c..9232ed2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -19,6 +19,48 @@ export type Cluster = { last_sync_status: string | null; }; +export type Project = { + id: string; + name: string; + tenant_id: string; + description: string | null; +}; + +export type Tenant = { + id: string; + name: string; + description: string | null; +}; + +export type User = { + id: string; + email: string; + display_name: string; + is_active: boolean; +}; + +export type Role = { + id: string; + name: string; + permissions: string[]; +}; + +export type Subnet = { + id: string; + network_id: string; + cidr: string; + gateway: string | null; + dhcp_enabled: boolean; +}; + +export type IpAddress = { + id: string; + subnet_id: string; + address: string; + status: string; + note: string | null; +}; + export type Network = { id: string; name: string; @@ -29,12 +71,42 @@ export type Network = { tags: string[]; }; +export type SecurityGroup = { + id: string; + project_id: string | null; + name: string; + description: string | null; +}; + +export type SecurityRule = { + id: string; + security_group_id: string; + direction: string; + action: string; + protocol: string; + source: string; + destination: string; + port: string | null; + priority: number; + logging: boolean; + description: string | null; +}; + export type Policy = { id: string; name: string; version: number; enabled: boolean; definition: Record; + last_compiled: Record | null; +}; + +export type ServiceCatalogItem = { + id: string; + name: string; + protocol: string; + ports: string; + editable: boolean; }; export type AuditLog = { @@ -76,4 +148,3 @@ export async function login(email: string, password: string) { setToken(data.access_token); return data; } - diff --git a/frontend/src/components/FormControls.tsx b/frontend/src/components/FormControls.tsx new file mode 100644 index 0000000..008b12a --- /dev/null +++ b/frontend/src/components/FormControls.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from "react"; + +type FieldProps = { + label: string; + children: ReactNode; +}; + +export function Field({ label, children }: FieldProps) { + return ( + + ); +} + +export const inputClass = "h-10 w-full rounded-md border border-border bg-transparent px-3 text-sm outline-none focus:border-accent"; +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"; + diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 3472ebc..08aa0ba 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -15,6 +15,7 @@ import { Server, Settings, Shield, + SquareStack, Sun, Users, } from "lucide-react"; @@ -33,6 +34,7 @@ const nav = [ { to: "/tenants", label: "Tenants", icon: BriefcaseBusiness }, { to: "/security-groups", label: "Security Groups", icon: Shield }, { to: "/policies", label: "Policies", icon: GitBranch }, + { to: "/services", label: "Service Catalog", icon: SquareStack }, { to: "/designer", label: "Policy Designer", icon: LockKeyhole }, { to: "/firewall", label: "Firewall Preview", icon: Flame }, { to: "/jobs", label: "Jobs", icon: ClipboardList }, diff --git a/frontend/src/pages/Clusters.tsx b/frontend/src/pages/Clusters.tsx new file mode 100644 index 0000000..416a980 --- /dev/null +++ b/frontend/src/pages/Clusters.tsx @@ -0,0 +1,92 @@ +import { FormEvent, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Cable, RefreshCcw, Server } from "lucide-react"; + +import { api, Cluster } from "../api/client"; +import { DataTable } from "../components/DataTable"; +import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls"; +import { PageHeader } from "../components/PageHeader"; + +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 [result, setResult] = useState(""); + + const create = useMutation({ + mutationFn: () => api("/clusters", { method: "POST", body: JSON.stringify(form) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clusters"] }), + }); + + async function submit(event: FormEvent) { + event.preventDefault(); + await create.mutateAsync(); + } + + async function action(cluster: Cluster, kind: "test" | "sync") { + const data = await api>(`/clusters/${cluster.id}/${kind}`, { method: "POST" }); + setResult(JSON.stringify(data, null, 2)); + await queryClient.invalidateQueries({ queryKey: ["clusters"] }); + } + + return ( + <> + +
+
+
Add Cluster
+
+ setForm({ ...form, name: event.target.value })} /> + setForm({ ...form, api_url: event.target.value })} /> + setForm({ ...form, api_token: event.target.value })} /> + + + + + + + + +
+
+
+ []} + columns={[ + { key: "name", label: "Name" }, + { key: "provider", label: "Provider" }, + { key: "mode", label: "Mode" }, + { key: "last_sync_status", label: "Sync" }, + ]} + /> +
+ {(clusters.data ?? []).map((cluster) => ( +
+ + +
+ ))} +
+
{result || "No cluster action result yet."}
+
+
+ + ); +} + diff --git a/frontend/src/pages/FirewallPreview.tsx b/frontend/src/pages/FirewallPreview.tsx index 3887236..d4db96a 100644 --- a/frontend/src/pages/FirewallPreview.tsx +++ b/frontend/src/pages/FirewallPreview.tsx @@ -1,33 +1,68 @@ import { useMutation, useQuery } from "@tanstack/react-query"; -import { Play } from "lucide-react"; +import { Play, ShieldCheck } from "lucide-react"; +import { useState } from "react"; -import { api, Policy } from "../api/client"; +import { api, Cluster, Policy } from "../api/client"; +import { buttonClass, Field, secondaryButtonClass, selectClass } from "../components/FormControls"; import { PageHeader } from "../components/PageHeader"; export function FirewallPreview() { const policies = useQuery({ queryKey: ["policies"], queryFn: () => api("/policies") }); + const clusters = useQuery({ queryKey: ["clusters"], queryFn: () => api("/clusters") }); + const [policyId, setPolicyId] = useState(""); + const [clusterId, setClusterId] = useState(""); + const [dryRun, setDryRun] = useState(true); const preview = useMutation({ mutationFn: (policyId: string) => api>(`/firewall/preview/${policyId}`, { method: "POST" }), }); - const firstPolicy = policies.data?.[0]; + const apply = useMutation({ + mutationFn: () => + api>("/firewall/apply", { + method: "POST", + body: JSON.stringify({ + policy_id: policyId || policies.data?.[0]?.id, + cluster_id: clusterId || clusters.data?.[0]?.id, + confirm: true, + dry_run: dryRun, + }), + }), + }); + const selectedPolicyId = policyId || policies.data?.[0]?.id || ""; return ( <> -
- -
-          {preview.data ? JSON.stringify(preview.data, null, 2) : "No preview generated yet."}
+      
+
+
+ + + + + + + + + +
+
+
+          {apply.data ? JSON.stringify(apply.data, null, 2) : preview.data ? JSON.stringify(preview.data, null, 2) : "No firewall output yet."}
         
); } - diff --git a/frontend/src/pages/Ipam.tsx b/frontend/src/pages/Ipam.tsx new file mode 100644 index 0000000..0601649 --- /dev/null +++ b/frontend/src/pages/Ipam.tsx @@ -0,0 +1,94 @@ +import { FormEvent, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Database, Download, Plus } from "lucide-react"; + +import { api, IpAddress, Network, Subnet, token } from "../api/client"; +import { DataTable } from "../components/DataTable"; +import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls"; +import { PageHeader } from "../components/PageHeader"; + +export function Ipam() { + const queryClient = useQueryClient(); + const networks = useQuery({ queryKey: ["networks"], queryFn: () => api("/networks") }); + const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api("/ipam/subnets") }); + const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api("/ipam/addresses") }); + const [subnetForm, setSubnetForm] = useState({ network_id: "", cidr: "10.50.0.0/24", gateway: "10.50.0.1", dns: ["10.50.0.10"], dhcp_enabled: false }); + const [ipForm, setIpForm] = useState({ subnet_id: "", address: "10.50.0.20", status: "reserved", note: "" }); + + const createSubnet = useMutation({ + mutationFn: () => api("/ipam/subnets", { method: "POST", body: JSON.stringify({ ...subnetForm, network_id: subnetForm.network_id || networks.data?.[0]?.id }) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["subnets"] }), + }); + const createIp = useMutation({ + mutationFn: () => api("/ipam/addresses", { method: "POST", body: JSON.stringify({ ...ipForm, subnet_id: ipForm.subnet_id || subnets.data?.[0]?.id }) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["addresses"] }), + }); + + async function submitSubnet(event: FormEvent) { + event.preventDefault(); + await createSubnet.mutateAsync(); + } + + async function submitIp(event: FormEvent) { + event.preventDefault(); + await createIp.mutateAsync(); + } + + async function exportCsv() { + const response = await fetch("/api/v1/ipam/export.csv", { + headers: token() ? { Authorization: `Bearer ${token()}` } : {}, + }); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "nexafabric-ipam.csv"; + link.click(); + URL.revokeObjectURL(url); + } + + return ( + <> + +
+
+
Add Subnet
+
+ + + + setSubnetForm({ ...subnetForm, cidr: event.target.value })} /> + setSubnetForm({ ...subnetForm, gateway: event.target.value })} /> + +
+
+
+
Reserve IP
+
+ + + + setIpForm({ ...ipForm, address: event.target.value })} /> + + + + setIpForm({ ...ipForm, note: event.target.value })} /> + +
+
+
+ + []} columns={[{ key: "address", label: "Address" }, { key: "status", label: "Status" }, { key: "note", label: "Note" }]} /> +
+
+ + ); +} diff --git a/frontend/src/pages/Networks.tsx b/frontend/src/pages/Networks.tsx new file mode 100644 index 0000000..905c121 --- /dev/null +++ b/frontend/src/pages/Networks.tsx @@ -0,0 +1,76 @@ +import { FormEvent, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Network as NetworkIcon, Plus } from "lucide-react"; + +import { api, Cluster, Network, Project } from "../api/client"; +import { DataTable } from "../components/DataTable"; +import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls"; +import { PageHeader } from "../components/PageHeader"; + +export function Networks() { + const queryClient = useQueryClient(); + const clusters = useQuery({ queryKey: ["clusters"], queryFn: () => api("/clusters") }); + const projects = useQuery({ queryKey: ["projects"], queryFn: () => api("/projects") }); + const networks = useQuery({ queryKey: ["networks"], queryFn: () => api("/networks") }); + const [form, setForm] = useState({ + cluster_id: "", + project_id: "", + name: "tenant-vlan-50", + kind: "vlan", + vlan_id: "50", + mtu: "1500", + gateway: "10.50.0.1", + description: "Tenant VLAN", + }); + const create = useMutation({ + mutationFn: () => + api("/networks", { + method: "POST", + body: JSON.stringify({ + cluster_id: form.cluster_id || clusters.data?.[0]?.id, + project_id: form.project_id || null, + name: form.name, + kind: form.kind, + vlan_id: form.vlan_id ? Number(form.vlan_id) : null, + mtu: Number(form.mtu), + gateway: form.gateway || null, + dns: [], + dhcp_enabled: false, + tags: [], + description: form.description, + }), + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["networks"] }), + }); + + async function submit(event: FormEvent) { + event.preventDefault(); + await create.mutateAsync(); + } + + return ( + <> + +
+
+
Add Network
+
+ + + setForm({ ...form, name: event.target.value })} /> +
+ + setForm({ ...form, vlan_id: event.target.value })} /> + setForm({ ...form, mtu: event.target.value })} /> +
+ setForm({ ...form, gateway: event.target.value })} /> + setForm({ ...form, description: event.target.value })} /> + +
+
+ []} columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "vlan_id", label: "VLAN" }, { key: "gateway", label: "Gateway" }, { key: "mtu", label: "MTU" }]} /> +
+ + ); +} + diff --git a/frontend/src/pages/Policies.tsx b/frontend/src/pages/Policies.tsx new file mode 100644 index 0000000..9a6e337 --- /dev/null +++ b/frontend/src/pages/Policies.tsx @@ -0,0 +1,112 @@ +import { FormEvent, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { GitBranch, Play, Plus } 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 { PageHeader } from "../components/PageHeader"; + +export function Policies() { + const queryClient = useQueryClient(); + const policies = useQuery({ queryKey: ["policies"], queryFn: () => api("/policies") }); + const projects = useQuery({ queryKey: ["projects"], queryFn: () => api("/projects") }); + const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api("/service-catalog") }); + const [preview, setPreview] = useState(""); + 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", + logging: true, + description: "Allow application database traffic", + }); + + const create = useMutation({ + mutationFn: () => { + const service = services.data?.find((item) => item.id === form.service_id); + return api("/policies", { + method: "POST", + body: JSON.stringify({ + project_id: form.project_id || null, + name: form.name, + enabled: true, + definition: { + source: form.source, + destination: form.destination, + service: { protocol: service?.protocol ?? form.protocol, ports: service?.ports ?? form.ports }, + action: form.action, + direction: form.direction, + logging: form.logging, + description: form.description, + }, + }), + }); + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["policies"] }), + }); + + async function submit(event: FormEvent) { + event.preventDefault(); + await create.mutateAsync(); + } + + async function compile(policy: Policy) { + const data = await api(`/policies/${policy.id}/compile`, { method: "POST" }); + setPreview(JSON.stringify(data.last_compiled, null, 2)); + await queryClient.invalidateQueries({ queryKey: ["policies"] }); + } + + async function firewallPreview(policy: Policy) { + const data = await api>(`/firewall/preview/${policy.id}`, { method: "POST" }); + setPreview(JSON.stringify(data, null, 2)); + } + + return ( + <> + +
+
+
Add Policy
+
+ + setForm({ ...form, name: event.target.value })} /> +
+ setForm({ ...form, source: event.target.value })} /> + setForm({ ...form, destination: event.target.value })} /> +
+ +
+ setForm({ ...form, protocol: event.target.value })} /> + setForm({ ...form, ports: event.target.value })} /> +
+
+ + +
+ setForm({ ...form, description: event.target.value })} /> + +
+
+
+ []} columns={[{ key: "name", label: "Policy" }, { key: "version", label: "Version" }, { key: "enabled", label: "Enabled" }]} /> +
+ {(policies.data ?? []).map((policy) => ( +
+ + +
+ ))} +
+
{preview || "No policy output yet."}
+
+
+ + ); +} + diff --git a/frontend/src/pages/SecurityGroups.tsx b/frontend/src/pages/SecurityGroups.tsx new file mode 100644 index 0000000..be95c29 --- /dev/null +++ b/frontend/src/pages/SecurityGroups.tsx @@ -0,0 +1,101 @@ +import { FormEvent, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, Shield } from "lucide-react"; + +import { api, Project, SecurityGroup, SecurityRule } from "../api/client"; +import { DataTable } from "../components/DataTable"; +import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls"; +import { PageHeader } from "../components/PageHeader"; + +export function SecurityGroups() { + const queryClient = useQueryClient(); + const projects = useQuery({ queryKey: ["projects"], queryFn: () => api("/projects") }); + const groups = useQuery({ queryKey: ["security-groups"], queryFn: () => api("/security-groups") }); + const [selectedGroupId, setSelectedGroupId] = useState(""); + const selectedGroup = useMemo(() => selectedGroupId || groups.data?.[0]?.id || "", [groups.data, selectedGroupId]); + const rules = useQuery({ + queryKey: ["security-rules", selectedGroup], + queryFn: () => api(`/security-groups/${selectedGroup}/rules`), + enabled: Boolean(selectedGroup), + }); + const [groupForm, setGroupForm] = useState({ project_id: "", name: "Web Tier", description: "Application frontend workloads" }); + const [ruleForm, setRuleForm] = useState({ + direction: "ingress", + action: "allow", + protocol: "tcp", + source: "any", + destination: "sg:Web Tier", + port: "443", + priority: 1000, + logging: true, + description: "Allow HTTPS", + }); + + const createGroup = useMutation({ + mutationFn: () => api("/security-groups", { method: "POST", body: JSON.stringify({ ...groupForm, project_id: groupForm.project_id || null }) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["security-groups"] }), + }); + const createRule = useMutation({ + mutationFn: () => api("/security-rules", { method: "POST", body: JSON.stringify({ ...ruleForm, security_group_id: selectedGroup }) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["security-rules", selectedGroup] }), + }); + + async function submitGroup(event: FormEvent) { + event.preventDefault(); + const group = await createGroup.mutateAsync(); + setSelectedGroupId(group.id); + } + + async function submitRule(event: FormEvent) { + event.preventDefault(); + await createRule.mutateAsync(); + } + + return ( + <> + +
+
+
Add Group
+
+ + + + setGroupForm({ ...groupForm, name: event.target.value })} /> + setGroupForm({ ...groupForm, description: event.target.value })} /> + +
+
+
+
Add Rule
+
+ + + +
+ + +
+ setRuleForm({ ...ruleForm, source: event.target.value })} /> + setRuleForm({ ...ruleForm, destination: event.target.value })} /> +
+ setRuleForm({ ...ruleForm, protocol: event.target.value })} /> + setRuleForm({ ...ruleForm, port: event.target.value })} /> +
+ +
+
+
+ []} columns={[{ key: "name", label: "Group" }, { key: "description", label: "Description" }]} /> + []} columns={[{ key: "priority", label: "Priority" }, { key: "direction", label: "Direction" }, { key: "action", label: "Action" }, { key: "protocol", label: "Protocol" }, { key: "port", label: "Port" }]} /> +
+
+ + ); +} + diff --git a/frontend/src/pages/ServiceCatalog.tsx b/frontend/src/pages/ServiceCatalog.tsx new file mode 100644 index 0000000..cbb911d --- /dev/null +++ b/frontend/src/pages/ServiceCatalog.tsx @@ -0,0 +1,42 @@ +import { FormEvent, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, SquareStack } from "lucide-react"; + +import { api, ServiceCatalogItem } from "../api/client"; +import { DataTable } from "../components/DataTable"; +import { buttonClass, Field, inputClass } from "../components/FormControls"; +import { PageHeader } from "../components/PageHeader"; + +export function ServiceCatalog() { + const queryClient = useQueryClient(); + const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api("/service-catalog") }); + const [form, setForm] = useState({ name: "Custom API", protocol: "tcp", ports: "8443", editable: true }); + const create = useMutation({ + mutationFn: () => api("/service-catalog", { method: "POST", body: JSON.stringify(form) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["service-catalog"] }), + }); + + async function submit(event: FormEvent) { + event.preventDefault(); + await create.mutateAsync(); + } + + return ( + <> + +
+
+
Add Service
+
+ setForm({ ...form, name: event.target.value })} /> + setForm({ ...form, protocol: event.target.value })} /> + setForm({ ...form, ports: event.target.value })} /> + +
+
+ []} columns={[{ key: "name", label: "Name" }, { key: "protocol", label: "Protocol" }, { key: "ports", label: "Ports" }]} /> +
+ + ); +} + diff --git a/frontend/src/pages/TenantsProjects.tsx b/frontend/src/pages/TenantsProjects.tsx new file mode 100644 index 0000000..5f0d6f4 --- /dev/null +++ b/frontend/src/pages/TenantsProjects.tsx @@ -0,0 +1,65 @@ +import { FormEvent, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { BriefcaseBusiness, Plus } from "lucide-react"; + +import { api, Project, Tenant } from "../api/client"; +import { DataTable } from "../components/DataTable"; +import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls"; +import { PageHeader } from "../components/PageHeader"; + +export function TenantsProjects() { + const queryClient = useQueryClient(); + const tenants = useQuery({ queryKey: ["tenants"], queryFn: () => api("/tenants") }); + const projects = useQuery({ queryKey: ["projects"], queryFn: () => api("/projects") }); + const [tenantForm, setTenantForm] = useState({ name: "Operations", description: "Operations tenant" }); + const [projectForm, setProjectForm] = useState({ tenant_id: "", name: "Monitoring", description: "Monitoring workloads" }); + + const createTenant = useMutation({ + mutationFn: () => api("/tenants", { method: "POST", body: JSON.stringify(tenantForm) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tenants"] }), + }); + const createProject = useMutation({ + mutationFn: () => api("/projects", { method: "POST", body: JSON.stringify({ ...projectForm, tenant_id: projectForm.tenant_id || tenants.data?.[0]?.id }) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["projects"] }), + }); + + async function submitTenant(event: FormEvent) { + event.preventDefault(); + await createTenant.mutateAsync(); + } + + async function submitProject(event: FormEvent) { + event.preventDefault(); + await createProject.mutateAsync(); + } + + return ( + <> + +
+
+
Add Tenant
+
+ setTenantForm({ ...tenantForm, name: event.target.value })} /> + setTenantForm({ ...tenantForm, description: event.target.value })} /> + +
+
+
+
Add Project
+
+ + setProjectForm({ ...projectForm, name: event.target.value })} /> + setProjectForm({ ...projectForm, description: event.target.value })} /> + +
+
+
+ []} columns={[{ key: "name", label: "Tenant" }, { key: "description", label: "Description" }]} /> + []} columns={[{ key: "name", label: "Project" }, { key: "description", label: "Description" }]} /> +
+
+ + ); +} + diff --git a/frontend/src/pages/UsersRoles.tsx b/frontend/src/pages/UsersRoles.tsx new file mode 100644 index 0000000..631f4a1 --- /dev/null +++ b/frontend/src/pages/UsersRoles.tsx @@ -0,0 +1,65 @@ +import { FormEvent, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, Users } from "lucide-react"; + +import { api, Role, User } from "../api/client"; +import { DataTable } from "../components/DataTable"; +import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls"; +import { PageHeader } from "../components/PageHeader"; + +export function UsersRoles() { + const queryClient = useQueryClient(); + const users = useQuery({ queryKey: ["users"], queryFn: () => api("/users") }); + const roles = useQuery({ queryKey: ["roles"], queryFn: () => api("/roles") }); + const [roleForm, setRoleForm] = useState({ name: "Helpdesk", permissions: "clusters:read,networks:read,audit:read" }); + const [userForm, setUserForm] = useState({ email: "operator@nexafabric.local", display_name: "Operator", password: "ChangeMe_12345", role_id: "" }); + const createRole = useMutation({ + mutationFn: () => api("/roles", { method: "POST", body: JSON.stringify({ name: roleForm.name, permissions: roleForm.permissions.split(",").map((item) => item.trim()).filter(Boolean) }) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["roles"] }), + }); + const createUser = useMutation({ + mutationFn: () => api("/users", { method: "POST", body: JSON.stringify({ email: userForm.email, display_name: userForm.display_name, password: userForm.password, role_ids: userForm.role_id ? [userForm.role_id] : [] }) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users"] }), + }); + + async function submitRole(event: FormEvent) { + event.preventDefault(); + await createRole.mutateAsync(); + } + + async function submitUser(event: FormEvent) { + event.preventDefault(); + await createUser.mutateAsync(); + } + + return ( + <> + +
+
+
Add Role
+
+ setRoleForm({ ...roleForm, name: event.target.value })} /> + setRoleForm({ ...roleForm, permissions: event.target.value })} /> + +
+
+
+
Add User
+
+ setUserForm({ ...userForm, email: event.target.value })} /> + setUserForm({ ...userForm, display_name: event.target.value })} /> + setUserForm({ ...userForm, password: event.target.value })} /> + + +
+
+
+ []} columns={[{ key: "email", label: "Email" }, { key: "display_name", label: "Name" }, { key: "is_active", label: "Active" }]} /> + []} columns={[{ key: "name", label: "Role" }, { key: "permissions", label: "Permissions" }]} /> +
+
+ + ); +} +