| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215 |
- // Package render draws ZenMux subscription usage to the terminal.
- package render
- import (
- "fmt"
- "io"
- "strconv"
- "strings"
- "time"
- "github.com/fatih/color"
- "github.com/kotoyuuko/zenmux-usage-cli/internal/api"
- )
- const (
- barWidth = 30
- barFilled = "█"
- barEmpty = "░"
- )
- // AccountResult bundles a single account's fetch outcome for RenderAll.
- type AccountResult struct {
- Name string
- Response *api.Response
- Err error
- }
- // ProgressBar returns a width-char string of filled and empty glyphs
- // representing percent in [0, 1]. Out-of-range inputs are clamped.
- func ProgressBar(percent float64, width int) string {
- if percent < 0 {
- percent = 0
- }
- if percent > 1 {
- percent = 1
- }
- filled := int(percent*float64(width) + 0.5)
- if filled > width {
- filled = width
- }
- return strings.Repeat(barFilled, filled) + strings.Repeat(barEmpty, width-filled)
- }
- // BandColor returns a fatih/color attribute based on usage thresholds:
- // green < 60%, yellow 60–85%, red > 85%.
- func BandColor(percent float64) color.Attribute {
- switch {
- case percent > 0.85:
- return color.FgRed
- case percent >= 0.60:
- return color.FgYellow
- default:
- return color.FgGreen
- }
- }
- // RenderAll writes one block per account, separated by a blank line.
- // useColor controls ANSI emission; callers handle TTY/NO_COLOR detection.
- func RenderAll(w io.Writer, results []AccountResult, useColor bool) {
- setGlobalColor(useColor)
- for i, r := range results {
- if i > 0 {
- fmt.Fprintln(w)
- }
- if r.Err != nil {
- RenderAccountError(w, r.Name, r.Err, useColor)
- continue
- }
- RenderAccount(w, r.Name, r.Response, useColor)
- }
- }
- // setGlobalColor flips fatih/color's global NoColor flag.
- // The package is designed around this global; localizing it would require
- // wrapping every Sprint call, which the codebase is too small to justify.
- func setGlobalColor(useColor bool) {
- color.NoColor = !useColor
- }
- // RenderAccount writes the header + two quota rows + token summary + monthly
- // cap line for a single account. Caller must have already called
- // setGlobalColor via RenderAll, or set color.NoColor directly.
- func RenderAccount(w io.Writer, name string, resp *api.Response, useColor bool) {
- writeHeader(w, name)
- writePlanLine(w, resp)
- fmt.Fprintln(w)
- d := resp.Data
- rows := []struct {
- label string
- window api.QuotaWindow
- }{
- {"5 hour", d.Quota5Hour},
- {"7 day", d.Quota7Day},
- }
- // Pre-format each row's segments so we can align by max width.
- type formatted struct {
- label, bar, pct, flows, dollars string
- pctAttr color.Attribute
- }
- fmts := make([]formatted, len(rows))
- for i, r := range rows {
- f := formatted{label: r.label}
- f.bar = ProgressBar(r.window.UsagePercentage, barWidth)
- f.pct = fmt.Sprintf("%6.2f%%", r.window.UsagePercentage*100)
- f.pctAttr = BandColor(r.window.UsagePercentage)
- f.flows = fmt.Sprintf("%s / %s flows", fmtFlow(r.window.UsedFlows), fmtFlow(r.window.MaxFlows))
- f.dollars = fmt.Sprintf("%s / %s", fmtUSD(r.window.UsedValueUSD), fmtUSD(r.window.MaxValueUSD))
- fmts[i] = f
- }
- flowsW, dollarsW := 0, 0
- for _, f := range fmts {
- if len(f.flows) > flowsW {
- flowsW = len(f.flows)
- }
- if len(f.dollars) > dollarsW {
- dollarsW = len(f.dollars)
- }
- }
- for _, f := range fmts {
- bar := color.New(f.pctAttr).Sprint(f.bar)
- pct := color.New(f.pctAttr).Sprint(f.pct)
- fmt.Fprintf(w, " %-7s [%s] %s %-*s %-*s\n",
- f.label, bar, pct, flowsW, f.flows, dollarsW, f.dollars)
- }
- fmt.Fprintln(w)
- fmt.Fprintf(w, " Tokens consumed (estimated USD value): %s\n", fmtUSD(d.Quota7Day.UsedValueUSD))
- fmt.Fprintf(w, " Monthly cap: %s flows · %s\n",
- fmtFlow(d.QuotaMonthly.MaxFlows), fmtUSD(d.QuotaMonthly.MaxValueUSD))
- resets := resetsLine(d.Quota5Hour.ResetsAt, d.Quota7Day.ResetsAt)
- if resets != "" {
- fmt.Fprintf(w, " Next reset: %s\n", resets)
- }
- }
- // RenderAccountError writes the header block and a single red error line,
- // then returns. Used for per-account failures in multi-account runs.
- func RenderAccountError(w io.Writer, name string, err error, useColor bool) {
- writeHeader(w, name)
- msg := color.New(color.FgRed).Sprintf(" error: %v", err)
- fmt.Fprintln(w, msg)
- }
- func writeHeader(w io.Writer, name string) {
- label := fmt.Sprintf("━━━ %s ━━━", name)
- fmt.Fprintln(w, color.New(color.Bold).Sprint(label))
- }
- func writePlanLine(w io.Writer, resp *api.Response) {
- d := resp.Data
- tier := titleCase(d.Plan.Tier)
- status := colorizeStatus(d.AccountStatus)
- fmt.Fprintf(w, "ZenMux Subscription — %s plan ($%.0f/mo) · %s · $%.5f/flow\n",
- tier, d.Plan.AmountUSD, status, d.EffectiveUSDPerFlow)
- }
- // resetsLine composes "5h → 2026-04-22 14:05 · 7d → 2026-04-28 00:00".
- // Missing or unparseable timestamps are skipped silently.
- func resetsLine(fiveHour, sevenDay *string) string {
- parts := make([]string, 0, 2)
- if s := formatReset(fiveHour); s != "" {
- parts = append(parts, "5h → "+s)
- }
- if s := formatReset(sevenDay); s != "" {
- parts = append(parts, "7d → "+s)
- }
- return strings.Join(parts, " · ")
- }
- func formatReset(s *string) string {
- if s == nil || *s == "" {
- return ""
- }
- t, err := time.Parse(time.RFC3339, *s)
- if err != nil {
- return ""
- }
- return t.Local().Format("2006-01-02 15:04")
- }
- func colorizeStatus(status string) string {
- switch status {
- case "healthy":
- return color.New(color.FgGreen).Sprint(status)
- case "monitored":
- return color.New(color.FgYellow).Sprint(status)
- case "abusive", "suspended", "banned":
- return color.New(color.FgRed).Sprint(status)
- default:
- return status
- }
- }
- func titleCase(s string) string {
- if s == "" {
- return s
- }
- return strings.ToUpper(s[:1]) + s[1:]
- }
- // fmtFlow prints a flow count trimming trailing zeros but keeping
- // at most 2 decimal places so 57.2 stays "57.2", 800 stays "800",
- // and 416.11 stays "416.11".
- func fmtFlow(v float64) string {
- return strconv.FormatFloat(v, 'f', -1, 64)
- }
- func fmtUSD(v float64) string {
- return fmt.Sprintf("$%.2f", v)
- }
|