feat(gateway): refactor gateway form and connection check logic
This commit is contained in:
@@ -6,11 +6,9 @@ import { useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import {
|
||||
gatewaysStatusApiV1GatewaysStatusGet,
|
||||
type getGatewayApiV1GatewaysGatewayIdGetResponse,
|
||||
useGetGatewayApiV1GatewaysGatewayIdGet,
|
||||
useUpdateGatewayApiV1GatewaysGatewayIdPatch,
|
||||
@@ -20,30 +18,17 @@ import {
|
||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import type { GatewayUpdate } from "@/api/generated/model";
|
||||
import { GatewayForm } from "@/components/gateways/GatewayForm";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const DEFAULT_MAIN_SESSION_KEY = "agent:main:main";
|
||||
const DEFAULT_WORKSPACE_ROOT = "~/.openclaw";
|
||||
|
||||
const validateGatewayUrl = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "Gateway URL is required.";
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
return "Gateway URL must start with ws:// or wss://.";
|
||||
}
|
||||
if (!url.port) {
|
||||
return "Gateway URL must include an explicit port.";
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return "Enter a valid gateway URL including port.";
|
||||
}
|
||||
};
|
||||
import {
|
||||
DEFAULT_MAIN_SESSION_KEY,
|
||||
DEFAULT_WORKSPACE_ROOT,
|
||||
checkGatewayConnection,
|
||||
type GatewayCheckStatus,
|
||||
validateGatewayUrl,
|
||||
} from "@/lib/gateway-form";
|
||||
|
||||
export default function EditGatewayPage() {
|
||||
const { isSignedIn } = useAuth();
|
||||
@@ -81,9 +66,8 @@ export default function EditGatewayPage() {
|
||||
);
|
||||
|
||||
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
|
||||
const [gatewayCheckStatus, setGatewayCheckStatus] = useState<
|
||||
"idle" | "checking" | "success" | "error"
|
||||
>("idle");
|
||||
const [gatewayCheckStatus, setGatewayCheckStatus] =
|
||||
useState<GatewayCheckStatus>("idle");
|
||||
const [gatewayCheckMessage, setGatewayCheckMessage] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -147,36 +131,13 @@ export default function EditGatewayPage() {
|
||||
if (!isSignedIn) return;
|
||||
setGatewayCheckStatus("checking");
|
||||
setGatewayCheckMessage(null);
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
gateway_url: resolvedGatewayUrl.trim(),
|
||||
};
|
||||
if (resolvedGatewayToken.trim()) {
|
||||
params.gateway_token = resolvedGatewayToken.trim();
|
||||
}
|
||||
if (resolvedMainSessionKey.trim()) {
|
||||
params.gateway_main_session_key = resolvedMainSessionKey.trim();
|
||||
}
|
||||
const response = await gatewaysStatusApiV1GatewaysStatusGet(params);
|
||||
if (response.status !== 200) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage("Unable to reach gateway.");
|
||||
return;
|
||||
}
|
||||
const data = response.data;
|
||||
if (!data.connected) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(data.error ?? "Unable to reach gateway.");
|
||||
return;
|
||||
}
|
||||
setGatewayCheckStatus("success");
|
||||
setGatewayCheckMessage("Gateway reachable.");
|
||||
} catch (err) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(
|
||||
err instanceof Error ? err.message : "Unable to reach gateway.",
|
||||
);
|
||||
}
|
||||
const { ok, message } = await checkGatewayConnection({
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
gatewayToken: resolvedGatewayToken,
|
||||
mainSessionKey: resolvedMainSessionKey,
|
||||
});
|
||||
setGatewayCheckStatus(ok ? "success" : "error");
|
||||
setGatewayCheckMessage(message);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -253,139 +214,45 @@ export default function EditGatewayPage() {
|
||||
Only organization owners and admins can edit gateways.
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
<GatewayForm
|
||||
name={resolvedName}
|
||||
gatewayUrl={resolvedGatewayUrl}
|
||||
gatewayToken={resolvedGatewayToken}
|
||||
mainSessionKey={resolvedMainSessionKey}
|
||||
workspaceRoot={resolvedWorkspaceRoot}
|
||||
gatewayUrlError={gatewayUrlError}
|
||||
gatewayCheckStatus={gatewayCheckStatus}
|
||||
gatewayCheckMessage={gatewayCheckMessage}
|
||||
errorMessage={errorMessage}
|
||||
isLoading={isLoading}
|
||||
canSubmit={canSubmit}
|
||||
mainSessionKeyPlaceholder={DEFAULT_MAIN_SESSION_KEY}
|
||||
workspaceRootPlaceholder={DEFAULT_WORKSPACE_ROOT}
|
||||
cancelLabel="Back"
|
||||
submitLabel="Save changes"
|
||||
submitBusyLabel="Saving…"
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={resolvedName}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Primary gateway"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={resolvedGatewayUrl}
|
||||
onChange={(event) => {
|
||||
setGatewayUrl(event.target.value);
|
||||
setGatewayUrlError(null);
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="ws://gateway:18789"
|
||||
disabled={isLoading}
|
||||
className={
|
||||
gatewayUrlError ? "border-red-500" : undefined
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runGatewayCheck}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
aria-label="Check gateway connection"
|
||||
>
|
||||
{gatewayCheckStatus === "checking" ? (
|
||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||
) : gatewayCheckStatus === "success" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
) : gatewayCheckStatus === "error" ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{gatewayUrlError ? (
|
||||
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
||||
) : gatewayCheckMessage ? (
|
||||
<p
|
||||
className={
|
||||
gatewayCheckStatus === "success"
|
||||
? "text-xs text-emerald-600"
|
||||
: "text-xs text-red-500"
|
||||
}
|
||||
>
|
||||
{gatewayCheckMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway token
|
||||
</label>
|
||||
<Input
|
||||
value={resolvedGatewayToken}
|
||||
onChange={(event) => {
|
||||
setGatewayToken(event.target.value);
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="Bearer token"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Main session key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={resolvedMainSessionKey}
|
||||
onChange={(event) => {
|
||||
setMainSessionKey(event.target.value);
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
placeholder={DEFAULT_MAIN_SESSION_KEY}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Workspace root <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={resolvedWorkspaceRoot}
|
||||
onChange={(event) => setWorkspaceRoot(event.target.value)}
|
||||
placeholder={DEFAULT_WORKSPACE_ROOT}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push("/gateways")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || !canSubmit}>
|
||||
{isLoading ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
onCancel={() => router.push("/gateways")}
|
||||
onRunGatewayCheck={runGatewayCheck}
|
||||
onNameChange={setName}
|
||||
onGatewayUrlChange={(next) => {
|
||||
setGatewayUrl(next);
|
||||
setGatewayUrlError(null);
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
onGatewayTokenChange={(next) => {
|
||||
setGatewayToken(next);
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
onMainSessionKeyChange={(next) => {
|
||||
setMainSessionKey(next);
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
onWorkspaceRootChange={setWorkspaceRoot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -6,41 +6,24 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
||||
|
||||
import { ApiError } from "@/api/mutator";
|
||||
import {
|
||||
gatewaysStatusApiV1GatewaysStatusGet,
|
||||
useCreateGatewayApiV1GatewaysPost,
|
||||
} from "@/api/generated/gateways/gateways";
|
||||
import { useCreateGatewayApiV1GatewaysPost } from "@/api/generated/gateways/gateways";
|
||||
import {
|
||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||
} from "@/api/generated/organizations/organizations";
|
||||
import { GatewayForm } from "@/components/gateways/GatewayForm";
|
||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const DEFAULT_MAIN_SESSION_KEY = "agent:main:main";
|
||||
const DEFAULT_WORKSPACE_ROOT = "~/.openclaw";
|
||||
|
||||
const validateGatewayUrl = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "Gateway URL is required.";
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
return "Gateway URL must start with ws:// or wss://.";
|
||||
}
|
||||
if (!url.port) {
|
||||
return "Gateway URL must include an explicit port.";
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return "Enter a valid gateway URL including port.";
|
||||
}
|
||||
};
|
||||
import {
|
||||
DEFAULT_MAIN_SESSION_KEY,
|
||||
DEFAULT_WORKSPACE_ROOT,
|
||||
checkGatewayConnection,
|
||||
type GatewayCheckStatus,
|
||||
validateGatewayUrl,
|
||||
} from "@/lib/gateway-form";
|
||||
|
||||
export default function NewGatewayPage() {
|
||||
const { isSignedIn } = useAuth();
|
||||
@@ -69,9 +52,8 @@ export default function NewGatewayPage() {
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState(DEFAULT_WORKSPACE_ROOT);
|
||||
|
||||
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
|
||||
const [gatewayCheckStatus, setGatewayCheckStatus] = useState<
|
||||
"idle" | "checking" | "success" | "error"
|
||||
>("idle");
|
||||
const [gatewayCheckStatus, setGatewayCheckStatus] =
|
||||
useState<GatewayCheckStatus>("idle");
|
||||
const [gatewayCheckMessage, setGatewayCheckMessage] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -111,37 +93,13 @@ export default function NewGatewayPage() {
|
||||
if (!isSignedIn) return;
|
||||
setGatewayCheckStatus("checking");
|
||||
setGatewayCheckMessage(null);
|
||||
try {
|
||||
const params: Record<string, string> = {
|
||||
gateway_url: gatewayUrl.trim(),
|
||||
};
|
||||
if (gatewayToken.trim()) {
|
||||
params.gateway_token = gatewayToken.trim();
|
||||
}
|
||||
if (mainSessionKey.trim()) {
|
||||
params.gateway_main_session_key = mainSessionKey.trim();
|
||||
}
|
||||
|
||||
const response = await gatewaysStatusApiV1GatewaysStatusGet(params);
|
||||
if (response.status !== 200) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage("Unable to reach gateway.");
|
||||
return;
|
||||
}
|
||||
const data = response.data;
|
||||
if (!data.connected) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(data.error ?? "Unable to reach gateway.");
|
||||
return;
|
||||
}
|
||||
setGatewayCheckStatus("success");
|
||||
setGatewayCheckMessage("Gateway reachable.");
|
||||
} catch (err) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(
|
||||
err instanceof Error ? err.message : "Unable to reach gateway.",
|
||||
);
|
||||
}
|
||||
const { ok, message } = await checkGatewayConnection({
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
mainSessionKey,
|
||||
});
|
||||
setGatewayCheckStatus(ok ? "success" : "error");
|
||||
setGatewayCheckMessage(message);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -214,137 +172,45 @@ export default function NewGatewayPage() {
|
||||
Only organization owners and admins can create gateways.
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
<GatewayForm
|
||||
name={name}
|
||||
gatewayUrl={gatewayUrl}
|
||||
gatewayToken={gatewayToken}
|
||||
mainSessionKey={mainSessionKey}
|
||||
workspaceRoot={workspaceRoot}
|
||||
gatewayUrlError={gatewayUrlError}
|
||||
gatewayCheckStatus={gatewayCheckStatus}
|
||||
gatewayCheckMessage={gatewayCheckMessage}
|
||||
errorMessage={error}
|
||||
isLoading={isLoading}
|
||||
canSubmit={canSubmit}
|
||||
mainSessionKeyPlaceholder={DEFAULT_MAIN_SESSION_KEY}
|
||||
workspaceRootPlaceholder={DEFAULT_WORKSPACE_ROOT}
|
||||
cancelLabel="Cancel"
|
||||
submitLabel="Create gateway"
|
||||
submitBusyLabel="Creating…"
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Primary gateway"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={gatewayUrl}
|
||||
onChange={(event) => {
|
||||
setGatewayUrl(event.target.value);
|
||||
setGatewayUrlError(null);
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="ws://gateway:18789"
|
||||
disabled={isLoading}
|
||||
className={
|
||||
gatewayUrlError ? "border-red-500" : undefined
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runGatewayCheck}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
aria-label="Check gateway connection"
|
||||
>
|
||||
{gatewayCheckStatus === "checking" ? (
|
||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||
) : gatewayCheckStatus === "success" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
) : gatewayCheckStatus === "error" ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{gatewayUrlError ? (
|
||||
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
||||
) : gatewayCheckMessage ? (
|
||||
<p
|
||||
className={
|
||||
gatewayCheckStatus === "success"
|
||||
? "text-xs text-emerald-600"
|
||||
: "text-xs text-red-500"
|
||||
}
|
||||
>
|
||||
{gatewayCheckMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway token
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayToken}
|
||||
onChange={(event) => {
|
||||
setGatewayToken(event.target.value);
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
onBlur={runGatewayCheck}
|
||||
placeholder="Bearer token"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Main session key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={mainSessionKey}
|
||||
onChange={(event) => {
|
||||
setMainSessionKey(event.target.value);
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
placeholder={DEFAULT_MAIN_SESSION_KEY}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Workspace root <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={workspaceRoot}
|
||||
onChange={(event) => setWorkspaceRoot(event.target.value)}
|
||||
placeholder={DEFAULT_WORKSPACE_ROOT}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => router.push("/gateways")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || !canSubmit}>
|
||||
{isLoading ? "Creating…" : "Create gateway"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
onCancel={() => router.push("/gateways")}
|
||||
onRunGatewayCheck={runGatewayCheck}
|
||||
onNameChange={setName}
|
||||
onGatewayUrlChange={(next) => {
|
||||
setGatewayUrl(next);
|
||||
setGatewayUrlError(null);
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
onGatewayTokenChange={(next) => {
|
||||
setGatewayToken(next);
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
onMainSessionKeyChange={(next) => {
|
||||
setMainSessionKey(next);
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
onWorkspaceRootChange={setWorkspaceRoot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
174
frontend/src/components/gateways/GatewayForm.tsx
Normal file
174
frontend/src/components/gateways/GatewayForm.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
||||
|
||||
import type { GatewayCheckStatus } from "@/lib/gateway-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type GatewayFormProps = {
|
||||
name: string;
|
||||
gatewayUrl: string;
|
||||
gatewayToken: string;
|
||||
mainSessionKey: string;
|
||||
workspaceRoot: string;
|
||||
gatewayUrlError: string | null;
|
||||
gatewayCheckStatus: GatewayCheckStatus;
|
||||
gatewayCheckMessage: string | null;
|
||||
errorMessage: string | null;
|
||||
isLoading: boolean;
|
||||
canSubmit: boolean;
|
||||
mainSessionKeyPlaceholder: string;
|
||||
workspaceRootPlaceholder: string;
|
||||
cancelLabel: string;
|
||||
submitLabel: string;
|
||||
submitBusyLabel: string;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onCancel: () => void;
|
||||
onRunGatewayCheck: () => Promise<void>;
|
||||
onNameChange: (next: string) => void;
|
||||
onGatewayUrlChange: (next: string) => void;
|
||||
onGatewayTokenChange: (next: string) => void;
|
||||
onMainSessionKeyChange: (next: string) => void;
|
||||
onWorkspaceRootChange: (next: string) => void;
|
||||
};
|
||||
|
||||
export function GatewayForm({
|
||||
name,
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
mainSessionKey,
|
||||
workspaceRoot,
|
||||
gatewayUrlError,
|
||||
gatewayCheckStatus,
|
||||
gatewayCheckMessage,
|
||||
errorMessage,
|
||||
isLoading,
|
||||
canSubmit,
|
||||
mainSessionKeyPlaceholder,
|
||||
workspaceRootPlaceholder,
|
||||
cancelLabel,
|
||||
submitLabel,
|
||||
submitBusyLabel,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onRunGatewayCheck,
|
||||
onNameChange,
|
||||
onGatewayUrlChange,
|
||||
onGatewayTokenChange,
|
||||
onMainSessionKeyChange,
|
||||
onWorkspaceRootChange,
|
||||
}: GatewayFormProps) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
placeholder="Primary gateway"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={gatewayUrl}
|
||||
onChange={(event) => onGatewayUrlChange(event.target.value)}
|
||||
onBlur={onRunGatewayCheck}
|
||||
placeholder="ws://gateway:18789"
|
||||
disabled={isLoading}
|
||||
className={gatewayUrlError ? "border-red-500" : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onRunGatewayCheck()}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
aria-label="Check gateway connection"
|
||||
>
|
||||
{gatewayCheckStatus === "checking" ? (
|
||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||
) : gatewayCheckStatus === "success" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
) : gatewayCheckStatus === "error" ? (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{gatewayUrlError ? (
|
||||
<p className="text-xs text-red-500">{gatewayUrlError}</p>
|
||||
) : gatewayCheckMessage ? (
|
||||
<p
|
||||
className={
|
||||
gatewayCheckStatus === "success"
|
||||
? "text-xs text-emerald-600"
|
||||
: "text-xs text-red-500"
|
||||
}
|
||||
>
|
||||
{gatewayCheckMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Gateway token
|
||||
</label>
|
||||
<Input
|
||||
value={gatewayToken}
|
||||
onChange={(event) => onGatewayTokenChange(event.target.value)}
|
||||
onBlur={onRunGatewayCheck}
|
||||
placeholder="Bearer token"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Main session key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={mainSessionKey}
|
||||
onChange={(event) => onMainSessionKeyChange(event.target.value)}
|
||||
placeholder={mainSessionKeyPlaceholder}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Workspace root <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={workspaceRoot}
|
||||
onChange={(event) => onWorkspaceRootChange(event.target.value)}
|
||||
placeholder={workspaceRootPlaceholder}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? <p className="text-sm text-red-500">{errorMessage}</p> : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="ghost" onClick={onCancel} disabled={isLoading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading || !canSubmit}>
|
||||
{isLoading ? submitBusyLabel : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
56
frontend/src/lib/gateway-form.ts
Normal file
56
frontend/src/lib/gateway-form.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { gatewaysStatusApiV1GatewaysStatusGet } from "@/api/generated/gateways/gateways";
|
||||
|
||||
export const DEFAULT_MAIN_SESSION_KEY = "agent:main:main";
|
||||
export const DEFAULT_WORKSPACE_ROOT = "~/.openclaw";
|
||||
|
||||
export type GatewayCheckStatus = "idle" | "checking" | "success" | "error";
|
||||
|
||||
export const validateGatewayUrl = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "Gateway URL is required.";
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
return "Gateway URL must start with ws:// or wss://.";
|
||||
}
|
||||
if (!url.port) {
|
||||
return "Gateway URL must include an explicit port.";
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return "Enter a valid gateway URL including port.";
|
||||
}
|
||||
};
|
||||
|
||||
export async function checkGatewayConnection(params: {
|
||||
gatewayUrl: string;
|
||||
gatewayToken: string;
|
||||
mainSessionKey: string;
|
||||
}): Promise<{ ok: boolean; message: string }> {
|
||||
try {
|
||||
const requestParams: Record<string, string> = {
|
||||
gateway_url: params.gatewayUrl.trim(),
|
||||
};
|
||||
if (params.gatewayToken.trim()) {
|
||||
requestParams.gateway_token = params.gatewayToken.trim();
|
||||
}
|
||||
if (params.mainSessionKey.trim()) {
|
||||
requestParams.gateway_main_session_key = params.mainSessionKey.trim();
|
||||
}
|
||||
|
||||
const response = await gatewaysStatusApiV1GatewaysStatusGet(requestParams);
|
||||
if (response.status !== 200) {
|
||||
return { ok: false, message: "Unable to reach gateway." };
|
||||
}
|
||||
const data = response.data;
|
||||
if (!data.connected) {
|
||||
return { ok: false, message: data.error ?? "Unable to reach gateway." };
|
||||
}
|
||||
return { ok: true, message: "Gateway reachable." };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: error instanceof Error ? error.message : "Unable to reach gateway.",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user