mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 00:14:33 +00:00
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 <doakythanh@gmail.com>
This commit is contained in:
1 parent
d47cc51aa0
commit
89e4560f20
8 files changed
+420
-72
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "删除工作站",
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -62,7 +70,9 @@ export function useWorkstations() {
|
||||
|
||||
const updateWorkstation = useCallback(
|
||||
async (id: string, params: UpdateWorkstationParams): Promise<void> => {
|
||||
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],
|
||||
|
||||
@@ -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 "<field> 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<string, unknown>;
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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<BackendType>("ssh");
|
||||
const [backend, setBackend] = useState<WorkstationBackendType>("ssh");
|
||||
// SSH fields
|
||||
const [host, setHost] = useState("");
|
||||
const [port, setPort] = useState("22");
|
||||
const [user, setUser] = useState("");
|
||||
const [identityFile, setIdentityFile] = useState("");
|
||||
const [authMethod, setAuthMethod] = useState<SshAuthMethod>("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<string | null>(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<string, unknown>;
|
||||
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({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("createDialog.backendLabel")}</Label>
|
||||
<Select value={backend} onValueChange={(v) => setBackend(v as BackendType)}>
|
||||
<Select value={backend} onValueChange={(v) => setBackend(v as WorkstationBackendType)}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -189,15 +192,48 @@ export function WorkstationCreateDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ws-identity">{t("createDialog.identityFileLabel")}</Label>
|
||||
<Input
|
||||
id="ws-identity"
|
||||
value={identityFile}
|
||||
onChange={(e) => setIdentityFile(e.target.value)}
|
||||
placeholder={t("createDialog.identityFilePlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<Label>{t("createDialog.authMethodLabel")}</Label>
|
||||
<Select value={authMethod} onValueChange={(v) => setAuthMethod(v as SshAuthMethod)}>
|
||||
<SelectTrigger className="text-base md:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="privateKey">{t("createDialog.authPrivateKeyOption")}</SelectItem>
|
||||
<SelectItem value="password">{t("createDialog.authPasswordOption")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{authMethod === "privateKey" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ws-private-key">{t("createDialog.privateKeyLabel")}</Label>
|
||||
<Textarea
|
||||
id="ws-private-key"
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder={t("createDialog.privateKeyPlaceholder")}
|
||||
rows={5}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="font-mono text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("createDialog.privateKeyHint")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ws-password">{t("createDialog.passwordLabel")}</Label>
|
||||
<Input
|
||||
id="ws-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("createDialog.passwordPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("createDialog.passwordHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -214,12 +250,22 @@ export function WorkstationCreateDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ws-docker-host">{t("createDialog.dockerHostLabel")}</Label>
|
||||
<Label htmlFor="ws-image">{t("createDialog.imageLabel")}</Label>
|
||||
<Input
|
||||
id="ws-docker-host"
|
||||
value={dockerHost}
|
||||
onChange={(e) => setDockerHost(e.target.value)}
|
||||
placeholder={t("createDialog.dockerHostPlaceholder")}
|
||||
id="ws-image"
|
||||
value={image}
|
||||
onChange={(e) => setImage(e.target.value)}
|
||||
placeholder={t("createDialog.imagePlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ws-socket-path">{t("createDialog.socketPathLabel")}</Label>
|
||||
<Input
|
||||
id="ws-socket-path"
|
||||
value={socketPath}
|
||||
onChange={(e) => setSocketPath(e.target.value)}
|
||||
placeholder={t("createDialog.socketPathPlaceholder")}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -91,9 +91,9 @@ export function WorkstationsPage() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium">{ws.name}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{ws.workstation_key}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{ws.workstationKey}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline">{t(`backend.${ws.backend_type}`)}</Badge>
|
||||
<Badge variant="outline">{t(`backend.${ws.backendType}`)}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={ws.active ? "default" : "secondary"}>
|
||||
@@ -101,7 +101,7 @@ export function WorkstationsPage() {
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{formatDate(new Date(ws.created_at))}
|
||||
{formatDate(new Date(ws.createdAt))}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
|
||||
Reference in new issue
Block a user