render.go 6.2 KB

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