feat(ui): replace all native confirm() dialogs with styled ConfirmDialog
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 8s

Add ConfirmDialogProvider + useConfirm hook (Promise-based API) so every
window.confirm() in the app is replaced with a consistent themed modal.

Replaced in 11 files (16 call-sites):
- Settings: update panel action
- Templates: delete template
- Storages: delete storage
- SSH Keys: delete key
- Nodes: delete server
- NodeNetworks: delete interface, apply pending, revert pending
- HAResources: remove HA resource
- SDNManager: apply SDN, delete zone/vnet, delete subnet
- IPAM: release allocation, delete pool, cleanup orphans

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 09:09:26 +05:00
parent 9b4fbf13ba
commit 14cac12191
13 changed files with 132 additions and 33 deletions

View File

@@ -5,6 +5,7 @@ import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
import { router } from '@/router';
import { useAuthStore } from '@/stores/auth-store';
import { ConfirmDialogProvider } from '@/components/shared/ConfirmDialog';
const queryClient = new QueryClient({
defaultOptions: {
@@ -26,8 +27,10 @@ export default function App() {
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider delay={200}>
<RouterProvider router={router} />
<Toaster position="bottom-right" richColors />
<ConfirmDialogProvider>
<RouterProvider router={router} />
<Toaster position="bottom-right" richColors />
</ConfirmDialogProvider>
</TooltipProvider>
</QueryClientProvider>
);

View File

@@ -0,0 +1,73 @@
import { createContext, useCallback, useContext, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Dialog, DialogContent, DialogDescription,
DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface ConfirmOptions {
title?: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'default' | 'destructive';
}
interface ConfirmState extends ConfirmOptions {
open: boolean;
message: string;
}
type ConfirmFn = (message: string, options?: ConfirmOptions) => Promise<boolean>;
const ConfirmContext = createContext<ConfirmFn | null>(null);
export function ConfirmDialogProvider({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const [state, setState] = useState<ConfirmState>({ open: false, message: '' });
const resolveRef = useRef<((value: boolean) => void) | null>(null);
const confirm: ConfirmFn = useCallback((message, options = {}) => {
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve;
setState({ open: true, message, ...options });
});
}, []);
const handleClose = (result: boolean) => {
setState((s) => ({ ...s, open: false }));
resolveRef.current?.(result);
resolveRef.current = null;
};
return (
<ConfirmContext.Provider value={confirm}>
{children}
<Dialog open={state.open} onOpenChange={(open) => { if (!open) handleClose(false); }}>
<DialogContent showCloseButton={false} className="max-w-sm">
<DialogHeader>
<DialogTitle>{state.title ?? t('common.confirm')}</DialogTitle>
<DialogDescription>{state.message}</DialogDescription>
</DialogHeader>
<DialogFooter className="border-t-0 bg-transparent pt-2">
<Button variant="outline" onClick={() => handleClose(false)}>
{state.cancelLabel ?? t('common.cancel')}
</Button>
<Button
variant={state.variant === 'destructive' ? 'destructive' : 'default'}
onClick={() => handleClose(true)}
>
{state.confirmLabel ?? t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ConfirmContext.Provider>
);
}
export function useConfirm(): ConfirmFn {
const ctx = useContext(ConfirmContext);
if (!ctx) throw new Error('useConfirm must be used within ConfirmDialogProvider');
return ctx;
}

View File

@@ -16,6 +16,7 @@ import {
} from '@/hooks/use-backups';
import { formatBytes } from '@/lib/format';
import { toast } from 'sonner';
import { useConfirm } from '@/components/shared/ConfirmDialog';
const STORAGE_TYPES = ['dir', 'nfs', 'cifs', 'pbs'];
@@ -180,6 +181,7 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
export default function StoragesTab() {
const { t } = useTranslation();
const confirm = useConfirm();
const { data: servers = [] } = useServers();
const [serverId, setServerId] = useState(0);
@@ -197,8 +199,8 @@ export default function StoragesTab() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<StorageRow | null>(null);
const handleDelete = (s: string) => {
if (!confirm(`${t('storages.delete_confirm')} "${s}"?`)) return;
const handleDelete = async (s: string) => {
if (!await confirm(`${t('storages.delete_confirm')} "${s}"?`)) return;
deleteStorage.mutate(s, {
onSuccess: () => toast.success(t('storages.deleted')),
onError: (e: Error) => toast.error(e.message),

View File

@@ -15,12 +15,14 @@ import {
} from '@/hooks/use-ipam';
import type { IPAMAllocation } from '@/types';
import { toast } from 'sonner';
import { useConfirm } from '@/components/shared/ConfirmDialog';
const STATUSES = ['allocated', 'reserved', 'available', 'conflict'] as const;
const RESOURCE_TYPES = ['vm', 'lxc', 'physical', 'service', 'reserved'] as const;
export default function IPAMAllocationsPage() {
const { t } = useTranslation();
const confirm = useConfirm();
const [networkId, setNetworkId] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [search, setSearch] = useState('');
@@ -48,8 +50,8 @@ export default function IPAMAllocationsPage() {
return true;
});
const handleDelete = (a: IPAMAllocation) => {
if (!confirm(t('ipam.delete_allocation_confirm', { ip: a.ip_address }))) return;
const handleDelete = async (a: IPAMAllocation) => {
if (!await confirm(t('ipam.delete_allocation_confirm', { ip: a.ip_address }))) return;
deleteAllocation.mutate(a.id, {
onSuccess: () => toast.success(t('ipam.released')),
onError: (e: Error) => toast.error(e.message),

View File

@@ -16,6 +16,7 @@ import {
} from '@/hooks/use-ipam';
import type { IPAMNetwork, IPAMPool } from '@/types';
import { toast } from 'sonner';
import { useConfirm } from '@/components/shared/ConfirmDialog';
export default function IPAMNetworkDetailPage() {
const { t } = useTranslation();
@@ -223,6 +224,7 @@ const emptyPool = { name: '', pool_type: 'static', range_start: '', range_end: '
function PoolsCard({ networkId, pools }: { networkId: number; pools: IPAMPool[] }) {
const { t } = useTranslation();
const confirm = useConfirm();
const createPool = useCreatePool();
const updatePool = useUpdatePool();
const deletePool = useDeletePool();
@@ -259,8 +261,8 @@ function PoolsCard({ networkId, pools }: { networkId: number; pools: IPAMPool[]
}
};
const handleDelete = (p: IPAMPool) => {
if (!confirm(t('ipam.delete_pool_confirm', { name: p.name }))) return;
const handleDelete = async (p: IPAMPool) => {
if (!await confirm(t('ipam.delete_pool_confirm', { name: p.name }))) return;
deletePool.mutate(p.id, {
onSuccess: () => toast.success(t('ipam.pool_deleted')),
onError: (e: Error) => toast.error(e.message),

View File

@@ -15,6 +15,7 @@ import {
useCleanupOrphans, useLinkAllocations, useAutoAllocate, useNextAvailableIP, useIPHistoryLookup,
} from '@/hooks/use-ipam';
import { toast } from 'sonner';
import { useConfirm } from '@/components/shared/ConfirmDialog';
export default function IPAMToolsPage() {
const { t } = useTranslation();
@@ -174,12 +175,13 @@ function ConflictsCard() {
function OrphansCard() {
const { t } = useTranslation();
const confirm = useConfirm();
const { data, isLoading } = useIPAMOrphans();
const cleanup = useCleanupOrphans();
const orphans = data?.orphans ?? [];
const handleCleanup = () => {
if (!confirm(t('ipam.cleanup_confirm'))) return;
const handleCleanup = async () => {
if (!await confirm(t('ipam.cleanup_confirm'))) return;
cleanup.mutate(undefined, {
onSuccess: (r) => toast.success(`${t('ipam.released')}: ${r.released_count}`),
onError: (e: Error) => toast.error(e.message),

View File

@@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { useHAStatus, useAddToHA, useRemoveFromHA } from '@/hooks/use-ha';
import type { VirtualMachine } from '@/types';
import { toast } from 'sonner';
import { useConfirm } from '@/components/shared/ConfirmDialog';
const HA_STATES = ['started', 'stopped', 'enabled', 'disabled', 'ignored'];
@@ -35,6 +36,7 @@ const emptyAddForm: AddForm = {
export default function HAResources({ serverId, vms }: { serverId: number; vms: VirtualMachine[] }) {
const { t } = useTranslation();
const confirm = useConfirm();
const { data, isLoading, isFetching, refetch } = useHAStatus(serverId);
const addHA = useAddToHA(serverId);
const removeHA = useRemoveFromHA(serverId);
@@ -74,8 +76,8 @@ export default function HAResources({ serverId, vms }: { serverId: number; vms:
);
};
const handleRemove = (sid: string) => {
if (!confirm(`${t('ha.remove_confirm')} "${sid}"?`)) return;
const handleRemove = async (sid: string) => {
if (!await confirm(`${t('ha.remove_confirm')} "${sid}"?`)) return;
const [vmType, vmidStr] = sid.split(':');
removeHA.mutate(
{ vmType: vmType as 'vm' | 'ct', vmid: Number(vmidStr) },

View File

@@ -19,6 +19,7 @@ import {
} from '@/hooks/use-nodes';
import type { NodeNetworkInterface } from '@/types';
import { toast } from 'sonner';
import { useConfirm } from '@/components/shared/ConfirmDialog';
const IFACE_TYPES = ['bridge', 'bond', 'vlan', 'eth', 'alias', 'OVSBridge', 'OVSBond', 'OVSIntPort'];
@@ -91,6 +92,7 @@ function buildPayload(form: FormState): Record<string, unknown> {
export default function NodeNetworks({ serverId, nodeNames }: { serverId: number; nodeNames: string[] }) {
const { t } = useTranslation();
const confirm = useConfirm();
const [node, setNode] = useState(nodeNames[0] || '');
const { data, isLoading, isFetching, refetch } = useNodeNetworks(serverId, node);
@@ -144,24 +146,24 @@ export default function NodeNetworks({ serverId, nodeNames }: { serverId: number
}
};
const handleDelete = (iface: string) => {
if (!confirm(`${t('common.confirm_delete')} "${iface}"?`)) return;
const handleDelete = async (iface: string) => {
if (!await confirm(`${t('common.confirm_delete')} "${iface}"?`)) return;
deleteNet.mutate(iface, {
onSuccess: () => toast.success(t('netif.deleted')),
onError: (err: Error) => toast.error(err.message),
});
};
const handleApply = () => {
if (!confirm(t('netif.apply_confirm'))) return;
const handleApply = async () => {
if (!await confirm(t('netif.apply_confirm'))) return;
applyNet.mutate(undefined, {
onSuccess: () => toast.success(t('netif.applied')),
onError: (err: Error) => toast.error(err.message),
});
};
const handleRevert = () => {
if (!confirm(t('netif.revert_confirm'))) return;
const handleRevert = async () => {
if (!await confirm(t('netif.revert_confirm'))) return;
revertNet.mutate(undefined, {
onSuccess: () => toast.success(t('netif.reverted')),
onError: (err: Error) => toast.error(err.message),

View File

@@ -12,6 +12,7 @@ import { useServers, useCreateServer, useUpdateServer, useDeleteServer, useTestS
import { useVirtualMachines } from '@/hooks/use-instances';
import type { ProxmoxServerCreate } from '@/types';
import { toast } from 'sonner';
import { useConfirm } from '@/components/shared/ConfirmDialog';
interface ServerFormData {
name: string;
@@ -198,6 +199,7 @@ function ServerFormDialog({
export default function NodesPage() {
const { t } = useTranslation();
const confirm = useConfirm();
const { data: servers = [], isLoading } = useServers();
const { data: vms = [] } = useVirtualMachines();
const createServer = useCreateServer();
@@ -228,8 +230,8 @@ export default function NodesPage() {
});
};
const handleDelete = (id: number, name: string) => {
if (!confirm(`${t('common.confirm_delete')} "${name}"?`)) return;
const handleDelete = async (id: number, name: string) => {
if (!await confirm(`${t('common.confirm_delete')} "${name}"?`)) return;
deleteServer.mutate(id, {
onError: (err) => toast.error(err.message),
});

View File

@@ -18,6 +18,7 @@ import {
} from '@/hooks/use-sdn';
import type { SDNZone, SDNVNet } from '@/types';
import { toast } from 'sonner';
import { useConfirm } from '@/components/shared/ConfirmDialog';
const ZONE_TYPES = ['simple', 'vlan', 'qinq', 'vxlan', 'evpn'];
@@ -210,6 +211,7 @@ const emptySubnet: SubnetForm = { subnet: '', gateway: '', snat: false, dnszonep
function SubnetSection({ serverId, vnet }: { serverId: number; vnet: string }) {
const { t } = useTranslation();
const confirm = useConfirm();
const { data, isLoading } = useSDNSubnets(serverId, vnet);
const createSubnet = useCreateSDNSubnet(serverId);
const deleteSubnet = useDeleteSDNSubnet(serverId);
@@ -241,8 +243,8 @@ function SubnetSection({ serverId, vnet }: { serverId: number; vnet: string }) {
});
};
const remove = (subnetId: string, cidr: string) => {
if (!confirm(`${t('sdn.delete_subnet')} "${cidr}"?`)) return;
const remove = async (subnetId: string, cidr: string) => {
if (!await confirm(`${t('sdn.delete_subnet')} "${cidr}"?`)) return;
deleteSubnet.mutate({ vnet, subnetId }, {
onSuccess: () => toast.success(t('sdn.subnet_deleted')),
onError: (e: Error) => toast.error(e.message),
@@ -321,6 +323,7 @@ function SubnetSection({ serverId, vnet }: { serverId: number; vnet: string }) {
export default function SDNManager({ serverId }: { serverId: number }) {
const { t } = useTranslation();
const confirm = useConfirm();
const { data: status, isLoading: statusLoading, isFetching, refetch } = useSDNStatus(serverId);
const available = status?.sdn_available ?? false;
@@ -339,20 +342,20 @@ export default function SDNManager({ serverId }: { serverId: number }) {
const [editVNet, setEditVNet] = useState<SDNVNet | null>(null);
const [expandedVNet, setExpandedVNet] = useState<string | null>(null);
const handleApply = () => {
if (!confirm(t('sdn.apply_confirm'))) return;
const handleApply = async () => {
if (!await confirm(t('sdn.apply_confirm'))) return;
applySDN.mutate(undefined, {
onSuccess: () => toast.success(t('sdn.applied')),
onError: (e: Error) => toast.error(e.message),
});
};
const removeZone = (zone: string) => {
if (!confirm(`${t('sdn.delete_zone')} "${zone}"?`)) return;
const removeZone = async (zone: string) => {
if (!await confirm(`${t('sdn.delete_zone')} "${zone}"?`)) return;
deleteZone.mutate(zone, { onSuccess: () => toast.success(t('sdn.zone_deleted')), onError: (e: Error) => toast.error(e.message) });
};
const removeVNet = (vnet: string) => {
if (!confirm(`${t('sdn.delete_vnet')} "${vnet}"?`)) return;
const removeVNet = async (vnet: string) => {
if (!await confirm(`${t('sdn.delete_vnet')} "${vnet}"?`)) return;
deleteVNet.mutate(vnet, { onSuccess: () => toast.success(t('sdn.vnet_deleted')), onError: (e: Error) => toast.error(e.message) });
};

View File

@@ -15,6 +15,7 @@ import {
useUserSSHKeys, useAdminCreateUserSSHKey, useAdminUpdateUserSSHKey,
useAdminDeleteUserSSHKey, useAdminDownloadUserPrivateKey,
} from '@/hooks/use-ssh-keys';
import { useConfirm } from '@/components/shared/ConfirmDialog';
interface Props {
/** When provided + admin=true, manage another user's keys via admin endpoints. */
@@ -29,6 +30,7 @@ function getErrMsg(err: unknown): string {
export function SSHKeysManager({ userId = null, admin = false }: Props) {
const { t } = useTranslation();
const confirm = useConfirm();
const myList = useMySSHKeys();
const adminList = useUserSSHKeys(admin && userId ? userId : null);
@@ -87,8 +89,8 @@ export function SSHKeysManager({ userId = null, admin = false }: Props) {
}
};
const onDelete = (id: number) => {
if (!confirm(t('ssh_keys.confirm_delete'))) return;
const onDelete = async (id: number) => {
if (!await confirm(t('ssh_keys.confirm_delete'))) return;
if (isAdminMode && userId) {
adminDelete.mutate({ userId, keyId: id }, {
onSuccess: () => toast.success(t('ssh_keys.deleted')),

View File

@@ -23,6 +23,7 @@ import {
} from '@/hooks/use-notifications';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { SSHKeysManager } from './SSHKeysManager';
import { useConfirm } from '@/components/shared/ConfirmDialog';
export default function SettingsPage() {
const { t } = useTranslation();
@@ -376,6 +377,7 @@ function NotificationPreferencesCard() {
function AboutTab() {
const { t } = useTranslation();
const confirm = useConfirm();
const { data: version } = useAppVersion();
const checkUpdates = useCheckUpdates();
const { data: repo } = useUpdateRepository();
@@ -402,8 +404,8 @@ function AboutTab() {
});
};
const handlePerform = () => {
if (!confirm(t('settings.update_confirm'))) return;
const handlePerform = async () => {
if (!await confirm(t('settings.update_confirm'))) return;
performUpdate.mutate(undefined, {
onSuccess: (r) => r.success ? toast.success(r.message || t('settings.update_started')) : toast.error(r.error || 'Error'),
onError: (e: Error) => toast.error(e.message),

View File

@@ -13,9 +13,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { useTemplates, useTemplateGroups, useAutoImportTemplates, useDeleteTemplate } from '@/hooks/use-templates';
import { useServers } from '@/hooks/use-nodes';
import { toast } from 'sonner';
import { useConfirm } from '@/components/shared/ConfirmDialog';
export default function TemplatesPage() {
const { t } = useTranslation();
const confirm = useConfirm();
const [search, setSearch] = useState('');
const [groupFilter, setGroupFilter] = useState<string>('');
const [serverFilter, setServerFilter] = useState<string>('');
@@ -125,7 +127,7 @@ export default function TemplatesPage() {
variant="ghost"
size="sm"
className="opacity-0 group-hover:opacity-100 transition-opacity h-7 w-7 p-0 text-destructive"
onClick={() => { if (confirm(t('common.confirm_delete'))) deleteTpl.mutate(tpl.id); }}
onClick={async () => { if (await confirm(t('common.confirm_delete'))) deleteTpl.mutate(tpl.id); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>