package webui import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "bee/audit/internal/platform" ) func TestChartLegendNumber(t *testing.T) { tests := []struct { in float64 want string }{ {in: 0.4, want: "0"}, {in: 61.5, want: "62"}, {in: 999.4, want: "999"}, {in: 1200, want: "1,2k"}, {in: 1250, want: "1,25k"}, {in: 1310, want: "1,31k"}, {in: 1500, want: "1,5k"}, {in: 2600, want: "2,6k"}, {in: 10200, want: "10k"}, } for _, tc := range tests { if got := chartLegendNumber(tc.in); got != tc.want { t.Fatalf("chartLegendNumber(%v)=%q want %q", tc.in, got, tc.want) } } } func TestChartDataFromSamplesUsesFullHistory(t *testing.T) { samples := []platform.LiveMetricSample{ { Timestamp: time.Now().Add(-3 * time.Minute), CPULoadPct: 10, MemLoadPct: 20, PowerW: 300, GPUs: []platform.GPUMetricRow{ {GPUIndex: 0, UsagePct: 90, MemUsagePct: 5, PowerW: 120, TempC: 50}, }, }, { Timestamp: time.Now().Add(-2 * time.Minute), CPULoadPct: 30, MemLoadPct: 40, PowerW: 320, GPUs: []platform.GPUMetricRow{ {GPUIndex: 0, UsagePct: 95, MemUsagePct: 7, PowerW: 125, TempC: 51}, }, }, { Timestamp: time.Now().Add(-1 * time.Minute), CPULoadPct: 50, MemLoadPct: 60, PowerW: 340, GPUs: []platform.GPUMetricRow{ {GPUIndex: 0, UsagePct: 97, MemUsagePct: 9, PowerW: 130, TempC: 52}, }, }, } datasets, names, labels, title, _, _, ok := chartDataFromSamples("gpu-all-power", samples) if !ok { t.Fatal("chartDataFromSamples returned ok=false") } if title != "GPU Power" { t.Fatalf("title=%q", title) } if len(names) != 1 || names[0] != "GPU 0" { t.Fatalf("names=%v", names) } if len(labels) != len(samples) { t.Fatalf("labels len=%d want %d", len(labels), len(samples)) } if len(datasets) != 1 || len(datasets[0]) != len(samples) { t.Fatalf("datasets shape=%v", datasets) } if got := datasets[0][0]; got != 120 { t.Fatalf("datasets[0][0]=%v want 120", got) } if got := datasets[0][2]; got != 130 { t.Fatalf("datasets[0][2]=%v want 130", got) } } func TestChartDataFromSamplesKeepsStableGPUSeriesOrder(t *testing.T) { samples := []platform.LiveMetricSample{ { Timestamp: time.Now().Add(-2 * time.Minute), GPUs: []platform.GPUMetricRow{ {GPUIndex: 7, PowerW: 170}, {GPUIndex: 2, PowerW: 120}, {GPUIndex: 0, PowerW: 100}, }, }, { Timestamp: time.Now().Add(-1 * time.Minute), GPUs: []platform.GPUMetricRow{ {GPUIndex: 0, PowerW: 101}, {GPUIndex: 7, PowerW: 171}, {GPUIndex: 2, PowerW: 121}, }, }, } datasets, names, _, title, _, _, ok := chartDataFromSamples("gpu-all-power", samples) if !ok { t.Fatal("chartDataFromSamples returned ok=false") } if title != "GPU Power" { t.Fatalf("title=%q", title) } wantNames := []string{"GPU 0", "GPU 2", "GPU 7"} if len(names) != len(wantNames) { t.Fatalf("names len=%d want %d: %v", len(names), len(wantNames), names) } for i := range wantNames { if names[i] != wantNames[i] { t.Fatalf("names[%d]=%q want %q; full=%v", i, names[i], wantNames[i], names) } } if got := datasets[0]; len(got) != 2 || got[0] != 100 || got[1] != 101 { t.Fatalf("GPU 0 dataset=%v want [100 101]", got) } if got := datasets[1]; len(got) != 2 || got[0] != 120 || got[1] != 121 { t.Fatalf("GPU 2 dataset=%v want [120 121]", got) } if got := datasets[2]; len(got) != 2 || got[0] != 170 || got[1] != 171 { t.Fatalf("GPU 7 dataset=%v want [170 171]", got) } } func TestNormalizePowerSeriesHoldsLastPositive(t *testing.T) { got := normalizePowerSeries([]float64{0, 480, 0, 0, 510, 0}) want := []float64{0, 480, 480, 480, 510, 510} if len(got) != len(want) { t.Fatalf("len=%d want %d", len(got), len(want)) } for i := range want { if got[i] != want[i] { t.Fatalf("got[%d]=%v want %v", i, got[i], want[i]) } } } func TestRenderMetricsUsesBufferedChartRefresh(t *testing.T) { body := renderMetrics() if !strings.Contains(body, "const probe = new Image();") { t.Fatalf("metrics page should preload chart images before swap: %s", body) } if !strings.Contains(body, "el.dataset.loading === '1'") { t.Fatalf("metrics page should avoid overlapping chart reloads: %s", body) } } func TestChartLegendVisible(t *testing.T) { if !chartLegendVisible(8) { t.Fatal("legend should stay visible for charts with up to 8 series") } if chartLegendVisible(9) { t.Fatal("legend should be hidden for charts with more than 8 series") } } func TestChartYAxisNumber(t *testing.T) { tests := []struct { in float64 want string }{ {in: 999, want: "999"}, {in: 1000, want: "1к"}, {in: 1370, want: "1,4к"}, {in: 1500, want: "1,5к"}, {in: 1700, want: "1,7к"}, {in: 2000, want: "2к"}, {in: 9999, want: "10к"}, {in: 10200, want: "10к"}, {in: -1500, want: "-1,5к"}, } for _, tc := range tests { if got := chartYAxisNumber(tc.in); got != tc.want { t.Fatalf("chartYAxisNumber(%v)=%q want %q", tc.in, got, tc.want) } } } func TestChartCanvasHeight(t *testing.T) { if got := chartCanvasHeight(4); got != 360 { t.Fatalf("chartCanvasHeight(4)=%d want 360", got) } if got := chartCanvasHeight(12); got != 288 { t.Fatalf("chartCanvasHeight(12)=%d want 288", got) } } func TestNormalizeFanSeriesHoldsLastPositive(t *testing.T) { got := normalizeFanSeries([]float64{4200, 0, 0, 4300, 0}) want := []float64{4200, 4200, 4200, 4300, 4300} if len(got) != len(want) { t.Fatalf("len=%d want %d", len(got), len(want)) } for i := range want { if got[i] != want[i] { t.Fatalf("got[%d]=%v want %v", i, got[i], want[i]) } } } func TestChartYAxisOption(t *testing.T) { min := floatPtr(0) max := floatPtr(100) opt := chartYAxisOption(min, max) if opt.Min != min || opt.Max != max { t.Fatalf("chartYAxisOption min/max mismatch: %#v", opt) } if opt.LabelCount != 11 { t.Fatalf("chartYAxisOption labelCount=%d want 11", opt.LabelCount) } if got := opt.ValueFormatter(1000); got != "1к" { t.Fatalf("chartYAxisOption formatter(1000)=%q want 1к", got) } } func TestSnapshotFanRingsUsesTimelineLabels(t *testing.T) { r1 := newMetricsRing(4) r2 := newMetricsRing(4) r1.push(1000) r1.push(1100) r2.push(1200) r2.push(1300) datasets, names, labels := snapshotFanRings([]*metricsRing{r1, r2}, []string{"FAN_A", "FAN_B"}) if len(datasets) != 2 { t.Fatalf("datasets=%d want 2", len(datasets)) } if len(names) != 2 || names[0] != "FAN_A RPM" || names[1] != "FAN_B RPM" { t.Fatalf("names=%v", names) } if len(labels) != 2 { t.Fatalf("labels=%v want 2 entries", labels) } if labels[0] == "" || labels[1] == "" { t.Fatalf("labels should contain timeline values, got %v", labels) } } func TestRenderNetworkInlineSyncsPendingState(t *testing.T) { body := renderNetworkInline() if !strings.Contains(body, "d.pending_change") { t.Fatalf("network UI should read pending network state from API: %s", body) } if !strings.Contains(body, "setInterval(loadNetwork, 5000)") { t.Fatalf("network UI should periodically refresh network state: %s", body) } if !strings.Contains(body, "showNetPending(NET_ROLLBACK_SECS)") { t.Fatalf("network UI should show pending confirmation immediately on apply: %s", body) } } func TestRootRendersDashboard(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.json") exportDir := filepath.Join(dir, "export") if err := os.MkdirAll(exportDir, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(`{"collected_at":"2026-03-15T00:00:00Z","hardware":{"board":{"serial_number":"SERIAL-OLD"}}}`), 0644); err != nil { t.Fatal(err) } handler := NewHandler(HandlerOptions{ Title: "Bee Hardware Audit", AuditPath: path, ExportDir: exportDir, }) first := httptest.NewRecorder() handler.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/", nil)) if first.Code != http.StatusOK { t.Fatalf("first status=%d", first.Code) } // Dashboard should contain the audit nav link and hardware summary if !strings.Contains(first.Body.String(), `href="/audit"`) { t.Fatalf("first body missing audit nav link: %s", first.Body.String()) } if !strings.Contains(first.Body.String(), `/viewer`) { t.Fatalf("first body missing viewer link: %s", first.Body.String()) } if got := first.Header().Get("Cache-Control"); got != "no-store" { t.Fatalf("first cache-control=%q", got) } if err := os.WriteFile(path, []byte(`{"collected_at":"2026-03-15T00:05:00Z","hardware":{"board":{"serial_number":"SERIAL-NEW"}}}`), 0644); err != nil { t.Fatal(err) } second := httptest.NewRecorder() handler.ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/", nil)) if second.Code != http.StatusOK { t.Fatalf("second status=%d", second.Code) } if !strings.Contains(second.Body.String(), `Hardware Summary`) { t.Fatalf("second body missing hardware summary: %s", second.Body.String()) } } func TestRootShowsRunAuditButtonWhenSnapshotMissing(t *testing.T) { dir := t.TempDir() exportDir := filepath.Join(dir, "export") if err := os.MkdirAll(exportDir, 0755); err != nil { t.Fatal(err) } handler := NewHandler(HandlerOptions{ Title: "Bee Hardware Audit", AuditPath: filepath.Join(dir, "missing-audit.json"), ExportDir: exportDir, }) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } body := rec.Body.String() if !strings.Contains(body, `Run Audit`) { t.Fatalf("dashboard missing run audit button: %s", body) } if strings.Contains(body, `No audit data`) { t.Fatalf("dashboard still shows empty audit badge: %s", body) } } func TestAuditPageRendersViewerFrameAndActions(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.json") if err := os.WriteFile(path, []byte(`{"collected_at":"2026-03-15T00:00:00Z"}`), 0644); err != nil { t.Fatal(err) } handler := NewHandler(HandlerOptions{AuditPath: path}) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audit", nil)) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } body := rec.Body.String() if !strings.Contains(body, `iframe class="viewer-frame" src="/viewer"`) { t.Fatalf("audit page missing viewer frame: %s", body) } if !strings.Contains(body, `openAuditModal()`) { t.Fatalf("audit page missing action modal trigger: %s", body) } } func TestTasksPageRendersLogModalAndPaginationControls(t *testing.T) { handler := NewHandler(HandlerOptions{}) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil)) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } body := rec.Body.String() if !strings.Contains(body, `id="task-log-overlay"`) { t.Fatalf("tasks page missing log modal overlay: %s", body) } if !strings.Contains(body, `_taskPageSize = 50`) { t.Fatalf("tasks page missing pagination size config: %s", body) } if !strings.Contains(body, `Previous`) || !strings.Contains(body, `Next`) { t.Fatalf("tasks page missing pagination controls: %s", body) } } func TestViewerRendersLatestSnapshot(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.json") if err := os.WriteFile(path, []byte(`{"collected_at":"2026-03-15T00:00:00Z","hardware":{"board":{"serial_number":"SERIAL-OLD"}}}`), 0644); err != nil { t.Fatal(err) } handler := NewHandler(HandlerOptions{AuditPath: path}) first := httptest.NewRecorder() handler.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/viewer", nil)) if first.Code != http.StatusOK { t.Fatalf("first status=%d", first.Code) } if !strings.Contains(first.Body.String(), "SERIAL-OLD") { t.Fatalf("viewer body missing old serial: %s", first.Body.String()) } if err := os.WriteFile(path, []byte(`{"collected_at":"2026-03-15T00:05:00Z","hardware":{"board":{"serial_number":"SERIAL-NEW"}}}`), 0644); err != nil { t.Fatal(err) } second := httptest.NewRecorder() handler.ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/viewer", nil)) if second.Code != http.StatusOK { t.Fatalf("second status=%d", second.Code) } if !strings.Contains(second.Body.String(), "SERIAL-NEW") { t.Fatalf("viewer body missing new serial: %s", second.Body.String()) } if strings.Contains(second.Body.String(), "SERIAL-OLD") { t.Fatalf("viewer body still contains old serial: %s", second.Body.String()) } } func TestAuditJSONServesLatestSnapshot(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "audit.json") body := `{"hardware":{"board":{"serial_number":"SERIAL-API"}}}` if err := os.WriteFile(path, []byte(body), 0644); err != nil { t.Fatal(err) } handler := NewHandler(HandlerOptions{AuditPath: path}) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audit.json", nil)) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } if !strings.Contains(rec.Body.String(), "SERIAL-API") { t.Fatalf("body missing expected serial: %s", rec.Body.String()) } if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") { t.Fatalf("content-type=%q", got) } } func TestMissingAuditJSONReturnsNotFound(t *testing.T) { handler := NewHandler(HandlerOptions{AuditPath: "/missing/audit.json"}) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audit.json", nil)) if rec.Code != http.StatusNotFound { t.Fatalf("status=%d want %d", rec.Code, http.StatusNotFound) } } func TestSupportBundleEndpointReturnsArchive(t *testing.T) { dir := t.TempDir() exportDir := filepath.Join(dir, "export") if err := os.MkdirAll(exportDir, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(exportDir, "bee-audit.log"), []byte("audit log"), 0644); err != nil { t.Fatal(err) } archive, err := os.CreateTemp(os.TempDir(), "bee-support-server-test-*.tar.gz") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Remove(archive.Name()) }) if _, err := archive.WriteString("support-bundle"); err != nil { t.Fatal(err) } if err := archive.Close(); err != nil { t.Fatal(err) } handler := NewHandler(HandlerOptions{ExportDir: exportDir}) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/export/support.tar.gz", nil)) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, "attachment;") { t.Fatalf("content-disposition=%q", got) } if rec.Body.Len() == 0 { t.Fatal("empty archive body") } } func TestRuntimeHealthEndpointReturnsJSON(t *testing.T) { dir := t.TempDir() exportDir := filepath.Join(dir, "export") if err := os.MkdirAll(exportDir, 0755); err != nil { t.Fatal(err) } body := `{"status":"PARTIAL","checked_at":"2026-03-16T10:00:00Z"}` if err := os.WriteFile(filepath.Join(exportDir, "runtime-health.json"), []byte(body), 0644); err != nil { t.Fatal(err) } handler := NewHandler(HandlerOptions{ExportDir: exportDir}) rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime-health.json", nil)) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if strings.TrimSpace(rec.Body.String()) != body { t.Fatalf("body=%q want %q", strings.TrimSpace(rec.Body.String()), body) } }