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
This commit is contained in:
+358
-13
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-7
@@ -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() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<Layout />}>
|
||||
<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="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="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="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="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="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="networks" element={<Networks />} />
|
||||
<Route path="ipam" element={<Ipam />} />
|
||||
<Route path="tenants" element={<TenantsProjects />} />
|
||||
<Route path="security-groups" element={<SecurityGroups />} />
|
||||
<Route path="policies" element={<Policies />} />
|
||||
<Route path="services" element={<ServiceCatalog />} />
|
||||
<Route path="designer" element={<PolicyDesigner />} />
|
||||
<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="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>
|
||||
</Routes>
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
last_compiled: Record<string, unknown> | 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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({
|
||||
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 (
|
||||
<>
|
||||
<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">
|
||||
<button
|
||||
className="inline-flex h-10 items-center gap-2 rounded-md bg-accent px-4 text-sm text-white disabled:opacity-50"
|
||||
disabled={!firstPolicy}
|
||||
onClick={() => firstPolicy && preview.mutate(firstPolicy.id)}
|
||||
>
|
||||
<Play size={18} />
|
||||
Generate Preview
|
||||
</button>
|
||||
<pre className="mt-4 max-h-[520px] overflow-auto rounded-md border border-border bg-canvas p-4 text-xs">
|
||||
{preview.data ? JSON.stringify(preview.data, null, 2) : "No preview generated yet."}
|
||||
<div className="grid gap-4 lg:grid-cols-[360px_1fr]">
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="grid gap-3">
|
||||
<Field label="Policy">
|
||||
<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} />
|
||||
Generate Preview
|
||||
</button>
|
||||
<button className={buttonClass} disabled={!selectedPolicyId || apply.isPending} onClick={() => apply.mutate()}>
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user