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  }