chore: initial project setup with backend, frontend, CI/CD, and documentation
Add complete NexaFabric project structure including: - FastAPI backend with SQLAlchemy models, JWT auth, RBAC, audit logging, and provider interfaces - React + TypeScript frontend with Vite, Tailwind CSS, TanStack Query, and Zustand - Docker Compose configuration for PostgreSQL, Redis, API, worker, frontend, and nginx - GitHub Actions and GitLab CI workflows for testing, linting, building, and security scanning - Environment
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { Layout } from "./components/Layout";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { FirewallPreview } from "./pages/FirewallPreview";
|
||||
import { ListPage } from "./pages/ListPage";
|
||||
import { Login } from "./pages/Login";
|
||||
import { PolicyDesigner } from "./pages/PolicyDesigner";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<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="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="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="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>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
|
||||
|
||||
export type Dashboard = {
|
||||
clusters: number;
|
||||
nodes: number;
|
||||
workloads: number;
|
||||
networks: number;
|
||||
open_policy_violations: number;
|
||||
faulty_nodes: Array<{ id: string; name: string; status: string }>;
|
||||
top_talkers: Array<{ name: string; bytes: number }>;
|
||||
};
|
||||
|
||||
export type Cluster = {
|
||||
id: string;
|
||||
name: string;
|
||||
api_url: string;
|
||||
provider: string;
|
||||
mode: string;
|
||||
last_sync_status: string | null;
|
||||
};
|
||||
|
||||
export type Network = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
vlan_id: number | null;
|
||||
gateway: string | null;
|
||||
mtu: number;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type Policy = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: number;
|
||||
enabled: boolean;
|
||||
definition: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AuditLog = {
|
||||
id: string;
|
||||
created_at: string;
|
||||
action: string;
|
||||
object_type: string;
|
||||
result: string;
|
||||
};
|
||||
|
||||
export function token() {
|
||||
return localStorage.getItem("nexafabric.token");
|
||||
}
|
||||
|
||||
export function setToken(value: string) {
|
||||
localStorage.setItem("nexafabric.token", value);
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token() ? { Authorization: `Bearer ${token()}` } : {}),
|
||||
...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) {
|
||||
const data = await api<{ access_token: string }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
setToken(data.access_token);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
type DataTableProps<T extends Record<string, unknown>> = {
|
||||
columns: Array<{ key: keyof T; label: string; render?: (row: T) => string }>;
|
||||
rows: T[];
|
||||
};
|
||||
|
||||
export function DataTable<T extends Record<string, unknown>>({ columns, rows }: DataTableProps<T>) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-border bg-panel">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="border-b border-border text-xs uppercase text-slate-500 dark:text-slate-400">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={String(column.key)} className="px-4 py-3 font-medium">
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={String(row.id ?? index)} className="border-b border-border last:border-0">
|
||||
{columns.map((column) => (
|
||||
<td key={String(column.key)} className="px-4 py-3">
|
||||
{column.render ? column.render(row) : String(row[column.key] ?? "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{rows.length === 0 ? <div className="p-8 text-center text-sm text-slate-500">No records found.</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Activity,
|
||||
Blocks,
|
||||
BookOpen,
|
||||
BriefcaseBusiness,
|
||||
ClipboardList,
|
||||
Database,
|
||||
Flame,
|
||||
GitBranch,
|
||||
LayoutDashboard,
|
||||
LockKeyhole,
|
||||
Moon,
|
||||
Network,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
Sun,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { token } from "../api/client";
|
||||
import { useTheme } from "../stores/theme";
|
||||
|
||||
const nav = [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ to: "/clusters", label: "Clusters", icon: Server },
|
||||
{ to: "/nodes", label: "Nodes", icon: Activity },
|
||||
{ to: "/workloads", label: "VMs/LXCs", icon: Blocks },
|
||||
{ to: "/networks", label: "Networks", icon: Network },
|
||||
{ to: "/ipam", label: "IPAM", icon: Database },
|
||||
{ to: "/tenants", label: "Tenants", icon: BriefcaseBusiness },
|
||||
{ to: "/security-groups", label: "Security Groups", icon: Shield },
|
||||
{ to: "/policies", label: "Policies", icon: GitBranch },
|
||||
{ to: "/designer", label: "Policy Designer", icon: LockKeyhole },
|
||||
{ to: "/firewall", label: "Firewall Preview", icon: Flame },
|
||||
{ to: "/jobs", label: "Jobs", icon: ClipboardList },
|
||||
{ to: "/audit", label: "Audit Logs", icon: BookOpen },
|
||||
{ to: "/users", label: "Users", icon: Users },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const { dark, toggle } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
}, [dark]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token()) navigate("/login");
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas text-slate-900 dark:text-slate-100">
|
||||
<aside className="fixed inset-y-0 left-0 hidden w-64 border-r border-border bg-panel md:block">
|
||||
<div className="flex h-16 items-center border-b border-border px-5">
|
||||
<div>
|
||||
<div className="text-lg font-semibold">NexaFabric</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Control Plane</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="h-[calc(100vh-4rem)] overflow-y-auto p-3">
|
||||
{nav.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`mb-1 flex h-10 items-center gap-3 rounded-md px-3 text-sm ${
|
||||
isActive ? "bg-accent text-white" : "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="md:pl-64">
|
||||
<header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b border-border bg-panel px-4 md:px-6">
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">SDN-like network and security operations</div>
|
||||
<button className="rounded-md border border-border p-2 hover:bg-slate-100 dark:hover:bg-slate-800" onClick={toggle} aria-label="Toggle theme">
|
||||
{dark ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
</header>
|
||||
<div className="p-4 md:p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
type PageHeaderProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
export function PageHeader({ title, subtitle }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<h1 className="text-2xl font-semibold tracking-normal">{title}</h1>
|
||||
{subtitle ? <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{subtitle}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Boxes, Network, Server, ShieldAlert } from "lucide-react";
|
||||
|
||||
import { api, Dashboard as DashboardData } from "../api/client";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
const cards = [
|
||||
["clusters", "Clusters", Server],
|
||||
["nodes", "Nodes", Boxes],
|
||||
["workloads", "VMs/LXCs", Network],
|
||||
["networks", "Networks", Network],
|
||||
["open_policy_violations", "Policy Violations", ShieldAlert],
|
||||
] as const;
|
||||
|
||||
export function Dashboard() {
|
||||
const { data } = useQuery({ queryKey: ["dashboard"], queryFn: () => api<DashboardData>("/dashboard") });
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Dashboard" subtitle="Operational overview across clusters, networks, policies, and sync health." />
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{cards.map(([key, label, Icon]) => (
|
||||
<div key={key} className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 flex items-center justify-between text-slate-500">
|
||||
<span className="text-sm">{label}</span>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="text-3xl font-semibold">{data?.[key] ?? 0}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 flex items-center gap-2 font-medium">
|
||||
<AlertTriangle size={18} />
|
||||
Faulty Nodes
|
||||
</div>
|
||||
{(data?.faulty_nodes ?? []).map((node) => (
|
||||
<div key={node.id} className="flex justify-between border-t border-border py-3 text-sm">
|
||||
<span>{node.name}</span>
|
||||
<span className="text-danger">{node.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 font-medium">Top Talkers</div>
|
||||
{(data?.top_talkers ?? []).map((item) => (
|
||||
<div key={item.name} className="flex justify-between border-t border-border py-3 text-sm">
|
||||
<span>{item.name}</span>
|
||||
<span>{Math.round(item.bytes / 1_000_000)} MB</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
import { api, Policy } from "../api/client";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
export function FirewallPreview() {
|
||||
const policies = useQuery({ queryKey: ["policies"], queryFn: () => api<Policy[]>("/policies") });
|
||||
const preview = useMutation({
|
||||
mutationFn: (policyId: string) => api<Record<string, unknown>>(`/firewall/preview/${policyId}`, { method: "POST" }),
|
||||
});
|
||||
const firstPolicy = policies.data?.[0];
|
||||
|
||||
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."}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { DataTable } from "../components/DataTable";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
path: string;
|
||||
columns: Array<{ key: string; label: string }>;
|
||||
};
|
||||
|
||||
export function ListPage({ title, subtitle, path, columns }: Props) {
|
||||
const { data, isLoading, error } = useQuery({ queryKey: [path], queryFn: () => api<Record<string, unknown>[] | Record<string, unknown>>(path) });
|
||||
const rows = Array.isArray(data) ? data : data ? [data] : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={title} subtitle={subtitle} />
|
||||
{isLoading ? <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading...</div> : null}
|
||||
{error ? <div className="rounded-md border border-danger p-4 text-sm text-danger">Failed to load data.</div> : null}
|
||||
{!isLoading && !error ? <DataTable columns={columns as never} rows={rows} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
|
||||
import { login } from "../api/client";
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("admin@nexafabric.local");
|
||||
const [password, setPassword] = useState("ChangeMe_UseEnvInstead");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate("/");
|
||||
} catch {
|
||||
setError("Login failed.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-canvas px-4">
|
||||
<form onSubmit={submit} className="w-full max-w-sm rounded-md border border-border bg-panel p-6 shadow-sm">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="rounded-md bg-accent p-2 text-white">
|
||||
<ShieldCheck size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">NexaFabric</h1>
|
||||
<p className="text-sm text-slate-500">Sign in to the control plane</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="mb-4 block text-sm">
|
||||
Email
|
||||
<input className="mt-1 w-full rounded-md border border-border bg-transparent px-3 py-2" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
</label>
|
||||
<label className="mb-4 block text-sm">
|
||||
Password
|
||||
<input className="mt-1 w-full rounded-md border border-border bg-transparent px-3 py-2" type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||
</label>
|
||||
{error ? <div className="mb-4 rounded-md border border-danger px-3 py-2 text-sm text-danger">{error}</div> : null}
|
||||
<button className="h-10 w-full rounded-md bg-accent text-sm font-medium text-white">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Save, Wand2 } from "lucide-react";
|
||||
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
|
||||
export function PolicyDesigner() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Policy Designer" subtitle="Build tenant-aware microsegmentation policies and inspect their intended impact." />
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
|
||||
<section className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{["Source", "Destination", "Service", "Action", "Direction", "Logging"].map((label) => (
|
||||
<label key={label} className="text-sm">
|
||||
{label}
|
||||
<select className="mt-1 h-10 w-full rounded-md border border-border bg-transparent px-3">
|
||||
<option>{label === "Action" ? "allow" : label === "Direction" ? "ingress" : "Any"}</option>
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="mt-4 block text-sm">
|
||||
Description
|
||||
<input className="mt-1 h-10 w-full rounded-md border border-border bg-transparent px-3" placeholder="Policy intent" />
|
||||
</label>
|
||||
<div className="mt-5 flex gap-3">
|
||||
<button className="inline-flex h-10 items-center gap-2 rounded-md border border-border px-4 text-sm">
|
||||
<Wand2 size={18} />
|
||||
Dry Run
|
||||
</button>
|
||||
<button className="inline-flex h-10 items-center gap-2 rounded-md bg-accent px-4 text-sm text-white">
|
||||
<Save size={18} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<aside className="rounded-md border border-border bg-panel p-4">
|
||||
<div className="mb-3 font-medium">Impact Preview</div>
|
||||
<div className="space-y-3 text-sm text-slate-600 dark:text-slate-300">
|
||||
<div className="rounded-md border border-border p-3">Affected VMs: calculated after dry run</div>
|
||||
<div className="rounded-md border border-border p-3">Conflicts: none detected in draft</div>
|
||||
<div className="rounded-md border border-border p-3">Generated rules: preview required before apply</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
type ThemeState = {
|
||||
dark: boolean;
|
||||
toggle: () => void;
|
||||
};
|
||||
|
||||
export const useTheme = create<ThemeState>((set) => ({
|
||||
dark: localStorage.getItem("nexafabric.theme") === "dark",
|
||||
toggle: () =>
|
||||
set((state) => {
|
||||
const dark = !state.dark;
|
||||
localStorage.setItem("nexafabric.theme", dark ? "dark" : "light");
|
||||
return { dark };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-canvas: 247 248 250;
|
||||
--color-panel: 255 255 255;
|
||||
--color-border: 214 219 226;
|
||||
--color-accent: 20 132 122;
|
||||
--color-danger: 205 54 65;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-canvas: 18 22 28;
|
||||
--color-panel: 28 34 43;
|
||||
--color-border: 63 72 86;
|
||||
--color-accent: 61 185 171;
|
||||
--color-danger: 239 92 101;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: rgb(var(--color-canvas));
|
||||
color: #18202a;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
color: #edf2f7;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user