diff --git a/cmd/sandbox/metrics.go b/cmd/sandbox/metrics.go index 3601468..3f7bf7f 100644 --- a/cmd/sandbox/metrics.go +++ b/cmd/sandbox/metrics.go @@ -6,14 +6,28 @@ import ( "fmt" "os" "os/signal" + "strings" "syscall" "time" + "github.com/dustin/go-humanize" "github.com/guptarohit/asciigraph" "github.com/spf13/cobra" "github.com/ucloud/ucloud-sandbox-cli/internal/config" "github.com/ucloud/ucloud-sandbox-cli/internal/datetime" sdk "github.com/ucloud/ucloud-sandbox-sdk-go" + "golang.org/x/term" +) + +const ( + defaultMetricsWidth = 80 + xAxisTime = "15:04:05" + xAxisTimeShort = "15:04" + xAxisHour = "15" + xAxisDateTime = "01-02 15:04" + xAxisDate = "01-02" + xAxisDateTimeYear = "2006-01-02 15:04" + xAxisDateYear = "2006-01-02" ) func newMetricsCmd() *cobra.Command { @@ -91,48 +105,282 @@ func fetchMetrics(ctx context.Context, sbx *sdk.Sandbox, start, end time.Time) ( } func renderMetrics(metrics []sdk.SandboxMetrics, sandboxID string) { + width := terminalWidth() + if width == 0 { + width = defaultMetricsWidth + } + fmt.Print(formatMetrics(metrics, sandboxID, width)) +} + +// terminalWidth returns stdout's usable column count. A zero result means +// stdout is not a terminal (for example, when output is redirected). +func terminalWidth() int { + w, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || w <= 0 { + return 0 + } + return w +} + +// formatMetrics renders a width-constrained metrics dashboard. Keeping this +// separate from renderMetrics makes the layout deterministic for tests and +// prevents chart libraries from expanding to the number of samples. +func formatMetrics(metrics []sdk.SandboxMetrics, sandboxID string, width int) string { + if width <= 0 { + width = defaultMetricsWidth + } + + var b strings.Builder + writeMetricLine(&b, "Sandbox metrics", width) + idWidth := width - len("ID ") - 2 + if idWidth < 1 { + idWidth = 1 + } + writeMetricLine(&b, "ID "+truncateMetricText(sandboxID, idWidth), width) + + sampleWord := "samples" + if len(metrics) == 1 { + sampleWord = "sample" + } + writeMetricLine(&b, fmt.Sprintf("Samples %d %s", len(metrics), sampleWord), width) if len(metrics) == 0 { - fmt.Println("No metrics data available.") - return + b.WriteByte('\n') + writeMetricLine(&b, "No metrics data available.", width) + return b.String() + } + + first, last := metricDisplayRange(metrics) + if !first.IsZero() && !last.IsZero() { + writeMetricLine(&b, fmt.Sprintf("Time %s - %s", first.Format("15:04:05"), last.Format("15:04:05")), width) + } + + b.WriteByte('\n') + writeMetricLine(&b, "Current usage", width) + series := buildMetricSeries(metrics) + for _, item := range series { + writeMetricLine(&b, item.usageLine(width), width) + } + + b.WriteByte('\n') + if len(metrics) < 2 { + writeMetricLine(&b, "Waiting for more samples...", width) + return b.String() + } + + for _, item := range series { + writeMetricLine(&b, "── "+item.name+" Usage (%)", width) + for _, line := range strings.Split(formatMetricsTrendChart(item.values, first, last, width), "\n") { + if line != "" { + writeMetricLine(&b, line, width) + } + } + b.WriteByte('\n') + } + return b.String() +} + +func metricDisplayRange(metrics []sdk.SandboxMetrics) (time.Time, time.Time) { + first := metrics[0].Timestamp + last := metrics[len(metrics)-1].Timestamp + if !first.IsZero() { + first = first.In(time.Local) + } + if !last.IsZero() { + last = last.In(time.Local) + } + return first, last +} + +func writeMetricLine(b *strings.Builder, line string, width int) { + b.WriteString(truncateMetricText(line, width)) + b.WriteByte('\n') +} + +func truncateMetricText(value string, width int) string { + if width <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= width { + return value + } + if width <= 3 { + return string(runes[:width]) + } + return string(runes[:width-3]) + "..." +} + +type metricSeries struct { + name string + values []float64 + current float64 + peak float64 + available bool + detail string +} + +func (m metricSeries) usageLine(width int) string { + if !m.available { + return fmt.Sprintf("%-12sn/a not reported", m.name) } + if width >= 60 { + return fmt.Sprintf("%-12s%6.1f%% peak %6.1f%% %s", m.name, m.current, m.peak, m.detail) + } + line := fmt.Sprintf("%-12s%.1f%%", m.name, m.current) + if m.peak != m.current { + line += fmt.Sprintf(" peak %.1f%%", m.peak) + } + if m.detail != "" { + line += " " + m.detail + } + return line +} +func buildMetricSeries(metrics []sdk.SandboxMetrics) []metricSeries { + latest := metrics[len(metrics)-1] cpu := make([]float64, len(metrics)) - mem := make([]float64, len(metrics)) + memory := make([]float64, len(metrics)) disk := make([]float64, len(metrics)) + for i, item := range metrics { + cpu[i] = item.CPUUsedPct + memory[i] = percent(item.MemUsed, item.MemTotal) + disk[i] = percent(item.DiskUsed, item.DiskTotal) + } + return []metricSeries{ + { + name: "CPU", + values: cpu, + current: latest.CPUUsedPct, + peak: maxValue(cpu), + available: true, + detail: fmt.Sprintf("%d vCPU", latest.CPUCount), + }, + { + name: "Memory", + values: memory, + current: memory[len(memory)-1], + peak: maxValue(memory), + available: latest.MemTotal > 0, + detail: storageDetail(latest.MemUsed, latest.MemTotal), + }, + { + name: "Disk", + values: disk, + current: disk[len(disk)-1], + peak: maxValue(disk), + available: latest.DiskTotal > 0, + detail: storageDetail(latest.DiskUsed, latest.DiskTotal), + }, + } +} - for i, m := range metrics { - cpu[i] = m.CPUUsedPct - if m.MemTotal > 0 { - mem[i] = float64(m.MemUsed) / float64(m.MemTotal) * 100 - } - if m.DiskTotal > 0 { - disk[i] = float64(m.DiskUsed) / float64(m.DiskTotal) * 100 +func maxValue(values []float64) float64 { + var max float64 + for _, value := range values { + if value > max { + max = value } } + return max +} + +func storageDetail(used, total int64) string { + if total <= 0 { + return "" + } + return fmt.Sprintf("%s / %s", humanize.IBytes(uint64(max(used, 0))), humanize.IBytes(uint64(total))) +} + +func percent(used, total int64) float64 { + if total <= 0 { + return 0 + } + return float64(used) / float64(total) * 100 +} - tStart := float64(metrics[0].Timestamp.Unix()) - tEnd := float64(metrics[len(metrics)-1].Timestamp.Unix()) - timeFormatter := func(v float64) string { - return time.Unix(int64(v), 0).Format("15:04:05") +func formatMetricsTrendChart(values []float64, first, last time.Time, width int) string { + // Leave room for the Y-axis labels and the plot's offset. Width is the + // terminal width, while asciigraph's Width option is only the plot area. + plotWidth := width - 10 + if plotWidth < 8 { + plotWidth = 8 } - chartOpts := []asciigraph.Option{ + options := []asciigraph.Option{ + asciigraph.Width(plotWidth), asciigraph.Height(8), asciigraph.LowerBound(0), asciigraph.UpperBound(100), - asciigraph.XAxisRange(tStart, tEnd), - asciigraph.XAxisValueFormatter(timeFormatter), - } - - fmt.Printf("Sandbox: %s (%d samples)\n\n", sandboxID, len(metrics)) - fmt.Println("── CPU Usage (%)") - fmt.Println(asciigraph.Plot(cpu, chartOpts...)) - fmt.Println() - fmt.Println("── Memory Usage (%)") - fmt.Println(asciigraph.Plot(mem, chartOpts...)) - fmt.Println() - fmt.Println("── Disk Usage (%)") - fmt.Println(asciigraph.Plot(disk, chartOpts...)) + // Keep the original chart's percentage scale and two-decimal labels. + asciigraph.Precision(2), + } + + chart := asciigraph.Plot(values, options...) + if first.IsZero() || last.IsZero() { + return chart + } + return chart + "\n" + formatMetricsXAxis(chart, plotWidth, first, last) +} + +func formatMetricsXAxis(chart string, plotWidth int, first, last time.Time) string { + lines := strings.Split(chart, "\n") + axisColumn := -1 + for _, line := range lines { + if column := strings.IndexRune(line, '┤'); column >= 0 { + axisColumn = column + 1 + break + } + } + if axisColumn < 1 { + return "" + } + + timeFormat, tickCount := metricsXAxisLayout(plotWidth, first, last) + + lineWidth := axisColumn + plotWidth + axis := []rune(strings.Repeat(" ", lineWidth)) + axis[axisColumn-1] = '└' + for i := 0; i < plotWidth; i++ { + axis[axisColumn+i] = '─' + } + + labelLine := []rune(strings.Repeat(" ", lineWidth)) + labelWidth := len([]rune(first.Format(timeFormat))) + labelRange := plotWidth - labelWidth + for i := 0; i < tickCount; i++ { + fraction := float64(i) / float64(tickCount-1) + position := int(float64(plotWidth-1) * fraction) + axis[axisColumn+position] = '┬' + + stamp := first.Add(time.Duration(float64(last.Sub(first)) * fraction)) + label := []rune(stamp.Format(timeFormat)) + start := axisColumn + labelRange*i/(tickCount-1) + copy(labelLine[start:], label) + } + + return strings.TrimRight(string(axis), " ") + "\n" + strings.TrimRight(string(labelLine), " ") +} + +func metricsXAxisLayout(plotWidth int, first, last time.Time) (string, int) { + formats := []string{xAxisTime, xAxisTimeShort, xAxisHour} + if first.Year() != last.Year() { + formats = []string{xAxisDateTimeYear, xAxisDateYear, xAxisDate, xAxisHour} + } else if first.YearDay() != last.YearDay() { + formats = []string{xAxisDateTime, xAxisDate, xAxisTimeShort, xAxisHour} + } + + for _, format := range formats { + maxTicks := (plotWidth + 1) / (len(format) + 1) + switch { + case maxTicks >= 10: + return format, 10 + case maxTicks >= 5: + return format, 5 + case maxTicks >= 2: + return format, 2 + } + } + return formats[len(formats)-1], 2 } func showMetrics(ctx context.Context, sbx *sdk.Sandbox, start, end time.Time, raw bool) error { diff --git a/cmd/sandbox/metrics_test.go b/cmd/sandbox/metrics_test.go new file mode 100644 index 0000000..c1ec690 --- /dev/null +++ b/cmd/sandbox/metrics_test.go @@ -0,0 +1,154 @@ +package sandbox + +import ( + "strings" + "testing" + "time" + "unicode/utf8" + + sdk "github.com/ucloud/ucloud-sandbox-sdk-go" +) + +func TestFormatMetricsDashboard(t *testing.T) { + start := time.Date(2026, time.July, 17, 14, 30, 0, 0, time.Local) + metrics := []sdk.SandboxMetrics{ + { + Timestamp: start, + CPUCount: 2, + CPUUsedPct: 10, + MemUsed: 256 << 20, + MemTotal: 1 << 30, + DiskUsed: 2 << 30, + DiskTotal: 10 << 30, + }, + { + Timestamp: start.Add(time.Minute), + CPUCount: 2, + CPUUsedPct: 25, + MemUsed: 768 << 20, + MemTotal: 1 << 30, + DiskUsed: 3 << 30, + DiskTotal: 10 << 30, + }, + { + Timestamp: start.Add(2 * time.Minute), + CPUCount: 2, + CPUUsedPct: 20, + MemUsed: 512 << 20, + MemTotal: 1 << 30, + DiskUsed: 4 << 30, + DiskTotal: 10 << 30, + }, + } + + out := formatMetrics(metrics, "sandbox-123", 80) + + for _, want := range []string{ + "Sandbox metrics", + "ID sandbox-123", + "3 samples", + "Current usage", + "CPU", + " 20.0% peak 25.0% 2 vCPU", + "Memory", + " 50.0% peak 75.0% 512 MiB / 1.0 GiB", + "Disk", + " 40.0% peak 40.0% 4.0 GiB / 10 GiB", + "── CPU Usage (%)", + "── Memory Usage (%)", + "── Disk Usage (%)", + } { + if !strings.Contains(out, want) { + t.Errorf("output does not contain %q:\n%s", want, out) + } + } + if strings.Contains(out, "\033[") { + t.Fatalf("color-disabled output contains ANSI escapes:\n%s", out) + } + assertMetricsLineWidth(t, out, 80) +} + +func TestFormatMetricsNarrowAndUnavailable(t *testing.T) { + metrics := []sdk.SandboxMetrics{{ + Timestamp: time.Date(2026, time.July, 17, 14, 30, 0, 0, time.Local), + CPUCount: 1, + CPUUsedPct: 8.5, + }} + + out := formatMetrics(metrics, "sandbox-with-a-very-long-identifier", 32) + + for _, want := range []string{ + "ID sandbox-with-a-very...", + "Memory n/a not reported", + "Disk n/a not reported", + "Waiting for more samples...", + } { + if !strings.Contains(out, want) { + t.Errorf("output does not contain %q:\n%s", want, out) + } + } + if strings.Contains(out, "[█") || strings.Contains(out, "── CPU Usage (%)") { + t.Fatalf("narrow single-sample output should use the compact layout:\n%s", out) + } + assertMetricsLineWidth(t, out, 32) +} + +func TestFormatMetricsWideXAxisKeepsEndpointLabels(t *testing.T) { + start := time.Date(2026, time.July, 17, 17, 20, 0, 0, time.Local) + end := time.Date(2026, time.July, 17, 19, 45, 0, 0, time.Local) + metrics := []sdk.SandboxMetrics{ + {Timestamp: start, CPUUsedPct: 10, MemTotal: 1, DiskTotal: 1}, + {Timestamp: end, CPUUsedPct: 20, MemTotal: 1, DiskTotal: 1}, + } + + out := formatMetrics(metrics, "sandbox-123", 100) + for _, label := range []string{"17:20:00", "19:45:00"} { + if count := strings.Count(out, label); count != 4 { + t.Errorf("time label %q appears %d times, want once in the summary and each chart:\n%s", label, count, out) + } + } + assertMetricsLineWidth(t, out, 100) +} + +func TestMetricsXAxisLayoutIncludesDateWhenNeeded(t *testing.T) { + dayStart := time.Date(2026, time.July, 17, 23, 30, 0, 0, time.Local) + dayEnd := dayStart.Add(2 * time.Hour) + format, ticks := metricsXAxisLayout(100, dayStart, dayEnd) + if format != "01-02 15:04" || ticks != 5 { + t.Fatalf("cross-day layout = (%q, %d), want (%q, %d)", format, ticks, "01-02 15:04", 5) + } + + yearEnd := time.Date(2027, time.January, 1, 0, 30, 0, 0, time.Local) + format, ticks = metricsXAxisLayout(100, dayEnd, yearEnd) + if format != "2006-01-02 15:04" || ticks != 5 { + t.Fatalf("cross-year layout = (%q, %d), want (%q, %d)", format, ticks, "2006-01-02 15:04", 5) + } +} + +func TestMetricDisplayRangeUsesLocalTimezone(t *testing.T) { + sourceLocation := time.FixedZone("source", 2*60*60) + firstInput := time.Date(2026, time.July, 17, 23, 30, 0, 0, sourceLocation) + lastInput := firstInput.Add(2 * time.Hour) + first, last := metricDisplayRange([]sdk.SandboxMetrics{ + {Timestamp: firstInput}, + {Timestamp: lastInput}, + }) + + wantFirst := firstInput.In(time.Local) + wantLast := lastInput.In(time.Local) + if !first.Equal(wantFirst) || first.Location() != time.Local { + t.Fatalf("first display time = %v (%v), want %v (%v)", first, first.Location(), wantFirst, time.Local) + } + if !last.Equal(wantLast) || last.Location() != time.Local { + t.Fatalf("last display time = %v (%v), want %v (%v)", last, last.Location(), wantLast, time.Local) + } +} + +func assertMetricsLineWidth(t *testing.T, output string, maxWidth int) { + t.Helper() + for _, line := range strings.Split(strings.TrimSuffix(output, "\n"), "\n") { + if width := utf8.RuneCountInString(line); width > maxWidth { + t.Errorf("line is %d columns wide, want at most %d: %q", width, maxWidth, line) + } + } +}