LCOV - code coverage report
Current view: top level - dashql-app/src/ui/version - version_viewer.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 79.5 % 297 236
Test Date: 2026-11-06 19:30:32

            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 : }
        

Generated by: LCOV version 2.0-1