import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts";import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";import { isAtomCommandInterrupted, squashAtomCommandFailure,} from "@t3tools/client-runtime/state/runtime";import { CheckIcon } from "lucide-react"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";import { cn } from "~/lib/utils";import { serverEnvironment } from "~/state/server";import { useAtomCommand } from "~/state/use-atom-command";import { manualServerUpdateCommand } from "~/versionSkew";import { Button } from "./ui/button";import { toastManager } from "./ui/toast"; const UPDATE_STEPS = [ { stage: "downloading", label: "Download", activeLabel: "Downloading" }, { stage: "installing", label: "Install", activeLabel: "Installing" }, { stage: "resuming", label: "Resume", activeLabel: "Resuming" },] as const;const pendingUpdateEnvironmentIds = new Set<EnvironmentId>(); function updateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed.";} /** * Monochrome step rail for an in-flight server update. The update is a * wait, not a warning: done steps recede to gray, the active step is the * only bright element and pulses. Failure keeps the rail but surfaces the * error below it. */export function ServerUpdateProgress({ fromVersion, state,}: { readonly fromVersion: string; readonly state: Exclude<ServerUpdateState, { status: "idle" }>;}) { const currentIndex = UPDATE_STEPS.findIndex(({ stage }) => stage === state.stage); return ( <div className="mt-1 min-w-0 space-y-2.5"> <p className="text-xs text-muted-foreground/70"> {fromVersion} <span aria-hidden="true">→</span> {state.targetVersion} </p> <ol className="flex min-w-0 items-center" aria-label="Server update progress"> {UPDATE_STEPS.map((step, index) => { const complete = index < currentIndex; const current = index === currentIndex; const failed = current && state.status === "failed"; const running = current && state.status === "running"; return ( <li key={step.stage} className={cn( "flex min-w-0 items-center text-xs font-medium", index < UPDATE_STEPS.length - 1 ? "flex-1" : null, complete ? "text-muted-foreground" : failed ? "text-destructive" : running ? "animate-status-pulse text-foreground" : "text-muted-foreground/50", )} aria-current={running ? "step" : undefined} > {complete ? ( <CheckIcon className="size-3.5 shrink-0 opacity-70" strokeWidth={2.5} aria-hidden="true" /> ) : ( <span className={cn( "size-1.5 shrink-0 rounded-full", failed ? "bg-destructive" : running ? "bg-foreground" : "border border-muted-foreground/40", )} aria-hidden="true" /> )} <span className="ml-1.5 truncate">{running ? step.activeLabel : step.label}</span> {index < UPDATE_STEPS.length - 1 ? ( <span className={cn( "mx-2 h-px min-w-3 flex-1", complete ? "bg-muted-foreground/40" : "bg-border", )} aria-hidden="true" /> ) : null} </li> ); })} </ol> {state.status === "failed" ? ( <p className="text-xs text-destructive" role="alert"> {state.message} </p> ) : null} </div> );} /** * Offers the update path advertised by a version-skewed server. Self-updates * delegate their full lifecycle to client-runtime so this component can * unmount during reconnect without losing operation state. */export function ServerUpdateAction({ environmentId, serverLabel, selfUpdate, targetVersion, label = "Update server",}: { readonly environmentId: EnvironmentId; readonly serverLabel: string; readonly selfUpdate: ServerSelfUpdateCapability | null; readonly targetVersion: string; readonly label?: string;}) { const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false, }); const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ target: "update command", onCopy: ({ command }) => { toastManager.add({ type: "success", title: "Update command copied", description: `Run \`${command}\` on ${serverLabel} to update it.`, }); }, onError: (error) => { toastManager.add({ type: "error", title: "Could not copy update command", description: error.message, }); }, }); const handleUpdate = async () => { if (pendingUpdateEnvironmentIds.has(environmentId)) { return; } pendingUpdateEnvironmentIds.add(environmentId); try { const result = await updateServer({ environmentId, input: { targetVersion }, }); if (result._tag === "Failure") { if (isAtomCommandInterrupted(result)) { return; } toastManager.add({ type: "error", title: "Server update failed", description: updateFailureMessage(squashAtomCommandFailure(result)), }); return; } toastManager.add({ type: "success", title: `${serverLabel} updated`, description: `Reconnected on t3@${result.value.targetVersion}.`, }); } finally { pendingUpdateEnvironmentIds.delete(environmentId); } }; if (selfUpdate === "desktop-managed") { return ( <span className="text-muted-foreground text-xs"> Update the desktop app on that machine to update this server. </span> ); } if (selfUpdate === null) { const command = manualServerUpdateCommand(targetVersion); return ( <Button size="xs" variant="outline" onClick={() => copyToClipboard(command, { command })}> Copy update command </Button> ); } return ( <Button size="xs" onClick={() => void handleUpdate()}> {label} </Button> );}
import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts";
import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { CheckIcon } from "lucide-react";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { cn } from "~/lib/utils";
import { serverEnvironment } from "~/state/server";
import { useAtomCommand } from "~/state/use-atom-command";
import { manualServerUpdateCommand } from "~/versionSkew";
import { Button } from "./ui/button";
import { toastManager } from "./ui/toast";
const UPDATE_STEPS = [
{ stage: "downloading", label: "Download", activeLabel: "Downloading" },
{ stage: "installing", label: "Install", activeLabel: "Installing" },
{ stage: "resuming", label: "Resume", activeLabel: "Resuming" },
] as const;
const pendingUpdateEnvironmentIds = new Set<EnvironmentId>();
function updateFailureMessage(error: unknown): string {
return error instanceof Error ? error.message : "Server update failed.";
}
/**
* Monochrome step rail for an in-flight server update. The update is a
* wait, not a warning: done steps recede to gray, the active step is the
* only bright element and pulses. Failure keeps the rail but surfaces the
* error below it.
*/
export function ServerUpdateProgress({
fromVersion,
state,
}: {
readonly fromVersion: string;
readonly state: Exclude<ServerUpdateState, { status: "idle" }>;
}) {
const currentIndex = UPDATE_STEPS.findIndex(({ stage }) => stage === state.stage);
return (
<div className="mt-1 min-w-0 space-y-2.5">
<p className="text-xs text-muted-foreground/70">
{fromVersion} <span aria-hidden="true">→</span> {state.targetVersion}
</p>
<ol className="flex min-w-0 items-center" aria-label="Server update progress">
{UPDATE_STEPS.map((step, index) => {
const complete = index < currentIndex;
const current = index === currentIndex;
const failed = current && state.status === "failed";
const running = current && state.status === "running";
return (
<li
key={step.stage}
className={cn(
"flex min-w-0 items-center text-xs font-medium",
index < UPDATE_STEPS.length - 1 ? "flex-1" : null,
complete
? "text-muted-foreground"
: failed
? "text-destructive"
: running
? "animate-status-pulse text-foreground"
: "text-muted-foreground/50",
)}
aria-current={running ? "step" : undefined}
>
{complete ? (
<CheckIcon
className="size-3.5 shrink-0 opacity-70"
strokeWidth={2.5}
aria-hidden="true"
/>
) : (
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
failed
? "bg-destructive"
: running
? "bg-foreground"
: "border border-muted-foreground/40",
)}
aria-hidden="true"
/>
)}
<span className="ml-1.5 truncate">{running ? step.activeLabel : step.label}</span>
{index < UPDATE_STEPS.length - 1 ? (
<span
className={cn(
"mx-2 h-px min-w-3 flex-1",
complete ? "bg-muted-foreground/40" : "bg-border",
)}
aria-hidden="true"
/>
) : null}
</li>
);
})}
</ol>
{state.status === "failed" ? (
<p className="text-xs text-destructive" role="alert">
{state.message}
</p>
) : null}
</div>
);
}
/**
* Offers the update path advertised by a version-skewed server. Self-updates
* delegate their full lifecycle to client-runtime so this component can
* unmount during reconnect without losing operation state.
*/
export function ServerUpdateAction({
environmentId,
serverLabel,
selfUpdate,
targetVersion,
label = "Update server",
}: {
readonly environmentId: EnvironmentId;
readonly serverLabel: string;
readonly selfUpdate: ServerSelfUpdateCapability | null;
readonly targetVersion: string;
readonly label?: string;
}) {
const updateServer = useAtomCommand(serverEnvironment.updateServer, {
reportFailure: false,
});
const { copyToClipboard } = useCopyToClipboard<{ command: string }>({
target: "update command",
onCopy: ({ command }) => {
toastManager.add({
type: "success",
title: "Update command copied",
description: `Run \`${command}\` on ${serverLabel} to update it.`,
});
},
onError: (error) => {
toastManager.add({
type: "error",
title: "Could not copy update command",
description: error.message,
});
},
});
const handleUpdate = async () => {
if (pendingUpdateEnvironmentIds.has(environmentId)) {
return;
}
pendingUpdateEnvironmentIds.add(environmentId);
try {
const result = await updateServer({
environmentId,
input: { targetVersion },
});
if (result._tag === "Failure") {
if (isAtomCommandInterrupted(result)) {
return;
}
toastManager.add({
type: "error",
title: "Server update failed",
description: updateFailureMessage(squashAtomCommandFailure(result)),
});
return;
}
toastManager.add({
type: "success",
title: `${serverLabel} updated`,
description: `Reconnected on t3@${result.value.targetVersion}.`,
});
} finally {
pendingUpdateEnvironmentIds.delete(environmentId);
}
};
if (selfUpdate === "desktop-managed") {
return (
<span className="text-muted-foreground text-xs">
Update the desktop app on that machine to update this server.
</span>
);
}
if (selfUpdate === null) {
const command = manualServerUpdateCommand(targetVersion);
return (
<Button size="xs" variant="outline" onClick={() => copyToClipboard(command, { command })}>
Copy update command
</Button>
);
}
return (
<Button size="xs" onClick={() => void handleUpdate()}>
{label}
</Button>
);
}