diff --git a/apps/web/src/components/settings/DiagnosticsSettings.logic.test.ts b/apps/web/src/components/settings/DiagnosticsSettings.logic.test.ts new file mode 100644 index 00000000000..f336f037225 --- /dev/null +++ b/apps/web/src/components/settings/DiagnosticsSettings.logic.test.ts @@ -0,0 +1,189 @@ +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; + +import { + addPendingProcessSignal, + diagnosticsConnectionNotice, + pendingProcessSignalPids, + removePendingProcessSignal, + resolveDiagnosticsEnvironmentId, + type PendingProcessSignal, +} from "./DiagnosticsSettings.logic"; + +const PRIMARY = EnvironmentId.make("primary"); +const ACTIVE = EnvironmentId.make("active"); +const SELECTED = EnvironmentId.make("selected"); + +describe("resolveDiagnosticsEnvironmentId", () => { + it("preserves an explicit available selection", () => { + assert.equal( + resolveDiagnosticsEnvironmentId({ + selectedEnvironmentId: SELECTED, + primaryEnvironmentId: PRIMARY, + activeEnvironmentId: ACTIVE, + availableEnvironmentIds: [PRIMARY, ACTIVE, SELECTED], + }), + SELECTED, + ); + }); + + it("defaults to the primary environment when one is available", () => { + assert.equal( + resolveDiagnosticsEnvironmentId({ + selectedEnvironmentId: null, + primaryEnvironmentId: PRIMARY, + activeEnvironmentId: ACTIVE, + availableEnvironmentIds: [ACTIVE, PRIMARY], + }), + PRIMARY, + ); + }); + + it("uses the active environment when there is no primary environment", () => { + assert.equal( + resolveDiagnosticsEnvironmentId({ + selectedEnvironmentId: null, + primaryEnvironmentId: null, + activeEnvironmentId: ACTIVE, + availableEnvironmentIds: [SELECTED, ACTIVE], + }), + ACTIVE, + ); + }); + + it("falls back to the first available environment", () => { + assert.equal( + resolveDiagnosticsEnvironmentId({ + selectedEnvironmentId: null, + primaryEnvironmentId: null, + activeEnvironmentId: null, + availableEnvironmentIds: [SELECTED, ACTIVE], + }), + SELECTED, + ); + }); + + it("recovers when the explicit selection is no longer available", () => { + assert.equal( + resolveDiagnosticsEnvironmentId({ + selectedEnvironmentId: SELECTED, + primaryEnvironmentId: PRIMARY, + activeEnvironmentId: ACTIVE, + availableEnvironmentIds: [PRIMARY, ACTIVE], + }), + PRIMARY, + ); + }); + + it("returns null when no environments are available", () => { + assert.isNull( + resolveDiagnosticsEnvironmentId({ + selectedEnvironmentId: SELECTED, + primaryEnvironmentId: PRIMARY, + activeEnvironmentId: ACTIVE, + availableEnvironmentIds: [], + }), + ); + }); +}); + +describe("diagnosticsConnectionNotice", () => { + it("returns null while the environment is connected", () => { + assert.isNull( + diagnosticsConnectionNotice({ phase: "connected", label: "Laptop", error: null }), + ); + }); + + it("explains that an offline environment cannot report diagnostics", () => { + assert.equal( + diagnosticsConnectionNotice({ phase: "offline", label: "Laptop", error: null }), + "Laptop is offline. Diagnostics load once it reconnects.", + ); + }); + + it("explains that a never-connected environment cannot report diagnostics", () => { + assert.equal( + diagnosticsConnectionNotice({ phase: "available", label: "Laptop", error: null }), + "Laptop is not connected. Diagnostics load once it connects.", + ); + }); + + it("includes the failure reason while reconnecting", () => { + assert.equal( + diagnosticsConnectionNotice({ + phase: "reconnecting", + label: "Laptop", + error: "socket hang up", + }), + "Reconnecting to Laptop... Reason: socket hang up", + ); + }); + + it("reports a blocked connection", () => { + assert.equal( + diagnosticsConnectionNotice({ phase: "error", label: "Laptop", error: "unauthorized" }), + "Could not connect to Laptop. Reason: unauthorized", + ); + }); + + it("reports an initial connection attempt", () => { + assert.equal( + diagnosticsConnectionNotice({ phase: "connecting", label: "Laptop", error: null }), + "Connecting to Laptop...", + ); + }); +}); + +describe("pending process signals", () => { + it("tracks pending signals per environment and pid", () => { + const pending = addPendingProcessSignal( + addPendingProcessSignal([], { environmentId: PRIMARY, pid: 100 }), + { environmentId: ACTIVE, pid: 200 }, + ); + + assert.deepEqual([...pendingProcessSignalPids(pending, PRIMARY)], [100]); + assert.deepEqual([...pendingProcessSignalPids(pending, ACTIVE)], [200]); + assert.deepEqual([...pendingProcessSignalPids(pending, SELECTED)], []); + assert.deepEqual([...pendingProcessSignalPids(pending, null)], []); + }); + + it("does not duplicate a signal that is already pending", () => { + const pending = addPendingProcessSignal([{ environmentId: PRIMARY, pid: 100 }], { + environmentId: PRIMARY, + pid: 100, + }); + + assert.equal(pending.length, 1); + }); + + it("keeps the same pid pending in another environment", () => { + const pending = addPendingProcessSignal( + addPendingProcessSignal([], { environmentId: PRIMARY, pid: 100 }), + { environmentId: ACTIVE, pid: 100 }, + ); + + assert.deepEqual([...pendingProcessSignalPids(pending, PRIMARY)], [100]); + assert.deepEqual([...pendingProcessSignalPids(pending, ACTIVE)], [100]); + }); + + it("only clears the completed request, leaving other environments pending", () => { + const pending = addPendingProcessSignal( + addPendingProcessSignal([], { environmentId: PRIMARY, pid: 100 }), + { environmentId: ACTIVE, pid: 200 }, + ); + + const remaining = removePendingProcessSignal(pending, { environmentId: PRIMARY, pid: 100 }); + + assert.deepEqual([...pendingProcessSignalPids(remaining, PRIMARY)], []); + assert.deepEqual([...pendingProcessSignalPids(remaining, ACTIVE)], [200]); + }); + + it("ignores completions for signals that are no longer pending", () => { + const pending: ReadonlyArray = [{ environmentId: PRIMARY, pid: 100 }]; + + assert.strictEqual( + removePendingProcessSignal(pending, { environmentId: ACTIVE, pid: 100 }), + pending, + ); + }); +}); diff --git a/apps/web/src/components/settings/DiagnosticsSettings.logic.ts b/apps/web/src/components/settings/DiagnosticsSettings.logic.ts new file mode 100644 index 00000000000..a5dc5bcd5f9 --- /dev/null +++ b/apps/web/src/components/settings/DiagnosticsSettings.logic.ts @@ -0,0 +1,104 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; + +export function resolveDiagnosticsEnvironmentId(input: { + readonly selectedEnvironmentId: EnvironmentId | null; + readonly primaryEnvironmentId: EnvironmentId | null; + readonly activeEnvironmentId: EnvironmentId | null; + readonly availableEnvironmentIds: ReadonlyArray; +}): EnvironmentId | null { + const availableEnvironmentIds = new Set(input.availableEnvironmentIds); + + if ( + input.selectedEnvironmentId !== null && + availableEnvironmentIds.has(input.selectedEnvironmentId) + ) { + return input.selectedEnvironmentId; + } + if ( + input.primaryEnvironmentId !== null && + availableEnvironmentIds.has(input.primaryEnvironmentId) + ) { + return input.primaryEnvironmentId; + } + if ( + input.activeEnvironmentId !== null && + availableEnvironmentIds.has(input.activeEnvironmentId) + ) { + return input.activeEnvironmentId; + } + return input.availableEnvironmentIds[0] ?? null; +} + +/** + * Diagnostics queries only run while the selected environment has a connected + * supervisor generation; otherwise they stay pending forever. Returns the + * message to show instead of a loading state, or `null` when diagnostics can + * actually be collected. + */ +export function diagnosticsConnectionNotice(input: { + readonly phase: EnvironmentConnectionPhase; + readonly label: string; + readonly error: string | null; +}): string | null { + switch (input.phase) { + case "connected": + return null; + case "connecting": + return `Connecting to ${input.label}...`; + case "reconnecting": + return input.error + ? `Reconnecting to ${input.label}... Reason: ${input.error}` + : `Reconnecting to ${input.label}...`; + case "offline": + return `${input.label} is offline. Diagnostics load once it reconnects.`; + case "available": + return `${input.label} is not connected. Diagnostics load once it connects.`; + case "error": + return input.error + ? `Could not connect to ${input.label}. Reason: ${input.error}` + : `Could not connect to ${input.label}.`; + } +} + +/** + * An in-flight process signal. Signals are identified by environment as well as + * pid so that a completion in one environment cannot clear pending state that + * belongs to another one. + */ +export interface PendingProcessSignal { + readonly environmentId: EnvironmentId; + readonly pid: number; +} + +function isSamePendingProcessSignal(left: PendingProcessSignal, right: PendingProcessSignal) { + return left.environmentId === right.environmentId && left.pid === right.pid; +} + +export function addPendingProcessSignal( + pending: ReadonlyArray, + signal: PendingProcessSignal, +): ReadonlyArray { + return pending.some((entry) => isSamePendingProcessSignal(entry, signal)) + ? pending + : [...pending, signal]; +} + +export function removePendingProcessSignal( + pending: ReadonlyArray, + signal: PendingProcessSignal, +): ReadonlyArray { + const next = pending.filter((entry) => !isSamePendingProcessSignal(entry, signal)); + return next.length === pending.length ? pending : next; +} + +export function pendingProcessSignalPids( + pending: ReadonlyArray, + environmentId: EnvironmentId | null, +): ReadonlySet { + return new Set( + pending + .filter((entry) => environmentId !== null && entry.environmentId === environmentId) + .map((entry) => entry.pid), + ); +} diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index b1a54feb718..f4f3413e204 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -2,18 +2,22 @@ import { AlertTriangleIcon, ChevronDownIcon, ChevronRightIcon, + CloudIcon, CopyIcon, FolderOpenIcon, InfoIcon, + MonitorIcon, RefreshCwIcon, } from "lucide-react"; -import { useAtomValue } from "@effect/atom-react"; +import { Link } from "@tanstack/react-router"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; import { useCallback, useMemo, useState, type ReactNode } from "react"; import type { + EnvironmentId, ServerProcessDiagnosticsEntry, ServerProcessResourceHistorySummary, ServerProcessSignal, @@ -25,19 +29,38 @@ import { cn } from "../../lib/utils"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { useEnvironmentQuery } from "../../state/query"; -import { - primaryServerAvailableEditorsAtom, - primaryServerObservabilityAtom, - serverEnvironment, -} from "../../state/server"; +import { serverEnvironment } from "../../state/server"; import { shellEnvironment } from "../../state/shell"; -import { usePrimaryEnvironment } from "../../state/environments"; +import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; +import { useActiveEnvironmentId } from "../../state/entities"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; +import { + Select, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../ui/select"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; -import { SettingsPageContainer, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; +import { + SettingsPageContainer, + SettingsRow, + SettingsSection, + useRelativeTimeTick, +} from "./settingsLayout"; +import { + addPendingProcessSignal, + diagnosticsConnectionNotice, + pendingProcessSignalPids, + removePendingProcessSignal, + resolveDiagnosticsEnvironmentId, + type PendingProcessSignal, +} from "./DiagnosticsSettings.logic"; import { useAtomCommand } from "../../state/use-atom-command"; const NUMBER_FORMAT = new Intl.NumberFormat(); @@ -394,12 +417,12 @@ function ProcessSignalActions({ function ProcessDiagnosticsTable({ processes, - signalingPid, + signalingPids, onSignal, emptyLabel, }: { processes: ReadonlyArray; - signalingPid: number | null; + signalingPids: ReadonlySet; onSignal: (pid: number, signal: ServerProcessSignal) => void; emptyLabel?: string; }) { @@ -508,7 +531,7 @@ function ProcessDiagnosticsTable({ @@ -779,10 +802,12 @@ function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null function DiagnosticsRefreshButton({ isPending, + isDisabled = false, label, onClick, }: { isPending: boolean; + isDisabled?: boolean; label: string; onClick: () => void; }) { @@ -794,7 +819,7 @@ function DiagnosticsRefreshButton({ size="icon-xs" variant="ghost" className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" - disabled={isPending} + disabled={isPending || isDisabled} onClick={onClick} aria-label={label} > @@ -807,11 +832,35 @@ function DiagnosticsRefreshButton({ ); } +interface LogsDirectoryState { + readonly environmentId: EnvironmentId | null; + readonly isOpening: boolean; + readonly error: string | null; +} + export function DiagnosticsSettingsPanel() { - const observability = useAtomValue(primaryServerObservabilityAtom); - const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); - const environmentId = primaryEnvironment?.environmentId ?? null; + const activeEnvironmentId = useActiveEnvironmentId(); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); + const environmentId = resolveDiagnosticsEnvironmentId({ + selectedEnvironmentId, + primaryEnvironmentId: primaryEnvironment?.environmentId ?? null, + activeEnvironmentId, + availableEnvironmentIds: environments.map((environment) => environment.environmentId), + }); + const diagnosticsEnvironment = + environments.find((environment) => environment.environmentId === environmentId) ?? null; + const observability = diagnosticsEnvironment?.serverConfig?.observability ?? null; + const availableEditors = diagnosticsEnvironment?.serverConfig?.availableEditors ?? []; + const environmentItems = useMemo( + () => + environments.map((environment) => ({ + value: environment.environmentId, + label: environment.label, + })), + [environments], + ); const signalServerProcess = useAtomCommand(serverEnvironment.signalProcess, { reportFailure: false, }); @@ -853,9 +902,20 @@ export function DiagnosticsSettingsPanel() { }, }), ); - const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false); - const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState(null); - const [signalingPid, setSignalingPid] = useState(null); + // Panel-local state is keyed by environment so that a result produced for one + // environment is never shown for (or applied to) another one. + const [logsDirectoryState, setLogsDirectoryState] = useState(null); + const [pendingSignals, setPendingSignals] = useState>([]); + const logsDirectory = + logsDirectoryState !== null && logsDirectoryState.environmentId === environmentId + ? logsDirectoryState + : null; + const isOpeningLogsDirectory = logsDirectory?.isOpening ?? false; + const openLogsDirectoryError = logsDirectory?.error ?? null; + const signalingPids = useMemo( + () => pendingProcessSignalPids(pendingSignals, environmentId), + [environmentId, pendingSignals], + ); const openLogsDirectory = useCallback(() => { const logsDirectoryPath = observability?.logsDirectoryPath ?? null; @@ -863,16 +923,24 @@ export function DiagnosticsSettingsPanel() { const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); if (!editor) { - setOpenLogsDirectoryError("No available editors found."); + setLogsDirectoryState({ + environmentId, + isOpening: false, + error: "No available editors found.", + }); return; } if (environmentId === null) { - setOpenLogsDirectoryError("No environment is selected."); + setLogsDirectoryState({ + environmentId, + isOpening: false, + error: "No environment is selected.", + }); return; } - setIsOpeningLogsDirectory(true); - setOpenLogsDirectoryError(null); + const request: LogsDirectoryState = { environmentId, isOpening: true, error: null }; + setLogsDirectoryState(request); void (async () => { const result = await openInEditor({ environmentId, @@ -881,13 +949,21 @@ export function DiagnosticsSettingsPanel() { editor, }, }); - setIsOpeningLogsDirectory(false); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setOpenLogsDirectoryError( - error instanceof Error ? error.message : "Unable to open logs folder.", - ); - } + const failure = + result._tag === "Failure" && !isAtomCommandInterrupted(result) + ? squashAtomCommandFailure(result) + : null; + const failureMessage = + failure === null + ? null + : failure instanceof Error + ? failure.message + : "Unable to open logs folder."; + // Identity check: only the request that still owns the slot may clear it, + // so a later request (in this or another environment) is never disturbed. + setLogsDirectoryState((current) => + current === request ? { environmentId, isOpening: false, error: failureMessage } : current, + ); })(); }, [availableEditors, environmentId, observability?.logsDirectoryPath, openInEditor]); @@ -905,13 +981,14 @@ export function DiagnosticsSettingsPanel() { return; } - setSignalingPid(pid); + const pendingSignal: PendingProcessSignal = { environmentId, pid }; + setPendingSignals((current) => addPendingProcessSignal(current, pendingSignal)); void (async () => { const result = await signalServerProcess({ environmentId, input: { pid, signal }, }); - setSignalingPid(null); + setPendingSignals((current) => removePendingProcessSignal(current, pendingSignal)); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); @@ -949,6 +1026,16 @@ export function DiagnosticsSettingsPanel() { [environmentId, refreshProcesses, signalServerProcess], ); + // Diagnostics RPCs only run while the environment's supervisor is connected, + // so anything else has to surface as a state instead of an endless spinner. + const connectionNotice = diagnosticsConnectionNotice({ + phase: diagnosticsEnvironment?.connection.phase ?? "available", + label: diagnosticsEnvironment?.label ?? "This environment", + error: diagnosticsEnvironment?.connection.error ?? null, + }); + const isConnected = connectionNotice === null; + const statPlaceholder = isConnected ? "..." : "—"; + const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null; const processResourceError = resourceData ? Option.getOrNull(resourceData.error) : null; const traceDiagnosticsError = data ? Option.getOrNull(data.error) : null; @@ -956,15 +1043,93 @@ export function DiagnosticsSettingsPanel() { ? Option.getOrElse(data.partialFailure, () => false) : false; + if (environmentId === null) { + return ( + + + } size="xs" variant="outline"> + Manage connections + + } + /> + + + ); + } + return ( + + + {connectionStatusText(diagnosticsEnvironment.connection)} + {diagnosticsEnvironment.displayUrl + ? ` · ${diagnosticsEnvironment.displayUrl}` + : null} + + ) : ( + "Connect an environment to view diagnostics." + ) + } + control={ + diagnosticsEnvironment ? ( + + ) : ( + + ) + } + /> + + @@ -974,21 +1139,21 @@ export function DiagnosticsSettingsPanel() { {processDiagnosticsError || processError ? ( @@ -1009,12 +1174,13 @@ export function DiagnosticsSettingsPanel() { ) : null} @@ -1029,7 +1195,8 @@ export function DiagnosticsSettingsPanel() { /> @@ -1039,21 +1206,23 @@ export function DiagnosticsSettingsPanel() { {processResourceError || resourceError ? ( @@ -1076,9 +1245,10 @@ export function DiagnosticsSettingsPanel() { @@ -1095,7 +1265,9 @@ export function DiagnosticsSettingsPanel() { size="icon-xs" variant="ghost" className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" - disabled={!observability?.logsDirectoryPath || isOpeningLogsDirectory} + disabled={ + !isConnected || !observability?.logsDirectoryPath || isOpeningLogsDirectory + } onClick={openLogsDirectory} aria-label="Open logs folder" > @@ -1106,7 +1278,8 @@ export function DiagnosticsSettingsPanel() { Open logs folder @@ -1114,15 +1287,15 @@ export function DiagnosticsSettingsPanel() { } > - + 0 ? "danger" : "default"} /> 0 ? "warning" : "default"} /> @@ -1192,7 +1365,12 @@ export function DiagnosticsSettingsPanel() { ))} ) : ( - + )} @@ -1221,7 +1399,10 @@ export function DiagnosticsSettingsPanel() { ) : ( )} @@ -1251,7 +1432,11 @@ export function DiagnosticsSettingsPanel() { ))} ) : ( - + )} @@ -1314,7 +1499,10 @@ export function DiagnosticsSettingsPanel() { ) : ( )} @@ -1347,7 +1535,11 @@ export function DiagnosticsSettingsPanel() { ))} ) : ( - + )}