mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: Improve resource preview and first-time experience (#946)
* Improve CLI documentation * feat: Allow workspace resources to attach multiple agents This enables a "kubernetes_pod" to attach multiple agents that could be for multiple services. Each agent is required to have a unique name, so SSH syntax is: `coder ssh <workspace>.<agent>` A resource can have zero agents too, they aren't required. * Add tree view * Improve table UI * feat: Allow workspace resources to attach multiple agents This enables a "kubernetes_pod" to attach multiple agents that could be for multiple services. Each agent is required to have a unique name, so SSH syntax is: `coder ssh <workspace>.<agent>` A resource can have zero agents too, they aren't required. * Rename `tunnel` to `skip-tunnel` This command was `true` by default, which causes a confusing user experience. * Add disclaimer about editing templates * Add help to template create * Improve workspace create flow * Add end-to-end test for config-ssh * Improve testing of config-ssh * Fix workspace list * Fix config ssh tests * Update cli/configssh.go Co-authored-by: Cian Johnston <public@cianjohnston.ie> * Fix requested changes * Remove socat requirement * Fix resources not reading in TTY Co-authored-by: Cian Johnston <public@cianjohnston.ie>
This commit is contained in:
co-authored by
Cian Johnston
parent
19b4323512
commit
fb9dc4f346
@@ -26,6 +26,7 @@ var Styles = struct {
|
||||
Checkmark,
|
||||
Code,
|
||||
Crossmark,
|
||||
Error,
|
||||
Field,
|
||||
Keyword,
|
||||
Paragraph,
|
||||
@@ -41,6 +42,7 @@ var Styles = struct {
|
||||
Checkmark: defaultStyles.Checkmark,
|
||||
Code: defaultStyles.Code,
|
||||
Crossmark: defaultStyles.Error.Copy().SetString("✘"),
|
||||
Error: defaultStyles.Error,
|
||||
Field: defaultStyles.Code.Copy().Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}),
|
||||
Keyword: defaultStyles.Keyword,
|
||||
Paragraph: defaultStyles.Paragraph,
|
||||
|
||||
+40
-20
@@ -5,7 +5,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
@@ -45,11 +44,11 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
var line string
|
||||
var err error
|
||||
|
||||
inFile, valid := cmd.InOrStdin().(*os.File)
|
||||
if opts.Secret && valid && isatty.IsTerminal(inFile.Fd()) {
|
||||
inFile, isInputFile := cmd.InOrStdin().(*os.File)
|
||||
if opts.Secret && isInputFile && isatty.IsTerminal(inFile.Fd()) {
|
||||
line, err = speakeasy.Ask("")
|
||||
} else {
|
||||
if !opts.IsConfirm && runtime.GOOS == "darwin" && valid {
|
||||
if !opts.IsConfirm && runtime.GOOS == "darwin" && isInputFile {
|
||||
var restore func()
|
||||
restore, err = removeLineLengthLimit(int(inFile.Fd()))
|
||||
if err != nil {
|
||||
@@ -66,22 +65,7 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
// This enables multiline JSON to be pasted into an input, and have
|
||||
// it parse properly.
|
||||
if err == nil && (strings.HasPrefix(line, "{") || strings.HasPrefix(line, "[")) {
|
||||
pipeReader, pipeWriter := io.Pipe()
|
||||
defer pipeWriter.Close()
|
||||
defer pipeReader.Close()
|
||||
go func() {
|
||||
_, _ = pipeWriter.Write([]byte(line))
|
||||
_, _ = reader.WriteTo(pipeWriter)
|
||||
}()
|
||||
var rawMessage json.RawMessage
|
||||
err := json.NewDecoder(pipeReader).Decode(&rawMessage)
|
||||
if err == nil {
|
||||
var buf bytes.Buffer
|
||||
err = json.Compact(&buf, rawMessage)
|
||||
if err == nil {
|
||||
line = buf.String()
|
||||
}
|
||||
}
|
||||
line, err = promptJSON(reader, line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
@@ -118,3 +102,39 @@ func Prompt(cmd *cobra.Command, opts PromptOptions) (string, error) {
|
||||
return "", Canceled
|
||||
}
|
||||
}
|
||||
|
||||
func promptJSON(reader *bufio.Reader, line string) (string, error) {
|
||||
var data bytes.Buffer
|
||||
for {
|
||||
_, _ = data.WriteString(line)
|
||||
var rawMessage json.RawMessage
|
||||
err := json.Unmarshal(data.Bytes(), &rawMessage)
|
||||
if err != nil {
|
||||
if err.Error() != "unexpected end of JSON input" {
|
||||
// If a real syntax error occurs in JSON,
|
||||
// we want to return that partial line to the user.
|
||||
err = nil
|
||||
line = data.String()
|
||||
break
|
||||
}
|
||||
|
||||
// Read line-by-line. We can't use a JSON decoder
|
||||
// here because it doesn't work by newline, so
|
||||
// reads will block.
|
||||
line, err = reader.ReadString('\n')
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Compacting the JSON makes it easier for parsing and testing.
|
||||
rawJSON := data.Bytes()
|
||||
data.Reset()
|
||||
err = json.Compact(&data, rawJSON)
|
||||
if err != nil {
|
||||
return line, xerrors.Errorf("compact json: %w", err)
|
||||
}
|
||||
return data.String(), nil
|
||||
}
|
||||
return line, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
type WorkspaceResourcesOptions struct {
|
||||
WorkspaceName string
|
||||
HideAgentState bool
|
||||
HideAccess bool
|
||||
Title string
|
||||
}
|
||||
|
||||
// WorkspaceResources displays the connection status and tree-view of provided resources.
|
||||
// ┌────────────────────────────────────────────────────────────────────────────┐
|
||||
// │ RESOURCE STATUS ACCESS │
|
||||
// ├────────────────────────────────────────────────────────────────────────────┤
|
||||
// │ google_compute_disk.root persistent │
|
||||
// ├────────────────────────────────────────────────────────────────────────────┤
|
||||
// │ google_compute_instance.dev ephemeral │
|
||||
// │ └─ dev (linux, amd64) ⦾ connecting [10s] coder ssh dev.dev │
|
||||
// ├────────────────────────────────────────────────────────────────────────────┤
|
||||
// │ kubernetes_pod.dev ephemeral │
|
||||
// │ ├─ 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
|
||||
})
|
||||
|
||||
// Address on stop indexes whether a resource still exists when in the stopped transition.
|
||||
addressOnStop := map[string]codersdk.WorkspaceResource{}
|
||||
for _, resource := range resources {
|
||||
if resource.Transition != database.WorkspaceTransitionStop {
|
||||
continue
|
||||
}
|
||||
addressOnStop[resource.Address] = resource
|
||||
}
|
||||
// Displayed stores whether a resource has already been shown.
|
||||
// Resources can be stored with numerous states, which we
|
||||
// process prior to display.
|
||||
displayed := map[string]struct{}{}
|
||||
|
||||
tableWriter := table.NewWriter()
|
||||
if options.Title != "" {
|
||||
tableWriter.SetTitle(options.Title)
|
||||
}
|
||||
tableWriter.SetStyle(table.StyleLight)
|
||||
tableWriter.Style().Options.SeparateColumns = false
|
||||
row := table.Row{"Resource", "Status"}
|
||||
if !options.HideAccess {
|
||||
row = append(row, "Access")
|
||||
}
|
||||
tableWriter.AppendHeader(row)
|
||||
|
||||
totalAgents := 0
|
||||
for _, resource := range resources {
|
||||
totalAgents += len(resource.Agents)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if _, shown := displayed[resource.Address]; shown {
|
||||
// The same resource can have multiple transitions.
|
||||
continue
|
||||
}
|
||||
displayed[resource.Address] = struct{}{}
|
||||
|
||||
// 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
|
||||
})
|
||||
_, existsOnStop := addressOnStop[resource.Address]
|
||||
resourceState := "ephemeral"
|
||||
if existsOnStop {
|
||||
resourceState = "persistent"
|
||||
}
|
||||
// Display a line for the resource.
|
||||
tableWriter.AppendRow(table.Row{
|
||||
Styles.Bold.Render(resource.Type + "." + resource.Name),
|
||||
Styles.Placeholder.Render(resourceState),
|
||||
"",
|
||||
})
|
||||
// Display all agents associated with the resource.
|
||||
for index, agent := range resource.Agents {
|
||||
sshCommand := "coder ssh " + options.WorkspaceName
|
||||
if totalAgents > 1 {
|
||||
sshCommand += "." + agent.Name
|
||||
}
|
||||
sshCommand = Styles.Code.Render(sshCommand)
|
||||
var agentStatus string
|
||||
if !options.HideAgentState {
|
||||
switch agent.Status {
|
||||
case codersdk.WorkspaceAgentConnecting:
|
||||
since := database.Now().Sub(agent.CreatedAt)
|
||||
agentStatus = Styles.Warn.Render("⦾ connecting") + " " +
|
||||
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
case codersdk.WorkspaceAgentDisconnected:
|
||||
since := database.Now().Sub(*agent.DisconnectedAt)
|
||||
agentStatus = Styles.Error.Render("⦾ disconnected") + " " +
|
||||
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
case codersdk.WorkspaceAgentConnected:
|
||||
agentStatus = Styles.Keyword.Render("⦿ connected")
|
||||
}
|
||||
}
|
||||
|
||||
pipe := "├"
|
||||
if index == len(resource.Agents)-1 {
|
||||
pipe = "└"
|
||||
}
|
||||
row := table.Row{
|
||||
// These tree from a resource!
|
||||
fmt.Sprintf("%s─ %s (%s, %s)", pipe, agent.Name, agent.OperatingSystem, agent.Architecture),
|
||||
agentStatus,
|
||||
}
|
||||
if !options.HideAccess {
|
||||
row = append(row, sshCommand)
|
||||
}
|
||||
tableWriter.AppendRow(row)
|
||||
}
|
||||
tableWriter.AppendSeparator()
|
||||
}
|
||||
_, err := fmt.Fprintln(writer, tableWriter.Render())
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package cliui_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWorkspaceResources(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("SingleAgentSSH", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
go func() {
|
||||
err := cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{
|
||||
Type: "google_compute_instance",
|
||||
Name: "dev",
|
||||
Transition: database.WorkspaceTransitionStart,
|
||||
Agents: []codersdk.WorkspaceAgent{{
|
||||
Name: "dev",
|
||||
Status: codersdk.WorkspaceAgentConnected,
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
}},
|
||||
}}, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: "example",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
ptty.ExpectMatch("coder ssh example")
|
||||
})
|
||||
|
||||
t.Run("MultipleStates", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ptty := ptytest.New(t)
|
||||
disconnected := database.Now().Add(-4 * time.Second)
|
||||
go func() {
|
||||
err := cliui.WorkspaceResources(ptty.Output(), []codersdk.WorkspaceResource{{
|
||||
Address: "disk",
|
||||
Transition: database.WorkspaceTransitionStart,
|
||||
Type: "google_compute_disk",
|
||||
Name: "root",
|
||||
}, {
|
||||
Address: "disk",
|
||||
Transition: database.WorkspaceTransitionStop,
|
||||
Type: "google_compute_disk",
|
||||
Name: "root",
|
||||
}, {
|
||||
Address: "another",
|
||||
Transition: database.WorkspaceTransitionStart,
|
||||
Type: "google_compute_instance",
|
||||
Name: "dev",
|
||||
Agents: []codersdk.WorkspaceAgent{{
|
||||
CreatedAt: database.Now().Add(-10 * time.Second),
|
||||
Status: codersdk.WorkspaceAgentConnecting,
|
||||
Name: "dev",
|
||||
OperatingSystem: "linux",
|
||||
Architecture: "amd64",
|
||||
}},
|
||||
}, {
|
||||
Transition: database.WorkspaceTransitionStart,
|
||||
Type: "kubernetes_pod",
|
||||
Name: "dev",
|
||||
Agents: []codersdk.WorkspaceAgent{{
|
||||
Status: codersdk.WorkspaceAgentConnected,
|
||||
Name: "go",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
}, {
|
||||
DisconnectedAt: &disconnected,
|
||||
Status: codersdk.WorkspaceAgentDisconnected,
|
||||
Name: "postgres",
|
||||
Architecture: "amd64",
|
||||
OperatingSystem: "linux",
|
||||
}},
|
||||
}}, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: "dev",
|
||||
HideAgentState: false,
|
||||
HideAccess: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
ptty.ExpectMatch("google_compute_disk.root")
|
||||
ptty.ExpectMatch("google_compute_instance.dev")
|
||||
ptty.ExpectMatch("coder ssh dev.postgres")
|
||||
})
|
||||
}
|
||||
+5
-1
@@ -1,11 +1,13 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -48,7 +50,6 @@ func Select(cmd *cobra.Command, opts SelectOptions) (string, error) {
|
||||
if flag.Lookup("test.v") != nil {
|
||||
return opts.Options[0], nil
|
||||
}
|
||||
opts.HideSearch = false
|
||||
var value string
|
||||
err := survey.AskOne(&survey.Select{
|
||||
Options: opts.Options,
|
||||
@@ -63,6 +64,9 @@ func Select(cmd *cobra.Command, opts SelectOptions) (string, error) {
|
||||
}, fileReadWriter{
|
||||
Writer: cmd.OutOrStdout(),
|
||||
}, cmd.OutOrStdout()))
|
||||
if errors.Is(err, terminal.InterruptErr) {
|
||||
return value, Canceled
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user