| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import type { OtlpEndpointCheckResult, OtlpSignal } from "@t3tools/contracts"; |
| 3 | import { useEffect, useEffectEvent, useState } from "react"; |
| 4 | |
| 5 | import { serverEnvironment } from "../../state/server"; |
| 6 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 7 | import { Button } from "../ui/button"; |
| 8 | import { Input } from "../ui/input"; |
| 9 | import { Label } from "../ui/label"; |
| 10 | import { ConnectionStatusDot } from "../ConnectionStatusDot"; |
| 11 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 12 | import { useScopedSettings } from "./useScopedSettings"; |
| 13 | import { SettingsSection } from "./settingsLayout"; |
| 14 | import { SettingsScopeNotice } from "./SettingsScopeNotice"; |
| 15 | |
| 16 | const SIGNALS = [ |
| 17 | { key: "otlpTracesUrl", label: "Traces", signal: "traces" }, |
| 18 | { key: "otlpMetricsUrl", label: "Metrics", signal: "metrics" }, |
| 19 | { key: "otlpLogsUrl", label: "Logs", signal: "logs" }, |
| 20 | ] as const; |
| 21 | |
| 22 | type SignalKey = (typeof SIGNALS)[number]["key"]; |
| 23 | |
| 24 | /** The latest check for a signal; it describes the field only while `url` is still its value. */ |
| 25 | type EndpointCheck = { readonly url: string; readonly result: OtlpEndpointCheckResult | null }; |
| 26 | |
| 27 | const isHttpUrl = (url: string) => /^https?:\/\/./i.test(url); |
| 28 | |
| 29 | function endpointStatus(url: string, check: EndpointCheck | undefined) { |
| 30 | if (url === "") return { label: "Off", dot: "bg-muted-foreground/40" }; |
| 31 | if (check?.url !== url) return { label: "Not checked", dot: "bg-muted-foreground/40" }; |
| 32 | const { result } = check; |
| 33 | if (result === null) return { label: "Checking…", dot: "bg-warning" }; |
| 34 | switch (result._tag) { |
| 35 | case "Accepted": |
| 36 | return { label: `Connected · ${Math.round(result.latencyMs)} ms`, dot: "bg-success" }; |
| 37 | case "Rejected": |
| 38 | return { label: `Rejected · HTTP ${result.status}`, dot: "bg-destructive" }; |
| 39 | case "Unreachable": |
| 40 | return { label: result.timedOut ? "Timed out" : "Unreachable", dot: "bg-destructive" }; |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | export function TelemetryExportSettings() { |
| 45 | const { environment, scope } = useSettingsScope(); |
| 46 | if (scope.kind !== "environment" || !environment) { |
| 47 | return ( |
| 48 | <SettingsSection title="OpenTelemetry export" id="telemetry-export"> |
| 49 | <SettingsScopeNotice target="environment"> |
| 50 | Choose one environment to configure its telemetry exports. |
| 51 | </SettingsScopeNotice> |
| 52 | </SettingsSection> |
| 53 | ); |
| 54 | } |
| 55 | // Mounts per environment so its saved endpoints are the ones checked on open. |
| 56 | return <TelemetryExportForm key={environment.environmentId} />; |
| 57 | } |
| 58 | |
| 59 | function TelemetryExportForm() { |
| 60 | const { environment } = useSettingsScope(); |
| 61 | const saved = useScopedSettings((settings) => settings.observability); |
| 62 | const [draft, setDraft] = useState<Partial<typeof saved>>({}); |
| 63 | const [saving, setSaving] = useState(false); |
| 64 | const [message, setMessage] = useState<string | null>(null); |
| 65 | const environmentId = environment?.environmentId; |
| 66 | // Checks need the same grant as saving, so one permission covers the whole form. |
| 67 | const canEdit = useAtomValue( |
| 68 | serverEnvironment.checkOtlpEndpoint.permissionAtom(environmentId ?? null), |
| 69 | ); |
| 70 | // Servers from before endpoint checks keep the editor without Test. |
| 71 | const canCheck = |
| 72 | canEdit && environment?.serverConfig?.environment.capabilities.otlpEndpointCheck === true; |
| 73 | // Saved endpoints open as pending; the effect below sends their checks. |
| 74 | const [checks, setChecks] = useState<Partial<Record<SignalKey, EndpointCheck>>>(() => |
| 75 | Object.fromEntries( |
| 76 | SIGNALS.filter(({ key }) => isHttpUrl(saved[key])).map(({ key }) => [ |
| 77 | key, |
| 78 | { url: saved[key], result: null }, |
| 79 | ]), |
| 80 | ), |
| 81 | ); |
| 82 | const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: true }); |
| 83 | const checkOtlpEndpoint = useAtomCommand(serverEnvironment.checkOtlpEndpoint); |
| 84 | const values = { ...saved, ...draft }; |
| 85 | const changedSignals = SIGNALS.filter(({ key }) => values[key].trim() !== saved[key]); |
| 86 | const changed = changedSignals.length > 0; |
| 87 | const running = environment?.serverConfig?.observability; |
| 88 | |
| 89 | const sendCheck = async (key: SignalKey, signal: OtlpSignal, url: string) => { |
| 90 | if (!environmentId) return; |
| 91 | const response = await checkOtlpEndpoint({ environmentId, input: { signal, url } }); |
| 92 | // A newer check for the same field replaces this one. |
| 93 | setChecks((previous) => { |
| 94 | if (previous[key]?.url !== url || previous[key].result !== null) return previous; |
| 95 | const next = { ...previous }; |
| 96 | if (response._tag === "Success") next[key] = { url, result: response.value }; |
| 97 | else delete next[key]; |
| 98 | return next; |
| 99 | }); |
| 100 | }; |
| 101 | |
| 102 | const checkEndpoint = (key: SignalKey, signal: OtlpSignal, url: string) => { |
| 103 | if (!isHttpUrl(url)) return; |
| 104 | setChecks((previous) => ({ ...previous, [key]: { url, result: null } })); |
| 105 | void sendCheck(key, signal, url); |
| 106 | }; |
| 107 | |
| 108 | // Sends checks still pending, such as the saved endpoints' when the form opens. |
| 109 | const sendPendingChecks = useEffectEvent(() => { |
| 110 | for (const { key, signal } of SIGNALS) { |
| 111 | const check = checks[key]; |
| 112 | if (check?.result === null) void sendCheck(key, signal, check.url); |
| 113 | } |
| 114 | }); |
| 115 | useEffect(() => { |
| 116 | // oxlint-disable-next-line react/set-state-in-effect -- State changes only after the response arrives. |
| 117 | if (canCheck) sendPendingChecks(); |
| 118 | }, [canCheck]); |
| 119 | |
| 120 | return ( |
| 121 | <SettingsSection title="OpenTelemetry export" id="telemetry-export"> |
| 122 | <form |
| 123 | className="grid gap-4 px-4 py-4 sm:px-5" |
| 124 | onSubmit={async (event) => { |
| 125 | event.preventDefault(); |
| 126 | if (!environment || saving) return; |
| 127 | setSaving(true); |
| 128 | setMessage(null); |
| 129 | try { |
| 130 | const result = await updateSettings({ |
| 131 | environmentId: environment.environmentId, |
| 132 | input: { |
| 133 | patch: { |
| 134 | observability: Object.fromEntries( |
| 135 | changedSignals.map(({ key }) => [key, values[key]]), |
| 136 | ), |
| 137 | }, |
| 138 | }, |
| 139 | }); |
| 140 | if (result._tag === "Success") { |
| 141 | setDraft({}); |
| 142 | setMessage("Saved. Restart the server to apply."); |
| 143 | } |
| 144 | } finally { |
| 145 | setSaving(false); |
| 146 | } |
| 147 | }} |
| 148 | > |
| 149 | <p className="text-xs text-muted-foreground"> |
| 150 | Send traces, metrics, and logs to an OTLP HTTP receiver. Restart the server to apply. |
| 151 | Environment variables override these settings. |
| 152 | </p> |
| 153 | {!canEdit && ( |
| 154 | <p className="text-xs text-muted-foreground"> |
| 155 | This connection lacks permission to change settings on{" "} |
| 156 | {environment?.label ?? "the selected environment"}. |
| 157 | </p> |
| 158 | )} |
| 159 | {SIGNALS.map(({ key, label, signal }) => { |
| 160 | const url = values[key].trim(); |
| 161 | const status = canCheck ? endpointStatus(url, checks[key]) : null; |
| 162 | return ( |
| 163 | <div key={key} className="grid gap-1.5"> |
| 164 | <div className="flex items-center justify-between gap-3"> |
| 165 | <div className="flex items-center gap-1.5"> |
| 166 | {status && <ConnectionStatusDot dotClassName={status.dot} />} |
| 167 | <Label htmlFor={key}>{label} endpoint</Label> |
| 168 | </div> |
| 169 | {status && <span className="text-xs text-muted-foreground">{status.label}</span>} |
| 170 | </div> |
| 171 | <div className="flex gap-2"> |
| 172 | <Input |
| 173 | id={key} |
| 174 | size="sm" |
| 175 | type="url" |
| 176 | pattern="[Hh][Tt][Tt][Pp][Ss]?://.*" |
| 177 | title="Enter an HTTP or HTTPS endpoint, or leave empty to disable export." |
| 178 | placeholder={`http://localhost:4318/v1/${signal}`} |
| 179 | value={values[key]} |
| 180 | disabled={saving || !canEdit} |
| 181 | onChange={(event) => { |
| 182 | const value = event.target.value; |
| 183 | // Restoring the saved value drops the draft, so a later save can't resend it |
| 184 | // over another client's change. |
| 185 | setDraft((previous) => { |
| 186 | const next = { ...previous, [key]: value }; |
| 187 | if (value.trim() === saved[key]) delete next[key]; |
| 188 | return next; |
| 189 | }); |
| 190 | setMessage(null); |
| 191 | }} |
| 192 | /> |
| 193 | {canCheck && ( |
| 194 | <Button |
| 195 | type="button" |
| 196 | size="sm" |
| 197 | variant="outline" |
| 198 | disabled={!isHttpUrl(url) || checks[key]?.result === null} |
| 199 | onClick={() => void checkEndpoint(key, signal, url)} |
| 200 | > |
| 201 | Test |
| 202 | </Button> |
| 203 | )} |
| 204 | </div> |
| 205 | {saved[key] !== (running?.[key] ?? "") && ( |
| 206 | <p className="break-all text-xs text-muted-foreground"> |
| 207 | Running: {running?.[key] || "Disabled"} |
| 208 | </p> |
| 209 | )} |
| 210 | </div> |
| 211 | ); |
| 212 | })} |
| 213 | <div className="flex items-center justify-between gap-3"> |
| 214 | <p role="status" className="text-xs text-muted-foreground"> |
| 215 | {message} |
| 216 | </p> |
| 217 | <div className="flex shrink-0 gap-2"> |
| 218 | <Button |
| 219 | type="button" |
| 220 | size="xs" |
| 221 | variant="outline" |
| 222 | disabled={!changed || saving || !canEdit} |
| 223 | onClick={() => { |
| 224 | setDraft({}); |
| 225 | setMessage(null); |
| 226 | // A tested draft replaced the saved endpoint's status; check the saved one again. |
| 227 | if (!canCheck) return; |
| 228 | for (const { key, signal } of SIGNALS) { |
| 229 | if (checks[key]?.url !== saved[key]) checkEndpoint(key, signal, saved[key]); |
| 230 | } |
| 231 | }} |
| 232 | > |
| 233 | Discard |
| 234 | </Button> |
| 235 | <Button type="submit" size="xs" disabled={!changed || saving || !canEdit}> |
| 236 | {saving ? "Saving…" : "Save"} |
| 237 | </Button> |
| 238 | </div> |
| 239 | </div> |
| 240 | </form> |
| 241 | </SettingsSection> |
| 242 | ); |
| 243 | } |