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
This commit is contained in:
Steven Masley 2023-06-30 11:32:35 -04:00 committed by GitHub
parent 1e8cc2ca8d
commit f0bd258ff1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 645 additions and 342 deletions

View File

@ -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
}

View File

@ -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) {

32
coderd/apidoc/docs.go generated
View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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},
})
}

View File

@ -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(&regions)
}

View File

@ -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) |
<h3 id="get-workspace-proxies-responseschema">Response Schema</h3>
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"
}
```

View File

@ -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

View File

@ -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).

View File

@ -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
}

View File

@ -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")
})
}

View File

@ -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,

View File

@ -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{}

View File

@ -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()

View File

@ -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,

View File

@ -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)
})
}

View File

@ -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

View File

@ -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"`
//}

View File

@ -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<R extends Custom> {
readonly something: R[]
}
// From codersdk/genericmap.go
export type Custom = Foo | Buzz

View File

@ -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<string, ProxyLatencyReport>
}
return proxies.regions.reduce((acc, proxy) => {
return proxies.reduce((acc, proxy) => {
acc[proxy.id] = {
accurate: true,
// Return a constant latency of 8ms.

View File

@ -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]

View File

@ -276,6 +276,10 @@ export const AppRouter: FC = () => {
<Route path="network" element={<NetworkSettingsPage />} />
<Route path="userauth" element={<UserAuthSettingsPage />} />
<Route path="gitauth" element={<GitAuthSettingsPage />} />
<Route
path="workspace-proxies"
element={<WorkspaceProxyPage />}
/>
</Route>
<Route path="settings" element={<SettingsLayout />}>
@ -286,10 +290,6 @@ export const AppRouter: FC = () => {
<Route index element={<TokensPage />} />
<Route path="new" element={<CreateTokenPage />} />
</Route>
<Route
path="workspace-proxies"
element={<WorkspaceProxyPage />}
/>
</Route>
<Route path="/:username">

View File

@ -966,13 +966,23 @@ export const getFile = async (fileId: string): Promise<ArrayBuffer> => {
return response.data
}
export const getWorkspaceProxies =
async (): Promise<TypesGen.RegionsResponse> => {
const response = await axios.get<TypesGen.RegionsResponse>(
`/api/v2/regions`,
)
return response.data
}
export const getWorkspaceProxyRegions = async (): Promise<
TypesGen.RegionsResponse<TypesGen.Region>
> => {
const response = await axios.get<TypesGen.RegionsResponse<TypesGen.Region>>(
`/api/v2/regions`,
)
return response.data
}
export const getWorkspaceProxies = async (): Promise<
TypesGen.RegionsResponse<TypesGen.WorkspaceProxy>
> => {
const response = await axios.get<
TypesGen.RegionsResponse<TypesGen.WorkspaceProxy>
>(`/api/v2/workspaceproxies`)
return response.data
}
export const getAppearance = async (): Promise<TypesGen.AppearanceConfig> => {
try {

View File

@ -747,8 +747,8 @@ export interface Region {
}
// From codersdk/workspaceproxy.go
export interface RegionsResponse {
readonly regions: Region[]
export interface RegionsResponse<R extends RegionTypes> {
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

View File

@ -40,6 +40,24 @@ export const NotHealthyBadge: FC = () => {
)
}
export const NotRegisteredBadge: FC = () => {
const styles = useStyles()
return (
<span className={combineClasses([styles.badge, styles.warnBadge])}>
Not Registered
</span>
)
}
export const NotReachableBadge: FC = () => {
const styles = useStyles()
return (
<span className={combineClasses([styles.badge, styles.errorBadge])}>
Not Reachable
</span>
)
}
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,

View File

@ -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 (
<nav className={styles.sidebar}>
@ -76,6 +79,14 @@ export const Sidebar: React.FC = () => {
<SidebarNavItem href="network" icon={<SidebarNavItemIcon icon={Globe} />}>
Network
</SidebarNavItem>
{dashboard.experiments.includes("moons") && (
<SidebarNavItem
href="workspace-proxies"
icon={<SidebarNavItemIcon icon={HubOutlinedIcon} />}
>
Workspace Proxy
</SidebarNavItem>
)}
<SidebarNavItem
href="security"
icon={<SidebarNavItemIcon icon={LockRounded} />}

View File

@ -4,7 +4,7 @@ import {
MockUser,
MockUser2,
} from "../../testHelpers/entities"
import { render } from "../../testHelpers/renderHelpers"
import { renderWithAuth } from "../../testHelpers/renderHelpers"
import { Language as navLanguage, NavbarView } from "./NavbarView"
import { ProxyContextValue } from "contexts/ProxyContext"
import { action } from "@storybook/addon-actions"
@ -41,7 +41,7 @@ describe("NavbarView", () => {
})
it("workspaces nav link has the correct href", async () => {
render(
renderWithAuth(
<NavbarView
proxyContextValue={proxyContextValue}
user={MockUser}
@ -55,7 +55,7 @@ describe("NavbarView", () => {
})
it("templates nav link has the correct href", async () => {
render(
renderWithAuth(
<NavbarView
proxyContextValue={proxyContextValue}
user={MockUser}
@ -69,7 +69,7 @@ describe("NavbarView", () => {
})
it("users nav link has the correct href", async () => {
render(
renderWithAuth(
<NavbarView
proxyContextValue={proxyContextValue}
user={MockUser}
@ -91,7 +91,7 @@ describe("NavbarView", () => {
}
// When
render(
renderWithAuth(
<NavbarView
proxyContextValue={proxyContextValue}
user={mockUser}
@ -108,7 +108,7 @@ describe("NavbarView", () => {
})
it("audit nav link has the correct href", async () => {
render(
renderWithAuth(
<NavbarView
proxyContextValue={proxyContextValue}
user={MockUser}
@ -122,7 +122,7 @@ describe("NavbarView", () => {
})
it("audit nav link is hidden for members", async () => {
render(
renderWithAuth(
<NavbarView
proxyContextValue={proxyContextValue}
user={MockUser2}
@ -136,7 +136,7 @@ describe("NavbarView", () => {
})
it("deployment nav link has the correct href", async () => {
render(
renderWithAuth(
<NavbarView
proxyContextValue={proxyContextValue}
user={MockUser}
@ -152,7 +152,7 @@ describe("NavbarView", () => {
})
it("deployment nav link is hidden for members", async () => {
render(
renderWithAuth(
<NavbarView
proxyContextValue={proxyContextValue}
user={MockUser2}

View File

@ -23,6 +23,7 @@ import Divider from "@mui/material/Divider"
import Skeleton from "@mui/material/Skeleton"
import { BUTTON_SM_HEIGHT } from "theme/theme"
import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency"
import { usePermissions } from "hooks/usePermissions"
export const USERS_LINK = `/users?filter=${encodeURIComponent("status:active")}`
@ -194,6 +195,7 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
const latencies = proxyContextValue.proxyLatencies
const isLoadingLatencies = Object.keys(latencies).length === 0
const isLoading = proxyContextValue.isLoading || isLoadingLatencies
const permissions = usePermissions()
if (isLoading) {
return (
@ -280,15 +282,25 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
</MenuItem>
))}
<Divider sx={{ borderColor: (theme) => theme.palette.divider }} />
{Boolean(permissions.editWorkspaceProxies) && (
<MenuItem
sx={{ fontSize: 14 }}
onClick={() => {
navigate("settings/deployment/workspace-proxies")
}}
>
Proxy settings
</MenuItem>
)}
<MenuItem
sx={{ fontSize: 14 }}
onClick={() => {
navigate("/settings/workspace-proxies")
onClick={(e) => {
// Stop the menu from closing
e.stopPropagation()
// Refresh the latencies.
refreshLatencies()
}}
>
Proxy settings
</MenuItem>
<MenuItem sx={{ fontSize: 14 }} onClick={refreshLatencies}>
Refresh Latencies
</MenuItem>
</Menu>

View File

@ -9,8 +9,6 @@ import { NavLink } from "react-router-dom"
import { combineClasses } from "utils/combineClasses"
import AccountIcon from "@mui/icons-material/Person"
import SecurityIcon from "@mui/icons-material/LockOutlined"
import PublicIcon from "@mui/icons-material/Public"
import { useDashboard } from "components/Dashboard/DashboardProvider"
const SidebarNavItem: FC<
PropsWithChildren<{ href: string; icon: ReactNode }>
@ -43,7 +41,6 @@ const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
const styles = useStyles()
const dashboard = useDashboard()
return (
<nav className={styles.sidebar}>
@ -79,14 +76,6 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
>
Tokens
</SidebarNavItem>
{dashboard.experiments.includes("moons") && (
<SidebarNavItem
href="workspace-proxies"
icon={<SidebarNavItemIcon icon={PublicIcon} />}
>
Workspace Proxy
</SidebarNavItem>
)}
</nav>
)
}

View File

@ -360,6 +360,14 @@ describe("ProxyContextSelection", () => {
}),
)
}),
rest.get("/api/v2/workspaceproxies", async (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
regions: regions,
}),
)
}),
)
TestingComponent()

View File

@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query"
import { getWorkspaceProxies } from "api/api"
import { Region } from "api/typesGenerated"
import { getWorkspaceProxies, getWorkspaceProxyRegions } from "api/api"
import { Region, WorkspaceProxy } from "api/typesGenerated"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import {
createContext,
@ -12,6 +12,7 @@ import {
useState,
} from "react"
import { ProxyLatencyReport, useProxyLatency } from "./useProxyLatency"
import { usePermissions } from "hooks/usePermissions"
export interface ProxyContextValue {
// proxy is **always** the workspace proxy that should be used.
@ -36,7 +37,12 @@ export interface ProxyContextValue {
// proxies is the list of proxies returned by coderd. This is fetched async.
// isFetched, isLoading, and error are used to track the state of the async call.
proxies?: Region[]
//
// Region[] is returned if the user is a non-admin.
// WorkspaceProxy[] is returned if the user is an admin. WorkspaceProxy extends Region with
// more information about the proxy and the status. More information includes the error message if
// the proxy is unhealthy.
proxies?: Region[] | WorkspaceProxy[]
// isFetched is true when the 'proxies' api call is complete.
isFetched: boolean
isLoading: boolean
@ -103,12 +109,26 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
if (regions) {
const rawContent = regions.getAttribute("content")
try {
return JSON.parse(rawContent as string)
const obj = JSON.parse(rawContent as string)
if ("regions" in obj) {
return obj.regions as Region[]
}
return obj as Region[]
} catch (ex) {
// Ignore this and fetch as normal!
}
}
})
const permissions = usePermissions()
const query = async (): Promise<Region[]> => {
const endpoint = permissions.editWorkspaceProxies
? getWorkspaceProxies
: getWorkspaceProxyRegions
const resp = await endpoint()
return resp.regions
}
const {
data: proxiesResp,
error: proxiesError,
@ -116,7 +136,7 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
isFetched: proxiesFetched,
} = useQuery({
queryKey,
queryFn: getWorkspaceProxies,
queryFn: query,
staleTime: initialData ? Infinity : undefined,
initialData,
})
@ -133,7 +153,7 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
setUserSavedProxy(loadUserSelectedProxy())
setProxy(
getPreferredProxy(
proxiesResp?.regions ?? [],
proxiesResp ?? [],
loadUserSelectedProxy(),
proxyLatencies,
// Do not auto select based on latencies, as inconsistent latencies can cause this
@ -161,9 +181,9 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
: {
// If the experiment is disabled, then call 'getPreferredProxy' with the regions from
// the api call. The default behavior is to use the `primary` proxy.
...getPreferredProxy(proxiesResp?.regions || []),
...getPreferredProxy(proxiesResp || []),
},
proxies: proxiesResp?.regions,
proxies: proxiesResp,
isLoading: proxiesLoading,
isFetched: proxiesFetched,
error: proxiesError,

View File

@ -1,4 +1,4 @@
import { Region, RegionsResponse } from "api/typesGenerated"
import { Region } from "api/typesGenerated"
import { useEffect, useReducer, useState } from "react"
import PerformanceObserver from "@fastly/performance-observer-polyfill"
import axios from "axios"
@ -33,11 +33,11 @@ const proxyLatenciesReducer = (
}
export const useProxyLatency = (
proxies?: RegionsResponse,
proxies?: Region[],
): {
// Refetch can be called to refetch the proxy latencies.
// Until the new values are loaded, the old values will still be used.
refetch: () => void
refetch: () => Date
proxyLatencies: Record<string, ProxyLatencyReport>
} => {
// maxStoredLatencies is the maximum number of latencies to store per proxy in local storage.
@ -62,7 +62,9 @@ export const useProxyLatency = (
new Date().toISOString(),
)
const refetch = () => {
setLatestFetchRequest(new Date().toISOString())
const d = new Date()
setLatestFetchRequest(d.toISOString())
return d
}
// Only run latency updates when the proxies change.
@ -74,7 +76,7 @@ export const useProxyLatency = (
// proxyMap is a map of the proxy path_app_url to the proxy object.
// This is for the observer to know which requests are important to
// record.
const proxyChecks = proxies.regions.reduce((acc, proxy) => {
const proxyChecks = proxies.reduce((acc, proxy) => {
// Only run the latency check on healthy proxies.
if (!proxy.healthy) {
return acc
@ -216,7 +218,7 @@ const updateStoredLatencies = (action: ProxyLatencyAction): void => {
// garbageCollectStoredLatencies will remove any latencies that are older then 1 week or latencies of proxies
// that no longer exist. This is intended to keep the size of local storage down.
const garbageCollectStoredLatencies = (
regions: RegionsResponse,
regions: Region[],
maxStored: number,
): void => {
const latencies = loadStoredLatencies()
@ -228,12 +230,12 @@ const garbageCollectStoredLatencies = (
const cleanupLatencies = (
stored: Record<string, ProxyLatencyReport[]>,
regions: RegionsResponse,
regions: Region[],
now: Date,
maxStored: number,
): Record<string, ProxyLatencyReport[]> => {
Object.keys(stored).forEach((proxyID) => {
if (!regions.regions.find((region) => region.id === proxyID)) {
if (!regions.find((region) => region.id === proxyID)) {
delete stored[proxyID]
return
}

View File

@ -2,16 +2,13 @@ import { FC, PropsWithChildren } from "react"
import { Section } from "components/SettingsLayout/Section"
import { WorkspaceProxyView } from "./WorkspaceProxyView"
import makeStyles from "@mui/styles/makeStyles"
import { displayError } from "components/GlobalSnackbar/utils"
import { useProxy } from "contexts/ProxyContext"
export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
const styles = useStyles()
const description =
"Workspace proxies are used to reduce the latency of connections to your workspaces." +
"To get the best experience, choose the workspace proxy that is closest to you." +
"This selection only affects browser connections to your workspace."
"Workspace proxies are used to reduce the latency of connections to your workspaces."
const {
proxyLatencies,
@ -20,7 +17,6 @@ export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
isFetched: proxiesFetched,
isLoading: proxiesLoading,
proxy,
setProxy,
} = useProxy()
return (
@ -37,14 +33,6 @@ export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
hasLoaded={proxiesFetched}
getWorkspaceProxiesError={proxiesError}
preferredProxy={proxy.proxy}
onSelect={(proxy) => {
if (!proxy.healthy) {
displayError("Please select a healthy workspace proxy.")
return
}
setProxy(proxy)
}}
/>
</Section>
)

View File

@ -1,83 +1,95 @@
import { Region } from "api/typesGenerated"
import { Region, WorkspaceProxy } from "api/typesGenerated"
import { AvatarData } from "components/AvatarData/AvatarData"
import { Avatar } from "components/Avatar/Avatar"
import { useClickableTableRow } from "hooks/useClickableTableRow"
import TableCell from "@mui/material/TableCell"
import TableRow from "@mui/material/TableRow"
import { FC } from "react"
import {
HealthyBadge,
NotHealthyBadge,
NotReachableBadge,
NotRegisteredBadge,
} from "components/DeploySettingsLayout/Badges"
import { makeStyles } from "@mui/styles"
import { combineClasses } from "utils/combineClasses"
import { ProxyLatencyReport } from "contexts/useProxyLatency"
import { getLatencyColor } from "utils/latency"
import { alpha } from "@mui/material/styles"
export const ProxyRow: FC<{
latency?: ProxyLatencyReport
proxy: Region
onSelectRegion: (proxy: Region) => void
preferred: boolean
}> = ({ proxy, onSelectRegion, preferred, latency }) => {
const styles = useStyles()
const clickable = useClickableTableRow(() => {
onSelectRegion(proxy)
})
}> = ({ proxy, latency }) => {
// If we have a more specific proxy status, use that.
// All users can see healthy/unhealthy, some can see more.
let statusBadge = <ProxyStatus proxy={proxy} />
if ("status" in proxy) {
statusBadge = <DetailedProxyStatus proxy={proxy as WorkspaceProxy} />
}
return (
<TableRow
key={proxy.name}
data-testid={`${proxy.name}`}
{...clickable}
// Make sure to include our classname here.
className={combineClasses({
[clickable.className]: true,
[styles.preferredrow]: preferred,
})}
>
<TableCell>
<AvatarData
title={
proxy.display_name && proxy.display_name.length > 0
? proxy.display_name
: proxy.name
}
avatar={
proxy.icon_url !== "" && (
<Avatar
size="sm"
src={proxy.icon_url}
variant="square"
fitImage
/>
)
}
/>
</TableCell>
<>
<TableRow key={proxy.name} data-testid={`${proxy.name}`}>
<TableCell>
<AvatarData
title={
proxy.display_name && proxy.display_name.length > 0
? proxy.display_name
: proxy.name
}
avatar={
proxy.icon_url !== "" && (
<Avatar
size="sm"
src={proxy.icon_url}
variant="square"
fitImage
/>
)
}
/>
</TableCell>
<TableCell sx={{ fontSize: 14 }}>{proxy.path_app_url}</TableCell>
<TableCell sx={{ fontSize: 14 }}>
<ProxyStatus proxy={proxy} />
</TableCell>
<TableCell
sx={{
fontSize: 14,
textAlign: "right",
color: (theme) =>
latency
? getLatencyColor(theme, latency.latencyMS)
: theme.palette.text.secondary,
}}
>
{latency ? `${latency.latencyMS.toFixed(0)} ms` : "Not available"}
</TableCell>
</TableRow>
<TableCell sx={{ fontSize: 14 }}>{proxy.path_app_url}</TableCell>
<TableCell sx={{ fontSize: 14 }}>{statusBadge}</TableCell>
<TableCell
sx={{
fontSize: 14,
textAlign: "right",
color: (theme) =>
latency
? getLatencyColor(theme, latency.latencyMS)
: theme.palette.text.secondary,
}}
>
{latency ? `${latency.latencyMS.toFixed(0)} ms` : "Not available"}
</TableCell>
</TableRow>
</>
)
}
// DetailedProxyStatus allows a more precise status to be displayed.
const DetailedProxyStatus: FC<{
proxy: WorkspaceProxy
}> = ({ proxy }) => {
if (!proxy.status) {
// If the status is null/undefined/not provided, just go with the boolean "healthy" value.
return <ProxyStatus proxy={proxy} />
}
switch (proxy.status.status) {
case "ok":
return <HealthyBadge />
case "unhealthy":
return <NotHealthyBadge />
case "unreachable":
return <NotReachableBadge />
case "unregistered":
return <NotRegisteredBadge />
default:
return <NotHealthyBadge />
}
}
// ProxyStatus will only show "healthy" or "not healthy" status.
const ProxyStatus: FC<{
proxy: Region
}> = ({ proxy }) => {
@ -88,14 +100,3 @@ const ProxyStatus: FC<{
return icon
}
const useStyles = makeStyles((theme) => ({
preferredrow: {
backgroundColor: alpha(
theme.palette.primary.main,
theme.palette.action.hoverOpacity,
),
outline: `1px solid ${theme.palette.primary.main}`,
outlineOffset: "-1px",
},
}))

View File

@ -20,7 +20,6 @@ export interface WorkspaceProxyViewProps {
getWorkspaceProxiesError?: Error | unknown
isLoading: boolean
hasLoaded: boolean
onSelect: (proxy: Region) => void
preferredProxy?: Region
selectProxyError?: Error | unknown
}
@ -33,9 +32,7 @@ export const WorkspaceProxyView: FC<
getWorkspaceProxiesError,
isLoading,
hasLoaded,
onSelect,
selectProxyError,
preferredProxy,
}) => {
return (
<Stack>
@ -69,10 +66,6 @@ export const WorkspaceProxyView: FC<
latency={proxyLatencies?.[proxy.id]}
key={proxy.id}
proxy={proxy}
onSelectRegion={onSelect}
preferred={
preferredProxy ? proxy.id === preferredProxy.id : false
}
/>
))}
</Cond>

View File

@ -30,9 +30,6 @@ PrimarySelected.args = {
proxies: MockWorkspaceProxies,
proxyLatencies: MockProxyLatencies,
preferredProxy: MockPrimaryWorkspaceProxy,
onSelect: () => {
return Promise.resolve()
},
}
export const Example = Template.bind({})
@ -42,9 +39,6 @@ Example.args = {
proxies: MockWorkspaceProxies,
proxyLatencies: MockProxyLatencies,
preferredProxy: MockHealthyWildWorkspaceProxy,
onSelect: () => {
return Promise.resolve()
},
}
export const Loading = Template.bind({})

View File

@ -71,7 +71,7 @@ export const MockTokens: TypesGen.APIKeyWithOwner[] = [
},
]
export const MockPrimaryWorkspaceProxy: TypesGen.Region = {
export const MockPrimaryWorkspaceProxy: TypesGen.WorkspaceProxy = {
id: "4aa23000-526a-481f-a007-0f20b98b1e12",
name: "primary",
display_name: "Default",
@ -79,9 +79,16 @@ export const MockPrimaryWorkspaceProxy: TypesGen.Region = {
healthy: true,
path_app_url: "https://coder.com",
wildcard_hostname: "*.coder.com",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted: false,
status: {
status: "ok",
checked_at: new Date().toISOString(),
},
}
export const MockHealthyWildWorkspaceProxy: TypesGen.Region = {
export const MockHealthyWildWorkspaceProxy: TypesGen.WorkspaceProxy = {
id: "5e2c1ab7-479b-41a9-92ce-aa85625de52c",
name: "haswildcard",
display_name: "Subdomain Supported",
@ -89,9 +96,16 @@ export const MockHealthyWildWorkspaceProxy: TypesGen.Region = {
healthy: true,
path_app_url: "https://external.com",
wildcard_hostname: "*.external.com",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted: false,
status: {
status: "ok",
checked_at: new Date().toISOString(),
},
}
export const MockUnhealthyWildWorkspaceProxy: TypesGen.Region = {
export const MockUnhealthyWildWorkspaceProxy: TypesGen.WorkspaceProxy = {
id: "8444931c-0247-4171-842a-569d9f9cbadb",
name: "unhealthy",
display_name: "Unhealthy",
@ -99,9 +113,20 @@ export const MockUnhealthyWildWorkspaceProxy: TypesGen.Region = {
healthy: false,
path_app_url: "https://unhealthy.coder.com",
wildcard_hostname: "*unhealthy..coder.com",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted: false,
status: {
status: "unhealthy",
report: {
errors: ["This workspace proxy is manually marked as unhealthy."],
warnings: ["This is a manual warning for this workspace proxy."],
},
checked_at: new Date().toISOString(),
},
}
export const MockWorkspaceProxies: TypesGen.Region[] = [
export const MockWorkspaceProxies: TypesGen.WorkspaceProxy[] = [
MockPrimaryWorkspaceProxy,
MockHealthyWildWorkspaceProxy,
MockUnhealthyWildWorkspaceProxy,
@ -113,6 +138,13 @@ export const MockWorkspaceProxies: TypesGen.Region[] = [
healthy: true,
path_app_url: "https://cowboy.coder.com",
wildcard_hostname: "",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted: false,
status: {
status: "ok",
checked_at: new Date().toISOString(),
},
},
]
@ -1625,6 +1657,7 @@ export const MockPermissions: Permissions = {
viewUpdateCheck: true,
viewDeploymentStats: true,
viewGitAuthConfig: true,
editWorkspaceProxies: true,
}
export const MockDeploymentConfig: Types.DeploymentConfig = {

View File

@ -24,6 +24,14 @@ export const handlers = [
}),
)
}),
rest.get("/api/v2/workspaceproxies", async (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
regions: M.MockWorkspaceProxies,
}),
)
}),
// build info
rest.get("/api/v2/buildinfo", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockBuildInfo))

View File

@ -19,6 +19,7 @@ export const checks = {
viewUpdateCheck: "viewUpdateCheck",
viewGitAuthConfig: "viewGitAuthConfig",
viewDeploymentStats: "viewDeploymentStats",
editWorkspaceProxies: "editWorkspaceProxies",
} as const
export const permissionsToCheck = {
@ -88,6 +89,12 @@ export const permissionsToCheck = {
},
action: "read",
},
[checks.editWorkspaceProxies]: {
object: {
resource_type: "workspace_proxy",
},
action: "create",
},
} as const
export type Permissions = Record<keyof typeof permissionsToCheck, boolean>