mirror of https://github.com/coder/coder.git
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:
parent
1e8cc2ca8d
commit
f0bd258ff1
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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},
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
```
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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{}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"`
|
||||
//}
|
|
@ -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
|
|
@ -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.
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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} />}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
}))
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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({})
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue