diff --git a/internal/monitors/monitor_create.go b/internal/monitors/monitor_create.go index 1b108ec..c5337fa 100644 --- a/internal/monitors/monitor_create.go +++ b/internal/monitors/monitor_create.go @@ -68,19 +68,53 @@ func httpMonitorToLocal(m *monitorv1.HTTPMonitor) (Monitor, error) { if err != nil { return Monitor{}, fmt.Errorf("invalid monitor ID %q: %w", m.GetId(), err) } + + var headers []Header + for _, h := range m.GetHeaders() { + headers = append(headers, Header{Key: h.GetKey(), Value: h.GetValue()}) + } + + var assertions []Assertion + for _, a := range m.GetStatusCodeAssertions() { + assertions = append(assertions, Assertion{ + Type: "status_code", + Compare: string(convertNumberComparator(a.GetComparator())), + Target: int(a.GetTarget()), + }) + } + for _, a := range m.GetBodyAssertions() { + assertions = append(assertions, Assertion{ + Type: "text_body", + Compare: string(convertStringComparator(a.GetComparator())), + Target: a.GetTarget(), + }) + } + for _, a := range m.GetHeaderAssertions() { + assertions = append(assertions, Assertion{ + Type: "header", + Compare: string(convertStringComparator(a.GetComparator())), + Target: a.GetTarget(), + Key: a.GetKey(), + }) + } + return Monitor{ - ID: id, - Name: m.GetName(), - Description: m.GetDescription(), - URL: m.GetUrl(), - Periodicity: periodicityToString(m.GetPeriodicity()), - Method: httpMethodToString(m.GetMethod()), - Regions: regionsToStrings(m.GetRegions()), - Active: m.GetActive(), - Public: m.GetPublic(), - Timeout: int(m.GetTimeout()), - Retry: int(m.GetRetry()), - JobType: "http", + ID: id, + Name: m.GetName(), + Description: m.GetDescription(), + URL: m.GetUrl(), + Periodicity: periodicityToString(m.GetPeriodicity()), + Method: httpMethodToString(m.GetMethod()), + Regions: regionsToStrings(m.GetRegions()), + Active: m.GetActive(), + Public: m.GetPublic(), + Timeout: int(m.GetTimeout()), + DegradedAfter: int(m.GetDegradedAt()), + Body: m.GetBody(), + Headers: headers, + Assertions: assertions, + Retry: int(m.GetRetry()), + JobType: "http", }, nil } @@ -90,17 +124,18 @@ func tcpMonitorToLocal(m *monitorv1.TCPMonitor) (Monitor, error) { return Monitor{}, fmt.Errorf("invalid monitor ID %q: %w", m.GetId(), err) } return Monitor{ - ID: id, - Name: m.GetName(), - Description: m.GetDescription(), - URL: m.GetUri(), - Periodicity: periodicityToString(m.GetPeriodicity()), - Regions: regionsToStrings(m.GetRegions()), - Active: m.GetActive(), - Public: m.GetPublic(), - Timeout: int(m.GetTimeout()), - Retry: int(m.GetRetry()), - JobType: "tcp", + ID: id, + Name: m.GetName(), + Description: m.GetDescription(), + URL: m.GetUri(), + Periodicity: periodicityToString(m.GetPeriodicity()), + Regions: regionsToStrings(m.GetRegions()), + Active: m.GetActive(), + Public: m.GetPublic(), + Timeout: int(m.GetTimeout()), + DegradedAfter: int(m.GetDegradedAt()), + Retry: int(m.GetRetry()), + JobType: "tcp", }, nil } diff --git a/internal/monitors/monitor_info.go b/internal/monitors/monitor_info.go index ea42bce..696ac82 100644 --- a/internal/monitors/monitor_info.go +++ b/internal/monitors/monitor_info.go @@ -6,19 +6,167 @@ import ( "net/http" "os" "strings" + "sync" monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" - "github.com/openstatusHQ/cli/internal/api" - "github.com/openstatusHQ/cli/internal/auth" - output "github.com/openstatusHQ/cli/internal/cli" + "github.com/fatih/color" "github.com/logrusorgru/aurora/v4" "github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter/renderer" "github.com/olekukonko/tablewriter/tw" + "github.com/openstatusHQ/cli/internal/api" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" "github.com/urfave/cli/v3" ) -func GetMonitorInfo(ctx context.Context, httpClient *http.Client, apiKey string, monitorId string, s *output.Spinner) error { +type MonitorInfoOutput struct { + Monitor Monitor `json:"monitor"` + Status string `json:"status,omitempty"` + Regions []RegionStatusOutput `json:"regions,omitempty"` + Summary *SummaryOutput `json:"summary,omitempty"` +} + +type RegionStatusOutput struct { + Region string `json:"region"` + Provider string `json:"provider"` + Status string `json:"status"` +} + +type SummaryOutput struct { + TotalSuccessful int64 `json:"total_successful"` + TotalDegraded int64 `json:"total_degraded"` + TotalFailed int64 `json:"total_failed"` + P50 int64 `json:"p50"` + P75 int64 `json:"p75"` + P90 int64 `json:"p90"` + P95 int64 `json:"p95"` + P99 int64 `json:"p99"` + TimeRange string `json:"time_range"` + LastPingAt string `json:"last_ping_at,omitempty"` +} + +func monitorStatusToString(s monitorv1.MonitorStatus) string { + switch s { + case monitorv1.MonitorStatus_MONITOR_STATUS_ACTIVE: + return "active" + case monitorv1.MonitorStatus_MONITOR_STATUS_DEGRADED: + return "degraded" + case monitorv1.MonitorStatus_MONITOR_STATUS_ERROR: + return "error" + default: + return "unknown" + } +} + +func colorizeStatus(status string) string { + switch status { + case "active": + return color.GreenString("● active") + case "degraded": + return color.YellowString("● degraded") + case "error": + return color.RedString("● error") + case "unknown": + return "● unknown" + default: + return status + } +} + +func parseTimeRange(s string) (monitorv1.TimeRange, error) { + switch s { + case "1d": + return monitorv1.TimeRange_TIME_RANGE_1D, nil + case "7d": + return monitorv1.TimeRange_TIME_RANGE_7D, nil + case "14d": + return monitorv1.TimeRange_TIME_RANGE_14D, nil + default: + return monitorv1.TimeRange_TIME_RANGE_UNSPECIFIED, fmt.Errorf("invalid time range %q: must be 1d, 7d, or 14d", s) + } +} + +func regionProviderLabel(r monitorv1.Region) string { + code := regionToString(r) + enumStr := r.String() + switch { + case strings.HasPrefix(enumStr, "REGION_FLY_"): + return fmt.Sprintf("%s (Fly.io)", code) + case strings.HasPrefix(enumStr, "REGION_KOYEB_"): + return fmt.Sprintf("%s (Koyeb)", code) + case strings.HasPrefix(enumStr, "REGION_RAILWAY_"): + return fmt.Sprintf("%s (Railway)", code) + default: + return code + } +} + +func regionProvider(r monitorv1.Region) string { + enumStr := r.String() + switch { + case strings.HasPrefix(enumStr, "REGION_FLY_"): + return "Fly.io" + case strings.HasPrefix(enumStr, "REGION_KOYEB_"): + return "Koyeb" + case strings.HasPrefix(enumStr, "REGION_RAILWAY_"): + return "Railway" + default: + return "Unknown" + } +} + +func deriveGlobalStatus(regions []*monitorv1.RegionStatus) string { + if len(regions) == 0 { + return "unknown" + } + hasError := false + hasDegraded := false + for _, r := range regions { + switch r.GetStatus() { + case monitorv1.MonitorStatus_MONITOR_STATUS_ERROR: + hasError = true + case monitorv1.MonitorStatus_MONITOR_STATUS_DEGRADED: + hasDegraded = true + } + } + if hasError { + return "error" + } + if hasDegraded { + return "degraded" + } + return "active" +} + +func newBlueprintTable() *tablewriter.Table { + return tablewriter.NewTable(os.Stdout, + tablewriter.WithRenderer(renderer.NewBlueprint()), + tablewriter.WithRendition(tw.Rendition{ + Symbols: tw.NewSymbolCustom("custom").WithColumn("="), + Borders: tw.Border{ + Top: tw.Off, + Left: tw.Off, + Right: tw.Off, + Bottom: tw.Off, + }, + Settings: tw.Settings{ + Lines: tw.Lines{ + ShowHeaderLine: tw.Off, + ShowFooterLine: tw.On, + }, + Separators: tw.Separators{ + BetweenRows: tw.Off, + BetweenColumns: tw.On, + }, + }, + }), + tablewriter.WithRowAlignment(tw.AlignLeft), + tablewriter.WithHeaderAlignment(tw.AlignLeft), + ) +} + +func GetMonitorInfo(ctx context.Context, httpClient *http.Client, apiKey string, monitorId string, timeRange monitorv1.TimeRange, timeRangeStr string, s *output.Spinner) error { if monitorId == "" { output.StopSpinner(s) @@ -63,42 +211,85 @@ func GetMonitorInfo(ctx context.Context, httpClient *http.Client, apiKey string, return fmt.Errorf("unknown monitor type for monitor ID: %s", monitorId) } + var ( + statusResp *monitorv1.GetMonitorStatusResponse + summaryResp *monitorv1.GetMonitorSummaryResponse + statusErr error + summaryErr error + wg sync.WaitGroup + ) + wg.Add(2) + go func() { + defer wg.Done() + statusResp, statusErr = client.GetMonitorStatus(ctx, &monitorv1.GetMonitorStatusRequest{Id: monitorId}) + }() + go func() { + defer wg.Done() + summaryResp, summaryErr = client.GetMonitorSummary(ctx, &monitorv1.GetMonitorSummaryRequest{ + Id: monitorId, + TimeRange: timeRange, + }) + }() + wg.Wait() + + if statusErr != nil { + fmt.Fprintln(os.Stderr, "Warning: could not fetch monitor status:", statusErr) + statusResp = nil + } + if summaryErr != nil { + fmt.Fprintln(os.Stderr, "Warning: could not fetch monitor summary:", summaryErr) + summaryResp = nil + } + + var globalStatus string + if statusResp != nil { + globalStatus = deriveGlobalStatus(statusResp.GetRegions()) + } + if output.IsJSONOutput() { - return output.PrintJSON(monitor) + infoOutput := MonitorInfoOutput{ + Monitor: monitor, + } + if statusResp != nil { + infoOutput.Status = globalStatus + for _, rs := range statusResp.GetRegions() { + infoOutput.Regions = append(infoOutput.Regions, RegionStatusOutput{ + Region: regionToString(rs.GetRegion()), + Provider: regionProvider(rs.GetRegion()), + Status: monitorStatusToString(rs.GetStatus()), + }) + } + } + if summaryResp != nil { + infoOutput.Summary = &SummaryOutput{ + TotalSuccessful: summaryResp.GetTotalSuccessful(), + TotalDegraded: summaryResp.GetTotalDegraded(), + TotalFailed: summaryResp.GetTotalFailed(), + P50: summaryResp.GetP50(), + P75: summaryResp.GetP75(), + P90: summaryResp.GetP90(), + P95: summaryResp.GetP95(), + P99: summaryResp.GetP99(), + TimeRange: timeRangeStr, + LastPingAt: summaryResp.GetLastPingAt(), + } + } + return output.PrintJSON(infoOutput) } + // Section 1: Monitor config table fmt.Println(aurora.Bold("Monitor:")) - table := tablewriter.NewTable(os.Stdout, - tablewriter.WithRenderer(renderer.NewBlueprint()), - tablewriter.WithRendition(tw.Rendition{ - Symbols: tw.NewSymbolCustom("custom").WithColumn("="), - Borders: tw.Border{ - Top: tw.Off, - Left: tw.Off, - Right: tw.Off, - Bottom: tw.Off, - }, - Settings: tw.Settings{ - Lines: tw.Lines{ - ShowHeaderLine: tw.Off, - ShowFooterLine: tw.On, - }, - Separators: tw.Separators{ - BetweenRows: tw.Off, - BetweenColumns: tw.On, - }, - }, - }), - tablewriter.WithRowAlignment(tw.AlignLeft), - tablewriter.WithHeaderAlignment(tw.AlignLeft), - ) + table := newBlueprintTable() data := [][]string{ {"ID", fmt.Sprintf("%d", monitor.ID)}, - {"Name", monitor.Name}, - {"Description", monitor.Description}, - {"Endpoint", monitor.URL}, } + if statusResp != nil { + data = append(data, []string{"Status", colorizeStatus(globalStatus)}) + } + data = append(data, []string{"Name", monitor.Name}) + data = append(data, []string{"Description", monitor.Description}) + data = append(data, []string{"Endpoint", monitor.URL}) if monitor.Method != "" { data = append(data, []string{"Method", monitor.Method}) } @@ -121,7 +312,7 @@ func GetMonitorInfo(ctx context.Context, httpClient *http.Client, apiKey string, data = append(data, []string{"Timeout", fmt.Sprintf("%d ms", monitor.Timeout)}) } if monitor.DegradedAfter > 0 { - data = append(data, []string{"Degraded After", fmt.Sprintf("%d", monitor.DegradedAfter)}) + data = append(data, []string{"Degraded After", fmt.Sprintf("%d ms", monitor.DegradedAfter)}) } if monitor.Body != "" { @@ -134,6 +325,61 @@ func GetMonitorInfo(ctx context.Context, httpClient *http.Client, apiKey string, table.Bulk(data) table.Render() + // Section 2: Assertions table + if len(monitor.Assertions) > 0 { + fmt.Println() + fmt.Println(aurora.Bold("Assertions:")) + assertionTable := newBlueprintTable() + var assertionData [][]string + for _, a := range monitor.Assertions { + row := []string{a.Type, a.Compare, fmt.Sprintf("%v", a.Target)} + if a.Key != "" { + row = append(row, a.Key) + } + assertionData = append(assertionData, row) + } + assertionTable.Bulk(assertionData) + assertionTable.Render() + } + + // Section 3: Region status table + if statusResp != nil && len(statusResp.GetRegions()) > 0 { + fmt.Println() + fmt.Println(aurora.Bold("Region Status:")) + regionTable := newBlueprintTable() + var regionData [][]string + for _, rs := range statusResp.GetRegions() { + regionData = append(regionData, []string{ + regionProviderLabel(rs.GetRegion()), + colorizeStatus(monitorStatusToString(rs.GetStatus())), + }) + } + regionTable.Bulk(regionData) + regionTable.Render() + } + + // Section 3: Summary table + if summaryResp != nil { + fmt.Println() + fmt.Println(aurora.Bold(fmt.Sprintf("Summary (%s):", timeRangeStr))) + summaryTable := newBlueprintTable() + summaryData := [][]string{ + {"Total Successful", fmt.Sprintf("%d", summaryResp.GetTotalSuccessful())}, + {"Total Degraded", fmt.Sprintf("%d", summaryResp.GetTotalDegraded())}, + {"Total Failed", fmt.Sprintf("%d", summaryResp.GetTotalFailed())}, + {"P50 Latency", fmt.Sprintf("%d ms", summaryResp.GetP50())}, + {"P75 Latency", fmt.Sprintf("%d ms", summaryResp.GetP75())}, + {"P90 Latency", fmt.Sprintf("%d ms", summaryResp.GetP90())}, + {"P95 Latency", fmt.Sprintf("%d ms", summaryResp.GetP95())}, + {"P99 Latency", fmt.Sprintf("%d ms", summaryResp.GetP99())}, + } + if summaryResp.GetLastPingAt() != "" { + summaryData = append(summaryData, []string{"Last Ping", summaryResp.GetLastPingAt()}) + } + summaryTable.Bulk(summaryData) + summaryTable.Render() + } + return nil } @@ -142,16 +388,22 @@ func GetMonitorInfoCmd() *cli.Command { Name: "info", Usage: "Get a monitor information", UsageText: `openstatus monitors info - openstatus monitors info 12345`, - Description: "Fetch the monitor information. The monitor information includes details such as name, description, endpoint, method, frequency, locations, active status, public status, timeout, degraded after, and body.", + openstatus monitors info 12345 + openstatus monitors info 12345 --time-range 7d`, + Description: "Fetch the monitor information including configuration, live status per region, and summary metrics.", Action: func(ctx context.Context, cmd *cli.Command) error { apiKey, err := auth.ResolveAccessToken(cmd) if err != nil { return cli.Exit(err.Error(), 1) } monitorId := cmd.Args().Get(0) + timeRangeStr := cmd.String("time-range") + timeRange, err := parseTimeRange(timeRangeStr) + if err != nil { + return cli.Exit(err.Error(), 1) + } s := output.StartSpinner("Fetching monitor details...") - err = GetMonitorInfo(ctx, api.DefaultHTTPClient, apiKey, monitorId, s) + err = GetMonitorInfo(ctx, api.DefaultHTTPClient, apiKey, monitorId, timeRange, timeRangeStr, s) if err != nil { return cli.Exit(err.Error(), 1) } @@ -163,7 +415,13 @@ func GetMonitorInfoCmd() *cli.Command { Usage: "OpenStatus API Access Token", Aliases: []string{"t"}, Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), - }}, + }, + &cli.StringFlag{ + Name: "time-range", + Usage: "Time range for summary metrics (1d, 7d, 14d)", + Value: "1d", + }, + }, } return &monitorInfoCmd } diff --git a/internal/monitors/monitor_info_test.go b/internal/monitors/monitor_info_test.go index e4522aa..ee76edb 100644 --- a/internal/monitors/monitor_info_test.go +++ b/internal/monitors/monitor_info_test.go @@ -3,20 +3,48 @@ package monitors_test import ( "bytes" "context" + "fmt" "io" "log" "net/http" "os" + "strings" "testing" + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" "github.com/openstatusHQ/cli/internal/monitors" ) +type routeEntry struct { + suffix string + body string +} + +func monitorInfoInterceptor(routes []routeEntry) *interceptorHTTPClient { + return &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + for _, r := range routes { + if strings.HasSuffix(req.URL.Path, r.suffix) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(r.body))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + } + } + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewReader([]byte(`{"code":"internal","message":"unexpected call"}`))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }, + } +} + func Test_getMonitorInfo(t *testing.T) { t.Parallel() t.Run("Monitor ID is required", func(t *testing.T) { - interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { return &http.Response{ @@ -30,24 +58,91 @@ func Test_getMonitorInfo(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := monitors.GetMonitorInfo(context.Background(), interceptor.GetHTTPClient(), "", "", nil) + err := monitors.GetMonitorInfo(context.Background(), interceptor.GetHTTPClient(), "", "", monitorv1.TimeRange_TIME_RANGE_1D, "1d", nil) if err == nil { - t.Errorf("Expected log output, got nothing") + t.Errorf("Expected error, got nil") + } + }) + + t.Run("Should work with all three RPCs", func(t *testing.T) { + interceptor := monitorInfoInterceptor([]routeEntry{ + {"GetMonitor", `{"monitor":{"http":{"id":"2260","name":"Vercel Checker Edge","description":"","url":"https://www.openstatus.dev","periodicity":"PERIODICITY_10M","method":"HTTP_METHOD_GET","regions":["REGION_FLY_IAD","REGION_FLY_JNB","REGION_FLY_SYD","REGION_FLY_GRU"],"active":false,"public":false,"timeout":45000}}}`}, + {"GetMonitorStatus", `{"id":"2260","regions":[{"region":"REGION_FLY_IAD","status":"MONITOR_STATUS_ACTIVE"},{"region":"REGION_FLY_JNB","status":"MONITOR_STATUS_ERROR"}]}`}, + {"GetMonitorSummary", `{"id":"2260","totalSuccessful":"1420","totalDegraded":"3","totalFailed":"1","p50":"120","p75":"180","p90":"250","p95":"300","p99":"500","timeRange":"TIME_RANGE_1D","lastPingAt":"2026-03-23T10:00:00Z"}`}, + }) + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + err := monitors.GetMonitorInfo(context.Background(), interceptor.GetHTTPClient(), "test", "2260", monitorv1.TimeRange_TIME_RANGE_1D, "1d", nil) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Should populate all fields including degradedAt, body, headers, and assertions", func(t *testing.T) { + interceptor := monitorInfoInterceptor([]routeEntry{ + {"GetMonitor", `{"monitor":{"http":{"id":"2260","name":"Full Monitor","description":"test desc","url":"https://www.openstatus.dev","periodicity":"PERIODICITY_10M","method":"HTTP_METHOD_POST","regions":["REGION_FLY_IAD"],"active":true,"public":true,"timeout":45000,"degradedAt":"30000","body":"{\"key\":\"value\"}","headers":[{"key":"Content-Type","value":"application/json"},{"key":"Authorization","value":"Bearer token"}],"statusCodeAssertions":[{"target":200,"comparator":"NUMBER_COMPARATOR_EQUAL"}],"bodyAssertions":[{"target":"ok","comparator":"STRING_COMPARATOR_CONTAINS"}],"headerAssertions":[{"key":"x-custom","target":"expected","comparator":"STRING_COMPARATOR_EQUAL"}]}}}`}, + {"GetMonitorStatus", `{"id":"2260","regions":[{"region":"REGION_FLY_IAD","status":"MONITOR_STATUS_ACTIVE"}]}`}, + {"GetMonitorSummary", `{"id":"2260","totalSuccessful":"100","totalDegraded":"0","totalFailed":"0","p50":"50","p75":"75","p90":"90","p95":"95","p99":"99","timeRange":"TIME_RANGE_1D"}`}, + }) + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + err := monitors.GetMonitorInfo(context.Background(), interceptor.GetHTTPClient(), "test", "2260", monitorv1.TimeRange_TIME_RANGE_1D, "1d", nil) + if err != nil { + t.Errorf("Expected no error, got %v", err) } }) - t.Run("Should work", func(t *testing.T) { - // Connect RPC response format with MonitorConfig oneof - body := `{"monitor":{"http":{"id":"2260","name":"Vercel Checker Edge","description":"","url":"https://www.openstatus.dev","periodicity":"PERIODICITY_10M","method":"HTTP_METHOD_GET","regions":["REGION_FLY_IAD","REGION_FLY_JNB","REGION_FLY_SYD","REGION_FLY_GRU"],"active":false,"public":false,"timeout":45000}}}` - r := io.NopCloser(bytes.NewReader([]byte(body))) + t.Run("Should work with TCP monitor and degradedAt", func(t *testing.T) { + interceptor := monitorInfoInterceptor([]routeEntry{ + {"GetMonitor", `{"monitor":{"tcp":{"id":"3001","name":"TCP Check","description":"tcp test","uri":"example.com:443","periodicity":"PERIODICITY_5M","regions":["REGION_FLY_IAD"],"active":true,"public":false,"timeout":10000,"degradedAt":"5000"}}}`}, + {"GetMonitorStatus", `{"id":"3001","regions":[{"region":"REGION_FLY_IAD","status":"MONITOR_STATUS_ACTIVE"}]}`}, + {"GetMonitorSummary", `{"id":"3001","totalSuccessful":"500","totalDegraded":"0","totalFailed":"0","p50":"20","p75":"30","p90":"40","p95":"50","p99":"60","timeRange":"TIME_RANGE_1D"}`}, + }) + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + err := monitors.GetMonitorInfo(context.Background(), interceptor.GetHTTPClient(), "test", "3001", monitorv1.TimeRange_TIME_RANGE_1D, "1d", nil) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Should gracefully degrade when status RPC fails", func(t *testing.T) { interceptor := &interceptorHTTPClient{ f: func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Body: r, - Header: http.Header{"Content-Type": []string{"application/json"}}, - }, nil + if strings.HasSuffix(req.URL.Path, "GetMonitor") { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(`{"monitor":{"http":{"id":"2260","name":"Test","description":"","url":"https://example.com","periodicity":"PERIODICITY_1M","method":"HTTP_METHOD_GET","regions":["REGION_FLY_IAD"],"active":true,"public":false,"timeout":30000}}}`))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + } + if strings.HasSuffix(req.URL.Path, "GetMonitorStatus") { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewReader([]byte(`{"code":"internal","message":"status unavailable"}`))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + } + if strings.HasSuffix(req.URL.Path, "GetMonitorSummary") { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(`{"id":"2260","totalSuccessful":"10","totalDegraded":"0","totalFailed":"0","p50":"50","p75":"75","p90":"90","p95":"95","p99":"99","timeRange":"TIME_RANGE_1D"}`))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) }, } @@ -56,10 +151,49 @@ func Test_getMonitorInfo(t *testing.T) { t.Cleanup(func() { log.SetOutput(os.Stdout) }) - err := monitors.GetMonitorInfo(context.Background(), interceptor.GetHTTPClient(), "test", "1", nil) + err := monitors.GetMonitorInfo(context.Background(), interceptor.GetHTTPClient(), "test", "2260", monitorv1.TimeRange_TIME_RANGE_1D, "1d", nil) if err != nil { - t.Errorf("Expected no error, got %v", err) + t.Errorf("Expected no error (graceful degradation), got %v", err) } }) + t.Run("Should gracefully degrade when summary RPC fails", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + if strings.HasSuffix(req.URL.Path, "GetMonitor") { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(`{"monitor":{"http":{"id":"2260","name":"Test","description":"","url":"https://example.com","periodicity":"PERIODICITY_1M","method":"HTTP_METHOD_GET","regions":["REGION_FLY_IAD"],"active":true,"public":false,"timeout":30000}}}`))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + } + if strings.HasSuffix(req.URL.Path, "GetMonitorStatus") { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(`{"id":"2260","regions":[{"region":"REGION_FLY_IAD","status":"MONITOR_STATUS_ACTIVE"}]}`))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + } + if strings.HasSuffix(req.URL.Path, "GetMonitorSummary") { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewReader([]byte(`{"code":"internal","message":"summary unavailable"}`))), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL.Path) + }, + } + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + err := monitors.GetMonitorInfo(context.Background(), interceptor.GetHTTPClient(), "test", "2260", monitorv1.TimeRange_TIME_RANGE_1D, "1d", nil) + if err != nil { + t.Errorf("Expected no error (graceful degradation), got %v", err) + } + }) } +