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