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 }