Line data Source code
1 : import * as React from 'react';
2 : import symbols from '@ankoh/dashql-svg-symbols';
3 : import * as styles from './version_viewer.module.css';
4 :
5 : import { SyncIcon, XIcon } from '../foundations/symbol_icon.js';
6 :
7 : import { DASHQL_GIT_COMMIT, DASHQL_VERSION } from '../../globals.js';
8 : import {
9 : InstallableUpdate,
10 : InstallationStatusCode,
11 : InstallationState,
12 : useLatestReleaseManifest,
13 : useLatestUpdateManifest,
14 : useInstallationStatus,
15 : useStableReleaseManifest,
16 : useStableUpdateManifest,
17 : useVersionCheck,
18 : useVersionCheckRefresh,
19 : VersionCheckStatusCode,
20 : } from '../../platform/version/version_check.js';
21 : import { Button, ButtonSize, ButtonVariant, IconButton } from '../foundations/button.js';
22 : import { PlatformType, usePlatformType } from '../../platform/platform_type.js';
23 : import { canInstallReleaseVersion, detectReleaseChannel, ReleaseChannel, ReleaseManifest } from '../../platform/version/web_version_check.js';
24 : import { Result, RESULT_ERROR, RESULT_OK } from '../../utils/result.js';
25 : import { IndicatorStatus, StatusIndicator } from '../foundations/status_indicator.js';
26 : import { ReleaseBundles } from './release_bundle.js';
27 : import { AnchoredOverlay } from '../foundations/anchored_overlay.js';
28 : import { AnchorAlignment, AnchorSide } from '../foundations/anchored_position.js';
29 : import { ProgressBar } from '../foundations/progress_bar.js';
30 : import { relaunch } from '../../platform/process.js';
31 : import { useLogger } from '../../platform/logger/logger_provider.js';
32 :
33 1 : const VERSION_CHECK_INTERVAL_MS = 5 * 60 * 1000;
34 :
35 : interface UpdateChannelProps {
36 : channel: ReleaseChannel;
37 : name: string;
38 : /// Whether this is the channel the app currently tracks for updates
39 : isActive: boolean;
40 : releaseManifest: Result<ReleaseManifest | null> | null;
41 : updateManifest: Result<InstallableUpdate | null> | null;
42 : installationStatus: InstallationState | null;
43 : /// Install an update from this channel. Also persists the channel as the active one.
44 : onInstall: (channel: ReleaseChannel, update: InstallableUpdate) => void;
45 : }
46 :
47 1 : const UpdateChannel: React.FC<UpdateChannelProps> = (props: UpdateChannelProps) => {
48 1 : let versionName: string | null = null;
49 1 : let status: React.ReactElement | null = null;
50 :
51 : // Get the newest version of the channel from the release manifest
52 1 : if (props.releaseManifest?.type === RESULT_OK) {
53 1 : if (props.releaseManifest.value != null) {
54 1 : versionName = props.releaseManifest.value.version;
55 : }
56 : }
57 1 : switch (props.updateManifest?.type) {
58 1 : case RESULT_OK:
59 : // Check if there's an installation status.
60 : // If there is no installation ongoing, we render a download button.
61 : // If the installation status refers to this channel, we render the installation status instead.
62 : // If it's not referring to this channel, we render nothing.
63 1 : if (props.installationStatus == null) {
64 : // Installs only move forward, also when switching channels. Moving from a latest build back
65 : // to an older stable would run it on storage the latest build already migrated.
66 1 : const isInstallable = versionName != null && canInstallReleaseVersion(versionName, DASHQL_VERSION);
67 1 : if (!isInstallable) {
68 1 : status = props.isActive
69 0 : ? <span className={styles.update_channel_action_status}>up to date</span>
70 1 : : null;
71 0 : } else if (props.updateManifest.value != null) {
72 : // The upgrade is installable, render a button to trigger installation.
73 0 : const installable = props.updateManifest.value;
74 0 : status = <Button size={ButtonSize.Small} onClick={() => props.onInstall(props.channel, installable)}>Install</Button>;
75 0 : } else {
76 : // The manifest advertises a newer version but no installable update was returned.
77 : // update yet (e.g. the check is still in flight or failed). Surface it without an action.
78 0 : status = <span className={styles.update_channel_action_status}>update available</span>;
79 : }
80 1 : } else {
81 : // Does the current installation refer to this channel?
82 0 : if (props.installationStatus.update == props.updateManifest.value) {
83 0 : console.log(props.installationStatus);
84 : // Do we know the total bytes already?
85 : // Render a query_status bar then.
86 0 : if (props.installationStatus.totalBytes != null && props.installationStatus.totalBytes > 0) {
87 0 : if (props.installationStatus.statusCode == InstallationStatusCode.InProgress) {
88 0 : const progress = props.installationStatus.loadedBytes / props.installationStatus.totalBytes;
89 0 : console.log(progress * 100);
90 0 : status = <ProgressBar className={styles.update_channel_action_progress} progress={progress * 100} />;
91 0 : } else {
92 0 : status = (
93 0 : <StatusIndicator width="16px" height="16px" status={IndicatorStatus.Succeeded} />
94 : );
95 : }
96 0 : } else {
97 : // Otherwise just tell the user we're doing something
98 0 : status = <span>installing</span>
99 : }
100 0 : } else {
101 : // Otherwise render nothing since we're installing a different update
102 : }
103 : }
104 1 : break;
105 0 : case RESULT_ERROR:
106 0 : status = <span>{(props.updateManifest.error as Error).message}</span>;
107 : break;
108 1 : }
109 1 : return (
110 1 : <>
111 1 : <div className={styles.update_channel_version}>
112 1 : <svg className={styles.update_channel_version_icon} width="16px" height="16px">
113 1 : <use xlinkHref={`${symbols}#package`} />
114 1 : </svg>
115 1 : <div className={styles.update_channel_version_name}>
116 1 : {versionName}
117 1 : </div>
118 1 : </div>
119 1 : <div className={styles.update_channel_name}>
120 1 : {props.name}
121 1 : </div>
122 1 : <div className={styles.update_channel_action}>
123 1 : {status ?? undefined}
124 1 : </div>
125 1 : </>
126 : );
127 1 : }
128 :
129 : interface VersionViewerProps {
130 : onClose: () => void;
131 : onRefresh: () => void;
132 : }
133 :
134 1 : export const VersionInfo: React.FC<VersionViewerProps> = (props: VersionViewerProps) => {
135 1 : const platformType = usePlatformType();
136 1 : const isWebPlatform = platformType == PlatformType.WEB;
137 1 : const stableReleaseManifest = useStableReleaseManifest();
138 1 : const stableUpdateManifest = useStableUpdateManifest();
139 1 : const latestReleaseManifest = useLatestReleaseManifest();
140 1 : const latestUpdateManifest = useLatestUpdateManifest();
141 1 : const installationStatus = useInstallationStatus();
142 1 : const versionCheck = useVersionCheck();
143 1 : const logger = useLogger();
144 1 : const [relaunching, setRelaunching] = React.useState(false);
145 :
146 : // The channel the app currently tracks. It is fully dictated by the installed version scheme.
147 1 : const activeChannel: ReleaseChannel = detectReleaseChannel(DASHQL_VERSION);
148 1 : const activeUpdateManifest = activeChannel == "latest" ? latestUpdateManifest : stableUpdateManifest;
149 :
150 : // Are we on the most recent version of the active channel?
151 : // A successful update check that yields no update means we're up to date.
152 1 : let isUpToDate: boolean | null = null;
153 1 : if (activeUpdateManifest?.type === RESULT_OK) {
154 0 : isUpToDate = activeUpdateManifest.value == null;
155 : }
156 :
157 : // Install an update. The app restarts on the new version afterwards, so the channel re-derives itself.
158 1 : const onInstall = React.useCallback((_channel: ReleaseChannel, update: InstallableUpdate) => {
159 0 : update.download().catch((e: unknown) => {
160 0 : console.error(e);
161 0 : });
162 1 : }, []);
163 :
164 1 : const onRelaunch = React.useCallback(() => {
165 0 : if (relaunching) {
166 0 : return;
167 : }
168 0 : setRelaunching(true);
169 0 : relaunch(logger).catch((e: unknown) => {
170 0 : setRelaunching(false);
171 0 : console.error(e);
172 0 : });
173 1 : }, [logger, relaunching]);
174 :
175 1 : if (versionCheck == VersionCheckStatusCode.RestartPending) {
176 0 : return (
177 0 : <div className={styles.restart_overlay}>
178 0 : <div className={styles.header_container}>
179 0 : <div className={styles.header_left_container}>
180 0 : <div className={styles.title}>Update ready</div>
181 0 : </div>
182 0 : <div className={styles.header_right_container}>
183 0 : <IconButton
184 0 : variant={ButtonVariant.Invisible}
185 0 : aria-label="Close"
186 0 : onClick={props.onClose}
187 : >
188 0 : <XIcon />
189 0 : </IconButton>
190 0 : </div>
191 0 : </div>
192 0 : <div className={styles.restart_body}>
193 0 : <div className={styles.restart_text}>
194 : A new version has been installed. Relaunch to apply the update.
195 0 : </div>
196 0 : <Button
197 0 : variant={ButtonVariant.Primary}
198 0 : block
199 0 : disabled={relaunching}
200 0 : onClick={onRelaunch}
201 : >
202 0 : {relaunching ? 'Relaunching...' : 'Relaunch'}
203 0 : </Button>
204 0 : </div>
205 0 : </div>
206 : );
207 : }
208 :
209 1 : return (
210 1 : <div className={styles.overlay}>
211 1 : <div className={styles.header_container}>
212 1 : <div className={styles.header_left_container}>
213 1 : <div className={styles.title}>Version</div>
214 1 : </div>
215 1 : <div className={styles.header_right_container}>
216 1 : <VersionRefreshButton onRefresh={props.onRefresh} />
217 1 : <IconButton
218 1 : variant={ButtonVariant.Invisible}
219 1 : aria-label="Close"
220 1 : onClick={props.onClose}
221 : >
222 1 : <XIcon />
223 1 : </IconButton>
224 1 : </div>
225 1 : </div>
226 1 : <div className={styles.version_info_container}>
227 1 : <div className={styles.version_info_key}>
228 : Current Version
229 1 : </div>
230 1 : <div className={styles.version_info_value}>
231 1 : <span>{DASHQL_VERSION}</span>
232 1 : {!isWebPlatform && isUpToDate === true && (
233 0 : <span className={styles.version_info_up_to_date}>up to date</span>
234 : )}
235 1 : </div>
236 1 : <div className={styles.version_info_key}>
237 : Channel
238 1 : </div>
239 1 : <div className={styles.version_info_value}>
240 1 : {activeChannel}
241 1 : </div>
242 1 : <div className={styles.version_info_key}>
243 : Git Commit
244 1 : </div>
245 1 : <div className={styles.version_info_value}>
246 1 : {DASHQL_GIT_COMMIT}
247 1 : </div>
248 1 : </div>
249 1 : {!isWebPlatform && (
250 1 : <div className={styles.update_channels_container}>
251 1 : <div className={styles.update_channels_title}>
252 : Release Channels
253 1 : </div>
254 1 : <div className={styles.update_channel_list}>
255 1 : <UpdateChannel
256 1 : channel="stable"
257 1 : name="stable"
258 1 : isActive={activeChannel == "stable"}
259 1 : releaseManifest={stableReleaseManifest}
260 1 : updateManifest={stableUpdateManifest}
261 1 : installationStatus={installationStatus}
262 1 : onInstall={onInstall}
263 1 : />
264 1 : <UpdateChannel
265 1 : channel="latest"
266 1 : name="latest"
267 1 : isActive={activeChannel == "latest"}
268 1 : releaseManifest={latestReleaseManifest}
269 1 : updateManifest={latestUpdateManifest}
270 1 : installationStatus={installationStatus}
271 1 : onInstall={onInstall}
272 1 : />
273 1 : </div>
274 1 : </div>
275 : )}
276 1 : {isWebPlatform && <ReleaseBundles className={styles.release_bundles} />}
277 1 : </div>
278 : );
279 1 : }
280 :
281 : type VersionInfoOverlayProps = {
282 : isOpen: boolean;
283 : onOpen?: () => void;
284 : onClose: () => void;
285 : renderAnchor: (p: object) => React.ReactElement;
286 : side?: AnchorSide;
287 : align?: AnchorAlignment;
288 : anchorOffset?: number;
289 : }
290 1 : export function VersionInfoOverlay(props: VersionInfoOverlayProps) {
291 1 : const refreshVersionCheck = useVersionCheckRefresh();
292 1 : const lastOverlayCheckAt = React.useRef<number | null>(null);
293 1 : const refresh = React.useCallback(() => {
294 1 : lastOverlayCheckAt.current = Date.now();
295 1 : refreshVersionCheck?.refresh();
296 1 : }, [refreshVersionCheck?.refresh]);
297 1 : React.useEffect(() => {
298 1 : if (props.isOpen && refreshVersionCheck != null) {
299 1 : const now = Date.now();
300 1 : if (lastOverlayCheckAt.current != null && now - lastOverlayCheckAt.current < VERSION_CHECK_INTERVAL_MS) {
301 1 : return;
302 : }
303 1 : refresh();
304 1 : }
305 1 : }, [props.isOpen, refresh]);
306 :
307 1 : return (
308 1 : <AnchoredOverlay
309 1 : open={props.isOpen}
310 1 : onOpen={props.onOpen}
311 1 : onClose={props.onClose}
312 1 : renderAnchor={props.renderAnchor}
313 1 : side={props.side}
314 1 : align={props.align}
315 1 : anchorOffset={props.anchorOffset}
316 : >
317 1 : <VersionInfo onClose={props.onClose} onRefresh={refresh} />
318 1 : </AnchoredOverlay>
319 : );
320 1 : }
321 :
322 1 : function VersionRefreshButton(props: {onRefresh: () => void}) {
323 1 : const refreshVersionCheck = useVersionCheckRefresh();
324 1 : if (refreshVersionCheck == null) {
325 0 : return null;
326 : }
327 1 : return (
328 1 : <IconButton
329 1 : variant={ButtonVariant.Invisible}
330 1 : aria-label={refreshVersionCheck.isRefreshing ? 'Checking for updates' : 'Check for updates'}
331 1 : aria-disabled={refreshVersionCheck.isRefreshing}
332 1 : data-testid="version-check-refresh"
333 1 : onClick={() => {
334 1 : if (!refreshVersionCheck.isRefreshing) {
335 1 : props.onRefresh();
336 : }
337 1 : }}
338 : >
339 1 : <span aria-hidden="true">
340 1 : {refreshVersionCheck.isRefreshing
341 1 : ? <StatusIndicator width="16px" height="16px" fill="currentColor" status={IndicatorStatus.Running} />
342 1 : : <SyncIcon />}
343 1 : </span>
344 1 : </IconButton>
345 : );
346 1 : }
347 :
348 : interface VersionCheckIndicatorProps {
349 : status: VersionCheckStatusCode;
350 : /// The size of the package icon in px, 16 by default, the badge of the status keeps its size
351 : size?: number;
352 : }
353 :
354 1 : export function VersionCheckIndicator(props: VersionCheckIndicatorProps) {
355 1 : const size = `${props.size ?? 16}px`;
356 1 : switch (props.status) {
357 1 : case VersionCheckStatusCode.Unknown:
358 1 : case VersionCheckStatusCode.Disabled:
359 1 : case VersionCheckStatusCode.UpToDate:
360 1 : return (
361 1 : <div className={styles.version_check_container}>
362 1 : <div className={styles.version_check_icon}>
363 1 : <svg width={size} height={size}>
364 1 : <use xlinkHref={`${symbols}#package`} />
365 1 : </svg>
366 1 : </div>
367 1 : </div>
368 : );
369 1 : case VersionCheckStatusCode.UpdateAvailable:
370 1 : return (
371 1 : <div className={styles.version_check_container_with_indicator}>
372 1 : <div className={styles.version_check_icon}>
373 1 : <svg width={size} height={size}>
374 1 : <use xlinkHref={`${symbols}#package_cut_24`} />
375 1 : </svg>
376 1 : </div>
377 1 : <div className={styles.version_check_indicator}>
378 1 : <svg width="10px" height="10px">
379 1 : <use xlinkHref={`${symbols}#alert_fill_12`} />
380 1 : </svg>
381 1 : </div>
382 1 : </div>
383 : );
384 1 : case VersionCheckStatusCode.UpdateInstalling:
385 1 : return (
386 1 : <div className={styles.version_check_container_with_indicator}>
387 1 : <div className={styles.version_check_icon}>
388 1 : <svg width={size} height={size}>
389 1 : <use xlinkHref={`${symbols}#package_cut_24`} />
390 1 : </svg>
391 1 : </div>
392 1 : <div className={styles.version_check_indicator}>
393 1 : <svg width="10px" height="10px">
394 1 : <use xlinkHref={`${symbols}#refresh`} />
395 1 : </svg>
396 1 : </div>
397 1 : </div>
398 : );
399 1 : case VersionCheckStatusCode.RestartPending:
400 1 : return (
401 1 : <div className={styles.version_check_container_with_indicator}>
402 1 : <div className={styles.version_check_icon}>
403 1 : <svg width={size} height={size}>
404 1 : <use xlinkHref={`${symbols}#package_cut_24`} />
405 1 : </svg>
406 1 : </div>
407 1 : <div className={styles.version_check_indicator}>
408 1 : <svg width="10px" height="10px">
409 1 : <use xlinkHref={`${symbols}#rocket_circle_16`} />
410 1 : </svg>
411 1 : </div>
412 1 : </div>
413 : );
414 1 : case VersionCheckStatusCode.UpdateFailed:
415 1 : return (
416 1 : <div className={styles.version_check_container_with_indicator}>
417 1 : <div className={styles.version_check_icon}>
418 1 : <svg width={size} height={size}>
419 1 : <use xlinkHref={`${symbols}#package_cut_24`} />
420 1 : </svg>
421 1 : </div>
422 1 : <div className={styles.version_check_indicator}>
423 1 : <svg width="10px" height="10px">
424 1 : <use xlinkHref={`${symbols}#x_circle_16`} />
425 1 : </svg>
426 1 : </div>
427 1 : </div>
428 : );
429 1 : }
430 1 : }
|