From f0bd258ff1ad8fcc1845ef1c7395ccec232fedcd Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 30 Jun 2023 11:32:35 -0400 Subject: [PATCH] feat: move proxy settings page to deployment options (#8246) * feat: Move workspace proxy page to deployment options Workspace proxy settings page is now an admin feature * WorkspaceProxy response extends region --- cli/cliui/table.go | 33 ++-- cli/cliui/table_test.go | 30 ++++ coderd/apidoc/docs.go | 32 ++-- coderd/apidoc/swagger.json | 32 ++-- coderd/workspaceproxies.go | 4 +- codersdk/workspaceproxy.go | 49 +++--- docs/api/enterprise.md | 98 ++++++------ docs/api/schemas.md | 67 +++++++-- docs/api/workspaceproxies.md | 6 +- enterprise/cli/workspaceproxy.go | 8 +- enterprise/cli/workspaceproxy_test.go | 6 +- enterprise/coderd/coderd.go | 11 +- enterprise/coderd/proxyhealth/proxyhealth.go | 5 + .../coderd/proxyhealth/proxyhealth_test.go | 8 + enterprise/coderd/workspaceproxy.go | 97 ++++++------ enterprise/coderd/workspaceproxy_test.go | 4 +- scripts/apitypings/main.go | 18 ++- .../testdata/genericmap/genericmap.go | 28 ++++ .../testdata/genericmap/genericmap.ts | 20 +++ site/jest.setup.ts | 6 +- site/site.go | 4 +- site/src/AppRouter.tsx | 8 +- site/src/api/api.ts | 24 ++- site/src/api/typesGenerated.ts | 17 +-- .../DeploySettingsLayout/Badges.tsx | 24 +++ .../DeploySettingsLayout/Sidebar.tsx | 11 ++ .../src/components/Navbar/NavbarView.test.tsx | 18 +-- site/src/components/Navbar/NavbarView.tsx | 22 ++- .../src/components/SettingsLayout/Sidebar.tsx | 11 -- site/src/contexts/ProxyContext.test.tsx | 8 + site/src/contexts/ProxyContext.tsx | 36 ++++- site/src/contexts/useProxyLatency.ts | 18 ++- .../WorkspaceProxyPage/WorkspaceProxyPage.tsx | 14 +- .../WorkspaceProxyPage/WorkspaceProxyRow.tsx | 141 +++++++++--------- .../WorkspaceProxyPage/WorkspaceProxyView.tsx | 7 - .../WorspaceProxyView.stories.tsx | 6 - site/src/testHelpers/entities.ts | 41 ++++- site/src/testHelpers/handlers.ts | 8 + site/src/xServices/auth/authXService.ts | 7 + 39 files changed, 645 insertions(+), 342 deletions(-) create mode 100644 scripts/apitypings/testdata/genericmap/genericmap.go create mode 100644 scripts/apitypings/testdata/genericmap/genericmap.ts diff --git a/cli/cliui/table.go b/cli/cliui/table.go index b7ed7a2dff..b4b00e8759 100644 --- a/cli/cliui/table.go +++ b/cli/cliui/table.go @@ -188,32 +188,39 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error) // returned. If the table tag is malformed, an error is returned. // // The returned name is transformed from "snake_case" to "normal text". -func parseTableStructTag(field reflect.StructField) (name string, defaultSort, recursive bool, err error) { +func parseTableStructTag(field reflect.StructField) (name string, defaultSort, recursive bool, skipParentName bool, err error) { tags, err := structtag.Parse(string(field.Tag)) if err != nil { - return "", false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err) + return "", false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err) } tag, err := tags.Get("table") if err != nil || tag.Name == "-" { // tags.Get only returns an error if the tag is not found. - return "", false, false, nil + return "", false, false, false, nil } defaultSortOpt := false recursiveOpt := false + skipParentNameOpt := false for _, opt := range tag.Options { switch opt { case "default_sort": defaultSortOpt = true case "recursive": recursiveOpt = true + case "recursive_inline": + // recursive_inline is a helper to make recursive tables look nicer. + // It skips prefixing the parent name to the child name. If you do this, + // make sure the child name is unique across all nested structs in the parent. + recursiveOpt = true + skipParentNameOpt = true default: - return "", false, false, xerrors.Errorf("unknown option %q in struct field tag", opt) + return "", false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt) } } - return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, recursiveOpt, nil + return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, recursiveOpt, skipParentNameOpt, nil } func isStructOrStructPointer(t reflect.Type) bool { @@ -235,7 +242,7 @@ func typeToTableHeaders(t reflect.Type) ([]string, string, error) { defaultSortName := "" for i := 0; i < t.NumField(); i++ { field := t.Field(i) - name, defaultSort, recursive, err := parseTableStructTag(field) + name, defaultSort, recursive, skip, err := parseTableStructTag(field) if err != nil { return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err) } @@ -260,7 +267,11 @@ func typeToTableHeaders(t reflect.Type) ([]string, string, error) { return nil, "", xerrors.Errorf("get child field header names for field %q in type %q: %w", field.Name, fieldType.String(), err) } for _, childName := range childNames { - headers = append(headers, fmt.Sprintf("%s %s", name, childName)) + fullName := fmt.Sprintf("%s %s", name, childName) + if skip { + fullName = childName + } + headers = append(headers, fullName) } continue } @@ -296,7 +307,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) { for i := 0; i < val.NumField(); i++ { field := val.Type().Field(i) fieldVal := val.Field(i) - name, _, recursive, err := parseTableStructTag(field) + name, _, recursive, skip, err := parseTableStructTag(field) if err != nil { return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err) } @@ -318,7 +329,11 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) { return nil, xerrors.Errorf("get child field values for field %q in type %q: %w", field.Name, fieldType.String(), err) } for childName, childValue := range childMap { - row[fmt.Sprintf("%s %s", name, childName)] = childValue + fullName := fmt.Sprintf("%s %s", name, childName) + if skip { + fullName = childName + } + row[fullName] = childValue } continue } diff --git a/cli/cliui/table_test.go b/cli/cliui/table_test.go index 249e3f00c3..aca6f7bc82 100644 --- a/cli/cliui/table_test.go +++ b/cli/cliui/table_test.go @@ -49,6 +49,11 @@ type tableTest3 struct { Sub tableTest2 `table:"inner,recursive,default_sort"` } +type tableTest4 struct { + Inline tableTest2 `table:"ignored,recursive_inline"` + SortField string `table:"sort_field,default_sort"` +} + func Test_DisplayTable(t *testing.T) { t.Parallel() @@ -188,6 +193,31 @@ foo foo1 foo3 2022-08-02T15:49:10Z compareTables(t, expected, out) }) + t.Run("Inline", func(t *testing.T) { + t.Parallel() + + expected := ` +NAME AGE +Alice 25 + ` + + inlineIn := []tableTest4{ + { + Inline: tableTest2{ + Name: stringWrapper{ + str: "Alice", + }, + Age: 25, + NotIncluded: "IgnoreMe", + }, + }, + } + out, err := cliui.DisplayTable(inlineIn, "", []string{"name", "age"}) + log.Println("rendered table:\n" + out) + require.NoError(t, err) + compareTables(t, expected, out) + }) + // This test ensures that safeties against invalid use of `table` tags // causes errors (even without data). t.Run("Errors", func(t *testing.T) { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f415a0ee50..54c2f68cbc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1711,7 +1711,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.RegionsResponse" + "$ref": "#/definitions/codersdk.RegionsResponse-codersdk_Region" } } } @@ -5109,7 +5109,7 @@ const docTemplate = `{ "schema": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.WorkspaceProxy" + "$ref": "#/definitions/codersdk.RegionsResponse-codersdk_WorkspaceProxy" } } } @@ -8648,7 +8648,7 @@ const docTemplate = `{ } } }, - "codersdk.RegionsResponse": { + "codersdk.RegionsResponse-codersdk_Region": { "type": "object", "properties": { "regions": { @@ -8659,6 +8659,17 @@ const docTemplate = `{ } } }, + "codersdk.RegionsResponse-codersdk_WorkspaceProxy": { + "type": "object", + "properties": { + "regions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceProxy" + } + } + } + }, "codersdk.Replica": { "type": "object", "properties": { @@ -10144,7 +10155,10 @@ const docTemplate = `{ "display_name": { "type": "string" }, - "icon": { + "healthy": { + "type": "boolean" + }, + "icon_url": { "type": "string" }, "id": { @@ -10154,6 +10168,10 @@ const docTemplate = `{ "name": { "type": "string" }, + "path_app_url": { + "description": "PathAppURL is the URL to the base path for path apps. Optional\nunless wildcard_hostname is set.\nE.g. https://us.example.com", + "type": "string" + }, "status": { "description": "Status is the latest status check of the proxy. This will be empty for deleted\nproxies. This value can be used to determine if a workspace proxy is healthy\nand ready to use.", "allOf": [ @@ -10166,12 +10184,8 @@ const docTemplate = `{ "type": "string", "format": "date-time" }, - "url": { - "description": "Full url including scheme of the proxy api url: https://us.example.com", - "type": "string" - }, "wildcard_hostname": { - "description": "WildcardHostname with the wildcard for subdomain based app hosting: *.us.example.com", + "description": "WildcardHostname is the wildcard hostname for subdomain apps.\nE.g. *.us.example.com\nE.g. *--suffix.au.example.com\nOptional. Does not need to be on the same domain as PathAppURL.", "type": "string" } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e5e8d051cd..30d2b5bb31 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1493,7 +1493,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.RegionsResponse" + "$ref": "#/definitions/codersdk.RegionsResponse-codersdk_Region" } } } @@ -4505,7 +4505,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/codersdk.WorkspaceProxy" + "$ref": "#/definitions/codersdk.RegionsResponse-codersdk_WorkspaceProxy" } } } @@ -7776,7 +7776,7 @@ } } }, - "codersdk.RegionsResponse": { + "codersdk.RegionsResponse-codersdk_Region": { "type": "object", "properties": { "regions": { @@ -7787,6 +7787,17 @@ } } }, + "codersdk.RegionsResponse-codersdk_WorkspaceProxy": { + "type": "object", + "properties": { + "regions": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceProxy" + } + } + } + }, "codersdk.Replica": { "type": "object", "properties": { @@ -9182,7 +9193,10 @@ "display_name": { "type": "string" }, - "icon": { + "healthy": { + "type": "boolean" + }, + "icon_url": { "type": "string" }, "id": { @@ -9192,6 +9206,10 @@ "name": { "type": "string" }, + "path_app_url": { + "description": "PathAppURL is the URL to the base path for path apps. Optional\nunless wildcard_hostname is set.\nE.g. https://us.example.com", + "type": "string" + }, "status": { "description": "Status is the latest status check of the proxy. This will be empty for deleted\nproxies. This value can be used to determine if a workspace proxy is healthy\nand ready to use.", "allOf": [ @@ -9204,12 +9222,8 @@ "type": "string", "format": "date-time" }, - "url": { - "description": "Full url including scheme of the proxy api url: https://us.example.com", - "type": "string" - }, "wildcard_hostname": { - "description": "WildcardHostname with the wildcard for subdomain based app hosting: *.us.example.com", + "description": "WildcardHostname is the wildcard hostname for subdomain apps.\nE.g. *.us.example.com\nE.g. *--suffix.au.example.com\nOptional. Does not need to be on the same domain as PathAppURL.", "type": "string" } } diff --git a/coderd/workspaceproxies.go b/coderd/workspaceproxies.go index e7db84898b..f861233507 100644 --- a/coderd/workspaceproxies.go +++ b/coderd/workspaceproxies.go @@ -72,7 +72,7 @@ func (api *API) PrimaryWorkspaceProxy(ctx context.Context) (database.WorkspacePr // @Security CoderSessionToken // @Produce json // @Tags WorkspaceProxies -// @Success 200 {object} codersdk.RegionsResponse +// @Success 200 {object} codersdk.RegionsResponse[codersdk.Region] // @Router /regions [get] func (api *API) regions(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -87,7 +87,7 @@ func (api *API) regions(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(ctx, rw, http.StatusOK, codersdk.RegionsResponse{ + httpapi.Write(ctx, rw, http.StatusOK, codersdk.RegionsResponse[codersdk.Region]{ Regions: []codersdk.Region{region}, }) } diff --git a/codersdk/workspaceproxy.go b/codersdk/workspaceproxy.go index 1c10e9981b..2ce15b043e 100644 --- a/codersdk/workspaceproxy.go +++ b/codersdk/workspaceproxy.go @@ -46,22 +46,17 @@ type ProxyHealthReport struct { } type WorkspaceProxy struct { - ID uuid.UUID `json:"id" format:"uuid" table:"id"` - Name string `json:"name" table:"name,default_sort"` - DisplayName string `json:"display_name" table:"display_name"` - Icon string `json:"icon" table:"icon"` - // Full url including scheme of the proxy api url: https://us.example.com - URL string `json:"url" table:"url"` - // WildcardHostname with the wildcard for subdomain based app hosting: *.us.example.com - WildcardHostname string `json:"wildcard_hostname" table:"wildcard_hostname"` - CreatedAt time.Time `json:"created_at" format:"date-time" table:"created_at"` - UpdatedAt time.Time `json:"updated_at" format:"date-time" table:"updated_at"` - Deleted bool `json:"deleted" table:"deleted"` + // Extends Region with extra information + Region `table:"region,recursive_inline"` // Status is the latest status check of the proxy. This will be empty for deleted // proxies. This value can be used to determine if a workspace proxy is healthy // and ready to use. Status WorkspaceProxyStatus `json:"status,omitempty" table:"proxy,recursive"` + + CreatedAt time.Time `json:"created_at" format:"date-time" table:"created_at,default_sort"` + UpdatedAt time.Time `json:"updated_at" format:"date-time" table:"updated_at"` + Deleted bool `json:"deleted" table:"deleted"` } type CreateWorkspaceProxyRequest struct { @@ -93,21 +88,21 @@ func (c *Client) CreateWorkspaceProxy(ctx context.Context, req CreateWorkspacePr return resp, json.NewDecoder(res.Body).Decode(&resp) } -func (c *Client) WorkspaceProxies(ctx context.Context) ([]WorkspaceProxy, error) { +func (c *Client) WorkspaceProxies(ctx context.Context) (RegionsResponse[WorkspaceProxy], error) { res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaceproxies", nil, ) if err != nil { - return nil, xerrors.Errorf("make request: %w", err) + return RegionsResponse[WorkspaceProxy]{}, xerrors.Errorf("make request: %w", err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { - return nil, ReadBodyAsError(res) + return RegionsResponse[WorkspaceProxy]{}, ReadBodyAsError(res) } - var proxies []WorkspaceProxy + var proxies RegionsResponse[WorkspaceProxy] return proxies, json.NewDecoder(res.Body).Decode(&proxies) } @@ -179,27 +174,31 @@ func (c *Client) WorkspaceProxyByID(ctx context.Context, id uuid.UUID) (Workspac return c.WorkspaceProxyByName(ctx, id.String()) } -type RegionsResponse struct { - Regions []Region `json:"regions"` +type RegionTypes interface { + Region | WorkspaceProxy +} + +type RegionsResponse[R RegionTypes] struct { + Regions []R `json:"regions"` } type Region struct { - ID uuid.UUID `json:"id" format:"uuid"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - IconURL string `json:"icon_url"` - Healthy bool `json:"healthy"` + ID uuid.UUID `json:"id" format:"uuid" table:"id"` + Name string `json:"name" table:"name,default_sort"` + DisplayName string `json:"display_name" table:"display_name"` + IconURL string `json:"icon_url" table:"icon_url"` + Healthy bool `json:"healthy" table:"healthy"` // PathAppURL is the URL to the base path for path apps. Optional // unless wildcard_hostname is set. // E.g. https://us.example.com - PathAppURL string `json:"path_app_url"` + PathAppURL string `json:"path_app_url" table:"url"` // WildcardHostname is the wildcard hostname for subdomain apps. // E.g. *.us.example.com // E.g. *--suffix.au.example.com // Optional. Does not need to be on the same domain as PathAppURL. - WildcardHostname string `json:"wildcard_hostname"` + WildcardHostname string `json:"wildcard_hostname" table:"wildcard_hostname"` } func (c *Client) Regions(ctx context.Context) ([]Region, error) { @@ -216,6 +215,6 @@ func (c *Client) Regions(ctx context.Context) ([]Region, error) { return nil, ReadBodyAsError(res) } - var regions RegionsResponse + var regions RegionsResponse[Region] return regions.Regions, json.NewDecoder(res.Body).Decode(®ions) } diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index c5ecaea1c2..844cef85f9 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -1180,55 +1180,62 @@ curl -X GET http://coder-server:8080/api/v2/workspaceproxies \ ```json [ { - "created_at": "2019-08-24T14:15:22Z", - "deleted": true, - "display_name": "string", - "icon": "string", - "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", - "name": "string", - "status": { - "checked_at": "2019-08-24T14:15:22Z", - "report": { - "errors": ["string"], - "warnings": ["string"] - }, - "status": "ok" - }, - "updated_at": "2019-08-24T14:15:22Z", - "url": "string", - "wildcard_hostname": "string" + "regions": [ + { + "created_at": "2019-08-24T14:15:22Z", + "deleted": true, + "display_name": "string", + "healthy": true, + "icon_url": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "path_app_url": "string", + "status": { + "checked_at": "2019-08-24T14:15:22Z", + "report": { + "errors": ["string"], + "warnings": ["string"] + }, + "status": "ok" + }, + "updated_at": "2019-08-24T14:15:22Z", + "wildcard_hostname": "string" + } + ] } ] ``` ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | --------------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.WorkspaceProxy](schemas.md#codersdkworkspaceproxy) | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.RegionsResponse-codersdk_WorkspaceProxy](schemas.md#codersdkregionsresponse-codersdk_workspaceproxy) |

Response Schema

Status Code **200** -| Name | Type | Required | Restrictions | Description | -| --------------------- | ------------------------------------------------------------------------ | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `[array item]` | array | false | | | -| `» created_at` | string(date-time) | false | | | -| `» deleted` | boolean | false | | | -| `» display_name` | string | false | | | -| `» icon` | string | false | | | -| `» id` | string(uuid) | false | | | -| `» name` | string | false | | | -| `» status` | [codersdk.WorkspaceProxyStatus](schemas.md#codersdkworkspaceproxystatus) | false | | Status is the latest status check of the proxy. This will be empty for deleted proxies. This value can be used to determine if a workspace proxy is healthy and ready to use. | -| `»» checked_at` | string(date-time) | false | | | -| `»» report` | [codersdk.ProxyHealthReport](schemas.md#codersdkproxyhealthreport) | false | | Report provides more information about the health of the workspace proxy. | -| `»»» errors` | array | false | | Errors are problems that prevent the workspace proxy from being healthy | -| `»»» warnings` | array | false | | Warnings do not prevent the workspace proxy from being healthy, but should be addressed. | -| `»» status` | [codersdk.ProxyHealthStatus](schemas.md#codersdkproxyhealthstatus) | false | | | -| `» updated_at` | string(date-time) | false | | | -| `» url` | string | false | | Full URL including scheme of the proxy api url: https://us.example.com | -| `» wildcard_hostname` | string | false | | Wildcard hostname with the wildcard for subdomain based app hosting: \*.us.example.com | +| Name | Type | Required | Restrictions | Description | +| ---------------------- | ------------------------------------------------------------------------ | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» regions` | array | false | | | +| `»» created_at` | string(date-time) | false | | | +| `»» deleted` | boolean | false | | | +| `»» display_name` | string | false | | | +| `»» healthy` | boolean | false | | | +| `»» icon_url` | string | false | | | +| `»» id` | string(uuid) | false | | | +| `»» name` | string | false | | | +| `»» path_app_url` | string | false | | »path app URL is the URL to the base path for path apps. Optional unless wildcard_hostname is set. E.g. https://us.example.com | +| `»» status` | [codersdk.WorkspaceProxyStatus](schemas.md#codersdkworkspaceproxystatus) | false | | Status is the latest status check of the proxy. This will be empty for deleted proxies. This value can be used to determine if a workspace proxy is healthy and ready to use. | +| `»»» checked_at` | string(date-time) | false | | | +| `»»» report` | [codersdk.ProxyHealthReport](schemas.md#codersdkproxyhealthreport) | false | | Report provides more information about the health of the workspace proxy. | +| `»»»» errors` | array | false | | Errors are problems that prevent the workspace proxy from being healthy | +| `»»»» warnings` | array | false | | Warnings do not prevent the workspace proxy from being healthy, but should be addressed. | +| `»»» status` | [codersdk.ProxyHealthStatus](schemas.md#codersdkproxyhealthstatus) | false | | | +| `»» updated_at` | string(date-time) | false | | | +| `»» wildcard_hostname` | string | false | | »wildcard hostname is the wildcard hostname for subdomain apps. E.g. _.us.example.com E.g. _--suffix.au.example.com Optional. Does not need to be on the same domain as PathAppURL. | #### Enumerated Values @@ -1280,9 +1287,11 @@ curl -X POST http://coder-server:8080/api/v2/workspaceproxies \ "created_at": "2019-08-24T14:15:22Z", "deleted": true, "display_name": "string", - "icon": "string", + "healthy": true, + "icon_url": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", + "path_app_url": "string", "status": { "checked_at": "2019-08-24T14:15:22Z", "report": { @@ -1292,7 +1301,6 @@ curl -X POST http://coder-server:8080/api/v2/workspaceproxies \ "status": "ok" }, "updated_at": "2019-08-24T14:15:22Z", - "url": "string", "wildcard_hostname": "string" } ``` @@ -1333,9 +1341,11 @@ curl -X GET http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} \ "created_at": "2019-08-24T14:15:22Z", "deleted": true, "display_name": "string", - "icon": "string", + "healthy": true, + "icon_url": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", + "path_app_url": "string", "status": { "checked_at": "2019-08-24T14:15:22Z", "report": { @@ -1345,7 +1355,6 @@ curl -X GET http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} \ "status": "ok" }, "updated_at": "2019-08-24T14:15:22Z", - "url": "string", "wildcard_hostname": "string" } ``` @@ -1444,9 +1453,11 @@ curl -X PATCH http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} "created_at": "2019-08-24T14:15:22Z", "deleted": true, "display_name": "string", - "icon": "string", + "healthy": true, + "icon_url": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", + "path_app_url": "string", "status": { "checked_at": "2019-08-24T14:15:22Z", "report": { @@ -1456,7 +1467,6 @@ curl -X PATCH http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} "status": "ok" }, "updated_at": "2019-08-24T14:15:22Z", - "url": "string", "wildcard_hostname": "string" } ``` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 53c76eaaed..12a988ee11 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3606,7 +3606,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `path_app_url` | string | false | | Path app URL is the URL to the base path for path apps. Optional unless wildcard_hostname is set. E.g. https://us.example.com | | `wildcard_hostname` | string | false | | Wildcard hostname is the wildcard hostname for subdomain apps. E.g. _.us.example.com E.g. _--suffix.au.example.com Optional. Does not need to be on the same domain as PathAppURL. | -## codersdk.RegionsResponse +## codersdk.RegionsResponse-codersdk_Region ```json { @@ -3630,6 +3630,41 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | --------- | ------------------------------------------- | -------- | ------------ | ----------- | | `regions` | array of [codersdk.Region](#codersdkregion) | false | | | +## codersdk.RegionsResponse-codersdk_WorkspaceProxy + +```json +{ + "regions": [ + { + "created_at": "2019-08-24T14:15:22Z", + "deleted": true, + "display_name": "string", + "healthy": true, + "icon_url": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "path_app_url": "string", + "status": { + "checked_at": "2019-08-24T14:15:22Z", + "report": { + "errors": ["string"], + "warnings": ["string"] + }, + "status": "ok" + }, + "updated_at": "2019-08-24T14:15:22Z", + "wildcard_hostname": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| --------- | ----------------------------------------------------------- | -------- | ------------ | ----------- | +| `regions` | array of [codersdk.WorkspaceProxy](#codersdkworkspaceproxy) | false | | | + ## codersdk.Replica ```json @@ -5448,9 +5483,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "created_at": "2019-08-24T14:15:22Z", "deleted": true, "display_name": "string", - "icon": "string", + "healthy": true, + "icon_url": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", + "path_app_url": "string", "status": { "checked_at": "2019-08-24T14:15:22Z", "report": { @@ -5460,25 +5497,25 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "status": "ok" }, "updated_at": "2019-08-24T14:15:22Z", - "url": "string", "wildcard_hostname": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------- | -------------------------------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `created_at` | string | false | | | -| `deleted` | boolean | false | | | -| `display_name` | string | false | | | -| `icon` | string | false | | | -| `id` | string | false | | | -| `name` | string | false | | | -| `status` | [codersdk.WorkspaceProxyStatus](#codersdkworkspaceproxystatus) | false | | Status is the latest status check of the proxy. This will be empty for deleted proxies. This value can be used to determine if a workspace proxy is healthy and ready to use. | -| `updated_at` | string | false | | | -| `url` | string | false | | Full URL including scheme of the proxy api url: https://us.example.com | -| `wildcard_hostname` | string | false | | Wildcard hostname with the wildcard for subdomain based app hosting: \*.us.example.com | +| Name | Type | Required | Restrictions | Description | +| ------------------- | -------------------------------------------------------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `created_at` | string | false | | | +| `deleted` | boolean | false | | | +| `display_name` | string | false | | | +| `healthy` | boolean | false | | | +| `icon_url` | string | false | | | +| `id` | string | false | | | +| `name` | string | false | | | +| `path_app_url` | string | false | | Path app URL is the URL to the base path for path apps. Optional unless wildcard_hostname is set. E.g. https://us.example.com | +| `status` | [codersdk.WorkspaceProxyStatus](#codersdkworkspaceproxystatus) | false | | Status is the latest status check of the proxy. This will be empty for deleted proxies. This value can be used to determine if a workspace proxy is healthy and ready to use. | +| `updated_at` | string | false | | | +| `wildcard_hostname` | string | false | | Wildcard hostname is the wildcard hostname for subdomain apps. E.g. _.us.example.com E.g. _--suffix.au.example.com Optional. Does not need to be on the same domain as PathAppURL. | ## codersdk.WorkspaceProxyStatus diff --git a/docs/api/workspaceproxies.md b/docs/api/workspaceproxies.md index 5cd961c2a5..2113d53d16 100644 --- a/docs/api/workspaceproxies.md +++ b/docs/api/workspaceproxies.md @@ -35,8 +35,8 @@ curl -X GET http://coder-server:8080/api/v2/regions \ ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.RegionsResponse](schemas.md#codersdkregionsresponse) | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.RegionsResponse-codersdk_Region](schemas.md#codersdkregionsresponse-codersdk_region) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/enterprise/cli/workspaceproxy.go b/enterprise/cli/workspaceproxy.go index f53eb242af..15e2727b5a 100644 --- a/enterprise/cli/workspaceproxy.go +++ b/enterprise/cli/workspaceproxy.go @@ -63,7 +63,7 @@ func (r *RootCmd) regenerateProxyToken() *clibase.Cmd { ID: proxy.ID, Name: proxy.Name, DisplayName: proxy.DisplayName, - Icon: proxy.Icon, + Icon: proxy.IconURL, RegenerateToken: true, }) if err != nil { @@ -138,7 +138,7 @@ func (r *RootCmd) patchProxy() *clibase.Cmd { displayName = proxy.DisplayName } if proxyIcon == "" { - proxyIcon = proxy.Icon + proxyIcon = proxy.IconURL } updated, err := client.PatchWorkspaceProxy(ctx, codersdk.PatchWorkspaceProxy{ @@ -322,7 +322,7 @@ func (r *RootCmd) listProxies() *clibase.Cmd { sep := "" for i, proxy := range resp { _, _ = str.WriteString(sep) - _, _ = str.WriteString(fmt.Sprintf("%d: %s %s %s", i, proxy.Name, proxy.URL, proxy.Status.Status)) + _, _ = str.WriteString(fmt.Sprintf("%d: %s %s %s", i, proxy.Name, proxy.PathAppURL, proxy.Status.Status)) for _, errMsg := range proxy.Status.Report.Errors { _, _ = str.WriteString(color.RedString("\n\tErr: %s", errMsg)) } @@ -351,7 +351,7 @@ func (r *RootCmd) listProxies() *clibase.Cmd { return xerrors.Errorf("list workspace proxies: %w", err) } - output, err := formatter.Format(ctx, proxies) + output, err := formatter.Format(ctx, proxies.Regions) if err != nil { return err } diff --git a/enterprise/cli/workspaceproxy_test.go b/enterprise/cli/workspaceproxy_test.go index 357c609125..11ef569322 100644 --- a/enterprise/cli/workspaceproxy_test.go +++ b/enterprise/cli/workspaceproxy_test.go @@ -83,9 +83,9 @@ func Test_ProxyCRUD(t *testing.T) { proxies, err := client.WorkspaceProxies(ctx) require.NoError(t, err, "failed to get workspace proxies") // Include primary - require.Len(t, proxies, 2, "expected 1 proxy") + require.Len(t, proxies.Regions, 2, "expected 1 proxy") found := false - for _, proxy := range proxies { + for _, proxy := range proxies.Regions { if proxy.Name == expectedName { found = true } @@ -137,6 +137,6 @@ func Test_ProxyCRUD(t *testing.T) { proxies, err := client.WorkspaceProxies(ctx) require.NoError(t, err, "failed to get workspace proxies") - require.Len(t, proxies, 1, "expected only primary proxy") + require.Len(t, proxies.Regions, 1, "expected only primary proxy") }) } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 1df66af44a..1a4a6d9e7d 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -18,6 +18,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/coderd" agplaudit "github.com/coder/coder/coderd/audit" + "github.com/coder/coder/coderd/database/dbauthz" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" @@ -67,7 +68,15 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { api.AGPL.Options.SetUserGroups = api.setUserGroups api.AGPL.SiteHandler.AppearanceFetcher = api.fetchAppearanceConfig - api.AGPL.SiteHandler.RegionsFetcher = api.fetchRegions + api.AGPL.SiteHandler.RegionsFetcher = func(ctx context.Context) (any, error) { + // If the user can read the workspace proxy resource, return that. + // If not, always default to the regions. + actor, ok := dbauthz.ActorFromContext(ctx) + if ok && api.Authorizer.Authorize(ctx, actor, rbac.ActionRead, rbac.ResourceWorkspaceProxy) == nil { + return api.fetchWorkspaceProxies(ctx) + } + return api.fetchRegions(ctx) + } oauthConfigs := &httpmw.OAuth2Configs{ Github: options.GithubOAuth2Config, diff --git a/enterprise/coderd/proxyhealth/proxyhealth.go b/enterprise/coderd/proxyhealth/proxyhealth.go index be7368e9c6..d95a44d5a0 100644 --- a/enterprise/coderd/proxyhealth/proxyhealth.go +++ b/enterprise/coderd/proxyhealth/proxyhealth.go @@ -171,6 +171,11 @@ func (p *ProxyHealth) ForceUpdate(ctx context.Context) error { // HealthStatus returns the current health status of all proxies stored in the // cache. func (p *ProxyHealth) HealthStatus() map[uuid.UUID]ProxyStatus { + if p == nil { + // This can happen because workspace proxies are still an experiment. + // For the /regions endpoint, this will be nil in those cases. + return map[uuid.UUID]ProxyStatus{} + } ptr := p.cache.Load() if ptr == nil { return map[uuid.UUID]ProxyStatus{} diff --git a/enterprise/coderd/proxyhealth/proxyhealth_test.go b/enterprise/coderd/proxyhealth/proxyhealth_test.go index 5fb9614385..10d8d69dba 100644 --- a/enterprise/coderd/proxyhealth/proxyhealth_test.go +++ b/enterprise/coderd/proxyhealth/proxyhealth_test.go @@ -36,6 +36,14 @@ func insertProxy(t *testing.T, db database.Store, url string) database.Workspace return proxy } +// Test the nil guard for experiment off cases. +func TestProxyHealth_Nil(t *testing.T) { + t.Parallel() + var ph *proxyhealth.ProxyHealth + + require.NotNil(t, ph.HealthStatus()) +} + func TestProxyHealth_Unregistered(t *testing.T) { t.Parallel() db := dbfake.New() diff --git a/enterprise/coderd/workspaceproxy.go b/enterprise/coderd/workspaceproxy.go index 296c6482be..8de4a95f02 100644 --- a/enterprise/coderd/workspaceproxy.go +++ b/enterprise/coderd/workspaceproxy.go @@ -48,47 +48,27 @@ func (api *API) regions(rw http.ResponseWriter, r *http.Request) { httpapi.Write(r.Context(), rw, http.StatusOK, regions) } -func (api *API) fetchRegions(ctx context.Context) (codersdk.RegionsResponse, error) { +func (api *API) fetchRegions(ctx context.Context) (codersdk.RegionsResponse[codersdk.Region], error) { //nolint:gocritic // this intentionally requests resources that users // cannot usually access in order to give them a full list of available - // regions. + // regions. Regions are just a data subset of proxies. ctx = dbauthz.AsSystemRestricted(ctx) - - primaryRegion, err := api.AGPL.PrimaryRegion(ctx) + proxies, err := api.fetchWorkspaceProxies(ctx) if err != nil { - return codersdk.RegionsResponse{}, err - } - regions := []codersdk.Region{primaryRegion} - - proxies, err := api.Database.GetWorkspaceProxies(ctx) - if err != nil { - return codersdk.RegionsResponse{}, err + return codersdk.RegionsResponse[codersdk.Region]{}, err } - // Only add additional regions if the proxy health is enabled. - // If it is nil, it is because the moons feature flag is not on. - // By default, we still want to return the primary region. - if api.ProxyHealth != nil { - proxyHealth := api.ProxyHealth.HealthStatus() - for _, proxy := range proxies { - if proxy.Deleted { - continue - } - - health := proxyHealth[proxy.ID] - regions = append(regions, codersdk.Region{ - ID: proxy.ID, - Name: proxy.Name, - DisplayName: proxy.DisplayName, - IconURL: proxy.Icon, - Healthy: health.Status == proxyhealth.Healthy, - PathAppURL: proxy.Url, - WildcardHostname: proxy.WildcardHostname, - }) + regions := make([]codersdk.Region, 0, len(proxies.Regions)) + for i := range proxies.Regions { + // Ignore deleted proxies. + if proxies.Regions[i].Deleted { + continue } + // Append the inner region data. + regions = append(regions, proxies.Regions[i].Region) } - return codersdk.RegionsResponse{ + return codersdk.RegionsResponse[codersdk.Region]{ Regions: regions, }, nil } @@ -415,27 +395,41 @@ func validateProxyURL(u string) error { // @Security CoderSessionToken // @Produce json // @Tags Enterprise -// @Success 200 {array} codersdk.WorkspaceProxy +// @Success 200 {array} codersdk.RegionsResponse[codersdk.WorkspaceProxy] // @Router /workspaceproxies [get] func (api *API) workspaceProxies(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - - proxies, err := api.Database.GetWorkspaceProxies(ctx) - if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + proxies, err := api.fetchWorkspaceProxies(r.Context()) + if err != nil { + if dbauthz.IsNotAuthorizedError(err) { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "You are not authorized to use this endpoint.", + }) + return + } httpapi.InternalServerError(rw, err) return } + httpapi.Write(ctx, rw, http.StatusOK, proxies) +} + +func (api *API) fetchWorkspaceProxies(ctx context.Context) (codersdk.RegionsResponse[codersdk.WorkspaceProxy], error) { + proxies, err := api.Database.GetWorkspaceProxies(ctx) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + return codersdk.RegionsResponse[codersdk.WorkspaceProxy]{}, err + } // Add the primary as well primaryProxy, err := api.AGPL.PrimaryWorkspaceProxy(ctx) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { - httpapi.InternalServerError(rw, err) - return + return codersdk.RegionsResponse[codersdk.WorkspaceProxy]{}, err } proxies = append([]database.WorkspaceProxy{primaryProxy}, proxies...) statues := api.ProxyHealth.HealthStatus() - httpapi.Write(ctx, rw, http.StatusOK, convertProxies(proxies, statues)) + return codersdk.RegionsResponse[codersdk.WorkspaceProxy]{ + Regions: convertProxies(proxies, statues), + }, nil } // @Summary Issue signed workspace app token @@ -710,6 +704,18 @@ func convertProxies(p []database.WorkspaceProxy, statuses map[uuid.UUID]proxyhea return resp } +func convertRegion(proxy database.WorkspaceProxy, status proxyhealth.ProxyStatus) codersdk.Region { + return codersdk.Region{ + ID: proxy.ID, + Name: proxy.Name, + DisplayName: proxy.DisplayName, + IconURL: proxy.Icon, + Healthy: status.Status == proxyhealth.Healthy, + PathAppURL: proxy.Url, + WildcardHostname: proxy.WildcardHostname, + } +} + func convertProxy(p database.WorkspaceProxy, status proxyhealth.ProxyStatus) codersdk.WorkspaceProxy { if p.IsPrimary() { // Primary is always healthy since the primary serves the api that this @@ -727,15 +733,10 @@ func convertProxy(p database.WorkspaceProxy, status proxyhealth.ProxyStatus) cod status.Status = proxyhealth.Unknown } return codersdk.WorkspaceProxy{ - ID: p.ID, - Name: p.Name, - DisplayName: p.DisplayName, - Icon: p.Icon, - URL: p.Url, - WildcardHostname: p.WildcardHostname, - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, - Deleted: p.Deleted, + Region: convertRegion(p, status), + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + Deleted: p.Deleted, Status: codersdk.WorkspaceProxyStatus{ Status: codersdk.ProxyHealthStatus(status.Status), Report: status.Report, diff --git a/enterprise/coderd/workspaceproxy_test.go b/enterprise/coderd/workspaceproxy_test.go index f96b1dd2bf..9793269216 100644 --- a/enterprise/coderd/workspaceproxy_test.go +++ b/enterprise/coderd/workspaceproxy_test.go @@ -288,7 +288,7 @@ func TestWorkspaceProxyCRUD(t *testing.T) { require.NoError(t, err) require.Equal(t, expName, found.Name, "name") require.Equal(t, expDisplayName, found.DisplayName, "display name") - require.Equal(t, expIcon, found.Icon, "icon") + require.Equal(t, expIcon, found.IconURL, "icon") }) t.Run("Delete", func(t *testing.T) { @@ -323,7 +323,7 @@ func TestWorkspaceProxyCRUD(t *testing.T) { proxies, err := client.WorkspaceProxies(ctx) require.NoError(t, err) // Default proxy is always there - require.Len(t, proxies, 1) + require.Len(t, proxies.Regions, 1) }) } diff --git a/scripts/apitypings/main.go b/scripts/apitypings/main.go index e39d15e171..3919d24a00 100644 --- a/scripts/apitypings/main.go +++ b/scripts/apitypings/main.go @@ -584,7 +584,7 @@ type TypescriptType struct { // Example: 'C = comparable'. GenericTypes map[string]string // GenericValue is the value using the Generic name, rather than the constraint. - // This is only usedful if you can use the generic syntax. Things like maps + // This is only useful if you can use the generic syntax. Things like maps // don't currently support this, and will use the ValueType instead. // Example: // Given the Golang @@ -667,9 +667,14 @@ func (g *Generator) typescriptType(ty types.Type) (TypescriptType, error) { } aboveTypeLine = aboveTypeLine + valueType.AboveTypeLine + mergeGens := keyType.GenericTypes + for k, v := range valueType.GenericTypes { + mergeGens[k] = v + } return TypescriptType{ ValueType: fmt.Sprintf("Record<%s, %s>", keyType.ValueType, valueType.ValueType), AboveTypeLine: aboveTypeLine, + GenericTypes: mergeGens, }, nil case *types.Slice, *types.Array: // Slice/Arrays are pretty much the same. @@ -691,7 +696,16 @@ func (g *Generator) typescriptType(ty types.Type) (TypescriptType, error) { if err != nil { return TypescriptType{}, xerrors.Errorf("array: %w", err) } - return TypescriptType{ValueType: underlying.ValueType + "[]", AboveTypeLine: underlying.AboveTypeLine}, nil + genValue := "" + if underlying.GenericValue != "" { + genValue = underlying.GenericValue + "[]" + } + return TypescriptType{ + ValueType: underlying.ValueType + "[]", + GenericValue: genValue, + AboveTypeLine: underlying.AboveTypeLine, + GenericTypes: underlying.GenericTypes, + }, nil } case *types.Named: n := ty diff --git a/scripts/apitypings/testdata/genericmap/genericmap.go b/scripts/apitypings/testdata/genericmap/genericmap.go new file mode 100644 index 0000000000..ab9163b587 --- /dev/null +++ b/scripts/apitypings/testdata/genericmap/genericmap.go @@ -0,0 +1,28 @@ +package genericmap + +type Foo struct { + Bar string `json:"bar"` +} + +type Buzz struct { + Foo `json:"foo"` + Bazz string `json:"bazz"` +} + +type Custom interface { + Foo | Buzz +} + +type FooBuzz[R Custom] struct { + Something []R `json:"something"` +} + +// Not yet supported +//type FooBuzzMap[R Custom] struct { +// Something map[string]R `json:"something"` +//} + +// Not yet supported +//type FooBuzzAnonymousUnion[R Foo | Buzz] struct { +// Something []R `json:"something"` +//} diff --git a/scripts/apitypings/testdata/genericmap/genericmap.ts b/scripts/apitypings/testdata/genericmap/genericmap.ts new file mode 100644 index 0000000000..5489d03970 --- /dev/null +++ b/scripts/apitypings/testdata/genericmap/genericmap.ts @@ -0,0 +1,20 @@ +// Code generated by 'make site/src/api/typesGenerated.ts'. DO NOT EDIT. + +// From codersdk/genericmap.go +export interface Buzz { + readonly foo: Foo + readonly bazz: string +} + +// From codersdk/genericmap.go +export interface Foo { + readonly bar: string +} + +// From codersdk/genericmap.go +export interface FooBuzz { + readonly something: R[] +} + +// From codersdk/genericmap.go +export type Custom = Foo | Buzz diff --git a/site/jest.setup.ts b/site/jest.setup.ts index 1ee61c0a6e..9eef56164b 100644 --- a/site/jest.setup.ts +++ b/site/jest.setup.ts @@ -7,7 +7,7 @@ import { TextEncoder, TextDecoder } from "util" import { Blob } from "buffer" import jestFetchMock from "jest-fetch-mock" import { ProxyLatencyReport } from "contexts/useProxyLatency" -import { RegionsResponse } from "api/typesGenerated" +import { Region } from "api/typesGenerated" import { useMemo } from "react" jestFetchMock.enableMocks() @@ -16,14 +16,14 @@ jestFetchMock.enableMocks() // This would fail unit testing, or at least make it very slow with // actual network requests. So just globally mock this hook. jest.mock("contexts/useProxyLatency", () => ({ - useProxyLatency: (proxies?: RegionsResponse) => { + useProxyLatency: (proxies?: Region[]) => { // Must use `useMemo` here to avoid infinite loop. // Mocking the hook with a hook. const proxyLatencies = useMemo(() => { if (!proxies) { return {} as Record } - return proxies.regions.reduce((acc, proxy) => { + return proxies.reduce((acc, proxy) => { acc[proxy.id] = { accurate: true, // Return a constant latency of 8ms. diff --git a/site/site.go b/site/site.go index 2ada3c4908..a358d458e5 100644 --- a/site/site.go +++ b/site/site.go @@ -146,7 +146,9 @@ type Handler struct { buildInfoJSON string AppearanceFetcher func(ctx context.Context) (codersdk.AppearanceConfig, error) - RegionsFetcher func(ctx context.Context) (codersdk.RegionsResponse, error) + // RegionsFetcher will attempt to fetch the more detailed WorkspaceProxy data, but will fall back to the + // regions if the user does not have the correct permissions. + RegionsFetcher func(ctx context.Context) (any, error) Entitlements atomic.Pointer[codersdk.Entitlements] Experiments atomic.Pointer[codersdk.Experiments] diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index b9af04fb47..0b31a88737 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -276,6 +276,10 @@ export const AppRouter: FC = () => { } /> } /> } /> + } + /> }> @@ -286,10 +290,6 @@ export const AppRouter: FC = () => { } /> } /> - } - /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 945abd5599..d31ee22168 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -966,13 +966,23 @@ export const getFile = async (fileId: string): Promise => { return response.data } -export const getWorkspaceProxies = - async (): Promise => { - const response = await axios.get( - `/api/v2/regions`, - ) - return response.data - } +export const getWorkspaceProxyRegions = async (): Promise< + TypesGen.RegionsResponse +> => { + const response = await axios.get>( + `/api/v2/regions`, + ) + return response.data +} + +export const getWorkspaceProxies = async (): Promise< + TypesGen.RegionsResponse +> => { + const response = await axios.get< + TypesGen.RegionsResponse + >(`/api/v2/workspaceproxies`) + return response.data +} export const getAppearance = async (): Promise => { try { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d159e89ef7..050ef91d88 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -747,8 +747,8 @@ export interface Region { } // From codersdk/workspaceproxy.go -export interface RegionsResponse { - readonly regions: Region[] +export interface RegionsResponse { + readonly regions: R[] } // From codersdk/replicas.go @@ -1307,17 +1307,11 @@ export interface WorkspaceOptions { } // From codersdk/workspaceproxy.go -export interface WorkspaceProxy { - readonly id: string - readonly name: string - readonly display_name: string - readonly icon: string - readonly url: string - readonly wildcard_hostname: string +export interface WorkspaceProxy extends Region { + readonly status?: WorkspaceProxyStatus readonly created_at: string readonly updated_at: string readonly deleted: boolean - readonly status?: WorkspaceProxyStatus } // From codersdk/deployment.go @@ -1732,3 +1726,6 @@ export const WorkspaceTransitions: WorkspaceTransition[] = [ "start", "stop", ] + +// From codersdk/workspaceproxy.go +export type RegionTypes = Region | WorkspaceProxy diff --git a/site/src/components/DeploySettingsLayout/Badges.tsx b/site/src/components/DeploySettingsLayout/Badges.tsx index 8a3173f1f4..14d2c0c60d 100644 --- a/site/src/components/DeploySettingsLayout/Badges.tsx +++ b/site/src/components/DeploySettingsLayout/Badges.tsx @@ -40,6 +40,24 @@ export const NotHealthyBadge: FC = () => { ) } +export const NotRegisteredBadge: FC = () => { + const styles = useStyles() + return ( + + Not Registered + + ) +} + +export const NotReachableBadge: FC = () => { + const styles = useStyles() + return ( + + Not Reachable + + ) +} + export const DisabledBadge: FC = () => { const styles = useStyles() return ( @@ -88,6 +106,7 @@ const useStyles = makeStyles((theme) => ({ display: "flex", alignItems: "center", width: "fit-content", + whiteSpace: "nowrap", }, enterpriseBadge: { @@ -115,6 +134,11 @@ const useStyles = makeStyles((theme) => ({ backgroundColor: theme.palette.error.dark, }, + warnBadge: { + border: `1px solid ${theme.palette.warning.light}`, + backgroundColor: theme.palette.warning.dark, + }, + disabledBadge: { border: `1px solid ${theme.palette.divider}`, backgroundColor: theme.palette.background.paper, diff --git a/site/src/components/DeploySettingsLayout/Sidebar.tsx b/site/src/components/DeploySettingsLayout/Sidebar.tsx index e7db4e2b5f..788877558e 100644 --- a/site/src/components/DeploySettingsLayout/Sidebar.tsx +++ b/site/src/components/DeploySettingsLayout/Sidebar.tsx @@ -4,12 +4,14 @@ import LaunchOutlined from "@mui/icons-material/LaunchOutlined" import ApprovalIcon from "@mui/icons-material/VerifiedUserOutlined" import LockRounded from "@mui/icons-material/LockOutlined" import Globe from "@mui/icons-material/PublicOutlined" +import HubOutlinedIcon from "@mui/icons-material/HubOutlined" import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined" import { GitIcon } from "components/Icons/GitIcon" import { Stack } from "components/Stack/Stack" import { ElementType, PropsWithChildren, ReactNode, FC } from "react" import { NavLink } from "react-router-dom" import { combineClasses } from "utils/combineClasses" +import { useDashboard } from "components/Dashboard/DashboardProvider" const SidebarNavItem: FC< PropsWithChildren<{ href: string; icon: ReactNode }> @@ -40,6 +42,7 @@ const SidebarNavItemIcon: FC<{ icon: ElementType }> = ({ icon: Icon }) => { export const Sidebar: React.FC = () => { const styles = useStyles() + const dashboard = useDashboard() return (