render.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. // Package render draws ZenMux subscription usage to the terminal.
  2. package render
  3. import (
  4. "fmt"
  5. "io"
  6. "strconv"
  7. "strings"
  8. "time"
  9. "github.com/fatih/color"
  10. "github.com/kotoyuuko/zenmux-usage-cli/internal/api"
  11. )
  12. const (
  13. barWidth = 30
  14. barFilled = "█"
  15. barEmpty = "░"
  16. )
  17. // AccountResult bundles a single account's fetch outcome for RenderAll.
  18. type AccountResult struct {
  19. Name string
  20. Response *api.Response
  21. Err error
  22. }
  23. // ProgressBar returns a width-char string of filled and empty glyphs
  24. // representing percent in [0, 1]. Out-of-range inputs are clamped.
  25. func ProgressBar(percent float64, width int) string {
  26. if percent < 0 {
  27. percent = 0
  28. }
  29. if percent > 1 {
  30. percent = 1
  31. }
  32. filled := int(percent*float64(width) + 0.5)
  33. if filled > width {
  34. filled = width
  35. }
  36. return strings.Repeat(barFilled, filled) + strings.Repeat(barEmpty, width-filled)
  37. }
  38. // BandColor returns a fatih/color attribute based on usage thresholds:
  39. // green < 60%, yellow 60–85%, red > 85%.
  40. func BandColor(percent float64) color.Attribute {
  41. switch {
  42. case percent > 0.85:
  43. return color.FgRed
  44. case percent >= 0.60:
  45. return color.FgYellow
  46. default:
  47. return color.FgGreen
  48. }
  49. }
  50. // RenderAll writes one block per account, separated by a blank line.
  51. // useColor controls ANSI emission; callers handle TTY/NO_COLOR detection.
  52. func RenderAll(w io.Writer, results []AccountResult, useColor bool) {
  53. setGlobalColor(useColor)
  54. for i, r := range results {
  55. if i > 0 {
  56. fmt.Fprintln(w)
  57. }
  58. if r.Err != nil {
  59. RenderAccountError(w, r.Name, r.Err, useColor)
  60. continue
  61. }
  62. RenderAccount(w, r.Name, r.Response, useColor)
  63. }
  64. }
  65. // setGlobalColor flips fatih/color's global NoColor flag.
  66. // The package is designed around this global; localizing it would require
  67. // wrapping every Sprint call, which the codebase is too small to justify.
  68. func setGlobalColor(useColor bool) {
  69. color.NoColor = !useColor
  70. }
  71. // RenderAccount writes the header + two quota rows + token summary + monthly
  72. // cap line for a single account. Caller must have already called
  73. // setGlobalColor via RenderAll, or set color.NoColor directly.
  74. func RenderAccount(w io.Writer, name string, resp *api.Response, useColor bool) {
  75. writeHeader(w, name)
  76. writePlanLine(w, resp)
  77. fmt.Fprintln(w)
  78. d := resp.Data
  79. rows := []struct {
  80. label string
  81. window api.QuotaWindow
  82. }{
  83. {"5 hour", d.Quota5Hour},
  84. {"7 day", d.Quota7Day},
  85. }
  86. // Pre-format each row's segments so we can align by max width.
  87. type formatted struct {
  88. label, bar, pct, flows, dollars string
  89. pctAttr color.Attribute
  90. }
  91. fmts := make([]formatted, len(rows))
  92. for i, r := range rows {
  93. f := formatted{label: r.label}
  94. f.bar = ProgressBar(r.window.UsagePercentage, barWidth)
  95. f.pct = fmt.Sprintf("%6.2f%%", r.window.UsagePercentage*100)
  96. f.pctAttr = BandColor(r.window.UsagePercentage)
  97. f.flows = fmt.Sprintf("%s / %s flows", fmtFlow(r.window.UsedFlows), fmtFlow(r.window.MaxFlows))
  98. f.dollars = fmt.Sprintf("%s / %s", fmtUSD(r.window.UsedValueUSD), fmtUSD(r.window.MaxValueUSD))
  99. fmts[i] = f
  100. }
  101. flowsW, dollarsW := 0, 0
  102. for _, f := range fmts {
  103. if len(f.flows) > flowsW {
  104. flowsW = len(f.flows)
  105. }
  106. if len(f.dollars) > dollarsW {
  107. dollarsW = len(f.dollars)
  108. }
  109. }
  110. for _, f := range fmts {
  111. bar := color.New(f.pctAttr).Sprint(f.bar)
  112. pct := color.New(f.pctAttr).Sprint(f.pct)
  113. fmt.Fprintf(w, " %-7s [%s] %s %-*s %-*s\n",
  114. f.label, bar, pct, flowsW, f.flows, dollarsW, f.dollars)
  115. }
  116. fmt.Fprintln(w)
  117. fmt.Fprintf(w, " Tokens consumed (estimated USD value): %s\n", fmtUSD(d.Quota7Day.UsedValueUSD))
  118. fmt.Fprintf(w, " Monthly cap: %s flows · %s\n",
  119. fmtFlow(d.QuotaMonthly.MaxFlows), fmtUSD(d.QuotaMonthly.MaxValueUSD))
  120. resets := resetsLine(d.Quota5Hour.ResetsAt, d.Quota7Day.ResetsAt)
  121. if resets != "" {
  122. fmt.Fprintf(w, " Next reset: %s\n", resets)
  123. }
  124. }
  125. // RenderAccountError writes the header block and a single red error line,
  126. // then returns. Used for per-account failures in multi-account runs.
  127. func RenderAccountError(w io.Writer, name string, err error, useColor bool) {
  128. writeHeader(w, name)
  129. msg := color.New(color.FgRed).Sprintf(" error: %v", err)
  130. fmt.Fprintln(w, msg)
  131. }
  132. func writeHeader(w io.Writer, name string) {
  133. label := fmt.Sprintf("━━━ %s ━━━", name)
  134. fmt.Fprintln(w, color.New(color.Bold).Sprint(label))
  135. }
  136. func writePlanLine(w io.Writer, resp *api.Response) {
  137. d := resp.Data
  138. tier := titleCase(d.Plan.Tier)
  139. status := colorizeStatus(d.AccountStatus)
  140. fmt.Fprintf(w, "ZenMux Subscription — %s plan ($%.0f/mo) · %s · $%.5f/flow\n",
  141. tier, d.Plan.AmountUSD, status, d.EffectiveUSDPerFlow)
  142. }
  143. // resetsLine composes "5h → 2026-04-22 14:05 · 7d → 2026-04-28 00:00".
  144. // Missing or unparseable timestamps are skipped silently.
  145. func resetsLine(fiveHour, sevenDay *string) string {
  146. parts := make([]string, 0, 2)
  147. if s := formatReset(fiveHour); s != "" {
  148. parts = append(parts, "5h → "+s)
  149. }
  150. if s := formatReset(sevenDay); s != "" {
  151. parts = append(parts, "7d → "+s)
  152. }
  153. return strings.Join(parts, " · ")
  154. }
  155. func formatReset(s *string) string {
  156. if s == nil || *s == "" {
  157. return ""
  158. }
  159. t, err := time.Parse(time.RFC3339, *s)
  160. if err != nil {
  161. return ""
  162. }
  163. return t.Local().Format("2006-01-02 15:04")
  164. }
  165. func colorizeStatus(status string) string {
  166. switch status {
  167. case "healthy":
  168. return color.New(color.FgGreen).Sprint(status)
  169. case "monitored":
  170. return color.New(color.FgYellow).Sprint(status)
  171. case "abusive", "suspended", "banned":
  172. return color.New(color.FgRed).Sprint(status)
  173. default:
  174. return status
  175. }
  176. }
  177. func titleCase(s string) string {
  178. if s == "" {
  179. return s
  180. }
  181. return strings.ToUpper(s[:1]) + s[1:]
  182. }
  183. // fmtFlow prints a flow count trimming trailing zeros but keeping
  184. // at most 2 decimal places so 57.2 stays "57.2", 800 stays "800",
  185. // and 416.11 stays "416.11".
  186. func fmtFlow(v float64) string {
  187. return strconv.FormatFloat(v, 'f', -1, 64)
  188. }
  189. func fmtUSD(v float64) string {
  190. return fmt.Sprintf("$%.2f", v)
  191. }