Files
NetMesh/components/ProxyProfilesManager.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

737 lines
26 KiB
TypeScript

import {
AlertTriangle,
ChevronDown,
Copy,
Globe,
KeyRound,
LayoutGrid,
List as ListIcon,
Pencil,
Plus,
Route,
Settings2,
SquareTerminal,
Trash2,
} from "lucide-react";
import React, { useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import {
formatProxyConfigEndpoint,
hasIncompleteProxyIdentity,
hasMissingProxyIdentity,
hasUnreadableProxyCredential,
isProxyCommandConfig,
isValidProxyPort,
normalizeManualProxyConfig,
removeProxyProfileReferences,
updateProxyConfigField,
} from "../domain/proxyProfiles";
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
import {
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
} from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
import type { GroupConfig, Host, Identity, ProxyConfig, ProxyProfile } from "../types";
import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
} from "./ui/aside-panel";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "./ui/context-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { toast } from "./ui/toast";
import {
VaultHeaderSearch,
VaultPageHeader,
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
import {
VaultEntityIcon,
vaultProxyCommandIconClass,
vaultProxyHttpIconClass,
vaultProxySocksIconClass,
} from "./vault/VaultEntityIcon";
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
interface ProxyProfilesManagerProps {
proxyProfiles: ProxyProfile[];
hosts: Host[];
groupConfigs: GroupConfig[];
identities: Identity[];
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
onUpdateHosts: (hosts: Host[]) => void;
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
}
export type ProxyProfileSaveError =
| "required"
| "port"
| "missingIdentity"
| "incompleteIdentity"
| "unreadableIdentity";
export const prepareProxyProfileForSave = (
draft: ProxyProfile,
identities: Identity[],
updatedAt = Date.now(),
): { saved?: ProxyProfile; error?: ProxyProfileSaveError } => {
const label = draft.label.trim();
const host = draft.config.host.trim();
const command = draft.config.command?.trim() || "";
const isCommand = isProxyCommandConfig(draft.config);
if (!label || (isCommand ? !command : (!host || !draft.config.port))) {
return { error: "required" };
}
if (!isCommand && !isValidProxyPort(draft.config.port)) {
return { error: "port" };
}
if (!isCommand && hasMissingProxyIdentity(draft.config, identities)) {
return { error: "missingIdentity" };
}
if (!isCommand && hasIncompleteProxyIdentity(draft.config, identities)) {
return { error: "incompleteIdentity" };
}
if (!isCommand && hasUnreadableProxyCredential(draft.config, identities)) {
return { error: "unreadableIdentity" };
}
const normalizedConfig = normalizeManualProxyConfig(draft.config);
if (!normalizedConfig) return { error: "required" };
return {
saved: {
...draft,
label,
config: normalizedConfig,
updatedAt,
},
};
};
const createDraftProfile = (): ProxyProfile => {
const now = Date.now();
return {
id: crypto.randomUUID(),
label: "",
config: {
type: "http",
host: "",
port: 8080,
},
createdAt: now,
updatedAt: now,
};
};
const getProfileUsageCount = (
profileId: string,
hosts: Host[],
groupConfigs: GroupConfig[],
): number =>
hosts.filter((host) => host.proxyProfileId === profileId).length +
groupConfigs.filter((config) => config.proxyProfileId === profileId).length;
type ProxyProfilesViewMode = "grid" | "list";
const proxyProtocolMeta = {
http: {
label: "HTTP",
Icon: Globe,
iconClassName: vaultProxyHttpIconClass,
},
socks5: {
label: "SOCKS5",
Icon: Route,
iconClassName: vaultProxySocksIconClass,
},
command: {
labelKey: "hostDetails.proxyPanel.command",
Icon: SquareTerminal,
iconClassName: vaultProxyCommandIconClass,
},
} satisfies Record<ProxyConfig["type"], {
label?: string;
labelKey?: string;
Icon: React.ComponentType<{ size?: number; className?: string }>;
iconClassName: string;
}>;
interface ProxyProfileCardProps {
profile: ProxyProfile;
usageCount: number;
viewMode: ProxyProfilesViewMode;
isSelected: boolean;
reorderProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
onClick: () => void;
onEdit: () => void;
onDuplicate: () => void;
onDelete: () => void;
}
const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
profile,
usageCount,
viewMode,
isSelected,
reorderProps,
onClick,
onEdit,
onDuplicate,
onDelete,
}) => {
const { t } = useI18n();
const usageLabel = t("proxyProfiles.usage", { count: usageCount });
const protocol = proxyProtocolMeta[profile.config.type];
const protocolLabel = protocol.labelKey ? t(protocol.labelKey) : protocol.label;
const ProtocolIcon = protocol.Icon;
const endpoint = formatProxyConfigEndpoint(profile.config);
const accessibleLabel = `${profile.label}, ${protocolLabel}, ${endpoint}, ${usageLabel}`;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<button
{...reorderProps}
type="button"
aria-label={accessibleLabel}
className={cn(
reorderProps && "vault-drop-indicator-row",
"group w-full text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
viewMode === "grid"
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
isSelected && "ring-2 ring-primary",
reorderProps?.className,
)}
onClick={onClick}
>
<div className="flex items-center gap-3 h-full">
<VaultEntityIcon
className={protocol.iconClassName}
title={protocolLabel}
icon={<ProtocolIcon size={18} />}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<div className="text-sm font-semibold truncate">{profile.label}</div>
</div>
<div className="text-[11px] font-mono text-muted-foreground truncate">
{endpoint} -{" "}
{protocolLabel}
</div>
</div>
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onEdit}>
<Pencil size={14} className="mr-2" />
{t("action.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={onDuplicate}>
<Copy size={14} className="mr-2" />
{t("action.duplicate")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
<Trash2 size={14} className="mr-2" />
{t("action.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
proxyProfiles,
hosts,
groupConfigs,
identities,
onUpdateProxyProfiles,
onUpdateHosts,
onUpdateGroupConfigs,
}) => {
const { t } = useI18n();
const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
"grid",
);
const proxyProfilesViewMode: ProxyProfilesViewMode =
viewMode === "list" ? "list" : "grid";
const [draft, setDraft] = useState<ProxyProfile | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ProxyProfile | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const manualCredentialsValue = "__manual_credentials__";
const missingIdentityValue = "__missing_identity__";
const selectedDraftIdentity = useMemo(
() => identities.find((identity) => identity.id === draft?.config.identityId),
[draft?.config.identityId, identities],
);
const hasMissingDraftIdentity = hasMissingProxyIdentity(draft?.config, identities);
const hasIncompleteDraftIdentity = hasIncompleteProxyIdentity(draft?.config, identities);
const hasUnreadableDraftIdentity = hasUnreadableProxyCredential(draft?.config, identities);
const selectedDraftIdentityValue =
selectedDraftIdentity?.id ||
(hasMissingDraftIdentity ? missingIdentityValue : manualCredentialsValue);
const usageByProfileId = useMemo(() => {
const map = new Map<string, number>();
for (const profile of proxyProfiles) {
map.set(profile.id, getProfileUsageCount(profile.id, hosts, groupConfigs));
}
return map;
}, [groupConfigs, hosts, proxyProfiles]);
const filteredProfiles = useMemo(() => {
const q = search.trim().toLowerCase();
const result = !q ? proxyProfiles : proxyProfiles.filter((profile) =>
profile.label.toLowerCase().includes(q) ||
profile.config.host.toLowerCase().includes(q) ||
(profile.config.command || "").toLowerCase().includes(q) ||
profile.config.type.toLowerCase().includes(q),
);
return sortByVaultOrder(result);
}, [proxyProfiles, search]);
const profileReorder = useVaultItemReorder({
containerRef: listRef,
viewMode: proxyProfilesViewMode,
dragType: "proxy-profile-id",
targetAttribute: "data-proxy-profile-id",
disabled: search.trim().length > 0,
onReorder: (sourceId, targetId, position) => {
onUpdateProxyProfiles(reorderVaultItems(proxyProfiles, sourceId, targetId, position));
},
});
const updateDraftConfig = (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
setDraft((prev) => {
if (!prev) return prev;
return {
...prev,
config: updateProxyConfigField(prev.config, field, value),
};
});
};
const openCreate = () => {
setDraft(createDraftProfile());
};
const openEdit = (profile: ProxyProfile) => {
setDraft({
...profile,
config: { ...profile.config },
});
};
const duplicateProfile = (profile: ProxyProfile) => {
const now = Date.now();
onUpdateProxyProfiles([
...proxyProfiles,
{
...profile,
id: crypto.randomUUID(),
label: t("proxyProfiles.copyName", { name: profile.label }),
config: { ...profile.config },
createdAt: now,
updatedAt: now,
},
]);
};
const saveDraft = () => {
if (!draft) return;
const result = prepareProxyProfileForSave(draft, identities);
if (!result.saved) {
const messageKey = result.error === "port"
? "proxyProfiles.error.port"
: result.error === "missingIdentity"
? "hostDetails.proxyPanel.missingIdentity"
: result.error === "incompleteIdentity"
? "hostDetails.proxyPanel.incompleteIdentity"
: result.error === "unreadableIdentity"
? "hostDetails.proxyPanel.unreadableIdentity"
: "proxyProfiles.error.required";
toast.error(t(messageKey));
return;
}
const saved = result.saved;
onUpdateProxyProfiles(
proxyProfiles.some((profile) => profile.id === saved.id)
? proxyProfiles.map((profile) => profile.id === saved.id ? saved : profile)
: [...proxyProfiles, saved],
);
setDraft(null);
};
const confirmDelete = () => {
if (!deleteTarget) return;
const cleaned = removeProxyProfileReferences(deleteTarget.id, {
hosts,
groupConfigs,
});
onUpdateProxyProfiles(proxyProfiles.filter((profile) => profile.id !== deleteTarget.id));
onUpdateHosts(cleaned.hosts);
onUpdateGroupConfigs(cleaned.groupConfigs);
if (draft?.id === deleteTarget.id) {
setDraft(null);
}
setDeleteTarget(null);
};
return (
<div className="h-full flex relative">
<div className={cn("flex-1 flex flex-col min-h-0 transition-all duration-200", draft && "mr-[380px]")}>
<VaultPageHeader>
<Button
onClick={openCreate}
variant="secondary"
className={vaultHeaderSecondaryButtonClass}
>
<Plus size={14} />
{t("proxyProfiles.action.add")}
</Button>
<div className="ml-auto flex items-center gap-2 min-w-0 flex-shrink">
<VaultHeaderSearch
aria-label={t("proxyProfiles.search.placeholder")}
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("proxyProfiles.search.placeholder")}
className="flex-shrink w-64"
/>
<Dropdown>
<DropdownTrigger asChild>
<Button
aria-label={t("proxyProfiles.viewMode")}
variant="ghost"
size="icon"
className={cn(vaultHeaderIconButtonClass, "flex-shrink-0")}
>
{proxyProfilesViewMode === "grid" ? (
<LayoutGrid size={16} />
) : (
<ListIcon size={16} />
)}
<ChevronDown size={10} className="ml-0.5" />
</Button>
</DropdownTrigger>
<DropdownContent className="w-32" align="end">
<Button
variant={proxyProfilesViewMode === "grid" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("grid")}
>
<LayoutGrid size={14} /> {t("vault.view.grid")}
</Button>
<Button
variant={proxyProfilesViewMode === "list" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("list")}
>
<ListIcon size={14} /> {t("vault.view.list")}
</Button>
</DropdownContent>
</Dropdown>
</div>
</VaultPageHeader>
<div
ref={listRef}
className="flex-1 overflow-y-auto"
onDragOverCapture={profileReorder.handleDragOverCapture}
onDragOver={profileReorder.handleDragOver}
onDropCapture={profileReorder.handleDropCapture}
onDragEndCapture={profileReorder.handleDragEndCapture}
>
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className={vaultSectionTitleClass}>
{t("proxyProfiles.section.proxies")}
</h2>
<span className="text-xs text-muted-foreground">
{t("proxyProfiles.count.items", { count: filteredProfiles.length })}
</span>
</div>
{filteredProfiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<Globe size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{t("proxyProfiles.empty.title")}
</h3>
<p className="text-sm text-center max-w-sm mb-4">
{t("proxyProfiles.empty.desc")}
</p>
<Button onClick={openCreate}>
<Plus size={14} className="mr-2" />
{t("proxyProfiles.action.add")}
</Button>
</div>
) : (
<div
className={
proxyProfilesViewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0"
}
>
{filteredProfiles.map((profile) => (
<ProxyProfileCard
key={profile.id}
profile={profile}
usageCount={usageByProfileId.get(profile.id) ?? 0}
viewMode={proxyProfilesViewMode}
isSelected={draft?.id === profile.id}
reorderProps={profileReorder.getItemReorderProps(profile.id, `proxy:${profile.id}`)}
onClick={() => openEdit(profile)}
onEdit={() => openEdit(profile)}
onDuplicate={() => duplicateProfile(profile)}
onDelete={() => setDeleteTarget(profile)}
/>
))}
</div>
)}
</div>
</div>
</div>
{draft && (
<AsidePanel
open={true}
onClose={() => setDraft(null)}
title={draft.label || t("proxyProfiles.panel.newTitle")}
>
<AsidePanelContent>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Settings2 size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("proxyProfiles.field.name")}</p>
</div>
<Input
aria-label={t("proxyProfiles.field.name")}
value={draft.label}
onChange={(event) => setDraft({ ...draft, label: event.target.value })}
placeholder={t("proxyProfiles.field.name")}
className="h-10"
/>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("field.type")}</p>
</div>
<Select
value={draft.config.type}
onValueChange={(value) => updateDraftConfig("type", value as ProxyConfig["type"])}
>
<SelectTrigger aria-label={t("field.type")} className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="socks5">SOCKS5</SelectItem>
<SelectItem value="command">{t("hostDetails.proxyPanel.command")}</SelectItem>
</SelectContent>
</Select>
</div>
{isProxyCommandConfig(draft.config) ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{t("hostDetails.proxyPanel.commandHelp")}
</p>
<Input
aria-label={t("hostDetails.proxyPanel.commandPlaceholder")}
value={draft.config.command || ""}
onChange={(event) => updateDraftConfig("command", event.target.value)}
placeholder={t("hostDetails.proxyPanel.commandPlaceholder")}
className="h-10 font-mono text-xs"
/>
</div>
) : (
<div className="flex gap-2">
<Input
aria-label={t("hostDetails.proxyPanel.hostPlaceholder")}
value={draft.config.host}
onChange={(event) => updateDraftConfig("host", event.target.value)}
placeholder={t("hostDetails.proxyPanel.hostPlaceholder")}
className="h-10 flex-1"
/>
<Input
aria-label={t("hostDetails.port")}
type="number"
value={draft.config.port || ""}
onChange={(event) => updateDraftConfig("port", event.target.value === "" ? 0 : Number(event.target.value))}
placeholder="3128"
min={1}
max={65535}
step={1}
className="h-10 w-24 text-center"
/>
</div>
)}
</Card>
{!isProxyCommandConfig(draft.config) && <Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.proxyPanel.credentials")}</p>
</div>
<Badge variant="secondary" className="text-xs">{t("common.optional")}</Badge>
</div>
{identities.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{t("hostDetails.proxyPanel.keychainIdentity")}
</p>
<Select
value={selectedDraftIdentityValue}
onValueChange={(value) => {
if (value === missingIdentityValue) return;
updateDraftConfig(
"identityId",
value === manualCredentialsValue ? undefined : value,
);
}}
>
<SelectTrigger
aria-label={t("hostDetails.proxyPanel.keychainIdentity")}
className="h-10"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={manualCredentialsValue}>
{t("hostDetails.proxyPanel.manualCredentials")}
</SelectItem>
{hasMissingDraftIdentity && (
<SelectItem value={missingIdentityValue}>
{t("hostDetails.proxyPanel.missingIdentity")}
</SelectItem>
)}
{identities.map((identity) => (
<SelectItem key={identity.id} value={identity.id}>
{identity.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{hasMissingDraftIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t("hostDetails.proxyPanel.missingIdentity")}
</div>
)}
{hasIncompleteDraftIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t("hostDetails.proxyPanel.incompleteIdentity")}
</div>
)}
{hasUnreadableDraftIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t("hostDetails.proxyPanel.unreadableIdentity")}
</div>
)}
{selectedDraftIdentity ? (
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="secondary" className="text-xs shrink-0">
{t("hostDetails.proxyPanel.keychainIdentity")}
</Badge>
<span className="truncate">
{selectedDraftIdentity.label} - {selectedDraftIdentity.username}
</span>
</div>
</div>
) : (
<>
<Input
aria-label={t("hostDetails.proxyPanel.usernamePlaceholder")}
value={draft.config.username || ""}
onChange={(event) => updateDraftConfig("username", event.target.value)}
placeholder={t("hostDetails.proxyPanel.usernamePlaceholder")}
className="h-10"
/>
<Input
aria-label={t("hostDetails.proxyPanel.passwordPlaceholder")}
type="password"
value={draft.config.password || ""}
onChange={(event) => updateDraftConfig("password", event.target.value)}
placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")}
className="h-10"
/>
</>
)}
</Card>}
</AsidePanelContent>
<AsidePanelFooter>
<Button className="w-full" onClick={saveDraft}>
{t("common.save")}
</Button>
</AsidePanelFooter>
</AsidePanel>
)}
<Dialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle size={18} className="text-destructive" />
{t("proxyProfiles.delete.title")}
</DialogTitle>
<DialogDescription>
{deleteTarget
? t("proxyProfiles.delete.desc", {
name: deleteTarget.label,
count: usageByProfileId.get(deleteTarget.id) ?? 0,
})
: ""}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t("action.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default ProxyProfilesManager;