Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
113 changes: 73 additions & 40 deletions apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { findErrorTraceId } from "@t3tools/client-runtime/errors";
import {
type EnvironmentConnectionPresentation,
RelayConnectionRegistration,
RelayConnectionTarget,
} from "@t3tools/client-runtime/connection";
Expand All @@ -10,7 +11,7 @@ import {
import type { EnvironmentId } from "@t3tools/contracts";
import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay";
import * as Option from "effect/Option";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { type ReactNode, useCallback, useEffect, useState } from "react";

import { environmentCatalog } from "~/connection/catalog";
import { cn } from "~/lib/utils";
Expand All @@ -22,6 +23,12 @@ import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "../settings/itemRo
import { Button } from "../ui/button";
import { Skeleton } from "../ui/skeleton";
import { toastManager } from "../ui/toast";
import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation";

export interface SavedCloudEnvironmentConnection {
readonly environmentId: EnvironmentId;
readonly connection: EnvironmentConnectionPresentation;
}

export function RemoteEnvironmentRowsSkeleton() {
return (
Expand All @@ -40,19 +47,19 @@ export function RemoteEnvironmentRowsSkeleton() {
/**
* The user's T3 Connect environments from relay discovery, each with a
* Connect button. The primary environment is always excluded; already-saved
* environments are hidden unless `showSavedAsConnected` renders them as
* connected instead (used by onboarding, where the full device mesh should be
* visible).
* environments are hidden unless `showSavedEnvironments` renders them with
* their live connection state (used by onboarding, where the full device mesh
* should be visible).
*/
export function CloudEnvironmentConnectRows({
primaryEnvironmentId,
savedEnvironmentIds,
showSavedAsConnected = false,
savedEnvironments,
showSavedEnvironments = false,
empty = null,
}: {
readonly primaryEnvironmentId: EnvironmentId | null;
readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>;
readonly showSavedAsConnected?: boolean;
readonly savedEnvironments: ReadonlyArray<SavedCloudEnvironmentConnection>;
readonly showSavedEnvironments?: boolean;
readonly empty?: ReactNode;
}) {
const environmentsState = useRelayEnvironmentDiscovery();
Expand All @@ -77,7 +84,9 @@ export function CloudEnvironmentConnectRows({
const [connectingEnvironmentId, setConnectingEnvironmentId] = useState<EnvironmentId | null>(
null,
);
const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]);
const savedById = new Map(
savedEnvironments.map((environment) => [environment.environmentId, environment]),
);

useEffect(() => {
void refreshRelayEnvironments();
Expand All @@ -90,8 +99,8 @@ export function CloudEnvironmentConnectRows({
if (result._tag === "Success") {
toastManager.add({
type: "success",
title: "Environment connected",
description: `${environment.label} is available through T3 Connect.`,
title: "Environment added",
description: `Connecting to ${environment.label} through T3 Connect.`,
});
return;
}
Expand Down Expand Up @@ -121,10 +130,10 @@ export function CloudEnvironmentConnectRows({
const visibleEnvironments = [...environmentsState.environments.values()].filter(
({ environment }) =>
environment.environmentId !== primaryEnvironmentId &&
(showSavedAsConnected || !savedIds.has(environment.environmentId)),
(showSavedEnvironments || !savedById.has(environment.environmentId)),
);

const standalone = showSavedAsConnected || savedEnvironmentIds.length === 0;
const standalone = showSavedEnvironments || savedEnvironments.length === 0;

if (
standalone &&
Expand Down Expand Up @@ -163,53 +172,77 @@ export function CloudEnvironmentConnectRows({
}

return visibleEnvironments.map(({ environment, availability, error }) => {
const alreadyConnected = savedIds.has(environment.environmentId);
const savedEnvironment = savedById.get(environment.environmentId);
const savedConnection = savedEnvironment
? presentSavedCloudEnvironmentConnection(savedEnvironment.connection)
: null;
const dotClassName = savedConnection
? savedConnection.tone === "connected"
? "bg-success"
: savedConnection.tone === "connecting"
? "bg-warning"
: savedConnection.tone === "error"
? "bg-destructive"
: "bg-muted-foreground/35"
: availability === "online"
? "bg-success"
: availability === "error"
? "bg-destructive"
: availability === "checking"
? "bg-warning"
: "bg-muted-foreground/35";
const statusText = savedConnection
? savedConnection.statusText
: availability === "online"
? "Available · Relay online"
: availability === "offline"
? "Available · Relay offline"
: availability === "checking"
? "Available · Checking relay status…"
: (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable");
return (
<div key={environment.environmentId} className={ITEM_ROW_CLASSNAME}>
<div className={ITEM_ROW_INNER_CLASSNAME}>
<div className="min-w-0">
<div className="flex items-center gap-2">
<ConnectionStatusDot
dotClassName={
availability === "online"
? "bg-success"
: availability === "error"
? "bg-destructive"
: availability === "checking"
? "bg-warning"
: "bg-muted-foreground/35"
dotClassName={dotClassName}
pingClassName={
savedConnection?.tone === "connecting" ||
(savedConnection === null && availability === "checking")
? "bg-warning/60 duration-2000"
: null
}
pingClassName={availability === "checking" ? "bg-warning/60 duration-2000" : null}
tooltipText={
availability === "online"
? "Relay online"
: availability === "offline"
? "Relay offline"
: availability === "checking"
? "Checking relay status"
: (Option.getOrNull(error)?.message ?? "Relay status unavailable")
savedConnection
? savedConnection.statusText
: availability === "online"
? "Relay online"
: availability === "offline"
? "Relay offline"
: availability === "checking"
? "Checking relay status"
: (Option.getOrNull(error)?.message ?? "Relay status unavailable")
}
/>
<p className="truncate text-sm font-medium">{environment.label}</p>
</div>
<p
className={cn(
"mt-1 truncate text-xs",
availability === "error" ? "text-destructive" : "text-muted-foreground",
savedConnection?.tone === "error" ||
(savedConnection?.tone === "connecting" && savedEnvironment?.connection.error) ||
(savedConnection === null && availability === "error")
? "text-destructive"
: "text-muted-foreground",
)}
>
{availability === "online"
? "Available · Relay online"
: availability === "offline"
? "Available · Relay offline"
: availability === "checking"
? "Available · Checking relay status…"
: (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")}
{statusText}
</p>
</div>
{alreadyConnected ? (
{savedConnection ? (
<Button size="sm" variant="outline" disabled>
Connected
{savedConnection.buttonLabel}
</Button>
) : (
<Button
Expand Down
14 changes: 5 additions & 9 deletions apps/web/src/components/cloud/ConnectOnboardingDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useAuth } from "@clerk/react";
import { AuthAdministrativeScopes, AuthRelayWriteScope } from "@t3tools/contracts";
import { CheckIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";

import {
CONNECT_ONBOARDING_OPT_OUT_STORAGE_KEY,
Expand Down Expand Up @@ -411,20 +411,16 @@ function OnboardingToggleRow({
function DevicesStep() {
const { environments } = useEnvironments();
const primaryEnvironment = usePrimaryEnvironment();
const savedEnvironmentIds = useMemo(
() =>
environments
.filter((environment) => environment.entry.target._tag !== "PrimaryConnectionTarget")
.map((environment) => environment.environmentId),
[environments],
const savedEnvironments = environments.filter(
(environment) => environment.entry.target._tag !== "PrimaryConnectionTarget",
);

return (
<div className="overflow-hidden rounded-lg border">
<CloudEnvironmentConnectRows
primaryEnvironmentId={primaryEnvironment?.environmentId ?? null}
savedEnvironmentIds={savedEnvironmentIds}
showSavedAsConnected
savedEnvironments={savedEnvironments}
showSavedEnvironments
empty={
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
No other environments are published to your account yet. Publish one from another device
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
import { describe, expect, it } from "vite-plus/test";

import { presentSavedCloudEnvironmentConnection } from "./cloudEnvironmentConnectionPresentation";

function connection(
phase: EnvironmentConnectionPresentation["phase"],
error: string | null = null,
): EnvironmentConnectionPresentation {
return { phase, error, traceId: null };
}

describe("saved cloud environment connection presentation", () => {
it("only labels a live connection as connected", () => {
expect(presentSavedCloudEnvironmentConnection(connection("connected"))).toEqual({
buttonLabel: "Connected",
statusText: "Connected",
tone: "connected",
});

expect(presentSavedCloudEnvironmentConnection(connection("connecting"))).toEqual({
buttonLabel: "Connecting…",
statusText: "Connecting...",
tone: "connecting",
});
});

it("surfaces a failed attempt while the supervisor reconnects", () => {
expect(
presentSavedCloudEnvironmentConnection(
connection("reconnecting", "Relay environment endpoint is unavailable."),
),
).toEqual({
buttonLabel: "Reconnecting…",
statusText:
"Failed to connect. Reconnecting... Reason: Relay environment endpoint is unavailable.",
tone: "connecting",
});
});

it.each([
["error", "Connection failed", "Connection failed. Reason: Access denied.", "error"],
["offline", "Offline", "Offline", "idle"],
["available", "Not connected", "Available", "idle"],
] as const)(
"presents %s without claiming the environment is connected",
(phase, buttonLabel, statusText, tone) => {
expect(
presentSavedCloudEnvironmentConnection(
connection(phase, phase === "error" ? "Access denied." : null),
),
).toEqual({ buttonLabel, statusText, tone });
},
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
connectionStatusText,
type EnvironmentConnectionPresentation,
} from "@t3tools/client-runtime/connection";

export interface SavedCloudEnvironmentConnectionPresentation {
readonly buttonLabel: string;
readonly statusText: string;
readonly tone: "connected" | "connecting" | "error" | "idle";
}

/**
* Present the live supervisor state for an environment that is already in the
* connection catalog. Catalog membership only means the environment is saved;
* it does not mean the connection attempt succeeded.
*/
export function presentSavedCloudEnvironmentConnection(
connection: EnvironmentConnectionPresentation,
): SavedCloudEnvironmentConnectionPresentation {
switch (connection.phase) {
case "connected":
return {
buttonLabel: "Connected",
statusText: connectionStatusText(connection),
tone: "connected",
};
case "connecting":
return {
buttonLabel: "Connecting…",
statusText: connectionStatusText(connection),
tone: "connecting",
};
case "reconnecting":
return {
buttonLabel: "Reconnecting…",
statusText: connectionStatusText(connection),
tone: "connecting",
};
case "error":
return {
buttonLabel: "Connection failed",
statusText: connectionStatusText(connection),
tone: "error",
};
case "offline":
return {
buttonLabel: "Offline",
statusText: connectionStatusText(connection),
tone: "idle",
};
case "available":
return {
buttonLabel: "Not connected",
statusText: connectionStatusText(connection),
tone: "idle",
};
}
}
14 changes: 5 additions & 9 deletions apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1672,18 +1672,18 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable

function CloudRemoteEnvironmentRows({
primaryEnvironmentId,
savedEnvironmentIds,
savedEnvironments,
}: {
readonly primaryEnvironmentId: EnvironmentId | null;
readonly savedEnvironmentIds: ReadonlyArray<EnvironmentId>;
readonly savedEnvironments: ReadonlyArray<EnvironmentPresentation>;
}) {
return hasCloudPublicConfig() ? (
<CloudEnvironmentConnectRows
primaryEnvironmentId={primaryEnvironmentId}
savedEnvironmentIds={savedEnvironmentIds}
savedEnvironments={savedEnvironments}
empty={<EmptyRemoteEnvironments />}
/>
) : savedEnvironmentIds.length === 0 ? (
) : savedEnvironments.length === 0 ? (
<EmptyRemoteEnvironments cloudEnabled={false} />
) : null;
}
Expand Down Expand Up @@ -1713,10 +1713,6 @@ export function ConnectionsSettings() {
.toSorted((left, right) => left.label.localeCompare(right.label)),
[environments],
);
const savedEnvironmentIds = useMemo(
() => savedEnvironments.map((environment) => environment.environmentId),
[savedEnvironments],
);
const savedDesktopSshEnvironmentsByAlias = useMemo(
() =>
savedEnvironments.reduce<Record<string, EnvironmentPresentation>>(
Expand Down Expand Up @@ -3363,7 +3359,7 @@ export function ConnectionsSettings() {
))}
<CloudRemoteEnvironmentRows
primaryEnvironmentId={primaryEnvironmentId}
savedEnvironmentIds={savedEnvironmentIds}
savedEnvironments={savedEnvironments}
/>
</SettingsSection>
</SettingsPageContainer>
Expand Down
Loading