| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { |
| 3 | AuthEnvironmentMaintainScope, |
| 4 | type AuthSessionState, |
| 5 | sessionGrantsScope, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import type { AsyncResult } from "effect/reactivity"; |
| 8 | import { environmentSession } from "~/state/session"; |
| 9 | import type { |
| 10 | EnvironmentId, |
| 11 | ServerInstallation, |
| 12 | ServerSelfUpdateCapability, |
| 13 | } from "@t3tools/contracts"; |
| 14 | import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server"; |
| 15 | import { |
| 16 | isAtomCommandInterrupted, |
| 17 | squashAtomCommandFailure, |
| 18 | } from "@t3tools/client-runtime/state/runtime"; |
| 19 | import { CircleArrowUpIcon } from "lucide-react"; |
| 20 | import { type ComponentProps, useRef, useState } from "react"; |
| 21 | |
| 22 | import { requestConfirmDialog } from "~/confirmDialog"; |
| 23 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 24 | import { useEnvironmentSettings } from "~/hooks/useSettings"; |
| 25 | import { serverEnvironment, updateOutdatedServer } from "~/state/server"; |
| 26 | import { appAtomRegistry } from "~/rpc/atomRegistry"; |
| 27 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 28 | import { manualServerUpdateCommand } from "~/versionSkew"; |
| 29 | import { Button } from "./ui/button"; |
| 30 | import { toastManager } from "./ui/toast"; |
| 31 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 32 | |
| 33 | // The wire "installing" stage is a sub-second launcher handoff, so the UI |
| 34 | // folds it into the download phase; everything after the handoff is the |
| 35 | // restart the user is actually waiting through. |
| 36 | const UPDATE_STAGE_LABELS: Record<ServerUpdateStage, string> = { |
| 37 | downloading: "Downloading…", |
| 38 | installing: "Downloading…", |
| 39 | resuming: "Restarting…", |
| 40 | }; |
| 41 | const pendingUpdateEnvironmentIds = new Set<EnvironmentId>(); |
| 42 | |
| 43 | export function serverUpdateStageLabel(stage: ServerUpdateStage): string { |
| 44 | return UPDATE_STAGE_LABELS[stage]; |
| 45 | } |
| 46 | |
| 47 | function updateFailureMessage(error: unknown): string { |
| 48 | return error instanceof Error ? error.message : "Server update failed."; |
| 49 | } |
| 50 | |
| 51 | export interface ServerUpdateTarget { |
| 52 | readonly environmentId: EnvironmentId; |
| 53 | readonly serverLabel: string; |
| 54 | readonly selfUpdate: ServerSelfUpdateCapability | null; |
| 55 | readonly installation?: ServerInstallation | undefined; |
| 56 | readonly desktopAppUpdate?: boolean; |
| 57 | readonly threadContinuation?: boolean; |
| 58 | readonly targetVersion: string; |
| 59 | readonly continueThreadsAfterServerUpdate?: boolean; |
| 60 | } |
| 61 | |
| 62 | type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size" | "className"> & { |
| 63 | readonly label?: string; |
| 64 | /** "icon" renders a compact icon button with the label in a tooltip. */ |
| 65 | readonly appearance?: "button" | "icon"; |
| 66 | }; |
| 67 | |
| 68 | /** What {@link useServerUpdate} did. "skipped" covers a missing permission, an |
| 69 | update already in flight, and an interrupted request. */ |
| 70 | export type ServerUpdateOutcome = |
| 71 | | { readonly status: "updated" | "skipped" } |
| 72 | | { readonly status: "failed"; readonly message: string }; |
| 73 | |
| 74 | /** Updates one server and waits until it is back on the target version. |
| 75 | Toasts its success or failure unless `quiet`; never rejects. */ |
| 76 | export function useServerUpdate() { |
| 77 | const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false }); |
| 78 | return async ( |
| 79 | target: ServerUpdateTarget, |
| 80 | { quiet = false }: { readonly quiet?: boolean } = {}, |
| 81 | ): Promise<ServerUpdateOutcome> => { |
| 82 | const { environmentId, serverLabel, selfUpdate, targetVersion } = target; |
| 83 | if ( |
| 84 | !canUpdateServer(appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId))) || |
| 85 | pendingUpdateEnvironmentIds.has(environmentId) |
| 86 | ) |
| 87 | return { status: "skipped" }; |
| 88 | pendingUpdateEnvironmentIds.add(environmentId); |
| 89 | try { |
| 90 | const result = await updateServer({ |
| 91 | environmentId, |
| 92 | input: { |
| 93 | targetVersion, |
| 94 | ...(target.threadContinuation && target.continueThreadsAfterServerUpdate |
| 95 | ? { continueRunningThreads: true } |
| 96 | : {}), |
| 97 | }, |
| 98 | }); |
| 99 | if (result._tag === "Failure") { |
| 100 | if (isAtomCommandInterrupted(result)) return { status: "skipped" }; |
| 101 | throw squashAtomCommandFailure(result); |
| 102 | } |
| 103 | if (!quiet) { |
| 104 | toastManager.add({ |
| 105 | type: "success", |
| 106 | title: `${serverLabel} updated`, |
| 107 | description: |
| 108 | selfUpdate === "desktop-managed" |
| 109 | ? `Desktop app relaunched on ${result.value.targetVersion}.` |
| 110 | : `Reconnected on t3@${result.value.targetVersion}.`, |
| 111 | }); |
| 112 | } |
| 113 | return { status: "updated" }; |
| 114 | } catch (error) { |
| 115 | const message = updateFailureMessage(error); |
| 116 | if (!quiet) { |
| 117 | toastManager.add({ |
| 118 | type: "error", |
| 119 | title: `${serverLabel} update failed`, |
| 120 | description: message, |
| 121 | }); |
| 122 | } |
| 123 | return { status: "failed", message }; |
| 124 | } finally { |
| 125 | pendingUpdateEnvironmentIds.delete(environmentId); |
| 126 | } |
| 127 | }; |
| 128 | } |
| 129 | |
| 130 | /** Updates eligible machines independently; manual paths remain in the machine list. */ |
| 131 | export function ServerUpdatesAction({ |
| 132 | targets, |
| 133 | label = "Update all", |
| 134 | variant = "outline", |
| 135 | size = "xs", |
| 136 | className, |
| 137 | }: UpdateButtonProps & { |
| 138 | readonly targets: ReadonlyArray<ServerUpdateTarget>; |
| 139 | }) { |
| 140 | const update = useServerUpdate(); |
| 141 | const pending = useRef(false); |
| 142 | const [isPending, setIsPending] = useState(false); |
| 143 | const eligible = targets.filter( |
| 144 | (target) => |
| 145 | target.selfUpdate !== null && |
| 146 | (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate), |
| 147 | ); |
| 148 | const handleUpdate = async () => { |
| 149 | if (pending.current) return; |
| 150 | pending.current = true; |
| 151 | setIsPending(true); |
| 152 | try { |
| 153 | const available = eligible.filter( |
| 154 | (target) => !pendingUpdateEnvironmentIds.has(target.environmentId), |
| 155 | ); |
| 156 | const desktopTargets = available.filter((target) => target.selfUpdate === "desktop-managed"); |
| 157 | if (desktopTargets.length > 0) { |
| 158 | const confirmed = |
| 159 | (await requestConfirmDialog( |
| 160 | `Update The Knife desktop apps on ${desktopTargets.map((target) => target.serverLabel).join(", ")}? They will close and relaunch on those machines.`, |
| 161 | )) ?? true; |
| 162 | if (!confirmed) return; |
| 163 | } |
| 164 | await Promise.all(available.map((target) => update(target))); |
| 165 | } finally { |
| 166 | pending.current = false; |
| 167 | setIsPending(false); |
| 168 | } |
| 169 | }; |
| 170 | return ( |
| 171 | <Button |
| 172 | size={size} |
| 173 | variant={variant} |
| 174 | className={className} |
| 175 | disabled={isPending || eligible.length === 0} |
| 176 | onClick={() => void handleUpdate()} |
| 177 | > |
| 178 | {label} |
| 179 | </Button> |
| 180 | ); |
| 181 | } |
| 182 | |
| 183 | export function canUpdateServer( |
| 184 | result: AsyncResult.AsyncResult<AuthSessionState, unknown>, |
| 185 | ): boolean { |
| 186 | if (result._tag !== "Success" || !result.value.authenticated) return false; |
| 187 | return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope); |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * One-row status for an in-flight server update: "Downloading…" then |
| 192 | * "Restarting…". The update is a wait, not a warning: a single static dot |
| 193 | * and label, no step rail, no versions. Failure turns the row red with the |
| 194 | * rollback reason. |
| 195 | */ |
| 196 | export function ServerUpdateProgress({ |
| 197 | state, |
| 198 | }: { |
| 199 | readonly state: Exclude<ServerUpdateState, { status: "idle" }>; |
| 200 | }) { |
| 201 | if (state.status === "failed") { |
| 202 | return ( |
| 203 | <div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-destructive" role="alert"> |
| 204 | <span className="size-1.5 shrink-0 rounded-full bg-destructive" aria-hidden="true" /> |
| 205 | <Tooltip> |
| 206 | <TooltipTrigger render={<span className="min-w-0 truncate">{state.message}</span>} /> |
| 207 | <TooltipPopup side="top">{state.message}</TooltipPopup> |
| 208 | </Tooltip> |
| 209 | </div> |
| 210 | ); |
| 211 | } |
| 212 | return ( |
| 213 | <div className="mt-1 flex items-center gap-2 text-xs font-medium text-foreground"> |
| 214 | <span className="size-1.5 shrink-0 rounded-full bg-foreground" aria-hidden="true" /> |
| 215 | <span>{serverUpdateStageLabel(state.stage)}</span> |
| 216 | </div> |
| 217 | ); |
| 218 | } |
| 219 | |
| 220 | /** |
| 221 | * Offers the update path advertised by a version-skewed server. Self-updates |
| 222 | * delegate their full lifecycle to client-runtime so this component can |
| 223 | * unmount during reconnect without losing operation state. |
| 224 | */ |
| 225 | export function ServerUpdateAction({ |
| 226 | environmentId, |
| 227 | serverLabel, |
| 228 | selfUpdate, |
| 229 | installation, |
| 230 | desktopAppUpdate = false, |
| 231 | threadContinuation = false, |
| 232 | targetVersion, |
| 233 | label = "Update", |
| 234 | variant = "outline", |
| 235 | size = "xs", |
| 236 | className, |
| 237 | appearance = "button", |
| 238 | }: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) { |
| 239 | const isDesktopAppUpdate = selfUpdate === "desktop-managed"; |
| 240 | const sessionStateAtom = environmentSession.sessionStateAtom(environmentId); |
| 241 | const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom)); |
| 242 | const continueThreadsAfterServerUpdate = useEnvironmentSettings( |
| 243 | environmentId, |
| 244 | (settings) => settings.continueThreadsAfterServerUpdate, |
| 245 | ); |
| 246 | const update = useServerUpdate(); |
| 247 | const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ |
| 248 | target: installation?.kind === "npm-global" ? "update command" : "relaunch command", |
| 249 | onCopy: ({ command }) => { |
| 250 | toastManager.add({ |
| 251 | type: "success", |
| 252 | title: |
| 253 | installation?.kind === "npm-global" ? "Update command copied" : "Relaunch command copied", |
| 254 | description: |
| 255 | installation?.kind === "npm-global" |
| 256 | ? `Run \`${command}\` on ${serverLabel}, then restart t3 with your usual options.` |
| 257 | : `Stop t3 on ${serverLabel}, then relaunch with \`${command}\` using the same subcommand and options. This does not update an installed t3 command.`, |
| 258 | }); |
| 259 | }, |
| 260 | onError: (error) => { |
| 261 | toastManager.add({ |
| 262 | type: "error", |
| 263 | title: "Could not copy update command", |
| 264 | description: error.message, |
| 265 | }); |
| 266 | }, |
| 267 | }); |
| 268 | |
| 269 | const handleUpdate = async () => { |
| 270 | if ( |
| 271 | !canUpdateServer(appAtomRegistry.get(sessionStateAtom)) || |
| 272 | pendingUpdateEnvironmentIds.has(environmentId) |
| 273 | ) { |
| 274 | return; |
| 275 | } |
| 276 | if (isDesktopAppUpdate) { |
| 277 | // No themed host mounted (undefined) means proceed: the click itself |
| 278 | // was the request. This is the only confirmation in the flow; the |
| 279 | // remote machine installs without asking anyone there. |
| 280 | const confirmed = |
| 281 | (await requestConfirmDialog( |
| 282 | `Update The Knife desktop app that runs the ${serverLabel}? It will close and relaunch on that machine.`, |
| 283 | )) ?? true; |
| 284 | if (!confirmed) { |
| 285 | return; |
| 286 | } |
| 287 | } |
| 288 | if (!canUpdateServer(appAtomRegistry.get(sessionStateAtom))) return; |
| 289 | await update({ |
| 290 | environmentId, |
| 291 | serverLabel, |
| 292 | selfUpdate, |
| 293 | desktopAppUpdate, |
| 294 | threadContinuation, |
| 295 | targetVersion, |
| 296 | continueThreadsAfterServerUpdate, |
| 297 | }); |
| 298 | }; |
| 299 | |
| 300 | if (selfUpdate === "desktop-managed" && !desktopAppUpdate) { |
| 301 | return ( |
| 302 | <span className="text-muted-foreground text-xs"> |
| 303 | Update the desktop app on that machine to update this server. |
| 304 | </span> |
| 305 | ); |
| 306 | } |
| 307 | |
| 308 | const manualCommand = |
| 309 | selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null; |
| 310 | const actionLabel = |
| 311 | manualCommand !== null |
| 312 | ? installation?.kind === "npm-global" |
| 313 | ? "Copy update command" |
| 314 | : "Copy relaunch command" |
| 315 | : label; |
| 316 | const onClick = |
| 317 | manualCommand !== null |
| 318 | ? () => copyToClipboard(manualCommand, { command: manualCommand }) |
| 319 | : () => void handleUpdate(); |
| 320 | |
| 321 | if (appearance === "icon") { |
| 322 | return ( |
| 323 | <Tooltip> |
| 324 | <TooltipTrigger |
| 325 | render={ |
| 326 | <Button |
| 327 | size="icon-xs" |
| 328 | variant="ghost-muted" |
| 329 | className={className} |
| 330 | aria-label={`${actionLabel} for ${serverLabel}`} |
| 331 | disabled={manualCommand === null && !canUpdate} |
| 332 | onClick={onClick} |
| 333 | /> |
| 334 | } |
| 335 | > |
| 336 | <CircleArrowUpIcon className="size-3.5" /> |
| 337 | </TooltipTrigger> |
| 338 | <TooltipPopup side="top">{actionLabel}</TooltipPopup> |
| 339 | </Tooltip> |
| 340 | ); |
| 341 | } |
| 342 | |
| 343 | return ( |
| 344 | <Button |
| 345 | size={size} |
| 346 | variant={variant} |
| 347 | className={className} |
| 348 | disabled={manualCommand === null && !canUpdate} |
| 349 | onClick={onClick} |
| 350 | > |
| 351 | {actionLabel} |
| 352 | </Button> |
| 353 | ); |
| 354 | } |
| 355 | |
| 356 | /** |
| 357 | * Updates a host too old for this client to connect to. Its version comes |
| 358 | * from the host descriptor because the host never delivers a server config. |
| 359 | */ |
| 360 | export function OutdatedServerUpdateAction({ |
| 361 | environmentId, |
| 362 | serverLabel, |
| 363 | fromVersion, |
| 364 | targetVersion, |
| 365 | label = "Update", |
| 366 | }: { |
| 367 | readonly environmentId: EnvironmentId; |
| 368 | readonly serverLabel: string; |
| 369 | readonly fromVersion: string | undefined; |
| 370 | readonly targetVersion: string; |
| 371 | readonly label?: string; |
| 372 | }) { |
| 373 | const update = useAtomCommand(updateOutdatedServer, { reportFailure: false }); |
| 374 | const handleUpdate = async () => { |
| 375 | if (pendingUpdateEnvironmentIds.has(environmentId)) return; |
| 376 | pendingUpdateEnvironmentIds.add(environmentId); |
| 377 | try { |
| 378 | const result = await update({ |
| 379 | environmentId, |
| 380 | input: { targetVersion }, |
| 381 | ...(fromVersion === undefined ? {} : { fromVersion }), |
| 382 | }); |
| 383 | if (result._tag === "Failure") { |
| 384 | if (isAtomCommandInterrupted(result)) return; |
| 385 | throw squashAtomCommandFailure(result); |
| 386 | } |
| 387 | toastManager.add({ |
| 388 | type: "success", |
| 389 | title: `${serverLabel} updated`, |
| 390 | description: `Reconnected on t3@${result.value.targetVersion}.`, |
| 391 | }); |
| 392 | } catch (error) { |
| 393 | toastManager.add({ |
| 394 | type: "error", |
| 395 | title: `${serverLabel} update failed`, |
| 396 | description: updateFailureMessage(error), |
| 397 | }); |
| 398 | } finally { |
| 399 | pendingUpdateEnvironmentIds.delete(environmentId); |
| 400 | } |
| 401 | }; |
| 402 | return ( |
| 403 | <Button size="xs" variant="outline" onClick={() => void handleUpdate()}> |
| 404 | {label} |
| 405 | </Button> |
| 406 | ); |
| 407 | } |