go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/test_verdict/legacy/invocation_page/invocation_details_tab.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 '@material/mwc-icon';
    16  import { css, html } from 'lit';
    17  import { customElement } from 'lit/decorators.js';
    18  import { styleMap } from 'lit/directives/style-map.js';
    19  import { DateTime } from 'luxon';
    20  import { autorun, computed, makeObservable, observable } from 'mobx';
    21  
    22  import '@/common/components/timeline';
    23  import { RecoverableErrorBoundary } from '@/common/components/error_handling';
    24  import { TimelineBlock } from '@/common/components/timeline';
    25  import { Invocation } from '@/common/services/resultdb';
    26  import { consumeStore, StoreInstance } from '@/common/store';
    27  import {
    28    consumeInvocationState,
    29    InvocationStateInstance,
    30  } from '@/common/store/invocation_state';
    31  import { commonStyles } from '@/common/styles/stylesheets';
    32  import { logging } from '@/common/tools/logging';
    33  import { MobxExtLitElement } from '@/generic_libs/components/lit_mobx_ext';
    34  import { useTabId } from '@/generic_libs/components/routed_tabs';
    35  import { consumer } from '@/generic_libs/tools/lit_context';
    36  
    37  const MARGIN = 20;
    38  const MIN_GRAPH_WIDTH = 900;
    39  
    40  function stripInvocationPrefix(invocationName: string): string {
    41    return invocationName.slice('invocations/'.length);
    42  }
    43  
    44  @customElement('milo-invocation-details-tab')
    45  @consumer
    46  export class InvocationDetailsTabElement extends MobxExtLitElement {
    47    @observable.ref
    48    @consumeStore()
    49    store!: StoreInstance;
    50  
    51    @observable.ref
    52    @consumeInvocationState()
    53    invState!: InvocationStateInstance;
    54  
    55    @computed
    56    private get hasTags() {
    57      return (this.invState.invocation!.tags || []).length > 0;
    58    }
    59  
    60    @observable
    61    private graphWidth = MIN_GRAPH_WIDTH;
    62  
    63    @observable
    64    private numRequestsCompleted = 0;
    65  
    66    private includedInvocations: Invocation[] = [];
    67  
    68    constructor() {
    69      super();
    70      makeObservable(this);
    71      this.addDisposer(
    72        autorun(() => {
    73          try {
    74            if (
    75              !this.invState ||
    76              !this.invState.invocation ||
    77              !this.invState.invocation.includedInvocations
    78            ) {
    79              return;
    80            }
    81            let invs = this.invState.invocation.includedInvocations || [];
    82            // No more than 512 requests in flight at a time to prevent browsers cancelling them
    83            // TODO: implement a BatchGetInvocation call in ResultDB and remove this compensation.
    84            const delayedInvs = invs.slice(512);
    85            invs = invs.slice(0, 512);
    86            const invocationReceivedCallback = (invocation: Invocation) => {
    87              this.includedInvocations.push(invocation);
    88              // this.numRequestsCompleted += 1;
    89              this.batchRequestComplete();
    90              if (delayedInvs.length) {
    91                this.store.services.resultDb
    92                  ?.getInvocation(
    93                    { name: delayedInvs.pop()! },
    94                    { skipUpdate: true },
    95                  )
    96                  .then(invocationReceivedCallback)
    97                  .catch((e) => {
    98                    // TODO(mwarton): display the error to the user.
    99                    logging.error(e);
   100                  });
   101              }
   102            };
   103            for (const invocationName of invs) {
   104              this.store.services.resultDb
   105                ?.getInvocation({ name: invocationName }, { skipUpdate: true })
   106                .then(invocationReceivedCallback)
   107                .catch((e) => {
   108                  // TODO(mwarton): display the error to the user.
   109                  logging.error(e);
   110                });
   111            }
   112          } catch (e) {
   113            // TODO(mwarton): display the error to the user.
   114            logging.error(e);
   115          }
   116        }),
   117      );
   118    }
   119  
   120    // requestsInBatch is NOT observable so we can batch updates to the observable numRequestsCompleted.
   121    private requestsInBatch = 0;
   122    // equivalent to this.numRequestsCompleted += 1, but batches all of the updates until the next idle period.
   123    // This ensures a render is only kicked off once the last one is finished, giving the minimum number of re-renders.
   124    batchRequestComplete() {
   125      if (this.requestsInBatch === 0) {
   126        window.requestIdleCallback(() => {
   127          this.numRequestsCompleted += this.requestsInBatch;
   128          this.requestsInBatch = 0;
   129        });
   130      }
   131      this.requestsInBatch += 1;
   132    }
   133  
   134    private now = DateTime.now();
   135  
   136    connectedCallback() {
   137      super.connectedCallback();
   138      this.now = DateTime.now();
   139  
   140      const syncWidth = () => {
   141        this.graphWidth = Math.max(
   142          window.innerWidth - 2 * MARGIN,
   143          MIN_GRAPH_WIDTH,
   144        );
   145      };
   146      window.addEventListener('resize', syncWidth);
   147      this.addDisposer(() => window.removeEventListener('resize', syncWidth));
   148      syncWidth();
   149    }
   150  
   151    protected render() {
   152      const invocation = this.invState.invocation;
   153      if (invocation === null) {
   154        return html``;
   155      }
   156  
   157      const blocks: TimelineBlock[] = this.includedInvocations.map((i) => ({
   158        text: stripInvocationPrefix(i.name),
   159        href: `/ui/inv/${stripInvocationPrefix(i.name)}/invocation-details`,
   160        start: DateTime.fromISO(i.createTime),
   161        end: i.finalizeTime ? DateTime.fromISO(i.finalizeTime) : undefined,
   162      }));
   163      blocks.sort((a, b) => {
   164        if (a.end && (!b.end || a.end < b.end)) {
   165          return -1;
   166        } else if (b.end && (!a.end || a.end > b.end)) {
   167          return 1;
   168        } else {
   169          // Invocations always have a create time, no need for undefined checks here.
   170          return a.start!.toMillis() - b.start!.toMillis();
   171        }
   172      });
   173      return html`
   174        <div>
   175          Create Time: ${new Date(invocation.createTime).toLocaleString()}
   176        </div>
   177        <div>
   178          Finalize Time: ${new Date(invocation.finalizeTime).toLocaleString()}
   179        </div>
   180        <div>Deadline: ${new Date(invocation.deadline).toLocaleDateString()}</div>
   181        <div style=${styleMap({ display: this.hasTags ? '' : 'none' })}>
   182          Tags:
   183          <table id="tag-table" border="0">
   184            ${invocation.tags?.map(
   185              (tag) => html`
   186                <tr>
   187                  <td>${tag.key}:</td>
   188                  <td>${tag.value}</td>
   189                </tr>
   190              `,
   191            )}
   192          </table>
   193        </div>
   194        <div id="included-invocations">
   195          ${invocation.includedInvocations?.length
   196            ? html`Included Invocations: (loaded ${this.numRequestsCompleted} of
   197                ${invocation.includedInvocations?.length})
   198                <milo-timeline
   199                  .width=${this.graphWidth}
   200                  .startTime=${DateTime.fromISO(invocation.createTime)}
   201                  .endTime=${invocation.finalizeTime
   202                    ? DateTime.fromISO(invocation.finalizeTime)
   203                    : this.now}
   204                  .blocks=${blocks}
   205                >
   206                </milo-timeline>`
   207            : 'Included Invocations: None'}
   208        </div>
   209      `;
   210    }
   211  
   212    static styles = [
   213      commonStyles,
   214      css`
   215        :host {
   216          display: block;
   217          padding: 10px 20px;
   218        }
   219  
   220        #included-invocations ul {
   221          list-style-type: none;
   222          margin-block-start: auto;
   223          margin-block-end: auto;
   224          padding-inline-start: 32px;
   225        }
   226  
   227        #tag-table {
   228          margin-left: 29px;
   229        }
   230      `,
   231    ];
   232  }
   233  
   234  declare global {
   235    // eslint-disable-next-line @typescript-eslint/no-namespace
   236    namespace JSX {
   237      interface IntrinsicElements {
   238        'milo-invocation-details-tab': Record<string, never>;
   239      }
   240    }
   241  }
   242  
   243  export function InvocationDetailsTab() {
   244    return <milo-invocation-details-tab />;
   245  }
   246  
   247  export function Component() {
   248    useTabId('invocation-details');
   249  
   250    return (
   251      // See the documentation for `<LoginPage />` for why we handle error this
   252      // way.
   253      <RecoverableErrorBoundary key="invocation-details">
   254        <InvocationDetailsTab />
   255      </RecoverableErrorBoundary>
   256    );
   257  }