blob: 31194cc0902db6a93337eb5a798189d5c7cf26a7 [file] [log] [blame]
// Copyright 2022 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 (
"fmt"
"html"
"net/url"
"sort"
"strings"
"time"
"github.com/dustin/go-humanize"
"google.golang.org/protobuf/types/known/timestamppb"
"go.chromium.org/luci/server/router"
"go.chromium.org/luci/server/templates"
"go.chromium.org/luci/deploy/api/modelpb"
"go.chromium.org/luci/deploy/api/rpcpb"
)
var (
// Timezone for displaying absolute times.
hardcodedTZ = time.FixedZone("UTC-07", -7*60*60)
// For sorting, to make sure "missing" timestamps show up first.
distantFuture = time.Date(2112, 1, 1, 1, 1, 1, 1, time.UTC)
)
// assetState is an overall asset state to display in the UI.
type assetState string
const (
stateUnknown assetState = "UNKNOWN" // should be unreachable
stateUpToDate assetState = "UP-TO-DATE" // matches intent
stateLocked assetState = "LOCKED" // has outstanding locks
stateDisabled assetState = "DISABLED" // disabled in the config
stateUpdating assetState = "UPDATING" // has an active actuation right now
stateBroken assetState = "BROKEN" // broken configuration
stateFailed assetState = "FAILED" // the last actuation failed
)
// deriveState derives an overall state based on the asset proto.
func deriveState(a *modelpb.Asset) assetState {
switch a.LastDecision.GetDecision() {
case modelpb.ActuationDecision_SKIP_UPTODATE:
return stateUpToDate
case modelpb.ActuationDecision_SKIP_DISABLED:
return stateDisabled
case modelpb.ActuationDecision_SKIP_LOCKED:
return stateLocked
case modelpb.ActuationDecision_SKIP_BROKEN:
return stateBroken
case modelpb.ActuationDecision_ACTUATE_FORCE, modelpb.ActuationDecision_ACTUATE_STALE:
switch a.LastActuation.GetState() {
case modelpb.Actuation_EXECUTING:
return stateUpdating
case modelpb.Actuation_SUCCEEDED:
return stateUpToDate
case modelpb.Actuation_FAILED, modelpb.Actuation_EXPIRED:
return stateFailed
default:
return stateUnknown // this should not be possible
}
default:
return stateUnknown // this should not be possible
}
}
// tableClass is the corresponding Bootstrap CSS table class.
func (s assetState) tableClass() string {
switch s {
case stateUpToDate:
return "table-light"
case stateLocked:
return "table-warning"
case stateDisabled:
return "table-secondary"
case stateUpdating:
return "table-info"
case stateUnknown, stateBroken, stateFailed:
return "table-danger"
default:
panic("impossible")
}
}
// badgeClass is the corresponding Bootstrap CSS badge class.
func (s assetState) badgeClass() string {
switch s {
case stateUpToDate:
return "bg-success"
case stateLocked:
return "bg-warning text-dark"
case stateDisabled:
return "bg-secondary"
case stateUpdating:
return "bg-info text-dark"
case stateUnknown, stateBroken, stateFailed:
return "bg-danger"
default:
panic("impossible")
}
}
// assetRef is a reference to an asset in the UI.
type assetRef struct {
Kind string // kind of the asset (as human readable string)
Icon string // icon image file name
Name string // display name of the asset e.g. GAE app ID
Href string // link to the asset page
}
// assetRefFromID constructs assetRef based on the asset ID.
func assetRefFromID(assetID string) assetRef {
href := fmt.Sprintf("/a/%s", assetID)
switch {
case strings.HasPrefix(assetID, "apps/"):
return assetRef{
Kind: "Appengine service",
Icon: "app_engine.svg",
Name: strings.TrimPrefix(assetID, "apps/"),
Href: href,
}
default:
return assetRef{
Kind: "Unknown",
Icon: "unknown.svg",
Name: assetID,
Href: href,
}
}
}
// linkHref is a potentially clickable link.
type linkHref struct {
Text string
Href string
Tooltip string
Target string
}
// hrefTarget derives "target" attribute for an href.
func hrefTarget(href string) string {
if !strings.HasPrefix(href, "/") {
return "_blank"
}
return ""
}
// timestampHref a link that looks like a timestamp.
func timestampHref(ts *timestamppb.Timestamp, href, tooltipSfx string) linkHref {
if ts == nil {
return linkHref{
Href: href,
Target: hrefTarget(href),
}
}
t := ts.AsTime()
tooltip := t.In(hardcodedTZ).Format(time.RFC822)
if tooltipSfx != "" {
tooltip += " " + tooltipSfx
}
return linkHref{
Text: humanize.RelTime(t, time.Now(), "ago", "from now"),
Href: href,
Tooltip: tooltip,
Target: hrefTarget(href),
}
}
// buildbucketHref returns a link to the build running the actuation.
func buildbucketHref(act *modelpb.ActuatorInfo) string {
if act.BuildbucketBuild == 0 {
return ""
}
return fmt.Sprintf("https://%s/build/%d", act.Buildbucket, act.BuildbucketBuild)
}
// appengineServiceHref returns a link to a GAE service Cloud Console page.
func appengineServiceHref(project, service string) linkHref {
return linkHref{
Text: service,
Href: "https://console.cloud.google.com/appengine/versions?" + (url.Values{
"project": {project},
"serviceId": {service},
}).Encode(),
Target: "_blank",
}
}
// appengineVersionHref returns a link to a GAE version Cloud Console logs page.
func appengineVersionHref(project, service, version string) linkHref {
// That's how "TOOLS => Logs" link looks like on GAE Versions list page.
return linkHref{
Text: version,
Href: "https://console.cloud.google.com/logs?" + (url.Values{
"project": {project},
"serviceId": {service},
"service": {"appengine.googleapis.com"},
"key1": {service},
"key2": {version},
}).Encode(),
Target: "_blank",
}
}
// commitHref returns a link to the commit matching the deployment.
func commitHref(dep *modelpb.Deployment) linkHref {
if dep.GetId().GetRepoHost() == "" || dep.GetConfigRev() == "" {
return linkHref{}
}
return linkHref{
Text: dep.ConfigRev[:8],
Href: fmt.Sprintf("https://%s.googlesource.com/%s/+/%s",
dep.Id.RepoHost, dep.Id.RepoName, dep.ConfigRev),
Target: "_blank",
}
}
// assetOverview is a subset of asset data passed to the index HTML template.
type assetOverview struct {
Ref assetRef // link to the asset page
State assetState // overall state
TableClass string // CSS class for the table row
BadgeClass string // CSS class for the state cell
LastCheckIn linkHref // when its state was reported last time
LastActuation linkHref // when the last non-trivial actuation happened
Revision linkHref // last applied IaC revision
}
// deriveAssetOverview derives assetOverview from the asset proto.
func deriveAssetOverview(asset *modelpb.Asset) assetOverview {
out := assetOverview{
Ref: assetRefFromID(asset.Id),
State: deriveState(asset),
}
out.TableClass = out.State.tableClass()
out.BadgeClass = out.State.badgeClass()
// The last "check in" time always matches the last performed actuation
// regardless of its outcome. Most of the time this outcome is SKIP_UPTODATE.
if asset.LastActuation != nil {
out.LastCheckIn = timestampHref(
asset.LastActuation.Created,
buildbucketHref(asset.LastActuation.Actuator),
"",
)
}
// Show the last non-trivial actuation (may still be executing).
if asset.LastActuateActuation != nil {
if asset.LastActuateActuation.Finished != nil {
out.LastActuation = timestampHref(
asset.LastActuateActuation.Finished,
asset.LastActuateActuation.LogUrl,
"",
)
} else {
out.LastActuation = linkHref{
Text: "now",
Href: asset.LastActuateActuation.LogUrl,
Target: "_blank",
}
}
}
// If have an actuation executing right now, show its IaC revision (as the
// one being applied now), otherwise show the last successfully applied
// revision.
if asset.LastActuation.GetState() == modelpb.Actuation_EXECUTING {
out.Revision = commitHref(asset.LastActuation.Deployment)
} else if asset.AppliedState != nil {
out.Revision = commitHref(asset.AppliedState.Deployment)
}
return out
}
////////////////////////////////////////////////////////////////////////////////
type versionState struct {
Service linkHref // name of the service e.g. "default"
Version linkHref // name of the version e.g. "1234-abcedf"
Deployed linkHref // when it was deployed
TrafficIntended int32 // intended percent of traffic
TrafficReported int32 // reported percent of traffic
RowSpan int // helper to merge HTML table cells
sortKey1 string // sorting helper, derived from service name
sortKey2 int64 // sorting helper, derived from deployment timestamp
}
func versionsSummary(asset *modelpb.Asset, active bool) []versionState {
type versionID struct {
svc string
ver string
}
versions := map[versionID]versionState{}
serviceSortKey := func(svc string) string {
// We want to show services like "default" and "default-go" on top, they
// are known to be the most important.
if strings.HasPrefix(svc, "default") {
return "a " + svc
}
return "b " + svc
}
projectID := strings.TrimPrefix(asset.Id, "apps/")
// Add all currently running versions to the table.
for _, svc := range asset.GetReportedState().GetAppengine().GetServices() {
for _, ver := range svc.Versions {
// Visit only active or only inactive versions, based on `active` flag.
traffic := (svc.TrafficAllocation[ver.Name] * 100) / 1000
if active != (traffic != 0) {
continue
}
// Trim giant email suffixes of service accounts. There are very few of
// service accounts that should be deploying stuff, and they are easily
// identified by their name alone.
deployer := ver.GetCapturedState().CreatedBy
if strings.HasSuffix(deployer, ".gserviceaccount.com") {
deployer = strings.Split(deployer, "@")[0]
}
versions[versionID{svc.Name, ver.Name}] = versionState{
Service: appengineServiceHref(projectID, svc.Name),
Version: appengineVersionHref(projectID, svc.Name, ver.Name),
Deployed: timestampHref(ver.GetCapturedState().CreateTime, "", "<br>by "+html.EscapeString(deployer)),
TrafficReported: traffic,
sortKey1: serviceSortKey(svc.Name),
sortKey2: ver.GetCapturedState().CreateTime.AsTime().Unix(),
}
}
}
// Add all versions that are declared in the configs. They are all considered
// active (even when they get 0% intended traffic), since they are "actively"
// declared in the configs.
if active {
for _, svc := range asset.GetIntendedState().GetAppengine().GetServices() {
for _, ver := range svc.Versions {
key := versionID{svc.Name, ver.Name}
val, ok := versions[key]
if !ok {
val = versionState{
Service: appengineServiceHref(projectID, svc.Name),
Version: appengineVersionHref(projectID, svc.Name, ver.Name),
sortKey1: serviceSortKey(svc.Name),
sortKey2: distantFuture.Unix(), // will show up before any currently deployed version
}
}
val.TrafficIntended = (svc.TrafficAllocation[ver.Name] * 100) / 1000
versions[key] = val
}
}
}
// Sort by service, then by version deployment time (most recent on top).
versionsList := make([]versionState, 0, len(versions))
for _, v := range versions {
versionsList = append(versionsList, v)
}
sort.Slice(versionsList, func(li, ri int) bool {
l, r := versionsList[li], versionsList[ri]
if l.sortKey1 == r.sortKey1 {
return l.sortKey2 > r.sortKey2 // reversed intentionally
}
return l.sortKey1 < r.sortKey1
})
if len(versionsList) == 0 {
return versionsList
}
// Populate RowSpan by "merging" rows with the same Service name.
curIdx := 0
for i := range versionsList {
if versionsList[i].Service != versionsList[curIdx].Service {
curIdx = i
}
versionsList[curIdx].RowSpan += 1
}
return versionsList
}
////////////////////////////////////////////////////////////////////////////////
// assetPage renders the asset page.
func (ui *UI) assetPage(ctx *router.Context, assetID string) error {
const historyLimit = 10
assetHistory, err := ui.assets.ListAssetHistory(ctx.Request.Context(), &rpcpb.ListAssetHistoryRequest{
AssetId: assetID,
Limit: historyLimit,
})
if err != nil {
return err
}
if assetHistory.Current != nil {
// TODO: Show detailed UI.
}
history := make([]*historyOverview, len(assetHistory.History))
for i, rec := range assetHistory.History {
history[i] = deriveHistoryOverview(assetHistory.Asset, rec)
}
ref := assetRefFromID(assetHistory.Asset.Id)
templates.MustRender(ctx.Request.Context(), ctx.Writer, "pages/asset.html", map[string]any{
"Breadcrumbs": assetBreadcrumbs(ref),
"Ref": ref,
"Overview": deriveAssetOverview(assetHistory.Asset),
"ActiveVersions": versionsSummary(assetHistory.Asset, true),
"InactiveVersions": versionsSummary(assetHistory.Asset, false),
"History": history,
"LikelyMoreHistory": len(history) == historyLimit,
"HistoryHref": fmt.Sprintf("/a/%s/history", assetID),
})
return nil
}