diff --git a/cli/login.go b/cli/login.go index 65a94d8a4ec3e..87cfea103c271 100644 --- a/cli/login.go +++ b/cli/login.go @@ -239,7 +239,7 @@ func (r *RootCmd) login() *serpent.Command { if !inv.ParsedFlags().Changed("first-user-trial") && os.Getenv(firstUserTrialEnv) == "" { v, _ := cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Start a 30-day trial of Enterprise?", + Text: "Start a trial of Enterprise?", IsConfirm: true, Default: "yes", }) diff --git a/cli/server.go b/cli/server.go index 3706b2ee1bc92..612972014fbb7 100644 --- a/cli/server.go +++ b/cli/server.go @@ -796,31 +796,18 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. cliui.Infof(inv.Stdout, "\n==> Logs will stream in below (press ctrl+c to gracefully exit):") if vals.Telemetry.Enable { - gitAuth := make([]telemetry.GitAuth, 0) - // TODO: - var gitAuthConfigs []codersdk.ExternalAuthConfig - for _, cfg := range gitAuthConfigs { - gitAuth = append(gitAuth, telemetry.GitAuth{ - Type: cfg.Type, - }) + vals, err := vals.WithoutSecrets() + if err != nil { + return xerrors.Errorf("remove secrets from deployment values: %w", err) } - options.Telemetry, err = telemetry.New(telemetry.Options{ - BuiltinPostgres: builtinPostgres, - DeploymentID: deploymentID, - Database: options.Database, - Logger: logger.Named("telemetry"), - URL: vals.Telemetry.URL.Value(), - Wildcard: vals.WildcardAccessURL.String() != "", - DERPServerRelayURL: vals.DERP.Server.RelayURL.String(), - GitAuth: gitAuth, - GitHubOAuth: vals.OAuth2.Github.ClientID != "", - OIDCAuth: vals.OIDC.ClientID != "", - OIDCIssuerURL: vals.OIDC.IssuerURL.String(), - Prometheus: vals.Prometheus.Enable.Value(), - STUN: len(vals.DERP.Server.STUNAddresses) != 0, - Tunnel: tunnel != nil, - Experiments: vals.Experiments.Value(), + BuiltinPostgres: builtinPostgres, + DeploymentID: deploymentID, + Database: options.Database, + Logger: logger.Named("telemetry"), + URL: vals.Telemetry.URL.Value(), + Tunnel: tunnel != nil, + DeploymentConfig: vals, ParseLicenseJWT: func(lic *telemetry.License) error { // This will be nil when running in AGPL-only mode. if options.ParseLicenseClaims == nil { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a284e46d0a0bb..ad5acf06e34c8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8717,6 +8717,10 @@ const docTemplate = `{ "description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.", "type": "string" }, + "telemetry": { + "description": "Telemetry is a boolean that indicates whether telemetry is enabled.", + "type": "boolean" + }, "upgrade_message": { "description": "UpgradeMessage is the message displayed to users when an outdated client\nis detected.", "type": "string" @@ -9782,12 +9786,6 @@ const docTemplate = `{ "description": "DisplayName is shown in the UI to identify the auth config.", "type": "string" }, - "extra_token_keys": { - "type": "array", - "items": { - "type": "string" - } - }, "id": { "description": "ID is a unique identifier for the auth config.\nIt defaults to ` + "`" + `type` + "`" + ` when not provided.", "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 28212bdaa8342..33090f548dada 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7761,6 +7761,10 @@ "description": "ExternalURL references the current Coder version.\nFor production builds, this will link directly to a release. For development builds, this will link to a commit.", "type": "string" }, + "telemetry": { + "description": "Telemetry is a boolean that indicates whether telemetry is enabled.", + "type": "boolean" + }, "upgrade_message": { "description": "UpgradeMessage is the message displayed to users when an outdated client\nis detected.", "type": "string" @@ -8769,12 +8773,6 @@ "description": "DisplayName is shown in the UI to identify the auth config.", "type": "string" }, - "extra_token_keys": { - "type": "array", - "items": { - "type": "string" - } - }, "id": { "description": "ID is a unique identifier for the auth config.\nIt defaults to `type` when not provided.", "type": "string" diff --git a/coderd/coderd.go b/coderd/coderd.go index 25763530db702..befda4d3c78df 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -450,6 +450,7 @@ func New(options *Options) *API { WorkspaceProxy: false, UpgradeMessage: api.DeploymentValues.CLIUpgradeMessage.String(), DeploymentID: api.DeploymentID, + Telemetry: api.Telemetry.Enabled(), } api.SiteHandler = site.New(&site.Options{ BinFS: binFS, diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 36292179da478..9d16ba7922098 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -41,20 +41,13 @@ type Options struct { // URL is an endpoint to direct telemetry towards! URL *url.URL - BuiltinPostgres bool - DeploymentID string - GitHubOAuth bool - OIDCAuth bool - OIDCIssuerURL string - Wildcard bool - DERPServerRelayURL string - GitAuth []GitAuth - Prometheus bool - STUN bool - SnapshotFrequency time.Duration - Tunnel bool - ParseLicenseJWT func(lic *License) error - Experiments []string + DeploymentID string + DeploymentConfig *codersdk.DeploymentValues + BuiltinPostgres bool + Tunnel bool + + SnapshotFrequency time.Duration + ParseLicenseJWT func(lic *License) error } // New constructs a reporter for telemetry data. @@ -100,6 +93,7 @@ type Reporter interface { // database. For example, if a new user is added, a snapshot can // contain just that user entry. Report(snapshot *Snapshot) + Enabled() bool Close() } @@ -116,6 +110,10 @@ type remoteReporter struct { shutdownAt *time.Time } +func (*remoteReporter) Enabled() bool { + return true +} + func (r *remoteReporter) Report(snapshot *Snapshot) { go r.reportSync(snapshot) } @@ -242,31 +240,24 @@ func (r *remoteReporter) deployment() error { } data, err := json.Marshal(&Deployment{ - ID: r.options.DeploymentID, - Architecture: sysInfo.Architecture, - BuiltinPostgres: r.options.BuiltinPostgres, - Containerized: containerized, - Wildcard: r.options.Wildcard, - DERPServerRelayURL: r.options.DERPServerRelayURL, - GitAuth: r.options.GitAuth, - Kubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "", - GitHubOAuth: r.options.GitHubOAuth, - OIDCAuth: r.options.OIDCAuth, - OIDCIssuerURL: r.options.OIDCIssuerURL, - Prometheus: r.options.Prometheus, - InstallSource: installSource, - STUN: r.options.STUN, - Tunnel: r.options.Tunnel, - OSType: sysInfo.OS.Type, - OSFamily: sysInfo.OS.Family, - OSPlatform: sysInfo.OS.Platform, - OSName: sysInfo.OS.Name, - OSVersion: sysInfo.OS.Version, - CPUCores: runtime.NumCPU(), - MemoryTotal: mem.Total, - MachineID: sysInfo.UniqueID, - StartedAt: r.startedAt, - ShutdownAt: r.shutdownAt, + ID: r.options.DeploymentID, + Architecture: sysInfo.Architecture, + BuiltinPostgres: r.options.BuiltinPostgres, + Containerized: containerized, + Config: r.options.DeploymentConfig, + Kubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "", + InstallSource: installSource, + Tunnel: r.options.Tunnel, + OSType: sysInfo.OS.Type, + OSFamily: sysInfo.OS.Family, + OSPlatform: sysInfo.OS.Platform, + OSName: sysInfo.OS.Name, + OSVersion: sysInfo.OS.Version, + CPUCores: runtime.NumCPU(), + MemoryTotal: mem.Total, + MachineID: sysInfo.UniqueID, + StartedAt: r.startedAt, + ShutdownAt: r.shutdownAt, }) if err != nil { return xerrors.Errorf("marshal deployment: %w", err) @@ -481,10 +472,6 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { } return nil }) - eg.Go(func() error { - snapshot.Experiments = ConvertExperiments(r.options.Experiments) - return nil - }) err := eg.Wait() if err != nil { @@ -745,16 +732,6 @@ func ConvertExternalProvisioner(id uuid.UUID, tags map[string]string, provisione } } -func ConvertExperiments(experiments []string) []Experiment { - var out []Experiment - - for _, exp := range experiments { - out = append(out, Experiment{Name: exp}) - } - - return out -} - // Snapshot represents a point-in-time anonymized database dump. // Data is aggregated by latest on the server-side, so partial data // can be sent without issue. @@ -777,40 +754,28 @@ type Snapshot struct { WorkspaceResourceMetadata []WorkspaceResourceMetadata `json:"workspace_resource_metadata"` WorkspaceResources []WorkspaceResource `json:"workspace_resources"` Workspaces []Workspace `json:"workspaces"` - Experiments []Experiment `json:"experiments"` } // Deployment contains information about the host running Coder. type Deployment struct { - ID string `json:"id"` - Architecture string `json:"architecture"` - BuiltinPostgres bool `json:"builtin_postgres"` - Containerized bool `json:"containerized"` - Kubernetes bool `json:"kubernetes"` - Tunnel bool `json:"tunnel"` - Wildcard bool `json:"wildcard"` - DERPServerRelayURL string `json:"derp_server_relay_url"` - GitAuth []GitAuth `json:"git_auth"` - GitHubOAuth bool `json:"github_oauth"` - OIDCAuth bool `json:"oidc_auth"` - OIDCIssuerURL string `json:"oidc_issuer_url"` - Prometheus bool `json:"prometheus"` - InstallSource string `json:"install_source"` - STUN bool `json:"stun"` - OSType string `json:"os_type"` - OSFamily string `json:"os_family"` - OSPlatform string `json:"os_platform"` - OSName string `json:"os_name"` - OSVersion string `json:"os_version"` - CPUCores int `json:"cpu_cores"` - MemoryTotal uint64 `json:"memory_total"` - MachineID string `json:"machine_id"` - StartedAt time.Time `json:"started_at"` - ShutdownAt *time.Time `json:"shutdown_at"` -} - -type GitAuth struct { - Type string `json:"type"` + ID string `json:"id"` + Architecture string `json:"architecture"` + BuiltinPostgres bool `json:"builtin_postgres"` + Containerized bool `json:"containerized"` + Kubernetes bool `json:"kubernetes"` + Config *codersdk.DeploymentValues `json:"config"` + Tunnel bool `json:"tunnel"` + InstallSource string `json:"install_source"` + OSType string `json:"os_type"` + OSFamily string `json:"os_family"` + OSPlatform string `json:"os_platform"` + OSName string `json:"os_name"` + OSVersion string `json:"os_version"` + CPUCores int `json:"cpu_cores"` + MemoryTotal uint64 `json:"memory_total"` + MachineID string `json:"machine_id"` + StartedAt time.Time `json:"started_at"` + ShutdownAt *time.Time `json:"shutdown_at"` } type APIKey struct { @@ -985,11 +950,8 @@ type ExternalProvisioner struct { ShutdownAt *time.Time `json:"shutdown_at"` } -type Experiment struct { - Name string `json:"name"` -} - type noopReporter struct{} func (*noopReporter) Report(_ *Snapshot) {} +func (*noopReporter) Enabled() bool { return false } func (*noopReporter) Close() {} diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 4661a4f8f21bf..fa8650de3f3d5 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -114,17 +114,6 @@ func TestTelemetry(t *testing.T) { require.Len(t, snapshot.Users, 1) require.Equal(t, snapshot.Users[0].EmailHashed, "bb44bf07cf9a2db0554bba63a03d822c927deae77df101874496df5a6a3e896d@coder.com") }) - t.Run("Experiments", func(t *testing.T) { - t.Parallel() - - const expName = "my-experiment" - exps := []string{expName} - _, snapshot := collectSnapshot(t, dbmem.New(), func(opts telemetry.Options) telemetry.Options { - opts.Experiments = exps - return opts - }) - require.Equal(t, []telemetry.Experiment{{Name: expName}}, snapshot.Experiments) - }) } // nolint:paralleltest diff --git a/codersdk/deployment.go b/codersdk/deployment.go index c89a78668637d..2969d2cfd745d 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -392,7 +392,7 @@ type ExternalAuthConfig struct { AppInstallationsURL string `json:"app_installations_url" yaml:"app_installations_url"` NoRefresh bool `json:"no_refresh" yaml:"no_refresh"` Scopes []string `json:"scopes" yaml:"scopes"` - ExtraTokenKeys []string `json:"extra_token_keys" yaml:"extra_token_keys"` + ExtraTokenKeys []string `json:"-" yaml:"extra_token_keys"` DeviceFlow bool `json:"device_flow" yaml:"device_flow"` DeviceCodeURL string `json:"device_code_url" yaml:"device_code_url"` // Regex allows API requesters to match an auth config by @@ -2162,11 +2162,12 @@ type BuildInfoResponse struct { ExternalURL string `json:"external_url"` // Version returns the semantic version of the build. Version string `json:"version"` - // DashboardURL is the URL to hit the deployment's dashboard. // For external workspace proxies, this is the coderd they are connected // to. DashboardURL string `json:"dashboard_url"` + // Telemetry is a boolean that indicates whether telemetry is enabled. + Telemetry bool `json:"telemetry"` WorkspaceProxy bool `json:"workspace_proxy"` diff --git a/docs/api/general.md b/docs/api/general.md index 52313409cb02c..aa77d9d272c94 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -57,6 +57,7 @@ curl -X GET http://coder-server:8080/api/v2/buildinfo \ "dashboard_url": "string", "deployment_id": "string", "external_url": "string", + "telemetry": true, "upgrade_message": "string", "version": "string", "workspace_proxy": true @@ -227,7 +228,6 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "device_flow": true, "display_icon": "string", "display_name": "string", - "extra_token_keys": ["string"], "id": "string", "no_refresh": true, "regex": "string", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 82804508b0e96..6f6b6ae87d3df 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1234,6 +1234,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "dashboard_url": "string", "deployment_id": "string", "external_url": "string", + "telemetry": true, "upgrade_message": "string", "version": "string", "workspace_proxy": true @@ -1248,6 +1249,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `dashboard_url` | string | false | | Dashboard URL is the URL to hit the deployment's dashboard. For external workspace proxies, this is the coderd they are connected to. | | `deployment_id` | string | false | | Deployment ID is the unique identifier for this deployment. | | `external_url` | string | false | | External URL references the current Coder version. For production builds, this will link directly to a release. For development builds, this will link to a commit. | +| `telemetry` | boolean | false | | Telemetry is a boolean that indicates whether telemetry is enabled. | | `upgrade_message` | string | false | | Upgrade message is the message displayed to users when an outdated client is detected. | | `version` | string | false | | Version returns the semantic version of the build. | | `workspace_proxy` | boolean | false | | | @@ -2009,7 +2011,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "device_flow": true, "display_icon": "string", "display_name": "string", - "extra_token_keys": ["string"], "id": "string", "no_refresh": true, "regex": "string", @@ -2382,7 +2383,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "device_flow": true, "display_icon": "string", "display_name": "string", - "extra_token_keys": ["string"], "id": "string", "no_refresh": true, "regex": "string", @@ -2801,7 +2801,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "device_flow": true, "display_icon": "string", "display_name": "string", - "extra_token_keys": ["string"], "id": "string", "no_refresh": true, "regex": "string", @@ -2824,7 +2823,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `device_flow` | boolean | false | | | | `display_icon` | string | false | | Display icon is a URL to an icon to display in the UI. | | `display_name` | string | false | | Display name is shown in the UI to identify the auth config. | -| `extra_token_keys` | array of string | false | | | | `id` | string | false | | ID is a unique identifier for the auth config. It defaults to `type` when not provided. | | `no_refresh` | boolean | false | | | | `regex` | string | false | | Regex allows API requesters to match an auth config by a string (e.g. coder.com) instead of by it's type. | @@ -8844,7 +8842,6 @@ _None_ "device_flow": true, "display_icon": "string", "display_name": "string", - "extra_token_keys": ["string"], "id": "string", "no_refresh": true, "regex": "string", diff --git a/enterprise/trialer/trialer.go b/enterprise/trialer/trialer.go index fd846df58db61..fa5d15a65b25a 100644 --- a/enterprise/trialer/trialer.go +++ b/enterprise/trialer/trialer.go @@ -39,6 +39,22 @@ func New(db database.Store, url string, keys map[string]ed25519.PublicKey) func( return xerrors.Errorf("perform license request: %w", err) } defer res.Body.Close() + if res.StatusCode > 300 { + body, err := io.ReadAll(res.Body) + if err != nil { + return xerrors.Errorf("read license response: %w", err) + } + // This is the format of the error response from + // the license server. + var msg struct { + Error string `json:"error"` + } + err = json.Unmarshal(body, &msg) + if err != nil { + return xerrors.Errorf("unmarshal error: %w", err) + } + return xerrors.New(msg.Error) + } raw, err := io.ReadAll(res.Body) if err != nil { return xerrors.Errorf("read license: %w", err) diff --git a/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go b/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go index 870d06b71da6d..c94b712cc9872 100644 --- a/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go +++ b/enterprise/wsproxy/wsproxysdk/wsproxysdk_test.go @@ -216,7 +216,7 @@ func TestDialCoordinator(t *testing.T) { Node: &proto.Node{ Id: 55, AsOf: timestamppb.New(time.Unix(1689653252, 0)), - Key: peerNodeKey[:], + Key: peerNodeKey, Disco: string(peerDiscoKey), PreferredDerp: 0, DerpLatency: map[string]float64{ diff --git a/scripts/release.sh b/scripts/release.sh index 66c30a6792821..ec887cd9c3dc1 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -374,7 +374,7 @@ You can follow the release progress [here](https://github.com/coder/coder/action create_pr_stash=1 fi maybedryrun "${dry_run}" git checkout -b "${pr_branch}" "${remote}/${branch}" - execrelative go run ./release autoversion --channel "${channel}" "${new_version}" --dry-run + execrelative go run ./release autoversion --channel "${channel}" "${new_version}" --dry-run="${dry_run}" maybedryrun "${dry_run}" git add docs maybedryrun "${dry_run}" git commit -m "${title}" # Return to previous branch. diff --git a/scripts/release/main.go b/scripts/release/main.go index 8eaeb20825a92..3eb67c7b96225 100644 --- a/scripts/release/main.go +++ b/scripts/release/main.go @@ -242,6 +242,7 @@ func (r *releaseCommand) promoteVersionToStable(ctx context.Context, inv *serpen updatedBody := removeMainlineBlurb(newStable.GetBody()) updatedBody = addStableSince(time.Now().UTC(), updatedBody) updatedNewStable.Body = github.String(updatedBody) + updatedNewStable.MakeLatest = github.String("true") updatedNewStable.Prerelease = github.Bool(false) updatedNewStable.Draft = github.Bool(false) if !r.dryRun { diff --git a/site/jest.setup.ts b/site/jest.setup.ts index 6282295870681..40bb92fa44965 100644 --- a/site/jest.setup.ts +++ b/site/jest.setup.ts @@ -41,6 +41,7 @@ global.scrollTo = jest.fn(); window.HTMLElement.prototype.scrollIntoView = jest.fn(); window.open = jest.fn(); +navigator.sendBeacon = jest.fn(); // Polyfill the getRandomValues that is used on utils/random.ts Object.defineProperty(global.self, "crypto", { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 88e5c7e508f67..3bf37219ea1e8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -171,6 +171,7 @@ export interface BuildInfoResponse { readonly external_url: string; readonly version: string; readonly dashboard_url: string; + readonly telemetry: boolean; readonly workspace_proxy: boolean; readonly agent_api_version: string; readonly upgrade_message: string; @@ -506,7 +507,6 @@ export interface ExternalAuthConfig { readonly app_installations_url: string; readonly no_refresh: boolean; readonly scopes: readonly string[]; - readonly extra_token_keys: readonly string[]; readonly device_flow: boolean; readonly device_code_url: string; readonly regex: string; diff --git a/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx b/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx index 22a57140511f5..3ed1d8143738e 100644 --- a/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx +++ b/site/src/pages/DeploySettingsPage/ExternalAuthSettingsPage/ExternalAuthSettingsPageView.stories.tsx @@ -19,7 +19,6 @@ const meta: Meta = { app_installations_url: "", no_refresh: false, scopes: [], - extra_token_keys: [], device_flow: true, device_code_url: "", display_icon: "", diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index f05c0b40d981f..d519a7673816b 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -1,4 +1,4 @@ -import type { FC } from "react"; +import { useEffect, type FC } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; import { Navigate, useLocation, useNavigate } from "react-router-dom"; @@ -8,6 +8,7 @@ import { useAuthContext } from "contexts/auth/AuthProvider"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { getApplicationName } from "utils/appearance"; import { retrieveRedirect } from "utils/redirect"; +import { sendDeploymentEvent } from "utils/telemetry"; import { LoginPageView } from "./LoginPageView"; export const LoginPage: FC = () => { @@ -19,16 +20,37 @@ export const LoginPage: FC = () => { signIn, isSigningIn, signInError, + user, } = useAuthContext(); const authMethodsQuery = useQuery(authMethods()); const redirectTo = retrieveRedirect(location.search); const applicationName = getApplicationName(); const navigate = useNavigate(); - const { metadata } = useEmbeddedMetadata(); const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); + useEffect(() => { + if (!buildInfoQuery.data || isSignedIn) { + // isSignedIn already tracks with window.href! + return; + } + // This uses `navigator.sendBeacon`, so navigating away will not prevent it! + sendDeploymentEvent(buildInfoQuery.data, { + type: "deployment_login", + user_id: user?.id, + }); + }, [isSignedIn, buildInfoQuery.data, user?.id]); + if (isSignedIn) { + if (buildInfoQuery.data) { + // This uses `navigator.sendBeacon`, so window.href + // will not stop the request from being sent! + sendDeploymentEvent(buildInfoQuery.data, { + type: "deployment_login", + user_id: user?.id, + }); + } + // If the redirect is going to a workspace application, and we // are missing authentication, then we need to change the href location // to trigger a HTTP request. This allows the BE to generate the auth @@ -74,6 +96,15 @@ export const LoginPage: FC = () => { isSigningIn={isSigningIn} onSignIn={async ({ email, password }) => { await signIn(email, password); + if (buildInfoQuery.data) { + // This uses `navigator.sendBeacon`, so navigating away + // will not prevent it! + sendDeploymentEvent(buildInfoQuery.data, { + type: "deployment_login", + user_id: user?.id, + }); + } + navigate("/"); }} /> diff --git a/site/src/pages/SetupPage/SetupPage.test.tsx b/site/src/pages/SetupPage/SetupPage.test.tsx index 2f558316d95cc..fb22dcf4f303a 100644 --- a/site/src/pages/SetupPage/SetupPage.test.tsx +++ b/site/src/pages/SetupPage/SetupPage.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import { HttpResponse, http } from "msw"; import { createMemoryRouter } from "react-router-dom"; import type { Response, User } from "api/typesGenerated"; -import { MockUser } from "testHelpers/entities"; +import { MockBuildInfo, MockUser } from "testHelpers/entities"; import { renderWithRouter, waitForLoaderToBeRemoved, @@ -99,4 +99,42 @@ describe("Setup Page", () => { await fillForm(); await waitFor(() => screen.findByText("Templates")); }); + it("calls sendBeacon with telemetry", async () => { + const sendBeacon = jest.fn(); + Object.defineProperty(window.navigator, "sendBeacon", { + value: sendBeacon, + }); + renderWithRouter( + createMemoryRouter( + [ + { + path: "/setup", + element: , + }, + { + path: "/templates", + element:

Templates

, + }, + ], + { initialEntries: ["/setup"] }, + ), + ); + await waitForLoaderToBeRemoved(); + await waitFor(() => { + expect(navigator.sendBeacon).toBeCalledWith( + "https://coder.com/api/track-deployment", + new Blob( + [ + JSON.stringify({ + type: "deployment_setup", + deployment_id: MockBuildInfo.deployment_id, + }), + ], + { + type: "application/json", + }, + ), + ); + }); + }); }); diff --git a/site/src/pages/SetupPage/SetupPage.tsx b/site/src/pages/SetupPage/SetupPage.tsx index fc5d0cf35f957..20899157c3b30 100644 --- a/site/src/pages/SetupPage/SetupPage.tsx +++ b/site/src/pages/SetupPage/SetupPage.tsx @@ -1,11 +1,14 @@ -import type { FC } from "react"; +import { useEffect, type FC } from "react"; import { Helmet } from "react-helmet-async"; -import { useMutation } from "react-query"; +import { useMutation, useQuery } from "react-query"; import { Navigate, useNavigate } from "react-router-dom"; +import { buildInfo } from "api/queries/buildInfo"; import { createFirstUser } from "api/queries/users"; import { Loader } from "components/Loader/Loader"; import { useAuthContext } from "contexts/auth/AuthProvider"; +import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; import { pageTitle } from "utils/page"; +import { sendDeploymentEvent } from "utils/telemetry"; import { SetupPageView } from "./SetupPageView"; export const SetupPage: FC = () => { @@ -18,7 +21,17 @@ export const SetupPage: FC = () => { } = useAuthContext(); const createFirstUserMutation = useMutation(createFirstUser()); const setupIsComplete = !isConfiguringTheFirstUser; + const { metadata } = useEmbeddedMetadata(); + const buildInfoQuery = useQuery(buildInfo(metadata["build-info"])); const navigate = useNavigate(); + useEffect(() => { + if (!buildInfoQuery.data) { + return; + } + sendDeploymentEvent(buildInfoQuery.data, { + type: "deployment_setup", + }); + }, [buildInfoQuery.data]); if (isLoading) { return ; diff --git a/site/src/pages/SetupPage/SetupPageView.stories.tsx b/site/src/pages/SetupPage/SetupPageView.stories.tsx index 239fb10cab930..030115fbbddb9 100644 --- a/site/src/pages/SetupPage/SetupPageView.stories.tsx +++ b/site/src/pages/SetupPage/SetupPageView.stories.tsx @@ -22,6 +22,15 @@ export const FormError: Story = { }, }; +export const TrialError: Story = { + args: { + error: mockApiError({ + message: "Couldn't generate trial!", + detail: "It looks like your team is already trying Coder.", + }), + }, +}; + export const Loading: Story = { args: { isLoading: true, diff --git a/site/src/pages/SetupPage/SetupPageView.tsx b/site/src/pages/SetupPage/SetupPageView.tsx index af673acacc333..7f97deb973991 100644 --- a/site/src/pages/SetupPage/SetupPageView.tsx +++ b/site/src/pages/SetupPage/SetupPageView.tsx @@ -1,13 +1,16 @@ import LoadingButton from "@mui/lab/LoadingButton"; +import AlertTitle from "@mui/material/AlertTitle"; import Autocomplete from "@mui/material/Autocomplete"; import Checkbox from "@mui/material/Checkbox"; import Link from "@mui/material/Link"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; +import { isAxiosError } from "axios"; import { type FormikContextType, useFormik } from "formik"; import type { FC } from "react"; import * as Yup from "yup"; import type * as TypesGen from "api/typesGenerated"; +import { Alert, AlertDetail } from "components/Alert/Alert"; import { FormFields, VerticalForm } from "components/Form/Form"; import { CoderIcon } from "components/Icons/CoderIcon"; import { SignInLayout } from "components/SignInLayout/SignInLayout"; @@ -187,7 +190,7 @@ export const SetupPageView: FC = ({
- Start a 30-day free trial of Enterprise + Start a free trial of Enterprise ({ @@ -316,6 +319,21 @@ export const SetupPageView: FC = ({ )} + {isAxiosError(error) && error.response?.data?.message && ( + + {error.response.data.message} + {error.response.data.detail && ( + + {error.response.data.detail} +
+ + Contact Sales + +
+ )} +
+ )} + = ({ const [showControlsAnyway, setShowControlsAnyway] = useState(false); let onClickScheduleIcon: (() => void) | undefined; - let activity: ReactNode = null; if (activityStatus === "connected") { onClickScheduleIcon = () => setShowControlsAnyway((it) => !it); - activity = Connected; const now = dayjs(); const noRequiredStopSoon = @@ -183,12 +180,7 @@ const AutostopDisplay: FC = ({ // User has shown controls manually, or we should warn about a nearby required stop if (!showControlsAnyway && noRequiredStopSoon) { - return ( - <> - {activity} - - - ); + return ; } } @@ -239,24 +231,18 @@ const AutostopDisplay: FC = ({ if (tooltip) { return ( - <> - {activity} - - {display} - {controls} - - + + {display} + {controls} + ); } return ( - <> - {activity} - - {display} - {controls} - - + + {display} + {controls} + ); }; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 1e2cf21e23383..e083c4c605b05 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -202,6 +202,7 @@ export const MockBuildInfo: TypesGen.BuildInfoResponse = { workspace_proxy: false, upgrade_message: "My custom upgrade message", deployment_id: "510d407f-e521-4180-b559-eab4a6d802b8", + telemetry: true, }; export const MockSupportLinks: TypesGen.LinkConfig[] = [ diff --git a/site/src/utils/telemetry.ts b/site/src/utils/telemetry.ts new file mode 100644 index 0000000000000..3b6906690cd6a --- /dev/null +++ b/site/src/utils/telemetry.ts @@ -0,0 +1,33 @@ +import type { BuildInfoResponse } from "api/typesGenerated"; + +// sendDeploymentEvent sends a CORs payload to coder.com +// to track a deployment event. +export const sendDeploymentEvent = ( + buildInfo: BuildInfoResponse, + payload: { + type: "deployment_setup" | "deployment_login"; + user_id?: string; + }, +) => { + if (typeof navigator === "undefined" || !navigator.sendBeacon) { + // It's fine if we don't report this, it's not required! + return; + } + if (!buildInfo.telemetry) { + return; + } + navigator.sendBeacon( + "https://coder.com/api/track-deployment", + new Blob( + [ + JSON.stringify({ + ...payload, + deployment_id: buildInfo.deployment_id, + }), + ], + { + type: "application/json", + }, + ), + ); +}; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy