From 89e4560f20708a49b2be732387cd528a6ab78c8a Mon Sep 17 00:00:00 2001 From: Eddy Date: Sat, 1 Aug 2026 14:30:42 +0700 Subject: [PATCH] fix(ui): align workstation web contract with gateway API shape (#1461) The workstations page was written against snake_case field names the gateway never served. Go's json decoder drops unknown members silently, so create requests arrived with an empty WorkstationKey and were rejected with "workstationKey is required", while list rows read undefined and rendered a blank key, "backend.undefined" and "Invalid Date". Align the TypeScript contract on the camelCase shape that both the WS handler and store model already use: - Rename Workstation, CreateWorkstationParams and every field read on the workstations page to workstationKey/backendType/createdAt/updatedAt. - Nest update params under `updates`; a flattened body decoded to an empty map and was rejected with "no updates provided". - Replace the Identity File input, which mapped to a field SSHMetadata does not accept, with a private key or password credential path. - Add the image field required by DockerMetadata and map the container name onto host and the daemon endpoint onto socketPath, so Docker workstations can be created at all. - Extract the payload builder so the wire contract is unit-testable, and move validation strings into the en/vi/zh catalogs. Covered by tests asserting the create payload shape and list rendering against a verbatim gateway response body. Co-authored-by: Eddy Lockwood --- ui/web/src/i18n/locales/en/workstations.json | 30 +++- ui/web/src/i18n/locales/vi/workstations.json | 30 +++- ui/web/src/i18n/locales/zh/workstations.json | 30 +++- .../__tests__/workstation-contract.test.ts | 135 +++++++++++++++++ .../workstations/hooks/use-workstations.ts | 24 ++- .../workstation-create-dialog-helpers.ts | 97 ++++++++++++ .../workstation-create-dialog.tsx | 140 ++++++++++++------ .../pages/workstations/workstations-page.tsx | 6 +- 8 files changed, 420 insertions(+), 72 deletions(-) create mode 100644 ui/web/src/pages/workstations/__tests__/workstation-contract.test.ts create mode 100644 ui/web/src/pages/workstations/workstation-create-dialog-helpers.ts diff --git a/ui/web/src/i18n/locales/en/workstations.json b/ui/web/src/i18n/locales/en/workstations.json index 14e7fd93..18df231b 100644 --- a/ui/web/src/i18n/locales/en/workstations.json +++ b/ui/web/src/i18n/locales/en/workstations.json @@ -43,14 +43,34 @@ "portLabel": "Port", "userLabel": "SSH User", "userPlaceholder": "ubuntu", - "identityFileLabel": "Identity File (optional)", - "identityFilePlaceholder": "~/.ssh/id_rsa", + "authMethodLabel": "Authentication", + "authPrivateKeyOption": "Private key", + "authPasswordOption": "Password", + "privateKeyLabel": "Private Key (PEM)", + "privateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "privateKeyHint": "Pasted key material is encrypted at rest and never returned by the API.", + "passwordLabel": "SSH Password", + "passwordPlaceholder": "Password for the SSH user", + "passwordHint": "Prefer key-based auth where possible.", "containerLabel": "Container Name / ID", "containerPlaceholder": "my-container", - "dockerHostLabel": "Docker Host (optional)", - "dockerHostPlaceholder": "unix:///var/run/docker.sock", + "imageLabel": "Image", + "imagePlaceholder": "ubuntu:24.04", + "socketPathLabel": "Docker Socket Path (optional)", + "socketPathPlaceholder": "unix:///var/run/docker.sock", "cancel": "Cancel", - "create": "Create" + "create": "Create", + "errors": { + "keyRequired": "Workstation key is required.", + "nameRequired": "Display name is required.", + "sshHostUserRequired": "Host and SSH user are required for the SSH backend.", + "sshPortRange": "Port must be between 1 and 65535.", + "sshPrivateKeyRequired": "A private key is required for key-based authentication.", + "sshPasswordRequired": "A password is required for password authentication.", + "dockerContainerRequired": "Container name is required for the Docker backend.", + "dockerImageRequired": "Image is required for the Docker backend.", + "createFailed": "Failed to create workstation." + } }, "deleteDialog": { "title": "Delete Workstation", diff --git a/ui/web/src/i18n/locales/vi/workstations.json b/ui/web/src/i18n/locales/vi/workstations.json index 60d421ce..0932c430 100644 --- a/ui/web/src/i18n/locales/vi/workstations.json +++ b/ui/web/src/i18n/locales/vi/workstations.json @@ -43,14 +43,34 @@ "portLabel": "Cổng", "userLabel": "SSH User", "userPlaceholder": "ubuntu", - "identityFileLabel": "File khóa (tùy chọn)", - "identityFilePlaceholder": "~/.ssh/id_rsa", + "authMethodLabel": "Xác thực", + "authPrivateKeyOption": "Khóa riêng tư", + "authPasswordOption": "Mật khẩu", + "privateKeyLabel": "Khóa riêng tư (PEM)", + "privateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "privateKeyHint": "Nội dung khóa được mã hóa khi lưu và không bao giờ trả về qua API.", + "passwordLabel": "Mật khẩu SSH", + "passwordPlaceholder": "Mật khẩu của SSH user", + "passwordHint": "Nên ưu tiên xác thực bằng khóa khi có thể.", "containerLabel": "Tên / ID container", "containerPlaceholder": "my-container", - "dockerHostLabel": "Docker Host (tùy chọn)", - "dockerHostPlaceholder": "unix:///var/run/docker.sock", + "imageLabel": "Image", + "imagePlaceholder": "ubuntu:24.04", + "socketPathLabel": "Đường dẫn Docker socket (tùy chọn)", + "socketPathPlaceholder": "unix:///var/run/docker.sock", "cancel": "Hủy", - "create": "Tạo" + "create": "Tạo", + "errors": { + "keyRequired": "Vui lòng nhập khóa workstation.", + "nameRequired": "Vui lòng nhập tên hiển thị.", + "sshHostUserRequired": "Backend SSH yêu cầu Host và SSH user.", + "sshPortRange": "Cổng phải nằm trong khoảng 1 đến 65535.", + "sshPrivateKeyRequired": "Cần khóa riêng tư khi xác thực bằng khóa.", + "sshPasswordRequired": "Cần mật khẩu khi xác thực bằng mật khẩu.", + "dockerContainerRequired": "Backend Docker yêu cầu tên container.", + "dockerImageRequired": "Backend Docker yêu cầu image.", + "createFailed": "Tạo workstation thất bại." + } }, "deleteDialog": { "title": "Xóa Workstation", diff --git a/ui/web/src/i18n/locales/zh/workstations.json b/ui/web/src/i18n/locales/zh/workstations.json index 92773a1b..77429a8d 100644 --- a/ui/web/src/i18n/locales/zh/workstations.json +++ b/ui/web/src/i18n/locales/zh/workstations.json @@ -43,14 +43,34 @@ "portLabel": "端口", "userLabel": "SSH 用户", "userPlaceholder": "ubuntu", - "identityFileLabel": "密钥文件(可选)", - "identityFilePlaceholder": "~/.ssh/id_rsa", + "authMethodLabel": "身份验证", + "authPrivateKeyOption": "私钥", + "authPasswordOption": "密码", + "privateKeyLabel": "私钥(PEM)", + "privateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "privateKeyHint": "粘贴的密钥内容会加密存储,且不会通过 API 返回。", + "passwordLabel": "SSH 密码", + "passwordPlaceholder": "SSH 用户的密码", + "passwordHint": "在可能的情况下优先使用密钥认证。", "containerLabel": "容器名称 / ID", "containerPlaceholder": "my-container", - "dockerHostLabel": "Docker Host(可选)", - "dockerHostPlaceholder": "unix:///var/run/docker.sock", + "imageLabel": "镜像", + "imagePlaceholder": "ubuntu:24.04", + "socketPathLabel": "Docker Socket 路径(可选)", + "socketPathPlaceholder": "unix:///var/run/docker.sock", "cancel": "取消", - "create": "创建" + "create": "创建", + "errors": { + "keyRequired": "请填写工作站键名。", + "nameRequired": "请填写显示名称。", + "sshHostUserRequired": "SSH 后端需要填写主机和 SSH 用户。", + "sshPortRange": "端口必须在 1 到 65535 之间。", + "sshPrivateKeyRequired": "使用密钥认证时必须提供私钥。", + "sshPasswordRequired": "使用密码认证时必须提供密码。", + "dockerContainerRequired": "Docker 后端需要填写容器名称。", + "dockerImageRequired": "Docker 后端需要填写镜像。", + "createFailed": "创建工作站失败。" + } }, "deleteDialog": { "title": "删除工作站", diff --git a/ui/web/src/pages/workstations/__tests__/workstation-contract.test.ts b/ui/web/src/pages/workstations/__tests__/workstation-contract.test.ts new file mode 100644 index 00000000..46add07a --- /dev/null +++ b/ui/web/src/pages/workstations/__tests__/workstation-contract.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { + buildWorkstationCreatePayload, + type WorkstationCreateFormState, +} from "../workstation-create-dialog-helpers"; +import type { Workstation } from "../hooks/use-workstations"; + +function form(overrides: Partial = {}): WorkstationCreateFormState { + return { + key: "dev-server", + name: "Dev Server", + backend: "ssh", + host: "192.168.1.100", + port: "22", + user: "ubuntu", + authMethod: "privateKey", + privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----", + password: "", + container: "", + image: "", + socketPath: "", + ...overrides, + }; +} + +describe("workstation create payload contract", () => { + it("submits camelCase params the gateway handler actually decodes", () => { + const result = buildWorkstationCreatePayload(form()); + + expect(result.kind).toBe("ok"); + if (result.kind !== "ok") return; + + // Regression guard for #1258: snake_case here decodes to the zero value in + // Go and the handler rejects the request with "workstationKey is required". + expect(Object.keys(result.payload).sort()).toEqual([ + "backendType", + "metadata", + "name", + "workstationKey", + ]); + expect(result.payload.workstationKey).toBe("dev-server"); + expect(result.payload.backendType).toBe("ssh"); + expect(result.payload).not.toHaveProperty("workstation_key"); + expect(result.payload).not.toHaveProperty("backend_type"); + }); + + it("sends SSH credentials as privateKey, not the unsupported identity_file", () => { + const result = buildWorkstationCreatePayload(form()); + if (result.kind !== "ok") throw new Error("expected ok"); + + // store.SSHMetadata accepts privateKey or password only; identity_file was + // silently dropped and left the request failing metadata validation. + expect(result.payload.metadata).toEqual({ + host: "192.168.1.100", + port: 22, + user: "ubuntu", + privateKey: expect.stringContaining("BEGIN OPENSSH PRIVATE KEY"), + }); + expect(result.payload.metadata).not.toHaveProperty("identity_file"); + }); + + it("sends a password when password auth is selected", () => { + const result = buildWorkstationCreatePayload( + form({ authMethod: "password", privateKey: "", password: "hunter2" }), + ); + if (result.kind !== "ok") throw new Error("expected ok"); + + expect(result.payload.metadata).toMatchObject({ password: "hunter2" }); + expect(result.payload.metadata).not.toHaveProperty("privateKey"); + }); + + it("rejects SSH auth with no credential rather than letting the backend 400", () => { + expect( + buildWorkstationCreatePayload(form({ privateKey: "" })), + ).toEqual({ kind: "error", errorKey: "sshPrivateKeyRequired" }); + + expect( + buildWorkstationCreatePayload(form({ authMethod: "password", privateKey: "", password: "" })), + ).toEqual({ kind: "error", errorKey: "sshPasswordRequired" }); + }); + + it("maps the Docker container name onto host and requires an image", () => { + const ok = buildWorkstationCreatePayload( + form({ backend: "docker", container: "my-box", image: "ubuntu:24.04", socketPath: "" }), + ); + if (ok.kind !== "ok") throw new Error("expected ok"); + + // store.DockerMetadata carries the container name in `host` and requires + // `image`; the old payload sent {container, docker_host} and always failed. + expect(ok.payload.metadata).toEqual({ host: "my-box", image: "ubuntu:24.04" }); + + expect( + buildWorkstationCreatePayload(form({ backend: "docker", container: "my-box", image: "" })), + ).toEqual({ kind: "error", errorKey: "dockerImageRequired" }); + }); + + it("requires a workstation key before hitting the network", () => { + expect(buildWorkstationCreatePayload(form({ key: " " }))).toEqual({ + kind: "error", + errorKey: "keyRequired", + }); + }); +}); + +describe("workstation list contract", () => { + // Verbatim body of store.Workstation.SanitizedView() as serialized by the + // gateway, kept complete even though the UI type is a narrower subset — that + // is what proves the subset is actually assignable from a real response. + // If the TS interface drifts back to snake_case this stops compiling. + const apiResponse = { + id: "8f2b0f7e-1c4a-4c9e-9f1a-2b3c4d5e6f70", + workstationKey: "aether", + tenantId: "11111111-2222-3333-4444-555555555555", + name: "Aether", + backendType: "ssh", + defaultCwd: "/home/ubuntu", + active: true, + createdAt: "2026-06-22T15:06:38Z", + updatedAt: "2026-06-22T15:06:38Z", + createdBy: "admin", + metadataSummary: { host: "10.0.0.5", port: 22, user: "ubuntu", hasKey: true }, + } as const; + + it("resolves every field the workstations table renders", () => { + const ws: Workstation = apiResponse; + + // Regression guard for #1258: these read undefined under the old snake_case + // interface, rendering a blank key, "backend.undefined" and "Invalid Date". + expect(ws.workstationKey).toBe("aether"); + expect(ws.backendType).toBe("ssh"); + expect(Number.isNaN(new Date(ws.createdAt).getTime())).toBe(false); + expect(ws.name).toBe("Aether"); + expect(ws.active).toBe(true); + }); +}); diff --git a/ui/web/src/pages/workstations/hooks/use-workstations.ts b/ui/web/src/pages/workstations/hooks/use-workstations.ts index 142576a0..2b210000 100644 --- a/ui/web/src/pages/workstations/hooks/use-workstations.ts +++ b/ui/web/src/pages/workstations/hooks/use-workstations.ts @@ -3,20 +3,28 @@ import { useWs } from "@/hooks/use-ws"; import { useAuthStore } from "@/stores/use-auth-store"; import { Methods } from "@/api/protocol"; +export type WorkstationBackendType = "ssh" | "docker"; + +/** + * Subset of store.SanitizedWorkstation that this page consumes. Field names are + * camelCase to match the Go json tags — the WS client sends and receives params + * verbatim, so any divergence here silently yields undefined rather than a + * decode error. + */ export interface Workstation { id: string; - workstation_key: string; + workstationKey: string; name: string; - backend_type: "ssh" | "docker"; + backendType: WorkstationBackendType; active: boolean; - created_at: string; - updated_at: string; + createdAt: string; + updatedAt: string; } export interface CreateWorkstationParams { - workstation_key: string; + workstationKey: string; name: string; - backend_type: "ssh" | "docker"; + backendType: WorkstationBackendType; metadata?: Record; } @@ -62,7 +70,9 @@ export function useWorkstations() { const updateWorkstation = useCallback( async (id: string, params: UpdateWorkstationParams): Promise => { - await ws.call(Methods.WORKSTATIONS_UPDATE, { id, ...params }); + // The handler reads a nested `updates` map; a flattened body decodes to an + // empty map and is rejected with "no updates provided". + await ws.call(Methods.WORKSTATIONS_UPDATE, { id, updates: params }); await load(); }, [ws, load], diff --git a/ui/web/src/pages/workstations/workstation-create-dialog-helpers.ts b/ui/web/src/pages/workstations/workstation-create-dialog-helpers.ts new file mode 100644 index 00000000..3efe76b2 --- /dev/null +++ b/ui/web/src/pages/workstations/workstation-create-dialog-helpers.ts @@ -0,0 +1,97 @@ +import type { CreateWorkstationParams, WorkstationBackendType } from "./hooks/use-workstations"; + +/** Auth method for the SSH backend. The gateway accepts inline PEM or a password. */ +export type SshAuthMethod = "privateKey" | "password"; + +export interface WorkstationCreateFormState { + key: string; + name: string; + backend: WorkstationBackendType; + // SSH + host: string; + port: string; + user: string; + authMethod: SshAuthMethod; + privateKey: string; + password: string; + // Docker + container: string; + image: string; + socketPath: string; +} + +export type BuildCreatePayloadResult = + | { kind: "ok"; payload: CreateWorkstationParams } + | { kind: "error"; errorKey: string }; + +/** + * Builds the workstations.create RPC payload from dialog form state. + * + * Field names here must match the gateway wire contract exactly — the WS client + * serializes params verbatim with no case conversion, and Go's json decoder + * silently drops unknown members rather than erroring. A mismatched name + * therefore surfaces as " is required" rather than as a decode failure. + * + * Metadata shape mirrors store.SSHMetadata / store.DockerMetadata: SSH requires + * privateKey or password, and Docker requires an image plus host or socketPath. + */ +export function buildWorkstationCreatePayload( + form: WorkstationCreateFormState, +): BuildCreatePayloadResult { + const key = form.key.trim(); + const name = form.name.trim(); + + if (!key) return { kind: "error", errorKey: "keyRequired" }; + if (!name) return { kind: "error", errorKey: "nameRequired" }; + + let metadata: Record; + + if (form.backend === "ssh") { + const host = form.host.trim(); + const user = form.user.trim(); + if (!host || !user) return { kind: "error", errorKey: "sshHostUserRequired" }; + + const port = parseInt(form.port, 10) || 22; + if (port < 1 || port > 65535) return { kind: "error", errorKey: "sshPortRange" }; + + const privateKey = form.privateKey.trim(); + const password = form.password; + if (form.authMethod === "privateKey" && !privateKey) { + return { kind: "error", errorKey: "sshPrivateKeyRequired" }; + } + if (form.authMethod === "password" && !password) { + return { kind: "error", errorKey: "sshPasswordRequired" }; + } + + metadata = { + host, + port, + user, + ...(form.authMethod === "privateKey" ? { privateKey } : { password }), + }; + } else { + // The gateway stores the container name in DockerMetadata.host and surfaces + // it back as metadataSummary.containerName. + const container = form.container.trim(); + const image = form.image.trim(); + const socketPath = form.socketPath.trim(); + if (!container) return { kind: "error", errorKey: "dockerContainerRequired" }; + if (!image) return { kind: "error", errorKey: "dockerImageRequired" }; + + metadata = { + host: container, + image, + ...(socketPath ? { socketPath } : {}), + }; + } + + return { + kind: "ok", + payload: { + workstationKey: key, + name, + backendType: form.backend, + metadata, + }, + }; +} diff --git a/ui/web/src/pages/workstations/workstation-create-dialog.tsx b/ui/web/src/pages/workstations/workstation-create-dialog.tsx index 563821a8..03a3b328 100644 --- a/ui/web/src/pages/workstations/workstation-create-dialog.tsx +++ b/ui/web/src/pages/workstations/workstation-create-dialog.tsx @@ -18,7 +18,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { CreateWorkstationParams } from "./hooks/use-workstations"; +import { Textarea } from "@/components/ui/textarea"; +import type { CreateWorkstationParams, WorkstationBackendType } from "./hooks/use-workstations"; +import { + buildWorkstationCreatePayload, + type SshAuthMethod, +} from "./workstation-create-dialog-helpers"; interface WorkstationCreateDialogProps { open: boolean; @@ -26,8 +31,6 @@ interface WorkstationCreateDialogProps { onCreate: (params: CreateWorkstationParams) => Promise; } -type BackendType = "ssh" | "docker"; - export function WorkstationCreateDialog({ open, onOpenChange, @@ -37,15 +40,18 @@ export function WorkstationCreateDialog({ const [name, setName] = useState(""); const [key, setKey] = useState(""); - const [backend, setBackend] = useState("ssh"); + const [backend, setBackend] = useState("ssh"); // SSH fields const [host, setHost] = useState(""); const [port, setPort] = useState("22"); const [user, setUser] = useState(""); - const [identityFile, setIdentityFile] = useState(""); + const [authMethod, setAuthMethod] = useState("privateKey"); + const [privateKey, setPrivateKey] = useState(""); + const [password, setPassword] = useState(""); // Docker fields const [container, setContainer] = useState(""); - const [dockerHost, setDockerHost] = useState(""); + const [image, setImage] = useState(""); + const [socketPath, setSocketPath] = useState(""); const [submitting, setSubmitting] = useState(false); const [fieldError, setFieldError] = useState(null); @@ -57,48 +63,45 @@ export function WorkstationCreateDialog({ setHost(""); setPort("22"); setUser(""); - setIdentityFile(""); + setAuthMethod("privateKey"); + setPrivateKey(""); + setPassword(""); setContainer(""); - setDockerHost(""); + setImage(""); + setSocketPath(""); setFieldError(null); } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (!name.trim() || !key.trim()) return; - // Build backend metadata - let metadata: Record; - if (backend === "ssh") { - if (!host.trim() || !user.trim()) { - setFieldError("Host and SSH user are required for SSH backend."); - return; - } - metadata = { - host: host.trim(), - port: parseInt(port, 10) || 22, - user: user.trim(), - ...(identityFile.trim() ? { identity_file: identityFile.trim() } : {}), - }; - } else { - if (!container.trim()) { - setFieldError("Container name is required for Docker backend."); - return; - } - metadata = { - container: container.trim(), - ...(dockerHost.trim() ? { docker_host: dockerHost.trim() } : {}), - }; + const built = buildWorkstationCreatePayload({ + key, + name, + backend, + host, + port, + user, + authMethod, + privateKey, + password, + container, + image, + socketPath, + }); + if (built.kind === "error") { + setFieldError(t(`createDialog.errors.${built.errorKey}`)); + return; } setFieldError(null); setSubmitting(true); try { - await onCreate({ workstation_key: key.trim(), name: name.trim(), backend_type: backend, metadata }); + await onCreate(built.payload); resetForm(); onOpenChange(false); } catch (err) { - setFieldError(err instanceof Error ? err.message : "Failed to create workstation."); + setFieldError(err instanceof Error ? err.message : t("createDialog.errors.createFailed")); } finally { setSubmitting(false); } @@ -141,7 +144,7 @@ export function WorkstationCreateDialog({
- setBackend(v as WorkstationBackendType)}> @@ -189,15 +192,48 @@ export function WorkstationCreateDialog({ />
- - setIdentityFile(e.target.value)} - placeholder={t("createDialog.identityFilePlaceholder")} - className="text-base md:text-sm" - /> + +
+ + {authMethod === "privateKey" ? ( +
+ +