diff --git a/frontend/src/app/dialogs/edit-runner-config.tsx b/frontend/src/app/dialogs/edit-runner-config.tsx index 14594d8953..f21b4f9a55 100644 --- a/frontend/src/app/dialogs/edit-runner-config.tsx +++ b/frontend/src/app/dialogs/edit-runner-config.tsx @@ -1,23 +1,44 @@ +import { faTriangleExclamation, Icon } from "@rivet-gg/icons"; import type { Rivet } from "@rivetkit/engine-api-full"; import { useMutation, useSuspenseInfiniteQuery, useSuspenseQuery, } from "@tanstack/react-query"; -import { useMemo, useState } from "react"; -import { useFormContext } from "react-hook-form"; +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { useFormContext, useFormState, useWatch } from "react-hook-form"; + +const SERVERLESS_FIELDS = [ + "url", + "headers", + "requestLifespan", + "maxRunners", + "minRunners", + "runnersMargin", + "slotsPerRunner", + "maxConcurrentActors", + "drainGracePeriod", + "autoUpgrade", +] as const; import * as EditRunnerConfigForm from "@/app/forms/edit-shared-runner-config-form"; import * as EditSingleRunnerConfigForm from "@/app/forms/edit-single-runner-config-form"; +import { + EndpointHealthCheckProvider, + useEndpointHealthChecksLoading, + useEndpointHealthChecksValid, +} from "@/app/forms/serverless-endpoint-health"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, + Button, Combobox, type DialogContentProps, Frame, ToggleGroup, ToggleGroupItem, + WithTooltip, } from "@/components"; import { ActorRegion, useEngineCompatDataProvider } from "@/components/actors"; import { queryClient } from "@/queries/global"; @@ -30,6 +51,8 @@ const defaultServerlessConfig: Rivet.RunnerConfigServerless = { headers: {}, }; +type RuntimeMode = "serverless" | "serverfull"; + function hasProtocolVersion( datacenters: Record, ): boolean { @@ -42,6 +65,24 @@ function dcHasProtocolVersion(dc: Rivet.RunnerConfigResponse): boolean { return dc.protocolVersion != null; } +function dcMode( + dc: Rivet.RunnerConfigResponse | undefined, +): RuntimeMode | undefined { + if (!dc) return undefined; + if (dc.serverless) return "serverless"; + // `normal` may be present as `{}` for serverfull configs. + if ((dc as { normal?: unknown }).normal !== undefined) return "serverfull"; + return undefined; +} + +function dcSignatureWithoutMetadata(dc: Rivet.RunnerConfigResponse): string { + const { metadata: _metadata, ...rest } = dc as + Rivet.RunnerConfigResponse & { + metadata?: unknown; + }; + return JSON.stringify(rest); +} + interface EditRunnerConfigFrameContentProps extends DialogContentProps { name: string; dc?: string; @@ -59,10 +100,10 @@ export default function EditRunnerConfigFrameContent({ }); const isSharedSettings = useMemo(() => { - const configs = Object.values(data.datacenters).map((dc) => - JSON.stringify(dc.serverless || {}), + const sigs = Object.values(data.datacenters).map( + dcSignatureWithoutMetadata, ); - return configs.every((config) => config === configs[0]); + return sigs.length === 0 || sigs.every((s) => s === sigs[0]); }, [data.datacenters]); const [settingsMode, setSettingsMode] = useState( @@ -84,20 +125,22 @@ export default function EditRunnerConfigFrameContent({ /> -
- {settingsMode === "shared" ? ( - <> -
- These settings will apply to all datacenters. -
- - - ) : null} - - {settingsMode === "datacenter" ? ( - - ) : null} -
+ +
+ {settingsMode === "shared" ? ( + <> +
+ These settings will apply to all datacenters. +
+ + + ) : null} + + {settingsMode === "datacenter" ? ( + + ) : null} +
+
); } @@ -135,6 +178,94 @@ function SharedSettingsToggleGroup({ ); } +function ResetOnModeChange({ pathPrefix }: { pathPrefix?: string }) { + const form = useFormContext(); + const modeName = pathPrefix ? `${pathPrefix}.mode` : "mode"; + const mode = useWatch({ name: modeName }) as RuntimeMode | undefined; + const prevMode = useRef(undefined); + useEffect(() => { + if (prevMode.current === undefined) { + prevMode.current = mode; + return; + } + if (prevMode.current === mode) return; + for (const field of SERVERLESS_FIELDS) { + const path = pathPrefix ? `${pathPrefix}.${field}` : field; + // biome-ignore lint/suspicious/noExplicitAny: dynamic field path + form.resetField(path as any); + } + prevMode.current = mode; + }, [mode, form, pathPrefix]); + return null; +} + +function WhenMode({ + name = "mode", + value, + children, +}: { + name?: string; + value: RuntimeMode; + children: ReactNode; +}) { + const mode = (useWatch({ name }) as RuntimeMode | undefined) ?? "serverless"; + if (mode !== value) return null; + return <>{children}; +} + +function fallbackMetadata( + datacenters: Record, +): unknown | undefined { + for (const dc of Object.values(datacenters)) { + const meta = (dc as { metadata?: unknown }).metadata; + if (meta) return meta; + } + return undefined; +} + +interface ModeSwitch { + regionId: string; + from: RuntimeMode; + to: RuntimeMode; +} + +function describeSwitches(switches: ModeSwitch[]): string { + const grouped = switches.reduce>((acc, s) => { + const key = `${labelForMode(s.from)} → ${labelForMode(s.to)}`; + (acc[key] = acc[key] || []).push(s.regionId); + return acc; + }, {}); + return Object.entries(grouped) + .map( + ([transition, regions]) => + `${transition} for ${regions.join(", ")}`, + ) + .join("; "); +} + +function labelForMode(mode: RuntimeMode): string { + return mode === "serverless" ? "Serverless" : "Runners"; +} + +function ServerfullModeNotice() { + return ( +
+ This is a serverfull (Runners) configuration. Runners connect to + Rivet directly using the runner SDK. No additional configuration is + required here.{" "} + + Learn more + + . +
+ ); +} + function SharedSettingsForm({ onClose, name, @@ -157,46 +288,90 @@ function SharedSettingsForm({ const isNewConfig = hasProtocolVersion(data.datacenters); - const currentDcConfig = Object.values(data.datacenters).find((dc) => !!dc.serverless); - const currentServerless = currentDcConfig?.serverless ?? defaultServerlessConfig; + const currentDcConfig = + Object.values(data.datacenters).find((dc) => !!dc.serverless) ?? + Object.values(data.datacenters).find( + (dc) => (dc as { normal?: unknown }).normal !== undefined, + ); + const currentServerless = + currentDcConfig?.serverless ?? defaultServerlessConfig; + + const detectedMode: RuntimeMode = useMemo(() => { + const modes = Object.values(data.datacenters) + .map(dcMode) + .filter((m): m is RuntimeMode => !!m); + return modes[0] ?? "serverless"; + }, [data.datacenters]); return ( { - const serverless: Rivet.RunnerConfigServerless = isNewConfig - ? { - url: values.url, - requestLifespan: values.requestLifespan, - headers: Object.fromEntries(values.headers || []), - maxRunners: 0, - slotsPerRunner: 0, - maxConcurrentActors: values.maxConcurrentActors, - drainGracePeriod: values.drainGracePeriod, - } - : { - url: values.url, - requestLifespan: values.requestLifespan, - headers: Object.fromEntries(values.headers || []), - maxRunners: values.maxRunners ?? 100_000, - minRunners: values.minRunners ?? 0, - runnersMargin: values.runnersMargin ?? 0, - slotsPerRunner: values.slotsPerRunner ?? 1, - }; - - const config = { - ...(currentDcConfig || {}), - serverless, - ...(isNewConfig ? { drainOnVersionUpgrade: autoUpgrade } : {}), - }; - - const providerConfig: Record = {}; - + onSubmit={async ({ + mode, + regions, + autoUpgrade, + ...values + }) => { const selectedRegions = regions || {}; + const sharedFallback = fallbackMetadata(data.datacenters); + const providerConfig: Record = {}; + for (const [regionId, isSelected] of Object.entries( selectedRegions, )) { - if (isSelected) { - providerConfig[regionId] = config; + if (!isSelected) continue; + const existing = data.datacenters[regionId] || {}; + const metadata = + (existing as { metadata?: unknown }).metadata ?? + sharedFallback; + if (mode === "serverless") { + const serverless: Rivet.RunnerConfigServerless = + isNewConfig + ? { + url: values.url ?? "", + requestLifespan: + values.requestLifespan ?? 300, + headers: Object.fromEntries( + values.headers || [], + ), + maxRunners: 0, + slotsPerRunner: 1, + maxConcurrentActors: + values.maxConcurrentActors, + drainGracePeriod: values.drainGracePeriod, + } + : { + url: values.url ?? "", + requestLifespan: + values.requestLifespan ?? 300, + headers: Object.fromEntries( + values.headers || [], + ), + maxRunners: values.maxRunners ?? 100_000, + minRunners: values.minRunners ?? 0, + runnersMargin: values.runnersMargin ?? 0, + slotsPerRunner: + values.slotsPerRunner ?? 1, + }; + const { normal: _drop, ...existingRest } = existing as Rivet.RunnerConfig & { + normal?: unknown; + }; + providerConfig[regionId] = { + ...existingRest, + ...(metadata ? { metadata } : {}), + serverless, + ...(isNewConfig + ? { drainOnVersionUpgrade: autoUpgrade } + : {}), + } as Rivet.RunnerConfig; + } else { + const { serverless: _drop, ...existingRest } = existing as Rivet.RunnerConfig & { + serverless?: unknown; + }; + providerConfig[regionId] = { + ...existingRest, + ...(metadata ? { metadata } : {}), + normal: {}, + } as Rivet.RunnerConfig; } } @@ -214,11 +389,12 @@ function SharedSettingsForm({ onClose?.(); }} defaultValues={{ + mode: detectedMode, url: currentServerless.url, requestLifespan: currentServerless.requestLifespan, - headers: Object.entries( - currentServerless.headers || {}, - ).map(([key, value]) => [key, value]), + headers: Object.entries(currentServerless.headers || {}).map( + ([key, value]) => [key, value], + ), regions: Object.fromEntries( Object.keys(data.datacenters).map((dcId) => [ dcId, @@ -228,10 +404,13 @@ function SharedSettingsForm({ ...(isNewConfig ? { // eslint-disable-next-line @typescript-eslint/no-explicit-any - maxConcurrentActors: (currentServerless as any).maxConcurrentActors, + maxConcurrentActors: (currentServerless as any) + .maxConcurrentActors, // eslint-disable-next-line @typescript-eslint/no-explicit-any - drainGracePeriod: (currentServerless as any).drainGracePeriod, - autoUpgrade: currentDcConfig?.drainOnVersionUpgrade ?? false, + drainGracePeriod: (currentServerless as any) + .drainGracePeriod, + autoUpgrade: + currentDcConfig?.drainOnVersionUpgrade ?? false, } : { maxRunners: currentServerless.maxRunners, @@ -241,35 +420,42 @@ function SharedSettingsForm({ }), }} > - - {isNewConfig ? ( - <> -
- - -
- - - - ) : ( - <> -
- - -
-
- - -
- - - )} - + + + + + {isNewConfig ? ( + <> +
+ + +
+ + + + ) : ( + <> +
+ + +
+
+ + +
+ + + )} + +
+ + +
- - Save - +
); @@ -304,64 +490,91 @@ function DatacenterSettingsForm({ [ - dc.name, - { - ...(data.datacenters[dc.name]?.serverless || - defaultServerlessConfig), - enable: !!data.datacenters[dc.name]?.serverless, - headers: Object.entries( - data.datacenters[dc.name]?.serverless - ?.headers || {}, - ).map(([key, value]) => [key, value]), - autoUpgrade: data.datacenters[dc.name]?.drainOnVersionUpgrade ?? false, - }, - ]), + Object.values(datacenters).map((dc) => { + const existing = data.datacenters[dc.name]; + const detectedMode: RuntimeMode = + dcMode(existing) ?? "serverless"; + return [ + dc.name, + { + ...(existing?.serverless || + defaultServerlessConfig), + mode: detectedMode, + enable: + !!existing?.serverless || + (existing as { normal?: unknown }) + ?.normal !== undefined, + headers: Object.entries( + existing?.serverless?.headers || {}, + ).map(([key, value]) => [key, value]), + autoUpgrade: + existing?.drainOnVersionUpgrade ?? false, + }, + ]; + }), ), }} onSubmit={async (values) => { - const providerConfig: Record< - string, - { serverless: Rivet.RunnerConfigServerless } - > = {}; + const sharedFallback = fallbackMetadata(data.datacenters); + const providerConfig: Record = {}; for (const [dcId, dcConfig] of Object.entries( values.datacenters || {}, )) { - if (dcConfig?.enable) { - const { - enable, - headers, - autoUpgrade, - ...rest - } = dcConfig; - const isNew = dcHasProtocolVersion( - data.datacenters[dcId] || {}, - ); + if (!dcConfig?.enable) continue; + const existing = data.datacenters[dcId] || {}; + const metadata = + (existing as { metadata?: unknown }).metadata ?? + sharedFallback; + const { + enable, + mode, + headers, + autoUpgrade, + ...rest + } = dcConfig; + if (mode === "serverless" || !mode) { + const isNew = dcHasProtocolVersion(existing); const serverless: Rivet.RunnerConfigServerless = isNew ? { - url: rest.url, - requestLifespan: rest.requestLifespan, + url: rest.url ?? "", + requestLifespan: rest.requestLifespan ?? 300, headers: Object.fromEntries(headers || []), maxRunners: 0, - slotsPerRunner: 0, - maxConcurrentActors: rest.maxConcurrentActors, + slotsPerRunner: 1, + maxConcurrentActors: + rest.maxConcurrentActors, drainGracePeriod: rest.drainGracePeriod, } : { - url: rest.url, - requestLifespan: rest.requestLifespan, + url: rest.url ?? "", + requestLifespan: rest.requestLifespan ?? 300, headers: Object.fromEntries(headers || []), maxRunners: rest.maxRunners ?? 100_000, minRunners: rest.minRunners ?? 0, runnersMargin: rest.runnersMargin ?? 0, slotsPerRunner: rest.slotsPerRunner ?? 1, }; + const { normal: _drop, ...existingRest } = existing as Rivet.RunnerConfig & { + normal?: unknown; + }; providerConfig[dcId] = { - ...(data.datacenters[dcId] || {}), + ...existingRest, + ...(metadata ? { metadata } : {}), serverless, - ...(isNew ? { drainOnVersionUpgrade: autoUpgrade } : {}), + ...(isNew + ? { drainOnVersionUpgrade: autoUpgrade } + : {}), + } as Rivet.RunnerConfig; + } else { + const { serverless: _drop, ...existingRest } = existing as Rivet.RunnerConfig & { + serverless?: unknown; }; + providerConfig[dcId] = { + ...existingRest, + ...(metadata ? { metadata } : {}), + normal: {}, + } as Rivet.RunnerConfig; } } @@ -393,9 +606,9 @@ function DatacenterSettingsForm({
- - Save - +
); @@ -428,57 +641,263 @@ function DatacenterAccordion({ currentRegionId={regionId} /> - - {isNew ? ( - <> -
- - -
- - - - ) : ( - <> -
- - + + + + {isNew ? ( + <> +
+ + +
+ -
-
- - + ) : ( + <> +
+ + +
+
+ + +
+ -
- - - )} - + + )} + + + + + ); } +function ConfirmableSaveButton({ + blocked, + blockedReason, + computeSwitches, +}: { + blocked: boolean; + blockedReason: string | null; + computeSwitches: () => ModeSwitch[]; +}) { + const form = useFormContext(); + const { isSubmitting, isValidating } = useFormState(); + const [pending, setPending] = useState(null); + const hiddenSubmitRef = useRef(null); + + const allValues = useWatch(); + useEffect(() => { + setPending(null); + }, [allValues]); + + const onSaveClick = async (e: React.MouseEvent) => { + e.preventDefault(); + const valid = await form.trigger(); + if (!valid) return; + const switches = computeSwitches(); + if (switches.length === 0) { + hiddenSubmitRef.current?.click(); + } else { + setPending(switches); + } + }; + + const onConfirmClick = (e: React.MouseEvent) => { + e.preventDefault(); + setPending(null); + hiddenSubmitRef.current?.click(); + }; + + const onCancelClick = () => setPending(null); + + const saveButton = ( + + ); + + return ( + <> + + + + + ) : blocked && blockedReason ? ( + {saveButton}} + content={blockedReason} + /> + ) : ( + saveButton + )} + + ); +} + +function SharedSettingsSubmit({ + dataDatacenters, +}: { + dataDatacenters: Record; +}) { + const valid = useEndpointHealthChecksValid(); + const loading = useEndpointHealthChecksLoading(); + const blocked = !valid || loading; + const blockedReason = !valid + ? "Endpoint is not reachable. Fix the endpoint to enable saving." + : loading + ? "Verifying endpoint connection..." + : null; + const form = useFormContext(); + + const computeSwitches = (): ModeSwitch[] => { + const values = form.getValues() as { + mode?: RuntimeMode; + regions?: Record; + }; + const mode: RuntimeMode = values.mode ?? "serverless"; + const switches: ModeSwitch[] = []; + for (const [regionId, isSelected] of Object.entries( + values.regions || {}, + )) { + if (!isSelected) continue; + const prev = dcMode(dataDatacenters[regionId]); + if (prev && prev !== mode) { + switches.push({ regionId, from: prev, to: mode }); + } + } + return switches; + }; + + return ( + + ); +} + +function DatacenterSettingsSubmit({ + dataDatacenters, +}: { + dataDatacenters: Record; +}) { + const valid = useEndpointHealthChecksValid(); + const loading = useEndpointHealthChecksLoading(); + const blocked = !valid || loading; + const blockedReason = !valid + ? "One or more endpoints are not reachable. Fix the endpoints to enable saving." + : loading + ? "Verifying endpoint connection..." + : null; + const form = useFormContext(); + + const computeSwitches = (): ModeSwitch[] => { + const values = form.getValues() as { + datacenters?: Record< + string, + { enable?: boolean; mode?: RuntimeMode } | undefined + >; + }; + const switches: ModeSwitch[] = []; + for (const [dcId, dcConfig] of Object.entries( + values.datacenters || {}, + )) { + if (!dcConfig?.enable) continue; + const submittedMode: RuntimeMode = dcConfig.mode ?? "serverless"; + const prev = dcMode(dataDatacenters[dcId]); + if (prev && prev !== submittedMode) { + switches.push({ regionId: dcId, from: prev, to: submittedMode }); + } + } + return switches; + }; + + return ( + + ); +} + function SelectDatacenterSettingsSource({ currentRegionId, name, diff --git a/frontend/src/app/forms/edit-shared-runner-config-form.tsx b/frontend/src/app/forms/edit-shared-runner-config-form.tsx index 0566592a6e..7e2d0c5be2 100644 --- a/frontend/src/app/forms/edit-shared-runner-config-form.tsx +++ b/frontend/src/app/forms/edit-shared-runner-config-form.tsx @@ -27,12 +27,18 @@ import { } from "@/components"; import { ActorRegion, useEngineCompatDataProvider } from "@/components/actors"; import { VisibilitySensor } from "@/components/visibility-sensor"; +import { EndpointHealthIndicator } from "@/app/forms/serverless-endpoint-health"; +import { RunnerConfigToggleGroup } from "@/app/runner-config-toggle-group"; -export const formSchema = z.object({ - url: z.string().url(), +export const runtimeModeSchema = z.enum(["serverless", "serverfull"]); +export type RuntimeMode = z.infer; + +export const baseFormSchema = z.object({ + mode: runtimeModeSchema.default("serverless"), + url: z.string().optional().default(""), maxRunners: z.coerce.number().positive().optional(), minRunners: z.coerce.number().min(0).optional(), - requestLifespan: z.coerce.number().positive(), + requestLifespan: z.coerce.number().positive().optional(), runnersMargin: z.coerce.number().min(0).optional(), slotsPerRunner: z.coerce.number().positive().optional(), maxConcurrentActors: z.coerce.number().positive().optional(), @@ -47,6 +53,26 @@ export const formSchema = z.object({ }, "At least one region must be selected."), }); +export function validateRuntimeModeFields( + data: { mode?: RuntimeMode; url?: string }, + ctx: z.RefinementCtx, + pathPrefix: (string | number)[] = [], +) { + if (data.mode === "serverless") { + if (!data.url || !z.string().url().safeParse(data.url).success) { + ctx.addIssue({ + path: [...pathPrefix, "url"], + code: z.ZodIssueCode.custom, + message: "Please enter a valid URL.", + }); + } + } +} + +export const formSchema = baseFormSchema.superRefine((data, ctx) => { + validateRuntimeModeFields(data, ctx); +}); + export type FormValues = z.infer; export type SubmitHandler = ( values: FormValues, @@ -56,11 +82,38 @@ export type SubmitHandler = ( const { Form, Submit, SetValue } = createSchemaForm(formSchema); export { Form, Submit, SetValue }; +export const Mode = = FormValues>({ + name = "mode" as FieldPath, + className, +}: { + name?: FieldPath; + className?: string; +}) => { + const { control } = useFormContext(); + return ( + ( + + )} + /> + ); +}; + export const Url = = FormValues>({ name = "url" as FieldPath, + headersName, + enabledName, className, }: { name?: FieldPath; + headersName?: string; + enabledName?: string; className?: string; }) => { const { control } = useFormContext(); @@ -72,10 +125,18 @@ export const Url = = FormValues>({ Endpoint - +
+ + +
@@ -396,10 +457,16 @@ export const MaxConcurrentActors = < Max Concurrent Actors - + Maximum actors allowed to run concurrently per runner. + Leave blank for unlimited. @@ -425,14 +492,19 @@ export const DrainGracePeriod = < render={({ field }) => ( - Drain Grace Period (s) + Drain Grace Period - + - Time to wait for actors to finish before forcefully - stopping. + Time (in seconds) to wait for actors to finish before + forcefully stopping. diff --git a/frontend/src/app/forms/edit-single-runner-config-form.tsx b/frontend/src/app/forms/edit-single-runner-config-form.tsx index afcd3ae88c..aa3d196d8d 100644 --- a/frontend/src/app/forms/edit-single-runner-config-form.tsx +++ b/frontend/src/app/forms/edit-single-runner-config-form.tsx @@ -12,22 +12,32 @@ import { } from "@/components"; import * as SingleRunnerConfigForm from "./edit-shared-runner-config-form"; -export const formSchema = z.object({ - datacenters: z - .record( - z.string(), - SingleRunnerConfigForm.formSchema - .omit({ regions: true }) - .and(z.object({ enable: z.boolean().optional() })) - .optional(), - ) - .optional() - .refine((obj) => { - return Object.values(obj || {}).some( - (dcConfig) => dcConfig?.enable, - ); - }, "At least one datacenter must be enabled."), -}); +const dcEntrySchema = SingleRunnerConfigForm.baseFormSchema + .omit({ regions: true }) + .extend({ enable: z.boolean().optional() }); + +export const formSchema = z + .object({ + datacenters: z + .record(z.string(), dcEntrySchema.optional()) + .optional() + .refine((obj) => { + return Object.values(obj || {}).some( + (dcConfig) => dcConfig?.enable, + ); + }, "At least one datacenter must be enabled."), + }) + .superRefine((values, ctx) => { + for (const [regionId, dcConfig] of Object.entries( + values.datacenters || {}, + )) { + if (!dcConfig?.enable) continue; + SingleRunnerConfigForm.validateRuntimeModeFields(dcConfig, ctx, [ + "datacenters", + regionId, + ]); + } + }); export type FormValues = z.infer; export type SubmitHandler = ( @@ -73,6 +83,7 @@ export const Datacenters = () => { ); }; +export const Mode = SingleRunnerConfigForm.Mode; export const Url = SingleRunnerConfigForm.Url; export const MaxRunners = SingleRunnerConfigForm.MaxRunners; export const MinRunners = SingleRunnerConfigForm.MinRunners; diff --git a/frontend/src/app/forms/serverless-endpoint-health.tsx b/frontend/src/app/forms/serverless-endpoint-health.tsx new file mode 100644 index 0000000000..9ba062dd63 --- /dev/null +++ b/frontend/src/app/forms/serverless-endpoint-health.tsx @@ -0,0 +1,215 @@ +import { + faCheck, + faSpinnerThird, + faTriangleExclamation, + Icon, +} from "@rivet-gg/icons"; +import { useQuery } from "@tanstack/react-query"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useState, +} from "react"; +import { useWatch } from "react-hook-form"; +import { useDebounceValue } from "usehooks-ts"; +import z from "zod"; +import { WithTooltip } from "@/components"; +import { useEngineCompatDataProvider } from "@/components/actors"; +import { endpointSchema } from "@/app/serverless-connection-check"; + +type Status = "idle" | "loading" | "success" | "error"; + +interface ContextValue { + statuses: Record; + setStatus: (id: string, status: Status) => void; +} + +const EndpointHealthCheckContext = createContext(null); + +export function EndpointHealthCheckProvider({ + children, +}: { + children: ReactNode; +}) { + const [statuses, setStatuses] = useState>({}); + const setStatus = useCallback((id: string, status: Status) => { + setStatuses((prev) => { + if (status === "idle") { + if (!(id in prev)) return prev; + const { [id]: _, ...rest } = prev; + return rest; + } + if (prev[id] === status) return prev; + return { ...prev, [id]: status }; + }); + }, []); + const value = useMemo(() => ({ statuses, setStatus }), [statuses, setStatus]); + return ( + + {children} + + ); +} + +export function useEndpointHealthChecksValid() { + const ctx = useContext(EndpointHealthCheckContext); + if (!ctx) return true; + return Object.values(ctx.statuses).every((s) => s === "success"); +} + +export function useEndpointHealthChecksLoading() { + const ctx = useContext(EndpointHealthCheckContext); + if (!ctx) return false; + return Object.values(ctx.statuses).some((s) => s === "loading"); +} + +interface EndpointHealthIndicatorProps { + endpointName: string; + headersName?: string; + enabledName?: string; + pollIntervalMs?: number; +} + +export function EndpointHealthIndicator({ + endpointName, + headersName, + enabledName, + pollIntervalMs = 5_000, +}: EndpointHealthIndicatorProps) { + const id = useId(); + const setStatus = useContext(EndpointHealthCheckContext)?.setStatus; + const dataProvider = useEngineCompatDataProvider(); + + const endpointRaw = useWatch({ name: endpointName }) as string | undefined; + const headersRaw = useWatch({ name: headersName ?? "" }) as + | [string, string][] + | undefined; + const fieldEnabled = useWatch({ name: enabledName ?? "" }) as + | boolean + | undefined; + const isFieldActive = enabledName ? fieldEnabled !== false : true; + + const endpoint = endpointRaw ?? ""; + const parsed = endpointSchema.safeParse(endpoint); + const enabled = isFieldActive && Boolean(endpoint) && parsed.success; + + const [debouncedEndpoint] = useDebounceValue( + parsed.success ? parsed.data : "", + 500, + ); + const [debouncedHeaders] = useDebounceValue(headersRaw, 500); + + const { data, isLoading, isError, error } = useQuery({ + ...dataProvider.runnerHealthCheckQueryOptions({ + runnerUrl: debouncedEndpoint, + headers: Object.fromEntries( + (debouncedHeaders || []) + .filter(([k, v]) => k && v) + .map(([k, v]) => [k, v]), + ), + }), + enabled, + retry: 0, + refetchInterval: pollIntervalMs, + }); + + const isSuccess = !!(data && "success" in data && data.success); + const isFailure = + !!(data && "failure" in data && data.failure) || isError; + const status: Status = !enabled + ? "idle" + : isLoading + ? "loading" + : isSuccess + ? "success" + : isFailure + ? "error" + : "loading"; + + useEffect(() => { + setStatus?.(id, status); + return () => setStatus?.(id, "idle"); + }, [setStatus, id, status]); + + if (!enabled) return null; + + if (status === "loading") { + return ( +
+ +
+ ); + } + + if (status === "success") { + return ( +
+ +
+ ); + } + + if (status === "error") { + return ( +
+ + + + } + content={extractErrorMessage(data, error)} + /> +
+ ); + } + + return null; +} + +const failureSchema = z.object({ + failure: z.object({ + error: z.object({ + message: z.string().optional(), + details: z.string().optional(), + metadata: z + .object({ + kind: z.string().optional(), + status_code: z.number().optional(), + }) + .partial() + .optional(), + }), + }), +}); + +function extractErrorMessage(data: unknown, error: unknown): string { + const fallback = "Health check failed. Verify the endpoint is reachable."; + const parsed = failureSchema.safeParse(data); + if (parsed.success) { + const { message, details, metadata } = parsed.data.failure.error; + if (message) { + return details ? `${message} (${details})` : message; + } + if (metadata?.kind && metadata.status_code) { + return `${metadata.kind.replace(/_/g, " ")} (HTTP ${metadata.status_code})`; + } + } + if (error instanceof Error && error.message) { + return error.message.slice(0, 200); + } + return fallback; +}