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