go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/deploy/service/ui/history.go (about) 1 // Copyright 2022 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 package ui 16 17 import ( 18 "fmt" 19 "strconv" 20 "strings" 21 22 "google.golang.org/grpc/codes" 23 "google.golang.org/grpc/status" 24 25 "go.chromium.org/luci/server/router" 26 "go.chromium.org/luci/server/templates" 27 28 "go.chromium.org/luci/deploy/api/modelpb" 29 "go.chromium.org/luci/deploy/api/rpcpb" 30 "go.chromium.org/luci/deploy/service/model" 31 ) 32 33 // actuationOutcome is an outcome of an actuation of a single asset. 34 type actuationOutcome string 35 36 const ( 37 outcomeUnknown actuationOutcome = "UNKNOWN" // should be unreachable 38 outcomeUnchanged actuationOutcome = "UNCHANGED" // matches intent 39 outcomeDisabled actuationOutcome = "DISABLED" // disabled in the config 40 outcomeLocked actuationOutcome = "LOCKED" // has outstanding locks 41 outcomeBroken actuationOutcome = "BROKEN" // broken configuration 42 outcomeUpdating actuationOutcome = "UPDATING" // running right now 43 outcomeUpdated actuationOutcome = "UPDATED" // applied changes 44 outcomeFailed actuationOutcome = "FAILED" // failed to apply changes 45 ) 46 47 func deriveOutcome(r *modelpb.AssetHistory) actuationOutcome { 48 switch r.Decision.GetDecision() { 49 case modelpb.ActuationDecision_SKIP_UPTODATE: 50 return outcomeUnchanged 51 52 case modelpb.ActuationDecision_SKIP_DISABLED: 53 return outcomeDisabled 54 55 case modelpb.ActuationDecision_SKIP_LOCKED: 56 return outcomeLocked 57 58 case modelpb.ActuationDecision_SKIP_BROKEN: 59 return outcomeBroken 60 61 case modelpb.ActuationDecision_ACTUATE_FORCE, modelpb.ActuationDecision_ACTUATE_STALE: 62 switch r.Actuation.GetState() { 63 case modelpb.Actuation_EXECUTING: 64 return outcomeUpdating 65 case modelpb.Actuation_SUCCEEDED: 66 return outcomeUpdated 67 case modelpb.Actuation_FAILED, modelpb.Actuation_EXPIRED: 68 return outcomeFailed 69 default: 70 return outcomeUnknown // this should not be possible 71 } 72 73 default: 74 return outcomeUnknown // this should not be possible 75 } 76 } 77 78 // tableClass is the corresponding Bootstrap CSS table class. 79 func (o actuationOutcome) tableClass() string { 80 switch o { 81 case outcomeUnchanged, outcomeUpdated: 82 return "" // default transparent background 83 case outcomeDisabled: 84 return "table-secondary" 85 case outcomeLocked: 86 return "table-warning" 87 case outcomeUnknown, outcomeBroken, outcomeFailed: 88 return "table-danger" 89 case outcomeUpdating: 90 return "table-info" 91 default: 92 panic("impossible") 93 } 94 } 95 96 // badgeClass is the corresponding Bootstrap CSS badge class. 97 func (o actuationOutcome) badgeClass() string { 98 switch o { 99 case outcomeUnchanged, outcomeDisabled: 100 return "bg-secondary" 101 case outcomeLocked: 102 return "bg-warning text-dark" 103 case outcomeUnknown, outcomeBroken, outcomeFailed: 104 return "bg-danger" 105 case outcomeUpdating: 106 return "bg-info text-dark" 107 case outcomeUpdated: 108 return "bg-success" 109 default: 110 panic("impossible") 111 } 112 } 113 114 type commitDetails struct { 115 Subject linkHref // commit subject linking to gitiles 116 Rev string // full commit revision 117 AuthorEmail string // author email address 118 CommitMessage string // full commit message 119 } 120 121 func getCommitDetails(dep *modelpb.Deployment) commitDetails { 122 message := strings.TrimSpace(dep.GetLatestCommit().GetCommitMessage()) 123 lines := strings.SplitN(message, "\n", 2) 124 125 subject := commitHref(dep) 126 if len(lines) == 0 { 127 subject.Text = "-" 128 } else { 129 subject.Text = lines[0] 130 } 131 132 return commitDetails{ 133 Subject: subject, 134 Rev: dep.GetConfigRev(), 135 AuthorEmail: dep.GetLatestCommit().GetAuthorEmail(), 136 CommitMessage: message, 137 } 138 } 139 140 type historyOverview struct { 141 ID linkHref // a link to the dedicate history entry page 142 Age linkHref // when it started 143 Commit commitDetails // commit subject, commit message, etc. 144 Outcome actuationOutcome // summary of what happened 145 TableClass string // CSS class for the table row 146 BadgeClass string // CSS class for the state cell 147 Actuation linkHref // link to the actuator invocation (e.g. build) 148 Log linkHref // link to the concrete actuation log 149 } 150 151 func deriveHistoryOverview(asset *modelpb.Asset, rec *modelpb.AssetHistory) *historyOverview { 152 out := &historyOverview{ 153 ID: linkHref{ 154 Text: fmt.Sprintf("#%d", rec.HistoryId), 155 Href: fmt.Sprintf("/a/%s/history/%d", asset.Id, rec.HistoryId), 156 }, 157 Age: timestampHref(rec.Actuation.Created, "", ""), 158 Commit: getCommitDetails(rec.Actuation.Deployment), 159 Outcome: deriveOutcome(rec), 160 Actuation: linkHref{ 161 Text: "link", 162 Href: buildbucketHref(rec.Actuation.Actuator), 163 Target: "_blank", 164 }, 165 } 166 out.TableClass = out.Outcome.tableClass() 167 out.BadgeClass = out.Outcome.badgeClass() 168 169 if model.IsActuateDecision(rec.Decision.GetDecision()) { 170 out.Log = linkHref{ 171 Text: "link", 172 Href: rec.Actuation.LogUrl, 173 Target: "_blank", 174 } 175 } 176 177 return out 178 } 179 180 func parseHistoryID(historyID string) (int64, error) { 181 id, err := strconv.ParseInt(historyID, 10, 64) 182 if err != nil { 183 return 0, status.Errorf(codes.InvalidArgument, "Bad history ID %q: %s", historyID, err) 184 } 185 if id <= 0 { 186 return 0, status.Errorf(codes.InvalidArgument, "Bad history ID %q: must be non-negative", historyID) 187 } 188 return id, nil 189 } 190 191 // historyListingPage renders the history listing page. 192 func (ui *UI) historyListingPage(ctx *router.Context, assetID string) error { 193 const pageSize = 200 194 195 latest := int64(0) 196 if latestVal := ctx.Request.FormValue("latest"); latestVal != "" { 197 var err error 198 if latest, err = parseHistoryID(latestVal); err != nil { 199 return err 200 } 201 } 202 203 assetHistory, err := ui.assets.ListAssetHistory(ctx.Request.Context(), &rpcpb.ListAssetHistoryRequest{ 204 AssetId: assetID, 205 LatestHistoryId: latest, 206 Limit: pageSize, 207 }) 208 if err != nil { 209 return err 210 } 211 212 history := make([]*historyOverview, len(assetHistory.History)) 213 for i, rec := range assetHistory.History { 214 history[i] = deriveHistoryOverview(assetHistory.Asset, rec) 215 } 216 217 ref := assetRefFromID(assetHistory.Asset.Id) 218 219 newerHref := "" 220 if latest != 0 && assetHistory.LastRecordedHistoryId > latest { 221 newer := latest + pageSize 222 if newer >= assetHistory.LastRecordedHistoryId { 223 newerHref = fmt.Sprintf("/a/%s/history", assetID) 224 } else { 225 newerHref = fmt.Sprintf("/a/%s/history?latest=%d", assetID, newer) 226 } 227 } 228 229 olderHref := "" 230 if len(assetHistory.History) == pageSize { 231 older := assetHistory.History[len(assetHistory.History)-1].HistoryId - 1 232 if older != 0 { 233 olderHref = fmt.Sprintf("/a/%s/history?latest=%d", assetID, older) 234 } 235 } 236 237 templates.MustRender(ctx.Request.Context(), ctx.Writer, "pages/history-listing.html", map[string]any{ 238 "Breadcrumbs": historyListingBreadcrumbs(ref), 239 "Ref": ref, 240 "Overview": deriveAssetOverview(assetHistory.Asset), 241 "History": history, 242 "NewerHref": newerHref, 243 "OlderHref": olderHref, 244 }) 245 return nil 246 } 247 248 // historyEntryPage renders a page with a single actuation history entry. 249 func (ui *UI) historyEntryPage(ctx *router.Context, assetID, historyID string) error { 250 entryID, err := parseHistoryID(historyID) 251 if err != nil { 252 return err 253 } 254 255 assetHistory, err := ui.assets.ListAssetHistory(ctx.Request.Context(), &rpcpb.ListAssetHistoryRequest{ 256 AssetId: assetID, 257 LatestHistoryId: entryID, 258 Limit: 2, // to see if we have the previous one for the pager 259 }) 260 if err != nil { 261 return err 262 } 263 264 // We allow to ask for the current unfinished actuation. That way it is 265 // possible to send a permanent HTTP link to the currently executing actuation 266 // in notifications. It will "transform" into the historical link once the 267 // actuation finishes. 268 var entry *modelpb.AssetHistory 269 switch { 270 case assetHistory.Current != nil && assetHistory.Current.HistoryId == entryID: 271 entry = assetHistory.Current 272 case len(assetHistory.History) > 0 && assetHistory.History[0].HistoryId == entryID: 273 entry = assetHistory.History[0] 274 default: 275 return status.Errorf(codes.NotFound, "Actuation #%d doesn't exist: either it hasn't started yet or it was already deleted.", entryID) 276 } 277 278 ref := assetRefFromID(assetID) 279 overview := deriveHistoryOverview(assetHistory.Asset, entry) 280 281 newerHref := "" 282 if assetHistory.LastRecordedHistoryId > entryID { 283 newerHref = fmt.Sprintf("/a/%s/history/%d", assetID, entryID+1) 284 } 285 286 olderHref := "" 287 if len(assetHistory.History) == 2 { 288 olderHref = fmt.Sprintf("/a/%s/history/%d", assetID, entryID-1) 289 } 290 291 // TODO: Add GAE-specific details. 292 293 templates.MustRender(ctx.Request.Context(), ctx.Writer, "pages/history-entry.html", map[string]any{ 294 "Breadcrumbs": historyEntryBreadcrumbs(ref, entryID), 295 "Ref": ref, 296 "Overview": overview, 297 "NewerHref": newerHref, 298 "OlderHref": olderHref, 299 }) 300 return nil 301 }