feat: add comprehensive CRUD endpoints, cluster sync improvements, and firewall orchestration
CI / backend (push) Failing after 2s
CI / frontend (push) Failing after 30s

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
This commit is contained in:
2026-07-09 12:33:36 +02:00
parent dea27b5459
commit a911d36f34
15 changed files with 1267 additions and 36 deletions
+358 -13
View File
@@ -1,19 +1,30 @@
from datetime import datetime
import csv
import io
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import CurrentUser from app.api.deps import CurrentUser
from app.api.v1 import auth from app.api.v1 import auth
from app.core.security import hash_password
from app.db.session import get_db from app.db.session import get_db
from app.models.domain import ( from app.models.domain import (
AuditLog, AuditLog,
Cluster, Cluster,
IpAddress,
Job, Job,
Network, Network,
Node, Node,
Policy, Policy,
Project, Project,
Role,
SecurityGroup, SecurityGroup,
SecurityRule,
ServiceCatalogItem,
Subnet, Subnet,
Tenant, Tenant,
User, User,
@@ -23,19 +34,31 @@ from app.schemas.domain import (
AuditLogRead, AuditLogRead,
ClusterCreate, ClusterCreate,
ClusterRead, ClusterRead,
FirewallApplyRequest,
FirewallPreview, FirewallPreview,
IpAddressRead, IpAddressRead,
IpReservationCreate, IpReservationCreate,
JobRead, JobRead,
NetworkCreate,
NetworkRead, NetworkRead,
NodeRead, NodeRead,
PolicyCreate, PolicyCreate,
PolicyRead, PolicyRead,
ProjectCreate,
ProjectRead, ProjectRead,
RoleCreate,
RoleRead,
SecurityRuleCreate,
SecurityRuleRead,
ServiceCatalogCreate,
ServiceCatalogRead,
SecurityGroupCreate, SecurityGroupCreate,
SecurityGroupRead, SecurityGroupRead,
SubnetCreate,
SubnetRead, SubnetRead,
TenantCreate,
TenantRead, TenantRead,
UserCreate,
UserRead, UserRead,
WorkloadRead, WorkloadRead,
) )
@@ -48,6 +71,14 @@ api_router = APIRouter()
api_router.include_router(auth.router) 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") @api_router.get("/dashboard")
def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict: def dashboard(_: CurrentUser, db: Session = Depends(get_db)) -> dict:
return { 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() 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]) @api_router.get("/clusters", response_model=list[ClusterRead])
def clusters(_: CurrentUser, db: Session = Depends(get_db)) -> list[Cluster]: def clusters(_: CurrentUser, db: Session = Depends(get_db)) -> list[Cluster]:
return db.scalars(select(Cluster).order_by(Cluster.name)).all() 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( cluster = Cluster(
name=payload.name, name=payload.name,
api_url=payload.api_url, api_url=payload.api_url,
provider=payload.provider,
token_ref=payload.api_token, token_ref=payload.api_token,
mode=payload.mode, mode=payload.mode,
verify_tls=payload.verify_tls, verify_tls=payload.verify_tls,
) )
db.add(cluster) db.add(cluster)
db.commit() commit_or_400(db)
db.refresh(cluster) db.refresh(cluster)
write_audit(db, action="cluster.created", object_type="cluster", object_id=cluster.id, user_id=user.id) write_audit(db, action="cluster.created", object_type="cluster", object_id=cluster.id, user_id=user.id)
return cluster return cluster
@api_router.post("/clusters/{cluster_id}/test") @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) cluster = db.get(Cluster, cluster_id)
if not cluster: if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found") 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") @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", read_only=cluster.mode == "read_only",
) )
) )
cluster.last_sync_at = datetime.utcnow()
cluster.last_sync_status = "success" cluster.last_sync_status = "success"
cluster.last_sync_error = None 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.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) 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()}} 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() 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]) @api_router.get("/ipam/subnets", response_model=list[SubnetRead])
def subnets(_: CurrentUser, db: Session = Depends(get_db)) -> list[Subnet]: def subnets(_: CurrentUser, db: Session = Depends(get_db)) -> list[Subnet]:
return db.scalars(select(Subnet).order_by(Subnet.cidr)).all() return db.scalars(select(Subnet).order_by(Subnet.cidr)).all()
@api_router.get("/ipam/addresses", response_model=list[IpAddressRead]) @api_router.post("/ipam/subnets", response_model=SubnetRead)
def ipam_addresses(_: CurrentUser, db: Session = Depends(get_db)): def create_subnet(payload: SubnetCreate, user: CurrentUser, db: Session = Depends(get_db)) -> Subnet:
from app.models.domain import IpAddress 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() return db.scalars(select(IpAddress).order_by(IpAddress.address)).all()
@api_router.post("/ipam/addresses", response_model=IpAddressRead) @api_router.post("/ipam/addresses", response_model=IpAddressRead)
def reserve_ip(payload: IpReservationCreate, user: CurrentUser, db: Session = Depends(get_db)): def reserve_ip(payload: IpReservationCreate, user: CurrentUser, db: Session = Depends(get_db)) -> IpAddress:
from app.models.domain import 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) address = IpAddress(subnet_id=payload.subnet_id, address=payload.address, status=payload.status, note=payload.note)
db.add(address) db.add(address)
db.commit() commit_or_400(db)
db.refresh(address) db.refresh(address)
write_audit(db, action="ipam.address.created", object_type="ip_address", object_id=address.id, user_id=user.id) write_audit(db, action="ipam.address.created", object_type="ip_address", object_id=address.id, user_id=user.id)
return address 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]) @api_router.get("/tenants", response_model=list[TenantRead])
def tenants(_: CurrentUser, db: Session = Depends(get_db)) -> list[Tenant]: def tenants(_: CurrentUser, db: Session = Depends(get_db)) -> list[Tenant]:
return db.scalars(select(Tenant).order_by(Tenant.name)).all() 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]) @api_router.get("/projects", response_model=list[ProjectRead])
def projects(_: CurrentUser, db: Session = Depends(get_db)) -> list[Project]: def projects(_: CurrentUser, db: Session = Depends(get_db)) -> list[Project]:
return db.scalars(select(Project).order_by(Project.name)).all() 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]) @api_router.get("/security-groups", response_model=list[SecurityGroupRead])
def security_groups(_: CurrentUser, db: Session = Depends(get_db)) -> list[SecurityGroup]: def security_groups(_: CurrentUser, db: Session = Depends(get_db)) -> list[SecurityGroup]:
return db.scalars(select(SecurityGroup).order_by(SecurityGroup.name)).all() 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: 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) group = SecurityGroup(project_id=payload.project_id, name=payload.name, description=payload.description)
db.add(group) db.add(group)
db.commit() commit_or_400(db)
db.refresh(group) db.refresh(group)
write_audit(db, action="security_group.created", object_type="security_group", object_id=group.id, user_id=user.id) write_audit(db, action="security_group.created", object_type="security_group", object_id=group.id, user_id=user.id)
return group 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]) @api_router.get("/policies", response_model=list[PolicyRead])
def policies(_: CurrentUser, db: Session = Depends(get_db)) -> list[Policy]: def policies(_: CurrentUser, db: Session = Depends(get_db)) -> list[Policy]:
return db.scalars(select(Policy).order_by(Policy.name)).all() 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: 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) policy = Policy(project_id=payload.project_id, name=payload.name, enabled=payload.enabled, definition=payload.definition)
db.add(policy) db.add(policy)
db.commit() commit_or_400(db)
db.refresh(policy) db.refresh(policy)
write_audit(db, action="policy.created", object_type="policy", object_id=policy.id, user_id=user.id) write_audit(db, action="policy.created", object_type="policy", object_id=policy.id, user_id=user.id)
return policy 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) @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: async def firewall_preview(policy_id: str, user: CurrentUser, db: Session = Depends(get_db)) -> FirewallPreview:
policy = db.get(Policy, policy_id) policy = db.get(Policy, policy_id)
@@ -211,6 +498,64 @@ async def firewall_preview(policy_id: str, user: CurrentUser, db: Session = Depe
return preview 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]) @api_router.get("/jobs", response_model=list[JobRead])
def jobs(_: CurrentUser, db: Session = Depends(get_db)) -> list[Job]: def jobs(_: CurrentUser, db: Session = Depends(get_db)) -> list[Job]:
return db.scalars(select(Job).order_by(Job.created_at.desc())).all() return db.scalars(select(Job).order_by(Job.created_at.desc())).all()
+101
View File
@@ -26,20 +26,85 @@ class UserRead(OrmModel):
is_active: bool 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): class ClusterCreate(BaseModel):
name: str name: str
api_url: str api_url: str
api_token: str = Field(min_length=8) api_token: str = Field(min_length=8)
provider: str = "proxmox"
mode: str = "read_only" mode: str = "read_only"
verify_tls: bool = True 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): class SecurityGroupCreate(BaseModel):
project_id: str | None = None project_id: str | None = None
name: str name: str
description: str | None = None 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): class PolicyCreate(BaseModel):
project_id: str | None = None project_id: str | None = None
name: str name: str
@@ -54,6 +119,20 @@ class IpReservationCreate(BaseModel):
note: str | None = None 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): class ClusterRead(OrmModel):
id: str id: str
name: str name: str
@@ -139,6 +218,20 @@ class SecurityGroupRead(OrmModel):
description: str | None 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): class PolicyRead(OrmModel):
id: str id: str
project_id: str | None project_id: str | None
@@ -149,6 +242,14 @@ class PolicyRead(OrmModel):
last_compiled: dict[str, Any] | None last_compiled: dict[str, Any] | None
class ServiceCatalogRead(OrmModel):
id: str
name: str
protocol: str
ports: str
editable: bool
class FirewallPreview(BaseModel): class FirewallPreview(BaseModel):
policy_id: str policy_id: str
dry_run: bool = True dry_run: bool = True
+16 -7
View File
@@ -5,9 +5,17 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Layout } from "./components/Layout"; import { Layout } from "./components/Layout";
import { Dashboard } from "./pages/Dashboard"; import { Dashboard } from "./pages/Dashboard";
import { FirewallPreview } from "./pages/FirewallPreview"; import { FirewallPreview } from "./pages/FirewallPreview";
import { Clusters } from "./pages/Clusters";
import { Ipam } from "./pages/Ipam";
import { ListPage } from "./pages/ListPage"; import { ListPage } from "./pages/ListPage";
import { Login } from "./pages/Login"; import { Login } from "./pages/Login";
import { Networks } from "./pages/Networks";
import { Policies } from "./pages/Policies";
import { PolicyDesigner } from "./pages/PolicyDesigner"; 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"; import { useTheme } from "./stores/theme";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -26,19 +34,20 @@ export function App() {
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route element={<Layout />}> <Route element={<Layout />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="clusters" element={<ListPage title="Clusters" subtitle="Registered Proxmox clusters and sync state." path="/clusters" columns={[{ key: "name", label: "Name" }, { key: "api_url", label: "API URL" }, { key: "mode", label: "Mode" }, { key: "last_sync_status", label: "Sync" }]} />} /> <Route path="clusters" element={<Clusters />} />
<Route path="nodes" element={<ListPage title="Nodes" subtitle="Cluster nodes, capacity, and health." path="/nodes" columns={[{ key: "name", label: "Name" }, { key: "status", label: "Status" }, { key: "cpu_count", label: "CPU" }, { key: "memory_mb", label: "Memory MB" }]} />} /> <Route path="nodes" element={<ListPage title="Nodes" subtitle="Cluster nodes, capacity, and health." path="/nodes" columns={[{ key: "name", label: "Name" }, { key: "status", label: "Status" }, { key: "cpu_count", label: "CPU" }, { key: "memory_mb", label: "Memory MB" }]} />} />
<Route path="workloads" element={<ListPage title="VMs/LXCs" subtitle="Virtual machine and container inventory." path="/vms" columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "external_id", label: "VMID" }]} />} /> <Route path="workloads" element={<ListPage title="VMs/LXCs" subtitle="Virtual machine and container inventory." path="/vms" columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "external_id", label: "VMID" }]} />} />
<Route path="networks" element={<ListPage title="Networks" subtitle="Bridges, VLANs, VNets, gateways, tags, and MTU." path="/networks" columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "vlan_id", label: "VLAN" }, { key: "gateway", label: "Gateway" }, { key: "mtu", label: "MTU" }]} />} /> <Route path="networks" element={<Networks />} />
<Route path="ipam" element={<ListPage title="IPAM" subtitle="Subnets and tracked IP address states." path="/ipam/addresses" columns={[{ key: "address", label: "Address" }, { key: "status", label: "Status" }, { key: "note", label: "Note" }]} />} /> <Route path="ipam" element={<Ipam />} />
<Route path="tenants" element={<ListPage title="Tenants" subtitle="Tenant and project boundaries for RBAC and policies." path="/tenants" columns={[{ key: "name", label: "Name" }, { key: "description", label: "Description" }]} />} /> <Route path="tenants" element={<TenantsProjects />} />
<Route path="security-groups" element={<ListPage title="Security Groups" subtitle="Logical targets for microsegmentation rules." path="/security-groups" columns={[{ key: "name", label: "Name" }, { key: "description", label: "Description" }]} />} /> <Route path="security-groups" element={<SecurityGroups />} />
<Route path="policies" element={<ListPage title="Policies" subtitle="Versioned policy definitions and compile state." path="/policies" columns={[{ key: "name", label: "Name" }, { key: "version", label: "Version" }, { key: "enabled", label: "Enabled" }]} />} /> <Route path="policies" element={<Policies />} />
<Route path="services" element={<ServiceCatalog />} />
<Route path="designer" element={<PolicyDesigner />} /> <Route path="designer" element={<PolicyDesigner />} />
<Route path="firewall" element={<FirewallPreview />} /> <Route path="firewall" element={<FirewallPreview />} />
<Route path="jobs" element={<ListPage title="Jobs" subtitle="Background task state and execution logs." path="/jobs" columns={[{ key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "progress", label: "Progress" }]} />} /> <Route path="jobs" element={<ListPage title="Jobs" subtitle="Background task state and execution logs." path="/jobs" columns={[{ key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "progress", label: "Progress" }]} />} />
<Route path="audit" element={<ListPage title="Audit Logs" subtitle="Security-relevant activity and change history." path="/audit" columns={[{ key: "created_at", label: "Time" }, { key: "action", label: "Action" }, { key: "object_type", label: "Object" }, { key: "result", label: "Result" }]} />} /> <Route path="audit" element={<ListPage title="Audit Logs" subtitle="Security-relevant activity and change history." path="/audit" columns={[{ key: "created_at", label: "Time" }, { key: "action", label: "Action" }, { key: "object_type", label: "Object" }, { key: "result", label: "Result" }]} />} />
<Route path="users" element={<ListPage title="Users" subtitle="Local users, roles, and access state." path="/users" columns={[{ key: "email", label: "Email" }, { key: "display_name", label: "Name" }, { key: "is_active", label: "Active" }]} />} /> <Route path="users" element={<UsersRoles />} />
<Route path="settings" element={<ListPage title="Settings" subtitle="Runtime settings and safety defaults." path="/settings" columns={[{ key: "product", label: "Product" }, { key: "firewall_apply_requires_preview", label: "Preview Required" }, { key: "agent_optional", label: "Agent Optional" }]} />} /> <Route path="settings" element={<ListPage title="Settings" subtitle="Runtime settings and safety defaults." path="/settings" columns={[{ key: "product", label: "Product" }, { key: "firewall_apply_requires_preview", label: "Preview Required" }, { key: "agent_optional", label: "Agent Optional" }]} />} />
</Route> </Route>
</Routes> </Routes>
+72 -1
View File
@@ -19,6 +19,48 @@ export type Cluster = {
last_sync_status: string | null; 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 = { export type Network = {
id: string; id: string;
name: string; name: string;
@@ -29,12 +71,42 @@ export type Network = {
tags: string[]; 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 = { export type Policy = {
id: string; id: string;
name: string; name: string;
version: number; version: number;
enabled: boolean; enabled: boolean;
definition: Record<string, unknown>; definition: Record<string, unknown>;
last_compiled: Record<string, unknown> | null;
};
export type ServiceCatalogItem = {
id: string;
name: string;
protocol: string;
ports: string;
editable: boolean;
}; };
export type AuditLog = { export type AuditLog = {
@@ -76,4 +148,3 @@ export async function login(email: string, password: string) {
setToken(data.access_token); setToken(data.access_token);
return data; return data;
} }
+21
View File
@@ -0,0 +1,21 @@
import { ReactNode } from "react";
type FieldProps = {
label: string;
children: ReactNode;
};
export function Field({ label, children }: FieldProps) {
return (
<label className="block text-sm">
<span className="mb-1 block text-slate-600 dark:text-slate-300">{label}</span>
{children}
</label>
);
}
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";
+2
View File
@@ -15,6 +15,7 @@ import {
Server, Server,
Settings, Settings,
Shield, Shield,
SquareStack,
Sun, Sun,
Users, Users,
} from "lucide-react"; } from "lucide-react";
@@ -33,6 +34,7 @@ const nav = [
{ to: "/tenants", label: "Tenants", icon: BriefcaseBusiness }, { to: "/tenants", label: "Tenants", icon: BriefcaseBusiness },
{ to: "/security-groups", label: "Security Groups", icon: Shield }, { to: "/security-groups", label: "Security Groups", icon: Shield },
{ to: "/policies", label: "Policies", icon: GitBranch }, { to: "/policies", label: "Policies", icon: GitBranch },
{ to: "/services", label: "Service Catalog", icon: SquareStack },
{ to: "/designer", label: "Policy Designer", icon: LockKeyhole }, { to: "/designer", label: "Policy Designer", icon: LockKeyhole },
{ to: "/firewall", label: "Firewall Preview", icon: Flame }, { to: "/firewall", label: "Firewall Preview", icon: Flame },
{ to: "/jobs", label: "Jobs", icon: ClipboardList }, { to: "/jobs", label: "Jobs", icon: ClipboardList },
+92
View File
@@ -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<Cluster[]>("/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<Cluster>("/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<Record<string, unknown>>(`/clusters/${cluster.id}/${kind}`, { method: "POST" });
setResult(JSON.stringify(data, null, 2));
await queryClient.invalidateQueries({ queryKey: ["clusters"] });
}
return (
<>
<PageHeader title="Clusters" subtitle="Register, test, and sync Proxmox or demo providers." />
<div className="grid gap-4 xl:grid-cols-[380px_1fr]">
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><Server size={18} /> Add Cluster</div>
<div className="grid gap-3">
<Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
<Field label="API URL"><input className={inputClass} value={form.api_url} onChange={(event) => setForm({ ...form, api_url: event.target.value })} /></Field>
<Field label="API Token"><input className={inputClass} value={form.api_token} onChange={(event) => setForm({ ...form, api_token: event.target.value })} /></Field>
<Field label="Provider">
<select className={selectClass} value={form.provider} onChange={(event) => setForm({ ...form, provider: event.target.value })}>
<option value="demo">demo</option>
<option value="proxmox">proxmox</option>
</select>
</Field>
<Field label="Mode">
<select className={selectClass} value={form.mode} onChange={(event) => setForm({ ...form, mode: event.target.value })}>
<option value="read_only">read_only</option>
<option value="write_enabled">write_enabled</option>
</select>
</Field>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={form.verify_tls} onChange={(event) => setForm({ ...form, verify_tls: event.target.checked })} />
Verify TLS
</label>
<button className={buttonClass} disabled={create.isPending}>Save Cluster</button>
</div>
</form>
<section className="space-y-4">
<DataTable
rows={(clusters.data ?? []) as unknown as Record<string, unknown>[]}
columns={[
{ key: "name", label: "Name" },
{ key: "provider", label: "Provider" },
{ key: "mode", label: "Mode" },
{ key: "last_sync_status", label: "Sync" },
]}
/>
<div className="flex flex-wrap gap-2">
{(clusters.data ?? []).map((cluster) => (
<div key={cluster.id} className="flex gap-2">
<button className={secondaryButtonClass} onClick={() => action(cluster, "test")}><Cable size={16} /> {cluster.name}</button>
<button className={secondaryButtonClass} onClick={() => action(cluster, "sync")}><RefreshCcw size={16} /> Sync</button>
</div>
))}
</div>
<pre className="min-h-24 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{result || "No cluster action result yet."}</pre>
</section>
</div>
</>
);
}
+47 -12
View File
@@ -1,33 +1,68 @@
import { useMutation, useQuery } from "@tanstack/react-query"; 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"; import { PageHeader } from "../components/PageHeader";
export function FirewallPreview() { export function FirewallPreview() {
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") }); const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") });
const clusters = useQuery({ queryKey: ["clusters"], queryFn: () => api<Cluster[]>("/clusters") });
const [policyId, setPolicyId] = useState("");
const [clusterId, setClusterId] = useState("");
const [dryRun, setDryRun] = useState(true);
const preview = useMutation({ const preview = useMutation({
mutationFn: (policyId: string) => api<Record<string, unknown>>(`/firewall/preview/${policyId}`, { method: "POST" }), mutationFn: (policyId: string) => api<Record<string, unknown>>(`/firewall/preview/${policyId}`, { method: "POST" }),
}); });
const firstPolicy = policies.data?.[0]; const apply = useMutation({
mutationFn: () =>
api<Record<string, unknown>>("/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 ( return (
<> <>
<PageHeader title="Firewall Preview" subtitle="Compile policies into provider-specific rules before any apply operation." /> <PageHeader title="Firewall Preview" subtitle="Compile policies into provider-specific rules before any apply operation." />
<div className="rounded-md border border-border bg-panel p-4"> <div className="grid gap-4 lg:grid-cols-[360px_1fr]">
<button <section className="rounded-md border border-border bg-panel p-4">
className="inline-flex h-10 items-center gap-2 rounded-md bg-accent px-4 text-sm text-white disabled:opacity-50" <div className="grid gap-3">
disabled={!firstPolicy} <Field label="Policy">
onClick={() => firstPolicy && preview.mutate(firstPolicy.id)} <select className={selectClass} value={selectedPolicyId} onChange={(event) => setPolicyId(event.target.value)}>
> {(policies.data ?? []).map((policy) => <option key={policy.id} value={policy.id}>{policy.name}</option>)}
</select>
</Field>
<Field label="Cluster">
<select className={selectClass} value={clusterId || clusters.data?.[0]?.id || ""} onChange={(event) => setClusterId(event.target.value)}>
{(clusters.data ?? []).map((cluster) => <option key={cluster.id} value={cluster.id}>{cluster.name}</option>)}
</select>
</Field>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={dryRun} onChange={(event) => setDryRun(event.target.checked)} />
Dry run
</label>
<button className={secondaryButtonClass} disabled={!selectedPolicyId} onClick={() => preview.mutate(selectedPolicyId)}>
<Play size={18} /> <Play size={18} />
Generate Preview Generate Preview
</button> </button>
<pre className="mt-4 max-h-[520px] overflow-auto rounded-md border border-border bg-canvas p-4 text-xs"> <button className={buttonClass} disabled={!selectedPolicyId || apply.isPending} onClick={() => apply.mutate()}>
{preview.data ? JSON.stringify(preview.data, null, 2) : "No preview generated yet."} <ShieldCheck size={18} />
Apply Confirmed
</button>
</div>
</section>
<pre className="max-h-[620px] overflow-auto rounded-md border border-border bg-panel p-4 text-xs">
{apply.data ? JSON.stringify(apply.data, null, 2) : preview.data ? JSON.stringify(preview.data, null, 2) : "No firewall output yet."}
</pre> </pre>
</div> </div>
</> </>
); );
} }
+94
View File
@@ -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<Network[]>("/networks") });
const subnets = useQuery({ queryKey: ["subnets"], queryFn: () => api<Subnet[]>("/ipam/subnets") });
const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api<IpAddress[]>("/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<Subnet>("/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<IpAddress>("/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 (
<>
<PageHeader title="IPAM" subtitle="Manage subnets, reservations, assignments, conflicts, and export state." />
<div className="grid gap-4 xl:grid-cols-[360px_360px_1fr]">
<form onSubmit={submitSubnet} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><Database size={18} /> Add Subnet</div>
<div className="grid gap-3">
<Field label="Network">
<select className={selectClass} value={subnetForm.network_id} onChange={(event) => setSubnetForm({ ...subnetForm, network_id: event.target.value })}>
<option value="">Auto select</option>
{(networks.data ?? []).map((network) => <option key={network.id} value={network.id}>{network.name}</option>)}
</select>
</Field>
<Field label="CIDR"><input className={inputClass} value={subnetForm.cidr} onChange={(event) => setSubnetForm({ ...subnetForm, cidr: event.target.value })} /></Field>
<Field label="Gateway"><input className={inputClass} value={subnetForm.gateway} onChange={(event) => setSubnetForm({ ...subnetForm, gateway: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Add Subnet</button>
</div>
</form>
<form onSubmit={submitIp} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><Plus size={18} /> Reserve IP</div>
<div className="grid gap-3">
<Field label="Subnet">
<select className={selectClass} value={ipForm.subnet_id} onChange={(event) => setIpForm({ ...ipForm, subnet_id: event.target.value })}>
<option value="">Auto select</option>
{(subnets.data ?? []).map((subnet) => <option key={subnet.id} value={subnet.id}>{subnet.cidr}</option>)}
</select>
</Field>
<Field label="Address"><input className={inputClass} value={ipForm.address} onChange={(event) => setIpForm({ ...ipForm, address: event.target.value })} /></Field>
<Field label="Status">
<select className={selectClass} value={ipForm.status} onChange={(event) => setIpForm({ ...ipForm, status: event.target.value })}>
{["free", "reserved", "assigned", "deprecated", "conflict"].map((status) => <option key={status}>{status}</option>)}
</select>
</Field>
<Field label="Note"><input className={inputClass} value={ipForm.note} onChange={(event) => setIpForm({ ...ipForm, note: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save IP</button>
</div>
</form>
<section className="space-y-4">
<button className={secondaryButtonClass} onClick={exportCsv}><Download size={16} /> Export CSV</button>
<DataTable rows={(addresses.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "address", label: "Address" }, { key: "status", label: "Status" }, { key: "note", label: "Note" }]} />
</section>
</div>
</>
);
}
+76
View File
@@ -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<Cluster[]>("/clusters") });
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
const networks = useQuery({ queryKey: ["networks"], queryFn: () => api<Network[]>("/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<Network>("/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 (
<>
<PageHeader title="Networks" subtitle="Create bridges, VLANs, VNets, gateways, MTU, and ownership metadata." />
<div className="grid gap-4 lg:grid-cols-[380px_1fr]">
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><NetworkIcon size={18} /> Add Network</div>
<div className="grid gap-3">
<Field label="Cluster"><select className={selectClass} value={form.cluster_id} onChange={(event) => setForm({ ...form, cluster_id: event.target.value })}><option value="">Auto select</option>{(clusters.data ?? []).map((cluster) => <option key={cluster.id} value={cluster.id}>{cluster.name}</option>)}</select></Field>
<Field label="Project"><select className={selectClass} value={form.project_id} onChange={(event) => setForm({ ...form, project_id: event.target.value })}><option value="">Global</option>{(projects.data ?? []).map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}</select></Field>
<Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
<div className="grid grid-cols-3 gap-3">
<Field label="Kind"><select className={selectClass} value={form.kind} onChange={(event) => setForm({ ...form, kind: event.target.value })}><option>bridge</option><option>vlan</option><option>vxlan</option><option>vnet</option></select></Field>
<Field label="VLAN"><input className={inputClass} value={form.vlan_id} onChange={(event) => setForm({ ...form, vlan_id: event.target.value })} /></Field>
<Field label="MTU"><input className={inputClass} value={form.mtu} onChange={(event) => setForm({ ...form, mtu: event.target.value })} /></Field>
</div>
<Field label="Gateway"><input className={inputClass} value={form.gateway} onChange={(event) => setForm({ ...form, gateway: event.target.value })} /></Field>
<Field label="Description"><input className={inputClass} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Network</button>
</div>
</form>
<DataTable rows={(networks.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "vlan_id", label: "VLAN" }, { key: "gateway", label: "Gateway" }, { key: "mtu", label: "MTU" }]} />
</div>
</>
);
}
+112
View File
@@ -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<Policy[]>("/policies") });
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/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<Policy>("/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<Policy>(`/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<Record<string, unknown>>(`/firewall/preview/${policy.id}`, { method: "POST" });
setPreview(JSON.stringify(data, null, 2));
}
return (
<>
<PageHeader title="Policies" subtitle="Create versioned microsegmentation policies and compile firewall previews." />
<div className="grid gap-4 xl:grid-cols-[420px_1fr]">
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><GitBranch size={18} /> Add Policy</div>
<div className="grid gap-3">
<Field label="Project"><select className={selectClass} value={form.project_id} onChange={(event) => setForm({ ...form, project_id: event.target.value })}><option value="">Global</option>{(projects.data ?? []).map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}</select></Field>
<Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Source"><input className={inputClass} value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })} /></Field>
<Field label="Destination"><input className={inputClass} value={form.destination} onChange={(event) => setForm({ ...form, destination: event.target.value })} /></Field>
</div>
<Field label="Service"><select className={selectClass} value={form.service_id} onChange={(event) => setForm({ ...form, service_id: event.target.value })}><option value="">Custom</option>{(services.data ?? []).map((service) => <option key={service.id} value={service.id}>{service.name} {service.ports}</option>)}</select></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Protocol"><input className={inputClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })} /></Field>
<Field label="Ports"><input className={inputClass} value={form.ports} onChange={(event) => setForm({ ...form, ports: event.target.value })} /></Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Action"><select className={selectClass} value={form.action} onChange={(event) => setForm({ ...form, action: event.target.value })}><option>allow</option><option>deny</option><option>reject</option></select></Field>
<Field label="Direction"><select className={selectClass} value={form.direction} onChange={(event) => setForm({ ...form, direction: event.target.value })}><option>ingress</option><option>egress</option></select></Field>
</div>
<Field label="Description"><input className={inputClass} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Policy</button>
</div>
</form>
<section className="space-y-4">
<DataTable rows={(policies.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Policy" }, { key: "version", label: "Version" }, { key: "enabled", label: "Enabled" }]} />
<div className="flex flex-wrap gap-2">
{(policies.data ?? []).map((policy) => (
<div key={policy.id} className="flex gap-2">
<button className={secondaryButtonClass} onClick={() => compile(policy)}><Play size={16} /> Compile {policy.name}</button>
<button className={secondaryButtonClass} onClick={() => firewallPreview(policy)}><Play size={16} /> Preview</button>
</div>
))}
</div>
<pre className="min-h-40 overflow-auto rounded-md border border-border bg-panel p-3 text-xs">{preview || "No policy output yet."}</pre>
</section>
</div>
</>
);
}
+101
View File
@@ -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<Project[]>("/projects") });
const groups = useQuery({ queryKey: ["security-groups"], queryFn: () => api<SecurityGroup[]>("/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<SecurityRule[]>(`/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<SecurityGroup>("/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<SecurityRule>("/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 (
<>
<PageHeader title="Security Groups" subtitle="Create logical groups and attach ordered ingress or egress rules." />
<div className="grid gap-4 xl:grid-cols-[340px_360px_1fr]">
<form onSubmit={submitGroup} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><Shield size={18} /> Add Group</div>
<div className="grid gap-3">
<Field label="Project">
<select className={selectClass} value={groupForm.project_id} onChange={(event) => setGroupForm({ ...groupForm, project_id: event.target.value })}>
<option value="">Global</option>
{(projects.data ?? []).map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
</select>
</Field>
<Field label="Name"><input className={inputClass} value={groupForm.name} onChange={(event) => setGroupForm({ ...groupForm, name: event.target.value })} /></Field>
<Field label="Description"><input className={inputClass} value={groupForm.description} onChange={(event) => setGroupForm({ ...groupForm, description: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Group</button>
</div>
</form>
<form onSubmit={submitRule} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 font-medium">Add Rule</div>
<div className="grid gap-3">
<Field label="Security Group">
<select className={selectClass} value={selectedGroup} onChange={(event) => setSelectedGroupId(event.target.value)}>
{(groups.data ?? []).map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Direction"><select className={selectClass} value={ruleForm.direction} onChange={(event) => setRuleForm({ ...ruleForm, direction: event.target.value })}><option>ingress</option><option>egress</option></select></Field>
<Field label="Action"><select className={selectClass} value={ruleForm.action} onChange={(event) => setRuleForm({ ...ruleForm, action: event.target.value })}><option>allow</option><option>deny</option><option>reject</option></select></Field>
</div>
<Field label="Source"><input className={inputClass} value={ruleForm.source} onChange={(event) => setRuleForm({ ...ruleForm, source: event.target.value })} /></Field>
<Field label="Destination"><input className={inputClass} value={ruleForm.destination} onChange={(event) => setRuleForm({ ...ruleForm, destination: event.target.value })} /></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Protocol"><input className={inputClass} value={ruleForm.protocol} onChange={(event) => setRuleForm({ ...ruleForm, protocol: event.target.value })} /></Field>
<Field label="Port"><input className={inputClass} value={ruleForm.port} onChange={(event) => setRuleForm({ ...ruleForm, port: event.target.value })} /></Field>
</div>
<button className={buttonClass} disabled={!selectedGroup}><Plus size={16} /> Save Rule</button>
</div>
</form>
<section className="space-y-4">
<DataTable rows={(groups.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Group" }, { key: "description", label: "Description" }]} />
<DataTable rows={(rules.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "priority", label: "Priority" }, { key: "direction", label: "Direction" }, { key: "action", label: "Action" }, { key: "protocol", label: "Protocol" }, { key: "port", label: "Port" }]} />
</section>
</div>
</>
);
}
+42
View File
@@ -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<ServiceCatalogItem[]>("/service-catalog") });
const [form, setForm] = useState({ name: "Custom API", protocol: "tcp", ports: "8443", editable: true });
const create = useMutation({
mutationFn: () => api<ServiceCatalogItem>("/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 (
<>
<PageHeader title="Service Catalog" subtitle="Maintain reusable protocols and port ranges for policy rules." />
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><SquareStack size={18} /> Add Service</div>
<div className="grid gap-3">
<Field label="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
<Field label="Protocol"><input className={inputClass} value={form.protocol} onChange={(event) => setForm({ ...form, protocol: event.target.value })} /></Field>
<Field label="Ports"><input className={inputClass} value={form.ports} onChange={(event) => setForm({ ...form, ports: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Service</button>
</div>
</form>
<DataTable rows={(services.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Name" }, { key: "protocol", label: "Protocol" }, { key: "ports", label: "Ports" }]} />
</div>
</>
);
}
+65
View File
@@ -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<Tenant[]>("/tenants") });
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/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<Tenant>("/tenants", { method: "POST", body: JSON.stringify(tenantForm) }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tenants"] }),
});
const createProject = useMutation({
mutationFn: () => api<Project>("/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 (
<>
<PageHeader title="Tenants & Projects" subtitle="Define ownership boundaries for visibility, IPAM, networks, and policies." />
<div className="grid gap-4 xl:grid-cols-[330px_330px_1fr]">
<form onSubmit={submitTenant} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><BriefcaseBusiness size={18} /> Add Tenant</div>
<div className="grid gap-3">
<Field label="Name"><input className={inputClass} value={tenantForm.name} onChange={(event) => setTenantForm({ ...tenantForm, name: event.target.value })} /></Field>
<Field label="Description"><input className={inputClass} value={tenantForm.description} onChange={(event) => setTenantForm({ ...tenantForm, description: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Tenant</button>
</div>
</form>
<form onSubmit={submitProject} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 font-medium">Add Project</div>
<div className="grid gap-3">
<Field label="Tenant"><select className={selectClass} value={projectForm.tenant_id} onChange={(event) => setProjectForm({ ...projectForm, tenant_id: event.target.value })}><option value="">Auto select</option>{(tenants.data ?? []).map((tenant) => <option key={tenant.id} value={tenant.id}>{tenant.name}</option>)}</select></Field>
<Field label="Name"><input className={inputClass} value={projectForm.name} onChange={(event) => setProjectForm({ ...projectForm, name: event.target.value })} /></Field>
<Field label="Description"><input className={inputClass} value={projectForm.description} onChange={(event) => setProjectForm({ ...projectForm, description: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Project</button>
</div>
</form>
<section className="space-y-4">
<DataTable rows={(tenants.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Tenant" }, { key: "description", label: "Description" }]} />
<DataTable rows={(projects.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Project" }, { key: "description", label: "Description" }]} />
</section>
</div>
</>
);
}
+65
View File
@@ -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<User[]>("/users") });
const roles = useQuery({ queryKey: ["roles"], queryFn: () => api<Role[]>("/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<Role>("/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<User>("/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 (
<>
<PageHeader title="Users & Roles" subtitle="Manage local users, roles, and permission sets." />
<div className="grid gap-4 xl:grid-cols-[340px_340px_1fr]">
<form onSubmit={submitRole} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 font-medium">Add Role</div>
<div className="grid gap-3">
<Field label="Name"><input className={inputClass} value={roleForm.name} onChange={(event) => setRoleForm({ ...roleForm, name: event.target.value })} /></Field>
<Field label="Permissions"><input className={inputClass} value={roleForm.permissions} onChange={(event) => setRoleForm({ ...roleForm, permissions: event.target.value })} /></Field>
<button className={buttonClass}><Plus size={16} /> Save Role</button>
</div>
</form>
<form onSubmit={submitUser} className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><Users size={18} /> Add User</div>
<div className="grid gap-3">
<Field label="Email"><input className={inputClass} value={userForm.email} onChange={(event) => setUserForm({ ...userForm, email: event.target.value })} /></Field>
<Field label="Name"><input className={inputClass} value={userForm.display_name} onChange={(event) => setUserForm({ ...userForm, display_name: event.target.value })} /></Field>
<Field label="Password"><input className={inputClass} type="password" value={userForm.password} onChange={(event) => setUserForm({ ...userForm, password: event.target.value })} /></Field>
<Field label="Role"><select className={selectClass} value={userForm.role_id} onChange={(event) => setUserForm({ ...userForm, role_id: event.target.value })}><option value="">No role</option>{(roles.data ?? []).map((role) => <option key={role.id} value={role.id}>{role.name}</option>)}</select></Field>
<button className={buttonClass}><Plus size={16} /> Save User</button>
</div>
</form>
<section className="space-y-4">
<DataTable rows={(users.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "email", label: "Email" }, { key: "display_name", label: "Name" }, { key: "is_active", label: "Active" }]} />
<DataTable rows={(roles.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Role" }, { key: "permissions", label: "Permissions" }]} />
</section>
</div>
</>
);
}