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  }