feat: add LoadingOverlay component with contextual busy messages for async operations across cluster sync, firewall preview/apply, IPAM discovery/export, node agent installer, and policy compilation
Add LoadingOverlay component with spinner, customizable title/message, and backdrop blur styling, implement busy state tracking with setBusyMessage in Clusters/FirewallPreview/Ipam/Nodes/Policies pages, wrap async operations (cluster test/sync, firewall preview/apply, IPAM discover/export CSV, node agent installer
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
|
||||||
|
type LoadingOverlayProps = {
|
||||||
|
open: boolean;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoadingOverlay({ open, title = "Working...", message = "Loading data..." }: LoadingOverlayProps) {
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/35 px-4 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-sm rounded-md border border-border bg-panel p-5 shadow-2xl">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="grid h-11 w-11 shrink-0 place-items-center rounded-md bg-accent/10 text-accent">
|
||||||
|
<LoaderCircle className="animate-spin" size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{title}</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">{message}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Cable, Pencil, Plus, RefreshCcw, Server, Trash2 } 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, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
|
||||||
|
import { LoadingOverlay } from "../components/LoadingOverlay";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export function Clusters() {
|
|||||||
const [result, setResult] = useState("");
|
const [result, setResult] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Cluster | null>(null);
|
const [editing, setEditing] = useState<Cluster | null>(null);
|
||||||
|
const [busyMessage, setBusyMessage] = useState("");
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
@@ -69,9 +71,14 @@ export function Clusters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function action(cluster: Cluster, kind: "test" | "sync") {
|
async function action(cluster: Cluster, kind: "test" | "sync") {
|
||||||
|
setBusyMessage(kind === "sync" ? `Syncing inventory for ${cluster.name}...` : `Testing connection to ${cluster.name}...`);
|
||||||
|
try {
|
||||||
const data = await api<Record<string, unknown>>(`/clusters/${cluster.id}/${kind}`, { method: "POST" });
|
const data = await api<Record<string, unknown>>(`/clusters/${cluster.id}/${kind}`, { method: "POST" });
|
||||||
setResult(JSON.stringify(data, null, 2));
|
setResult(JSON.stringify(data, null, 2));
|
||||||
await queryClient.invalidateQueries({ queryKey: ["clusters"] });
|
await queryClient.invalidateQueries({ queryKey: ["clusters"] });
|
||||||
|
} finally {
|
||||||
|
setBusyMessage("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteCluster(cluster: Cluster) {
|
function deleteCluster(cluster: Cluster) {
|
||||||
@@ -83,6 +90,7 @@ 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." />
|
||||||
|
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button className={buttonClass} onClick={addCluster}><Plus size={16} /> Add Cluster</button>
|
<button className={buttonClass} onClick={addCluster}><Plus size={16} /> Add Cluster</button>
|
||||||
<Modal title={editing ? "Edit Cluster" : "Add Cluster"} open={open} onClose={() => setOpen(false)}>
|
<Modal title={editing ? "Edit Cluster" : "Add Cluster"} open={open} onClose={() => setOpen(false)}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import { api, Cluster, Policy } from "../api/client";
|
import { api, Cluster, Policy } from "../api/client";
|
||||||
import { buttonClass, Field, secondaryButtonClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||||
|
import { LoadingOverlay } from "../components/LoadingOverlay";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
export function FirewallPreview() {
|
export function FirewallPreview() {
|
||||||
@@ -28,10 +29,18 @@ export function FirewallPreview() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const selectedPolicyId = policyId || policies.data?.[0]?.id || "";
|
const selectedPolicyId = policyId || policies.data?.[0]?.id || "";
|
||||||
|
const busyMessage = preview.isPending
|
||||||
|
? "Generating firewall preview..."
|
||||||
|
: apply.isPending
|
||||||
|
? dryRun
|
||||||
|
? "Running dry apply..."
|
||||||
|
: "Applying firewall rules..."
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Firewall Preview" subtitle="Compile policies into provider-specific rules before any apply operation." />
|
<PageHeader title="Firewall Preview" subtitle="Compile policies into provider-specific rules before any apply operation." />
|
||||||
|
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
|
||||||
<div className="grid gap-4 lg:grid-cols-[360px_1fr]">
|
<div className="grid gap-4 lg:grid-cols-[360px_1fr]">
|
||||||
<section className="rounded-md border border-border bg-panel p-4">
|
<section className="rounded-md border border-border bg-panel p-4">
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Database, Download, Plus } from "lucide-react";
|
|||||||
import { api, authorizedFetch, IpAddress, Network, Subnet } from "../api/client";
|
import { api, authorizedFetch, IpAddress, Network, Subnet } 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 { LoadingOverlay } from "../components/LoadingOverlay";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export function Ipam() {
|
|||||||
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 [subnetOpen, setSubnetOpen] = useState(false);
|
||||||
const [ipOpen, setIpOpen] = useState(false);
|
const [ipOpen, setIpOpen] = useState(false);
|
||||||
|
const [busyMessage, setBusyMessage] = useState("");
|
||||||
|
|
||||||
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 }) }),
|
||||||
@@ -49,6 +51,8 @@ export function Ipam() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exportCsv() {
|
async function exportCsv() {
|
||||||
|
setBusyMessage("Preparing IPAM export...");
|
||||||
|
try {
|
||||||
const response = await authorizedFetch("/ipam/export.csv");
|
const response = await authorizedFetch("/ipam/export.csv");
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -57,9 +61,13 @@ export function Ipam() {
|
|||||||
link.download = "nexafabric-ipam.csv";
|
link.download = "nexafabric-ipam.csv";
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
} finally {
|
||||||
|
setBusyMessage("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discoverIpam() {
|
async function discoverIpam() {
|
||||||
|
setBusyMessage("Discovering IP addresses from Proxmox...");
|
||||||
try {
|
try {
|
||||||
const result = await api<{ imported: number; removed: number; errors: Array<Record<string, unknown>> }>("/ipam/discover", { method: "POST" });
|
const result = await api<{ imported: number; removed: number; errors: Array<Record<string, unknown>> }>("/ipam/discover", { method: "POST" });
|
||||||
setMessage(`Discovery imported ${result.imported} IP addresses and removed ${result.removed} container bridge entries${result.errors.length ? " with errors" : ""}.`);
|
setMessage(`Discovery imported ${result.imported} IP addresses and removed ${result.removed} container bridge entries${result.errors.length ? " with errors" : ""}.`);
|
||||||
@@ -67,12 +75,15 @@ export function Ipam() {
|
|||||||
await queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
await queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setMessage(error instanceof Error ? error.message : "IPAM discovery failed.");
|
setMessage(error instanceof Error ? error.message : "IPAM discovery failed.");
|
||||||
|
} finally {
|
||||||
|
setBusyMessage("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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." />
|
||||||
|
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button className={buttonClass} onClick={() => setSubnetOpen(true)}><Plus size={16} /> Add Subnet</button>
|
<button className={buttonClass} onClick={() => setSubnetOpen(true)}><Plus size={16} /> Add Subnet</button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
|||||||
import { AgentInstallInfo, api, Node } from "../api/client";
|
import { AgentInstallInfo, api, Node } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
import { iconButtonClass, secondaryButtonClass } from "../components/FormControls";
|
import { iconButtonClass, secondaryButtonClass } from "../components/FormControls";
|
||||||
|
import { LoadingOverlay } from "../components/LoadingOverlay";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ export function Nodes() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Nodes" subtitle="Cluster nodes, capacity, health, and NexaFabric agent enrollment." />
|
<PageHeader title="Nodes" subtitle="Cluster nodes, capacity, health, and NexaFabric agent enrollment." />
|
||||||
|
<LoadingOverlay open={installInfo.isPending} message="Generating node agent installer..." />
|
||||||
{nodes.isLoading ? <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading...</div> : null}
|
{nodes.isLoading ? <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading...</div> : null}
|
||||||
{nodes.error ? <div className="rounded-md border border-danger p-4 text-sm text-danger">Failed to load nodes.</div> : null}
|
{nodes.error ? <div className="rounded-md border border-danger p-4 text-sm text-danger">Failed to load nodes.</div> : null}
|
||||||
{!nodes.isLoading && !nodes.error ? (
|
{!nodes.isLoading && !nodes.error ? (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Eye, GitBranch, Pencil, Play, Plus, Trash2 } 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, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
|
import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls";
|
||||||
|
import { LoadingOverlay } from "../components/LoadingOverlay";
|
||||||
import { Modal } from "../components/Modal";
|
import { Modal } from "../components/Modal";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ export function Policies() {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<Policy | null>(null);
|
const [editing, setEditing] = useState<Policy | null>(null);
|
||||||
const [form, setForm] = useState(defaultPolicyForm);
|
const [form, setForm] = useState(defaultPolicyForm);
|
||||||
|
const [busyMessage, setBusyMessage] = useState("");
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
@@ -85,14 +87,24 @@ export function Policies() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function compile(policy: Policy) {
|
async function compile(policy: Policy) {
|
||||||
|
setBusyMessage(`Compiling ${policy.name}...`);
|
||||||
|
try {
|
||||||
const data = await api<Policy>(`/policies/${policy.id}/compile`, { method: "POST" });
|
const data = await api<Policy>(`/policies/${policy.id}/compile`, { method: "POST" });
|
||||||
setPreview(JSON.stringify(data.last_compiled, null, 2));
|
setPreview(JSON.stringify(data.last_compiled, null, 2));
|
||||||
await queryClient.invalidateQueries({ queryKey: ["policies"] });
|
await queryClient.invalidateQueries({ queryKey: ["policies"] });
|
||||||
|
} finally {
|
||||||
|
setBusyMessage("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function firewallPreview(policy: Policy) {
|
async function firewallPreview(policy: Policy) {
|
||||||
|
setBusyMessage(`Generating preview for ${policy.name}...`);
|
||||||
|
try {
|
||||||
const data = await api<Record<string, unknown>>(`/firewall/preview/${policy.id}`, { method: "POST" });
|
const data = await api<Record<string, unknown>>(`/firewall/preview/${policy.id}`, { method: "POST" });
|
||||||
setPreview(JSON.stringify(data, null, 2));
|
setPreview(JSON.stringify(data, null, 2));
|
||||||
|
} finally {
|
||||||
|
setBusyMessage("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addPolicy() {
|
function addPolicy() {
|
||||||
@@ -140,6 +152,7 @@ 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." />
|
||||||
|
<LoadingOverlay open={Boolean(busyMessage)} message={busyMessage} />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button className={buttonClass} onClick={addPolicy}><Plus size={16} /> Add Policy</button>
|
<button className={buttonClass} onClick={addPolicy}><Plus size={16} /> Add Policy</button>
|
||||||
<Modal title={editing ? "Edit Policy" : "Add Policy"} open={open} onClose={() => setOpen(false)}>
|
<Modal title={editing ? "Edit Policy" : "Add Policy"} open={open} onClose={() => setOpen(false)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user