Merge branch 'master' into copilot/feature-allow-self-signed-tls
# Conflicts: # backend/app/api/gateways.py # backend/app/schemas/gateways.py # backend/app/services/openclaw/admin_service.py # backend/app/services/openclaw/gateway_resolver.py # backend/app/services/openclaw/gateway_rpc.py # backend/app/services/openclaw/provisioning.py # backend/app/services/openclaw/provisioning_db.py # frontend/src/api/generated/model/gatewayCreate.ts # frontend/src/api/generated/model/gatewayRead.ts # frontend/src/api/generated/model/gatewayUpdate.ts
This commit is contained in:
@@ -22,7 +22,7 @@ import type {
|
||||
|
||||
import type {
|
||||
AgentCreate,
|
||||
AgentHeartbeatCreate,
|
||||
AgentHealthStatusResponse,
|
||||
AgentNudge,
|
||||
AgentRead,
|
||||
ApprovalCreate,
|
||||
@@ -67,6 +67,192 @@ import { customFetch } from "../../mutator";
|
||||
|
||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||
|
||||
/**
|
||||
* Token-authenticated liveness probe for agent API clients.
|
||||
|
||||
Use this endpoint when the caller needs to verify both service availability and agent-token validity in one request.
|
||||
* @summary Agent Auth Health Check
|
||||
*/
|
||||
export type agentHealthzApiV1AgentHealthzGetResponse200 = {
|
||||
data: AgentHealthStatusResponse;
|
||||
status: 200;
|
||||
};
|
||||
|
||||
export type agentHealthzApiV1AgentHealthzGetResponse422 = {
|
||||
data: HTTPValidationError;
|
||||
status: 422;
|
||||
};
|
||||
|
||||
export type agentHealthzApiV1AgentHealthzGetResponseSuccess =
|
||||
agentHealthzApiV1AgentHealthzGetResponse200 & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type agentHealthzApiV1AgentHealthzGetResponseError =
|
||||
agentHealthzApiV1AgentHealthzGetResponse422 & {
|
||||
headers: Headers;
|
||||
};
|
||||
|
||||
export type agentHealthzApiV1AgentHealthzGetResponse =
|
||||
| agentHealthzApiV1AgentHealthzGetResponseSuccess
|
||||
| agentHealthzApiV1AgentHealthzGetResponseError;
|
||||
|
||||
export const getAgentHealthzApiV1AgentHealthzGetUrl = () => {
|
||||
return `/api/v1/agent/healthz`;
|
||||
};
|
||||
|
||||
export const agentHealthzApiV1AgentHealthzGet = async (
|
||||
options?: RequestInit,
|
||||
): Promise<agentHealthzApiV1AgentHealthzGetResponse> => {
|
||||
return customFetch<agentHealthzApiV1AgentHealthzGetResponse>(
|
||||
getAgentHealthzApiV1AgentHealthzGetUrl(),
|
||||
{
|
||||
...options,
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getAgentHealthzApiV1AgentHealthzGetQueryKey = () => {
|
||||
return [`/api/v1/agent/healthz`] as const;
|
||||
};
|
||||
|
||||
export const getAgentHealthzApiV1AgentHealthzGetQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getAgentHealthzApiV1AgentHealthzGetQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>
|
||||
> = ({ signal }) =>
|
||||
agentHealthzApiV1AgentHealthzGet({ signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
};
|
||||
|
||||
export type AgentHealthzApiV1AgentHealthzGetQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>
|
||||
>;
|
||||
export type AgentHealthzApiV1AgentHealthzGetQueryError = HTTPValidationError;
|
||||
|
||||
export function useAgentHealthzApiV1AgentHealthzGet<
|
||||
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
options: {
|
||||
query: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
> &
|
||||
Pick<
|
||||
DefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>
|
||||
>,
|
||||
"initialData"
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): DefinedUseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
export function useAgentHealthzApiV1AgentHealthzGet<
|
||||
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
> &
|
||||
Pick<
|
||||
UndefinedInitialDataOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>
|
||||
>,
|
||||
"initialData"
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
export function useAgentHealthzApiV1AgentHealthzGet<
|
||||
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
};
|
||||
/**
|
||||
* @summary Agent Auth Health Check
|
||||
*/
|
||||
|
||||
export function useAgentHealthzApiV1AgentHealthzGet<
|
||||
TData = Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError = HTTPValidationError,
|
||||
>(
|
||||
options?: {
|
||||
query?: Partial<
|
||||
UseQueryOptions<
|
||||
Awaited<ReturnType<typeof agentHealthzApiV1AgentHealthzGet>>,
|
||||
TError,
|
||||
TData
|
||||
>
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
},
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<TData, TError> & {
|
||||
queryKey: DataTag<QueryKey, TData, TError>;
|
||||
} {
|
||||
const queryOptions = getAgentHealthzApiV1AgentHealthzGetQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
|
||||
TData,
|
||||
TError
|
||||
> & { queryKey: DataTag<QueryKey, TData, TError> };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return boards the authenticated agent can access.
|
||||
|
||||
@@ -3326,9 +3512,9 @@ export const useAgentLeadNudgeAgent = <
|
||||
);
|
||||
};
|
||||
/**
|
||||
* Record liveness for the authenticated agent's current status.
|
||||
* Record liveness for the authenticated agent.
|
||||
|
||||
Use this when the agent heartbeat loop reports status changes.
|
||||
Use this when the agent heartbeat loop checks in.
|
||||
* @summary Upsert agent heartbeat
|
||||
*/
|
||||
export type agentHeartbeatApiV1AgentHeartbeatPostResponse200 = {
|
||||
@@ -3359,7 +3545,6 @@ export const getAgentHeartbeatApiV1AgentHeartbeatPostUrl = () => {
|
||||
};
|
||||
|
||||
export const agentHeartbeatApiV1AgentHeartbeatPost = async (
|
||||
agentHeartbeatCreate: AgentHeartbeatCreate,
|
||||
options?: RequestInit,
|
||||
): Promise<agentHeartbeatApiV1AgentHeartbeatPostResponse> => {
|
||||
return customFetch<agentHeartbeatApiV1AgentHeartbeatPostResponse>(
|
||||
@@ -3367,8 +3552,6 @@ export const agentHeartbeatApiV1AgentHeartbeatPost = async (
|
||||
{
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(agentHeartbeatCreate),
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -3380,14 +3563,14 @@ export const getAgentHeartbeatApiV1AgentHeartbeatPostMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
|
||||
TError,
|
||||
{ data: AgentHeartbeatCreate },
|
||||
void,
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
|
||||
TError,
|
||||
{ data: AgentHeartbeatCreate },
|
||||
void,
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["agentHeartbeatApiV1AgentHeartbeatPost"];
|
||||
@@ -3401,11 +3584,9 @@ export const getAgentHeartbeatApiV1AgentHeartbeatPostMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
|
||||
{ data: AgentHeartbeatCreate }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return agentHeartbeatApiV1AgentHeartbeatPost(data, requestOptions);
|
||||
void
|
||||
> = () => {
|
||||
return agentHeartbeatApiV1AgentHeartbeatPost(requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
@@ -3414,8 +3595,7 @@ export const getAgentHeartbeatApiV1AgentHeartbeatPostMutationOptions = <
|
||||
export type AgentHeartbeatApiV1AgentHeartbeatPostMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>
|
||||
>;
|
||||
export type AgentHeartbeatApiV1AgentHeartbeatPostMutationBody =
|
||||
AgentHeartbeatCreate;
|
||||
|
||||
export type AgentHeartbeatApiV1AgentHeartbeatPostMutationError =
|
||||
HTTPValidationError;
|
||||
|
||||
@@ -3430,7 +3610,7 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
|
||||
TError,
|
||||
{ data: AgentHeartbeatCreate },
|
||||
void,
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
@@ -3439,7 +3619,7 @@ export const useAgentHeartbeatApiV1AgentHeartbeatPost = <
|
||||
): UseMutationResult<
|
||||
Awaited<ReturnType<typeof agentHeartbeatApiV1AgentHeartbeatPost>>,
|
||||
TError,
|
||||
{ data: AgentHeartbeatCreate },
|
||||
void,
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Generated by orval v8.3.0 🍺
|
||||
* Do not edit manually.
|
||||
* Mission Control API
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Agent-authenticated liveness payload for agent route probes.
|
||||
*/
|
||||
export interface AgentHealthStatusResponse {
|
||||
/** Indicates whether the probe check succeeded. */
|
||||
ok: boolean;
|
||||
/** Authenticated agent id derived from `X-Agent-Token`. */
|
||||
agent_id: string;
|
||||
/** Board scope for the authenticated agent, when applicable. */
|
||||
board_id?: string | null;
|
||||
/** Gateway owning the authenticated agent. */
|
||||
gateway_id: string;
|
||||
/** Current persisted lifecycle status for the authenticated agent. */
|
||||
status: string;
|
||||
/** Whether the authenticated agent is the board lead. */
|
||||
is_board_lead: boolean;
|
||||
}
|
||||
@@ -13,5 +13,6 @@ export interface GatewayCreate {
|
||||
url: string;
|
||||
workspace_root: string;
|
||||
allow_insecure_tls?: boolean;
|
||||
disable_device_pairing?: boolean;
|
||||
token?: string | null;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface GatewayRead {
|
||||
url: string;
|
||||
workspace_root: string;
|
||||
allow_insecure_tls: boolean;
|
||||
disable_device_pairing?: boolean;
|
||||
id: string;
|
||||
organization_id: string;
|
||||
token?: string | null;
|
||||
|
||||
@@ -14,4 +14,5 @@ export interface GatewayUpdate {
|
||||
token?: string | null;
|
||||
workspace_root?: string | null;
|
||||
allow_insecure_tls?: boolean | null;
|
||||
disable_device_pairing?: boolean | null;
|
||||
}
|
||||
|
||||
@@ -9,4 +9,5 @@ export type GatewaysStatusApiV1GatewaysStatusGetParams = {
|
||||
board_id?: string | null;
|
||||
gateway_url?: string | null;
|
||||
gateway_token?: string | null;
|
||||
gateway_disable_device_pairing?: boolean;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from "./activityTaskCommentFeedItemRead";
|
||||
export * from "./agentCreate";
|
||||
export * from "./agentCreateHeartbeatConfig";
|
||||
export * from "./agentCreateIdentityProfile";
|
||||
export * from "./agentHealthStatusResponse";
|
||||
export * from "./agentHeartbeat";
|
||||
export * from "./agentHeartbeatCreate";
|
||||
export * from "./agentNudge";
|
||||
|
||||
@@ -40,6 +40,9 @@ export default function EditGatewayPage() {
|
||||
const [gatewayToken, setGatewayToken] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [disableDevicePairing, setDisableDevicePairing] = useState<
|
||||
boolean | undefined
|
||||
>(undefined);
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -85,40 +88,25 @@ export default function EditGatewayPage() {
|
||||
const resolvedName = name ?? loadedGateway?.name ?? "";
|
||||
const resolvedGatewayUrl = gatewayUrl ?? loadedGateway?.url ?? "";
|
||||
const resolvedGatewayToken = gatewayToken ?? loadedGateway?.token ?? "";
|
||||
const resolvedDisableDevicePairing =
|
||||
disableDevicePairing ?? loadedGateway?.disable_device_pairing ?? false;
|
||||
const resolvedWorkspaceRoot =
|
||||
workspaceRoot ?? loadedGateway?.workspace_root ?? DEFAULT_WORKSPACE_ROOT;
|
||||
const resolvedAllowInsecureTls =
|
||||
allowInsecureTls ?? loadedGateway?.allow_insecure_tls ?? false;
|
||||
|
||||
const isLoading = gatewayQuery.isLoading || updateMutation.isPending;
|
||||
const isLoading =
|
||||
gatewayQuery.isLoading ||
|
||||
updateMutation.isPending ||
|
||||
gatewayCheckStatus === "checking";
|
||||
const errorMessage = error ?? gatewayQuery.error?.message ?? null;
|
||||
|
||||
const canSubmit =
|
||||
Boolean(resolvedName.trim()) &&
|
||||
Boolean(resolvedGatewayUrl.trim()) &&
|
||||
Boolean(resolvedWorkspaceRoot.trim()) &&
|
||||
gatewayCheckStatus === "success";
|
||||
Boolean(resolvedWorkspaceRoot.trim());
|
||||
|
||||
const runGatewayCheck = async () => {
|
||||
const validationError = validateGatewayUrl(resolvedGatewayUrl);
|
||||
setGatewayUrlError(validationError);
|
||||
if (validationError) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(validationError);
|
||||
return;
|
||||
}
|
||||
if (!isSignedIn) return;
|
||||
setGatewayCheckStatus("checking");
|
||||
setGatewayCheckMessage(null);
|
||||
const { ok, message } = await checkGatewayConnection({
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
gatewayToken: resolvedGatewayToken,
|
||||
});
|
||||
setGatewayCheckStatus(ok ? "success" : "error");
|
||||
setGatewayCheckMessage(message);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isSignedIn || !gatewayId) return;
|
||||
|
||||
@@ -138,12 +126,26 @@ export default function EditGatewayPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setGatewayCheckStatus("checking");
|
||||
setGatewayCheckMessage(null);
|
||||
const { ok, message } = await checkGatewayConnection({
|
||||
gatewayUrl: resolvedGatewayUrl,
|
||||
gatewayToken: resolvedGatewayToken,
|
||||
gatewayDisableDevicePairing: resolvedDisableDevicePairing,
|
||||
});
|
||||
setGatewayCheckStatus(ok ? "success" : "error");
|
||||
setGatewayCheckMessage(message);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
const payload: GatewayUpdate = {
|
||||
name: resolvedName.trim(),
|
||||
url: resolvedGatewayUrl.trim(),
|
||||
token: resolvedGatewayToken.trim() || null,
|
||||
disable_device_pairing: resolvedDisableDevicePairing,
|
||||
workspace_root: resolvedWorkspaceRoot.trim(),
|
||||
allow_insecure_tls: resolvedAllowInsecureTls,
|
||||
};
|
||||
@@ -170,6 +172,7 @@ export default function EditGatewayPage() {
|
||||
name={resolvedName}
|
||||
gatewayUrl={resolvedGatewayUrl}
|
||||
gatewayToken={resolvedGatewayToken}
|
||||
disableDevicePairing={resolvedDisableDevicePairing}
|
||||
workspaceRoot={resolvedWorkspaceRoot}
|
||||
allowInsecureTls={resolvedAllowInsecureTls}
|
||||
gatewayUrlError={gatewayUrlError}
|
||||
@@ -184,7 +187,6 @@ export default function EditGatewayPage() {
|
||||
submitBusyLabel="Saving…"
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => router.push("/gateways")}
|
||||
onRunGatewayCheck={runGatewayCheck}
|
||||
onNameChange={setName}
|
||||
onGatewayUrlChange={(next) => {
|
||||
setGatewayUrl(next);
|
||||
@@ -197,6 +199,11 @@ export default function EditGatewayPage() {
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
onDisableDevicePairingChange={(next) => {
|
||||
setDisableDevicePairing(next);
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
onWorkspaceRootChange={setWorkspaceRoot}
|
||||
onAllowInsecureTlsChange={setAllowInsecureTls}
|
||||
/>
|
||||
|
||||
@@ -115,6 +115,7 @@ export default function GatewayDetailPage() {
|
||||
? {
|
||||
gateway_url: gateway.url,
|
||||
gateway_token: gateway.token ?? undefined,
|
||||
gateway_disable_device_pairing: gateway.disable_device_pairing,
|
||||
}
|
||||
: {};
|
||||
|
||||
@@ -232,6 +233,14 @@ export default function GatewayDetailPage() {
|
||||
{maskToken(gateway.token)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-slate-400">
|
||||
Device pairing
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||
{gateway.disable_device_pairing ? "Disabled" : "Required"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function NewGatewayPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [gatewayUrl, setGatewayUrl] = useState("");
|
||||
const [gatewayToken, setGatewayToken] = useState("");
|
||||
const [disableDevicePairing, setDisableDevicePairing] = useState(false);
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState(DEFAULT_WORKSPACE_ROOT);
|
||||
const [allowInsecureTls, setAllowInsecureTls] = useState(false);
|
||||
|
||||
@@ -53,34 +54,15 @@ export default function NewGatewayPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = createMutation.isPending;
|
||||
const isLoading =
|
||||
createMutation.isPending || gatewayCheckStatus === "checking";
|
||||
|
||||
const canSubmit =
|
||||
Boolean(name.trim()) &&
|
||||
Boolean(gatewayUrl.trim()) &&
|
||||
Boolean(workspaceRoot.trim()) &&
|
||||
gatewayCheckStatus === "success";
|
||||
Boolean(workspaceRoot.trim());
|
||||
|
||||
const runGatewayCheck = async () => {
|
||||
const validationError = validateGatewayUrl(gatewayUrl);
|
||||
setGatewayUrlError(validationError);
|
||||
if (validationError) {
|
||||
setGatewayCheckStatus("error");
|
||||
setGatewayCheckMessage(validationError);
|
||||
return;
|
||||
}
|
||||
if (!isSignedIn) return;
|
||||
setGatewayCheckStatus("checking");
|
||||
setGatewayCheckMessage(null);
|
||||
const { ok, message } = await checkGatewayConnection({
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
});
|
||||
setGatewayCheckStatus(ok ? "success" : "error");
|
||||
setGatewayCheckMessage(message);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!isSignedIn) return;
|
||||
|
||||
@@ -100,12 +82,26 @@ export default function NewGatewayPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setGatewayCheckStatus("checking");
|
||||
setGatewayCheckMessage(null);
|
||||
const { ok, message } = await checkGatewayConnection({
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
gatewayDisableDevicePairing: disableDevicePairing,
|
||||
});
|
||||
setGatewayCheckStatus(ok ? "success" : "error");
|
||||
setGatewayCheckMessage(message);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
createMutation.mutate({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
url: gatewayUrl.trim(),
|
||||
token: gatewayToken.trim() || null,
|
||||
disable_device_pairing: disableDevicePairing,
|
||||
workspace_root: workspaceRoot.trim(),
|
||||
allow_insecure_tls: allowInsecureTls,
|
||||
},
|
||||
@@ -127,6 +123,7 @@ export default function NewGatewayPage() {
|
||||
name={name}
|
||||
gatewayUrl={gatewayUrl}
|
||||
gatewayToken={gatewayToken}
|
||||
disableDevicePairing={disableDevicePairing}
|
||||
workspaceRoot={workspaceRoot}
|
||||
allowInsecureTls={allowInsecureTls}
|
||||
gatewayUrlError={gatewayUrlError}
|
||||
@@ -141,7 +138,6 @@ export default function NewGatewayPage() {
|
||||
submitBusyLabel="Creating…"
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => router.push("/gateways")}
|
||||
onRunGatewayCheck={runGatewayCheck}
|
||||
onNameChange={setName}
|
||||
onGatewayUrlChange={(next) => {
|
||||
setGatewayUrl(next);
|
||||
@@ -154,6 +150,11 @@ export default function NewGatewayPage() {
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
onDisableDevicePairingChange={(next) => {
|
||||
setDisableDevicePairing(next);
|
||||
setGatewayCheckStatus("idle");
|
||||
setGatewayCheckMessage(null);
|
||||
}}
|
||||
onWorkspaceRootChange={setWorkspaceRoot}
|
||||
onAllowInsecureTlsChange={setAllowInsecureTls}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -21,9 +27,7 @@ vi.mock("@/components/ui/dialog", () => ({
|
||||
DialogFooter: ({ children }: { children?: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children?: ReactNode }) => (
|
||||
<h2>{children}</h2>
|
||||
),
|
||||
DialogTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/generated/board-onboarding/board-onboarding", () => ({
|
||||
@@ -31,10 +35,12 @@ vi.mock("@/api/generated/board-onboarding/board-onboarding", () => ({
|
||||
startOnboardingMock(...args),
|
||||
getOnboardingApiV1BoardsBoardIdOnboardingGet: (...args: unknown[]) =>
|
||||
getOnboardingMock(...args),
|
||||
answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost: (...args: unknown[]) =>
|
||||
answerOnboardingMock(...args),
|
||||
confirmOnboardingApiV1BoardsBoardIdOnboardingConfirmPost: (...args: unknown[]) =>
|
||||
confirmOnboardingMock(...args),
|
||||
answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost: (
|
||||
...args: unknown[]
|
||||
) => answerOnboardingMock(...args),
|
||||
confirmOnboardingApiV1BoardsBoardIdOnboardingConfirmPost: (
|
||||
...args: unknown[]
|
||||
) => confirmOnboardingMock(...args),
|
||||
}));
|
||||
|
||||
const buildQuestionSession = (question: string): BoardOnboardingRead => ({
|
||||
@@ -116,7 +122,9 @@ describe("BoardOnboardingChat polling", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getOnboardingMock.mock.calls.length).toBeGreaterThan(callsBeforePoll);
|
||||
expect(getOnboardingMock.mock.calls.length).toBeGreaterThan(
|
||||
callsBeforePoll,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -9,6 +8,7 @@ type GatewayFormProps = {
|
||||
name: string;
|
||||
gatewayUrl: string;
|
||||
gatewayToken: string;
|
||||
disableDevicePairing: boolean;
|
||||
workspaceRoot: string;
|
||||
allowInsecureTls: boolean;
|
||||
gatewayUrlError: string | null;
|
||||
@@ -23,10 +23,10 @@ type GatewayFormProps = {
|
||||
submitBusyLabel: string;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onCancel: () => void;
|
||||
onRunGatewayCheck: () => Promise<void>;
|
||||
onNameChange: (next: string) => void;
|
||||
onGatewayUrlChange: (next: string) => void;
|
||||
onGatewayTokenChange: (next: string) => void;
|
||||
onDisableDevicePairingChange: (next: boolean) => void;
|
||||
onWorkspaceRootChange: (next: string) => void;
|
||||
onAllowInsecureTlsChange: (next: boolean) => void;
|
||||
};
|
||||
@@ -35,6 +35,7 @@ export function GatewayForm({
|
||||
name,
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
disableDevicePairing,
|
||||
workspaceRoot,
|
||||
allowInsecureTls,
|
||||
gatewayUrlError,
|
||||
@@ -49,10 +50,10 @@ export function GatewayForm({
|
||||
submitBusyLabel,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onRunGatewayCheck,
|
||||
onNameChange,
|
||||
onGatewayUrlChange,
|
||||
onGatewayTokenChange,
|
||||
onDisableDevicePairingChange,
|
||||
onWorkspaceRootChange,
|
||||
onAllowInsecureTlsChange,
|
||||
}: GatewayFormProps) {
|
||||
@@ -82,40 +83,15 @@ export function GatewayForm({
|
||||
<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>
|
||||
) : gatewayCheckStatus === "error" && gatewayCheckMessage ? (
|
||||
<p className="text-xs text-red-500">{gatewayCheckMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -125,23 +101,53 @@ export function GatewayForm({
|
||||
<Input
|
||||
value={gatewayToken}
|
||||
onChange={(event) => onGatewayTokenChange(event.target.value)}
|
||||
onBlur={onRunGatewayCheck}
|
||||
placeholder="Bearer token"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</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 className="grid gap-6 md:grid-cols-2">
|
||||
<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 className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-900">
|
||||
Disable device pairing
|
||||
</label>
|
||||
<label className="flex h-10 items-center gap-3 px-1 text-sm text-slate-900">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={disableDevicePairing}
|
||||
aria-label="Disable device pairing"
|
||||
onClick={() =>
|
||||
onDisableDevicePairingChange(!disableDevicePairing)
|
||||
}
|
||||
disabled={isLoading}
|
||||
className={`inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
|
||||
disableDevicePairing
|
||||
? "border-emerald-600 bg-emerald-600"
|
||||
: "border-slate-300 bg-slate-200"
|
||||
} ${isLoading ? "cursor-not-allowed opacity-60" : "cursor-pointer"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
|
||||
disableDevicePairing ? "translate-x-5" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
69
frontend/src/lib/gateway-form.test.ts
Normal file
69
frontend/src/lib/gateway-form.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { gatewaysStatusApiV1GatewaysStatusGet } from "@/api/generated/gateways/gateways";
|
||||
|
||||
import { checkGatewayConnection, validateGatewayUrl } from "./gateway-form";
|
||||
|
||||
vi.mock("@/api/generated/gateways/gateways", () => ({
|
||||
gatewaysStatusApiV1GatewaysStatusGet: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedGatewaysStatusApiV1GatewaysStatusGet = vi.mocked(
|
||||
gatewaysStatusApiV1GatewaysStatusGet,
|
||||
);
|
||||
|
||||
describe("validateGatewayUrl", () => {
|
||||
it("requires ws/wss with an explicit port", () => {
|
||||
expect(validateGatewayUrl("https://gateway.example")).toBe(
|
||||
"Gateway URL must start with ws:// or wss://.",
|
||||
);
|
||||
expect(validateGatewayUrl("ws://gateway.example")).toBe(
|
||||
"Gateway URL must include an explicit port.",
|
||||
);
|
||||
expect(validateGatewayUrl("ws://gateway.example:18789")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkGatewayConnection", () => {
|
||||
beforeEach(() => {
|
||||
mockedGatewaysStatusApiV1GatewaysStatusGet.mockReset();
|
||||
});
|
||||
|
||||
it("passes pairing toggle to gateway status API", async () => {
|
||||
mockedGatewaysStatusApiV1GatewaysStatusGet.mockResolvedValue({
|
||||
status: 200,
|
||||
data: { connected: true },
|
||||
} as never);
|
||||
|
||||
const result = await checkGatewayConnection({
|
||||
gatewayUrl: "ws://gateway.example:18789",
|
||||
gatewayToken: "secret-token",
|
||||
gatewayDisableDevicePairing: true,
|
||||
});
|
||||
|
||||
expect(mockedGatewaysStatusApiV1GatewaysStatusGet).toHaveBeenCalledWith({
|
||||
gateway_url: "ws://gateway.example:18789",
|
||||
gateway_token: "secret-token",
|
||||
gateway_disable_device_pairing: true,
|
||||
});
|
||||
expect(result).toEqual({ ok: true, message: "Gateway reachable." });
|
||||
});
|
||||
|
||||
it("returns gateway-provided error message when offline", async () => {
|
||||
mockedGatewaysStatusApiV1GatewaysStatusGet.mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
connected: false,
|
||||
error: "missing required scope",
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await checkGatewayConnection({
|
||||
gatewayUrl: "ws://gateway.example:18789",
|
||||
gatewayToken: "",
|
||||
gatewayDisableDevicePairing: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: false, message: "missing required scope" });
|
||||
});
|
||||
});
|
||||
@@ -24,10 +24,16 @@ export const validateGatewayUrl = (value: string) => {
|
||||
export async function checkGatewayConnection(params: {
|
||||
gatewayUrl: string;
|
||||
gatewayToken: string;
|
||||
gatewayDisableDevicePairing: boolean;
|
||||
}): Promise<{ ok: boolean; message: string }> {
|
||||
try {
|
||||
const requestParams: Record<string, string> = {
|
||||
const requestParams: {
|
||||
gateway_url: string;
|
||||
gateway_token?: string;
|
||||
gateway_disable_device_pairing: boolean;
|
||||
} = {
|
||||
gateway_url: params.gatewayUrl.trim(),
|
||||
gateway_disable_device_pairing: params.gatewayDisableDevicePairing,
|
||||
};
|
||||
if (params.gatewayToken.trim()) {
|
||||
requestParams.gateway_token = params.gatewayToken.trim();
|
||||
|
||||
Reference in New Issue
Block a user