diff --git a/internal/state/resource.go b/internal/state/resource.go index 606e2ae3..f32641b5 100644 --- a/internal/state/resource.go +++ b/internal/state/resource.go @@ -19,9 +19,9 @@ func (r *Resource) String() string { return string(r.Address) } -func newResource(ws resource.Resource, addr ResourceAddress, attrs json.RawMessage) (*Resource, error) { +func newResource(state resource.Resource, addr ResourceAddress, attrs json.RawMessage) (*Resource, error) { res := &Resource{ - Common: resource.New(resource.StateResource, ws), + Common: resource.New(resource.StateResource, state), Address: addr, } if err := json.Unmarshal(attrs, &res.Attributes); err != nil { diff --git a/internal/state/state.go b/internal/state/state.go index 22d99e49..313665d0 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -65,7 +65,7 @@ func newState(ws resource.Resource, r io.Reader) (*State, error) { } addr := ResourceAddress(b.String()) var err error - m[addr], err = newResource(ws, addr, instance.Attributes) + m[addr], err = newResource(state, addr, instance.Attributes) if err != nil { return nil, fmt.Errorf("decoding resource %s: %w", addr, err) } diff --git a/internal/task/group.go b/internal/task/group.go index d88c2ee8..02565a6f 100644 --- a/internal/task/group.go +++ b/internal/task/group.go @@ -56,6 +56,26 @@ func (g *Group) Finished() int { return finished } +func (g *Group) Exited() int { + var exited int + for _, t := range g.Tasks { + if t.State == Exited { + exited++ + } + } + return exited +} + +func (g *Group) Errored() int { + var errored int + for _, t := range g.Tasks { + if t.State == Errored { + errored++ + } + } + return errored +} + func SortGroupsByCreated(i, j *Group) int { if i.Created.After(j.Created) { return -1 diff --git a/internal/tui/color.go b/internal/tui/color.go index e4f8f09f..66119b21 100644 --- a/internal/tui/color.go +++ b/internal/tui/color.go @@ -54,7 +54,8 @@ var ( Light: string(Grey), } - RunReportBackgroundColor = EvenLighterGrey + RunReportBackgroundColor = EvenLighterGrey + GroupReportBackgroundColor = EvenLighterGrey ScrollPercentageBackground = lipgloss.AdaptiveColor{ Dark: string(DarkGrey), diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go index 8144443f..34d47c20 100644 --- a/internal/tui/helpers.go +++ b/internal/tui/helpers.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/leg100/pug/internal/logging" @@ -217,29 +218,27 @@ func (h *Helpers) RunReport(report run.Report, table bool) string { return s } -func Breadcrumbs(title string, res resource.Resource, crumbs ...string) string { - // format: title{task command}[workspace name](module path) - switch res := res.(type) { - case *task.Task: - cmd := TitleCommand.Render(res.String()) - return Breadcrumbs(title, res.GetParent(), cmd) - case *state.Resource: - addr := TitleAddress.Render(res.String()) - return Breadcrumbs(title, res.GetParent(), addr) - case *task.Group: - cmd := TitleCommand.Render(res.String()) - id := TitleID.Render(res.GetID().String()) - return Breadcrumbs(title, res.GetParent(), cmd, id) - case *run.Run: - // Skip run info in breadcrumbs - return Breadcrumbs(title, res.GetParent(), crumbs...) - case *workspace.Workspace: - name := TitleWorkspace.Render(res.String()) - return Breadcrumbs(title, res.GetParent(), append(crumbs, name)...) - case *module.Module: - crumbs = append(crumbs, TitlePath.Render(res.String())) +// RunReport renders a colored summary of a run's changes. Set table to true if +// the report is rendered within a table row. +func (h *Helpers) GroupReport(group *task.Group, table bool) string { + var background lipgloss.TerminalColor = lipgloss.NoColor{} + if !table { + background = GroupReportBackgroundColor } - return fmt.Sprintf("%s%s", Title.Render(title), strings.Join(crumbs, "")) + slash := Regular.Copy().Background(background).Foreground(Black).Render("/") + exited := Regular.Copy().Background(background).Foreground(Green).Render(fmt.Sprintf("%d", group.Exited())) + total := Regular.Copy().Background(background).Foreground(Black).Render(fmt.Sprintf("%d", len(group.Tasks))) + + s := fmt.Sprintf("%s%s%s", exited, slash, total) + if errored := group.Errored(); errored > 0 { + erroredString := Regular.Copy().Background(background).Foreground(Red).Render(fmt.Sprintf("%d", errored)) + s = fmt.Sprintf("%s%s%s", erroredString, slash, s) + } + + if !table { + s = Padded.Background(background).Render(s) + } + return s } func (h *Helpers) CreateTasks(cmd string, fn task.Func, ids ...resource.ID) tea.Cmd { @@ -262,3 +261,46 @@ func (h *Helpers) CreateTasks(cmd string, fn task.Func, ids ...resource.ID) tea. } } } + +func (h *Helpers) Move(workspaceID resource.ID, from state.ResourceAddress) tea.Cmd { + return CmdHandler(PromptMsg{ + Prompt: "Enter destination address: ", + InitialValue: string(from), + Action: func(v string) tea.Cmd { + if v == "" { + return nil + } + fn := func(workspaceID resource.ID) (*task.Task, error) { + return h.StateService.Move(workspaceID, from, state.ResourceAddress(v)) + } + return h.CreateTasks("state-mv", fn, workspaceID) + }, + Key: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), + Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), + }) +} + +func Breadcrumbs(title string, res resource.Resource, crumbs ...string) string { + // format: title{task command}[workspace name](module path) + switch res := res.(type) { + case *task.Task: + cmd := TitleCommand.Render(res.String()) + return Breadcrumbs(title, res.GetParent(), cmd) + case *state.Resource: + addr := TitleAddress.Render(res.String()) + return Breadcrumbs(title, res.GetParent().GetParent(), addr) + case *task.Group: + cmd := TitleCommand.Render(res.String()) + id := TitleID.Render(res.GetID().String()) + return Breadcrumbs(title, res.GetParent(), cmd, id) + case *run.Run: + // Skip run info in breadcrumbs + return Breadcrumbs(title, res.GetParent(), crumbs...) + case *workspace.Workspace: + name := TitleWorkspace.Render(res.String()) + return Breadcrumbs(title, res.GetParent(), append(crumbs, name)...) + case *module.Module: + crumbs = append(crumbs, TitlePath.Render(res.String())) + } + return fmt.Sprintf("%s%s", Title.Render(title), strings.Join(crumbs, "")) +} diff --git a/internal/tui/logs/model.go b/internal/tui/logs/model.go index b5aec936..b19c7bb9 100644 --- a/internal/tui/logs/model.go +++ b/internal/tui/logs/model.go @@ -95,10 +95,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m model) Title() string { - title := tui.Title.Render("LogMessage") - id := tui.Regular.Copy().Foreground(tui.Pink).Render(fmt.Sprintf("#%d", m.msg.Serial)) - s := fmt.Sprintf("%s(%s)", title, id) - return tui.Bold.Render(s) + serial := tui.TitleSerial.Render(fmt.Sprintf("#%d", m.msg.Serial)) + return tui.Breadcrumbs("LogMessage", resource.GlobalResource, serial) } func (m model) View() string { diff --git a/internal/tui/style.go b/internal/tui/style.go index 0aa670f3..0151170c 100644 --- a/internal/tui/style.go +++ b/internal/tui/style.go @@ -24,6 +24,7 @@ var ( TitleID = Padded.Copy().Foreground(White).Background(Green) TitleAddress = Padded.Copy().Foreground(White).Background(Blue) TitleSerial = Padded.Copy().Foreground(White).Background(Orange) + TitleTainted = Padded.Copy().Foreground(White).Background(Red) RunReportStyle = Padded.Copy().Background(EvenLighterGrey) ) diff --git a/internal/tui/task/group.go b/internal/tui/task/group.go index 86b77503..36e81b43 100644 --- a/internal/tui/task/group.go +++ b/internal/tui/task/group.go @@ -49,9 +49,10 @@ func (mm *GroupMaker) Make(parent resource.Resource, width, height int) (tea.Mod return nil, errors.New("expected taskgroup resource") } - progress := progress.New(progress.WithDefaultGradient()) + progress := progress.New(progress.WithDefaultGradient(), progress.WithScaledGradient("253", "#606362")) progress.Width = 20 progress.ShowPercentage = false + progress.EmptyColor = "0" list, err := mm.taskListMaker.Make(parent, width, height) if err != nil { @@ -130,8 +131,7 @@ func (m groupModel) Title() string { } func (m groupModel) Status() string { - pbar := m.progress.View() - return fmt.Sprintf("%s %d/%d", pbar, m.group.Finished(), len(m.group.Tasks)) + return m.helpers.GroupReport(m.group, false) } func (m *groupModel) handleTasks(tasks ...*task.Task) (tea.Cmd, bool) { diff --git a/internal/tui/task/group_list.go b/internal/tui/task/group_list.go index 3bf12d55..04510cd6 100644 --- a/internal/tui/task/group_list.go +++ b/internal/tui/task/group_list.go @@ -1,7 +1,6 @@ package task import ( - "fmt" "time" "github.com/charmbracelet/bubbles/key" @@ -17,7 +16,7 @@ var ( taskGroupCount = table.Column{ Key: "tasks", Title: "TASKS", - Width: len("TASKS"), + Width: 10, } taskGroupID = table.Column{ Key: "task_group_id", @@ -43,7 +42,7 @@ func (m *GroupListMaker) Make(_ resource.Resource, width, height int) (tea.Model row := table.RenderedRow{ commandColumn.Key: g.Command, taskGroupID.Key: g.ID.String(), - taskGroupCount.Key: fmt.Sprintf("%d/%d", g.Finished(), len(g.Tasks)), + taskGroupCount.Key: m.Helpers.GroupReport(g, true), ageColumn.Key: tui.Ago(time.Now(), g.Created), } return row diff --git a/internal/tui/workspace/resource.go b/internal/tui/workspace/resource.go index 7da6b920..1ed56b26 100644 --- a/internal/tui/workspace/resource.go +++ b/internal/tui/workspace/resource.go @@ -7,13 +7,17 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/leg100/pug/internal/resource" + "github.com/leg100/pug/internal/run" "github.com/leg100/pug/internal/state" + "github.com/leg100/pug/internal/task" "github.com/leg100/pug/internal/tui" "github.com/leg100/pug/internal/tui/keys" ) type ResourceMaker struct { - Helpers *tui.Helpers + StateService tui.StateService + RunService tui.RunService + Helpers *tui.Helpers disableBorders bool } @@ -25,9 +29,10 @@ func (mm *ResourceMaker) Make(res resource.Resource, width, height int) (tea.Mod } m := resourceModel{ - helpers: mm.Helpers, - resource: stateResource, - border: !mm.disableBorders, + StateService: mm.StateService, + helpers: mm.Helpers, + resource: stateResource, + border: !mm.disableBorders, } marshaled, err := json.MarshalIndent(stateResource.Attributes, "", "\t") @@ -45,6 +50,9 @@ func (mm *ResourceMaker) Make(res resource.Resource, width, height int) (tea.Mod } type resourceModel struct { + StateService tui.StateService + RunService tui.RunService + viewport tui.Viewport resource *state.Resource helpers *tui.Helpers @@ -57,11 +65,46 @@ func (m resourceModel) Init() tea.Cmd { func (m resourceModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( - cmd tea.Cmd - cmds []tea.Cmd + cmd tea.Cmd + cmds []tea.Cmd + createRunOptions run.CreateOptions ) switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, resourcesKeys.Taint): + fn := func(workspaceID resource.ID) (*task.Task, error) { + return m.StateService.Taint(workspaceID, m.resource.Address) + } + return m, m.helpers.CreateTasks("taint", fn, m.resource.Workspace().GetID()) + case key.Matches(msg, resourcesKeys.Untaint): + fn := func(workspaceID resource.ID) (*task.Task, error) { + return m.StateService.Untaint(workspaceID, m.resource.Address) + } + return m, m.helpers.CreateTasks("untaint", fn, m.resource.Workspace().GetID()) + case key.Matches(msg, resourcesKeys.Move): + return m, m.helpers.Move(m.resource.Workspace().GetID(), m.resource.Address) + case key.Matches(msg, keys.Common.Delete): + fn := func(workspaceID resource.ID) (*task.Task, error) { + return m.StateService.Delete(workspaceID, m.resource.Address) + } + return m, tui.YesNoPrompt( + "Delete resource?", + m.helpers.CreateTasks("state-rm", fn, m.resource.Workspace().GetID()), + ) + case key.Matches(msg, keys.Common.Destroy): + // Create a targeted destroy plan. + createRunOptions.Destroy = true + fallthrough + case key.Matches(msg, keys.Common.Plan): + // Create a targeted plan. + createRunOptions.TargetAddrs = []state.ResourceAddress{m.resource.Address} + fn := func(workspaceID resource.ID) (*task.Task, error) { + return m.RunService.Plan(workspaceID, createRunOptions) + } + return m, m.helpers.CreateTasks("plan", fn, m.resource.Workspace().GetID()) + } case tea.WindowSizeMsg: m.viewport.SetDimensions(m.viewportWidth(msg.Width), m.viewportHeight(msg.Height)) return m, nil @@ -96,12 +139,20 @@ func (m resourceModel) viewportHeight(height int) int { } func (m resourceModel) Title() string { - return tui.Breadcrumbs("Resource", m.resource) + var tainted string + if m.resource.Tainted { + tainted = tui.TitleTainted.Render("tainted") + } + return tui.Breadcrumbs("Resource", m.resource) + tainted } func (m resourceModel) HelpBindings() []key.Binding { - bindings := []key.Binding{ - keys.Common.Cancel, + return []key.Binding{ + keys.Common.Plan, + keys.Common.Destroy, + keys.Common.Delete, + resourcesKeys.Move, + resourcesKeys.Taint, + resourcesKeys.Untaint, } - return bindings } diff --git a/internal/tui/workspace/resource_list.go b/internal/tui/workspace/resource_list.go index a8c00a37..fe45ac18 100644 --- a/internal/tui/workspace/resource_list.go +++ b/internal/tui/workspace/resource_list.go @@ -51,6 +51,8 @@ func (m *ResourceListMaker) Make(ws resource.Resource, width, height int) (tea.M Width: width, Height: height, Maker: &ResourceMaker{ + StateService: m.StateService, + RunService: m.RunService, Helpers: m.Helpers, disableBorders: true, }, @@ -156,27 +158,14 @@ func (m resourceList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, resourcesKeys.Move): if row, ok := m.Table.CurrentRow(); ok { from := row.Value.Address - return m, tui.CmdHandler(tui.PromptMsg{ - Prompt: "Enter destination address: ", - InitialValue: string(from), - Action: func(v string) tea.Cmd { - if v == "" { - return nil - } - fn := func(workspaceID resource.ID) (*task.Task, error) { - return m.states.Move(workspaceID, from, state.ResourceAddress(v)) - } - return m.helpers.CreateTasks("state-mv", fn, m.workspace.GetID()) - }, - Key: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), - Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), - }) + return m, m.helpers.Move(m.workspace.GetID(), from) } case key.Matches(msg, keys.Common.Destroy): + // Create a targeted destroy plan. createRunOptions.Destroy = true fallthrough case key.Matches(msg, keys.Common.Plan): - // Create a targeted run. + // Create a targeted plan. createRunOptions.TargetAddrs = m.selectedOrCurrentAddresses() // NOTE: even if the user hasn't selected any rows, we still proceed // to create a run without targeted resources. @@ -246,7 +235,7 @@ func (m resourceList) View() string { func (m resourceList) Title() string { var serial string if m.state != nil { - serial = tui.TitleSerial.Render(fmt.Sprintf("%d", m.state.Serial)) + serial = serialBreadcrumb(m.state.Serial) } return tui.Breadcrumbs("State", m.workspace, serial) } @@ -308,3 +297,7 @@ var resourcesKeys = resourcesKeyMap{ key.WithHelp("ctrl+r", "reload"), ), } + +func serialBreadcrumb(serial int64) string { + return tui.TitleSerial.Render(fmt.Sprintf("%d", serial)) +}