blob: b79dc558e0e5d7b7a4a60ba12ca8b6c6742523a9 [file] [log] [blame]
// 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)
}