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 { useParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
import {
|
import {
|
||||||
gatewaysStatusApiV1GatewaysStatusGet,
|
|
||||||
type getGatewayApiV1GatewaysGatewayIdGetResponse,
|
type getGatewayApiV1GatewaysGatewayIdGetResponse,
|
||||||
useGetGatewayApiV1GatewaysGatewayIdGet,
|
useGetGatewayApiV1GatewaysGatewayIdGet,
|
||||||
useUpdateGatewayApiV1GatewaysGatewayIdPatch,
|
useUpdateGatewayApiV1GatewaysGatewayIdPatch,
|
||||||
@@ -20,30 +18,17 @@ import {
|
|||||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
} from "@/api/generated/organizations/organizations";
|
} from "@/api/generated/organizations/organizations";
|
||||||
import type { GatewayUpdate } from "@/api/generated/model";
|
import type { GatewayUpdate } from "@/api/generated/model";
|
||||||
|
import { GatewayForm } from "@/components/gateways/GatewayForm";
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import {
|
||||||
|
DEFAULT_MAIN_SESSION_KEY,
|
||||||
const DEFAULT_MAIN_SESSION_KEY = "agent:main:main";
|
DEFAULT_WORKSPACE_ROOT,
|
||||||
const DEFAULT_WORKSPACE_ROOT = "~/.openclaw";
|
checkGatewayConnection,
|
||||||
|
type GatewayCheckStatus,
|
||||||
const validateGatewayUrl = (value: string) => {
|
validateGatewayUrl,
|
||||||
const trimmed = value.trim();
|
} from "@/lib/gateway-form";
|
||||||
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 default function EditGatewayPage() {
|
export default function EditGatewayPage() {
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
@@ -81,9 +66,8 @@ export default function EditGatewayPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
|
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
|
||||||
const [gatewayCheckStatus, setGatewayCheckStatus] = useState<
|
const [gatewayCheckStatus, setGatewayCheckStatus] =
|
||||||
"idle" | "checking" | "success" | "error"
|
useState<GatewayCheckStatus>("idle");
|
||||||
>("idle");
|
|
||||||
const [gatewayCheckMessage, setGatewayCheckMessage] = useState<string | null>(
|
const [gatewayCheckMessage, setGatewayCheckMessage] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -147,36 +131,13 @@ export default function EditGatewayPage() {
|
|||||||
if (!isSignedIn) return;
|
if (!isSignedIn) return;
|
||||||
setGatewayCheckStatus("checking");
|
setGatewayCheckStatus("checking");
|
||||||
setGatewayCheckMessage(null);
|
setGatewayCheckMessage(null);
|
||||||
try {
|
const { ok, message } = await checkGatewayConnection({
|
||||||
const params: Record<string, string> = {
|
gatewayUrl: resolvedGatewayUrl,
|
||||||
gateway_url: resolvedGatewayUrl.trim(),
|
gatewayToken: resolvedGatewayToken,
|
||||||
};
|
mainSessionKey: resolvedMainSessionKey,
|
||||||
if (resolvedGatewayToken.trim()) {
|
});
|
||||||
params.gateway_token = resolvedGatewayToken.trim();
|
setGatewayCheckStatus(ok ? "success" : "error");
|
||||||
}
|
setGatewayCheckMessage(message);
|
||||||
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 handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
@@ -253,139 +214,45 @@ export default function EditGatewayPage() {
|
|||||||
Only organization owners and admins can edit gateways.
|
Only organization owners and admins can edit gateways.
|
||||||
</div>
|
</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}
|
onSubmit={handleSubmit}
|
||||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
onCancel={() => router.push("/gateways")}
|
||||||
>
|
onRunGatewayCheck={runGatewayCheck}
|
||||||
<div className="space-y-2">
|
onNameChange={setName}
|
||||||
<label className="text-sm font-medium text-slate-900">
|
onGatewayUrlChange={(next) => {
|
||||||
Gateway name <span className="text-red-500">*</span>
|
setGatewayUrl(next);
|
||||||
</label>
|
setGatewayUrlError(null);
|
||||||
<Input
|
setGatewayCheckStatus("idle");
|
||||||
value={resolvedName}
|
setGatewayCheckMessage(null);
|
||||||
onChange={(event) => setName(event.target.value)}
|
}}
|
||||||
placeholder="Primary gateway"
|
onGatewayTokenChange={(next) => {
|
||||||
disabled={isLoading}
|
setGatewayToken(next);
|
||||||
/>
|
setGatewayCheckStatus("idle");
|
||||||
</div>
|
setGatewayCheckMessage(null);
|
||||||
|
}}
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
onMainSessionKeyChange={(next) => {
|
||||||
<div className="space-y-2">
|
setMainSessionKey(next);
|
||||||
<label className="text-sm font-medium text-slate-900">
|
setGatewayCheckStatus("idle");
|
||||||
Gateway URL <span className="text-red-500">*</span>
|
setGatewayCheckMessage(null);
|
||||||
</label>
|
}}
|
||||||
<div className="relative">
|
onWorkspaceRootChange={setWorkspaceRoot}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -6,41 +6,24 @@ import { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
import { CheckCircle2, RefreshCcw, XCircle } from "lucide-react";
|
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
import {
|
import { useCreateGatewayApiV1GatewaysPost } from "@/api/generated/gateways/gateways";
|
||||||
gatewaysStatusApiV1GatewaysStatusGet,
|
|
||||||
useCreateGatewayApiV1GatewaysPost,
|
|
||||||
} from "@/api/generated/gateways/gateways";
|
|
||||||
import {
|
import {
|
||||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
useGetMyMembershipApiV1OrganizationsMeMemberGet,
|
||||||
} from "@/api/generated/organizations/organizations";
|
} from "@/api/generated/organizations/organizations";
|
||||||
|
import { GatewayForm } from "@/components/gateways/GatewayForm";
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import {
|
||||||
|
DEFAULT_MAIN_SESSION_KEY,
|
||||||
const DEFAULT_MAIN_SESSION_KEY = "agent:main:main";
|
DEFAULT_WORKSPACE_ROOT,
|
||||||
const DEFAULT_WORKSPACE_ROOT = "~/.openclaw";
|
checkGatewayConnection,
|
||||||
|
type GatewayCheckStatus,
|
||||||
const validateGatewayUrl = (value: string) => {
|
validateGatewayUrl,
|
||||||
const trimmed = value.trim();
|
} from "@/lib/gateway-form";
|
||||||
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 default function NewGatewayPage() {
|
export default function NewGatewayPage() {
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
@@ -69,9 +52,8 @@ export default function NewGatewayPage() {
|
|||||||
const [workspaceRoot, setWorkspaceRoot] = useState(DEFAULT_WORKSPACE_ROOT);
|
const [workspaceRoot, setWorkspaceRoot] = useState(DEFAULT_WORKSPACE_ROOT);
|
||||||
|
|
||||||
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
|
const [gatewayUrlError, setGatewayUrlError] = useState<string | null>(null);
|
||||||
const [gatewayCheckStatus, setGatewayCheckStatus] = useState<
|
const [gatewayCheckStatus, setGatewayCheckStatus] =
|
||||||
"idle" | "checking" | "success" | "error"
|
useState<GatewayCheckStatus>("idle");
|
||||||
>("idle");
|
|
||||||
const [gatewayCheckMessage, setGatewayCheckMessage] = useState<string | null>(
|
const [gatewayCheckMessage, setGatewayCheckMessage] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -111,37 +93,13 @@ export default function NewGatewayPage() {
|
|||||||
if (!isSignedIn) return;
|
if (!isSignedIn) return;
|
||||||
setGatewayCheckStatus("checking");
|
setGatewayCheckStatus("checking");
|
||||||
setGatewayCheckMessage(null);
|
setGatewayCheckMessage(null);
|
||||||
try {
|
const { ok, message } = await checkGatewayConnection({
|
||||||
const params: Record<string, string> = {
|
gatewayUrl,
|
||||||
gateway_url: gatewayUrl.trim(),
|
gatewayToken,
|
||||||
};
|
mainSessionKey,
|
||||||
if (gatewayToken.trim()) {
|
});
|
||||||
params.gateway_token = gatewayToken.trim();
|
setGatewayCheckStatus(ok ? "success" : "error");
|
||||||
}
|
setGatewayCheckMessage(message);
|
||||||
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 handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
@@ -214,137 +172,45 @@ export default function NewGatewayPage() {
|
|||||||
Only organization owners and admins can create gateways.
|
Only organization owners and admins can create gateways.
|
||||||
</div>
|
</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}
|
onSubmit={handleSubmit}
|
||||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
onCancel={() => router.push("/gateways")}
|
||||||
>
|
onRunGatewayCheck={runGatewayCheck}
|
||||||
<div className="space-y-2">
|
onNameChange={setName}
|
||||||
<label className="text-sm font-medium text-slate-900">
|
onGatewayUrlChange={(next) => {
|
||||||
Gateway name <span className="text-red-500">*</span>
|
setGatewayUrl(next);
|
||||||
</label>
|
setGatewayUrlError(null);
|
||||||
<Input
|
setGatewayCheckStatus("idle");
|
||||||
value={name}
|
setGatewayCheckMessage(null);
|
||||||
onChange={(event) => setName(event.target.value)}
|
}}
|
||||||
placeholder="Primary gateway"
|
onGatewayTokenChange={(next) => {
|
||||||
disabled={isLoading}
|
setGatewayToken(next);
|
||||||
/>
|
setGatewayCheckStatus("idle");
|
||||||
</div>
|
setGatewayCheckMessage(null);
|
||||||
|
}}
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
onMainSessionKeyChange={(next) => {
|
||||||
<div className="space-y-2">
|
setMainSessionKey(next);
|
||||||
<label className="text-sm font-medium text-slate-900">
|
setGatewayCheckStatus("idle");
|
||||||
Gateway URL <span className="text-red-500">*</span>
|
setGatewayCheckMessage(null);
|
||||||
</label>
|
}}
|
||||||
<div className="relative">
|
onWorkspaceRootChange={setWorkspaceRoot}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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