go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/scheduler/appengine/ui/presentation.go (about) 1 // Copyright 2015 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 "context" 19 "encoding/json" 20 "fmt" 21 "sort" 22 "strings" 23 "time" 24 25 "github.com/dustin/go-humanize" 26 "github.com/golang/protobuf/jsonpb" 27 "github.com/golang/protobuf/proto" 28 "google.golang.org/protobuf/types/known/structpb" 29 30 "go.chromium.org/luci/auth/identity" 31 "go.chromium.org/luci/common/clock" 32 "go.chromium.org/luci/common/data/sortby" 33 "go.chromium.org/luci/common/errors" 34 "go.chromium.org/luci/common/logging" 35 "go.chromium.org/luci/server/auth/realms" 36 37 "go.chromium.org/luci/scheduler/appengine/catalog" 38 "go.chromium.org/luci/scheduler/appengine/engine" 39 "go.chromium.org/luci/scheduler/appengine/engine/policy" 40 "go.chromium.org/luci/scheduler/appengine/internal" 41 "go.chromium.org/luci/scheduler/appengine/messages" 42 "go.chromium.org/luci/scheduler/appengine/presentation" 43 "go.chromium.org/luci/scheduler/appengine/schedule" 44 "go.chromium.org/luci/scheduler/appengine/task" 45 ) 46 47 // schedulerJob is UI representation of engine.Job entity. 48 type schedulerJob struct { 49 ProjectID string 50 JobName string 51 Schedule string 52 Definition string 53 Policy string 54 Revision string 55 RevisionURL string 56 State presentation.PublicStateKind 57 NextRun string 58 Paused bool 59 PausedBy string // an email or "unknown" for legacy entities 60 PausedWhen string 61 PausedReason string 62 LabelClass string 63 JobFlavorIcon string 64 JobFlavorTitle string 65 66 CanPause bool 67 CanResume bool 68 CanAbort bool 69 CanTrigger bool 70 71 TriageLog struct { 72 Available bool 73 LastTriage string // e.g. "10 sec ago" 74 Stale bool 75 Staleness time.Duration 76 DebugLog string 77 } 78 79 sortGroup string // used only for sorting, doesn't show up in UI 80 now time.Time // as passed to makeJob 81 traits task.Traits // as extracted from corresponding task.Manager 82 } 83 84 var stateToLabelClass = map[presentation.PublicStateKind]string{ 85 presentation.PublicStatePaused: "label-default", 86 presentation.PublicStateScheduled: "label-primary", 87 presentation.PublicStateRunning: "label-info", 88 presentation.PublicStateWaiting: "label-warning", 89 } 90 91 var flavorToIconClass = []string{ 92 catalog.JobFlavorPeriodic: "glyphicon-time", 93 catalog.JobFlavorTriggered: "glyphicon-flash", 94 catalog.JobFlavorTrigger: "glyphicon-bell", 95 } 96 97 var flavorToTitle = []string{ 98 catalog.JobFlavorPeriodic: "Periodic job", 99 catalog.JobFlavorTriggered: "Triggered job", 100 catalog.JobFlavorTrigger: "Triggering job", 101 } 102 103 // makeJob builds UI presentation for engine.Job. 104 func makeJob(c context.Context, j *engine.Job, log *engine.JobTriageLog) *schedulerJob { 105 traits, err := presentation.GetJobTraits(c, config(c).Catalog, j) 106 if err != nil { 107 logging.WithError(err).Warningf(c, "Failed to get task traits for %s", j.JobID) 108 } 109 110 now := clock.Now(c).UTC() 111 nextRun := "" 112 switch ts := j.CronTickTime(); { 113 case ts == schedule.DistantFuture: 114 nextRun = "-" 115 case !ts.IsZero(): 116 nextRun = humanize.RelTime(ts, now, "ago", "from now") 117 default: 118 nextRun = "not scheduled yet" 119 } 120 121 pausedBy := "unknown" 122 if j.PausedOrResumedBy != "" { 123 if j.PausedOrResumedBy.Kind() == identity.User { 124 pausedBy = j.PausedOrResumedBy.Email() 125 } else { 126 pausedBy = string(j.PausedOrResumedBy) 127 } 128 } 129 130 pausedWhen := "" 131 if !j.PausedOrResumedWhen.IsZero() { 132 pausedWhen = humanize.RelTime(j.PausedOrResumedWhen, now, "ago", "from now") 133 } 134 135 pausedReason := "Unknown reason." 136 if j.PausedOrResumedReason != "" { 137 pausedReason = j.PausedOrResumedReason 138 } 139 140 // Internal state names aren't very user friendly. Introduce some aliases. 141 state := presentation.GetPublicStateKind(j, traits) 142 labelClass := stateToLabelClass[state] 143 144 // Put triggers after regular jobs. 145 sortGroup := "A" 146 if j.Flavor == catalog.JobFlavorTrigger { 147 sortGroup = "B" 148 } 149 150 can := func(perm realms.Permission) bool { 151 switch err := engine.CheckPermission(c, j, perm); { 152 case err == nil: 153 return true 154 case err == engine.ErrNoPermission: 155 return false 156 default: 157 panic(fmt.Sprintf("error when checking permission %s: %s", perm, err)) 158 } 159 } 160 161 out := &schedulerJob{ 162 ProjectID: j.ProjectID, 163 JobName: j.JobName(), 164 Schedule: j.Schedule, 165 Definition: taskToText(j.Task), 166 Policy: policyToText(j.TriggeringPolicyRaw), 167 Revision: j.Revision, 168 RevisionURL: j.RevisionURL, 169 State: state, 170 NextRun: nextRun, 171 Paused: j.Paused, 172 PausedBy: pausedBy, 173 PausedWhen: pausedWhen, 174 PausedReason: pausedReason, 175 LabelClass: labelClass, 176 JobFlavorIcon: flavorToIconClass[j.Flavor], 177 JobFlavorTitle: flavorToTitle[j.Flavor], 178 179 CanPause: can(engine.PermJobsPause), 180 CanResume: can(engine.PermJobsResume), 181 CanAbort: can(engine.PermJobsAbort), 182 CanTrigger: can(engine.PermJobsTrigger), 183 184 sortGroup: sortGroup, 185 now: now, 186 traits: traits, 187 } 188 189 // Fill in job triage log details if available. They are not available in 190 // job listings, for example. 191 if log != nil { 192 out.TriageLog.Available = true 193 out.TriageLog.LastTriage = humanize.RelTime(log.LastTriage, now, "ago", "") 194 out.TriageLog.Stale = log.Stale() 195 out.TriageLog.Staleness = j.LastTriage.Sub(log.LastTriage) 196 out.TriageLog.DebugLog = log.DebugLog 197 } 198 199 return out 200 } 201 202 func taskToText(task []byte) string { 203 if len(task) == 0 { 204 return "" 205 } 206 msg := messages.TaskDefWrapper{} 207 if err := proto.Unmarshal(task, &msg); err != nil { 208 return fmt.Sprintf("Failed to unmarshal the task - %s", err) 209 } 210 return proto.MarshalTextString(&msg) 211 } 212 213 func policyToText(p []byte) string { 214 msg, err := policy.UnmarshalDefinition(p) 215 if err != nil { 216 return err.Error() 217 } 218 return proto.MarshalTextString(msg) 219 } 220 221 type sortedJobs []*schedulerJob 222 223 func (s sortedJobs) Len() int { return len(s) } 224 func (s sortedJobs) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 225 func (s sortedJobs) Less(i, j int) bool { 226 return sortby.Chain{ 227 func(i, j int) bool { return s[i].ProjectID < s[j].ProjectID }, 228 func(i, j int) bool { return s[i].sortGroup < s[j].sortGroup }, 229 func(i, j int) bool { return s[i].JobName < s[j].JobName }, 230 }.Use(i, j) 231 } 232 233 // sortJobs instantiate a bunch of schedulerJob objects and sorts them in 234 // display order. 235 func sortJobs(c context.Context, jobs []*engine.Job) sortedJobs { 236 out := make(sortedJobs, len(jobs)) 237 for i, job := range jobs { 238 out[i] = makeJob(c, job, nil) 239 } 240 sort.Sort(out) 241 return out 242 } 243 244 // invocation is UI representation of engine.Invocation entity. 245 type invocation struct { 246 ProjectID string 247 JobName string 248 InvID int64 249 Attempt int64 250 Revision string 251 RevisionURL string 252 Definition string 253 TriggeredBy string 254 Properties string 255 Tags []string 256 IncomingTriggers []trigger 257 OutgoingTriggers []trigger 258 Started string 259 Duration string 260 Status string 261 DebugLog string 262 RowClass string 263 LabelClass string 264 ViewURL string 265 CanAbort bool 266 } 267 268 var statusToRowClass = map[task.Status]string{ 269 task.StatusStarting: "active", 270 task.StatusRetrying: "warning", 271 task.StatusRunning: "info", 272 task.StatusSucceeded: "success", 273 task.StatusFailed: "danger", 274 task.StatusOverrun: "warning", 275 task.StatusAborted: "danger", 276 } 277 278 var statusToLabelClass = map[task.Status]string{ 279 task.StatusStarting: "label-default", 280 task.StatusRetrying: "label-warning", 281 task.StatusRunning: "label-info", 282 task.StatusSucceeded: "label-success", 283 task.StatusFailed: "label-danger", 284 task.StatusOverrun: "label-warning", 285 task.StatusAborted: "label-danger", 286 } 287 288 // makeInvocation builds UI presentation of some Invocation of a job. 289 func makeInvocation(j *schedulerJob, i *engine.Invocation) *invocation { 290 // Invocations with Multistage == false trait are never in "RUNNING" state, 291 // they perform all their work in 'LaunchTask' while technically being in 292 // "STARTING" state. We display them as "RUNNING" instead. See comment for 293 // task.Traits.Multistage for more info. 294 status := i.Status 295 if !j.traits.Multistage && status == task.StatusStarting { 296 status = task.StatusRunning 297 } 298 299 triggeredBy := "-" 300 if i.TriggeredBy != "" { 301 triggeredBy = string(i.TriggeredBy) 302 if i.TriggeredBy.Email() != "" { 303 triggeredBy = i.TriggeredBy.Email() // triggered by a user (not a service) 304 } 305 } 306 307 finished := i.Finished 308 if finished.IsZero() { 309 finished = j.now 310 } 311 duration := humanize.RelTime(i.Started, finished, "", "") 312 if duration == "now" { 313 duration = "1 second" // "now" looks weird for durations 314 } 315 316 incTriggers, err := i.IncomingTriggers() 317 if err != nil { 318 panic(errors.Annotate(err, "failed to deserialize incoming triggers").Err()) 319 } 320 outTriggers, err := i.OutgoingTriggers() 321 if err != nil { 322 panic(errors.Annotate(err, "failed to deserialize outgoing triggers").Err()) 323 } 324 325 return &invocation{ 326 ProjectID: j.ProjectID, 327 JobName: j.JobName, 328 InvID: i.ID, 329 Attempt: i.RetryCount + 1, 330 Revision: i.Revision, 331 RevisionURL: i.RevisionURL, 332 Definition: taskToText(i.Task), 333 TriggeredBy: triggeredBy, 334 Properties: makeJSONFromProtoStruct(i.PropertiesRaw), 335 Tags: i.Tags, 336 IncomingTriggers: makeTriggerList(j.now, incTriggers), 337 OutgoingTriggers: makeTriggerList(j.now, outTriggers), 338 Started: humanize.RelTime(i.Started, j.now, "ago", "from now"), 339 Duration: duration, 340 Status: string(status), 341 DebugLog: i.DebugLog, 342 RowClass: statusToRowClass[status], 343 LabelClass: statusToLabelClass[status], 344 ViewURL: i.ViewURL, 345 CanAbort: j.CanAbort, 346 } 347 } 348 349 // trigger is UI representation of internal.Trigger struct. 350 type trigger struct { 351 Title string 352 URL string 353 RelTime string 354 EmittedBy string 355 } 356 357 // makeTrigger builds UI presentation of some internal.Trigger. 358 func makeTrigger(t *internal.Trigger, now time.Time) trigger { 359 out := trigger{ 360 Title: t.Title, 361 URL: t.Url, 362 EmittedBy: strings.TrimPrefix(t.EmittedByUser, "user:"), 363 } 364 if out.Title == "" { 365 out.Title = t.Id 366 } 367 if t.Created != nil { 368 out.RelTime = humanize.RelTime(t.Created.AsTime(), now, "ago", "from now") 369 } 370 return out 371 } 372 373 // makeTriggerList builds UI presentation of a bunch of triggers. 374 func makeTriggerList(now time.Time, list []*internal.Trigger) []trigger { 375 out := make([]trigger, len(list)) 376 for i, t := range list { 377 out[i] = makeTrigger(t, now) 378 } 379 return out 380 } 381 382 // makeJSONFromProtoStruct reformats serialized protobuf.Struct as JSON. 383 // 384 // If the blob is empty, returns empty string. If the blob is not valid proto 385 // message, returns a string with error message instead. This is exclusively for 386 // UI after all. 387 func makeJSONFromProtoStruct(blob []byte) string { 388 if len(blob) == 0 { 389 return "" 390 } 391 392 // Binary proto => internal representation. 393 obj := structpb.Struct{} 394 if err := proto.Unmarshal(blob, &obj); err != nil { 395 return fmt.Sprintf("<not a valid protobuf.Struct - %s>", err) 396 } 397 398 // Internal representation => JSON. But JSONPB produces very ugly JSON when 399 // using Ident. So we are not done yet... 400 ugly, err := (&jsonpb.Marshaler{}).MarshalToString(&obj) 401 if err != nil { 402 return fmt.Sprintf("<failed to marshal to JSON - %s>", err) 403 } 404 405 // JSON => internal representation 2, sigh. Because there's no existing 406 // structpb.Struct => map converter and writing one just for the sake of 407 // JSON pretty printing is kind of annoying. 408 var obj2 map[string]any 409 if err := json.Unmarshal([]byte(ugly), &obj2); err != nil { 410 return fmt.Sprintf("<internal error when unmarshaling JSON - %s>", err) 411 } 412 413 // Internal representation 2 => pretty (well, prettier) JSON. 414 pretty, err := json.MarshalIndent(obj2, "", " ") 415 if err != nil { 416 return fmt.Sprintf("<internal error when marshaling JSON - %s>", err) 417 } 418 return string(pretty) 419 }