Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { environmentPresentations } from "../../state/presentation";
import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
import { hasCloudPublicConfig } from "../cloud/publicConfig";
import { ConnectionStatusDot } from "./ConnectionStatusDot";
import { useConnectionDiagnosis } from "./ConnectionDiagnosisDetails";
import { environmentStatusText } from "./environmentStatusText";
import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController";

Expand Down Expand Up @@ -193,6 +194,7 @@ function ConnectedCloudEnvironmentRow(props: {
);
const canRetry = presentation !== null && hasSavedBearerRoute(presentation.entry);
const enabled = props.environment.isEnabled && !unsupported;
const { diagnosis } = useConnectionDiagnosis(props.environment.environmentId, presentation);
const [lastDescriptor, setLastDescriptor] = useState(props.descriptor);
if (props.descriptor !== undefined && props.descriptor !== lastDescriptor) {
setLastDescriptor(props.descriptor);
Expand All @@ -213,7 +215,11 @@ function ConnectedCloudEnvironmentRow(props: {
onValueChange={props.onSetEnabled}
onToggleError={props.onToggleError}
disabled={unsupported && !canRetry}
{...(enabled || unsupported ? {} : { statusText: "Off" })}
{...(enabled || unsupported
? diagnosis
? { statusText: diagnosis.headline }
: {}
: { statusText: "Off" })}
value={enabled}
/>
);
Expand Down
92 changes: 92 additions & 0 deletions apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useAtomValue } from "@effect/atom-react";
import {
AVAILABLE_CONNECTION_STATE,
type ConnectionDiagnosis,
connectionDiagnosisText,
connectionRoutes,
hasRelayRoute,
presentConnectionDiagnosis,
type EnvironmentPresentation,
} from "@t3tools/client-runtime/connection";
import type { EnvironmentId } from "@t3tools/contracts";
import Constants from "expo-constants";
import * as Option from "effect/Option";
import { AsyncResult } from "effect/reactivity";
import { View } from "react-native";

import { AppText as Text } from "../../components/ui/AppText";
import { Button } from "../../components/ui/Button";
import { environmentCatalog } from "../../connection/catalog";
import { cn } from "../../lib/cn";
import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";

const HOP_MARK = { ok: "✓", failed: "✕", unchecked: "–" } as const;
const HOP_NOTE = { ok: "", failed: " · Failed", unchecked: " · Not checked" } as const;

/** Same model as the web Settings row; null while connected, off, or not failed. */
export function useConnectionDiagnosis(
environmentId: EnvironmentId,
presentation: EnvironmentPresentation | null,
) {
const state = Option.getOrElse(
AsyncResult.value(useAtomValue(environmentCatalog.stateAtom(environmentId))),
() => AVAILABLE_CONNECTION_STATE,
);
if (presentation === null || !presentation.entry.enabled) return { diagnosis: null, state };
const phase = presentation.connection.phase;
if (phase === "connected" || phase === "unsupported") return { diagnosis: null, state };
const relayOnly =
connectionRoutes(presentation.entry).length === 1 && hasRelayRoute(presentation.entry);
const diagnosis = presentConnectionDiagnosis({
hostLabel: presentation.entry.target.label,
state,
via: relayOnly ? "relay" : "direct",
clientVersion: Constants.expoConfig?.version,
serverVersion: presentation.serverConfig?.environment.serverVersion,
});
return { diagnosis, state };
}

export function ConnectionDiagnosisDetails(props: {
readonly hostLabel: string;
readonly diagnosis: ConnectionDiagnosis;
}) {
const { diagnosis } = props;
return (
<View className="gap-3">
<View className="gap-0.5">
{diagnosis.hops.map((hop) => (
<Text
key={hop.hop}
className={cn(
"text-sm",
hop.result === "failed" ? "text-danger-foreground" : "text-foreground-muted",
)}
>
{`${HOP_MARK[hop.result]} ${hop.label}${HOP_NOTE[hop.result]}`}
</Text>
))}
</View>
<View className="gap-0.5">
<Text className="text-sm font-t3-bold text-foreground">What to try</Text>
{diagnosis.steps.map((step, index) => (
<Text key={step} className="text-sm text-foreground-muted">
{`${index + 1}. ${step}`}
</Text>
))}
</View>
<Text selectable className="font-mono text-xs text-foreground-muted">
{diagnosis.facts.map(([label, value]) => `${label}: ${value}`).join("\n")}
</Text>
<Button
label="Copy diagnostics"
icon="doc.on.doc"
onPress={() => {
copyTextWithHaptic(connectionDiagnosisText(props.hostLabel, diagnosis), {
target: "connection-diagnostics",
});
}}
/>
</View>
);
}
22 changes: 21 additions & 1 deletion apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { cn } from "../../lib/cn";
import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
import { serverEnvironment } from "../../state/server";
import { environmentPresentations } from "../../state/presentation";
import { ConnectionDiagnosisDetails, useConnectionDiagnosis } from "./ConnectionDiagnosisDetails";
import { ConnectionFormField } from "./ConnectionFormField";
import { ConnectionStatusDot } from "./ConnectionStatusDot";
import { environmentStatusText } from "./environmentStatusText";
Expand Down Expand Up @@ -58,6 +59,10 @@ export function ConnectionEnvironmentRow(props: {
environmentPresentations.presentationAtom(props.environment.environmentId),
);
const canRetry = presentation !== null && hasSavedBearerRoute(presentation.entry);
const { diagnosis, state: connectionState } = useConnectionDiagnosis(
props.environment.environmentId,
presentation,
);
const enabled = props.environment.isEnabled && !unsupported;
const statusLabel = connectionStatusLabel(props.environment);
const statusTraceId = enabled ? props.environment.connectionErrorTraceId : null;
Expand Down Expand Up @@ -118,7 +123,16 @@ export function ConnectionEnvironmentRow(props: {
{props.environment.displayUrl}
</Text>
) : null}
{statusLabel ? (
{diagnosis ? (
<>
<Text className="text-xs text-danger-foreground">{diagnosis.headline}</Text>
{connectionState.attempt > 0 ? (
<Text className="text-xs text-foreground-muted">
{`Retrying · attempt ${connectionState.attempt}`}
</Text>
) : null}
</>
) : statusLabel ? (
<Text
className={cn(
"text-xs",
Expand Down Expand Up @@ -177,6 +191,12 @@ export function ConnectionEnvironmentRow(props: {
exiting={FadeOut.duration(150)}
className="gap-3 px-4 pb-4"
>
{diagnosis ? (
<ConnectionDiagnosisDetails
hostLabel={props.environment.environmentLabel}
diagnosis={diagnosis}
/>
) : null}
{props.environment.isRelayManaged ? (
<Text className="text-sm text-foreground-muted">
Managed by T3 Connect. Tunnel details update automatically.
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ import {
} from "../lib/composerContextRecords";
import { type ReviewCommentContext } from "../reviewCommentContext";
import { environmentCatalog } from "../connection/catalog";
import { ConnectionDiagnosisHeadline } from "./settings/ConnectionDiagnosisDetails";
import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal";
import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay";
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
Expand Down Expand Up @@ -2350,6 +2351,9 @@ export default function ChatView(props: ChatViewProps) {
variant: unavailableConnection.phase === "error" ? "error" : "warning",
icon: <WifiOffIcon />,
title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`,
description: activeEnvironment ? (
<ConnectionDiagnosisHeadline environment={activeEnvironment} />
) : undefined,
actions: (
<>
{!environmentReconnecting ? (
Expand Down
201 changes: 201 additions & 0 deletions apps/web/src/components/settings/ConnectionDiagnosisDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { useAtomValue } from "@effect/atom-react";
import {
AVAILABLE_CONNECTION_STATE,
type ConnectionDiagnosis,
connectionDiagnosisText,
connectionRoutes,
hasRelayRoute,
presentConnectionDiagnosis,
} from "@t3tools/client-runtime/connection";
import * as Option from "effect/Option";
import { AsyncResult } from "effect/reactivity";
import { ChevronRightIcon } from "lucide-react";
import { useEffect, useState } from "react";

import { APP_VERSION } from "~/branding";
import { environmentCatalog } from "~/connection/catalog";
import { type EnvironmentPresentation, useRelayEnvironmentDiscovery } from "~/state/environments";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
import { stackedThreadToast, toastManager } from "../ui/toast";

/**
* What failed on the way to an environment, or null while it is connected, off,
* or has not failed. Reads the supervisor state itself so callers that only need
* the headline do not re-render on every attempt.
*/
export function useConnectionDiagnosis(environment: EnvironmentPresentation) {
const environmentId = environment.environmentId;
const state = Option.getOrElse(
AsyncResult.value(useAtomValue(environmentCatalog.stateAtom(environmentId))),
() => AVAILABLE_CONNECTION_STATE,
);
const relayStatus = useRelayEnvironmentDiscovery().environments.get(environmentId)?.status;
const routes = connectionRoutes(environment.entry);
const relayOnly = routes.length === 1 && hasRelayRoute(environment.entry);
const phase = environment.connection.phase;
const diagnosis =
environment.entry.enabled && phase !== "connected" && phase !== "unsupported"
? presentConnectionDiagnosis({
hostLabel: environment.label,
state,
via: relayOnly ? "relay" : "direct",
// With a direct route as well, the relay's health says nothing about the route in use.
relayStatus: relayOnly ? Option.getOrNull(relayStatus ?? Option.none()) : null,
clientVersion: APP_VERSION,
serverVersion: environment.serverConfig?.environment.serverVersion,
})
: null;
return { diagnosis, state, relayOnly };
}

/** The failure headline alone, for banners outside Settings. */
export function ConnectionDiagnosisHeadline({
environment,
}: {
readonly environment: EnvironmentPresentation;
}) {
return useConnectionDiagnosis(environment).diagnosis?.headline ?? null;
}

const HOP_MARK = { ok: "✓", failed: "✕", unchecked: "–" } as const;
const HOP_NOTE = { ok: "", failed: "Failed", unchecked: "Not checked" } as const;

/** Ticks once per second, and only while a retry is scheduled, so the list around it does not re-render. */
function RetryCountdown({ retryAt }: { readonly retryAt: number }) {
const [now, setNow] = useState(Date.now);
useEffect(() => {
if (retryAt <= Date.now()) return;
const timer = setInterval(() => setNow(Date.now()), 1_000);
return () => clearInterval(timer);
}, [retryAt]);
const seconds = Math.ceil((retryAt - now) / 1_000);
return <>{seconds > 0 ? `retrying in ${seconds}s` : "retrying"}</>;
}

/** The collapsed failure: the full headline, then the retry state and a Details toggle. */
export function ConnectionDiagnosisSummary({
diagnosis,
retryAt,
attempt,
expanded,
onToggle,
}: {
readonly diagnosis: ConnectionDiagnosis;
readonly retryAt: number | null;
readonly attempt: number;
readonly expanded: boolean;
readonly onToggle: () => void;
}) {
return (
<div className="text-xs">
<p className="text-destructive">{diagnosis.headline}</p>
<p className="flex flex-wrap items-center gap-1 text-muted-foreground">
{retryAt !== null ? <RetryCountdown retryAt={retryAt} /> : null}
{retryAt !== null && attempt > 0 ? <span aria-hidden>·</span> : null}
{attempt > 0 ? <span>attempt {attempt}</span> : null}
{retryAt !== null || attempt > 0 ? <span aria-hidden>·</span> : null}
<button
type="button"
aria-expanded={expanded}
onClick={onToggle}
className="inline-flex items-center gap-0.5 rounded-sm outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"
>
Details
<ChevronRightIcon
aria-hidden
className={cn(
"size-3 transition-transform duration-150 motion-reduce:transition-none",
expanded && "rotate-90",
)}
/>
</button>
</p>
</div>
);
}

/** Hops in order, what to try, the facts, and the actions. */
export function ConnectionDiagnosisPanel({
hostLabel,
diagnosis,
onRetry,
onAddRoute,
}: {
readonly hostLabel: string;
readonly diagnosis: ConnectionDiagnosis;
readonly onRetry: () => void;
readonly onAddRoute: (() => void) | null;
}) {
const { copyToClipboard } = useCopyToClipboard({
target: "connection diagnostics",
onCopy: () => toastManager.add({ type: "success", title: "Diagnostics copied" }),
onError: (error) =>
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not copy diagnostics",
description: error.message,
}),
),
});
return (
<div className="mt-2 grid gap-3 border-t border-border/50 pt-2 text-xs">
<ol className="grid gap-0.5">
{diagnosis.hops.map((hop) => (
<li
key={hop.hop}
className={cn(
"flex gap-2",
hop.result === "failed" ? "text-destructive" : "text-muted-foreground",
)}
>
<span aria-hidden className="w-3 text-center">
{HOP_MARK[hop.result]}
</span>
<span className={cn(hop.result === "ok" && "text-foreground")}>{hop.label}</span>
<span>{HOP_NOTE[hop.result]}</span>
</li>
))}
</ol>
<div>
<p className="font-medium text-foreground">What to try</p>
<ol className="list-decimal pl-4 text-muted-foreground">
{diagnosis.steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 font-mono text-muted-foreground">
{diagnosis.facts.map(([label, value]) => (
<div key={label} className="contents">
<dt>{label}</dt>
<dd className="break-all text-foreground">{value}</dd>
</div>
))}
</dl>
<div className="flex flex-wrap gap-2">
<Button type="button" size="xs" variant="outline" onClick={onRetry}>
Retry now
</Button>
<Button
type="button"
size="xs"
variant="outline"
onClick={() => {
const text = connectionDiagnosisText(hostLabel, diagnosis);
copyToClipboard(text, undefined);
}}
>
Copy diagnostics
</Button>
{onAddRoute ? (
<Button type="button" size="xs" variant="outline" onClick={onAddRoute}>
Add direct route
</Button>
) : null}
</div>
</div>
);
}
Loading
Loading