Files
Netcatty/components/PortForwardingNew.tsx
2026-06-11 16:05:17 +08:00

961 lines
29 KiB
TypeScript

import {
AlertTriangle,
Check,
ChevronDown,
Globe,
LayoutGrid,
List as ListIcon,
Server,
Shuffle,
Zap,
} from "lucide-react";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import {
GroupConfig,
Host,
ManagedSource,
PortForwardingRule,
PortForwardingType,
ProxyProfile,
SSHKey,
} from "../domain/models";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { cn } from "../lib/utils";
import SelectHostPanel from "./SelectHostPanel";
import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
} from "./ui/aside-panel";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { SortDropdown } from "./ui/sort-dropdown";
import { toast } from "./ui/toast";
import {
VaultHeaderSearch,
VaultPageHeader,
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
// Import components and utilities from port-forwarding module
import {
EditPanel,
generateRuleLabel as buildRuleLabel,
getTypeMenuLabel,
NewFormPanel,
RuleCard,
WizardContent,
} from "./port-forwarding";
type WizardStep =
| "type"
| "local-config"
| "remote-host-selection"
| "remote-config"
| "destination"
| "host-selection"
| "label";
interface PortForwardingProps {
hosts: Host[];
keys: SSHKey[];
identities?: import('../domain/models').Identity[];
customGroups: string[];
managedSources?: ManagedSource[];
groupConfigs?: GroupConfig[];
proxyProfiles?: ProxyProfile[];
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const PortForwarding: React.FC<PortForwardingProps> = ({
hosts,
keys,
identities = [],
customGroups: _customGroups,
managedSources = [],
groupConfigs = [],
proxyProfiles = [],
onNewHost: _onNewHost,
onSaveHost,
onCreateGroup: _onCreateGroup,
terminalSettings,
}) => {
const { t } = useI18n();
const {
rules: _rules,
selectedRuleId,
viewMode,
sortMode,
search,
setSelectedRuleId,
setViewMode,
setSortMode,
setSearch,
addRule,
updateRule,
deleteRule,
duplicateRule,
reorderRule,
setRuleStatus,
startTunnel,
stopTunnel,
filteredRules,
selectedRule: _selectedRule,
preferFormMode,
setPreferFormMode,
} = usePortForwardingState();
// Track connecting/stopping states
const [pendingOperations, setPendingOperations] = useState<Set<string>>(
new Set(),
);
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const ruleListRef = useRef<HTMLDivElement | null>(null);
const ruleReorder = useVaultItemReorder({
containerRef: ruleListRef,
viewMode,
dragType: "rule-id",
targetAttribute: "data-rule-id",
disabled: search.trim().length > 0,
onReorder: reorderRule,
});
const resolveEffectiveHost = useCallback(
(host: Host): Host => {
const withGroupDefaults = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
},
[groupConfigs, proxyProfileIdSet, proxyProfiles],
);
// Start a port forwarding tunnel
const handleStartTunnel = useCallback(
async (rule: PortForwardingRule) => {
const _rawHost = hosts.find((h) => h.id === rule.hostId);
if (!_rawHost) {
setRuleStatus(rule.id, "error", t("pf.error.hostNotFound"));
toast.error(
t("pf.error.hostNotFound"),
t("pf.toast.titleWithLabel", { label: rule.label }),
);
return;
}
const _host = resolveEffectiveHost(_rawHost);
const effectiveHosts = hosts.map((host) => resolveEffectiveHost(host));
setPendingOperations((prev) => new Set([...prev, rule.id]));
let errorShown = false;
try {
const result = await startTunnel(
rule,
_host,
effectiveHosts,
keys,
identities,
(status, error) => {
// Show toast on error (only once)
if (status === "error" && error && !errorShown) {
errorShown = true;
toast.error(
error,
t("pf.toast.titleWithLabel", { label: rule.label }),
);
}
},
rule.autoStart, // Enable reconnect for auto-start rules
terminalSettings,
);
// Show error from result only if not already shown
if (!result.success && result.error && !errorShown) {
errorShown = true;
toast.error(
result.error,
t("pf.toast.titleWithLabel", { label: rule.label }),
);
}
} finally {
setPendingOperations((prev) => {
const next = new Set(prev);
next.delete(rule.id);
return next;
});
}
},
[hosts, identities, keys, resolveEffectiveHost, setRuleStatus, startTunnel, t, terminalSettings],
);
// Stop a port forwarding tunnel
const handleStopTunnel = useCallback(
async (rule: PortForwardingRule) => {
setPendingOperations((prev) => new Set([...prev, rule.id]));
try {
await stopTunnel(rule.id);
} finally {
setPendingOperations((prev) => {
const next = new Set(prev);
next.delete(rule.id);
return next;
});
}
},
[stopTunnel],
);
// Wizard state
const [showWizard, setShowWizard] = useState(false);
const [wizardStep, setWizardStep] = useState<WizardStep>("type");
const [wizardType, setWizardType] = useState<PortForwardingType>("local");
const [draftRule, setDraftRule] = useState<Partial<PortForwardingRule>>({
label: "",
type: "local",
localPort: undefined,
bindAddress: "127.0.0.1",
remoteHost: "",
remotePort: undefined,
hostId: undefined,
});
const [isEditing, setIsEditing] = useState(false);
const [showHostSelector, setShowHostSelector] = useState(false);
// Edit panel state (separate from wizard)
const [showEditPanel, setShowEditPanel] = useState(false);
const [editingRule, setEditingRule] = useState<PortForwardingRule | null>(
null,
);
const [editDraft, setEditDraft] = useState<Partial<PortForwardingRule>>({});
// New forwarding form mode (skip wizard, all-in-one form)
const [showNewForm, setShowNewForm] = useState(false);
const [newFormDraft, setNewFormDraft] = useState<Partial<PortForwardingRule>>(
{
label: "",
type: "local",
localPort: undefined,
bindAddress: "127.0.0.1",
remoteHost: "",
remotePort: undefined,
hostId: undefined,
},
);
// New forwarding menu
const [showNewMenu, setShowNewMenu] = useState(false);
// Delete confirmation dialog state for active tunnels
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [ruleToDelete, setRuleToDelete] = useState<PortForwardingRule | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Reset wizard
const resetWizard = () => {
setWizardStep("type");
setWizardType("local");
setDraftRule({
label: "",
type: "local",
localPort: undefined,
bindAddress: "127.0.0.1",
remoteHost: "",
remotePort: undefined,
hostId: undefined,
});
setIsEditing(false);
};
// Reset new form
const resetNewForm = () => {
setNewFormDraft({
label: "",
type: "local",
localPort: undefined,
bindAddress: "127.0.0.1",
remoteHost: "",
remotePort: undefined,
hostId: undefined,
});
};
// Start new rule - wizard or form based on user preference
const startNewRule = (type: PortForwardingType) => {
setShowNewMenu(false);
if (preferFormMode) {
// Form mode: show all-in-one form
resetNewForm();
setNewFormDraft((prev) => ({ ...prev, type }));
setShowNewForm(true);
setShowWizard(false);
} else {
// Wizard mode
resetWizard();
setWizardType(type);
setDraftRule((prev) => ({ ...prev, type }));
setShowWizard(true);
setShowNewForm(false);
setWizardStep("type");
}
};
// Skip wizard and switch to form mode
const skipWizardToForm = () => {
// Save preference
setPreferFormMode(true);
// Transfer current draft to form
setNewFormDraft({
...draftRule,
type: wizardType,
});
setShowWizard(false);
setShowNewForm(true);
};
// Open wizard from form
const openWizardFromForm = () => {
// User opens wizard - prefer wizard mode next time
setPreferFormMode(false);
// Transfer current form draft to wizard
setWizardType(newFormDraft.type || "local");
setDraftRule({ ...newFormDraft });
setShowNewForm(false);
setShowWizard(true);
setWizardStep("type");
};
// Save new rule from form
const saveNewFormRule = () => {
const label =
newFormDraft.label?.trim() ||
(() => {
switch (newFormDraft.type) {
case "local":
return `Local:${newFormDraft.localPort}${newFormDraft.remoteHost}:${newFormDraft.remotePort}`;
case "remote":
return `Remote:${newFormDraft.localPort}${newFormDraft.remoteHost}:${newFormDraft.remotePort}`;
case "dynamic":
return `SOCKS:${newFormDraft.localPort}`;
default:
return "New Rule";
}
})();
addRule({
label,
type: newFormDraft.type || "local",
localPort: newFormDraft.localPort!,
bindAddress: newFormDraft.bindAddress || "127.0.0.1",
remoteHost: newFormDraft.remoteHost,
remotePort: newFormDraft.remotePort,
hostId: newFormDraft.hostId,
});
setShowNewForm(false);
resetNewForm();
};
// Close new form
const closeNewForm = () => {
setShowNewForm(false);
resetNewForm();
};
// Check if new form is valid
const isNewFormValid = (): boolean => {
if (
!newFormDraft.localPort ||
newFormDraft.localPort <= 0 ||
newFormDraft.localPort >= 65536
)
return false;
if (!newFormDraft.hostId) return false;
if (newFormDraft.type !== "dynamic") {
if (!newFormDraft.remoteHost || !newFormDraft.remotePort) return false;
}
return true;
};
// Edit existing rule - open edit panel
const startEditRule = (rule: PortForwardingRule) => {
setEditingRule(rule);
setEditDraft({ ...rule });
setShowEditPanel(true);
setShowWizard(false);
setShowNewForm(false);
};
// Save edited rule
const saveEditedRule = () => {
if (editingRule && editDraft.id) {
updateRule(editDraft.id, editDraft);
setShowEditPanel(false);
setEditingRule(null);
setEditDraft({});
}
};
// Close edit panel
const closeEditPanel = useCallback(() => {
setShowEditPanel(false);
setEditingRule(null);
setEditDraft({});
setSelectedRuleId(null);
}, [setSelectedRuleId]);
// Handle delete with confirmation for active tunnels
const handleDeleteRule = useCallback(
(rule: PortForwardingRule) => {
// If tunnel is active or connecting, show confirmation dialog
if (rule.status === "active" || rule.status === "connecting") {
setRuleToDelete(rule);
setShowDeleteConfirm(true);
} else {
// If inactive, delete directly
if (editingRule?.id === rule.id) {
closeEditPanel();
}
deleteRule(rule.id);
}
},
[editingRule, deleteRule, closeEditPanel],
);
// Confirm delete of active tunnel: stop first, then delete
const confirmDeleteActiveRule = useCallback(async () => {
if (!ruleToDelete) return;
setIsDeleting(true);
try {
// Stop the tunnel first
await stopTunnel(ruleToDelete.id);
// Then delete the rule
if (editingRule?.id === ruleToDelete.id) {
closeEditPanel();
}
deleteRule(ruleToDelete.id);
} finally {
setIsDeleting(false);
setShowDeleteConfirm(false);
setRuleToDelete(null);
}
}, [ruleToDelete, stopTunnel, deleteRule, editingRule, closeEditPanel]);
// Handle wizard navigation
// Flow for local: type -> local-config -> destination -> host-selection
// Flow for remote: type -> remote-host-selection (select host first) -> remote-config (port on remote) -> destination -> label
// Flow for dynamic: type -> local-config -> host-selection
const getNextStep = (): WizardStep | null => {
switch (wizardStep) {
case "type":
if (wizardType === "dynamic") return "local-config";
if (wizardType === "local") return "local-config";
if (wizardType === "remote") return "remote-host-selection";
return null;
case "local-config":
if (wizardType === "dynamic") return "host-selection";
if (wizardType === "local") return "destination";
return null;
case "remote-host-selection":
return "remote-config";
case "remote-config":
return "destination";
case "destination":
if (wizardType === "remote") return "label";
return "host-selection"; // Host selection is last for local
case "host-selection":
return null;
case "label":
return null;
default:
return null;
}
};
const getPrevStep = (): WizardStep | null => {
switch (wizardStep) {
case "type":
return null;
case "local-config":
return "type";
case "remote-host-selection":
return "type";
case "remote-config":
return "remote-host-selection";
case "destination":
if (wizardType === "local") return "local-config";
if (wizardType === "remote") return "remote-config";
return null;
case "host-selection":
if (wizardType === "dynamic") return "local-config";
return "destination";
case "label":
return "destination";
default:
return null;
}
};
const canProceed = (): boolean => {
switch (wizardStep) {
case "type":
// Type step just shows description, always can proceed
return true;
case "local-config":
return !!(
draftRule.localPort &&
draftRule.localPort > 0 &&
draftRule.localPort < 65536
);
case "remote-host-selection":
return !!draftRule.hostId;
case "remote-config":
return !!(
draftRule.localPort &&
draftRule.localPort > 0 &&
draftRule.localPort < 65536
);
case "destination":
return !!(
draftRule.remoteHost &&
draftRule.remotePort &&
draftRule.remotePort > 0
);
case "host-selection":
return !!draftRule.hostId;
case "label":
return true; // Label is optional
default:
return false;
}
};
const isLastStep = (): boolean => {
return getNextStep() === null;
};
// Save rule
const saveRule = () => {
// Generate label if not provided
const label = draftRule.label?.trim() || generateRuleLabel();
if (isEditing && draftRule.id) {
updateRule(draftRule.id, { ...draftRule, label });
} else {
addRule({
label,
type: wizardType,
localPort: draftRule.localPort!,
bindAddress: draftRule.bindAddress || "127.0.0.1",
remoteHost: draftRule.remoteHost,
remotePort: draftRule.remotePort,
hostId: draftRule.hostId,
});
}
setShowWizard(false);
resetWizard();
};
const generateRuleLabel = (): string => {
return buildRuleLabel(
wizardType,
draftRule.localPort,
draftRule.remoteHost,
draftRule.remotePort,
);
};
// Render wizard panel content
const hasRules = filteredRules.length > 0;
return (
<div className="flex h-full relative">
{/* Main Content */}
<div
className={cn(
"flex-1 flex flex-col min-h-0",
showWizard || showEditPanel || showNewForm ? "mr-[360px]" : "",
)}
>
<VaultPageHeader className="z-20">
<Dropdown open={showNewMenu} onOpenChange={setShowNewMenu}>
<DropdownTrigger asChild>
<Button
variant="secondary"
className={vaultHeaderSecondaryButtonClass}
>
<Zap size={14} />
{t("pf.action.newForwarding")}
<ChevronDown
size={14}
className={cn(
"transition-transform",
showNewMenu ? "rotate-180" : "",
)}
/>
</Button>
</DropdownTrigger>
<DropdownContent className="w-max flex flex-col" align="start" sideOffset={8}>
<Button
variant="ghost"
className="justify-start gap-3 h-10 px-3 whitespace-nowrap"
onClick={() => startNewRule("local")}
>
<Globe size={16} className="text-blue-500" />
{getTypeMenuLabel(t, "local")}
</Button>
<Button
variant="ghost"
className="justify-start gap-3 h-10 px-3 whitespace-nowrap"
onClick={() => startNewRule("remote")}
>
<Server size={16} className="text-orange-500" />
{getTypeMenuLabel(t, "remote")}
</Button>
<Button
variant="ghost"
className="justify-start gap-3 h-10 px-3 whitespace-nowrap"
onClick={() => startNewRule("dynamic")}
>
<Shuffle size={16} className="text-purple-500" />
{getTypeMenuLabel(t, "dynamic")}
</Button>
</DropdownContent>
</Dropdown>
<div className="ml-auto flex items-center gap-2">
<VaultHeaderSearch
placeholder={t("common.searchPlaceholder")}
className="w-64"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{/* View mode toggle */}
<Dropdown>
<DropdownTrigger asChild>
<Button variant="ghost" size="icon" className={vaultHeaderIconButtonClass}>
{viewMode === "grid" ? (
<LayoutGrid size={16} />
) : (
<ListIcon size={16} />
)}
<ChevronDown size={10} className="ml-0.5" />
</Button>
</DropdownTrigger>
<DropdownContent className="w-32" align="end">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("grid")}
>
<LayoutGrid size={14} /> {t("pf.view.grid")}
{viewMode === "grid" && (
<Check size={12} className="ml-auto" />
)}
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("list")}
>
<ListIcon size={14} /> {t("pf.view.list")}
{viewMode === "list" && (
<Check size={12} className="ml-auto" />
)}
</Button>
</DropdownContent>
</Dropdown>
{/* Sort mode toggle */}
<SortDropdown
value={sortMode}
onChange={(mode) => {
if (mode !== "group") setSortMode(mode);
}}
modes={["manual", "az", "za", "newest", "oldest"]}
className={vaultHeaderIconButtonClass}
/>
</div>
</VaultPageHeader>
{/* Rules List */}
<div className="flex-1 overflow-y-auto">
{!hasRules ? (
<div className="flex h-full flex-col items-center justify-center p-3 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<Zap size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{t("pf.empty.title")}
</h3>
<p className="text-sm text-center max-w-sm">
{t("pf.empty.desc")}
</p>
</div>
) : (
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className={vaultSectionTitleClass}>{t("pf.title")}</h2>
<span className="text-xs text-muted-foreground">
{t("pf.rulesCount", { count: filteredRules.length })}
</span>
</div>
<div
ref={ruleListRef}
className={cn(
viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
: "flex flex-col gap-2.5",
)}
onDragOverCapture={ruleReorder.handleDragOverCapture}
onDragOver={ruleReorder.handleDragOver}
onDropCapture={ruleReorder.handleDropCapture}
onDragEndCapture={ruleReorder.handleDragEndCapture}
>
{filteredRules.map((rule) => (
<RuleCard
key={rule.id}
rule={rule}
host={hosts.find((h) => h.id === rule.hostId)}
viewMode={viewMode}
isSelected={selectedRuleId === rule.id}
isPending={pendingOperations.has(rule.id)}
reorderProps={ruleReorder.getItemReorderProps(rule.id, `rule:${rule.id}`)}
onSelect={() => {
setSelectedRuleId(rule.id);
startEditRule(rule);
}}
onEdit={() => startEditRule(rule)}
onDuplicate={() => duplicateRule(rule.id)}
onDelete={() => handleDeleteRule(rule)}
onStart={() => handleStartTunnel(rule)}
onStop={() => handleStopTunnel(rule)}
/>
))}
</div>
</div>
)}
</div>
</div>
{/* Edit Panel - shown when a rule is selected */}
{showEditPanel && editingRule && (
<EditPanel
rule={editingRule}
draft={editDraft}
hosts={hosts}
onDraftChange={(updates) =>
setEditDraft((prev) => ({ ...prev, ...updates }))
}
onSave={saveEditedRule}
onClose={closeEditPanel}
onDuplicate={() => {
duplicateRule(editingRule.id);
closeEditPanel();
}}
onDelete={() => handleDeleteRule(editingRule)}
onOpenHostSelector={() => setShowHostSelector(true)}
/>
)}
{/* Wizard Panel */}
{showWizard && (
<AsidePanel
open={true}
onClose={() => {
setShowWizard(false);
resetWizard();
}}
title={isEditing ? t("pf.wizard.editTitle") : t("pf.wizard.newTitle")}
width="w-[360px]"
showBackButton={!!getPrevStep()}
onBack={
getPrevStep()
? () => {
const prev = getPrevStep();
if (prev) setWizardStep(prev);
}
: undefined
}
>
<AsidePanelContent>
<WizardContent
step={wizardStep}
type={wizardType}
draft={draftRule}
hosts={hosts}
onTypeChange={(type) => {
setWizardType(type);
setDraftRule((prev) => ({ ...prev, type }));
}}
onDraftChange={(updates) =>
setDraftRule((prev) => ({ ...prev, ...updates }))
}
onOpenHostSelector={() => setShowHostSelector(true)}
/>
</AsidePanelContent>
<AsidePanelFooter className="space-y-2">
<Button
className="w-full h-10"
disabled={!canProceed()}
onClick={() => {
if (isLastStep()) {
saveRule();
} else {
const next = getNextStep();
if (next) setWizardStep(next);
}
}}
>
{isLastStep()
? isEditing
? t("pf.wizard.saveChanges")
: t("pf.wizard.done")
: t("pf.wizard.continue")}
</Button>
<Button
variant="ghost"
className="w-full h-10 text-muted-foreground hover:text-foreground hover:bg-foreground/5"
onClick={() => {
if (isEditing) {
setShowWizard(false);
resetWizard();
} else {
skipWizardToForm();
}
}}
>
{isEditing ? t("pf.wizard.cancel") : t("pf.wizard.skipWizard")}
</Button>
</AsidePanelFooter>
</AsidePanel>
)}
{/* Host Selector Overlay */}
{showHostSelector && (
<SelectHostPanel
hosts={hosts}
customGroups={_customGroups}
selectedHostIds={
showEditPanel
? editDraft.hostId
? [editDraft.hostId]
: []
: showNewForm
? newFormDraft.hostId
? [newFormDraft.hostId]
: []
: draftRule.hostId
? [draftRule.hostId]
: []
}
multiSelect={false}
onSelect={(host) => {
if (showEditPanel) {
setEditDraft((prev) => ({ ...prev, hostId: host.id }));
} else if (showNewForm) {
setNewFormDraft((prev) => ({ ...prev, hostId: host.id }));
} else {
setDraftRule((prev) => ({ ...prev, hostId: host.id }));
}
setShowHostSelector(false);
}}
onBack={() => setShowHostSelector(false)}
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={_onCreateGroup}
/>
)}
{/* New Form Panel (skip wizard mode) */}
{showNewForm && (
<NewFormPanel
draft={newFormDraft}
hosts={hosts}
onDraftChange={(updates) =>
setNewFormDraft((prev) => ({ ...prev, ...updates }))
}
onSave={saveNewFormRule}
onClose={closeNewForm}
onOpenHostSelector={() => setShowHostSelector(true)}
onOpenWizard={openWizardFromForm}
isValid={isNewFormValid()}
/>
)}
{/* Delete Active Tunnel Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={(open) => {
if (!isDeleting) {
setShowDeleteConfirm(open);
if (!open) setRuleToDelete(null);
}
}}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle size={20} />
{t("pf.deleteActive.title")}
</DialogTitle>
<DialogDescription>
{t("pf.deleteActive.desc", { label: ruleToDelete?.label ?? "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setShowDeleteConfirm(false);
setRuleToDelete(null);
}}
disabled={isDeleting}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={confirmDeleteActiveRule}
disabled={isDeleting}
>
{t("pf.deleteActive.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default PortForwarding;