diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 34e8f928e5daf..515db0c94e2f2 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -12107,6 +12107,9 @@ const docTemplate = `{ }, "uses_websocket": { "type": "boolean" + }, + "warning": { + "type": "boolean" } } }, @@ -12127,6 +12130,9 @@ const docTemplate = `{ }, "region": { "$ref": "#/definitions/tailcfg.DERPRegion" + }, + "warning": { + "type": "boolean" } } }, @@ -12156,6 +12162,9 @@ const docTemplate = `{ "additionalProperties": { "$ref": "#/definitions/derphealth.RegionReport" } + }, + "warning": { + "type": "boolean" } } }, @@ -12250,6 +12259,10 @@ const docTemplate = `{ "description": "Time is the time the report was generated at.", "type": "string" }, + "warning": { + "description": "Warning is true when Coder is operational but its performance might be impacted.", + "type": "boolean" + }, "websocket": { "$ref": "#/definitions/healthcheck.WebsocketReport" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7b82ad33bc58d..14a81a23cf708 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11028,6 +11028,9 @@ }, "uses_websocket": { "type": "boolean" + }, + "warning": { + "type": "boolean" } } }, @@ -11048,6 +11051,9 @@ }, "region": { "$ref": "#/definitions/tailcfg.DERPRegion" + }, + "warning": { + "type": "boolean" } } }, @@ -11077,6 +11083,9 @@ "additionalProperties": { "$ref": "#/definitions/derphealth.RegionReport" } + }, + "warning": { + "type": "boolean" } } }, @@ -11171,6 +11180,10 @@ "description": "Time is the time the report was generated at.", "type": "string" }, + "warning": { + "description": "Warning is true when Coder is operational but its performance might be impacted.", + "type": "boolean" + }, "websocket": { "$ref": "#/definitions/healthcheck.WebsocketReport" } diff --git a/coderd/healthcheck/derphealth/derp.go b/coderd/healthcheck/derphealth/derp.go index 2570b9fcb10f0..2cbe90b330b77 100644 --- a/coderd/healthcheck/derphealth/derp.go +++ b/coderd/healthcheck/derphealth/derp.go @@ -27,6 +27,7 @@ import ( // @typescript-generate Report type Report struct { Healthy bool `json:"healthy"` + Warning bool `json:"warning"` Regions map[int]*RegionReport `json:"regions"` @@ -41,6 +42,7 @@ type Report struct { type RegionReport struct { mu sync.Mutex Healthy bool `json:"healthy"` + Warning bool `json:"warning"` Region *tailcfg.DERPRegion `json:"region"` NodeReports []*NodeReport `json:"node_reports"` @@ -53,6 +55,7 @@ type NodeReport struct { clientCounter int Healthy bool `json:"healthy"` + Warning bool `json:"warning"` Node *tailcfg.DERPNode `json:"node"` ServerInfo derp.ServerInfoMessage `json:"node_info"` @@ -108,6 +111,9 @@ func (r *Report) Run(ctx context.Context, opts *ReportOptions) { if !regionReport.Healthy { r.Healthy = false } + if regionReport.Warning { + r.Warning = true + } mu.Unlock() }() } @@ -159,6 +165,9 @@ func (r *RegionReport) Run(ctx context.Context) { if !nodeReport.Healthy { r.Healthy = false } + if nodeReport.Warning { + r.Warning = true + } r.mu.Unlock() }() } @@ -208,14 +217,15 @@ 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 } + + // 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.Warning = r.UsesWebsocket } 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..80c7e8f72b321 100644 --- a/coderd/healthcheck/derphealth/derp_test.go +++ b/coderd/healthcheck/derphealth/derp_test.go @@ -174,7 +174,7 @@ func TestDERP(t *testing.T) { for _, region := range report.Regions { assert.False(t, region.Healthy) for _, node := range region.NodeReports { - assert.False(t, node.Healthy) + assert.True(t, node.Healthy) assert.True(t, node.CanExchangeMessages) assert.NotEmpty(t, node.RoundTripPing) assert.Len(t, node.ClientLogs, 2) diff --git a/coderd/healthcheck/healthcheck.go b/coderd/healthcheck/healthcheck.go index d59de08592203..5c19ef8e79bae 100644 --- a/coderd/healthcheck/healthcheck.go +++ b/coderd/healthcheck/healthcheck.go @@ -31,6 +31,8 @@ type Report struct { Time time.Time `json:"time"` // Healthy is true if the report returns no errors. Healthy bool `json:"healthy"` + // Warning is true when Coder is operational but its performance might be impacted. + Warning bool `json:"warning"` // FailingSections is a list of sections that have failed their healthcheck. FailingSections []string `json:"failing_sections"` @@ -139,6 +141,9 @@ func Run(ctx context.Context, opts *ReportOptions) *Report { if !report.DERP.Healthy { report.FailingSections = append(report.FailingSections, SectionDERP) } + if report.DERP.Warning { + report.Warning = true + } if !report.AccessURL.Healthy { report.FailingSections = append(report.FailingSections, SectionAccessURL) } diff --git a/coderd/healthcheck/healthcheck_test.go b/coderd/healthcheck/healthcheck_test.go index f89f12116dc88..207de0977a916 100644 --- a/coderd/healthcheck/healthcheck_test.go +++ b/coderd/healthcheck/healthcheck_test.go @@ -40,6 +40,7 @@ func TestHealthcheck(t *testing.T) { name string checker *testChecker healthy bool + warning bool failingSections []string }{{ name: "OK", @@ -77,6 +78,26 @@ func TestHealthcheck(t *testing.T) { }, healthy: false, failingSections: []string{healthcheck.SectionDERP}, + }, { + name: "DERPWarning", + checker: &testChecker{ + DERPReport: derphealth.Report{ + Healthy: true, + Warning: true, + }, + AccessURLReport: healthcheck.AccessURLReport{ + Healthy: true, + }, + WebsocketReport: healthcheck.WebsocketReport{ + Healthy: true, + }, + DatabaseReport: healthcheck.DatabaseReport{ + Healthy: true, + }, + }, + healthy: true, + warning: true, + failingSections: nil, }, { name: "AccessURLFail", checker: &testChecker{ @@ -151,8 +172,10 @@ func TestHealthcheck(t *testing.T) { }) assert.Equal(t, c.healthy, report.Healthy) + assert.Equal(t, c.warning, report.Warning) assert.Equal(t, c.failingSections, report.FailingSections) assert.Equal(t, c.checker.DERPReport.Healthy, report.DERP.Healthy) + assert.Equal(t, c.checker.DERPReport.Warning, report.DERP.Warning) 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/docs/api/debug.md b/docs/api/debug.md index e5d0c0cd505de..68d9644f05a4a 100644 --- a/docs/api/debug.md +++ b/docs/api/debug.md @@ -128,7 +128,8 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "enabled": true, "error": "string" }, - "uses_websocket": true + "uses_websocket": true, + "warning": true } ], "region": { @@ -154,7 +155,8 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "regionCode": "string", "regionID": 0, "regionName": "string" - } + }, + "warning": true }, "property2": { "error": "string", @@ -192,7 +194,8 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "enabled": true, "error": "string" }, - "uses_websocket": true + "uses_websocket": true, + "warning": true } ], "region": { @@ -218,13 +221,16 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \ "regionCode": "string", "regionID": 0, "regionName": "string" - } + }, + "warning": true } - } + }, + "warning": true }, "failing_sections": ["string"], "healthy": true, "time": "string", + "warning": true, "websocket": { "body": "string", "code": 0, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index d3a61585d096c..a3a26b9d97591 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, + "warning": true } ``` @@ -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 | | | +| `warning` | boolean | 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, + "warning": true } ], "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" - } + }, + "warning": true } ``` @@ -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 | | | +| `warning` | boolean | 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, + "warning": true } ], "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" - } + }, + "warning": true }, "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, + "warning": true } ], "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" - } + }, + "warning": true } - } + }, + "warning": true } ``` @@ -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 | | | +| `warning` | boolean | false | | | ## derphealth.StunReport @@ -7578,7 +7589,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, + "warning": true } ], "region": { @@ -7604,7 +7616,8 @@ If the schedule is empty, the user will be updated to use the default schedule.| "regionCode": "string", "regionID": 0, "regionName": "string" - } + }, + "warning": true }, "property2": { "error": "string", @@ -7642,7 +7655,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, + "warning": true } ], "region": { @@ -7668,13 +7682,16 @@ If the schedule is empty, the user will be updated to use the default schedule.| "regionCode": "string", "regionID": 0, "regionName": "string" - } + }, + "warning": true } - } + }, + "warning": true }, "failing_sections": ["string"], "healthy": true, "time": "string", + "warning": true, "websocket": { "body": "string", "code": 0, @@ -7686,16 +7703,17 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------ | ---------------------------------------------------------- | -------- | ------------ | -------------------------------------------------------------------------- | -| `access_url` | [healthcheck.AccessURLReport](#healthcheckaccessurlreport) | false | | | -| `coder_version` | string | false | | The Coder version of the server that the report was generated on. | -| `database` | [healthcheck.DatabaseReport](#healthcheckdatabasereport) | false | | | -| `derp` | [derphealth.Report](#derphealthreport) | false | | | -| `failing_sections` | array of string | false | | Failing sections is a list of sections that have failed their healthcheck. | -| `healthy` | boolean | false | | Healthy is true if the report returns no errors. | -| `time` | string | false | | Time is the time the report was generated at. | -| `websocket` | [healthcheck.WebsocketReport](#healthcheckwebsocketreport) | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------ | ---------------------------------------------------------- | -------- | ------------ | -------------------------------------------------------------------------------- | +| `access_url` | [healthcheck.AccessURLReport](#healthcheckaccessurlreport) | false | | | +| `coder_version` | string | false | | The Coder version of the server that the report was generated on. | +| `database` | [healthcheck.DatabaseReport](#healthcheckdatabasereport) | false | | | +| `derp` | [derphealth.Report](#derphealthreport) | false | | | +| `failing_sections` | array of string | false | | Failing sections is a list of sections that have failed their healthcheck. | +| `healthy` | boolean | false | | Healthy is true if the report returns no errors. | +| `time` | string | false | | Time is the time the report was generated at. | +| `warning` | boolean | false | | Warning is true when Coder is operational but its performance might be impacted. | +| `websocket` | [healthcheck.WebsocketReport](#healthcheckwebsocketreport) | false | | | ## healthcheck.WebsocketReport diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 032c3854138dd..cd6f008561553 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2103,6 +2103,7 @@ export interface HealthcheckDatabaseReport { export interface HealthcheckReport { readonly time: string; readonly healthy: boolean; + readonly warning: boolean; readonly failing_sections: string[]; readonly derp: DerphealthReport; readonly access_url: HealthcheckAccessURLReport; @@ -2169,6 +2170,7 @@ export const ClibaseValueSources: ClibaseValueSource[] = [ // From derphealth/derp.go export interface DerphealthNodeReport { readonly healthy: boolean; + readonly warning: boolean; // 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 +2190,7 @@ export interface DerphealthNodeReport { // From derphealth/derp.go export interface DerphealthRegionReport { readonly healthy: boolean; + readonly warning: boolean; // 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 +2201,7 @@ export interface DerphealthRegionReport { // From derphealth/derp.go export interface DerphealthReport { readonly healthy: boolean; + readonly warning: boolean; 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..5e2b2b2f0717e 100644 --- a/site/src/pages/HealthPage/HealthPage.stories.tsx +++ b/site/src/pages/HealthPage/HealthPage.stories.tsx @@ -31,3 +31,16 @@ export const UnhealthyDERP: Story = { }, }, }; + +export const HealthyDERPWithWarning: Story = { + args: { + healthStatus: { + ...MockHealth, + warning: true, + derp: { + ...MockHealth.derp, + warning: true, + }, + }, + }, +}; diff --git a/site/src/pages/HealthPage/HealthPage.tsx b/site/src/pages/HealthPage/HealthPage.tsx index 70ddd62185a72..e25430d2b9697 100644 --- a/site/src/pages/HealthPage/HealthPage.tsx +++ b/site/src/pages/HealthPage/HealthPage.tsx @@ -19,6 +19,7 @@ import { import { Stats, StatsItem } from "components/Stats/Stats"; import { createDayString } from "utils/createDayString"; import { DashboardFullPage } from "components/Dashboard/DashboardLayout"; +import { DerphealthReport } from "api/typesGenerated"; const sections = { derp: "DERP", @@ -85,7 +86,9 @@ export function HealthPageView({ {healthStatus.healthy - ? "All systems operational" + ? healthStatus.warning + ? "All systems operational, but performance might be degraded" + : "All systems operational" : "Some issues have been detected"} @@ -137,9 +140,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 as DerphealthReport)?.warning; return ( {isHealthy ? ( - theme.palette.success.light, - }} - /> + isWarning ? ( + theme.palette.warning.main, + }} + /> + ) : ( + theme.palette.success.light, + }} + /> + ) ) : (