github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/hud/resourceview.go (about)

     1  package hud
     2  
     3  import (
     4  	"fmt"
     5  	"runtime"
     6  	"strings"
     7  	"time"
     8  
     9  	"github.com/gdamore/tcell"
    10  	"github.com/rivo/tview"
    11  
    12  	"github.com/tilt-dev/tilt/internal/hud/view"
    13  	"github.com/tilt-dev/tilt/internal/rty"
    14  	"github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1"
    15  	"github.com/tilt-dev/tilt/pkg/model"
    16  	"github.com/tilt-dev/tilt/pkg/model/logstore"
    17  )
    18  
    19  // These widths are determined experimentally, to see what shows up in a typical UX.
    20  const DeployCellMinWidth = 8
    21  const BuildDurCellMinWidth = 7
    22  const BuildStatusCellMinWidth = 8
    23  const MaxInlineErrHeight = 6
    24  
    25  type ResourceView struct {
    26  	logReader   logstore.Reader
    27  	res         view.Resource
    28  	rv          view.ResourceViewState
    29  	triggerMode model.TriggerMode
    30  	selected    bool
    31  
    32  	clock func() time.Time
    33  }
    34  
    35  func NewResourceView(logReader logstore.Reader, res view.Resource, rv view.ResourceViewState, triggerMode model.TriggerMode,
    36  	selected bool, clock func() time.Time) *ResourceView {
    37  	return &ResourceView{
    38  		logReader:   logReader,
    39  		res:         res,
    40  		rv:          rv,
    41  		triggerMode: triggerMode,
    42  		selected:    selected,
    43  		clock:       clock,
    44  	}
    45  }
    46  
    47  func (v *ResourceView) Build() rty.Component {
    48  	layout := rty.NewConcatLayout(rty.DirVert)
    49  	layout.Add(v.resourceTitle())
    50  	if v.res.IsCollapsed(v.rv) {
    51  		return layout
    52  	}
    53  
    54  	layout.Add(v.resourceExpandedPane())
    55  	return layout
    56  }
    57  
    58  func (v *ResourceView) resourceTitle() rty.Component {
    59  	l := rty.NewConcatLayout(rty.DirHor)
    60  	l.Add(v.titleTextName())
    61  	l.Add(rty.TextString(" "))
    62  	l.AddDynamic(rty.Fg(rty.NewFillerString('╌'), cLightText))
    63  	l.Add(rty.TextString(" "))
    64  
    65  	if tt := v.titleText(); tt != nil {
    66  		l.Add(tt)
    67  		l.Add(middotText())
    68  	}
    69  
    70  	l.Add(v.titleTextBuild())
    71  	l.Add(middotText())
    72  	l.Add(v.titleTextDeploy())
    73  	return rty.OneLine(l)
    74  }
    75  
    76  type statusDisplay struct {
    77  	color   tcell.Color
    78  	spinner bool
    79  }
    80  
    81  // NOTE: This should be in-sync with combinedStatus in the web UI
    82  func combinedStatus(res view.Resource) statusDisplay {
    83  	currentBuild := res.CurrentBuild
    84  	hasCurrentBuild := !currentBuild.Empty()
    85  	hasPendingBuild := !res.PendingBuildSince.IsZero() && res.TriggerMode.AutoOnChange()
    86  	buildHistory := res.BuildHistory
    87  	lastBuild := res.LastBuild()
    88  	lastBuildError := lastBuild.Error != nil
    89  
    90  	if hasCurrentBuild {
    91  		return statusDisplay{color: cPending, spinner: true}
    92  	} else if hasPendingBuild {
    93  		return statusDisplay{color: cPending}
    94  	} else if lastBuildError {
    95  		return statusDisplay{color: cBad}
    96  	}
    97  
    98  	runtimeStatus := v1alpha1.RuntimeStatusUnknown
    99  	if res.ResourceInfo != nil {
   100  		runtimeStatus = res.ResourceInfo.RuntimeStatus()
   101  	}
   102  
   103  	switch runtimeStatus {
   104  	case v1alpha1.RuntimeStatusError:
   105  		return statusDisplay{color: cBad}
   106  	case v1alpha1.RuntimeStatusPending:
   107  		return statusDisplay{color: cPending, spinner: true}
   108  	case v1alpha1.RuntimeStatusOK:
   109  		return statusDisplay{color: cGood}
   110  	case v1alpha1.RuntimeStatusNotApplicable:
   111  		if len(buildHistory) > 0 {
   112  			return statusDisplay{color: cGood}
   113  		} else {
   114  			return statusDisplay{color: cPending}
   115  		}
   116  	}
   117  	return statusDisplay{color: cPending}
   118  }
   119  
   120  func (v *ResourceView) titleTextName() rty.Component {
   121  	sb := rty.NewStringBuilder()
   122  	selected := v.selected
   123  
   124  	p := " "
   125  	if selected {
   126  		p = "▼"
   127  		if runtime.GOOS == "windows" {
   128  			// Windows default fonts support fewer symbols.
   129  			p = "↓"
   130  		}
   131  	}
   132  	if selected && v.res.IsCollapsed(v.rv) {
   133  		p = "▶"
   134  		if runtime.GOOS == "windows" {
   135  			p = "→"
   136  		}
   137  	}
   138  
   139  	display := combinedStatus(v.res)
   140  	sb.Text(p)
   141  
   142  	switch display.color {
   143  	case cGood:
   144  		sb.Fg(display.color).Textf(" ● ")
   145  	case cBad:
   146  		sb.Fg(display.color).Textf(" %s ", xMark())
   147  	default:
   148  		sb.Fg(display.color).Textf(" ○ ")
   149  	}
   150  
   151  	name := v.res.Name.String()
   152  	if display.spinner {
   153  		name = fmt.Sprintf("%s %s", v.res.Name, v.spinner())
   154  	}
   155  	if len(v.warnings()) > 0 {
   156  		name = fmt.Sprintf("%s %s", v.res.Name, "— Warning ⚠️")
   157  	}
   158  	sb.Fg(tcell.ColorDefault).Text(name)
   159  	return sb.Build()
   160  }
   161  
   162  func (v *ResourceView) warnings() []string {
   163  	spanID := v.res.LastBuild().SpanID
   164  	if spanID == "" {
   165  		return nil
   166  	}
   167  	return v.logReader.Warnings(spanID)
   168  }
   169  
   170  func (v *ResourceView) titleText() rty.Component {
   171  	switch i := v.res.ResourceInfo.(type) {
   172  	case view.DCResourceInfo:
   173  		return titleTextDC(i)
   174  	case view.K8sResourceInfo:
   175  		return titleTextK8s(i)
   176  	default:
   177  		return nil
   178  	}
   179  }
   180  
   181  func titleTextK8s(k8sInfo view.K8sResourceInfo) rty.Component {
   182  	status := k8sInfo.PodStatus
   183  	if status == "" {
   184  		status = "Pending"
   185  	}
   186  	return rty.TextString(status)
   187  }
   188  
   189  func titleTextDC(dcInfo view.DCResourceInfo) rty.Component {
   190  	sb := rty.NewStringBuilder()
   191  	status := dcInfo.Status()
   192  	if status == "" {
   193  		status = "Pending"
   194  	}
   195  	sb.Text(status)
   196  	return sb.Build()
   197  }
   198  
   199  func (v *ResourceView) titleTextBuild() rty.Component {
   200  	return buildStatusCell(makeBuildStatus(v.res, v.triggerMode))
   201  }
   202  
   203  func (v *ResourceView) titleTextDeploy() rty.Component {
   204  	return deployTimeCell(v.res.LastDeployTime, tcell.ColorDefault)
   205  }
   206  
   207  func (v *ResourceView) resourceExpandedPane() rty.Component {
   208  	l := rty.NewConcatLayout(rty.DirHor)
   209  	l.Add(rty.TextString(strings.Repeat(" ", 4)))
   210  
   211  	rhs := rty.NewConcatLayout(rty.DirVert)
   212  	rhs.Add(v.resourceExpandedHistory())
   213  	rhs.Add(v.resourceExpanded())
   214  	rhs.Add(v.resourceExpandedEndpoints())
   215  	rhs.Add(v.resourceExpandedError())
   216  	l.AddDynamic(rhs)
   217  	return l
   218  }
   219  
   220  func (v *ResourceView) resourceExpanded() rty.Component {
   221  	switch v.res.ResourceInfo.(type) {
   222  	case view.DCResourceInfo:
   223  		return v.resourceExpandedDC()
   224  	case view.K8sResourceInfo:
   225  		return v.resourceExpandedK8s()
   226  	case view.YAMLResourceInfo:
   227  		return v.resourceExpandedYAML()
   228  	default:
   229  		return rty.EmptyLayout
   230  	}
   231  }
   232  
   233  func (v *ResourceView) resourceExpandedYAML() rty.Component {
   234  	yi := v.res.YAMLInfo()
   235  
   236  	if !v.res.IsYAML() || len(yi.K8sDisplayNames) == 0 {
   237  		return rty.EmptyLayout
   238  	}
   239  
   240  	l := rty.NewConcatLayout(rty.DirHor)
   241  	l.Add(rty.TextString(strings.Repeat(" ", 2)))
   242  	rhs := rty.NewConcatLayout(rty.DirVert)
   243  	rhs.Add(rty.NewStringBuilder().Fg(cLightText).Text("(Kubernetes objects that don't match a group)").Build())
   244  	rhs.Add(rty.TextString(strings.Join(yi.K8sDisplayNames, "\n")))
   245  	l.AddDynamic(rhs)
   246  	return l
   247  }
   248  
   249  func (v *ResourceView) resourceExpandedDC() rty.Component {
   250  	dcInfo := v.res.DCInfo()
   251  
   252  	l := rty.NewConcatLayout(rty.DirHor)
   253  	l.Add(v.resourceTextDCContainer(dcInfo))
   254  	l.Add(rty.TextString(" "))
   255  	l.AddDynamic(rty.NewFillerString(' '))
   256  
   257  	st := v.res.DockerComposeTarget().StartTime
   258  	if !st.IsZero() {
   259  		if len(v.res.Endpoints) > 0 {
   260  			v.appendEndpoints(l)
   261  			l.Add(middotText())
   262  		}
   263  		l.Add(resourceTextAge(st))
   264  	}
   265  
   266  	return rty.OneLine(l)
   267  }
   268  
   269  func (v *ResourceView) resourceTextDCContainer(dcInfo view.DCResourceInfo) rty.Component {
   270  	if dcInfo.ContainerID.String() == "" {
   271  		return rty.EmptyLayout
   272  	}
   273  
   274  	sb := rty.NewStringBuilder()
   275  	sb.Fg(cLightText).Text("Container ID: ")
   276  	sb.Fg(tcell.ColorDefault).Text(dcInfo.ContainerID.ShortStr())
   277  	return sb.Build()
   278  }
   279  
   280  func (v *ResourceView) endpointsNeedSecondLine() bool {
   281  	if len(v.res.Endpoints) > 1 {
   282  		return true
   283  	}
   284  	if v.res.IsK8s() && v.res.K8sInfo().PodRestarts > 0 && len(v.res.Endpoints) == 1 {
   285  		return true
   286  	}
   287  	return false
   288  }
   289  
   290  func (v *ResourceView) resourceExpandedK8s() rty.Component {
   291  	k8sInfo := v.res.K8sInfo()
   292  	if k8sInfo.PodName == "" {
   293  		return rty.EmptyLayout
   294  	}
   295  
   296  	l := rty.NewConcatLayout(rty.DirHor)
   297  	l.Add(resourceTextPodName(k8sInfo))
   298  	l.Add(rty.TextString(" "))
   299  	l.AddDynamic(rty.NewFillerString(' '))
   300  	l.Add(rty.TextString(" "))
   301  
   302  	if k8sInfo.PodRestarts > 0 {
   303  		l.Add(resourceTextPodRestarts(k8sInfo))
   304  		l.Add(middotText())
   305  	}
   306  
   307  	if len(v.res.Endpoints) > 0 && !v.endpointsNeedSecondLine() {
   308  		v.appendEndpoints(l)
   309  		l.Add(middotText())
   310  	}
   311  
   312  	l.Add(resourceTextAge(k8sInfo.PodCreationTime))
   313  	return rty.OneLine(l)
   314  }
   315  
   316  func resourceTextPodName(k8sInfo view.K8sResourceInfo) rty.Component {
   317  	sb := rty.NewStringBuilder()
   318  	sb.Fg(cLightText).Text("K8S POD: ")
   319  	sb.Fg(tcell.ColorDefault).Text(k8sInfo.PodName)
   320  	return sb.Build()
   321  }
   322  
   323  func resourceTextPodRestarts(k8sInfo view.K8sResourceInfo) rty.Component {
   324  	s := "restarts"
   325  	if k8sInfo.PodRestarts == 1 {
   326  		s = "restart"
   327  	}
   328  	return rty.NewStringBuilder().
   329  		Fg(cPending).
   330  		Textf("%d %s", k8sInfo.PodRestarts, s).
   331  		Build()
   332  }
   333  
   334  func resourceTextAge(t time.Time) rty.Component {
   335  	sb := rty.NewStringBuilder()
   336  	sb.Fg(cLightText).Text("AGE ")
   337  	sb.Fg(tcell.ColorDefault).Text(formatDeployAge(time.Since(t)))
   338  	return rty.NewMinLengthLayout(DeployCellMinWidth, rty.DirHor).
   339  		SetAlign(rty.AlignEnd).
   340  		Add(sb.Build())
   341  }
   342  
   343  func (v *ResourceView) appendEndpoints(l *rty.ConcatLayout) {
   344  	for i, endpoint := range v.res.Endpoints {
   345  		if i != 0 {
   346  			l.Add(middotText())
   347  		}
   348  		l.Add(rty.TextString(endpoint))
   349  	}
   350  }
   351  
   352  func (v *ResourceView) resourceExpandedEndpoints() rty.Component {
   353  	if !v.endpointsNeedSecondLine() {
   354  		return rty.NewConcatLayout(rty.DirVert)
   355  	}
   356  
   357  	l := rty.NewConcatLayout(rty.DirHor)
   358  	l.Add(resourceTextURLPrefix())
   359  	v.appendEndpoints(l)
   360  
   361  	return l
   362  }
   363  
   364  func resourceTextURLPrefix() rty.Component {
   365  	sb := rty.NewStringBuilder()
   366  	sb.Fg(cLightText).Text("URL: ")
   367  	return sb.Build()
   368  }
   369  
   370  func (v *ResourceView) resourceExpandedHistory() rty.Component {
   371  	if v.res.IsYAML() {
   372  		return rty.NewConcatLayout(rty.DirVert)
   373  	}
   374  
   375  	if v.res.CurrentBuild.Empty() && len(v.res.BuildHistory) == 0 {
   376  		return rty.NewConcatLayout(rty.DirVert)
   377  	}
   378  
   379  	l := rty.NewConcatLayout(rty.DirHor)
   380  	l.Add(rty.NewStringBuilder().Fg(cLightText).Text("HISTORY: ").Build())
   381  
   382  	rows := rty.NewConcatLayout(rty.DirVert)
   383  	rowCount := 0
   384  	if !v.res.CurrentBuild.Empty() {
   385  		rows.Add(NewEditStatusLine(buildStatus{
   386  			edits:    v.res.CurrentBuild.Edits,
   387  			reason:   v.res.CurrentBuild.Reason,
   388  			duration: v.res.CurrentBuild.Duration(),
   389  			status:   "Building",
   390  			muted:    true,
   391  		}))
   392  		rowCount++
   393  	}
   394  	for _, bStatus := range v.res.BuildHistory {
   395  		if rowCount >= 2 {
   396  			// at most 2 rows
   397  			break
   398  		}
   399  
   400  		status := "OK"
   401  		if bStatus.Error != nil {
   402  			status = "Error"
   403  		}
   404  
   405  		rows.Add(NewEditStatusLine(buildStatus{
   406  			edits:      bStatus.Edits,
   407  			reason:     bStatus.Reason,
   408  			duration:   bStatus.Duration(),
   409  			status:     status,
   410  			deployTime: bStatus.FinishTime,
   411  		}))
   412  		rowCount++
   413  	}
   414  	l.AddDynamic(rows)
   415  	return l
   416  }
   417  
   418  func (v *ResourceView) resourceExpandedError() rty.Component {
   419  	errPane, ok := v.resourceExpandedBuildError()
   420  	isWarnings := false
   421  	if !ok {
   422  		errPane, ok = v.resourceExpandedRuntimeError()
   423  	}
   424  	if !ok {
   425  		errPane, ok = v.resourceExpandedWarnings()
   426  		if ok {
   427  			isWarnings = true
   428  		}
   429  	}
   430  
   431  	if !ok {
   432  		return rty.NewConcatLayout(rty.DirVert)
   433  	}
   434  
   435  	l := rty.NewConcatLayout(rty.DirVert)
   436  	if isWarnings {
   437  		l.Add(rty.NewStringBuilder().Fg(cLightText).Text("WARNINGS:").Build())
   438  	} else {
   439  		l.Add(rty.NewStringBuilder().Fg(cLightText).Text("ERROR:").Build())
   440  	}
   441  
   442  	indentPane := rty.NewConcatLayout(rty.DirHor)
   443  	indentPane.Add(rty.TextString(strings.Repeat(" ", 3)))
   444  
   445  	errPane = rty.NewTailLayout(errPane)
   446  	errPane = rty.NewMaxLengthLayout(errPane, rty.DirVert, MaxInlineErrHeight)
   447  	indentPane.Add(errPane)
   448  	l.Add(indentPane)
   449  
   450  	return l
   451  }
   452  
   453  func (v *ResourceView) resourceExpandedRuntimeError() (rty.Component, bool) {
   454  	pane := rty.NewConcatLayout(rty.DirVert)
   455  	ok := false
   456  	if isCrashing(v.res) {
   457  		spanID := v.res.ResourceInfo.RuntimeSpanID()
   458  		runtimeLog := v.logReader.TailSpan(abbreviatedLogLineCount, spanID)
   459  		abbrevLog := abbreviateLog(runtimeLog)
   460  		for _, logLine := range abbrevLog {
   461  			pane.Add(rty.TextString(logLine))
   462  			ok = true
   463  		}
   464  	}
   465  	return pane, ok
   466  }
   467  
   468  func (v *ResourceView) resourceExpandedWarnings() (rty.Component, bool) {
   469  	pane := rty.NewConcatLayout(rty.DirVert)
   470  	ok := false
   471  
   472  	warnings := v.warnings()
   473  	if len(warnings) > 0 {
   474  		abbrevLog := abbreviateLog(strings.Join(warnings, ""))
   475  		for _, logLine := range abbrevLog {
   476  			pane.Add(rty.TextString(logLine))
   477  			ok = true
   478  		}
   479  	}
   480  	return pane, ok
   481  }
   482  
   483  func (v *ResourceView) resourceExpandedBuildError() (rty.Component, bool) {
   484  	pane := rty.NewConcatLayout(rty.DirVert)
   485  	ok := false
   486  
   487  	if v.res.LastBuild().Error != nil {
   488  		spanID := v.res.LastBuild().SpanID
   489  		abbrevLog := abbreviateLog(v.logReader.TailSpan(abbreviatedLogLineCount, spanID))
   490  		for _, logLine := range abbrevLog {
   491  			pane.Add(rty.TextString(logLine))
   492  			ok = true
   493  		}
   494  
   495  		// if the build log is non-empty, it will contain the error, so we don't need to show this separately
   496  		if len(abbrevLog) == 0 {
   497  			pane.Add(rty.TextString(fmt.Sprintf("Error: %s", v.res.LastBuild().Error)))
   498  			ok = true
   499  		}
   500  	}
   501  
   502  	return pane, ok
   503  }
   504  
   505  var spinnerChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
   506  
   507  var spinnerCharsWindows = []string{
   508  	string(tview.BoxDrawingsLightDownAndRight),
   509  	string(tview.BoxDrawingsLightHorizontal),
   510  	string(tview.BoxDrawingsLightHorizontal),
   511  	string(tview.BoxDrawingsLightDownAndLeft),
   512  	string(tview.BoxDrawingsLightVertical),
   513  	string(tview.BoxDrawingsLightUpAndLeft),
   514  	string(tview.BoxDrawingsLightHorizontal),
   515  	string(tview.BoxDrawingsLightHorizontal),
   516  	string(tview.BoxDrawingsLightUpAndRight),
   517  	string(tview.BoxDrawingsLightVertical),
   518  }
   519  
   520  func (v *ResourceView) spinner() string {
   521  	chars := spinnerChars
   522  	if runtime.GOOS == "windows" {
   523  		chars = spinnerCharsWindows
   524  	}
   525  	decisecond := v.clock().Nanosecond() / int(time.Second/10)
   526  	return chars[decisecond%len(chars)] // tick spinner every 10x/second
   527  }