Files
doktainer-upstream/src/app/firewall/page.tsx
T

1173 lines
38 KiB
TypeScript

"use client";
import ConfirmActionDialog from "@/components/ConfirmActionDialog";
import DashboardLayout from "@/components/DashboardLayout";
import IssueDetailsSummary from "@/components/IssueDetailsSummary";
import TablePagination from "@/components/TablePagination";
import ToastViewport from "@/components/ToastViewport";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Flame,
Shield,
Plus,
Trash2,
RefreshCw,
ToggleLeft,
ToggleRight,
Loader2,
Server as ServerIcon,
X,
} from "lucide-react";
import {
security as securityApi,
servers as serversApi,
FirewallStatus,
SecurityFirewallRule,
SecurityOverviewItem,
Server,
} from "@/lib/api";
import {
readCachedPageData,
readStoredServerSelection,
storeServerSelection,
writeCachedPageData,
} from "@/lib/page-state";
import { useTablePagination } from "@/lib/use-table-pagination";
import { useToastManager } from "@/lib/use-toast-manager";
const POLL_INTERVAL = 15000;
const PAGE_KEY = "firewall";
type PendingConfirmAction = {
title: string;
description: string;
confirmLabel: string;
tone?: "danger" | "warning" | "info";
note?: string;
onConfirm: () => void;
};
export default function FirewallPage() {
const { toasts, pushToast, dismissToast } = useToastManager();
const [servers, setServers] = useState<Server[]>([]);
const [overview, setOverview] = useState<SecurityOverviewItem[]>([]);
const [firewall, setFirewall] = useState<FirewallStatus | null>(null);
const [selectedServerId, setSelectedServerId] = useState("");
const [loading, setLoading] = useState(true);
const [firewallLoading, setFirewallLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
const [showAddRule, setShowAddRule] = useState(false);
const [ruleForm, setRuleForm] = useState({
rule: "",
action: "allow" as "allow" | "deny",
from: "",
});
const [actionKey, setActionKey] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] =
useState<PendingConfirmAction | null>(null);
const latestRequestIdRef = useRef(0);
const applyCachedState = useCallback((serverId: string) => {
const cached = readCachedPageData<{
servers: Server[];
overview: SecurityOverviewItem[];
firewall: FirewallStatus | null;
}>(PAGE_KEY, serverId);
if (!cached) return false;
if (serverId && !cached.firewall) {
return false;
}
setServers(cached.servers);
setOverview(cached.overview);
setFirewall(cached.firewall);
setLoading(false);
return true;
}, []);
const loadData = useCallback(
async (preferredServerId?: string, silent = false) => {
const requestId = ++latestRequestIdRef.current;
if (silent) {
setRefreshing(true);
} else {
setLoading(true);
}
try {
setError("");
const overviewPromise = securityApi.overview();
const serversRes = await serversApi.list();
const nextServers = serversRes.data ?? [];
const requestedServerId = preferredServerId ?? "";
const resolvedServerId = nextServers.some(
(server) => server.id === requestedServerId,
)
? requestedServerId
: "";
if (latestRequestIdRef.current !== requestId) {
return;
}
setServers(nextServers);
setSelectedServerId(resolvedServerId);
if (!resolvedServerId) {
setFirewall(null);
setFirewallLoading(false);
} else {
setFirewallLoading(true);
}
const [overviewResult] = await Promise.allSettled([overviewPromise]);
if (latestRequestIdRef.current !== requestId) {
return;
}
const nextOverview =
overviewResult.status === "fulfilled"
? (overviewResult.value.data ?? [])
: [];
setOverview(nextOverview);
if (!resolvedServerId) {
storeServerSelection(PAGE_KEY, resolvedServerId);
writeCachedPageData(
PAGE_KEY,
{
servers: nextServers,
overview: nextOverview,
firewall: null,
},
resolvedServerId,
);
return;
}
const [firewallResult] = await Promise.allSettled([
securityApi.getFirewall(resolvedServerId),
]);
if (latestRequestIdRef.current !== requestId) {
return;
}
let nextFirewall: FirewallStatus | null = null;
if (firewallResult.status === "fulfilled") {
nextFirewall = firewallResult.value.data ?? null;
setFirewall(nextFirewall);
} else {
setFirewall(null);
}
setFirewallLoading(false);
storeServerSelection(PAGE_KEY, resolvedServerId);
writeCachedPageData(
PAGE_KEY,
{
servers: nextServers,
overview: nextOverview,
firewall: nextFirewall,
},
resolvedServerId,
);
} catch (err: unknown) {
if (latestRequestIdRef.current !== requestId) {
return;
}
setFirewallLoading(false);
setError(
err instanceof Error ? err.message : "Failed to load firewall data",
);
} finally {
if (latestRequestIdRef.current === requestId) {
setLoading(false);
setRefreshing(false);
}
}
},
[],
);
useEffect(() => {
const storedServerId = readStoredServerSelection(PAGE_KEY);
setSelectedServerId(storedServerId);
const hasCache = applyCachedState(storedServerId);
void loadData(storedServerId, hasCache);
}, [applyCachedState, loadData]);
useEffect(() => {
if (!selectedServerId) return;
const interval = window.setInterval(() => {
void loadData(selectedServerId, true);
}, POLL_INTERVAL);
return () => window.clearInterval(interval);
}, [loadData, selectedServerId]);
const selectedServer = useMemo(
() => servers.find((server) => server.id === selectedServerId) ?? null,
[selectedServerId, servers],
);
const selectedFirewall = useMemo(
() => (selectedServerId ? firewall : null),
[firewall, selectedServerId],
);
const summary = useMemo(
() => ({
enabledServers: overview.filter((item) => item.firewall.enabled).length,
totalRules: overview.reduce(
(sum, item) => sum + item.firewall.rulesCount,
0,
),
totalServers: overview.length,
}),
[overview],
);
const rulesPagination = useTablePagination({
items: selectedFirewall?.rules ?? [],
resetKey: selectedServerId,
});
const handleSelectAllServers = () => {
storeServerSelection(PAGE_KEY, "");
setSelectedServerId("");
setFirewall(null);
setFirewallLoading(false);
const hasCache = applyCachedState("");
void loadData("", hasCache);
};
const handleSelectServer = (serverId: string) => {
storeServerSelection(PAGE_KEY, serverId);
setSelectedServerId(serverId);
const hasCache = applyCachedState(serverId);
if (!hasCache) {
setFirewall(null);
}
setFirewallLoading(true);
void loadData(serverId, true);
};
const toggleFirewall = async () => {
if (!selectedServerId || !selectedFirewall) return;
setActionKey("toggle-firewall");
try {
if (selectedFirewall.enabled) {
await securityApi.disableFirewall(selectedServerId);
} else {
await securityApi.enableFirewall(selectedServerId);
}
await loadData(selectedServerId, true);
pushToast({
tone: "success",
title: "Firewall",
message: `Firewall ${selectedFirewall.enabled ? "disabled" : "enabled"} successfully`,
showProgress: true,
});
} catch (err: unknown) {
pushToast({
tone: "error",
title: "Firewall",
message:
err instanceof Error
? err.message
: "Failed to update firewall state",
showProgress: true,
});
} finally {
setActionKey(null);
}
};
const addRule = async () => {
if (!selectedServerId || !ruleForm.rule.trim()) return;
setActionKey("add-rule");
try {
await securityApi.addFirewallRule(selectedServerId, {
rule: ruleForm.rule.trim(),
action: ruleForm.action,
from: ruleForm.from.trim() || undefined,
});
setRuleForm({ rule: "", action: "allow", from: "" });
setShowAddRule(false);
await loadData(selectedServerId, true);
pushToast({
tone: "success",
title: "Firewall Rule",
message: "Firewall rule added successfully",
showProgress: true,
});
} catch (err: unknown) {
pushToast({
tone: "error",
title: "Firewall Rule",
message:
err instanceof Error ? err.message : "Failed to add firewall rule",
showProgress: true,
});
} finally {
setActionKey(null);
}
};
const removeRule = async (ruleNum: number) => {
if (!selectedServerId) return;
setActionKey(`delete-rule-${ruleNum}`);
try {
await securityApi.deleteFirewallRule(selectedServerId, ruleNum);
await loadData(selectedServerId, true);
pushToast({
tone: "success",
title: "Firewall Rule",
message: `Firewall rule #${ruleNum} deleted successfully`,
showProgress: true,
});
} catch (err: unknown) {
pushToast({
tone: "error",
title: "Firewall Rule",
message:
err instanceof Error ? err.message : "Failed to delete firewall rule",
showProgress: true,
});
} finally {
setActionKey(null);
}
};
const toggleRule = async (rule: SecurityFirewallRule) => {
if (!selectedServerId) return;
const actionId = `toggle-rule-${rule.id ?? rule.number}`;
setActionKey(actionId);
try {
if (rule.enabled) {
if (rule.number === null) return;
await securityApi.disableFirewallRule(selectedServerId, rule.number, {
rule: rule.rule,
action: rule.action.toLowerCase() as "allow" | "deny",
direction: rule.direction,
from: rule.from,
description: rule.description,
});
} else {
if (!rule.id) return;
await securityApi.enableSavedFirewallRule(selectedServerId, rule.id);
}
await loadData(selectedServerId, true);
pushToast({
tone: "success",
title: "Firewall Rule",
message: `Firewall rule ${rule.enabled ? "disabled" : "enabled"} successfully`,
showProgress: true,
});
} catch (err: unknown) {
pushToast({
tone: "error",
title: "Firewall Rule",
message:
err instanceof Error ? err.message : "Failed to toggle firewall rule",
showProgress: true,
});
} finally {
setActionKey(null);
}
};
const removeSavedRule = async (ruleId: string) => {
if (!selectedServerId) return;
setActionKey(`delete-saved-rule-${ruleId}`);
try {
await securityApi.deleteSavedFirewallRule(selectedServerId, ruleId);
await loadData(selectedServerId, true);
pushToast({
tone: "success",
title: "Firewall Rule",
message: "Saved firewall rule deleted successfully",
showProgress: true,
});
} catch (err: unknown) {
pushToast({
tone: "error",
title: "Firewall Rule",
message:
err instanceof Error
? err.message
: "Failed to delete saved firewall rule",
showProgress: true,
});
} finally {
setActionKey(null);
}
};
const deleteRule = async (ruleNum: number) => {
setConfirmDialog({
title: "Delete Firewall Rule",
description: `Delete firewall rule #${ruleNum}?`,
confirmLabel: "Delete Rule",
tone: "danger",
note: "Traffic matching this rule will no longer be filtered by UFW.",
onConfirm: () => {
void removeRule(ruleNum);
},
});
};
const deleteSavedRule = async (ruleId: string) => {
setConfirmDialog({
title: "Delete Saved Firewall Rule",
description: "Delete this saved disabled firewall rule?",
confirmLabel: "Delete Saved Rule",
tone: "danger",
note: "This preset will be removed from the saved rules list.",
onConfirm: () => {
void removeSavedRule(ruleId);
},
});
};
return (
<DashboardLayout
title="Firewall"
subtitle="Realtime UFW firewall management per server"
>
<ConfirmActionDialog
open={confirmDialog !== null}
title={confirmDialog?.title ?? ""}
description={confirmDialog?.description ?? ""}
confirmLabel={confirmDialog?.confirmLabel ?? "Confirm"}
tone={confirmDialog?.tone ?? "danger"}
note={confirmDialog?.note}
onClose={() => setConfirmDialog(null)}
onConfirm={() => {
const current = confirmDialog;
setConfirmDialog(null);
current?.onConfirm();
}}
/>
<ToastViewport
toasts={toasts}
onClose={dismissToast}
position="bottom-right"
/>
<div
className="animate-slide-in"
style={{ display: "flex", flexDirection: "column", gap: 16 }}
>
{error && (
<IssueDetailsSummary
label="Firewall"
message={error}
description="Firewall data could not be loaded or refreshed."
/>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 12,
}}
>
{[
{
label: "Firewall Enabled",
value: `${summary.enabledServers}/${summary.totalServers}`,
color: "#10b981",
icon: Shield,
},
{
label: "Total Rules",
value: summary.totalRules,
color: "#f59e0b",
icon: Flame,
},
{
label: "Realtime Poll",
value: "15s",
color: "#3b82f6",
icon: RefreshCw,
},
].map((item) => {
const Icon = item.icon;
return (
<div
key={item.label}
className="card"
style={{
padding: 16,
display: "flex",
alignItems: "center",
gap: 14,
}}
>
<div
style={{
width: 42,
height: 42,
borderRadius: 10,
background: `${item.color}15`,
border: `1px solid ${item.color}25`,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Icon size={18} style={{ color: item.color }} />
</div>
<div>
<p
style={{
fontSize: 22,
fontWeight: 700,
color: "var(--text-primary)",
lineHeight: 1,
}}
>
{item.value}
</p>
<p
style={{
fontSize: 11,
color: "var(--text-muted)",
marginTop: 3,
}}
>
{item.label}
</p>
</div>
</div>
);
})}
</div>
<div
className="card ui-responsive-toolbar"
style={{
padding: "12px 16px",
justifyContent: "space-between",
}}
>
<div className="ui-inline-cluster">
<ServerIcon size={14} style={{ color: "var(--text-muted)" }} />
<span
className="ui-inline-label"
style={{
fontSize: 13,
color: "var(--text-secondary)",
fontWeight: 500,
}}
>
Server:
</span>
<div className="ui-chip-scroll no-scrollbar">
<button
onClick={handleSelectAllServers}
style={{
padding: "5px 14px",
borderRadius: 7,
border: "none",
cursor: "pointer",
background:
selectedServerId === ""
? "rgba(59,130,246,0.15)"
: "var(--bg-input)",
color:
selectedServerId === "" ? "#3b82f6" : "var(--text-muted)",
fontSize: 12,
fontWeight: 500,
outline:
selectedServerId === ""
? "1px solid rgba(59,130,246,0.3)"
: "1px solid var(--border)",
}}
>
All Servers
</button>
{servers.map((server) => (
<button
key={server.id}
onClick={() => handleSelectServer(server.id)}
style={{
padding: "5px 14px",
borderRadius: 7,
border: "none",
cursor: "pointer",
background:
selectedServerId === server.id
? "rgba(59,130,246,0.15)"
: "var(--bg-input)",
color:
selectedServerId === server.id
? "#3b82f6"
: "var(--text-muted)",
fontSize: 12,
fontWeight: 500,
outline:
selectedServerId === server.id
? "1px solid rgba(59,130,246,0.3)"
: "1px solid var(--border)",
}}
>
{server.name}
</button>
))}
</div>
</div>
<div className="ui-toolbar-actions">
<button
className="btn btn-ghost"
style={{ fontSize: 12 }}
onClick={() => void loadData(selectedServerId, true)}
disabled={refreshing || loading}
>
{refreshing ? (
<Loader2 size={12} className="animate-spin" />
) : (
<RefreshCw size={12} />
)}
Refresh Now
</button>
</div>
</div>
{loading ? (
<div className="card" style={{ padding: 42, textAlign: "center" }}>
<Loader2
size={24}
className="animate-spin"
style={{ color: "var(--accent)", margin: "0 auto 10px" }}
/>
<p style={{ color: "var(--text-muted)", fontSize: 14 }}>
Loading firewall status...
</p>
</div>
) : !selectedServerId ? (
<div
className="card"
style={{
padding: 42,
textAlign: "center",
color: "var(--text-muted)",
fontSize: 14,
}}
>
Select a server to inspect firewall rules. Overview all servers is
shown below.
</div>
) : firewallLoading && !selectedFirewall ? (
<div className="card" style={{ padding: 42, textAlign: "center" }}>
<Loader2
size={24}
className="animate-spin"
style={{ color: "var(--accent)", margin: "0 auto 10px" }}
/>
<p style={{ color: "var(--text-muted)", fontSize: 14 }}>
Loading selected server firewall details...
</p>
</div>
) : !selectedFirewall || !selectedServer ? (
<div
className="card"
style={{
padding: 42,
textAlign: "center",
color: "var(--text-muted)",
fontSize: 14,
}}
>
Unable to load firewall details for the selected server.
</div>
) : (
<div className="card" style={{ overflow: "hidden" }}>
<div
style={{
padding: "16px 20px",
borderBottom: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<Flame size={16} style={{ color: "#f59e0b" }} />
<div>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
Firewall (UFW)
</h2>
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
{selectedServer.name} · {selectedServer.ip}
</p>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span
style={{
fontSize: 12,
color: selectedFirewall.enabled ? "#10b981" : "#ef4444",
}}
>
{selectedFirewall.enabled ? "Enabled" : "Disabled"}
</span>
<button
onClick={() => void toggleFirewall()}
style={{
background: "none",
border: "none",
cursor: "pointer",
color: selectedFirewall.enabled
? "#10b981"
: "var(--text-muted)",
}}
disabled={actionKey === "toggle-firewall"}
>
{actionKey === "toggle-firewall" ? (
<Loader2 size={20} className="animate-spin" />
) : selectedFirewall.enabled ? (
<ToggleRight size={24} />
) : (
<ToggleLeft size={24} />
)}
</button>
<button
className="btn btn-primary"
style={{ fontSize: 11, padding: "5px 10px" }}
onClick={() => setShowAddRule(true)}
>
<Plus size={11} /> Add Rule
</button>
</div>
</div>
<div style={{ overflowX: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th>#</th>
<th>Status</th>
<th>Rule</th>
<th>Action</th>
<th>From</th>
<th />
</tr>
</thead>
<tbody>
{selectedFirewall.rules.length === 0 ? (
<tr>
<td
colSpan={6}
style={{
textAlign: "center",
padding: 24,
color: "var(--text-muted)",
}}
>
No firewall rules detected.
</td>
</tr>
) : (
rulesPagination.paginatedItems.map((rule) => (
<tr key={rule.id ?? `${rule.number}-${rule.rule}`}>
<td
style={{ fontSize: 11, color: "var(--text-muted)" }}
>
{rule.number === null ? "Saved" : `#${rule.number}`}
</td>
<td>
<span
style={{
background: rule.enabled
? "rgba(16,185,129,0.1)"
: "rgba(148,163,184,0.12)",
color: rule.enabled ? "#10b981" : "#94a3b8",
border: `1px solid ${rule.enabled ? "rgba(16,185,129,0.3)" : "rgba(148,163,184,0.25)"}`,
padding: "2px 8px",
borderRadius: 5,
fontSize: 11,
fontWeight: 600,
}}
>
{rule.enabled ? "ACTIVE" : "DISABLED"}
</span>
</td>
<td
style={{
fontFamily: "JetBrains Mono, monospace",
fontSize: 12,
color: rule.enabled
? "var(--text-primary)"
: "var(--text-secondary)",
fontWeight: 600,
opacity: rule.enabled ? 1 : 0.75,
}}
>
{rule.rule}
</td>
<td>
<span
style={{
background:
rule.action === "ALLOW"
? "rgba(16,185,129,0.1)"
: "rgba(239,68,68,0.1)",
color:
rule.action === "ALLOW" ? "#10b981" : "#ef4444",
border: `1px solid ${rule.action === "ALLOW" ? "rgba(16,185,129,0.3)" : "rgba(239,68,68,0.3)"}`,
padding: "2px 8px",
borderRadius: 5,
fontSize: 11,
fontWeight: 600,
}}
>
{rule.action}
</span>
</td>
<td
style={{ fontSize: 11, color: "var(--text-muted)" }}
>
{rule.from}
</td>
<td style={{ textAlign: "right" }}>
<button
className="btn btn-ghost"
style={{ padding: "4px 7px", marginRight: 6 }}
onClick={() => void toggleRule(rule)}
disabled={
actionKey ===
`toggle-rule-${rule.id ?? rule.number}`
}
>
{actionKey ===
`toggle-rule-${rule.id ?? rule.number}` ? (
<Loader2 size={11} className="animate-spin" />
) : rule.enabled ? (
<ToggleRight size={14} />
) : (
<ToggleLeft size={14} />
)}
</button>
<button
className="btn btn-danger"
style={{ padding: "4px 7px" }}
onClick={() => {
if (rule.enabled && rule.number !== null) {
void deleteRule(rule.number);
return;
}
if (rule.id) {
void deleteSavedRule(rule.id);
}
}}
disabled={
rule.enabled
? actionKey === `delete-rule-${rule.number}`
: actionKey === `delete-saved-rule-${rule.id}`
}
>
{(
rule.enabled
? actionKey === `delete-rule-${rule.number}`
: actionKey === `delete-saved-rule-${rule.id}`
) ? (
<Loader2 size={11} className="animate-spin" />
) : (
<Trash2 size={11} />
)}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<TablePagination
currentPage={rulesPagination.currentPage}
totalPages={rulesPagination.totalPages}
totalItems={rulesPagination.totalItems}
startItem={rulesPagination.startItem}
endItem={rulesPagination.endItem}
itemLabel="rules"
onPageChange={rulesPagination.setCurrentPage}
/>
</div>
)}
<div className="card" style={{ padding: 20 }}>
<h2
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--text-primary)",
marginBottom: 16,
}}
>
Firewall Overview — All Servers
</h2>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))",
gap: 10,
}}
>
{overview.map((item) => (
<div
key={item.server.id}
className="card"
style={{ padding: 14, background: "var(--bg-input)" }}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 12,
}}
>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{item.server.name}
</span>
<span
style={{
fontSize: 10,
fontWeight: 600,
padding: "2px 8px",
borderRadius: 5,
background: item.firewall.enabled
? "rgba(16,185,129,0.1)"
: item.error
? "rgba(239,68,68,0.1)"
: "rgba(148,163,184,0.12)",
color: item.firewall.enabled
? "#10b981"
: item.error
? "#ef4444"
: "#94a3b8",
border: `1px solid ${item.firewall.enabled ? "rgba(16,185,129,0.3)" : item.error ? "rgba(239,68,68,0.3)" : "rgba(148,163,184,0.25)"}`,
}}
>
{item.firewall.enabled
? "Enabled"
: item.error
? "Error"
: "Disabled"}
</span>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span
style={{ fontSize: 11, color: "var(--text-secondary)" }}
>
Rules
</span>
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>
{item.firewall.rulesCount}
</span>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span
style={{ fontSize: 11, color: "var(--text-secondary)" }}
>
Fail2ban
</span>
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>
{!item.fail2ban.installed
? "Not installed"
: item.fail2ban.enabled
? "Active"
: "Inactive"}
</span>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span
style={{ fontSize: 11, color: "var(--text-secondary)" }}
>
IP
</span>
<span style={{ fontSize: 11, color: "var(--text-muted)" }}>
{item.server.ip}
</span>
</div>
</div>
</div>
))}
</div>
</div>
{showAddRule && (
<div className="modal-overlay" onClick={() => setShowAddRule(false)}>
<div
className="modal-shell"
style={{ maxWidth: 420 }}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={() => setShowAddRule(false)}
className="modal-close"
aria-label="Close add firewall rule modal"
>
<X size={22} />
</button>
<div
className="modal-content card"
style={{ width: "100%", maxWidth: 420, padding: 20 }}
>
<div
style={{
display: "flex",
alignItems: "center",
marginBottom: 16,
paddingRight: 36,
}}
>
<div>
<h2
style={{
fontSize: 16,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
Add Firewall Rule
</h2>
<p style={{ fontSize: 12, color: "var(--text-muted)" }}>
Configure a new UFW rule for this server.
</p>
</div>
</div>
<div
style={{ display: "flex", flexDirection: "column", gap: 14 }}
>
<div>
<label className="form-label">Rule</label>
<input
className="input"
value={ruleForm.rule}
onChange={(event) =>
setRuleForm((current) => ({
...current,
rule: event.target.value,
}))
}
placeholder="22/tcp or 8080"
/>
</div>
<div>
<label className="form-label">Action</label>
<select
className="input"
value={ruleForm.action}
onChange={(event) =>
setRuleForm((current) => ({
...current,
action: event.target.value as "allow" | "deny",
}))
}
>
<option value="allow">Allow</option>
<option value="deny">Deny</option>
</select>
</div>
<div>
<label className="form-label">Source (optional)</label>
<input
className="input"
value={ruleForm.from}
onChange={(event) =>
setRuleForm((current) => ({
...current,
from: event.target.value,
}))
}
placeholder="Anywhere or 1.2.3.4"
/>
</div>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 10,
marginTop: 4,
}}
>
<button
className="btn btn-ghost"
onClick={() => setShowAddRule(false)}
>
Cancel
</button>
<button
className="btn btn-primary"
onClick={() => void addRule()}
disabled={actionKey === "add-rule" || !ruleForm.rule.trim()}
>
{actionKey === "add-rule" ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Plus size={14} />
)}
Add Rule
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</DashboardLayout>
);
}