Open History to see the last commit
1import { useAtomValue } from "@effect/atom-react";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
3import { useState } from "react";
4
5import { environmentIdsKey } from "~/state/environmentAtomList";
6import type { EnvironmentPresentation } from "~/state/environments";
7import { environmentUpdateStatesAtom } from "~/state/server";
8import {
9 buildVersionMismatchDismissalKey,
10 dismissServerUpdateFailure,
11 dismissVersionMismatch,
12 isServerUpdateFailureDismissed,
13 isVersionMismatchDismissed,
14 resolveServerConfigVersionMismatch,
15 resolveServerSelfUpdateCapability,
16 supportsDesktopAppUpdate,
17} from "~/versionSkew";
18import {
19 ServerUpdateAction,
20 ServerUpdateProgress,
21 ServerUpdatesAction,
22} from "../ServerUpdateAction";
23import { InlineButton } from "../ui/button";
24import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
25import type { ComposerBannerStackItem } from "./ComposerBannerStack";
26import { ComposerServerUpdateIcon } from "./ComposerServerUpdateStatus";
27
28export function useAutoBalanceUpdateBanner(
29 environments: readonly EnvironmentPresentation[],
30): ComposerBannerStackItem | null {
31 const updateStates = useAtomValue(
32 environmentUpdateStatesAtom(
33 environmentIdsKey(environments.map(({ environmentId }) => environmentId)),
34 ),
35 );
36 const states = environments.flatMap((environment, index) => {
37 const state = updateStates[index];
38 return state ? [{ environment, state }] : [];
39 });
40 const [dismissedNotices, setDismissedNotices] = useState<ReadonlySet<string | ServerUpdateState>>(
41 () => new Set(),
42 );
43 const machines = states.flatMap(({ environment, state }) => {
44 const mismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
45 const dismissKey = mismatch
46 ? buildVersionMismatchDismissalKey(environment.environmentId, mismatch)
47 : null;
48 if (
49 state.status === "idle"
50 ? !mismatch ||
51 (dismissKey !== null && dismissedNotices.has(dismissKey)) ||
52 isVersionMismatchDismissed(dismissKey)
53 : dismissedNotices.has(state) || isServerUpdateFailureDismissed(state)
54 )
55 return [];
56 const selfUpdate = resolveServerSelfUpdateCapability(environment.serverConfig);
57 const desktopAppUpdate = supportsDesktopAppUpdate(environment.serverConfig);
58 return [
59 {
60 environmentId: environment.environmentId,
61 serverLabel: environment.label,
62 selfUpdate,
63 installation: environment.serverConfig?.environment.capabilities.serverInstallation,
64 desktopAppUpdate,
65 targetVersion: state.status === "idle" ? mismatch!.clientVersion : state.targetVersion,
66 connected: environment.connection.phase === "connected",
67 remoteUpdate: selfUpdate !== null && (selfUpdate !== "desktop-managed" || desktopAppUpdate),
68 state,
69 dismissKey,
70 },
71 ];
72 });
73 if (machines.length === 0) return null;
74
75 const running = machines.filter((machine) => machine.state.status === "running").length;
76 const failed = machines.filter((machine) => machine.state.status === "failed").length;
77 const manual = machines.filter((machine) => !machine.remoteUpdate).length;
78 const targets = machines.filter(
79 (machine) => machine.connected && machine.remoteUpdate && machine.state.status !== "running",
80 );
81 const count = running || failed || machines.length;
82 const status = running ? "running" : failed ? "failed" : "idle";
83 const prefix = running ? "Updating" : failed ? "Could not update" : "Update available for";
84 const title = `${prefix} ${count} ${count === 1 ? "machine" : "machines"}`;
85 return {
86 id: `auto-balance-server-updates-${dismissedNotices.size}`,
87 variant: failed ? "error" : "default",
88 group: "environment",
89 icon: <ComposerServerUpdateIcon status={status} />,
90 title: (
91 <Popover>
92 <PopoverTrigger
93 render={<InlineButton />}
94 className="max-w-full"
95 aria-label={`${title}. View machines`}
96 >
97 <span className="min-w-0 truncate">{title}</span>
98 </PopoverTrigger>
99 <PopoverPopup side="top" align="start" width="md">
100 <div className="space-y-3 text-xs">
101 {machines.map((machine) => (
102 <div key={machine.environmentId} className="space-y-1">
103 <div className="font-medium">{machine.serverLabel}</div>
104 {machine.state.status !== "idle" ? (
105 <ServerUpdateProgress state={machine.state} />
106 ) : !machine.remoteUpdate ? (
107 <>
108 <div className="text-muted-foreground">Manual update required</div>
109 <ServerUpdateAction {...machine} />
110 </>
111 ) : (
112 <div className="text-muted-foreground">
113 {machine.connected
114 ? `Ready to update to ${machine.targetVersion}`
115 : "Reconnect this machine to update"}
116 </div>
117 )}
118 </div>
119 ))}
120 </div>
121 </PopoverPopup>
122 </Popover>
123 ),
124 description:
125 manual > 0 ? `${manual} ${manual === 1 ? "needs" : "need"} a manual update` : undefined,
126 actions:
127 running === 0 && targets.length > 0 ? (
128 <ServerUpdatesAction
129 targets={targets}
130 variant="ghost"
131 label={
132 failed > 0
133 ? "Retry"
134 : targets.length === machines.length
135 ? "Update all"
136 : `Update ${targets.length} ${targets.length === 1 ? "machine" : "machines"}`
137 }
138 />
139 ) : undefined,
140 dismissLabel: "Dismiss update notice",
141 ...(running
142 ? {}
143 : {
144 onDismiss: () => {
145 const next = new Set(dismissedNotices);
146 for (const { state, dismissKey } of machines) {
147 dismissServerUpdateFailure(state);
148 dismissVersionMismatch(dismissKey);
149 if (dismissKey) next.add(dismissKey);
150 if (state.status === "failed") next.add(state);
151 }
152 setDismissedNotices(next);
153 },
154 }),
155 };
156}