"use client"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@workspace/ui/components/button"; import { Checkbox } from "@workspace/ui/components/checkbox"; import { Label } from "@workspace/ui/components/label"; import { ScrollArea } from "@workspace/ui/components/scroll-area"; import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@workspace/ui/components/sheet"; import { Switch } from "@workspace/ui/components/switch"; import { Combobox } from "@workspace/ui/composed/combobox"; import { EnhancedInput } from "@workspace/ui/composed/enhanced-input"; import TagInput from "@workspace/ui/composed/tag-input"; import { getGroupConfig, getNodeGroupList, } from "@workspace/ui/services/admin/group"; import { updateNode } from "@workspace/ui/services/admin/server"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { useNode } from "@/stores/node"; import { useServer } from "@/stores/server"; type FieldKey = | "name" | "enabled" | "tags" | "server_id" | "protocol" | "address" | "port" | "node_group_ids"; interface BatchPatch { name?: string; enabled?: boolean; tags?: string[]; server_id?: number; protocol?: string; address?: string; port?: number; node_group_ids?: number[]; } export default function NodeBatchSheet({ rows, onSuccess, }: { rows: API.Node[]; onSuccess: () => void; }) { const { t } = useTranslation("nodes"); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); // Which fields are active (will be applied) const [enabledFields, setEnabledFields] = useState>(new Set()); // Patch values const [patch, setPatch] = useState({ name: "", enabled: true, tags: [], server_id: undefined, protocol: undefined, address: "", port: undefined, node_group_ids: [], }); const { servers, getAvailableProtocols } = useServer(); const { tags: existingTags } = useNode(); const { data: nodeGroupsData } = useQuery({ queryKey: ["nodeGroups"], queryFn: async () => { const { data } = await getNodeGroupList({ page: 1, size: 1000 }); return data.data?.list || []; }, }); const { data: groupConfigData } = useQuery({ queryKey: ["groupConfig"], queryFn: async () => { const { data } = await getGroupConfig(); return data.data; }, }); const isGroupEnabled = groupConfigData?.enabled; const availableProtocols = getAvailableProtocols(patch.server_id); function toggleField(key: FieldKey) { setEnabledFields((prev) => { const next = new Set(prev); if (next.has(key)) { next.delete(key); // When disabling server_id, also disable protocol (it depends on server selection) if (key === "server_id") next.delete("protocol"); } else { next.add(key); // When enabling server_id, auto-enable protocol as it must be set together if (key === "server_id") next.add("protocol"); } return next; }); } function isEnabled(key: FieldKey) { return enabledFields.has(key); } async function handleSubmit() { if (rows.length === 0) return; if (enabledFields.size === 0) { toast.warning( t( "batch_no_fields_selected", "Please select at least one field to update" ) ); return; } // Validate enabled fields before submitting if (enabledFields.has("name") && !patch.name?.trim()) { toast.warning(t("batch_name_required", "Name cannot be empty")); return; } if (enabledFields.has("server_id") && !patch.server_id) { toast.warning(t("batch_server_required", "Please select a server")); return; } if (enabledFields.has("protocol") && !patch.protocol) { toast.warning(t("batch_protocol_required", "Please select a protocol")); return; } if ( enabledFields.has("port") && (!patch.port || patch.port < 1 || patch.port > 65_535) ) { toast.warning( t("batch_port_invalid", "Port must be between 1 and 65535") ); return; } const activePatch: Partial = {}; for (const key of enabledFields) { (activePatch as any)[key] = (patch as any)[key]; } setLoading(true); try { const results = await Promise.allSettled( rows.map((row) => { const body: API.UpdateNodeRequest = { id: row.id, name: row.name, tags: row.tags, port: row.port, address: row.address, server_id: row.server_id, protocol: row.protocol, enabled: row.enabled, ...activePatch, } as any; return updateNode(body); }) ); const succeeded = results.filter((r) => r.status === "fulfilled").length; const failed = results.filter((r) => r.status === "rejected").length; if (failed === 0) { toast.success( t("batch_updated", "Updated {{count}} nodes", { count: succeeded }) ); setOpen(false); setEnabledFields(new Set()); onSuccess(); } else if (succeeded > 0) { toast.warning( t("batch_partial", "{{succeeded}} updated, {{failed}} failed", { succeeded, failed, }) ); onSuccess(); } else { toast.error(t("batch_update_failed", "Batch update failed")); } } finally { setLoading(false); } } function handleOpen() { setEnabledFields(new Set()); setPatch({ name: "", enabled: true, tags: [], server_id: undefined, protocol: undefined, address: "", port: undefined, node_group_ids: [], }); setOpen(true); } return ( {t("batch_update_title", "Batch Update ({{count}} nodes)", { count: rows.length, })}

{t( "batch_update_desc", "Check the fields you want to overwrite. Unchecked fields will keep their original values." )}

{/* name */}
toggleField("name")} />
setPatch((p) => ({ ...p, name: String(v ?? "") })) } value={patch.name ?? ""} />
{/* enabled */}
toggleField("enabled")} />
setPatch((p) => ({ ...p, enabled: v })) } />
{/* tags */}
toggleField("tags")} />
setPatch((p) => ({ ...p, tags: v }))} options={existingTags || []} placeholder={t( "tags_placeholder", "Use Enter or comma (,) to add" )} value={patch.tags || []} />
{/* server_id */}
toggleField("server_id")} />
onChange={(v) => { setPatch((p) => ({ ...p, server_id: v ?? undefined, protocol: undefined, })); }} options={servers.map((s) => ({ value: s.id, label: `${s.name} (${(s.address as any) || ""})`, }))} placeholder={t("select_server", "Select server…")} value={patch.server_id} />
{/* protocol */}
toggleField("protocol")} />
onChange={(v) => setPatch((p) => ({ ...p, protocol: v ?? undefined })) } options={availableProtocols.map((p) => ({ value: p.protocol, label: `${p.protocol}${p.port ? ` (${p.port})` : ""}`, }))} placeholder={t("select_protocol", "Select protocol…")} value={patch.protocol} />
{/* address */}
toggleField("address")} />
setPatch((p) => ({ ...p, address: String(v ?? "") })) } value={patch.address ?? ""} />
{/* port */}
toggleField("port")} />
setPatch((p) => ({ ...p, port: v ? Number(v) : undefined, })) } placeholder="1-65535" type="number" value={patch.port ?? ""} />
{/* node_group_ids — only when group feature is enabled */} {isGroupEnabled && (
toggleField("node_group_ids")} />
{nodeGroupsData?.map((g) => { const ids = patch.node_group_ids || []; const checked = ids.includes(g.id); return (
{ setPatch((p) => { const current = p.node_group_ids || []; return { ...p, node_group_ids: c ? [...current, g.id] : current.filter((id) => id !== g.id), }; }); }} />
); })}
)}
); }