Project>project overview from the dashboard endpoint; tools chooser drawn in Board>board; docs for Shell and t
This commit is contained in:
16
tui/board.go
16
tui/board.go
@@ -355,6 +355,17 @@ func (m boardModel) View() string {
|
||||
m.width, m.height))
|
||||
}
|
||||
|
||||
if m.tools {
|
||||
entries := make([][2]string, 0, len(tools))
|
||||
for i, t := range tools {
|
||||
mark := " "
|
||||
if i == m.toolAt {
|
||||
mark = "▸ "
|
||||
}
|
||||
entries = append(entries, [2]string{mark + t.name, t.about})
|
||||
}
|
||||
return helpOverlay("open a tool pane here", entries, m.width, m.height)
|
||||
}
|
||||
if m.showHelp {
|
||||
return helpOverlay("keys on this panel", [][2]string{
|
||||
{"↑ ↓ j k", "choose a task"},
|
||||
@@ -517,7 +528,10 @@ func (m boardModel) treeLines(width int) []string {
|
||||
// makes the groups visible at a glance without a box around every one.
|
||||
func groupHeading(name string, count, width int) string {
|
||||
label := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Mauve)).Render(strings.ToUpper(name))
|
||||
n := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)).Render(fmt.Sprintf(" %d ", count))
|
||||
n := ""
|
||||
if count >= 0 {
|
||||
n = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)).Render(fmt.Sprintf(" %d ", count))
|
||||
}
|
||||
rule := strings.Repeat("─", max(0, width-lipgloss.Width(label)-lipgloss.Width(n)))
|
||||
return label + dimStyle().Render(rule) + n
|
||||
}
|
||||
|
||||
@@ -458,3 +458,44 @@ func (c *Client) AgentRaw(agentID string) (map[string]any, error) {
|
||||
err := c.do("GET", "/api/agents/"+agentID, nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Dashboard is the company's own summary: counts, spend and recent run activity.
|
||||
// One call, 1.8 KB, cheap enough to poll.
|
||||
type Dashboard struct {
|
||||
Agents struct {
|
||||
Active int `json:"active"`
|
||||
Running int `json:"running"`
|
||||
Paused int `json:"paused"`
|
||||
Error int `json:"error"`
|
||||
} `json:"agents"`
|
||||
Tasks struct {
|
||||
Open int `json:"open"`
|
||||
InProgress int `json:"inProgress"`
|
||||
Blocked int `json:"blocked"`
|
||||
Done int `json:"done"`
|
||||
} `json:"tasks"`
|
||||
Costs struct {
|
||||
MonthSpendCents int `json:"monthSpendCents"`
|
||||
MonthBudgetCents int `json:"monthBudgetCents"`
|
||||
MonthUtilizationPercent float64 `json:"monthUtilizationPercent"`
|
||||
} `json:"costs"`
|
||||
PendingApprovals int `json:"pendingApprovals"`
|
||||
Budgets struct {
|
||||
ActiveIncidents int `json:"activeIncidents"`
|
||||
PausedAgents int `json:"pausedAgents"`
|
||||
PausedProjects int `json:"pausedProjects"`
|
||||
} `json:"budgets"`
|
||||
RunActivity []struct {
|
||||
Date string `json:"date"`
|
||||
Total int `json:"total"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
} `json:"runActivity"`
|
||||
}
|
||||
|
||||
// DashboardSummary reads the company's dashboard.
|
||||
func (c *Client) DashboardSummary() (Dashboard, error) {
|
||||
var out Dashboard
|
||||
err := c.do("GET", "/api/companies/"+c.Company+"/dashboard", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
)
|
||||
|
||||
type projectMsg struct {
|
||||
dash paperclip.Dashboard
|
||||
project paperclip.Project
|
||||
cfg map[string]string
|
||||
agents []paperclip.Agent
|
||||
@@ -38,6 +39,7 @@ type projectModel struct {
|
||||
client *paperclip.Client
|
||||
folder string
|
||||
|
||||
dash paperclip.Dashboard
|
||||
project paperclip.Project
|
||||
cfg map[string]string
|
||||
agents []paperclip.Agent
|
||||
@@ -53,7 +55,7 @@ type projectModel struct {
|
||||
width, height int
|
||||
}
|
||||
|
||||
var projectTabs = []string{"facts", "costs", "settings"}
|
||||
var projectTabs = []string{"overview", "facts", "costs", "settings"}
|
||||
|
||||
func newProjectModel(c *paperclip.Client, folder string) projectModel {
|
||||
return projectModel{client: c, folder: folder}
|
||||
@@ -96,7 +98,8 @@ func (m projectModel) load() tea.Cmd {
|
||||
}
|
||||
costs = append(costs, taskCost{t.Identifier, t.Status, cost.CostCents, cost.RunCount})
|
||||
}
|
||||
return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs}
|
||||
dash, _ := c.DashboardSummary()
|
||||
return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs, dash: dash}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +185,7 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
case projectMsg:
|
||||
m.dash = msg.dash
|
||||
m.project = msg.project
|
||||
m.cfg = msg.cfg
|
||||
m.agents = msg.agents
|
||||
@@ -225,8 +229,10 @@ func (m projectModel) View() string {
|
||||
var body []string
|
||||
switch m.tab {
|
||||
case 0:
|
||||
body = m.factLines()
|
||||
body = m.overviewLines(m.width - 4)
|
||||
case 1:
|
||||
body = m.factLines()
|
||||
case 2:
|
||||
body = m.costLines()
|
||||
default:
|
||||
body = m.settingLines()
|
||||
@@ -268,6 +274,82 @@ func (m projectModel) row(label, value string) string {
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(value)
|
||||
}
|
||||
|
||||
// overviewLines is the company dashboard, drawn as numbers a person can act on
|
||||
// and a sparkline of the last two weeks of runs. Everything here comes from
|
||||
// GET /api/companies/{id}/dashboard in one call.
|
||||
func (m projectModel) overviewLines(width int) []string {
|
||||
d := m.dash
|
||||
out := []string{"", groupHeading("agents", -1, width), ""}
|
||||
out = append(out, " "+stat("active", d.Agents.Active, active.Text)+
|
||||
" "+stat("running", d.Agents.Running, active.Info)+
|
||||
" "+stat("paused", d.Agents.Paused, active.Warning)+
|
||||
" "+stat("error", d.Agents.Error, active.Error))
|
||||
out = append(out, "", groupHeading("tasks", -1, width), "")
|
||||
out = append(out, " "+stat("open", d.Tasks.Open, active.Text)+
|
||||
" "+stat("in progress", d.Tasks.InProgress, active.Info)+
|
||||
" "+stat("blocked", d.Tasks.Blocked, active.Error)+
|
||||
" "+stat("done", d.Tasks.Done, active.Success))
|
||||
out = append(out, "", groupHeading("money, this month", -1, width), "")
|
||||
budget := "no company budget set"
|
||||
if d.Costs.MonthBudgetCents > 0 {
|
||||
budget = fmt.Sprintf("%.0f%% of %d cents", d.Costs.MonthUtilizationPercent, d.Costs.MonthBudgetCents)
|
||||
}
|
||||
out = append(out, " "+stat("cents spent", d.Costs.MonthSpendCents, active.Text)+" "+
|
||||
mutedStyle2().Render(budget))
|
||||
out = append(out, " "+stat("pending approvals", d.PendingApprovals, active.Warning))
|
||||
if d.Budgets.ActiveIncidents > 0 || d.Budgets.PausedAgents > 0 || d.Budgets.PausedProjects > 0 {
|
||||
out = append(out, " "+stat("budget incidents", d.Budgets.ActiveIncidents, active.Error)+
|
||||
mutedStyle2().Render(fmt.Sprintf(" paused: %d agent(s), %d project(s)",
|
||||
d.Budgets.PausedAgents, d.Budgets.PausedProjects)))
|
||||
}
|
||||
out = append(out, "", groupHeading("runs, last two weeks", -1, width), "")
|
||||
out = append(out, " "+sparkline(d.RunActivity))
|
||||
if n := len(d.RunActivity); n > 0 {
|
||||
first, last := d.RunActivity[0].Date, d.RunActivity[n-1].Date
|
||||
out = append(out, dimStyle().Render(" "+first+" → "+last+" one bar per day, taller is more runs"))
|
||||
}
|
||||
out = append(out, "")
|
||||
out = append(out, dimStyle().Render(" read from Paperclip's own dashboard, not counted here."))
|
||||
return out
|
||||
}
|
||||
|
||||
// stat is a label and a number, the number first because it is what you look at.
|
||||
func stat(label string, value int, colour string) string {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Bold(true).Render(fmt.Sprintf("%d", value)) +
|
||||
" " + mutedStyle2().Render(label)
|
||||
}
|
||||
|
||||
// sparkline draws one bar per day using block characters, scaled so the busiest
|
||||
// day fills the height. No chart library, no second language: the data is in
|
||||
// memory and the terminal already knows how to draw blocks.
|
||||
func sparkline(points []struct {
|
||||
Date string `json:"date"`
|
||||
Total int `json:"total"`
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
}) string {
|
||||
bars := []rune("▁▂▃▄▅▆▇█")
|
||||
peak := 0
|
||||
for _, p := range points {
|
||||
if p.Total > peak {
|
||||
peak = p.Total
|
||||
}
|
||||
}
|
||||
if peak == 0 {
|
||||
return mutedStyle2().Render("no runs in the last two weeks")
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, p := range points {
|
||||
idx := int(float64(p.Total) / float64(peak) * float64(len(bars)-1))
|
||||
colour := active.Success
|
||||
if p.Failed > 0 {
|
||||
colour = active.Warning
|
||||
}
|
||||
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(string(bars[idx])))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m projectModel) factLines() []string {
|
||||
out := []string{""}
|
||||
if m.project.Name != "" {
|
||||
|
||||
Reference in New Issue
Block a user