1import { useAtomValue } from "@effect/atom-react";
2import type { OtlpEndpointCheckResult, OtlpSignal } from "@t3tools/contracts";
3import { useEffect, useEffectEvent, useState } from "react";
4
5import { serverEnvironment } from "../../state/server";
6import { useAtomCommand } from "../../state/use-atom-command";
7import { Button } from "../ui/button";
8import { Input } from "../ui/input";
9import { Label } from "../ui/label";
10import { ConnectionStatusDot } from "../ConnectionStatusDot";
11import { useSettingsScope } from "./SettingsScopeContext";
12import { useScopedSettings } from "./useScopedSettings";
13import { SettingsSection } from "./settingsLayout";
14import { SettingsScopeNotice } from "./SettingsScopeNotice";
15
16const 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
22type SignalKey = (typeof SIGNALS)[number]["key"];
23
24/** The latest check for a signal; it describes the field only while `url` is still its value. */
25type EndpointCheck = { readonly url: string; readonly result: OtlpEndpointCheckResult | null };
26
27const isHttpUrl = (url: string) => /^https?:\/\/./i.test(url);
28
29function 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
44export 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
59function 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}