Skip to content

Commit 79cf7b7

Browse files
hi-leiMilosz Szewczak
andauthored
Feat/verda status dashboard (#30)
* feat(status): add status dashboard command with aggregation logic Co-Authored-By: Milosz Szewczak <milosz@datacrunch.io>
1 parent d6fe448 commit 79cf7b7

12 files changed

Lines changed: 773 additions & 17 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ go mod tidy # Sync dependencies
4040

4141
## Pricing Model
4242

43-
- Instance `price_per_hour` from API is the TOTAL price (not per-GPU). Derive per-unit by dividing.
44-
- Volume pricing is `price_per_month_per_gb`. Hourly = `ceil(monthly_per_gb * size / 30 / 24 * 10000) / 10000`
43+
- Instance `price_per_hour` from API is **per-unit** (per-GPU or per-vCPU). Total = `price_per_hour * units`.
44+
- Use `cmdutil.InstanceTotalHourlyCost(inst)` or `cmdutil.InstanceBillableUnits(inst)` from `cmd/util/pricing.go`.
45+
- Volume pricing is `price_per_month_per_gb`. Hourly = `ceil(monthly_per_gb * size / 730 * 10000) / 10000`
4546

4647
## Per-Command Knowledge
4748

internal/verda-cli/cmd/cmd.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github/verda-cloud/verda-cli/internal/verda-cli/cmd/ssh"
2424
"github/verda-cloud/verda-cli/internal/verda-cli/cmd/sshkey"
2525
"github/verda-cloud/verda-cli/internal/verda-cli/cmd/startupscript"
26+
"github/verda-cloud/verda-cli/internal/verda-cli/cmd/status"
2627
"github/verda-cloud/verda-cli/internal/verda-cli/cmd/update"
2728
cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util"
2829
"github/verda-cloud/verda-cli/internal/verda-cli/cmd/vm"
@@ -119,6 +120,7 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op
119120
{
120121
Message: "Info Commands:",
121122
Commands: []*cobra.Command{
123+
status.NewCmdStatus(f, ioStreams),
122124
cost.NewCmdCost(f, ioStreams),
123125
},
124126
},
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
package status
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"math"
7+
"sort"
8+
"strings"
9+
10+
"charm.land/lipgloss/v2"
11+
"github.com/spf13/cobra"
12+
"github.com/verda-cloud/verdacloud-sdk-go/pkg/verda"
13+
14+
cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util"
15+
)
16+
17+
// Dashboard is the structured output for the status command.
18+
type Dashboard struct {
19+
Instances InstanceSummary `json:"instances" yaml:"instances"`
20+
Volumes VolumeSummary `json:"volumes" yaml:"volumes"`
21+
Financials FinancialSummary `json:"financials" yaml:"financials"`
22+
Locations []LocationInfo `json:"locations" yaml:"locations"`
23+
}
24+
25+
// InstanceSummary holds instance counts grouped by status.
26+
type InstanceSummary struct {
27+
Total int `json:"total" yaml:"total"`
28+
Running int `json:"running" yaml:"running"`
29+
Offline int `json:"offline" yaml:"offline"`
30+
Provisioning int `json:"provisioning,omitempty" yaml:"provisioning,omitempty"`
31+
Error int `json:"error,omitempty" yaml:"error,omitempty"`
32+
Other int `json:"other,omitempty" yaml:"other,omitempty"`
33+
SpotRunning int `json:"spot_running" yaml:"spot_running"`
34+
}
35+
36+
// VolumeSummary holds volume counts and total size.
37+
type VolumeSummary struct {
38+
Total int `json:"total" yaml:"total"`
39+
Attached int `json:"attached" yaml:"attached"`
40+
Detached int `json:"detached" yaml:"detached"`
41+
TotalSizeGB int `json:"total_size_gb" yaml:"total_size_gb"`
42+
}
43+
44+
// FinancialSummary holds burn rate, balance, and runway.
45+
type FinancialSummary struct {
46+
BurnRateHourly float64 `json:"burn_rate_hourly" yaml:"burn_rate_hourly"`
47+
BurnRateDaily float64 `json:"burn_rate_daily" yaml:"burn_rate_daily"`
48+
Balance float64 `json:"balance" yaml:"balance"`
49+
RunwayDays int `json:"runway_days" yaml:"runway_days"`
50+
Currency string `json:"currency" yaml:"currency"`
51+
}
52+
53+
// LocationInfo holds per-location instance counts.
54+
type LocationInfo struct {
55+
Code string `json:"code" yaml:"code"`
56+
Instances int `json:"instances" yaml:"instances"`
57+
Running int `json:"running" yaml:"running"`
58+
Offline int `json:"offline" yaml:"offline"`
59+
}
60+
61+
// NewCmdStatus creates the status command.
62+
func NewCmdStatus(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command {
63+
return &cobra.Command{
64+
Use: "status",
65+
Short: "Show dashboard overview of your Verda Cloud resources",
66+
Long: cmdutil.LongDesc(`
67+
Display a dashboard overview including instance and volume
68+
counts, burn rate, account balance, runway estimate, and
69+
resource distribution across locations.
70+
`),
71+
Example: cmdutil.Examples(`
72+
verda status
73+
verda status -o json
74+
`),
75+
Aliases: []string{"dash"},
76+
Args: cobra.NoArgs,
77+
RunE: func(cmd *cobra.Command, args []string) error {
78+
return runStatus(cmd, f, ioStreams)
79+
},
80+
}
81+
}
82+
83+
func runStatus(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams) error {
84+
client, err := f.VerdaClient()
85+
if err != nil {
86+
return err
87+
}
88+
89+
ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout)
90+
defer cancel()
91+
92+
var sp interface{ Stop(string) }
93+
if status := f.Status(); status != nil {
94+
sp, _ = status.Spinner(ctx, "Loading dashboard...")
95+
}
96+
97+
// Fetch all data off the main goroutine (sequential to keep error handling simple).
98+
type result struct {
99+
instances []verda.Instance
100+
volumes []verda.Volume
101+
balance *verda.Balance
102+
err error
103+
}
104+
105+
ch := make(chan result, 1)
106+
go func() {
107+
var r result
108+
// Instances
109+
r.instances, r.err = client.Instances.Get(ctx, "")
110+
if r.err != nil {
111+
ch <- r
112+
return
113+
}
114+
// Volumes
115+
r.volumes, r.err = client.Volumes.ListVolumes(ctx)
116+
if r.err != nil {
117+
ch <- r
118+
return
119+
}
120+
// Balance
121+
r.balance, r.err = client.Balance.Get(ctx)
122+
ch <- r
123+
}()
124+
125+
res := <-ch
126+
if sp != nil {
127+
sp.Stop("")
128+
}
129+
if res.err != nil {
130+
return res.err
131+
}
132+
133+
dashboard := buildDashboard(res.instances, res.volumes, res.balance)
134+
135+
cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Status dashboard:", dashboard)
136+
137+
if wrote, err := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), dashboard); wrote {
138+
return err
139+
}
140+
141+
renderDashboard(ioStreams.Out, &dashboard)
142+
return nil
143+
}
144+
145+
func buildDashboard(instances []verda.Instance, volumes []verda.Volume, balance *verda.Balance) Dashboard {
146+
d := Dashboard{}
147+
148+
// Instance aggregation.
149+
locMap := make(map[string]*LocationInfo)
150+
for i := range instances {
151+
inst := &instances[i]
152+
d.Instances.Total++
153+
154+
switch inst.Status {
155+
case verda.StatusRunning:
156+
d.Instances.Running++
157+
if inst.IsSpot {
158+
d.Instances.SpotRunning++
159+
}
160+
case verda.StatusOffline:
161+
d.Instances.Offline++
162+
case verda.StatusProvisioning, verda.StatusValidating, verda.StatusOrdered, verda.StatusNew:
163+
d.Instances.Provisioning++
164+
case verda.StatusError:
165+
d.Instances.Error++
166+
default:
167+
d.Instances.Other++
168+
}
169+
170+
// Offline instances still charge — include all non-terminated instances in burn rate.
171+
if inst.Status == verda.StatusRunning || inst.Status == verda.StatusOffline {
172+
d.Financials.BurnRateHourly += cmdutil.InstanceTotalHourlyCost(inst)
173+
}
174+
175+
// Location tracking.
176+
loc, ok := locMap[inst.Location]
177+
if !ok {
178+
loc = &LocationInfo{Code: inst.Location}
179+
locMap[inst.Location] = loc
180+
}
181+
loc.Instances++
182+
switch inst.Status {
183+
case verda.StatusRunning:
184+
loc.Running++
185+
case verda.StatusOffline:
186+
loc.Offline++
187+
}
188+
}
189+
190+
// Volume aggregation — both attached and detached volumes incur charges.
191+
for i := range volumes {
192+
vol := &volumes[i]
193+
d.Volumes.Total++
194+
d.Volumes.TotalSizeGB += vol.Size
195+
196+
switch vol.Status {
197+
case verda.VolumeStatusAttached:
198+
d.Volumes.Attached++
199+
case verda.VolumeStatusDetached:
200+
d.Volumes.Detached++
201+
}
202+
203+
d.Financials.BurnRateHourly += vol.BaseHourlyCost
204+
}
205+
206+
// Financials.
207+
d.Financials.BurnRateDaily = d.Financials.BurnRateHourly * 24
208+
if balance != nil {
209+
d.Financials.Balance = balance.Amount
210+
d.Financials.Currency = balance.Currency
211+
}
212+
if d.Financials.BurnRateDaily > 0 {
213+
d.Financials.RunwayDays = int(math.Floor(d.Financials.Balance / d.Financials.BurnRateDaily))
214+
}
215+
216+
// Locations sorted by instance count descending, then code ascending for stability.
217+
for _, loc := range locMap {
218+
d.Locations = append(d.Locations, *loc)
219+
}
220+
sort.Slice(d.Locations, func(i, j int) bool {
221+
if d.Locations[i].Instances != d.Locations[j].Instances {
222+
return d.Locations[i].Instances > d.Locations[j].Instances
223+
}
224+
return d.Locations[i].Code < d.Locations[j].Code
225+
})
226+
227+
return d
228+
}
229+
230+
func renderDashboard(w interface{ Write([]byte) (int, error) }, d *Dashboard) {
231+
bold := lipgloss.NewStyle().Bold(true)
232+
dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
233+
green := lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
234+
yellow := lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
235+
red := lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
236+
price := lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
237+
238+
// Build content lines.
239+
var lines []string
240+
241+
// Header.
242+
lines = append(lines,
243+
bold.Render("Verda Cloud Status"),
244+
dim.Render(strings.Repeat("─", 45)))
245+
246+
// Instances.
247+
instParts := []string{green.Render(fmt.Sprintf("%d running", d.Instances.Running))}
248+
if d.Instances.Offline > 0 {
249+
instParts = append(instParts, yellow.Render(fmt.Sprintf("%d offline", d.Instances.Offline)))
250+
}
251+
if d.Instances.Provisioning > 0 {
252+
instParts = append(instParts, yellow.Render(fmt.Sprintf("%d provisioning", d.Instances.Provisioning)))
253+
}
254+
if d.Instances.Error > 0 {
255+
instParts = append(instParts, red.Render(fmt.Sprintf("%d error", d.Instances.Error)))
256+
}
257+
lines = append(lines, fmt.Sprintf("%-13s%s", bold.Render("Instances:"), strings.Join(instParts, " ")))
258+
259+
// Warning: offline instances still charge.
260+
if d.Instances.Offline > 0 {
261+
lines = append(lines, fmt.Sprintf("%-13s%s",
262+
"",
263+
yellow.Render(fmt.Sprintf("! %d offline instance(s) still charging", d.Instances.Offline))))
264+
}
265+
266+
// Spot (only if any running spot instances).
267+
if d.Instances.SpotRunning > 0 {
268+
lines = append(lines, fmt.Sprintf("%-13s%s",
269+
bold.Render("Spot:"),
270+
yellow.Render(fmt.Sprintf("%d of %d running", d.Instances.SpotRunning, d.Instances.Running))))
271+
}
272+
273+
// Volumes.
274+
if d.Volumes.Total > 0 {
275+
volParts := []string{green.Render(fmt.Sprintf("%d attached", d.Volumes.Attached))}
276+
if d.Volumes.Detached > 0 {
277+
volParts = append(volParts, dim.Render(fmt.Sprintf("%d detached", d.Volumes.Detached)))
278+
}
279+
volParts = append(volParts, dim.Render(fmt.Sprintf("%d GB", d.Volumes.TotalSizeGB)))
280+
lines = append(lines, fmt.Sprintf("%-13s%s", bold.Render("Volumes:"), strings.Join(volParts, " ")))
281+
}
282+
283+
// Burn rate and balance.
284+
lines = append(lines,
285+
"",
286+
fmt.Sprintf("%-13s%s %s",
287+
bold.Render("Burn rate:"),
288+
price.Render(cmdutil.FormatPrice(d.Financials.BurnRateHourly)+"/hr"),
289+
dim.Render(fmt.Sprintf("(%s/day)", cmdutil.FormatPrice(d.Financials.BurnRateDaily)))),
290+
fmt.Sprintf("%-13s%s",
291+
bold.Render("Balance:"),
292+
price.Render(fmt.Sprintf("$%.2f", d.Financials.Balance))))
293+
294+
// Runway.
295+
if d.Financials.RunwayDays > 0 {
296+
lines = append(lines, fmt.Sprintf("%-13s%s",
297+
bold.Render("Runway:"),
298+
dim.Render(fmt.Sprintf("~%d days at current rate", d.Financials.RunwayDays))))
299+
}
300+
301+
// Locations.
302+
if len(d.Locations) > 0 {
303+
lines = append(lines, "")
304+
maxLocs := 5
305+
for i, loc := range d.Locations {
306+
if i >= maxLocs {
307+
lines = append(lines, fmt.Sprintf("%-13s%s",
308+
"",
309+
dim.Render(fmt.Sprintf("+%d more", len(d.Locations)-maxLocs))))
310+
break
311+
}
312+
label := ""
313+
if i == 0 {
314+
label = bold.Render("Locations:")
315+
}
316+
317+
locDesc := fmt.Sprintf("%s (%d instance", loc.Code, loc.Instances)
318+
if loc.Instances != 1 {
319+
locDesc += "s"
320+
}
321+
if loc.Offline > 0 {
322+
locDesc += fmt.Sprintf(", %d offline", loc.Offline)
323+
}
324+
locDesc += ")"
325+
lines = append(lines, fmt.Sprintf("%-13s%s", label, locDesc))
326+
}
327+
}
328+
329+
// Disclaimer.
330+
lines = append(lines,
331+
"",
332+
dim.Render("* Estimated. For details, see Billing & Settings at console.verda.com"))
333+
334+
// Render box.
335+
content := strings.Join(lines, "\n")
336+
337+
box := lipgloss.NewStyle().
338+
Border(lipgloss.RoundedBorder()).
339+
BorderForeground(lipgloss.Color("8")).
340+
Padding(1, 2)
341+
342+
_, _ = fmt.Fprintf(w, "\n%s\n\n", box.Render(content))
343+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package status
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
7+
cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util"
8+
)
9+
10+
func TestNewCmdStatusHasCorrectUse(t *testing.T) {
11+
t.Parallel()
12+
13+
f := cmdutil.NewTestFactory(nil)
14+
ioStreams := cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}
15+
16+
cmd := NewCmdStatus(f, ioStreams)
17+
18+
if cmd.Use != "status" {
19+
t.Fatalf("expected Use 'status', got %q", cmd.Use)
20+
}
21+
if cmd.Short == "" {
22+
t.Fatal("expected Short description to be non-empty")
23+
}
24+
}

0 commit comments

Comments
 (0)