mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
5f50dcce5a
Fixes coder/internal#747
352 lines
12 KiB
Go
352 lines
12 KiB
Go
package cliui
|
||
|
||
import (
|
||
"fmt"
|
||
"io"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jedib0t/go-pretty/v6/table"
|
||
"golang.org/x/mod/semver"
|
||
|
||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||
"github.com/coder/coder/v2/coderd/util/slice"
|
||
"github.com/coder/coder/v2/codersdk"
|
||
"github.com/coder/pretty"
|
||
)
|
||
|
||
var (
|
||
pipeMid = "├"
|
||
pipeEnd = "└"
|
||
)
|
||
|
||
type WorkspaceResourcesOptions struct {
|
||
WorkspaceName string
|
||
HideAgentState bool
|
||
HideAccess bool
|
||
Title string
|
||
ServerVersion string
|
||
ListeningPorts map[uuid.UUID]codersdk.WorkspaceAgentListeningPortsResponse
|
||
Devcontainers map[uuid.UUID]codersdk.WorkspaceAgentListContainersResponse
|
||
ShowDetails bool
|
||
}
|
||
|
||
// WorkspaceResources displays the connection status and tree-view of provided resources.
|
||
// ┌────────────────────────────────────────────────────────────────────────────┐
|
||
// │ RESOURCE STATUS ACCESS │
|
||
// ├────────────────────────────────────────────────────────────────────────────┤
|
||
// │ google_compute_disk.root │
|
||
// ├────────────────────────────────────────────────────────────────────────────┤
|
||
// │ google_compute_instance.dev │
|
||
// │ └─ dev (linux, amd64) ⦾ connecting [10s] coder ssh dev.dev │
|
||
// ├────────────────────────────────────────────────────────────────────────────┤
|
||
// │ kubernetes_pod.dev │
|
||
// │ ├─ go (linux, amd64) ⦿ connected coder ssh dev.go │
|
||
// │ └─ postgres (linux, amd64) ⦾ disconnected [4s] coder ssh dev.postgres │
|
||
// └────────────────────────────────────────────────────────────────────────────┘
|
||
func WorkspaceResources(writer io.Writer, resources []codersdk.WorkspaceResource, options WorkspaceResourcesOptions) error {
|
||
// Sort resources by type for consistent output.
|
||
sort.Slice(resources, func(i, j int) bool {
|
||
return resources[i].Type < resources[j].Type
|
||
})
|
||
|
||
tableWriter := table.NewWriter()
|
||
if options.Title != "" {
|
||
tableWriter.SetTitle(options.Title)
|
||
}
|
||
tableWriter.SetStyle(table.StyleLight)
|
||
tableWriter.Style().Options.SeparateColumns = false
|
||
row := table.Row{"Resource"}
|
||
if !options.HideAgentState {
|
||
row = append(row, "Status")
|
||
row = append(row, "Health")
|
||
row = append(row, "Version")
|
||
}
|
||
if !options.HideAccess {
|
||
row = append(row, "Access")
|
||
}
|
||
tableWriter.AppendHeader(row)
|
||
|
||
totalAgents := 0
|
||
for _, resource := range resources {
|
||
for _, agent := range resource.Agents {
|
||
if !agent.ParentID.Valid {
|
||
totalAgents++
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, resource := range resources {
|
||
if resource.Type == "random_string" {
|
||
// Hide resources that aren't substantial to a user!
|
||
// This is an unfortunate case, and we should allow
|
||
// callers to hide resources eventually.
|
||
continue
|
||
}
|
||
resourceAddress := resource.Type + "." + resource.Name
|
||
|
||
// Sort agents by name for consistent output.
|
||
sort.Slice(resource.Agents, func(i, j int) bool {
|
||
return resource.Agents[i].Name < resource.Agents[j].Name
|
||
})
|
||
|
||
// Display a line for the resource.
|
||
tableWriter.AppendRow(table.Row{
|
||
Bold(resourceAddress),
|
||
"",
|
||
"",
|
||
"",
|
||
})
|
||
// Display all agents associated with the resource.
|
||
agents := slice.Filter(resource.Agents, func(agent codersdk.WorkspaceAgent) bool {
|
||
return !agent.ParentID.Valid
|
||
})
|
||
for index, agent := range agents {
|
||
tableWriter.AppendRow(renderAgentRow(agent, index, totalAgents, options))
|
||
for _, row := range renderListeningPorts(options, agent.ID, index, totalAgents) {
|
||
tableWriter.AppendRow(row)
|
||
}
|
||
for _, row := range renderDevcontainers(resources, options, agent.ID, index, totalAgents) {
|
||
tableWriter.AppendRow(row)
|
||
}
|
||
}
|
||
tableWriter.AppendSeparator()
|
||
}
|
||
_, err := fmt.Fprintln(writer, tableWriter.Render())
|
||
return err
|
||
}
|
||
|
||
func renderAgentRow(agent codersdk.WorkspaceAgent, index, totalAgents int, options WorkspaceResourcesOptions) table.Row {
|
||
row := table.Row{
|
||
// These tree from a resource!
|
||
fmt.Sprintf("%s─ %s (%s, %s)", renderPipe(index, totalAgents), agent.Name, agent.OperatingSystem, agent.Architecture),
|
||
}
|
||
if !options.HideAgentState {
|
||
var agentStatus, agentHealth, agentVersion string
|
||
if !options.HideAgentState {
|
||
agentStatus = renderAgentStatus(agent)
|
||
agentHealth = renderAgentHealth(agent)
|
||
agentVersion = renderAgentVersion(agent.Version, options.ServerVersion)
|
||
}
|
||
row = append(row, agentStatus, agentHealth, agentVersion)
|
||
}
|
||
if !options.HideAccess {
|
||
sshCommand := "coder ssh " + options.WorkspaceName
|
||
if totalAgents > 1 || len(options.Devcontainers) > 0 {
|
||
sshCommand += "." + agent.Name
|
||
}
|
||
sshCommand = pretty.Sprint(DefaultStyles.Code, sshCommand)
|
||
row = append(row, sshCommand)
|
||
}
|
||
return row
|
||
}
|
||
|
||
func renderListeningPorts(wro WorkspaceResourcesOptions, agentID uuid.UUID, idx, total int) []table.Row {
|
||
var rows []table.Row
|
||
if wro.ListeningPorts == nil {
|
||
return []table.Row{}
|
||
}
|
||
lp, ok := wro.ListeningPorts[agentID]
|
||
if !ok || len(lp.Ports) == 0 {
|
||
return []table.Row{}
|
||
}
|
||
rows = append(rows, table.Row{
|
||
fmt.Sprintf(" %s─ Open Ports", renderPipe(idx, total)),
|
||
})
|
||
for idx, port := range lp.Ports {
|
||
rows = append(rows, renderPortRow(port, idx, len(lp.Ports)))
|
||
}
|
||
return rows
|
||
}
|
||
|
||
func renderPortRow(port codersdk.WorkspaceAgentListeningPort, idx, total int) table.Row {
|
||
var sb strings.Builder
|
||
_, _ = sb.WriteString(" ")
|
||
_, _ = sb.WriteString(renderPipe(idx, total))
|
||
_, _ = sb.WriteString("─ ")
|
||
_, _ = sb.WriteString(pretty.Sprintf(DefaultStyles.Code, "%5d/%s", port.Port, port.Network))
|
||
if port.ProcessName != "" {
|
||
_, _ = sb.WriteString(pretty.Sprintf(DefaultStyles.Keyword, " [%s]", port.ProcessName))
|
||
}
|
||
return table.Row{sb.String()}
|
||
}
|
||
|
||
func renderDevcontainers(resources []codersdk.WorkspaceResource, wro WorkspaceResourcesOptions, agentID uuid.UUID, index, totalAgents int) []table.Row {
|
||
var rows []table.Row
|
||
if wro.Devcontainers == nil {
|
||
return []table.Row{}
|
||
}
|
||
dc, ok := wro.Devcontainers[agentID]
|
||
if !ok || len(dc.Devcontainers) == 0 {
|
||
return []table.Row{}
|
||
}
|
||
rows = append(rows, table.Row{
|
||
fmt.Sprintf(" %s─ %s", renderPipe(index, totalAgents), "Devcontainers"),
|
||
})
|
||
for idx, devcontainer := range dc.Devcontainers {
|
||
rows = append(rows, renderDevcontainerRow(resources, devcontainer, idx, len(dc.Devcontainers), wro)...)
|
||
}
|
||
return rows
|
||
}
|
||
|
||
func renderDevcontainerRow(resources []codersdk.WorkspaceResource, devcontainer codersdk.WorkspaceAgentDevcontainer, index, total int, wro WorkspaceResourcesOptions) []table.Row {
|
||
var rows []table.Row
|
||
|
||
// If the devcontainer is running and has an associated agent, we want to
|
||
// display the agent's details. Otherwise, we just display the devcontainer
|
||
// name and status.
|
||
var subAgent *codersdk.WorkspaceAgent
|
||
displayName := devcontainer.Name
|
||
if devcontainer.Agent != nil && devcontainer.Status == codersdk.WorkspaceAgentDevcontainerStatusRunning {
|
||
for _, resource := range resources {
|
||
if agent, found := slice.Find(resource.Agents, func(agent codersdk.WorkspaceAgent) bool {
|
||
return agent.ID == devcontainer.Agent.ID
|
||
}); found {
|
||
subAgent = &agent
|
||
break
|
||
}
|
||
}
|
||
if subAgent != nil {
|
||
displayName = subAgent.Name
|
||
displayName += fmt.Sprintf(" (%s, %s)", subAgent.OperatingSystem, subAgent.Architecture)
|
||
}
|
||
}
|
||
|
||
if devcontainer.Container != nil {
|
||
displayName += " " + pretty.Sprint(DefaultStyles.Keyword, "["+devcontainer.Container.FriendlyName+"]")
|
||
}
|
||
|
||
// Build the main row.
|
||
row := table.Row{
|
||
fmt.Sprintf(" %s─ %s", renderPipe(index, total), displayName),
|
||
}
|
||
|
||
// Add status, health, and version columns.
|
||
if !wro.HideAgentState {
|
||
if subAgent != nil {
|
||
row = append(row, renderAgentStatus(*subAgent))
|
||
row = append(row, renderAgentHealth(*subAgent))
|
||
row = append(row, renderAgentVersion(subAgent.Version, wro.ServerVersion))
|
||
} else {
|
||
row = append(row, renderDevcontainerStatus(devcontainer.Status))
|
||
row = append(row, "") // No health for devcontainer without agent.
|
||
row = append(row, "") // No version for devcontainer without agent.
|
||
}
|
||
}
|
||
|
||
// Add access column.
|
||
if !wro.HideAccess {
|
||
if subAgent != nil {
|
||
accessString := fmt.Sprintf("coder ssh %s.%s", wro.WorkspaceName, subAgent.Name)
|
||
row = append(row, pretty.Sprint(DefaultStyles.Code, accessString))
|
||
} else {
|
||
row = append(row, "") // No access for devcontainers without agent.
|
||
}
|
||
}
|
||
|
||
rows = append(rows, row)
|
||
|
||
// Add error message if present.
|
||
if errorMessage := devcontainer.Error; errorMessage != "" {
|
||
// Cap error message length for display.
|
||
if !wro.ShowDetails && len(errorMessage) > 80 {
|
||
errorMessage = errorMessage[:79] + "…"
|
||
}
|
||
errorRow := table.Row{
|
||
" × " + pretty.Sprint(DefaultStyles.Error, errorMessage),
|
||
"",
|
||
"",
|
||
"",
|
||
}
|
||
if !wro.HideAccess {
|
||
errorRow = append(errorRow, "")
|
||
}
|
||
rows = append(rows, errorRow)
|
||
}
|
||
|
||
// Add listening ports for the devcontainer agent.
|
||
if subAgent != nil {
|
||
portRows := renderListeningPorts(wro, subAgent.ID, index, total)
|
||
for _, portRow := range portRows {
|
||
// Adjust indentation for ports under devcontainer agent.
|
||
if len(portRow) > 0 {
|
||
if str, ok := portRow[0].(string); ok {
|
||
portRow[0] = " " + str // Add extra indentation.
|
||
}
|
||
}
|
||
rows = append(rows, portRow)
|
||
}
|
||
}
|
||
|
||
return rows
|
||
}
|
||
|
||
func renderDevcontainerStatus(status codersdk.WorkspaceAgentDevcontainerStatus) string {
|
||
switch status {
|
||
case codersdk.WorkspaceAgentDevcontainerStatusRunning:
|
||
return pretty.Sprint(DefaultStyles.Keyword, "▶ running")
|
||
case codersdk.WorkspaceAgentDevcontainerStatusStopped:
|
||
return pretty.Sprint(DefaultStyles.Placeholder, "⏹ stopped")
|
||
case codersdk.WorkspaceAgentDevcontainerStatusStarting:
|
||
return pretty.Sprint(DefaultStyles.Warn, "⧗ starting")
|
||
case codersdk.WorkspaceAgentDevcontainerStatusError:
|
||
return pretty.Sprint(DefaultStyles.Error, "✘ error")
|
||
default:
|
||
return pretty.Sprint(DefaultStyles.Placeholder, "○ "+string(status))
|
||
}
|
||
}
|
||
|
||
func renderAgentStatus(agent codersdk.WorkspaceAgent) string {
|
||
switch agent.Status {
|
||
case codersdk.WorkspaceAgentConnecting:
|
||
since := dbtime.Now().Sub(agent.CreatedAt)
|
||
return pretty.Sprint(DefaultStyles.Warn, "⦾ connecting") + " " +
|
||
pretty.Sprint(DefaultStyles.Placeholder, "["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||
case codersdk.WorkspaceAgentDisconnected:
|
||
since := dbtime.Now().Sub(*agent.DisconnectedAt)
|
||
return pretty.Sprint(DefaultStyles.Error, "⦾ disconnected") + " " +
|
||
pretty.Sprint(DefaultStyles.Placeholder, "["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||
case codersdk.WorkspaceAgentTimeout:
|
||
since := dbtime.Now().Sub(agent.CreatedAt)
|
||
return fmt.Sprintf(
|
||
"%s %s",
|
||
pretty.Sprint(DefaultStyles.Warn, "⦾ timeout"),
|
||
pretty.Sprint(DefaultStyles.Placeholder, "["+strconv.Itoa(int(since.Seconds()))+"s]"),
|
||
)
|
||
case codersdk.WorkspaceAgentConnected:
|
||
return pretty.Sprint(DefaultStyles.Keyword, "⦿ connected")
|
||
default:
|
||
return pretty.Sprint(DefaultStyles.Warn, "○ unknown")
|
||
}
|
||
}
|
||
|
||
func renderAgentHealth(agent codersdk.WorkspaceAgent) string {
|
||
if agent.Health.Healthy {
|
||
return pretty.Sprint(DefaultStyles.Keyword, "✔ healthy")
|
||
}
|
||
return pretty.Sprint(DefaultStyles.Error, "✘ "+agent.Health.Reason)
|
||
}
|
||
|
||
func renderAgentVersion(agentVersion, serverVersion string) string {
|
||
if agentVersion == "" {
|
||
agentVersion = "(unknown)"
|
||
}
|
||
if !semver.IsValid(serverVersion) || !semver.IsValid(agentVersion) {
|
||
return pretty.Sprint(DefaultStyles.Placeholder, agentVersion)
|
||
}
|
||
outdated := semver.Compare(agentVersion, serverVersion) < 0
|
||
if outdated {
|
||
return pretty.Sprint(DefaultStyles.Warn, agentVersion+" (outdated)")
|
||
}
|
||
return pretty.Sprint(DefaultStyles.Keyword, agentVersion)
|
||
}
|
||
|
||
func renderPipe(idx, total int) string {
|
||
if idx == total-1 {
|
||
return pipeEnd
|
||
}
|
||
return pipeMid
|
||
}
|