diff --git a/frontend/src/components/LoadingOverlay.tsx b/frontend/src/components/LoadingOverlay.tsx new file mode 100644 index 0000000..c67202d --- /dev/null +++ b/frontend/src/components/LoadingOverlay.tsx @@ -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 ( +
+
+
+
+ +
+
+
{title}
+
{message}
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/Clusters.tsx b/frontend/src/pages/Clusters.tsx index 85a79d6..47c0c2f 100644 --- a/frontend/src/pages/Clusters.tsx +++ b/frontend/src/pages/Clusters.tsx @@ -5,6 +5,7 @@ import { Cable, Pencil, Plus, RefreshCcw, Server, Trash2 } from "lucide-react"; import { api, Cluster } from "../api/client"; import { DataTable } from "../components/DataTable"; import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls"; +import { LoadingOverlay } from "../components/LoadingOverlay"; import { Modal } from "../components/Modal"; import { PageHeader } from "../components/PageHeader"; @@ -24,6 +25,7 @@ export function Clusters() { const [result, setResult] = useState(""); const [open, setOpen] = useState(false); const [editing, setEditing] = useState(null); + const [busyMessage, setBusyMessage] = useState(""); const save = useMutation({ mutationFn: () => { @@ -69,9 +71,14 @@ export function Clusters() { } async function action(cluster: Cluster, kind: "test" | "sync") { - const data = await api>(`/clusters/${cluster.id}/${kind}`, { method: "POST" }); - setResult(JSON.stringify(data, null, 2)); - await queryClient.invalidateQueries({ queryKey: ["clusters"] }); + setBusyMessage(kind === "sync" ? `Syncing inventory for ${cluster.name}...` : `Testing connection to ${cluster.name}...`); + try { + const data = await api>(`/clusters/${cluster.id}/${kind}`, { method: "POST" }); + setResult(JSON.stringify(data, null, 2)); + await queryClient.invalidateQueries({ queryKey: ["clusters"] }); + } finally { + setBusyMessage(""); + } } function deleteCluster(cluster: Cluster) { @@ -83,6 +90,7 @@ export function Clusters() { return ( <> +
setOpen(false)}> diff --git a/frontend/src/pages/FirewallPreview.tsx b/frontend/src/pages/FirewallPreview.tsx index 8626bc6..69bac45 100644 --- a/frontend/src/pages/FirewallPreview.tsx +++ b/frontend/src/pages/FirewallPreview.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { api, Cluster, Policy } from "../api/client"; import { buttonClass, Field, secondaryButtonClass, selectClass } from "../components/FormControls"; +import { LoadingOverlay } from "../components/LoadingOverlay"; import { PageHeader } from "../components/PageHeader"; export function FirewallPreview() { @@ -28,10 +29,18 @@ export function FirewallPreview() { }), }); const selectedPolicyId = policyId || policies.data?.[0]?.id || ""; + const busyMessage = preview.isPending + ? "Generating firewall preview..." + : apply.isPending + ? dryRun + ? "Running dry apply..." + : "Applying firewall rules..." + : ""; return ( <> +
diff --git a/frontend/src/pages/Ipam.tsx b/frontend/src/pages/Ipam.tsx index bbab6fe..e5f0068 100644 --- a/frontend/src/pages/Ipam.tsx +++ b/frontend/src/pages/Ipam.tsx @@ -5,6 +5,7 @@ import { Database, Download, Plus } from "lucide-react"; import { api, authorizedFetch, IpAddress, Network, Subnet } from "../api/client"; import { DataTable } from "../components/DataTable"; import { buttonClass, Field, inputClass, secondaryButtonClass, selectClass } from "../components/FormControls"; +import { LoadingOverlay } from "../components/LoadingOverlay"; import { Modal } from "../components/Modal"; 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 [subnetOpen, setSubnetOpen] = useState(false); const [ipOpen, setIpOpen] = useState(false); + const [busyMessage, setBusyMessage] = useState(""); const createSubnet = useMutation({ mutationFn: () => api("/ipam/subnets", { method: "POST", body: JSON.stringify({ ...subnetForm, network_id: subnetForm.network_id || networks.data?.[0]?.id }) }), @@ -49,17 +51,23 @@ export function Ipam() { } async function exportCsv() { - const response = await authorizedFetch("/ipam/export.csv"); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "nexafabric-ipam.csv"; - link.click(); - URL.revokeObjectURL(url); + setBusyMessage("Preparing IPAM export..."); + try { + const response = await authorizedFetch("/ipam/export.csv"); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "nexafabric-ipam.csv"; + link.click(); + URL.revokeObjectURL(url); + } finally { + setBusyMessage(""); + } } async function discoverIpam() { + setBusyMessage("Discovering IP addresses from Proxmox..."); try { const result = await api<{ imported: number; removed: number; errors: Array> }>("/ipam/discover", { method: "POST" }); 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"] }); } catch (error) { setMessage(error instanceof Error ? error.message : "IPAM discovery failed."); + } finally { + setBusyMessage(""); } } return ( <> +
diff --git a/frontend/src/pages/Nodes.tsx b/frontend/src/pages/Nodes.tsx index e0f6529..114ba9b 100644 --- a/frontend/src/pages/Nodes.tsx +++ b/frontend/src/pages/Nodes.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { AgentInstallInfo, api, Node } from "../api/client"; import { DataTable } from "../components/DataTable"; import { iconButtonClass, secondaryButtonClass } from "../components/FormControls"; +import { LoadingOverlay } from "../components/LoadingOverlay"; import { Modal } from "../components/Modal"; import { PageHeader } from "../components/PageHeader"; @@ -47,6 +48,7 @@ export function Nodes() { return ( <> + {nodes.isLoading ?
Loading...
: null} {nodes.error ?
Failed to load nodes.
: null} {!nodes.isLoading && !nodes.error ? ( diff --git a/frontend/src/pages/Policies.tsx b/frontend/src/pages/Policies.tsx index ccc631b..3208c84 100644 --- a/frontend/src/pages/Policies.tsx +++ b/frontend/src/pages/Policies.tsx @@ -5,6 +5,7 @@ import { Eye, GitBranch, Pencil, Play, Plus, Trash2 } from "lucide-react"; import { api, Policy, Project, ServiceCatalogItem } from "../api/client"; import { DataTable } from "../components/DataTable"; import { buttonClass, Field, iconButtonClass, inputClass, selectClass } from "../components/FormControls"; +import { LoadingOverlay } from "../components/LoadingOverlay"; import { Modal } from "../components/Modal"; import { PageHeader } from "../components/PageHeader"; @@ -45,6 +46,7 @@ export function Policies() { const [open, setOpen] = useState(false); const [editing, setEditing] = useState(null); const [form, setForm] = useState(defaultPolicyForm); + const [busyMessage, setBusyMessage] = useState(""); const save = useMutation({ mutationFn: () => { @@ -85,14 +87,24 @@ export function Policies() { } async function compile(policy: Policy) { - const data = await api(`/policies/${policy.id}/compile`, { method: "POST" }); - setPreview(JSON.stringify(data.last_compiled, null, 2)); - await queryClient.invalidateQueries({ queryKey: ["policies"] }); + setBusyMessage(`Compiling ${policy.name}...`); + try { + const data = await api(`/policies/${policy.id}/compile`, { method: "POST" }); + setPreview(JSON.stringify(data.last_compiled, null, 2)); + await queryClient.invalidateQueries({ queryKey: ["policies"] }); + } finally { + setBusyMessage(""); + } } async function firewallPreview(policy: Policy) { - const data = await api>(`/firewall/preview/${policy.id}`, { method: "POST" }); - setPreview(JSON.stringify(data, null, 2)); + setBusyMessage(`Generating preview for ${policy.name}...`); + try { + const data = await api>(`/firewall/preview/${policy.id}`, { method: "POST" }); + setPreview(JSON.stringify(data, null, 2)); + } finally { + setBusyMessage(""); + } } function addPolicy() { @@ -140,6 +152,7 @@ export function Policies() { return ( <> +
setOpen(false)}>