Merge branch 'main' into node-20

This commit is contained in:
Muhammad Atif Ali 2024-04-16 22:14:37 +03:00 committed by GitHub
commit 41e640fab3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 1606 additions and 637 deletions

View File

@ -64,7 +64,7 @@ func (r *RootCmd) openVSCode() *serpent.Command {
// need to wait for the agent to start.
workspaceQuery := inv.Args[0]
autostart := true
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, codersdk.Me, workspaceQuery)
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, workspaceQuery)
if err != nil {
return xerrors.Errorf("get workspace and agent: %w", err)
}

View File

@ -42,7 +42,7 @@ func (r *RootCmd) ping() *serpent.Command {
_, workspaceAgent, err := getWorkspaceAndAgent(
ctx, inv, client,
false, // Do not autostart for a ping.
codersdk.Me, workspaceName,
workspaceName,
)
if err != nil {
return err

View File

@ -73,7 +73,7 @@ func (r *RootCmd) portForward() *serpent.Command {
return xerrors.New("no port-forwards requested")
}
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, codersdk.Me, inv.Args[0])
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, inv.Args[0])
if err != nil {
return err
}

View File

@ -39,7 +39,7 @@ func (r *RootCmd) speedtest() *serpent.Command {
ctx, cancel := context.WithCancel(inv.Context())
defer cancel()
_, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, false, codersdk.Me, inv.Args[0])
_, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, false, inv.Args[0])
if err != nil {
return err
}

View File

@ -157,7 +157,7 @@ func (r *RootCmd) ssh() *serpent.Command {
}
}
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, codersdk.Me, inv.Args[0])
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, !disableAutostart, inv.Args[0])
if err != nil {
return err
}
@ -551,10 +551,12 @@ startWatchLoop:
// getWorkspaceAgent returns the workspace and agent selected using either the
// `<workspace>[.<agent>]` syntax via `in`.
// If autoStart is true, the workspace will be started if it is not already running.
func getWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, autostart bool, userID string, in string) (codersdk.Workspace, codersdk.WorkspaceAgent, error) { //nolint:revive
func getWorkspaceAndAgent(ctx context.Context, inv *serpent.Invocation, client *codersdk.Client, autostart bool, input string) (codersdk.Workspace, codersdk.WorkspaceAgent, error) { //nolint:revive
var (
workspace codersdk.Workspace
workspaceParts = strings.Split(in, ".")
workspace codersdk.Workspace
// The input will be `owner/name.agent`
// The agent is optional.
workspaceParts = strings.Split(input, ".")
err error
)

View File

@ -101,7 +101,7 @@ func (r *RootCmd) supportBundle() *serpent.Command {
// Check if we're running inside a workspace
if val, found := os.LookupEnv("CODER"); found && val == "true" {
_, _ = fmt.Fprintln(inv.Stderr, "Running inside Coder workspace; this can affect results!")
cliui.Warn(inv.Stderr, "Running inside Coder workspace; this can affect results!")
cliLog.Debug(inv.Context(), "running inside coder workspace")
}
@ -122,7 +122,7 @@ func (r *RootCmd) supportBundle() *serpent.Command {
if len(inv.Args) == 0 {
cliLog.Warn(inv.Context(), "no workspace specified")
_, _ = fmt.Fprintln(inv.Stderr, "Warning: no workspace specified. This will result in incomplete information.")
cliui.Warn(inv.Stderr, "No workspace specified. This will result in incomplete information.")
} else {
ws, err := namedWorkspace(inv.Context(), client, inv.Args[0])
if err != nil {
@ -184,6 +184,16 @@ func (r *RootCmd) supportBundle() *serpent.Command {
_ = os.Remove(outputPath) // best effort
return xerrors.Errorf("create support bundle: %w", err)
}
docsURL := bun.Deployment.Config.Values.DocsURL.String()
deployHealthSummary := bun.Deployment.HealthReport.Summarize(docsURL)
if len(deployHealthSummary) > 0 {
cliui.Warn(inv.Stdout, "Deployment health issues detected:", deployHealthSummary...)
}
clientNetcheckSummary := bun.Network.Netcheck.Summarize("Client netcheck:", docsURL)
if len(clientNetcheckSummary) > 0 {
cliui.Warn(inv.Stdout, "Networking issues detected:", deployHealthSummary...)
}
bun.CLILogs = cliLogBuf.Bytes()
if err := writeBundle(bun, zwr); err != nil {
@ -191,6 +201,7 @@ func (r *RootCmd) supportBundle() *serpent.Command {
return xerrors.Errorf("write support bundle to %s: %w", outputPath, err)
}
_, _ = fmt.Fprintln(inv.Stderr, "Wrote support bundle to "+outputPath)
return nil
},
}

View File

@ -110,7 +110,7 @@ func (r *RootCmd) vscodeSSH() *serpent.Command {
// will call this command after the workspace is started.
autostart := false
_, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, owner, name)
_, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, fmt.Sprintf("%s/%s", owner, name))
if err != nil {
return xerrors.Errorf("find workspace and agent: %w", err)
}

25
coderd/apidoc/docs.go generated
View File

@ -13834,7 +13834,16 @@ const docTemplate = `{
}
},
"severity": {
"$ref": "#/definitions/health.Severity"
"enum": [
"ok",
"warning",
"error"
],
"allOf": [
{
"$ref": "#/definitions/health.Severity"
}
]
},
"warnings": {
"type": "array",
@ -13917,7 +13926,7 @@ const docTemplate = `{
"warnings": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/health.Message"
}
}
}
@ -13932,10 +13941,20 @@ const docTemplate = `{
"type": "string"
},
"healthy": {
"description": "Healthy is deprecated and left for backward compatibility purposes, use ` + "`" + `Severity` + "`" + ` instead.",
"type": "boolean"
},
"severity": {
"$ref": "#/definitions/health.Severity"
"enum": [
"ok",
"warning",
"error"
],
"allOf": [
{
"$ref": "#/definitions/health.Severity"
}
]
},
"warnings": {
"type": "array",

View File

@ -12574,7 +12574,12 @@
}
},
"severity": {
"$ref": "#/definitions/health.Severity"
"enum": ["ok", "warning", "error"],
"allOf": [
{
"$ref": "#/definitions/health.Severity"
}
]
},
"warnings": {
"type": "array",
@ -12653,7 +12658,7 @@
"warnings": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/definitions/health.Message"
}
}
}
@ -12668,10 +12673,16 @@
"type": "string"
},
"healthy": {
"description": "Healthy is deprecated and left for backward compatibility purposes, use `Severity` instead.",
"type": "boolean"
},
"severity": {
"$ref": "#/definitions/health.Severity"
"enum": ["ok", "warning", "error"],
"allOf": [
{
"$ref": "#/definitions/health.Severity"
}
]
},
"warnings": {
"type": "array",

View File

@ -9089,7 +9089,6 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
params = append(params, param)
}
var innerErr error
index := slices.IndexFunc(params, func(buildParam database.WorkspaceBuildParameter) bool {
// If hasParam matches, then we are done. This is a good match.
if slices.ContainsFunc(arg.HasParam, func(name string) bool {
@ -9116,9 +9115,6 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
return match
})
if innerErr != nil {
return nil, xerrors.Errorf("error searching workspace build params: %w", innerErr)
}
if index < 0 {
continue
}

View File

@ -179,8 +179,9 @@ func (m metricsStore) DeleteApplicationConnectAPIKeysByUserID(ctx context.Contex
func (m metricsStore) DeleteCoordinator(ctx context.Context, id uuid.UUID) error {
start := time.Now()
defer m.queryLatencies.WithLabelValues("DeleteCoordinator").Observe(time.Since(start).Seconds())
return m.s.DeleteCoordinator(ctx, id)
r0 := m.s.DeleteCoordinator(ctx, id)
m.queryLatencies.WithLabelValues("DeleteCoordinator").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) DeleteExternalAuthLink(ctx context.Context, arg database.DeleteExternalAuthLinkParams) error {
@ -283,14 +284,16 @@ func (m metricsStore) DeleteReplicasUpdatedBefore(ctx context.Context, updatedAt
func (m metricsStore) DeleteTailnetAgent(ctx context.Context, arg database.DeleteTailnetAgentParams) (database.DeleteTailnetAgentRow, error) {
start := time.Now()
defer m.queryLatencies.WithLabelValues("DeleteTailnetAgent").Observe(time.Since(start).Seconds())
return m.s.DeleteTailnetAgent(ctx, arg)
r0, r1 := m.s.DeleteTailnetAgent(ctx, arg)
m.queryLatencies.WithLabelValues("DeleteTailnetAgent").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) DeleteTailnetClient(ctx context.Context, arg database.DeleteTailnetClientParams) (database.DeleteTailnetClientRow, error) {
start := time.Now()
defer m.queryLatencies.WithLabelValues("DeleteTailnetClient").Observe(time.Since(start).Seconds())
return m.s.DeleteTailnetClient(ctx, arg)
r0, r1 := m.s.DeleteTailnetClient(ctx, arg)
m.queryLatencies.WithLabelValues("DeleteTailnetClient").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) DeleteTailnetClientSubscription(ctx context.Context, arg database.DeleteTailnetClientSubscriptionParams) error {
@ -855,14 +858,16 @@ func (m metricsStore) GetServiceBanner(ctx context.Context) (string, error) {
func (m metricsStore) GetTailnetAgents(ctx context.Context, id uuid.UUID) ([]database.TailnetAgent, error) {
start := time.Now()
defer m.queryLatencies.WithLabelValues("GetTailnetAgents").Observe(time.Since(start).Seconds())
return m.s.GetTailnetAgents(ctx, id)
r0, r1 := m.s.GetTailnetAgents(ctx, id)
m.queryLatencies.WithLabelValues("GetTailnetAgents").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetTailnetClientsForAgent(ctx context.Context, agentID uuid.UUID) ([]database.TailnetClient, error) {
start := time.Now()
defer m.queryLatencies.WithLabelValues("GetTailnetClientsForAgent").Observe(time.Since(start).Seconds())
return m.s.GetTailnetClientsForAgent(ctx, agentID)
r0, r1 := m.s.GetTailnetClientsForAgent(ctx, agentID)
m.queryLatencies.WithLabelValues("GetTailnetClientsForAgent").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]database.TailnetPeer, error) {
@ -2204,14 +2209,16 @@ func (m metricsStore) UpsertServiceBanner(ctx context.Context, value string) err
func (m metricsStore) UpsertTailnetAgent(ctx context.Context, arg database.UpsertTailnetAgentParams) (database.TailnetAgent, error) {
start := time.Now()
defer m.queryLatencies.WithLabelValues("UpsertTailnetAgent").Observe(time.Since(start).Seconds())
return m.s.UpsertTailnetAgent(ctx, arg)
r0, r1 := m.s.UpsertTailnetAgent(ctx, arg)
m.queryLatencies.WithLabelValues("UpsertTailnetAgent").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) UpsertTailnetClient(ctx context.Context, arg database.UpsertTailnetClientParams) (database.TailnetClient, error) {
start := time.Now()
defer m.queryLatencies.WithLabelValues("UpsertTailnetClient").Observe(time.Since(start).Seconds())
return m.s.UpsertTailnetClient(ctx, arg)
r0, r1 := m.s.UpsertTailnetClient(ctx, arg)
m.queryLatencies.WithLabelValues("UpsertTailnetClient").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) UpsertTailnetClientSubscription(ctx context.Context, arg database.UpsertTailnetClientSubscriptionParams) error {
@ -2223,8 +2230,9 @@ func (m metricsStore) UpsertTailnetClientSubscription(ctx context.Context, arg d
func (m metricsStore) UpsertTailnetCoordinator(ctx context.Context, id uuid.UUID) (database.TailnetCoordinator, error) {
start := time.Now()
defer m.queryLatencies.WithLabelValues("UpsertTailnetCoordinator").Observe(time.Since(start).Seconds())
return m.s.UpsertTailnetCoordinator(ctx, id)
r0, r1 := m.s.UpsertTailnetCoordinator(ctx, id)
m.queryLatencies.WithLabelValues("UpsertTailnetCoordinator").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) UpsertTailnetPeer(ctx context.Context, arg database.UpsertTailnetPeerParams) (database.TailnetPeer, error) {

View File

@ -32,6 +32,8 @@ const (
warningNodeUsesWebsocket = `Node uses WebSockets because the "Upgrade: DERP" header may be blocked on the load balancer.`
oneNodeUnhealthy = "Region is operational, but performance might be degraded as one node is unhealthy."
missingNodeReport = "Missing node health report, probably a developer error."
noSTUN = "No STUN servers are available."
stunMapVaryDest = "STUN returned different addresses; you may be behind a hard NAT."
)
type ReportOptions struct {
@ -107,9 +109,30 @@ func (r *Report) Run(ctx context.Context, opts *ReportOptions) {
ncReport, netcheckErr := nc.GetReport(ctx, opts.DERPMap)
r.Netcheck = ncReport
r.NetcheckErr = convertError(netcheckErr)
if mapVaryDest, _ := r.Netcheck.MappingVariesByDestIP.Get(); mapVaryDest {
r.Warnings = append(r.Warnings, health.Messagef(health.CodeSTUNMapVaryDest, stunMapVaryDest))
}
wg.Wait()
// Count the number of STUN-capable nodes.
var stunCapableNodes int
var stunTotalNodes int
for _, region := range r.Regions {
for _, node := range region.NodeReports {
if node.STUN.Enabled {
stunTotalNodes++
}
if node.STUN.CanSTUN {
stunCapableNodes++
}
}
}
if stunCapableNodes == 0 && stunTotalNodes > 0 {
r.Severity = health.SeverityWarning
r.Warnings = append(r.Warnings, health.Messagef(health.CodeSTUNNoNodes, noSTUN))
}
// Review region reports and select the highest severity.
for _, regionReport := range r.Regions {
if regionReport.Severity.Value() > r.Severity.Value() {
@ -133,8 +156,8 @@ func (r *RegionReport) Run(ctx context.Context) {
node = node
nodeReport = NodeReport{
DERPNodeReport: healthsdk.DERPNodeReport{
Node: node,
Healthy: true,
Node: node,
},
}
)

View File

@ -129,9 +129,67 @@ func TestDERP(t *testing.T) {
assert.True(t, report.Healthy)
assert.Equal(t, health.SeverityWarning, report.Severity)
assert.True(t, report.Dismissed)
if assert.NotEmpty(t, report.Warnings) {
if assert.Len(t, report.Warnings, 1) {
assert.Contains(t, report.Warnings[0].Code, health.CodeDERPOneNodeUnhealthy)
}
for _, region := range report.Regions {
assert.True(t, region.Healthy)
assert.True(t, region.NodeReports[0].Healthy)
assert.Empty(t, region.NodeReports[0].Warnings)
assert.Equal(t, health.SeverityOK, region.NodeReports[0].Severity)
assert.False(t, region.NodeReports[1].Healthy)
assert.Equal(t, health.SeverityError, region.NodeReports[1].Severity)
assert.Len(t, region.Warnings, 1)
}
})
t.Run("HealthyWithNoSTUN", func(t *testing.T) {
t.Parallel()
healthyDerpSrv := derp.NewServer(key.NewNode(), func(format string, args ...any) { t.Logf(format, args...) })
defer healthyDerpSrv.Close()
healthySrv := httptest.NewServer(derphttp.Handler(healthyDerpSrv))
defer healthySrv.Close()
var (
ctx = context.Background()
report = derphealth.Report{}
derpURL, _ = url.Parse(healthySrv.URL)
opts = &derphealth.ReportOptions{
DERPMap: &tailcfg.DERPMap{Regions: map[int]*tailcfg.DERPRegion{
1: {
EmbeddedRelay: true,
RegionID: 999,
Nodes: []*tailcfg.DERPNode{{
Name: "1a",
RegionID: 999,
HostName: derpURL.Host,
IPv4: derpURL.Host,
STUNPort: -1,
InsecureForTests: true,
ForceHTTP: true,
}, {
Name: "badstun",
RegionID: 999,
HostName: derpURL.Host,
STUNPort: 19302,
STUNOnly: true,
InsecureForTests: true,
ForceHTTP: true,
}},
},
}},
}
)
report.Run(ctx, opts)
assert.True(t, report.Healthy)
assert.Equal(t, health.SeverityWarning, report.Severity)
if assert.Len(t, report.Warnings, 2) {
assert.EqualValues(t, report.Warnings[1].Code, health.CodeSTUNNoNodes)
assert.EqualValues(t, report.Warnings[0].Code, health.CodeDERPOneNodeUnhealthy)
}
for _, region := range report.Regions {
assert.True(t, region.Healthy)
assert.True(t, region.NodeReports[0].Healthy)
@ -291,8 +349,10 @@ func TestDERP(t *testing.T) {
report.Run(ctx, opts)
assert.True(t, report.Healthy)
assert.Equal(t, health.SeverityOK, report.Severity)
for _, region := range report.Regions {
assert.True(t, region.Healthy)
assert.Equal(t, health.SeverityOK, region.Severity)
for _, node := range region.NodeReports {
assert.True(t, node.Healthy)
assert.False(t, node.CanExchangeMessages)
@ -304,6 +364,107 @@ func TestDERP(t *testing.T) {
}
}
})
t.Run("STUNOnly/OneBadOneGood", func(t *testing.T) {
t.Parallel()
var (
ctx = context.Background()
report = derphealth.Report{}
opts = &derphealth.ReportOptions{
DERPMap: &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: {
EmbeddedRelay: true,
RegionID: 999,
Nodes: []*tailcfg.DERPNode{{
Name: "badstun",
RegionID: 999,
HostName: "badstun.example.com",
STUNPort: 19302,
STUNOnly: true,
InsecureForTests: true,
ForceHTTP: true,
}, {
Name: "goodstun",
RegionID: 999,
HostName: "stun.l.google.com",
STUNPort: 19302,
STUNOnly: true,
InsecureForTests: true,
ForceHTTP: true,
}},
},
},
},
}
)
report.Run(ctx, opts)
assert.True(t, report.Healthy)
assert.Equal(t, health.SeverityWarning, report.Severity)
if assert.Len(t, report.Warnings, 1) {
assert.Equal(t, health.CodeDERPOneNodeUnhealthy, report.Warnings[0].Code)
}
for _, region := range report.Regions {
assert.True(t, region.Healthy)
assert.Equal(t, health.SeverityWarning, region.Severity)
// badstun
assert.False(t, region.NodeReports[0].Healthy)
assert.True(t, region.NodeReports[0].STUN.Enabled)
assert.False(t, region.NodeReports[0].STUN.CanSTUN)
assert.NotNil(t, region.NodeReports[0].STUN.Error)
// goodstun
assert.True(t, region.NodeReports[1].Healthy)
assert.True(t, region.NodeReports[1].STUN.Enabled)
assert.True(t, region.NodeReports[1].STUN.CanSTUN)
assert.Nil(t, region.NodeReports[1].STUN.Error)
}
})
t.Run("STUNOnly/NoStun", func(t *testing.T) {
t.Parallel()
var (
ctx = context.Background()
report = derphealth.Report{}
opts = &derphealth.ReportOptions{
DERPMap: &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: {
EmbeddedRelay: true,
RegionID: 999,
Nodes: []*tailcfg.DERPNode{{
Name: "badstun",
RegionID: 999,
HostName: "badstun.example.com",
STUNPort: 19302,
STUNOnly: true,
InsecureForTests: true,
ForceHTTP: true,
}},
},
},
},
}
)
report.Run(ctx, opts)
assert.False(t, report.Healthy)
assert.Equal(t, health.SeverityError, report.Severity)
for _, region := range report.Regions {
assert.False(t, region.Healthy)
assert.Equal(t, health.SeverityError, region.Severity)
for _, node := range region.NodeReports {
assert.False(t, node.Healthy)
assert.False(t, node.CanExchangeMessages)
assert.Empty(t, node.ClientLogs)
assert.True(t, node.STUN.Enabled)
assert.False(t, node.STUN.CanSTUN)
assert.NotNil(t, node.STUN.Error)
}
}
})
}
func tsDERPMap(ctx context.Context, t testing.TB) *tailcfg.DERPMap {

View File

@ -4,6 +4,7 @@ import (
"fmt"
"strings"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/coderd/util/ptr"
)
@ -36,12 +37,19 @@ const (
CodeDERPNodeUsesWebsocket Code = `EDERP01`
CodeDERPOneNodeUnhealthy Code = `EDERP02`
CodeSTUNNoNodes = `ESTUN01`
CodeSTUNMapVaryDest = `ESTUN02`
CodeProvisionerDaemonsNoProvisionerDaemons Code = `EPD01`
CodeProvisionerDaemonVersionMismatch Code = `EPD02`
CodeProvisionerDaemonAPIMajorVersionDeprecated Code = `EPD03`
)
// Default docs URL
var (
docsURLDefault = "https://coder.com/docs/v2"
)
// @typescript-generate Severity
type Severity string
@ -70,6 +78,30 @@ func (m Message) String() string {
return sb.String()
}
// URL returns a link to the admin/healthcheck docs page for the given Message.
// NOTE: if using a custom docs URL, specify base.
func (m Message) URL(base string) string {
var codeAnchor string
if m.Code == "" {
codeAnchor = strings.ToLower(string(CodeUnknown))
} else {
codeAnchor = strings.ToLower(string(m.Code))
}
if base == "" {
base = docsURLDefault
versionPath := buildinfo.Version()
if buildinfo.IsDev() {
// for development versions, just use latest
versionPath = "latest"
}
return fmt.Sprintf("%s/%s/admin/healthcheck#%s", base, versionPath, codeAnchor)
}
// We don't assume that custom docs URLs are versioned.
return fmt.Sprintf("%s/admin/healthcheck#%s", base, codeAnchor)
}
// Code is a stable identifier used to link to documentation.
// @typescript-generate Code
type Code string

View File

@ -0,0 +1,32 @@
package health_test
import (
"testing"
"github.com/coder/coder/v2/coderd/healthcheck/health"
"github.com/stretchr/testify/assert"
)
func Test_MessageURL(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
code health.Code
base string
expected string
}{
{"empty", "", "", "https://coder.com/docs/v2/latest/admin/healthcheck#eunknown"},
{"default", health.CodeAccessURLFetch, "", "https://coder.com/docs/v2/latest/admin/healthcheck#eacs03"},
{"custom docs base", health.CodeAccessURLFetch, "https://example.com/docs", "https://example.com/docs/admin/healthcheck#eacs03"},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
uut := health.Message{Code: tt.code}
actual := uut.URL(tt.base)
assert.Equal(t, tt.expected, actual)
})
}
}

View File

@ -58,27 +58,39 @@ func TestHealthcheck(t *testing.T) {
name: "OK",
checker: &testChecker{
DERPReport: healthsdk.DERPHealthReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
AccessURLReport: healthsdk.AccessURLReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WebsocketReport: healthsdk.WebsocketReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
DatabaseReport: healthsdk.DatabaseReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WorkspaceProxyReport: healthsdk.WorkspaceProxyReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
ProvisionerDaemonsReport: healthsdk.ProvisionerDaemonsReport{
Severity: health.SeverityOK,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
},
healthy: true,
@ -88,27 +100,39 @@ func TestHealthcheck(t *testing.T) {
name: "DERPFail",
checker: &testChecker{
DERPReport: healthsdk.DERPHealthReport{
Healthy: false,
Severity: health.SeverityError,
Healthy: false,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityError,
},
},
AccessURLReport: healthsdk.AccessURLReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WebsocketReport: healthsdk.WebsocketReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
DatabaseReport: healthsdk.DatabaseReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WorkspaceProxyReport: healthsdk.WorkspaceProxyReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
ProvisionerDaemonsReport: healthsdk.ProvisionerDaemonsReport{
Severity: health.SeverityOK,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
},
healthy: false,
@ -118,28 +142,40 @@ func TestHealthcheck(t *testing.T) {
name: "DERPWarning",
checker: &testChecker{
DERPReport: healthsdk.DERPHealthReport{
Healthy: true,
Warnings: []health.Message{{Message: "foobar", Code: "EFOOBAR"}},
Severity: health.SeverityWarning,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Warnings: []health.Message{{Message: "foobar", Code: "EFOOBAR"}},
Severity: health.SeverityWarning,
},
},
AccessURLReport: healthsdk.AccessURLReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WebsocketReport: healthsdk.WebsocketReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
DatabaseReport: healthsdk.DatabaseReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WorkspaceProxyReport: healthsdk.WorkspaceProxyReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
ProvisionerDaemonsReport: healthsdk.ProvisionerDaemonsReport{
Severity: health.SeverityOK,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
},
healthy: true,
@ -149,27 +185,39 @@ func TestHealthcheck(t *testing.T) {
name: "AccessURLFail",
checker: &testChecker{
DERPReport: healthsdk.DERPHealthReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
AccessURLReport: healthsdk.AccessURLReport{
Healthy: false,
Severity: health.SeverityWarning,
Healthy: false,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityWarning,
},
},
WebsocketReport: healthsdk.WebsocketReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
DatabaseReport: healthsdk.DatabaseReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WorkspaceProxyReport: healthsdk.WorkspaceProxyReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
ProvisionerDaemonsReport: healthsdk.ProvisionerDaemonsReport{
Severity: health.SeverityOK,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
},
healthy: false,
@ -179,27 +227,39 @@ func TestHealthcheck(t *testing.T) {
name: "WebsocketFail",
checker: &testChecker{
DERPReport: healthsdk.DERPHealthReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
AccessURLReport: healthsdk.AccessURLReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WebsocketReport: healthsdk.WebsocketReport{
Healthy: false,
Severity: health.SeverityError,
Healthy: false,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityError,
},
},
DatabaseReport: healthsdk.DatabaseReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WorkspaceProxyReport: healthsdk.WorkspaceProxyReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
ProvisionerDaemonsReport: healthsdk.ProvisionerDaemonsReport{
Severity: health.SeverityOK,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
},
healthy: false,
@ -209,27 +269,39 @@ func TestHealthcheck(t *testing.T) {
name: "DatabaseFail",
checker: &testChecker{
DERPReport: healthsdk.DERPHealthReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
AccessURLReport: healthsdk.AccessURLReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WebsocketReport: healthsdk.WebsocketReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
DatabaseReport: healthsdk.DatabaseReport{
Healthy: false,
Severity: health.SeverityError,
Healthy: false,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityError,
},
},
WorkspaceProxyReport: healthsdk.WorkspaceProxyReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
ProvisionerDaemonsReport: healthsdk.ProvisionerDaemonsReport{
Severity: health.SeverityOK,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
},
healthy: false,
@ -239,27 +311,39 @@ func TestHealthcheck(t *testing.T) {
name: "ProxyFail",
checker: &testChecker{
DERPReport: healthsdk.DERPHealthReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
AccessURLReport: healthsdk.AccessURLReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WebsocketReport: healthsdk.WebsocketReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
DatabaseReport: healthsdk.DatabaseReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WorkspaceProxyReport: healthsdk.WorkspaceProxyReport{
Healthy: false,
Severity: health.SeverityError,
Healthy: false,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityError,
},
},
ProvisionerDaemonsReport: healthsdk.ProvisionerDaemonsReport{
Severity: health.SeverityOK,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
},
severity: health.SeverityError,
@ -269,28 +353,40 @@ func TestHealthcheck(t *testing.T) {
name: "ProxyWarn",
checker: &testChecker{
DERPReport: healthsdk.DERPHealthReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
AccessURLReport: healthsdk.AccessURLReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WebsocketReport: healthsdk.WebsocketReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
DatabaseReport: healthsdk.DatabaseReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WorkspaceProxyReport: healthsdk.WorkspaceProxyReport{
Healthy: true,
Warnings: []health.Message{{Message: "foobar", Code: "EFOOBAR"}},
Severity: health.SeverityWarning,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Warnings: []health.Message{{Message: "foobar", Code: "EFOOBAR"}},
Severity: health.SeverityWarning,
},
},
ProvisionerDaemonsReport: healthsdk.ProvisionerDaemonsReport{
Severity: health.SeverityOK,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
},
severity: health.SeverityWarning,
@ -300,27 +396,39 @@ func TestHealthcheck(t *testing.T) {
name: "ProvisionerDaemonsFail",
checker: &testChecker{
DERPReport: healthsdk.DERPHealthReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
AccessURLReport: healthsdk.AccessURLReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WebsocketReport: healthsdk.WebsocketReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
DatabaseReport: healthsdk.DatabaseReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WorkspaceProxyReport: healthsdk.WorkspaceProxyReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
ProvisionerDaemonsReport: healthsdk.ProvisionerDaemonsReport{
Severity: health.SeverityError,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityError,
},
},
},
severity: health.SeverityError,
@ -330,28 +438,40 @@ func TestHealthcheck(t *testing.T) {
name: "ProvisionerDaemonsWarn",
checker: &testChecker{
DERPReport: healthsdk.DERPHealthReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
AccessURLReport: healthsdk.AccessURLReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WebsocketReport: healthsdk.WebsocketReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
DatabaseReport: healthsdk.DatabaseReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
WorkspaceProxyReport: healthsdk.WorkspaceProxyReport{
Healthy: true,
Severity: health.SeverityOK,
Healthy: true,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityOK,
},
},
ProvisionerDaemonsReport: healthsdk.ProvisionerDaemonsReport{
Severity: health.SeverityWarning,
Warnings: []health.Message{{Message: "foobar", Code: "EFOOBAR"}},
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityWarning,
Warnings: []health.Message{{Message: "foobar", Code: "EFOOBAR"}},
},
},
},
severity: health.SeverityWarning,
@ -362,27 +482,39 @@ func TestHealthcheck(t *testing.T) {
healthy: false,
checker: &testChecker{
DERPReport: healthsdk.DERPHealthReport{
Healthy: false,
Severity: health.SeverityError,
Healthy: false,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityError,
},
},
AccessURLReport: healthsdk.AccessURLReport{
Healthy: false,
Severity: health.SeverityError,
Healthy: false,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityError,
},
},
WebsocketReport: healthsdk.WebsocketReport{
Healthy: false,
Severity: health.SeverityError,
Healthy: false,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityError,
},
},
DatabaseReport: healthsdk.DatabaseReport{
Healthy: false,
Severity: health.SeverityError,
Healthy: false,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityError,
},
},
WorkspaceProxyReport: healthsdk.WorkspaceProxyReport{
Healthy: false,
Severity: health.SeverityError,
Healthy: false,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityError,
},
},
ProvisionerDaemonsReport: healthsdk.ProvisionerDaemonsReport{
Severity: health.SeverityError,
BaseReport: healthsdk.BaseReport{
Severity: health.SeverityError,
},
},
},
severity: health.SeverityError,

View File

@ -31,7 +31,7 @@ func (r *WebsocketReport) Run(ctx context.Context, opts *WebsocketReportOptions)
defer cancel()
r.Severity = health.SeverityOK
r.Warnings = []string{}
r.Warnings = []health.Message{}
r.Dismissed = opts.Dismissed
u, err := opts.AccessURL.Parse("/api/v2/debug/ws")

View File

@ -624,6 +624,7 @@ func ConvertWorkspaceResource(resource database.WorkspaceResource) WorkspaceReso
return WorkspaceResource{
ID: resource.ID,
JobID: resource.JobID,
CreatedAt: resource.CreatedAt,
Transition: resource.Transition,
Type: resource.Type,
InstanceType: resource.InstanceType.String,
@ -833,6 +834,7 @@ type User struct {
type WorkspaceResource struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created_at"`
JobID uuid.UUID `json:"job_id"`
Transition database.WorkspaceTransition `json:"transition"`
Type string `json:"type"`

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"golang.org/x/xerrors"
@ -95,6 +96,7 @@ func (c *HealthClient) PutHealthSettings(ctx context.Context, settings HealthSet
return nil
}
// HealthcheckReport contains information about the health status of a Coder deployment.
type HealthcheckReport struct {
// Time is the time the report was generated at.
Time time.Time `json:"time" format:"date-time"`
@ -117,52 +119,97 @@ type HealthcheckReport struct {
CoderVersion string `json:"coder_version"`
}
// Summarize returns a summary of all errors and warnings of components of HealthcheckReport.
func (r *HealthcheckReport) Summarize(docsURL string) []string {
var msgs []string
msgs = append(msgs, r.AccessURL.Summarize("Access URL:", docsURL)...)
msgs = append(msgs, r.Database.Summarize("Database:", docsURL)...)
msgs = append(msgs, r.DERP.Summarize("DERP:", docsURL)...)
msgs = append(msgs, r.ProvisionerDaemons.Summarize("Provisioner Daemons:", docsURL)...)
msgs = append(msgs, r.Websocket.Summarize("Websocket:", docsURL)...)
msgs = append(msgs, r.WorkspaceProxy.Summarize("Workspace Proxies:", docsURL)...)
return msgs
}
// BaseReport holds fields common to various health reports.
type BaseReport struct {
Error *string `json:"error"`
Severity health.Severity `json:"severity" enums:"ok,warning,error"`
Warnings []health.Message `json:"warnings"`
Dismissed bool `json:"dismissed"`
}
// Summarize returns a list of strings containing the errors and warnings of BaseReport, if present.
// All strings are prefixed with prefix.
func (b *BaseReport) Summarize(prefix, docsURL string) []string {
if b == nil {
return []string{}
}
var msgs []string
if b.Error != nil {
var sb strings.Builder
if prefix != "" {
_, _ = sb.WriteString(prefix)
_, _ = sb.WriteString(" ")
}
_, _ = sb.WriteString("Error: ")
_, _ = sb.WriteString(*b.Error)
msgs = append(msgs, sb.String())
}
for _, warn := range b.Warnings {
var sb strings.Builder
if prefix != "" {
_, _ = sb.WriteString(prefix)
_, _ = sb.WriteString(" ")
}
_, _ = sb.WriteString("Warn: ")
_, _ = sb.WriteString(warn.String())
msgs = append(msgs, sb.String())
msgs = append(msgs, "See: "+warn.URL(docsURL))
}
return msgs
}
// AccessURLReport shows the results of performing a HTTP_GET to the /healthz endpoint through the configured access URL.
type AccessURLReport struct {
BaseReport
// Healthy is deprecated and left for backward compatibility purposes, use `Severity` instead.
Healthy bool `json:"healthy"`
Severity health.Severity `json:"severity" enums:"ok,warning,error"`
Warnings []health.Message `json:"warnings"`
Dismissed bool `json:"dismissed"`
AccessURL string `json:"access_url"`
Reachable bool `json:"reachable"`
StatusCode int `json:"status_code"`
HealthzResponse string `json:"healthz_response"`
Error *string `json:"error"`
Healthy bool `json:"healthy"`
AccessURL string `json:"access_url"`
Reachable bool `json:"reachable"`
StatusCode int `json:"status_code"`
HealthzResponse string `json:"healthz_response"`
}
// DERPHealthReport includes health details of each configured DERP/STUN region.
type DERPHealthReport struct {
BaseReport
// Healthy is deprecated and left for backward compatibility purposes, use `Severity` instead.
Healthy bool `json:"healthy"`
Severity health.Severity `json:"severity" enums:"ok,warning,error"`
Warnings []health.Message `json:"warnings"`
Dismissed bool `json:"dismissed"`
Regions map[int]*DERPRegionReport `json:"regions"`
Netcheck *netcheck.Report `json:"netcheck"`
NetcheckErr *string `json:"netcheck_err"`
NetcheckLogs []string `json:"netcheck_logs"`
Error *string `json:"error"`
Healthy bool `json:"healthy"`
Regions map[int]*DERPRegionReport `json:"regions"`
Netcheck *netcheck.Report `json:"netcheck"`
NetcheckErr *string `json:"netcheck_err"`
NetcheckLogs []string `json:"netcheck_logs"`
}
// DERPHealthReport includes health details of each node in a single region.
type DERPRegionReport struct {
// Healthy is deprecated and left for backward compatibility purposes, use `Severity` instead.
Healthy bool `json:"healthy"`
Severity health.Severity `json:"severity" enums:"ok,warning,error"`
Warnings []health.Message `json:"warnings"`
Healthy bool `json:"healthy"`
Severity health.Severity `json:"severity" enums:"ok,warning,error"`
Warnings []health.Message `json:"warnings"`
Error *string `json:"error"`
Region *tailcfg.DERPRegion `json:"region"`
NodeReports []*DERPNodeReport `json:"node_reports"`
Error *string `json:"error"`
}
// DERPHealthReport includes health details of a single node in a single region.
type DERPNodeReport struct {
// Healthy is deprecated and left for backward compatibility purposes, use `Severity` instead.
Healthy bool `json:"healthy"`
Severity health.Severity `json:"severity" enums:"ok,warning,error"`
Warnings []health.Message `json:"warnings"`
Error *string `json:"error"`
Node *tailcfg.DERPNode `json:"node"`
@ -173,37 +220,31 @@ type DERPNodeReport struct {
UsesWebsocket bool `json:"uses_websocket"`
ClientLogs [][]string `json:"client_logs"`
ClientErrs [][]string `json:"client_errs"`
Error *string `json:"error"`
STUN STUNReport `json:"stun"`
}
// STUNReport contains information about a given node's STUN capabilities.
type STUNReport struct {
Enabled bool
CanSTUN bool
Error *string
}
// DatabaseReport shows the results of pinging the configured database.Conn.
type DatabaseReport struct {
BaseReport
// Healthy is deprecated and left for backward compatibility purposes, use `Severity` instead.
Healthy bool `json:"healthy"`
Severity health.Severity `json:"severity" enums:"ok,warning,error"`
Warnings []health.Message `json:"warnings"`
Dismissed bool `json:"dismissed"`
Reachable bool `json:"reachable"`
Latency string `json:"latency"`
LatencyMS int64 `json:"latency_ms"`
ThresholdMS int64 `json:"threshold_ms"`
Error *string `json:"error"`
Healthy bool `json:"healthy"`
Reachable bool `json:"reachable"`
Latency string `json:"latency"`
LatencyMS int64 `json:"latency_ms"`
ThresholdMS int64 `json:"threshold_ms"`
}
// ProvisionerDaemonsReport includes health details of each connected provisioner daemon.
type ProvisionerDaemonsReport struct {
Severity health.Severity `json:"severity"`
Warnings []health.Message `json:"warnings"`
Dismissed bool `json:"dismissed"`
Error *string `json:"error"`
BaseReport
Items []ProvisionerDaemonsReportItem `json:"items"`
}
@ -212,24 +253,19 @@ type ProvisionerDaemonsReportItem struct {
Warnings []health.Message `json:"warnings"`
}
// WebsocketReport shows if the configured access URL allows establishing WebSocket connections.
type WebsocketReport struct {
// Healthy is deprecated and left for backward compatibility purposes, use `Severity` instead.
Healthy bool `json:"healthy"`
Severity health.Severity `json:"severity" enums:"ok,warning,error"`
Warnings []string `json:"warnings"`
Dismissed bool `json:"dismissed"`
Body string `json:"body"`
Code int `json:"code"`
Error *string `json:"error"`
Healthy bool `json:"healthy"`
BaseReport
Body string `json:"body"`
Code int `json:"code"`
}
// WorkspaceProxyReport includes health details of each connected workspace proxy.
type WorkspaceProxyReport struct {
Healthy bool `json:"healthy"`
Severity health.Severity `json:"severity"`
Warnings []health.Message `json:"warnings"`
Dismissed bool `json:"dismissed"`
Error *string `json:"error"`
// Healthy is deprecated and left for backward compatibility purposes, use `Severity` instead.
Healthy bool `json:"healthy"`
BaseReport
WorkspaceProxies codersdk.RegionsResponse[codersdk.WorkspaceProxy] `json:"workspace_proxies"`
}

View File

@ -0,0 +1,137 @@
package healthsdk_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/coder/coder/v2/coderd/healthcheck/health"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk/healthsdk"
)
func TestSummarize(t *testing.T) {
t.Parallel()
t.Run("HealthcheckReport", func(t *testing.T) {
unhealthy := healthsdk.BaseReport{
Error: ptr.Ref("test error"),
Warnings: []health.Message{{Code: "TEST", Message: "testing"}},
}
hr := healthsdk.HealthcheckReport{
AccessURL: healthsdk.AccessURLReport{
BaseReport: unhealthy,
},
Database: healthsdk.DatabaseReport{
BaseReport: unhealthy,
},
DERP: healthsdk.DERPHealthReport{
BaseReport: unhealthy,
},
ProvisionerDaemons: healthsdk.ProvisionerDaemonsReport{
BaseReport: unhealthy,
},
Websocket: healthsdk.WebsocketReport{
BaseReport: unhealthy,
},
WorkspaceProxy: healthsdk.WorkspaceProxyReport{
BaseReport: unhealthy,
},
}
expected := []string{
"Access URL: Error: test error",
"Access URL: Warn: TEST: testing",
"See: https://coder.com/docs/v2/latest/admin/healthcheck#test",
"Database: Error: test error",
"Database: Warn: TEST: testing",
"See: https://coder.com/docs/v2/latest/admin/healthcheck#test",
"DERP: Error: test error",
"DERP: Warn: TEST: testing",
"See: https://coder.com/docs/v2/latest/admin/healthcheck#test",
"Provisioner Daemons: Error: test error",
"Provisioner Daemons: Warn: TEST: testing",
"See: https://coder.com/docs/v2/latest/admin/healthcheck#test",
"Websocket: Error: test error",
"Websocket: Warn: TEST: testing",
"See: https://coder.com/docs/v2/latest/admin/healthcheck#test",
"Workspace Proxies: Error: test error",
"Workspace Proxies: Warn: TEST: testing",
"See: https://coder.com/docs/v2/latest/admin/healthcheck#test",
}
actual := hr.Summarize("")
assert.Equal(t, expected, actual)
})
for _, tt := range []struct {
name string
br healthsdk.BaseReport
pfx string
expected []string
}{
{
name: "empty",
br: healthsdk.BaseReport{},
pfx: "",
expected: []string{},
},
{
name: "no prefix",
br: healthsdk.BaseReport{
Error: ptr.Ref("testing"),
Warnings: []health.Message{
{
Code: "TEST01",
Message: "testing one",
},
{
Code: "TEST02",
Message: "testing two",
},
},
},
pfx: "",
expected: []string{
"Error: testing",
"Warn: TEST01: testing one",
"See: https://coder.com/docs/v2/latest/admin/healthcheck#test01",
"Warn: TEST02: testing two",
"See: https://coder.com/docs/v2/latest/admin/healthcheck#test02",
},
},
{
name: "prefix",
br: healthsdk.BaseReport{
Error: ptr.Ref("testing"),
Warnings: []health.Message{
{
Code: "TEST01",
Message: "testing one",
},
{
Code: "TEST02",
Message: "testing two",
},
},
},
pfx: "TEST:",
expected: []string{
"TEST: Error: testing",
"TEST: Warn: TEST01: testing one",
"See: https://coder.com/docs/v2/latest/admin/healthcheck#test01",
"TEST: Warn: TEST02: testing two",
"See: https://coder.com/docs/v2/latest/admin/healthcheck#test02",
},
},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
actual := tt.br.Summarize(tt.pfx, "")
if len(tt.expected) == 0 {
assert.Empty(t, actual)
return
}
assert.Equal(t, tt.expected, actual)
})
}
}

View File

@ -170,6 +170,31 @@ curl -v "https://coder.company.com/derp"
# DERP requires connection upgrade
```
### ESTUN01
_No STUN servers available._
**Problem:** This is shown if no STUN servers are available. Coder will use STUN
to establish [direct connections](../networking/stun.md). Without at least one
working STUN server, direct connections may not be possible.
**Solution:** Ensure that the
[configured STUN severs](../cli/server.md#derp-server-stun-addresses) are
reachable from Coder and that UDP traffic can be sent/received on the configured
port.
### ESTUN02
_STUN returned different addresses; you may be behind a hard NAT._
**Problem:** This is a warning shown when multiple attempts to determine our
public IP address/port via STUN resulted in different `ip:port` combinations.
This is a sign that you are behind a "hard NAT", and may result in difficulty
establishing direct connections. However, it does not mean that direct
connections are impossible.
**Solution:** Engage with your network administrator.
## Websocket
Coder makes heavy use of [WebSockets](https://datatracker.ietf.org/doc/rfc6455/)

7
docs/api/debug.md generated
View File

@ -325,7 +325,12 @@ curl -X GET http://coder-server:8080/api/v2/debug/health \
"error": "string",
"healthy": true,
"severity": "ok",
"warnings": ["string"]
"warnings": [
{
"code": "EUNKNOWN",
"message": "string"
}
]
},
"workspace_proxy": {
"dismissed": true,

64
docs/api/schemas.md generated
View File

@ -8134,7 +8134,12 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"error": "string",
"healthy": true,
"severity": "ok",
"warnings": ["string"]
"warnings": [
{
"code": "EUNKNOWN",
"message": "string"
}
]
},
"workspace_proxy": {
"dismissed": true,
@ -8251,6 +8256,14 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `severity` | [health.Severity](#healthseverity) | false | | |
| `warnings` | array of [health.Message](#healthmessage) | false | | |
#### Enumerated Values
| Property | Value |
| ---------- | --------- |
| `severity` | `ok` |
| `severity` | `warning` |
| `severity` | `error` |
## healthsdk.ProvisionerDaemonsReportItem
```json
@ -8326,21 +8339,26 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"error": "string",
"healthy": true,
"severity": "ok",
"warnings": ["string"]
"warnings": [
{
"code": "EUNKNOWN",
"message": "string"
}
]
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ----------- | ---------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------- |
| `body` | string | false | | |
| `code` | integer | false | | |
| `dismissed` | boolean | false | | |
| `error` | string | false | | |
| `healthy` | boolean | false | | Healthy is deprecated and left for backward compatibility purposes, use `Severity` instead. |
| `severity` | [health.Severity](#healthseverity) | false | | |
| `warnings` | array of string | false | | |
| Name | Type | Required | Restrictions | Description |
| ----------- | ----------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------- |
| `body` | string | false | | |
| `code` | integer | false | | |
| `dismissed` | boolean | false | | |
| `error` | string | false | | |
| `healthy` | boolean | false | | Healthy is deprecated and left for backward compatibility purposes, use `Severity` instead. |
| `severity` | [health.Severity](#healthseverity) | false | | |
| `warnings` | array of [health.Message](#healthmessage) | false | | |
#### Enumerated Values
@ -8396,14 +8414,22 @@ If the schedule is empty, the user will be updated to use the default schedule.|
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------- | ---------------------------------------------------------------------------------------------------- | -------- | ------------ | ----------- |
| `dismissed` | boolean | false | | |
| `error` | string | false | | |
| `healthy` | boolean | false | | |
| `severity` | [health.Severity](#healthseverity) | false | | |
| `warnings` | array of [health.Message](#healthmessage) | false | | |
| `workspace_proxies` | [codersdk.RegionsResponse-codersdk_WorkspaceProxy](#codersdkregionsresponse-codersdk_workspaceproxy) | false | | |
| Name | Type | Required | Restrictions | Description |
| ------------------- | ---------------------------------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------- |
| `dismissed` | boolean | false | | |
| `error` | string | false | | |
| `healthy` | boolean | false | | Healthy is deprecated and left for backward compatibility purposes, use `Severity` instead. |
| `severity` | [health.Severity](#healthseverity) | false | | |
| `warnings` | array of [health.Message](#healthmessage) | false | | |
| `workspace_proxies` | [codersdk.RegionsResponse-codersdk_WorkspaceProxy](#codersdkregionsresponse-codersdk_workspaceproxy) | false | | |
#### Enumerated Values
| Property | Value |
| ---------- | --------- |
| `severity` | `ok` |
| `severity` | `warning` |
| `severity` | `error` |
## key.NodePublic

View File

@ -12,7 +12,7 @@ March 17, 2024
This guide will walk you through the process of adding
[JFrog Xray](https://jfrog.com/xray/) integration to Coder Kubernetes workspaces
using Coder's [JFrog Xray Integration](github.com/coder/coder-xray).
using Coder's [JFrog Xray Integration](https://github.com/coder/coder-xray).
## Prerequisites
@ -65,8 +65,8 @@ image = "<ARTIFACTORY_URL>/<REPO>/<IMAGE>:<TAG>"
> **Note**: To authenticate with the Artifactory registry, you may need to
> create a
> [Docker config](https://jfrog.com/artifactory/docs/docker/#docker-login) and
> use it in the `imagePullSecrets` field of the kubernetes pod. See this
> [Docker config](https://jfrog.com/help/r/jfrog-artifactory-documentation/docker-advanced-topics)
> and use it in the `imagePullSecrets` field of the kubernetes pod. See this
> [guide](./image-pull-secret.md) for more information.
![JFrog Xray Integration](../images/guides/xray-integration/example.png)

View File

@ -213,7 +213,7 @@ while ! maybedryrun "$DRY_RUN" timeout 1 bash -c "echo > /dev/tcp/localhost/6061
echo "pprof failed to become ready in time!"
exit 1
fi
pprof_attempt_counter+=1
((pprof_attempt_counter += 1))
maybedryrun "$DRY_RUN" sleep 3
done

View File

@ -544,7 +544,7 @@ func (g *Generator) buildStruct(obj types.Object, st *types.Struct) (string, err
tag := reflect.StructTag(st.Tag(i))
// Adding a json struct tag causes the json package to consider
// the field unembedded.
if field.Embedded() && tag.Get("json") == "" && field.Pkg().Name() == "codersdk" {
if field.Embedded() && tag.Get("json") == "" {
extendedFields[i] = true
extends = append(extends, field.Name())
}
@ -814,11 +814,17 @@ func (g *Generator) typescriptType(ty types.Type) (TypescriptType, error) {
return TypescriptType{}, xerrors.Errorf("array: %w", err)
}
genValue := ""
// Always wrap in parentheses for proper scoped types.
// Running prettier on this output will remove redundant parenthesis,
// so this makes our decision-making easier.
// The example that breaks without this is:
// readonly readonly string[][]
if underlying.GenericValue != "" {
genValue = underlying.GenericValue + "[]"
genValue = "(readonly " + underlying.GenericValue + "[])"
}
return TypescriptType{
ValueType: underlying.ValueType + "[]",
ValueType: "(readonly " + underlying.ValueType + "[])",
GenericValue: genValue,
AboveTypeLine: underlying.AboveTypeLine,
GenericTypes: underlying.GenericTypes,

View File

@ -7,6 +7,7 @@
package main
import (
"flag"
"os"
"path/filepath"
"strings"
@ -15,6 +16,9 @@ import (
"github.com/stretchr/testify/require"
)
// updateGoldenFiles is a flag that can be set to update golden files.
var updateGoldenFiles = flag.Bool("update", false, "Update golden files")
func TestGeneration(t *testing.T) {
t.Parallel()
files, err := os.ReadDir("testdata")
@ -37,7 +41,13 @@ func TestGeneration(t *testing.T) {
require.NoErrorf(t, err, "read file %s", golden)
expectedString := strings.TrimSpace(string(expected))
output = strings.TrimSpace(output)
require.Equal(t, expectedString, output, "matched output")
if *updateGoldenFiles {
// nolint:gosec
err := os.WriteFile(golden, []byte(output), 0o644)
require.NoError(t, err, "write golden file")
} else {
require.Equal(t, expectedString, output, "matched output")
}
})
}
}

View File

@ -1,8 +1,8 @@
package codersdk
type (
Enum string
Enums []Enum
Enum string
EnumSliceType []Enum
)
const (

View File

@ -1,5 +1,5 @@
// From codersdk/enums.go
export type Enums = Enum[]
export type EnumSliceType = (readonly Enum[])
// From codersdk/enums.go
export type Enum = "bar" | "baz" | "foo" | "qux"

View File

@ -1,22 +1,22 @@
package codersdk
type Foo struct {
Bar string `json:"bar"`
}
type Buzz struct {
Foo `json:"foo"`
Bazz string `json:"bazz"`
}
type Custom interface {
Foo | Buzz
type Foo struct {
Bar string `json:"bar"`
}
type FooBuzz[R Custom] struct {
Something []R `json:"something"`
}
type Custom interface {
Foo | Buzz
}
// Not yet supported
//type FooBuzzMap[R Custom] struct {
// Something map[string]R `json:"something"`

View File

@ -11,8 +11,8 @@ export interface Foo {
// From codersdk/genericmap.go
export interface FooBuzz<R extends Custom> {
readonly something: R[]
readonly something: (readonly R[])
}
// From codersdk/genericmap.go
export type Custom = Foo | Buzz
export type Custom = Foo | Buzz

View File

@ -33,9 +33,9 @@ export interface Static {
}
// From codersdk/generics.go
export type Custom = string | boolean | number | string[] | null
export type Custom = string | boolean | number | (readonly string[]) | null
// From codersdk/generics.go
export type Single = string
export type comparable = boolean | number | string | any
export type comparable = boolean | number | string | any

View File

@ -0,0 +1,10 @@
package codersdk
type Bar struct {
Bar string
}
type Foo[R any] struct {
Slice []R
TwoD [][]R
}

View File

@ -0,0 +1,10 @@
// From codersdk/genericslice.go
export interface Bar {
readonly Bar: string
}
// From codersdk/genericslice.go
export interface Foo<R extends any> {
readonly Slice: (readonly R[])
readonly TwoD: (readonly (readonly R[])[])
}

View File

@ -1,4 +1,5 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import * as API from "api/api";
import { coderPort } from "./constants";
import { findSessionToken, randomName } from "./helpers";
@ -47,3 +48,68 @@ export const createGroup = async (orgId: string) => {
});
return group;
};
export async function verifyConfigFlag(
page: Page,
config: API.DeploymentConfig,
flag: string,
) {
const opt = config.options.find((option) => option.flag === flag);
if (opt === undefined) {
// must be undefined as `false` is expected
throw new Error(`Option with env ${flag} has undefined value.`);
}
// Map option type to test class name.
let type: string;
let value = opt.value;
if (typeof value === "boolean") {
// Boolean options map to string (Enabled/Disabled).
type = value ? "option-enabled" : "option-disabled";
value = value ? "Enabled" : "Disabled";
} else if (typeof value === "number") {
type = "option-value-number";
value = String(value);
} else if (!value || value.length === 0) {
type = "option-value-empty";
} else if (typeof value === "string") {
type = "option-value-string";
} else if (typeof value === "object") {
type = "option-array";
} else {
type = "option-value-json";
}
// Special cases
if (opt.flag === "strict-transport-security" && opt.value === 0) {
type = "option-value-string";
value = "Disabled"; // Display "Disabled" instead of zero seconds.
}
const configOption = page.locator(
`div.options-table .option-${flag} .${type}`,
);
// Verify array of options with green marks.
if (typeof value === "object" && !Array.isArray(value)) {
Object.entries(value)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(async ([item]) => {
await expect(
configOption.locator(`.option-array-item-${item}.option-enabled`, {
hasText: item,
}),
).toBeVisible();
});
return;
}
// Verify array of options with simmple dots
if (Array.isArray(value)) {
for (const item of value) {
await expect(configOption.locator("li", { hasText: item })).toBeVisible();
}
return;
}
await expect(configOption).toHaveText(String(value));
}

View File

@ -111,6 +111,15 @@ export default defineConfig({
),
CODER_PPROF_ADDRESS: "127.0.0.1:" + coderdPProfPort,
CODER_EXPERIMENTS: e2eFakeExperiment1 + "," + e2eFakeExperiment2,
// Tests for Deployment / User Authentication / OIDC
CODER_OIDC_ISSUER_URL: "https://accounts.google.com",
CODER_OIDC_EMAIL_DOMAIN: "coder.com",
CODER_OIDC_CLIENT_ID: "1234567890", // FIXME: https://github.com/coder/coder/issues/12585
CODER_OIDC_CLIENT_SECRET: "1234567890Secret",
CODER_OIDC_ALLOW_SIGNUPS: "false",
CODER_OIDC_SIGN_IN_TEXT: "Hello",
CODER_OIDC_ICON_URL: "/icon/google.svg",
},
reuseExistingServer: false,
},

View File

@ -0,0 +1,28 @@
import { test } from "@playwright/test";
import { getDeploymentConfig } from "api/api";
import { setupApiCalls, verifyConfigFlag } from "../../api";
test("enabled security settings", async ({ page }) => {
await setupApiCalls(page);
const config = await getDeploymentConfig();
await page.goto("/deployment/security", { waitUntil: "domcontentloaded" });
const flags = [
"ssh-keygen-algorithm",
"secure-auth-cookie",
"disable-owner-workspace-access",
"tls-redirect-http-to-https",
"strict-transport-security",
"tls-address",
"tls-allow-insecure-ciphers",
"tls-client-auth",
"tls-enable",
"tls-min-version",
];
for (const flag of flags) {
await verifyConfigFlag(page, config, flag);
}
});

View File

@ -0,0 +1,33 @@
import { test } from "@playwright/test";
import { getDeploymentConfig } from "api/api";
import { setupApiCalls, verifyConfigFlag } from "../../api";
test("login with OIDC", async ({ page }) => {
await setupApiCalls(page);
const config = await getDeploymentConfig();
await page.goto("/deployment/userauth", { waitUntil: "domcontentloaded" });
const flags = [
"oidc-group-auto-create",
"oidc-allow-signups",
"oidc-auth-url-params",
"oidc-client-id",
"oidc-email-domain",
"oidc-email-field",
"oidc-group-mapping",
"oidc-ignore-email-verified",
"oidc-ignore-userinfo",
"oidc-issuer-url",
"oidc-group-regex-filter",
"oidc-scopes",
"oidc-user-role-mapping",
"oidc-username-field",
"oidc-sign-in-text",
"oidc-icon-url",
];
for (const flag of flags) {
await verifyConfigFlag(page, config, flag);
}
});

View File

@ -4,8 +4,8 @@
// From codersdk/templates.go
export interface ACLAvailable {
readonly users: ReducedUser[];
readonly groups: Group[];
readonly users: readonly ReducedUser[];
readonly groups: readonly Group[];
}
// From codersdk/apikey.go
@ -49,7 +49,7 @@ export interface AppearanceConfig {
readonly application_name: string;
readonly logo_url: string;
readonly service_banner: ServiceBannerConfig;
readonly support_links?: LinkConfig[];
readonly support_links?: readonly LinkConfig[];
}
// From codersdk/templates.go
@ -60,7 +60,7 @@ export interface ArchiveTemplateVersionsRequest {
// From codersdk/templates.go
export interface ArchiveTemplateVersionsResponse {
readonly template_id: string;
readonly archived_ids: string[];
readonly archived_ids: readonly string[];
}
// From codersdk/roles.go
@ -108,7 +108,7 @@ export interface AuditLog {
// From codersdk/audit.go
export interface AuditLogResponse {
readonly audit_logs: AuditLog[];
readonly audit_logs: readonly AuditLog[];
readonly count: number;
}
@ -153,7 +153,7 @@ export type AuthorizationResponse = Record<string, boolean>;
// From codersdk/deployment.go
export interface AvailableExperiments {
readonly safe: Experiment[];
readonly safe: readonly Experiment[];
}
// From codersdk/deployment.go
@ -241,8 +241,8 @@ export interface CreateTemplateRequest {
// From codersdk/templateversions.go
export interface CreateTemplateVersionDryRunRequest {
readonly workspace_name: string;
readonly rich_parameter_values: WorkspaceBuildParameter[];
readonly user_variable_values?: VariableValue[];
readonly rich_parameter_values: readonly WorkspaceBuildParameter[];
readonly user_variable_values?: readonly VariableValue[];
}
// From codersdk/organizations.go
@ -255,7 +255,7 @@ export interface CreateTemplateVersionRequest {
readonly example_id?: string;
readonly provisioner: ProvisionerType;
readonly tags: Record<string, string>;
readonly user_variable_values?: VariableValue[];
readonly user_variable_values?: readonly VariableValue[];
}
// From codersdk/audit.go
@ -292,7 +292,7 @@ export interface CreateWorkspaceBuildRequest {
readonly dry_run?: boolean;
readonly state?: string;
readonly orphan?: boolean;
readonly rich_parameter_values?: WorkspaceBuildParameter[];
readonly rich_parameter_values?: readonly WorkspaceBuildParameter[];
readonly log_level?: ProvisionerLogLevel;
}
@ -310,7 +310,7 @@ export interface CreateWorkspaceRequest {
readonly name: string;
readonly autostart_schedule?: string;
readonly ttl_ms?: number;
readonly rich_parameter_values?: WorkspaceBuildParameter[];
readonly rich_parameter_values?: readonly WorkspaceBuildParameter[];
readonly automatic_updates?: AutomaticUpdates;
}
@ -327,7 +327,7 @@ export interface DAURequest {
// From codersdk/deployment.go
export interface DAUsResponse {
readonly entries: DAUEntry[];
readonly entries: readonly DAUEntry[];
readonly tz_hour_offset: number;
}
@ -434,7 +434,7 @@ export interface DeploymentValues {
readonly session_lifetime?: SessionLifetime;
readonly disable_password_auth?: boolean;
readonly support?: SupportConfig;
readonly external_auth?: ExternalAuthConfig[];
readonly external_auth?: readonly ExternalAuthConfig[];
readonly config_ssh?: SSHConfig;
readonly wgtunnel_host?: string;
readonly disable_owner_workspace_exec?: boolean;
@ -453,8 +453,8 @@ export interface DeploymentValues {
// From codersdk/deployment.go
export interface Entitlements {
readonly features: Record<FeatureName, Feature>;
readonly warnings: string[];
readonly errors: string[];
readonly warnings: readonly string[];
readonly errors: readonly string[];
readonly has_license: boolean;
readonly trial: boolean;
readonly require_telemetry: boolean;
@ -462,7 +462,7 @@ export interface Entitlements {
}
// From codersdk/deployment.go
export type Experiments = Experiment[];
export type Experiments = readonly Experiment[];
// From codersdk/externalauth.go
export interface ExternalAuth {
@ -471,7 +471,7 @@ export interface ExternalAuth {
readonly display_name: string;
readonly user?: ExternalAuthUser;
readonly app_installable: boolean;
readonly installations: ExternalAuthAppInstallation[];
readonly installations: readonly ExternalAuthAppInstallation[];
readonly app_install_url: string;
}
@ -493,8 +493,8 @@ export interface ExternalAuthConfig {
readonly app_install_url: string;
readonly app_installations_url: string;
readonly no_refresh: boolean;
readonly scopes: string[];
readonly extra_token_keys: string[];
readonly scopes: readonly string[];
readonly extra_token_keys: readonly string[];
readonly device_flow: boolean;
readonly device_code_url: string;
readonly regex: string;
@ -561,7 +561,7 @@ export interface GenerateAPIKeyResponse {
// From codersdk/users.go
export interface GetUsersResponse {
readonly users: User[];
readonly users: readonly User[];
readonly count: number;
}
@ -579,7 +579,7 @@ export interface Group {
readonly name: string;
readonly display_name: string;
readonly organization_id: string;
readonly members: ReducedUser[];
readonly members: readonly ReducedUser[];
readonly avatar_url: string;
readonly quota_allowance: number;
readonly source: GroupSource;
@ -638,8 +638,8 @@ export interface LinkConfig {
// From codersdk/externalauth.go
export interface ListUserExternalAuthResponse {
readonly providers: ExternalAuthLinkProvider[];
readonly links: ExternalAuthLink[];
readonly providers: readonly ExternalAuthLinkProvider[];
readonly links: readonly ExternalAuthLink[];
}
// From codersdk/deployment.go
@ -753,7 +753,7 @@ export interface OIDCConfig {
readonly groups_field: string;
readonly group_mapping: Record<string, string>;
readonly user_role_field: string;
readonly user_role_mapping: Record<string, string[]>;
readonly user_role_mapping: Record<string, readonly string[]>;
readonly user_roles_default: string[];
readonly sign_in_text: string;
readonly icon_url: string;
@ -775,7 +775,7 @@ export interface OrganizationMember {
readonly organization_id: string;
readonly created_at: string;
readonly updated_at: string;
readonly roles: Role[];
readonly roles: readonly Role[];
}
// From codersdk/pagination.go
@ -787,8 +787,8 @@ export interface Pagination {
// From codersdk/groups.go
export interface PatchGroupRequest {
readonly add_users: string[];
readonly remove_users: string[];
readonly add_users: readonly string[];
readonly remove_users: readonly string[];
readonly name: string;
readonly display_name?: string;
readonly avatar_url?: string;
@ -850,7 +850,7 @@ export interface ProvisionerDaemon {
readonly name: string;
readonly version: string;
readonly api_version: string;
readonly provisioners: ProvisionerType[];
readonly provisioners: readonly ProvisionerType[];
readonly tags: Record<string, string>;
}
@ -883,8 +883,8 @@ export interface ProvisionerJobLog {
// From codersdk/workspaceproxy.go
export interface ProxyHealthReport {
readonly errors: string[];
readonly warnings: string[];
readonly errors: readonly string[];
readonly warnings: readonly string[];
}
// From codersdk/workspaces.go
@ -929,7 +929,7 @@ export interface Region {
// From codersdk/workspaceproxy.go
export interface RegionsResponse<R extends RegionTypes> {
readonly regions: R[];
readonly regions: readonly R[];
}
// From codersdk/replicas.go
@ -952,7 +952,7 @@ export interface ResolveAutostartResponse {
export interface Response {
readonly message: string;
readonly detail?: string;
readonly validations?: ValidationError[];
readonly validations?: readonly ValidationError[];
}
// From codersdk/roles.go
@ -1005,7 +1005,7 @@ export interface SessionLifetime {
// From codersdk/deployment.go
export interface SupportConfig {
readonly links: LinkConfig[];
readonly links: readonly LinkConfig[];
}
// From codersdk/deployment.go
@ -1070,13 +1070,13 @@ export interface Template {
// From codersdk/templates.go
export interface TemplateACL {
readonly users: TemplateUser[];
readonly group: TemplateGroup[];
readonly users: readonly TemplateUser[];
readonly group: readonly TemplateGroup[];
}
// From codersdk/insights.go
export interface TemplateAppUsage {
readonly template_ids: string[];
readonly template_ids: readonly string[];
readonly type: TemplateAppsType;
readonly display_name: string;
readonly slug: string;
@ -1086,12 +1086,12 @@ export interface TemplateAppUsage {
// From codersdk/templates.go
export interface TemplateAutostartRequirement {
readonly days_of_week: string[];
readonly days_of_week: readonly string[];
}
// From codersdk/templates.go
export interface TemplateAutostopRequirement {
readonly days_of_week: string[];
readonly days_of_week: readonly string[];
readonly weeks: number;
}
@ -1108,7 +1108,7 @@ export interface TemplateExample {
readonly name: string;
readonly description: string;
readonly icon: string;
readonly tags: string[];
readonly tags: readonly string[];
readonly markdown: string;
}
@ -1121,7 +1121,7 @@ export interface TemplateGroup extends Group {
export interface TemplateInsightsIntervalReport {
readonly start_time: string;
readonly end_time: string;
readonly template_ids: string[];
readonly template_ids: readonly string[];
readonly interval: InsightsReportInterval;
readonly active_users: number;
}
@ -1130,36 +1130,36 @@ export interface TemplateInsightsIntervalReport {
export interface TemplateInsightsReport {
readonly start_time: string;
readonly end_time: string;
readonly template_ids: string[];
readonly template_ids: readonly string[];
readonly active_users: number;
readonly apps_usage: TemplateAppUsage[];
readonly parameters_usage: TemplateParameterUsage[];
readonly apps_usage: readonly TemplateAppUsage[];
readonly parameters_usage: readonly TemplateParameterUsage[];
}
// From codersdk/insights.go
export interface TemplateInsightsRequest {
readonly start_time: string;
readonly end_time: string;
readonly template_ids: string[];
readonly template_ids: readonly string[];
readonly interval: InsightsReportInterval;
readonly sections: TemplateInsightsSection[];
readonly sections: readonly TemplateInsightsSection[];
}
// From codersdk/insights.go
export interface TemplateInsightsResponse {
readonly report?: TemplateInsightsReport;
readonly interval_reports?: TemplateInsightsIntervalReport[];
readonly interval_reports?: readonly TemplateInsightsIntervalReport[];
}
// From codersdk/insights.go
export interface TemplateParameterUsage {
readonly template_ids: string[];
readonly template_ids: readonly string[];
readonly display_name: string;
readonly name: string;
readonly type: string;
readonly description: string;
readonly options?: TemplateVersionParameterOption[];
readonly values: TemplateParameterValue[];
readonly options?: readonly TemplateVersionParameterOption[];
readonly values: readonly TemplateParameterValue[];
}
// From codersdk/insights.go
@ -1186,7 +1186,7 @@ export interface TemplateVersion {
readonly readme: string;
readonly created_by: MinimalUser;
readonly archived: boolean;
readonly warnings?: TemplateVersionWarning[];
readonly warnings?: readonly TemplateVersionWarning[];
}
// From codersdk/templateversions.go
@ -1210,7 +1210,7 @@ export interface TemplateVersionParameter {
readonly mutable: boolean;
readonly default_value: string;
readonly icon: string;
readonly options: TemplateVersionParameterOption[];
readonly options: readonly TemplateVersionParameterOption[];
readonly validation_error?: string;
readonly validation_regex?: string;
readonly validation_min?: number;
@ -1290,7 +1290,7 @@ export interface UpdateCheckResponse {
// From codersdk/users.go
export interface UpdateRoles {
readonly roles: string[];
readonly roles: readonly string[];
}
// From codersdk/templates.go
@ -1391,13 +1391,13 @@ export interface UpsertWorkspaceAgentPortShareRequest {
// From codersdk/users.go
export interface User extends ReducedUser {
readonly organization_ids: string[];
readonly roles: Role[];
readonly organization_ids: readonly string[];
readonly roles: readonly Role[];
}
// From codersdk/insights.go
export interface UserActivity {
readonly template_ids: string[];
readonly template_ids: readonly string[];
readonly user_id: string;
readonly username: string;
readonly avatar_url: string;
@ -1408,15 +1408,15 @@ export interface UserActivity {
export interface UserActivityInsightsReport {
readonly start_time: string;
readonly end_time: string;
readonly template_ids: string[];
readonly users: UserActivity[];
readonly template_ids: readonly string[];
readonly users: readonly UserActivity[];
}
// From codersdk/insights.go
export interface UserActivityInsightsRequest {
readonly start_time: string;
readonly end_time: string;
readonly template_ids: string[];
readonly template_ids: readonly string[];
}
// From codersdk/insights.go
@ -1426,7 +1426,7 @@ export interface UserActivityInsightsResponse {
// From codersdk/insights.go
export interface UserLatency {
readonly template_ids: string[];
readonly template_ids: readonly string[];
readonly user_id: string;
readonly username: string;
readonly avatar_url: string;
@ -1437,15 +1437,15 @@ export interface UserLatency {
export interface UserLatencyInsightsReport {
readonly start_time: string;
readonly end_time: string;
readonly template_ids: string[];
readonly users: UserLatency[];
readonly template_ids: readonly string[];
readonly users: readonly UserLatency[];
}
// From codersdk/insights.go
export interface UserLatencyInsightsRequest {
readonly start_time: string;
readonly end_time: string;
readonly template_ids: string[];
readonly template_ids: readonly string[];
}
// From codersdk/insights.go
@ -1482,8 +1482,8 @@ export interface UserQuietHoursScheduleResponse {
// From codersdk/users.go
export interface UserRoles {
readonly roles: string[];
readonly organization_roles: Record<string, string[]>;
readonly roles: readonly string[];
readonly organization_roles: Record<string, readonly string[]>;
}
// From codersdk/users.go
@ -1557,15 +1557,15 @@ export interface WorkspaceAgent {
readonly expanded_directory?: string;
readonly version: string;
readonly api_version: string;
readonly apps: WorkspaceApp[];
readonly apps: readonly WorkspaceApp[];
readonly latency?: Record<string, DERPRegion>;
readonly connection_timeout_seconds: number;
readonly troubleshooting_url: string;
readonly subsystems: AgentSubsystem[];
readonly subsystems: readonly AgentSubsystem[];
readonly health: WorkspaceAgentHealth;
readonly display_apps: DisplayApp[];
readonly log_sources: WorkspaceAgentLogSource[];
readonly scripts: WorkspaceAgentScript[];
readonly display_apps: readonly DisplayApp[];
readonly log_sources: readonly WorkspaceAgentLogSource[];
readonly scripts: readonly WorkspaceAgentScript[];
readonly startup_script_behavior: WorkspaceAgentStartupScriptBehavior;
}
@ -1584,7 +1584,7 @@ export interface WorkspaceAgentListeningPort {
// From codersdk/workspaceagents.go
export interface WorkspaceAgentListeningPortsResponse {
readonly ports: WorkspaceAgentListeningPort[];
readonly ports: readonly WorkspaceAgentListeningPort[];
}
// From codersdk/workspaceagents.go
@ -1639,7 +1639,7 @@ export interface WorkspaceAgentPortShare {
// From codersdk/workspaceagentportshare.go
export interface WorkspaceAgentPortShares {
readonly shares: WorkspaceAgentPortShare[];
readonly shares: readonly WorkspaceAgentPortShare[];
}
// From codersdk/workspaceagents.go
@ -1688,7 +1688,7 @@ export interface WorkspaceBuild {
readonly initiator_name: string;
readonly job: ProvisionerJob;
readonly reason: BuildReason;
readonly resources: WorkspaceResource[];
readonly resources: readonly WorkspaceResource[];
readonly deadline?: string;
readonly max_deadline?: string;
readonly status: WorkspaceStatus;
@ -1732,7 +1732,7 @@ export interface WorkspaceFilter {
// From codersdk/workspaces.go
export interface WorkspaceHealth {
readonly healthy: boolean;
readonly failing_agents: string[];
readonly failing_agents: readonly string[];
}
// From codersdk/workspaces.go
@ -1780,8 +1780,8 @@ export interface WorkspaceResource {
readonly name: string;
readonly hide: boolean;
readonly icon: string;
readonly agents?: WorkspaceAgent[];
readonly metadata?: WorkspaceResourceMetadata[];
readonly agents?: readonly WorkspaceAgent[];
readonly metadata?: readonly WorkspaceResourceMetadata[];
readonly daily_cost: number;
}
@ -1799,7 +1799,7 @@ export interface WorkspacesRequest extends Pagination {
// From codersdk/workspaces.go
export interface WorkspacesResponse {
readonly workspaces: Workspace[];
readonly workspaces: readonly Workspace[];
readonly count: number;
}
@ -2282,38 +2282,39 @@ export type RegionTypes = Region | WorkspaceProxy;
// The code below is generated from codersdk/healthsdk.
// From healthsdk/healthsdk.go
export interface AccessURLReport {
export interface AccessURLReport extends BaseReport {
readonly healthy: boolean;
readonly severity: HealthSeverity;
readonly warnings: HealthMessage[];
readonly dismissed: boolean;
readonly access_url: string;
readonly reachable: boolean;
readonly status_code: number;
readonly healthz_response: string;
readonly error?: string;
}
// From healthsdk/healthsdk.go
export interface DERPHealthReport {
readonly healthy: boolean;
export interface BaseReport {
readonly error?: string;
readonly severity: HealthSeverity;
readonly warnings: HealthMessage[];
readonly warnings: readonly HealthMessage[];
readonly dismissed: boolean;
}
// From healthsdk/healthsdk.go
export interface DERPHealthReport extends BaseReport {
readonly healthy: boolean;
readonly regions: Record<number, DERPRegionReport>;
// Named type "tailscale.com/net/netcheck.Report" unknown, using "any"
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type
readonly netcheck?: any;
readonly netcheck_err?: string;
readonly netcheck_logs: string[];
readonly error?: string;
readonly netcheck_logs: readonly string[];
}
// From healthsdk/healthsdk.go
export interface DERPNodeReport {
readonly healthy: boolean;
readonly severity: HealthSeverity;
readonly warnings: HealthMessage[];
readonly warnings: readonly HealthMessage[];
readonly error?: string;
// Named type "tailscale.com/tailcfg.DERPNode" unknown, using "any"
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type
readonly node?: any;
@ -2324,9 +2325,8 @@ export interface DERPNodeReport {
readonly round_trip_ping: string;
readonly round_trip_ping_ms: number;
readonly uses_websocket: boolean;
readonly client_logs: string[][];
readonly client_errs: string[][];
readonly error?: string;
readonly client_logs: readonly (readonly string[])[];
readonly client_errs: readonly (readonly string[])[];
readonly stun: STUNReport;
}
@ -2334,30 +2334,26 @@ export interface DERPNodeReport {
export interface DERPRegionReport {
readonly healthy: boolean;
readonly severity: HealthSeverity;
readonly warnings: HealthMessage[];
readonly warnings: readonly HealthMessage[];
readonly error?: string;
// Named type "tailscale.com/tailcfg.DERPRegion" unknown, using "any"
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type
readonly region?: any;
readonly node_reports: DERPNodeReport[];
readonly error?: string;
readonly node_reports: readonly DERPNodeReport[];
}
// From healthsdk/healthsdk.go
export interface DatabaseReport {
export interface DatabaseReport extends BaseReport {
readonly healthy: boolean;
readonly severity: HealthSeverity;
readonly warnings: HealthMessage[];
readonly dismissed: boolean;
readonly reachable: boolean;
readonly latency: string;
readonly latency_ms: number;
readonly threshold_ms: number;
readonly error?: string;
}
// From healthsdk/healthsdk.go
export interface HealthSettings {
readonly dismissed_healthchecks: HealthSection[];
readonly dismissed_healthchecks: readonly HealthSection[];
}
// From healthsdk/healthsdk.go
@ -2365,7 +2361,7 @@ export interface HealthcheckReport {
readonly time: string;
readonly healthy: boolean;
readonly severity: HealthSeverity;
readonly failing_sections: HealthSection[];
readonly failing_sections: readonly HealthSection[];
readonly derp: DERPHealthReport;
readonly access_url: AccessURLReport;
readonly websocket: WebsocketReport;
@ -2376,18 +2372,14 @@ export interface HealthcheckReport {
}
// From healthsdk/healthsdk.go
export interface ProvisionerDaemonsReport {
readonly severity: HealthSeverity;
readonly warnings: HealthMessage[];
readonly dismissed: boolean;
readonly error?: string;
readonly items: ProvisionerDaemonsReportItem[];
export interface ProvisionerDaemonsReport extends BaseReport {
readonly items: readonly ProvisionerDaemonsReportItem[];
}
// From healthsdk/healthsdk.go
export interface ProvisionerDaemonsReportItem {
readonly provisioner_daemon: ProvisionerDaemon;
readonly warnings: HealthMessage[];
readonly warnings: readonly HealthMessage[];
}
// From healthsdk/healthsdk.go
@ -2399,27 +2391,19 @@ export interface STUNReport {
// From healthsdk/healthsdk.go
export interface UpdateHealthSettings {
readonly dismissed_healthchecks: HealthSection[];
readonly dismissed_healthchecks: readonly HealthSection[];
}
// From healthsdk/healthsdk.go
export interface WebsocketReport {
export interface WebsocketReport extends BaseReport {
readonly healthy: boolean;
readonly severity: HealthSeverity;
readonly warnings: string[];
readonly dismissed: boolean;
readonly body: string;
readonly code: number;
readonly error?: string;
}
// From healthsdk/healthsdk.go
export interface WorkspaceProxyReport {
export interface WorkspaceProxyReport extends BaseReport {
readonly healthy: boolean;
readonly severity: HealthSeverity;
readonly warnings: HealthMessage[];
readonly dismissed: boolean;
readonly error?: string;
readonly workspace_proxies: RegionsResponse<WorkspaceProxy>;
}
@ -2520,13 +2504,13 @@ export interface SerpentOption {
readonly value?: any;
readonly annotations?: SerpentAnnotations;
readonly group?: SerpentGroup;
readonly use_instead?: SerpentOption[];
readonly use_instead?: readonly SerpentOption[];
readonly hidden?: boolean;
readonly value_source?: SerpentValueSource;
}
// From serpent/option.go
export type SerpentOptionSet = SerpentOption[];
export type SerpentOptionSet = readonly SerpentOption[];
// From serpent/option.go
export type SerpentValueSource = "" | "default" | "env" | "flag" | "yaml";

View File

@ -42,7 +42,7 @@ ChartJS.register(
const USER_LIMIT_DISPLAY_THRESHOLD = 60;
export interface ActiveUserChartProps {
data: Array<{ date: string; amount: number }>;
data: readonly { date: string; amount: number }[];
interval: "day" | "week";
userLimit: number | undefined;
}

View File

@ -41,7 +41,11 @@ const styles = {
} satisfies Record<string, Interpolation<Theme>>;
export const EnabledBadge: FC = () => {
return <span css={[styles.badge, styles.enabledBadge]}>Enabled</span>;
return (
<span css={[styles.badge, styles.enabledBadge]} className="option-enabled">
Enabled
</span>
);
};
export const EntitledBadge: FC = () => {
@ -95,6 +99,7 @@ export const DisabledBadge: FC = forwardRef<
color: theme.experimental.l1.text,
}),
]}
className="option-disabled"
>
Disabled
</span>

View File

@ -4,7 +4,7 @@ import { TimelineDateRow } from "components/Timeline/TimelineDateRow";
type GetDateFn<TData> = (data: TData) => Date;
const groupByDate = <TData,>(
items: TData[],
items: readonly TData[],
getDate: GetDateFn<TData>,
): Record<string, TData[]> => {
const itemsByDate: Record<string, TData[]> = {};
@ -23,7 +23,7 @@ const groupByDate = <TData,>(
};
export interface TimelineProps<TData> {
items: TData[];
items: readonly TData[];
getDate: GetDateFn<TData>;
row: (item: TData) => JSX.Element;
}

View File

@ -41,7 +41,7 @@ export interface ProxyContextValue {
// 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[];
proxies?: readonly Region[] | readonly WorkspaceProxy[];
// isFetched is true when the 'proxies' api call is complete.
isFetched: boolean;
isLoading: boolean;
@ -117,7 +117,7 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
});
const { permissions } = useAuthenticated();
const query = async (): Promise<Region[]> => {
const query = async (): Promise<readonly Region[]> => {
const endpoint = permissions.editWorkspaceProxies
? getWorkspaceProxies
: getWorkspaceProxyRegions;
@ -218,7 +218,7 @@ export const useProxy = (): ProxyContextValue => {
* If not, `primary` is always the best default.
*/
export const getPreferredProxy = (
proxies: Region[],
proxies: readonly Region[],
selectedProxy?: Region,
latencies?: Record<string, ProxyLatencyReport>,
autoSelectBasedOnLatency = true,
@ -245,7 +245,7 @@ export const getPreferredProxy = (
};
const selectByLatency = (
proxies: Region[],
proxies: readonly Region[],
latencies?: Record<string, ProxyLatencyReport>,
): Region | undefined => {
if (!latencies) {

View File

@ -37,7 +37,7 @@ const proxyLatenciesReducer = (
};
export const useProxyLatency = (
proxies?: Region[],
proxies?: readonly Region[],
): {
// Refetch can be called to refetch the proxy latencies.
// Until the new values are loaded, the old values will still be used.
@ -265,7 +265,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: Region[],
regions: readonly Region[],
maxStored: number,
): void => {
const latencies = loadStoredLatencies();
@ -282,7 +282,7 @@ const garbageCollectStoredLatencies = (
const cleanupLatencies = (
stored: Record<string, ProxyLatencyReport[]>,
regions: Region[],
regions: readonly Region[],
now: Date,
maxStored: number,
): Record<string, ProxyLatencyReport[]> => {

View File

@ -27,8 +27,8 @@ const styles = {
} satisfies Record<string, Interpolation<Theme>>;
export interface LicenseBannerViewProps {
errors: string[];
warnings: string[];
errors: readonly string[];
warnings: readonly string[];
}
export const LicenseBannerView: FC<LicenseBannerViewProps> = ({

View File

@ -30,7 +30,7 @@ export interface NavbarViewProps {
logo_url?: string;
user?: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: TypesGen.LinkConfig[];
supportLinks?: readonly TypesGen.LinkConfig[];
onSignOut: () => void;
canViewAuditLog: boolean;
canViewDeployment: boolean;
@ -342,57 +342,58 @@ const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
<Divider css={{ borderColor: theme.palette.divider }} />
{proxyContextValue.proxies
?.sort((a, b) => {
const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity;
const latencyB = latencies?.[b.id]?.latencyMS ?? Infinity;
return latencyA - latencyB;
})
.map((proxy) => (
<MenuItem
key={proxy.id}
selected={proxy.id === selectedProxy?.id}
css={{ fontSize: 14 }}
onClick={() => {
if (!proxy.healthy) {
displayError("Please select a healthy workspace proxy.");
closeMenu();
return;
}
{proxyContextValue.proxies &&
[...proxyContextValue.proxies]
.sort((a, b) => {
const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity;
const latencyB = latencies?.[b.id]?.latencyMS ?? Infinity;
return latencyA - latencyB;
})
.map((proxy) => (
<MenuItem
key={proxy.id}
selected={proxy.id === selectedProxy?.id}
css={{ fontSize: 14 }}
onClick={() => {
if (!proxy.healthy) {
displayError("Please select a healthy workspace proxy.");
closeMenu();
return;
}
proxyContextValue.setProxy(proxy);
closeMenu();
}}
>
<div
css={{
display: "flex",
gap: 24,
alignItems: "center",
width: "100%",
proxyContextValue.setProxy(proxy);
closeMenu();
}}
>
<div css={{ width: 14, height: 14, lineHeight: 0 }}>
<img
src={proxy.icon_url}
alt=""
css={{
objectFit: "contain",
width: "100%",
height: "100%",
}}
<div
css={{
display: "flex",
gap: 24,
alignItems: "center",
width: "100%",
}}
>
<div css={{ width: 14, height: 14, lineHeight: 0 }}>
<img
src={proxy.icon_url}
alt=""
css={{
objectFit: "contain",
width: "100%",
height: "100%",
}}
/>
</div>
{proxy.display_name}
<Latency
latency={latencies?.[proxy.id]?.latencyMS}
isLoading={proxyLatencyLoading(proxy)}
/>
</div>
{proxy.display_name}
<Latency
latency={latencies?.[proxy.id]?.latencyMS}
isLoading={proxyLatencyLoading(proxy)}
/>
</div>
</MenuItem>
))}
</MenuItem>
))}
<Divider css={{ borderColor: theme.palette.divider }} />

View File

@ -15,7 +15,7 @@ import { UserDropdownContent } from "./UserDropdownContent";
export interface UserDropdownProps {
user: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: TypesGen.LinkConfig[];
supportLinks?: readonly TypesGen.LinkConfig[];
onSignOut: () => void;
children?: ReactNode;
}

View File

@ -83,7 +83,7 @@ const styles = {
export interface UserDropdownContentProps {
user: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: TypesGen.LinkConfig[];
supportLinks?: readonly TypesGen.LinkConfig[];
onSignOut: () => void;
}

View File

@ -20,8 +20,8 @@ type AgentLogsProps = Omit<
ComponentProps<typeof List>,
"children" | "itemSize" | "itemCount"
> & {
logs: LineWithID[];
sources: WorkspaceAgentLogSource[];
logs: readonly LineWithID[];
sources: readonly WorkspaceAgentLogSource[];
};
export const AgentLogs = forwardRef<List, AgentLogsProps>(

View File

@ -16,7 +16,7 @@ import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { type FormikContextType, useFormik } from "formik";
import type { FC } from "react";
import { useState, type FC } from "react";
import { useQuery, useMutation } from "react-query";
import * as Yup from "yup";
import { getAgentListeningPorts } from "api/api";
@ -48,7 +48,11 @@ import { type ClassName, useClassName } from "hooks/useClassName";
import { useDashboard } from "modules/dashboard/useDashboard";
import { docs } from "utils/docs";
import { getFormHelpers } from "utils/formUtils";
import { portForwardURL } from "utils/portForward";
import {
getWorkspaceListeningPortsProtocol,
portForwardURL,
saveWorkspaceListeningPortsProtocol,
} from "utils/portForward";
export interface PortForwardButtonProps {
host: string;
@ -116,7 +120,7 @@ const getValidationSchema = (): Yup.AnyObjectSchema =>
});
interface PortForwardPopoverViewProps extends PortForwardButtonProps {
listeningPorts?: WorkspaceAgentListeningPort[];
listeningPorts?: readonly WorkspaceAgentListeningPort[];
portSharingExperimentEnabled: boolean;
portSharingControlsEnabled: boolean;
}
@ -135,6 +139,9 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
portSharingControlsEnabled,
}) => {
const theme = useTheme();
const [listeningPortProtocol, setListeningPortProtocol] = useState(
getWorkspaceListeningPortsProtocol(workspaceID),
);
const sharedPortsQuery = useQuery({
...workspacePortShares(workspaceID),
@ -189,15 +196,9 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
(port) => port.agent_name === agent.name,
);
// we don't want to show listening ports if it's a shared port
const filteredListeningPorts = listeningPorts?.filter((port) => {
for (let i = 0; i < filteredSharedPorts.length; i++) {
if (filteredSharedPorts[i].port === port.port) {
return false;
}
}
return true;
});
const filteredListeningPorts = (listeningPorts ?? []).filter((port) =>
filteredSharedPorts.every((sharedPort) => sharedPort.port !== port.port),
);
// only disable the form if shared port controls are entitled and the template doesn't allow sharing ports
const canSharePorts =
portSharingExperimentEnabled &&
@ -224,95 +225,117 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
overflowY: "auto",
}}
>
<header
css={(theme) => ({
<Stack
direction="column"
css={{
padding: 20,
paddingBottom: 10,
position: "sticky",
top: 0,
background: theme.palette.background.paper,
// For some reason the Share button label has a higher z-index than
// the header. Probably some tricky stuff from MUI.
zIndex: 1,
})}
}}
>
<Stack
direction="row"
justifyContent="space-between"
alignItems="start"
>
<HelpTooltipTitle>Listening ports</HelpTooltipTitle>
<HelpTooltipTitle>Listening Ports</HelpTooltipTitle>
<HelpTooltipLink
href={docs("/networking/port-forwarding#dashboard")}
>
Learn more
</HelpTooltipLink>
</Stack>
<HelpTooltipText css={{ color: theme.palette.text.secondary }}>
{filteredListeningPorts?.length === 0
? "No open ports were detected."
: "The listening ports are exclusively accessible to you."}
</HelpTooltipText>
<form
css={styles.newPortForm}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const port = Number(formData.get("portNumber"));
const url = portForwardURL(
host,
port,
agent.name,
workspaceName,
username,
);
window.open(url, "_blank");
}}
>
<input
aria-label="Port number"
name="portNumber"
type="number"
placeholder="Connect to port..."
min={9}
max={65535}
required
css={styles.newPortInput}
/>
<Button
type="submit"
size="small"
variant="text"
<Stack direction="column" gap={1}>
<HelpTooltipText css={{ color: theme.palette.text.secondary }}>
The listening ports are exclusively accessible to you. Selecting
HTTP/S will change the protocol for all listening ports.
</HelpTooltipText>
<Stack
direction="row"
gap={2}
css={{
paddingLeft: 12,
paddingRight: 12,
minWidth: 0,
paddingBottom: 8,
}}
>
<OpenInNewOutlined
css={{
flexShrink: 0,
width: 14,
height: 14,
color: theme.palette.text.primary,
<FormControl size="small" css={styles.protocolFormControl}>
<Select
css={styles.listeningPortProtocol}
value={listeningPortProtocol}
onChange={async (event) => {
const selectedProtocol = event.target.value as
| "http"
| "https";
setListeningPortProtocol(selectedProtocol);
saveWorkspaceListeningPortsProtocol(
workspaceID,
selectedProtocol,
);
}}
>
<MenuItem value="http">HTTP</MenuItem>
<MenuItem value="https">HTTPS</MenuItem>
</Select>
</FormControl>
<form
css={styles.newPortForm}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const port = Number(formData.get("portNumber"));
const url = portForwardURL(
host,
port,
agent.name,
workspaceName,
username,
listeningPortProtocol,
);
window.open(url, "_blank");
}}
/>
</Button>
</form>
</header>
<div
css={{
padding: 20,
paddingTop: 0,
}}
>
{filteredListeningPorts?.map((port) => {
>
<input
aria-label="Port number"
name="portNumber"
type="number"
placeholder="Connect to port..."
min={9}
max={65535}
required
css={styles.newPortInput}
/>
<Button
type="submit"
size="small"
variant="text"
css={{
paddingLeft: 12,
paddingRight: 12,
minWidth: 0,
}}
>
<OpenInNewOutlined
css={{
flexShrink: 0,
width: 14,
height: 14,
color: theme.palette.text.primary,
}}
/>
</Button>
</form>
</Stack>
</Stack>
{filteredListeningPorts.length === 0 && (
<HelpTooltipText css={styles.noPortText}>
No open ports were detected.
</HelpTooltipText>
)}
{filteredListeningPorts.map((port) => {
const url = portForwardURL(
host,
port.port,
agent.name,
workspaceName,
username,
listeningPortProtocol,
);
const label =
port.process_name !== "" ? port.process_name : port.port;
@ -323,22 +346,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
alignItems="center"
justifyContent="space-between"
>
<Link
underline="none"
css={styles.portLink}
href={url}
target="_blank"
rel="noreferrer"
>
<SensorsIcon css={{ width: 14, height: 14 }} />
{label}
</Link>
<Stack
direction="row"
gap={2}
justifyContent="flex-end"
alignItems="center"
>
<Stack direction="row" gap={3}>
<Link
underline="none"
css={styles.portLink}
@ -346,8 +354,25 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
target="_blank"
rel="noreferrer"
>
<span css={styles.portNumber}>{port.port}</span>
<SensorsIcon css={{ width: 14, height: 14 }} />
{port.port}
</Link>
<Link
underline="none"
css={styles.portLink}
href={url}
target="_blank"
rel="noreferrer"
>
{label}
</Link>
</Stack>
<Stack
direction="row"
gap={2}
justifyContent="flex-end"
alignItems="center"
>
{canSharePorts && (
<Button
size="small"
@ -356,7 +381,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
await upsertSharedPortMutation.mutateAsync({
agent_name: agent.name,
port: port.port,
protocol: "http",
protocol: listeningPortProtocol,
share_level: "authenticated",
});
await sharedPortsQuery.refetch();
@ -369,7 +394,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
</Stack>
);
})}
</div>
</Stack>
</div>
{portSharingExperimentEnabled && (
<div
@ -393,7 +418,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
agent.name,
workspaceName,
username,
share.protocol === "https",
share.protocol,
);
const label = share.port;
return (
@ -619,6 +644,22 @@ const styles = {
"&:focus-within": {
borderColor: theme.palette.primary.main,
},
width: "100%",
}),
listeningPortProtocol: (theme) => ({
boxShadow: "none",
".MuiOutlinedInput-notchedOutline": { border: 0 },
"&.MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline": {
border: 0,
},
"&.MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": {
border: 0,
},
border: `1px solid ${theme.palette.divider}`,
borderRadius: "4px",
marginTop: 8,
minWidth: "100px",
}),
newPortInput: (theme) => ({
@ -633,6 +674,12 @@ const styles = {
display: "block",
width: "100%",
}),
noPortText: (theme) => ({
color: theme.palette.text.secondary,
paddingTop: 20,
paddingBottom: 10,
textAlign: "center",
}),
sharedPortLink: () => ({
minWidth: 80,
}),

View File

@ -15,7 +15,7 @@ export interface VSCodeDesktopButtonProps {
workspaceName: string;
agentName?: string;
folderPath?: string;
displayApps: DisplayApp[];
displayApps: readonly DisplayApp[];
}
type VSCodeVariant = "vscode" | "vscode-insiders";

View File

@ -32,7 +32,7 @@ export const Language = {
};
export interface AuditPageViewProps {
auditLogs?: AuditLog[];
auditLogs?: readonly AuditLog[];
isNonInitialPage: boolean;
isAuditLogVisible: boolean;
error?: unknown;

View File

@ -34,19 +34,35 @@ export const OptionValue: FC<OptionValueProps> = (props) => {
const theme = useTheme();
if (typeof value === "boolean") {
return value ? <EnabledBadge /> : <DisabledBadge />;
return (
<div className="option-value-boolean">
{value ? <EnabledBadge /> : <DisabledBadge />}
</div>
);
}
if (typeof value === "number") {
return <span css={styles.option}>{value}</span>;
return (
<span css={styles.option} className="option-value-number">
{value}
</span>
);
}
if (!value || value.length === 0) {
return <span css={styles.option}>Not set</span>;
return (
<span css={styles.option} className="option-value-empty">
Not set
</span>
);
}
if (typeof value === "string") {
return <span css={styles.option}>{value}</span>;
return (
<span css={styles.option} className="option-value-string">
{value}
</span>
);
}
if (typeof value === "object" && !Array.isArray(value)) {
@ -94,7 +110,7 @@ export const OptionValue: FC<OptionValueProps> = (props) => {
if (Array.isArray(value)) {
return (
<ul css={{ listStylePosition: "inside" }}>
<ul css={{ listStylePosition: "inside" }} className="option-array">
{value.map((item) => (
<li key={item} css={styles.option}>
{item}
@ -104,7 +120,11 @@ export const OptionValue: FC<OptionValueProps> = (props) => {
);
}
return <span css={styles.option}>{JSON.stringify(value)}</span>;
return (
<span css={styles.option} className="option-value-json">
{JSON.stringify(value)}
</span>
);
};
type OptionConfigProps = HTMLAttributes<HTMLDivElement> & { isSource: boolean };

View File

@ -17,8 +17,8 @@ import {
import { optionValue } from "./optionValue";
interface OptionsTableProps {
options: SerpentOption[];
additionalValues?: string[];
options: readonly SerpentOption[];
additionalValues?: readonly string[];
}
const OptionsTable: FC<OptionsTableProps> = ({ options, additionalValues }) => {

View File

@ -4,7 +4,7 @@ import type { SerpentOption } from "api/typesGenerated";
// optionValue is a helper function to format the value of a specific deployment options
export function optionValue(
option: SerpentOption,
additionalValues?: string[],
additionalValues?: readonly string[],
) {
// If option annotations are present, use them to format the value.
if (option.annotations) {

View File

@ -214,7 +214,7 @@ export const BooleanPill: FC<BooleanPillProps> = ({
);
};
type LogsProps = { lines: string[] } & HTMLAttributes<HTMLDivElement>;
type LogsProps = HTMLAttributes<HTMLDivElement> & { lines: readonly string[] };
export const Logs: FC<LogsProps> = ({ lines, ...divProps }) => {
const theme = useTheme();

View File

@ -41,8 +41,8 @@ export const WebsocketPage = () => {
{websocket.warnings.map((warning) => {
return (
<Alert key={warning} severity="warning">
{warning}
<Alert key={warning.code} severity="warning">
{warning.message}
</Alert>
);
})}

View File

@ -296,7 +296,7 @@ const UsersLatencyPanel: FC<UsersLatencyPanelProps> = ({
{!data && <Loader css={{ height: "100%" }} />}
{users && users.length === 0 && <NoDataAvailable />}
{users &&
users
[...users]
.sort((a, b) => b.latency_ms.p50 - a.latency_ms.p50)
.map((row) => (
<div
@ -367,7 +367,7 @@ const UsersActivityPanel: FC<UsersActivityPanelProps> = ({
{!data && <Loader css={{ height: "100%" }} />}
{users && users.length === 0 && <NoDataAvailable />}
{users &&
users
[...users]
.sort((a, b) => b.seconds - a.seconds)
.map((row) => (
<div
@ -405,7 +405,7 @@ const UsersActivityPanel: FC<UsersActivityPanelProps> = ({
};
interface TemplateUsagePanelProps extends PanelProps {
data: TemplateAppUsage[] | undefined;
data: readonly TemplateAppUsage[] | undefined;
}
const TemplateUsagePanel: FC<TemplateUsagePanelProps> = ({
@ -508,7 +508,7 @@ const TemplateUsagePanel: FC<TemplateUsagePanelProps> = ({
};
interface TemplateParametersUsagePanelProps extends PanelProps {
data: TemplateParameterUsage[] | undefined;
data: readonly TemplateParameterUsage[] | undefined;
}
const TemplateParametersUsagePanel: FC<TemplateParametersUsagePanelProps> = ({
@ -579,7 +579,7 @@ const TemplateParametersUsagePanel: FC<TemplateParametersUsagePanelProps> = ({
<div>Count</div>
</Tooltip>
</ParameterUsageRow>
{parameter.values
{[...parameter.values]
.sort((a, b) => b.count - a.count)
.filter((usage) => filterOrphanValues(usage, parameter))
.map((usage, usageIndex) => (

View File

@ -61,6 +61,7 @@ export interface TemplateSettingsForm {
// Helpful to show field errors on Storybook
initialTouched?: FormikTouched<UpdateTemplateMeta>;
accessControlEnabled: boolean;
advancedSchedulingEnabled: boolean;
portSharingExperimentEnabled: boolean;
portSharingControlsEnabled: boolean;
}
@ -73,6 +74,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
isSubmitting,
initialTouched,
accessControlEnabled,
advancedSchedulingEnabled,
portSharingExperimentEnabled,
portSharingControlsEnabled,
}) => {
@ -195,39 +197,54 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
</Stack>
</Stack>
</label>
<label htmlFor="require_active_version">
<Stack direction="row" spacing={1}>
<Checkbox
id="require_active_version"
name="require_active_version"
checked={form.values.require_active_version}
onChange={form.handleChange}
/>
<Stack spacing={2}>
<label htmlFor="require_active_version">
<Stack direction="row" spacing={1}>
<Checkbox
id="require_active_version"
name="require_active_version"
checked={form.values.require_active_version}
onChange={form.handleChange}
disabled={
!template.require_active_version &&
!advancedSchedulingEnabled
}
/>
<Stack direction="column" spacing={0.5}>
<Stack
direction="row"
alignItems="center"
spacing={0.5}
css={styles.optionText}
>
Require workspaces automatically update when started.
<HelpTooltip>
<HelpTooltipTrigger />
<HelpTooltipContent>
<HelpTooltipText>
This setting is not enforced for template admins.
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
<Stack direction="column" spacing={0.5}>
<Stack
direction="row"
alignItems="center"
spacing={0.5}
css={styles.optionText}
>
Require workspaces automatically update when started.
<HelpTooltip>
<HelpTooltipTrigger />
<HelpTooltipContent>
<HelpTooltipText>
This setting is not enforced for template admins.
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
</Stack>
<span css={styles.optionHelperText}>
Workspaces that are manually started or auto-started will
use the active template version.
</span>
</Stack>
</Stack>
</label>
{!advancedSchedulingEnabled && (
<Stack direction="row">
<EnterpriseBadge />
<span css={styles.optionHelperText}>
Workspaces that are manually started or auto-started will use
the active template version.
Enterprise license required to enabled.
</span>
</Stack>
</Stack>
</label>
)}
</Stack>
</Stack>
</FormSection>
@ -241,7 +258,9 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
helperText:
"Leave the message empty to keep the template active. Any message provided will mark the template as deprecated. Use this message to inform users of the deprecation and how to migrate to a new template.",
})}
disabled={isSubmitting || !accessControlEnabled}
disabled={
isSubmitting || (!template.deprecated && !accessControlEnabled)
}
fullWidth
label="Deprecation Message"
/>
@ -250,6 +269,8 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
<EnterpriseBadge />
<span css={styles.optionHelperText}>
Enterprise license required to deprecate templates.
{template.deprecated &&
" You cannot change the message, but you may remove it to mark this template as no longer deprecated."}
</span>
</Stack>
)}

View File

@ -20,6 +20,8 @@ export const TemplateSettingsPage: FC = () => {
const queryClient = useQueryClient();
const { entitlements, experiments } = useDashboard();
const accessControlEnabled = entitlements.features.access_control.enabled;
const advancedSchedulingEnabled =
entitlements.features.advanced_template_scheduling.enabled;
const sharedPortsExperimentEnabled = experiments.includes("shared-ports");
const sharedPortControlsEnabled =
entitlements.features.control_shared_ports.enabled;
@ -70,6 +72,7 @@ export const TemplateSettingsPage: FC = () => {
});
}}
accessControlEnabled={accessControlEnabled}
advancedSchedulingEnabled={advancedSchedulingEnabled}
sharedPortsExperimentEnabled={sharedPortsExperimentEnabled}
sharedPortControlsEnabled={sharedPortControlsEnabled}
/>

View File

@ -8,6 +8,7 @@ const meta: Meta<typeof TemplateSettingsPageView> = {
args: {
template: MockTemplate,
accessControlEnabled: true,
advancedSchedulingEnabled: true,
},
};
@ -36,5 +37,19 @@ export const SaveTemplateSettingsError: Story = {
export const NoEntitlements: Story = {
args: {
accessControlEnabled: false,
advancedSchedulingEnabled: false,
},
};
export const NoEntitlementsExpiredSettings: Story = {
args: {
template: {
...MockTemplate,
deprecated: true,
deprecation_message: "This template tastes bad",
require_active_version: true,
},
accessControlEnabled: false,
advancedSchedulingEnabled: false,
},
};

View File

@ -13,6 +13,7 @@ export interface TemplateSettingsPageViewProps {
typeof TemplateSettingsForm
>["initialTouched"];
accessControlEnabled: boolean;
advancedSchedulingEnabled: boolean;
sharedPortsExperimentEnabled: boolean;
sharedPortControlsEnabled: boolean;
}
@ -25,6 +26,7 @@ export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
submitError,
initialTouched,
accessControlEnabled,
advancedSchedulingEnabled,
sharedPortsExperimentEnabled,
sharedPortControlsEnabled,
}) => {
@ -42,6 +44,7 @@ export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
onCancel={onCancel}
error={submitError}
accessControlEnabled={accessControlEnabled}
advancedSchedulingEnabled={advancedSchedulingEnabled}
portSharingExperimentEnabled={sharedPortsExperimentEnabled}
portSharingControlsEnabled={sharedPortControlsEnabled}
/>

View File

@ -113,7 +113,7 @@ const ProxyMessagesRow: FC<ProxyMessagesRowProps> = ({ proxy }) => {
interface ProxyMessagesListProps {
title: ReactNode;
messages?: string[];
messages?: readonly string[];
}
const ProxyMessagesList: FC<ProxyMessagesListProps> = ({ title, messages }) => {

View File

@ -15,7 +15,7 @@ import type { ProxyLatencyReport } from "contexts/useProxyLatency";
import { ProxyRow } from "./WorkspaceProxyRow";
export interface WorkspaceProxyViewProps {
proxies?: Region[];
proxies?: readonly Region[];
proxyLatencies?: Record<string, ProxyLatencyReport>;
getWorkspaceProxiesError?: unknown;
isLoading: boolean;

View File

@ -9,7 +9,7 @@ import { UsersFilter } from "./UsersFilter";
import { UsersTable } from "./UsersTable/UsersTable";
export interface UsersPageViewProps {
users?: TypesGen.User[];
users?: readonly TypesGen.User[];
roles?: TypesGen.AssignableRoles[];
isUpdatingUserRoles?: boolean;
canEditUsers: boolean;

View File

@ -69,7 +69,7 @@ const Option: FC<OptionProps> = ({
export interface EditRolesButtonProps {
isLoading: boolean;
roles: Role[];
roles: readonly Role[];
selectedRoleNames: Set<string>;
onChange: (roles: Role["name"][]) => void;
isDefaultOpen?: boolean;

View File

@ -160,7 +160,7 @@ const roleNamesByAccessLevel: readonly string[] = [
"auditor",
];
function sortRolesByAccessLevel(roles: Role[]) {
function sortRolesByAccessLevel(roles: readonly Role[]): readonly Role[] {
if (roles.length === 0) {
return roles;
}

View File

@ -21,7 +21,7 @@ export const Language = {
} as const;
export interface UsersTableProps {
users: TypesGen.User[] | undefined;
users: readonly TypesGen.User[] | undefined;
roles: TypesGen.AssignableRoles[] | undefined;
groupsByUserId: GroupsByUserId | undefined;
isUpdatingUserRoles?: boolean;

View File

@ -36,7 +36,7 @@ import { UserRoleCell } from "./UserRoleCell";
dayjs.extend(relativeTime);
interface UsersTableBodyProps {
users: TypesGen.User[] | undefined;
users: readonly TypesGen.User[] | undefined;
groupsByUserId: GroupsByUserId | undefined;
authMethods?: TypesGen.AuthMethods;
roles?: TypesGen.AssignableRoles[];

View File

@ -13,7 +13,7 @@ import { getResourceIconPath } from "utils/workspace";
dayjs.extend(relativeTime);
type BatchDeleteConfirmationProps = {
checkedWorkspaces: Workspace[];
checkedWorkspaces: readonly Workspace[];
open: boolean;
isLoading: boolean;
onClose: () => void;
@ -111,7 +111,7 @@ export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
};
interface StageProps {
workspaces: Workspace[];
workspaces: readonly Workspace[];
}
const Consequences: FC = () => {

View File

@ -1,6 +1,7 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { useQueryClient } from "react-query";
import type { Workspace } from "api/typesGenerated";
import { chromatic } from "testHelpers/chromatic";
import {
MockWorkspace,
@ -29,23 +30,33 @@ const workspaces = [
},
];
const updates = new Map<string, Update>();
for (const it of workspaces) {
const versionId = it.template_active_version_id;
const version = updates.get(versionId);
function getPopulatedUpdates(): Map<string, Update> {
type MutableUpdate = Omit<Update, "affected_workspaces"> & {
affected_workspaces: Workspace[];
};
if (version) {
version.affected_workspaces.push(it);
continue;
const updates = new Map<string, MutableUpdate>();
for (const it of workspaces) {
const versionId = it.template_active_version_id;
const version = updates.get(versionId);
if (version) {
version.affected_workspaces.push(it);
continue;
}
updates.set(versionId, {
...MockTemplateVersion,
template_display_name: it.template_display_name,
affected_workspaces: [it],
});
}
updates.set(versionId, {
...MockTemplateVersion,
template_display_name: it.template_display_name,
affected_workspaces: [it],
});
return updates as Map<string, Update>;
}
const updates = getPopulatedUpdates();
const meta: Meta<typeof BatchUpdateConfirmation> = {
title: "pages/WorkspacesPage/BatchUpdateConfirmation",
parameters: { chromatic },

View File

@ -18,7 +18,7 @@ import { Stack } from "components/Stack/Stack";
dayjs.extend(relativeTime);
type BatchUpdateConfirmationProps = {
checkedWorkspaces: Workspace[];
checkedWorkspaces: readonly Workspace[];
open: boolean;
isLoading: boolean;
onClose: () => void;
@ -27,7 +27,7 @@ type BatchUpdateConfirmationProps = {
export interface Update extends TemplateVersion {
template_display_name: string;
affected_workspaces: Workspace[];
affected_workspaces: readonly Workspace[];
}
export const BatchUpdateConfirmation: FC<BatchUpdateConfirmationProps> = ({
@ -90,11 +90,13 @@ export const BatchUpdateConfirmation: FC<BatchUpdateConfirmationProps> = ({
// Figure out which new versions everything will be updated to so that we can
// show update messages and such.
const newVersions = useMemo(() => {
const newVersions = new Map<
string,
Pick<Update, "id" | "template_display_name" | "affected_workspaces">
>();
type MutableUpdateInfo = {
id: string;
template_display_name: string;
affected_workspaces: Workspace[];
};
const newVersions = new Map<string, MutableUpdateInfo>();
for (const it of workspacesToUpdate) {
const versionId = it.template_active_version_id;
const version = newVersions.get(versionId);
@ -111,7 +113,11 @@ export const BatchUpdateConfirmation: FC<BatchUpdateConfirmationProps> = ({
});
}
return newVersions;
type ReadonlyUpdateInfo = Readonly<MutableUpdateInfo> & {
affected_workspaces: readonly Workspace[];
};
return newVersions as Map<string, ReadonlyUpdateInfo>;
}, [workspacesToUpdate]);
// Not all of the information we want is included in the `Workspace` type, so we
@ -401,7 +407,7 @@ const TemplateVersionMessages: FC<TemplateVersionMessagesProps> = ({
};
interface UsedByProps {
workspaces: Workspace[];
workspaces: readonly Workspace[];
}
const UsedBy: FC<UsedByProps> = ({ workspaces }) => {

View File

@ -54,7 +54,9 @@ const WorkspacesPage: FC = () => {
});
const updateWorkspace = useWorkspaceUpdate(queryKey);
const [checkedWorkspaces, setCheckedWorkspaces] = useState<Workspace[]>([]);
const [checkedWorkspaces, setCheckedWorkspaces] = useState<
readonly Workspace[]
>([]);
const [confirmingBatchAction, setConfirmingBatchAction] = useState<
"delete" | "update" | null
>(null);

View File

@ -44,15 +44,15 @@ type TemplateQuery = UseQueryResult<Template[]>;
export interface WorkspacesPageViewProps {
error: unknown;
workspaces?: Workspace[];
checkedWorkspaces: Workspace[];
workspaces?: readonly Workspace[];
checkedWorkspaces: readonly Workspace[];
count?: number;
filterProps: ComponentProps<typeof WorkspacesFilter>;
page: number;
limit: number;
onPageChange: (page: number) => void;
onUpdateWorkspace: (workspace: Workspace) => void;
onCheckChange: (checkedWorkspaces: Workspace[]) => void;
onCheckChange: (checkedWorkspaces: readonly Workspace[]) => void;
isRunningBatchAction: boolean;
onDeleteAll: () => void;
onUpdateAll: () => void;

View File

@ -30,12 +30,12 @@ import { getDisplayWorkspaceTemplateName } from "utils/workspace";
import { WorkspacesEmpty } from "./WorkspacesEmpty";
export interface WorkspacesTableProps {
workspaces?: Workspace[];
checkedWorkspaces: Workspace[];
workspaces?: readonly Workspace[];
checkedWorkspaces: readonly Workspace[];
error?: unknown;
isUsingFilter: boolean;
onUpdateWorkspace: (workspace: Workspace) => void;
onCheckChange: (checkedWorkspaces: Workspace[]) => void;
onCheckChange: (checkedWorkspaces: readonly Workspace[]) => void;
canCheckWorkspaces: boolean;
templates?: Template[];
canCreateTemplate: boolean;

View File

@ -18,7 +18,7 @@ export function useBatchActions(options: UseBatchActionsProps) {
const { onSuccess } = options;
const startAllMutation = useMutation({
mutationFn: (workspaces: Workspace[]) => {
mutationFn: (workspaces: readonly Workspace[]) => {
return Promise.all(
workspaces.map((w) =>
startWorkspace(w.id, w.latest_build.template_version_id),
@ -32,7 +32,7 @@ export function useBatchActions(options: UseBatchActionsProps) {
});
const stopAllMutation = useMutation({
mutationFn: (workspaces: Workspace[]) => {
mutationFn: (workspaces: readonly Workspace[]) => {
return Promise.all(workspaces.map((w) => stopWorkspace(w.id)));
},
onSuccess,
@ -42,7 +42,7 @@ export function useBatchActions(options: UseBatchActionsProps) {
});
const deleteAllMutation = useMutation({
mutationFn: (workspaces: Workspace[]) => {
mutationFn: (workspaces: readonly Workspace[]) => {
return Promise.all(workspaces.map((w) => deleteWorkspace(w.id)));
},
onSuccess,
@ -52,7 +52,7 @@ export function useBatchActions(options: UseBatchActionsProps) {
});
const updateAllMutation = useMutation({
mutationFn: (workspaces: Workspace[]) => {
mutationFn: (workspaces: readonly Workspace[]) => {
return Promise.all(
workspaces
.filter((w) => w.outdated && !w.dormant_at)
@ -66,7 +66,7 @@ export function useBatchActions(options: UseBatchActionsProps) {
});
const favoriteAllMutation = useMutation({
mutationFn: (workspaces: Workspace[]) => {
mutationFn: (workspaces: readonly Workspace[]) => {
return Promise.all(
workspaces
.filter((w) => !w.favorite)
@ -80,7 +80,7 @@ export function useBatchActions(options: UseBatchActionsProps) {
});
const unfavoriteAllMutation = useMutation({
mutationFn: (workspaces: Workspace[]) => {
mutationFn: (workspaces: readonly Workspace[]) => {
return Promise.all(
workspaces
.filter((w) => w.favorite)

View File

@ -3261,7 +3261,7 @@ export const MockHealth: TypesGen.HealthcheckReport = {
export const MockListeningPortsResponse: TypesGen.WorkspaceAgentListeningPortsResponse =
{
ports: [
{ process_name: "webb", network: "", port: 3000 },
{ process_name: "webb", network: "", port: 30000 },
{ process_name: "gogo", network: "", port: 8080 },
{ process_name: "", network: "", port: 8081 },
],

View File

@ -1,13 +1,15 @@
import type { WorkspaceAgentPortShareProtocol } from "api/typesGenerated";
export const portForwardURL = (
host: string,
port: number,
agentName: string,
workspaceName: string,
username: string,
https = false,
protocol: WorkspaceAgentPortShareProtocol,
): string => {
const { location } = window;
const suffix = https ? "s" : "";
const suffix = protocol === "https" ? "s" : "";
const subdomain = `${port}${suffix}--${agentName}--${workspaceName}--${username}`;
return `${location.protocol}//${host}`.replace("*", subdomain);
@ -56,9 +58,28 @@ export const openMaybePortForwardedURL = (
agentName,
workspaceName,
username,
url.protocol.replace(":", "") as WorkspaceAgentPortShareProtocol,
) + url.pathname,
);
} catch (ex) {
open(uri);
}
};
export const saveWorkspaceListeningPortsProtocol = (
workspaceID: string,
protocol: WorkspaceAgentPortShareProtocol,
) => {
localStorage.setItem(
`listening-ports-protocol-workspace-${workspaceID}`,
protocol,
);
};
export const getWorkspaceListeningPortsProtocol = (
workspaceID: string,
): WorkspaceAgentPortShareProtocol => {
return (localStorage.getItem(
`listening-ports-protocol-workspace-${workspaceID}`,
) || "http") as WorkspaceAgentPortShareProtocol;
};