import { useEffect, useMemo, useState } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; import { rankResponseSample, type AuthTemplateSlug, type HealthCheckCandidate, type HealthCheckResult, type HealthCheckSpec, type IntegrationSlug, type Owner, } from "@executor-js/sdk/shared"; import { toast } from "sonner"; import { integrationHealthCheckAtom, integrationHealthCheckCandidatesAtom, setIntegrationHealthCheck, validateConnection, } from "../api/atoms"; import { healthCheckWriteKeys } from "../api/reactivity-keys"; import { messageFromExit } from "../api/error-reporting"; import { HEALTH_STATUS_LABEL, HEALTH_TEXT_CLASS } from "../lib/health-display"; import { Button } from "./button"; import { FreeformCombobox, type FreeformComboboxOption } from "./combobox"; import { Input } from "./input"; import { Label } from "./label"; import { NativeSelect, NativeSelectOption } from "./native-select"; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, } from "./sheet"; // --------------------------------------------------------------------------- // Health-check editor: the integration-level configuration for "what single // authenticated call tells us a connection is still alive and (optionally) whose // account it is". It is the same shape as configuring an auth method: pick one of // the integration's operations (ranked so the obvious read-only identity endpoint // floats up), optionally pin the required arguments (Google's People API needs // `resourceName=people/me`), and optionally name a response field to show as the // account identity. // // Identity is a facet, not the point: with no identity field the check degrades // to a pure "alive / expired" probe. The typed identity picker and the live // preview (a real probe against a pasted test key, showing the response) exist so // the user can SEE what the operation returns and pick the right field. // // The check is run per-connection elsewhere (the AccountRow "Check now" probe and // the key-first connect validation); this surface only declares it. // // It hides itself when the owning integration exposes no candidate operations AND // none is configured (i.e. the plugin has no health-check capability), so it // costs nothing on integrations that can't support it. // --------------------------------------------------------------------------- /** Context the edit sheet needs to run a live preview: which owner to probe as * and the credential auth-templates a pasted test key can be validated against. * Absent (or empty) on surfaces with no persisted integration yet (the add * screen), where the preview is suppressed. */ export interface HealthCheckLivePreview { readonly owner: Owner; readonly templates: ReadonlyArray<{ readonly template: AuthTemplateSlug; readonly label: string; }>; } /** "GET /users/me" style label for a candidate, with a writes marker so a * mutating operation picked as a health check reads as the hazard it is. */ const candidateLabel = (candidate: HealthCheckCandidate): string => { const head = `${candidate.method.toUpperCase()} ${candidate.operation}`; return candidate.destructive ? `${head} (writes)` : head; }; /** The summary line for the configured spec: the operation, prefixed with its * method when we can still find it among the candidates. */ const specSummary = ( spec: HealthCheckSpec, candidates: readonly HealthCheckCandidate[], ): string => { const match = candidates.find((c) => c.operation === spec.operation); return match ? `${match.method.toUpperCase()} ${spec.operation}` : spec.operation; }; // --------------------------------------------------------------------------- // HealthCheckConfigFields: the presentational operation + identity + pinned-arg // form, shared by the edit sheet and the add-integration screen so the two stay // in lockstep. State (and the `selected` candidate) is owned by the parent; this // component only renders and reports edits. // --------------------------------------------------------------------------- function HealthCheckConfigFields(props: { readonly candidates: readonly HealthCheckCandidate[]; readonly selected: HealthCheckCandidate | null; readonly operation: string; readonly onOperationChange: (operation: string) => void; readonly identityField: string; readonly onIdentityFieldChange: (path: string) => void; readonly args: Record; readonly onArgChange: (name: string, value: string) => void; readonly disabled?: boolean; readonly idPrefix: string; }) { const { candidates, selected, operation, identityField, args, disabled, idPrefix } = props; const operationOptions = useMemo( () => candidates.map((c) => ({ value: c.operation, label: candidateLabel(c), description: c.summary, })), [candidates], ); // The identity picker is a typed combobox over the operation's response fields, // with a leading "None" that clears the field (pure health check). It stays // freeform so a custom dot-path the projector missed is still reachable. const identityOptions = useMemo( () => [ { value: "", label: "None - health check only", description: "Status only, no account identity", }, ...(selected?.responseFields ?? []).map((f) => ({ value: f.path, label: f.path, description: f.type, })), ], [selected], ); const requiredParams = useMemo( () => (selected?.parameters ?? []).filter((p) => p.required), [selected], ); return ( <>
{selected?.summary ? (

{selected.summary}

) : null} {selected?.destructive ? (

This operation writes data. Prefer a read-only (GET) operation for a health check.

) : null}
{requiredParams.length > 0 ? (

Required arguments

Pinned into every probe. An identity endpoint often needs a fixed value here (for example resourceName ={" "} people/me).

{requiredParams.map((param) => (
) => props.onArgChange(param.name, e.target.value) } placeholder={param.description ?? `Value for ${param.name}`} disabled={disabled} /> {param.description ? (

{param.description}

) : null}
))}
) : null}

Optional. Pick a response field whose value labels the connected account ( user.login,{" "} emailAddresses.0.value). Leave as{" "} None for a pure health check. Numeric segments index arrays.

); } // --------------------------------------------------------------------------- // Live preview: runs the drafted check against a pasted test key WITHOUT // saving it (the same `validateConnection` probe the key-first connect uses), // then shows the real response so the user can confirm the status and see which // field carries the identity. Edit-sheet only: it needs a persisted integration // and a credential template, neither of which exists on the add screen. // --------------------------------------------------------------------------- function HealthCheckLivePreviewBlock(props: { readonly integration: IntegrationSlug; readonly preview: HealthCheckLivePreview; readonly operation: string; readonly identityField: string; readonly args: Record; readonly disabled: boolean; }) { const { integration, preview, operation, identityField, args, disabled } = props; const doValidate = useAtomSet(validateConnection, { mode: "promiseExit" }); const [value, setValue] = useState(""); const [template, setTemplate] = useState(preview.templates[0]?.template ?? ""); const [running, setRunning] = useState(false); const [result, setResult] = useState(null); const handlePreview = async () => { const credential = value.trim(); if (operation.length === 0 || credential.length === 0) return; const slug = (template || preview.templates[0]?.template) as AuthTemplateSlug | undefined; if (slug === undefined) return; const identity = identityField.trim(); const argEntries = Object.entries(args) .map(([key, v]) => [key, v.trim()] as const) .filter(([, v]) => v.length > 0); const spec: HealthCheckSpec = { operation, ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), ...(identity.length > 0 ? { identityField: identity } : {}), }; setRunning(true); const exit = await doValidate({ payload: { owner: preview.owner, integration, template: slug, value: credential, spec }, }); setRunning(false); if (Exit.isFailure(exit)) { setResult(null); toast.error(messageFromExit(exit, "Couldn't run the preview")); return; } setResult(exit.value); }; return (

Live preview

Paste a test credential to run this check now. Nothing is saved; the response below is what the operation returns.

{preview.templates.length > 1 ? (
) => setTemplate(e.target.value)} > {preview.templates.map((t) => ( {t.label} ))}
) : null}
) => setValue(e.target.value)} placeholder="Paste a key to probe" disabled={disabled || running} className="flex-1" />
{result ? (

Status:{" "} {HEALTH_STATUS_LABEL[result.status]} {result.httpStatus !== undefined ? ( (HTTP {result.httpStatus}) ) : null}

{identityField.trim().length > 0 ? (

Resolves to:{" "} {result.identity ? ( {result.identity} ) : ( no value at this field )}

) : null} {result.detail ?

{result.detail}

: null} {result.responseSample && result.responseSample.length > 0 ? (

Response

{rankResponseSample(result.responseSample).map((row) => (
{row.path}
{row.value}
))}
) : null}
) : null}
); } function HealthCheckEditorSheet(props: { readonly integration: IntegrationSlug; readonly spec: HealthCheckSpec | null; readonly candidates: readonly HealthCheckCandidate[]; readonly livePreview?: HealthCheckLivePreview; readonly open: boolean; readonly onOpenChange: (open: boolean) => void; }) { const { integration, spec, candidates, livePreview, open, onOpenChange } = props; const doSet = useAtomSet(setIntegrationHealthCheck, { mode: "promiseExit" }); const [operation, setOperation] = useState(""); const [identityField, setIdentityField] = useState(""); // Pinned argument values keyed by parameter name; stored as strings (the form // input value) and trimmed/dropped when empty at save. const [args, setArgs] = useState>({}); const [saving, setSaving] = useState(false); // Re-seed the drafts from the persisted spec each time the sheet opens. useEffect(() => { if (!open) return; setOperation(spec?.operation ?? candidates[0]?.operation ?? ""); setIdentityField(spec?.identityField ?? ""); setArgs( spec?.args ? Object.fromEntries( Object.entries(spec.args).map(([key, value]) => [ key, value == null ? "" : String(value), ]), ) : {}, ); }, [open, spec, candidates]); const selected = useMemo( () => candidates.find((c) => c.operation === operation) ?? null, [candidates, operation], ); const requiredParams = useMemo( () => (selected?.parameters ?? []).filter((p) => p.required), [selected], ); const missingRequired = requiredParams.some((p) => (args[p.name] ?? "").trim().length === 0); const onOperationChange = (next: string) => { setOperation(next); // Parameters and the response shape differ per operation; drop the prior // pick's pinned args and identity path so neither dangles onto a new op. setArgs({}); setIdentityField(""); }; const onArgChange = (name: string, value: string) => setArgs((prev) => ({ ...prev, [name]: value })); const handleSave = async () => { if (operation.length === 0) return; setSaving(true); const identity = identityField.trim(); const argEntries = Object.entries(args) .map(([key, value]) => [key, value.trim()] as const) .filter(([, value]) => value.length > 0); const nextSpec: HealthCheckSpec = { operation, ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), ...(identity.length > 0 ? { identityField: identity } : {}), }; const exit = await doSet({ params: { slug: integration }, payload: { spec: nextSpec }, reactivityKeys: healthCheckWriteKeys, }); setSaving(false); if (Exit.isFailure(exit)) { toast.error(messageFromExit(exit, "Failed to save health check")); return; } toast.success("Health check saved"); onOpenChange(false); }; const handleClear = async () => { setSaving(true); const exit = await doSet({ params: { slug: integration }, payload: { spec: null }, reactivityKeys: healthCheckWriteKeys, }); setSaving(false); if (Exit.isFailure(exit)) { toast.error(messageFromExit(exit, "Failed to clear health check")); return; } toast.success("Health check cleared"); onOpenChange(false); }; const canPreview = livePreview !== undefined && livePreview.templates.length > 0; return ( // Non-modal: a modal sheet's `react-remove-scroll` locks the wheel to the // sheet subtree, so the operation combobox's portaled popup can't scroll. Health check Optional read-only depth for live upstream checks. OAuth validity and identity come from the grant itself. For non-OAuth methods, a 401 or 403 marks the connection expired.
{canPreview ? ( ) : null}
{spec ? ( ) : null}
); } export function HealthCheckEditor(props: { readonly integration: IntegrationSlug; readonly livePreview?: HealthCheckLivePreview; }) { const { integration, livePreview } = props; const specResult = useAtomValue(integrationHealthCheckAtom(integration)); const candidatesResult = useAtomValue(integrationHealthCheckCandidatesAtom(integration)); const [open, setOpen] = useState(false); const spec = AsyncResult.isSuccess(specResult) ? specResult.value : null; const candidates: readonly HealthCheckCandidate[] = AsyncResult.isSuccess(candidatesResult) ? candidatesResult.value : []; // No capability (no candidate operations) and nothing configured: render // nothing. This also covers the still-loading window, so the section doesn't // flash an empty shell before the candidates arrive. if (candidates.length === 0 && spec === null) return null; return (

Health check

Optional read-only depth for live upstream checks. OAuth identity comes from the account grant, while probe identity fields are useful for non-OAuth methods.

{spec ? ( <>

{specSummary(spec, candidates)}

{spec.identityField ? (

Identity: {spec.identityField}

) : (

Health check only (no identity).

)} ) : (

No health check configured.

)}
); } // Re-exported so the add-integration screen can compose the same operation + // identity form without the sheet/live-preview chrome. export { HealthCheckConfigFields };