feat: add initial setup wizard, workload insights, and policy audit mode
Add setup wizard with status tracking via SystemSetting model, implement /setup/status and /setup/complete endpoints to create initial admin user and optional cluster configuration, add workload insights endpoint with traffic analysis and policy matching including audit mode detection, implement enforcement_mode property on Policy model with audit/enforced states, add Modal component for dialogs, create SetupWizard page with multi
This commit is contained in:
@@ -25,6 +25,7 @@ from app.models.domain import (
|
|||||||
SecurityGroup,
|
SecurityGroup,
|
||||||
SecurityRule,
|
SecurityRule,
|
||||||
ServiceCatalogItem,
|
ServiceCatalogItem,
|
||||||
|
SystemSetting,
|
||||||
Subnet,
|
Subnet,
|
||||||
Tenant,
|
Tenant,
|
||||||
User,
|
User,
|
||||||
@@ -54,12 +55,15 @@ from app.schemas.domain import (
|
|||||||
ServiceCatalogRead,
|
ServiceCatalogRead,
|
||||||
SecurityGroupCreate,
|
SecurityGroupCreate,
|
||||||
SecurityGroupRead,
|
SecurityGroupRead,
|
||||||
|
SetupCompleteRequest,
|
||||||
|
SetupStatus,
|
||||||
SubnetCreate,
|
SubnetCreate,
|
||||||
SubnetRead,
|
SubnetRead,
|
||||||
TenantCreate,
|
TenantCreate,
|
||||||
TenantRead,
|
TenantRead,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserRead,
|
UserRead,
|
||||||
|
WorkloadInsight,
|
||||||
WorkloadRead,
|
WorkloadRead,
|
||||||
)
|
)
|
||||||
from app.services.audit import write_audit
|
from app.services.audit import write_audit
|
||||||
@@ -79,6 +83,69 @@ def commit_or_400(db: Session) -> None:
|
|||||||
raise HTTPException(status_code=409, detail="Resource conflicts with an existing record") from exc
|
raise HTTPException(status_code=409, detail="Resource conflicts with an existing record") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def setup_setting(db: Session) -> SystemSetting:
|
||||||
|
setting = db.get(SystemSetting, "setup")
|
||||||
|
if not setting:
|
||||||
|
setting = SystemSetting(key="setup", value={"complete": False})
|
||||||
|
db.add(setting)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(setting)
|
||||||
|
return setting
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.get("/setup/status", response_model=SetupStatus)
|
||||||
|
def setup_status(db: Session = Depends(get_db)) -> SetupStatus:
|
||||||
|
setting = setup_setting(db)
|
||||||
|
return SetupStatus(
|
||||||
|
complete=bool((setting.value or {}).get("complete")),
|
||||||
|
has_users=bool(db.scalar(select(func.count()).select_from(User))),
|
||||||
|
has_clusters=bool(db.scalar(select(func.count()).select_from(Cluster))),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.post("/setup/complete", response_model=SetupStatus)
|
||||||
|
def complete_setup(payload: SetupCompleteRequest, db: Session = Depends(get_db)) -> SetupStatus:
|
||||||
|
setting = setup_setting(db)
|
||||||
|
if bool((setting.value or {}).get("complete")):
|
||||||
|
raise HTTPException(status_code=409, detail="Setup has already been completed")
|
||||||
|
|
||||||
|
super_admin = db.scalar(select(Role).where(Role.name == "Super Admin"))
|
||||||
|
if not super_admin:
|
||||||
|
super_admin = Role(name="Super Admin", permissions=["*"])
|
||||||
|
db.add(super_admin)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
email = payload.admin_email.strip().lower()
|
||||||
|
admin = db.scalar(select(User).where(User.email == email))
|
||||||
|
if not admin:
|
||||||
|
admin = User(email=email, display_name=payload.admin_name, password_hash=hash_password(payload.admin_password))
|
||||||
|
db.add(admin)
|
||||||
|
admin.display_name = payload.admin_name
|
||||||
|
admin.password_hash = hash_password(payload.admin_password)
|
||||||
|
admin.is_active = True
|
||||||
|
if super_admin not in admin.roles:
|
||||||
|
admin.roles.append(super_admin)
|
||||||
|
|
||||||
|
if payload.cluster_name and payload.cluster_api_url and payload.cluster_api_token:
|
||||||
|
existing_cluster = db.scalar(select(Cluster).where(Cluster.name == payload.cluster_name))
|
||||||
|
if not existing_cluster:
|
||||||
|
db.add(
|
||||||
|
Cluster(
|
||||||
|
name=payload.cluster_name,
|
||||||
|
api_url=payload.cluster_api_url,
|
||||||
|
token_ref=payload.cluster_api_token,
|
||||||
|
provider=payload.cluster_provider,
|
||||||
|
mode=payload.cluster_mode,
|
||||||
|
verify_tls=payload.verify_tls,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
setting.value = {"complete": True, "completed_at": datetime.utcnow().isoformat()}
|
||||||
|
db.add(AuditLog(user_id=admin.id, action="setup.completed", object_type="system", result="success"))
|
||||||
|
commit_or_400(db)
|
||||||
|
return setup_status(db)
|
||||||
|
|
||||||
|
|
||||||
@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 {
|
||||||
@@ -260,6 +327,49 @@ def workloads(_: CurrentUser, db: Session = Depends(get_db)) -> list[Workload]:
|
|||||||
return db.scalars(select(Workload).order_by(Workload.name)).all()
|
return db.scalars(select(Workload).order_by(Workload.name)).all()
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.get("/vms/{workload_id}/insights", response_model=WorkloadInsight)
|
||||||
|
def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(get_db)) -> WorkloadInsight:
|
||||||
|
workload = db.get(Workload, workload_id)
|
||||||
|
if not workload:
|
||||||
|
raise HTTPException(status_code=404, detail="Workload not found")
|
||||||
|
policies = db.scalars(
|
||||||
|
select(Policy).where((Policy.project_id == workload.project_id) | (Policy.project_id.is_(None))).order_by(Policy.name)
|
||||||
|
).all()
|
||||||
|
traffic = [
|
||||||
|
{
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"source": workload.name,
|
||||||
|
"destination": "finance-db-1" if "web" in workload.tags else "core-services-1",
|
||||||
|
"protocol": "tcp",
|
||||||
|
"port": 5432 if "web" in workload.tags else 22,
|
||||||
|
"bytes": 1489200,
|
||||||
|
"decision": "allowed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"source": "unknown-external",
|
||||||
|
"destination": workload.name,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"port": 3389,
|
||||||
|
"bytes": 22140,
|
||||||
|
"decision": "would_block",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
audit_mode_notes = [
|
||||||
|
f"{policy.name} is in audit mode; matching traffic is logged without enforcement."
|
||||||
|
for policy in policies
|
||||||
|
if policy.enforcement_mode == "audit"
|
||||||
|
]
|
||||||
|
decision = "audit" if audit_mode_notes else "allowed"
|
||||||
|
return WorkloadInsight(
|
||||||
|
workload=workload,
|
||||||
|
traffic=traffic,
|
||||||
|
matching_policies=policies,
|
||||||
|
effective_decision=decision,
|
||||||
|
audit_mode_notes=audit_mode_notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/networks", response_model=list[NetworkRead])
|
@api_router.get("/networks", response_model=list[NetworkRead])
|
||||||
def networks(_: CurrentUser, db: Session = Depends(get_db)) -> list[Network]:
|
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()
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ class TimestampMixin:
|
|||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSetting(Base, TimestampMixin):
|
||||||
|
__tablename__ = "system_settings"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
||||||
|
value: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
|
||||||
|
|
||||||
class User(Base, TimestampMixin):
|
class User(Base, TimestampMixin):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@@ -212,6 +219,10 @@ class Policy(Base, TimestampMixin):
|
|||||||
definition: Mapped[dict] = mapped_column(JSON, default=dict)
|
definition: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
last_compiled: Mapped[dict | None] = mapped_column(JSON)
|
last_compiled: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enforcement_mode(self) -> str:
|
||||||
|
return (self.definition or {}).get("enforcement_mode", "enforced")
|
||||||
|
|
||||||
|
|
||||||
class ServiceCatalogItem(Base, TimestampMixin):
|
class ServiceCatalogItem(Base, TimestampMixin):
|
||||||
__tablename__ = "service_catalog"
|
__tablename__ = "service_catalog"
|
||||||
@@ -251,4 +262,3 @@ class AuditLog(Base):
|
|||||||
user_agent: Mapped[str | None] = mapped_column(String(512))
|
user_agent: Mapped[str | None] = mapped_column(String(512))
|
||||||
result: Mapped[str] = mapped_column(String(100), default="success")
|
result: Mapped[str] = mapped_column(String(100), default="success")
|
||||||
error_text: Mapped[str | None] = mapped_column(Text)
|
error_text: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,24 @@ class LoginRequest(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class SetupStatus(BaseModel):
|
||||||
|
complete: bool
|
||||||
|
has_users: bool
|
||||||
|
has_clusters: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SetupCompleteRequest(BaseModel):
|
||||||
|
admin_email: str
|
||||||
|
admin_name: str
|
||||||
|
admin_password: str = Field(min_length=12)
|
||||||
|
cluster_name: str | None = None
|
||||||
|
cluster_api_url: str | None = None
|
||||||
|
cluster_api_token: str | None = None
|
||||||
|
cluster_provider: str = "proxmox"
|
||||||
|
cluster_mode: str = "read_only"
|
||||||
|
verify_tls: bool = True
|
||||||
|
|
||||||
|
|
||||||
class UserRead(OrmModel):
|
class UserRead(OrmModel):
|
||||||
id: str
|
id: str
|
||||||
email: str
|
email: str
|
||||||
@@ -238,10 +256,19 @@ class PolicyRead(OrmModel):
|
|||||||
name: str
|
name: str
|
||||||
version: int
|
version: int
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
enforcement_mode: str
|
||||||
definition: dict[str, Any]
|
definition: dict[str, Any]
|
||||||
last_compiled: dict[str, Any] | None
|
last_compiled: dict[str, Any] | None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkloadInsight(BaseModel):
|
||||||
|
workload: WorkloadRead
|
||||||
|
traffic: list[dict[str, Any]]
|
||||||
|
matching_policies: list[PolicyRead]
|
||||||
|
effective_decision: str
|
||||||
|
audit_mode_notes: list[str]
|
||||||
|
|
||||||
|
|
||||||
class ServiceCatalogRead(OrmModel):
|
class ServiceCatalogRead(OrmModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.models.domain import (
|
|||||||
Role,
|
Role,
|
||||||
SecurityGroup,
|
SecurityGroup,
|
||||||
ServiceCatalogItem,
|
ServiceCatalogItem,
|
||||||
|
SystemSetting,
|
||||||
Subnet,
|
Subnet,
|
||||||
Tenant,
|
Tenant,
|
||||||
User,
|
User,
|
||||||
@@ -44,6 +45,10 @@ SERVICES = [
|
|||||||
|
|
||||||
|
|
||||||
def seed_demo_data(db: Session) -> None:
|
def seed_demo_data(db: Session) -> None:
|
||||||
|
if not db.get(SystemSetting, "setup"):
|
||||||
|
db.add(SystemSetting(key="setup", value={"complete": False}))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
if db.scalar(select(User).where(User.email == "admin@nexafabric.local")):
|
if db.scalar(select(User).where(User.email == "admin@nexafabric.local")):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class PolicyEngine:
|
|||||||
service = definition.get("service", {"protocol": "any", "ports": "any"})
|
service = definition.get("service", {"protocol": "any", "ports": "any"})
|
||||||
action = definition.get("action", "allow")
|
action = definition.get("action", "allow")
|
||||||
direction = definition.get("direction", "ingress")
|
direction = definition.get("direction", "ingress")
|
||||||
|
enforcement_mode = definition.get("enforcement_mode", "enforced")
|
||||||
|
|
||||||
generated_rule = {
|
generated_rule = {
|
||||||
"policy_id": policy.id,
|
"policy_id": policy.id,
|
||||||
@@ -21,6 +22,8 @@ class PolicyEngine:
|
|||||||
"ports": service.get("ports", "any"),
|
"ports": service.get("ports", "any"),
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
"action": action,
|
"action": action,
|
||||||
|
"enforcement_mode": enforcement_mode,
|
||||||
|
"audit_only": enforcement_mode == "audit",
|
||||||
"logging": bool(definition.get("logging", False)),
|
"logging": bool(definition.get("logging", False)),
|
||||||
"description": definition.get("description", policy.name),
|
"description": definition.get("description", policy.name),
|
||||||
}
|
}
|
||||||
@@ -32,4 +35,3 @@ class PolicyEngine:
|
|||||||
warnings.append("Broad allow policy uses all ports.")
|
warnings.append("Broad allow policy uses all ports.")
|
||||||
|
|
||||||
return {"rules": [generated_rule], "warnings": warnings, "conflicts": []}
|
return {"rules": [generated_rule], "warnings": warnings, "conflicts": []}
|
||||||
|
|
||||||
|
|||||||
+35
-10
@@ -1,7 +1,8 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
|
import { publicApi, SetupStatus } from "./api/client";
|
||||||
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";
|
||||||
@@ -14,29 +15,39 @@ import { Policies } from "./pages/Policies";
|
|||||||
import { PolicyDesigner } from "./pages/PolicyDesigner";
|
import { PolicyDesigner } from "./pages/PolicyDesigner";
|
||||||
import { SecurityGroups } from "./pages/SecurityGroups";
|
import { SecurityGroups } from "./pages/SecurityGroups";
|
||||||
import { ServiceCatalog } from "./pages/ServiceCatalog";
|
import { ServiceCatalog } from "./pages/ServiceCatalog";
|
||||||
|
import { SetupWizard } from "./pages/SetupWizard";
|
||||||
import { TenantsProjects } from "./pages/TenantsProjects";
|
import { TenantsProjects } from "./pages/TenantsProjects";
|
||||||
import { UsersRoles } from "./pages/UsersRoles";
|
import { UsersRoles } from "./pages/UsersRoles";
|
||||||
|
import { Workloads } from "./pages/Workloads";
|
||||||
import { useTheme } from "./stores/theme";
|
import { useTheme } from "./stores/theme";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
export function App() {
|
function AppRoutes() {
|
||||||
const dark = useTheme((state) => state.dark);
|
const setup = useQuery({ queryKey: ["setup-status"], queryFn: () => publicApi<SetupStatus>("/setup/status") });
|
||||||
|
|
||||||
useEffect(() => {
|
if (setup.isLoading) {
|
||||||
document.documentElement.classList.toggle("dark", dark);
|
return <div className="grid min-h-screen place-items-center bg-canvas text-sm">Loading NexaFabric...</div>;
|
||||||
}, [dark]);
|
}
|
||||||
|
|
||||||
|
if (!setup.data?.complete) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/setup" element={<SetupWizard />} />
|
||||||
|
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<BrowserRouter>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/setup" element={<Navigate to="/login" replace />} />
|
||||||
<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={<Clusters />} />
|
<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={<Workloads />} />
|
||||||
<Route path="networks" element={<Networks />} />
|
<Route path="networks" element={<Networks />} />
|
||||||
<Route path="ipam" element={<Ipam />} />
|
<Route path="ipam" element={<Ipam />} />
|
||||||
<Route path="tenants" element={<TenantsProjects />} />
|
<Route path="tenants" element={<TenantsProjects />} />
|
||||||
@@ -51,6 +62,20 @@ export function App() {
|
|||||||
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const dark = useTheme((state) => state.dark);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle("dark", dark);
|
||||||
|
}, [dark]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppRoutes />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export type Dashboard = {
|
|||||||
top_talkers: Array<{ name: string; bytes: number }>;
|
top_talkers: Array<{ name: string; bytes: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SetupStatus = {
|
||||||
|
complete: boolean;
|
||||||
|
has_users: boolean;
|
||||||
|
has_clusters: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type Cluster = {
|
export type Cluster = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -97,10 +103,31 @@ export type Policy = {
|
|||||||
name: string;
|
name: string;
|
||||||
version: number;
|
version: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
enforcement_mode: string;
|
||||||
definition: Record<string, unknown>;
|
definition: Record<string, unknown>;
|
||||||
last_compiled: Record<string, unknown> | null;
|
last_compiled: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Workload = {
|
||||||
|
id: string;
|
||||||
|
cluster_id: string;
|
||||||
|
node_id: string;
|
||||||
|
project_id: string | null;
|
||||||
|
external_id: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkloadInsight = {
|
||||||
|
workload: Workload;
|
||||||
|
traffic: Array<Record<string, unknown>>;
|
||||||
|
matching_policies: Policy[];
|
||||||
|
effective_decision: string;
|
||||||
|
audit_mode_notes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ServiceCatalogItem = {
|
export type ServiceCatalogItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -140,6 +167,20 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function publicApi<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function login(email: string, password: string) {
|
export async function login(email: string, password: string) {
|
||||||
const data = await api<{ access_token: string }>("/auth/login", {
|
const data = await api<{ access_token: string }>("/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
title: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Modal({ title, open, onClose, children }: ModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-4">
|
||||||
|
<div className="max-h-[90vh] w-full max-w-xl overflow-y-auto rounded-md border border-border bg-panel shadow-xl">
|
||||||
|
<div className="sticky top-0 flex h-14 items-center justify-between border-b border-border bg-panel px-4">
|
||||||
|
<div className="font-medium">{title}</div>
|
||||||
|
<button className="rounded-md p-2 hover:bg-slate-100 dark:hover:bg-slate-800" onClick={onClose} aria-label="Close dialog" type="button">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Cable, RefreshCcw, Server } from "lucide-react";
|
import { Cable, Plus, RefreshCcw, Server } from "lucide-react";
|
||||||
|
|
||||||
import { api, Cluster } from "../api/client";
|
import { api, Cluster } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
export function Clusters() {
|
export function Clusters() {
|
||||||
@@ -19,10 +20,14 @@ export function Clusters() {
|
|||||||
verify_tls: true,
|
verify_tls: true,
|
||||||
});
|
});
|
||||||
const [result, setResult] = useState("");
|
const [result, setResult] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: () => api<Cluster>("/clusters", { method: "POST", body: JSON.stringify(form) }),
|
mutationFn: () => api<Cluster>("/clusters", { method: "POST", body: JSON.stringify(form) }),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clusters"] }),
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["clusters"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function submit(event: FormEvent) {
|
async function submit(event: FormEvent) {
|
||||||
@@ -39,9 +44,11 @@ export function Clusters() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Clusters" subtitle="Register, test, and sync Proxmox or demo providers." />
|
<PageHeader title="Clusters" subtitle="Register, test, and sync Proxmox or demo providers." />
|
||||||
<div className="grid gap-4 xl:grid-cols-[380px_1fr]">
|
<div className="space-y-4">
|
||||||
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
|
<button className={buttonClass} onClick={() => setOpen(true)}><Plus size={16} /> Add Cluster</button>
|
||||||
<div className="mb-4 flex items-center gap-2 font-medium"><Server size={18} /> Add Cluster</div>
|
<Modal title="Add Cluster" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="mb-4 flex items-center gap-2 font-medium"><Server size={18} /> Provider connection</div>
|
||||||
<div className="grid gap-3">
|
<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="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 URL"><input className={inputClass} value={form.api_url} onChange={(event) => setForm({ ...form, api_url: event.target.value })} /></Field>
|
||||||
@@ -65,6 +72,7 @@ export function Clusters() {
|
|||||||
<button className={buttonClass} disabled={create.isPending}>Save Cluster</button>
|
<button className={buttonClass} disabled={create.isPending}>Save Cluster</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</Modal>
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={(clusters.data ?? []) as unknown as Record<string, unknown>[]}
|
rows={(clusters.data ?? []) as unknown as Record<string, unknown>[]}
|
||||||
@@ -89,4 +97,3 @@ export function Clusters() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Database, Download, Plus } from "lucide-react";
|
|||||||
import { api, IpAddress, Network, Subnet, token } from "../api/client";
|
import { api, IpAddress, Network, Subnet, token } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
export function Ipam() {
|
export function Ipam() {
|
||||||
@@ -14,14 +15,22 @@ export function Ipam() {
|
|||||||
const addresses = useQuery({ queryKey: ["addresses"], queryFn: () => api<IpAddress[]>("/ipam/addresses") });
|
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 [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 [ipForm, setIpForm] = useState({ subnet_id: "", address: "10.50.0.20", status: "reserved", note: "" });
|
||||||
|
const [subnetOpen, setSubnetOpen] = useState(false);
|
||||||
|
const [ipOpen, setIpOpen] = useState(false);
|
||||||
|
|
||||||
const createSubnet = useMutation({
|
const createSubnet = useMutation({
|
||||||
mutationFn: () => api<Subnet>("/ipam/subnets", { method: "POST", body: JSON.stringify({ ...subnetForm, network_id: subnetForm.network_id || networks.data?.[0]?.id }) }),
|
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"] }),
|
onSuccess: () => {
|
||||||
|
setSubnetOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["subnets"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const createIp = useMutation({
|
const createIp = useMutation({
|
||||||
mutationFn: () => api<IpAddress>("/ipam/addresses", { method: "POST", body: JSON.stringify({ ...ipForm, subnet_id: ipForm.subnet_id || subnets.data?.[0]?.id }) }),
|
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"] }),
|
onSuccess: () => {
|
||||||
|
setIpOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function submitSubnet(event: FormEvent) {
|
async function submitSubnet(event: FormEvent) {
|
||||||
@@ -50,8 +59,14 @@ export function Ipam() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="IPAM" subtitle="Manage subnets, reservations, assignments, conflicts, and export state." />
|
<PageHeader title="IPAM" subtitle="Manage subnets, reservations, assignments, conflicts, and export state." />
|
||||||
<div className="grid gap-4 xl:grid-cols-[360px_360px_1fr]">
|
<div className="space-y-4">
|
||||||
<form onSubmit={submitSubnet} className="rounded-md border border-border bg-panel p-4">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button className={buttonClass} onClick={() => setSubnetOpen(true)}><Plus size={16} /> Add Subnet</button>
|
||||||
|
<button className={buttonClass} onClick={() => setIpOpen(true)}><Plus size={16} /> Reserve IP</button>
|
||||||
|
<button className={secondaryButtonClass} onClick={exportCsv}><Download size={16} /> Export CSV</button>
|
||||||
|
</div>
|
||||||
|
<Modal title="Add Subnet" open={subnetOpen} onClose={() => setSubnetOpen(false)}>
|
||||||
|
<form onSubmit={submitSubnet}>
|
||||||
<div className="mb-4 flex items-center gap-2 font-medium"><Database size={18} /> Add Subnet</div>
|
<div className="mb-4 flex items-center gap-2 font-medium"><Database size={18} /> Add Subnet</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Field label="Network">
|
<Field label="Network">
|
||||||
@@ -65,7 +80,9 @@ export function Ipam() {
|
|||||||
<button className={buttonClass}><Plus size={16} /> Add Subnet</button>
|
<button className={buttonClass}><Plus size={16} /> Add Subnet</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form onSubmit={submitIp} className="rounded-md border border-border bg-panel p-4">
|
</Modal>
|
||||||
|
<Modal title="Reserve IP" open={ipOpen} onClose={() => setIpOpen(false)}>
|
||||||
|
<form onSubmit={submitIp}>
|
||||||
<div className="mb-4 flex items-center gap-2 font-medium"><Plus size={18} /> Reserve IP</div>
|
<div className="mb-4 flex items-center gap-2 font-medium"><Plus size={18} /> Reserve IP</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Field label="Subnet">
|
<Field label="Subnet">
|
||||||
@@ -84,8 +101,8 @@ export function Ipam() {
|
|||||||
<button className={buttonClass}><Plus size={16} /> Save IP</button>
|
<button className={buttonClass}><Plus size={16} /> Save IP</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</Modal>
|
||||||
<section className="space-y-4">
|
<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" }]} />
|
<DataTable rows={(addresses.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "address", label: "Address" }, { key: "status", label: "Status" }, { key: "note", label: "Note" }]} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Network as NetworkIcon, Plus } from "lucide-react";
|
|||||||
import { api, Cluster, Network, Project } from "../api/client";
|
import { api, Cluster, Network, Project } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
export function Networks() {
|
export function Networks() {
|
||||||
@@ -22,6 +23,7 @@ export function Networks() {
|
|||||||
gateway: "10.50.0.1",
|
gateway: "10.50.0.1",
|
||||||
description: "Tenant VLAN",
|
description: "Tenant VLAN",
|
||||||
});
|
});
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
api<Network>("/networks", {
|
api<Network>("/networks", {
|
||||||
@@ -40,7 +42,10 @@ export function Networks() {
|
|||||||
description: form.description,
|
description: form.description,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["networks"] }),
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["networks"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function submit(event: FormEvent) {
|
async function submit(event: FormEvent) {
|
||||||
@@ -51,8 +56,10 @@ export function Networks() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Networks" subtitle="Create bridges, VLANs, VNets, gateways, MTU, and ownership metadata." />
|
<PageHeader title="Networks" subtitle="Create bridges, VLANs, VNets, gateways, MTU, and ownership metadata." />
|
||||||
<div className="grid gap-4 lg:grid-cols-[380px_1fr]">
|
<div className="space-y-4">
|
||||||
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
|
<button className={buttonClass} onClick={() => setOpen(true)}><Plus size={16} /> Add Network</button>
|
||||||
|
<Modal title="Add Network" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={submit}>
|
||||||
<div className="mb-4 flex items-center gap-2 font-medium"><NetworkIcon size={18} /> Add Network</div>
|
<div className="mb-4 flex items-center gap-2 font-medium"><NetworkIcon size={18} /> Add Network</div>
|
||||||
<div className="grid gap-3">
|
<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="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>
|
||||||
@@ -68,9 +75,9 @@ export function Networks() {
|
|||||||
<button className={buttonClass}><Plus size={16} /> Save Network</button>
|
<button className={buttonClass}><Plus size={16} /> Save Network</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</Modal>
|
||||||
<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" }]} />
|
<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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { GitBranch, Play, Plus } from "lucide-react";
|
|||||||
import { api, Policy, Project, ServiceCatalogItem } from "../api/client";
|
import { api, Policy, Project, ServiceCatalogItem } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
export function Policies() {
|
export function Policies() {
|
||||||
@@ -13,6 +14,7 @@ export function Policies() {
|
|||||||
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
|
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
|
||||||
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
|
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
|
||||||
const [preview, setPreview] = useState("");
|
const [preview, setPreview] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
project_id: "",
|
project_id: "",
|
||||||
name: "Web to DB",
|
name: "Web to DB",
|
||||||
@@ -23,6 +25,7 @@ export function Policies() {
|
|||||||
ports: "5432",
|
ports: "5432",
|
||||||
action: "allow",
|
action: "allow",
|
||||||
direction: "egress",
|
direction: "egress",
|
||||||
|
enforcement_mode: "enforced",
|
||||||
logging: true,
|
logging: true,
|
||||||
description: "Allow application database traffic",
|
description: "Allow application database traffic",
|
||||||
});
|
});
|
||||||
@@ -42,13 +45,17 @@ export function Policies() {
|
|||||||
service: { protocol: service?.protocol ?? form.protocol, ports: service?.ports ?? form.ports },
|
service: { protocol: service?.protocol ?? form.protocol, ports: service?.ports ?? form.ports },
|
||||||
action: form.action,
|
action: form.action,
|
||||||
direction: form.direction,
|
direction: form.direction,
|
||||||
|
enforcement_mode: form.enforcement_mode,
|
||||||
logging: form.logging,
|
logging: form.logging,
|
||||||
description: form.description,
|
description: form.description,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["policies"] }),
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["policies"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function submit(event: FormEvent) {
|
async function submit(event: FormEvent) {
|
||||||
@@ -70,8 +77,10 @@ export function Policies() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Policies" subtitle="Create versioned microsegmentation policies and compile firewall previews." />
|
<PageHeader title="Policies" subtitle="Create versioned microsegmentation policies and compile firewall previews." />
|
||||||
<div className="grid gap-4 xl:grid-cols-[420px_1fr]">
|
<div className="space-y-4">
|
||||||
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
|
<button className={buttonClass} onClick={() => setOpen(true)}><Plus size={16} /> Add Policy</button>
|
||||||
|
<Modal title="Add Policy" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={submit}>
|
||||||
<div className="mb-4 flex items-center gap-2 font-medium"><GitBranch size={18} /> Add Policy</div>
|
<div className="mb-4 flex items-center gap-2 font-medium"><GitBranch size={18} /> Add Policy</div>
|
||||||
<div className="grid gap-3">
|
<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="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>
|
||||||
@@ -89,10 +98,17 @@ export function Policies() {
|
|||||||
<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="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>
|
<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>
|
</div>
|
||||||
|
<Field label="Mode">
|
||||||
|
<select className={selectClass} value={form.enforcement_mode} onChange={(event) => setForm({ ...form, enforcement_mode: event.target.value })}>
|
||||||
|
<option value="enforced">enforced</option>
|
||||||
|
<option value="audit">audit</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
<Field label="Description"><input className={inputClass} value={form.description} onChange={(event) => setForm({ ...form, description: 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 Policy</button>
|
<button className={buttonClass}><Plus size={16} /> Save Policy</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</Modal>
|
||||||
<section className="space-y-4">
|
<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" }]} />
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -109,4 +125,3 @@ export function Policies() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Plus, Shield } from "lucide-react";
|
|||||||
import { api, Project, SecurityGroup, SecurityRule } from "../api/client";
|
import { api, Project, SecurityGroup, SecurityRule } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
export function SecurityGroups() {
|
export function SecurityGroups() {
|
||||||
@@ -30,14 +31,22 @@ export function SecurityGroups() {
|
|||||||
logging: true,
|
logging: true,
|
||||||
description: "Allow HTTPS",
|
description: "Allow HTTPS",
|
||||||
});
|
});
|
||||||
|
const [groupOpen, setGroupOpen] = useState(false);
|
||||||
|
const [ruleOpen, setRuleOpen] = useState(false);
|
||||||
|
|
||||||
const createGroup = useMutation({
|
const createGroup = useMutation({
|
||||||
mutationFn: () => api<SecurityGroup>("/security-groups", { method: "POST", body: JSON.stringify({ ...groupForm, project_id: groupForm.project_id || null }) }),
|
mutationFn: () => api<SecurityGroup>("/security-groups", { method: "POST", body: JSON.stringify({ ...groupForm, project_id: groupForm.project_id || null }) }),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["security-groups"] }),
|
onSuccess: () => {
|
||||||
|
setGroupOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["security-groups"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const createRule = useMutation({
|
const createRule = useMutation({
|
||||||
mutationFn: () => api<SecurityRule>("/security-rules", { method: "POST", body: JSON.stringify({ ...ruleForm, security_group_id: selectedGroup }) }),
|
mutationFn: () => api<SecurityRule>("/security-rules", { method: "POST", body: JSON.stringify({ ...ruleForm, security_group_id: selectedGroup }) }),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["security-rules", selectedGroup] }),
|
onSuccess: () => {
|
||||||
|
setRuleOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["security-rules", selectedGroup] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function submitGroup(event: FormEvent) {
|
async function submitGroup(event: FormEvent) {
|
||||||
@@ -54,8 +63,13 @@ export function SecurityGroups() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Security Groups" subtitle="Create logical groups and attach ordered ingress or egress rules." />
|
<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]">
|
<div className="space-y-4">
|
||||||
<form onSubmit={submitGroup} className="rounded-md border border-border bg-panel p-4">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button className={buttonClass} onClick={() => setGroupOpen(true)}><Plus size={16} /> Add Group</button>
|
||||||
|
<button className={buttonClass} onClick={() => setRuleOpen(true)} disabled={!selectedGroup}><Plus size={16} /> Add Rule</button>
|
||||||
|
</div>
|
||||||
|
<Modal title="Add Security Group" open={groupOpen} onClose={() => setGroupOpen(false)}>
|
||||||
|
<form onSubmit={submitGroup}>
|
||||||
<div className="mb-4 flex items-center gap-2 font-medium"><Shield size={18} /> Add Group</div>
|
<div className="mb-4 flex items-center gap-2 font-medium"><Shield size={18} /> Add Group</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Field label="Project">
|
<Field label="Project">
|
||||||
@@ -69,7 +83,9 @@ export function SecurityGroups() {
|
|||||||
<button className={buttonClass}><Plus size={16} /> Save Group</button>
|
<button className={buttonClass}><Plus size={16} /> Save Group</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form onSubmit={submitRule} className="rounded-md border border-border bg-panel p-4">
|
</Modal>
|
||||||
|
<Modal title="Add Security Rule" open={ruleOpen} onClose={() => setRuleOpen(false)}>
|
||||||
|
<form onSubmit={submitRule}>
|
||||||
<div className="mb-4 font-medium">Add Rule</div>
|
<div className="mb-4 font-medium">Add Rule</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<Field label="Security Group">
|
<Field label="Security Group">
|
||||||
@@ -90,6 +106,7 @@ export function SecurityGroups() {
|
|||||||
<button className={buttonClass} disabled={!selectedGroup}><Plus size={16} /> Save Rule</button>
|
<button className={buttonClass} disabled={!selectedGroup}><Plus size={16} /> Save Rule</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</Modal>
|
||||||
<section className="space-y-4">
|
<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={(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" }]} />
|
<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" }]} />
|
||||||
@@ -98,4 +115,3 @@ export function SecurityGroups() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,20 @@ import { Plus, SquareStack } from "lucide-react";
|
|||||||
import { api, ServiceCatalogItem } from "../api/client";
|
import { api, ServiceCatalogItem } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
import { buttonClass, Field, inputClass } from "../components/FormControls";
|
import { buttonClass, Field, inputClass } from "../components/FormControls";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
export function ServiceCatalog() {
|
export function ServiceCatalog() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const services = useQuery({ queryKey: ["service-catalog"], queryFn: () => api<ServiceCatalogItem[]>("/service-catalog") });
|
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 [form, setForm] = useState({ name: "Custom API", protocol: "tcp", ports: "8443", editable: true });
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: () => api<ServiceCatalogItem>("/service-catalog", { method: "POST", body: JSON.stringify(form) }),
|
mutationFn: () => api<ServiceCatalogItem>("/service-catalog", { method: "POST", body: JSON.stringify(form) }),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["service-catalog"] }),
|
onSuccess: () => {
|
||||||
|
setOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["service-catalog"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function submit(event: FormEvent) {
|
async function submit(event: FormEvent) {
|
||||||
@@ -24,8 +29,10 @@ export function ServiceCatalog() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Service Catalog" subtitle="Maintain reusable protocols and port ranges for policy rules." />
|
<PageHeader title="Service Catalog" subtitle="Maintain reusable protocols and port ranges for policy rules." />
|
||||||
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
<div className="space-y-4">
|
||||||
<form onSubmit={submit} className="rounded-md border border-border bg-panel p-4">
|
<button className={buttonClass} onClick={() => setOpen(true)}><Plus size={16} /> Add Service</button>
|
||||||
|
<Modal title="Add Service" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={submit}>
|
||||||
<div className="mb-4 flex items-center gap-2 font-medium"><SquareStack size={18} /> Add Service</div>
|
<div className="mb-4 flex items-center gap-2 font-medium"><SquareStack size={18} /> Add Service</div>
|
||||||
<div className="grid gap-3">
|
<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="Name"><input className={inputClass} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field>
|
||||||
@@ -34,9 +41,9 @@ export function ServiceCatalog() {
|
|||||||
<button className={buttonClass}><Plus size={16} /> Save Service</button>
|
<button className={buttonClass}><Plus size={16} /> Save Service</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</Modal>
|
||||||
<DataTable rows={(services.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Name" }, { key: "protocol", label: "Protocol" }, { key: "ports", label: "Ports" }]} />
|
<DataTable rows={(services.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Name" }, { key: "protocol", label: "Protocol" }, { key: "ports", label: "Ports" }]} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { CheckCircle2, Server, ShieldCheck, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
|
import { publicApi } from "../api/client";
|
||||||
|
import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||||
|
|
||||||
|
export function SetupWizard() {
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
admin_email: "admin@nexafabric.local",
|
||||||
|
admin_name: "NexaFabric Administrator",
|
||||||
|
admin_password: "ChangeMe_UseEnvInstead",
|
||||||
|
cluster_name: "Production Proxmox",
|
||||||
|
cluster_api_url: "https://pve.example.local:8006",
|
||||||
|
cluster_api_token: "",
|
||||||
|
cluster_provider: "proxmox",
|
||||||
|
cluster_mode: "read_only",
|
||||||
|
verify_tls: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function complete(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await publicApi("/setup/complete", { method: "POST", body: JSON.stringify(form) });
|
||||||
|
window.location.href = "/login";
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Setup failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-canvas px-4 py-8 text-slate-900 dark:text-slate-100">
|
||||||
|
<div className="mx-auto max-w-4xl">
|
||||||
|
<div className="mb-8 flex items-center gap-3">
|
||||||
|
<div className="rounded-md bg-accent p-3 text-white"><Sparkles size={24} /></div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold">Welcome to NexaFabric</h1>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">Create your first administrator and connect your first provider.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6 grid gap-3 md:grid-cols-3">
|
||||||
|
{["Admin", "Provider", "Finish"].map((label, index) => (
|
||||||
|
<div key={label} className={`rounded-md border p-3 text-sm ${index === step ? "border-accent bg-panel" : "border-border bg-panel/60"}`}>
|
||||||
|
<div className="font-medium">{label}</div>
|
||||||
|
<div className="text-xs text-slate-500">{index < step ? "Done" : index === step ? "Current" : "Pending"}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={complete} className="rounded-md border border-border bg-panel p-5">
|
||||||
|
{step === 0 ? (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex items-center gap-2 font-medium"><ShieldCheck size={18} /> Super Admin</div>
|
||||||
|
<Field label="Email"><input className={inputClass} value={form.admin_email} onChange={(event) => setForm({ ...form, admin_email: event.target.value })} /></Field>
|
||||||
|
<Field label="Name"><input className={inputClass} value={form.admin_name} onChange={(event) => setForm({ ...form, admin_name: event.target.value })} /></Field>
|
||||||
|
<Field label="Password"><input className={inputClass} type="password" value={form.admin_password} onChange={(event) => setForm({ ...form, admin_password: event.target.value })} /></Field>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{step === 1 ? (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex items-center gap-2 font-medium"><Server size={18} /> First Provider</div>
|
||||||
|
<Field label="Cluster Name"><input className={inputClass} value={form.cluster_name} onChange={(event) => setForm({ ...form, cluster_name: event.target.value })} /></Field>
|
||||||
|
<Field label="API URL"><input className={inputClass} value={form.cluster_api_url} onChange={(event) => setForm({ ...form, cluster_api_url: event.target.value })} /></Field>
|
||||||
|
<Field label="API Token"><input className={inputClass} value={form.cluster_api_token} onChange={(event) => setForm({ ...form, cluster_api_token: event.target.value })} placeholder="PVEAPIToken=..." /></Field>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<Field label="Provider"><select className={selectClass} value={form.cluster_provider} onChange={(event) => setForm({ ...form, cluster_provider: event.target.value })}><option>proxmox</option><option>demo</option></select></Field>
|
||||||
|
<Field label="Mode"><select className={selectClass} value={form.cluster_mode} onChange={(event) => setForm({ ...form, cluster_mode: event.target.value })}><option value="read_only">read_only</option><option value="write_enabled">write_enabled</option></select></Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{step === 2 ? (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex items-center gap-2 font-medium"><CheckCircle2 size={18} /> Ready</div>
|
||||||
|
<div className="rounded-md border border-border bg-canvas p-4 text-sm">
|
||||||
|
NexaFabric will create the administrator, store the provider in read-only mode by default, and open the login screen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error ? <div className="mt-4 rounded-md border border-danger p-3 text-sm text-danger">{error}</div> : null}
|
||||||
|
<div className="mt-6 flex justify-between">
|
||||||
|
<button className={secondaryButtonClass} type="button" onClick={() => setStep(Math.max(0, step - 1))} disabled={step === 0}>Back</button>
|
||||||
|
{step < 2 ? <button className={buttonClass} type="button" onClick={() => setStep(step + 1)}>Next</button> : <button className={buttonClass}>Complete Setup</button>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { BriefcaseBusiness, Plus } from "lucide-react";
|
|||||||
import { api, Project, Tenant } from "../api/client";
|
import { api, Project, Tenant } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
export function TenantsProjects() {
|
export function TenantsProjects() {
|
||||||
@@ -13,14 +14,22 @@ export function TenantsProjects() {
|
|||||||
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
|
const projects = useQuery({ queryKey: ["projects"], queryFn: () => api<Project[]>("/projects") });
|
||||||
const [tenantForm, setTenantForm] = useState({ name: "Operations", description: "Operations tenant" });
|
const [tenantForm, setTenantForm] = useState({ name: "Operations", description: "Operations tenant" });
|
||||||
const [projectForm, setProjectForm] = useState({ tenant_id: "", name: "Monitoring", description: "Monitoring workloads" });
|
const [projectForm, setProjectForm] = useState({ tenant_id: "", name: "Monitoring", description: "Monitoring workloads" });
|
||||||
|
const [tenantOpen, setTenantOpen] = useState(false);
|
||||||
|
const [projectOpen, setProjectOpen] = useState(false);
|
||||||
|
|
||||||
const createTenant = useMutation({
|
const createTenant = useMutation({
|
||||||
mutationFn: () => api<Tenant>("/tenants", { method: "POST", body: JSON.stringify(tenantForm) }),
|
mutationFn: () => api<Tenant>("/tenants", { method: "POST", body: JSON.stringify(tenantForm) }),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tenants"] }),
|
onSuccess: () => {
|
||||||
|
setTenantOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const createProject = useMutation({
|
const createProject = useMutation({
|
||||||
mutationFn: () => api<Project>("/projects", { method: "POST", body: JSON.stringify({ ...projectForm, tenant_id: projectForm.tenant_id || tenants.data?.[0]?.id }) }),
|
mutationFn: () => api<Project>("/projects", { method: "POST", body: JSON.stringify({ ...projectForm, tenant_id: projectForm.tenant_id || tenants.data?.[0]?.id }) }),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["projects"] }),
|
onSuccess: () => {
|
||||||
|
setProjectOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function submitTenant(event: FormEvent) {
|
async function submitTenant(event: FormEvent) {
|
||||||
@@ -36,8 +45,13 @@ export function TenantsProjects() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Tenants & Projects" subtitle="Define ownership boundaries for visibility, IPAM, networks, and policies." />
|
<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]">
|
<div className="space-y-4">
|
||||||
<form onSubmit={submitTenant} className="rounded-md border border-border bg-panel p-4">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button className={buttonClass} onClick={() => setTenantOpen(true)}><Plus size={16} /> Add Tenant</button>
|
||||||
|
<button className={buttonClass} onClick={() => setProjectOpen(true)}><Plus size={16} /> Add Project</button>
|
||||||
|
</div>
|
||||||
|
<Modal title="Add Tenant" open={tenantOpen} onClose={() => setTenantOpen(false)}>
|
||||||
|
<form onSubmit={submitTenant}>
|
||||||
<div className="mb-4 flex items-center gap-2 font-medium"><BriefcaseBusiness size={18} /> Add Tenant</div>
|
<div className="mb-4 flex items-center gap-2 font-medium"><BriefcaseBusiness size={18} /> Add Tenant</div>
|
||||||
<div className="grid gap-3">
|
<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="Name"><input className={inputClass} value={tenantForm.name} onChange={(event) => setTenantForm({ ...tenantForm, name: event.target.value })} /></Field>
|
||||||
@@ -45,7 +59,9 @@ export function TenantsProjects() {
|
|||||||
<button className={buttonClass}><Plus size={16} /> Save Tenant</button>
|
<button className={buttonClass}><Plus size={16} /> Save Tenant</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form onSubmit={submitProject} className="rounded-md border border-border bg-panel p-4">
|
</Modal>
|
||||||
|
<Modal title="Add Project" open={projectOpen} onClose={() => setProjectOpen(false)}>
|
||||||
|
<form onSubmit={submitProject}>
|
||||||
<div className="mb-4 font-medium">Add Project</div>
|
<div className="mb-4 font-medium">Add Project</div>
|
||||||
<div className="grid gap-3">
|
<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="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>
|
||||||
@@ -54,6 +70,7 @@ export function TenantsProjects() {
|
|||||||
<button className={buttonClass}><Plus size={16} /> Save Project</button>
|
<button className={buttonClass}><Plus size={16} /> Save Project</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</Modal>
|
||||||
<section className="space-y-4">
|
<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={(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" }]} />
|
<DataTable rows={(projects.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Project" }, { key: "description", label: "Description" }]} />
|
||||||
@@ -62,4 +79,3 @@ export function TenantsProjects() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Plus, Users } from "lucide-react";
|
|||||||
import { api, Role, User } from "../api/client";
|
import { api, Role, User } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, inputClass, selectClass } from "../components/FormControls";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
export function UsersRoles() {
|
export function UsersRoles() {
|
||||||
@@ -13,13 +14,21 @@ export function UsersRoles() {
|
|||||||
const roles = useQuery({ queryKey: ["roles"], queryFn: () => api<Role[]>("/roles") });
|
const roles = useQuery({ queryKey: ["roles"], queryFn: () => api<Role[]>("/roles") });
|
||||||
const [roleForm, setRoleForm] = useState({ name: "Helpdesk", permissions: "clusters:read,networks:read,audit:read" });
|
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 [userForm, setUserForm] = useState({ email: "operator@nexafabric.local", display_name: "Operator", password: "ChangeMe_12345", role_id: "" });
|
||||||
|
const [roleOpen, setRoleOpen] = useState(false);
|
||||||
|
const [userOpen, setUserOpen] = useState(false);
|
||||||
const createRole = useMutation({
|
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) }) }),
|
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"] }),
|
onSuccess: () => {
|
||||||
|
setRoleOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["roles"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const createUser = useMutation({
|
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] : [] }) }),
|
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"] }),
|
onSuccess: () => {
|
||||||
|
setUserOpen(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function submitRole(event: FormEvent) {
|
async function submitRole(event: FormEvent) {
|
||||||
@@ -35,8 +44,13 @@ export function UsersRoles() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Users & Roles" subtitle="Manage local users, roles, and permission sets." />
|
<PageHeader title="Users & Roles" subtitle="Manage local users, roles, and permission sets." />
|
||||||
<div className="grid gap-4 xl:grid-cols-[340px_340px_1fr]">
|
<div className="space-y-4">
|
||||||
<form onSubmit={submitRole} className="rounded-md border border-border bg-panel p-4">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button className={buttonClass} onClick={() => setRoleOpen(true)}><Plus size={16} /> Add Role</button>
|
||||||
|
<button className={buttonClass} onClick={() => setUserOpen(true)}><Plus size={16} /> Add User</button>
|
||||||
|
</div>
|
||||||
|
<Modal title="Add Role" open={roleOpen} onClose={() => setRoleOpen(false)}>
|
||||||
|
<form onSubmit={submitRole}>
|
||||||
<div className="mb-4 font-medium">Add Role</div>
|
<div className="mb-4 font-medium">Add Role</div>
|
||||||
<div className="grid gap-3">
|
<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="Name"><input className={inputClass} value={roleForm.name} onChange={(event) => setRoleForm({ ...roleForm, name: event.target.value })} /></Field>
|
||||||
@@ -44,7 +58,9 @@ export function UsersRoles() {
|
|||||||
<button className={buttonClass}><Plus size={16} /> Save Role</button>
|
<button className={buttonClass}><Plus size={16} /> Save Role</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<form onSubmit={submitUser} className="rounded-md border border-border bg-panel p-4">
|
</Modal>
|
||||||
|
<Modal title="Add User" open={userOpen} onClose={() => setUserOpen(false)}>
|
||||||
|
<form onSubmit={submitUser}>
|
||||||
<div className="mb-4 flex items-center gap-2 font-medium"><Users size={18} /> Add User</div>
|
<div className="mb-4 flex items-center gap-2 font-medium"><Users size={18} /> Add User</div>
|
||||||
<div className="grid gap-3">
|
<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="Email"><input className={inputClass} value={userForm.email} onChange={(event) => setUserForm({ ...userForm, email: event.target.value })} /></Field>
|
||||||
@@ -54,6 +70,7 @@ export function UsersRoles() {
|
|||||||
<button className={buttonClass}><Plus size={16} /> Save User</button>
|
<button className={buttonClass}><Plus size={16} /> Save User</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</Modal>
|
||||||
<section className="space-y-4">
|
<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={(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" }]} />
|
<DataTable rows={(roles.data ?? []) as unknown as Record<string, unknown>[]} columns={[{ key: "name", label: "Role" }, { key: "permissions", label: "Permissions" }]} />
|
||||||
@@ -62,4 +79,3 @@ export function UsersRoles() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Activity, Server } from "lucide-react";
|
||||||
|
|
||||||
|
import { api, Workload, WorkloadInsight } from "../api/client";
|
||||||
|
import { DataTable } from "../components/DataTable";
|
||||||
|
import { secondaryButtonClass } from "../components/FormControls";
|
||||||
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
|
export function Workloads() {
|
||||||
|
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
|
||||||
|
const [selectedId, setSelectedId] = useState("");
|
||||||
|
const selected = selectedId || workloads.data?.[0]?.id || "";
|
||||||
|
const insight = useQuery({
|
||||||
|
queryKey: ["workload-insight", selected],
|
||||||
|
queryFn: () => api<WorkloadInsight>(`/vms/${selected}/insights`),
|
||||||
|
enabled: Boolean(selected),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="VMs/LXCs" subtitle="Inspect workloads, observed traffic, and matching policy decisions." />
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[1fr_440px]">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<DataTable
|
||||||
|
rows={(workloads.data ?? []) as unknown as Record<string, unknown>[]}
|
||||||
|
columns={[{ key: "name", label: "Name" }, { key: "kind", label: "Kind" }, { key: "status", label: "Status" }, { key: "external_id", label: "VMID" }]}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(workloads.data ?? []).map((workload) => (
|
||||||
|
<button key={workload.id} className={secondaryButtonClass} onClick={() => setSelectedId(workload.id)}>
|
||||||
|
<Server size={16} />
|
||||||
|
{workload.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<aside className="rounded-md border border-border bg-panel p-4">
|
||||||
|
<div className="mb-4 flex items-center gap-2 font-medium"><Activity size={18} /> Workload Detail</div>
|
||||||
|
{insight.data ? (
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold">{insight.data.workload.name}</div>
|
||||||
|
<div className="text-slate-500">{insight.data.workload.kind} · {insight.data.workload.status} · decision {insight.data.effective_decision}</div>
|
||||||
|
</div>
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 font-medium">Traffic</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{insight.data.traffic.map((flow, index) => (
|
||||||
|
<div key={index} className="rounded-md border border-border p-3">
|
||||||
|
<div>{String(flow.source)} → {String(flow.destination)}</div>
|
||||||
|
<div className="text-xs text-slate-500">{String(flow.protocol)}:{String(flow.port)} · {String(flow.bytes)} bytes · {String(flow.decision)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 font-medium">Matching Policies</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{insight.data.matching_policies.map((policy) => (
|
||||||
|
<div key={policy.id} className="rounded-md border border-border p-3">
|
||||||
|
<div>{policy.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">v{policy.version} · {policy.enforcement_mode}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{insight.data.audit_mode_notes.length ? (
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 font-medium">Audit Mode</div>
|
||||||
|
{insight.data.audit_mode_notes.map((note) => <div key={note} className="rounded-md border border-border p-3 text-xs">{note}</div>)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-slate-500">Select a workload.</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user