Compare commits

...

2 Commits

Author SHA1 Message Date
yushen
3698ff39b4 fix(api): correct deleteCloudRuntimeNode contract to match server
- Change from DELETE /api/cloud-runtime/nodes/:nodeId (no body) to
  DELETE /api/cloud-runtime/nodes with JSON body { id: nodeId }
- Use fetchRaw + Content-Type header to match server's withBody proxy
- Add contract test verifying URL, method, body, and Content-Type

Fixes review feedback on MUL-2510

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 17:40:10 +08:00
yushen
ca42352118 feat: add delete button to fleet nodes list
- Add deleteCloudRuntimeNode method to API client (DELETE /api/cloud-runtime/nodes/:nodeId)
- Add useDeleteCloudRuntimeNode mutation hook in cloud-runtime.ts
- Add delete button with Trash2 icon to CloudRuntimeNodeRow component
- Include confirmation dialog, loading state, and toast notifications
- Add i18n keys for en and zh-Hans locales

Co-authored-by: multica-agent <github@multica.ai>
2026-05-21 17:29:16 +08:00
6 changed files with 78 additions and 3 deletions

View File

@@ -234,6 +234,27 @@ describe("ApiClient", () => {
).resolves.toMatchObject({ id: "", status: "" });
});
it("deleteCloudRuntimeNode sends DELETE with JSON body containing node id", async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(null, { status: 204 }),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.deleteCloudRuntimeNode("node-abc-123");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0]!;
expect(url).toBe("https://api.example.test/api/cloud-runtime/nodes");
expect(opts).toMatchObject({
method: "DELETE",
body: JSON.stringify({ id: "node-abc-123" }),
});
expect((opts.headers as Record<string, string>)["Content-Type"]).toBe(
"application/json",
);
});
describe("getAttachment", () => {
it("returns the parsed attachment for a well-formed response", async () => {
vi.stubGlobal(

View File

@@ -869,6 +869,14 @@ export class ApiClient {
);
}
async deleteCloudRuntimeNode(nodeId: string): Promise<void> {
await this.fetchRaw("/api/cloud-runtime/nodes", {
method: "DELETE",
body: JSON.stringify({ id: nodeId }),
extraHeaders: { "Content-Type": "application/json" },
});
}
async deleteRuntime(runtimeId: string): Promise<void> {
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
}

View File

@@ -79,3 +79,13 @@ export function useCreateCloudRuntimeNode(wsId: string) {
},
});
}
export function useDeleteCloudRuntimeNode(wsId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (nodeId: string) => api.deleteCloudRuntimeNode(nodeId),
onSettled: () => {
qc.invalidateQueries({ queryKey: cloudRuntimeKeys.all(wsId) });
},
});
}

View File

@@ -172,6 +172,10 @@
"cancel": "Cancel",
"toast_created": "Cloud Runtime node created",
"toast_create_failed": "Failed to create Cloud Runtime node",
"delete": "Delete node",
"delete_confirm": "Are you sure you want to delete this cloud node? This action cannot be undone.",
"toast_deleted": "Cloud node deleted",
"toast_delete_failed": "Failed to delete cloud node",
"fields": {
"name": "Name",
"instance_type": "Instance type",

View File

@@ -163,6 +163,10 @@
"cancel": "取消",
"toast_created": "Cloud Runtime 节点已创建",
"toast_create_failed": "创建 Cloud Runtime 节点失败",
"delete": "删除节点",
"delete_confirm": "确定要删除此云节点吗?此操作无法撤销。",
"toast_deleted": "云节点已删除",
"toast_delete_failed": "删除云节点失败",
"fields": {
"name": "名称",
"instance_type": "实例规格",

View File

@@ -3,12 +3,13 @@
import { useId, useMemo, useState } from "react";
import type { FormEvent, HTMLAttributes } from "react";
import { useQuery } from "@tanstack/react-query";
import { Cloud, Loader2, RefreshCw, Rocket } from "lucide-react";
import { Cloud, Loader2, RefreshCw, Rocket, Trash2 } from "lucide-react";
import { toast } from "sonner";
import type { CloudRuntimeNode } from "@multica/core/runtimes";
import {
cloudRuntimeNodeListOptions,
useCreateCloudRuntimeNode,
useDeleteCloudRuntimeNode,
} from "@multica/core/runtimes";
import { useWorkspaceId } from "@multica/core/hooks";
import { Badge } from "@multica/ui/components/ui/badge";
@@ -192,7 +193,7 @@ export function CloudRuntimeDialog({ onClose }: { onClose: () => void }) {
<div className="max-h-[410px] overflow-y-auto p-2">
<div className="space-y-2">
{sortedNodes.map((node) => (
<CloudRuntimeNodeRow key={node.id} node={node} />
<CloudRuntimeNodeRow key={node.id} node={node} wsId={wsId} />
))}
</div>
</div>
@@ -290,8 +291,9 @@ function LabeledInput({
);
}
function CloudRuntimeNodeRow({ node }: { node: CloudRuntimeNode }) {
function CloudRuntimeNodeRow({ node, wsId }: { node: CloudRuntimeNode; wsId: string }) {
const { t } = useT("runtimes");
const deleteNode = useDeleteCloudRuntimeNode(wsId);
const title =
node.name.trim() ||
node.instance_id.trim() ||
@@ -317,6 +319,32 @@ function CloudRuntimeNodeRow({ node }: { node: CloudRuntimeNode }) {
)}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-destructive"
disabled={deleteNode.isPending}
onClick={() => {
if (!confirm(t(($) => $.cloud_runtime.delete_confirm))) return;
deleteNode.mutate(node.id, {
onSuccess: () => toast.success(t(($) => $.cloud_runtime.toast_deleted)),
onError: (err) =>
toast.error(
err instanceof Error
? err.message
: t(($) => $.cloud_runtime.toast_delete_failed),
),
});
}}
aria-label={t(($) => $.cloud_runtime.delete)}
>
{deleteNode.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
</div>
{node.instance_id && (
<div className="mt-2 truncate font-mono text-[11px] text-muted-foreground/80">