diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7d583695b66c4..93bccd78084d7 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12115,6 +12115,12 @@ const docTemplate = `{ }, "uses_websocket": { "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -12135,6 +12141,12 @@ const docTemplate = `{ }, "region": { "$ref": "#/definitions/tailcfg.DERPRegion" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -12164,6 +12176,12 @@ const docTemplate = `{ "additionalProperties": { "$ref": "#/definitions/derphealth.RegionReport" } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -12201,6 +12219,12 @@ const docTemplate = `{ }, "status_code": { "type": "integer" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -12224,6 +12248,12 @@ const docTemplate = `{ }, "threshold_ms": { "type": "integer" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -12277,6 +12307,12 @@ const docTemplate = `{ }, "healthy": { "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 550a90c6805d7..5c59d8048b32a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11036,6 +11036,12 @@ }, "uses_websocket": { "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -11056,6 +11062,12 @@ }, "region": { "$ref": "#/definitions/tailcfg.DERPRegion" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -11085,6 +11097,12 @@ "additionalProperties": { "$ref": "#/definitions/derphealth.RegionReport" } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -11122,6 +11140,12 @@ }, "status_code": { "type": "integer" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -11145,6 +11169,12 @@ }, "threshold_ms": { "type": "integer" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -11198,6 +11228,12 @@ }, "healthy": { "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } } } }, diff --git a/coderd/healthcheck/accessurl.go b/coderd/healthcheck/accessurl.go index 6f86944b7ca4e..10d2d936fb712 100644 --- a/coderd/healthcheck/accessurl.go +++ b/coderd/healthcheck/accessurl.go @@ -14,8 +14,10 @@ import ( // @typescript-generate AccessURLReport type AccessURLReport struct { + Healthy bool `json:"healthy"` + Warnings []string `json:"warnings"` + AccessURL string `json:"access_url"` - Healthy bool `json:"healthy"` Reachable bool `json:"reachable"` StatusCode int `json:"status_code"` HealthzResponse string `json:"healthz_response"` diff --git a/coderd/healthcheck/database.go b/coderd/healthcheck/database.go index 9ee92d7a71a9f..9a8726a484361 100644 --- a/coderd/healthcheck/database.go +++ b/coderd/healthcheck/database.go @@ -16,7 +16,9 @@ const ( // @typescript-generate DatabaseReport type DatabaseReport struct { - Healthy bool `json:"healthy"` + Healthy bool `json:"healthy"` + Warnings []string `json:"warnings"` + Reachable bool `json:"reachable"` Latency string `json:"latency"` LatencyMS int64 `json:"latency_ms"` diff --git a/coderd/healthcheck/derphealth/derp.go b/coderd/healthcheck/derphealth/derp.go index 2570b9fcb10f0..94cc89307d565 100644 --- a/coderd/healthcheck/derphealth/derp.go +++ b/coderd/healthcheck/derphealth/derp.go @@ -24,9 +24,14 @@ import ( "github.com/coder/coder/v2/coderd/util/ptr" ) +const ( + warningNodeUsesWebsocket = `Node uses WebSockets because the "Upgrade: DERP" header may be blocked on the load balancer.` +) + // @typescript-generate Report type Report struct { - Healthy bool `json:"healthy"` + Healthy bool `json:"healthy"` + Warnings []string `json:"warnings"` Regions map[int]*RegionReport `json:"regions"` @@ -39,8 +44,9 @@ type Report struct { // @typescript-generate RegionReport type RegionReport struct { - mu sync.Mutex - Healthy bool `json:"healthy"` + mu sync.Mutex + Healthy bool `json:"healthy"` + Warnings []string `json:"warnings"` Region *tailcfg.DERPRegion `json:"region"` NodeReports []*NodeReport `json:"node_reports"` @@ -52,8 +58,10 @@ type NodeReport struct { mu sync.Mutex clientCounter int - Healthy bool `json:"healthy"` - Node *tailcfg.DERPNode `json:"node"` + Healthy bool `json:"healthy"` + Warnings []string `json:"warnings"` + + Node *tailcfg.DERPNode `json:"node"` ServerInfo derp.ServerInfoMessage `json:"node_info"` CanExchangeMessages bool `json:"can_exchange_messages"` @@ -108,6 +116,10 @@ func (r *Report) Run(ctx context.Context, opts *ReportOptions) { if !regionReport.Healthy { r.Healthy = false } + + for _, w := range regionReport.Warnings { + r.Warnings = append(r.Warnings, fmt.Sprintf("[%s] %s", regionReport.Region.RegionName, w)) + } mu.Unlock() }() } @@ -159,6 +171,10 @@ func (r *RegionReport) Run(ctx context.Context) { if !nodeReport.Healthy { r.Healthy = false } + + for _, w := range nodeReport.Warnings { + r.Warnings = append(r.Warnings, fmt.Sprintf("[%s] %s", nodeReport.Node.Name, w)) + } r.mu.Unlock() }() } @@ -208,14 +224,14 @@ func (r *NodeReport) Run(ctx context.Context) { // We can't exchange messages with the node, if (!r.CanExchangeMessages && !r.Node.STUNOnly) || - // A node may use websockets because `Upgrade: DERP` may be blocked on - // the load balancer. This is unhealthy because websockets are slower - // than the regular DERP protocol. - r.UsesWebsocket || // The node was marked as STUN compatible but the STUN test failed. r.STUN.Error != nil { r.Healthy = false } + + if r.UsesWebsocket { + r.Warnings = append(r.Warnings, warningNodeUsesWebsocket) + } } func (r *NodeReport) doExchangeMessage(ctx context.Context) { diff --git a/coderd/healthcheck/derphealth/derp_test.go b/coderd/healthcheck/derphealth/derp_test.go index 5b72007150ba4..c9ed3d591557e 100644 --- a/coderd/healthcheck/derphealth/derp_test.go +++ b/coderd/healthcheck/derphealth/derp_test.go @@ -170,11 +170,14 @@ func TestDERP(t *testing.T) { report.Run(ctx, opts) - assert.False(t, report.Healthy) + assert.True(t, report.Healthy) + assert.NotEmpty(t, report.Warnings) for _, region := range report.Regions { - assert.False(t, region.Healthy) + assert.True(t, region.Healthy) + assert.NotEmpty(t, region.Warnings) for _, node := range region.NodeReports { - assert.False(t, node.Healthy) + assert.True(t, node.Healthy) + assert.NotEmpty(t, node.Warnings) assert.True(t, node.CanExchangeMessages) assert.NotEmpty(t, node.RoundTripPing) assert.Len(t, node.ClientLogs, 2) diff --git a/coderd/healthcheck/healthcheck_test.go b/coderd/healthcheck/healthcheck_test.go index f89f12116dc88..1a1840a37d581 100644 --- a/coderd/healthcheck/healthcheck_test.go +++ b/coderd/healthcheck/healthcheck_test.go @@ -77,6 +77,25 @@ func TestHealthcheck(t *testing.T) { }, healthy: false, failingSections: []string{healthcheck.SectionDERP}, + }, { + name: "DERPWarning", + checker: &testChecker{ + DERPReport: derphealth.Report{ + Healthy: true, + Warnings: []string{"foobar"}, + }, + AccessURLReport: healthcheck.AccessURLReport{ + Healthy: true, + }, + WebsocketReport: healthcheck.WebsocketReport{ + Healthy: true, + }, + DatabaseReport: healthcheck.DatabaseReport{ + Healthy: true, + }, + }, + healthy: true, + failingSections: nil, }, { name: "AccessURLFail", checker: &testChecker{ @@ -153,6 +172,7 @@ func TestHealthcheck(t *testing.T) { assert.Equal(t, c.healthy, report.Healthy) assert.Equal(t, c.failingSections, report.FailingSections) assert.Equal(t, c.checker.DERPReport.Healthy, report.DERP.Healthy) + assert.Equal(t, c.checker.DERPReport.Warnings, report.DERP.Warnings) assert.Equal(t, c.checker.AccessURLReport.Healthy, report.AccessURL.Healthy) assert.Equal(t, c.checker.WebsocketReport.Healthy, report.Websocket.Healthy) assert.NotZero(t, report.Time) diff --git a/coderd/healthcheck/websocket.go b/coderd/healthcheck/websocket.go index 0b4a56e2d5ca9..5723cf48bc9a9 100644 --- a/coderd/healthcheck/websocket.go +++ b/coderd/healthcheck/websocket.go @@ -21,10 +21,12 @@ type WebsocketReportOptions struct { // @typescript-generate WebsocketReport type WebsocketReport struct { - Healthy bool `json:"healthy"` - Body string `json:"body"` - Code int `json:"code"` - Error *string `json:"error"` + Healthy bool `json:"healthy"` + Warnings []string `json:"warnings"` + + Body string `json:"body"` + Code int `json:"code"` + Error *string `json:"error"` } func (r *WebsocketReport) Run(ctx context.Context, opts *WebsocketReportOptions) { diff --git a/docs/api/debug.md b/docs/api/debug.md index afa42728a1df6..521f6b766c7de 100644 --- a/docs/api/debug.md +++ b/docs/api/debug.md @@ -51,7 +51,8 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "healthy": true, "healthz_response": "string", "reachable": true, - "status_code": 0 + "status_code": 0, + "warnings": ["string"] }, "coder_version": "string", "database": { @@ -60,7 +61,8 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "latency": "string", "latency_ms": 0, "reachable": true, - "threshold_ms": 0 + "threshold_ms": 0, + "warnings": ["string"] }, "derp": { "error": "string", @@ -134,7 +136,8 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "enabled": true, "error": "string" }, - "uses_websocket": true + "uses_websocket": true, + "warnings": ["string"] } ], "region": { @@ -160,7 +163,8 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "regionCode": "string", "regionID": 0, "regionName": "string" - } + }, + "warnings": ["string"] }, "property2": { "error": "string", @@ -198,7 +202,8 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "enabled": true, "error": "string" }, - "uses_websocket": true + "uses_websocket": true, + "warnings": ["string"] } ], "region": { @@ -224,9 +229,11 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "regionCode": "string", "regionID": 0, "regionName": "string" - } + }, + "warnings": ["string"] } - } + }, + "warnings": ["string"] }, "failing_sections": ["string"], "healthy": true, @@ -235,7 +242,8 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "body": "string", "code": 0, "error": "string", - "healthy": true + "healthy": true, + "warnings": ["string"] } } ``` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index d3a61585d096c..f195d7f57a330 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -7138,7 +7138,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "enabled": true, "error": "string" }, - "uses_websocket": true + "uses_websocket": true, + "warnings": ["string"] } ``` @@ -7157,6 +7158,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `round_trip_ping_ms` | integer | false | | | | `stun` | [derphealth.StunReport](#derphealthstunreport) | false | | | | `uses_websocket` | boolean | false | | | +| `warnings` | array of string | false | | | ## derphealth.RegionReport @@ -7197,7 +7199,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "enabled": true, "error": "string" }, - "uses_websocket": true + "uses_websocket": true, + "warnings": ["string"] } ], "region": { @@ -7223,7 +7226,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "regionCode": "string", "regionID": 0, "regionName": "string" - } + }, + "warnings": ["string"] } ``` @@ -7235,6 +7239,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `healthy` | boolean | false | | | | `node_reports` | array of [derphealth.NodeReport](#derphealthnodereport) | false | | | | `region` | [tailcfg.DERPRegion](#tailcfgderpregion) | false | | | +| `warnings` | array of string | false | | | ## derphealth.Report @@ -7311,7 +7316,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "enabled": true, "error": "string" }, - "uses_websocket": true + "uses_websocket": true, + "warnings": ["string"] } ], "region": { @@ -7337,7 +7343,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "regionCode": "string", "regionID": 0, "regionName": "string" - } + }, + "warnings": ["string"] }, "property2": { "error": "string", @@ -7375,7 +7382,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "enabled": true, "error": "string" }, - "uses_websocket": true + "uses_websocket": true, + "warnings": ["string"] } ], "region": { @@ -7401,9 +7409,11 @@ If the schedule is empty, the user will be updated to use the default schedule.| "regionCode": "string", "regionID": 0, "regionName": "string" - } + }, + "warnings": ["string"] } - } + }, + "warnings": ["string"] } ``` @@ -7418,6 +7428,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `netcheck_logs` | array of string | false | | | | `regions` | object | false | | | | ยป `[any property]` | [derphealth.RegionReport](#derphealthregionreport) | false | | | +| `warnings` | array of string | false | | | ## derphealth.StunReport @@ -7446,20 +7457,22 @@ If the schedule is empty, the user will be updated to use the default schedule.| "healthy": true, "healthz_response": "string", "reachable": true, - "status_code": 0 + "status_code": 0, + "warnings": ["string"] } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | ------- | -------- | ------------ | ----------- | -| `access_url` | string | false | | | -| `error` | string | false | | | -| `healthy` | boolean | false | | | -| `healthz_response` | string | false | | | -| `reachable` | boolean | false | | | -| `status_code` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------ | --------------- | -------- | ------------ | ----------- | +| `access_url` | string | false | | | +| `error` | string | false | | | +| `healthy` | boolean | false | | | +| `healthz_response` | string | false | | | +| `reachable` | boolean | false | | | +| `status_code` | integer | false | | | +| `warnings` | array of string | false | | | ## healthcheck.DatabaseReport @@ -7470,20 +7483,22 @@ If the schedule is empty, the user will be updated to use the default schedule.| "latency": "string", "latency_ms": 0, "reachable": true, - "threshold_ms": 0 + "threshold_ms": 0, + "warnings": ["string"] } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| -------------- | ------- | -------- | ------------ | ----------- | -| `error` | string | false | | | -| `healthy` | boolean | false | | | -| `latency` | string | false | | | -| `latency_ms` | integer | false | | | -| `reachable` | boolean | false | | | -| `threshold_ms` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +| -------------- | --------------- | -------- | ------------ | ----------- | +| `error` | string | false | | | +| `healthy` | boolean | false | | | +| `latency` | string | false | | | +| `latency_ms` | integer | false | | | +| `reachable` | boolean | false | | | +| `threshold_ms` | integer | false | | | +| `warnings` | array of string | false | | | ## healthcheck.Report @@ -7495,7 +7510,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "healthy": true, "healthz_response": "string", "reachable": true, - "status_code": 0 + "status_code": 0, + "warnings": ["string"] }, "coder_version": "string", "database": { @@ -7504,7 +7520,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "latency": "string", "latency_ms": 0, "reachable": true, - "threshold_ms": 0 + "threshold_ms": 0, + "warnings": ["string"] }, "derp": { "error": "string", @@ -7578,7 +7595,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "enabled": true, "error": "string" }, - "uses_websocket": true + "uses_websocket": true, + "warnings": ["string"] } ], "region": { @@ -7604,7 +7622,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "regionCode": "string", "regionID": 0, "regionName": "string" - } + }, + "warnings": ["string"] }, "property2": { "error": "string", @@ -7642,7 +7661,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "enabled": true, "error": "string" }, - "uses_websocket": true + "uses_websocket": true, + "warnings": ["string"] } ], "region": { @@ -7668,9 +7688,11 @@ If the schedule is empty, the user will be updated to use the default schedule.| "regionCode": "string", "regionID": 0, "regionName": "string" - } + }, + "warnings": ["string"] } - } + }, + "warnings": ["string"] }, "failing_sections": ["string"], "healthy": true, @@ -7679,7 +7701,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "body": "string", "code": 0, "error": "string", - "healthy": true + "healthy": true, + "warnings": ["string"] } } ``` @@ -7704,18 +7727,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| "body": "string", "code": 0, "error": "string", - "healthy": true + "healthy": true, + "warnings": ["string"] } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| --------- | ------- | -------- | ------------ | ----------- | -| `body` | string | false | | | -| `code` | integer | false | | | -| `error` | string | false | | | -| `healthy` | boolean | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------- | --------------- | -------- | ------------ | ----------- | +| `body` | string | false | | | +| `code` | integer | false | | | +| `error` | string | false | | | +| `healthy` | boolean | false | | | +| `warnings` | array of string | false | | | ## netcheck.Report diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 032c3854138dd..93c3dbdddab57 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2081,8 +2081,9 @@ export type RegionTypes = Region | WorkspaceProxy; // From healthcheck/accessurl.go export interface HealthcheckAccessURLReport { - readonly access_url: string; readonly healthy: boolean; + readonly warnings: string[]; + readonly access_url: string; readonly reachable: boolean; readonly status_code: number; readonly healthz_response: string; @@ -2092,6 +2093,7 @@ export interface HealthcheckAccessURLReport { // From healthcheck/database.go export interface HealthcheckDatabaseReport { readonly healthy: boolean; + readonly warnings: string[]; readonly reachable: boolean; readonly latency: string; readonly latency_ms: number; @@ -2114,6 +2116,7 @@ export interface HealthcheckReport { // From healthcheck/websocket.go export interface HealthcheckWebsocketReport { readonly healthy: boolean; + readonly warnings: string[]; readonly body: string; readonly code: number; readonly error?: string; @@ -2169,6 +2172,7 @@ export const ClibaseValueSources: ClibaseValueSource[] = [ // From derphealth/derp.go export interface DerphealthNodeReport { readonly healthy: boolean; + readonly warnings: string[]; // Named type "tailscale.com/tailcfg.DERPNode" unknown, using "any" // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type readonly node?: any; @@ -2188,6 +2192,7 @@ export interface DerphealthNodeReport { // From derphealth/derp.go export interface DerphealthRegionReport { readonly healthy: boolean; + readonly warnings: string[]; // Named type "tailscale.com/tailcfg.DERPRegion" unknown, using "any" // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type readonly region?: any; @@ -2198,6 +2203,7 @@ export interface DerphealthRegionReport { // From derphealth/derp.go export interface DerphealthReport { readonly healthy: boolean; + readonly warnings: string[]; readonly regions: Record; // Named type "tailscale.com/net/netcheck.Report" unknown, using "any" // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type diff --git a/site/src/pages/HealthPage/HealthPage.stories.tsx b/site/src/pages/HealthPage/HealthPage.stories.tsx index f0a88cc5b4518..92887fb9e3a94 100644 --- a/site/src/pages/HealthPage/HealthPage.stories.tsx +++ b/site/src/pages/HealthPage/HealthPage.stories.tsx @@ -31,3 +31,15 @@ export const UnhealthyDERP: Story = { }, }, }; + +export const DERPWarnings: Story = { + args: { + healthStatus: { + ...MockHealth, + derp: { + ...MockHealth.derp, + warnings: ["foobar"], + }, + }, + }, +}; diff --git a/site/src/pages/HealthPage/HealthPage.tsx b/site/src/pages/HealthPage/HealthPage.tsx index 70ddd62185a72..c1692f7dd572f 100644 --- a/site/src/pages/HealthPage/HealthPage.tsx +++ b/site/src/pages/HealthPage/HealthPage.tsx @@ -85,7 +85,13 @@ export function HealthPageView({ {healthStatus.healthy - ? "All systems operational" + ? Object.keys(sections).some( + (key) => + healthStatus[key as keyof typeof sections]?.warnings + .length > 0, + ) + ? "All systems operational, but performance might be degraded" + : "All systems operational" : "Some issues have been detected"} @@ -137,9 +143,10 @@ export function HealthPageView({ .map((key) => { const label = sections[key as keyof typeof sections]; const isActive = tab.value === key; - const isHealthy = - healthStatus[key as keyof typeof sections].healthy; - + const healthSection = + healthStatus[key as keyof typeof sections]; + const isHealthy = healthSection.healthy; + const isWarning = healthSection.warnings.length > 0; return ( {isHealthy ? ( - theme.palette.success.light, - }} - /> + isWarning ? ( + theme.palette.warning.main, + }} + /> + ) : ( + theme.palette.success.light, + }} + /> + ) ) : (