go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/monitoring/components/alerts/alerts.tsx (about)

     1  // Copyright 2024 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 SearchIcon from '@mui/icons-material/Search';
    16  import { InputAdornment, TextField, Typography } from '@mui/material';
    17  import { useState } from 'react';
    18  
    19  import {
    20    AlertJson,
    21    AnnotationJson,
    22    TreeJson,
    23    BugId,
    24    Bug,
    25    bugFromId,
    26  } from '@/monitoring/util/server_json';
    27  
    28  import { AlertGroup } from './alert_group';
    29  import { BugGroup } from './bug_group';
    30  
    31  interface AlertsProps {
    32    tree: TreeJson;
    33    alerts: AlertJson[] | undefined | null;
    34    bugs: Bug[];
    35    annotations: { [key: string]: AnnotationJson };
    36  }
    37  export const Alerts = ({ tree, alerts, bugs, annotations }: AlertsProps) => {
    38    const [filter, setFilter] = useState('');
    39    if (!tree || !alerts) {
    40      return <></>;
    41    }
    42    const filtered = filterAlerts(alerts, filter);
    43    const categories = categorizeAlerts(filtered, annotations);
    44  
    45    // Add any bugs associated with issues that are not in the hotlist.
    46    // TODO: This probably needs to be pushed higher up so the details can be queried from Buganizer.
    47    for (const bug of Object.keys(categories.bugAlerts)) {
    48      if (bugs.filter((b) => b.number === bug).length === 0) {
    49        bugs.push(bugFromId(bug));
    50      }
    51    }
    52  
    53    return (
    54      <>
    55        <TextField
    56          id="input-with-icon-textfield"
    57          placeholder="Filter Alerts and Bugs"
    58          InputProps={{
    59            startAdornment: (
    60              <InputAdornment position="start">
    61                <SearchIcon />
    62              </InputAdornment>
    63            ),
    64          }}
    65          variant="outlined"
    66          fullWidth
    67          value={filter}
    68          onChange={(e) => setFilter(e.target.value)}
    69        />
    70        <AlertGroup
    71          groupName={'Consistent Failures'}
    72          alertBugs={categories.alertBugs}
    73          alerts={categories.consistentFailures}
    74          tree={tree}
    75          groupDescription="Failures that have occurred at least 2 times in a row and are not linked with a bug"
    76          defaultExpanded={true}
    77          bugs={bugs}
    78        />
    79        <AlertGroup
    80          groupName={'New Failures'}
    81          alertBugs={categories.alertBugs}
    82          alerts={categories.newFailures}
    83          tree={tree}
    84          groupDescription="Failures that have only been seen once and are not linked with a bug"
    85          defaultExpanded={false}
    86          bugs={bugs}
    87        />
    88  
    89        <Typography variant="h6" sx={{ margin: '24px 0 8px' }}>
    90          Bugs
    91        </Typography>
    92        {/* TODO: Get hotlist name */}
    93        {bugs.length === 0 ? (
    94          <Typography>There are currently no bugs in the hotlist.</Typography>
    95        ) : null}
    96        {bugs.map((bug) => {
    97          const numAlerts = categories.bugAlerts[bug.number]?.length || 0;
    98          if (filter !== '' && numAlerts === 0) {
    99            return null;
   100          }
   101          return (
   102            <BugGroup
   103              key={bug.link}
   104              bug={bug}
   105              alertBugs={categories.alertBugs}
   106              alerts={categories.bugAlerts[bug.number]}
   107              tree={tree}
   108              bugs={bugs}
   109            />
   110          );
   111        })}
   112        {filter !== '' && Object.keys(categories.bugAlerts).length === 0 && (
   113          <Typography>
   114            No alerts associated with bugs match your search filter, try changing
   115            or removing the search filter.
   116          </Typography>
   117        )}
   118      </>
   119    );
   120  };
   121  
   122  interface CategorizedAlerts {
   123    // Alerts not associated with a bug that have occurred in more than one consecutive build.
   124    consistentFailures: AlertJson[];
   125    // Alerts not associated with bugs that have only happened once.
   126    newFailures: AlertJson[];
   127    // All the alerts assoctiated with each bug.
   128    bugAlerts: { [bug: string]: AlertJson[] };
   129    // All the bugs associated with each alert.
   130    alertBugs: { [alertKey: string]: BugId[] };
   131  }
   132  
   133  // Sort alerts into categories - one for each bug, and the leftovers into
   134  // either consistent (multiple failures) or new (a single failure).
   135  const categorizeAlerts = (
   136    alerts: AlertJson[],
   137    annotations: { [key: string]: AnnotationJson },
   138  ): CategorizedAlerts => {
   139    const categories: CategorizedAlerts = {
   140      consistentFailures: [],
   141      newFailures: [],
   142      bugAlerts: {},
   143      alertBugs: {},
   144    };
   145    if (annotations) {
   146      for (const alert of alerts) {
   147        const annotation = annotations[alert.key];
   148        for (const b of annotation?.bugs || []) {
   149          categories.bugAlerts[b.id] = categories.bugAlerts[b.id] || [];
   150          categories.bugAlerts[b.id].push(alert);
   151          categories.alertBugs[alert.key] = categories.alertBugs[alert.key] || [];
   152          categories.alertBugs[alert.key].push(b);
   153        }
   154      }
   155    }
   156    for (const alert of alerts) {
   157      if (categories.alertBugs[alert.key]) {
   158        continue;
   159      }
   160      const builder = alert.extension?.builders?.[0];
   161      const failureCount =
   162        builder && builder.first_failure_build_number === 0
   163          ? undefined
   164          : builder.latest_failure_build_number -
   165            builder.first_failure_build_number +
   166            1;
   167      const isNewFailure = failureCount === 1;
   168      if (isNewFailure) {
   169        categories.newFailures.push(alert);
   170      } else {
   171        categories.consistentFailures.push(alert);
   172      }
   173    }
   174    return categories;
   175  };
   176  
   177  // filterAlerts returns the alerts that match the given filter string typed by the user.
   178  // alerts can match in step name, builder name, test id, or whatever else is useful to users.
   179  const filterAlerts = (alerts: AlertJson[], filter: string): AlertJson[] => {
   180    if (filter === '') {
   181      return alerts;
   182    }
   183    const re = new RegExp(filter);
   184    return alerts.filter((alert) => {
   185      if (
   186        alert.extension.builders.filter(
   187          (b) => re.test(b.bucket) || re.test(b.builder_group) || re.test(b.name),
   188        ).length > 0
   189      ) {
   190        return true;
   191      }
   192      if (
   193        alert.extension.reason?.tests?.filter(
   194          (t) => re.test(t.test_id) || re.test(t.test_id),
   195        ).length > 0
   196      ) {
   197        return true;
   198      }
   199      return re.test(alert.title) || re.test(alert.extension.reason?.step);
   200    });
   201  };