go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/build/legacy/build_page/build_page.tsx (about)

     1  // Copyright 2020 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  import { css } from '@emotion/react';
    16  import { LinearProgress, Link } from '@mui/material';
    17  import { observer } from 'mobx-react-lite';
    18  import { useEffect } from 'react';
    19  import { Link as RouterLink, useParams } from 'react-router-dom';
    20  
    21  import grayFavicon from '@/common/assets/favicons/gray-32.png';
    22  import greenFavicon from '@/common/assets/favicons/green-32.png';
    23  import miloFavicon from '@/common/assets/favicons/milo-32.png';
    24  import purpleFavicon from '@/common/assets/favicons/purple-32.png';
    25  import redFavicon from '@/common/assets/favicons/red-32.png';
    26  import tealFavicon from '@/common/assets/favicons/teal-32.png';
    27  import yellowFavicon from '@/common/assets/favicons/yellow-32.png';
    28  import { RecoverableErrorBoundary } from '@/common/components/error_handling';
    29  import { usePageSpecificConfig } from '@/common/components/page_config_state_provider';
    30  import { PageMeta } from '@/common/components/page_meta/page_meta';
    31  import { AppRoutedTab, AppRoutedTabs } from '@/common/components/routed_tabs';
    32  import {
    33    BUILD_STATUS_CLASS_MAP,
    34    BUILD_STATUS_COLOR_THEME_MAP,
    35    BUILD_STATUS_DISPLAY_MAP,
    36  } from '@/common/constants/legacy';
    37  import { UiPage } from '@/common/constants/view';
    38  import { BuildbucketStatus } from '@/common/services/buildbucket';
    39  import { useStore } from '@/common/store';
    40  import { InvocationProvider } from '@/common/store/invocation_state';
    41  import { displayDuration, LONG_TIME_FORMAT } from '@/common/tools/time_utils';
    42  import {
    43    getBuilderURLPath,
    44    getLegacyBuildURLPath,
    45    getProjectURLPath,
    46  } from '@/common/tools/url_utils';
    47  
    48  import { CountIndicator } from '../../../test_verdict/legacy/test_results_tab/count_indicator';
    49  
    50  import { BuildLitEnvProvider } from './build_lit_env_provider';
    51  import { ChangeConfigDialog } from './change_config_dialog';
    52  import { BuildContextProvider } from './context';
    53  import { CustomBugLink } from './custom_bug_link';
    54  
    55  const STATUS_FAVICON_MAP = Object.freeze({
    56    [BuildbucketStatus.Scheduled]: grayFavicon,
    57    [BuildbucketStatus.Started]: yellowFavicon,
    58    [BuildbucketStatus.Success]: greenFavicon,
    59    [BuildbucketStatus.Failure]: redFavicon,
    60    [BuildbucketStatus.InfraFailure]: purpleFavicon,
    61    [BuildbucketStatus.Canceled]: tealFavicon,
    62  });
    63  
    64  const delimiter = css({
    65    borderLeft: '1px solid var(--divider-color)',
    66    width: '1px',
    67    marginLeft: '10px',
    68    marginRight: '10px',
    69    '& + &': {
    70      display: 'none',
    71    },
    72  });
    73  
    74  export const BuildPage = observer(() => {
    75    const { project, bucket, builder, buildNumOrId } = useParams();
    76    const store = useStore();
    77  
    78    if (!project || !bucket || !builder || !buildNumOrId) {
    79      throw new Error(
    80        'invariant violated: project, bucket, builder, buildNumOrId should be set',
    81      );
    82    }
    83  
    84    const [showConfigDialog, setShowConfigDialog] = usePageSpecificConfig();
    85  
    86    useEffect(() => {
    87      store.buildPage.setParams({ project, bucket, builder }, buildNumOrId);
    88    }, [store, project, bucket, builder, buildNumOrId]);
    89  
    90    const build = store.buildPage.build;
    91  
    92    const status = build?.data?.status;
    93    const statusDisplay = status ? BUILD_STATUS_DISPLAY_MAP[status] : 'loading';
    94    const documentTitle = `${statusDisplay} - ${builder} ${buildNumOrId}`;
    95  
    96    const faviconUrl = build
    97      ? STATUS_FAVICON_MAP[build.data.status]
    98      : miloFavicon;
    99    useEffect(() => {
   100      document.getElementById('favicon')?.setAttribute('href', faviconUrl);
   101    }, [faviconUrl]);
   102  
   103    const handleSwitchVersion = (
   104      e: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
   105    ) => {
   106      const switchVerTemporarily =
   107        e.metaKey || e.shiftKey || e.ctrlKey || e.altKey;
   108  
   109      if (switchVerTemporarily) {
   110        return;
   111      }
   112  
   113      const expires = new Date(
   114        Date.now() + 365 * 24 * 60 * 60 * 1000,
   115      ).toUTCString();
   116      document.cookie = `showNewBuildPage=false; expires=${expires}; path=/`;
   117      store.redirectSw?.unregister();
   118    };
   119  
   120    return (
   121      <BuildContextProvider build={store.buildPage.build?.data || null}>
   122        <InvocationProvider value={store.buildPage.invocation}>
   123          <BuildLitEnvProvider>
   124            <PageMeta
   125              project={project}
   126              selectedPage={UiPage.Builders}
   127              title={documentTitle}
   128            />
   129            <ChangeConfigDialog
   130              open={showConfigDialog}
   131              onClose={() => setShowConfigDialog(false)}
   132            />
   133            <div
   134              css={{
   135                backgroundColor: 'var(--block-background-color)',
   136                padding: '6px 16px',
   137                display: 'flex',
   138              }}
   139            >
   140              <div css={{ flex: '0 auto' }}>
   141                <span css={{ color: 'var(--light-text-color)' }}>Build </span>
   142                <Link component={RouterLink} to={getProjectURLPath(project)}>
   143                  {project}
   144                </Link>
   145                <span>&nbsp;/&nbsp;</span>
   146                <span>{bucket}</span>
   147                <span>&nbsp;/&nbsp;</span>
   148                <Link
   149                  component={RouterLink}
   150                  to={getBuilderURLPath({ project, bucket, builder })}
   151                >
   152                  {builder}
   153                </Link>
   154                <span>&nbsp;/&nbsp;</span>
   155                <span>{buildNumOrId}</span>
   156              </div>
   157              <div css={delimiter}></div>
   158              <CustomBugLink project={project} build={build?.data} />
   159              <div css={delimiter}></div>
   160              <Link
   161                onClick={handleSwitchVersion}
   162                href={getLegacyBuildURLPath(
   163                  { project, bucket, builder },
   164                  buildNumOrId,
   165                )}
   166              >
   167                Switch to the legacy build page
   168              </Link>
   169              <div
   170                css={{
   171                  marginLeft: 'auto',
   172                  flex: '0 auto',
   173                }}
   174              >
   175                {build && (
   176                  <>
   177                    <i
   178                      className={`status ${
   179                        BUILD_STATUS_CLASS_MAP[build.data.status]
   180                      }`}
   181                    >
   182                      {BUILD_STATUS_DISPLAY_MAP[build.data.status] ||
   183                        'unknown status'}{' '}
   184                    </i>
   185                    {(() => {
   186                      switch (build.data.status) {
   187                        case BuildbucketStatus.Scheduled:
   188                          return `since ${build.createTime.toFormat(
   189                            LONG_TIME_FORMAT,
   190                          )}`;
   191                        case BuildbucketStatus.Started:
   192                          return `since ${build.startTime!.toFormat(
   193                            LONG_TIME_FORMAT,
   194                          )}`;
   195                        case BuildbucketStatus.Canceled:
   196                          return `after ${displayDuration(
   197                            build.endTime!.diff(build.createTime),
   198                          )} by ${build.data.canceledBy || 'unknown'}`;
   199                        case BuildbucketStatus.Failure:
   200                        case BuildbucketStatus.InfraFailure:
   201                        case BuildbucketStatus.Success:
   202                          return `after ${displayDuration(
   203                            build.endTime!.diff(
   204                              build.startTime || build.createTime,
   205                            ),
   206                          )}`;
   207                        default:
   208                          return '';
   209                      }
   210                    })()}
   211                  </>
   212                )}
   213              </div>
   214            </div>
   215            <LinearProgress
   216              value={100}
   217              variant={build ? 'determinate' : 'indeterminate'}
   218              color={
   219                build
   220                  ? BUILD_STATUS_COLOR_THEME_MAP[build.data.status]
   221                  : 'primary'
   222              }
   223            />
   224            <AppRoutedTabs>
   225              <AppRoutedTab label="Overview" value="overview" to="overview" />
   226              <AppRoutedTab
   227                label="Test Results"
   228                value="test-results"
   229                to="test-results"
   230                hideWhenInactive={
   231                  !store.buildPage.hasInvocation ||
   232                  !store.buildPage.canReadTestVerdicts
   233                }
   234                icon={<CountIndicator />}
   235                iconPosition="end"
   236              />
   237              <AppRoutedTab
   238                label="Steps & Logs"
   239                value="steps"
   240                to="steps"
   241                hideWhenInactive={!store.buildPage.canReadFullBuild}
   242              />
   243              <AppRoutedTab
   244                label="Related Builds"
   245                value="related-builds"
   246                to="related-builds"
   247                hideWhenInactive={!store.buildPage.canReadFullBuild}
   248              />
   249              <AppRoutedTab
   250                label="Timeline"
   251                value="timeline"
   252                to="timeline"
   253                hideWhenInactive={!store.buildPage.canReadFullBuild}
   254              />
   255              <AppRoutedTab
   256                label="Blamelist"
   257                value="blamelist"
   258                to="blamelist"
   259                hideWhenInactive={!store.buildPage.canReadFullBuild}
   260              />
   261            </AppRoutedTabs>
   262          </BuildLitEnvProvider>
   263        </InvocationProvider>
   264      </BuildContextProvider>
   265    );
   266  });
   267  
   268  export const element = (
   269    // See the documentation for `<LoginPage />` for why we handle error this way.
   270    <RecoverableErrorBoundary key="build-long-link">
   271      <BuildPage />
   272    </RecoverableErrorBoundary>
   273  );