From a27e95e1a4997dd3c2352873bd144edb2813c544 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 14:58:28 +0000 Subject: [PATCH 01/94] chore: add vendored version of experimental Coder SDK --- plugins/backstage-plugin-coder/package.json | 3 + .../src/api/vendoredSdk/README.md | 20 + .../src/api/vendoredSdk/api/api.ts | 1940 ++++++++++++ .../src/api/vendoredSdk/api/errors.ts | 124 + .../src/api/vendoredSdk/api/typesGenerated.ts | 2599 +++++++++++++++++ .../src/api/vendoredSdk/index.ts | 8 + .../src/api/vendoredSdk/utils/delay.ts | 4 + yarn.lock | 30 +- 8 files changed, 4713 insertions(+), 15 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/api/vendoredSdk/README.md create mode 100644 plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts create mode 100644 plugins/backstage-plugin-coder/src/api/vendoredSdk/api/errors.ts create mode 100644 plugins/backstage-plugin-coder/src/api/vendoredSdk/api/typesGenerated.ts create mode 100644 plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts create mode 100644 plugins/backstage-plugin-coder/src/api/vendoredSdk/utils/delay.ts diff --git a/plugins/backstage-plugin-coder/package.json b/plugins/backstage-plugin-coder/package.json index e21caf74..1d21b960 100644 --- a/plugins/backstage-plugin-coder/package.json +++ b/plugins/backstage-plugin-coder/package.json @@ -42,6 +42,8 @@ "@material-ui/lab": "4.0.0-alpha.61", "@tanstack/react-query": "4.36.1", "axios": "^1.6.8", + "dayjs": "^1.11.11", + "ua-parser-js": "^1.0.37", "use-sync-external-store": "^1.2.1", "valibot": "^0.28.1" }, @@ -57,6 +59,7 @@ "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^14.2.1", "@testing-library/user-event": "^14.0.0", + "@types/ua-parser-js": "^0.7.39", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/README.md b/plugins/backstage-plugin-coder/src/api/vendoredSdk/README.md new file mode 100644 index 00000000..354acb1c --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/README.md @@ -0,0 +1,20 @@ +# Coder SDK - Experimental Vendored Version + +This is a vendored version of the main API files from the +[core Coder OSS repo](https://github.com/coder/coder/tree/main/site/src/api). All files (aside from test files) have been copied over directly, with only a +few changes made to satisfy default Backstage ESLint rules. + +While there is a risk of this getting out of sync with the versions of the +files in Coder OSS, the Coder API itself should be treated as stable. Breaking +changes are only made when absolutely necessary. + +## General approach + +- Copy over relevant files from Coder OSS and place them in relevant folders + - As much as possible, the file structure of the vendored files should match the file structure of Coder OSS to make it easier to copy updated files over. +- Have a single file at the top level of this directory that exports out the files for consumption elsewhere in the plugin. No plugin code should interact with the vendored files directly. + +## Eventual plans + +Coder has eventual plans to create a true SDK published through NPM. Once +that is published, all of this vendored code should be removed in favor of it. diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts new file mode 100644 index 00000000..e0eafd1d --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts @@ -0,0 +1,1940 @@ +/** + * @file Coder is starting to import the Coder API file into more and more + * external projects, as a "pseudo-SDK". We are not at a stage where we are + * ready to commit to maintaining a public SDK, but we need equivalent + * functionality in other places. + * + * Message somebody from Team Blueberry if you need more context, but so far, + * these projects are importing the file: + * + * - The Coder VS Code extension + * @see {@link https://github.com/coder/vscode-coder} + * - The Coder Backstage plugin + * @see {@link https://github.com/coder/backstage-plugins} + * + * It is important that this file not do any aliased imports, or else the other + * consumers could break (particularly for platforms that limit how much you can + * touch their configuration files, like Backstage). Relative imports are still + * safe, though. + * + * For example, `utils/delay` must be imported using `../utils/delay` instead. + */ +import globalAxios, { type AxiosInstance, isAxiosError } from 'axios'; +import type dayjs from 'dayjs'; +import userAgentParser from 'ua-parser-js'; +import { delay } from '../utils/delay'; +import * as TypesGen from './typesGenerated'; + +const getMissingParameters = ( + oldBuildParameters: TypesGen.WorkspaceBuildParameter[], + newBuildParameters: TypesGen.WorkspaceBuildParameter[], + templateParameters: TypesGen.TemplateVersionParameter[], +) => { + const missingParameters: TypesGen.TemplateVersionParameter[] = []; + const requiredParameters: TypesGen.TemplateVersionParameter[] = []; + + templateParameters.forEach(p => { + // It is mutable and required. Mutable values can be changed after so we + // don't need to ask them if they are not required. + const isMutableAndRequired = p.mutable && p.required; + // Is immutable, so we can check if it is its first time on the build + const isImmutable = !p.mutable; + + if (isMutableAndRequired || isImmutable) { + requiredParameters.push(p); + } + }); + + for (const parameter of requiredParameters) { + // Check if there is a new value + let buildParameter = newBuildParameters.find( + p => p.name === parameter.name, + ); + + // If not, get the old one + if (!buildParameter) { + buildParameter = oldBuildParameters.find(p => p.name === parameter.name); + } + + // If there is a value from the new or old one, it is not missed + if (buildParameter) { + continue; + } + + missingParameters.push(parameter); + } + + // Check if parameter "options" changed and we can't use old build parameters. + templateParameters.forEach(templateParameter => { + if (templateParameter.options.length === 0) { + return; + } + + // Check if there is a new value + let buildParameter = newBuildParameters.find( + p => p.name === templateParameter.name, + ); + + // If not, get the old one + if (!buildParameter) { + buildParameter = oldBuildParameters.find( + p => p.name === templateParameter.name, + ); + } + + if (!buildParameter) { + return; + } + + const matchingOption = templateParameter.options.find( + option => option.value === buildParameter?.value, + ); + if (!matchingOption) { + missingParameters.push(templateParameter); + } + }); + return missingParameters; +}; + +/** + * + * @param agentId + * @returns An EventSource that emits agent metadata event objects + * (ServerSentEvent) + */ +export const watchAgentMetadata = (agentId: string): EventSource => { + return new EventSource( + `${location.protocol}//${location.host}/api/v2/workspaceagents/${agentId}/watch-metadata`, + { withCredentials: true }, + ); +}; + +/** + * @returns {EventSource} An EventSource that emits workspace event objects + * (ServerSentEvent) + */ +export const watchWorkspace = (workspaceId: string): EventSource => { + return new EventSource( + `${location.protocol}//${location.host}/api/v2/workspaces/${workspaceId}/watch`, + { withCredentials: true }, + ); +}; + +export const getURLWithSearchParams = ( + basePath: string, + options?: SearchParamOptions, +): string => { + if (!options) { + return basePath; + } + + const searchParams = new URLSearchParams(); + const keys = Object.keys(options) as (keyof SearchParamOptions)[]; + keys.forEach(key => { + const value = options[key]; + if (value !== undefined && value !== '') { + searchParams.append(key, value.toString()); + } + }); + + const searchString = searchParams.toString(); + return searchString ? `${basePath}?${searchString}` : basePath; +}; + +// withDefaultFeatures sets all unspecified features to not_entitled and +// disabled. +export const withDefaultFeatures = ( + fs: Partial, +): TypesGen.Entitlements['features'] => { + for (const feature of TypesGen.FeatureNames) { + // Skip fields that are already filled. + if (fs[feature] !== undefined) { + continue; + } + + fs[feature] = { + enabled: false, + entitlement: 'not_entitled', + }; + } + + return fs as TypesGen.Entitlements['features']; +}; + +type WatchBuildLogsByTemplateVersionIdOptions = { + after?: number; + onMessage: (log: TypesGen.ProvisionerJobLog) => void; + onDone?: () => void; + onError: (error: Error) => void; +}; + +export const watchBuildLogsByTemplateVersionId = ( + versionId: string, + { + onMessage, + onDone, + onError, + after, + }: WatchBuildLogsByTemplateVersionIdOptions, +) => { + const searchParams = new URLSearchParams({ follow: 'true' }); + if (after !== undefined) { + searchParams.append('after', after.toString()); + } + + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const socket = new WebSocket( + `${proto}//${ + location.host + }/api/v2/templateversions/${versionId}/logs?${searchParams.toString()}`, + ); + + socket.binaryType = 'blob'; + + socket.addEventListener('message', event => + onMessage(JSON.parse(event.data) as TypesGen.ProvisionerJobLog), + ); + + socket.addEventListener('error', () => { + onError(new Error('Connection for logs failed.')); + socket.close(); + }); + + socket.addEventListener('close', () => { + // When the socket closes, logs have finished streaming! + onDone?.(); + }); + + return socket; +}; + +export const watchWorkspaceAgentLogs = ( + agentId: string, + { after, onMessage, onDone, onError }: WatchWorkspaceAgentLogsOptions, +) => { + // WebSocket compression in Safari (confirmed in 16.5) is broken when + // the server sends large messages. The following error is seen: + // + // WebSocket connection to 'wss://.../logs?follow&after=0' failed: The operation couldn’t be completed. Protocol error + // + const noCompression = + userAgentParser(navigator.userAgent).browser.name === 'Safari' + ? '&no_compression' + : ''; + + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const socket = new WebSocket( + `${proto}//${location.host}/api/v2/workspaceagents/${agentId}/logs?follow&after=${after}${noCompression}`, + ); + socket.binaryType = 'blob'; + + socket.addEventListener('message', event => { + const logs = JSON.parse(event.data) as TypesGen.WorkspaceAgentLog[]; + onMessage(logs); + }); + + socket.addEventListener('error', () => { + onError(new Error('socket errored')); + }); + + socket.addEventListener('close', () => { + onDone?.(); + }); + + return socket; +}; + +type WatchWorkspaceAgentLogsOptions = { + after: number; + onMessage: (logs: TypesGen.WorkspaceAgentLog[]) => void; + onDone?: () => void; + onError: (error: Error) => void; +}; + +type WatchBuildLogsByBuildIdOptions = { + after?: number; + onMessage: (log: TypesGen.ProvisionerJobLog) => void; + onDone?: () => void; + onError?: (error: Error) => void; +}; +export const watchBuildLogsByBuildId = ( + buildId: string, + { onMessage, onDone, onError, after }: WatchBuildLogsByBuildIdOptions, +) => { + const searchParams = new URLSearchParams({ follow: 'true' }); + if (after !== undefined) { + searchParams.append('after', after.toString()); + } + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const socket = new WebSocket( + `${proto}//${ + location.host + }/api/v2/workspacebuilds/${buildId}/logs?${searchParams.toString()}`, + ); + socket.binaryType = 'blob'; + + socket.addEventListener('message', event => + onMessage(JSON.parse(event.data) as TypesGen.ProvisionerJobLog), + ); + + socket.addEventListener('error', () => { + onError?.(new Error('Connection for logs failed.')); + socket.close(); + }); + + socket.addEventListener('close', () => { + // When the socket closes, logs have finished streaming! + onDone?.(); + }); + + return socket; +}; + +// This is the base header that is used for several requests. This is defined as +// a readonly value, but only copies of it should be passed into the API calls, +// because Axios is able to mutate the headers +const BASE_CONTENT_TYPE_JSON = { + 'Content-Type': 'application/json', +} as const satisfies HeadersInit; + +type TemplateOptions = Readonly<{ + readonly deprecated?: boolean; +}>; + +type SearchParamOptions = TypesGen.Pagination & { + q?: string; +}; + +type RestartWorkspaceParameters = Readonly<{ + workspace: TypesGen.Workspace; + buildParameters?: TypesGen.WorkspaceBuildParameter[]; +}>; + +export type DeleteWorkspaceOptions = Pick< + TypesGen.CreateWorkspaceBuildRequest, + 'log_level' & 'orphan' +>; + +type Claims = { + license_expires: number; + account_type?: string; + account_id?: string; + trial: boolean; + all_features: boolean; + version: number; + features: Record; + require_telemetry?: boolean; +}; + +export type GetLicensesResponse = Omit & { + claims: Claims; + expires_at: string; +}; + +export type InsightsParams = { + start_time: string; + end_time: string; + template_ids: string; +}; + +export type InsightsTemplateParams = InsightsParams & { + interval: 'day' | 'week'; +}; + +export type GetJFrogXRayScanParams = { + workspaceId: string; + agentId: string; +}; + +export class MissingBuildParameters extends Error { + parameters: TypesGen.TemplateVersionParameter[] = []; + versionId: string; + + constructor( + parameters: TypesGen.TemplateVersionParameter[], + versionId: string, + ) { + super('Missing build parameters.'); + this.parameters = parameters; + this.versionId = versionId; + } +} + +/** + * This is the container for all API methods. It's split off to make it more + * clear where API methods should go, but it is eventually merged into the Api + * class with a more flat hierarchy + * + * All public methods should be defined as arrow functions to ensure that they + * can be passed around the React UI without losing their `this` context. + * + * This is one of the few cases where you have to worry about the difference + * between traditional methods and arrow function properties. Arrow functions + * disable JS's dynamic scope, and force all `this` references to resolve via + * lexical scope. + */ +class ApiMethods { + constructor(protected readonly axios: AxiosInstance) {} + + login = async ( + email: string, + password: string, + ): Promise => { + const payload = JSON.stringify({ email, password }); + const response = await this.axios.post( + '/api/v2/users/login', + payload, + { headers: { ...BASE_CONTENT_TYPE_JSON } }, + ); + + return response.data; + }; + + convertToOAUTH = async (request: TypesGen.ConvertLoginRequest) => { + const response = await this.axios.post( + '/api/v2/users/me/convert-login', + request, + ); + + return response.data; + }; + + logout = async (): Promise => { + return this.axios.post('/api/v2/users/logout'); + }; + + getAuthenticatedUser = async () => { + const response = await this.axios.get('/api/v2/users/me'); + return response.data; + }; + + getUserParameters = async (templateID: string) => { + const response = await this.axios.get( + `/api/v2/users/me/autofill-parameters?template_id=${templateID}`, + ); + + return response.data; + }; + + getAuthMethods = async (): Promise => { + const response = await this.axios.get( + '/api/v2/users/authmethods', + ); + + return response.data; + }; + + getUserLoginType = async (): Promise => { + const response = await this.axios.get( + '/api/v2/users/me/login-type', + ); + + return response.data; + }; + + checkAuthorization = async ( + params: TypesGen.AuthorizationRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/authcheck`, + params, + ); + + return response.data; + }; + + getApiKey = async (): Promise => { + const response = await this.axios.post( + '/api/v2/users/me/keys', + ); + + return response.data; + }; + + getTokens = async ( + params: TypesGen.TokensFilter, + ): Promise => { + const response = await this.axios.get( + `/api/v2/users/me/keys/tokens`, + { params }, + ); + + return response.data; + }; + + deleteToken = async (keyId: string): Promise => { + await this.axios.delete(`/api/v2/users/me/keys/${keyId}`); + }; + + createToken = async ( + params: TypesGen.CreateTokenRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/users/me/keys/tokens`, + params, + ); + + return response.data; + }; + + getTokenConfig = async (): Promise => { + const response = await this.axios.get( + '/api/v2/users/me/keys/tokens/tokenconfig', + ); + + return response.data; + }; + + getUsers = async ( + options: TypesGen.UsersRequest, + signal?: AbortSignal, + ): Promise => { + const url = getURLWithSearchParams('/api/v2/users', options); + const response = await this.axios.get( + url.toString(), + { signal }, + ); + + return response.data; + }; + + getOrganization = async ( + organizationId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}`, + ); + + return response.data; + }; + + getOrganizations = async (): Promise => { + const response = await this.axios.get( + '/api/v2/users/me/organizations', + ); + return response.data; + }; + + getTemplate = async (templateId: string): Promise => { + const response = await this.axios.get( + `/api/v2/templates/${templateId}`, + ); + + return response.data; + }; + + getTemplates = async ( + organizationId: string, + options?: TemplateOptions, + ): Promise => { + const params: Record = {}; + if (options?.deprecated !== undefined) { + // Just want to check if it isn't undefined. If it has + // a boolean value, convert it to a string and include + // it as a param. + params.deprecated = String(options.deprecated); + } + + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}/templates`, + { params }, + ); + + return response.data; + }; + + getTemplateByName = async ( + organizationId: string, + name: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}/templates/${name}`, + ); + + return response.data; + }; + + getTemplateVersion = async ( + versionId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templateversions/${versionId}`, + ); + + return response.data; + }; + + getTemplateVersionResources = async ( + versionId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templateversions/${versionId}/resources`, + ); + + return response.data; + }; + + getTemplateVersionVariables = async ( + versionId: string, + ): Promise => { + // Defined as separate variable to avoid wonky Prettier formatting because + // the type definition is so long + type VerArray = TypesGen.TemplateVersionVariable[]; + + const response = await this.axios.get( + `/api/v2/templateversions/${versionId}/variables`, + ); + + return response.data; + }; + + getTemplateVersions = async ( + templateId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templates/${templateId}/versions`, + ); + return response.data; + }; + + getTemplateVersionByName = async ( + organizationId: string, + templateName: string, + versionName: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}/templates/${templateName}/versions/${versionName}`, + ); + + return response.data; + }; + + getPreviousTemplateVersionByName = async ( + organizationId: string, + templateName: string, + versionName: string, + ) => { + try { + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}/templates/${templateName}/versions/${versionName}/previous`, + ); + + return response.data; + } catch (error) { + // When there is no previous version, like the first version of a + // template, the API returns 404 so in this case we can safely return + // undefined + const is404 = + isAxiosError(error) && error.response && error.response.status === 404; + + if (is404) { + return undefined; + } + + throw error; + } + }; + + createTemplateVersion = async ( + organizationId: string, + data: TypesGen.CreateTemplateVersionRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/organizations/${organizationId}/templateversions`, + data, + ); + + return response.data; + }; + + getTemplateVersionExternalAuth = async ( + versionId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templateversions/${versionId}/external-auth`, + ); + + return response.data; + }; + + getTemplateVersionRichParameters = async ( + versionId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templateversions/${versionId}/rich-parameters`, + ); + return response.data; + }; + + createTemplate = async ( + organizationId: string, + data: TypesGen.CreateTemplateRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/organizations/${organizationId}/templates`, + data, + ); + + return response.data; + }; + + updateActiveTemplateVersion = async ( + templateId: string, + data: TypesGen.UpdateActiveTemplateVersion, + ) => { + const response = await this.axios.patch( + `/api/v2/templates/${templateId}/versions`, + data, + ); + return response.data; + }; + + patchTemplateVersion = async ( + templateVersionId: string, + data: TypesGen.PatchTemplateVersionRequest, + ) => { + const response = await this.axios.patch( + `/api/v2/templateversions/${templateVersionId}`, + data, + ); + + return response.data; + }; + + archiveTemplateVersion = async (templateVersionId: string) => { + const response = await this.axios.post( + `/api/v2/templateversions/${templateVersionId}/archive`, + ); + + return response.data; + }; + + unarchiveTemplateVersion = async (templateVersionId: string) => { + const response = await this.axios.post( + `/api/v2/templateversions/${templateVersionId}/unarchive`, + ); + return response.data; + }; + + updateTemplateMeta = async ( + templateId: string, + data: TypesGen.UpdateTemplateMeta, + ): Promise => { + const response = await this.axios.patch( + `/api/v2/templates/${templateId}`, + data, + ); + + // On 304 response there is no data payload. + if (response.status === 304) { + return null; + } + + return response.data; + }; + + deleteTemplate = async (templateId: string): Promise => { + const response = await this.axios.delete( + `/api/v2/templates/${templateId}`, + ); + + return response.data; + }; + + getWorkspace = async ( + workspaceId: string, + params?: TypesGen.WorkspaceOptions, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspaces/${workspaceId}`, + { params }, + ); + + return response.data; + }; + + getWorkspaces = async ( + options: TypesGen.WorkspacesRequest, + ): Promise => { + const url = getURLWithSearchParams('/api/v2/workspaces', options); + const response = await this.axios.get(url); + return response.data; + }; + + getWorkspaceByOwnerAndName = async ( + username = 'me', + workspaceName: string, + params?: TypesGen.WorkspaceOptions, + ): Promise => { + const response = await this.axios.get( + `/api/v2/users/${username}/workspace/${workspaceName}`, + { params }, + ); + + return response.data; + }; + + getWorkspaceBuildByNumber = async ( + username = 'me', + workspaceName: string, + buildNumber: number, + ): Promise => { + const response = await this.axios.get( + `/api/v2/users/${username}/workspace/${workspaceName}/builds/${buildNumber}`, + ); + + return response.data; + }; + + waitForBuild = (build: TypesGen.WorkspaceBuild) => { + return new Promise((res, reject) => { + void (async () => { + let latestJobInfo: TypesGen.ProvisionerJob | undefined = undefined; + + while ( + // eslint-disable-next-line no-loop-func -- Not great, but should be harmless + !['succeeded', 'canceled'].some(status => + latestJobInfo?.status.includes(status), + ) + ) { + const { job } = await this.getWorkspaceBuildByNumber( + build.workspace_owner_name, + build.workspace_name, + build.build_number, + ); + + latestJobInfo = job; + if (latestJobInfo.status === 'failed') { + return reject(latestJobInfo); + } + + await delay(1000); + } + + return res(latestJobInfo); + })(); + }); + }; + + postWorkspaceBuild = async ( + workspaceId: string, + data: TypesGen.CreateWorkspaceBuildRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/workspaces/${workspaceId}/builds`, + data, + ); + + return response.data; + }; + + startWorkspace = ( + workspaceId: string, + templateVersionId: string, + logLevel?: TypesGen.ProvisionerLogLevel, + buildParameters?: TypesGen.WorkspaceBuildParameter[], + ) => { + return this.postWorkspaceBuild(workspaceId, { + transition: 'start', + template_version_id: templateVersionId, + log_level: logLevel, + rich_parameter_values: buildParameters, + }); + }; + + stopWorkspace = ( + workspaceId: string, + logLevel?: TypesGen.ProvisionerLogLevel, + ) => { + return this.postWorkspaceBuild(workspaceId, { + transition: 'stop', + log_level: logLevel, + }); + }; + + deleteWorkspace = (workspaceId: string, options?: DeleteWorkspaceOptions) => { + return this.postWorkspaceBuild(workspaceId, { + transition: 'delete', + ...options, + }); + }; + + cancelWorkspaceBuild = async ( + workspaceBuildId: TypesGen.WorkspaceBuild['id'], + ): Promise => { + const response = await this.axios.patch( + `/api/v2/workspacebuilds/${workspaceBuildId}/cancel`, + ); + + return response.data; + }; + + updateWorkspaceDormancy = async ( + workspaceId: string, + dormant: boolean, + ): Promise => { + const data: TypesGen.UpdateWorkspaceDormancy = { dormant }; + const response = await this.axios.put( + `/api/v2/workspaces/${workspaceId}/dormant`, + data, + ); + + return response.data; + }; + + updateWorkspaceAutomaticUpdates = async ( + workspaceId: string, + automaticUpdates: TypesGen.AutomaticUpdates, + ): Promise => { + const req: TypesGen.UpdateWorkspaceAutomaticUpdatesRequest = { + automatic_updates: automaticUpdates, + }; + + const response = await this.axios.put( + `/api/v2/workspaces/${workspaceId}/autoupdates`, + req, + ); + + return response.data; + }; + + restartWorkspace = async ({ + workspace, + buildParameters, + }: RestartWorkspaceParameters): Promise => { + const stopBuild = await this.stopWorkspace(workspace.id); + const awaitedStopBuild = await this.waitForBuild(stopBuild); + + // If the restart is canceled halfway through, make sure we bail + if (awaitedStopBuild?.status === 'canceled') { + return; + } + + const startBuild = await this.startWorkspace( + workspace.id, + workspace.latest_build.template_version_id, + undefined, + buildParameters, + ); + + await this.waitForBuild(startBuild); + }; + + cancelTemplateVersionBuild = async ( + templateVersionId: TypesGen.TemplateVersion['id'], + ): Promise => { + const response = await this.axios.patch( + `/api/v2/templateversions/${templateVersionId}/cancel`, + ); + + return response.data; + }; + + createUser = async ( + user: TypesGen.CreateUserRequest, + ): Promise => { + const response = await this.axios.post( + '/api/v2/users', + user, + ); + + return response.data; + }; + + createWorkspace = async ( + organizationId: string, + userId = 'me', + workspace: TypesGen.CreateWorkspaceRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/organizations/${organizationId}/members/${userId}/workspaces`, + workspace, + ); + + return response.data; + }; + + patchWorkspace = async ( + workspaceId: string, + data: TypesGen.UpdateWorkspaceRequest, + ): Promise => { + await this.axios.patch(`/api/v2/workspaces/${workspaceId}`, data); + }; + + getBuildInfo = async (): Promise => { + const response = await this.axios.get('/api/v2/buildinfo'); + return response.data; + }; + + getUpdateCheck = async (): Promise => { + const response = await this.axios.get('/api/v2/updatecheck'); + return response.data; + }; + + putWorkspaceAutostart = async ( + workspaceID: string, + autostart: TypesGen.UpdateWorkspaceAutostartRequest, + ): Promise => { + const payload = JSON.stringify(autostart); + await this.axios.put( + `/api/v2/workspaces/${workspaceID}/autostart`, + payload, + { headers: { ...BASE_CONTENT_TYPE_JSON } }, + ); + }; + + putWorkspaceAutostop = async ( + workspaceID: string, + ttl: TypesGen.UpdateWorkspaceTTLRequest, + ): Promise => { + const payload = JSON.stringify(ttl); + await this.axios.put(`/api/v2/workspaces/${workspaceID}/ttl`, payload, { + headers: { ...BASE_CONTENT_TYPE_JSON }, + }); + }; + + updateProfile = async ( + userId: string, + data: TypesGen.UpdateUserProfileRequest, + ): Promise => { + const response = await this.axios.put( + `/api/v2/users/${userId}/profile`, + data, + ); + return response.data; + }; + + updateAppearanceSettings = async ( + userId: string, + data: TypesGen.UpdateUserAppearanceSettingsRequest, + ): Promise => { + const response = await this.axios.put( + `/api/v2/users/${userId}/appearance`, + data, + ); + return response.data; + }; + + getUserQuietHoursSchedule = async ( + userId: TypesGen.User['id'], + ): Promise => { + const response = await this.axios.get( + `/api/v2/users/${userId}/quiet-hours`, + ); + return response.data; + }; + + updateUserQuietHoursSchedule = async ( + userId: TypesGen.User['id'], + data: TypesGen.UpdateUserQuietHoursScheduleRequest, + ): Promise => { + const response = await this.axios.put( + `/api/v2/users/${userId}/quiet-hours`, + data, + ); + + return response.data; + }; + + activateUser = async ( + userId: TypesGen.User['id'], + ): Promise => { + const response = await this.axios.put( + `/api/v2/users/${userId}/status/activate`, + ); + return response.data; + }; + + suspendUser = async (userId: TypesGen.User['id']): Promise => { + const response = await this.axios.put( + `/api/v2/users/${userId}/status/suspend`, + ); + + return response.data; + }; + + deleteUser = async (userId: TypesGen.User['id']): Promise => { + await this.axios.delete(`/api/v2/users/${userId}`); + }; + + // API definition: + // https://github.com/coder/coder/blob/db665e7261f3c24a272ccec48233a3e276878239/coderd/users.go#L33-L53 + hasFirstUser = async (): Promise => { + try { + // If it is success, it is true + await this.axios.get('/api/v2/users/first'); + return true; + } catch (error) { + // If it returns a 404, it is false + if (isAxiosError(error) && error.response?.status === 404) { + return false; + } + + throw error; + } + }; + + createFirstUser = async ( + req: TypesGen.CreateFirstUserRequest, + ): Promise => { + const response = await this.axios.post(`/api/v2/users/first`, req); + return response.data; + }; + + updateUserPassword = async ( + userId: TypesGen.User['id'], + updatePassword: TypesGen.UpdateUserPasswordRequest, + ): Promise => { + await this.axios.put(`/api/v2/users/${userId}/password`, updatePassword); + }; + + getRoles = async (): Promise> => { + const response = await this.axios.get( + `/api/v2/users/roles`, + ); + + return response.data; + }; + + updateUserRoles = async ( + roles: TypesGen.SlimRole['name'][], + userId: TypesGen.User['id'], + ): Promise => { + const response = await this.axios.put( + `/api/v2/users/${userId}/roles`, + { roles }, + ); + + return response.data; + }; + + getUserSSHKey = async (userId = 'me'): Promise => { + const response = await this.axios.get( + `/api/v2/users/${userId}/gitsshkey`, + ); + + return response.data; + }; + + regenerateUserSSHKey = async (userId = 'me'): Promise => { + const response = await this.axios.put( + `/api/v2/users/${userId}/gitsshkey`, + ); + + return response.data; + }; + + getWorkspaceBuilds = async ( + workspaceId: string, + req?: TypesGen.WorkspaceBuildsRequest, + ) => { + const response = await this.axios.get( + getURLWithSearchParams(`/api/v2/workspaces/${workspaceId}/builds`, req), + ); + + return response.data; + }; + + getWorkspaceBuildLogs = async ( + buildId: string, + before: Date, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspacebuilds/${buildId}/logs?before=${before.getTime()}`, + ); + + return response.data; + }; + + getWorkspaceAgentLogs = async ( + agentID: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspaceagents/${agentID}/logs`, + ); + + return response.data; + }; + + putWorkspaceExtension = async ( + workspaceId: string, + newDeadline: dayjs.Dayjs, + ): Promise => { + await this.axios.put(`/api/v2/workspaces/${workspaceId}/extend`, { + deadline: newDeadline, + }); + }; + + refreshEntitlements = async (): Promise => { + await this.axios.post('/api/v2/licenses/refresh-entitlements'); + }; + + getEntitlements = async (): Promise => { + try { + const response = await this.axios.get( + '/api/v2/entitlements', + ); + + return response.data; + } catch (ex) { + if (isAxiosError(ex) && ex.response?.status === 404) { + return { + errors: [], + features: withDefaultFeatures({}), + has_license: false, + require_telemetry: false, + trial: false, + warnings: [], + refreshed_at: '', + }; + } + throw ex; + } + }; + + getExperiments = async (): Promise => { + try { + const response = await this.axios.get( + '/api/v2/experiments', + ); + + return response.data; + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) { + return []; + } + + throw error; + } + }; + + getAvailableExperiments = + async (): Promise => { + try { + const response = await this.axios.get('/api/v2/experiments/available'); + + return response.data; + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) { + return { safe: [] }; + } + throw error; + } + }; + + getExternalAuthProvider = async ( + provider: string, + ): Promise => { + const res = await this.axios.get(`/api/v2/external-auth/${provider}`); + return res.data; + }; + + getExternalAuthDevice = async ( + provider: string, + ): Promise => { + const resp = await this.axios.get( + `/api/v2/external-auth/${provider}/device`, + ); + return resp.data; + }; + + exchangeExternalAuthDevice = async ( + provider: string, + req: TypesGen.ExternalAuthDeviceExchange, + ): Promise => { + const resp = await this.axios.post( + `/api/v2/external-auth/${provider}/device`, + req, + ); + + return resp.data; + }; + + getUserExternalAuthProviders = + async (): Promise => { + const resp = await this.axios.get(`/api/v2/external-auth`); + return resp.data; + }; + + unlinkExternalAuthProvider = async (provider: string): Promise => { + const resp = await this.axios.delete(`/api/v2/external-auth/${provider}`); + return resp.data; + }; + + getOAuth2ProviderApps = async ( + filter?: TypesGen.OAuth2ProviderAppFilter, + ): Promise => { + const params = filter?.user_id + ? new URLSearchParams({ user_id: filter.user_id }).toString() + : ''; + + const resp = await this.axios.get(`/api/v2/oauth2-provider/apps?${params}`); + return resp.data; + }; + + getOAuth2ProviderApp = async ( + id: string, + ): Promise => { + const resp = await this.axios.get(`/api/v2/oauth2-provider/apps/${id}`); + return resp.data; + }; + + postOAuth2ProviderApp = async ( + data: TypesGen.PostOAuth2ProviderAppRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/oauth2-provider/apps`, + data, + ); + return response.data; + }; + + putOAuth2ProviderApp = async ( + id: string, + data: TypesGen.PutOAuth2ProviderAppRequest, + ): Promise => { + const response = await this.axios.put( + `/api/v2/oauth2-provider/apps/${id}`, + data, + ); + return response.data; + }; + + deleteOAuth2ProviderApp = async (id: string): Promise => { + await this.axios.delete(`/api/v2/oauth2-provider/apps/${id}`); + }; + + getOAuth2ProviderAppSecrets = async ( + id: string, + ): Promise => { + const resp = await this.axios.get( + `/api/v2/oauth2-provider/apps/${id}/secrets`, + ); + return resp.data; + }; + + postOAuth2ProviderAppSecret = async ( + id: string, + ): Promise => { + const resp = await this.axios.post( + `/api/v2/oauth2-provider/apps/${id}/secrets`, + ); + return resp.data; + }; + + deleteOAuth2ProviderAppSecret = async ( + appId: string, + secretId: string, + ): Promise => { + await this.axios.delete( + `/api/v2/oauth2-provider/apps/${appId}/secrets/${secretId}`, + ); + }; + + revokeOAuth2ProviderApp = async (appId: string): Promise => { + await this.axios.delete(`/oauth2/tokens?client_id=${appId}`); + }; + + getAuditLogs = async ( + options: TypesGen.AuditLogsRequest, + ): Promise => { + const url = getURLWithSearchParams('/api/v2/audit', options); + const response = await this.axios.get(url); + return response.data; + }; + + getTemplateDAUs = async ( + templateId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templates/${templateId}/daus`, + ); + + return response.data; + }; + + getDeploymentDAUs = async ( + // Default to user's local timezone. + // As /api/v2/insights/daus only accepts whole-number values for tz_offset + // we truncate the tz offset down to the closest hour. + offset = Math.trunc(new Date().getTimezoneOffset() / 60), + ): Promise => { + const response = await this.axios.get( + `/api/v2/insights/daus?tz_offset=${offset}`, + ); + + return response.data; + }; + + getTemplateACLAvailable = async ( + templateId: string, + options: TypesGen.UsersRequest, + ): Promise => { + const url = getURLWithSearchParams( + `/api/v2/templates/${templateId}/acl/available`, + options, + ).toString(); + + const response = await this.axios.get(url); + return response.data; + }; + + getTemplateACL = async ( + templateId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templates/${templateId}/acl`, + ); + + return response.data; + }; + + updateTemplateACL = async ( + templateId: string, + data: TypesGen.UpdateTemplateACL, + ): Promise<{ message: string }> => { + const response = await this.axios.patch( + `/api/v2/templates/${templateId}/acl`, + data, + ); + + return response.data; + }; + + getApplicationsHost = async (): Promise => { + const response = await this.axios.get(`/api/v2/applications/host`); + return response.data; + }; + + getGroups = async (organizationId: string): Promise => { + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}/groups`, + ); + + return response.data; + }; + + createGroup = async ( + organizationId: string, + data: TypesGen.CreateGroupRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/organizations/${organizationId}/groups`, + data, + ); + return response.data; + }; + + getGroup = async (groupId: string): Promise => { + const response = await this.axios.get(`/api/v2/groups/${groupId}`); + return response.data; + }; + + patchGroup = async ( + groupId: string, + data: TypesGen.PatchGroupRequest, + ): Promise => { + const response = await this.axios.patch(`/api/v2/groups/${groupId}`, data); + return response.data; + }; + + addMember = async (groupId: string, userId: string) => { + return this.patchGroup(groupId, { + name: '', + add_users: [userId], + remove_users: [], + }); + }; + + removeMember = async (groupId: string, userId: string) => { + return this.patchGroup(groupId, { + name: '', + display_name: '', + add_users: [], + remove_users: [userId], + }); + }; + + deleteGroup = async (groupId: string): Promise => { + await this.axios.delete(`/api/v2/groups/${groupId}`); + }; + + getWorkspaceQuota = async ( + username: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspace-quota/${encodeURIComponent(username)}`, + ); + return response.data; + }; + + getAgentListeningPorts = async ( + agentID: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspaceagents/${agentID}/listening-ports`, + ); + return response.data; + }; + + getWorkspaceAgentSharedPorts = async ( + workspaceID: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspaces/${workspaceID}/port-share`, + ); + return response.data; + }; + + upsertWorkspaceAgentSharedPort = async ( + workspaceID: string, + req: TypesGen.UpsertWorkspaceAgentPortShareRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/workspaces/${workspaceID}/port-share`, + req, + ); + return response.data; + }; + + deleteWorkspaceAgentSharedPort = async ( + workspaceID: string, + req: TypesGen.DeleteWorkspaceAgentPortShareRequest, + ): Promise => { + const response = await this.axios.delete( + `/api/v2/workspaces/${workspaceID}/port-share`, + { data: req }, + ); + + return response.data; + }; + + // getDeploymentSSHConfig is used by the VSCode-Extension. + getDeploymentSSHConfig = async (): Promise => { + const response = await this.axios.get(`/api/v2/deployment/ssh`); + return response.data; + }; + + getDeploymentConfig = async (): Promise => { + const response = await this.axios.get(`/api/v2/deployment/config`); + return response.data; + }; + + getDeploymentStats = async (): Promise => { + const response = await this.axios.get(`/api/v2/deployment/stats`); + return response.data; + }; + + getReplicas = async (): Promise => { + const response = await this.axios.get(`/api/v2/replicas`); + return response.data; + }; + + getFile = async (fileId: string): Promise => { + const response = await this.axios.get( + `/api/v2/files/${fileId}`, + { responseType: 'arraybuffer' }, + ); + + return response.data; + }; + + getWorkspaceProxyRegions = async (): Promise< + TypesGen.RegionsResponse + > => { + const response = await this.axios.get< + TypesGen.RegionsResponse + >(`/api/v2/regions`); + + return response.data; + }; + + getWorkspaceProxies = async (): Promise< + TypesGen.RegionsResponse + > => { + const response = await this.axios.get< + TypesGen.RegionsResponse + >(`/api/v2/workspaceproxies`); + + return response.data; + }; + + createWorkspaceProxy = async ( + b: TypesGen.CreateWorkspaceProxyRequest, + ): Promise => { + const response = await this.axios.post(`/api/v2/workspaceproxies`, b); + return response.data; + }; + + getAppearance = async (): Promise => { + try { + const response = await this.axios.get(`/api/v2/appearance`); + return response.data || {}; + } catch (ex) { + if (isAxiosError(ex) && ex.response?.status === 404) { + return { + application_name: '', + logo_url: '', + notification_banners: [], + service_banner: { + enabled: false, + }, + }; + } + + throw ex; + } + }; + + updateAppearance = async ( + b: TypesGen.AppearanceConfig, + ): Promise => { + const response = await this.axios.put(`/api/v2/appearance`, b); + return response.data; + }; + + getTemplateExamples = async ( + organizationId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/organizations/${organizationId}/templates/examples`, + ); + + return response.data; + }; + + uploadFile = async (file: File): Promise => { + const response = await this.axios.post('/api/v2/files', file, { + headers: { 'Content-Type': 'application/x-tar' }, + }); + + return response.data; + }; + + getTemplateVersionLogs = async ( + versionId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/templateversions/${versionId}/logs`, + ); + return response.data; + }; + + updateWorkspaceVersion = async ( + workspace: TypesGen.Workspace, + ): Promise => { + const template = await this.getTemplate(workspace.template_id); + return this.startWorkspace(workspace.id, template.active_version_id); + }; + + getWorkspaceBuildParameters = async ( + workspaceBuildId: TypesGen.WorkspaceBuild['id'], + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspacebuilds/${workspaceBuildId}/parameters`, + ); + + return response.data; + }; + + getLicenses = async (): Promise => { + const response = await this.axios.get(`/api/v2/licenses`); + return response.data; + }; + + createLicense = async ( + data: TypesGen.AddLicenseRequest, + ): Promise => { + const response = await this.axios.post(`/api/v2/licenses`, data); + return response.data; + }; + + removeLicense = async (licenseId: number): Promise => { + await this.axios.delete(`/api/v2/licenses/${licenseId}`); + }; + + /** Steps to change the workspace version + * - Get the latest template to access the latest active version + * - Get the current build parameters + * - Get the template parameters + * - Update the build parameters and check if there are missed parameters for + * the new version + * - If there are missing parameters raise an error + * - Create a build with the version and updated build parameters + */ + changeWorkspaceVersion = async ( + workspace: TypesGen.Workspace, + templateVersionId: string, + newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [], + ): Promise => { + const [currentBuildParameters, templateParameters] = await Promise.all([ + this.getWorkspaceBuildParameters(workspace.latest_build.id), + this.getTemplateVersionRichParameters(templateVersionId), + ]); + + const missingParameters = getMissingParameters( + currentBuildParameters, + newBuildParameters, + templateParameters, + ); + + if (missingParameters.length > 0) { + throw new MissingBuildParameters(missingParameters, templateVersionId); + } + + return this.postWorkspaceBuild(workspace.id, { + transition: 'start', + template_version_id: templateVersionId, + rich_parameter_values: newBuildParameters, + }); + }; + + /** Steps to update the workspace + * - Get the latest template to access the latest active version + * - Get the current build parameters + * - Get the template parameters + * - Update the build parameters and check if there are missed parameters for + * the newest version + * - If there are missing parameters raise an error + * - Create a build with the latest version and updated build parameters + */ + updateWorkspace = async ( + workspace: TypesGen.Workspace, + newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [], + ): Promise => { + const [template, oldBuildParameters] = await Promise.all([ + this.getTemplate(workspace.template_id), + this.getWorkspaceBuildParameters(workspace.latest_build.id), + ]); + + const activeVersionId = template.active_version_id; + const templateParameters = await this.getTemplateVersionRichParameters( + activeVersionId, + ); + + const missingParameters = getMissingParameters( + oldBuildParameters, + newBuildParameters, + templateParameters, + ); + + if (missingParameters.length > 0) { + throw new MissingBuildParameters(missingParameters, activeVersionId); + } + + return this.postWorkspaceBuild(workspace.id, { + transition: 'start', + template_version_id: activeVersionId, + rich_parameter_values: newBuildParameters, + }); + }; + + getWorkspaceResolveAutostart = async ( + workspaceId: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/workspaces/${workspaceId}/resolve-autostart`, + ); + return response.data; + }; + + issueReconnectingPTYSignedToken = async ( + params: TypesGen.IssueReconnectingPTYSignedTokenRequest, + ): Promise => { + const response = await this.axios.post( + '/api/v2/applications/reconnecting-pty-signed-token', + params, + ); + + return response.data; + }; + + getWorkspaceParameters = async (workspace: TypesGen.Workspace) => { + const latestBuild = workspace.latest_build; + const [templateVersionRichParameters, buildParameters] = await Promise.all([ + this.getTemplateVersionRichParameters(latestBuild.template_version_id), + this.getWorkspaceBuildParameters(latestBuild.id), + ]); + + return { + templateVersionRichParameters, + buildParameters, + }; + }; + + getInsightsUserLatency = async ( + filters: InsightsParams, + ): Promise => { + const params = new URLSearchParams(filters); + const response = await this.axios.get( + `/api/v2/insights/user-latency?${params}`, + ); + + return response.data; + }; + + getInsightsUserActivity = async ( + filters: InsightsParams, + ): Promise => { + const params = new URLSearchParams(filters); + const response = await this.axios.get( + `/api/v2/insights/user-activity?${params}`, + ); + + return response.data; + }; + + getInsightsTemplate = async ( + params: InsightsTemplateParams, + ): Promise => { + const searchParams = new URLSearchParams(params); + const response = await this.axios.get( + `/api/v2/insights/templates?${searchParams}`, + ); + + return response.data; + }; + + getHealth = async (force: boolean = false) => { + const params = new URLSearchParams({ force: force.toString() }); + const response = await this.axios.get( + `/api/v2/debug/health?${params}`, + ); + return response.data; + }; + + getHealthSettings = async (): Promise => { + const res = await this.axios.get( + `/api/v2/debug/health/settings`, + ); + + return res.data; + }; + + updateHealthSettings = async (data: TypesGen.UpdateHealthSettings) => { + const response = await this.axios.put( + `/api/v2/debug/health/settings`, + data, + ); + + return response.data; + }; + + putFavoriteWorkspace = async (workspaceID: string) => { + await this.axios.put(`/api/v2/workspaces/${workspaceID}/favorite`); + }; + + deleteFavoriteWorkspace = async (workspaceID: string) => { + await this.axios.delete(`/api/v2/workspaces/${workspaceID}/favorite`); + }; + + getJFrogXRayScan = async (options: GetJFrogXRayScanParams) => { + const searchParams = new URLSearchParams({ + workspace_id: options.workspaceId, + agent_id: options.agentId, + }); + + try { + const res = await this.axios.get( + `/api/v2/integrations/jfrog/xray-scan?${searchParams}`, + ); + + return res.data; + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) { + // react-query library does not allow undefined to be returned as a + // query result + return null; + } + + throw error; + } + }; +} + +// This is a hard coded CSRF token/cookie pair for local development. In prod, +// the GoLang webserver generates a random cookie with a new token for each +// document request. For local development, we don't use the Go webserver for +// static files, so this is the 'hack' to make local development work with +// remote apis. The CSRF cookie for this token is "JXm9hOUdZctWt0ZZGAy9xiS/gxMKYOThdxjjMnMUyn4=" +const csrfToken = + 'KNKvagCBEHZK7ihe2t7fj6VeJ0UyTDco1yVUJE8N06oNqxLu5Zx1vRxZbgfC0mJJgeGkVjgs08mgPbcWPBkZ1A=='; + +// Always attach CSRF token to all requests. In puppeteer the document is +// undefined. In those cases, just do nothing. +const tokenMetadataElement = + typeof document !== 'undefined' + ? document.head.querySelector('meta[property="csrf-token"]') + : null; + +function getConfiguredAxiosInstance(): AxiosInstance { + const instance = globalAxios.create(); + + // Adds 304 for the default axios validateStatus function + // https://github.com/axios/axios#handling-errors Check status here + // https://httpstatusdogs.com/ + instance.defaults.validateStatus = status => { + return (status >= 200 && status < 300) || status === 304; + }; + + const metadataIsAvailable = + tokenMetadataElement !== null && + tokenMetadataElement.getAttribute('content') !== null; + + if (metadataIsAvailable) { + if (process.env.NODE_ENV === 'development') { + // Development mode uses a hard-coded CSRF token + instance.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken; + instance.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken; + tokenMetadataElement.setAttribute('content', csrfToken); + } else { + instance.defaults.headers.common['X-CSRF-TOKEN'] = + tokenMetadataElement.getAttribute('content') ?? ''; + } + } else { + // Do not write error logs if we are in a FE unit test. + if (process.env.JEST_WORKER_ID === undefined) { + // eslint-disable-next-line no-console -- Function should never run in vendored version of SDK + console.error('CSRF token not found'); + } + } + + return instance; +} + +// Other non-API methods defined here to make it a little easier to find them. +interface ClientApi extends ApiMethods { + getCsrfToken: () => string; + setSessionToken: (token: string) => void; + setHost: (host: string | undefined) => void; + getAxiosInstance: () => AxiosInstance; +} + +export class Api extends ApiMethods implements ClientApi { + constructor() { + const scopedAxiosInstance = getConfiguredAxiosInstance(); + super(scopedAxiosInstance); + } + + // As with ApiMethods, all public methods should be defined with arrow + // function syntax to ensure they can be passed around the React UI without + // losing/detaching their `this` context! + + getCsrfToken = (): string => { + return csrfToken; + }; + + setSessionToken = (token: string): void => { + this.axios.defaults.headers.common['Coder-Session-Token'] = token; + }; + + setHost = (host: string | undefined): void => { + this.axios.defaults.baseURL = host; + }; + + getAxiosInstance = (): AxiosInstance => { + return this.axios; + }; +} + +export const API = new Api(); diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/errors.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/errors.ts new file mode 100644 index 00000000..6d401a11 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/errors.ts @@ -0,0 +1,124 @@ +import { type AxiosError, type AxiosResponse, isAxiosError } from 'axios'; + +const Language = { + errorsByCode: { + defaultErrorCode: 'Invalid value', + }, +}; + +export interface FieldError { + field: string; + detail: string; +} + +export type FieldErrors = Record; + +export interface ApiErrorResponse { + message: string; + detail?: string; + validations?: FieldError[]; +} + +export type ApiError = AxiosError & { + response: AxiosResponse; +}; + +export const isApiErrorResponse = (err: unknown): err is ApiErrorResponse => { + return ( + typeof err === 'object' && + err !== null && + 'message' in err && + typeof err.message === 'string' && + (!('detail' in err) || + err.detail === undefined || + typeof err.detail === 'string') && + (!('validations' in err) || + err.validations === undefined || + Array.isArray(err.validations)) + ); +}; + +export const isApiError = (err: unknown): err is ApiError => { + return ( + isAxiosError(err) && + err.response !== undefined && + isApiErrorResponse(err.response.data) + ); +}; + +export const hasApiFieldErrors = (error: ApiError): boolean => + Array.isArray(error.response.data.validations); + +export const isApiValidationError = (error: unknown): error is ApiError => { + return isApiError(error) && hasApiFieldErrors(error); +}; + +export const hasError = (error: unknown) => + error !== undefined && error !== null; + +export const mapApiErrorToFieldErrors = ( + apiErrorResponse: ApiErrorResponse, +): FieldErrors => { + const result: FieldErrors = {}; + + if (apiErrorResponse.validations) { + for (const error of apiErrorResponse.validations) { + result[error.field] = + error.detail || Language.errorsByCode.defaultErrorCode; + } + } + + return result; +}; + +/** + * + * @param error + * @param defaultMessage + * @returns error's message if ApiError or Error, else defaultMessage + */ +export const getErrorMessage = ( + error: unknown, + defaultMessage: string, +): string => { + // if error is API error + // 404s result in the default message being returned + if (isApiError(error) && error.response.data.message) { + return error.response.data.message; + } + if (isApiErrorResponse(error)) { + return error.message; + } + // if error is a non-empty string + if (error && typeof error === 'string') { + return error; + } + return defaultMessage; +}; + +/** + * + * @param error + * @returns a combined validation error message if the error is an ApiError + * and contains validation messages for different form fields. + */ +export const getValidationErrorMessage = (error: unknown): string => { + const validationErrors = + isApiError(error) && error.response.data.validations + ? error.response.data.validations + : []; + return validationErrors.map(error => error.detail).join('\n'); +}; + +export const getErrorDetail = (error: unknown): string | undefined | null => { + if (error instanceof Error) { + return 'Please check the developer console for more details.'; + } + if (isApiError(error)) { + return error.response.data.detail; + } + if (isApiErrorResponse(error)) { + return error.detail; + } + return null; +}; diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/typesGenerated.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/typesGenerated.ts new file mode 100644 index 00000000..2e3b4f04 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/typesGenerated.ts @@ -0,0 +1,2599 @@ +// Code generated by 'make site/src/api/typesGenerated.ts'. DO NOT EDIT. + +// The code below is generated from codersdk. + +// From codersdk/templates.go +export interface ACLAvailable { + readonly users: readonly ReducedUser[]; + readonly groups: readonly Group[]; +} + +// From codersdk/apikey.go +export interface APIKey { + readonly id: string; + readonly user_id: string; + readonly last_used: string; + readonly expires_at: string; + readonly created_at: string; + readonly updated_at: string; + readonly login_type: LoginType; + readonly scope: APIKeyScope; + readonly token_name: string; + readonly lifetime_seconds: number; +} + +// From codersdk/apikey.go +export interface APIKeyWithOwner extends APIKey { + readonly username: string; +} + +// From codersdk/licenses.go +export interface AddLicenseRequest { + readonly license: string; +} + +// From codersdk/templates.go +export interface AgentStatsReportResponse { + readonly num_comms: number; + readonly rx_bytes: number; + readonly tx_bytes: number; +} + +// From codersdk/deployment.go +export interface AppHostResponse { + readonly host: string; +} + +// From codersdk/deployment.go +export interface AppearanceConfig { + readonly application_name: string; + readonly logo_url: string; + readonly service_banner: BannerConfig; + readonly notification_banners: readonly BannerConfig[]; + readonly support_links?: readonly LinkConfig[]; +} + +// From codersdk/templates.go +export interface ArchiveTemplateVersionsRequest { + readonly all: boolean; +} + +// From codersdk/templates.go +export interface ArchiveTemplateVersionsResponse { + readonly template_id: string; + readonly archived_ids: readonly string[]; +} + +// From codersdk/roles.go +export interface AssignableRoles extends Role { + readonly assignable: boolean; + readonly built_in: boolean; +} + +// From codersdk/audit.go +export type AuditDiff = Record; + +// From codersdk/audit.go +export interface AuditDiffField { + // Empty interface{} type, cannot resolve the type. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- interface{} + readonly old?: any; + // Empty interface{} type, cannot resolve the type. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- interface{} + readonly new?: any; + readonly secret: boolean; +} + +// From codersdk/audit.go +export interface AuditLog { + readonly id: string; + readonly request_id: string; + readonly time: string; + readonly organization_id: string; + // Named type "net/netip.Addr" unknown, using "any" + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type + readonly ip: any; + readonly user_agent: string; + readonly resource_type: ResourceType; + readonly resource_id: string; + readonly resource_target: string; + readonly resource_icon: string; + readonly action: AuditAction; + readonly diff: AuditDiff; + readonly status_code: number; + readonly additional_fields: Record; + readonly description: string; + readonly resource_link: string; + readonly is_deleted: boolean; + readonly user?: User; +} + +// From codersdk/audit.go +export interface AuditLogResponse { + readonly audit_logs: readonly AuditLog[]; + readonly count: number; +} + +// From codersdk/audit.go +export interface AuditLogsRequest extends Pagination { + readonly q?: string; +} + +// From codersdk/users.go +export interface AuthMethod { + readonly enabled: boolean; +} + +// From codersdk/users.go +export interface AuthMethods { + readonly terms_of_service_url?: string; + readonly password: AuthMethod; + readonly github: AuthMethod; + readonly oidc: OIDCAuthMethod; +} + +// From codersdk/authorization.go +export interface AuthorizationCheck { + readonly object: AuthorizationObject; + readonly action: RBACAction; +} + +// From codersdk/authorization.go +export interface AuthorizationObject { + readonly resource_type: RBACResource; + readonly owner_id?: string; + readonly organization_id?: string; + readonly resource_id?: string; +} + +// From codersdk/authorization.go +export interface AuthorizationRequest { + readonly checks: Record; +} + +// From codersdk/authorization.go +export type AuthorizationResponse = Record; + +// From codersdk/deployment.go +export interface AvailableExperiments { + readonly safe: readonly Experiment[]; +} + +// From codersdk/deployment.go +export interface BannerConfig { + readonly enabled: boolean; + readonly message?: string; + readonly background_color?: string; +} + +// From codersdk/deployment.go +export interface BuildInfoResponse { + readonly external_url: string; + readonly version: string; + readonly dashboard_url: string; + readonly workspace_proxy: boolean; + readonly agent_api_version: string; + readonly upgrade_message: string; + readonly deployment_id: string; +} + +// From codersdk/insights.go +export interface ConnectionLatency { + readonly p50: number; + readonly p95: number; +} + +// From codersdk/users.go +export interface ConvertLoginRequest { + readonly to_type: LoginType; + readonly password: string; +} + +// From codersdk/users.go +export interface CreateFirstUserRequest { + readonly email: string; + readonly username: string; + readonly password: string; + readonly trial: boolean; + readonly trial_info: CreateFirstUserTrialInfo; +} + +// From codersdk/users.go +export interface CreateFirstUserResponse { + readonly user_id: string; + readonly organization_id: string; +} + +// From codersdk/users.go +export interface CreateFirstUserTrialInfo { + readonly first_name: string; + readonly last_name: string; + readonly phone_number: string; + readonly job_title: string; + readonly company_name: string; + readonly country: string; + readonly developers: string; +} + +// From codersdk/groups.go +export interface CreateGroupRequest { + readonly name: string; + readonly display_name: string; + readonly avatar_url: string; + readonly quota_allowance: number; +} + +// From codersdk/organizations.go +export interface CreateOrganizationRequest { + readonly name: string; +} + +// From codersdk/organizations.go +export interface CreateTemplateRequest { + readonly name: string; + readonly display_name?: string; + readonly description?: string; + readonly icon?: string; + readonly template_version_id: string; + readonly default_ttl_ms?: number; + readonly activity_bump_ms?: number; + readonly autostop_requirement?: TemplateAutostopRequirement; + readonly autostart_requirement?: TemplateAutostartRequirement; + readonly allow_user_cancel_workspace_jobs?: boolean; + readonly allow_user_autostart?: boolean; + readonly allow_user_autostop?: boolean; + readonly failure_ttl_ms?: number; + readonly dormant_ttl_ms?: number; + readonly delete_ttl_ms?: number; + readonly disable_everyone_group_access: boolean; + readonly require_active_version: boolean; +} + +// From codersdk/templateversions.go +export interface CreateTemplateVersionDryRunRequest { + readonly workspace_name: string; + readonly rich_parameter_values: readonly WorkspaceBuildParameter[]; + readonly user_variable_values?: readonly VariableValue[]; +} + +// From codersdk/organizations.go +export interface CreateTemplateVersionRequest { + readonly name?: string; + readonly message?: string; + readonly template_id?: string; + readonly storage_method: ProvisionerStorageMethod; + readonly file_id?: string; + readonly example_id?: string; + readonly provisioner: ProvisionerType; + readonly tags: Record; + readonly user_variable_values?: readonly VariableValue[]; +} + +// From codersdk/audit.go +export interface CreateTestAuditLogRequest { + readonly action?: AuditAction; + readonly resource_type?: ResourceType; + readonly resource_id?: string; + readonly additional_fields?: Record; + readonly time?: string; + readonly build_reason?: BuildReason; +} + +// From codersdk/apikey.go +export interface CreateTokenRequest { + readonly lifetime: number; + readonly scope: APIKeyScope; + readonly token_name: string; +} + +// From codersdk/users.go +export interface CreateUserRequest { + readonly email: string; + readonly username: string; + readonly password: string; + readonly login_type: LoginType; + readonly disable_login: boolean; + readonly organization_id: string; +} + +// From codersdk/workspaces.go +export interface CreateWorkspaceBuildRequest { + readonly template_version_id?: string; + readonly transition: WorkspaceTransition; + readonly dry_run?: boolean; + readonly state?: string; + readonly orphan?: boolean; + readonly rich_parameter_values?: readonly WorkspaceBuildParameter[]; + readonly log_level?: ProvisionerLogLevel; +} + +// From codersdk/workspaceproxy.go +export interface CreateWorkspaceProxyRequest { + readonly name: string; + readonly display_name: string; + readonly icon: string; +} + +// From codersdk/organizations.go +export interface CreateWorkspaceRequest { + readonly template_id?: string; + readonly template_version_id?: string; + readonly name: string; + readonly autostart_schedule?: string; + readonly ttl_ms?: number; + readonly rich_parameter_values?: readonly WorkspaceBuildParameter[]; + readonly automatic_updates?: AutomaticUpdates; +} + +// From codersdk/deployment.go +export interface DAUEntry { + readonly date: string; + readonly amount: number; +} + +// From codersdk/deployment.go +export interface DAURequest { + readonly TZHourOffset: number; +} + +// From codersdk/deployment.go +export interface DAUsResponse { + readonly entries: readonly DAUEntry[]; + readonly tz_hour_offset: number; +} + +// From codersdk/deployment.go +export interface DERP { + readonly server: DERPServerConfig; + readonly config: DERPConfig; +} + +// From codersdk/deployment.go +export interface DERPConfig { + readonly block_direct: boolean; + readonly force_websockets: boolean; + readonly url: string; + readonly path: string; +} + +// From codersdk/workspaceagents.go +export interface DERPRegion { + readonly preferred: boolean; + readonly latency_ms: number; +} + +// From codersdk/deployment.go +export interface DERPServerConfig { + readonly enable: boolean; + readonly region_id: number; + readonly region_code: string; + readonly region_name: string; + readonly stun_addresses: string[]; + readonly relay_url: string; +} + +// From codersdk/deployment.go +export interface DangerousConfig { + readonly allow_path_app_sharing: boolean; + readonly allow_path_app_site_owner_access: boolean; + readonly allow_all_cors: boolean; +} + +// From codersdk/workspaceagentportshare.go +export interface DeleteWorkspaceAgentPortShareRequest { + readonly agent_name: string; + readonly port: number; +} + +// From codersdk/deployment.go +export interface DeploymentConfig { + readonly config?: DeploymentValues; + readonly options?: SerpentOptionSet; +} + +// From codersdk/deployment.go +export interface DeploymentStats { + readonly aggregated_from: string; + readonly collected_at: string; + readonly next_update_at: string; + readonly workspaces: WorkspaceDeploymentStats; + readonly session_count: SessionCountDeploymentStats; +} + +// From codersdk/deployment.go +export interface DeploymentValues { + readonly verbose?: boolean; + readonly access_url?: string; + readonly wildcard_access_url?: string; + readonly docs_url?: string; + readonly redirect_to_access_url?: boolean; + readonly http_address?: string; + readonly autobuild_poll_interval?: number; + readonly job_hang_detector_interval?: number; + readonly derp?: DERP; + readonly prometheus?: PrometheusConfig; + readonly pprof?: PprofConfig; + readonly proxy_trusted_headers?: string[]; + readonly proxy_trusted_origins?: string[]; + readonly cache_directory?: string; + readonly in_memory_database?: boolean; + readonly pg_connection_url?: string; + readonly pg_auth?: string; + readonly oauth2?: OAuth2Config; + readonly oidc?: OIDCConfig; + readonly telemetry?: TelemetryConfig; + readonly tls?: TLSConfig; + readonly trace?: TraceConfig; + readonly secure_auth_cookie?: boolean; + readonly strict_transport_security?: number; + readonly strict_transport_security_options?: string[]; + readonly ssh_keygen_algorithm?: string; + readonly metrics_cache_refresh_interval?: number; + readonly agent_stat_refresh_interval?: number; + readonly agent_fallback_troubleshooting_url?: string; + readonly browser_only?: boolean; + readonly scim_api_key?: string; + readonly external_token_encryption_keys?: string[]; + readonly provisioner?: ProvisionerConfig; + readonly rate_limit?: RateLimitConfig; + readonly experiments?: string[]; + readonly update_check?: boolean; + readonly swagger?: SwaggerConfig; + readonly logging?: LoggingConfig; + readonly dangerous?: DangerousConfig; + readonly disable_path_apps?: boolean; + readonly session_lifetime?: SessionLifetime; + readonly disable_password_auth?: boolean; + readonly support?: SupportConfig; + readonly external_auth?: readonly ExternalAuthConfig[]; + readonly config_ssh?: SSHConfig; + readonly wgtunnel_host?: string; + readonly disable_owner_workspace_exec?: boolean; + readonly proxy_health_status_interval?: number; + readonly enable_terraform_debug_mode?: boolean; + readonly user_quiet_hours_schedule?: UserQuietHoursScheduleConfig; + readonly web_terminal_renderer?: string; + readonly allow_workspace_renames?: boolean; + readonly healthcheck?: HealthcheckConfig; + readonly cli_upgrade_message?: string; + readonly terms_of_service_url?: string; + readonly config?: string; + readonly write_config?: boolean; + readonly address?: string; +} + +// From codersdk/deployment.go +export interface Entitlements { + readonly features: Record; + readonly warnings: readonly string[]; + readonly errors: readonly string[]; + readonly has_license: boolean; + readonly trial: boolean; + readonly require_telemetry: boolean; + readonly refreshed_at: string; +} + +// From codersdk/deployment.go +export type Experiments = readonly Experiment[]; + +// From codersdk/externalauth.go +export interface ExternalAuth { + readonly authenticated: boolean; + readonly device: boolean; + readonly display_name: string; + readonly user?: ExternalAuthUser; + readonly app_installable: boolean; + readonly installations: readonly ExternalAuthAppInstallation[]; + readonly app_install_url: string; +} + +// From codersdk/externalauth.go +export interface ExternalAuthAppInstallation { + readonly id: number; + readonly account: ExternalAuthUser; + readonly configure_url: string; +} + +// From codersdk/deployment.go +export interface ExternalAuthConfig { + readonly type: string; + readonly client_id: string; + readonly id: string; + readonly auth_url: string; + readonly token_url: string; + readonly validate_url: string; + readonly app_install_url: string; + 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; + readonly display_name: string; + readonly display_icon: string; +} + +// From codersdk/externalauth.go +export interface ExternalAuthDevice { + readonly device_code: string; + readonly user_code: string; + readonly verification_uri: string; + readonly expires_in: number; + readonly interval: number; +} + +// From codersdk/externalauth.go +export interface ExternalAuthDeviceExchange { + readonly device_code: string; +} + +// From codersdk/externalauth.go +export interface ExternalAuthLink { + readonly provider_id: string; + readonly created_at: string; + readonly updated_at: string; + readonly has_refresh_token: boolean; + readonly expires: string; + readonly authenticated: boolean; + readonly validate_error: string; +} + +// From codersdk/externalauth.go +export interface ExternalAuthLinkProvider { + readonly id: string; + readonly type: string; + readonly device: boolean; + readonly display_name: string; + readonly display_icon: string; + readonly allow_refresh: boolean; + readonly allow_validate: boolean; +} + +// From codersdk/externalauth.go +export interface ExternalAuthUser { + readonly login: string; + readonly avatar_url: string; + readonly profile_url: string; + readonly name: string; +} + +// From codersdk/deployment.go +export interface Feature { + readonly entitlement: Entitlement; + readonly enabled: boolean; + readonly limit?: number; + readonly actual?: number; +} + +// From codersdk/apikey.go +export interface GenerateAPIKeyResponse { + readonly key: string; +} + +// From codersdk/users.go +export interface GetUsersResponse { + readonly users: readonly User[]; + readonly count: number; +} + +// From codersdk/gitsshkey.go +export interface GitSSHKey { + readonly user_id: string; + readonly created_at: string; + readonly updated_at: string; + readonly public_key: string; +} + +// From codersdk/groups.go +export interface Group { + readonly id: string; + readonly name: string; + readonly display_name: string; + readonly organization_id: string; + readonly members: readonly ReducedUser[]; + readonly avatar_url: string; + readonly quota_allowance: number; + readonly source: GroupSource; +} + +// From codersdk/workspaceapps.go +export interface Healthcheck { + readonly url: string; + readonly interval: number; + readonly threshold: number; +} + +// From codersdk/deployment.go +export interface HealthcheckConfig { + readonly refresh: number; + readonly threshold_database: number; +} + +// From codersdk/workspaceagents.go +export interface IssueReconnectingPTYSignedTokenRequest { + readonly url: string; + readonly agentID: string; +} + +// From codersdk/workspaceagents.go +export interface IssueReconnectingPTYSignedTokenResponse { + readonly signed_token: string; +} + +// From codersdk/jfrog.go +export interface JFrogXrayScan { + readonly workspace_id: string; + readonly agent_id: string; + readonly critical: number; + readonly high: number; + readonly medium: number; + readonly results_url: string; +} + +// From codersdk/licenses.go +export interface License { + readonly id: number; + readonly uuid: string; + readonly uploaded_at: string; + // Empty interface{} type, cannot resolve the type. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- interface{} + readonly claims: Record; +} + +// From codersdk/deployment.go +export interface LinkConfig { + readonly name: string; + readonly target: string; + readonly icon: string; +} + +// From codersdk/externalauth.go +export interface ListUserExternalAuthResponse { + readonly providers: readonly ExternalAuthLinkProvider[]; + readonly links: readonly ExternalAuthLink[]; +} + +// From codersdk/deployment.go +export interface LoggingConfig { + readonly log_filter: string[]; + readonly human: string; + readonly json: string; + readonly stackdriver: string; +} + +// From codersdk/users.go +export interface LoginWithPasswordRequest { + readonly email: string; + readonly password: string; +} + +// From codersdk/users.go +export interface LoginWithPasswordResponse { + readonly session_token: string; +} + +// From codersdk/users.go +export interface MinimalUser { + readonly id: string; + readonly username: string; + readonly avatar_url: string; +} + +// From codersdk/oauth2.go +export interface OAuth2AppEndpoints { + readonly authorization: string; + readonly token: string; + readonly device_authorization: string; +} + +// From codersdk/deployment.go +export interface OAuth2Config { + readonly github: OAuth2GithubConfig; +} + +// From codersdk/deployment.go +export interface OAuth2GithubConfig { + readonly client_id: string; + readonly client_secret: string; + readonly allowed_orgs: string[]; + readonly allowed_teams: string[]; + readonly allow_signups: boolean; + readonly allow_everyone: boolean; + readonly enterprise_base_url: string; +} + +// From codersdk/oauth2.go +export interface OAuth2ProviderApp { + readonly id: string; + readonly name: string; + readonly callback_url: string; + readonly icon: string; + readonly endpoints: OAuth2AppEndpoints; +} + +// From codersdk/oauth2.go +export interface OAuth2ProviderAppFilter { + readonly user_id?: string; +} + +// From codersdk/oauth2.go +export interface OAuth2ProviderAppSecret { + readonly id: string; + readonly last_used_at?: string; + readonly client_secret_truncated: string; +} + +// From codersdk/oauth2.go +export interface OAuth2ProviderAppSecretFull { + readonly id: string; + readonly client_secret_full: string; +} + +// From codersdk/users.go +export interface OAuthConversionResponse { + readonly state_string: string; + readonly expires_at: string; + readonly to_type: LoginType; + readonly user_id: string; +} + +// From codersdk/users.go +export interface OIDCAuthMethod extends AuthMethod { + readonly signInText: string; + readonly iconUrl: string; +} + +// From codersdk/deployment.go +export interface OIDCConfig { + readonly allow_signups: boolean; + readonly client_id: string; + readonly client_secret: string; + readonly client_key_file: string; + readonly client_cert_file: string; + readonly email_domain: string[]; + readonly issuer_url: string; + readonly scopes: string[]; + readonly ignore_email_verified: boolean; + readonly username_field: string; + readonly email_field: string; + readonly auth_url_params: Record; + readonly ignore_user_info: boolean; + readonly group_auto_create: boolean; + readonly group_regex_filter: string; + readonly group_allow_list: string[]; + readonly groups_field: string; + readonly group_mapping: Record; + readonly user_role_field: string; + readonly user_role_mapping: Record; + readonly user_roles_default: string[]; + readonly sign_in_text: string; + readonly icon_url: string; + readonly signups_disabled_text: string; +} + +// From codersdk/organizations.go +export interface Organization { + readonly id: string; + readonly name: string; + readonly created_at: string; + readonly updated_at: string; + readonly is_default: boolean; +} + +// From codersdk/organizations.go +export interface OrganizationMember { + readonly user_id: string; + readonly organization_id: string; + readonly created_at: string; + readonly updated_at: string; + readonly roles: readonly SlimRole[]; +} + +// From codersdk/pagination.go +export interface Pagination { + readonly after_id?: string; + readonly limit?: number; + readonly offset?: number; +} + +// From codersdk/groups.go +export interface PatchGroupRequest { + readonly add_users: readonly string[]; + readonly remove_users: readonly string[]; + readonly name: string; + readonly display_name?: string; + readonly avatar_url?: string; + readonly quota_allowance?: number; +} + +// From codersdk/templateversions.go +export interface PatchTemplateVersionRequest { + readonly name: string; + readonly message?: string; +} + +// From codersdk/workspaceproxy.go +export interface PatchWorkspaceProxy { + readonly id: string; + readonly name: string; + readonly display_name: string; + readonly icon: string; + readonly regenerate_token: boolean; +} + +// From codersdk/roles.go +export interface Permission { + readonly negate: boolean; + readonly resource_type: RBACResource; + readonly action: RBACAction; +} + +// From codersdk/oauth2.go +export interface PostOAuth2ProviderAppRequest { + readonly name: string; + readonly callback_url: string; + readonly icon: string; +} + +// From codersdk/deployment.go +export interface PprofConfig { + readonly enable: boolean; + readonly address: string; +} + +// From codersdk/deployment.go +export interface PrometheusConfig { + readonly enable: boolean; + readonly address: string; + readonly collect_agent_stats: boolean; + readonly collect_db_metrics: boolean; + readonly aggregate_agent_stats_by: string[]; +} + +// From codersdk/deployment.go +export interface ProvisionerConfig { + readonly daemons: number; + readonly daemon_types: string[]; + readonly daemon_poll_interval: number; + readonly daemon_poll_jitter: number; + readonly force_cancel_interval: number; + readonly daemon_psk: string; +} + +// From codersdk/provisionerdaemons.go +export interface ProvisionerDaemon { + readonly id: string; + readonly created_at: string; + readonly last_seen_at?: string; + readonly name: string; + readonly version: string; + readonly api_version: string; + readonly provisioners: readonly ProvisionerType[]; + readonly tags: Record; +} + +// From codersdk/provisionerdaemons.go +export interface ProvisionerJob { + readonly id: string; + readonly created_at: string; + readonly started_at?: string; + readonly completed_at?: string; + readonly canceled_at?: string; + readonly error?: string; + readonly error_code?: JobErrorCode; + readonly status: ProvisionerJobStatus; + readonly worker_id?: string; + readonly file_id: string; + readonly tags: Record; + readonly queue_position: number; + readonly queue_size: number; +} + +// From codersdk/provisionerdaemons.go +export interface ProvisionerJobLog { + readonly id: number; + readonly created_at: string; + readonly log_source: LogSource; + readonly log_level: LogLevel; + readonly stage: string; + readonly output: string; +} + +// From codersdk/workspaceproxy.go +export interface ProxyHealthReport { + readonly errors: readonly string[]; + readonly warnings: readonly string[]; +} + +// From codersdk/workspaces.go +export interface PutExtendWorkspaceRequest { + readonly deadline: string; +} + +// From codersdk/oauth2.go +export interface PutOAuth2ProviderAppRequest { + readonly name: string; + readonly callback_url: string; + readonly icon: string; +} + +// From codersdk/deployment.go +export interface RateLimitConfig { + readonly disable_all: boolean; + readonly api: number; +} + +// From codersdk/users.go +export interface ReducedUser extends MinimalUser { + readonly name: string; + readonly email: string; + readonly created_at: string; + readonly last_seen_at: string; + readonly status: UserStatus; + readonly login_type: LoginType; + readonly theme_preference: string; +} + +// From codersdk/workspaceproxy.go +export interface Region { + readonly id: string; + readonly name: string; + readonly display_name: string; + readonly icon_url: string; + readonly healthy: boolean; + readonly path_app_url: string; + readonly wildcard_hostname: string; +} + +// From codersdk/workspaceproxy.go +export interface RegionsResponse { + readonly regions: readonly R[]; +} + +// From codersdk/replicas.go +export interface Replica { + readonly id: string; + readonly hostname: string; + readonly created_at: string; + readonly relay_address: string; + readonly region_id: number; + readonly error: string; + readonly database_latency: number; +} + +// From codersdk/workspaces.go +export interface ResolveAutostartResponse { + readonly parameter_mismatch: boolean; +} + +// From codersdk/client.go +export interface Response { + readonly message: string; + readonly detail?: string; + readonly validations?: readonly ValidationError[]; +} + +// From codersdk/roles.go +export interface Role { + readonly name: string; + readonly organization_id: string; + readonly display_name: string; + readonly site_permissions: readonly Permission[]; + readonly organization_permissions: Record; + readonly user_permissions: readonly Permission[]; +} + +// From codersdk/deployment.go +export interface SSHConfig { + readonly DeploymentName: string; + readonly SSHConfigOptions: string[]; +} + +// From codersdk/deployment.go +export interface SSHConfigResponse { + readonly hostname_prefix: string; + readonly ssh_config_options: Record; +} + +// From codersdk/serversentevents.go +export interface ServerSentEvent { + readonly type: ServerSentEventType; + // Empty interface{} type, cannot resolve the type. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- interface{} + readonly data: any; +} + +// From codersdk/deployment.go +export interface ServiceBannerConfig { + readonly enabled: boolean; + readonly message?: string; + readonly background_color?: string; +} + +// From codersdk/deployment.go +export interface SessionCountDeploymentStats { + readonly vscode: number; + readonly ssh: number; + readonly jetbrains: number; + readonly reconnecting_pty: number; +} + +// From codersdk/deployment.go +export interface SessionLifetime { + readonly disable_expiry_refresh?: boolean; + readonly default_duration: number; + readonly max_token_lifetime?: number; +} + +// From codersdk/roles.go +export interface SlimRole { + readonly name: string; + readonly display_name: string; +} + +// From codersdk/deployment.go +export interface SupportConfig { + readonly links: readonly LinkConfig[]; +} + +// From codersdk/deployment.go +export interface SwaggerConfig { + readonly enable: boolean; +} + +// From codersdk/deployment.go +export interface TLSConfig { + readonly enable: boolean; + readonly address: string; + readonly redirect_http: boolean; + readonly cert_file: string[]; + readonly client_auth: string; + readonly client_ca_file: string; + readonly key_file: string[]; + readonly min_version: string; + readonly client_cert_file: string; + readonly client_key_file: string; + readonly supported_ciphers: string[]; + readonly allow_insecure_ciphers: boolean; +} + +// From codersdk/deployment.go +export interface TelemetryConfig { + readonly enable: boolean; + readonly trace: boolean; + readonly url: string; +} + +// From codersdk/templates.go +export interface Template { + readonly id: string; + readonly created_at: string; + readonly updated_at: string; + readonly organization_id: string; + readonly name: string; + readonly display_name: string; + readonly provisioner: ProvisionerType; + readonly active_version_id: string; + readonly active_user_count: number; + readonly build_time_stats: TemplateBuildTimeStats; + readonly description: string; + readonly deprecated: boolean; + readonly deprecation_message: string; + readonly icon: string; + readonly default_ttl_ms: number; + readonly activity_bump_ms: number; + readonly autostop_requirement: TemplateAutostopRequirement; + readonly autostart_requirement: TemplateAutostartRequirement; + readonly created_by_id: string; + readonly created_by_name: string; + readonly allow_user_autostart: boolean; + readonly allow_user_autostop: boolean; + readonly allow_user_cancel_workspace_jobs: boolean; + readonly failure_ttl_ms: number; + readonly time_til_dormant_ms: number; + readonly time_til_dormant_autodelete_ms: number; + readonly require_active_version: boolean; + readonly max_port_share_level: WorkspaceAgentPortShareLevel; +} + +// From codersdk/templates.go +export interface TemplateACL { + readonly users: readonly TemplateUser[]; + readonly group: readonly TemplateGroup[]; +} + +// From codersdk/insights.go +export interface TemplateAppUsage { + readonly template_ids: readonly string[]; + readonly type: TemplateAppsType; + readonly display_name: string; + readonly slug: string; + readonly icon: string; + readonly seconds: number; + readonly times_used: number; +} + +// From codersdk/templates.go +export interface TemplateAutostartRequirement { + readonly days_of_week: readonly string[]; +} + +// From codersdk/templates.go +export interface TemplateAutostopRequirement { + readonly days_of_week: readonly string[]; + readonly weeks: number; +} + +// From codersdk/templates.go +export type TemplateBuildTimeStats = Record< + WorkspaceTransition, + TransitionStats +>; + +// From codersdk/templates.go +export interface TemplateExample { + readonly id: string; + readonly url: string; + readonly name: string; + readonly description: string; + readonly icon: string; + readonly tags: readonly string[]; + readonly markdown: string; +} + +// From codersdk/templates.go +export interface TemplateGroup extends Group { + readonly role: TemplateRole; +} + +// From codersdk/insights.go +export interface TemplateInsightsIntervalReport { + readonly start_time: string; + readonly end_time: string; + readonly template_ids: readonly string[]; + readonly interval: InsightsReportInterval; + readonly active_users: number; +} + +// From codersdk/insights.go +export interface TemplateInsightsReport { + readonly start_time: string; + readonly end_time: string; + readonly template_ids: readonly string[]; + readonly active_users: number; + 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: readonly string[]; + readonly interval: InsightsReportInterval; + readonly sections: readonly TemplateInsightsSection[]; +} + +// From codersdk/insights.go +export interface TemplateInsightsResponse { + readonly report?: TemplateInsightsReport; + readonly interval_reports?: readonly TemplateInsightsIntervalReport[]; +} + +// From codersdk/insights.go +export interface TemplateParameterUsage { + readonly template_ids: readonly string[]; + readonly display_name: string; + readonly name: string; + readonly type: string; + readonly description: string; + readonly options?: readonly TemplateVersionParameterOption[]; + readonly values: readonly TemplateParameterValue[]; +} + +// From codersdk/insights.go +export interface TemplateParameterValue { + readonly value: string; + readonly count: number; +} + +// From codersdk/templates.go +export interface TemplateUser extends User { + readonly role: TemplateRole; +} + +// From codersdk/templateversions.go +export interface TemplateVersion { + readonly id: string; + readonly template_id?: string; + readonly organization_id?: string; + readonly created_at: string; + readonly updated_at: string; + readonly name: string; + readonly message: string; + readonly job: ProvisionerJob; + readonly readme: string; + readonly created_by: MinimalUser; + readonly archived: boolean; + readonly warnings?: readonly TemplateVersionWarning[]; +} + +// From codersdk/templateversions.go +export interface TemplateVersionExternalAuth { + readonly id: string; + readonly type: string; + readonly display_name: string; + readonly display_icon: string; + readonly authenticate_url: string; + readonly authenticated: boolean; + readonly optional?: boolean; +} + +// From codersdk/templateversions.go +export interface TemplateVersionParameter { + readonly name: string; + readonly display_name?: string; + readonly description: string; + readonly description_plaintext: string; + readonly type: string; + readonly mutable: boolean; + readonly default_value: string; + readonly icon: string; + readonly options: readonly TemplateVersionParameterOption[]; + readonly validation_error?: string; + readonly validation_regex?: string; + readonly validation_min?: number; + readonly validation_max?: number; + readonly validation_monotonic?: ValidationMonotonicOrder; + readonly required: boolean; + readonly ephemeral: boolean; +} + +// From codersdk/templateversions.go +export interface TemplateVersionParameterOption { + readonly name: string; + readonly description: string; + readonly value: string; + readonly icon: string; +} + +// From codersdk/templateversions.go +export interface TemplateVersionVariable { + readonly name: string; + readonly description: string; + readonly type: string; + readonly value: string; + readonly default_value: string; + readonly required: boolean; + readonly sensitive: boolean; +} + +// From codersdk/templates.go +export interface TemplateVersionsByTemplateRequest extends Pagination { + readonly template_id: string; + readonly include_archived: boolean; +} + +// From codersdk/apikey.go +export interface TokenConfig { + readonly max_token_lifetime: number; +} + +// From codersdk/apikey.go +export interface TokensFilter { + readonly include_all: boolean; +} + +// From codersdk/deployment.go +export interface TraceConfig { + readonly enable: boolean; + readonly honeycomb_api_key: string; + readonly capture_logs: boolean; + readonly data_dog: boolean; +} + +// From codersdk/templates.go +export interface TransitionStats { + readonly P50?: number; + readonly P95?: number; +} + +// From codersdk/templates.go +export interface UpdateActiveTemplateVersion { + readonly id: string; +} + +// From codersdk/deployment.go +export interface UpdateAppearanceConfig { + readonly application_name: string; + readonly logo_url: string; + readonly service_banner: BannerConfig; + readonly notification_banners: readonly BannerConfig[]; +} + +// From codersdk/updatecheck.go +export interface UpdateCheckResponse { + readonly current: boolean; + readonly version: string; + readonly url: string; +} + +// From codersdk/organizations.go +export interface UpdateOrganizationRequest { + readonly name: string; +} + +// From codersdk/users.go +export interface UpdateRoles { + readonly roles: readonly string[]; +} + +// From codersdk/templates.go +export interface UpdateTemplateACL { + readonly user_perms?: Record; + readonly group_perms?: Record; +} + +// From codersdk/templates.go +export interface UpdateTemplateMeta { + readonly name?: string; + readonly display_name?: string; + readonly description?: string; + readonly icon?: string; + readonly default_ttl_ms?: number; + readonly activity_bump_ms?: number; + readonly autostop_requirement?: TemplateAutostopRequirement; + readonly autostart_requirement?: TemplateAutostartRequirement; + readonly allow_user_autostart?: boolean; + readonly allow_user_autostop?: boolean; + readonly allow_user_cancel_workspace_jobs?: boolean; + readonly failure_ttl_ms?: number; + readonly time_til_dormant_ms?: number; + readonly time_til_dormant_autodelete_ms?: number; + readonly update_workspace_last_used_at: boolean; + readonly update_workspace_dormant_at: boolean; + readonly require_active_version?: boolean; + readonly deprecation_message?: string; + readonly disable_everyone_group_access: boolean; + readonly max_port_share_level?: WorkspaceAgentPortShareLevel; +} + +// From codersdk/users.go +export interface UpdateUserAppearanceSettingsRequest { + readonly theme_preference: string; +} + +// From codersdk/users.go +export interface UpdateUserPasswordRequest { + readonly old_password: string; + readonly password: string; +} + +// From codersdk/users.go +export interface UpdateUserProfileRequest { + readonly username: string; + readonly name: string; +} + +// From codersdk/users.go +export interface UpdateUserQuietHoursScheduleRequest { + readonly schedule: string; +} + +// From codersdk/workspaces.go +export interface UpdateWorkspaceAutomaticUpdatesRequest { + readonly automatic_updates: AutomaticUpdates; +} + +// From codersdk/workspaces.go +export interface UpdateWorkspaceAutostartRequest { + readonly schedule?: string; +} + +// From codersdk/workspaces.go +export interface UpdateWorkspaceDormancy { + readonly dormant: boolean; +} + +// From codersdk/workspaceproxy.go +export interface UpdateWorkspaceProxyResponse { + readonly proxy: WorkspaceProxy; + readonly proxy_token: string; +} + +// From codersdk/workspaces.go +export interface UpdateWorkspaceRequest { + readonly name?: string; +} + +// From codersdk/workspaces.go +export interface UpdateWorkspaceTTLRequest { + readonly ttl_ms?: number; +} + +// From codersdk/files.go +export interface UploadResponse { + readonly hash: string; +} + +// From codersdk/workspaceagentportshare.go +export interface UpsertWorkspaceAgentPortShareRequest { + readonly agent_name: string; + readonly port: number; + readonly share_level: WorkspaceAgentPortShareLevel; + readonly protocol: WorkspaceAgentPortShareProtocol; +} + +// From codersdk/users.go +export interface User extends ReducedUser { + readonly organization_ids: readonly string[]; + readonly roles: readonly SlimRole[]; +} + +// From codersdk/insights.go +export interface UserActivity { + readonly template_ids: readonly string[]; + readonly user_id: string; + readonly username: string; + readonly avatar_url: string; + readonly seconds: number; +} + +// From codersdk/insights.go +export interface UserActivityInsightsReport { + readonly start_time: string; + readonly end_time: string; + 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: readonly string[]; +} + +// From codersdk/insights.go +export interface UserActivityInsightsResponse { + readonly report: UserActivityInsightsReport; +} + +// From codersdk/insights.go +export interface UserLatency { + readonly template_ids: readonly string[]; + readonly user_id: string; + readonly username: string; + readonly avatar_url: string; + readonly latency_ms: ConnectionLatency; +} + +// From codersdk/insights.go +export interface UserLatencyInsightsReport { + readonly start_time: string; + readonly end_time: string; + 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: readonly string[]; +} + +// From codersdk/insights.go +export interface UserLatencyInsightsResponse { + readonly report: UserLatencyInsightsReport; +} + +// From codersdk/users.go +export interface UserLoginType { + readonly login_type: LoginType; +} + +// From codersdk/users.go +export interface UserParameter { + readonly name: string; + readonly value: string; +} + +// From codersdk/deployment.go +export interface UserQuietHoursScheduleConfig { + readonly default_schedule: string; + readonly allow_user_custom: boolean; +} + +// From codersdk/users.go +export interface UserQuietHoursScheduleResponse { + readonly raw_schedule: string; + readonly user_set: boolean; + readonly user_can_set: boolean; + readonly time: string; + readonly timezone: string; + readonly next: string; +} + +// From codersdk/users.go +export interface UserRoles { + readonly roles: readonly string[]; + readonly organization_roles: Record; +} + +// From codersdk/users.go +export interface UsersRequest extends Pagination { + readonly q?: string; +} + +// From codersdk/client.go +export interface ValidationError { + readonly field: string; + readonly detail: string; +} + +// From codersdk/organizations.go +export interface VariableValue { + readonly name: string; + readonly value: string; +} + +// From codersdk/workspaces.go +export interface Workspace { + readonly id: string; + readonly created_at: string; + readonly updated_at: string; + readonly owner_id: string; + readonly owner_name: string; + readonly owner_avatar_url: string; + readonly organization_id: string; + readonly template_id: string; + readonly template_name: string; + readonly template_display_name: string; + readonly template_icon: string; + readonly template_allow_user_cancel_workspace_jobs: boolean; + readonly template_active_version_id: string; + readonly template_require_active_version: boolean; + readonly latest_build: WorkspaceBuild; + readonly outdated: boolean; + readonly name: string; + readonly autostart_schedule?: string; + readonly ttl_ms?: number; + readonly last_used_at: string; + readonly deleting_at?: string; + readonly dormant_at?: string; + readonly health: WorkspaceHealth; + readonly automatic_updates: AutomaticUpdates; + readonly allow_renames: boolean; + readonly favorite: boolean; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgent { + readonly id: string; + readonly created_at: string; + readonly updated_at: string; + readonly first_connected_at?: string; + readonly last_connected_at?: string; + readonly disconnected_at?: string; + readonly started_at?: string; + readonly ready_at?: string; + readonly status: WorkspaceAgentStatus; + readonly lifecycle_state: WorkspaceAgentLifecycle; + readonly name: string; + readonly resource_id: string; + readonly instance_id?: string; + readonly architecture: string; + readonly environment_variables: Record; + readonly operating_system: string; + readonly logs_length: number; + readonly logs_overflowed: boolean; + readonly directory?: string; + readonly expanded_directory?: string; + readonly version: string; + readonly api_version: string; + readonly apps: readonly WorkspaceApp[]; + readonly latency?: Record; + readonly connection_timeout_seconds: number; + readonly troubleshooting_url: string; + readonly subsystems: readonly AgentSubsystem[]; + readonly health: WorkspaceAgentHealth; + readonly display_apps: readonly DisplayApp[]; + readonly log_sources: readonly WorkspaceAgentLogSource[]; + readonly scripts: readonly WorkspaceAgentScript[]; + readonly startup_script_behavior: WorkspaceAgentStartupScriptBehavior; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentHealth { + readonly healthy: boolean; + readonly reason?: string; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentListeningPort { + readonly process_name: string; + readonly network: string; + readonly port: number; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentListeningPortsResponse { + readonly ports: readonly WorkspaceAgentListeningPort[]; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentLog { + readonly id: number; + readonly created_at: string; + readonly output: string; + readonly level: LogLevel; + readonly source_id: string; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentLogSource { + readonly workspace_agent_id: string; + readonly id: string; + readonly created_at: string; + readonly display_name: string; + readonly icon: string; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentMetadata { + readonly result: WorkspaceAgentMetadataResult; + readonly description: WorkspaceAgentMetadataDescription; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentMetadataDescription { + readonly display_name: string; + readonly key: string; + readonly script: string; + readonly interval: number; + readonly timeout: number; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentMetadataResult { + readonly collected_at: string; + readonly age: number; + readonly value: string; + readonly error: string; +} + +// From codersdk/workspaceagentportshare.go +export interface WorkspaceAgentPortShare { + readonly workspace_id: string; + readonly agent_name: string; + readonly port: number; + readonly share_level: WorkspaceAgentPortShareLevel; + readonly protocol: WorkspaceAgentPortShareProtocol; +} + +// From codersdk/workspaceagentportshare.go +export interface WorkspaceAgentPortShares { + readonly shares: readonly WorkspaceAgentPortShare[]; +} + +// From codersdk/workspaceagents.go +export interface WorkspaceAgentScript { + readonly log_source_id: string; + readonly log_path: string; + readonly script: string; + readonly cron: string; + readonly run_on_start: boolean; + readonly run_on_stop: boolean; + readonly start_blocks_login: boolean; + readonly timeout: number; +} + +// From codersdk/workspaceapps.go +export interface WorkspaceApp { + readonly id: string; + readonly url: string; + readonly external: boolean; + readonly slug: string; + readonly display_name: string; + readonly command?: string; + readonly icon?: string; + readonly subdomain: boolean; + readonly subdomain_name?: string; + readonly sharing_level: WorkspaceAppSharingLevel; + readonly healthcheck: Healthcheck; + readonly health: WorkspaceAppHealth; +} + +// From codersdk/workspacebuilds.go +export interface WorkspaceBuild { + readonly id: string; + readonly created_at: string; + readonly updated_at: string; + readonly workspace_id: string; + readonly workspace_name: string; + readonly workspace_owner_id: string; + readonly workspace_owner_name: string; + readonly workspace_owner_avatar_url: string; + readonly template_version_id: string; + readonly template_version_name: string; + readonly build_number: number; + readonly transition: WorkspaceTransition; + readonly initiator_id: string; + readonly initiator_name: string; + readonly job: ProvisionerJob; + readonly reason: BuildReason; + readonly resources: readonly WorkspaceResource[]; + readonly deadline?: string; + readonly max_deadline?: string; + readonly status: WorkspaceStatus; + readonly daily_cost: number; +} + +// From codersdk/workspacebuilds.go +export interface WorkspaceBuildParameter { + readonly name: string; + readonly value: string; +} + +// From codersdk/workspaces.go +export interface WorkspaceBuildsRequest extends Pagination { + readonly since?: string; +} + +// From codersdk/deployment.go +export interface WorkspaceConnectionLatencyMS { + readonly P50: number; + readonly P95: number; +} + +// From codersdk/deployment.go +export interface WorkspaceDeploymentStats { + readonly pending: number; + readonly building: number; + readonly running: number; + readonly failed: number; + readonly stopped: number; + readonly connection_latency_ms: WorkspaceConnectionLatencyMS; + readonly rx_bytes: number; + readonly tx_bytes: number; +} + +// From codersdk/workspaces.go +export interface WorkspaceFilter { + readonly q?: string; +} + +// From codersdk/workspaces.go +export interface WorkspaceHealth { + readonly healthy: boolean; + readonly failing_agents: readonly string[]; +} + +// From codersdk/workspaces.go +export interface WorkspaceOptions { + readonly include_deleted?: boolean; +} + +// From codersdk/workspaceproxy.go +export interface WorkspaceProxy extends Region { + readonly derp_enabled: boolean; + readonly derp_only: boolean; + readonly status?: WorkspaceProxyStatus; + readonly created_at: string; + readonly updated_at: string; + readonly deleted: boolean; + readonly version: string; +} + +// From codersdk/deployment.go +export interface WorkspaceProxyBuildInfo { + readonly workspace_proxy: boolean; + readonly dashboard_url: string; +} + +// From codersdk/workspaceproxy.go +export interface WorkspaceProxyStatus { + readonly status: ProxyHealthStatus; + readonly report?: ProxyHealthReport; + readonly checked_at: string; +} + +// From codersdk/workspaces.go +export interface WorkspaceQuota { + readonly credits_consumed: number; + readonly budget: number; +} + +// From codersdk/workspacebuilds.go +export interface WorkspaceResource { + readonly id: string; + readonly created_at: string; + readonly job_id: string; + readonly workspace_transition: WorkspaceTransition; + readonly type: string; + readonly name: string; + readonly hide: boolean; + readonly icon: string; + readonly agents?: readonly WorkspaceAgent[]; + readonly metadata?: readonly WorkspaceResourceMetadata[]; + readonly daily_cost: number; +} + +// From codersdk/workspacebuilds.go +export interface WorkspaceResourceMetadata { + readonly key: string; + readonly value: string; + readonly sensitive: boolean; +} + +// From codersdk/workspaces.go +export interface WorkspacesRequest extends Pagination { + readonly q?: string; +} + +// From codersdk/workspaces.go +export interface WorkspacesResponse { + readonly workspaces: readonly Workspace[]; + readonly count: number; +} + +// From codersdk/apikey.go +export type APIKeyScope = 'all' | 'application_connect'; +export const APIKeyScopes: APIKeyScope[] = ['all', 'application_connect']; + +// From codersdk/workspaceagents.go +export type AgentSubsystem = 'envbox' | 'envbuilder' | 'exectrace'; +export const AgentSubsystems: AgentSubsystem[] = [ + 'envbox', + 'envbuilder', + 'exectrace', +]; + +// From codersdk/audit.go +export type AuditAction = + | 'create' + | 'delete' + | 'login' + | 'logout' + | 'register' + | 'start' + | 'stop' + | 'write'; +export const AuditActions: AuditAction[] = [ + 'create', + 'delete', + 'login', + 'logout', + 'register', + 'start', + 'stop', + 'write', +]; + +// From codersdk/workspaces.go +export type AutomaticUpdates = 'always' | 'never'; +export const AutomaticUpdateses: AutomaticUpdates[] = ['always', 'never']; + +// From codersdk/workspacebuilds.go +export type BuildReason = 'autostart' | 'autostop' | 'initiator'; +export const BuildReasons: BuildReason[] = [ + 'autostart', + 'autostop', + 'initiator', +]; + +// From codersdk/workspaceagents.go +export type DisplayApp = + | 'port_forwarding_helper' + | 'ssh_helper' + | 'vscode' + | 'vscode_insiders' + | 'web_terminal'; +export const DisplayApps: DisplayApp[] = [ + 'port_forwarding_helper', + 'ssh_helper', + 'vscode', + 'vscode_insiders', + 'web_terminal', +]; + +// From codersdk/externalauth.go +export type EnhancedExternalAuthProvider = + | 'azure-devops' + | 'azure-devops-entra' + | 'bitbucket-cloud' + | 'bitbucket-server' + | 'gitea' + | 'github' + | 'gitlab' + | 'jfrog' + | 'slack'; +export const EnhancedExternalAuthProviders: EnhancedExternalAuthProvider[] = [ + 'azure-devops', + 'azure-devops-entra', + 'bitbucket-cloud', + 'bitbucket-server', + 'gitea', + 'github', + 'gitlab', + 'jfrog', + 'slack', +]; + +// From codersdk/deployment.go +export type Entitlement = 'entitled' | 'grace_period' | 'not_entitled'; +export const entitlements: Entitlement[] = [ + 'entitled', + 'grace_period', + 'not_entitled', +]; + +// From codersdk/deployment.go +export type Experiment = + | 'auto-fill-parameters' + | 'custom-roles' + | 'example' + | 'multi-organization'; +export const experiments: Experiment[] = [ + 'auto-fill-parameters', + 'custom-roles', + 'example', + 'multi-organization', +]; + +// From codersdk/deployment.go +export type FeatureName = + | 'access_control' + | 'advanced_template_scheduling' + | 'appearance' + | 'audit_log' + | 'browser_only' + | 'control_shared_ports' + | 'custom_roles' + | 'external_provisioner_daemons' + | 'external_token_encryption' + | 'high_availability' + | 'multiple_external_auth' + | 'scim' + | 'template_rbac' + | 'user_limit' + | 'user_role_management' + | 'workspace_batch_actions' + | 'workspace_proxy'; +export const FeatureNames: FeatureName[] = [ + 'access_control', + 'advanced_template_scheduling', + 'appearance', + 'audit_log', + 'browser_only', + 'control_shared_ports', + 'custom_roles', + 'external_provisioner_daemons', + 'external_token_encryption', + 'high_availability', + 'multiple_external_auth', + 'scim', + 'template_rbac', + 'user_limit', + 'user_role_management', + 'workspace_batch_actions', + 'workspace_proxy', +]; + +// From codersdk/groups.go +export type GroupSource = 'oidc' | 'user'; +export const GroupSources: GroupSource[] = ['oidc', 'user']; + +// From codersdk/insights.go +export type InsightsReportInterval = 'day' | 'week'; +export const InsightsReportIntervals: InsightsReportInterval[] = [ + 'day', + 'week', +]; + +// From codersdk/provisionerdaemons.go +export type JobErrorCode = 'REQUIRED_TEMPLATE_VARIABLES'; +export const JobErrorCodes: JobErrorCode[] = ['REQUIRED_TEMPLATE_VARIABLES']; + +// From codersdk/provisionerdaemons.go +export type LogLevel = 'debug' | 'error' | 'info' | 'trace' | 'warn'; +export const LogLevels: LogLevel[] = [ + 'debug', + 'error', + 'info', + 'trace', + 'warn', +]; + +// From codersdk/provisionerdaemons.go +export type LogSource = 'provisioner' | 'provisioner_daemon'; +export const LogSources: LogSource[] = ['provisioner', 'provisioner_daemon']; + +// From codersdk/apikey.go +export type LoginType = '' | 'github' | 'none' | 'oidc' | 'password' | 'token'; +export const LoginTypes: LoginType[] = [ + '', + 'github', + 'none', + 'oidc', + 'password', + 'token', +]; + +// From codersdk/oauth2.go +export type OAuth2ProviderGrantType = 'authorization_code' | 'refresh_token'; +export const OAuth2ProviderGrantTypes: OAuth2ProviderGrantType[] = [ + 'authorization_code', + 'refresh_token', +]; + +// From codersdk/oauth2.go +export type OAuth2ProviderResponseType = 'code'; +export const OAuth2ProviderResponseTypes: OAuth2ProviderResponseType[] = [ + 'code', +]; + +// From codersdk/deployment.go +export type PostgresAuth = 'awsiamrds' | 'password'; +export const PostgresAuths: PostgresAuth[] = ['awsiamrds', 'password']; + +// From codersdk/provisionerdaemons.go +export type ProvisionerJobStatus = + | 'canceled' + | 'canceling' + | 'failed' + | 'pending' + | 'running' + | 'succeeded' + | 'unknown'; +export const ProvisionerJobStatuses: ProvisionerJobStatus[] = [ + 'canceled', + 'canceling', + 'failed', + 'pending', + 'running', + 'succeeded', + 'unknown', +]; + +// From codersdk/workspaces.go +export type ProvisionerLogLevel = 'debug'; +export const ProvisionerLogLevels: ProvisionerLogLevel[] = ['debug']; + +// From codersdk/organizations.go +export type ProvisionerStorageMethod = 'file'; +export const ProvisionerStorageMethods: ProvisionerStorageMethod[] = ['file']; + +// From codersdk/organizations.go +export type ProvisionerType = 'echo' | 'terraform'; +export const ProvisionerTypes: ProvisionerType[] = ['echo', 'terraform']; + +// From codersdk/workspaceproxy.go +export type ProxyHealthStatus = + | 'ok' + | 'unhealthy' + | 'unreachable' + | 'unregistered'; +export const ProxyHealthStatuses: ProxyHealthStatus[] = [ + 'ok', + 'unhealthy', + 'unreachable', + 'unregistered', +]; + +// From codersdk/rbacresources_gen.go +export type RBACAction = + | 'application_connect' + | 'assign' + | 'create' + | 'delete' + | 'read' + | 'read_personal' + | 'ssh' + | 'start' + | 'stop' + | 'update' + | 'update_personal' + | 'use' + | 'view_insights'; +export const RBACActions: RBACAction[] = [ + 'application_connect', + 'assign', + 'create', + 'delete', + 'read', + 'read_personal', + 'ssh', + 'start', + 'stop', + 'update', + 'update_personal', + 'use', + 'view_insights', +]; + +// From codersdk/rbacresources_gen.go +export type RBACResource = + | '*' + | 'api_key' + | 'assign_org_role' + | 'assign_role' + | 'audit_log' + | 'debug_info' + | 'deployment_config' + | 'deployment_stats' + | 'file' + | 'group' + | 'license' + | 'oauth2_app' + | 'oauth2_app_code_token' + | 'oauth2_app_secret' + | 'organization' + | 'organization_member' + | 'provisioner_daemon' + | 'replicas' + | 'system' + | 'tailnet_coordinator' + | 'template' + | 'user' + | 'workspace' + | 'workspace_dormant' + | 'workspace_proxy'; +export const RBACResources: RBACResource[] = [ + '*', + 'api_key', + 'assign_org_role', + 'assign_role', + 'audit_log', + 'debug_info', + 'deployment_config', + 'deployment_stats', + 'file', + 'group', + 'license', + 'oauth2_app', + 'oauth2_app_code_token', + 'oauth2_app_secret', + 'organization', + 'organization_member', + 'provisioner_daemon', + 'replicas', + 'system', + 'tailnet_coordinator', + 'template', + 'user', + 'workspace', + 'workspace_dormant', + 'workspace_proxy', +]; + +// From codersdk/audit.go +export type ResourceType = + | 'api_key' + | 'convert_login' + | 'git_ssh_key' + | 'group' + | 'health_settings' + | 'license' + | 'oauth2_provider_app' + | 'oauth2_provider_app_secret' + | 'organization' + | 'template' + | 'template_version' + | 'user' + | 'workspace' + | 'workspace_build' + | 'workspace_proxy'; +export const ResourceTypes: ResourceType[] = [ + 'api_key', + 'convert_login', + 'git_ssh_key', + 'group', + 'health_settings', + 'license', + 'oauth2_provider_app', + 'oauth2_provider_app_secret', + 'organization', + 'template', + 'template_version', + 'user', + 'workspace', + 'workspace_build', + 'workspace_proxy', +]; + +// From codersdk/serversentevents.go +export type ServerSentEventType = 'data' | 'error' | 'ping'; +export const ServerSentEventTypes: ServerSentEventType[] = [ + 'data', + 'error', + 'ping', +]; + +// From codersdk/insights.go +export type TemplateAppsType = 'app' | 'builtin'; +export const TemplateAppsTypes: TemplateAppsType[] = ['app', 'builtin']; + +// From codersdk/insights.go +export type TemplateInsightsSection = 'interval_reports' | 'report'; +export const TemplateInsightsSections: TemplateInsightsSection[] = [ + 'interval_reports', + 'report', +]; + +// From codersdk/templates.go +export type TemplateRole = '' | 'admin' | 'use'; +export const TemplateRoles: TemplateRole[] = ['', 'admin', 'use']; + +// From codersdk/templateversions.go +export type TemplateVersionWarning = 'UNSUPPORTED_WORKSPACES'; +export const TemplateVersionWarnings: TemplateVersionWarning[] = [ + 'UNSUPPORTED_WORKSPACES', +]; + +// From codersdk/users.go +export type UserStatus = 'active' | 'dormant' | 'suspended'; +export const UserStatuses: UserStatus[] = ['active', 'dormant', 'suspended']; + +// From codersdk/templateversions.go +export type ValidationMonotonicOrder = 'decreasing' | 'increasing'; +export const ValidationMonotonicOrders: ValidationMonotonicOrder[] = [ + 'decreasing', + 'increasing', +]; + +// From codersdk/workspaceagents.go +export type WorkspaceAgentLifecycle = + | 'created' + | 'off' + | 'ready' + | 'shutdown_error' + | 'shutdown_timeout' + | 'shutting_down' + | 'start_error' + | 'start_timeout' + | 'starting'; +export const WorkspaceAgentLifecycles: WorkspaceAgentLifecycle[] = [ + 'created', + 'off', + 'ready', + 'shutdown_error', + 'shutdown_timeout', + 'shutting_down', + 'start_error', + 'start_timeout', + 'starting', +]; + +// From codersdk/workspaceagentportshare.go +export type WorkspaceAgentPortShareLevel = 'authenticated' | 'owner' | 'public'; +export const WorkspaceAgentPortShareLevels: WorkspaceAgentPortShareLevel[] = [ + 'authenticated', + 'owner', + 'public', +]; + +// From codersdk/workspaceagentportshare.go +export type WorkspaceAgentPortShareProtocol = 'http' | 'https'; +export const WorkspaceAgentPortShareProtocols: WorkspaceAgentPortShareProtocol[] = + ['http', 'https']; + +// From codersdk/workspaceagents.go +export type WorkspaceAgentStartupScriptBehavior = 'blocking' | 'non-blocking'; +export const WorkspaceAgentStartupScriptBehaviors: WorkspaceAgentStartupScriptBehavior[] = + ['blocking', 'non-blocking']; + +// From codersdk/workspaceagents.go +export type WorkspaceAgentStatus = + | 'connected' + | 'connecting' + | 'disconnected' + | 'timeout'; +export const WorkspaceAgentStatuses: WorkspaceAgentStatus[] = [ + 'connected', + 'connecting', + 'disconnected', + 'timeout', +]; + +// From codersdk/workspaceapps.go +export type WorkspaceAppHealth = + | 'disabled' + | 'healthy' + | 'initializing' + | 'unhealthy'; +export const WorkspaceAppHealths: WorkspaceAppHealth[] = [ + 'disabled', + 'healthy', + 'initializing', + 'unhealthy', +]; + +// From codersdk/workspaceapps.go +export type WorkspaceAppSharingLevel = 'authenticated' | 'owner' | 'public'; +export const WorkspaceAppSharingLevels: WorkspaceAppSharingLevel[] = [ + 'authenticated', + 'owner', + 'public', +]; + +// From codersdk/workspacebuilds.go +export type WorkspaceStatus = + | 'canceled' + | 'canceling' + | 'deleted' + | 'deleting' + | 'failed' + | 'pending' + | 'running' + | 'starting' + | 'stopped' + | 'stopping'; +export const WorkspaceStatuses: WorkspaceStatus[] = [ + 'canceled', + 'canceling', + 'deleted', + 'deleting', + 'failed', + 'pending', + 'running', + 'starting', + 'stopped', + 'stopping', +]; + +// From codersdk/workspacebuilds.go +export type WorkspaceTransition = 'delete' | 'start' | 'stop'; +export const WorkspaceTransitions: WorkspaceTransition[] = [ + 'delete', + 'start', + 'stop', +]; + +// From codersdk/workspaceproxy.go +export type RegionTypes = Region | WorkspaceProxy; + +// The code below is generated from codersdk/healthsdk. + +// From healthsdk/healthsdk.go +export interface AccessURLReport extends BaseReport { + readonly healthy: boolean; + readonly access_url: string; + readonly reachable: boolean; + readonly status_code: number; + readonly healthz_response: string; +} + +// From healthsdk/healthsdk.go +export interface BaseReport { + readonly error?: string; + readonly severity: HealthSeverity; + readonly warnings: readonly HealthMessage[]; + readonly dismissed: boolean; +} + +// From healthsdk/healthsdk.go +export interface DERPHealthReport extends BaseReport { + readonly healthy: boolean; + readonly regions: Record; + // 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: readonly string[]; +} + +// From healthsdk/healthsdk.go +export interface DERPNodeReport { + readonly healthy: boolean; + readonly severity: HealthSeverity; + 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; + // Named type "tailscale.com/derp.ServerInfoMessage" unknown, using "any" + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type + readonly node_info: any; + readonly can_exchange_messages: boolean; + readonly round_trip_ping: string; + readonly round_trip_ping_ms: number; + readonly uses_websocket: boolean; + readonly client_logs: readonly (readonly string[])[]; + readonly client_errs: readonly (readonly string[])[]; + readonly stun: STUNReport; +} + +// From healthsdk/healthsdk.go +export interface DERPRegionReport { + readonly healthy: boolean; + readonly severity: HealthSeverity; + 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: readonly DERPNodeReport[]; +} + +// From healthsdk/healthsdk.go +export interface DatabaseReport extends BaseReport { + readonly healthy: boolean; + readonly reachable: boolean; + readonly latency: string; + readonly latency_ms: number; + readonly threshold_ms: number; +} + +// From healthsdk/healthsdk.go +export interface HealthSettings { + readonly dismissed_healthchecks: readonly HealthSection[]; +} + +// From healthsdk/healthsdk.go +export interface HealthcheckReport { + readonly time: string; + readonly healthy: boolean; + readonly severity: HealthSeverity; + readonly failing_sections: readonly HealthSection[]; + readonly derp: DERPHealthReport; + readonly access_url: AccessURLReport; + readonly websocket: WebsocketReport; + readonly database: DatabaseReport; + readonly workspace_proxy: WorkspaceProxyReport; + readonly provisioner_daemons: ProvisionerDaemonsReport; + readonly coder_version: string; +} + +// From healthsdk/healthsdk.go +export interface ProvisionerDaemonsReport extends BaseReport { + readonly items: readonly ProvisionerDaemonsReportItem[]; +} + +// From healthsdk/healthsdk.go +export interface ProvisionerDaemonsReportItem { + readonly provisioner_daemon: ProvisionerDaemon; + readonly warnings: readonly HealthMessage[]; +} + +// From healthsdk/healthsdk.go +export interface STUNReport { + readonly Enabled: boolean; + readonly CanSTUN: boolean; + readonly Error?: string; +} + +// From healthsdk/healthsdk.go +export interface UpdateHealthSettings { + readonly dismissed_healthchecks: readonly HealthSection[]; +} + +// From healthsdk/healthsdk.go +export interface WebsocketReport extends BaseReport { + readonly healthy: boolean; + readonly body: string; + readonly code: number; +} + +// From healthsdk/healthsdk.go +export interface WorkspaceProxyReport extends BaseReport { + readonly healthy: boolean; + readonly workspace_proxies: RegionsResponse; +} + +// From healthsdk/healthsdk.go +export type HealthSection = + | 'AccessURL' + | 'DERP' + | 'Database' + | 'ProvisionerDaemons' + | 'Websocket' + | 'WorkspaceProxy'; +export const HealthSections: HealthSection[] = [ + 'AccessURL', + 'DERP', + 'Database', + 'ProvisionerDaemons', + 'Websocket', + 'WorkspaceProxy', +]; + +// The code below is generated from coderd/healthcheck/health. + +// From health/model.go +export interface HealthMessage { + readonly code: HealthCode; + readonly message: string; +} + +// From health/model.go +export type HealthCode = + | 'EACS01' + | 'EACS02' + | 'EACS03' + | 'EACS04' + | 'EDB01' + | 'EDB02' + | 'EDERP01' + | 'EDERP02' + | 'EPD01' + | 'EPD02' + | 'EPD03' + | 'EUNKNOWN' + | 'EWP01' + | 'EWP02' + | 'EWP04' + | 'EWS01' + | 'EWS02' + | 'EWS03'; +export const HealthCodes: HealthCode[] = [ + 'EACS01', + 'EACS02', + 'EACS03', + 'EACS04', + 'EDB01', + 'EDB02', + 'EDERP01', + 'EDERP02', + 'EPD01', + 'EPD02', + 'EPD03', + 'EUNKNOWN', + 'EWP01', + 'EWP02', + 'EWP04', + 'EWS01', + 'EWS02', + 'EWS03', +]; + +// From health/model.go +export type HealthSeverity = 'error' | 'ok' | 'warning'; +export const HealthSeveritys: HealthSeverity[] = ['error', 'ok', 'warning']; + +// The code below is generated from github.com/coder/serpent. + +// From serpent/serpent.go +export type SerpentAnnotations = Record; + +// From serpent/serpent.go +export interface SerpentGroup { + readonly parent?: SerpentGroup; + readonly name?: string; + readonly yaml?: string; + readonly description?: string; +} + +// From serpent/option.go +export interface SerpentOption { + readonly name?: string; + readonly description?: string; + readonly required?: boolean; + readonly flag?: string; + readonly flag_shorthand?: string; + readonly env?: string; + readonly yaml?: string; + readonly default?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Golang interface, unable to resolve type. + readonly value?: any; + readonly annotations?: SerpentAnnotations; + readonly group?: SerpentGroup; + readonly use_instead?: readonly SerpentOption[]; + readonly hidden?: boolean; + readonly value_source?: SerpentValueSource; +} + +// From serpent/option.go +export type SerpentOptionSet = readonly SerpentOption[]; + +// From serpent/option.go +export type SerpentValueSource = '' | 'default' | 'env' | 'flag' | 'yaml'; +export const SerpentValueSources: SerpentValueSource[] = [ + '', + 'default', + 'env', + 'flag', + 'yaml', +]; diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts new file mode 100644 index 00000000..df99ae32 --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts @@ -0,0 +1,8 @@ +/** + * Right now the file is doing barrel exports. But if something more + * sophisticated is needed down the line, those changes should be handled in + * this file, to provide some degree of insulation between the vendored files + * and the rest of the plugin logic. + */ +export * from './api/api'; +export type * from './api/typesGenerated'; diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/utils/delay.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/utils/delay.ts new file mode 100644 index 00000000..b915a7fb --- /dev/null +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/utils/delay.ts @@ -0,0 +1,4 @@ +export const delay = (ms: number): Promise => + new Promise(res => { + setTimeout(res, ms); + }); diff --git a/yarn.lock b/yarn.lock index e7553d7d..c287f84a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8919,6 +8919,11 @@ dependencies: "@types/node" "*" +"@types/ua-parser-js@^0.7.39": + version "0.7.39" + resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz#832c58e460c9435e4e34bb866e85e9146e12cdbb" + integrity sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg== + "@types/unist@^2", "@types/unist@^2.0.0": version "2.0.10" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.10.tgz#04ffa7f406ab628f7f7e97ca23e290cd8ab15efc" @@ -11859,6 +11864,11 @@ dateformat@^3.0.3: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== +dayjs@^1.11.11: + version "1.11.11" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.11.tgz#dfe0e9d54c5f8b68ccf8ca5f72ac603e7e5ed59e" + integrity sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg== + debounce@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" @@ -20248,14 +20258,6 @@ react-dom@^18.0.2: loose-envify "^1.1.0" scheduler "^0.23.0" -react-dom@^18.3.1: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" - integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== - dependencies: - loose-envify "^1.1.0" - scheduler "^0.23.2" - react-double-scrollbar@0.0.15: version "0.0.15" resolved "https://registry.yarnpkg.com/react-double-scrollbar/-/react-double-scrollbar-0.0.15.tgz#e915ab8cb3b959877075f49436debfdb04288fe4" @@ -21188,13 +21190,6 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" -scheduler@^0.23.2: - version "0.23.2" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" - integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== - dependencies: - loose-envify "^1.1.0" - schema-utils@2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" @@ -22953,6 +22948,11 @@ typescript@~5.2.0: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.2.tgz#5ebb5e5a5b75f085f22bc3f8460fba308310fa78" integrity sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w== +ua-parser-js@^1.0.37: + version "1.0.37" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f" + integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ== + uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" From 9c958d37009efda139f55772601d12177ede1c4f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 16:05:22 +0000 Subject: [PATCH 02/94] chore: update CoderClient class to use new SDK --- .../src/api/CoderClient.ts | 80 +++++++------------ .../src/api/vendoredSdk/index.ts | 8 +- 2 files changed, 38 insertions(+), 50 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index 7c09f72c..ecf1d67e 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -1,19 +1,18 @@ -import globalAxios, { +import { AxiosError, - type AxiosInstance, type InternalAxiosRequestConfig as RequestConfig, } from 'axios'; import { type IdentityApi, createApiRef } from '@backstage/core-plugin-api'; -import { - type Workspace, - CODER_API_REF_ID_PREFIX, - WorkspacesRequest, - WorkspacesResponse, - User, -} from '../typesConstants'; +import { CODER_API_REF_ID_PREFIX } from '../typesConstants'; import type { UrlSync } from './UrlSync'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; -import { CoderSdk } from './MockCoderSdk'; +import { + type User, + type Workspace, + type WorkspacesRequest, + type WorkspacesResponse, + CoderSdk, +} from './vendoredSdk'; export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; const DEFAULT_REQUEST_TIMEOUT_MS = 20_000; @@ -39,11 +38,6 @@ type CoderClientApi = Readonly<{ * Return value indicates whether the token is valid. */ syncToken: (newToken: string) => Promise; - - /** - * Cleans up a client instance, removing its links to all external systems. - */ - cleanupClient: () => void; }>; const sharedCleanupAbortReason = new DOMException( @@ -59,19 +53,30 @@ export const disabledClientError = new Error( ); type ConstructorInputs = Readonly<{ + /** + * initialToken is strictly for testing, and is basically limited to making it + * easier to test API logic. + * + * If trying to test UI logic that depends on CoderClient, it's probably + * better to interact with CoderClient indirectly through the auth components, + * so that React state is aware of everything. + */ initialToken?: string; - requestTimeoutMs?: number; + requestTimeoutMs?: number; apis: Readonly<{ urlSync: UrlSync; identityApi: IdentityApi; }>; }>; +type RequestInterceptor = ( + config: RequestConfig, +) => RequestConfig | Promise; + export class CoderClient implements CoderClientApi { private readonly urlSync: UrlSync; private readonly identityApi: IdentityApi; - private readonly axios: AxiosInstance; private readonly requestTimeoutMs: number; private readonly cleanupController: AbortController; @@ -82,33 +87,28 @@ export class CoderClient implements CoderClientApi { constructor(inputs: ConstructorInputs) { const { - apis, initialToken, + apis: { urlSync, identityApi }, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, } = inputs; - const { urlSync, identityApi } = apis; this.urlSync = urlSync; this.identityApi = identityApi; - this.axios = globalAxios.create(); - this.loadedSessionToken = initialToken; this.requestTimeoutMs = requestTimeoutMs; - this.cleanupController = new AbortController(); this.trackedEjectionIds = new Set(); - this.sdk = this.getBackstageCoderSdk(this.axios); + this.sdk = this.getBackstageCoderSdk(); this.addBaseRequestInterceptors(); } private addRequestInterceptor( - requestInterceptor: ( - config: RequestConfig, - ) => RequestConfig | Promise, + requestInterceptor: RequestInterceptor, errorInterceptor?: (error: unknown) => unknown, ): number { - const ejectionId = this.axios.interceptors.request.use( + const axios = this.sdk.getAxiosInstance(); + const ejectionId = axios.interceptors.request.use( requestInterceptor, errorInterceptor, ); @@ -120,7 +120,8 @@ export class CoderClient implements CoderClientApi { private removeRequestInterceptorById(ejectionId: number): boolean { // Even if we somehow pass in an ID that hasn't been associated with the // Axios instance, that's a noop. No harm in calling method no matter what - this.axios.interceptors.request.eject(ejectionId); + const axios = this.sdk.getAxiosInstance(); + axios.interceptors.request.eject(ejectionId); if (!this.trackedEjectionIds.has(ejectionId)) { return false; @@ -179,10 +180,8 @@ export class CoderClient implements CoderClientApi { this.addRequestInterceptor(baseRequestInterceptor, baseErrorInterceptor); } - private getBackstageCoderSdk( - axiosInstance: AxiosInstance, - ): BackstageCoderSdk { - const baseSdk = new CoderSdk(axiosInstance); + private getBackstageCoderSdk(): BackstageCoderSdk { + const baseSdk = new CoderSdk(); const getWorkspaces: (typeof baseSdk)['getWorkspaces'] = async request => { const workspacesRes = await baseSdk.getWorkspaces(request); @@ -335,23 +334,6 @@ export class CoderClient implements CoderClientApi { this.removeRequestInterceptorById(validationId); } }; - - cleanupClient = (): void => { - this.trackedEjectionIds.forEach(id => { - this.axios.interceptors.request.eject(id); - }); - - this.trackedEjectionIds.clear(); - this.cleanupController.abort(sharedCleanupAbortReason); - this.loadedSessionToken = undefined; - - // Not using this.addRequestInterceptor, because we don't want to track this - // interceptor at all. It should never be ejected once the client has been - // disabled - this.axios.interceptors.request.use(() => { - throw disabledClientError; - }); - }; } function appendParamToQuery( diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts index df99ae32..4f70e40c 100644 --- a/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts @@ -4,5 +4,11 @@ * this file, to provide some degree of insulation between the vendored files * and the rest of the plugin logic. */ -export * from './api/api'; export type * from './api/typesGenerated'; +export { + type DeleteWorkspaceOptions, + type GetLicensesResponse, + type InsightsParams, + type InsightsTemplateParams, + Api as CoderSdk, +} from './api/api'; From 4979067d3a7e1707c60cfcba9c76a3ae236e1793 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 16:09:07 +0000 Subject: [PATCH 03/94] chore: delete mock SDK --- .../src/api/MockCoderSdk.ts | 48 ------------------- 1 file changed, 48 deletions(-) delete mode 100644 plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts diff --git a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts b/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts deleted file mode 100644 index 3100242b..00000000 --- a/plugins/backstage-plugin-coder/src/api/MockCoderSdk.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @file This is a temporary (and significantly limited) implementation of the - * "Coder SDK" that will eventually be imported from Coder core - * - * @todo Replace this with a full, proper implementation, and then expose it to - * plugin users. - */ -import globalAxios, { type AxiosInstance } from 'axios'; -import { - type User, - type WorkspacesRequest, - type WorkspacesResponse, -} from '../typesConstants'; - -type CoderSdkApi = { - getAuthenticatedUser: () => Promise; - getWorkspaces: (request: WorkspacesRequest) => Promise; -}; - -export class CoderSdk implements CoderSdkApi { - private readonly axios: AxiosInstance; - - constructor(axiosInstance?: AxiosInstance) { - this.axios = axiosInstance ?? globalAxios.create(); - } - - getWorkspaces = async ( - request: WorkspacesRequest, - ): Promise => { - const urlParams = new URLSearchParams({ - q: request.q ?? '', - limit: String(request.limit || 0), - after_id: request.after_id ?? '', - offset: String(request.offset || 0), - }); - - const response = await this.axios.get( - `/workspaces?${urlParams.toString()}`, - ); - - return response.data; - }; - - getAuthenticatedUser = async (): Promise => { - const response = await this.axios.get('/users/me'); - return response.data; - }; -} From 5e7e01f464ad29ac750fb936b05a6c04da16b4b0 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 17:57:39 +0000 Subject: [PATCH 04/94] fix: improve data hiding for CoderSdk --- .../src/api/vendoredSdk/index.ts | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts index df99ae32..bcacd351 100644 --- a/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts @@ -1,8 +1,31 @@ -/** - * Right now the file is doing barrel exports. But if something more - * sophisticated is needed down the line, those changes should be handled in - * this file, to provide some degree of insulation between the vendored files - * and the rest of the plugin logic. - */ -export * from './api/api'; export type * from './api/typesGenerated'; +export type { + DeleteWorkspaceOptions, + GetLicensesResponse, + InsightsParams, + InsightsTemplateParams, +} from './api/api'; +import { Api } from './api/api'; + +// Union of all API properties that won't ever be relevant to Backstage users. +// Not a huge deal that they still exist at runtime; mainly concerned about +// whether they pollute Intellisense when someone is using the SDK. Most of +// these properties don't deal with APIs and are mainly helpers in Core +type PropertyToHide = + | 'getJFrogXRayScan' + | 'getCsrfToken' + | 'setSessionToken' + | 'setHost' + | 'getAvailableExperiments' + | 'login' + | 'logout'; + +// Wanted to have a CoderSdk class (mainly re-exporting the Api class as itself +// with the extra properties omitted). But because classes are wonky and exist +// as both runtime values and times, it didn't seem possible, even with things +// like class declarations. Making a new function is good enough for now, though +export type CoderSdk = Omit; +export function makeCoderSdk(): CoderSdk { + const api = new Api(); + return api as CoderSdk; +} From 937f6f51572dbbca45b9172e92159b8b976a081a Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 17:59:45 +0000 Subject: [PATCH 05/94] docs: update typo --- plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts index bcacd351..b64f8419 100644 --- a/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts @@ -22,7 +22,7 @@ type PropertyToHide = // Wanted to have a CoderSdk class (mainly re-exporting the Api class as itself // with the extra properties omitted). But because classes are wonky and exist -// as both runtime values and times, it didn't seem possible, even with things +// as both runtime values and types, it didn't seem possible, even with things // like class declarations. Making a new function is good enough for now, though export type CoderSdk = Omit; export function makeCoderSdk(): CoderSdk { From 294572d858e4ea05e1e70cdfd0ca4625f77f72f0 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 18:54:19 +0000 Subject: [PATCH 06/94] wip: commit progress on updating Coder client --- .../src/api/queryOptions.ts | 2 +- .../src/testHelpers/coderEntities.ts | 3440 +++++++++++++++++ .../src/testHelpers/mockCoderAppData.ts | 12 +- .../src/testHelpers/server.ts | 13 +- .../src/typesConstants.ts | 17 - 5 files changed, 3455 insertions(+), 29 deletions(-) create mode 100644 plugins/backstage-plugin-coder/src/testHelpers/coderEntities.ts diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index b10ecfe2..d15d6ce3 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -1,5 +1,5 @@ import type { UseQueryOptions } from '@tanstack/react-query'; -import type { Workspace, WorkspacesRequest } from '../typesConstants'; +import type { Workspace, WorkspacesRequest } from './vendoredSdk'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; import type { BackstageCoderSdk } from './CoderClient'; import type { CoderAuth } from '../components/CoderProvider'; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/coderEntities.ts b/plugins/backstage-plugin-coder/src/testHelpers/coderEntities.ts new file mode 100644 index 00000000..868daaaf --- /dev/null +++ b/plugins/backstage-plugin-coder/src/testHelpers/coderEntities.ts @@ -0,0 +1,3440 @@ +/** + * @file This is a subset of the mock data from the Coder OSS repo. No values + * are modified; if any values should be for Backstage, those should be updated + * in the mockCoderPluginData.ts file. + * + * @see {@link https://github.com/coder/coder/blob/main/site/src/testHelpers/entities.ts} + */ +import type * as TypesGen from '../api/vendoredSdk'; + +export const MockOrganization: TypesGen.Organization = { + id: 'fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0', + name: 'Test Organization', + created_at: '', + updated_at: '', + is_default: true, +}; + +export const MockOwnerRole: TypesGen.Role = { + name: 'owner', + display_name: 'Owner', + site_permissions: [], + organization_permissions: {}, + user_permissions: [], + organization_id: '', +}; + +export const MockUser: TypesGen.User = { + id: 'test-user', + username: 'TestUser', + email: 'test@coder.com', + created_at: '', + status: 'active', + organization_ids: [MockOrganization.id], + roles: [MockOwnerRole], + avatar_url: 'https://avatars.githubusercontent.com/u/95932066?s=200&v=4', + last_seen_at: '', + login_type: 'password', + theme_preference: '', + name: '', +}; + +export const MockTemplateDAUResponse: TypesGen.DAUsResponse = { + tz_hour_offset: 0, + entries: [ + { date: '2022-08-27', amount: 1 }, + { date: '2022-08-29', amount: 2 }, + { date: '2022-08-30', amount: 1 }, + ], +}; +export const MockDeploymentDAUResponse: TypesGen.DAUsResponse = { + tz_hour_offset: 0, + entries: [ + { date: '2022-08-27', amount: 10 }, + { date: '2022-08-29', amount: 22 }, + { date: '2022-08-30', amount: 14 }, + ], +}; +export const MockSessionToken: TypesGen.LoginWithPasswordResponse = { + session_token: 'my-session-token', +}; + +export const MockAPIKey: TypesGen.GenerateAPIKeyResponse = { + key: 'my-api-key', +}; + +export const MockToken: TypesGen.APIKeyWithOwner = { + id: 'tBoVE3dqLl', + user_id: 'f9ee61d8-1d84-4410-ab6e-c1ec1a641e0b', + last_used: '0001-01-01T00:00:00Z', + expires_at: '2023-01-15T20:10:45.637438Z', + created_at: '2022-12-16T20:10:45.637452Z', + updated_at: '2022-12-16T20:10:45.637452Z', + login_type: 'token', + scope: 'all', + lifetime_seconds: 2592000, + token_name: 'token-one', + username: 'admin', +}; + +export const MockTokens: TypesGen.APIKeyWithOwner[] = [ + MockToken, + { + id: 'tBoVE3dqLl', + user_id: 'f9ee61d8-1d84-4410-ab6e-c1ec1a641e0b', + last_used: '0001-01-01T00:00:00Z', + expires_at: '2023-01-15T20:10:45.637438Z', + created_at: '2022-12-16T20:10:45.637452Z', + updated_at: '2022-12-16T20:10:45.637452Z', + login_type: 'token', + scope: 'all', + lifetime_seconds: 2592000, + token_name: 'token-two', + username: 'admin', + }, +]; + +export const MockPrimaryWorkspaceProxy: TypesGen.WorkspaceProxy = { + id: '4aa23000-526a-481f-a007-0f20b98b1e12', + name: 'primary', + display_name: 'Default', + icon_url: '/emojis/1f60e.png', + healthy: true, + path_app_url: 'https://coder.com', + wildcard_hostname: '*.coder.com', + derp_enabled: true, + derp_only: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + version: 'v2.34.5-test+primary', + deleted: false, + status: { + status: 'ok', + checked_at: new Date().toISOString(), + }, +}; + +export const MockHealthyWildWorkspaceProxy: TypesGen.WorkspaceProxy = { + id: '5e2c1ab7-479b-41a9-92ce-aa85625de52c', + name: 'haswildcard', + display_name: 'Subdomain Supported', + icon_url: '/emojis/1f319.png', + healthy: true, + path_app_url: 'https://external.com', + wildcard_hostname: '*.external.com', + derp_enabled: true, + derp_only: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + deleted: false, + version: 'v2.34.5-test+haswildcard', + status: { + status: 'ok', + checked_at: new Date().toISOString(), + }, +}; + +export const MockUnhealthyWildWorkspaceProxy: TypesGen.WorkspaceProxy = { + id: '8444931c-0247-4171-842a-569d9f9cbadb', + name: 'unhealthy', + display_name: 'Unhealthy', + icon_url: '/emojis/1f92e.png', + healthy: false, + path_app_url: 'https://unhealthy.coder.com', + wildcard_hostname: '*unhealthy..coder.com', + derp_enabled: true, + derp_only: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + version: 'v2.34.5-test+unhealthy', + deleted: false, + status: { + status: 'unhealthy', + report: { + errors: ['This workspace proxy is manually marked as unhealthy.'], + warnings: ['This is a manual warning for this workspace proxy.'], + }, + checked_at: new Date().toISOString(), + }, +}; + +export const MockWorkspaceProxies: TypesGen.WorkspaceProxy[] = [ + MockPrimaryWorkspaceProxy, + MockHealthyWildWorkspaceProxy, + MockUnhealthyWildWorkspaceProxy, + { + id: '26e84c16-db24-4636-a62d-aa1a4232b858', + name: 'nowildcard', + display_name: 'No wildcard', + icon_url: '/emojis/1f920.png', + healthy: true, + path_app_url: 'https://cowboy.coder.com', + wildcard_hostname: '', + derp_enabled: false, + derp_only: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + deleted: false, + version: 'v2.34.5-test+nowildcard', + status: { + status: 'ok', + checked_at: new Date().toISOString(), + }, + }, +]; + +export const MockProxyLatencies: Record = { + ...MockWorkspaceProxies.reduce((acc, proxy) => { + if (!proxy.healthy) { + return acc; + } + acc[proxy.id] = { + // Make one of them inaccurate. + accurate: proxy.id !== '26e84c16-db24-4636-a62d-aa1a4232b858', + // This is a deterministic way to generate a latency to for each proxy. + // It will be the same for each run as long as the IDs don't change. + latencyMS: + (Number( + Array.from(proxy.id).reduce( + // Multiply each char code by some large prime number to increase the + // size of the number and allow use to get some decimal points. + (acc, char) => acc + char.charCodeAt(0) * 37, + 0, + ), + ) / + // Cap at 250ms + 100) % + 250, + at: new Date(), + }; + return acc; + }, {} as Record), +}; + +export const MockBuildInfo: TypesGen.BuildInfoResponse = { + agent_api_version: '1.0', + external_url: 'file:///mock-url', + version: 'v99.999.9999+c9cdf14', + dashboard_url: 'https:///mock-url', + workspace_proxy: false, + upgrade_message: 'My custom upgrade message', + deployment_id: '510d407f-e521-4180-b559-eab4a6d802b8', +}; + +export const MockSupportLinks: TypesGen.LinkConfig[] = [ + { + name: 'First link', + target: 'http://first-link', + icon: 'chat', + }, + { + name: 'Second link', + target: 'http://second-link', + icon: 'docs', + }, + { + name: 'Third link', + target: + 'https://github.com/coder/coder/issues/new?labels=needs+grooming&body={CODER_BUILD_INFO}', + icon: '', + }, +]; + +export const MockUpdateCheck: TypesGen.UpdateCheckResponse = { + current: true, + url: 'file:///mock-url', + version: 'v99.999.9999+c9cdf14', +}; + +export const MockUserAdminRole: TypesGen.Role = { + name: 'user_admin', + display_name: 'User Admin', + site_permissions: [], + organization_permissions: {}, + user_permissions: [], + organization_id: '', +}; + +export const MockTemplateAdminRole: TypesGen.Role = { + name: 'template_admin', + display_name: 'Template Admin', + site_permissions: [], + organization_permissions: {}, + user_permissions: [], + organization_id: '', +}; + +export const MockMemberRole: TypesGen.SlimRole = { + name: 'member', + display_name: 'Member', +}; + +export const MockAuditorRole: TypesGen.Role = { + name: 'auditor', + display_name: 'Auditor', + site_permissions: [], + organization_permissions: {}, + user_permissions: [], + organization_id: '', +}; + +// assignableRole takes a role and a boolean. The boolean implies if the +// actor can assign (add/remove) the role from other users. +export function assignableRole( + role: TypesGen.Role, + assignable: boolean, +): TypesGen.AssignableRoles { + return { + ...role, + assignable: assignable, + built_in: true, + }; +} + +export const MockSiteRoles = [MockUserAdminRole, MockAuditorRole]; +export const MockAssignableSiteRoles = [ + assignableRole(MockUserAdminRole, true), + assignableRole(MockAuditorRole, true), +]; + +export const MockMemberPermissions = { + viewAuditLog: false, +}; + +export const MockUserAdmin: TypesGen.User = { + id: 'test-user', + username: 'TestUser', + email: 'test@coder.com', + created_at: '', + status: 'active', + organization_ids: [MockOrganization.id], + roles: [MockUserAdminRole], + avatar_url: '', + last_seen_at: '', + login_type: 'password', + theme_preference: '', + name: '', +}; + +export const MockUser2: TypesGen.User = { + id: 'test-user-2', + username: 'TestUser2', + email: 'test2@coder.com', + created_at: '', + status: 'active', + organization_ids: [MockOrganization.id], + roles: [], + avatar_url: '', + last_seen_at: '2022-09-14T19:12:21Z', + login_type: 'oidc', + theme_preference: '', + name: 'Mock User The Second', +}; + +export const SuspendedMockUser: TypesGen.User = { + id: 'suspended-mock-user', + username: 'SuspendedMockUser', + email: 'iamsuspendedsad!@coder.com', + created_at: '', + status: 'suspended', + organization_ids: [MockOrganization.id], + roles: [], + avatar_url: '', + last_seen_at: '', + login_type: 'password', + theme_preference: '', + name: '', +}; + +export const MockProvisioner: TypesGen.ProvisionerDaemon = { + created_at: '2022-05-17T17:39:01.382927298Z', + id: 'test-provisioner', + name: 'Test Provisioner', + provisioners: ['echo'], + tags: { scope: 'organization' }, + version: 'v2.34.5', + api_version: '1.0', +}; + +export const MockUserProvisioner: TypesGen.ProvisionerDaemon = { + created_at: '2022-05-17T17:39:01.382927298Z', + id: 'test-user-provisioner', + name: 'Test User Provisioner', + provisioners: ['echo'], + tags: { scope: 'user', owner: '12345678-abcd-1234-abcd-1234567890abcd' }, + version: 'v2.34.5', + api_version: '1.0', +}; + +export const MockProvisionerJob: TypesGen.ProvisionerJob = { + created_at: '', + id: 'test-provisioner-job', + status: 'succeeded', + file_id: MockOrganization.id, + completed_at: '2022-05-17T17:39:01.382927298Z', + tags: { + scope: 'organization', + owner: '', + wowzers: 'whatatag', + isCapable: 'false', + department: 'engineering', + dreaming: 'true', + }, + queue_position: 0, + queue_size: 0, +}; + +export const MockFailedProvisionerJob: TypesGen.ProvisionerJob = { + ...MockProvisionerJob, + status: 'failed', +}; + +export const MockCancelingProvisionerJob: TypesGen.ProvisionerJob = { + ...MockProvisionerJob, + status: 'canceling', +}; +export const MockCanceledProvisionerJob: TypesGen.ProvisionerJob = { + ...MockProvisionerJob, + status: 'canceled', +}; +export const MockRunningProvisionerJob: TypesGen.ProvisionerJob = { + ...MockProvisionerJob, + status: 'running', +}; +export const MockPendingProvisionerJob: TypesGen.ProvisionerJob = { + ...MockProvisionerJob, + status: 'pending', + queue_position: 2, + queue_size: 4, +}; +export const MockTemplateVersion: TypesGen.TemplateVersion = { + id: 'test-template-version', + created_at: '2022-05-17T17:39:01.382927298Z', + updated_at: '2022-05-17T17:39:01.382927298Z', + template_id: 'test-template', + job: MockProvisionerJob, + name: 'test-version', + message: 'first version', + readme: `--- +name:Template test +--- +## Instructions +You can add instructions here + +[Some link info](https://coder.com)`, + created_by: MockUser, + archived: false, +}; + +export const MockTemplateVersion2: TypesGen.TemplateVersion = { + id: 'test-template-version-2', + created_at: '2022-05-17T17:39:01.382927298Z', + updated_at: '2022-05-17T17:39:01.382927298Z', + template_id: 'test-template', + job: MockProvisionerJob, + name: 'test-version-2', + message: 'first version', + readme: `--- +name:Template test 2 +--- +## Instructions +You can add instructions here + +[Some link info](https://coder.com)`, + created_by: MockUser, + archived: false, +}; + +export const MockTemplateVersionWithMarkdownMessage: TypesGen.TemplateVersion = + { + ...MockTemplateVersion, + message: ` +# Abiding Grace +## Enchantment +At the beginning of your end step, choose one — + +- You gain 1 life. + +- Return target creature card with mana value 1 from your graveyard to the battlefield. +`, + }; + +export const MockTemplate: TypesGen.Template = { + id: 'test-template', + created_at: '2022-05-17T17:39:01.382927298Z', + updated_at: '2022-05-18T17:39:01.382927298Z', + organization_id: MockOrganization.id, + name: 'test-template', + display_name: 'Test Template', + provisioner: MockProvisioner.provisioners[0], + active_version_id: MockTemplateVersion.id, + active_user_count: 1, + build_time_stats: { + start: { + P50: 1000, + P95: 1500, + }, + stop: { + P50: 1000, + P95: 1500, + }, + delete: { + P50: 1000, + P95: 1500, + }, + }, + description: 'This is a test description.', + default_ttl_ms: 24 * 60 * 60 * 1000, + activity_bump_ms: 1 * 60 * 60 * 1000, + autostop_requirement: { + days_of_week: ['sunday'], + weeks: 1, + }, + autostart_requirement: { + days_of_week: [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', + ], + }, + created_by_id: 'test-creator-id', + created_by_name: 'test_creator', + icon: '/icon/code.svg', + allow_user_cancel_workspace_jobs: true, + failure_ttl_ms: 0, + time_til_dormant_ms: 0, + time_til_dormant_autodelete_ms: 0, + allow_user_autostart: true, + allow_user_autostop: true, + require_active_version: false, + deprecated: false, + deprecation_message: '', + max_port_share_level: 'public', +}; + +export const MockTemplateVersionFiles: TemplateVersionFiles = { + 'README.md': '# Example\n\nThis is an example template.', + 'main.tf': `// Provides info about the workspace. +data "coder_workspace" "me" {} + +// Provides the startup script used to download +// the agent and communicate with Coder. +resource "coder_agent" "dev" { +os = "linux" +arch = "amd64" +} + +resource "kubernetes_pod" "main" { +// Ensures that the Pod dies when the workspace shuts down! +count = data.coder_workspace.me.start_count +metadata { + name = "dev-\${data.coder_workspace.me.id}" +} +spec { + container { + image = "ubuntu" + command = ["sh", "-c", coder_agent.main.init_script] + env { + name = "CODER_AGENT_TOKEN" + value = coder_agent.main.token + } + } +} +} +`, +}; + +export const MockWorkspaceApp: TypesGen.WorkspaceApp = { + id: 'test-app', + slug: 'test-app', + display_name: 'Test App', + icon: '', + subdomain: false, + health: 'disabled', + external: false, + url: '', + sharing_level: 'owner', + healthcheck: { + url: '', + interval: 0, + threshold: 0, + }, +}; + +export const MockWorkspaceAgentLogSource: TypesGen.WorkspaceAgentLogSource = { + created_at: '2023-05-04T11:30:41.402072Z', + id: 'dc790496-eaec-4f88-a53f-8ce1f61a1fff', + display_name: 'Startup Script', + icon: '', + workspace_agent_id: '', +}; + +export const MockWorkspaceAgentScript: TypesGen.WorkspaceAgentScript = { + log_source_id: MockWorkspaceAgentLogSource.id, + cron: '', + log_path: '', + run_on_start: true, + run_on_stop: false, + script: "echo 'hello world'", + start_blocks_login: false, + timeout: 0, +}; + +export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = { + apps: [MockWorkspaceApp], + architecture: 'amd64', + created_at: '', + environment_variables: {}, + id: 'test-workspace-agent', + name: 'a-workspace-agent', + operating_system: 'linux', + resource_id: '', + status: 'connected', + updated_at: '', + version: MockBuildInfo.version, + api_version: '1.0', + latency: { + 'Coder Embedded DERP': { + latency_ms: 32.55, + preferred: true, + }, + }, + connection_timeout_seconds: 120, + troubleshooting_url: 'https://coder.com/troubleshoot', + lifecycle_state: 'starting', + logs_length: 0, + logs_overflowed: false, + log_sources: [MockWorkspaceAgentLogSource], + scripts: [MockWorkspaceAgentScript], + startup_script_behavior: 'non-blocking', + subsystems: ['envbox', 'exectrace'], + health: { + healthy: true, + }, + display_apps: [ + 'ssh_helper', + 'port_forwarding_helper', + 'vscode', + 'vscode_insiders', + 'web_terminal', + ], +}; + +export const MockWorkspaceAgentDisconnected: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-2', + name: 'another-workspace-agent', + status: 'disconnected', + version: '', + latency: {}, + lifecycle_state: 'ready', + health: { + healthy: false, + reason: 'agent is not connected', + }, +}; + +export const MockWorkspaceAgentOutdated: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-3', + name: 'an-outdated-workspace-agent', + version: 'v99.999.9998+abcdef', + operating_system: 'Windows', + latency: { + ...MockWorkspaceAgent.latency, + Chicago: { + preferred: false, + latency_ms: 95.11, + }, + 'San Francisco': { + preferred: false, + latency_ms: 111.55, + }, + Paris: { + preferred: false, + latency_ms: 221.66, + }, + }, + lifecycle_state: 'ready', +}; + +export const MockWorkspaceAgentDeprecated: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-3', + name: 'an-outdated-workspace-agent', + version: 'v99.999.9998+abcdef', + api_version: '1.99', + operating_system: 'Windows', + latency: { + ...MockWorkspaceAgent.latency, + Chicago: { + preferred: false, + latency_ms: 95.11, + }, + 'San Francisco': { + preferred: false, + latency_ms: 111.55, + }, + Paris: { + preferred: false, + latency_ms: 221.66, + }, + }, + lifecycle_state: 'ready', +}; + +export const MockWorkspaceAgentConnecting: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-connecting', + name: 'another-workspace-agent', + status: 'connecting', + version: '', + latency: {}, + lifecycle_state: 'created', +}; + +export const MockWorkspaceAgentTimeout: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-timeout', + name: 'a-timed-out-workspace-agent', + status: 'timeout', + version: '', + latency: {}, + lifecycle_state: 'created', + health: { + healthy: false, + reason: 'agent is taking too long to connect', + }, +}; + +export const MockWorkspaceAgentStarting: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-starting', + name: 'a-starting-workspace-agent', + lifecycle_state: 'starting', +}; + +export const MockWorkspaceAgentReady: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-ready', + name: 'a-ready-workspace-agent', + lifecycle_state: 'ready', +}; + +export const MockWorkspaceAgentStartTimeout: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-start-timeout', + name: 'a-workspace-agent-timed-out-while-running-startup-script', + lifecycle_state: 'start_timeout', +}; + +export const MockWorkspaceAgentStartError: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-start-error', + name: 'a-workspace-agent-errored-while-running-startup-script', + lifecycle_state: 'start_error', + health: { + healthy: false, + reason: 'agent startup script failed', + }, +}; + +export const MockWorkspaceAgentShuttingDown: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-shutting-down', + name: 'a-shutting-down-workspace-agent', + lifecycle_state: 'shutting_down', + health: { + healthy: false, + reason: 'agent is shutting down', + }, +}; + +export const MockWorkspaceAgentShutdownTimeout: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-shutdown-timeout', + name: 'a-workspace-agent-timed-out-while-running-shutdownup-script', + lifecycle_state: 'shutdown_timeout', + health: { + healthy: false, + reason: 'agent is shutting down', + }, +}; + +export const MockWorkspaceAgentShutdownError: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-shutdown-error', + name: 'a-workspace-agent-errored-while-running-shutdownup-script', + lifecycle_state: 'shutdown_error', + health: { + healthy: false, + reason: 'agent is shutting down', + }, +}; + +export const MockWorkspaceAgentOff: TypesGen.WorkspaceAgent = { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-off', + name: 'a-workspace-agent-is-shut-down', + lifecycle_state: 'off', + health: { + healthy: false, + reason: 'agent is shutting down', + }, +}; + +export const MockWorkspaceResource: TypesGen.WorkspaceResource = { + id: 'test-workspace-resource', + name: 'a-workspace-resource', + agents: [MockWorkspaceAgent], + created_at: '', + job_id: '', + type: 'google_compute_disk', + workspace_transition: 'start', + hide: false, + icon: '', + metadata: [{ key: 'size', value: '32GB', sensitive: false }], + daily_cost: 10, +}; + +export const MockWorkspaceResourceSensitive: TypesGen.WorkspaceResource = { + ...MockWorkspaceResource, + id: 'test-workspace-resource-sensitive', + name: 'workspace-resource-sensitive', + metadata: [{ key: 'api_key', value: '12345678', sensitive: true }], +}; + +export const MockWorkspaceResourceMultipleAgents: TypesGen.WorkspaceResource = { + ...MockWorkspaceResource, + id: 'test-workspace-resource-multiple-agents', + name: 'workspace-resource-multiple-agents', + agents: [ + MockWorkspaceAgent, + MockWorkspaceAgentDisconnected, + MockWorkspaceAgentOutdated, + ], +}; + +export const MockWorkspaceResourceHidden: TypesGen.WorkspaceResource = { + ...MockWorkspaceResource, + id: 'test-workspace-resource-hidden', + name: 'workspace-resource-hidden', + hide: true, +}; + +export const MockWorkspaceVolumeResource: TypesGen.WorkspaceResource = { + id: 'test-workspace-volume-resource', + created_at: '', + job_id: '', + workspace_transition: 'start', + type: 'docker_volume', + name: 'home_volume', + hide: false, + icon: '', + daily_cost: 0, +}; + +export const MockWorkspaceImageResource: TypesGen.WorkspaceResource = { + id: 'test-workspace-image-resource', + created_at: '', + job_id: '', + workspace_transition: 'start', + type: 'docker_image', + name: 'main', + hide: false, + icon: '', + daily_cost: 0, +}; + +export const MockWorkspaceContainerResource: TypesGen.WorkspaceResource = { + id: 'test-workspace-container-resource', + created_at: '', + job_id: '', + workspace_transition: 'start', + type: 'docker_container', + name: 'workspace', + hide: false, + icon: '', + daily_cost: 0, +}; + +export const MockWorkspaceAutostartDisabled: TypesGen.UpdateWorkspaceAutostartRequest = + { + schedule: '', + }; + +export const MockWorkspaceAutostartEnabled: TypesGen.UpdateWorkspaceAutostartRequest = + { + // Runs at 9:30am Monday through Friday using Canada/Eastern + // (America/Toronto) time + schedule: 'CRON_TZ=Canada/Eastern 30 9 * * 1-5', + }; + +export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { + build_number: 1, + created_at: '2022-05-17T17:39:01.382927298Z', + id: '1', + initiator_id: MockUser.id, + initiator_name: MockUser.username, + job: MockProvisionerJob, + template_version_id: MockTemplateVersion.id, + template_version_name: MockTemplateVersion.name, + transition: 'start', + updated_at: '2022-05-17T17:39:01.382927298Z', + workspace_name: 'test-workspace', + workspace_owner_id: MockUser.id, + workspace_owner_name: MockUser.username, + workspace_owner_avatar_url: MockUser.avatar_url, + workspace_id: '759f1d46-3174-453d-aa60-980a9c1442f3', + deadline: '2022-05-17T23:39:00.00Z', + reason: 'initiator', + resources: [MockWorkspaceResource], + status: 'running', + daily_cost: 20, +}; + +export const MockWorkspaceBuildAutostart: TypesGen.WorkspaceBuild = { + build_number: 1, + created_at: '2022-05-17T17:39:01.382927298Z', + id: '1', + initiator_id: MockUser.id, + initiator_name: MockUser.username, + job: MockProvisionerJob, + template_version_id: MockTemplateVersion.id, + template_version_name: MockTemplateVersion.name, + transition: 'start', + updated_at: '2022-05-17T17:39:01.382927298Z', + workspace_name: 'test-workspace', + workspace_owner_id: MockUser.id, + workspace_owner_name: MockUser.username, + workspace_owner_avatar_url: MockUser.avatar_url, + workspace_id: '759f1d46-3174-453d-aa60-980a9c1442f3', + deadline: '2022-05-17T23:39:00.00Z', + reason: 'autostart', + resources: [MockWorkspaceResource], + status: 'running', + daily_cost: 20, +}; + +export const MockWorkspaceBuildAutostop: TypesGen.WorkspaceBuild = { + build_number: 1, + created_at: '2022-05-17T17:39:01.382927298Z', + id: '1', + initiator_id: MockUser.id, + initiator_name: MockUser.username, + job: MockProvisionerJob, + template_version_id: MockTemplateVersion.id, + template_version_name: MockTemplateVersion.name, + transition: 'start', + updated_at: '2022-05-17T17:39:01.382927298Z', + workspace_name: 'test-workspace', + workspace_owner_id: MockUser.id, + workspace_owner_name: MockUser.username, + workspace_owner_avatar_url: MockUser.avatar_url, + workspace_id: '759f1d46-3174-453d-aa60-980a9c1442f3', + deadline: '2022-05-17T23:39:00.00Z', + reason: 'autostop', + resources: [MockWorkspaceResource], + status: 'running', + daily_cost: 20, +}; + +export const MockFailedWorkspaceBuild = ( + transition: TypesGen.WorkspaceTransition = 'start', +): TypesGen.WorkspaceBuild => ({ + build_number: 1, + created_at: '2022-05-17T17:39:01.382927298Z', + id: '1', + initiator_id: MockUser.id, + initiator_name: MockUser.username, + job: MockFailedProvisionerJob, + template_version_id: MockTemplateVersion.id, + template_version_name: MockTemplateVersion.name, + transition: transition, + updated_at: '2022-05-17T17:39:01.382927298Z', + workspace_name: 'test-workspace', + workspace_owner_id: MockUser.id, + workspace_owner_name: MockUser.username, + workspace_owner_avatar_url: MockUser.avatar_url, + workspace_id: '759f1d46-3174-453d-aa60-980a9c1442f3', + deadline: '2022-05-17T23:39:00.00Z', + reason: 'initiator', + resources: [], + status: 'failed', + daily_cost: 20, +}); + +export const MockWorkspaceBuildStop: TypesGen.WorkspaceBuild = { + ...MockWorkspaceBuild, + id: '2', + transition: 'stop', +}; + +export const MockWorkspaceBuildDelete: TypesGen.WorkspaceBuild = { + ...MockWorkspaceBuild, + id: '3', + transition: 'delete', +}; + +export const MockBuilds = [ + { ...MockWorkspaceBuild, id: '1' }, + { ...MockWorkspaceBuildAutostart, id: '2' }, + { ...MockWorkspaceBuildAutostop, id: '3' }, + { ...MockWorkspaceBuildStop, id: '4' }, + { ...MockWorkspaceBuildDelete, id: '5' }, +]; + +export const MockWorkspace: TypesGen.Workspace = { + id: 'test-workspace', + name: 'Test-Workspace', + created_at: '', + updated_at: '', + template_id: MockTemplate.id, + template_name: MockTemplate.name, + template_icon: MockTemplate.icon, + template_display_name: MockTemplate.display_name, + template_allow_user_cancel_workspace_jobs: + MockTemplate.allow_user_cancel_workspace_jobs, + template_active_version_id: MockTemplate.active_version_id, + template_require_active_version: MockTemplate.require_active_version, + outdated: false, + owner_id: MockUser.id, + organization_id: MockOrganization.id, + owner_name: MockUser.username, + owner_avatar_url: 'https://avatars.githubusercontent.com/u/7122116?v=4', + autostart_schedule: MockWorkspaceAutostartEnabled.schedule, + ttl_ms: 2 * 60 * 60 * 1000, + latest_build: MockWorkspaceBuild, + last_used_at: '2022-05-16T15:29:10.302441433Z', + health: { + healthy: true, + failing_agents: [], + }, + automatic_updates: 'never', + allow_renames: true, + favorite: false, +}; + +export const MockFavoriteWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + id: 'test-favorite-workspace', + favorite: true, +}; + +export const MockStoppedWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + id: 'test-stopped-workspace', + latest_build: { ...MockWorkspaceBuildStop, status: 'stopped' }, +}; +export const MockStoppingWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + id: 'test-stopping-workspace', + latest_build: { + ...MockWorkspaceBuildStop, + job: MockRunningProvisionerJob, + status: 'stopping', + }, +}; +export const MockStartingWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + id: 'test-starting-workspace', + latest_build: { + ...MockWorkspaceBuild, + job: MockRunningProvisionerJob, + transition: 'start', + status: 'starting', + }, +}; +export const MockCancelingWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + id: 'test-canceling-workspace', + latest_build: { + ...MockWorkspaceBuild, + job: MockCancelingProvisionerJob, + status: 'canceling', + }, +}; +export const MockCanceledWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + id: 'test-canceled-workspace', + latest_build: { + ...MockWorkspaceBuild, + job: MockCanceledProvisionerJob, + status: 'canceled', + }, +}; +export const MockFailedWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + id: 'test-failed-workspace', + latest_build: { + ...MockWorkspaceBuild, + job: MockFailedProvisionerJob, + status: 'failed', + }, +}; +export const MockDeletingWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + id: 'test-deleting-workspace', + latest_build: { + ...MockWorkspaceBuildDelete, + job: MockRunningProvisionerJob, + status: 'deleting', + }, +}; + +export const MockWorkspaceWithDeletion = { + ...MockStoppedWorkspace, + deleting_at: new Date().toISOString(), +}; + +export const MockDeletedWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + id: 'test-deleted-workspace', + latest_build: { ...MockWorkspaceBuildDelete, status: 'deleted' }, +}; + +export const MockOutdatedWorkspace: TypesGen.Workspace = { + ...MockFailedWorkspace, + id: 'test-outdated-workspace', + outdated: true, +}; + +export const MockRunningOutdatedWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + id: 'test-running-outdated-workspace', + outdated: true, +}; + +export const MockDormantWorkspace: TypesGen.Workspace = { + ...MockStoppedWorkspace, + id: 'test-dormant-workspace', + dormant_at: new Date().toISOString(), +}; + +export const MockDormantOutdatedWorkspace: TypesGen.Workspace = { + ...MockStoppedWorkspace, + id: 'test-dormant-outdated-workspace', + name: 'Dormant-Workspace', + outdated: true, + dormant_at: new Date().toISOString(), +}; + +export const MockOutdatedRunningWorkspaceRequireActiveVersion: TypesGen.Workspace = + { + ...MockWorkspace, + id: 'test-outdated-workspace-require-active-version', + outdated: true, + template_require_active_version: true, + latest_build: { + ...MockWorkspaceBuild, + status: 'running', + }, + }; + +export const MockOutdatedRunningWorkspaceAlwaysUpdate: TypesGen.Workspace = { + ...MockWorkspace, + id: 'test-outdated-workspace-always-update', + outdated: true, + automatic_updates: 'always', + latest_build: { + ...MockWorkspaceBuild, + status: 'running', + }, +}; + +export const MockOutdatedStoppedWorkspaceRequireActiveVersion: TypesGen.Workspace = + { + ...MockOutdatedRunningWorkspaceRequireActiveVersion, + latest_build: { + ...MockWorkspaceBuild, + status: 'stopped', + }, + }; + +export const MockOutdatedStoppedWorkspaceAlwaysUpdate: TypesGen.Workspace = { + ...MockOutdatedRunningWorkspaceAlwaysUpdate, + latest_build: { + ...MockWorkspaceBuild, + status: 'stopped', + }, +}; + +export const MockPendingWorkspace: TypesGen.Workspace = { + ...MockWorkspace, + id: 'test-pending-workspace', + latest_build: { + ...MockWorkspaceBuild, + job: MockPendingProvisionerJob, + transition: 'start', + status: 'pending', + }, +}; + +// just over one page of workspaces +export const MockWorkspacesResponse: TypesGen.WorkspacesResponse = { + workspaces: range(1, 27).map((id: number) => ({ + ...MockWorkspace, + id: id.toString(), + name: `${MockWorkspace.name}${id}`, + })), + count: 26, +}; + +export const MockWorkspacesResponseWithDeletions = { + workspaces: [...MockWorkspacesResponse.workspaces, MockWorkspaceWithDeletion], + count: MockWorkspacesResponse.count + 1, +}; + +export const MockTemplateVersionParameter1: TypesGen.TemplateVersionParameter = + { + name: 'first_parameter', + type: 'string', + description: 'This is first parameter', + description_plaintext: 'Markdown: This is first parameter', + default_value: 'abc', + mutable: true, + icon: '/icon/folder.svg', + options: [], + required: true, + ephemeral: false, + }; + +export const MockTemplateVersionParameter2: TypesGen.TemplateVersionParameter = + { + name: 'second_parameter', + type: 'number', + description: 'This is second parameter', + description_plaintext: 'Markdown: This is second parameter', + default_value: '2', + mutable: true, + icon: '/icon/folder.svg', + options: [], + validation_min: 1, + validation_max: 3, + validation_monotonic: 'increasing', + required: true, + ephemeral: false, + }; + +export const MockTemplateVersionParameter3: TypesGen.TemplateVersionParameter = + { + name: 'third_parameter', + type: 'string', + description: 'This is third parameter', + description_plaintext: 'Markdown: This is third parameter', + default_value: 'aaa', + mutable: true, + icon: '/icon/database.svg', + options: [], + validation_error: 'No way!', + validation_regex: '^[a-z]{3}$', + required: true, + ephemeral: false, + }; + +export const MockTemplateVersionParameter4: TypesGen.TemplateVersionParameter = + { + name: 'fourth_parameter', + type: 'string', + description: 'This is fourth parameter', + description_plaintext: 'Markdown: This is fourth parameter', + default_value: 'def', + mutable: false, + icon: '/icon/database.svg', + options: [], + required: true, + ephemeral: false, + }; + +export const MockTemplateVersionParameter5: TypesGen.TemplateVersionParameter = + { + name: 'fifth_parameter', + type: 'number', + description: 'This is fifth parameter', + description_plaintext: 'Markdown: This is fifth parameter', + default_value: '5', + mutable: true, + icon: '/icon/folder.svg', + options: [], + validation_min: 1, + validation_max: 10, + validation_monotonic: 'decreasing', + required: true, + ephemeral: false, + }; + +export const MockTemplateVersionVariable1: TypesGen.TemplateVersionVariable = { + name: 'first_variable', + description: 'This is first variable.', + type: 'string', + value: '', + default_value: 'abc', + required: false, + sensitive: false, +}; + +export const MockTemplateVersionVariable2: TypesGen.TemplateVersionVariable = { + name: 'second_variable', + description: 'This is second variable.', + type: 'number', + value: '5', + default_value: '3', + required: false, + sensitive: false, +}; + +export const MockTemplateVersionVariable3: TypesGen.TemplateVersionVariable = { + name: 'third_variable', + description: 'This is third variable.', + type: 'bool', + value: '', + default_value: 'false', + required: false, + sensitive: false, +}; + +export const MockTemplateVersionVariable4: TypesGen.TemplateVersionVariable = { + name: 'fourth_variable', + description: 'This is fourth variable.', + type: 'string', + value: 'defghijk', + default_value: '', + required: true, + sensitive: true, +}; + +export const MockTemplateVersionVariable5: TypesGen.TemplateVersionVariable = { + name: 'fifth_variable', + description: 'This is fifth variable.', + type: 'string', + value: '', + default_value: '', + required: true, + sensitive: false, +}; + +export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = { + name: 'test', + template_version_id: 'test-template-version', + rich_parameter_values: [], +}; + +export const MockWorkspaceRichParametersRequest: TypesGen.CreateWorkspaceRequest = + { + name: 'test', + template_version_id: 'test-template-version', + rich_parameter_values: [ + { + name: MockTemplateVersionParameter1.name, + value: MockTemplateVersionParameter1.default_value, + }, + ], + }; + +export const MockUserAgent = { + browser: 'Chrome 99.0.4844', + device: 'Other', + ip_address: '11.22.33.44', + os: 'Windows 10', +}; + +export const MockAuthMethodsPasswordOnly: TypesGen.AuthMethods = { + password: { enabled: true }, + github: { enabled: false }, + oidc: { enabled: false, signInText: '', iconUrl: '' }, +}; + +export const MockAuthMethodsPasswordTermsOfService: TypesGen.AuthMethods = { + terms_of_service_url: 'https://www.youtube.com/watch?v=C2f37Vb2NAE', + password: { enabled: true }, + github: { enabled: false }, + oidc: { enabled: false, signInText: '', iconUrl: '' }, +}; + +export const MockAuthMethodsExternal: TypesGen.AuthMethods = { + password: { enabled: false }, + github: { enabled: true }, + oidc: { + enabled: true, + signInText: 'Google', + iconUrl: '/icon/google.svg', + }, +}; + +export const MockAuthMethodsAll: TypesGen.AuthMethods = { + password: { enabled: true }, + github: { enabled: true }, + oidc: { + enabled: true, + signInText: 'Google', + iconUrl: '/icon/google.svg', + }, +}; + +export const MockGitSSHKey: TypesGen.GitSSHKey = { + user_id: '1fa0200f-7331-4524-a364-35770666caa7', + created_at: '2022-05-16T14:30:34.148205897Z', + updated_at: '2022-05-16T15:29:10.302441433Z', + public_key: + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFJOQRIM7kE30rOzrfy+/+R+nQGCk7S9pioihy+2ARbq', +}; + +export const MockWorkspaceBuildLogs: TypesGen.ProvisionerJobLog[] = [ + { + id: 1, + created_at: '2022-05-19T16:45:31.005Z', + log_source: 'provisioner_daemon', + log_level: 'info', + stage: 'Setting up', + output: '', + }, + { + id: 2, + created_at: '2022-05-19T16:45:31.006Z', + log_source: 'provisioner_daemon', + log_level: 'info', + stage: 'Starting workspace', + output: '', + }, + { + id: 3, + created_at: '2022-05-19T16:45:31.072Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: '', + }, + { + id: 4, + created_at: '2022-05-19T16:45:31.073Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: 'Initializing the backend...', + }, + { + id: 5, + created_at: '2022-05-19T16:45:31.077Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: '', + }, + { + id: 6, + created_at: '2022-05-19T16:45:31.078Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: 'Initializing provider plugins...', + }, + { + id: 7, + created_at: '2022-05-19T16:45:31.078Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: '- Finding hashicorp/google versions matching "~\u003e 4.15"...', + }, + { + id: 8, + created_at: '2022-05-19T16:45:31.123Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: '- Finding coder/coder versions matching "0.3.4"...', + }, + { + id: 9, + created_at: '2022-05-19T16:45:31.137Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: '- Using hashicorp/google v4.21.0 from the shared cache directory', + }, + { + id: 10, + created_at: '2022-05-19T16:45:31.344Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: '- Using coder/coder v0.3.4 from the shared cache directory', + }, + { + id: 11, + created_at: '2022-05-19T16:45:31.388Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: '', + }, + { + id: 12, + created_at: '2022-05-19T16:45:31.388Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: + 'Terraform has created a lock file .terraform.lock.hcl to record the provider', + }, + { + id: 13, + created_at: '2022-05-19T16:45:31.389Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: + 'selections it made above. Include this file in your version control repository', + }, + { + id: 14, + created_at: '2022-05-19T16:45:31.389Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: + 'so that Terraform can guarantee to make the same selections by default when', + }, + { + id: 15, + created_at: '2022-05-19T16:45:31.39Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: 'you run "terraform init" in the future.', + }, + { + id: 16, + created_at: '2022-05-19T16:45:31.39Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: '', + }, + { + id: 17, + created_at: '2022-05-19T16:45:31.391Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Starting workspace', + output: 'Terraform has been successfully initialized!', + }, + { + id: 18, + created_at: '2022-05-19T16:45:31.42Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: 'Terraform 1.1.9', + }, + { + id: 19, + created_at: '2022-05-19T16:45:33.537Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: 'coder_agent.dev: Plan to create', + }, + { + id: 20, + created_at: '2022-05-19T16:45:33.537Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: 'google_compute_disk.root: Plan to create', + }, + { + id: 21, + created_at: '2022-05-19T16:45:33.538Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: 'google_compute_instance.dev[0]: Plan to create', + }, + { + id: 22, + created_at: '2022-05-19T16:45:33.539Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: 'Plan: 3 to add, 0 to change, 0 to destroy.', + }, + { + id: 23, + created_at: '2022-05-19T16:45:33.712Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: 'coder_agent.dev: Creating...', + }, + { + id: 24, + created_at: '2022-05-19T16:45:33.719Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: + 'coder_agent.dev: Creation complete after 0s [id=d07f5bdc-4a8d-4919-9cdb-0ac6ba9e64d6]', + }, + { + id: 25, + created_at: '2022-05-19T16:45:34.139Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: 'google_compute_disk.root: Creating...', + }, + { + id: 26, + created_at: '2022-05-19T16:45:44.14Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: 'google_compute_disk.root: Still creating... [10s elapsed]', + }, + { + id: 27, + created_at: '2022-05-19T16:45:47.106Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: + 'google_compute_disk.root: Creation complete after 13s [id=projects/bruno-coder-v2/zones/europe-west4-b/disks/coder-developer-bruno-dev-123-root]', + }, + { + id: 28, + created_at: '2022-05-19T16:45:47.118Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: 'google_compute_instance.dev[0]: Creating...', + }, + { + id: 29, + created_at: '2022-05-19T16:45:57.122Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: 'google_compute_instance.dev[0]: Still creating... [10s elapsed]', + }, + { + id: 30, + created_at: '2022-05-19T16:46:00.837Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: + 'google_compute_instance.dev[0]: Creation complete after 14s [id=projects/bruno-coder-v2/zones/europe-west4-b/instances/coder-developer-bruno-dev-123]', + }, + { + id: 31, + created_at: '2022-05-19T16:46:00.846Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: 'Apply complete! Resources: 3 added, 0 changed, 0 destroyed.', + }, + { + id: 32, + created_at: '2022-05-19T16:46:00.847Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Starting workspace', + output: 'Outputs: 0', + }, + { + id: 33, + created_at: '2022-05-19T16:46:02.283Z', + log_source: 'provisioner_daemon', + log_level: 'info', + stage: 'Cleaning Up', + output: '', + }, +]; + +export const MockWorkspaceExtendedBuildLogs: TypesGen.ProvisionerJobLog[] = [ + { + id: 938494, + created_at: '2023-08-25T19:07:43.331Z', + log_source: 'provisioner_daemon', + log_level: 'info', + stage: 'Setting up', + output: '', + }, + { + id: 938495, + created_at: '2023-08-25T19:07:43.331Z', + log_source: 'provisioner_daemon', + log_level: 'info', + stage: 'Parsing template parameters', + output: '', + }, + { + id: 938496, + created_at: '2023-08-25T19:07:43.339Z', + log_source: 'provisioner_daemon', + log_level: 'info', + stage: 'Detecting persistent resources', + output: '', + }, + { + id: 938497, + created_at: '2023-08-25T19:07:44.15Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: 'Initializing the backend...', + }, + { + id: 938498, + created_at: '2023-08-25T19:07:44.215Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: 'Initializing provider plugins...', + }, + { + id: 938499, + created_at: '2023-08-25T19:07:44.216Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: '- Finding coder/coder versions matching "~> 0.11.0"...', + }, + { + id: 938500, + created_at: '2023-08-25T19:07:44.668Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: '- Finding kreuzwerker/docker versions matching "~> 3.0.1"...', + }, + { + id: 938501, + created_at: '2023-08-25T19:07:44.722Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: '- Using coder/coder v0.11.1 from the shared cache directory', + }, + { + id: 938502, + created_at: '2023-08-25T19:07:44.857Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: '- Using kreuzwerker/docker v3.0.2 from the shared cache directory', + }, + { + id: 938503, + created_at: '2023-08-25T19:07:45.081Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: + 'Terraform has created a lock file .terraform.lock.hcl to record the provider', + }, + { + id: 938504, + created_at: '2023-08-25T19:07:45.081Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: + 'selections it made above. Include this file in your version control repository', + }, + { + id: 938505, + created_at: '2023-08-25T19:07:45.081Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: + 'so that Terraform can guarantee to make the same selections by default when', + }, + { + id: 938506, + created_at: '2023-08-25T19:07:45.082Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: 'you run "terraform init" in the future.', + }, + { + id: 938507, + created_at: '2023-08-25T19:07:45.083Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: 'Terraform has been successfully initialized!', + }, + { + id: 938508, + created_at: '2023-08-25T19:07:45.084Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: + 'You may now begin working with Terraform. Try running "terraform plan" to see', + }, + { + id: 938509, + created_at: '2023-08-25T19:07:45.084Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: + 'any changes that are required for your infrastructure. All Terraform commands', + }, + { + id: 938510, + created_at: '2023-08-25T19:07:45.084Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: 'should now work.', + }, + { + id: 938511, + created_at: '2023-08-25T19:07:45.084Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: + 'If you ever set or change modules or backend configuration for Terraform,', + }, + { + id: 938512, + created_at: '2023-08-25T19:07:45.084Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: + 'rerun this command to reinitialize your working directory. If you forget, other', + }, + { + id: 938513, + created_at: '2023-08-25T19:07:45.084Z', + log_source: 'provisioner', + log_level: 'debug', + stage: 'Detecting persistent resources', + output: 'commands will detect it and remind you to do so if necessary.', + }, + { + id: 938514, + created_at: '2023-08-25T19:07:45.143Z', + log_source: 'provisioner', + log_level: 'info', + stage: 'Detecting persistent resources', + output: 'Terraform 1.1.9', + }, + { + id: 938515, + created_at: '2023-08-25T19:07:46.297Z', + log_source: 'provisioner', + log_level: 'warn', + stage: 'Detecting persistent resources', + output: 'Warning: Argument is deprecated', + }, + { + id: 938516, + created_at: '2023-08-25T19:07:46.297Z', + log_source: 'provisioner', + log_level: 'warn', + stage: 'Detecting persistent resources', + output: 'on devcontainer-on-docker.tf line 15, in provider "coder":', + }, + { + id: 938517, + created_at: '2023-08-25T19:07:46.297Z', + log_source: 'provisioner', + log_level: 'warn', + stage: 'Detecting persistent resources', + output: ' 15: feature_use_managed_variables = true', + }, + { + id: 938518, + created_at: '2023-08-25T19:07:46.297Z', + log_source: 'provisioner', + log_level: 'warn', + stage: 'Detecting persistent resources', + output: '', + }, + { + id: 938519, + created_at: '2023-08-25T19:07:46.297Z', + log_source: 'provisioner', + log_level: 'warn', + stage: 'Detecting persistent resources', + output: + 'Terraform variables are now exclusively utilized for template-wide variables after the removal of support for legacy parameters.', + }, + { + id: 938520, + created_at: '2023-08-25T19:07:46.3Z', + log_source: 'provisioner', + log_level: 'error', + stage: 'Detecting persistent resources', + output: 'Error: ephemeral parameter requires the default property', + }, + { + id: 938521, + created_at: '2023-08-25T19:07:46.3Z', + log_source: 'provisioner', + log_level: 'error', + stage: 'Detecting persistent resources', + output: + 'on devcontainer-on-docker.tf line 27, in data "coder_parameter" "another_one":', + }, + { + id: 938522, + created_at: '2023-08-25T19:07:46.3Z', + log_source: 'provisioner', + log_level: 'error', + stage: 'Detecting persistent resources', + output: ' 27: data "coder_parameter" "another_one" {', + }, + { + id: 938523, + created_at: '2023-08-25T19:07:46.301Z', + log_source: 'provisioner', + log_level: 'error', + stage: 'Detecting persistent resources', + output: '', + }, + { + id: 938524, + created_at: '2023-08-25T19:07:46.301Z', + log_source: 'provisioner', + log_level: 'error', + stage: 'Detecting persistent resources', + output: '', + }, + { + id: 938525, + created_at: '2023-08-25T19:07:46.303Z', + log_source: 'provisioner', + log_level: 'warn', + stage: 'Detecting persistent resources', + output: 'Warning: Argument is deprecated', + }, + { + id: 938526, + created_at: '2023-08-25T19:07:46.303Z', + log_source: 'provisioner', + log_level: 'warn', + stage: 'Detecting persistent resources', + output: 'on devcontainer-on-docker.tf line 15, in provider "coder":', + }, + { + id: 938527, + created_at: '2023-08-25T19:07:46.303Z', + log_source: 'provisioner', + log_level: 'warn', + stage: 'Detecting persistent resources', + output: ' 15: feature_use_managed_variables = true', + }, + { + id: 938528, + created_at: '2023-08-25T19:07:46.303Z', + log_source: 'provisioner', + log_level: 'warn', + stage: 'Detecting persistent resources', + output: '', + }, + { + id: 938529, + created_at: '2023-08-25T19:07:46.303Z', + log_source: 'provisioner', + log_level: 'warn', + stage: 'Detecting persistent resources', + output: + 'Terraform variables are now exclusively utilized for template-wide variables after the removal of support for legacy parameters.', + }, + { + id: 938530, + created_at: '2023-08-25T19:07:46.311Z', + log_source: 'provisioner_daemon', + log_level: 'info', + stage: 'Cleaning Up', + output: '', + }, +]; + +export const MockCancellationMessage = { + message: 'Job successfully canceled', +}; + +type MockAPIInput = { + message?: string; + detail?: string; + validations?: FieldError[]; +}; + +type MockAPIOutput = { + isAxiosError: true; + response: { + data: { + message: string; + detail: string | undefined; + validations: FieldError[] | undefined; + }; + }; +}; + +export const mockApiError = ({ + message = 'Something went wrong.', + detail, + validations, +}: MockAPIInput): MockAPIOutput => ({ + // This is how axios can check if it is an axios error when calling isAxiosError + isAxiosError: true, + response: { + data: { + message, + detail, + validations, + }, + }, +}); + +export const MockEntitlements: TypesGen.Entitlements = { + errors: [], + warnings: [], + has_license: false, + features: withDefaultFeatures({ + workspace_batch_actions: { + enabled: true, + entitlement: 'entitled', + }, + }), + require_telemetry: false, + trial: false, + refreshed_at: '2022-05-20T16:45:57.122Z', +}; + +export const MockEntitlementsWithWarnings: TypesGen.Entitlements = { + errors: [], + warnings: ['You are over your active user limit.', 'And another thing.'], + has_license: true, + trial: false, + require_telemetry: false, + refreshed_at: '2022-05-20T16:45:57.122Z', + features: withDefaultFeatures({ + user_limit: { + enabled: true, + entitlement: 'grace_period', + limit: 100, + actual: 102, + }, + audit_log: { + enabled: true, + entitlement: 'entitled', + }, + browser_only: { + enabled: true, + entitlement: 'entitled', + }, + }), +}; + +export const MockEntitlementsWithAuditLog: TypesGen.Entitlements = { + errors: [], + warnings: [], + has_license: true, + require_telemetry: false, + trial: false, + refreshed_at: '2022-05-20T16:45:57.122Z', + features: withDefaultFeatures({ + audit_log: { + enabled: true, + entitlement: 'entitled', + }, + }), +}; + +export const MockEntitlementsWithScheduling: TypesGen.Entitlements = { + errors: [], + warnings: [], + has_license: true, + require_telemetry: false, + trial: false, + refreshed_at: '2022-05-20T16:45:57.122Z', + features: withDefaultFeatures({ + advanced_template_scheduling: { + enabled: true, + entitlement: 'entitled', + }, + }), +}; + +export const MockEntitlementsWithUserLimit: TypesGen.Entitlements = { + errors: [], + warnings: [], + has_license: true, + require_telemetry: false, + trial: false, + refreshed_at: '2022-05-20T16:45:57.122Z', + features: withDefaultFeatures({ + user_limit: { + enabled: true, + entitlement: 'entitled', + limit: 25, + }, + }), +}; + +export const MockExperiments: TypesGen.Experiment[] = []; + +export const MockAuditLog: TypesGen.AuditLog = { + id: 'fbd2116a-8961-4954-87ae-e4575bd29ce0', + request_id: '53bded77-7b9d-4e82-8771-991a34d759f9', + time: '2022-05-19T16:45:57.122Z', + organization_id: MockOrganization.id, + ip: '127.0.0.1', + user_agent: + '"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"', + resource_type: 'workspace', + resource_id: 'ef8d1cf4-82de-4fd9-8980-047dad6d06b5', + resource_target: 'bruno-dev', + resource_icon: '', + action: 'create', + diff: { + ttl: { + old: 0, + new: 3600000000000, + secret: false, + }, + }, + status_code: 200, + additional_fields: {}, + description: '{user} created workspace {target}', + user: MockUser, + resource_link: '/@admin/bruno-dev', + is_deleted: false, +}; + +export const MockAuditLog2: TypesGen.AuditLog = { + ...MockAuditLog, + id: '53bded77-7b9d-4e82-8771-991a34d759f9', + action: 'write', + time: '2022-05-20T16:45:57.122Z', + description: '{user} updated workspace {target}', + diff: { + workspace_name: { + old: 'old-workspace-name', + new: MockWorkspace.name, + secret: false, + }, + workspace_auto_off: { + old: true, + new: false, + secret: false, + }, + template_version_id: { + old: 'fbd2116a-8961-4954-87ae-e4575bd29ce0', + new: '53bded77-7b9d-4e82-8771-991a34d759f9', + secret: false, + }, + roles: { + old: null, + new: ['admin', 'auditor'], + secret: false, + }, + }, +}; + +export const MockWorkspaceCreateAuditLogForDifferentOwner = { + ...MockAuditLog, + additional_fields: { + workspace_owner: 'Member', + }, +}; + +export const MockAuditLogWithWorkspaceBuild: TypesGen.AuditLog = { + ...MockAuditLog, + id: 'f90995bf-4a2b-4089-b597-e66e025e523e', + request_id: '61555889-2875-475c-8494-f7693dd5d75b', + action: 'stop', + resource_type: 'workspace_build', + description: '{user} stopped build for workspace {target}', + additional_fields: { + workspace_name: 'test2', + }, +}; + +export const MockAuditLogWithDeletedResource: TypesGen.AuditLog = { + ...MockAuditLog, + is_deleted: true, +}; + +export const MockAuditLogGitSSH: TypesGen.AuditLog = { + ...MockAuditLog, + diff: { + private_key: { + old: '', + new: '', + secret: true, + }, + public_key: { + old: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINRUPjBSNtOAnL22+r07OSu9t3Lnm8/5OX8bRHECKS9g\n', + new: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEwoUPJPMekuSzMZyV0rA82TGGNzw/Uj/dhLbwiczTpV\n', + secret: false, + }, + }, +}; + +export const MockAuditOauthConvert: TypesGen.AuditLog = { + ...MockAuditLog, + resource_type: 'convert_login', + resource_target: 'oidc', + action: 'create', + status_code: 201, + description: '{user} created login type conversion to {target}}', + diff: { + created_at: { + old: '0001-01-01T00:00:00Z', + new: '2023-06-20T20:44:54.243019Z', + secret: false, + }, + expires_at: { + old: '0001-01-01T00:00:00Z', + new: '2023-06-20T20:49:54.243019Z', + secret: false, + }, + state_string: { + old: '', + new: '', + secret: true, + }, + to_type: { + old: '', + new: 'oidc', + secret: false, + }, + user_id: { + old: '', + new: 'dc790496-eaec-4f88-a53f-8ce1f61a1fff', + secret: false, + }, + }, +}; + +export const MockAuditLogSuccessfulLogin: TypesGen.AuditLog = { + ...MockAuditLog, + resource_type: 'api_key', + resource_target: '', + action: 'login', + status_code: 201, + description: '{user} logged in', +}; + +export const MockAuditLogUnsuccessfulLoginKnownUser: TypesGen.AuditLog = { + ...MockAuditLogSuccessfulLogin, + status_code: 401, +}; + +export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = { + credits_consumed: 0, + budget: 100, +}; + +export const MockGroup: TypesGen.Group = { + id: 'fbd2116a-8961-4954-87ae-e4575bd29ce0', + name: 'Front-End', + display_name: 'Front-End', + avatar_url: 'https://example.com', + organization_id: MockOrganization.id, + members: [MockUser, MockUser2], + quota_allowance: 5, + source: 'user', +}; + +const everyOneGroup = (organizationId: string): TypesGen.Group => ({ + id: organizationId, + name: 'Everyone', + display_name: '', + organization_id: organizationId, + members: [], + avatar_url: '', + quota_allowance: 0, + source: 'user', +}); + +export const MockTemplateACL: TypesGen.TemplateACL = { + group: [ + { ...everyOneGroup(MockOrganization.id), role: 'use' }, + { ...MockGroup, role: 'admin' }, + ], + users: [{ ...MockUser, role: 'use' }], +}; + +export const MockTemplateACLEmpty: TypesGen.TemplateACL = { + group: [], + users: [], +}; + +export const MockTemplateExample: TypesGen.TemplateExample = { + id: 'aws-windows', + url: 'https://github.com/coder/coder/tree/main/examples/templates/aws-windows', + name: 'Develop in an ECS-hosted container', + description: 'Get started with Linux development on AWS ECS.', + markdown: + '\n# aws-ecs\n\nThis is a sample template for running a Coder workspace on ECS. It assumes there\nis a pre-existing ECS cluster with EC2-based compute to host the workspace.\n\n## Architecture\n\nThis workspace is built using the following AWS resources:\n\n- Task definition - the container definition, includes the image, command, volume(s)\n- ECS service - manages the task definition\n\n## code-server\n\n`code-server` is installed via the `startup_script` argument in the `coder_agent`\nresource block. The `coder_app` resource is defined to access `code-server` through\nthe dashboard UI over `localhost:13337`.\n', + icon: '/icon/aws.svg', + tags: ['aws', 'cloud'], +}; + +export const MockTemplateExample2: TypesGen.TemplateExample = { + id: 'aws-linux', + url: 'https://github.com/coder/coder/tree/main/examples/templates/aws-linux', + name: 'Develop in Linux on AWS EC2', + description: 'Get started with Linux development on AWS EC2.', + markdown: + '\n# aws-linux\n\nTo get started, run `coder templates init`. When prompted, select this template.\nFollow the on-screen instructions to proceed.\n\n## Authentication\n\nThis template assumes that coderd is run in an environment that is authenticated\nwith AWS. For example, run `aws configure import` to import credentials on the\nsystem and user running coderd. For other ways to authenticate [consult the\nTerraform docs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#authentication-and-configuration).\n\n## Required permissions / policy\n\nThe following sample policy allows Coder to create EC2 instances and modify\ninstances provisioned by Coder:\n\n```json\n{\n "Version": "2012-10-17",\n "Statement": [\n {\n "Sid": "VisualEditor0",\n "Effect": "Allow",\n "Action": [\n "ec2:GetDefaultCreditSpecification",\n "ec2:DescribeIamInstanceProfileAssociations",\n "ec2:DescribeTags",\n "ec2:CreateTags",\n "ec2:RunInstances",\n "ec2:DescribeInstanceCreditSpecifications",\n "ec2:DescribeImages",\n "ec2:ModifyDefaultCreditSpecification",\n "ec2:DescribeVolumes"\n ],\n "Resource": "*"\n },\n {\n "Sid": "CoderResources",\n "Effect": "Allow",\n "Action": [\n "ec2:DescribeInstances",\n "ec2:DescribeInstanceAttribute",\n "ec2:UnmonitorInstances",\n "ec2:TerminateInstances",\n "ec2:StartInstances",\n "ec2:StopInstances",\n "ec2:DeleteTags",\n "ec2:MonitorInstances",\n "ec2:CreateTags",\n "ec2:RunInstances",\n "ec2:ModifyInstanceAttribute",\n "ec2:ModifyInstanceCreditSpecification"\n ],\n "Resource": "arn:aws:ec2:*:*:instance/*",\n "Condition": {\n "StringEquals": {\n "aws:ResourceTag/Coder_Provisioned": "true"\n }\n }\n }\n ]\n}\n```\n\n## code-server\n\n`code-server` is installed via the `startup_script` argument in the `coder_agent`\nresource block. The `coder_app` resource is defined to access `code-server` through\nthe dashboard UI over `localhost:13337`.\n', + icon: '/icon/aws.svg', + tags: ['aws', 'cloud'], +}; + +export const MockPermissions: Permissions = { + createGroup: true, + createTemplates: true, + createUser: true, + deleteTemplates: true, + updateTemplates: true, + readAllUsers: true, + updateUsers: true, + viewAuditLog: true, + viewDeploymentValues: true, + viewUpdateCheck: true, + viewDeploymentStats: true, + viewExternalAuthConfig: true, + editWorkspaceProxies: true, +}; + +export const MockDeploymentConfig: DeploymentConfig = { + config: { + enable_terraform_debug_mode: true, + }, + options: [], +}; + +export const MockAppearanceConfig: TypesGen.AppearanceConfig = { + application_name: '', + logo_url: '', + service_banner: { + enabled: false, + }, + notification_banners: [], +}; + +export const MockWorkspaceBuildParameter1: TypesGen.WorkspaceBuildParameter = { + name: MockTemplateVersionParameter1.name, + value: 'mock-abc', +}; + +export const MockWorkspaceBuildParameter2: TypesGen.WorkspaceBuildParameter = { + name: MockTemplateVersionParameter2.name, + value: '3', +}; + +export const MockWorkspaceBuildParameter3: TypesGen.WorkspaceBuildParameter = { + name: MockTemplateVersionParameter3.name, + value: 'my-database', +}; + +export const MockWorkspaceBuildParameter4: TypesGen.WorkspaceBuildParameter = { + name: MockTemplateVersionParameter4.name, + value: 'immutable-value', +}; + +export const MockWorkspaceBuildParameter5: TypesGen.WorkspaceBuildParameter = { + name: MockTemplateVersionParameter5.name, + value: '5', +}; + +export const MockTemplateVersionExternalAuthGithub: TypesGen.TemplateVersionExternalAuth = + { + id: 'github', + type: 'github', + authenticate_url: 'https://example.com/external-auth/github', + authenticated: false, + display_icon: '/icon/github.svg', + display_name: 'GitHub', + }; + +export const MockTemplateVersionExternalAuthGithubAuthenticated: TypesGen.TemplateVersionExternalAuth = + { + id: 'github', + type: 'github', + authenticate_url: 'https://example.com/external-auth/github', + authenticated: true, + display_icon: '/icon/github.svg', + display_name: 'GitHub', + }; + +export const MockDeploymentStats: TypesGen.DeploymentStats = { + aggregated_from: '2023-03-06T19:08:55.211625Z', + collected_at: '2023-03-06T19:12:55.211625Z', + next_update_at: '2023-03-06T19:20:55.211625Z', + session_count: { + vscode: 128, + jetbrains: 5, + ssh: 32, + reconnecting_pty: 15, + }, + workspaces: { + building: 15, + failed: 12, + pending: 5, + running: 32, + stopped: 16, + connection_latency_ms: { + P50: 32.56, + P95: 15.23, + }, + rx_bytes: 15613513253, + tx_bytes: 36113513253, + }, +}; + +export const MockDeploymentSSH: TypesGen.SSHConfigResponse = { + hostname_prefix: ' coder.', + ssh_config_options: {}, +}; + +export const MockWorkspaceAgentLogs: TypesGen.WorkspaceAgentLog[] = [ + { + id: 166663, + created_at: '2023-05-04T11:30:41.402072Z', + output: '+ curl -fsSL https://code-server.dev/install.sh', + level: 'info', + source_id: MockWorkspaceAgentLogSource.id, + }, + { + id: 166664, + created_at: '2023-05-04T11:30:41.40228Z', + output: + '+ sh -s -- --method=standalone --prefix=/tmp/code-server --version 4.8.3', + level: 'info', + source_id: MockWorkspaceAgentLogSource.id, + }, + { + id: 166665, + created_at: '2023-05-04T11:30:42.590731Z', + output: 'Ubuntu 22.04.2 LTS', + level: 'info', + source_id: MockWorkspaceAgentLogSource.id, + }, + { + id: 166666, + created_at: '2023-05-04T11:30:42.593686Z', + output: 'Installing v4.8.3 of the amd64 release from GitHub.', + level: 'info', + source_id: MockWorkspaceAgentLogSource.id, + }, +]; + +export const MockLicenseResponse: GetLicensesResponse[] = [ + { + id: 1, + uploaded_at: '1660104000', + expires_at: '3420244800', // expires on 5/20/2078 + uuid: '1', + claims: { + trial: false, + all_features: true, + version: 1, + features: {}, + license_expires: 3420244800, + }, + }, + { + id: 1, + uploaded_at: '1660104000', + expires_at: '1660104000', // expired on 8/10/2022 + uuid: '1', + claims: { + trial: false, + all_features: true, + version: 1, + features: {}, + license_expires: 1660104000, + }, + }, + { + id: 1, + uploaded_at: '1682346425', + expires_at: '1682346425', // expired on 4/24/2023 + uuid: '1', + claims: { + trial: false, + all_features: true, + version: 1, + features: {}, + license_expires: 1682346425, + }, + }, +]; + +export const MockHealth: TypesGen.HealthcheckReport = { + time: '2023-08-01T16:51:03.29792825Z', + healthy: true, + severity: 'ok', + failing_sections: [], + derp: { + healthy: true, + severity: 'ok', + warnings: [], + dismissed: false, + regions: { + '999': { + healthy: true, + severity: 'ok', + warnings: [], + region: { + EmbeddedRelay: true, + RegionID: 999, + RegionCode: 'coder', + RegionName: 'Council Bluffs, Iowa', + Nodes: [ + { + Name: '999stun0', + RegionID: 999, + HostName: 'stun.l.google.com', + STUNPort: 19302, + STUNOnly: true, + }, + { + Name: '999b', + RegionID: 999, + HostName: 'dev.coder.com', + STUNPort: -1, + DERPPort: 443, + }, + ], + }, + node_reports: [ + { + healthy: true, + severity: 'ok', + warnings: [], + node: { + Name: '999stun0', + RegionID: 999, + HostName: 'stun.l.google.com', + STUNPort: 19302, + STUNOnly: true, + }, + node_info: { + TokenBucketBytesPerSecond: 0, + TokenBucketBytesBurst: 0, + }, + can_exchange_messages: false, + round_trip_ping: '0', + round_trip_ping_ms: 0, + uses_websocket: false, + client_logs: [], + client_errs: [], + stun: { + Enabled: true, + CanSTUN: true, + }, + }, + { + healthy: true, + severity: 'ok', + warnings: [], + node: { + Name: '999b', + RegionID: 999, + HostName: 'dev.coder.com', + STUNPort: -1, + DERPPort: 443, + }, + node_info: { + TokenBucketBytesPerSecond: 0, + TokenBucketBytesBurst: 0, + }, + can_exchange_messages: true, + round_trip_ping: '7674330', + round_trip_ping_ms: 7674330, + uses_websocket: false, + client_logs: [ + [ + 'derphttp.Client.Connect: connecting to https://dev.coder.com/derp', + ], + [ + 'derphttp.Client.Connect: connecting to https://dev.coder.com/derp', + ], + ], + client_errs: [ + ['recv derp message: derphttp.Client closed'], + [ + 'connect to derp: derphttp.Client.Connect connect to : context deadline exceeded: read tcp 10.44.1.150:59546->149.248.214.149:443: use of closed network connection', + 'connect to derp: derphttp.Client closed', + 'connect to derp: derphttp.Client closed', + 'connect to derp: derphttp.Client closed', + 'connect to derp: derphttp.Client closed', + "couldn't connect after 5 tries, last error: couldn't connect after 5 tries, last error: derphttp.Client closed", + ], + ], + stun: { + Enabled: false, + CanSTUN: false, + }, + }, + ], + }, + '10007': { + healthy: true, + severity: 'ok', + warnings: [], + region: { + EmbeddedRelay: false, + RegionID: 10007, + RegionCode: 'coder_sydney', + RegionName: 'sydney', + Nodes: [ + { + Name: '10007stun0', + RegionID: 10007, + HostName: 'stun.l.google.com', + STUNPort: 19302, + STUNOnly: true, + }, + { + Name: '10007a', + RegionID: 10007, + HostName: 'sydney.dev.coder.com', + STUNPort: -1, + DERPPort: 443, + }, + ], + }, + node_reports: [ + { + healthy: true, + severity: 'ok', + warnings: [], + node: { + Name: '10007stun0', + RegionID: 10007, + HostName: 'stun.l.google.com', + STUNPort: 19302, + STUNOnly: true, + }, + node_info: { + TokenBucketBytesPerSecond: 0, + TokenBucketBytesBurst: 0, + }, + can_exchange_messages: false, + round_trip_ping: '0', + round_trip_ping_ms: 0, + uses_websocket: false, + client_logs: [], + client_errs: [], + stun: { + Enabled: true, + CanSTUN: true, + }, + }, + { + healthy: true, + severity: 'ok', + warnings: [], + node: { + Name: '10007a', + RegionID: 10007, + HostName: 'sydney.dev.coder.com', + STUNPort: -1, + DERPPort: 443, + }, + node_info: { + TokenBucketBytesPerSecond: 0, + TokenBucketBytesBurst: 0, + }, + can_exchange_messages: true, + round_trip_ping: '170527034', + round_trip_ping_ms: 170527034, + uses_websocket: false, + client_logs: [ + [ + 'derphttp.Client.Connect: connecting to https://sydney.dev.coder.com/derp', + ], + [ + 'derphttp.Client.Connect: connecting to https://sydney.dev.coder.com/derp', + ], + ], + client_errs: [[], []], + stun: { + Enabled: false, + CanSTUN: false, + }, + }, + ], + }, + '10008': { + healthy: true, + severity: 'ok', + warnings: [], + region: { + EmbeddedRelay: false, + RegionID: 10008, + RegionCode: 'coder_europe-frankfurt', + RegionName: 'europe-frankfurt', + Nodes: [ + { + Name: '10008stun0', + RegionID: 10008, + HostName: 'stun.l.google.com', + STUNPort: 19302, + STUNOnly: true, + }, + { + Name: '10008a', + RegionID: 10008, + HostName: 'europe.dev.coder.com', + STUNPort: -1, + DERPPort: 443, + }, + ], + }, + node_reports: [ + { + healthy: true, + severity: 'ok', + warnings: [], + node: { + Name: '10008stun0', + RegionID: 10008, + HostName: 'stun.l.google.com', + STUNPort: 19302, + STUNOnly: true, + }, + node_info: { + TokenBucketBytesPerSecond: 0, + TokenBucketBytesBurst: 0, + }, + can_exchange_messages: false, + round_trip_ping: '0', + round_trip_ping_ms: 0, + uses_websocket: false, + client_logs: [], + client_errs: [], + stun: { + Enabled: true, + CanSTUN: true, + }, + }, + { + healthy: true, + severity: 'ok', + warnings: [], + node: { + Name: '10008a', + RegionID: 10008, + HostName: 'europe.dev.coder.com', + STUNPort: -1, + DERPPort: 443, + }, + node_info: { + TokenBucketBytesPerSecond: 0, + TokenBucketBytesBurst: 0, + }, + can_exchange_messages: true, + round_trip_ping: '111329690', + round_trip_ping_ms: 111329690, + uses_websocket: false, + client_logs: [ + [ + 'derphttp.Client.Connect: connecting to https://europe.dev.coder.com/derp', + ], + [ + 'derphttp.Client.Connect: connecting to https://europe.dev.coder.com/derp', + ], + ], + client_errs: [[], []], + stun: { + Enabled: false, + CanSTUN: false, + }, + }, + ], + }, + '10009': { + healthy: true, + severity: 'ok', + warnings: [], + region: { + EmbeddedRelay: false, + RegionID: 10009, + RegionCode: 'coder_brazil-saopaulo', + RegionName: 'brazil-saopaulo', + Nodes: [ + { + Name: '10009stun0', + RegionID: 10009, + HostName: 'stun.l.google.com', + STUNPort: 19302, + STUNOnly: true, + }, + { + Name: '10009a', + RegionID: 10009, + HostName: 'brazil.dev.coder.com', + STUNPort: -1, + DERPPort: 443, + }, + ], + }, + node_reports: [ + { + healthy: true, + severity: 'ok', + warnings: [], + node: { + Name: '10009stun0', + RegionID: 10009, + HostName: 'stun.l.google.com', + STUNPort: 19302, + STUNOnly: true, + }, + node_info: { + TokenBucketBytesPerSecond: 0, + TokenBucketBytesBurst: 0, + }, + can_exchange_messages: false, + round_trip_ping: '0', + round_trip_ping_ms: 0, + uses_websocket: false, + client_logs: [], + client_errs: [], + stun: { + Enabled: true, + CanSTUN: true, + }, + }, + { + healthy: true, + severity: 'ok', + warnings: [], + node: { + Name: '10009a', + RegionID: 10009, + HostName: 'brazil.dev.coder.com', + STUNPort: -1, + DERPPort: 443, + }, + node_info: { + TokenBucketBytesPerSecond: 0, + TokenBucketBytesBurst: 0, + }, + can_exchange_messages: true, + round_trip_ping: '138185506', + round_trip_ping_ms: 138185506, + uses_websocket: false, + client_logs: [ + [ + 'derphttp.Client.Connect: connecting to https://brazil.dev.coder.com/derp', + ], + [ + 'derphttp.Client.Connect: connecting to https://brazil.dev.coder.com/derp', + ], + ], + client_errs: [[], []], + stun: { + Enabled: false, + CanSTUN: false, + }, + }, + ], + }, + }, + netcheck: { + UDP: true, + IPv6: false, + IPv4: true, + IPv6CanSend: false, + IPv4CanSend: true, + OSHasIPv6: true, + ICMPv4: false, + MappingVariesByDestIP: false, + HairPinning: null, + UPnP: false, + PMP: false, + PCP: false, + PreferredDERP: 999, + RegionLatency: { + '999': 1638180, + '10007': 174853022, + '10008': 112142029, + '10009': 138855606, + }, + RegionV4Latency: { + '999': 1638180, + '10007': 174853022, + '10008': 112142029, + '10009': 138855606, + }, + RegionV6Latency: {}, + GlobalV4: '34.71.26.24:55368', + GlobalV6: '', + CaptivePortal: null, + }, + netcheck_logs: [ + 'netcheck: netcheck.runProbe: got STUN response for 10007stun0 from 34.71.26.24:55368 (9b07930007da49dd7df79bc7) in 1.791799ms', + 'netcheck: netcheck.runProbe: got STUN response for 999stun0 from 34.71.26.24:55368 (7397fec097f1d5b01364566b) in 1.791529ms', + 'netcheck: netcheck.runProbe: got STUN response for 10008stun0 from 34.71.26.24:55368 (1fdaaa016ca386485f097f68) in 2.192899ms', + 'netcheck: netcheck.runProbe: got STUN response for 10009stun0 from 34.71.26.24:55368 (2596fe60895fbd9542823a76) in 2.146459ms', + 'netcheck: netcheck.runProbe: got STUN response for 10007stun0 from 34.71.26.24:55368 (19ec320f3b76e8b027b06d3e) in 2.139619ms', + 'netcheck: netcheck.runProbe: got STUN response for 999stun0 from 34.71.26.24:55368 (a17973bc57c35e606c0f46f5) in 2.131089ms', + 'netcheck: netcheck.runProbe: got STUN response for 10008stun0 from 34.71.26.24:55368 (c958e15209d139a6e410f13a) in 2.127549ms', + 'netcheck: netcheck.runProbe: got STUN response for 10009stun0 from 34.71.26.24:55368 (284a1b64dff22f40a3514524) in 2.107549ms', + 'netcheck: [v1] measureAllICMPLatency: listen ip4:icmp 0.0.0.0: socket: operation not permitted', + 'netcheck: [v1] report: udp=true v6=false v6os=true mapvarydest=false hair= portmap= v4a=34.71.26.24:55368 derp=999 derpdist=999v4:2ms,10007v4:175ms,10008v4:112ms,10009v4:139ms', + ], + }, + access_url: { + healthy: true, + severity: 'ok', + warnings: [], + dismissed: false, + access_url: 'https://dev.coder.com', + reachable: true, + status_code: 200, + healthz_response: 'OK', + }, + websocket: { + healthy: true, + severity: 'ok', + warnings: [], + dismissed: false, + body: '', + code: 101, + }, + database: { + healthy: true, + severity: 'ok', + warnings: [], + dismissed: false, + reachable: true, + latency: '92570', + latency_ms: 92570, + threshold_ms: 92570, + }, + workspace_proxy: { + healthy: true, + severity: 'warning', + warnings: [ + { + code: 'EWP04', + message: + 'unhealthy: request to proxy failed: Get "http://127.0.0.1:3001/healthz-report": dial tcp 127.0.0.1:3001: connect: connection refused', + }, + ], + dismissed: false, + error: undefined, + workspace_proxies: { + regions: [ + { + id: '1a3e5eb8-d785-4f7d-9188-2eeab140cd06', + name: 'primary', + display_name: 'Council Bluffs, Iowa', + icon_url: '/emojis/1f3e1.png', + healthy: true, + path_app_url: 'https://dev.coder.com', + wildcard_hostname: '*--apps.dev.coder.com', + derp_enabled: false, + derp_only: false, + status: { + status: 'ok', + report: { + errors: [], + warnings: [], + }, + checked_at: '2023-12-05T14:14:05.829032482Z', + }, + created_at: '0001-01-01T00:00:00Z', + updated_at: '0001-01-01T00:00:00Z', + deleted: false, + version: '', + }, + { + id: '2876ab4d-bcee-4643-944f-d86323642840', + name: 'sydney', + display_name: 'Sydney GCP', + icon_url: '/emojis/1f1e6-1f1fa.png', + healthy: true, + path_app_url: 'https://sydney.dev.coder.com', + wildcard_hostname: '*--apps.sydney.dev.coder.com', + derp_enabled: true, + derp_only: false, + status: { + status: 'ok', + report: { + errors: [], + warnings: [], + }, + checked_at: '2023-12-05T14:14:05.250322277Z', + }, + created_at: '2023-05-01T19:15:56.606593Z', + updated_at: '2023-12-05T14:13:36.647535Z', + deleted: false, + version: 'v2.5.0-devel+5fad61102', + }, + { + id: '9d786ce0-55b1-4ace-8acc-a4672ff8d41f', + name: 'europe-frankfurt', + display_name: 'Europe GCP (Frankfurt)', + icon_url: '/emojis/1f1e9-1f1ea.png', + healthy: true, + path_app_url: 'https://europe.dev.coder.com', + wildcard_hostname: '*--apps.europe.dev.coder.com', + derp_enabled: true, + derp_only: false, + status: { + status: 'ok', + report: { + errors: [], + warnings: [], + }, + checked_at: '2023-12-05T14:14:05.250322277Z', + }, + created_at: '2023-05-01T20:34:11.114005Z', + updated_at: '2023-12-05T14:13:45.941716Z', + deleted: false, + version: 'v2.5.0-devel+5fad61102', + }, + { + id: '2e209786-73b1-4838-ba78-e01c9334450a', + name: 'brazil-saopaulo', + display_name: 'Brazil GCP (Sao Paulo)', + icon_url: '/emojis/1f1e7-1f1f7.png', + healthy: true, + path_app_url: 'https://brazil.dev.coder.com', + wildcard_hostname: '*--apps.brazil.dev.coder.com', + derp_enabled: true, + derp_only: false, + status: { + status: 'ok', + report: { + errors: [], + warnings: [], + }, + checked_at: '2023-12-05T14:14:05.250322277Z', + }, + created_at: '2023-05-01T20:41:02.76448Z', + updated_at: '2023-12-05T14:13:41.968568Z', + deleted: false, + version: 'v2.5.0-devel+5fad61102', + }, + { + id: 'c272e80c-0cce-49d6-9782-1b5cf90398e8', + name: 'unregistered', + display_name: 'UnregisteredProxy', + icon_url: '/emojis/274c.png', + healthy: false, + path_app_url: '', + wildcard_hostname: '', + derp_enabled: true, + derp_only: false, + status: { + status: 'unregistered', + report: { + errors: [], + warnings: [], + }, + checked_at: '2023-12-05T14:14:05.250322277Z', + }, + created_at: '2023-07-10T14:51:11.539222Z', + updated_at: '2023-07-10T14:51:11.539223Z', + deleted: false, + version: '', + }, + { + id: 'a3efbff1-587b-4677-80a4-dc4f892fed3e', + name: 'unhealthy', + display_name: 'Unhealthy', + icon_url: '/emojis/1f92e.png', + healthy: false, + path_app_url: 'http://127.0.0.1:3001', + wildcard_hostname: '', + derp_enabled: true, + derp_only: false, + status: { + status: 'unreachable', + report: { + errors: [ + 'request to proxy failed: Get "http://127.0.0.1:3001/healthz-report": dial tcp 127.0.0.1:3001: connect: connection refused', + ], + warnings: [], + }, + checked_at: '2023-12-05T14:14:05.250322277Z', + }, + created_at: '2023-07-10T14:51:48.407017Z', + updated_at: '2023-07-10T14:51:57.993682Z', + deleted: false, + version: '', + }, + { + id: 'b6cefb69-cb6f-46e2-9c9c-39c089fb7e42', + name: 'paris-coder', + display_name: 'Europe (Paris)', + icon_url: '/emojis/1f1eb-1f1f7.png', + healthy: true, + path_app_url: 'https://paris-coder.fly.dev', + wildcard_hostname: '', + derp_enabled: true, + derp_only: false, + status: { + status: 'ok', + report: { + errors: [], + warnings: [], + }, + checked_at: '2023-12-05T14:14:05.250322277Z', + }, + created_at: '2023-12-01T09:21:15.996267Z', + updated_at: '2023-12-05T14:13:59.663174Z', + deleted: false, + version: 'v2.5.0-devel+5fad61102', + }, + { + id: '72649dc9-03c7-46a8-bc95-96775e93ddc1', + name: 'sydney-coder', + display_name: 'Australia (Sydney)', + icon_url: '/emojis/1f1e6-1f1fa.png', + healthy: true, + path_app_url: 'https://sydney-coder.fly.dev', + wildcard_hostname: '', + derp_enabled: true, + derp_only: false, + status: { + status: 'ok', + report: { + errors: [], + warnings: [], + }, + checked_at: '2023-12-05T14:14:05.250322277Z', + }, + created_at: '2023-12-01T09:23:44.505529Z', + updated_at: '2023-12-05T14:13:55.769058Z', + deleted: false, + version: 'v2.5.0-devel+5fad61102', + }, + { + id: '1f78398f-e5ae-4c38-aa89-30222181d443', + name: 'sao-paulo-coder', + display_name: 'Brazil (Sau Paulo)', + icon_url: '/emojis/1f1e7-1f1f7.png', + healthy: true, + path_app_url: 'https://sao-paulo-coder.fly.dev', + wildcard_hostname: '', + derp_enabled: true, + derp_only: false, + status: { + status: 'ok', + report: { + errors: [], + warnings: [], + }, + checked_at: '2023-12-05T14:14:05.250322277Z', + }, + created_at: '2023-12-01T09:36:00.231252Z', + updated_at: '2023-12-05T14:13:47.015031Z', + deleted: false, + version: 'v2.5.0-devel+5fad61102', + }, + ], + }, + }, + provisioner_daemons: { + severity: 'ok', + warnings: [ + { + message: 'Something is wrong!', + code: 'EUNKNOWN', + }, + { + message: 'This is also bad.', + code: 'EPD01', + }, + ], + dismissed: false, + items: [ + { + provisioner_daemon: { + id: 'e455b582-ac04-4323-9ad6-ab71301fa006', + created_at: '2024-01-04T15:53:03.21563Z', + last_seen_at: '2024-01-04T16:05:03.967551Z', + name: 'ok', + version: 'v2.3.4-devel+abcd1234', + api_version: '1.0', + provisioners: ['echo', 'terraform'], + tags: { + owner: '', + scope: 'organization', + tag_value: 'value', + tag_true: 'true', + tag_1: '1', + tag_yes: 'yes', + }, + }, + warnings: [], + }, + { + provisioner_daemon: { + id: '00000000-0000-0000-000000000000', + created_at: '2024-01-04T15:53:03.21563Z', + last_seen_at: '2024-01-04T16:05:03.967551Z', + name: 'user-scoped', + version: 'v2.34-devel+abcd1234', + api_version: '1.0', + provisioners: ['echo', 'terraform'], + tags: { + owner: '12345678-1234-1234-1234-12345678abcd', + scope: 'user', + tag_VALUE: 'VALUE', + tag_TRUE: 'TRUE', + tag_1: '1', + tag_YES: 'YES', + }, + }, + warnings: [], + }, + { + provisioner_daemon: { + id: 'e455b582-ac04-4323-9ad6-ab71301fa006', + created_at: '2024-01-04T15:53:03.21563Z', + last_seen_at: '2024-01-04T16:05:03.967551Z', + name: 'unhappy', + version: 'v0.0.1', + api_version: '0.1', + provisioners: ['echo', 'terraform'], + tags: { + owner: '', + scope: 'organization', + tag_string: 'value', + tag_false: 'false', + tag_0: '0', + tag_no: 'no', + }, + }, + warnings: [ + { + message: 'Something specific is wrong with this daemon.', + code: 'EUNKNOWN', + }, + { + message: 'And now for something completely different.', + code: 'EUNKNOWN', + }, + ], + }, + ], + }, + coder_version: 'v2.5.0-devel+5fad61102', +}; + +export const MockListeningPortsResponse: TypesGen.WorkspaceAgentListeningPortsResponse = + { + ports: [ + { process_name: 'webb', network: '', port: 30000 }, + { process_name: 'gogo', network: '', port: 8080 }, + { process_name: '', network: '', port: 8081 }, + ], + }; + +export const MockSharedPortsResponse: TypesGen.WorkspaceAgentPortShares = { + shares: [ + { + workspace_id: MockWorkspace.id, + agent_name: 'a-workspace-agent', + port: 4000, + share_level: 'authenticated', + protocol: 'http', + }, + { + workspace_id: MockWorkspace.id, + agent_name: 'a-workspace-agent', + port: 65535, + share_level: 'authenticated', + protocol: 'https', + }, + { + workspace_id: MockWorkspace.id, + agent_name: 'a-workspace-agent', + port: 8081, + share_level: 'public', + protocol: 'http', + }, + ], +}; + +export const DeploymentHealthUnhealthy: TypesGen.HealthcheckReport = { + healthy: false, + severity: 'ok', + failing_sections: [], // apparently this property is not used at all? + time: '2023-10-12T23:15:00.000000000Z', + coder_version: 'v2.3.0-devel+8cca4915a', + access_url: { + healthy: true, + severity: 'ok', + warnings: [], + dismissed: false, + access_url: '', + healthz_response: '', + reachable: true, + status_code: 0, + }, + database: { + healthy: false, + severity: 'ok', + warnings: [], + dismissed: false, + latency: '', + latency_ms: 0, + reachable: true, + threshold_ms: 92570, + }, + derp: { + healthy: false, + severity: 'ok', + warnings: [], + dismissed: false, + regions: [], + netcheck_logs: [], + }, + websocket: { + healthy: false, + severity: 'ok', + warnings: [], + dismissed: false, + body: '', + code: 0, + }, + workspace_proxy: { + healthy: false, + error: 'some error', + severity: 'error', + warnings: [], + dismissed: false, + workspace_proxies: { + regions: [ + { + id: 'df7e4b2b-2d40-47e5-a021-e5d08b219c77', + name: 'unhealthy', + display_name: 'unhealthy', + icon_url: '/emojis/1f5fa.png', + healthy: false, + path_app_url: 'http://127.0.0.1:3001', + wildcard_hostname: '', + derp_enabled: true, + derp_only: false, + status: { + status: 'unreachable', + report: { + errors: ['some error'], + warnings: [], + }, + checked_at: '2023-11-24T12:14:05.743303497Z', + }, + created_at: '2023-11-23T15:37:25.513213Z', + updated_at: '2023-11-23T18:09:19.734747Z', + deleted: false, + version: 'v2.5.0-devel+89bae7eff', + }, + ], + }, + }, + provisioner_daemons: { + severity: 'error', + error: 'something went wrong', + warnings: [ + { + message: 'this is a message', + code: 'EUNKNOWN', + }, + ], + dismissed: false, + items: [ + { + provisioner_daemon: { + id: 'e455b582-ac04-4323-9ad6-ab71301fa006', + created_at: '2024-01-04T15:53:03.21563Z', + last_seen_at: '2024-01-04T16:05:03.967551Z', + name: 'vvuurrkk-2', + version: 'v2.6.0-devel+965ad5e96', + api_version: '1.0', + provisioners: ['echo', 'terraform'], + tags: { + owner: '', + scope: 'organization', + }, + }, + warnings: [ + { + message: 'this is a specific message for this thing', + code: 'EUNKNOWN', + }, + ], + }, + ], + }, +}; + +export const MockHealthSettings: TypesGen.HealthSettings = { + dismissed_healthchecks: [], +}; + +export const MockGithubExternalProvider: TypesGen.ExternalAuthLinkProvider = { + id: 'github', + type: 'github', + device: false, + display_icon: '/icon/github.svg', + display_name: 'GitHub', + allow_refresh: true, + allow_validate: true, +}; + +export const MockGithubAuthLink: TypesGen.ExternalAuthLink = { + provider_id: 'github', + created_at: '', + updated_at: '', + has_refresh_token: true, + expires: '', + authenticated: true, + validate_error: '', +}; + +export const MockOAuth2ProviderApps: TypesGen.OAuth2ProviderApp[] = [ + { + id: '1', + name: 'foo', + callback_url: 'http://localhost:3001', + icon: '/icon/github.svg', + endpoints: { + authorization: 'http://localhost:3001/oauth2/authorize', + token: 'http://localhost:3001/oauth2/token', + device_authorization: '', + }, + }, +]; + +export const MockOAuth2ProviderAppSecrets: TypesGen.OAuth2ProviderAppSecret[] = + [ + { + id: '1', + client_secret_truncated: 'foo', + }, + { + id: '1', + last_used_at: '2022-12-16T20:10:45.637452Z', + client_secret_truncated: 'foo', + }, + ]; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts index 412e0e05..df137d69 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts @@ -1,5 +1,15 @@ +import { User } from '../api/vendoredSdk'; import type { Workspace } from '../typesConstants'; -import { mockBackstageApiEndpoint } from './mockBackstageData'; +import { MockUser } from './coderEntities'; +import { + mockBackstageApiEndpoint, + mockBackstageAssetsEndpoint, +} from './mockBackstageData'; + +export const mockUserWithProxyUrls: User = { + ...MockUser, + avatar_url: `${mockBackstageAssetsEndpoint}/blueberry.png`, +}; /** * The main mock for a workspace whose repo URL matches cleanedRepoUrl diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 69fe816a..b68f48f2 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -11,11 +11,11 @@ import { setupServer } from 'msw/node'; /* eslint-enable @backstage/no-undeclared-imports */ import { + mockUserWithProxyUrls, mockWorkspacesList, mockWorkspacesListForRepoSearch, } from './mockCoderAppData'; import { - mockBackstageAssetsEndpoint, mockBearerToken, mockCoderAuthToken, mockCoderWorkspacesConfig, @@ -23,7 +23,7 @@ import { } from './mockBackstageData'; import type { WorkspacesResponse } from '../typesConstants'; import { CODER_AUTH_HEADER_KEY } from '../api/CoderClient'; -import { User } from '../typesConstants'; +import type { User } from '../api/vendoredSdk'; type RestResolver = ResponseResolver< RestRequest, @@ -129,14 +129,7 @@ const mainTestHandlers: readonly RestHandler[] = [ // This is the dummy request used to verify a user's auth status wrappedGet(mockServerEndpoints.authenticatedUser, (_, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - id: '1', - avatar_url: `${mockBackstageAssetsEndpoint}/blueberry.png`, - username: 'blueberry', - }), - ); + return res(ctx.status(200), ctx.json(mockUserWithProxyUrls)); }), ]; diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index 76551f89..5ab133c2 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -87,23 +87,6 @@ export type WorkspaceBuild = Output; export type Workspace = Output; export type WorkspacesResponse = Output; -export type WorkspacesRequest = Readonly<{ - after_id?: string; - limit?: number; - offset?: number; - q?: string; -}>; - -// This is actually the MinimalUser type from Coder core (User extends from -// ReducedUser, which extends from MinimalUser). Don't need all the properties -// until we roll out full SDK support, so going with the least privileged -// type definition for now -export type User = Readonly<{ - id: string; - username: string; - avatar_url: string; -}>; - /** * 2024-05-22 - While this isn't documented anywhere, TanStack Query defaults to * retrying a failed API request 3 times before exposing an error to the UI From d9626a0e6164a21c54ef642e0dedb74aed35915f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 19:00:14 +0000 Subject: [PATCH 07/94] wip: commit more progress on updating types --- .../src/api/CoderClient.test.ts | 5 ++--- .../CoderWorkspacesCard/WorkspacesListItem.tsx | 3 ++- .../backstage-plugin-coder/src/testHelpers/server.ts | 3 +-- plugins/backstage-plugin-coder/src/typesConstants.ts | 12 ------------ .../backstage-plugin-coder/src/utils/workspaces.ts | 2 +- 5 files changed, 6 insertions(+), 19 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index 9addcd1a..4cdf3738 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -13,7 +13,7 @@ import { mockWorkspacesList, mockWorkspacesListForRepoSearch, } from '../testHelpers/mockCoderAppData'; -import type { Workspace, WorkspacesResponse } from '../typesConstants'; +import type { Workspace, WorkspacesResponse } from './vendoredSdk'; import { getMockConfigApi, getMockDiscoveryApi, @@ -103,7 +103,6 @@ describe(`${CoderClient.name}`, () => { describe('cleanupClient functionality', () => { it('Will prevent any new SDK requests from going through', async () => { const client = new CoderClient({ apis: getConstructorApis() }); - client.cleanupClient(); // Request should fail, even though token is valid await expect(() => { @@ -139,7 +138,7 @@ describe(`${CoderClient.name}`, () => { q: 'owner:me', limit: 0, }); - client.cleanupClient(); + await expect(() => workspacesPromise2).rejects.toThrow(); }); }); diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx index f7292e51..004d77aa 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx @@ -11,7 +11,8 @@ import { useId } from '../../hooks/hookPolyfills'; import { useCoderAppConfig } from '../CoderProvider'; import { getWorkspaceAgentStatuses } from '../../utils/workspaces'; -import type { Workspace, WorkspaceStatus } from '../../typesConstants'; +import type { WorkspaceStatus } from '../../api/vendoredSdk'; +import type { Workspace } from '../../typesConstants'; import { WorkspacesListIcon } from './WorkspacesListIcon'; import { VisuallyHidden } from '../VisuallyHidden'; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index b68f48f2..9ab2dae2 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -21,9 +21,8 @@ import { mockCoderWorkspacesConfig, mockBackstageApiEndpoint as root, } from './mockBackstageData'; -import type { WorkspacesResponse } from '../typesConstants'; import { CODER_AUTH_HEADER_KEY } from '../api/CoderClient'; -import type { User } from '../api/vendoredSdk'; +import type { User, WorkspacesResponse } from '../api/vendoredSdk'; type RestResolver = ResponseResolver< RestRequest, diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index 5ab133c2..ff226101 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -1,7 +1,6 @@ import { type Output, array, - number, object, string, union, @@ -74,18 +73,7 @@ export const workspaceSchema = object({ latest_build: workspaceBuildSchema, }); -export const workspacesResponseSchema = object({ - count: number(), - workspaces: array(workspaceSchema), -}); - -export type WorkspaceAgentStatus = Output; -export type WorkspaceAgent = Output; -export type WorkspaceResource = Output; -export type WorkspaceStatus = Output; -export type WorkspaceBuild = Output; export type Workspace = Output; -export type WorkspacesResponse = Output; /** * 2024-05-22 - While this isn't documented anywhere, TanStack Query defaults to diff --git a/plugins/backstage-plugin-coder/src/utils/workspaces.ts b/plugins/backstage-plugin-coder/src/utils/workspaces.ts index c36b6d4b..f9317a97 100644 --- a/plugins/backstage-plugin-coder/src/utils/workspaces.ts +++ b/plugins/backstage-plugin-coder/src/utils/workspaces.ts @@ -1,4 +1,4 @@ -import type { Workspace, WorkspaceAgentStatus } from '../typesConstants'; +import { Workspace, WorkspaceAgentStatus } from '../api/vendoredSdk'; export function getWorkspaceAgentStatuses( workspace: Workspace, From 1dcc13b6fbcf7e0dacf9f6cd4b24e319740f6022 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 19:07:41 +0000 Subject: [PATCH 08/94] chore: remove valibot type definitions from global constants file --- .../ReminderAccordion.test.tsx | 2 +- .../components/CoderWorkspacesCard/Root.tsx | 2 +- .../WorkspacesList.test.tsx | 2 +- .../CoderWorkspacesCard/WorkspacesList.tsx | 2 +- .../WorkspacesListItem.test.tsx | 2 +- .../WorkspacesListItem.tsx | 2 +- .../src/testHelpers/mockCoderAppData.ts | 2 +- .../src/typesConstants.ts | 56 ------------------- 8 files changed, 7 insertions(+), 63 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx index 0ae1d918..5be7284b 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ReminderAccordion.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderInCoderEnvironment } from '../../testHelpers/setup'; -import type { Workspace } from '../../typesConstants'; +import type { Workspace } from '../../api/vendoredSdk'; import { mockCoderWorkspacesConfig } from '../../testHelpers/mockBackstageData'; import { type WorkspacesCardContext, diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index 0866d95a..452f0a9c 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -15,7 +15,7 @@ import { useCoderWorkspacesConfig, type CoderWorkspacesConfig, } from '../../hooks/useCoderWorkspacesConfig'; -import type { Workspace } from '../../typesConstants'; +import type { Workspace } from '../../api/vendoredSdk'; import { useCoderWorkspacesQuery } from '../../hooks/useCoderWorkspacesQuery'; import { CoderAuthFormCardWrapper } from '../CoderAuthFormCardWrapper'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx index 50bc1de1..ccb60d47 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx @@ -4,7 +4,7 @@ import { renderInCoderEnvironment } from '../../testHelpers/setup'; import { CardContext, WorkspacesCardContext, WorkspacesQuery } from './Root'; import { mockCoderWorkspacesConfig } from '../../testHelpers/mockBackstageData'; import { mockWorkspaceWithMatch } from '../../testHelpers/mockCoderAppData'; -import { Workspace } from '../../typesConstants'; +import type { Workspace } from '../../api/vendoredSdk'; import { screen } from '@testing-library/react'; type RenderInputs = Readonly<{ diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx index 1e47b08a..9301d6a4 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.tsx @@ -1,7 +1,7 @@ import React, { type HTMLAttributes, type ReactNode, Fragment } from 'react'; import { type Theme, makeStyles } from '@material-ui/core'; -import type { Workspace } from '../../typesConstants'; +import type { Workspace } from '../../api/vendoredSdk'; import { useWorkspacesCardContext } from './Root'; import { WorkspacesListItem } from './WorkspacesListItem'; import { Placeholder } from './Placeholder'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx index 03ff2623..36922919 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { renderInCoderEnvironment } from '../../testHelpers/setup'; import { mockWorkspaceWithMatch } from '../../testHelpers/mockCoderAppData'; -import type { Workspace } from '../../typesConstants'; +import type { Workspace } from '../../api/vendoredSdk'; import { WorkspacesListItem } from './WorkspacesListItem'; type RenderInput = Readonly<{ diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx index 004d77aa..a5a588ae 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.tsx @@ -12,7 +12,7 @@ import { useCoderAppConfig } from '../CoderProvider'; import { getWorkspaceAgentStatuses } from '../../utils/workspaces'; import type { WorkspaceStatus } from '../../api/vendoredSdk'; -import type { Workspace } from '../../typesConstants'; +import type { Workspace } from '../../api/vendoredSdk'; import { WorkspacesListIcon } from './WorkspacesListIcon'; import { VisuallyHidden } from '../VisuallyHidden'; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts index df137d69..ea5f46ad 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts @@ -1,5 +1,5 @@ import { User } from '../api/vendoredSdk'; -import type { Workspace } from '../typesConstants'; +import type { Workspace } from '../api/vendoredSdk'; import { MockUser } from './coderEntities'; import { mockBackstageApiEndpoint, diff --git a/plugins/backstage-plugin-coder/src/typesConstants.ts b/plugins/backstage-plugin-coder/src/typesConstants.ts index ff226101..986696bd 100644 --- a/plugins/backstage-plugin-coder/src/typesConstants.ts +++ b/plugins/backstage-plugin-coder/src/typesConstants.ts @@ -1,13 +1,3 @@ -import { - type Output, - array, - object, - string, - union, - literal, - optional, -} from 'valibot'; - export type ReadonlyJsonValue = | string | number @@ -29,52 +19,6 @@ export const CODER_API_REF_ID_PREFIX = 'backstage-plugin-coder'; export const DEFAULT_CODER_DOCS_LINK = 'https://coder.com/docs/v2/latest'; -export const workspaceAgentStatusSchema = union([ - literal('connected'), - literal('connecting'), - literal('disconnected'), - literal('timeout'), -]); - -export const workspaceAgentSchema = object({ - id: string(), - status: workspaceAgentStatusSchema, -}); - -export const workspaceResourceSchema = object({ - id: string(), - agents: optional(array(workspaceAgentSchema)), -}); - -export const workspaceStatusSchema = union([ - literal('canceled'), - literal('canceling'), - literal('deleted'), - literal('deleting'), - literal('failed'), - literal('pending'), - literal('running'), - literal('starting'), - literal('stopped'), - literal('stopping'), -]); - -export const workspaceBuildSchema = object({ - id: string(), - resources: array(workspaceResourceSchema), - status: workspaceStatusSchema, -}); - -export const workspaceSchema = object({ - id: string(), - name: string(), - template_icon: string(), - owner_name: string(), - latest_build: workspaceBuildSchema, -}); - -export type Workspace = Output; - /** * 2024-05-22 - While this isn't documented anywhere, TanStack Query defaults to * retrying a failed API request 3 times before exposing an error to the UI From 692a763016c23d69fa0bd0b3c156318fde3106e6 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 19:08:24 +0000 Subject: [PATCH 09/94] chore: rename mocks file --- plugins/backstage-plugin-coder/src/api/CoderClient.test.ts | 2 +- .../components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx | 2 +- .../src/components/CoderWorkspacesCard/WorkspacesList.test.tsx | 2 +- .../components/CoderWorkspacesCard/WorkspacesListItem.test.tsx | 2 +- .../src/hooks/useCoderWorkspacesQuery.test.ts | 2 +- .../testHelpers/{mockCoderAppData.ts => mockCoderPluginData.ts} | 0 plugins/backstage-plugin-coder/src/testHelpers/server.ts | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename plugins/backstage-plugin-coder/src/testHelpers/{mockCoderAppData.ts => mockCoderPluginData.ts} (100%) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index 4cdf3738..113d29d9 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -12,7 +12,7 @@ import { delay } from '../utils/time'; import { mockWorkspacesList, mockWorkspacesListForRepoSearch, -} from '../testHelpers/mockCoderAppData'; +} from '../testHelpers/mockCoderPluginData'; import type { Workspace, WorkspacesResponse } from './vendoredSdk'; import { getMockConfigApi, diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx index a8cbef6c..8acc04a1 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/CoderWorkspacesCard.test.tsx @@ -10,7 +10,7 @@ import { mockWorkspaceNoParameters, mockWorkspaceWithMatch2, mockWorkspacesList, -} from '../../testHelpers/mockCoderAppData'; +} from '../../testHelpers/mockCoderPluginData'; import { type CoderAuthStatus } from '../CoderProvider'; import { CoderWorkspacesCard } from './CoderWorkspacesCard'; import userEvent from '@testing-library/user-event'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx index ccb60d47..bc7e0273 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesList.test.tsx @@ -3,7 +3,7 @@ import { type WorkspacesListProps, WorkspacesList } from './WorkspacesList'; import { renderInCoderEnvironment } from '../../testHelpers/setup'; import { CardContext, WorkspacesCardContext, WorkspacesQuery } from './Root'; import { mockCoderWorkspacesConfig } from '../../testHelpers/mockBackstageData'; -import { mockWorkspaceWithMatch } from '../../testHelpers/mockCoderAppData'; +import { mockWorkspaceWithMatch } from '../../testHelpers/mockCoderPluginData'; import type { Workspace } from '../../api/vendoredSdk'; import { screen } from '@testing-library/react'; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx index 36922919..3d9d7b87 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { renderInCoderEnvironment } from '../../testHelpers/setup'; -import { mockWorkspaceWithMatch } from '../../testHelpers/mockCoderAppData'; +import { mockWorkspaceWithMatch } from '../../testHelpers/mockCoderPluginData'; import type { Workspace } from '../../api/vendoredSdk'; import { WorkspacesListItem } from './WorkspacesListItem'; diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts index d29e64a5..49535619 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts @@ -6,7 +6,7 @@ import { mockCoderWorkspacesConfig } from '../testHelpers/mockBackstageData'; import { mockWorkspaceNoParameters, mockWorkspacesList, -} from '../testHelpers/mockCoderAppData'; +} from '../testHelpers/mockCoderPluginData'; beforeAll(() => { jest.useFakeTimers(); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderPluginData.ts similarity index 100% rename from plugins/backstage-plugin-coder/src/testHelpers/mockCoderAppData.ts rename to plugins/backstage-plugin-coder/src/testHelpers/mockCoderPluginData.ts diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 9ab2dae2..1031bffd 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -14,7 +14,7 @@ import { mockUserWithProxyUrls, mockWorkspacesList, mockWorkspacesListForRepoSearch, -} from './mockCoderAppData'; +} from './mockCoderPluginData'; import { mockBearerToken, mockCoderAuthToken, From 28accc8c75b07159d4a83070a3d854effe2ff4e3 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 19:40:29 +0000 Subject: [PATCH 10/94] fix: update type mismatches --- .../components/CoderErrorBoundary/CoderErrorBoundary.tsx | 2 +- plugins/backstage-plugin-coder/src/hooks/hookPolyfills.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.tsx b/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.tsx index c1f2bc61..5843a180 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderErrorBoundary/CoderErrorBoundary.tsx @@ -39,7 +39,7 @@ class ErrorBoundaryCore extends Component< render() { const { children, fallbackUi } = this.props; - return this.state.hasError ? fallbackUi : children; + return <>{this.state.hasError ? fallbackUi : children}; } } diff --git a/plugins/backstage-plugin-coder/src/hooks/hookPolyfills.ts b/plugins/backstage-plugin-coder/src/hooks/hookPolyfills.ts index 3b777c5e..ce15f948 100644 --- a/plugins/backstage-plugin-coder/src/hooks/hookPolyfills.ts +++ b/plugins/backstage-plugin-coder/src/hooks/hookPolyfills.ts @@ -25,5 +25,11 @@ function useIdPolyfill(): string { return readonlyId; } +const ReactWithNewerHooks = React as typeof React & { + useId?: () => string; +}; + export const useId = - typeof React.useId === 'undefined' ? useIdPolyfill : React.useId; + typeof ReactWithNewerHooks.useId === 'undefined' + ? useIdPolyfill + : ReactWithNewerHooks.useId; From d032768c6b761b1bb9ef294dcd99e2ff2fa8c4aa Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 20:22:51 +0000 Subject: [PATCH 11/94] wip: commit more update progress --- .../src/api/CoderClient.test.ts | 49 +- .../WorkspacesListItem.test.tsx | 6 + .../src/testHelpers/coderEntities.ts | 3341 +---------------- .../src/testHelpers/mockCoderPluginData.ts | 13 +- .../src/testHelpers/server.ts | 1 - 5 files changed, 120 insertions(+), 3290 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index 113d29d9..f807adb7 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -1,8 +1,4 @@ -import { - CODER_AUTH_HEADER_KEY, - CoderClient, - disabledClientError, -} from './CoderClient'; +import { CODER_AUTH_HEADER_KEY, CoderClient } from './CoderClient'; import type { IdentityApi } from '@backstage/core-plugin-api'; import { UrlSync } from './UrlSync'; import { rest } from 'msw'; @@ -100,49 +96,6 @@ describe(`${CoderClient.name}`, () => { }); }); - describe('cleanupClient functionality', () => { - it('Will prevent any new SDK requests from going through', async () => { - const client = new CoderClient({ apis: getConstructorApis() }); - - // Request should fail, even though token is valid - await expect(() => { - return client.syncToken(mockCoderAuthToken); - }).rejects.toThrow(disabledClientError); - - await expect(() => { - return client.sdk.getWorkspaces({ - q: 'owner:me', - limit: 0, - }); - }).rejects.toThrow(disabledClientError); - }); - - it('Will abort any pending requests', async () => { - const client = new CoderClient({ - initialToken: mockCoderAuthToken, - apis: getConstructorApis(), - }); - - // Sanity check to ensure that request can still go through normally - const workspacesPromise1 = client.sdk.getWorkspaces({ - q: 'owner:me', - limit: 0, - }); - - await expect(workspacesPromise1).resolves.toEqual({ - workspaces: mockWorkspacesList, - count: mockWorkspacesList.length, - }); - - const workspacesPromise2 = client.sdk.getWorkspaces({ - q: 'owner:me', - limit: 0, - }); - - await expect(() => workspacesPromise2).rejects.toThrow(); - }); - }); - // Eventually the Coder SDK is going to get too big to test every single // function. Focus tests on the functionality specifically being patched in // for Backstage diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx index 3d9d7b87..471d3356 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/WorkspacesListItem.test.tsx @@ -4,6 +4,10 @@ import { renderInCoderEnvironment } from '../../testHelpers/setup'; import { mockWorkspaceWithMatch } from '../../testHelpers/mockCoderPluginData'; import type { Workspace } from '../../api/vendoredSdk'; import { WorkspacesListItem } from './WorkspacesListItem'; +import { + MockWorkspaceAgent, + MockWorkspaceResource, +} from '../../testHelpers/coderEntities'; type RenderInput = Readonly<{ isOnline?: boolean; @@ -19,9 +23,11 @@ async function renderListItem(inputs?: RenderInput) { status: isOnline ? 'running' : 'stopped', resources: [ { + ...MockWorkspaceResource, id: '1', agents: [ { + ...MockWorkspaceAgent, id: '2', status: isOnline ? 'connected' : 'disconnected', }, diff --git a/plugins/backstage-plugin-coder/src/testHelpers/coderEntities.ts b/plugins/backstage-plugin-coder/src/testHelpers/coderEntities.ts index 868daaaf..b5cf5abf 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/coderEntities.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/coderEntities.ts @@ -1,13 +1,13 @@ /** * @file This is a subset of the mock data from the Coder OSS repo. No values - * are modified; if any values should be for Backstage, those should be updated - * in the mockCoderPluginData.ts file. + * are modified; if any values should be patched for Backstage testing, those + * should be updated in the mockCoderPluginData.ts file. * * @see {@link https://github.com/coder/coder/blob/main/site/src/testHelpers/entities.ts} */ import type * as TypesGen from '../api/vendoredSdk'; -export const MockOrganization: TypesGen.Organization = { +const MockOrganization: TypesGen.Organization = { id: 'fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0', name: 'Test Organization', created_at: '', @@ -15,7 +15,7 @@ export const MockOrganization: TypesGen.Organization = { is_default: true, }; -export const MockOwnerRole: TypesGen.Role = { +const MockOwnerRole: TypesGen.Role = { name: 'owner', display_name: 'Owner', site_permissions: [], @@ -39,334 +39,7 @@ export const MockUser: TypesGen.User = { name: '', }; -export const MockTemplateDAUResponse: TypesGen.DAUsResponse = { - tz_hour_offset: 0, - entries: [ - { date: '2022-08-27', amount: 1 }, - { date: '2022-08-29', amount: 2 }, - { date: '2022-08-30', amount: 1 }, - ], -}; -export const MockDeploymentDAUResponse: TypesGen.DAUsResponse = { - tz_hour_offset: 0, - entries: [ - { date: '2022-08-27', amount: 10 }, - { date: '2022-08-29', amount: 22 }, - { date: '2022-08-30', amount: 14 }, - ], -}; -export const MockSessionToken: TypesGen.LoginWithPasswordResponse = { - session_token: 'my-session-token', -}; - -export const MockAPIKey: TypesGen.GenerateAPIKeyResponse = { - key: 'my-api-key', -}; - -export const MockToken: TypesGen.APIKeyWithOwner = { - id: 'tBoVE3dqLl', - user_id: 'f9ee61d8-1d84-4410-ab6e-c1ec1a641e0b', - last_used: '0001-01-01T00:00:00Z', - expires_at: '2023-01-15T20:10:45.637438Z', - created_at: '2022-12-16T20:10:45.637452Z', - updated_at: '2022-12-16T20:10:45.637452Z', - login_type: 'token', - scope: 'all', - lifetime_seconds: 2592000, - token_name: 'token-one', - username: 'admin', -}; - -export const MockTokens: TypesGen.APIKeyWithOwner[] = [ - MockToken, - { - id: 'tBoVE3dqLl', - user_id: 'f9ee61d8-1d84-4410-ab6e-c1ec1a641e0b', - last_used: '0001-01-01T00:00:00Z', - expires_at: '2023-01-15T20:10:45.637438Z', - created_at: '2022-12-16T20:10:45.637452Z', - updated_at: '2022-12-16T20:10:45.637452Z', - login_type: 'token', - scope: 'all', - lifetime_seconds: 2592000, - token_name: 'token-two', - username: 'admin', - }, -]; - -export const MockPrimaryWorkspaceProxy: TypesGen.WorkspaceProxy = { - id: '4aa23000-526a-481f-a007-0f20b98b1e12', - name: 'primary', - display_name: 'Default', - icon_url: '/emojis/1f60e.png', - healthy: true, - path_app_url: 'https://coder.com', - wildcard_hostname: '*.coder.com', - derp_enabled: true, - derp_only: false, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - version: 'v2.34.5-test+primary', - deleted: false, - status: { - status: 'ok', - checked_at: new Date().toISOString(), - }, -}; - -export const MockHealthyWildWorkspaceProxy: TypesGen.WorkspaceProxy = { - id: '5e2c1ab7-479b-41a9-92ce-aa85625de52c', - name: 'haswildcard', - display_name: 'Subdomain Supported', - icon_url: '/emojis/1f319.png', - healthy: true, - path_app_url: 'https://external.com', - wildcard_hostname: '*.external.com', - derp_enabled: true, - derp_only: false, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - deleted: false, - version: 'v2.34.5-test+haswildcard', - status: { - status: 'ok', - checked_at: new Date().toISOString(), - }, -}; - -export const MockUnhealthyWildWorkspaceProxy: TypesGen.WorkspaceProxy = { - id: '8444931c-0247-4171-842a-569d9f9cbadb', - name: 'unhealthy', - display_name: 'Unhealthy', - icon_url: '/emojis/1f92e.png', - healthy: false, - path_app_url: 'https://unhealthy.coder.com', - wildcard_hostname: '*unhealthy..coder.com', - derp_enabled: true, - derp_only: true, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - version: 'v2.34.5-test+unhealthy', - deleted: false, - status: { - status: 'unhealthy', - report: { - errors: ['This workspace proxy is manually marked as unhealthy.'], - warnings: ['This is a manual warning for this workspace proxy.'], - }, - checked_at: new Date().toISOString(), - }, -}; - -export const MockWorkspaceProxies: TypesGen.WorkspaceProxy[] = [ - MockPrimaryWorkspaceProxy, - MockHealthyWildWorkspaceProxy, - MockUnhealthyWildWorkspaceProxy, - { - id: '26e84c16-db24-4636-a62d-aa1a4232b858', - name: 'nowildcard', - display_name: 'No wildcard', - icon_url: '/emojis/1f920.png', - healthy: true, - path_app_url: 'https://cowboy.coder.com', - wildcard_hostname: '', - derp_enabled: false, - derp_only: false, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - deleted: false, - version: 'v2.34.5-test+nowildcard', - status: { - status: 'ok', - checked_at: new Date().toISOString(), - }, - }, -]; - -export const MockProxyLatencies: Record = { - ...MockWorkspaceProxies.reduce((acc, proxy) => { - if (!proxy.healthy) { - return acc; - } - acc[proxy.id] = { - // Make one of them inaccurate. - accurate: proxy.id !== '26e84c16-db24-4636-a62d-aa1a4232b858', - // This is a deterministic way to generate a latency to for each proxy. - // It will be the same for each run as long as the IDs don't change. - latencyMS: - (Number( - Array.from(proxy.id).reduce( - // Multiply each char code by some large prime number to increase the - // size of the number and allow use to get some decimal points. - (acc, char) => acc + char.charCodeAt(0) * 37, - 0, - ), - ) / - // Cap at 250ms - 100) % - 250, - at: new Date(), - }; - return acc; - }, {} as Record), -}; - -export const MockBuildInfo: TypesGen.BuildInfoResponse = { - agent_api_version: '1.0', - external_url: 'file:///mock-url', - version: 'v99.999.9999+c9cdf14', - dashboard_url: 'https:///mock-url', - workspace_proxy: false, - upgrade_message: 'My custom upgrade message', - deployment_id: '510d407f-e521-4180-b559-eab4a6d802b8', -}; - -export const MockSupportLinks: TypesGen.LinkConfig[] = [ - { - name: 'First link', - target: 'http://first-link', - icon: 'chat', - }, - { - name: 'Second link', - target: 'http://second-link', - icon: 'docs', - }, - { - name: 'Third link', - target: - 'https://github.com/coder/coder/issues/new?labels=needs+grooming&body={CODER_BUILD_INFO}', - icon: '', - }, -]; - -export const MockUpdateCheck: TypesGen.UpdateCheckResponse = { - current: true, - url: 'file:///mock-url', - version: 'v99.999.9999+c9cdf14', -}; - -export const MockUserAdminRole: TypesGen.Role = { - name: 'user_admin', - display_name: 'User Admin', - site_permissions: [], - organization_permissions: {}, - user_permissions: [], - organization_id: '', -}; - -export const MockTemplateAdminRole: TypesGen.Role = { - name: 'template_admin', - display_name: 'Template Admin', - site_permissions: [], - organization_permissions: {}, - user_permissions: [], - organization_id: '', -}; - -export const MockMemberRole: TypesGen.SlimRole = { - name: 'member', - display_name: 'Member', -}; - -export const MockAuditorRole: TypesGen.Role = { - name: 'auditor', - display_name: 'Auditor', - site_permissions: [], - organization_permissions: {}, - user_permissions: [], - organization_id: '', -}; - -// assignableRole takes a role and a boolean. The boolean implies if the -// actor can assign (add/remove) the role from other users. -export function assignableRole( - role: TypesGen.Role, - assignable: boolean, -): TypesGen.AssignableRoles { - return { - ...role, - assignable: assignable, - built_in: true, - }; -} - -export const MockSiteRoles = [MockUserAdminRole, MockAuditorRole]; -export const MockAssignableSiteRoles = [ - assignableRole(MockUserAdminRole, true), - assignableRole(MockAuditorRole, true), -]; - -export const MockMemberPermissions = { - viewAuditLog: false, -}; - -export const MockUserAdmin: TypesGen.User = { - id: 'test-user', - username: 'TestUser', - email: 'test@coder.com', - created_at: '', - status: 'active', - organization_ids: [MockOrganization.id], - roles: [MockUserAdminRole], - avatar_url: '', - last_seen_at: '', - login_type: 'password', - theme_preference: '', - name: '', -}; - -export const MockUser2: TypesGen.User = { - id: 'test-user-2', - username: 'TestUser2', - email: 'test2@coder.com', - created_at: '', - status: 'active', - organization_ids: [MockOrganization.id], - roles: [], - avatar_url: '', - last_seen_at: '2022-09-14T19:12:21Z', - login_type: 'oidc', - theme_preference: '', - name: 'Mock User The Second', -}; - -export const SuspendedMockUser: TypesGen.User = { - id: 'suspended-mock-user', - username: 'SuspendedMockUser', - email: 'iamsuspendedsad!@coder.com', - created_at: '', - status: 'suspended', - organization_ids: [MockOrganization.id], - roles: [], - avatar_url: '', - last_seen_at: '', - login_type: 'password', - theme_preference: '', - name: '', -}; - -export const MockProvisioner: TypesGen.ProvisionerDaemon = { - created_at: '2022-05-17T17:39:01.382927298Z', - id: 'test-provisioner', - name: 'Test Provisioner', - provisioners: ['echo'], - tags: { scope: 'organization' }, - version: 'v2.34.5', - api_version: '1.0', -}; - -export const MockUserProvisioner: TypesGen.ProvisionerDaemon = { - created_at: '2022-05-17T17:39:01.382927298Z', - id: 'test-user-provisioner', - name: 'Test User Provisioner', - provisioners: ['echo'], - tags: { scope: 'user', owner: '12345678-abcd-1234-abcd-1234567890abcd' }, - version: 'v2.34.5', - api_version: '1.0', -}; - -export const MockProvisionerJob: TypesGen.ProvisionerJob = { +const MockProvisionerJob: TypesGen.ProvisionerJob = { created_at: '', id: 'test-provisioner-job', status: 'succeeded', @@ -384,30 +57,17 @@ export const MockProvisionerJob: TypesGen.ProvisionerJob = { queue_size: 0, }; -export const MockFailedProvisionerJob: TypesGen.ProvisionerJob = { - ...MockProvisionerJob, - status: 'failed', +const MockProvisioner: TypesGen.ProvisionerDaemon = { + created_at: '2022-05-17T17:39:01.382927298Z', + id: 'test-provisioner', + name: 'Test Provisioner', + provisioners: ['echo'], + tags: { scope: 'organization' }, + version: 'v2.34.5', + api_version: '1.0', }; -export const MockCancelingProvisionerJob: TypesGen.ProvisionerJob = { - ...MockProvisionerJob, - status: 'canceling', -}; -export const MockCanceledProvisionerJob: TypesGen.ProvisionerJob = { - ...MockProvisionerJob, - status: 'canceled', -}; -export const MockRunningProvisionerJob: TypesGen.ProvisionerJob = { - ...MockProvisionerJob, - status: 'running', -}; -export const MockPendingProvisionerJob: TypesGen.ProvisionerJob = { - ...MockProvisionerJob, - status: 'pending', - queue_position: 2, - queue_size: 4, -}; -export const MockTemplateVersion: TypesGen.TemplateVersion = { +const MockTemplateVersion: TypesGen.TemplateVersion = { id: 'test-template-version', created_at: '2022-05-17T17:39:01.382927298Z', updated_at: '2022-05-17T17:39:01.382927298Z', @@ -426,129 +86,25 @@ You can add instructions here archived: false, }; -export const MockTemplateVersion2: TypesGen.TemplateVersion = { - id: 'test-template-version-2', - created_at: '2022-05-17T17:39:01.382927298Z', - updated_at: '2022-05-17T17:39:01.382927298Z', - template_id: 'test-template', - job: MockProvisionerJob, - name: 'test-version-2', - message: 'first version', - readme: `--- -name:Template test 2 ---- -## Instructions -You can add instructions here - -[Some link info](https://coder.com)`, - created_by: MockUser, - archived: false, -}; - -export const MockTemplateVersionWithMarkdownMessage: TypesGen.TemplateVersion = - { - ...MockTemplateVersion, - message: ` -# Abiding Grace -## Enchantment -At the beginning of your end step, choose one — - -- You gain 1 life. - -- Return target creature card with mana value 1 from your graveyard to the battlefield. -`, - }; - -export const MockTemplate: TypesGen.Template = { - id: 'test-template', - created_at: '2022-05-17T17:39:01.382927298Z', - updated_at: '2022-05-18T17:39:01.382927298Z', - organization_id: MockOrganization.id, - name: 'test-template', - display_name: 'Test Template', - provisioner: MockProvisioner.provisioners[0], - active_version_id: MockTemplateVersion.id, - active_user_count: 1, - build_time_stats: { - start: { - P50: 1000, - P95: 1500, - }, - stop: { - P50: 1000, - P95: 1500, - }, - delete: { - P50: 1000, - P95: 1500, - }, - }, - description: 'This is a test description.', - default_ttl_ms: 24 * 60 * 60 * 1000, - activity_bump_ms: 1 * 60 * 60 * 1000, - autostop_requirement: { - days_of_week: ['sunday'], - weeks: 1, - }, - autostart_requirement: { - days_of_week: [ - 'monday', - 'tuesday', - 'wednesday', - 'thursday', - 'friday', - 'saturday', - 'sunday', - ], - }, - created_by_id: 'test-creator-id', - created_by_name: 'test_creator', - icon: '/icon/code.svg', - allow_user_cancel_workspace_jobs: true, - failure_ttl_ms: 0, - time_til_dormant_ms: 0, - time_til_dormant_autodelete_ms: 0, - allow_user_autostart: true, - allow_user_autostop: true, - require_active_version: false, - deprecated: false, - deprecation_message: '', - max_port_share_level: 'public', +const MockWorkspaceAgentLogSource: TypesGen.WorkspaceAgentLogSource = { + created_at: '2023-05-04T11:30:41.402072Z', + id: 'dc790496-eaec-4f88-a53f-8ce1f61a1fff', + display_name: 'Startup Script', + icon: '', + workspace_agent_id: '', }; -export const MockTemplateVersionFiles: TemplateVersionFiles = { - 'README.md': '# Example\n\nThis is an example template.', - 'main.tf': `// Provides info about the workspace. -data "coder_workspace" "me" {} - -// Provides the startup script used to download -// the agent and communicate with Coder. -resource "coder_agent" "dev" { -os = "linux" -arch = "amd64" -} - -resource "kubernetes_pod" "main" { -// Ensures that the Pod dies when the workspace shuts down! -count = data.coder_workspace.me.start_count -metadata { - name = "dev-\${data.coder_workspace.me.id}" -} -spec { - container { - image = "ubuntu" - command = ["sh", "-c", coder_agent.main.init_script] - env { - name = "CODER_AGENT_TOKEN" - value = coder_agent.main.token - } - } -} -} -`, +const MockBuildInfo: TypesGen.BuildInfoResponse = { + agent_api_version: '1.0', + external_url: 'file:///mock-url', + version: 'v99.999.9999+c9cdf14', + dashboard_url: 'https:///mock-url', + workspace_proxy: false, + upgrade_message: 'My custom upgrade message', + deployment_id: '510d407f-e521-4180-b559-eab4a6d802b8', }; -export const MockWorkspaceApp: TypesGen.WorkspaceApp = { +const MockWorkspaceApp: TypesGen.WorkspaceApp = { id: 'test-app', slug: 'test-app', display_name: 'Test App', @@ -565,15 +121,7 @@ export const MockWorkspaceApp: TypesGen.WorkspaceApp = { }, }; -export const MockWorkspaceAgentLogSource: TypesGen.WorkspaceAgentLogSource = { - created_at: '2023-05-04T11:30:41.402072Z', - id: 'dc790496-eaec-4f88-a53f-8ce1f61a1fff', - display_name: 'Startup Script', - icon: '', - workspace_agent_id: '', -}; - -export const MockWorkspaceAgentScript: TypesGen.WorkspaceAgentScript = { +const MockWorkspaceAgentScript: TypesGen.WorkspaceAgentScript = { log_source_id: MockWorkspaceAgentLogSource.id, cron: '', log_path: '', @@ -624,257 +172,21 @@ export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = { ], }; -export const MockWorkspaceAgentDisconnected: TypesGen.WorkspaceAgent = { - ...MockWorkspaceAgent, - id: 'test-workspace-agent-2', - name: 'another-workspace-agent', - status: 'disconnected', - version: '', - latency: {}, - lifecycle_state: 'ready', - health: { - healthy: false, - reason: 'agent is not connected', - }, -}; - -export const MockWorkspaceAgentOutdated: TypesGen.WorkspaceAgent = { - ...MockWorkspaceAgent, - id: 'test-workspace-agent-3', - name: 'an-outdated-workspace-agent', - version: 'v99.999.9998+abcdef', - operating_system: 'Windows', - latency: { - ...MockWorkspaceAgent.latency, - Chicago: { - preferred: false, - latency_ms: 95.11, - }, - 'San Francisco': { - preferred: false, - latency_ms: 111.55, - }, - Paris: { - preferred: false, - latency_ms: 221.66, - }, - }, - lifecycle_state: 'ready', -}; - -export const MockWorkspaceAgentDeprecated: TypesGen.WorkspaceAgent = { - ...MockWorkspaceAgent, - id: 'test-workspace-agent-3', - name: 'an-outdated-workspace-agent', - version: 'v99.999.9998+abcdef', - api_version: '1.99', - operating_system: 'Windows', - latency: { - ...MockWorkspaceAgent.latency, - Chicago: { - preferred: false, - latency_ms: 95.11, - }, - 'San Francisco': { - preferred: false, - latency_ms: 111.55, - }, - Paris: { - preferred: false, - latency_ms: 221.66, - }, - }, - lifecycle_state: 'ready', -}; - -export const MockWorkspaceAgentConnecting: TypesGen.WorkspaceAgent = { - ...MockWorkspaceAgent, - id: 'test-workspace-agent-connecting', - name: 'another-workspace-agent', - status: 'connecting', - version: '', - latency: {}, - lifecycle_state: 'created', -}; - -export const MockWorkspaceAgentTimeout: TypesGen.WorkspaceAgent = { - ...MockWorkspaceAgent, - id: 'test-workspace-agent-timeout', - name: 'a-timed-out-workspace-agent', - status: 'timeout', - version: '', - latency: {}, - lifecycle_state: 'created', - health: { - healthy: false, - reason: 'agent is taking too long to connect', - }, +export const MockWorkspaceResource: TypesGen.WorkspaceResource = { + id: 'test-workspace-resource', + name: 'a-workspace-resource', + agents: [MockWorkspaceAgent], + created_at: '', + job_id: '', + type: 'google_compute_disk', + workspace_transition: 'start', + hide: false, + icon: '', + metadata: [{ key: 'size', value: '32GB', sensitive: false }], + daily_cost: 10, }; -export const MockWorkspaceAgentStarting: TypesGen.WorkspaceAgent = { - ...MockWorkspaceAgent, - id: 'test-workspace-agent-starting', - name: 'a-starting-workspace-agent', - lifecycle_state: 'starting', -}; - -export const MockWorkspaceAgentReady: TypesGen.WorkspaceAgent = { - ...MockWorkspaceAgent, - id: 'test-workspace-agent-ready', - name: 'a-ready-workspace-agent', - lifecycle_state: 'ready', -}; - -export const MockWorkspaceAgentStartTimeout: TypesGen.WorkspaceAgent = { - ...MockWorkspaceAgent, - id: 'test-workspace-agent-start-timeout', - name: 'a-workspace-agent-timed-out-while-running-startup-script', - lifecycle_state: 'start_timeout', -}; - -export const MockWorkspaceAgentStartError: TypesGen.WorkspaceAgent = { - ...MockWorkspaceAgent, - id: 'test-workspace-agent-start-error', - name: 'a-workspace-agent-errored-while-running-startup-script', - lifecycle_state: 'start_error', - health: { - healthy: false, - reason: 'agent startup script failed', - }, -}; - -export const MockWorkspaceAgentShuttingDown: TypesGen.WorkspaceAgent = { - ...MockWorkspaceAgent, - id: 'test-workspace-agent-shutting-down', - name: 'a-shutting-down-workspace-agent', - lifecycle_state: 'shutting_down', - health: { - healthy: false, - reason: 'agent is shutting down', - }, -}; - -export const MockWorkspaceAgentShutdownTimeout: TypesGen.WorkspaceAgent = { - ...MockWorkspaceAgent, - id: 'test-workspace-agent-shutdown-timeout', - name: 'a-workspace-agent-timed-out-while-running-shutdownup-script', - lifecycle_state: 'shutdown_timeout', - health: { - healthy: false, - reason: 'agent is shutting down', - }, -}; - -export const MockWorkspaceAgentShutdownError: TypesGen.WorkspaceAgent = { - ...MockWorkspaceAgent, - id: 'test-workspace-agent-shutdown-error', - name: 'a-workspace-agent-errored-while-running-shutdownup-script', - lifecycle_state: 'shutdown_error', - health: { - healthy: false, - reason: 'agent is shutting down', - }, -}; - -export const MockWorkspaceAgentOff: TypesGen.WorkspaceAgent = { - ...MockWorkspaceAgent, - id: 'test-workspace-agent-off', - name: 'a-workspace-agent-is-shut-down', - lifecycle_state: 'off', - health: { - healthy: false, - reason: 'agent is shutting down', - }, -}; - -export const MockWorkspaceResource: TypesGen.WorkspaceResource = { - id: 'test-workspace-resource', - name: 'a-workspace-resource', - agents: [MockWorkspaceAgent], - created_at: '', - job_id: '', - type: 'google_compute_disk', - workspace_transition: 'start', - hide: false, - icon: '', - metadata: [{ key: 'size', value: '32GB', sensitive: false }], - daily_cost: 10, -}; - -export const MockWorkspaceResourceSensitive: TypesGen.WorkspaceResource = { - ...MockWorkspaceResource, - id: 'test-workspace-resource-sensitive', - name: 'workspace-resource-sensitive', - metadata: [{ key: 'api_key', value: '12345678', sensitive: true }], -}; - -export const MockWorkspaceResourceMultipleAgents: TypesGen.WorkspaceResource = { - ...MockWorkspaceResource, - id: 'test-workspace-resource-multiple-agents', - name: 'workspace-resource-multiple-agents', - agents: [ - MockWorkspaceAgent, - MockWorkspaceAgentDisconnected, - MockWorkspaceAgentOutdated, - ], -}; - -export const MockWorkspaceResourceHidden: TypesGen.WorkspaceResource = { - ...MockWorkspaceResource, - id: 'test-workspace-resource-hidden', - name: 'workspace-resource-hidden', - hide: true, -}; - -export const MockWorkspaceVolumeResource: TypesGen.WorkspaceResource = { - id: 'test-workspace-volume-resource', - created_at: '', - job_id: '', - workspace_transition: 'start', - type: 'docker_volume', - name: 'home_volume', - hide: false, - icon: '', - daily_cost: 0, -}; - -export const MockWorkspaceImageResource: TypesGen.WorkspaceResource = { - id: 'test-workspace-image-resource', - created_at: '', - job_id: '', - workspace_transition: 'start', - type: 'docker_image', - name: 'main', - hide: false, - icon: '', - daily_cost: 0, -}; - -export const MockWorkspaceContainerResource: TypesGen.WorkspaceResource = { - id: 'test-workspace-container-resource', - created_at: '', - job_id: '', - workspace_transition: 'start', - type: 'docker_container', - name: 'workspace', - hide: false, - icon: '', - daily_cost: 0, -}; - -export const MockWorkspaceAutostartDisabled: TypesGen.UpdateWorkspaceAutostartRequest = - { - schedule: '', - }; - -export const MockWorkspaceAutostartEnabled: TypesGen.UpdateWorkspaceAutostartRequest = - { - // Runs at 9:30am Monday through Friday using Canada/Eastern - // (America/Toronto) time - schedule: 'CRON_TZ=Canada/Eastern 30 9 * * 1-5', - }; - -export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { +const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { build_number: 1, created_at: '2022-05-17T17:39:01.382927298Z', id: '1', @@ -897,96 +209,69 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { daily_cost: 20, }; -export const MockWorkspaceBuildAutostart: TypesGen.WorkspaceBuild = { - build_number: 1, - created_at: '2022-05-17T17:39:01.382927298Z', - id: '1', - initiator_id: MockUser.id, - initiator_name: MockUser.username, - job: MockProvisionerJob, - template_version_id: MockTemplateVersion.id, - template_version_name: MockTemplateVersion.name, - transition: 'start', - updated_at: '2022-05-17T17:39:01.382927298Z', - workspace_name: 'test-workspace', - workspace_owner_id: MockUser.id, - workspace_owner_name: MockUser.username, - workspace_owner_avatar_url: MockUser.avatar_url, - workspace_id: '759f1d46-3174-453d-aa60-980a9c1442f3', - deadline: '2022-05-17T23:39:00.00Z', - reason: 'autostart', - resources: [MockWorkspaceResource], - status: 'running', - daily_cost: 20, -}; - -export const MockWorkspaceBuildAutostop: TypesGen.WorkspaceBuild = { - build_number: 1, - created_at: '2022-05-17T17:39:01.382927298Z', - id: '1', - initiator_id: MockUser.id, - initiator_name: MockUser.username, - job: MockProvisionerJob, - template_version_id: MockTemplateVersion.id, - template_version_name: MockTemplateVersion.name, - transition: 'start', - updated_at: '2022-05-17T17:39:01.382927298Z', - workspace_name: 'test-workspace', - workspace_owner_id: MockUser.id, - workspace_owner_name: MockUser.username, - workspace_owner_avatar_url: MockUser.avatar_url, - workspace_id: '759f1d46-3174-453d-aa60-980a9c1442f3', - deadline: '2022-05-17T23:39:00.00Z', - reason: 'autostop', - resources: [MockWorkspaceResource], - status: 'running', - daily_cost: 20, -}; - -export const MockFailedWorkspaceBuild = ( - transition: TypesGen.WorkspaceTransition = 'start', -): TypesGen.WorkspaceBuild => ({ - build_number: 1, +const MockTemplate: TypesGen.Template = { + id: 'test-template', created_at: '2022-05-17T17:39:01.382927298Z', - id: '1', - initiator_id: MockUser.id, - initiator_name: MockUser.username, - job: MockFailedProvisionerJob, - template_version_id: MockTemplateVersion.id, - template_version_name: MockTemplateVersion.name, - transition: transition, - updated_at: '2022-05-17T17:39:01.382927298Z', - workspace_name: 'test-workspace', - workspace_owner_id: MockUser.id, - workspace_owner_name: MockUser.username, - workspace_owner_avatar_url: MockUser.avatar_url, - workspace_id: '759f1d46-3174-453d-aa60-980a9c1442f3', - deadline: '2022-05-17T23:39:00.00Z', - reason: 'initiator', - resources: [], - status: 'failed', - daily_cost: 20, -}); - -export const MockWorkspaceBuildStop: TypesGen.WorkspaceBuild = { - ...MockWorkspaceBuild, - id: '2', - transition: 'stop', -}; - -export const MockWorkspaceBuildDelete: TypesGen.WorkspaceBuild = { - ...MockWorkspaceBuild, - id: '3', - transition: 'delete', + updated_at: '2022-05-18T17:39:01.382927298Z', + organization_id: MockOrganization.id, + name: 'test-template', + display_name: 'Test Template', + provisioner: MockProvisioner.provisioners[0], + active_version_id: MockTemplateVersion.id, + active_user_count: 1, + build_time_stats: { + start: { + P50: 1000, + P95: 1500, + }, + stop: { + P50: 1000, + P95: 1500, + }, + delete: { + P50: 1000, + P95: 1500, + }, + }, + description: 'This is a test description.', + default_ttl_ms: 24 * 60 * 60 * 1000, + activity_bump_ms: 1 * 60 * 60 * 1000, + autostop_requirement: { + days_of_week: ['sunday'], + weeks: 1, + }, + autostart_requirement: { + days_of_week: [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', + ], + }, + created_by_id: 'test-creator-id', + created_by_name: 'test_creator', + icon: '/icon/code.svg', + allow_user_cancel_workspace_jobs: true, + failure_ttl_ms: 0, + time_til_dormant_ms: 0, + time_til_dormant_autodelete_ms: 0, + allow_user_autostart: true, + allow_user_autostop: true, + require_active_version: false, + deprecated: false, + deprecation_message: '', + max_port_share_level: 'public', }; -export const MockBuilds = [ - { ...MockWorkspaceBuild, id: '1' }, - { ...MockWorkspaceBuildAutostart, id: '2' }, - { ...MockWorkspaceBuildAutostop, id: '3' }, - { ...MockWorkspaceBuildStop, id: '4' }, - { ...MockWorkspaceBuildDelete, id: '5' }, -]; +const MockWorkspaceAutostartEnabled: TypesGen.UpdateWorkspaceAutostartRequest = + { + // Runs at 9:30am Monday through Friday using Canada/Eastern + // (America/Toronto) time + schedule: 'CRON_TZ=Canada/Eastern 30 9 * * 1-5', + }; export const MockWorkspace: TypesGen.Workspace = { id: 'test-workspace', @@ -1018,2423 +303,3 @@ export const MockWorkspace: TypesGen.Workspace = { allow_renames: true, favorite: false, }; - -export const MockFavoriteWorkspace: TypesGen.Workspace = { - ...MockWorkspace, - id: 'test-favorite-workspace', - favorite: true, -}; - -export const MockStoppedWorkspace: TypesGen.Workspace = { - ...MockWorkspace, - id: 'test-stopped-workspace', - latest_build: { ...MockWorkspaceBuildStop, status: 'stopped' }, -}; -export const MockStoppingWorkspace: TypesGen.Workspace = { - ...MockWorkspace, - id: 'test-stopping-workspace', - latest_build: { - ...MockWorkspaceBuildStop, - job: MockRunningProvisionerJob, - status: 'stopping', - }, -}; -export const MockStartingWorkspace: TypesGen.Workspace = { - ...MockWorkspace, - id: 'test-starting-workspace', - latest_build: { - ...MockWorkspaceBuild, - job: MockRunningProvisionerJob, - transition: 'start', - status: 'starting', - }, -}; -export const MockCancelingWorkspace: TypesGen.Workspace = { - ...MockWorkspace, - id: 'test-canceling-workspace', - latest_build: { - ...MockWorkspaceBuild, - job: MockCancelingProvisionerJob, - status: 'canceling', - }, -}; -export const MockCanceledWorkspace: TypesGen.Workspace = { - ...MockWorkspace, - id: 'test-canceled-workspace', - latest_build: { - ...MockWorkspaceBuild, - job: MockCanceledProvisionerJob, - status: 'canceled', - }, -}; -export const MockFailedWorkspace: TypesGen.Workspace = { - ...MockWorkspace, - id: 'test-failed-workspace', - latest_build: { - ...MockWorkspaceBuild, - job: MockFailedProvisionerJob, - status: 'failed', - }, -}; -export const MockDeletingWorkspace: TypesGen.Workspace = { - ...MockWorkspace, - id: 'test-deleting-workspace', - latest_build: { - ...MockWorkspaceBuildDelete, - job: MockRunningProvisionerJob, - status: 'deleting', - }, -}; - -export const MockWorkspaceWithDeletion = { - ...MockStoppedWorkspace, - deleting_at: new Date().toISOString(), -}; - -export const MockDeletedWorkspace: TypesGen.Workspace = { - ...MockWorkspace, - id: 'test-deleted-workspace', - latest_build: { ...MockWorkspaceBuildDelete, status: 'deleted' }, -}; - -export const MockOutdatedWorkspace: TypesGen.Workspace = { - ...MockFailedWorkspace, - id: 'test-outdated-workspace', - outdated: true, -}; - -export const MockRunningOutdatedWorkspace: TypesGen.Workspace = { - ...MockWorkspace, - id: 'test-running-outdated-workspace', - outdated: true, -}; - -export const MockDormantWorkspace: TypesGen.Workspace = { - ...MockStoppedWorkspace, - id: 'test-dormant-workspace', - dormant_at: new Date().toISOString(), -}; - -export const MockDormantOutdatedWorkspace: TypesGen.Workspace = { - ...MockStoppedWorkspace, - id: 'test-dormant-outdated-workspace', - name: 'Dormant-Workspace', - outdated: true, - dormant_at: new Date().toISOString(), -}; - -export const MockOutdatedRunningWorkspaceRequireActiveVersion: TypesGen.Workspace = - { - ...MockWorkspace, - id: 'test-outdated-workspace-require-active-version', - outdated: true, - template_require_active_version: true, - latest_build: { - ...MockWorkspaceBuild, - status: 'running', - }, - }; - -export const MockOutdatedRunningWorkspaceAlwaysUpdate: TypesGen.Workspace = { - ...MockWorkspace, - id: 'test-outdated-workspace-always-update', - outdated: true, - automatic_updates: 'always', - latest_build: { - ...MockWorkspaceBuild, - status: 'running', - }, -}; - -export const MockOutdatedStoppedWorkspaceRequireActiveVersion: TypesGen.Workspace = - { - ...MockOutdatedRunningWorkspaceRequireActiveVersion, - latest_build: { - ...MockWorkspaceBuild, - status: 'stopped', - }, - }; - -export const MockOutdatedStoppedWorkspaceAlwaysUpdate: TypesGen.Workspace = { - ...MockOutdatedRunningWorkspaceAlwaysUpdate, - latest_build: { - ...MockWorkspaceBuild, - status: 'stopped', - }, -}; - -export const MockPendingWorkspace: TypesGen.Workspace = { - ...MockWorkspace, - id: 'test-pending-workspace', - latest_build: { - ...MockWorkspaceBuild, - job: MockPendingProvisionerJob, - transition: 'start', - status: 'pending', - }, -}; - -// just over one page of workspaces -export const MockWorkspacesResponse: TypesGen.WorkspacesResponse = { - workspaces: range(1, 27).map((id: number) => ({ - ...MockWorkspace, - id: id.toString(), - name: `${MockWorkspace.name}${id}`, - })), - count: 26, -}; - -export const MockWorkspacesResponseWithDeletions = { - workspaces: [...MockWorkspacesResponse.workspaces, MockWorkspaceWithDeletion], - count: MockWorkspacesResponse.count + 1, -}; - -export const MockTemplateVersionParameter1: TypesGen.TemplateVersionParameter = - { - name: 'first_parameter', - type: 'string', - description: 'This is first parameter', - description_plaintext: 'Markdown: This is first parameter', - default_value: 'abc', - mutable: true, - icon: '/icon/folder.svg', - options: [], - required: true, - ephemeral: false, - }; - -export const MockTemplateVersionParameter2: TypesGen.TemplateVersionParameter = - { - name: 'second_parameter', - type: 'number', - description: 'This is second parameter', - description_plaintext: 'Markdown: This is second parameter', - default_value: '2', - mutable: true, - icon: '/icon/folder.svg', - options: [], - validation_min: 1, - validation_max: 3, - validation_monotonic: 'increasing', - required: true, - ephemeral: false, - }; - -export const MockTemplateVersionParameter3: TypesGen.TemplateVersionParameter = - { - name: 'third_parameter', - type: 'string', - description: 'This is third parameter', - description_plaintext: 'Markdown: This is third parameter', - default_value: 'aaa', - mutable: true, - icon: '/icon/database.svg', - options: [], - validation_error: 'No way!', - validation_regex: '^[a-z]{3}$', - required: true, - ephemeral: false, - }; - -export const MockTemplateVersionParameter4: TypesGen.TemplateVersionParameter = - { - name: 'fourth_parameter', - type: 'string', - description: 'This is fourth parameter', - description_plaintext: 'Markdown: This is fourth parameter', - default_value: 'def', - mutable: false, - icon: '/icon/database.svg', - options: [], - required: true, - ephemeral: false, - }; - -export const MockTemplateVersionParameter5: TypesGen.TemplateVersionParameter = - { - name: 'fifth_parameter', - type: 'number', - description: 'This is fifth parameter', - description_plaintext: 'Markdown: This is fifth parameter', - default_value: '5', - mutable: true, - icon: '/icon/folder.svg', - options: [], - validation_min: 1, - validation_max: 10, - validation_monotonic: 'decreasing', - required: true, - ephemeral: false, - }; - -export const MockTemplateVersionVariable1: TypesGen.TemplateVersionVariable = { - name: 'first_variable', - description: 'This is first variable.', - type: 'string', - value: '', - default_value: 'abc', - required: false, - sensitive: false, -}; - -export const MockTemplateVersionVariable2: TypesGen.TemplateVersionVariable = { - name: 'second_variable', - description: 'This is second variable.', - type: 'number', - value: '5', - default_value: '3', - required: false, - sensitive: false, -}; - -export const MockTemplateVersionVariable3: TypesGen.TemplateVersionVariable = { - name: 'third_variable', - description: 'This is third variable.', - type: 'bool', - value: '', - default_value: 'false', - required: false, - sensitive: false, -}; - -export const MockTemplateVersionVariable4: TypesGen.TemplateVersionVariable = { - name: 'fourth_variable', - description: 'This is fourth variable.', - type: 'string', - value: 'defghijk', - default_value: '', - required: true, - sensitive: true, -}; - -export const MockTemplateVersionVariable5: TypesGen.TemplateVersionVariable = { - name: 'fifth_variable', - description: 'This is fifth variable.', - type: 'string', - value: '', - default_value: '', - required: true, - sensitive: false, -}; - -export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = { - name: 'test', - template_version_id: 'test-template-version', - rich_parameter_values: [], -}; - -export const MockWorkspaceRichParametersRequest: TypesGen.CreateWorkspaceRequest = - { - name: 'test', - template_version_id: 'test-template-version', - rich_parameter_values: [ - { - name: MockTemplateVersionParameter1.name, - value: MockTemplateVersionParameter1.default_value, - }, - ], - }; - -export const MockUserAgent = { - browser: 'Chrome 99.0.4844', - device: 'Other', - ip_address: '11.22.33.44', - os: 'Windows 10', -}; - -export const MockAuthMethodsPasswordOnly: TypesGen.AuthMethods = { - password: { enabled: true }, - github: { enabled: false }, - oidc: { enabled: false, signInText: '', iconUrl: '' }, -}; - -export const MockAuthMethodsPasswordTermsOfService: TypesGen.AuthMethods = { - terms_of_service_url: 'https://www.youtube.com/watch?v=C2f37Vb2NAE', - password: { enabled: true }, - github: { enabled: false }, - oidc: { enabled: false, signInText: '', iconUrl: '' }, -}; - -export const MockAuthMethodsExternal: TypesGen.AuthMethods = { - password: { enabled: false }, - github: { enabled: true }, - oidc: { - enabled: true, - signInText: 'Google', - iconUrl: '/icon/google.svg', - }, -}; - -export const MockAuthMethodsAll: TypesGen.AuthMethods = { - password: { enabled: true }, - github: { enabled: true }, - oidc: { - enabled: true, - signInText: 'Google', - iconUrl: '/icon/google.svg', - }, -}; - -export const MockGitSSHKey: TypesGen.GitSSHKey = { - user_id: '1fa0200f-7331-4524-a364-35770666caa7', - created_at: '2022-05-16T14:30:34.148205897Z', - updated_at: '2022-05-16T15:29:10.302441433Z', - public_key: - 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFJOQRIM7kE30rOzrfy+/+R+nQGCk7S9pioihy+2ARbq', -}; - -export const MockWorkspaceBuildLogs: TypesGen.ProvisionerJobLog[] = [ - { - id: 1, - created_at: '2022-05-19T16:45:31.005Z', - log_source: 'provisioner_daemon', - log_level: 'info', - stage: 'Setting up', - output: '', - }, - { - id: 2, - created_at: '2022-05-19T16:45:31.006Z', - log_source: 'provisioner_daemon', - log_level: 'info', - stage: 'Starting workspace', - output: '', - }, - { - id: 3, - created_at: '2022-05-19T16:45:31.072Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: '', - }, - { - id: 4, - created_at: '2022-05-19T16:45:31.073Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: 'Initializing the backend...', - }, - { - id: 5, - created_at: '2022-05-19T16:45:31.077Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: '', - }, - { - id: 6, - created_at: '2022-05-19T16:45:31.078Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: 'Initializing provider plugins...', - }, - { - id: 7, - created_at: '2022-05-19T16:45:31.078Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: '- Finding hashicorp/google versions matching "~\u003e 4.15"...', - }, - { - id: 8, - created_at: '2022-05-19T16:45:31.123Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: '- Finding coder/coder versions matching "0.3.4"...', - }, - { - id: 9, - created_at: '2022-05-19T16:45:31.137Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: '- Using hashicorp/google v4.21.0 from the shared cache directory', - }, - { - id: 10, - created_at: '2022-05-19T16:45:31.344Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: '- Using coder/coder v0.3.4 from the shared cache directory', - }, - { - id: 11, - created_at: '2022-05-19T16:45:31.388Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: '', - }, - { - id: 12, - created_at: '2022-05-19T16:45:31.388Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: - 'Terraform has created a lock file .terraform.lock.hcl to record the provider', - }, - { - id: 13, - created_at: '2022-05-19T16:45:31.389Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: - 'selections it made above. Include this file in your version control repository', - }, - { - id: 14, - created_at: '2022-05-19T16:45:31.389Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: - 'so that Terraform can guarantee to make the same selections by default when', - }, - { - id: 15, - created_at: '2022-05-19T16:45:31.39Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: 'you run "terraform init" in the future.', - }, - { - id: 16, - created_at: '2022-05-19T16:45:31.39Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: '', - }, - { - id: 17, - created_at: '2022-05-19T16:45:31.391Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Starting workspace', - output: 'Terraform has been successfully initialized!', - }, - { - id: 18, - created_at: '2022-05-19T16:45:31.42Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: 'Terraform 1.1.9', - }, - { - id: 19, - created_at: '2022-05-19T16:45:33.537Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: 'coder_agent.dev: Plan to create', - }, - { - id: 20, - created_at: '2022-05-19T16:45:33.537Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: 'google_compute_disk.root: Plan to create', - }, - { - id: 21, - created_at: '2022-05-19T16:45:33.538Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: 'google_compute_instance.dev[0]: Plan to create', - }, - { - id: 22, - created_at: '2022-05-19T16:45:33.539Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: 'Plan: 3 to add, 0 to change, 0 to destroy.', - }, - { - id: 23, - created_at: '2022-05-19T16:45:33.712Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: 'coder_agent.dev: Creating...', - }, - { - id: 24, - created_at: '2022-05-19T16:45:33.719Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: - 'coder_agent.dev: Creation complete after 0s [id=d07f5bdc-4a8d-4919-9cdb-0ac6ba9e64d6]', - }, - { - id: 25, - created_at: '2022-05-19T16:45:34.139Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: 'google_compute_disk.root: Creating...', - }, - { - id: 26, - created_at: '2022-05-19T16:45:44.14Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: 'google_compute_disk.root: Still creating... [10s elapsed]', - }, - { - id: 27, - created_at: '2022-05-19T16:45:47.106Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: - 'google_compute_disk.root: Creation complete after 13s [id=projects/bruno-coder-v2/zones/europe-west4-b/disks/coder-developer-bruno-dev-123-root]', - }, - { - id: 28, - created_at: '2022-05-19T16:45:47.118Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: 'google_compute_instance.dev[0]: Creating...', - }, - { - id: 29, - created_at: '2022-05-19T16:45:57.122Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: 'google_compute_instance.dev[0]: Still creating... [10s elapsed]', - }, - { - id: 30, - created_at: '2022-05-19T16:46:00.837Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: - 'google_compute_instance.dev[0]: Creation complete after 14s [id=projects/bruno-coder-v2/zones/europe-west4-b/instances/coder-developer-bruno-dev-123]', - }, - { - id: 31, - created_at: '2022-05-19T16:46:00.846Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: 'Apply complete! Resources: 3 added, 0 changed, 0 destroyed.', - }, - { - id: 32, - created_at: '2022-05-19T16:46:00.847Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Starting workspace', - output: 'Outputs: 0', - }, - { - id: 33, - created_at: '2022-05-19T16:46:02.283Z', - log_source: 'provisioner_daemon', - log_level: 'info', - stage: 'Cleaning Up', - output: '', - }, -]; - -export const MockWorkspaceExtendedBuildLogs: TypesGen.ProvisionerJobLog[] = [ - { - id: 938494, - created_at: '2023-08-25T19:07:43.331Z', - log_source: 'provisioner_daemon', - log_level: 'info', - stage: 'Setting up', - output: '', - }, - { - id: 938495, - created_at: '2023-08-25T19:07:43.331Z', - log_source: 'provisioner_daemon', - log_level: 'info', - stage: 'Parsing template parameters', - output: '', - }, - { - id: 938496, - created_at: '2023-08-25T19:07:43.339Z', - log_source: 'provisioner_daemon', - log_level: 'info', - stage: 'Detecting persistent resources', - output: '', - }, - { - id: 938497, - created_at: '2023-08-25T19:07:44.15Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: 'Initializing the backend...', - }, - { - id: 938498, - created_at: '2023-08-25T19:07:44.215Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: 'Initializing provider plugins...', - }, - { - id: 938499, - created_at: '2023-08-25T19:07:44.216Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: '- Finding coder/coder versions matching "~> 0.11.0"...', - }, - { - id: 938500, - created_at: '2023-08-25T19:07:44.668Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: '- Finding kreuzwerker/docker versions matching "~> 3.0.1"...', - }, - { - id: 938501, - created_at: '2023-08-25T19:07:44.722Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: '- Using coder/coder v0.11.1 from the shared cache directory', - }, - { - id: 938502, - created_at: '2023-08-25T19:07:44.857Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: '- Using kreuzwerker/docker v3.0.2 from the shared cache directory', - }, - { - id: 938503, - created_at: '2023-08-25T19:07:45.081Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: - 'Terraform has created a lock file .terraform.lock.hcl to record the provider', - }, - { - id: 938504, - created_at: '2023-08-25T19:07:45.081Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: - 'selections it made above. Include this file in your version control repository', - }, - { - id: 938505, - created_at: '2023-08-25T19:07:45.081Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: - 'so that Terraform can guarantee to make the same selections by default when', - }, - { - id: 938506, - created_at: '2023-08-25T19:07:45.082Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: 'you run "terraform init" in the future.', - }, - { - id: 938507, - created_at: '2023-08-25T19:07:45.083Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: 'Terraform has been successfully initialized!', - }, - { - id: 938508, - created_at: '2023-08-25T19:07:45.084Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: - 'You may now begin working with Terraform. Try running "terraform plan" to see', - }, - { - id: 938509, - created_at: '2023-08-25T19:07:45.084Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: - 'any changes that are required for your infrastructure. All Terraform commands', - }, - { - id: 938510, - created_at: '2023-08-25T19:07:45.084Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: 'should now work.', - }, - { - id: 938511, - created_at: '2023-08-25T19:07:45.084Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: - 'If you ever set or change modules or backend configuration for Terraform,', - }, - { - id: 938512, - created_at: '2023-08-25T19:07:45.084Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: - 'rerun this command to reinitialize your working directory. If you forget, other', - }, - { - id: 938513, - created_at: '2023-08-25T19:07:45.084Z', - log_source: 'provisioner', - log_level: 'debug', - stage: 'Detecting persistent resources', - output: 'commands will detect it and remind you to do so if necessary.', - }, - { - id: 938514, - created_at: '2023-08-25T19:07:45.143Z', - log_source: 'provisioner', - log_level: 'info', - stage: 'Detecting persistent resources', - output: 'Terraform 1.1.9', - }, - { - id: 938515, - created_at: '2023-08-25T19:07:46.297Z', - log_source: 'provisioner', - log_level: 'warn', - stage: 'Detecting persistent resources', - output: 'Warning: Argument is deprecated', - }, - { - id: 938516, - created_at: '2023-08-25T19:07:46.297Z', - log_source: 'provisioner', - log_level: 'warn', - stage: 'Detecting persistent resources', - output: 'on devcontainer-on-docker.tf line 15, in provider "coder":', - }, - { - id: 938517, - created_at: '2023-08-25T19:07:46.297Z', - log_source: 'provisioner', - log_level: 'warn', - stage: 'Detecting persistent resources', - output: ' 15: feature_use_managed_variables = true', - }, - { - id: 938518, - created_at: '2023-08-25T19:07:46.297Z', - log_source: 'provisioner', - log_level: 'warn', - stage: 'Detecting persistent resources', - output: '', - }, - { - id: 938519, - created_at: '2023-08-25T19:07:46.297Z', - log_source: 'provisioner', - log_level: 'warn', - stage: 'Detecting persistent resources', - output: - 'Terraform variables are now exclusively utilized for template-wide variables after the removal of support for legacy parameters.', - }, - { - id: 938520, - created_at: '2023-08-25T19:07:46.3Z', - log_source: 'provisioner', - log_level: 'error', - stage: 'Detecting persistent resources', - output: 'Error: ephemeral parameter requires the default property', - }, - { - id: 938521, - created_at: '2023-08-25T19:07:46.3Z', - log_source: 'provisioner', - log_level: 'error', - stage: 'Detecting persistent resources', - output: - 'on devcontainer-on-docker.tf line 27, in data "coder_parameter" "another_one":', - }, - { - id: 938522, - created_at: '2023-08-25T19:07:46.3Z', - log_source: 'provisioner', - log_level: 'error', - stage: 'Detecting persistent resources', - output: ' 27: data "coder_parameter" "another_one" {', - }, - { - id: 938523, - created_at: '2023-08-25T19:07:46.301Z', - log_source: 'provisioner', - log_level: 'error', - stage: 'Detecting persistent resources', - output: '', - }, - { - id: 938524, - created_at: '2023-08-25T19:07:46.301Z', - log_source: 'provisioner', - log_level: 'error', - stage: 'Detecting persistent resources', - output: '', - }, - { - id: 938525, - created_at: '2023-08-25T19:07:46.303Z', - log_source: 'provisioner', - log_level: 'warn', - stage: 'Detecting persistent resources', - output: 'Warning: Argument is deprecated', - }, - { - id: 938526, - created_at: '2023-08-25T19:07:46.303Z', - log_source: 'provisioner', - log_level: 'warn', - stage: 'Detecting persistent resources', - output: 'on devcontainer-on-docker.tf line 15, in provider "coder":', - }, - { - id: 938527, - created_at: '2023-08-25T19:07:46.303Z', - log_source: 'provisioner', - log_level: 'warn', - stage: 'Detecting persistent resources', - output: ' 15: feature_use_managed_variables = true', - }, - { - id: 938528, - created_at: '2023-08-25T19:07:46.303Z', - log_source: 'provisioner', - log_level: 'warn', - stage: 'Detecting persistent resources', - output: '', - }, - { - id: 938529, - created_at: '2023-08-25T19:07:46.303Z', - log_source: 'provisioner', - log_level: 'warn', - stage: 'Detecting persistent resources', - output: - 'Terraform variables are now exclusively utilized for template-wide variables after the removal of support for legacy parameters.', - }, - { - id: 938530, - created_at: '2023-08-25T19:07:46.311Z', - log_source: 'provisioner_daemon', - log_level: 'info', - stage: 'Cleaning Up', - output: '', - }, -]; - -export const MockCancellationMessage = { - message: 'Job successfully canceled', -}; - -type MockAPIInput = { - message?: string; - detail?: string; - validations?: FieldError[]; -}; - -type MockAPIOutput = { - isAxiosError: true; - response: { - data: { - message: string; - detail: string | undefined; - validations: FieldError[] | undefined; - }; - }; -}; - -export const mockApiError = ({ - message = 'Something went wrong.', - detail, - validations, -}: MockAPIInput): MockAPIOutput => ({ - // This is how axios can check if it is an axios error when calling isAxiosError - isAxiosError: true, - response: { - data: { - message, - detail, - validations, - }, - }, -}); - -export const MockEntitlements: TypesGen.Entitlements = { - errors: [], - warnings: [], - has_license: false, - features: withDefaultFeatures({ - workspace_batch_actions: { - enabled: true, - entitlement: 'entitled', - }, - }), - require_telemetry: false, - trial: false, - refreshed_at: '2022-05-20T16:45:57.122Z', -}; - -export const MockEntitlementsWithWarnings: TypesGen.Entitlements = { - errors: [], - warnings: ['You are over your active user limit.', 'And another thing.'], - has_license: true, - trial: false, - require_telemetry: false, - refreshed_at: '2022-05-20T16:45:57.122Z', - features: withDefaultFeatures({ - user_limit: { - enabled: true, - entitlement: 'grace_period', - limit: 100, - actual: 102, - }, - audit_log: { - enabled: true, - entitlement: 'entitled', - }, - browser_only: { - enabled: true, - entitlement: 'entitled', - }, - }), -}; - -export const MockEntitlementsWithAuditLog: TypesGen.Entitlements = { - errors: [], - warnings: [], - has_license: true, - require_telemetry: false, - trial: false, - refreshed_at: '2022-05-20T16:45:57.122Z', - features: withDefaultFeatures({ - audit_log: { - enabled: true, - entitlement: 'entitled', - }, - }), -}; - -export const MockEntitlementsWithScheduling: TypesGen.Entitlements = { - errors: [], - warnings: [], - has_license: true, - require_telemetry: false, - trial: false, - refreshed_at: '2022-05-20T16:45:57.122Z', - features: withDefaultFeatures({ - advanced_template_scheduling: { - enabled: true, - entitlement: 'entitled', - }, - }), -}; - -export const MockEntitlementsWithUserLimit: TypesGen.Entitlements = { - errors: [], - warnings: [], - has_license: true, - require_telemetry: false, - trial: false, - refreshed_at: '2022-05-20T16:45:57.122Z', - features: withDefaultFeatures({ - user_limit: { - enabled: true, - entitlement: 'entitled', - limit: 25, - }, - }), -}; - -export const MockExperiments: TypesGen.Experiment[] = []; - -export const MockAuditLog: TypesGen.AuditLog = { - id: 'fbd2116a-8961-4954-87ae-e4575bd29ce0', - request_id: '53bded77-7b9d-4e82-8771-991a34d759f9', - time: '2022-05-19T16:45:57.122Z', - organization_id: MockOrganization.id, - ip: '127.0.0.1', - user_agent: - '"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"', - resource_type: 'workspace', - resource_id: 'ef8d1cf4-82de-4fd9-8980-047dad6d06b5', - resource_target: 'bruno-dev', - resource_icon: '', - action: 'create', - diff: { - ttl: { - old: 0, - new: 3600000000000, - secret: false, - }, - }, - status_code: 200, - additional_fields: {}, - description: '{user} created workspace {target}', - user: MockUser, - resource_link: '/@admin/bruno-dev', - is_deleted: false, -}; - -export const MockAuditLog2: TypesGen.AuditLog = { - ...MockAuditLog, - id: '53bded77-7b9d-4e82-8771-991a34d759f9', - action: 'write', - time: '2022-05-20T16:45:57.122Z', - description: '{user} updated workspace {target}', - diff: { - workspace_name: { - old: 'old-workspace-name', - new: MockWorkspace.name, - secret: false, - }, - workspace_auto_off: { - old: true, - new: false, - secret: false, - }, - template_version_id: { - old: 'fbd2116a-8961-4954-87ae-e4575bd29ce0', - new: '53bded77-7b9d-4e82-8771-991a34d759f9', - secret: false, - }, - roles: { - old: null, - new: ['admin', 'auditor'], - secret: false, - }, - }, -}; - -export const MockWorkspaceCreateAuditLogForDifferentOwner = { - ...MockAuditLog, - additional_fields: { - workspace_owner: 'Member', - }, -}; - -export const MockAuditLogWithWorkspaceBuild: TypesGen.AuditLog = { - ...MockAuditLog, - id: 'f90995bf-4a2b-4089-b597-e66e025e523e', - request_id: '61555889-2875-475c-8494-f7693dd5d75b', - action: 'stop', - resource_type: 'workspace_build', - description: '{user} stopped build for workspace {target}', - additional_fields: { - workspace_name: 'test2', - }, -}; - -export const MockAuditLogWithDeletedResource: TypesGen.AuditLog = { - ...MockAuditLog, - is_deleted: true, -}; - -export const MockAuditLogGitSSH: TypesGen.AuditLog = { - ...MockAuditLog, - diff: { - private_key: { - old: '', - new: '', - secret: true, - }, - public_key: { - old: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINRUPjBSNtOAnL22+r07OSu9t3Lnm8/5OX8bRHECKS9g\n', - new: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEwoUPJPMekuSzMZyV0rA82TGGNzw/Uj/dhLbwiczTpV\n', - secret: false, - }, - }, -}; - -export const MockAuditOauthConvert: TypesGen.AuditLog = { - ...MockAuditLog, - resource_type: 'convert_login', - resource_target: 'oidc', - action: 'create', - status_code: 201, - description: '{user} created login type conversion to {target}}', - diff: { - created_at: { - old: '0001-01-01T00:00:00Z', - new: '2023-06-20T20:44:54.243019Z', - secret: false, - }, - expires_at: { - old: '0001-01-01T00:00:00Z', - new: '2023-06-20T20:49:54.243019Z', - secret: false, - }, - state_string: { - old: '', - new: '', - secret: true, - }, - to_type: { - old: '', - new: 'oidc', - secret: false, - }, - user_id: { - old: '', - new: 'dc790496-eaec-4f88-a53f-8ce1f61a1fff', - secret: false, - }, - }, -}; - -export const MockAuditLogSuccessfulLogin: TypesGen.AuditLog = { - ...MockAuditLog, - resource_type: 'api_key', - resource_target: '', - action: 'login', - status_code: 201, - description: '{user} logged in', -}; - -export const MockAuditLogUnsuccessfulLoginKnownUser: TypesGen.AuditLog = { - ...MockAuditLogSuccessfulLogin, - status_code: 401, -}; - -export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = { - credits_consumed: 0, - budget: 100, -}; - -export const MockGroup: TypesGen.Group = { - id: 'fbd2116a-8961-4954-87ae-e4575bd29ce0', - name: 'Front-End', - display_name: 'Front-End', - avatar_url: 'https://example.com', - organization_id: MockOrganization.id, - members: [MockUser, MockUser2], - quota_allowance: 5, - source: 'user', -}; - -const everyOneGroup = (organizationId: string): TypesGen.Group => ({ - id: organizationId, - name: 'Everyone', - display_name: '', - organization_id: organizationId, - members: [], - avatar_url: '', - quota_allowance: 0, - source: 'user', -}); - -export const MockTemplateACL: TypesGen.TemplateACL = { - group: [ - { ...everyOneGroup(MockOrganization.id), role: 'use' }, - { ...MockGroup, role: 'admin' }, - ], - users: [{ ...MockUser, role: 'use' }], -}; - -export const MockTemplateACLEmpty: TypesGen.TemplateACL = { - group: [], - users: [], -}; - -export const MockTemplateExample: TypesGen.TemplateExample = { - id: 'aws-windows', - url: 'https://github.com/coder/coder/tree/main/examples/templates/aws-windows', - name: 'Develop in an ECS-hosted container', - description: 'Get started with Linux development on AWS ECS.', - markdown: - '\n# aws-ecs\n\nThis is a sample template for running a Coder workspace on ECS. It assumes there\nis a pre-existing ECS cluster with EC2-based compute to host the workspace.\n\n## Architecture\n\nThis workspace is built using the following AWS resources:\n\n- Task definition - the container definition, includes the image, command, volume(s)\n- ECS service - manages the task definition\n\n## code-server\n\n`code-server` is installed via the `startup_script` argument in the `coder_agent`\nresource block. The `coder_app` resource is defined to access `code-server` through\nthe dashboard UI over `localhost:13337`.\n', - icon: '/icon/aws.svg', - tags: ['aws', 'cloud'], -}; - -export const MockTemplateExample2: TypesGen.TemplateExample = { - id: 'aws-linux', - url: 'https://github.com/coder/coder/tree/main/examples/templates/aws-linux', - name: 'Develop in Linux on AWS EC2', - description: 'Get started with Linux development on AWS EC2.', - markdown: - '\n# aws-linux\n\nTo get started, run `coder templates init`. When prompted, select this template.\nFollow the on-screen instructions to proceed.\n\n## Authentication\n\nThis template assumes that coderd is run in an environment that is authenticated\nwith AWS. For example, run `aws configure import` to import credentials on the\nsystem and user running coderd. For other ways to authenticate [consult the\nTerraform docs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#authentication-and-configuration).\n\n## Required permissions / policy\n\nThe following sample policy allows Coder to create EC2 instances and modify\ninstances provisioned by Coder:\n\n```json\n{\n "Version": "2012-10-17",\n "Statement": [\n {\n "Sid": "VisualEditor0",\n "Effect": "Allow",\n "Action": [\n "ec2:GetDefaultCreditSpecification",\n "ec2:DescribeIamInstanceProfileAssociations",\n "ec2:DescribeTags",\n "ec2:CreateTags",\n "ec2:RunInstances",\n "ec2:DescribeInstanceCreditSpecifications",\n "ec2:DescribeImages",\n "ec2:ModifyDefaultCreditSpecification",\n "ec2:DescribeVolumes"\n ],\n "Resource": "*"\n },\n {\n "Sid": "CoderResources",\n "Effect": "Allow",\n "Action": [\n "ec2:DescribeInstances",\n "ec2:DescribeInstanceAttribute",\n "ec2:UnmonitorInstances",\n "ec2:TerminateInstances",\n "ec2:StartInstances",\n "ec2:StopInstances",\n "ec2:DeleteTags",\n "ec2:MonitorInstances",\n "ec2:CreateTags",\n "ec2:RunInstances",\n "ec2:ModifyInstanceAttribute",\n "ec2:ModifyInstanceCreditSpecification"\n ],\n "Resource": "arn:aws:ec2:*:*:instance/*",\n "Condition": {\n "StringEquals": {\n "aws:ResourceTag/Coder_Provisioned": "true"\n }\n }\n }\n ]\n}\n```\n\n## code-server\n\n`code-server` is installed via the `startup_script` argument in the `coder_agent`\nresource block. The `coder_app` resource is defined to access `code-server` through\nthe dashboard UI over `localhost:13337`.\n', - icon: '/icon/aws.svg', - tags: ['aws', 'cloud'], -}; - -export const MockPermissions: Permissions = { - createGroup: true, - createTemplates: true, - createUser: true, - deleteTemplates: true, - updateTemplates: true, - readAllUsers: true, - updateUsers: true, - viewAuditLog: true, - viewDeploymentValues: true, - viewUpdateCheck: true, - viewDeploymentStats: true, - viewExternalAuthConfig: true, - editWorkspaceProxies: true, -}; - -export const MockDeploymentConfig: DeploymentConfig = { - config: { - enable_terraform_debug_mode: true, - }, - options: [], -}; - -export const MockAppearanceConfig: TypesGen.AppearanceConfig = { - application_name: '', - logo_url: '', - service_banner: { - enabled: false, - }, - notification_banners: [], -}; - -export const MockWorkspaceBuildParameter1: TypesGen.WorkspaceBuildParameter = { - name: MockTemplateVersionParameter1.name, - value: 'mock-abc', -}; - -export const MockWorkspaceBuildParameter2: TypesGen.WorkspaceBuildParameter = { - name: MockTemplateVersionParameter2.name, - value: '3', -}; - -export const MockWorkspaceBuildParameter3: TypesGen.WorkspaceBuildParameter = { - name: MockTemplateVersionParameter3.name, - value: 'my-database', -}; - -export const MockWorkspaceBuildParameter4: TypesGen.WorkspaceBuildParameter = { - name: MockTemplateVersionParameter4.name, - value: 'immutable-value', -}; - -export const MockWorkspaceBuildParameter5: TypesGen.WorkspaceBuildParameter = { - name: MockTemplateVersionParameter5.name, - value: '5', -}; - -export const MockTemplateVersionExternalAuthGithub: TypesGen.TemplateVersionExternalAuth = - { - id: 'github', - type: 'github', - authenticate_url: 'https://example.com/external-auth/github', - authenticated: false, - display_icon: '/icon/github.svg', - display_name: 'GitHub', - }; - -export const MockTemplateVersionExternalAuthGithubAuthenticated: TypesGen.TemplateVersionExternalAuth = - { - id: 'github', - type: 'github', - authenticate_url: 'https://example.com/external-auth/github', - authenticated: true, - display_icon: '/icon/github.svg', - display_name: 'GitHub', - }; - -export const MockDeploymentStats: TypesGen.DeploymentStats = { - aggregated_from: '2023-03-06T19:08:55.211625Z', - collected_at: '2023-03-06T19:12:55.211625Z', - next_update_at: '2023-03-06T19:20:55.211625Z', - session_count: { - vscode: 128, - jetbrains: 5, - ssh: 32, - reconnecting_pty: 15, - }, - workspaces: { - building: 15, - failed: 12, - pending: 5, - running: 32, - stopped: 16, - connection_latency_ms: { - P50: 32.56, - P95: 15.23, - }, - rx_bytes: 15613513253, - tx_bytes: 36113513253, - }, -}; - -export const MockDeploymentSSH: TypesGen.SSHConfigResponse = { - hostname_prefix: ' coder.', - ssh_config_options: {}, -}; - -export const MockWorkspaceAgentLogs: TypesGen.WorkspaceAgentLog[] = [ - { - id: 166663, - created_at: '2023-05-04T11:30:41.402072Z', - output: '+ curl -fsSL https://code-server.dev/install.sh', - level: 'info', - source_id: MockWorkspaceAgentLogSource.id, - }, - { - id: 166664, - created_at: '2023-05-04T11:30:41.40228Z', - output: - '+ sh -s -- --method=standalone --prefix=/tmp/code-server --version 4.8.3', - level: 'info', - source_id: MockWorkspaceAgentLogSource.id, - }, - { - id: 166665, - created_at: '2023-05-04T11:30:42.590731Z', - output: 'Ubuntu 22.04.2 LTS', - level: 'info', - source_id: MockWorkspaceAgentLogSource.id, - }, - { - id: 166666, - created_at: '2023-05-04T11:30:42.593686Z', - output: 'Installing v4.8.3 of the amd64 release from GitHub.', - level: 'info', - source_id: MockWorkspaceAgentLogSource.id, - }, -]; - -export const MockLicenseResponse: GetLicensesResponse[] = [ - { - id: 1, - uploaded_at: '1660104000', - expires_at: '3420244800', // expires on 5/20/2078 - uuid: '1', - claims: { - trial: false, - all_features: true, - version: 1, - features: {}, - license_expires: 3420244800, - }, - }, - { - id: 1, - uploaded_at: '1660104000', - expires_at: '1660104000', // expired on 8/10/2022 - uuid: '1', - claims: { - trial: false, - all_features: true, - version: 1, - features: {}, - license_expires: 1660104000, - }, - }, - { - id: 1, - uploaded_at: '1682346425', - expires_at: '1682346425', // expired on 4/24/2023 - uuid: '1', - claims: { - trial: false, - all_features: true, - version: 1, - features: {}, - license_expires: 1682346425, - }, - }, -]; - -export const MockHealth: TypesGen.HealthcheckReport = { - time: '2023-08-01T16:51:03.29792825Z', - healthy: true, - severity: 'ok', - failing_sections: [], - derp: { - healthy: true, - severity: 'ok', - warnings: [], - dismissed: false, - regions: { - '999': { - healthy: true, - severity: 'ok', - warnings: [], - region: { - EmbeddedRelay: true, - RegionID: 999, - RegionCode: 'coder', - RegionName: 'Council Bluffs, Iowa', - Nodes: [ - { - Name: '999stun0', - RegionID: 999, - HostName: 'stun.l.google.com', - STUNPort: 19302, - STUNOnly: true, - }, - { - Name: '999b', - RegionID: 999, - HostName: 'dev.coder.com', - STUNPort: -1, - DERPPort: 443, - }, - ], - }, - node_reports: [ - { - healthy: true, - severity: 'ok', - warnings: [], - node: { - Name: '999stun0', - RegionID: 999, - HostName: 'stun.l.google.com', - STUNPort: 19302, - STUNOnly: true, - }, - node_info: { - TokenBucketBytesPerSecond: 0, - TokenBucketBytesBurst: 0, - }, - can_exchange_messages: false, - round_trip_ping: '0', - round_trip_ping_ms: 0, - uses_websocket: false, - client_logs: [], - client_errs: [], - stun: { - Enabled: true, - CanSTUN: true, - }, - }, - { - healthy: true, - severity: 'ok', - warnings: [], - node: { - Name: '999b', - RegionID: 999, - HostName: 'dev.coder.com', - STUNPort: -1, - DERPPort: 443, - }, - node_info: { - TokenBucketBytesPerSecond: 0, - TokenBucketBytesBurst: 0, - }, - can_exchange_messages: true, - round_trip_ping: '7674330', - round_trip_ping_ms: 7674330, - uses_websocket: false, - client_logs: [ - [ - 'derphttp.Client.Connect: connecting to https://dev.coder.com/derp', - ], - [ - 'derphttp.Client.Connect: connecting to https://dev.coder.com/derp', - ], - ], - client_errs: [ - ['recv derp message: derphttp.Client closed'], - [ - 'connect to derp: derphttp.Client.Connect connect to : context deadline exceeded: read tcp 10.44.1.150:59546->149.248.214.149:443: use of closed network connection', - 'connect to derp: derphttp.Client closed', - 'connect to derp: derphttp.Client closed', - 'connect to derp: derphttp.Client closed', - 'connect to derp: derphttp.Client closed', - "couldn't connect after 5 tries, last error: couldn't connect after 5 tries, last error: derphttp.Client closed", - ], - ], - stun: { - Enabled: false, - CanSTUN: false, - }, - }, - ], - }, - '10007': { - healthy: true, - severity: 'ok', - warnings: [], - region: { - EmbeddedRelay: false, - RegionID: 10007, - RegionCode: 'coder_sydney', - RegionName: 'sydney', - Nodes: [ - { - Name: '10007stun0', - RegionID: 10007, - HostName: 'stun.l.google.com', - STUNPort: 19302, - STUNOnly: true, - }, - { - Name: '10007a', - RegionID: 10007, - HostName: 'sydney.dev.coder.com', - STUNPort: -1, - DERPPort: 443, - }, - ], - }, - node_reports: [ - { - healthy: true, - severity: 'ok', - warnings: [], - node: { - Name: '10007stun0', - RegionID: 10007, - HostName: 'stun.l.google.com', - STUNPort: 19302, - STUNOnly: true, - }, - node_info: { - TokenBucketBytesPerSecond: 0, - TokenBucketBytesBurst: 0, - }, - can_exchange_messages: false, - round_trip_ping: '0', - round_trip_ping_ms: 0, - uses_websocket: false, - client_logs: [], - client_errs: [], - stun: { - Enabled: true, - CanSTUN: true, - }, - }, - { - healthy: true, - severity: 'ok', - warnings: [], - node: { - Name: '10007a', - RegionID: 10007, - HostName: 'sydney.dev.coder.com', - STUNPort: -1, - DERPPort: 443, - }, - node_info: { - TokenBucketBytesPerSecond: 0, - TokenBucketBytesBurst: 0, - }, - can_exchange_messages: true, - round_trip_ping: '170527034', - round_trip_ping_ms: 170527034, - uses_websocket: false, - client_logs: [ - [ - 'derphttp.Client.Connect: connecting to https://sydney.dev.coder.com/derp', - ], - [ - 'derphttp.Client.Connect: connecting to https://sydney.dev.coder.com/derp', - ], - ], - client_errs: [[], []], - stun: { - Enabled: false, - CanSTUN: false, - }, - }, - ], - }, - '10008': { - healthy: true, - severity: 'ok', - warnings: [], - region: { - EmbeddedRelay: false, - RegionID: 10008, - RegionCode: 'coder_europe-frankfurt', - RegionName: 'europe-frankfurt', - Nodes: [ - { - Name: '10008stun0', - RegionID: 10008, - HostName: 'stun.l.google.com', - STUNPort: 19302, - STUNOnly: true, - }, - { - Name: '10008a', - RegionID: 10008, - HostName: 'europe.dev.coder.com', - STUNPort: -1, - DERPPort: 443, - }, - ], - }, - node_reports: [ - { - healthy: true, - severity: 'ok', - warnings: [], - node: { - Name: '10008stun0', - RegionID: 10008, - HostName: 'stun.l.google.com', - STUNPort: 19302, - STUNOnly: true, - }, - node_info: { - TokenBucketBytesPerSecond: 0, - TokenBucketBytesBurst: 0, - }, - can_exchange_messages: false, - round_trip_ping: '0', - round_trip_ping_ms: 0, - uses_websocket: false, - client_logs: [], - client_errs: [], - stun: { - Enabled: true, - CanSTUN: true, - }, - }, - { - healthy: true, - severity: 'ok', - warnings: [], - node: { - Name: '10008a', - RegionID: 10008, - HostName: 'europe.dev.coder.com', - STUNPort: -1, - DERPPort: 443, - }, - node_info: { - TokenBucketBytesPerSecond: 0, - TokenBucketBytesBurst: 0, - }, - can_exchange_messages: true, - round_trip_ping: '111329690', - round_trip_ping_ms: 111329690, - uses_websocket: false, - client_logs: [ - [ - 'derphttp.Client.Connect: connecting to https://europe.dev.coder.com/derp', - ], - [ - 'derphttp.Client.Connect: connecting to https://europe.dev.coder.com/derp', - ], - ], - client_errs: [[], []], - stun: { - Enabled: false, - CanSTUN: false, - }, - }, - ], - }, - '10009': { - healthy: true, - severity: 'ok', - warnings: [], - region: { - EmbeddedRelay: false, - RegionID: 10009, - RegionCode: 'coder_brazil-saopaulo', - RegionName: 'brazil-saopaulo', - Nodes: [ - { - Name: '10009stun0', - RegionID: 10009, - HostName: 'stun.l.google.com', - STUNPort: 19302, - STUNOnly: true, - }, - { - Name: '10009a', - RegionID: 10009, - HostName: 'brazil.dev.coder.com', - STUNPort: -1, - DERPPort: 443, - }, - ], - }, - node_reports: [ - { - healthy: true, - severity: 'ok', - warnings: [], - node: { - Name: '10009stun0', - RegionID: 10009, - HostName: 'stun.l.google.com', - STUNPort: 19302, - STUNOnly: true, - }, - node_info: { - TokenBucketBytesPerSecond: 0, - TokenBucketBytesBurst: 0, - }, - can_exchange_messages: false, - round_trip_ping: '0', - round_trip_ping_ms: 0, - uses_websocket: false, - client_logs: [], - client_errs: [], - stun: { - Enabled: true, - CanSTUN: true, - }, - }, - { - healthy: true, - severity: 'ok', - warnings: [], - node: { - Name: '10009a', - RegionID: 10009, - HostName: 'brazil.dev.coder.com', - STUNPort: -1, - DERPPort: 443, - }, - node_info: { - TokenBucketBytesPerSecond: 0, - TokenBucketBytesBurst: 0, - }, - can_exchange_messages: true, - round_trip_ping: '138185506', - round_trip_ping_ms: 138185506, - uses_websocket: false, - client_logs: [ - [ - 'derphttp.Client.Connect: connecting to https://brazil.dev.coder.com/derp', - ], - [ - 'derphttp.Client.Connect: connecting to https://brazil.dev.coder.com/derp', - ], - ], - client_errs: [[], []], - stun: { - Enabled: false, - CanSTUN: false, - }, - }, - ], - }, - }, - netcheck: { - UDP: true, - IPv6: false, - IPv4: true, - IPv6CanSend: false, - IPv4CanSend: true, - OSHasIPv6: true, - ICMPv4: false, - MappingVariesByDestIP: false, - HairPinning: null, - UPnP: false, - PMP: false, - PCP: false, - PreferredDERP: 999, - RegionLatency: { - '999': 1638180, - '10007': 174853022, - '10008': 112142029, - '10009': 138855606, - }, - RegionV4Latency: { - '999': 1638180, - '10007': 174853022, - '10008': 112142029, - '10009': 138855606, - }, - RegionV6Latency: {}, - GlobalV4: '34.71.26.24:55368', - GlobalV6: '', - CaptivePortal: null, - }, - netcheck_logs: [ - 'netcheck: netcheck.runProbe: got STUN response for 10007stun0 from 34.71.26.24:55368 (9b07930007da49dd7df79bc7) in 1.791799ms', - 'netcheck: netcheck.runProbe: got STUN response for 999stun0 from 34.71.26.24:55368 (7397fec097f1d5b01364566b) in 1.791529ms', - 'netcheck: netcheck.runProbe: got STUN response for 10008stun0 from 34.71.26.24:55368 (1fdaaa016ca386485f097f68) in 2.192899ms', - 'netcheck: netcheck.runProbe: got STUN response for 10009stun0 from 34.71.26.24:55368 (2596fe60895fbd9542823a76) in 2.146459ms', - 'netcheck: netcheck.runProbe: got STUN response for 10007stun0 from 34.71.26.24:55368 (19ec320f3b76e8b027b06d3e) in 2.139619ms', - 'netcheck: netcheck.runProbe: got STUN response for 999stun0 from 34.71.26.24:55368 (a17973bc57c35e606c0f46f5) in 2.131089ms', - 'netcheck: netcheck.runProbe: got STUN response for 10008stun0 from 34.71.26.24:55368 (c958e15209d139a6e410f13a) in 2.127549ms', - 'netcheck: netcheck.runProbe: got STUN response for 10009stun0 from 34.71.26.24:55368 (284a1b64dff22f40a3514524) in 2.107549ms', - 'netcheck: [v1] measureAllICMPLatency: listen ip4:icmp 0.0.0.0: socket: operation not permitted', - 'netcheck: [v1] report: udp=true v6=false v6os=true mapvarydest=false hair= portmap= v4a=34.71.26.24:55368 derp=999 derpdist=999v4:2ms,10007v4:175ms,10008v4:112ms,10009v4:139ms', - ], - }, - access_url: { - healthy: true, - severity: 'ok', - warnings: [], - dismissed: false, - access_url: 'https://dev.coder.com', - reachable: true, - status_code: 200, - healthz_response: 'OK', - }, - websocket: { - healthy: true, - severity: 'ok', - warnings: [], - dismissed: false, - body: '', - code: 101, - }, - database: { - healthy: true, - severity: 'ok', - warnings: [], - dismissed: false, - reachable: true, - latency: '92570', - latency_ms: 92570, - threshold_ms: 92570, - }, - workspace_proxy: { - healthy: true, - severity: 'warning', - warnings: [ - { - code: 'EWP04', - message: - 'unhealthy: request to proxy failed: Get "http://127.0.0.1:3001/healthz-report": dial tcp 127.0.0.1:3001: connect: connection refused', - }, - ], - dismissed: false, - error: undefined, - workspace_proxies: { - regions: [ - { - id: '1a3e5eb8-d785-4f7d-9188-2eeab140cd06', - name: 'primary', - display_name: 'Council Bluffs, Iowa', - icon_url: '/emojis/1f3e1.png', - healthy: true, - path_app_url: 'https://dev.coder.com', - wildcard_hostname: '*--apps.dev.coder.com', - derp_enabled: false, - derp_only: false, - status: { - status: 'ok', - report: { - errors: [], - warnings: [], - }, - checked_at: '2023-12-05T14:14:05.829032482Z', - }, - created_at: '0001-01-01T00:00:00Z', - updated_at: '0001-01-01T00:00:00Z', - deleted: false, - version: '', - }, - { - id: '2876ab4d-bcee-4643-944f-d86323642840', - name: 'sydney', - display_name: 'Sydney GCP', - icon_url: '/emojis/1f1e6-1f1fa.png', - healthy: true, - path_app_url: 'https://sydney.dev.coder.com', - wildcard_hostname: '*--apps.sydney.dev.coder.com', - derp_enabled: true, - derp_only: false, - status: { - status: 'ok', - report: { - errors: [], - warnings: [], - }, - checked_at: '2023-12-05T14:14:05.250322277Z', - }, - created_at: '2023-05-01T19:15:56.606593Z', - updated_at: '2023-12-05T14:13:36.647535Z', - deleted: false, - version: 'v2.5.0-devel+5fad61102', - }, - { - id: '9d786ce0-55b1-4ace-8acc-a4672ff8d41f', - name: 'europe-frankfurt', - display_name: 'Europe GCP (Frankfurt)', - icon_url: '/emojis/1f1e9-1f1ea.png', - healthy: true, - path_app_url: 'https://europe.dev.coder.com', - wildcard_hostname: '*--apps.europe.dev.coder.com', - derp_enabled: true, - derp_only: false, - status: { - status: 'ok', - report: { - errors: [], - warnings: [], - }, - checked_at: '2023-12-05T14:14:05.250322277Z', - }, - created_at: '2023-05-01T20:34:11.114005Z', - updated_at: '2023-12-05T14:13:45.941716Z', - deleted: false, - version: 'v2.5.0-devel+5fad61102', - }, - { - id: '2e209786-73b1-4838-ba78-e01c9334450a', - name: 'brazil-saopaulo', - display_name: 'Brazil GCP (Sao Paulo)', - icon_url: '/emojis/1f1e7-1f1f7.png', - healthy: true, - path_app_url: 'https://brazil.dev.coder.com', - wildcard_hostname: '*--apps.brazil.dev.coder.com', - derp_enabled: true, - derp_only: false, - status: { - status: 'ok', - report: { - errors: [], - warnings: [], - }, - checked_at: '2023-12-05T14:14:05.250322277Z', - }, - created_at: '2023-05-01T20:41:02.76448Z', - updated_at: '2023-12-05T14:13:41.968568Z', - deleted: false, - version: 'v2.5.0-devel+5fad61102', - }, - { - id: 'c272e80c-0cce-49d6-9782-1b5cf90398e8', - name: 'unregistered', - display_name: 'UnregisteredProxy', - icon_url: '/emojis/274c.png', - healthy: false, - path_app_url: '', - wildcard_hostname: '', - derp_enabled: true, - derp_only: false, - status: { - status: 'unregistered', - report: { - errors: [], - warnings: [], - }, - checked_at: '2023-12-05T14:14:05.250322277Z', - }, - created_at: '2023-07-10T14:51:11.539222Z', - updated_at: '2023-07-10T14:51:11.539223Z', - deleted: false, - version: '', - }, - { - id: 'a3efbff1-587b-4677-80a4-dc4f892fed3e', - name: 'unhealthy', - display_name: 'Unhealthy', - icon_url: '/emojis/1f92e.png', - healthy: false, - path_app_url: 'http://127.0.0.1:3001', - wildcard_hostname: '', - derp_enabled: true, - derp_only: false, - status: { - status: 'unreachable', - report: { - errors: [ - 'request to proxy failed: Get "http://127.0.0.1:3001/healthz-report": dial tcp 127.0.0.1:3001: connect: connection refused', - ], - warnings: [], - }, - checked_at: '2023-12-05T14:14:05.250322277Z', - }, - created_at: '2023-07-10T14:51:48.407017Z', - updated_at: '2023-07-10T14:51:57.993682Z', - deleted: false, - version: '', - }, - { - id: 'b6cefb69-cb6f-46e2-9c9c-39c089fb7e42', - name: 'paris-coder', - display_name: 'Europe (Paris)', - icon_url: '/emojis/1f1eb-1f1f7.png', - healthy: true, - path_app_url: 'https://paris-coder.fly.dev', - wildcard_hostname: '', - derp_enabled: true, - derp_only: false, - status: { - status: 'ok', - report: { - errors: [], - warnings: [], - }, - checked_at: '2023-12-05T14:14:05.250322277Z', - }, - created_at: '2023-12-01T09:21:15.996267Z', - updated_at: '2023-12-05T14:13:59.663174Z', - deleted: false, - version: 'v2.5.0-devel+5fad61102', - }, - { - id: '72649dc9-03c7-46a8-bc95-96775e93ddc1', - name: 'sydney-coder', - display_name: 'Australia (Sydney)', - icon_url: '/emojis/1f1e6-1f1fa.png', - healthy: true, - path_app_url: 'https://sydney-coder.fly.dev', - wildcard_hostname: '', - derp_enabled: true, - derp_only: false, - status: { - status: 'ok', - report: { - errors: [], - warnings: [], - }, - checked_at: '2023-12-05T14:14:05.250322277Z', - }, - created_at: '2023-12-01T09:23:44.505529Z', - updated_at: '2023-12-05T14:13:55.769058Z', - deleted: false, - version: 'v2.5.0-devel+5fad61102', - }, - { - id: '1f78398f-e5ae-4c38-aa89-30222181d443', - name: 'sao-paulo-coder', - display_name: 'Brazil (Sau Paulo)', - icon_url: '/emojis/1f1e7-1f1f7.png', - healthy: true, - path_app_url: 'https://sao-paulo-coder.fly.dev', - wildcard_hostname: '', - derp_enabled: true, - derp_only: false, - status: { - status: 'ok', - report: { - errors: [], - warnings: [], - }, - checked_at: '2023-12-05T14:14:05.250322277Z', - }, - created_at: '2023-12-01T09:36:00.231252Z', - updated_at: '2023-12-05T14:13:47.015031Z', - deleted: false, - version: 'v2.5.0-devel+5fad61102', - }, - ], - }, - }, - provisioner_daemons: { - severity: 'ok', - warnings: [ - { - message: 'Something is wrong!', - code: 'EUNKNOWN', - }, - { - message: 'This is also bad.', - code: 'EPD01', - }, - ], - dismissed: false, - items: [ - { - provisioner_daemon: { - id: 'e455b582-ac04-4323-9ad6-ab71301fa006', - created_at: '2024-01-04T15:53:03.21563Z', - last_seen_at: '2024-01-04T16:05:03.967551Z', - name: 'ok', - version: 'v2.3.4-devel+abcd1234', - api_version: '1.0', - provisioners: ['echo', 'terraform'], - tags: { - owner: '', - scope: 'organization', - tag_value: 'value', - tag_true: 'true', - tag_1: '1', - tag_yes: 'yes', - }, - }, - warnings: [], - }, - { - provisioner_daemon: { - id: '00000000-0000-0000-000000000000', - created_at: '2024-01-04T15:53:03.21563Z', - last_seen_at: '2024-01-04T16:05:03.967551Z', - name: 'user-scoped', - version: 'v2.34-devel+abcd1234', - api_version: '1.0', - provisioners: ['echo', 'terraform'], - tags: { - owner: '12345678-1234-1234-1234-12345678abcd', - scope: 'user', - tag_VALUE: 'VALUE', - tag_TRUE: 'TRUE', - tag_1: '1', - tag_YES: 'YES', - }, - }, - warnings: [], - }, - { - provisioner_daemon: { - id: 'e455b582-ac04-4323-9ad6-ab71301fa006', - created_at: '2024-01-04T15:53:03.21563Z', - last_seen_at: '2024-01-04T16:05:03.967551Z', - name: 'unhappy', - version: 'v0.0.1', - api_version: '0.1', - provisioners: ['echo', 'terraform'], - tags: { - owner: '', - scope: 'organization', - tag_string: 'value', - tag_false: 'false', - tag_0: '0', - tag_no: 'no', - }, - }, - warnings: [ - { - message: 'Something specific is wrong with this daemon.', - code: 'EUNKNOWN', - }, - { - message: 'And now for something completely different.', - code: 'EUNKNOWN', - }, - ], - }, - ], - }, - coder_version: 'v2.5.0-devel+5fad61102', -}; - -export const MockListeningPortsResponse: TypesGen.WorkspaceAgentListeningPortsResponse = - { - ports: [ - { process_name: 'webb', network: '', port: 30000 }, - { process_name: 'gogo', network: '', port: 8080 }, - { process_name: '', network: '', port: 8081 }, - ], - }; - -export const MockSharedPortsResponse: TypesGen.WorkspaceAgentPortShares = { - shares: [ - { - workspace_id: MockWorkspace.id, - agent_name: 'a-workspace-agent', - port: 4000, - share_level: 'authenticated', - protocol: 'http', - }, - { - workspace_id: MockWorkspace.id, - agent_name: 'a-workspace-agent', - port: 65535, - share_level: 'authenticated', - protocol: 'https', - }, - { - workspace_id: MockWorkspace.id, - agent_name: 'a-workspace-agent', - port: 8081, - share_level: 'public', - protocol: 'http', - }, - ], -}; - -export const DeploymentHealthUnhealthy: TypesGen.HealthcheckReport = { - healthy: false, - severity: 'ok', - failing_sections: [], // apparently this property is not used at all? - time: '2023-10-12T23:15:00.000000000Z', - coder_version: 'v2.3.0-devel+8cca4915a', - access_url: { - healthy: true, - severity: 'ok', - warnings: [], - dismissed: false, - access_url: '', - healthz_response: '', - reachable: true, - status_code: 0, - }, - database: { - healthy: false, - severity: 'ok', - warnings: [], - dismissed: false, - latency: '', - latency_ms: 0, - reachable: true, - threshold_ms: 92570, - }, - derp: { - healthy: false, - severity: 'ok', - warnings: [], - dismissed: false, - regions: [], - netcheck_logs: [], - }, - websocket: { - healthy: false, - severity: 'ok', - warnings: [], - dismissed: false, - body: '', - code: 0, - }, - workspace_proxy: { - healthy: false, - error: 'some error', - severity: 'error', - warnings: [], - dismissed: false, - workspace_proxies: { - regions: [ - { - id: 'df7e4b2b-2d40-47e5-a021-e5d08b219c77', - name: 'unhealthy', - display_name: 'unhealthy', - icon_url: '/emojis/1f5fa.png', - healthy: false, - path_app_url: 'http://127.0.0.1:3001', - wildcard_hostname: '', - derp_enabled: true, - derp_only: false, - status: { - status: 'unreachable', - report: { - errors: ['some error'], - warnings: [], - }, - checked_at: '2023-11-24T12:14:05.743303497Z', - }, - created_at: '2023-11-23T15:37:25.513213Z', - updated_at: '2023-11-23T18:09:19.734747Z', - deleted: false, - version: 'v2.5.0-devel+89bae7eff', - }, - ], - }, - }, - provisioner_daemons: { - severity: 'error', - error: 'something went wrong', - warnings: [ - { - message: 'this is a message', - code: 'EUNKNOWN', - }, - ], - dismissed: false, - items: [ - { - provisioner_daemon: { - id: 'e455b582-ac04-4323-9ad6-ab71301fa006', - created_at: '2024-01-04T15:53:03.21563Z', - last_seen_at: '2024-01-04T16:05:03.967551Z', - name: 'vvuurrkk-2', - version: 'v2.6.0-devel+965ad5e96', - api_version: '1.0', - provisioners: ['echo', 'terraform'], - tags: { - owner: '', - scope: 'organization', - }, - }, - warnings: [ - { - message: 'this is a specific message for this thing', - code: 'EUNKNOWN', - }, - ], - }, - ], - }, -}; - -export const MockHealthSettings: TypesGen.HealthSettings = { - dismissed_healthchecks: [], -}; - -export const MockGithubExternalProvider: TypesGen.ExternalAuthLinkProvider = { - id: 'github', - type: 'github', - device: false, - display_icon: '/icon/github.svg', - display_name: 'GitHub', - allow_refresh: true, - allow_validate: true, -}; - -export const MockGithubAuthLink: TypesGen.ExternalAuthLink = { - provider_id: 'github', - created_at: '', - updated_at: '', - has_refresh_token: true, - expires: '', - authenticated: true, - validate_error: '', -}; - -export const MockOAuth2ProviderApps: TypesGen.OAuth2ProviderApp[] = [ - { - id: '1', - name: 'foo', - callback_url: 'http://localhost:3001', - icon: '/icon/github.svg', - endpoints: { - authorization: 'http://localhost:3001/oauth2/authorize', - token: 'http://localhost:3001/oauth2/token', - device_authorization: '', - }, - }, -]; - -export const MockOAuth2ProviderAppSecrets: TypesGen.OAuth2ProviderAppSecret[] = - [ - { - id: '1', - client_secret_truncated: 'foo', - }, - { - id: '1', - last_used_at: '2022-12-16T20:10:45.637452Z', - client_secret_truncated: 'foo', - }, - ]; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderPluginData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderPluginData.ts index ea5f46ad..455e0629 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderPluginData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderPluginData.ts @@ -1,6 +1,5 @@ -import { User } from '../api/vendoredSdk'; -import type { Workspace } from '../api/vendoredSdk'; -import { MockUser } from './coderEntities'; +import type { User, Workspace } from '../api/vendoredSdk'; +import { MockUser, MockWorkspace } from './coderEntities'; import { mockBackstageApiEndpoint, mockBackstageAssetsEndpoint, @@ -15,11 +14,14 @@ export const mockUserWithProxyUrls: User = { * The main mock for a workspace whose repo URL matches cleanedRepoUrl */ export const mockWorkspaceWithMatch: Workspace = { + ...MockWorkspace, id: 'workspace-with-match', name: 'Test-Workspace', template_icon: `${mockBackstageApiEndpoint}/emojis/dog.svg`, owner_name: 'lil brudder', + latest_build: { + ...MockWorkspace.latest_build, id: 'workspace-with-match-build', status: 'running', resources: [ @@ -38,11 +40,14 @@ export const mockWorkspaceWithMatch: Workspace = { * return multiple values back */ export const mockWorkspaceWithMatch2: Workspace = { + ...MockWorkspace, id: 'workspace-with-match-2', name: 'Another-Test', template_icon: `${mockBackstageApiEndpoint}/emojis/z.svg`, owner_name: 'Coach Z', + latest_build: { + ...MockWorkspace.latest_build, id: 'workspace-with-match-2-build', status: 'running', resources: [ @@ -59,6 +64,7 @@ export const mockWorkspaceWithMatch2: Workspace = { * cleanedRepoUrl */ export const mockWorkspaceNoMatch: Workspace = { + ...MockWorkspace, id: 'workspace-no-match', name: 'No-match', template_icon: `${mockBackstageApiEndpoint}/emojis/star.svg`, @@ -82,6 +88,7 @@ export const mockWorkspaceNoMatch: Workspace = { * A workspace with no build parameters whatsoever */ export const mockWorkspaceNoParameters: Workspace = { + ...MockWorkspace, id: 'workspace-no-parameters', name: 'No-parameters', template_icon: `${mockBackstageApiEndpoint}/emojis/cheese.png`, diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 1031bffd..441cbc5a 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -82,7 +82,6 @@ export function wrappedGet( export const mockServerEndpoints = { workspaces: `${root}/workspaces`, authenticatedUser: `${root}/users/me`, - workspaceBuildParameters: `${root}/workspacebuilds/:workspaceBuildId/parameters`, } as const satisfies Record; const mainTestHandlers: readonly RestHandler[] = [ From a76db16c57df983c56e09027fc4b1070e869364e Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 20:58:55 +0000 Subject: [PATCH 12/94] wip: commit progress on updating client/SDK integration --- plugins/backstage-plugin-coder/src/api/UrlSync.ts | 2 +- .../backstage-plugin-coder/src/testHelpers/mockBackstageData.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.ts index ae05294b..686963ce 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.ts @@ -48,7 +48,7 @@ type UrlPrefixes = Readonly<{ export const defaultUrlPrefixes = { proxyPrefix: `/api/proxy`, - apiRoutePrefix: '/api/v2', + apiRoutePrefix: '', // Left as empty string because code assumes that CoderSdk will add /api/v2 assetsRoutePrefix: '', // Deliberately left as empty string } as const satisfies UrlPrefixes; diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 34f11218..bed1f457 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -73,7 +73,7 @@ export const mockBackstageUrlRoot = 'http://localhost:7007'; * the final result is. */ export const mockBackstageApiEndpoint = - `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.apiRoutePrefix}` as const; + `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.apiRoutePrefix}/api/v2` as const; /** * The assets endpoint to use during testing. From d22bc2056409436dae05eb358252df241e6e507d Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 21:03:26 +0000 Subject: [PATCH 13/94] fix: get all tests passing for CoderClient --- plugins/backstage-plugin-coder/src/api/CoderClient.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index f807adb7..2bfa6b24 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -132,10 +132,10 @@ describe(`${CoderClient.name}`, () => { }); const { urlSync } = apis; - const apiEndpoint = await urlSync.getApiEndpoint(); + const assetsEndpoint = await urlSync.getAssetsEndpoint(); - const allWorkspacesAreRemapped = !workspaces.some(ws => - ws.template_icon.startsWith(apiEndpoint), + const allWorkspacesAreRemapped = workspaces.every(ws => + ws.template_icon.startsWith(assetsEndpoint), ); expect(allWorkspacesAreRemapped).toBe(true); From 08cd04950a9ffc92101c959bb3d019314788341d Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 21:19:56 +0000 Subject: [PATCH 14/94] fix: update UrlSync updates --- .../src/api/UrlSync.test.ts | 8 ++++---- .../src/hooks/useUrlSync.test.tsx | 6 +++--- .../src/testHelpers/mockBackstageData.ts | 16 ++++++++++++++-- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts index 4932edea..62001e4e 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts @@ -4,8 +4,8 @@ import { getMockConfigApi, getMockDiscoveryApi, mockBackstageAssetsEndpoint, - mockBackstageApiEndpoint, mockBackstageUrlRoot, + mockBackstageApiEndpointWithoutSdkPath, } from '../testHelpers/mockBackstageData'; // Tests have to assume that DiscoveryApi and ConfigApi will always be in sync, @@ -23,7 +23,7 @@ describe(`${UrlSync.name}`, () => { const cachedUrls = urlSync.getCachedUrls(); expect(cachedUrls).toEqual({ baseUrl: mockBackstageUrlRoot, - apiRoute: mockBackstageApiEndpoint, + apiRoute: mockBackstageApiEndpointWithoutSdkPath, assetsRoute: mockBackstageAssetsEndpoint, }); }); @@ -50,7 +50,7 @@ describe(`${UrlSync.name}`, () => { expect(newSnapshot).toEqual({ baseUrl: 'blah', - apiRoute: 'blah/coder/api/v2', + apiRoute: 'blah/coder', assetsRoute: 'blah/coder', }); }); @@ -76,7 +76,7 @@ describe(`${UrlSync.name}`, () => { expect(onChange).toHaveBeenCalledWith({ baseUrl: 'blah', - apiRoute: 'blah/coder/api/v2', + apiRoute: 'blah/coder', assetsRoute: 'blah/coder', } satisfies UrlSyncSnapshot); diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx index 164242f7..90cac33d 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx @@ -6,13 +6,13 @@ import { type UseUrlSyncResult, useUrlSync } from './useUrlSync'; import type { DiscoveryApi } from '@backstage/core-plugin-api'; import { mockBackstageAssetsEndpoint, - mockBackstageApiEndpoint, mockBackstageUrlRoot, getMockConfigApi, + mockBackstageApiEndpointWithoutSdkPath, } from '../testHelpers/mockBackstageData'; function renderUseUrlSync() { - let proxyEndpoint: string = mockBackstageApiEndpoint; + let proxyEndpoint: string = mockBackstageApiEndpointWithoutSdkPath; const mockDiscoveryApi: DiscoveryApi = { getBaseUrl: async () => proxyEndpoint, }; @@ -53,7 +53,7 @@ describe(`${useUrlSync.name}`, () => { state: { baseUrl: mockBackstageUrlRoot, assetsRoute: mockBackstageAssetsEndpoint, - apiRoute: mockBackstageApiEndpoint, + apiRoute: mockBackstageApiEndpointWithoutSdkPath, }, }), ); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index bed1f457..88f45498 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -67,13 +67,25 @@ export const rawRepoUrl = `${cleanedRepoUrl}/tree/main/`; export const mockBackstageUrlRoot = 'http://localhost:7007'; /** - * The API endpoint to use with the mock server during testing. + * A version of the mock API endpoint that doesn't have the Coder API versioning + * prefix. Mainly used for tests that need to assert that the core API URL is + * formatted correctly, before the CoderSdk adds anything else to the end + * + * The string literal expression is complicated, but hover over it to see what + * the final result is. + */ +export const mockBackstageApiEndpointWithoutSdkPath = + `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.apiRoutePrefix}` as const; + +/** + * The API endpoint to use with the mock server during testing. Adds additional + * path information that will normally be added via the Coder SDK. * * The string literal expression is complicated, but hover over it to see what * the final result is. */ export const mockBackstageApiEndpoint = - `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.apiRoutePrefix}/api/v2` as const; + `${mockBackstageApiEndpointWithoutSdkPath}/api/v2` as const; /** * The assets endpoint to use during testing. From 2eb4987e0966af9fd1ec73f0da1e7b2a3b1097d6 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 22:01:31 +0000 Subject: [PATCH 15/94] fix: get all tests passing --- plugins/backstage-plugin-coder/src/testHelpers/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/testHelpers/server.ts b/plugins/backstage-plugin-coder/src/testHelpers/server.ts index 441cbc5a..bacd3f43 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/server.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/server.ts @@ -91,7 +91,7 @@ const mainTestHandlers: readonly RestHandler[] = [ `param:"\\w+?=${repoUrl.replace('/', '\\/')}"`, ); - const queryText = String(req.url.searchParams.get('q')); + const queryText = String(req.url.searchParams.get('q') ?? ''); const requestContainsRepoInfo = paramMatcherRe.test(queryText); const baseWorkspaces = requestContainsRepoInfo From 37645f4fda68e9998de6cd50874b418cdb1e92f6 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 24 May 2024 22:08:56 +0000 Subject: [PATCH 16/94] chore: update all mock data to use Coder core entity mocks --- .../src/testHelpers/mockCoderPluginData.ts | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderPluginData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderPluginData.ts index 455e0629..a3bfb10d 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockCoderPluginData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockCoderPluginData.ts @@ -1,5 +1,10 @@ import type { User, Workspace } from '../api/vendoredSdk'; -import { MockUser, MockWorkspace } from './coderEntities'; +import { + MockUser, + MockWorkspace, + MockWorkspaceAgent, + MockWorkspaceResource, +} from './coderEntities'; import { mockBackstageApiEndpoint, mockBackstageAssetsEndpoint, @@ -26,8 +31,15 @@ export const mockWorkspaceWithMatch: Workspace = { status: 'running', resources: [ { + ...MockWorkspaceResource, id: 'workspace-with-match-resource', - agents: [{ id: 'test-workspace-agent', status: 'connected' }], + agents: [ + { + ...MockWorkspaceAgent, + id: 'test-workspace-agent', + status: 'connected', + }, + ], }, ], }, @@ -52,8 +64,15 @@ export const mockWorkspaceWithMatch2: Workspace = { status: 'running', resources: [ { + ...MockWorkspaceResource, id: 'workspace-with-match-2-resource', - agents: [{ id: 'test-workspace-agent', status: 'connected' }], + agents: [ + { + ...MockWorkspaceAgent, + id: 'test-workspace-agent', + status: 'connected', + }, + ], }, ], }, @@ -69,15 +88,26 @@ export const mockWorkspaceNoMatch: Workspace = { name: 'No-match', template_icon: `${mockBackstageApiEndpoint}/emojis/star.svg`, owner_name: 'homestar runner', + latest_build: { + ...MockWorkspace.latest_build, id: 'workspace-no-match-build', status: 'stopped', resources: [ { + ...MockWorkspaceResource, id: 'workspace-no-match-resource', agents: [ - { id: 'test-workspace-agent-a', status: 'disconnected' }, - { id: 'test-workspace-agent-b', status: 'timeout' }, + { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-a', + status: 'disconnected', + }, + { + ...MockWorkspaceAgent, + id: 'test-workspace-agent-b', + status: 'timeout', + }, ], }, ], @@ -94,12 +124,16 @@ export const mockWorkspaceNoParameters: Workspace = { template_icon: `${mockBackstageApiEndpoint}/emojis/cheese.png`, owner_name: 'The Cheat', latest_build: { + ...MockWorkspace.latest_build, id: 'workspace-no-parameters-build', status: 'running', resources: [ { + ...MockWorkspaceResource, id: 'workspace-no-parameters-resource', - agents: [{ id: 'test-workspace-c', status: 'timeout' }], + agents: [ + { ...MockWorkspaceAgent, id: 'test-workspace-c', status: 'timeout' }, + ], }, ], }, From c6cc7b71a4efdd9766deddb8d99f9dc3e1bbaf0d Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 13:45:08 +0000 Subject: [PATCH 17/94] refactor: improve co-location for useCoderWorkspacesQuery --- .../CoderWorkspacesCard}/useCoderWorkspacesQuery.test.ts | 7 +++---- .../CoderWorkspacesCard}/useCoderWorkspacesQuery.ts | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) rename plugins/backstage-plugin-coder/src/{hooks => components/CoderWorkspacesCard}/useCoderWorkspacesQuery.test.ts (91%) rename plugins/backstage-plugin-coder/src/{hooks => components/CoderWorkspacesCard}/useCoderWorkspacesQuery.ts (67%) diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.test.ts similarity index 91% rename from plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts rename to plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.test.ts index 49535619..9f22cf94 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.test.ts +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.test.ts @@ -1,12 +1,11 @@ import { waitFor } from '@testing-library/react'; import { useCoderWorkspacesQuery } from './useCoderWorkspacesQuery'; - -import { renderHookAsCoderEntity } from '../testHelpers/setup'; -import { mockCoderWorkspacesConfig } from '../testHelpers/mockBackstageData'; +import { renderHookAsCoderEntity } from '../../testHelpers/setup'; +import { mockCoderWorkspacesConfig } from '../../testHelpers/mockBackstageData'; import { mockWorkspaceNoParameters, mockWorkspacesList, -} from '../testHelpers/mockCoderPluginData'; +} from '../../testHelpers/mockCoderPluginData'; beforeAll(() => { jest.useFakeTimers(); diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts similarity index 67% rename from plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts rename to plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts index 4e41ef86..0432659c 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts @@ -1,8 +1,8 @@ import { useQuery } from '@tanstack/react-query'; -import { workspaces, workspacesByRepo } from '../api/queryOptions'; -import type { CoderWorkspacesConfig } from './useCoderWorkspacesConfig'; -import { useCoderSdk } from './useCoderSdk'; -import { useInternalCoderAuth } from '../components/CoderProvider'; +import { workspaces, workspacesByRepo } from '../../api/queryOptions'; +import type { CoderWorkspacesConfig } from '../../hooks/useCoderWorkspacesConfig'; +import { useCoderSdk } from '../../hooks/useCoderSdk'; +import { useInternalCoderAuth } from '../../components/CoderProvider'; type QueryInput = Readonly<{ coderQuery: string; From 07f9cde4ae9c16aca9f5d6934714c17e14598bf8 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 13:53:42 +0000 Subject: [PATCH 18/94] wip: commit progress on React Query wrappers --- .../src/hooks/reactQueryWrappers.ts | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts new file mode 100644 index 00000000..9a0a84dd --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts @@ -0,0 +1,170 @@ +/** + * @file Defines a couple of wrappers over React Query/Tanstack Query that make + * it easier to use the Coder SDK within UI logic. + * + * These hooks are designed 100% for end-users, and should not be used + * internally. Use useEndUserCoderAuth when working with auth logic within these + * hooks. + */ +import { + type QueryKey, + type UseMutationOptions, + type UseMutationResult, + type UseQueryOptions, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { DEFAULT_TANSTACK_QUERY_RETRY_COUNT } from '../typesConstants'; +import { useEndUserCoderAuth } from '../components/CoderProvider'; + +export function useCoderQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + queryOptions: UseQueryOptions, +): UseQueryResult { + const { isAuthenticated } = useEndUserCoderAuth(); + + const patchedOptions: typeof queryOptions = { + ...queryOptions, + enabled: isAuthenticated && (queryOptions.enabled ?? true), + keepPreviousData: + isAuthenticated && (queryOptions.keepPreviousData ?? false), + refetchIntervalInBackground: + isAuthenticated && (queryOptions.refetchIntervalInBackground ?? false), + + refetchInterval: (data, query) => { + if (!isAuthenticated) { + return false; + } + + const externalRefetchInterval = queryOptions.refetchInterval; + if (typeof externalRefetchInterval !== 'function') { + return externalRefetchInterval ?? false; + } + + return externalRefetchInterval(data, query); + }, + + refetchOnMount: query => { + if (!isAuthenticated) { + return false; + } + + const externalRefetchOnMount = queryOptions.refetchOnMount; + if (typeof externalRefetchOnMount !== 'function') { + return externalRefetchOnMount ?? true; + } + + return externalRefetchOnMount(query); + }, + + retry: (failureCount, error) => { + if (!isAuthenticated) { + return false; + } + + const externalRetry = queryOptions.retry; + if (typeof externalRetry === 'number') { + return ( + failureCount < (externalRetry ?? DEFAULT_TANSTACK_QUERY_RETRY_COUNT) + ); + } + + if (typeof externalRetry !== 'function') { + return externalRetry ?? true; + } + + return externalRetry(failureCount, error); + }, + }; + + return useQuery(patchedOptions); +} + +export function useCoderMutation< + TData = unknown, + TError = unknown, + TVariables = void, + TContext = unknown, +>( + mutationOptions: UseMutationOptions, +): UseMutationResult { + const { isAuthenticated } = useEndUserCoderAuth(); + const queryClient = useQueryClient(); + + const patchedOptions: typeof mutationOptions = { + ...mutationOptions, + mutationFn: variables => { + // useMutation doesn't expose an enabled property, so the best we can do + // is immediately throw an error if the user isn't authenticated + if (!isAuthenticated) { + throw new Error( + 'Cannot perform Coder mutations without being authenticated', + ); + } + + const defaultMutationOptions = queryClient.getMutationDefaults(); + const externalMutationFn = + mutationOptions.mutationFn ?? defaultMutationOptions?.mutationFn; + + if (externalMutationFn === undefined) { + throw new Error('No mutation function has been provided'); + } + + return externalMutationFn(variables); + }, + + retry: (failureCount, error) => { + if (!isAuthenticated) { + return false; + } + + const externalRetry = mutationOptions.retry; + if (typeof externalRetry === 'number') { + return ( + failureCount < (externalRetry ?? DEFAULT_TANSTACK_QUERY_RETRY_COUNT) + ); + } + + if (typeof externalRetry !== 'function') { + return externalRetry ?? true; + } + + return externalRetry(failureCount, error); + }, + + retryDelay: (failureCount, error) => { + /** + * Formula is one of the examples of exponential backoff taken straight + * from the React Query docs + * @see {@link https://tanstack.com/query/v4/docs/framework/react/reference/useMutation} + */ + const exponentialDelay = Math.min( + failureCount > 1 ? 2 ** failureCount * 1000 : 1000, + 30 * 1000, + ); + + if (!isAuthenticated) { + // Doesn't matter what value we return out as long as the retry property + // consistently returns false when not authenticated. Considered using + // Infinity, but didn't have time to look up whether that would break + // anything in the React Query internals + return exponentialDelay; + } + + const externalRetryDelay = mutationOptions.retryDelay; + if (typeof externalRetryDelay !== 'function') { + return externalRetryDelay ?? exponentialDelay; + } + + return externalRetryDelay(failureCount, error); + }, + }; + + return useMutation(patchedOptions); +} From 864357dbb9ff646fad0f50365f434ea7a92da185 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 14:56:22 +0000 Subject: [PATCH 19/94] fix: add extra helpers to useCoderSdk --- .../src/api/queryOptions.ts | 10 +++--- .../CoderProvider/CoderAuthProvider.tsx | 24 +++++++------ .../src/hooks/useCoderSdk.ts | 36 +++++++++++++++++-- .../src/hooks/useCoderWorkspacesQuery.ts | 6 ++-- 4 files changed, 55 insertions(+), 21 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index d15d6ce3..4e55861d 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -44,13 +44,13 @@ function getSharedWorkspacesQueryKey(coderQuery: string) { type WorkspacesFetchInputs = Readonly<{ auth: CoderAuth; - coderSdk: BackstageCoderSdk; + sdk: BackstageCoderSdk; coderQuery: string; }>; export function workspaces({ auth, - coderSdk, + sdk, coderQuery, }: WorkspacesFetchInputs): UseQueryOptions { const enabled = auth.isAuthenticated; @@ -61,7 +61,7 @@ export function workspaces({ keepPreviousData: enabled && coderQuery !== '', refetchInterval: getCoderWorkspacesRefetchInterval, queryFn: async () => { - const res = await coderSdk.getWorkspaces({ + const res = await sdk.getWorkspaces({ q: coderQuery, limit: 0, }); @@ -79,7 +79,7 @@ type WorkspacesByRepoFetchInputs = Readonly< export function workspacesByRepo({ coderQuery, - coderSdk, + sdk, auth, workspacesConfig, }: WorkspacesByRepoFetchInputs): UseQueryOptions { @@ -95,7 +95,7 @@ export function workspacesByRepo({ refetchInterval: getCoderWorkspacesRefetchInterval, queryFn: async () => { const request: WorkspacesRequest = { q: coderQuery, limit: 0 }; - const res = await coderSdk.getWorkspacesByRepo(request, workspacesConfig); + const res = await sdk.getWorkspacesByRepo(request, workspacesConfig); return res.workspaces; }, }; diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index c9b6fbb1..664bb311 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -165,19 +165,23 @@ function useAuthState(): CoderAuth { return unsubscribe; }, [queryClient]); + const registerNewToken = useCallback((newToken: string) => { + if (newToken !== '') { + setAuthToken(newToken); + } + }, []); + + const ejectToken = useCallback(() => { + setAuthToken(''); + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); + }, [queryClient]); + return { ...authState, isAuthenticated: validAuthStatuses.includes(authState.status), - registerNewToken: newToken => { - if (newToken !== '') { - setAuthToken(newToken); - } - }, - ejectToken: () => { - setAuthToken(''); - window.localStorage.removeItem(TOKEN_STORAGE_KEY); - queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); - }, + registerNewToken, + ejectToken, }; } diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts index 8fbec12c..f394660c 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts @@ -1,7 +1,37 @@ +/** + * @file This defines the general helper for accessing the Coder SDK from + * Backstage in a type-safe way. + * + * This hook is meant to be used both internally AND externally. It exposes some + * auth helpers to make end users' lives easier, but all of them go through + * useEndUserCoderAuth. If building any internal components, be sure to have a + * call to useInternalCoderAuth somewhere, to make sure that the component + * interfaces with the fallback auth UI inputs properly. + * + * See CoderAuthProvider.tsx for more info. + */ import { useApi } from '@backstage/core-plugin-api'; import { coderClientApiRef, type BackstageCoderSdk } from '../api/CoderClient'; +import { useEndUserCoderAuth } from '../components/CoderProvider'; -export function useCoderSdk(): BackstageCoderSdk { - const coderClient = useApi(coderClientApiRef); - return coderClient.sdk; +type UseCoderSdkResult = Readonly<{ + sdk: BackstageCoderSdk; + backstageUtils: Readonly<{ + unlinkCoderAccount: () => void; + }>; +}>; + +export function useCoderSdk(): UseCoderSdkResult { + const { ejectToken } = useEndUserCoderAuth(); + const { sdk } = useApi(coderClientApiRef); + + return { + sdk, + backstageUtils: { + // Hoping that as we support more auth methods, this function gets beefed + // up to be an all-in-one function for removing any and all auth info. + // Simply doing a pass-through for now + unlinkCoderAccount: ejectToken, + }, + }; } diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index 4e41ef86..bea87361 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -14,12 +14,12 @@ export function useCoderWorkspacesQuery({ workspacesConfig, }: QueryInput) { const auth = useInternalCoderAuth(); - const coderSdk = useCoderSdk(); + const { sdk } = useCoderSdk(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; const queryOptions = hasRepoData - ? workspacesByRepo({ auth, coderSdk, coderQuery, workspacesConfig }) - : workspaces({ auth, coderSdk, coderQuery }); + ? workspacesByRepo({ auth, sdk, coderQuery, workspacesConfig }) + : workspaces({ auth, sdk, coderQuery }); return useQuery(queryOptions); } From 67ac52905420b9781c29ce0b21b2196ba6ec8cdc Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 15:22:53 +0000 Subject: [PATCH 20/94] wip: add test stubs for useCoderQuery --- .../src/hooks/reactQueryWrappers.test.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx new file mode 100644 index 00000000..3ee8c55c --- /dev/null +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx @@ -0,0 +1,27 @@ +import { useCoderQuery } from './reactQueryWrappers'; + +describe(`${useCoderQuery.name}`, () => { + it('Does not let requests go through until the user is authenticated', async () => { + expect.hasAssertions(); + }); + + it('Never retries requests if the user is not authenticated', () => { + expect.hasAssertions(); + }); + + it('Never displays previous data for changing query keys if the user is not authenticated', () => { + expect.hasAssertions(); + }); + + it('Automatically prefixes all query keys with the global Coder query key prefix', () => { + expect.hasAssertions(); + }); + + it('Disables all refetch-based properties when the user is not authenticated', () => { + expect.hasAssertions(); + }); + + it('Behaves exactly like useQuery if the user is fully authenticated (aside from queryKey patching)', () => { + expect.hasAssertions(); + }); +}); From 4d66cca5dc4a6ad9c137eff3cf28ae2a34ffdb67 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 15:24:02 +0000 Subject: [PATCH 21/94] fix: add queryKey patching to useCoderQuery --- .../src/hooks/reactQueryWrappers.ts | 104 +++--------------- plugins/backstage-plugin-coder/src/plugin.ts | 1 + 2 files changed, 18 insertions(+), 87 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts index 9a0a84dd..d96d9727 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts @@ -5,19 +5,27 @@ * These hooks are designed 100% for end-users, and should not be used * internally. Use useEndUserCoderAuth when working with auth logic within these * hooks. + * + * --- + * @todo 2024-05-28 - This isn't fully complete until we have an equivalent + * wrapper for useMutation, and have an idea of how useCoderQuery and + * useCoderMutation can be used together. + * + * Making the useMutation wrapper shouldn't be hard, but you want some good + * integration tests to verify that the two hooks can satisfy common user flows. + * + * Draft version of wrapper: + * @see {@link https://gist.github.com/Parkreiner/5c1e01f820500a49e2e81897a507e907} */ import { type QueryKey, - type UseMutationOptions, - type UseMutationResult, type UseQueryOptions, type UseQueryResult, - useMutation, useQuery, - useQueryClient, } from '@tanstack/react-query'; import { DEFAULT_TANSTACK_QUERY_RETRY_COUNT } from '../typesConstants'; import { useEndUserCoderAuth } from '../components/CoderProvider'; +import { CODER_QUERY_KEY_PREFIX } from '../api/queryOptions'; export function useCoderQuery< TQueryFnData = unknown, @@ -31,6 +39,11 @@ export function useCoderQuery< const patchedOptions: typeof queryOptions = { ...queryOptions, + queryKey: [ + CODER_QUERY_KEY_PREFIX, + ...(queryOptions.queryKey ?? []), + ] as QueryKey as TQueryKey, + enabled: isAuthenticated && (queryOptions.enabled ?? true), keepPreviousData: isAuthenticated && (queryOptions.keepPreviousData ?? false), @@ -85,86 +98,3 @@ export function useCoderQuery< return useQuery(patchedOptions); } - -export function useCoderMutation< - TData = unknown, - TError = unknown, - TVariables = void, - TContext = unknown, ->( - mutationOptions: UseMutationOptions, -): UseMutationResult { - const { isAuthenticated } = useEndUserCoderAuth(); - const queryClient = useQueryClient(); - - const patchedOptions: typeof mutationOptions = { - ...mutationOptions, - mutationFn: variables => { - // useMutation doesn't expose an enabled property, so the best we can do - // is immediately throw an error if the user isn't authenticated - if (!isAuthenticated) { - throw new Error( - 'Cannot perform Coder mutations without being authenticated', - ); - } - - const defaultMutationOptions = queryClient.getMutationDefaults(); - const externalMutationFn = - mutationOptions.mutationFn ?? defaultMutationOptions?.mutationFn; - - if (externalMutationFn === undefined) { - throw new Error('No mutation function has been provided'); - } - - return externalMutationFn(variables); - }, - - retry: (failureCount, error) => { - if (!isAuthenticated) { - return false; - } - - const externalRetry = mutationOptions.retry; - if (typeof externalRetry === 'number') { - return ( - failureCount < (externalRetry ?? DEFAULT_TANSTACK_QUERY_RETRY_COUNT) - ); - } - - if (typeof externalRetry !== 'function') { - return externalRetry ?? true; - } - - return externalRetry(failureCount, error); - }, - - retryDelay: (failureCount, error) => { - /** - * Formula is one of the examples of exponential backoff taken straight - * from the React Query docs - * @see {@link https://tanstack.com/query/v4/docs/framework/react/reference/useMutation} - */ - const exponentialDelay = Math.min( - failureCount > 1 ? 2 ** failureCount * 1000 : 1000, - 30 * 1000, - ); - - if (!isAuthenticated) { - // Doesn't matter what value we return out as long as the retry property - // consistently returns false when not authenticated. Considered using - // Infinity, but didn't have time to look up whether that would break - // anything in the React Query internals - return exponentialDelay; - } - - const externalRetryDelay = mutationOptions.retryDelay; - if (typeof externalRetryDelay !== 'function') { - return externalRetryDelay ?? exponentialDelay; - } - - return externalRetryDelay(failureCount, error); - }, - }; - - return useMutation(patchedOptions); -} diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 2aaaab89..7f739f74 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -191,6 +191,7 @@ export { useWorkspacesCardContext } from './components/CoderWorkspacesCard/Root' */ export { useCoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; export { useCoderSdk } from './hooks/useCoderSdk'; +export { useCoderQuery } from './hooks/reactQueryWrappers'; export { useEndUserCoderAuth as useCoderAuth } from './components/CoderProvider/CoderAuthProvider'; /** From 333aaa37f1a1f5f1898735a407d36ae0abdcd20f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 15:50:47 +0000 Subject: [PATCH 22/94] fix: only add queryKey prefix if it is missing --- .../src/hooks/reactQueryWrappers.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts index d96d9727..0f65b6a6 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts @@ -37,13 +37,22 @@ export function useCoderQuery< ): UseQueryResult { const { isAuthenticated } = useEndUserCoderAuth(); - const patchedOptions: typeof queryOptions = { - ...queryOptions, - queryKey: [ + let patchedQueryKey: TQueryKey; + if ( + queryOptions.queryKey && + queryOptions.queryKey[0] === CODER_QUERY_KEY_PREFIX + ) { + patchedQueryKey = queryOptions.queryKey; + } else { + patchedQueryKey = [ CODER_QUERY_KEY_PREFIX, ...(queryOptions.queryKey ?? []), - ] as QueryKey as TQueryKey, + ] as QueryKey as TQueryKey; + } + const patchedOptions: typeof queryOptions = { + ...queryOptions, + queryKey: patchedQueryKey, enabled: isAuthenticated && (queryOptions.enabled ?? true), keepPreviousData: isAuthenticated && (queryOptions.keepPreviousData ?? false), From e0bd1a2607a4edf09e6e5b253717e61b2620e998 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 16:33:57 +0000 Subject: [PATCH 23/94] fix: make Coder query key prefix an opaque string --- plugins/backstage-plugin-coder/src/api/queryOptions.ts | 5 ++++- plugins/backstage-plugin-coder/src/plugin.ts | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index 4e55861d..6bfbd800 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -4,7 +4,10 @@ import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; import type { BackstageCoderSdk } from './CoderClient'; import type { CoderAuth } from '../components/CoderProvider'; -export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin'; +// Making the type more broad to hide some implementation details from the end +// user; the prefix should be treated as an opaque string we can change whenever +// we want +export const CODER_QUERY_KEY_PREFIX = 'coder-backstage-plugin' as string; // Defined here and not in CoderAuthProvider.ts to avoid circular dependency // issues diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 7f739f74..3ac5fff0 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -194,6 +194,11 @@ export { useCoderSdk } from './hooks/useCoderSdk'; export { useCoderQuery } from './hooks/reactQueryWrappers'; export { useEndUserCoderAuth as useCoderAuth } from './components/CoderProvider/CoderAuthProvider'; +/** + * General constants + */ +export { CODER_QUERY_KEY_PREFIX } from './api/queryOptions'; + /** * All custom types */ From 3c835b47a6327cd734aa176b20fb44ce986e47ef Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 17:20:04 +0000 Subject: [PATCH 24/94] refactor: improve ergonomics of useCoderQuery --- .../src/hooks/reactQueryWrappers.ts | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts index 0f65b6a6..8f0da6b1 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts @@ -18,14 +18,37 @@ * @see {@link https://gist.github.com/Parkreiner/5c1e01f820500a49e2e81897a507e907} */ import { + type QueryFunctionContext, type QueryKey, type UseQueryOptions, type UseQueryResult, useQuery, + useQueryClient, } from '@tanstack/react-query'; import { DEFAULT_TANSTACK_QUERY_RETRY_COUNT } from '../typesConstants'; import { useEndUserCoderAuth } from '../components/CoderProvider'; import { CODER_QUERY_KEY_PREFIX } from '../api/queryOptions'; +import { useCoderSdk } from './useCoderSdk'; +import type { BackstageCoderSdk } from '../api/CoderClient'; + +export type CoderQueryFunctionContext = + QueryFunctionContext & { + sdk: BackstageCoderSdk; + }; + +export type CoderQueryFunction< + T = unknown, + TQueryKey extends QueryKey = QueryKey, +> = (context: CoderQueryFunctionContext) => Promise; + +export type UseCoderQueryOptions< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Omit, 'queryFn'> & { + queryFn: CoderQueryFunction; +}; export function useCoderQuery< TQueryFnData = unknown, @@ -33,9 +56,11 @@ export function useCoderQuery< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( - queryOptions: UseQueryOptions, + queryOptions: UseCoderQueryOptions, ): UseQueryResult { + const queryClient = useQueryClient(); const { isAuthenticated } = useEndUserCoderAuth(); + const { sdk } = useCoderSdk(); let patchedQueryKey: TQueryKey; if ( @@ -44,13 +69,21 @@ export function useCoderQuery< ) { patchedQueryKey = queryOptions.queryKey; } else { + const baseKey = + queryOptions.queryKey ?? queryClient.defaultQueryOptions().queryKey; + + if (baseKey === undefined) { + throw new Error('No Query Key provided to useCoderQuery'); + } + patchedQueryKey = [ CODER_QUERY_KEY_PREFIX, - ...(queryOptions.queryKey ?? []), + ...baseKey, ] as QueryKey as TQueryKey; } - const patchedOptions: typeof queryOptions = { + type Options = UseQueryOptions; + const patchedOptions: Options = { ...queryOptions, queryKey: patchedQueryKey, enabled: isAuthenticated && (queryOptions.enabled ?? true), @@ -59,6 +92,10 @@ export function useCoderQuery< refetchIntervalInBackground: isAuthenticated && (queryOptions.refetchIntervalInBackground ?? false), + queryFn: async context => { + return queryOptions.queryFn({ ...context, sdk }); + }, + refetchInterval: (data, query) => { if (!isAuthenticated) { return false; From 0d20670d5bba967ab8c8d9c235c859bc62cc771f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 17:23:02 +0000 Subject: [PATCH 25/94] refactor: clean up query key patching logic --- .../src/hooks/reactQueryWrappers.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts index 8f0da6b1..2d56007c 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts @@ -62,13 +62,11 @@ export function useCoderQuery< const { isAuthenticated } = useEndUserCoderAuth(); const { sdk } = useCoderSdk(); - let patchedQueryKey: TQueryKey; + let patchedQueryKey = queryOptions.queryKey; if ( - queryOptions.queryKey && - queryOptions.queryKey[0] === CODER_QUERY_KEY_PREFIX + patchedQueryKey === undefined || + patchedQueryKey[0] !== CODER_QUERY_KEY_PREFIX ) { - patchedQueryKey = queryOptions.queryKey; - } else { const baseKey = queryOptions.queryKey ?? queryClient.defaultQueryOptions().queryKey; From 6bff4520360f1b76fce639752a283831778cec5b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 17:23:41 +0000 Subject: [PATCH 26/94] chore: let users disable fallback auth UI --- .../components/CoderProvider/CoderAuthProvider.tsx | 13 +++++++++++-- .../src/components/CoderProvider/CoderProvider.tsx | 5 ++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 664bb311..5029e205 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -607,12 +607,21 @@ export const dummyTrackComponent: TrackComponent = () => { }; }; +type CoderAuthProviderProps = Readonly< + PropsWithChildren<{ + showFallbackAuthForm?: boolean; + }> +>; + export function CoderAuthProvider({ children, -}: Readonly>) { + showFallbackAuthForm = true, +}: CoderAuthProviderProps) { const authState = useAuthState(); const { hasNoAuthInputs, trackComponent } = useAuthFallbackState(); - const needFallbackUi = !authState.isAuthenticated && hasNoAuthInputs; + + const needFallbackUi = + showFallbackAuthForm && !authState.isAuthenticated && hasNoAuthInputs; return ( diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx index 1b825404..55eb12e2 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx @@ -45,13 +45,16 @@ const defaultClient = new QueryClient({ export const CoderProvider = ({ children, appConfig, + showFallbackAuthForm = true, queryClient = defaultClient, }: CoderProviderProps) => { return ( - {children} + + {children} + From 83ee8300af9b5ba3a711af6ed4513d05d9ac27cb Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 17:23:55 +0000 Subject: [PATCH 27/94] wip: commit progress on tests --- .../src/hooks/reactQueryWrappers.test.tsx | 101 +++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx index 3ee8c55c..df576992 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx @@ -1,7 +1,102 @@ +import React, { ReactNode } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import type { QueryClient } from '@tanstack/react-query'; import { useCoderQuery } from './reactQueryWrappers'; +import { useEndUserCoderAuth } from '../components/CoderProvider'; +import { CoderProvider } from '../plugin'; +import { + mockAppConfig, + mockCoderAuthToken, +} from '../testHelpers/mockBackstageData'; +import { getMockQueryClient } from '../testHelpers/setup'; +import userEvent from '@testing-library/user-event'; + +type RenderMockQueryComponentOptions = Readonly<{ + children: ReactNode; + queryClient?: QueryClient; +}>; + +function renderMockQueryComponent(options: RenderMockQueryComponentOptions) { + const { children: mainComponent, queryClient = getMockQueryClient() } = + options; + + const injectorLabel = 'Register mock Coder token'; + const TokenInjector = () => { + const { isAuthenticated, registerNewToken } = useEndUserCoderAuth(); + + if (isAuthenticated) { + return null; + } + + return ( + + ); + }; + + const injectMockToken = async (): Promise => { + const injectorButton = await screen.findByRole('button', { + name: injectorLabel, + }); + + const user = userEvent.setup(); + await user.click(injectorButton); + + return waitFor(() => expect(injectorButton).not.toBeInTheDocument()); + }; + + const renderOutput = render(mainComponent, { + wrapper: ({ children }) => ( + + {children} + + + ), + }); + + return { + ...renderOutput, + injectMockToken, + }; +} describe(`${useCoderQuery.name}`, () => { it('Does not let requests go through until the user is authenticated', async () => { + const MockUserComponent = () => { + const query = useCoderQuery({ + queryKey: ['workspaces'], + queryFn: ({ sdk }) => sdk.getWorkspaces({ q: 'owner:me' }), + select: response => response.workspaces, + }); + + return ( +
+ {query.error instanceof Error && ( +

Encountered error: {query.error.message}

+ )} + + {query.isLoading &&

Loading…

} + + {query.data !== undefined && ( +
    + {query.data.map(workspace => ( +
  • Blah
  • + ))} +
+ )} +
+ ); + }; + + renderMockQueryComponent({ + children: , + }); + expect.hasAssertions(); }); @@ -13,7 +108,7 @@ describe(`${useCoderQuery.name}`, () => { expect.hasAssertions(); }); - it('Automatically prefixes all query keys with the global Coder query key prefix', () => { + it("Automatically prefixes queryKey with the global Coder query key prefix if it doesn't already exist", () => { expect.hasAssertions(); }); @@ -24,4 +119,8 @@ describe(`${useCoderQuery.name}`, () => { it('Behaves exactly like useQuery if the user is fully authenticated (aside from queryKey patching)', () => { expect.hasAssertions(); }); + + it('Disables everything when the user unlinks their access token', () => { + expect.hasAssertions(); + }); }); From df6339db9c75b9e9b193451e40e2284a6baf1689 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 17:36:46 +0000 Subject: [PATCH 28/94] chore: update wording for clarity --- .../src/hooks/reactQueryWrappers.test.tsx | 6 +++--- .../backstage-plugin-coder/src/hooks/reactQueryWrappers.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx index df576992..184595a1 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx @@ -66,7 +66,7 @@ function renderMockQueryComponent(options: RenderMockQueryComponentOptions) { } describe(`${useCoderQuery.name}`, () => { - it('Does not let requests go through until the user is authenticated', async () => { + it('Does not enable requests until the user is authenticated', async () => { const MockUserComponent = () => { const query = useCoderQuery({ queryKey: ['workspaces'], @@ -85,7 +85,7 @@ describe(`${useCoderQuery.name}`, () => { {query.data !== undefined && (
    {query.data.map(workspace => ( -
  • Blah
  • +
  • {workspace.name}
  • ))}
)} @@ -93,7 +93,7 @@ describe(`${useCoderQuery.name}`, () => { ); }; - renderMockQueryComponent({ + const { injectMockToken } = renderMockQueryComponent({ children: , }); diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts index 2d56007c..bf5319de 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts @@ -71,7 +71,7 @@ export function useCoderQuery< queryOptions.queryKey ?? queryClient.defaultQueryOptions().queryKey; if (baseKey === undefined) { - throw new Error('No Query Key provided to useCoderQuery'); + throw new Error('No queryKey value provided to useCoderQuery'); } patchedQueryKey = [ From 5df507077946d4345187e59953bd9ce19562c138 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 18:26:42 +0000 Subject: [PATCH 29/94] fix: update import for workspaces card root --- .../src/components/CoderWorkspacesCard/Root.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index 452f0a9c..5814d55b 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -16,7 +16,7 @@ import { type CoderWorkspacesConfig, } from '../../hooks/useCoderWorkspacesConfig'; import type { Workspace } from '../../api/vendoredSdk'; -import { useCoderWorkspacesQuery } from '../../hooks/useCoderWorkspacesQuery'; +import { useCoderWorkspacesQuery } from './useCoderWorkspacesQuery'; import { CoderAuthFormCardWrapper } from '../CoderAuthFormCardWrapper'; export type WorkspacesQuery = UseQueryResult; From 5aa57595896bb8df97208f31735b69fa5cd92ea8 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 18:38:06 +0000 Subject: [PATCH 30/94] chore: get first test passing --- .../src/hooks/reactQueryWrappers.test.tsx | 110 ++++++++++-------- 1 file changed, 61 insertions(+), 49 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx index 184595a1..17563708 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx @@ -1,74 +1,74 @@ import React, { ReactNode } from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; -import type { QueryClient } from '@tanstack/react-query'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { type QueryClient } from '@tanstack/react-query'; import { useCoderQuery } from './reactQueryWrappers'; -import { useEndUserCoderAuth } from '../components/CoderProvider'; -import { CoderProvider } from '../plugin'; import { + CoderProvider, + CoderAuth, + useEndUserCoderAuth, +} from '../components/CoderProvider'; +import { + getMockApiList, mockAppConfig, mockCoderAuthToken, } from '../testHelpers/mockBackstageData'; import { getMockQueryClient } from '../testHelpers/setup'; -import userEvent from '@testing-library/user-event'; +import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; -type RenderMockQueryComponentOptions = Readonly<{ +type RenderMockQueryOptions = Readonly<{ children: ReactNode; queryClient?: QueryClient; }>; -function renderMockQueryComponent(options: RenderMockQueryComponentOptions) { +function renderMockQueryComponent(options: RenderMockQueryOptions) { const { children: mainComponent, queryClient = getMockQueryClient() } = options; - const injectorLabel = 'Register mock Coder token'; - const TokenInjector = () => { - const { isAuthenticated, registerNewToken } = useEndUserCoderAuth(); - - if (isAuthenticated) { - return null; - } - - return ( - - ); - }; - - const injectMockToken = async (): Promise => { - const injectorButton = await screen.findByRole('button', { - name: injectorLabel, - }); + let latestRegisterNewToken!: CoderAuth['registerNewToken']; + let latestEjectToken!: CoderAuth['ejectToken']; - const user = userEvent.setup(); - await user.click(injectorButton); + const AuthEscapeHatch = () => { + const auth = useEndUserCoderAuth(); + latestRegisterNewToken = auth.registerNewToken; + latestEjectToken = auth.ejectToken; - return waitFor(() => expect(injectorButton).not.toBeInTheDocument()); + return null; }; const renderOutput = render(mainComponent, { - wrapper: ({ children }) => ( - - {children} - - - ), + wrapper: ({ children }) => { + const mainMarkup = ( + + + {children} + + + + ); + + return wrapInTestApp(mainMarkup) as unknown as typeof mainMarkup; + }, }); return { ...renderOutput, - injectMockToken, + registerNewToken: (newToken: string) => { + return act(() => latestRegisterNewToken(newToken)); + }, + ejectToken: () => { + return act(() => latestEjectToken()); + }, }; } describe(`${useCoderQuery.name}`, () => { - it('Does not enable requests until the user is authenticated', async () => { + it('Disables requests while user is not authenticated', async () => { const MockUserComponent = () => { - const query = useCoderQuery({ + const workspacesQuery = useCoderQuery({ queryKey: ['workspaces'], queryFn: ({ sdk }) => sdk.getWorkspaces({ q: 'owner:me' }), select: response => response.workspaces, @@ -76,15 +76,15 @@ describe(`${useCoderQuery.name}`, () => { return (
- {query.error instanceof Error && ( -

Encountered error: {query.error.message}

+ {workspacesQuery.error instanceof Error && ( +

Encountered error: {workspacesQuery.error.message}

)} - {query.isLoading &&

Loading…

} + {workspacesQuery.isLoading &&

Loading…

} - {query.data !== undefined && ( + {workspacesQuery.data !== undefined && (
    - {query.data.map(workspace => ( + {workspacesQuery.data.map(workspace => (
  • {workspace.name}
  • ))}
@@ -93,11 +93,23 @@ describe(`${useCoderQuery.name}`, () => { ); }; - const { injectMockToken } = renderMockQueryComponent({ + const loadingMatcher = /^Loading/; + const { registerNewToken, ejectToken } = renderMockQueryComponent({ children: , }); - expect.hasAssertions(); + const initialLoadingIndicator = screen.getByText(loadingMatcher); + registerNewToken(mockCoderAuthToken); + + await waitFor(() => { + const workspaceItems = screen.getAllByRole('listitem'); + expect(workspaceItems.length).toBeGreaterThan(0); + expect(initialLoadingIndicator).not.toBeInTheDocument(); + }); + + ejectToken(); + const newLoadingIndicator = await screen.findByText(loadingMatcher); + expect(newLoadingIndicator).toBeInTheDocument(); }); it('Never retries requests if the user is not authenticated', () => { From 0b49e2fdf6f898c2d443d8d4d16f9429ce3b841c Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 19:13:20 +0000 Subject: [PATCH 31/94] chore: add inverted promise helper --- .../src/testHelpers/setup.tsx | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index 86ceedcb..80379adc 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -10,7 +10,11 @@ import { /* eslint-enable @backstage/no-undeclared-imports */ import React from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + type QueryClientConfig, + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { type CoderAuth, @@ -93,13 +97,16 @@ export function suppressErrorBoundaryWarnings(): void { afterEachCleanupFunctions.push(() => augmentedConsoleError.mockClear()); } -export function getMockQueryClient(): QueryClient { +export function getMockQueryClient(config?: QueryClientConfig): QueryClient { return new QueryClient({ + ...(config ?? {}), defaultOptions: { + ...(config?.defaultOptions ?? {}), queries: { retry: false, refetchOnWindowFocus: false, networkMode: 'offlineFirst', + ...(config?.defaultOptions?.queries ?? {}), }, }, }); @@ -221,3 +228,21 @@ export async function renderInCoderEnvironment({ await waitFor(() => expect(loadingIndicator).not.toBeInTheDocument()); return renderOutput; } + +type InvertedPromiseResult = Readonly<{ + promise: Promise; + resolve: (value: T) => void; + reject: (errorReason: unknown) => void; +}>; + +export function createInvertedPromise(): InvertedPromiseResult { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + + return { promise, resolve, reject }; +} From d50a3af7ad90b4094be311b02585625b2b12d3c6 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 19:57:10 +0000 Subject: [PATCH 32/94] fix: make non-authenticated queries fail faster --- .../backstage-plugin-coder/src/hooks/reactQueryWrappers.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts index bf5319de..f028ab1b 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts @@ -91,6 +91,10 @@ export function useCoderQuery< isAuthenticated && (queryOptions.refetchIntervalInBackground ?? false), queryFn: async context => { + if (!isAuthenticated) { + throw new Error('Cannot complete request - user is not authenticated'); + } + return queryOptions.queryFn({ ...context, sdk }); }, From ea46f3c030ce12148b0a55fb9666c847b295bb56 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 19:57:35 +0000 Subject: [PATCH 33/94] fix: update tests to make setup easier --- .../src/hooks/reactQueryWrappers.test.tsx | 178 +++++++++++------- 1 file changed, 107 insertions(+), 71 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx index 17563708..26a7e75c 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx @@ -1,7 +1,11 @@ -import React, { ReactNode } from 'react'; -import { act, render, screen, waitFor } from '@testing-library/react'; -import { type QueryClient } from '@tanstack/react-query'; -import { useCoderQuery } from './reactQueryWrappers'; +import React from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { + type QueryClient, + type QueryKey, + type UseQueryResult, +} from '@tanstack/react-query'; +import { type UseCoderQueryOptions, useCoderQuery } from './reactQueryWrappers'; import { CoderProvider, CoderAuth, @@ -12,21 +16,32 @@ import { mockAppConfig, mockCoderAuthToken, } from '../testHelpers/mockBackstageData'; -import { getMockQueryClient } from '../testHelpers/setup'; +import { + createInvertedPromise, + getMockQueryClient, +} from '../testHelpers/setup'; import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; -type RenderMockQueryOptions = Readonly<{ - children: ReactNode; +type RenderUseQueryOptions< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = Readonly<{ queryClient?: QueryClient; + queryOptions: UseCoderQueryOptions; }>; -function renderMockQueryComponent(options: RenderMockQueryOptions) { - const { children: mainComponent, queryClient = getMockQueryClient() } = - options; +async function renderCoderQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>(options: RenderUseQueryOptions) { + const { queryOptions, queryClient = getMockQueryClient() } = options; let latestRegisterNewToken!: CoderAuth['registerNewToken']; let latestEjectToken!: CoderAuth['ejectToken']; - const AuthEscapeHatch = () => { const auth = useEndUserCoderAuth(); latestRegisterNewToken = auth.registerNewToken; @@ -35,29 +50,36 @@ function renderMockQueryComponent(options: RenderMockQueryOptions) { return null; }; - const renderOutput = render(mainComponent, { - wrapper: ({ children }) => { - const mainMarkup = ( - - - {children} - - - - ); - - return wrapInTestApp(mainMarkup) as unknown as typeof mainMarkup; + type Result = UseQueryResult; + const renderOutput = renderHook( + newOptions => useCoderQuery(newOptions), + { + initialProps: queryOptions, + wrapper: ({ children }) => { + const mainMarkup = ( + + + {children} + + + + ); + + return wrapInTestApp(mainMarkup) as unknown as typeof mainMarkup; + }, }, - }); + ); + + await waitFor(() => expect(renderOutput.result.current).not.toBeNull()); return { ...renderOutput, - registerNewToken: (newToken: string) => { - return act(() => latestRegisterNewToken(newToken)); + registerMockToken: () => { + return act(() => latestRegisterNewToken(mockCoderAuthToken)); }, ejectToken: () => { return act(() => latestEjectToken()); @@ -66,54 +88,68 @@ function renderMockQueryComponent(options: RenderMockQueryOptions) { } describe(`${useCoderQuery.name}`, () => { - it('Disables requests while user is not authenticated', async () => { - const MockUserComponent = () => { - const workspacesQuery = useCoderQuery({ - queryKey: ['workspaces'], - queryFn: ({ sdk }) => sdk.getWorkspaces({ q: 'owner:me' }), - select: response => response.workspaces, + /** + * Really wanted to make mock components for each test case, to simulate some + * of the steps of using the hook as an actual end-user, but the setup steps + * got to be a bit much, just because of all the dependencies to juggle. + */ + describe('Hook functionality', () => { + it('Disables requests while user is not authenticated', async () => { + const { result, registerMockToken, ejectToken } = await renderCoderQuery({ + queryOptions: { + queryKey: ['workspaces'], + queryFn: ({ sdk }) => sdk.getWorkspaces({ q: 'owner:me' }), + select: response => response.workspaces, + }, }); - return ( -
- {workspacesQuery.error instanceof Error && ( -

Encountered error: {workspacesQuery.error.message}

- )} - - {workspacesQuery.isLoading &&

Loading…

} - - {workspacesQuery.data !== undefined && ( -
    - {workspacesQuery.data.map(workspace => ( -
  • {workspace.name}
  • - ))} -
- )} -
- ); - }; - - const loadingMatcher = /^Loading/; - const { registerNewToken, ejectToken } = renderMockQueryComponent({ - children: , - }); + expect(result.current.isLoading).toBe(true); - const initialLoadingIndicator = screen.getByText(loadingMatcher); - registerNewToken(mockCoderAuthToken); + registerMockToken(); + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(true); + expect(result.current.data?.length).toBeGreaterThan(0); + }); - await waitFor(() => { - const workspaceItems = screen.getAllByRole('listitem'); - expect(workspaceItems.length).toBeGreaterThan(0); - expect(initialLoadingIndicator).not.toBeInTheDocument(); + ejectToken(); + await waitFor(() => expect(result.current.isLoading).toBe(true)); }); - ejectToken(); - const newLoadingIndicator = await screen.findByText(loadingMatcher); - expect(newLoadingIndicator).toBeInTheDocument(); - }); + /** + * In case the title isn't clear (had to rewrite it a bunch), the flow is: + * + * 1. User gets authenticated + * 2. User makes a request that will fail + * 3. Before the request comes back, the user revokes their authentication + * 4. The failed request comes back, which would normally add error state, + * and kick off a bunch of retry logic for React Query + * 5. But the hook should tell the Query Client NOT retry the request + * because the user is no longer authenticated + */ + it.only('Will not retry a request if it gets sent out while the user is authenticated, but then fails after the user revokes authentication', async () => { + const { promise, reject } = createInvertedPromise(); + const queryFn = jest.fn(() => promise); + + const { registerMockToken, ejectToken } = await renderCoderQuery({ + queryOptions: { + queryFn, + queryKey: ['blah'], + + // From the end user's perspective, the query should always retry, but + // the hook should override that when the user isn't authenticated + retry: true, + }, + }); - it('Never retries requests if the user is not authenticated', () => { - expect.hasAssertions(); + registerMockToken(); + await waitFor(() => expect(queryFn).toHaveBeenCalled()); + ejectToken(); + + queryFn.mockReset(); + act(() => reject(new Error("Don't feel like giving you data today"))); + expect(queryFn).not.toHaveBeenCalled(); + }); }); it('Never displays previous data for changing query keys if the user is not authenticated', () => { From f4ca6e8a728a313969e487343873f2600cac1175 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 20:35:53 +0000 Subject: [PATCH 34/94] wip: get another test passing --- .../src/hooks/reactQueryWrappers.test.tsx | 108 +++++++++++------- 1 file changed, 69 insertions(+), 39 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx index 26a7e75c..2fbd9ab3 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx @@ -1,10 +1,6 @@ import React from 'react'; import { act, renderHook, waitFor } from '@testing-library/react'; -import { - type QueryClient, - type QueryKey, - type UseQueryResult, -} from '@tanstack/react-query'; +import { type QueryKey, type UseQueryResult } from '@tanstack/react-query'; import { type UseCoderQueryOptions, useCoderQuery } from './reactQueryWrappers'; import { CoderProvider, @@ -21,6 +17,7 @@ import { getMockQueryClient, } from '../testHelpers/setup'; import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; +import { CODER_QUERY_KEY_PREFIX } from '../plugin'; type RenderUseQueryOptions< TQueryFnData = unknown, @@ -28,7 +25,7 @@ type RenderUseQueryOptions< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > = Readonly<{ - queryClient?: QueryClient; + authenticateOnMount?: boolean; queryOptions: UseCoderQueryOptions; }>; @@ -38,7 +35,7 @@ async function renderCoderQuery< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >(options: RenderUseQueryOptions) { - const { queryOptions, queryClient = getMockQueryClient() } = options; + const { queryOptions, authenticateOnMount = true } = options; let latestRegisterNewToken!: CoderAuth['registerNewToken']; let latestEjectToken!: CoderAuth['ejectToken']; @@ -61,7 +58,7 @@ async function renderCoderQuery< {children} @@ -76,15 +73,19 @@ async function renderCoderQuery< await waitFor(() => expect(renderOutput.result.current).not.toBeNull()); - return { - ...renderOutput, - registerMockToken: () => { - return act(() => latestRegisterNewToken(mockCoderAuthToken)); - }, - ejectToken: () => { - return act(() => latestEjectToken()); - }, + const registerMockToken = () => { + return act(() => latestRegisterNewToken(mockCoderAuthToken)); + }; + + const ejectToken = () => { + return act(() => latestEjectToken()); }; + + if (authenticateOnMount) { + registerMockToken(); + } + + return { ...renderOutput, registerMockToken, ejectToken }; } describe(`${useCoderQuery.name}`, () => { @@ -92,10 +93,14 @@ describe(`${useCoderQuery.name}`, () => { * Really wanted to make mock components for each test case, to simulate some * of the steps of using the hook as an actual end-user, but the setup steps * got to be a bit much, just because of all the dependencies to juggle. + * + * @todo Add a new describe block with custom components to mirror some + * example user flows */ describe('Hook functionality', () => { it('Disables requests while user is not authenticated', async () => { const { result, registerMockToken, ejectToken } = await renderCoderQuery({ + authenticateOnMount: false, queryOptions: { queryKey: ['workspaces'], queryFn: ({ sdk }) => sdk.getWorkspaces({ q: 'owner:me' }), @@ -116,6 +121,52 @@ describe(`${useCoderQuery.name}`, () => { await waitFor(() => expect(result.current.isLoading).toBe(true)); }); + it("Automatically prefixes queryKey with the global Coder query key prefix if it doesn't already exist", async () => { + // Have to escape out the key because useQuery doesn't expose any way to + // access the key after it's been processed into a query result object + let processedQueryKey: QueryKey | undefined = undefined; + + // Verify that key is updated if the prefix isn't already there + const { unmount } = await renderCoderQuery({ + queryOptions: { + queryKey: ['blah'], + queryFn: ({ queryKey }) => { + processedQueryKey = queryKey; + return Promise.resolve('Working as expected!'); + }, + }, + }); + + await waitFor(() => { + expect(processedQueryKey).toEqual([CODER_QUERY_KEY_PREFIX, 'blah']); + }); + + unmount(); + + // Verify that the key is unchanged if the prefix is already present + await renderCoderQuery({ + queryOptions: { + queryKey: [CODER_QUERY_KEY_PREFIX, 'nah'], + queryFn: ({ queryKey }) => { + processedQueryKey = queryKey; + return Promise.resolve('Working as expected!'); + }, + }, + }); + + await waitFor(() => { + expect(processedQueryKey).toEqual([CODER_QUERY_KEY_PREFIX, 'nah']); + }); + }); + + it('Does not disable retries while the user is authenticated', async () => { + expect.hasAssertions(); + }); + + it('Disables everything when the user unlinks their access token', async () => { + expect.hasAssertions(); + }); + /** * In case the title isn't clear (had to rewrite it a bunch), the flow is: * @@ -127,11 +178,11 @@ describe(`${useCoderQuery.name}`, () => { * 5. But the hook should tell the Query Client NOT retry the request * because the user is no longer authenticated */ - it.only('Will not retry a request if it gets sent out while the user is authenticated, but then fails after the user revokes authentication', async () => { + it('Will not retry a request if it gets sent out while the user is authenticated, but then fails after the user revokes authentication', async () => { const { promise, reject } = createInvertedPromise(); const queryFn = jest.fn(() => promise); - const { registerMockToken, ejectToken } = await renderCoderQuery({ + const { ejectToken } = await renderCoderQuery({ queryOptions: { queryFn, queryKey: ['blah'], @@ -142,7 +193,6 @@ describe(`${useCoderQuery.name}`, () => { }, }); - registerMockToken(); await waitFor(() => expect(queryFn).toHaveBeenCalled()); ejectToken(); @@ -151,24 +201,4 @@ describe(`${useCoderQuery.name}`, () => { expect(queryFn).not.toHaveBeenCalled(); }); }); - - it('Never displays previous data for changing query keys if the user is not authenticated', () => { - expect.hasAssertions(); - }); - - it("Automatically prefixes queryKey with the global Coder query key prefix if it doesn't already exist", () => { - expect.hasAssertions(); - }); - - it('Disables all refetch-based properties when the user is not authenticated', () => { - expect.hasAssertions(); - }); - - it('Behaves exactly like useQuery if the user is fully authenticated (aside from queryKey patching)', () => { - expect.hasAssertions(); - }); - - it('Disables everything when the user unlinks their access token', () => { - expect.hasAssertions(); - }); }); From a12d4d836919a8c223d739a7221da32b17ee2b35 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 21:33:57 +0000 Subject: [PATCH 35/94] chore: finish all initial tests for useCoderQuery --- .../src/hooks/reactQueryWrappers.test.tsx | 90 ++++++++++++++----- 1 file changed, 67 insertions(+), 23 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx index 2fbd9ab3..23fadcc1 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx @@ -1,10 +1,18 @@ import React from 'react'; import { act, renderHook, waitFor } from '@testing-library/react'; -import { type QueryKey, type UseQueryResult } from '@tanstack/react-query'; -import { type UseCoderQueryOptions, useCoderQuery } from './reactQueryWrappers'; +import type { + QueryClient, + QueryKey, + UseQueryResult, +} from '@tanstack/react-query'; import { + type UseCoderQueryOptions, + useCoderQuery, + CoderQueryFunction, +} from './reactQueryWrappers'; +import { + type CoderAuth, CoderProvider, - CoderAuth, useEndUserCoderAuth, } from '../components/CoderProvider'; import { @@ -18,6 +26,7 @@ import { } from '../testHelpers/setup'; import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; import { CODER_QUERY_KEY_PREFIX } from '../plugin'; +import { mockWorkspacesList } from '../testHelpers/mockCoderPluginData'; type RenderUseQueryOptions< TQueryFnData = unknown, @@ -26,6 +35,7 @@ type RenderUseQueryOptions< TQueryKey extends QueryKey = QueryKey, > = Readonly<{ authenticateOnMount?: boolean; + queryClient?: QueryClient; queryOptions: UseCoderQueryOptions; }>; @@ -35,7 +45,11 @@ async function renderCoderQuery< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >(options: RenderUseQueryOptions) { - const { queryOptions, authenticateOnMount = true } = options; + const { + queryOptions, + authenticateOnMount = true, + queryClient = getMockQueryClient(), + } = options; let latestRegisterNewToken!: CoderAuth['registerNewToken']; let latestEjectToken!: CoderAuth['ejectToken']; @@ -58,7 +72,7 @@ async function renderCoderQuery< {children} @@ -109,8 +123,8 @@ describe(`${useCoderQuery.name}`, () => { }); expect(result.current.isLoading).toBe(true); - registerMockToken(); + await waitFor(() => { expect(result.current.isLoading).toBe(false); expect(result.current.isSuccess).toBe(true); @@ -121,50 +135,80 @@ describe(`${useCoderQuery.name}`, () => { await waitFor(() => expect(result.current.isLoading).toBe(true)); }); - it("Automatically prefixes queryKey with the global Coder query key prefix if it doesn't already exist", async () => { + it("Automatically prefixes queryKey with the global Coder query key prefix if it isn't already there", async () => { // Have to escape out the key because useQuery doesn't expose any way to // access the key after it's been processed into a query result object let processedQueryKey: QueryKey | undefined = undefined; + const queryFnWithEscape: CoderQueryFunction = ({ queryKey }) => { + processedQueryKey = queryKey; + return Promise.resolve(mockWorkspacesList); + }; + // Verify that key is updated if the prefix isn't already there const { unmount } = await renderCoderQuery({ queryOptions: { queryKey: ['blah'], - queryFn: ({ queryKey }) => { - processedQueryKey = queryKey; - return Promise.resolve('Working as expected!'); - }, + queryFn: queryFnWithEscape, }, }); await waitFor(() => { - expect(processedQueryKey).toEqual([CODER_QUERY_KEY_PREFIX, 'blah']); + expect(processedQueryKey).toEqual([ + CODER_QUERY_KEY_PREFIX, + 'blah', + ]); }); + // Unmounting shouldn't really be necessary, but it helps guarantee that + // there's never any risks of states messing with each other unmount(); // Verify that the key is unchanged if the prefix is already present await renderCoderQuery({ queryOptions: { queryKey: [CODER_QUERY_KEY_PREFIX, 'nah'], - queryFn: ({ queryKey }) => { - processedQueryKey = queryKey; - return Promise.resolve('Working as expected!'); - }, + queryFn: queryFnWithEscape, }, }); await waitFor(() => { - expect(processedQueryKey).toEqual([CODER_QUERY_KEY_PREFIX, 'nah']); + expect(processedQueryKey).toEqual([ + CODER_QUERY_KEY_PREFIX, + 'nah', + ]); }); }); - it('Does not disable retries while the user is authenticated', async () => { - expect.hasAssertions(); - }); - it('Disables everything when the user unlinks their access token', async () => { - expect.hasAssertions(); + const { result, ejectToken } = await renderCoderQuery({ + queryOptions: { + queryKey: ['workspaces'], + queryFn: () => Promise.resolve(mockWorkspacesList), + }, + }); + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining>({ + isSuccess: true, + isPaused: false, + data: mockWorkspacesList, + }), + ); + }); + + ejectToken(); + + await waitFor(() => { + expect(result.current).toEqual( + expect.objectContaining>({ + isLoading: true, + isPaused: false, + data: undefined, + }), + ); + }); }); /** @@ -196,7 +240,7 @@ describe(`${useCoderQuery.name}`, () => { await waitFor(() => expect(queryFn).toHaveBeenCalled()); ejectToken(); - queryFn.mockReset(); + queryFn.mockRestore(); act(() => reject(new Error("Don't feel like giving you data today"))); expect(queryFn).not.toHaveBeenCalled(); }); From 1c11f5cfbce648ece298ab172e6abaf687b7acfa Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 21:53:12 +0000 Subject: [PATCH 36/94] fix: tighten up types for inverted promises --- .../src/testHelpers/setup.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index 80379adc..196c2127 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -229,17 +229,20 @@ export async function renderInCoderEnvironment({ return renderOutput; } -type InvertedPromiseResult = Readonly<{ - promise: Promise; - resolve: (value: T) => void; - reject: (errorReason: unknown) => void; +type InvertedPromiseResult = Readonly<{ + promise: Promise; + resolve: (value: TData) => void; + reject: (errorReason: TError) => void; }>; -export function createInvertedPromise(): InvertedPromiseResult { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; +export function createInvertedPromise< + TData = unknown, + TError = unknown, +>(): InvertedPromiseResult { + let resolve!: (value: TData) => void; + let reject!: (error: TError) => void; - const promise = new Promise((innerResolve, innerReject) => { + const promise = new Promise((innerResolve, innerReject) => { resolve = innerResolve; reject = innerReject; }); From 071d8c8404c2457e0cfdfeac00439d94bc608f2c Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 21:53:35 +0000 Subject: [PATCH 37/94] fix: more tightening --- plugins/backstage-plugin-coder/src/testHelpers/setup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index 196c2127..708c08ae 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -237,7 +237,7 @@ type InvertedPromiseResult = Readonly<{ export function createInvertedPromise< TData = unknown, - TError = unknown, + TError = Error, >(): InvertedPromiseResult { let resolve!: (value: TData) => void; let reject!: (error: TError) => void; From 5120b5b7277d49d1b898757ea89131ecac71c74d Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 28 May 2024 22:14:27 +0000 Subject: [PATCH 38/94] fix: make sure queries aren't tried indefinitely by default --- .../src/hooks/reactQueryWrappers.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts index f028ab1b..0cf80821 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts @@ -13,9 +13,6 @@ * * Making the useMutation wrapper shouldn't be hard, but you want some good * integration tests to verify that the two hooks can satisfy common user flows. - * - * Draft version of wrapper: - * @see {@link https://gist.github.com/Parkreiner/5c1e01f820500a49e2e81897a507e907} */ import { type QueryFunctionContext, @@ -131,13 +128,19 @@ export function useCoderQuery< const externalRetry = queryOptions.retry; if (typeof externalRetry === 'number') { - return ( - failureCount < (externalRetry ?? DEFAULT_TANSTACK_QUERY_RETRY_COUNT) - ); + const normalized = Number.isInteger(externalRetry) + ? Math.max(1, externalRetry) + : DEFAULT_TANSTACK_QUERY_RETRY_COUNT; + + return failureCount < normalized; } if (typeof externalRetry !== 'function') { - return externalRetry ?? true; + // Could use the nullish coalescing operator here, but Prettier made the + // output hard to read + return externalRetry + ? externalRetry + : failureCount < DEFAULT_TANSTACK_QUERY_RETRY_COUNT; } return externalRetry(failureCount, error); From d17294173e82789e475fbfb071a571888f3b284f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 29 May 2024 03:11:20 +0000 Subject: [PATCH 39/94] wip: commit docs progress --- .../docs/guides/sdk-recommendations.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md new file mode 100644 index 00000000..50df56af --- /dev/null +++ b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md @@ -0,0 +1,48 @@ +# Using the Coder SDK + +This document walks you through using the Coder SDK from within Backstage. + +## Disclaimer + +As of May 28, 2024, Coder does not have a fully public, versioned SDK +published on a package manager like NPM. Coder does intend to release a true +JavaScript/TypeScript SDK, but until that is released, the SDK exposed through +Backstage can be best thought of as a "preview"/"testbed" SDK. + +If you encounter any issues while using the Backstage version of the SDK, +please don't hesitate to open an issue. We would be happy to get any issues +fixed, but expect some growing pains as we collect user feedback. + +## Welcome to the Coder SDK! + +The Coder SDK for Backstage allows Backstage admins to bring the entire Coder +API into Spotify's Backstage platform. While the plugin ships with a collection +of ready-made components, those can't meet every user's needs, and so, why not +give you access to the full set of building blocks, so you can build a solution +tailored to your specific use case? + +This guide covers the following: + +- Accessing the SDK from your own custom React components +- Authenticating + - The fallback auth UI +- Performing queries + - Recommendations for caching data + - How the Coder plugin caches data + - Cautions against other common UI caching strategies +- Performing mutations + +### Before you begin + +This guide assumes that you have already added the `CoderProvider` component to +your Backstage deployment. If you have not, +[please see the main README](../../README.md#setup) for instructions on getting +that set up. + +## Accessing the SDK from your own custom React components + +## Authenticating + +## Performing queries + +## Performing mutations From 583062400ae34049eb07457c8c7004de71c8e42b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 29 May 2024 03:22:38 +0000 Subject: [PATCH 40/94] fix: increase granularity for auth fallback behavior --- .../components/CoderProvider/CoderAuthProvider.tsx | 12 ++++++++---- .../components/CoderProvider/CoderProvider.test.tsx | 1 + .../src/components/CoderProvider/CoderProvider.tsx | 4 ++-- .../src/hooks/reactQueryWrappers.test.tsx | 2 +- .../backstage-plugin-coder/src/testHelpers/setup.tsx | 2 +- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 5029e205..1addffca 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -607,21 +607,25 @@ export const dummyTrackComponent: TrackComponent = () => { }; }; -type CoderAuthProviderProps = Readonly< +export type FallbackAuthInputBehavior = 'always' | 'never' | 'dynamic'; +export type CoderAuthProviderProps = Readonly< PropsWithChildren<{ - showFallbackAuthForm?: boolean; + fallbackAuthUiMode: FallbackAuthInputBehavior; }> >; export function CoderAuthProvider({ children, - showFallbackAuthForm = true, + fallbackAuthUiMode = 'dynamic', }: CoderAuthProviderProps) { const authState = useAuthState(); const { hasNoAuthInputs, trackComponent } = useAuthFallbackState(); const needFallbackUi = - showFallbackAuthForm && !authState.isAuthenticated && hasNoAuthInputs; + fallbackAuthUiMode === 'always' || + (fallbackAuthUiMode === 'dynamic' && + !authState.isAuthenticated && + hasNoAuthInputs); return ( diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx index 73acc13c..ba1d8ecd 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -86,6 +86,7 @@ describe(`${CoderProvider.name}`, () => { {children} diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx index 55eb12e2..b53851ae 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx @@ -45,14 +45,14 @@ const defaultClient = new QueryClient({ export const CoderProvider = ({ children, appConfig, - showFallbackAuthForm = true, + fallbackAuthUiMode: showFallbackAuthUi = 'dynamic', queryClient = defaultClient, }: CoderProviderProps) => { return ( - + {children} diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx index 23fadcc1..57800bb4 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx @@ -70,7 +70,7 @@ async function renderCoderQuery< const mainMarkup = ( diff --git a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx index 708c08ae..b7d3191a 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx +++ b/plugins/backstage-plugin-coder/src/testHelpers/setup.tsx @@ -113,7 +113,7 @@ export function getMockQueryClient(config?: QueryClientConfig): QueryClient { } type MockAuthProps = Readonly< - CoderProviderProps & { + Omit & { auth?: CoderAuth; /** From 883f1abee8ea5fa57af7c94cad653ab8082097d2 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 29 May 2024 04:06:40 +0000 Subject: [PATCH 41/94] wip: commit more docs progress --- .../docs/guides/sdk-recommendations.md | 114 +++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md index 50df56af..af342313 100644 --- a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md +++ b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md @@ -39,10 +39,122 @@ your Backstage deployment. If you have not, [please see the main README](../../README.md#setup) for instructions on getting that set up. +### The main SDK hooks + +There are three hooks that you want to consider when interacting with SDK +functionality: + +- `useCoderSdk` +- `useCoderAuth` +- `useCoderQuery` + +All three hooks must be called from within a `CoderProvider` or else they will +throw an error. + ## Accessing the SDK from your own custom React components -## Authenticating +There are two main ways of accessing the Coder SDK: + +- `useCoderSdk` +- `useCoderQuery` + +### Accessing the SDK through `useCoderSdk` + +`useCoderSdk` is a lower-level "primitive" for accessing the Coder SDK. It +exposes a mix of different REST API methods for interacting with your Coder +deployment's resources. + +The hook exposes two main properties – `sdk` and `backstageUtilities`. `sdk` contains the set of all API methods, while `backstageUtilities` provides some +general-purpose helpers for building more sophisticated UIs. + +```tsx +// Illustrative example - this exact code is a very bad idea in production! +function ExampleComponent() { + const { sdk } = useCoderSdk(); + + return ( + + ); +} +``` + +#### The `sdk` property + +`sdk` contains all available API methods. All methods follow the format +`` + ``. `sdk` has these verbs: + +- `get` +- `post` +- `put` +- `patch` +- `upsert` +- `delete` + +Depending on the Coder resource, there may be different API methods that work with a single resource vs all resources (e.g., `sdk.getWorkspace` vs `sdk.getWorkspaces`). + +Note that all of these functions will throw an error if the user is not +authenticated. + +#### The `backstageUtilities` property + +This property contains general-purpose methods that you might want to use +alongside the Coder SDK. + +Right now, the property contains the following methods: + +- `unlinkCoderAccount` - Logs out the current user +- `registerSessionToken` - Loads a new Coder session token into the SDK + +### Accessing the SDK through `useCoderQuery` + +The nuances of how `useCoderQuery` works are covered later in this guide, but +for convenience, the hook can access the SDK directly from its `queryFn` +function: + +```tsx +const workspacesQuery = useCoderQuery({ + queryKey: ['workspaces'], + + // Access the SDK without needing to import or call useCoderSdk + queryFn: ({ sdk }) => sdk.getWorkspaces({ q: 'owner:me' }), +}); +``` + +## Authentication + +All API methods from the SDK will throw an error if the user is not +authenticated. The Coder plugin provides a few different ways of letting the +user authenticate with Coder: + +- The official Coder components +- The fallback auth UI +- The `useCoderSdk` hook's `registerSessionToken` method ## Performing queries +### Problems with `useEffect` + +### Problems with `useAsync` + ## Performing mutations + +## Sharing data between different queries and mutations + +## Additional reading + +## Example components From 67713a1a5be67554fccd5d77b2801c2d3a3180d7 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 29 May 2024 04:12:57 +0000 Subject: [PATCH 42/94] fix: establish better boundaries between hooks --- .../useCoderWorkspacesQuery.ts | 2 +- .../src/hooks/reactQueryWrappers.ts | 2 +- .../src/hooks/useCoderSdk.ts | 22 ++----------------- 3 files changed, 4 insertions(+), 22 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts index f6477e7f..3fc7a4ca 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts @@ -14,7 +14,7 @@ export function useCoderWorkspacesQuery({ workspacesConfig, }: QueryInput) { const auth = useInternalCoderAuth(); - const { sdk } = useCoderSdk(); + const sdk = useCoderSdk(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; const queryOptions = hasRepoData diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts index 0cf80821..56d33e56 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts @@ -57,7 +57,7 @@ export function useCoderQuery< ): UseQueryResult { const queryClient = useQueryClient(); const { isAuthenticated } = useEndUserCoderAuth(); - const { sdk } = useCoderSdk(); + const sdk = useCoderSdk(); let patchedQueryKey = queryOptions.queryKey; if ( diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts index f394660c..bc2239bc 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts @@ -12,26 +12,8 @@ */ import { useApi } from '@backstage/core-plugin-api'; import { coderClientApiRef, type BackstageCoderSdk } from '../api/CoderClient'; -import { useEndUserCoderAuth } from '../components/CoderProvider'; -type UseCoderSdkResult = Readonly<{ - sdk: BackstageCoderSdk; - backstageUtils: Readonly<{ - unlinkCoderAccount: () => void; - }>; -}>; - -export function useCoderSdk(): UseCoderSdkResult { - const { ejectToken } = useEndUserCoderAuth(); +export function useCoderSdk(): BackstageCoderSdk { const { sdk } = useApi(coderClientApiRef); - - return { - sdk, - backstageUtils: { - // Hoping that as we support more auth methods, this function gets beefed - // up to be an all-in-one function for removing any and all auth info. - // Simply doing a pass-through for now - unlinkCoderAccount: ejectToken, - }, - }; + return sdk; } From 570cecc3e9f7940f5c9fcaf29d614a5a4e5cd493 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 29 May 2024 04:19:57 +0000 Subject: [PATCH 43/94] wip: commit more progress --- .../docs/guides/sdk-recommendations.md | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md index af342313..b88bc53d 100644 --- a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md +++ b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md @@ -42,14 +42,16 @@ that set up. ### The main SDK hooks There are three hooks that you want to consider when interacting with SDK -functionality: +functionality. These can be broken down into two main categories: + +#### Primitive hooks - `useCoderSdk` - `useCoderAuth` -- `useCoderQuery` -All three hooks must be called from within a `CoderProvider` or else they will -throw an error. +#### Convenience hooks + +- `useCoderQuery` ## Accessing the SDK from your own custom React components @@ -64,13 +66,14 @@ There are two main ways of accessing the Coder SDK: exposes a mix of different REST API methods for interacting with your Coder deployment's resources. -The hook exposes two main properties – `sdk` and `backstageUtilities`. `sdk` contains the set of all API methods, while `backstageUtilities` provides some -general-purpose helpers for building more sophisticated UIs. +Calling the hook will give you an object with all available API methods. As +these methods are all async, **none** of them are suitable for use in render +logic. They must be called from within effects or event handlers. ```tsx // Illustrative example - this exact code is a very bad idea in production! function ExampleComponent() { - const { sdk } = useCoderSdk(); + const sdk = useCoderSdk(); return ( + <> + + +
    + {workspaces.map(workspace => ( +
  • {workspace.name}
  • + ))} +
+ ); } ``` From 85b7942a60a3f6b63fc46b1881704f3c4f300954 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 30 May 2024 21:54:29 +0000 Subject: [PATCH 47/94] wip: commit more progress --- .../docs/guides/sdk-recommendations.md | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md index 4e746e6f..2b3034a3 100644 --- a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md +++ b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md @@ -4,13 +4,13 @@ This document walks you through using the Coder SDK from within Backstage. ## Disclaimer -As of May 28, 2024, Coder does not have a fully public, versioned SDK published on a package manager like NPM. Coder does intend to release a true JavaScript/TypeScript SDK, but until that is released, the SDK exposed through Backstage can be best thought of as a "preview"/"testbed" SDK. +As of May 2024, Coder does not have a fully public, versioned SDK published on a package manager like NPM. Coder does intend to release a true JavaScript/TypeScript SDK, but until that is released, the SDK exposed through Backstage can be best thought of as a "preview"/"testbed" SDK. At present, this SDK is also used in production for [the official Coder VS Code extension](https://github.com/coder/vscode-coder). If you encounter any issues while using the Backstage version of the SDK, please don't hesitate to open an issue. We would be happy to get any issues fixed, but expect some growing pains as we collect user feedback. ## Welcome to the Coder SDK! -The Coder SDK for Backstage allows Backstage admins to bring the entire Coder API into Spotify's Backstage platform. While the plugin ships with a collection of ready-made components, those can't meet every user's needs, and so, why not give you access to the full set of building blocks, so you can build a solution tailored to your specific use case? +The Coder SDK for Backstage makes it easy for Backstage admins to bring the entire Coder API into Spotify's Backstage platform. While the Coder Backstage plugin does ship with a collection of ready-made components, those can't meet every user's needs, and so, why not give you access to the full set of building blocks, so you can build a solution tailored to your specific use case? This guide covers the following: @@ -49,12 +49,12 @@ There are two main ways of accessing the Coder SDK: ### Accessing the SDK through `useCoderSdk` -`useCoderSdk` is a lower-level "primitive" for accessing the Coder SDK. It exposes a mix of different REST API methods for interacting with your Coder deployment's resources. +`useCoderSdk` is a lower-level "primitive" for accessing the Coder SDK. The SDK exposes a mix of different REST API methods for interacting with your Coder deployment's resources. Calling the hook will give you an object with all available API methods. As these methods are all async, **none** of them are suitable for use in render logic. They must be called from within effects or event handlers. ```tsx -// Illustrative example - this exact code is a very bad idea in production! +// Illustrative example - these patterns are a very bad idea in production! function ExampleComponent() { // The workspace type is exported via the plugin const [workspaces, setWorkspaces] = useState([]); @@ -71,7 +71,8 @@ function ExampleComponent() { void syncInitialWorkspaces(); // The SDK maintains a stable memory reference; there is no harm in - // including it as part of your dependency arrays + // including it as part of your dependency arrays. In this case, the + // dependency array may as well be empty. }, [sdk]); return ( @@ -118,6 +119,10 @@ Depending on the Coder resource, there may be different API methods that work wi Note that all of these functions will throw an error if the user is not authenticated. +#### Error behavior + +All SDK functions will throw in the event of an error. You will need to provide additional error handling to expose errors as values within the UI. (Tanstack Query does this automatically for you.) + ### Accessing the SDK through `useCoderQuery` The nuances of how `useCoderQuery` works are covered later in this guide, but for convenience, the hook can access the SDK directly from its `queryFn` function: @@ -141,8 +146,35 @@ All API methods from the SDK will throw an error if the user is not authenticate ### Authenticating via official Coder components +Every official Coder component (such as `CoderWorkspacesCard`) exported through the plugin is guaranteed to have some mechanism for supplying auth information. This is typically done via a UI form. + + + +#### Pros + +- Come pre-wired with the ability to let the user supply auth information +- Extensively tested +- Let you override the styles or re-compose the individual pieces for your needs +- Always audited for WCAG Level AA accessibility, and include landmark behavior for screen readers + +#### Cons + +- Not every Coder component makes sense for every page +- No easy mechanism for ensuring that your custom components don't run until the user authenticates via the official Coder components +- Components only work with a small sub-section of the total API, and won't be able to satisfy true power users + ### Authenticating via the fallback auth UI +When you include the `CoderProvider` component in your Backstage deployment, you have the option to set the value of `fallbackAuthUiMode`. This value affects how `CoderProvider` will inject a fallback auth input into the Backstage deployment's HTML. This means that, even if you don't use any Coder components, or are on a page that can't use them, users will always have some way of supplying auth information. + + + +The fallback auth UI will never be visible while the user is authenticated. However, if the user is not authenticated, then the value of `fallbackAuthUiMode` will affect what appears on screen: + +- `restrained` (default) - The fallback auth UI will not appear if there are official Coder components on screen. +- `hidden` - The fallback auth is **never** visible on the page. If you do not have any Coder components, you will need to include `useCoderAuth` in your custom components to authenticate your users. +- `assertive` - The fallback auth UI is always visible when the user is not authenticated, regardless of whether there are any official Coder components on screen. + ### Authenticating via `useCoderAuth` ## Caching API data for UIs @@ -159,7 +191,7 @@ At present, the Coder plugin provides a convenience wrapper for connecting `useQ We also plan to create `useMutation` wrapper called `useCoderMutation`. -### Problems with `useState` + `useEffect` +### Problems with fetching via `useEffect` All functions returned by the Coder SDK maintain stable memory references for the entire lifetime of the SDK. In that sense, every one of these functions can be safely placed inside a `useEffect` dependency array to perform data fetching. In theory, `usEffect` can be used to trigger API calls, while the results (and their relevant loading/error/success states) can be stored via `useState`. @@ -176,9 +208,9 @@ In practice, however, this setup causes a lot of problems: Fetching data has never been the hard part of calling APIs in React. It's always been figuring out how to cache it in a render-safe way that's been tricky. -### Problems with `useAsync` +### Problems with fetching via `useAsync` -While the [`useAsync` hook](https://github.com/streamich/react-use/blob/master/src/useAsync.ts) fares slightly better compared to using `useState` + `useEffect`, it still has a number of the same problems. In fact, it introduces a few new problems. +While the [`useAsync` hook](https://github.com/streamich/react-use/blob/master/src/useAsync.ts) fares slightly better compared to using `useState` + `useEffect`, it still has a number of the same problems. In fact, it introduces problems. #### Problems fixed @@ -190,14 +222,10 @@ While the [`useAsync` hook](https://github.com/streamich/react-use/blob/master/s #### New problems -- Even though `useAsync`'s API uses dependency arrays, by default, it is not eligible for the exhaustive deps ES Lint rule. While on the surface, this might seem freeing, in practice, it means that you have no safety nets for making sure that your effect logic runs the correct number of times. It can easily run too often or too little without you realizing. +- Even though `useAsync`'s API uses dependency arrays, by default, it is not eligible for the exhaustive deps ES Lint rule. This means that unless you update your ESLint rules, you have no safety nets for making sure that your effect logic runs the correct number of times. There are no protections against accidental typos. ## Performing queries ## Performing mutations ## Sharing data between different queries and mutations - -## Additional reading - -## Example components From 6814af8eb62cf541e068d6cdf55af418dd1e1579 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 30 May 2024 21:57:39 +0000 Subject: [PATCH 48/94] fix: update names for auth fallback modes --- .../components/CoderProvider/CoderAuthProvider.tsx | 12 ++++++------ .../src/components/CoderProvider/CoderProvider.tsx | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index cfb904ba..97f1bdd9 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -608,7 +608,7 @@ export const dummyTrackComponent: TrackComponent = () => { }; }; -export type FallbackAuthInputBehavior = 'always' | 'never' | 'dynamic'; +export type FallbackAuthInputBehavior = 'restrained' | 'assertive' | 'hidden'; export type CoderAuthProviderProps = Readonly< PropsWithChildren<{ fallbackAuthUiMode: FallbackAuthInputBehavior; @@ -629,23 +629,23 @@ type AuthProvider = FC< // meaning less performance overhead and fewer re-renders from something the // user isn't even using const fallbackProviders = { - never: ({ children }) => ( + hidden: ({ children }) => ( {children} ), - always: ({ children }) => ( + assertive: ({ children, isAuthenticated }) => ( // Don't need the live version of the tracker function if we're always // going to be showing the fallback auth input no matter what {children} - + {!isAuthenticated && } ), // Have to give function a name to satisfy ES Lint rules of hooks - dynamic: function DynamicFallback({ children, isAuthenticated }) { + restrained: function DynamicFallback({ children, isAuthenticated }) { const { hasNoAuthInputs, trackComponent } = useAuthFallbackState(); const needFallbackUi = !isAuthenticated && hasNoAuthInputs; @@ -667,7 +667,7 @@ const fallbackProviders = { export function CoderAuthProvider({ children, - fallbackAuthUiMode = 'dynamic', + fallbackAuthUiMode = 'restrained', }: CoderAuthProviderProps) { const authState = useAuthState(); const AuthFallbackProvider = fallbackProviders[fallbackAuthUiMode]; diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx index b53851ae..079e1f38 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx @@ -45,14 +45,14 @@ const defaultClient = new QueryClient({ export const CoderProvider = ({ children, appConfig, - fallbackAuthUiMode: showFallbackAuthUi = 'dynamic', + fallbackAuthUiMode = 'restrained', queryClient = defaultClient, }: CoderProviderProps) => { return ( - + {children} From 032e5a93d9c5f1e6dd237cc7f80a3ae67be2ab20 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 30 May 2024 22:44:25 +0000 Subject: [PATCH 49/94] wip: more progress --- .../docs/guides/sdk-recommendations.md | 53 +++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md index 2b3034a3..184556b4 100644 --- a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md +++ b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md @@ -15,11 +15,14 @@ The Coder SDK for Backstage makes it easy for Backstage admins to bring the enti This guide covers the following: - Accessing the SDK from your own custom React components + - Accessing the SDK via `useCoderSdk` + - Accessing the SDK via `useCoderQuery` - Authenticating - The fallback auth UI - Performing queries - Recommendations for caching data - How the Coder plugin caches data + - The `useCoderQuery` custom hook - Cautions against other common UI caching strategies - Performing mutations @@ -62,13 +65,13 @@ function ExampleComponent() { // The SDK can be called from any effect useEffect(() => { - const syncInitialWorkspaces = async () => { + const getInitialWorkspaces = async () => { const workspacesResponse = await sdk.getWorkspaces({ q: 'owner:me' }); const workspaces = workspacesResponse.workspaces; setWorkspaces(workspaces); }; - void syncInitialWorkspaces(); + void getInitialWorkspaces(); // The SDK maintains a stable memory reference; there is no harm in // including it as part of your dependency arrays. In this case, the @@ -144,6 +147,8 @@ All API methods from the SDK will throw an error if the user is not authenticate - The `CoderProvider` component's fallback auth UI - The `useCoderAuth` hook +All three solutions, directly or indirectly, involve the `CoderAuth` type. More information can be found under the `useCoderAuth` section. + ### Authenticating via official Coder components Every official Coder component (such as `CoderWorkspacesCard`) exported through the plugin is guaranteed to have some mechanism for supplying auth information. This is typically done via a UI form. @@ -162,6 +167,7 @@ Every official Coder component (such as `CoderWorkspacesCard`) exported through - Not every Coder component makes sense for every page - No easy mechanism for ensuring that your custom components don't run until the user authenticates via the official Coder components - Components only work with a small sub-section of the total API, and won't be able to satisfy true power users +- Must be mounted within a `CoderProvider` component ### Authenticating via the fallback auth UI @@ -169,14 +175,53 @@ When you include the `CoderProvider` component in your Backstage deployment, you -The fallback auth UI will never be visible while the user is authenticated. However, if the user is not authenticated, then the value of `fallbackAuthUiMode` will affect what appears on screen: +The fallback auth UI will never be visible while the user is authenticated. However, if the user is **not** authenticated, then the value of `fallbackAuthUiMode` will affect what appears on screen: - `restrained` (default) - The fallback auth UI will not appear if there are official Coder components on screen. -- `hidden` - The fallback auth is **never** visible on the page. If you do not have any Coder components, you will need to include `useCoderAuth` in your custom components to authenticate your users. +- `hidden` - The fallback auth is **never** visible on the page. If no official Coder components are on screen, you will need to import `useCoderAuth` into your custom components to authenticate your users. - `assertive` - The fallback auth UI is always visible when the user is not authenticated, regardless of whether there are any official Coder components on screen. +#### Pros + +- Helps guarantee that the user always has a way of supplying auth information +- Multiple ways of setting the behavior for the fallback. If you don't want to display it at all times, you can disable it +- Automatic integration with official Coder components. The auth fallback UI can detect Coder components without you needing to rewrite any code. +- All auth UI logic has been tested and audited for accessibility at the same standards as the other Coder components + +#### Cons + +- Even with three options for setting behavior, fallback auth input may not be visible exactly when you want it to be +- The `restrained` behavior is only effective on pages where you can place official Coder components. If you are not on one of these pages, the fallback auth UI will always be visible until the user authenticates. +- Fewer options for customizing the styling + ### Authenticating via `useCoderAuth` +The `useCoderAuth` hook provides state and functions for updating Coder authentication state within your Backstage deployment. When called, it gives you back a `CoderAuth` object + +```tsx +// This is a simplified version of the type; the real type is set up as a +// discriminated union to increase type safety and ergonomics further +type CoderAuth = Readonly<{ + status: CoderAuthStatus; // Union of strings + token: string | undefined; + error: unknown; + + isAuthenticated: boolean; + registerNewToken: (newToken: string) => void; + ejectToken: () => void; +}>; +``` + +#### Pros + +- Gives you the finest level of control over all auth concerns +- Easy to import into any component + +#### Cons + +- Zero UI logic out of the box; you have to make all components yourself +- Must be called within a `CoderProvider` component + ## Caching API data for UIs All core logic in the Coder plugin uses [Tanstack Query v4](https://tanstack.com/query/v4). As it is already a dependency for the Coder plugin, it is highly recommended that you also use the library when building out your own components. From 5494443a7d0262bb44b262d6ff7f8b4c573cb8a7 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 30 May 2024 22:46:20 +0000 Subject: [PATCH 50/94] fix: remove repetitive wording --- .../backstage-plugin-coder/docs/guides/sdk-recommendations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md index 184556b4..607d446f 100644 --- a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md +++ b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md @@ -4,7 +4,7 @@ This document walks you through using the Coder SDK from within Backstage. ## Disclaimer -As of May 2024, Coder does not have a fully public, versioned SDK published on a package manager like NPM. Coder does intend to release a true JavaScript/TypeScript SDK, but until that is released, the SDK exposed through Backstage can be best thought of as a "preview"/"testbed" SDK. At present, this SDK is also used in production for [the official Coder VS Code extension](https://github.com/coder/vscode-coder). +As of May 2024, Coder does not have a fully public, versioned SDK published on a package manager like NPM. Coder does intend to release a true JavaScript/TypeScript SDK, but until that is released, the version exposed through Backstage can be best thought of as a "preview"/"testbed" SDK. It is also used in production for [the official Coder VS Code extension](https://github.com/coder/vscode-coder). If you encounter any issues while using the Backstage version of the SDK, please don't hesitate to open an issue. We would be happy to get any issues fixed, but expect some growing pains as we collect user feedback. From cac828db3268643a6059bf67da9516f862311d52 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 00:12:17 +0000 Subject: [PATCH 51/94] wip: more progress --- .../docs/guides/sdk-recommendations.md | 81 +++++++++++++++++-- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md index 607d446f..5a51e7ef 100644 --- a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md +++ b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md @@ -43,6 +43,8 @@ There are three hooks that you want to consider when interacting with SDK functi - `useCoderQuery` +## Quick-start + ## Accessing the SDK from your own custom React components There are two main ways of accessing the Coder SDK: @@ -226,15 +228,23 @@ type CoderAuth = Readonly<{ All core logic in the Coder plugin uses [Tanstack Query v4](https://tanstack.com/query/v4). As it is already a dependency for the Coder plugin, it is highly recommended that you also use the library when building out your own components. -The three main hooks that you will likely use with the SDK are: +The three main hooks from the library that you will likely use with the SDK are: - `useQuery` - `useMutation` - `useQueryClient` -At present, the Coder plugin provides a convenience wrapper for connecting `useQuery` to the Coder SDK and to Coder auth state. This is the `useCoderQuery` hook – if a component should only care about making queries and doesn't need to interact directly with auth state, this is a great option. +At present, the Coder plugin provides a convenience wrapper for connecting `useQuery` to the Coder SDK and to Coder auth state. This is the `useCoderQuery` hook – this is a great option if a component should only care about making queries and doesn't need to interact directly with auth state. + +We are also considering making a `useMutation` wrapper called `useCoderMutation`. If you have any thoughts or requests on how it should behave, please open an issue! + +## Why React Query? -We also plan to create `useMutation` wrapper called `useCoderMutation`. +ui.dev has a phenomenal [article](https://ui.dev/why-react-query) and [video](https://www.youtube.com/watch?v=OrliU0e09io) series that breaks things down more exhaustively, but in short, React Query: + +- Simplifies how API data is shared throughout an application +- Manages race conditions, canceling requests, and revalidating requests +- Provides a shared API for both queries and mutations ### Problems with fetching via `useEffect` @@ -269,8 +279,69 @@ While the [`useAsync` hook](https://github.com/streamich/react-use/blob/master/s - Even though `useAsync`'s API uses dependency arrays, by default, it is not eligible for the exhaustive deps ES Lint rule. This means that unless you update your ESLint rules, you have no safety nets for making sure that your effect logic runs the correct number of times. There are no protections against accidental typos. +## Sharing data between different queries and mutations + +Internally, the official components for the Coder plugin use one shared query key prefix value (`CODER_QUERY_KEY_PREFIX`) for every single query and mutation. This ensures that all Coder-based queries share a common root, and can be easily vacated from the query cache in response to certain events like the user unlinking their Coder account (basically "logging out"). + +This same prefix is exported to users of the plugin. When using the Coder SDK with React Query, it is **strongly, strongly** recommended that you use this same query prefix. It lets custom components and Coder components share the same underlying data (reducing the total number of requests). But the plugin is also set up to monitor queries with this prefix, so it can automate cache management and has more ways of detecting expired auth tokens. + ## Performing queries -## Performing mutations +You have two main options for performing queries: -## Sharing data between different queries and mutations +- `useQuery` +- `useCoderQuery` + +### `useQuery` + +#### Example + +#### Pros + +- Gives you the most direct control over caching data using a declarative API +- Fine-tuned to a mirror sheen – plays well with all React rendering behavior out of the box. + +#### Cons + +- Requires an additional import for `useCoderSdk` at a minimum, and usually requires an import for `useCoderAuth` +- The process of wiring up auth to all properties in the hook can be confusing. The Coder SDK throws when it makes a request that isn't authenticated, but you probably want to disable those requests altogether. +- While the `queryKey` property is less confusing overall, it is basically a `useEffect` dependency array. Dependency arrays can be confusing and hard to wire up correctly + +### `useCoderQuery` + +`useCoderQuery` is a convenience wrapper over `useQuery`, and can basically be thought of as a pre-wired combo of `useQuery`, `useCoderAuth`, and `useCoderSdk`. + +#### Example + +```tsx +// Without useCoderQuery +const { sdk } = useCoderSdk(); +const { isAuthenticated } = useCoderAuth(); +const query = useQuery({ + queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces'], + queryFn: () => sdk.getWorkspaces({ q: 'owner:me' }), + enabled: isAuthenticated, +}); + +// This is fully equivalent and has additional properties get updated based +// on auth status +const query2 = useCoderQuery({ + queryKey: ['workspaces'], + queryFn: ({ sdk }) => sdk.getWorkspaces({ q: 'owner:me' }), +}); +``` + +#### Pros + +- Reduces the total number of imports needed to get query logic set up, and pre-configures values to be more fool-proof. The Coder SDK is automatically patched into React Query's `queryFn` function context. +- Ensures that all Coder-based queries share the same query prefix, and are correctly evicted when the user unlinks their Coder account +- Automatically prohibits some deprecated properties from React Query v4 (using one of them is treated as a type error). This means guaranteed forwards-compatibility with React Query v5 + +#### Cons + +- Introduces extra overhead to ensure that queries are fully locked down when the user is not authenticated – even if they're not needed for every use case +- Doesn't offer quite as much fine-grained control compared to `useQuery` +- Does not simplify process of connecting Coder queries and mutations, because a `useCoderMutation` hook does not exist (yet) +- `queryKey` is slightly more confusing, because a new value is implicitly appended to the beginning of the array + +## Performing mutations From 256e2bdadbda8175531322dfdb02dee23e4d6a61 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 00:13:04 +0000 Subject: [PATCH 52/94] fix: add table of contents header --- .../backstage-plugin-coder/docs/guides/sdk-recommendations.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md index 5a51e7ef..d60acbfe 100644 --- a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md +++ b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md @@ -12,6 +12,8 @@ If you encounter any issues while using the Backstage version of the SDK, please The Coder SDK for Backstage makes it easy for Backstage admins to bring the entire Coder API into Spotify's Backstage platform. While the Coder Backstage plugin does ship with a collection of ready-made components, those can't meet every user's needs, and so, why not give you access to the full set of building blocks, so you can build a solution tailored to your specific use case? +### Table of contents + This guide covers the following: - Accessing the SDK from your own custom React components From 4f6adb3b9ce7ae7e49cc0f5eb8a12fe824ab8900 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 00:35:43 +0000 Subject: [PATCH 53/94] fix: improve granularity of expired token spy logic --- .../src/components/CoderProvider/CoderAuthProvider.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 97f1bdd9..2891e10c 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -146,9 +146,13 @@ function useAuthState(): CoderAuth { let isRevalidatingToken = false; const revalidateTokenOnError = async (event: QueryCacheNotifyEvent) => { + const queryKey = event.query.queryKey; const queryError = event.query.state.error; + const shouldRevalidate = !isRevalidatingToken && + Array.isArray(queryKey) && + queryKey[0] === CODER_QUERY_KEY_PREFIX && BackstageHttpError.isInstance(queryError) && queryError.status === 401; From 0a0b6476f41890a6af2712be9000d32252118e18 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 01:41:51 +0000 Subject: [PATCH 54/94] fix: prevent infinite revalidation loop --- .../CoderProvider/CoderAuthProvider.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 2891e10c..536b6f56 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -137,22 +137,22 @@ function useAuthState(): CoderAuth { return () => window.clearTimeout(distrustTimeoutId); }, [authState.status]); + const isAuthenticated = validAuthStatuses.includes(authState.status); + // Sets up subscription to spy on potentially-expired tokens. Can't do this // outside React because we let the user connect their own queryClient const queryClient = useQueryClient(); useEffect(() => { // Pseudo-mutex; makes sure that if we get a bunch of errors, only one // revalidation will be processed at a time - let isRevalidatingToken = false; + let canRevalidate = true; const revalidateTokenOnError = async (event: QueryCacheNotifyEvent) => { - const queryKey = event.query.queryKey; const queryError = event.query.state.error; const shouldRevalidate = - !isRevalidatingToken && - Array.isArray(queryKey) && - queryKey[0] === CODER_QUERY_KEY_PREFIX && + isAuthenticated && + !canRevalidate && BackstageHttpError.isInstance(queryError) && queryError.status === 401; @@ -160,15 +160,19 @@ function useAuthState(): CoderAuth { return; } - isRevalidatingToken = true; + canRevalidate = false; await queryClient.refetchQueries({ queryKey: sharedAuthQueryKey }); - isRevalidatingToken = false; + canRevalidate = true; }; const queryCache = queryClient.getQueryCache(); const unsubscribe = queryCache.subscribe(revalidateTokenOnError); - return unsubscribe; - }, [queryClient]); + + return () => { + canRevalidate = false; + unsubscribe(); + }; + }, [queryClient, isAuthenticated]); const registerNewToken = useCallback((newToken: string) => { if (newToken !== '') { @@ -184,7 +188,7 @@ function useAuthState(): CoderAuth { return { ...authState, - isAuthenticated: validAuthStatuses.includes(authState.status), + isAuthenticated, registerNewToken, ejectToken, }; From c2decf89c756ea193875e9b80e77b193695d6393 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 01:47:11 +0000 Subject: [PATCH 55/94] fix: clean up the cleanup logic --- .../components/CoderProvider/CoderAuthProvider.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 536b6f56..a1a4643b 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -145,14 +145,14 @@ function useAuthState(): CoderAuth { useEffect(() => { // Pseudo-mutex; makes sure that if we get a bunch of errors, only one // revalidation will be processed at a time - let canRevalidate = true; + let isRevalidating = false; const revalidateTokenOnError = async (event: QueryCacheNotifyEvent) => { const queryError = event.query.state.error; const shouldRevalidate = isAuthenticated && - !canRevalidate && + !isRevalidating && BackstageHttpError.isInstance(queryError) && queryError.status === 401; @@ -160,18 +160,14 @@ function useAuthState(): CoderAuth { return; } - canRevalidate = false; + isRevalidating = true; await queryClient.refetchQueries({ queryKey: sharedAuthQueryKey }); - canRevalidate = true; + isRevalidating = false; }; const queryCache = queryClient.getQueryCache(); const unsubscribe = queryCache.subscribe(revalidateTokenOnError); - - return () => { - canRevalidate = false; - unsubscribe(); - }; + return unsubscribe; }, [queryClient, isAuthenticated]); const registerNewToken = useCallback((newToken: string) => { From 0b60ebdbae55567dc8a2f6fcf5b45500518628d7 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 02:15:12 +0000 Subject: [PATCH 56/94] fix: update example code --- .../docs/guides/sdk-recommendations.md | 108 +++++++++++++++--- 1 file changed, 94 insertions(+), 14 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md index d60acbfe..f3c3ed2c 100644 --- a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md +++ b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md @@ -298,6 +298,57 @@ You have two main options for performing queries: #### Example +```tsx +function WorkspacesList() { + // Pretend that useDebouncedValue exists and exposes a version of searchFilter + // that updates on a delay + const [searchFilter, setSearchFilter] = useState('owner:me'); + const debouncedSearchFilter = useDebouncedValue({ + value: searchFilter, + debounceDelayMs: 500, + }); + + const { isAuthenticated } = useCoderAuth(); + const sdk = useCoderSdk(); + + // The type of query.data is automatically inferred to be of type + // (undefined | Workspace[]) based on the return type of queryFn + const query = useQuery({ + queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces', debouncedSearchFilter], + queryFn: () => sdk.getWorkspaces({ q: debouncedSearchFilter }), + enabled: isAuthenticated, + }); + + return ( + <> + + + {query.error instanceof Error && ( +

Encountered the following error: {query.error.message}

+ )} + + {query.isLoading &&

Loading…

} + +
    + {query.data?.map(workspace => ( + + ))} +
+ + ); +} +``` + #### Pros - Gives you the most direct control over caching data using a declarative API @@ -316,21 +367,50 @@ You have two main options for performing queries: #### Example ```tsx -// Without useCoderQuery -const { sdk } = useCoderSdk(); -const { isAuthenticated } = useCoderAuth(); -const query = useQuery({ - queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces'], - queryFn: () => sdk.getWorkspaces({ q: 'owner:me' }), - enabled: isAuthenticated, -}); +function WorkspacesList() { + // Pretend that we have the same useDebouncedValue from the useQuery example + // above + const [searchFilter, setSearchFilter] = useState('owner:me'); + const debouncedSearchFilter = useDebouncedValue({ + value: searchFilter, + debounceDelayMs: 500, + }); + + // The type of query.data is automatically inferred to be of type + // (undefined | Workspace[]) based on the return type of queryFn + const query = useCoderQuery({ + queryKey: ['workspaces', debouncedSearchFilter], + queryFn: ({ sdk }) => sdk.getWorkspaces({ q: debouncedSearchFilter }), + }); -// This is fully equivalent and has additional properties get updated based -// on auth status -const query2 = useCoderQuery({ - queryKey: ['workspaces'], - queryFn: ({ sdk }) => sdk.getWorkspaces({ q: 'owner:me' }), -}); + return ( + <> + + + {query.error instanceof Error && ( +

Encountered the following error: {query.error.message}

+ )} + + {query.isLoading &&

Loading…

} + +
    + {query.data?.map(workspace => ( + + ))} +
+ + ); +} ``` #### Pros From e4411f5373612d9eaf1a8ddcf6128f5fec852cfa Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 02:27:43 +0000 Subject: [PATCH 57/94] fix: update header levels --- .../docs/guides/sdk-recommendations.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md index f3c3ed2c..4d97471f 100644 --- a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md +++ b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md @@ -111,7 +111,7 @@ function ExampleComponent() { } ``` -#### The SDK object +### The SDK object The SDK object contains all available API methods. All methods follow the format `` + ``. The SDK has these verbs: @@ -126,7 +126,7 @@ Depending on the Coder resource, there may be different API methods that work wi Note that all of these functions will throw an error if the user is not authenticated. -#### Error behavior +### Error behavior All SDK functions will throw in the event of an error. You will need to provide additional error handling to expose errors as values within the UI. (Tanstack Query does this automatically for you.) @@ -177,6 +177,12 @@ Every official Coder component (such as `CoderWorkspacesCard`) exported through When you include the `CoderProvider` component in your Backstage deployment, you have the option to set the value of `fallbackAuthUiMode`. This value affects how `CoderProvider` will inject a fallback auth input into the Backstage deployment's HTML. This means that, even if you don't use any Coder components, or are on a page that can't use them, users will always have some way of supplying auth information. +```tsx + + + +``` + The fallback auth UI will never be visible while the user is authenticated. However, if the user is **not** authenticated, then the value of `fallbackAuthUiMode` will affect what appears on screen: @@ -296,6 +302,8 @@ You have two main options for performing queries: ### `useQuery` +`useQuery` offers the most flexible options overall, and can be a great solution when you don't mind wiring things up manually. + #### Example ```tsx @@ -376,8 +384,8 @@ function WorkspacesList() { debounceDelayMs: 500, }); - // The type of query.data is automatically inferred to be of type - // (undefined | Workspace[]) based on the return type of queryFn + // Like with useQuery, the type of query.data is automatically inferred to be + // of type (undefined | Workspace[]) based on the return type of queryFn const query = useCoderQuery({ queryKey: ['workspaces', debouncedSearchFilter], queryFn: ({ sdk }) => sdk.getWorkspaces({ q: debouncedSearchFilter }), From 2b924e9f9b70107df1f5727a01cb6d5b5b449160 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 02:31:00 +0000 Subject: [PATCH 58/94] fix: make prop optional --- .../CoderProvider/CoderAuthProvider.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index a1a4643b..a78804f3 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -613,13 +613,7 @@ export const dummyTrackComponent: TrackComponent = () => { }; export type FallbackAuthInputBehavior = 'restrained' | 'assertive' | 'hidden'; -export type CoderAuthProviderProps = Readonly< - PropsWithChildren<{ - fallbackAuthUiMode: FallbackAuthInputBehavior; - }> ->; - -type AuthProvider = FC< +type AuthFallbackProvider = FC< Readonly< PropsWithChildren<{ isAuthenticated: boolean; @@ -648,8 +642,8 @@ const fallbackProviders = { ), - // Have to give function a name to satisfy ES Lint rules of hooks - restrained: function DynamicFallback({ children, isAuthenticated }) { + // Have to give function a name to satisfy ES Lint (rules of hooks) + restrained: function Restrained({ children, isAuthenticated }) { const { hasNoAuthInputs, trackComponent } = useAuthFallbackState(); const needFallbackUi = !isAuthenticated && hasNoAuthInputs; @@ -667,7 +661,13 @@ const fallbackProviders = { ); }, -} as const satisfies Record; +} as const satisfies Record; + +export type CoderAuthProviderProps = Readonly< + PropsWithChildren<{ + fallbackAuthUiMode?: FallbackAuthInputBehavior; + }> +>; export function CoderAuthProvider({ children, From 9cf133cad33293ea633230fb5c3b6f472c1f66f8 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 02:53:23 +0000 Subject: [PATCH 59/94] chore: add warning about query client mistakes --- .../docs/guides/sdk-recommendations.md | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md index 4d97471f..2d11fd39 100644 --- a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md +++ b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md @@ -178,7 +178,7 @@ Every official Coder component (such as `CoderWorkspacesCard`) exported through When you include the `CoderProvider` component in your Backstage deployment, you have the option to set the value of `fallbackAuthUiMode`. This value affects how `CoderProvider` will inject a fallback auth input into the Backstage deployment's HTML. This means that, even if you don't use any Coder components, or are on a page that can't use them, users will always have some way of supplying auth information. ```tsx - + ``` @@ -287,6 +287,53 @@ While the [`useAsync` hook](https://github.com/streamich/react-use/blob/master/s - Even though `useAsync`'s API uses dependency arrays, by default, it is not eligible for the exhaustive deps ES Lint rule. This means that unless you update your ESLint rules, you have no safety nets for making sure that your effect logic runs the correct number of times. There are no protections against accidental typos. +## Bring your own Query Client + +By default, `CoderProvider` manages and maintains its own `QueryClient` instance for managing all ongoing queries and mutations. This client is isolated from any other code, and especially if you are only using official Coder components, probably doesn't need to be touched. + +However, let's say you're using React Query for other purposes and would like all Coder requests to go through your query client instance instead. In that case, you can feed that instance into `CoderProvider`, and it will handle all requests through it instead + +```tsx + + + +``` + +### Warning + +Be sure to provide a custom query client if you put any custom components inside `CoderProvider`. Because of React Context rules, any calls to Tanstack APIs (including the `useCoderQuery` convenience wrapper) will go through the default `CoderProvider` query client, rather than your custom client. + +```tsx +const customQueryClient = new QueryClient(); + +function YourCustomComponent() { + const client = useQueryClient(); + console.log(client); + + return

Here's my content!

; +} + + + {/* When mounted here, component receives customQueryClient */} + + + {/* + * Forgot to thread customQueryClient into CoderProvider. All children will + * receive the default query client that CoderProvider maintains + */} + + {/* + * Exact same component definition, but the location completely changes the + * client that gets accessed + */} + + +; +``` + ## Sharing data between different queries and mutations Internally, the official components for the Coder plugin use one shared query key prefix value (`CODER_QUERY_KEY_PREFIX`) for every single query and mutation. This ensures that all Coder-based queries share a common root, and can be easily vacated from the query cache in response to certain events like the user unlinking their Coder account (basically "logging out"). From dae3e614ea6e82edb4a1af1859eb367a3e4562cf Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 03:47:39 +0000 Subject: [PATCH 60/94] wip: finish last code example --- .../docs/guides/sdk-recommendations.md | 82 +++++++++++++++++-- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md index 2d11fd39..a1fc5b20 100644 --- a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md +++ b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md @@ -246,9 +246,9 @@ At present, the Coder plugin provides a convenience wrapper for connecting `useQ We are also considering making a `useMutation` wrapper called `useCoderMutation`. If you have any thoughts or requests on how it should behave, please open an issue! -## Why React Query? +## Why Tanstack Query (aka React Query)? -ui.dev has a phenomenal [article](https://ui.dev/why-react-query) and [video](https://www.youtube.com/watch?v=OrliU0e09io) series that breaks things down more exhaustively, but in short, React Query: +ui.dev has a phenomenal [article](https://ui.dev/why-react-query) and [video](https://www.youtube.com/watch?v=OrliU0e09io) series that breaks things down more exhaustively, but in short, Tanstack Query: - Simplifies how API data is shared throughout an application - Manages race conditions, canceling requests, and revalidating requests @@ -291,7 +291,7 @@ While the [`useAsync` hook](https://github.com/streamich/react-use/blob/master/s By default, `CoderProvider` manages and maintains its own `QueryClient` instance for managing all ongoing queries and mutations. This client is isolated from any other code, and especially if you are only using official Coder components, probably doesn't need to be touched. -However, let's say you're using React Query for other purposes and would like all Coder requests to go through your query client instance instead. In that case, you can feed that instance into `CoderProvider`, and it will handle all requests through it instead +However, let's say you're using Tanstack Query for other purposes and would like all Coder requests to go through your query client instance instead. In that case, you can feed that instance into `CoderProvider`, and it will handle all requests through it instead ```tsx ; + +function ExampleComponent({ workspaceId }: Props) { + // Unfortunately, the SDK does still need to be brought in for mutations. + // One of useCoderQuery's perks (automatic SDK injection via queryFn) goes + // away slightly. + const sdk = useCoderSdk(); + + // Same goes for Coder auth; needs to be brought in manually for mutations + const { isAuthenticated } = useCoderAuth(); + + // useCoderQuery still automatically handles wiring up auth logic to all + // relevant query option properties and auto-prefixes the query key + const workspaceQuery = useCoderQuery({ + queryKey: ['workspace', workspaceId], + + // How you access the SDK doesn't matter at this point because there's + // already an SDK in scope + queryFn: () => sdk.getWorkspace(workspaceId), + }); + + const queryClient = useQueryClient(); + const deleteWorkspaceMutation = useMutation({ + mutationFn: () => { + // useMutation does not expose an enabled property. You can fail fast by throwing when the user isn't authenticated + if (!isAuthenticated) { + throw new Error('Unable to complete request - not authenticated'); + } + + // Even if you forget to fail fast, the SDK method will throw eventually + // because the Coder deployment will respond with an error status + return sdk.deleteWorkspace(workspaceId); + }, + + // Need to make sure that the cache is invalidated after the workspace is + // definitely deleted + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['workspace', workspaceId], + }); + }, + }); + + return ( + <> + {deleteWorkspaceMutation.isSuccess && ( +

Workspace deleted successfully

+ )} + + {workspaceQuery.data !== undefined && ( +
+

{workspaceQuery.data.name}

+

+ Workspace is {workspaceQuery.data.health.healthy ? '' : 'not '}{' '} + healthy. +

+
+ )} + + ); +} +``` From cdec143547a5a100cb3c02d395846649e81358ba Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 03:54:34 +0000 Subject: [PATCH 61/94] fix: update union/intersection mismatch --- plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts index e0eafd1d..bf293267 100644 --- a/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts @@ -312,7 +312,7 @@ type RestartWorkspaceParameters = Readonly<{ export type DeleteWorkspaceOptions = Pick< TypesGen.CreateWorkspaceBuildRequest, - 'log_level' & 'orphan' + 'log_level' | 'orphan' >; type Claims = { From c07aff068f036c0c8b2d2d4957efdbe080ef1fee Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 04:13:03 +0000 Subject: [PATCH 62/94] chore: finish initial version of SDK readme --- .../docs/guides/sdk-recommendations.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md index a1fc5b20..eb526e89 100644 --- a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md +++ b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md @@ -32,20 +32,23 @@ This guide covers the following: This guide assumes that you have already added the `CoderProvider` component to your Backstage deployment. If you have not, [please see the main README](../../README.md#setup) for instructions on getting that set up. -### The main SDK hooks +## Quick-start + +### The main hooks to use when working with the SDK -There are three hooks that you want to consider when interacting with SDK functionality. These can be broken down into two main categories: +There are about six main hooks that you want to consider when using the Coder SDK.These can be broken down into two main categories: #### Primitive hooks -- `useCoderSdk` -- `useCoderAuth` +- `useCoderSdk` - Gives you the Coder SDK instance +- `useCoderAuth` - Exposes properties and methods for examining/updating Coder auth state +- `useQuery` (via Tanstack Query library) - Declarative querying +- `useMutation` (via Tanstack Query library) - Imperative mutations +- `useQueryClient` (via Tanstack Query library) - Lets you coordinate queries and mutations within the same query cache #### Convenience hooks -- `useCoderQuery` - -## Quick-start +- `useCoderQuery` (wrapper over `useQuery`) ## Accessing the SDK from your own custom React components From f3e90ca67b70d7a6adcfed341af0a49aca6ebe1f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 04:20:44 +0000 Subject: [PATCH 63/94] wip: make placeholders more obvious --- .../backstage-plugin-coder/docs/guides/sdk-recommendations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md index eb526e89..8d970f04 100644 --- a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md +++ b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md @@ -160,7 +160,7 @@ All three solutions, directly or indirectly, involve the `CoderAuth` type. More Every official Coder component (such as `CoderWorkspacesCard`) exported through the plugin is guaranteed to have some mechanism for supplying auth information. This is typically done via a UI form. - +<-- [Include screenshot/video of auth input here] --> #### Pros @@ -186,7 +186,7 @@ When you include the `CoderProvider` component in your Backstage deployment, you
``` - +<-- [Include screenshot/video of fallback auth input here] --> The fallback auth UI will never be visible while the user is authenticated. However, if the user is **not** authenticated, then the value of `fallbackAuthUiMode` will affect what appears on screen: From 977b2ebb829d2e3e99acca16efc9274e42bbd472 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 15:30:22 +0000 Subject: [PATCH 64/94] fix: add additional properties to hide from SDK --- .../backstage-plugin-coder/src/api/vendoredSdk/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts index b64f8419..f8451116 100644 --- a/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts @@ -18,7 +18,12 @@ type PropertyToHide = | 'setHost' | 'getAvailableExperiments' | 'login' - | 'logout'; + | 'logout' + | 'convertToOAUTH' + | 'waitForBuild' + | 'addMember' + | 'removeMember' + | 'getWorkspaceParameters'; // Wanted to have a CoderSdk class (mainly re-exporting the Api class as itself // with the extra properties omitted). But because classes are wonky and exist From 09240cc78513fa49508b9db8bd9063ee68e75664 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Fri, 31 May 2024 17:18:54 +0000 Subject: [PATCH 65/94] fix: shrink down the API of useCoderSdk --- .../src/hooks/useCoderSdk.ts | 30 ++----------------- .../src/hooks/useCoderWorkspacesQuery.ts | 2 +- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts index f394660c..7b7017a1 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts @@ -2,36 +2,12 @@ * @file This defines the general helper for accessing the Coder SDK from * Backstage in a type-safe way. * - * This hook is meant to be used both internally AND externally. It exposes some - * auth helpers to make end users' lives easier, but all of them go through - * useEndUserCoderAuth. If building any internal components, be sure to have a - * call to useInternalCoderAuth somewhere, to make sure that the component - * interfaces with the fallback auth UI inputs properly. - * - * See CoderAuthProvider.tsx for more info. + * This hook is meant to be used both internally AND externally. */ import { useApi } from '@backstage/core-plugin-api'; import { coderClientApiRef, type BackstageCoderSdk } from '../api/CoderClient'; -import { useEndUserCoderAuth } from '../components/CoderProvider'; - -type UseCoderSdkResult = Readonly<{ - sdk: BackstageCoderSdk; - backstageUtils: Readonly<{ - unlinkCoderAccount: () => void; - }>; -}>; -export function useCoderSdk(): UseCoderSdkResult { - const { ejectToken } = useEndUserCoderAuth(); +export function useCoderSdk(): BackstageCoderSdk { const { sdk } = useApi(coderClientApiRef); - - return { - sdk, - backstageUtils: { - // Hoping that as we support more auth methods, this function gets beefed - // up to be an all-in-one function for removing any and all auth info. - // Simply doing a pass-through for now - unlinkCoderAccount: ejectToken, - }, - }; + return sdk; } diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts index bea87361..63b4f2f7 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderWorkspacesQuery.ts @@ -13,8 +13,8 @@ export function useCoderWorkspacesQuery({ coderQuery, workspacesConfig, }: QueryInput) { + const sdk = useCoderSdk(); const auth = useInternalCoderAuth(); - const { sdk } = useCoderSdk(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; const queryOptions = hasRepoData From 3a8accb15bb31610cd2e3894ba2e1fe98895c37b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 3 Jun 2024 13:39:54 +0000 Subject: [PATCH 66/94] update method name for clarity --- plugins/backstage-plugin-coder/src/api/CoderClient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index 30f9b767..4c5333dd 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -100,7 +100,7 @@ export class CoderClient implements CoderClientApi { this.cleanupController = new AbortController(); this.trackedEjectionIds = new Set(); - this.sdk = this.getBackstageCoderSdk(); + this.sdk = this.createBackstageCoderSdk(); this.addBaseRequestInterceptors(); } @@ -181,7 +181,7 @@ export class CoderClient implements CoderClientApi { this.addRequestInterceptor(baseRequestInterceptor, baseErrorInterceptor); } - private getBackstageCoderSdk(): BackstageCoderSdk { + private createBackstageCoderSdk(): BackstageCoderSdk { const baseSdk = makeCoderSdk(); const getWorkspaces: (typeof baseSdk)['getWorkspaces'] = async request => { From a67fbcfd198ad8e091311de947fdc64ec8549985 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 3 Jun 2024 13:49:44 +0000 Subject: [PATCH 67/94] chore: removal vestigal endpoint properties --- plugins/backstage-plugin-coder/src/api/UrlSync.ts | 10 ++-------- .../src/testHelpers/mockBackstageData.ts | 4 ++-- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.ts index 686963ce..8b3548d6 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.ts @@ -42,14 +42,10 @@ const PROXY_URL_KEY_FOR_DISCOVERY_API = 'proxy'; type UrlPrefixes = Readonly<{ proxyPrefix: string; - apiRoutePrefix: string; - assetsRoutePrefix: string; }>; export const defaultUrlPrefixes = { proxyPrefix: `/api/proxy`, - apiRoutePrefix: '', // Left as empty string because code assumes that CoderSdk will add /api/v2 - assetsRoutePrefix: '', // Deliberately left as empty string } as const satisfies UrlPrefixes; export type UrlSyncSnapshot = Readonly<{ @@ -104,12 +100,10 @@ export class UrlSync implements UrlSyncApi { } private prepareNewSnapshot(newProxyUrl: string): UrlSyncSnapshot { - const { assetsRoutePrefix, apiRoutePrefix } = this.urlPrefixes; - return { baseUrl: newProxyUrl.replace(proxyRouteReplacer, ''), - assetsRoute: `${newProxyUrl}${CODER_PROXY_PREFIX}${assetsRoutePrefix}`, - apiRoute: `${newProxyUrl}${CODER_PROXY_PREFIX}${apiRoutePrefix}`, + assetsRoute: `${newProxyUrl}${CODER_PROXY_PREFIX}`, + apiRoute: `${newProxyUrl}${CODER_PROXY_PREFIX}`, }; } diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 88f45498..8c96f8d2 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -75,7 +75,7 @@ export const mockBackstageUrlRoot = 'http://localhost:7007'; * the final result is. */ export const mockBackstageApiEndpointWithoutSdkPath = - `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.apiRoutePrefix}` as const; + `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}` as const; /** * The API endpoint to use with the mock server during testing. Adds additional @@ -94,7 +94,7 @@ export const mockBackstageApiEndpoint = * the final result is. */ export const mockBackstageAssetsEndpoint = - `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}${defaultUrlPrefixes.assetsRoutePrefix}` as const; + `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}` as const; export const mockBearerToken = 'This-is-an-opaque-value-by-design'; export const mockCoderAuthToken = 'ZG0HRy2gGN-mXljc1s5FqtE8WUJ4sUc5X'; From a0f4340527b28b9de16d6d784d9238be59f61c11 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 10 Jun 2024 22:57:46 +0000 Subject: [PATCH 68/94] fix: swap public 'SDK' usage with 'API' --- .../src/api/CoderClient.test.ts | 26 ++++++------ .../src/api/CoderClient.ts | 40 +++++++++---------- .../src/api/UrlSync.test.ts | 4 +- .../src/api/queryOptions.ts | 12 +++--- .../src/api/vendoredSdk/api/api.ts | 2 +- .../src/api/vendoredSdk/index.ts | 6 +-- .../CoderProvider/CoderAuthProvider.tsx | 6 +-- .../CoderProvider/CoderProvider.test.tsx | 11 +++-- .../components/CoderWorkspacesCard/Root.tsx | 1 + .../useCoderWorkspacesQuery.ts | 8 ++-- .../src/hooks/reactQueryWrappers.test.tsx | 4 +- .../src/hooks/reactQueryWrappers.ts | 12 +++--- .../hooks/{useCoderSdk.ts => useCoderApi.ts} | 13 +++--- .../src/hooks/useUrlSync.test.tsx | 6 +-- plugins/backstage-plugin-coder/src/plugin.ts | 14 +++++-- .../src/testHelpers/mockBackstageData.ts | 19 +++++---- 16 files changed, 100 insertions(+), 84 deletions(-) rename plugins/backstage-plugin-coder/src/hooks/{useCoderSdk.ts => useCoderApi.ts} (51%) diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts index 2bfa6b24..db9a7275 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.test.ts @@ -1,4 +1,4 @@ -import { CODER_AUTH_HEADER_KEY, CoderClient } from './CoderClient'; +import { CODER_AUTH_HEADER_KEY, CoderClientWrapper } from './CoderClient'; import type { IdentityApi } from '@backstage/core-plugin-api'; import { UrlSync } from './UrlSync'; import { rest } from 'msw'; @@ -34,10 +34,10 @@ function getConstructorApis(): ConstructorApis { return { urlSync, identityApi }; } -describe(`${CoderClient.name}`, () => { +describe(`${CoderClientWrapper.name}`, () => { describe('syncToken functionality', () => { it('Will load the provided token into the client if it is valid', async () => { - const client = new CoderClient({ apis: getConstructorApis() }); + const client = new CoderClientWrapper({ apis: getConstructorApis() }); const syncResult = await client.syncToken(mockCoderAuthToken); expect(syncResult).toBe(true); @@ -50,12 +50,12 @@ describe(`${CoderClient.name}`, () => { }), ); - await client.sdk.getAuthenticatedUser(); + await client.api.getAuthenticatedUser(); expect(serverToken).toBe(mockCoderAuthToken); }); it('Will NOT load the provided token into the client if it is invalid', async () => { - const client = new CoderClient({ apis: getConstructorApis() }); + const client = new CoderClientWrapper({ apis: getConstructorApis() }); const syncResult = await client.syncToken('Definitely not valid'); expect(syncResult).toBe(false); @@ -68,12 +68,12 @@ describe(`${CoderClient.name}`, () => { }), ); - await client.sdk.getAuthenticatedUser(); + await client.api.getAuthenticatedUser(); expect(serverToken).toBe(null); }); it('Will propagate any other error types to the caller', async () => { - const client = new CoderClient({ + const client = new CoderClientWrapper({ // Setting the timeout to 0 will make requests instantly fail from the // next microtask queue tick requestTimeoutMs: 0, @@ -96,13 +96,13 @@ describe(`${CoderClient.name}`, () => { }); }); - // Eventually the Coder SDK is going to get too big to test every single + // Eventually the Coder API is going to get too big to test every single // function. Focus tests on the functionality specifically being patched in // for Backstage - describe('Coder SDK', () => { + describe('Coder API', () => { it('Will remap all workspace icon URLs to use the proxy URL if necessary', async () => { const apis = getConstructorApis(); - const client = new CoderClient({ + const client = new CoderClientWrapper({ apis, initialToken: mockCoderAuthToken, }); @@ -126,7 +126,7 @@ describe(`${CoderClient.name}`, () => { }), ); - const { workspaces } = await client.sdk.getWorkspaces({ + const { workspaces } = await client.api.getWorkspaces({ q: 'owner:me', limit: 0, }); @@ -142,12 +142,12 @@ describe(`${CoderClient.name}`, () => { }); it('Lets the user search for workspaces by repo URL', async () => { - const client = new CoderClient({ + const client = new CoderClientWrapper({ initialToken: mockCoderAuthToken, apis: getConstructorApis(), }); - const { workspaces } = await client.sdk.getWorkspacesByRepo( + const { workspaces } = await client.api.getWorkspacesByRepo( { q: 'owner:me' }, mockCoderWorkspacesConfig, ); diff --git a/plugins/backstage-plugin-coder/src/api/CoderClient.ts b/plugins/backstage-plugin-coder/src/api/CoderClient.ts index 4c5333dd..c760f1d2 100644 --- a/plugins/backstage-plugin-coder/src/api/CoderClient.ts +++ b/plugins/backstage-plugin-coder/src/api/CoderClient.ts @@ -7,23 +7,23 @@ import { CODER_API_REF_ID_PREFIX } from '../typesConstants'; import type { UrlSync } from './UrlSync'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; import { - type CoderSdk, + type CoderApi, type User, type Workspace, type WorkspacesRequest, type WorkspacesResponse, - makeCoderSdk, + createCoderApi, } from './vendoredSdk'; export const CODER_AUTH_HEADER_KEY = 'Coder-Session-Token'; const DEFAULT_REQUEST_TIMEOUT_MS = 20_000; /** - * A version of the main Coder SDK API, with additional Backstage-specific + * A version of the main Coder API, with additional Backstage-specific * methods and properties. */ -export type BackstageCoderSdk = Readonly< - CoderSdk & { +export type BackstageCoderApi = Readonly< + CoderApi & { getWorkspacesByRepo: ( request: WorkspacesRequest, config: CoderWorkspacesConfig, @@ -31,8 +31,8 @@ export type BackstageCoderSdk = Readonly< } >; -type CoderClientApi = Readonly<{ - sdk: BackstageCoderSdk; +type CoderClientWrapperApi = Readonly<{ + api: BackstageCoderApi; /** * Validates a new token, and loads it only if it is valid. @@ -75,7 +75,7 @@ type RequestInterceptor = ( config: RequestConfig, ) => RequestConfig | Promise; -export class CoderClient implements CoderClientApi { +export class CoderClientWrapper implements CoderClientWrapperApi { private readonly urlSync: UrlSync; private readonly identityApi: IdentityApi; @@ -84,7 +84,7 @@ export class CoderClient implements CoderClientApi { private readonly trackedEjectionIds: Set; private loadedSessionToken: string | undefined; - readonly sdk: BackstageCoderSdk; + readonly api: BackstageCoderApi; constructor(inputs: ConstructorInputs) { const { @@ -100,7 +100,7 @@ export class CoderClient implements CoderClientApi { this.cleanupController = new AbortController(); this.trackedEjectionIds = new Set(); - this.sdk = this.createBackstageCoderSdk(); + this.api = this.createBackstageCoderApi(); this.addBaseRequestInterceptors(); } @@ -108,7 +108,7 @@ export class CoderClient implements CoderClientApi { requestInterceptor: RequestInterceptor, errorInterceptor?: (error: unknown) => unknown, ): number { - const axios = this.sdk.getAxiosInstance(); + const axios = this.api.getAxiosInstance(); const ejectionId = axios.interceptors.request.use( requestInterceptor, errorInterceptor, @@ -121,7 +121,7 @@ export class CoderClient implements CoderClientApi { private removeRequestInterceptorById(ejectionId: number): boolean { // Even if we somehow pass in an ID that hasn't been associated with the // Axios instance, that's a noop. No harm in calling method no matter what - const axios = this.sdk.getAxiosInstance(); + const axios = this.api.getAxiosInstance(); axios.interceptors.request.eject(ejectionId); if (!this.trackedEjectionIds.has(ejectionId)) { @@ -181,11 +181,11 @@ export class CoderClient implements CoderClientApi { this.addRequestInterceptor(baseRequestInterceptor, baseErrorInterceptor); } - private createBackstageCoderSdk(): BackstageCoderSdk { - const baseSdk = makeCoderSdk(); + private createBackstageCoderApi(): BackstageCoderApi { + const baseApi = createCoderApi(); - const getWorkspaces: (typeof baseSdk)['getWorkspaces'] = async request => { - const workspacesRes = await baseSdk.getWorkspaces(request); + const getWorkspaces: (typeof baseApi)['getWorkspaces'] = async request => { + const workspacesRes = await baseApi.getWorkspaces(request); const remapped = await this.remapWorkspaceIconUrls( workspacesRes.workspaces, ); @@ -214,7 +214,7 @@ export class CoderClient implements CoderClientApi { q: appendParamToQuery(request.q, key, stringUrl), }; - return baseSdk.getWorkspaces(patchedRequest); + return baseApi.getWorkspaces(patchedRequest); }), ); @@ -237,7 +237,7 @@ export class CoderClient implements CoderClientApi { }; return { - ...baseSdk, + ...baseApi, getWorkspaces, getWorkspacesByRepo, }; @@ -312,7 +312,7 @@ export class CoderClient implements CoderClientApi { // Actual request type doesn't matter; just need to make some kind of // dummy request. Should favor requests that all users have access to and // that don't require request bodies - const dummyUser = await this.sdk.getAuthenticatedUser(); + const dummyUser = await this.api.getAuthenticatedUser(); // Most of the time, we're going to trust the types returned back from the // server without doing any type-checking, but because this request does @@ -376,6 +376,6 @@ function assertValidUser(value: unknown): asserts value is User { } } -export const coderClientApiRef = createApiRef({ +export const coderClientWrapperApiRef = createApiRef({ id: `${CODER_API_REF_ID_PREFIX}.coder-client`, }); diff --git a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts index 62001e4e..00e86a7c 100644 --- a/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts +++ b/plugins/backstage-plugin-coder/src/api/UrlSync.test.ts @@ -5,7 +5,7 @@ import { getMockDiscoveryApi, mockBackstageAssetsEndpoint, mockBackstageUrlRoot, - mockBackstageApiEndpointWithoutSdkPath, + mockBackstageApiEndpointWithoutVersionSuffix, } from '../testHelpers/mockBackstageData'; // Tests have to assume that DiscoveryApi and ConfigApi will always be in sync, @@ -23,7 +23,7 @@ describe(`${UrlSync.name}`, () => { const cachedUrls = urlSync.getCachedUrls(); expect(cachedUrls).toEqual({ baseUrl: mockBackstageUrlRoot, - apiRoute: mockBackstageApiEndpointWithoutSdkPath, + apiRoute: mockBackstageApiEndpointWithoutVersionSuffix, assetsRoute: mockBackstageAssetsEndpoint, }); }); diff --git a/plugins/backstage-plugin-coder/src/api/queryOptions.ts b/plugins/backstage-plugin-coder/src/api/queryOptions.ts index 6bfbd800..b622e415 100644 --- a/plugins/backstage-plugin-coder/src/api/queryOptions.ts +++ b/plugins/backstage-plugin-coder/src/api/queryOptions.ts @@ -1,7 +1,7 @@ import type { UseQueryOptions } from '@tanstack/react-query'; import type { Workspace, WorkspacesRequest } from './vendoredSdk'; import type { CoderWorkspacesConfig } from '../hooks/useCoderWorkspacesConfig'; -import type { BackstageCoderSdk } from './CoderClient'; +import type { BackstageCoderApi } from './CoderClient'; import type { CoderAuth } from '../components/CoderProvider'; // Making the type more broad to hide some implementation details from the end @@ -47,13 +47,13 @@ function getSharedWorkspacesQueryKey(coderQuery: string) { type WorkspacesFetchInputs = Readonly<{ auth: CoderAuth; - sdk: BackstageCoderSdk; + api: BackstageCoderApi; coderQuery: string; }>; export function workspaces({ auth, - sdk, + api, coderQuery, }: WorkspacesFetchInputs): UseQueryOptions { const enabled = auth.isAuthenticated; @@ -64,7 +64,7 @@ export function workspaces({ keepPreviousData: enabled && coderQuery !== '', refetchInterval: getCoderWorkspacesRefetchInterval, queryFn: async () => { - const res = await sdk.getWorkspaces({ + const res = await api.getWorkspaces({ q: coderQuery, limit: 0, }); @@ -82,7 +82,7 @@ type WorkspacesByRepoFetchInputs = Readonly< export function workspacesByRepo({ coderQuery, - sdk, + api, auth, workspacesConfig, }: WorkspacesByRepoFetchInputs): UseQueryOptions { @@ -98,7 +98,7 @@ export function workspacesByRepo({ refetchInterval: getCoderWorkspacesRefetchInterval, queryFn: async () => { const request: WorkspacesRequest = { q: coderQuery, limit: 0 }; - const res = await sdk.getWorkspacesByRepo(request, workspacesConfig); + const res = await api.getWorkspacesByRepo(request, workspacesConfig); return res.workspaces; }, }; diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts index bf293267..6877a614 100644 --- a/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/api/api.ts @@ -1894,7 +1894,7 @@ function getConfiguredAxiosInstance(): AxiosInstance { } else { // Do not write error logs if we are in a FE unit test. if (process.env.JEST_WORKER_ID === undefined) { - // eslint-disable-next-line no-console -- Function should never run in vendored version of SDK + // eslint-disable-next-line no-console -- Function should never run in vendored version of API console.error('CSRF token not found'); } } diff --git a/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts index f8451116..18fc9eae 100644 --- a/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts +++ b/plugins/backstage-plugin-coder/src/api/vendoredSdk/index.ts @@ -29,8 +29,8 @@ type PropertyToHide = // with the extra properties omitted). But because classes are wonky and exist // as both runtime values and types, it didn't seem possible, even with things // like class declarations. Making a new function is good enough for now, though -export type CoderSdk = Omit; -export function makeCoderSdk(): CoderSdk { +export type CoderApi = Omit; +export function createCoderApi(): CoderApi { const api = new Api(); - return api as CoderSdk; + return api as CoderApi; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index a78804f3..6592c7e5 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -24,7 +24,7 @@ import { CODER_QUERY_KEY_PREFIX, sharedAuthQueryKey, } from '../../api/queryOptions'; -import { coderClientApiRef } from '../../api/CoderClient'; +import { coderClientWrapperApiRef } from '../../api/CoderClient'; import { CoderLogo } from '../CoderLogo'; import { CoderAuthFormDialog } from '../CoderAuthFormDialog'; @@ -91,7 +91,7 @@ function useAuthState(): CoderAuth { const [readonlyInitialAuthToken] = useState(authToken); const [isInsideGracePeriod, setIsInsideGracePeriod] = useState(true); - const coderClient = useApi(coderClientApiRef); + const coderClient = useApi(coderClientWrapperApiRef); const queryIsEnabled = authToken !== ''; const authValidityQuery = useQuery({ @@ -273,7 +273,7 @@ export function useInternalCoderAuth(): CoderAuth { /** * Exposes Coder auth state to the rest of the UI. */ -// This hook should only be used by end users trying to use the Coder SDK inside +// This hook should only be used by end users trying to use the Coder API inside // Backstage. The hook is renamed on final export to avoid confusion export function useEndUserCoderAuth(): CoderAuth { const authContextValue = useContext(AuthStateContext); diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx index ba1d8ecd..731977b5 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -27,7 +27,10 @@ import { renderHookAsCoderEntity, } from '../../testHelpers/setup'; import { UrlSync, urlSyncApiRef } from '../../api/UrlSync'; -import { CoderClient, coderClientApiRef } from '../../api/CoderClient'; +import { + CoderClientWrapper, + coderClientWrapperApiRef, +} from '../../api/CoderClient'; describe(`${CoderProvider.name}`, () => { describe('AppConfig', () => { @@ -66,7 +69,7 @@ describe(`${CoderProvider.name}`, () => { apis: { discoveryApi, configApi }, }); - const coderClientApi = new CoderClient({ + const coderClientApi = new CoderClientWrapper({ apis: { urlSync, identityApi }, }); @@ -80,13 +83,13 @@ describe(`${CoderProvider.name}`, () => { [discoveryApiRef, discoveryApi], [urlSyncApiRef, urlSync], - [coderClientApiRef, coderClientApi], + [coderClientWrapperApiRef, coderClientApi], ]} > {children} diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index 5814d55b..217aa960 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -18,6 +18,7 @@ import { import type { Workspace } from '../../api/vendoredSdk'; import { useCoderWorkspacesQuery } from './useCoderWorkspacesQuery'; import { CoderAuthFormCardWrapper } from '../CoderAuthFormCardWrapper'; +import { useCoderQuery } from '../../plugin'; export type WorkspacesQuery = UseQueryResult; diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts index 5f82e6b7..305a5bab 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/useCoderWorkspacesQuery.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { workspaces, workspacesByRepo } from '../../api/queryOptions'; import type { CoderWorkspacesConfig } from '../../hooks/useCoderWorkspacesConfig'; -import { useCoderSdk } from '../../hooks/useCoderSdk'; +import { useCoderApi } from '../../hooks/useCoderApi'; import { useInternalCoderAuth } from '../../components/CoderProvider'; type QueryInput = Readonly<{ @@ -13,13 +13,13 @@ export function useCoderWorkspacesQuery({ coderQuery, workspacesConfig, }: QueryInput) { - const sdk = useCoderSdk(); + const api = useCoderApi(); const auth = useInternalCoderAuth(); const hasRepoData = workspacesConfig && workspacesConfig.repoUrl; const queryOptions = hasRepoData - ? workspacesByRepo({ auth, sdk, coderQuery, workspacesConfig }) - : workspaces({ auth, sdk, coderQuery }); + ? workspacesByRepo({ auth, api, coderQuery, workspacesConfig }) + : workspaces({ auth, api, coderQuery }); return useQuery(queryOptions); } diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx index 57800bb4..ee29fd6f 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx @@ -70,7 +70,7 @@ async function renderCoderQuery< const mainMarkup = ( @@ -117,7 +117,7 @@ describe(`${useCoderQuery.name}`, () => { authenticateOnMount: false, queryOptions: { queryKey: ['workspaces'], - queryFn: ({ sdk }) => sdk.getWorkspaces({ q: 'owner:me' }), + queryFn: ({ api }) => api.getWorkspaces({ q: 'owner:me' }), select: response => response.workspaces, }, }); diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts index 6dff0240..a9eed3a5 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts @@ -1,6 +1,6 @@ /** * @file Defines a couple of wrappers over React Query/Tanstack Query that make - * it easier to use the Coder SDK within UI logic. + * it easier to use the Coder API within UI logic. * * These hooks are designed 100% for end-users, and should not be used * internally. Use useEndUserCoderAuth when working with auth logic within these @@ -25,12 +25,12 @@ import { import { DEFAULT_TANSTACK_QUERY_RETRY_COUNT } from '../typesConstants'; import { useEndUserCoderAuth } from '../components/CoderProvider'; import { CODER_QUERY_KEY_PREFIX } from '../api/queryOptions'; -import { useCoderSdk } from './useCoderSdk'; -import type { BackstageCoderSdk } from '../api/CoderClient'; +import { useCoderApi } from './useCoderApi'; +import type { BackstageCoderApi } from '../api/CoderClient'; export type CoderQueryFunctionContext = QueryFunctionContext & { - sdk: BackstageCoderSdk; + api: BackstageCoderApi; }; export type CoderQueryFunction< @@ -63,7 +63,7 @@ export function useCoderQuery< ): UseQueryResult { const queryClient = useQueryClient(); const { isAuthenticated } = useEndUserCoderAuth(); - const sdk = useCoderSdk(); + const api = useCoderApi(); let patchedQueryKey = queryOptions.queryKey; if ( @@ -98,7 +98,7 @@ export function useCoderQuery< throw new Error('Cannot complete request - user is not authenticated'); } - return queryOptions.queryFn({ ...context, sdk }); + return queryOptions.queryFn({ ...context, api: api }); }, refetchInterval: (data, query) => { diff --git a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts b/plugins/backstage-plugin-coder/src/hooks/useCoderApi.ts similarity index 51% rename from plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts rename to plugins/backstage-plugin-coder/src/hooks/useCoderApi.ts index 7b7017a1..962f009c 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useCoderSdk.ts +++ b/plugins/backstage-plugin-coder/src/hooks/useCoderApi.ts @@ -1,13 +1,16 @@ /** - * @file This defines the general helper for accessing the Coder SDK from + * @file This defines the general helper for accessing the Coder API from * Backstage in a type-safe way. * * This hook is meant to be used both internally AND externally. */ import { useApi } from '@backstage/core-plugin-api'; -import { coderClientApiRef, type BackstageCoderSdk } from '../api/CoderClient'; +import { + type BackstageCoderApi, + coderClientWrapperApiRef, +} from '../api/CoderClient'; -export function useCoderSdk(): BackstageCoderSdk { - const { sdk } = useApi(coderClientApiRef); - return sdk; +export function useCoderApi(): BackstageCoderApi { + const { api } = useApi(coderClientWrapperApiRef); + return api; } diff --git a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx index 90cac33d..2662b1e6 100644 --- a/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/useUrlSync.test.tsx @@ -8,11 +8,11 @@ import { mockBackstageAssetsEndpoint, mockBackstageUrlRoot, getMockConfigApi, - mockBackstageApiEndpointWithoutSdkPath, + mockBackstageApiEndpointWithoutVersionSuffix, } from '../testHelpers/mockBackstageData'; function renderUseUrlSync() { - let proxyEndpoint: string = mockBackstageApiEndpointWithoutSdkPath; + let proxyEndpoint: string = mockBackstageApiEndpointWithoutVersionSuffix; const mockDiscoveryApi: DiscoveryApi = { getBaseUrl: async () => proxyEndpoint, }; @@ -53,7 +53,7 @@ describe(`${useUrlSync.name}`, () => { state: { baseUrl: mockBackstageUrlRoot, assetsRoute: mockBackstageAssetsEndpoint, - apiRoute: mockBackstageApiEndpointWithoutSdkPath, + apiRoute: mockBackstageApiEndpointWithoutVersionSuffix, }, }), ); diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 3ac5fff0..1429e210 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -8,7 +8,10 @@ import { } from '@backstage/core-plugin-api'; import { rootRouteRef } from './routes'; import { UrlSync, urlSyncApiRef } from './api/UrlSync'; -import { CoderClient, coderClientApiRef } from './api/CoderClient'; +import { + CoderClientWrapper, + coderClientWrapperApiRef, +} from './api/CoderClient'; export const coderPlugin = createPlugin({ id: 'coder', @@ -27,13 +30,13 @@ export const coderPlugin = createPlugin({ }, }), createApiFactory({ - api: coderClientApiRef, + api: coderClientWrapperApiRef, deps: { urlSync: urlSyncApiRef, identityApi: identityApiRef, }, factory: ({ urlSync, identityApi }) => { - return new CoderClient({ + return new CoderClientWrapper({ apis: { urlSync, identityApi }, }); }, @@ -190,8 +193,11 @@ export { useWorkspacesCardContext } from './components/CoderWorkspacesCard/Root' * General custom hooks that can be used in various places. */ export { useCoderWorkspacesConfig } from './hooks/useCoderWorkspacesConfig'; -export { useCoderSdk } from './hooks/useCoderSdk'; +export { useCoderApi } from './hooks/useCoderApi'; export { useCoderQuery } from './hooks/reactQueryWrappers'; + +// Deliberately renamed so that end users don't have to be aware that there are +// two different versions of the auth hook export { useEndUserCoderAuth as useCoderAuth } from './components/CoderProvider/CoderAuthProvider'; /** diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index 8c96f8d2..ce45b591 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -33,7 +33,10 @@ import { defaultUrlPrefixes, urlSyncApiRef, } from '../api/UrlSync'; -import { CoderClient, coderClientApiRef } from '../api/CoderClient'; +import { + CoderClientWrapper, + coderClientWrapperApiRef, +} from '../api/CoderClient'; /** * This is the key that Backstage checks from the entity data to determine the @@ -68,24 +71,24 @@ export const mockBackstageUrlRoot = 'http://localhost:7007'; /** * A version of the mock API endpoint that doesn't have the Coder API versioning - * prefix. Mainly used for tests that need to assert that the core API URL is - * formatted correctly, before the CoderSdk adds anything else to the end + * suffix. Mainly used for tests that need to assert that the core API URL is + * formatted correctly, before the Coder API adds anything else to the end * * The string literal expression is complicated, but hover over it to see what * the final result is. */ -export const mockBackstageApiEndpointWithoutSdkPath = +export const mockBackstageApiEndpointWithoutVersionSuffix = `${mockBackstageUrlRoot}${defaultUrlPrefixes.proxyPrefix}${CODER_PROXY_PREFIX}` as const; /** * The API endpoint to use with the mock server during testing. Adds additional - * path information that will normally be added via the Coder SDK. + * path information that will normally be added via the Coder API. * * The string literal expression is complicated, but hover over it to see what * the final result is. */ export const mockBackstageApiEndpoint = - `${mockBackstageApiEndpointWithoutSdkPath}/api/v2` as const; + `${mockBackstageApiEndpointWithoutVersionSuffix}/api/v2` as const; /** * The assets endpoint to use during testing. @@ -309,7 +312,7 @@ export function getMockApiList(): readonly ApiTuple[] { }, }); - const mockCoderClient = new CoderClient({ + const mockCoderClient = new CoderClientWrapper({ initialToken: mockCoderAuthToken, apis: { urlSync: mockUrlSyncApi, @@ -327,6 +330,6 @@ export function getMockApiList(): readonly ApiTuple[] { // Custom APIs specific to the Coder plugin [urlSyncApiRef, mockUrlSyncApi], - [coderClientApiRef, mockCoderClient], + [coderClientWrapperApiRef, mockCoderClient], ]; } From 3329f3def4178fd6090b1dc44a823cc46ccb10dc Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 10 Jun 2024 22:59:31 +0000 Subject: [PATCH 69/94] fix: remove temp import --- .../src/components/CoderWorkspacesCard/Root.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx index 217aa960..5814d55b 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/Root.tsx @@ -18,7 +18,6 @@ import { import type { Workspace } from '../../api/vendoredSdk'; import { useCoderWorkspacesQuery } from './useCoderWorkspacesQuery'; import { CoderAuthFormCardWrapper } from '../CoderAuthFormCardWrapper'; -import { useCoderQuery } from '../../plugin'; export type WorkspacesQuery = UseQueryResult; From a56f3328cbd85feeb45eb24704b99181d2b14430 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 10 Jun 2024 23:13:54 +0000 Subject: [PATCH 70/94] fix: update exports for end-types --- .../backstage-plugin-coder/src/hooks/reactQueryWrappers.ts | 6 +++--- plugins/backstage-plugin-coder/src/plugin.ts | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts index a9eed3a5..95dcdffd 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.ts @@ -30,7 +30,7 @@ import type { BackstageCoderApi } from '../api/CoderClient'; export type CoderQueryFunctionContext = QueryFunctionContext & { - api: BackstageCoderApi; + coderApi: BackstageCoderApi; }; export type CoderQueryFunction< @@ -63,7 +63,7 @@ export function useCoderQuery< ): UseQueryResult { const queryClient = useQueryClient(); const { isAuthenticated } = useEndUserCoderAuth(); - const api = useCoderApi(); + const coderApi = useCoderApi(); let patchedQueryKey = queryOptions.queryKey; if ( @@ -98,7 +98,7 @@ export function useCoderQuery< throw new Error('Cannot complete request - user is not authenticated'); } - return queryOptions.queryFn({ ...context, api: api }); + return queryOptions.queryFn({ ...context, coderApi }); }, refetchInterval: (data, query) => { diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index 1429e210..d165c36f 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -209,3 +209,4 @@ export { CODER_QUERY_KEY_PREFIX } from './api/queryOptions'; * All custom types */ export type { CoderAppConfig } from './components/CoderProvider'; +export type * from './api/vendoredSdk/api/typesGenerated'; From 3a5582a198ae2f4eec93ba79956309723a5e5c60 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 10 Jun 2024 23:14:38 +0000 Subject: [PATCH 71/94] fix: update query wrapper tests --- .../src/hooks/reactQueryWrappers.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx index ee29fd6f..911ffa9c 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx @@ -117,7 +117,7 @@ describe(`${useCoderQuery.name}`, () => { authenticateOnMount: false, queryOptions: { queryKey: ['workspaces'], - queryFn: ({ api }) => api.getWorkspaces({ q: 'owner:me' }), + queryFn: ({ coderApi: api }) => api.getWorkspaces({ q: 'owner:me' }), select: response => response.workspaces, }, }); From 91185aa79f184b2a39e8bd52b21d308c6dcf9e5a Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 11 Jun 2024 02:43:22 +0000 Subject: [PATCH 72/94] wip: commit current rewrite progress --- .../docs/guides/coder-api.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 plugins/backstage-plugin-coder/docs/guides/coder-api.md diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api.md b/plugins/backstage-plugin-coder/docs/guides/coder-api.md new file mode 100644 index 00000000..c58861cb --- /dev/null +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api.md @@ -0,0 +1,158 @@ +# Coder API - Quick-start guide + +## Overview + +The Coder plugin makes it easy to bring the entire Coder API into your Backstage deployment. + +### Before you begin + +Please ensure that you have the Coder plugin fully installed before proceeding. You can find instructions for getting up and running in [our main README](../../README.md). + +### Software architecture + +All Coder plugin logic is centered around the `useCoderApi` custom hook. Calling this exposes an object with all Coder API methods, but does not provide any caching. For this, we recommend using React Query/Tanstack Query. The plugin already has a dependency on v4 of the plugin, and even provides a `useCoderQuery` convenience hook to make querying with the API even easier. + +## Main recommendations for accessing the API + +1. If querying data, prefer `useCoderQuery`. It automatically wires up all auth logic to React Query, and lets you access the Coder API via its query function. `useQuery` is also a good escape hatch if `useCoderQuery` doesn't meet your needs, but it requires more work to wire up correctly. +2. If mutating data, you will need to call `useMutation`, `useQueryClient`, and `useCoderApi` in tandem\*. +3. We recommend not manually setting the auth fallback updating the + +We highly recommend **not** fetching with `useState` + `useEffect`, or with `useAsync`. Both face performance issues when trying to share state. See [ui.dev](https://www.ui.dev/)'s wonderful [_The Story of React Query_ video](https://www.youtube.com/watch?v=OrliU0e09io) for more info on some of the problems they face. + +\* A `useCoderMutation` hook is in the works to simplify wiring these up. + +### Comparing query caching strategies + +| | `useState` + `useEffect` | `useAsync` | `useQuery` | `useCoderQuery` | +| ---------------------------------------------------------------------- | ------------------------ | ---------- | ---------- | --------------- | +| Automatically handles race conditions | đźš« | âś… | âś… | âś… | +| Can retain state after component unmounts | đźš« | đźš« | âś… | âś… | +| Easy, on-command query invalidation | đźš« | đźš« | âś… | âś… | +| Automatic retry logic when a query fails | đźš« | đźš« | âś… | âś… | +| Less need to fight dependency arrays | đźš« | đźš« | âś… | âś… | +| Easy to share state for sibling components | đźš« | đźš« | âś… | âś… | +| Pre-wired to Coder auth logic | đźš« | đźš« | đźš« | âś… | +| Can consume Coder API directly from query function | đźš« | đźš« | đźš« | âś… | +| Automatically prefixes Coder query keys to group Coder-related queries | đźš« | đźš« | đźš« | âś… | + +## Component examples + +Here are some full code examples showcasing patterns you can bring into your own codebase. + +Note: To keep the examples brief, none of them contain any CSS styling. + +### Displaying recent audit logs + +```tsx +import React from 'react'; +import { useCoderQuery } from '@coder/backstage-plugin-coder'; + +function RecentAuditLogsList() { + const auditLogsQuery = useCoderQuery({ + queryKey: ['audits', 'logs'], + queryFn: ({ coderApi }) => coderApi.getAuditLogs({ limit: 10 }), + }); + + return ( + <> + {auditLogsQuery.isLoading &&

Loading…

} + {auditLogsQuery.error instanceof Error && ( +

Encountered the following error: {auditLogsQuery.error.message}

+ )} + + {auditLogsQuery.data !== undefined && ( +
    + {auditLogsQuery.data.audit_logs.map(log => ( +
  • {log.description}
  • + ))} +
+ )} + + ); +} +``` + +## Creating a new workspace + +```tsx +import React, { type FormEvent, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + type CreateWorkspaceRequest, + CODER_QUERY_KEY_PREFIX, + useCoderQuery, + useCoderApi, +} from '@coder/backstage-plugin-coder'; + +export function WorkspaceCreationForm() { + const [newWorkspaceName, setNewWorkspaceName] = useState(''); + const coderApi = useCoderSdk(); + const queryClient = useQueryClient(); + + const currentUserQuery = useCoderQuery({ + queryKey: ['currentUser'], + queryFn: coderApi.getAuthenticatedUser, + }); + + const workspacesQuery = useCoderQuery({ + queryKey: ['workspaces'], + queryFn: coderApi.getWorkspaces, + }); + + const createWorkspaceMutation = useMutation({ + mutationFn: (payload: CreateWorkspaceRequest) => { + if (currentUserQuery.data === undefined) { + throw new Error( + 'Cannot create workspace without data for current user', + ); + } + + const { organization_ids, id: userId } = currentUserQuery.data; + return coderApi.createWorkspace(organization_ids[0], userId, payload); + }, + }); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + + // If the mutation fails, useMutation will expose the error in the UI via + // its own exposed properties + await createWorkspaceMutation.mutateAsync({ + name: newWorkspaceName, + }); + + setNewWorkspaceName(''); + queryClient.invalidateQueries({ + queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces'], + }); + }; + + return ( + <> + {createWorkspaceMutation.isSuccess && ( +

+ Workspace {createWorkspaceMutation.data.name} created successfully! +

+ )} + +
+
+ Required fields + + +
+ + +
+ + ); +} +``` From 5dc9035bc1ddcfc1bfe9c8b8983263ac0e542a44 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 11 Jun 2024 02:44:16 +0000 Subject: [PATCH 73/94] fix: update structure of directory readme --- plugins/backstage-plugin-coder/docs/README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/docs/README.md b/plugins/backstage-plugin-coder/docs/README.md index 1aac4a05..82a5a2f2 100644 --- a/plugins/backstage-plugin-coder/docs/README.md +++ b/plugins/backstage-plugin-coder/docs/README.md @@ -2,10 +2,16 @@ For users who need more information about how to extend and modify the Coder plugin. For general setup, please see our main [README](../README.md). -All documentation reflects version `v0.2.0` of the plugin. Note that breaking API changes may continue to happen for minor versions until the plugin reaches version `v1.0.0`. +All documentation reflects version `v0.2.1` of the plugin. Note that breaking API changes may continue to happen for minor versions until the plugin reaches version `v1.0.0`. ## Documentation directory +### Guides + +- [Using the Coder API from Backstage](./guides/coder-api.md) + +### API reference + - [Components](./components.md) - [Custom React hooks](./hooks.md) - [Important types](./types.md) From 4b52bf75a4a9f7ae0b89963543f90b36cc5a1b0c Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 11 Jun 2024 03:36:14 +0000 Subject: [PATCH 74/94] wip: commit more docs progress --- .../docs/guides/coder-api-advanced.md | 1 + .../docs/guides/coder-api.md | 66 ++++++++++++++----- 2 files changed, 50 insertions(+), 17 deletions(-) create mode 100644 plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md b/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md new file mode 100644 index 00000000..ad0b2f6a --- /dev/null +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md @@ -0,0 +1 @@ +- Include section for changing the behavior of the auth fallback diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api.md b/plugins/backstage-plugin-coder/docs/guides/coder-api.md index c58861cb..e13fa0f1 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api.md @@ -4,19 +4,41 @@ The Coder plugin makes it easy to bring the entire Coder API into your Backstage deployment. +Note: this covers the main expected use cases with the plugin. For more information and options on customizing your Backstage deployment further, see our [Advanced API guide](./coder-api-advanced.md). + ### Before you begin Please ensure that you have the Coder plugin fully installed before proceeding. You can find instructions for getting up and running in [our main README](../../README.md). -### Software architecture +### Important hooks for using the Coder API + +There are a few React hooks that are needed to interact with the Coder API. These can be split into three categories: React Query hooks, core plugin hooks, and convenience hooks. + +#### React Query hooks + +The Coder plugin uses React Query/TanStack Query for all of its data caching. We recommend that you use it for your own data caching, because of the sheer amount of headaches it can spare you. + +There are three main hooks that you will likely need: + +- `useQuery` - Query and cache data +- `useMutation` - Perform mutations on an API resource +- `useQueryClient` - Coordinate queries and mutations + +#### Core plugin hooks + +These are hooks that provide direct access to various parts of the Coder API. -All Coder plugin logic is centered around the `useCoderApi` custom hook. Calling this exposes an object with all Coder API methods, but does not provide any caching. For this, we recommend using React Query/Tanstack Query. The plugin already has a dependency on v4 of the plugin, and even provides a `useCoderQuery` convenience hook to make querying with the API even easier. +- `useCoderApi` - Exposes an object with all available Coder API methods +- `useCoderAuth` - Provides methods and state values for interacting with your current Coder auth session -## Main recommendations for accessing the API +#### Convenience hooks -1. If querying data, prefer `useCoderQuery`. It automatically wires up all auth logic to React Query, and lets you access the Coder API via its query function. `useQuery` is also a good escape hatch if `useCoderQuery` doesn't meet your needs, but it requires more work to wire up correctly. -2. If mutating data, you will need to call `useMutation`, `useQueryClient`, and `useCoderApi` in tandem\*. -3. We recommend not manually setting the auth fallback updating the +- `useCoderQuery` - Simplifies wiring up `useQuery`, `useCoderApi`, and `useCoderAuth` + +## Recommendations for accessing the API + +1. If querying data, prefer `useCoderQuery`. It automatically wires up all auth logic to React Query (which includes pausing queries if the user is not authenticated). It also lets you access the Coder API via its query function. `useQuery` is also a good escape hatch if `useCoderQuery` doesn't meet your needs, but it requires more work to wire up correctly. +2. If mutating data, you will need to call `useMutation`, `useQueryClient`, and `useCoderApi` in tandem\*. The plugin exposes a `CODER_QUERY_KEY_PREFIX` constant that you can use to group all Coder queries together. We highly recommend **not** fetching with `useState` + `useEffect`, or with `useAsync`. Both face performance issues when trying to share state. See [ui.dev](https://www.ui.dev/)'s wonderful [_The Story of React Query_ video](https://www.youtube.com/watch?v=OrliU0e09io) for more info on some of the problems they face. @@ -24,17 +46,27 @@ We highly recommend **not** fetching with `useState` + `useEffect`, or with `use ### Comparing query caching strategies -| | `useState` + `useEffect` | `useAsync` | `useQuery` | `useCoderQuery` | -| ---------------------------------------------------------------------- | ------------------------ | ---------- | ---------- | --------------- | -| Automatically handles race conditions | đźš« | âś… | âś… | âś… | -| Can retain state after component unmounts | đźš« | đźš« | âś… | âś… | -| Easy, on-command query invalidation | đźš« | đźš« | âś… | âś… | -| Automatic retry logic when a query fails | đźš« | đźš« | âś… | âś… | -| Less need to fight dependency arrays | đźš« | đźš« | âś… | âś… | -| Easy to share state for sibling components | đźš« | đźš« | âś… | âś… | -| Pre-wired to Coder auth logic | đźš« | đźš« | đźš« | âś… | -| Can consume Coder API directly from query function | đźš« | đźš« | đźš« | âś… | -| Automatically prefixes Coder query keys to group Coder-related queries | đźš« | đźš« | đźš« | âś… | +| | `useState` + `useEffect` | `useAsync` | `useQuery` | `useCoderQuery` | +| ------------------------------------------------------------------ | ------------------------ | ---------- | ---------- | --------------- | +| Automatically handles race conditions | đźš« | âś… | âś… | âś… | +| Can retain state after component unmounts | đźš« | đźš« | âś… | âś… | +| Easy, on-command query invalidation | đźš« | đźš« | âś… | âś… | +| Automatic retry logic when a query fails | đźš« | đźš« | âś… | âś… | +| Less need to fight dependency arrays | đźš« | đźš« | âś… | âś… | +| Easy to share state for sibling components | đźš« | đźš« | âś… | âś… | +| Pre-wired to Coder auth logic | đźš« | đźš« | đźš« | âś… | +| Can consume Coder API directly from query function | đźš« | đźš« | đźš« | âś… | +| Automatically groups Coder-related queries by prefixing query keys | đźš« | đźš« | đźš« | âś… | + +## Authentication + +All API calls to **any** of the Coder API functions will fail if you have not authenticated yet. Authentication can be handled via any of the official Coder components that can be imported via the plugin. However, if there are no Coder components on the screen, the `CoderProvider` component will automatically\* inject a fallback auth button for letting the user add their auth info. + +<-- Add video of auth flow with fallback button --> + +Once the user has been authenticated, all Coder API functions will become available. When the user unlinks their auth token (effectively logging out), all queries that start with `CODER_QUERY_KEY_PREFIX` will automatically be vacated. + +\* This behavior can be disabled. Please see our [advanced API guide](./coder-api-advanced.md) for more information. ## Component examples From 753a5e5d38346c0dd3cc3473133190aa9e96b3d1 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 11 Jun 2024 13:23:26 +0000 Subject: [PATCH 75/94] chore: finish second draft of main README --- .../docs/guides/coder-api.md | 79 +++++++++++++++++-- 1 file changed, 73 insertions(+), 6 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api.md b/plugins/backstage-plugin-coder/docs/guides/coder-api.md index e13fa0f1..92b940d8 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api.md @@ -2,7 +2,7 @@ ## Overview -The Coder plugin makes it easy to bring the entire Coder API into your Backstage deployment. +The Coder plugin makes it easy to bring the entire Coder API into your Backstage deployment. This guide covers how to get it set up so that you can start accessing Coder from Backstage. Note: this covers the main expected use cases with the plugin. For more information and options on customizing your Backstage deployment further, see our [Advanced API guide](./coder-api-advanced.md). @@ -16,7 +16,7 @@ There are a few React hooks that are needed to interact with the Coder API. Thes #### React Query hooks -The Coder plugin uses React Query/TanStack Query for all of its data caching. We recommend that you use it for your own data caching, because of the sheer amount of headaches it can spare you. +The Coder plugin uses [React Query/TanStack Query v4](https://tanstack.com/query/v4/docs/framework/react/overview) for all of its data caching. We recommend that you use it for your own data caching, because of the sheer amount of headaches it can spare you. There are three main hooks that you will likely need: @@ -28,8 +28,8 @@ There are three main hooks that you will likely need: These are hooks that provide direct access to various parts of the Coder API. -- `useCoderApi` - Exposes an object with all available Coder API methods -- `useCoderAuth` - Provides methods and state values for interacting with your current Coder auth session +- `useCoderApi` - Exposes an object with all available Coder API methods. For the most part, there is no exposed state on this object; you can consider it a "function bucket". +- `useCoderAuth` - Provides methods and state values for interacting with your current Coder auth session from within Backstage. #### Convenience hooks @@ -64,15 +64,82 @@ All API calls to **any** of the Coder API functions will fail if you have not au <-- Add video of auth flow with fallback button --> -Once the user has been authenticated, all Coder API functions will become available. When the user unlinks their auth token (effectively logging out), all queries that start with `CODER_QUERY_KEY_PREFIX` will automatically be vacated. +Once the user has been authenticated, all Coder API functions will become available. When the user unlinks their auth token (effectively logging out), all cached queries that start with `CODER_QUERY_KEY_PREFIX` will automatically be vacated. \* This behavior can be disabled. Please see our [advanced API guide](./coder-api-advanced.md) for more information. +## Connecting a custom query client to the Coder plugin + +By default, the Coder plugin uses and manages its own query client. This works perfectly well if you aren't using React Query for any other purposes, but if you are using it throughout your Backstage deployment, it can cause issues around redundant state (e.g., not all cached data being vacated when the user logs out). + +To prevent this, you will need to do two things: + +1. Pass in your custom React Query query client into the `CoderProvider` component +2. "Group" your queries with the Coder query key prefix + +### Passing in a custom query client + +The `CoderProvider` component accepts an optional `queryClient` prop. When provided, the Coder plugin will use this client for **all** queries (those made by the built-in Coder components, or any custom components that you put inside `CoderProvider`). + +```tsx +const customQueryClient = new QueryClient(); + + + +; +``` + +### Grouping queries with the Coder query key prefix + +The plugin exposes a `CODER_QUERY_KEY_PREFIX` constant that you can use to group all Coder queries together for `useQuery` and `useQueryClient`. All queries made by official Coder components put this as the first value of their query key. The `useCoderQuery` convenience hook also automatically injects this constant at the beginning of all query keys (even if not explicitly added). + +```tsx +// Starting all query keys with the constant "groups" them together +const coderApi = useCoderApi(); +const workspacesQuery = useQuery({ + queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces'], + queryFn: () => + coderApi.getWorkspaces({ + limit: 10, + }), +}); + +const workspacesQuery2 = useCoderQuery({ + // The query key will automatically have CODER_QUERY_KEY_PREFIX added to the + // beginning + queryKey: ['workspaces'], + queryFn: ({ coderApi }) => + coderApi.getWorkspaces({ + limit: 10, + }), +}); + +// All grouped queries can be invalidated at once from the query client +const queryClient = useQueryClient(); +const invalidateAllCoderQueries = () => { + queryClient.invalidateQuery({ + queryKey: [CODER_QUERY_KEY_PREFIX], + }); +}; + +// When the user unlinks their session token, all queries grouped under +// CODER_QUERY_KEY_PREFIX are vacated from the active query cache +function LogOutButton() { + const { ejectToken } = useCoderAuth(); + + return ( + + ); +} +``` + ## Component examples Here are some full code examples showcasing patterns you can bring into your own codebase. -Note: To keep the examples brief, none of them contain any CSS styling. +Note: To keep the examples simple, none of them contain any CSS styling or MUI components. ### Displaying recent audit logs From 3402ea5ad4fbbbc0cce9b735d93591dc18dc91e3 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 11 Jun 2024 13:29:25 +0000 Subject: [PATCH 76/94] refactor: rename ejectToken to unlinkToken --- .../CoderAuthForm/CoderAuthDistrustedForm.tsx | 2 +- .../CoderAuthForm/CoderAuthForm.test.tsx | 16 ++++---- .../CoderAuthForm/UnlinkAccountButton.tsx | 4 +- .../CoderProvider/CoderAuthProvider.tsx | 6 +-- .../CoderProvider/CoderProvider.test.tsx | 4 +- .../ExtraActionsButton.test.tsx | 9 +++-- .../ExtraActionsButton.tsx | 4 +- .../src/hooks/reactQueryWrappers.test.tsx | 37 ++++++++++--------- .../src/testHelpers/mockBackstageData.ts | 4 +- 9 files changed, 46 insertions(+), 40 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx index a37c1916..3f58804d 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthDistrustedForm.tsx @@ -36,7 +36,7 @@ export const CoderAuthDistrustedForm = () => {

Unable to verify token authenticity. Please check your internet - connection, or try ejecting the token. + connection, or try unlinking the token.

diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx index 95ce2993..79b263ca 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/CoderAuthForm.test.tsx @@ -16,12 +16,12 @@ type RenderInputs = Readonly<{ }>; async function renderAuthWrapper({ authStatus }: RenderInputs) { - const ejectToken = jest.fn(); + const unlinkToken = jest.fn(); const registerNewToken = jest.fn(); const auth: CoderAuth = { ...mockAuthStates[authStatus], - ejectToken, + unlinkToken, registerNewToken, }; @@ -40,7 +40,7 @@ async function renderAuthWrapper({ authStatus }: RenderInputs) { , ); - return { ...renderOutput, ejectToken, registerNewToken }; + return { ...renderOutput, unlinkToken, registerNewToken }; } describe(`${CoderAuthForm.name}`, () => { @@ -70,18 +70,18 @@ describe(`${CoderAuthForm.name}`, () => { } }); - it('Lets the user eject the current token', async () => { - const { ejectToken } = await renderAuthWrapper({ + it('Lets the user unlink the current token', async () => { + const { unlinkToken } = await renderAuthWrapper({ authStatus: 'distrusted', }); const user = userEvent.setup(); - const ejectButton = await screen.findByRole('button', { + const unlinkButton = await screen.findByRole('button', { name: /Unlink Coder account/, }); - await user.click(ejectButton); - expect(ejectToken).toHaveBeenCalled(); + await user.click(unlinkButton); + expect(unlinkToken).toHaveBeenCalled(); }); }); diff --git a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx index 63b9fdd0..efc23329 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderAuthForm/UnlinkAccountButton.tsx @@ -19,7 +19,7 @@ export function UnlinkAccountButton({ ...delegatedProps }: Props) { const styles = useStyles(); - const { ejectToken } = useInternalCoderAuth(); + const { unlinkToken } = useInternalCoderAuth(); return ( { - ejectToken(); + unlinkToken(); onClick?.(event); }} {...delegatedProps} diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx index 6592c7e5..a8155be3 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderAuthProvider.tsx @@ -67,7 +67,7 @@ export type CoderAuth = Readonly< AuthState & { isAuthenticated: boolean; registerNewToken: (newToken: string) => void; - ejectToken: () => void; + unlinkToken: () => void; } >; @@ -176,7 +176,7 @@ function useAuthState(): CoderAuth { } }, []); - const ejectToken = useCallback(() => { + const unlinkToken = useCallback(() => { setAuthToken(''); window.localStorage.removeItem(TOKEN_STORAGE_KEY); queryClient.removeQueries({ queryKey: [CODER_QUERY_KEY_PREFIX] }); @@ -186,7 +186,7 @@ function useAuthState(): CoderAuth { ...authState, isAuthenticated, registerNewToken, - ejectToken, + unlinkToken, }; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx index 731977b5..b58af930 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.test.tsx @@ -98,7 +98,7 @@ describe(`${CoderProvider.name}`, () => { }); }; - it('Should let the user eject their auth token', async () => { + it('Should let the user unlink their auth token', async () => { const { result } = renderUseCoderAuth(); act(() => result.current.registerNewToken(mockCoderAuthToken)); @@ -112,7 +112,7 @@ describe(`${CoderProvider.name}`, () => { ); }); - act(() => result.current.ejectToken()); + act(() => result.current.unlinkToken()); expect(result.current).toEqual( expect.objectContaining>({ diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx index 008d931a..d170db36 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.test.tsx @@ -29,8 +29,11 @@ type RenderInputs = Readonly<{ }>; async function renderButton({ buttonText }: RenderInputs) { - const ejectToken = jest.fn(); - const auth: CoderAuth = { ...mockAuthStates.authenticated, ejectToken }; + const unlinkToken = jest.fn(); + const auth: CoderAuth = { + ...mockAuthStates.authenticated, + unlinkToken: unlinkToken, + }; /** * Pretty sure there has to be a more elegant and fault-tolerant way of @@ -58,7 +61,7 @@ async function renderButton({ buttonText }: RenderInputs) { return { ...renderOutput, button: screen.getByRole('button', { name: new RegExp(buttonText) }), - unlinkCoderAccount: ejectToken, + unlinkCoderAccount: unlinkToken, refreshWorkspaces: refetch, }; } diff --git a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx index 3d9dbcf6..a6ccfb19 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderWorkspacesCard/ExtraActionsButton.tsx @@ -102,7 +102,7 @@ export const ExtraActionsButton = ({ const hookId = useId(); const [loadedAnchor, setLoadedAnchor] = useState(); const refreshWorkspaces = useRefreshWorkspaces(); - const { ejectToken } = useInternalCoderAuth(); + const { unlinkToken } = useInternalCoderAuth(); const styles = useStyles(); const closeMenu = () => setLoadedAnchor(undefined); @@ -178,7 +178,7 @@ export const ExtraActionsButton = ({ { - ejectToken(); + unlinkToken(); closeMenu(); }} > diff --git a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx index 911ffa9c..65029704 100644 --- a/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx +++ b/plugins/backstage-plugin-coder/src/hooks/reactQueryWrappers.test.tsx @@ -52,11 +52,11 @@ async function renderCoderQuery< } = options; let latestRegisterNewToken!: CoderAuth['registerNewToken']; - let latestEjectToken!: CoderAuth['ejectToken']; + let latestUnlinkToken!: CoderAuth['unlinkToken']; const AuthEscapeHatch = () => { const auth = useEndUserCoderAuth(); latestRegisterNewToken = auth.registerNewToken; - latestEjectToken = auth.ejectToken; + latestUnlinkToken = auth.unlinkToken; return null; }; @@ -91,15 +91,15 @@ async function renderCoderQuery< return act(() => latestRegisterNewToken(mockCoderAuthToken)); }; - const ejectToken = () => { - return act(() => latestEjectToken()); + const unlinkToken = () => { + return act(() => latestUnlinkToken()); }; if (authenticateOnMount) { registerMockToken(); } - return { ...renderOutput, registerMockToken, ejectToken }; + return { ...renderOutput, registerMockToken, unlinkToken }; } describe(`${useCoderQuery.name}`, () => { @@ -113,14 +113,17 @@ describe(`${useCoderQuery.name}`, () => { */ describe('Hook functionality', () => { it('Disables requests while user is not authenticated', async () => { - const { result, registerMockToken, ejectToken } = await renderCoderQuery({ - authenticateOnMount: false, - queryOptions: { - queryKey: ['workspaces'], - queryFn: ({ coderApi: api }) => api.getWorkspaces({ q: 'owner:me' }), - select: response => response.workspaces, + const { result, registerMockToken, unlinkToken } = await renderCoderQuery( + { + authenticateOnMount: false, + queryOptions: { + queryKey: ['workspaces'], + queryFn: ({ coderApi: api }) => + api.getWorkspaces({ q: 'owner:me' }), + select: response => response.workspaces, + }, }, - }); + ); expect(result.current.isLoading).toBe(true); registerMockToken(); @@ -131,7 +134,7 @@ describe(`${useCoderQuery.name}`, () => { expect(result.current.data?.length).toBeGreaterThan(0); }); - ejectToken(); + unlinkToken(); await waitFor(() => expect(result.current.isLoading).toBe(true)); }); @@ -181,7 +184,7 @@ describe(`${useCoderQuery.name}`, () => { }); it('Disables everything when the user unlinks their access token', async () => { - const { result, ejectToken } = await renderCoderQuery({ + const { result, unlinkToken } = await renderCoderQuery({ queryOptions: { queryKey: ['workspaces'], queryFn: () => Promise.resolve(mockWorkspacesList), @@ -198,7 +201,7 @@ describe(`${useCoderQuery.name}`, () => { ); }); - ejectToken(); + unlinkToken(); await waitFor(() => { expect(result.current).toEqual( @@ -226,7 +229,7 @@ describe(`${useCoderQuery.name}`, () => { const { promise, reject } = createInvertedPromise(); const queryFn = jest.fn(() => promise); - const { ejectToken } = await renderCoderQuery({ + const { unlinkToken } = await renderCoderQuery({ queryOptions: { queryFn, queryKey: ['blah'], @@ -238,7 +241,7 @@ describe(`${useCoderQuery.name}`, () => { }); await waitFor(() => expect(queryFn).toHaveBeenCalled()); - ejectToken(); + unlinkToken(); queryFn.mockRestore(); act(() => reject(new Error("Don't feel like giving you data today"))); diff --git a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts index ce45b591..843e4743 100644 --- a/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts +++ b/plugins/backstage-plugin-coder/src/testHelpers/mockBackstageData.ts @@ -176,7 +176,7 @@ const authedState = { error: undefined, isAuthenticated: true, registerNewToken: jest.fn(), - ejectToken: jest.fn(), + unlinkToken: jest.fn(), } as const satisfies Partial; const notAuthedState = { @@ -184,7 +184,7 @@ const notAuthedState = { error: undefined, isAuthenticated: false, registerNewToken: jest.fn(), - ejectToken: jest.fn(), + unlinkToken: jest.fn(), } as const satisfies Partial; export const mockAuthStates = { From 45eac85c394c758d7affdf7ca407717b4695a647 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 11 Jun 2024 13:32:47 +0000 Subject: [PATCH 77/94] refactor: reorganize readme file structure --- plugins/backstage-plugin-coder/docs/README.md | 7 ++++--- .../docs/{ => api-reference}/catalog-info.md | 0 .../docs/{ => api-reference}/components.md | 0 .../docs/{ => api-reference}/hooks.md | 0 .../docs/{ => api-reference}/types.md | 0 5 files changed, 4 insertions(+), 3 deletions(-) rename plugins/backstage-plugin-coder/docs/{ => api-reference}/catalog-info.md (100%) rename plugins/backstage-plugin-coder/docs/{ => api-reference}/components.md (100%) rename plugins/backstage-plugin-coder/docs/{ => api-reference}/hooks.md (100%) rename plugins/backstage-plugin-coder/docs/{ => api-reference}/types.md (100%) diff --git a/plugins/backstage-plugin-coder/docs/README.md b/plugins/backstage-plugin-coder/docs/README.md index 82a5a2f2..a3084c31 100644 --- a/plugins/backstage-plugin-coder/docs/README.md +++ b/plugins/backstage-plugin-coder/docs/README.md @@ -9,9 +9,10 @@ All documentation reflects version `v0.2.1` of the plugin. Note that breaking AP ### Guides - [Using the Coder API from Backstage](./guides/coder-api.md) + - [Advanced use cases for the Coder API](./guides//coder-api-advanced.md) ### API reference -- [Components](./components.md) -- [Custom React hooks](./hooks.md) -- [Important types](./types.md) +- [Components](./api-reference/components.md) +- [Custom React hooks](./api-reference/hooks.md) +- [Important types](./api-reference/types.md) diff --git a/plugins/backstage-plugin-coder/docs/catalog-info.md b/plugins/backstage-plugin-coder/docs/api-reference/catalog-info.md similarity index 100% rename from plugins/backstage-plugin-coder/docs/catalog-info.md rename to plugins/backstage-plugin-coder/docs/api-reference/catalog-info.md diff --git a/plugins/backstage-plugin-coder/docs/components.md b/plugins/backstage-plugin-coder/docs/api-reference/components.md similarity index 100% rename from plugins/backstage-plugin-coder/docs/components.md rename to plugins/backstage-plugin-coder/docs/api-reference/components.md diff --git a/plugins/backstage-plugin-coder/docs/hooks.md b/plugins/backstage-plugin-coder/docs/api-reference/hooks.md similarity index 100% rename from plugins/backstage-plugin-coder/docs/hooks.md rename to plugins/backstage-plugin-coder/docs/api-reference/hooks.md diff --git a/plugins/backstage-plugin-coder/docs/types.md b/plugins/backstage-plugin-coder/docs/api-reference/types.md similarity index 100% rename from plugins/backstage-plugin-coder/docs/types.md rename to plugins/backstage-plugin-coder/docs/api-reference/types.md From 2bc683831957e1e6882a26a8d7d79dc48a46196a Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 11 Jun 2024 13:48:45 +0000 Subject: [PATCH 78/94] update details for new versions of README --- .../docs/guides/coder-api-advanced.md | 29 ++++++++++++++++++- .../docs/guides/coder-api.md | 4 +-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md b/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md index ad0b2f6a..ae9ad929 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md @@ -1 +1,28 @@ -- Include section for changing the behavior of the auth fallback +# Working with the Coder API - advanced use cases + +This guide covers some more use cases that you can leverage for more advanced configuration of the Coder API from within Backstage. + +## Changing fallback auth component behavior + +By default, `CoderProvider` is configured to display a fallback auth UI component when two cases are true: + +1. The user is not authenticated +2. There are no official Coder components are being rendered to the screen. + +<-- Add image of fallback --> + +All official Coder plugin components are configured to let the user add auth information if the user isn't already authenticated, so the fallback component only displays when there would be no other way to add the information. + +However, depending on your use cases, `CoderProvider` can be configured to change how it displays the fallback, based on the value of the `fallbackAuthUiMode` prop. + +```tsx + + + +``` + +There are three values that can be set for the mode: + +- `restrained` (default) - The auth fallback will only display if the user is not authenticated, and there would be no other way for the user to add their auth info. +- `assertive` - The auth fallback will always display when the user is not authenticated, regardless of what Coder component are on-screen. But the fallback will **not** appear if the user is authenticated. +- `hidden` - The auth fallback will never appear under any circumstances. Useful if you want to create entirely custom components and don't mind wiring your auth logic manually via `useCoderAuth`. diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api.md b/plugins/backstage-plugin-coder/docs/guides/coder-api.md index 92b940d8..2a3d3075 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api.md @@ -125,10 +125,10 @@ const invalidateAllCoderQueries = () => { // When the user unlinks their session token, all queries grouped under // CODER_QUERY_KEY_PREFIX are vacated from the active query cache function LogOutButton() { - const { ejectToken } = useCoderAuth(); + const { unlinkToken } = useCoderAuth(); return ( - ); From 852226dcf20f3f0fa8fd9100f2b1c52f14d9b819 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 11 Jun 2024 13:50:31 +0000 Subject: [PATCH 79/94] chore: delete first draft of the README --- .../docs/guides/sdk-recommendations.md | 557 ------------------ 1 file changed, 557 deletions(-) delete mode 100644 plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md diff --git a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md b/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md deleted file mode 100644 index 8d970f04..00000000 --- a/plugins/backstage-plugin-coder/docs/guides/sdk-recommendations.md +++ /dev/null @@ -1,557 +0,0 @@ -# Using the Coder SDK - -This document walks you through using the Coder SDK from within Backstage. - -## Disclaimer - -As of May 2024, Coder does not have a fully public, versioned SDK published on a package manager like NPM. Coder does intend to release a true JavaScript/TypeScript SDK, but until that is released, the version exposed through Backstage can be best thought of as a "preview"/"testbed" SDK. It is also used in production for [the official Coder VS Code extension](https://github.com/coder/vscode-coder). - -If you encounter any issues while using the Backstage version of the SDK, please don't hesitate to open an issue. We would be happy to get any issues fixed, but expect some growing pains as we collect user feedback. - -## Welcome to the Coder SDK! - -The Coder SDK for Backstage makes it easy for Backstage admins to bring the entire Coder API into Spotify's Backstage platform. While the Coder Backstage plugin does ship with a collection of ready-made components, those can't meet every user's needs, and so, why not give you access to the full set of building blocks, so you can build a solution tailored to your specific use case? - -### Table of contents - -This guide covers the following: - -- Accessing the SDK from your own custom React components - - Accessing the SDK via `useCoderSdk` - - Accessing the SDK via `useCoderQuery` -- Authenticating - - The fallback auth UI -- Performing queries - - Recommendations for caching data - - How the Coder plugin caches data - - The `useCoderQuery` custom hook - - Cautions against other common UI caching strategies -- Performing mutations - -### Before you begin - -This guide assumes that you have already added the `CoderProvider` component to your Backstage deployment. If you have not, [please see the main README](../../README.md#setup) for instructions on getting that set up. - -## Quick-start - -### The main hooks to use when working with the SDK - -There are about six main hooks that you want to consider when using the Coder SDK.These can be broken down into two main categories: - -#### Primitive hooks - -- `useCoderSdk` - Gives you the Coder SDK instance -- `useCoderAuth` - Exposes properties and methods for examining/updating Coder auth state -- `useQuery` (via Tanstack Query library) - Declarative querying -- `useMutation` (via Tanstack Query library) - Imperative mutations -- `useQueryClient` (via Tanstack Query library) - Lets you coordinate queries and mutations within the same query cache - -#### Convenience hooks - -- `useCoderQuery` (wrapper over `useQuery`) - -## Accessing the SDK from your own custom React components - -There are two main ways of accessing the Coder SDK: - -- `useCoderSdk` -- `useCoderQuery` - -### Accessing the SDK through `useCoderSdk` - -`useCoderSdk` is a lower-level "primitive" for accessing the Coder SDK. The SDK exposes a mix of different REST API methods for interacting with your Coder deployment's resources. - -Calling the hook will give you an object with all available API methods. As these methods are all async, **none** of them are suitable for use in render logic. They must be called from within effects or event handlers. - -```tsx -// Illustrative example - these patterns are a very bad idea in production! -function ExampleComponent() { - // The workspace type is exported via the plugin - const [workspaces, setWorkspaces] = useState([]); - const sdk = useCoderSdk(); - - // The SDK can be called from any effect - useEffect(() => { - const getInitialWorkspaces = async () => { - const workspacesResponse = await sdk.getWorkspaces({ q: 'owner:me' }); - const workspaces = workspacesResponse.workspaces; - setWorkspaces(workspaces); - }; - - void getInitialWorkspaces(); - - // The SDK maintains a stable memory reference; there is no harm in - // including it as part of your dependency arrays. In this case, the - // dependency array may as well be empty. - }, [sdk]); - - return ( - <> - - -
    - {workspaces.map(workspace => ( -
  • {workspace.name}
  • - ))} -
- - ); -} -``` - -### The SDK object - -The SDK object contains all available API methods. All methods follow the format `` + ``. The SDK has these verbs: - -- `get` -- `post` -- `put` -- `patch` -- `upsert` -- `delete` - -Depending on the Coder resource, there may be different API methods that work with a single resource vs all resources (e.g., `sdk.getWorkspace` vs `sdk.getWorkspaces`). - -Note that all of these functions will throw an error if the user is not authenticated. - -### Error behavior - -All SDK functions will throw in the event of an error. You will need to provide additional error handling to expose errors as values within the UI. (Tanstack Query does this automatically for you.) - -### Accessing the SDK through `useCoderQuery` - -The nuances of how `useCoderQuery` works are covered later in this guide, but for convenience, the hook can access the SDK directly from its `queryFn` function: - -```tsx -const workspacesQuery = useCoderQuery({ - queryKey: ['workspaces'], - - // Access the SDK without needing to import or call useCoderSdk - queryFn: ({ sdk }) => sdk.getWorkspaces({ q: 'owner:me' }), -}); -``` - -## Authentication - -All API methods from the SDK will throw an error if the user is not authenticated. The Coder plugin provides a few different ways of letting the user authenticate with Coder: - -- The official Coder components -- The `CoderProvider` component's fallback auth UI -- The `useCoderAuth` hook - -All three solutions, directly or indirectly, involve the `CoderAuth` type. More information can be found under the `useCoderAuth` section. - -### Authenticating via official Coder components - -Every official Coder component (such as `CoderWorkspacesCard`) exported through the plugin is guaranteed to have some mechanism for supplying auth information. This is typically done via a UI form. - -<-- [Include screenshot/video of auth input here] --> - -#### Pros - -- Come pre-wired with the ability to let the user supply auth information -- Extensively tested -- Let you override the styles or re-compose the individual pieces for your needs -- Always audited for WCAG Level AA accessibility, and include landmark behavior for screen readers - -#### Cons - -- Not every Coder component makes sense for every page -- No easy mechanism for ensuring that your custom components don't run until the user authenticates via the official Coder components -- Components only work with a small sub-section of the total API, and won't be able to satisfy true power users -- Must be mounted within a `CoderProvider` component - -### Authenticating via the fallback auth UI - -When you include the `CoderProvider` component in your Backstage deployment, you have the option to set the value of `fallbackAuthUiMode`. This value affects how `CoderProvider` will inject a fallback auth input into the Backstage deployment's HTML. This means that, even if you don't use any Coder components, or are on a page that can't use them, users will always have some way of supplying auth information. - -```tsx - - - -``` - -<-- [Include screenshot/video of fallback auth input here] --> - -The fallback auth UI will never be visible while the user is authenticated. However, if the user is **not** authenticated, then the value of `fallbackAuthUiMode` will affect what appears on screen: - -- `restrained` (default) - The fallback auth UI will not appear if there are official Coder components on screen. -- `hidden` - The fallback auth is **never** visible on the page. If no official Coder components are on screen, you will need to import `useCoderAuth` into your custom components to authenticate your users. -- `assertive` - The fallback auth UI is always visible when the user is not authenticated, regardless of whether there are any official Coder components on screen. - -#### Pros - -- Helps guarantee that the user always has a way of supplying auth information -- Multiple ways of setting the behavior for the fallback. If you don't want to display it at all times, you can disable it -- Automatic integration with official Coder components. The auth fallback UI can detect Coder components without you needing to rewrite any code. -- All auth UI logic has been tested and audited for accessibility at the same standards as the other Coder components - -#### Cons - -- Even with three options for setting behavior, fallback auth input may not be visible exactly when you want it to be -- The `restrained` behavior is only effective on pages where you can place official Coder components. If you are not on one of these pages, the fallback auth UI will always be visible until the user authenticates. -- Fewer options for customizing the styling - -### Authenticating via `useCoderAuth` - -The `useCoderAuth` hook provides state and functions for updating Coder authentication state within your Backstage deployment. When called, it gives you back a `CoderAuth` object - -```tsx -// This is a simplified version of the type; the real type is set up as a -// discriminated union to increase type safety and ergonomics further -type CoderAuth = Readonly<{ - status: CoderAuthStatus; // Union of strings - token: string | undefined; - error: unknown; - - isAuthenticated: boolean; - registerNewToken: (newToken: string) => void; - ejectToken: () => void; -}>; -``` - -#### Pros - -- Gives you the finest level of control over all auth concerns -- Easy to import into any component - -#### Cons - -- Zero UI logic out of the box; you have to make all components yourself -- Must be called within a `CoderProvider` component - -## Caching API data for UIs - -All core logic in the Coder plugin uses [Tanstack Query v4](https://tanstack.com/query/v4). As it is already a dependency for the Coder plugin, it is highly recommended that you also use the library when building out your own components. - -The three main hooks from the library that you will likely use with the SDK are: - -- `useQuery` -- `useMutation` -- `useQueryClient` - -At present, the Coder plugin provides a convenience wrapper for connecting `useQuery` to the Coder SDK and to Coder auth state. This is the `useCoderQuery` hook – this is a great option if a component should only care about making queries and doesn't need to interact directly with auth state. - -We are also considering making a `useMutation` wrapper called `useCoderMutation`. If you have any thoughts or requests on how it should behave, please open an issue! - -## Why Tanstack Query (aka React Query)? - -ui.dev has a phenomenal [article](https://ui.dev/why-react-query) and [video](https://www.youtube.com/watch?v=OrliU0e09io) series that breaks things down more exhaustively, but in short, Tanstack Query: - -- Simplifies how API data is shared throughout an application -- Manages race conditions, canceling requests, and revalidating requests -- Provides a shared API for both queries and mutations - -### Problems with fetching via `useEffect` - -All functions returned by the Coder SDK maintain stable memory references for the entire lifetime of the SDK. In that sense, every one of these functions can be safely placed inside a `useEffect` dependency array to perform data fetching. In theory, `usEffect` can be used to trigger API calls, while the results (and their relevant loading/error/success states) can be stored via `useState`. - -In practice, however, this setup causes a lot of problems: - -- Potential race conditions if `useEffect` makes a different API call on each - render -- No easy way to retain state after a component unmounts. If a component unmounts and remounts, it needs to fetch new data – even if it just had the data moments ago. -- No easy ways to invalidate data and make new requests from the same dependencies -- No easy ways to trigger background re-fetches -- No automatic retry logic -- Making sure that the effect doesn't run too often can require careful memoization throughout several parts of the app, and can be hard to get right. -- No easy way to share the state of a single `useEffect` call across multiple components. Traditional React solutions would make you choose between duplicating the query state across each component, or putting a single shared state value in React Context – and introducing the risks of performance issues from too many app-wide re-renders. - -Fetching data has never been the hard part of calling APIs in React. It's always been figuring out how to cache it in a render-safe way that's been tricky. - -### Problems with fetching via `useAsync` - -While the [`useAsync` hook](https://github.com/streamich/react-use/blob/master/src/useAsync.ts) fares slightly better compared to using `useState` + `useEffect`, it still has a number of the same problems. In fact, it introduces problems. - -#### Problems fixed - -- No more race conditions - -#### Problems retained - -- Everything else from the `useEffect` section - -#### New problems - -- Even though `useAsync`'s API uses dependency arrays, by default, it is not eligible for the exhaustive deps ES Lint rule. This means that unless you update your ESLint rules, you have no safety nets for making sure that your effect logic runs the correct number of times. There are no protections against accidental typos. - -## Bring your own Query Client - -By default, `CoderProvider` manages and maintains its own `QueryClient` instance for managing all ongoing queries and mutations. This client is isolated from any other code, and especially if you are only using official Coder components, probably doesn't need to be touched. - -However, let's say you're using Tanstack Query for other purposes and would like all Coder requests to go through your query client instance instead. In that case, you can feed that instance into `CoderProvider`, and it will handle all requests through it instead - -```tsx - - - -``` - -### Warning - -Be sure to provide a custom query client if you put any custom components inside `CoderProvider`. Because of React Context rules, any calls to Tanstack APIs (including the `useCoderQuery` convenience wrapper) will go through the default `CoderProvider` query client, rather than your custom client. - -```tsx -const customQueryClient = new QueryClient(); - -function YourCustomComponent() { - const client = useQueryClient(); - console.log(client); - - return

Here's my content!

; -} - - - {/* When mounted here, component receives customQueryClient */} - - - {/* - * Forgot to thread customQueryClient into CoderProvider. All children will - * receive the default query client that CoderProvider maintains - */} - - {/* - * Exact same component definition, but the location completely changes the - * client that gets accessed - */} - - -; -``` - -## Sharing data between different queries and mutations - -Internally, the official components for the Coder plugin use one shared query key prefix value (`CODER_QUERY_KEY_PREFIX`) for every single query and mutation. This ensures that all Coder-based queries share a common root, and can be easily vacated from the query cache in response to certain events like the user unlinking their Coder account (basically "logging out"). - -This same prefix is exported to users of the plugin. When using the Coder SDK with Tanstack Query, it is **strongly, strongly** recommended that you use this same query prefix. It lets custom components and Coder components share the same underlying data (reducing the total number of requests). But the plugin is also set up to monitor queries with this prefix, so it can automate cache management and has more ways of detecting expired auth tokens. - -## Performing queries - -You have two main options for performing queries: - -- `useQuery` -- `useCoderQuery` - -### `useQuery` - -`useQuery` offers the most flexible options overall, and can be a great solution when you don't mind wiring things up manually. - -#### Example - -```tsx -function WorkspacesList() { - // Pretend that useDebouncedValue exists and exposes a version of searchFilter - // that updates on a delay - const [searchFilter, setSearchFilter] = useState('owner:me'); - const debouncedSearchFilter = useDebouncedValue({ - value: searchFilter, - debounceDelayMs: 500, - }); - - const { isAuthenticated } = useCoderAuth(); - const sdk = useCoderSdk(); - - // The type of query.data is automatically inferred to be of type - // (undefined | Workspace[]) based on the return type of queryFn - const query = useQuery({ - queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces', debouncedSearchFilter], - queryFn: () => sdk.getWorkspaces({ q: debouncedSearchFilter }), - enabled: isAuthenticated, - }); - - return ( - <> - - - {query.error instanceof Error && ( -

Encountered the following error: {query.error.message}

- )} - - {query.isLoading &&

Loading…

} - -
    - {query.data?.map(workspace => ( - - ))} -
- - ); -} -``` - -#### Pros - -- Gives you the most direct control over caching data using a declarative API -- Fine-tuned to a mirror sheen – plays well with all React rendering behavior out of the box. - -#### Cons - -- Requires an additional import for `useCoderSdk` at a minimum, and usually requires an import for `useCoderAuth` -- The process of wiring up auth to all properties in the hook can be confusing. The Coder SDK throws when it makes a request that isn't authenticated, but you probably want to disable those requests altogether. -- While the `queryKey` property is less confusing overall, it is basically a `useEffect` dependency array. Dependency arrays can be confusing and hard to wire up correctly - -### `useCoderQuery` - -`useCoderQuery` is a convenience wrapper over `useQuery`, and can basically be thought of as a pre-wired combo of `useQuery`, `useCoderAuth`, and `useCoderSdk`. - -#### Example - -```tsx -function WorkspacesList() { - // Pretend that we have the same useDebouncedValue from the useQuery example - // above - const [searchFilter, setSearchFilter] = useState('owner:me'); - const debouncedSearchFilter = useDebouncedValue({ - value: searchFilter, - debounceDelayMs: 500, - }); - - // Like with useQuery, the type of query.data is automatically inferred to be - // of type (undefined | Workspace[]) based on the return type of queryFn - const query = useCoderQuery({ - queryKey: ['workspaces', debouncedSearchFilter], - queryFn: ({ sdk }) => sdk.getWorkspaces({ q: debouncedSearchFilter }), - }); - - return ( - <> - - - {query.error instanceof Error && ( -

Encountered the following error: {query.error.message}

- )} - - {query.isLoading &&

Loading…

} - -
    - {query.data?.map(workspace => ( - - ))} -
- - ); -} -``` - -#### Pros - -- Reduces the total number of imports needed to get query logic set up, and pre-configures values to be more fool-proof. The Coder SDK is automatically patched into Tanstack Query's `queryFn` function context. -- Ensures that all Coder-based queries share the same query prefix, and are correctly evicted when the user unlinks their Coder account -- Automatically prohibits some deprecated properties from Tanstack Query v4 (using one of them is treated as a type error). This means guaranteed forwards-compatibility with Tanstack Query v5 - -#### Cons - -- Introduces extra overhead to ensure that queries are fully locked down when the user is not authenticated – even if they're not needed for every use case -- Doesn't offer quite as much fine-grained control compared to `useQuery` -- Does not simplify process of connecting Coder queries and mutations, because a `useCoderMutation` hook does not exist (yet) -- `queryKey` is slightly more confusing, because a new value is implicitly appended to the beginning of the array - -## Performing mutations - -We do not currently offer any convenience wrappers over `useMutation`. The only way to perform them is directly through the Tanstack API. - -However, not much changes when using it together with `useCoderQuery`: - -```tsx -type Props = Readonly<{ - workspaceId: string; -}>; - -function ExampleComponent({ workspaceId }: Props) { - // Unfortunately, the SDK does still need to be brought in for mutations. - // One of useCoderQuery's perks (automatic SDK injection via queryFn) goes - // away slightly. - const sdk = useCoderSdk(); - - // Same goes for Coder auth; needs to be brought in manually for mutations - const { isAuthenticated } = useCoderAuth(); - - // useCoderQuery still automatically handles wiring up auth logic to all - // relevant query option properties and auto-prefixes the query key - const workspaceQuery = useCoderQuery({ - queryKey: ['workspace', workspaceId], - - // How you access the SDK doesn't matter at this point because there's - // already an SDK in scope - queryFn: () => sdk.getWorkspace(workspaceId), - }); - - const queryClient = useQueryClient(); - const deleteWorkspaceMutation = useMutation({ - mutationFn: () => { - // useMutation does not expose an enabled property. You can fail fast by throwing when the user isn't authenticated - if (!isAuthenticated) { - throw new Error('Unable to complete request - not authenticated'); - } - - // Even if you forget to fail fast, the SDK method will throw eventually - // because the Coder deployment will respond with an error status - return sdk.deleteWorkspace(workspaceId); - }, - - // Need to make sure that the cache is invalidated after the workspace is - // definitely deleted - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['workspace', workspaceId], - }); - }, - }); - - return ( - <> - {deleteWorkspaceMutation.isSuccess && ( -

Workspace deleted successfully

- )} - - {workspaceQuery.data !== undefined && ( -
-

{workspaceQuery.data.name}

-

- Workspace is {workspaceQuery.data.health.healthy ? '' : 'not '}{' '} - healthy. -

-
- )} - - ); -} -``` From 21b4b7f97243237aa7b2c3ea1058371c9892d4c4 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 11 Jun 2024 14:23:14 +0000 Subject: [PATCH 80/94] fix: remove duplicate destructuring --- .../src/components/CoderProvider/CoderProvider.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx index 4111f224..079e1f38 100644 --- a/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx +++ b/plugins/backstage-plugin-coder/src/components/CoderProvider/CoderProvider.tsx @@ -47,7 +47,6 @@ export const CoderProvider = ({ appConfig, fallbackAuthUiMode = 'restrained', queryClient = defaultClient, - fallbackAuthUiMode = 'restrained', }: CoderProviderProps) => { return ( From 7c03d2738b28272872d8c7541f4269c39ec8835b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Tue, 11 Jun 2024 16:35:54 +0000 Subject: [PATCH 81/94] fix: update duplicate exports --- plugins/backstage-plugin-coder/src/plugin.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/plugins/backstage-plugin-coder/src/plugin.ts b/plugins/backstage-plugin-coder/src/plugin.ts index c2fef24a..d165c36f 100644 --- a/plugins/backstage-plugin-coder/src/plugin.ts +++ b/plugins/backstage-plugin-coder/src/plugin.ts @@ -199,12 +199,6 @@ export { useCoderQuery } from './hooks/reactQueryWrappers'; // Deliberately renamed so that end users don't have to be aware that there are // two different versions of the auth hook export { useEndUserCoderAuth as useCoderAuth } from './components/CoderProvider/CoderAuthProvider'; -export { useCoderQuery } from './hooks/reactQueryWrappers'; - -/** - * General constants - */ -export { CODER_QUERY_KEY_PREFIX } from './api/queryOptions'; /** * General constants From 6b58a89f84e932cecfb001c6a2527d57bda1d376 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 12 Jun 2024 20:06:32 +0000 Subject: [PATCH 82/94] fix: update semver message --- plugins/backstage-plugin-coder/docs/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/README.md b/plugins/backstage-plugin-coder/docs/README.md index a3084c31..d9cc9d8b 100644 --- a/plugins/backstage-plugin-coder/docs/README.md +++ b/plugins/backstage-plugin-coder/docs/README.md @@ -1,8 +1,8 @@ # Plugin API Reference – Coder for Backstage -For users who need more information about how to extend and modify the Coder plugin. For general setup, please see our main [README](../README.md). +The Coder plugin for Backstage does follow semantic versioning. -All documentation reflects version `v0.2.1` of the plugin. Note that breaking API changes may continue to happen for minor versions until the plugin reaches version `v1.0.0`. +For users who need more information about how to extend and modify the Coder plugin. For general setup, please see our main [README](../README.md). ## Documentation directory From 3c647e9059cad6195f58e40a917325507090e5be Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 12 Jun 2024 20:11:44 +0000 Subject: [PATCH 83/94] fix: remove useEffect comparison column --- .../docs/guides/coder-api.md | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api.md b/plugins/backstage-plugin-coder/docs/guides/coder-api.md index 2a3d3075..c3a3c0bb 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api.md @@ -46,17 +46,17 @@ We highly recommend **not** fetching with `useState` + `useEffect`, or with `use ### Comparing query caching strategies -| | `useState` + `useEffect` | `useAsync` | `useQuery` | `useCoderQuery` | -| ------------------------------------------------------------------ | ------------------------ | ---------- | ---------- | --------------- | -| Automatically handles race conditions | 🚫 | ✅ | ✅ | ✅ | -| Can retain state after component unmounts | 🚫 | 🚫 | ✅ | ✅ | -| Easy, on-command query invalidation | 🚫 | 🚫 | ✅ | ✅ | -| Automatic retry logic when a query fails | 🚫 | 🚫 | ✅ | ✅ | -| Less need to fight dependency arrays | 🚫 | 🚫 | ✅ | ✅ | -| Easy to share state for sibling components | 🚫 | 🚫 | ✅ | ✅ | -| Pre-wired to Coder auth logic | 🚫 | 🚫 | 🚫 | ✅ | -| Can consume Coder API directly from query function | 🚫 | 🚫 | 🚫 | ✅ | -| Automatically groups Coder-related queries by prefixing query keys | 🚫 | 🚫 | 🚫 | ✅ | +| | `useAsync` | `useQuery` | `useCoderQuery` | +| ------------------------------------------------------------------ | ---------- | ---------- | --------------- | +| Automatically handles race conditions | ✅ | ✅ | ✅ | +| Can retain state after component unmounts | 🚫 | ✅ | ✅ | +| Easy, on-command query invalidation | 🚫 | ✅ | ✅ | +| Automatic retry logic when a query fails | 🚫 | ✅ | ✅ | +| Less need to fight dependency arrays | 🚫 | ✅ | ✅ | +| Easy to share state for sibling components | 🚫 | ✅ | ✅ | +| Pre-wired to Coder auth logic | 🚫 | 🚫 | ✅ | +| Can consume Coder API directly from query function | 🚫 | 🚫 | ✅ | +| Automatically groups Coder-related queries by prefixing query keys | 🚫 | 🚫 | ✅ | ## Authentication From a37d9c747bdfe560fe24527a5e9fc7636e19523c Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 12 Jun 2024 20:26:38 +0000 Subject: [PATCH 84/94] fix: move custom query client into advanced section --- .../docs/guides/coder-api-advanced.md | 41 +++++++++++++++++++ .../docs/guides/coder-api.md | 9 ---- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md b/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md index ae9ad929..76c399e0 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md @@ -26,3 +26,44 @@ There are three values that can be set for the mode: - `restrained` (default) - The auth fallback will only display if the user is not authenticated, and there would be no other way for the user to add their auth info. - `assertive` - The auth fallback will always display when the user is not authenticated, regardless of what Coder component are on-screen. But the fallback will **not** appear if the user is authenticated. - `hidden` - The auth fallback will never appear under any circumstances. Useful if you want to create entirely custom components and don't mind wiring your auth logic manually via `useCoderAuth`. + +## Connecting a custom query client to the Coder plugin + +By default, the Coder plugin uses and manages its own query client. This works perfectly well if you aren't using React Query for any other purposes, but if you are using it throughout your Backstage deployment, it can cause issues around redundant state (e.g., not all cached data being vacated when the user logs out). + +To prevent this, you will need to do two things: + +1. Pass in your custom React Query query client into the `CoderProvider` component +2. "Group" your queries with the Coder query key prefix + +```tsx +const yourCustomQueryClient = new QueryClient(); + + + + + +// Ensure that all queries have the correct query key prefix +import { useQuery } from "@tanstack/react-react-query"; +import { CODER_QUERY_KEY_PREFIX, useCoderQuery} from "@coder/backstage-plugin-coder"; + +function CustomComponent () { + const query1 = useQuery({ + queryKey: [CODER_QUERY_KEY_PREFIX, "workspaces"] + queryFn () => { + // Get workspaces here + } + }); + + // useCoderQuery automatically prefixes all query keys with + // CODER_QUERY_KEY_PREFIX if it's not already the first value of the array + const query2 = useCoderQuery({ + queryKey: ["workspaces"], + queryFn () => { + // Get workspaces here + } + }) + + return
+} +``` diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api.md b/plugins/backstage-plugin-coder/docs/guides/coder-api.md index c3a3c0bb..f58a0342 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api.md @@ -68,15 +68,6 @@ Once the user has been authenticated, all Coder API functions will become availa \* This behavior can be disabled. Please see our [advanced API guide](./coder-api-advanced.md) for more information. -## Connecting a custom query client to the Coder plugin - -By default, the Coder plugin uses and manages its own query client. This works perfectly well if you aren't using React Query for any other purposes, but if you are using it throughout your Backstage deployment, it can cause issues around redundant state (e.g., not all cached data being vacated when the user logs out). - -To prevent this, you will need to do two things: - -1. Pass in your custom React Query query client into the `CoderProvider` component -2. "Group" your queries with the Coder query key prefix - ### Passing in a custom query client The `CoderProvider` component accepts an optional `queryClient` prop. When provided, the Coder plugin will use this client for **all** queries (those made by the built-in Coder components, or any custom components that you put inside `CoderProvider`). From 8fef8742681ec878dcb97d515783534f9e069a3f Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 12 Jun 2024 20:40:34 +0000 Subject: [PATCH 85/94] fix: remove redundant examples --- .../docs/guides/coder-api.md | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api.md b/plugins/backstage-plugin-coder/docs/guides/coder-api.md index f58a0342..bb439c9b 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api.md @@ -82,29 +82,9 @@ const customQueryClient = new QueryClient(); ### Grouping queries with the Coder query key prefix -The plugin exposes a `CODER_QUERY_KEY_PREFIX` constant that you can use to group all Coder queries together for `useQuery` and `useQueryClient`. All queries made by official Coder components put this as the first value of their query key. The `useCoderQuery` convenience hook also automatically injects this constant at the beginning of all query keys (even if not explicitly added). +The plugin exposes a `CODER_QUERY_KEY_PREFIX` constant that you can use to group all Coder queries together for `useCoderQuery`, `useQuery` and `useQueryClient`. All queries made by official Coder components put this as the first value of their query key. The `useCoderQuery` convenience hook also automatically injects this constant at the beginning of all query keys (even if not explicitly added). ```tsx -// Starting all query keys with the constant "groups" them together -const coderApi = useCoderApi(); -const workspacesQuery = useQuery({ - queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces'], - queryFn: () => - coderApi.getWorkspaces({ - limit: 10, - }), -}); - -const workspacesQuery2 = useCoderQuery({ - // The query key will automatically have CODER_QUERY_KEY_PREFIX added to the - // beginning - queryKey: ['workspaces'], - queryFn: ({ coderApi }) => - coderApi.getWorkspaces({ - limit: 10, - }), -}); - // All grouped queries can be invalidated at once from the query client const queryClient = useQueryClient(); const invalidateAllCoderQueries = () => { From 2ab5836d70ef19d7449638b301f8ef20b828076b Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 12 Jun 2024 21:02:39 +0000 Subject: [PATCH 86/94] fix: update hook overview --- .../docs/guides/coder-api.md | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api.md b/plugins/backstage-plugin-coder/docs/guides/coder-api.md index bb439c9b..d308de95 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api.md @@ -12,28 +12,14 @@ Please ensure that you have the Coder plugin fully installed before proceeding. ### Important hooks for using the Coder API -There are a few React hooks that are needed to interact with the Coder API. These can be split into three categories: React Query hooks, core plugin hooks, and convenience hooks. - -#### React Query hooks - -The Coder plugin uses [React Query/TanStack Query v4](https://tanstack.com/query/v4/docs/framework/react/overview) for all of its data caching. We recommend that you use it for your own data caching, because of the sheer amount of headaches it can spare you. - -There are three main hooks that you will likely need: - -- `useQuery` - Query and cache data -- `useMutation` - Perform mutations on an API resource -- `useQueryClient` - Coordinate queries and mutations - -#### Core plugin hooks - -These are hooks that provide direct access to various parts of the Coder API. +The Coder plugin exposes three (soon to be four) main hooks for accessing Coder plugin state and making queries/mutations - `useCoderApi` - Exposes an object with all available Coder API methods. For the most part, there is no exposed state on this object; you can consider it a "function bucket". - `useCoderAuth` - Provides methods and state values for interacting with your current Coder auth session from within Backstage. +- `useCoderQuery` - Makes it simple to query data from the Coder API and share it throughout your application +- `useCoderMutation` (coming soon) - Makes it simple to mutate data via the Coder API -#### Convenience hooks - -- `useCoderQuery` - Simplifies wiring up `useQuery`, `useCoderApi`, and `useCoderAuth` +Internally, the Coder plugin uses [React Query/TanStack Query v4](https://tanstack.com/query/v4/docs/framework/react/overview). In fact, `useCoderQuery` and `useCoderMutation` are simply wrappers over `useQuery` and `useMutation`. If you ever need to coordinate queries and mutations, you can use `useQueryClient` from React Query. ## Recommendations for accessing the API From 51de0c92766c238ff728301c3010fef45a9f6da1 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 12 Jun 2024 21:16:24 +0000 Subject: [PATCH 87/94] fix: update formatting for advanced file --- .../docs/guides/coder-api-advanced.md | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md b/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md index 76c399e0..82b0065f 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md @@ -41,29 +41,32 @@ const yourCustomQueryClient = new QueryClient(); - +; // Ensure that all queries have the correct query key prefix -import { useQuery } from "@tanstack/react-react-query"; -import { CODER_QUERY_KEY_PREFIX, useCoderQuery} from "@coder/backstage-plugin-coder"; +import { useQuery } from '@tanstack/react-react-query'; +import { + CODER_QUERY_KEY_PREFIX, + useCoderQuery, +} from '@coder/backstage-plugin-coder'; -function CustomComponent () { +function CustomComponent() { const query1 = useQuery({ - queryKey: [CODER_QUERY_KEY_PREFIX, "workspaces"] - queryFn () => { + queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces'], + queryFn: () => { // Get workspaces here - } + }, }); // useCoderQuery automatically prefixes all query keys with // CODER_QUERY_KEY_PREFIX if it's not already the first value of the array const query2 = useCoderQuery({ - queryKey: ["workspaces"], - queryFn () => { + queryKey: ['workspaces'], + queryFn: () => { // Get workspaces here - } - }) + }, + }); - return
+ return
Main component content
; } ``` From 0bb2b92686163c55d8293d31bb1bac635bb1b3dd Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Wed, 12 Jun 2024 21:56:39 +0000 Subject: [PATCH 88/94] fix: regorganize prefix section --- .../docs/guides/coder-api.md | 76 ++++++++----------- 1 file changed, 33 insertions(+), 43 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api.md b/plugins/backstage-plugin-coder/docs/guides/coder-api.md index d308de95..3a4dfc92 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api.md @@ -16,19 +16,47 @@ The Coder plugin exposes three (soon to be four) main hooks for accessing Coder - `useCoderApi` - Exposes an object with all available Coder API methods. For the most part, there is no exposed state on this object; you can consider it a "function bucket". - `useCoderAuth` - Provides methods and state values for interacting with your current Coder auth session from within Backstage. -- `useCoderQuery` - Makes it simple to query data from the Coder API and share it throughout your application -- `useCoderMutation` (coming soon) - Makes it simple to mutate data via the Coder API +- `useCoderQuery` - Makes it simple to query data from the Coder API and share it throughout your application. Can access the Coder API without needing you to call `useCoderApi`. +- `useCoderMutation` (coming soon) - Makes it simple to mutate data via the Coder API. Can access the Coder API without needing you to call `useCoderApi`. -Internally, the Coder plugin uses [React Query/TanStack Query v4](https://tanstack.com/query/v4/docs/framework/react/overview). In fact, `useCoderQuery` and `useCoderMutation` are simply wrappers over `useQuery` and `useMutation`. If you ever need to coordinate queries and mutations, you can use `useQueryClient` from React Query. +Internally, the Coder plugin uses [React Query/TanStack Query v4](https://tanstack.com/query/v4/docs/framework/react/overview). In fact, `useCoderQuery` and `useCoderMutation` are simply wrappers over `useQuery` and `useMutation`. Both simplify the process of wiring up the hooks' various properties to the Coder auth, while exposing a more convenient way of accessing the Coder API object. + +If you ever need to coordinate queries and mutations, you can use `useQueryClient` from React Query - no custom plugin-specific hook needed. + +### Grouping queries with the Coder query key prefix + +The plugin exposes a `CODER_QUERY_KEY_PREFIX` constant that you can use to group all Coder queries together for `useCoderQuery` and `useQueryClient` (and `useQuery` if you need to escape out). All queries made by official Coder components put this as the first value of their query key. The `useCoderQuery` convenience hook also automatically injects this constant at the beginning of all query keys (if it isn't already there). + +```tsx +// All grouped queries can be invalidated at once from the query client +const queryClient = useQueryClient(); +const invalidateAllCoderQueries = () => { + queryClient.invalidateQuery({ + queryKey: [CODER_QUERY_KEY_PREFIX], + }); +}; + +// When the user unlinks their session token, all queries grouped under +// CODER_QUERY_KEY_PREFIX are vacated from the active query cache +function LogOutButton() { + const { unlinkToken } = useCoderAuth(); + + return ( + + ); +} +``` ## Recommendations for accessing the API -1. If querying data, prefer `useCoderQuery`. It automatically wires up all auth logic to React Query (which includes pausing queries if the user is not authenticated). It also lets you access the Coder API via its query function. `useQuery` is also a good escape hatch if `useCoderQuery` doesn't meet your needs, but it requires more work to wire up correctly. +1. If querying data, prefer `useCoderQuery`. It automatically wires up all auth logic to React Query (which includes pausing queries if the user is not authenticated). It also lets you access the Coder API via its query function. `useQuery` works as an escape hatch if `useCoderQuery` doesn't meet your needs, but it requires more work to wire up correctly. 2. If mutating data, you will need to call `useMutation`, `useQueryClient`, and `useCoderApi` in tandem\*. The plugin exposes a `CODER_QUERY_KEY_PREFIX` constant that you can use to group all Coder queries together. We highly recommend **not** fetching with `useState` + `useEffect`, or with `useAsync`. Both face performance issues when trying to share state. See [ui.dev](https://www.ui.dev/)'s wonderful [_The Story of React Query_ video](https://www.youtube.com/watch?v=OrliU0e09io) for more info on some of the problems they face. -\* A `useCoderMutation` hook is in the works to simplify wiring these up. +\* `useCoderMutation` can be used once it is available. ### Comparing query caching strategies @@ -54,44 +82,6 @@ Once the user has been authenticated, all Coder API functions will become availa \* This behavior can be disabled. Please see our [advanced API guide](./coder-api-advanced.md) for more information. -### Passing in a custom query client - -The `CoderProvider` component accepts an optional `queryClient` prop. When provided, the Coder plugin will use this client for **all** queries (those made by the built-in Coder components, or any custom components that you put inside `CoderProvider`). - -```tsx -const customQueryClient = new QueryClient(); - - - -; -``` - -### Grouping queries with the Coder query key prefix - -The plugin exposes a `CODER_QUERY_KEY_PREFIX` constant that you can use to group all Coder queries together for `useCoderQuery`, `useQuery` and `useQueryClient`. All queries made by official Coder components put this as the first value of their query key. The `useCoderQuery` convenience hook also automatically injects this constant at the beginning of all query keys (even if not explicitly added). - -```tsx -// All grouped queries can be invalidated at once from the query client -const queryClient = useQueryClient(); -const invalidateAllCoderQueries = () => { - queryClient.invalidateQuery({ - queryKey: [CODER_QUERY_KEY_PREFIX], - }); -}; - -// When the user unlinks their session token, all queries grouped under -// CODER_QUERY_KEY_PREFIX are vacated from the active query cache -function LogOutButton() { - const { unlinkToken } = useCoderAuth(); - - return ( - - ); -} -``` - ## Component examples Here are some full code examples showcasing patterns you can bring into your own codebase. From f4d05aa472e0d3268228dde968ed02eadbb32918 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 13 Jun 2024 13:04:42 +0000 Subject: [PATCH 89/94] chore: finish v3 of reorganization --- .../docs/guides/coder-api.md | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api.md b/plugins/backstage-plugin-coder/docs/guides/coder-api.md index 3a4dfc92..0b930eb1 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api.md @@ -14,15 +14,61 @@ Please ensure that you have the Coder plugin fully installed before proceeding. The Coder plugin exposes three (soon to be four) main hooks for accessing Coder plugin state and making queries/mutations -- `useCoderApi` - Exposes an object with all available Coder API methods. For the most part, there is no exposed state on this object; you can consider it a "function bucket". - `useCoderAuth` - Provides methods and state values for interacting with your current Coder auth session from within Backstage. -- `useCoderQuery` - Makes it simple to query data from the Coder API and share it throughout your application. Can access the Coder API without needing you to call `useCoderApi`. -- `useCoderMutation` (coming soon) - Makes it simple to mutate data via the Coder API. Can access the Coder API without needing you to call `useCoderApi`. + + ```tsx + function SessionTokenInputForm() { + const [sessionTokenDraft, setSessionTokenDraft] = useState(''); + const coderAuth = useCoderAuth(); + + const onSubmit = (event: FormEvent) => { + coderAuth.registerNewToken(sessionToken); + setSessionTokenDraft(''); + }; + + return ( +
+ + + ); + } + ``` + +- `useCoderQuery` - Makes it simple to query data from the Coder API and share it throughout your application. + + ```tsx + function WorkspacesList() { + // Return type matches the return type of React Query's useQuerys + const workspacesQuery = useCoderQuery({ + queryKey: ['workspaces'], + queryFn: ({ coderApi }) => coderApi.getWorkspaces({ limit: 5 }), + }); + } + ``` + +- `useCoderMutation` (coming soon) - Makes it simple to mutate data via the Coder API. +- `useCoderApi` - Exposes an object with all available Coder API methods. None of the state in this object is tied to React render logic - it can be treated as a "function bucket". Once `useCoderMutation` is available, the main value of this hook will be as an escape hatch in the rare situations where `useCoderQuery` and `useCoderMutation` don't meet your needs. Under the hood, both `useCoderQuery` and `useCoderMutation` receive their `coderApi` context value from this hook. + + ```tsx + function HealthCheckComponent() { + const coderApi = useCoderApi(); + + const processWorkspaces = async () => { + const workspacesResponse = await coderApi.getWorkspaces({ + limit: 10, + }); + + processHealthChecks(workspacesResponse.workspaces); + }; + } + ``` Internally, the Coder plugin uses [React Query/TanStack Query v4](https://tanstack.com/query/v4/docs/framework/react/overview). In fact, `useCoderQuery` and `useCoderMutation` are simply wrappers over `useQuery` and `useMutation`. Both simplify the process of wiring up the hooks' various properties to the Coder auth, while exposing a more convenient way of accessing the Coder API object. If you ever need to coordinate queries and mutations, you can use `useQueryClient` from React Query - no custom plugin-specific hook needed. +The bottom of this document has examples of both queries and mutations. + ### Grouping queries with the Coder query key prefix The plugin exposes a `CODER_QUERY_KEY_PREFIX` constant that you can use to group all Coder queries together for `useCoderQuery` and `useQueryClient` (and `useQuery` if you need to escape out). All queries made by official Coder components put this as the first value of their query key. The `useCoderQuery` convenience hook also automatically injects this constant at the beginning of all query keys (if it isn't already there). @@ -121,6 +167,8 @@ function RecentAuditLogsList() { ## Creating a new workspace +Note: this example showcases how to perform mutations with `useMutation`. The example will be updated once `useCoderMutation` is available. + ```tsx import React, { type FormEvent, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; From d010cb693c85cfb02ed4c0bd5d7e200c8c1ebba2 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 13 Jun 2024 13:33:26 +0000 Subject: [PATCH 90/94] chore: reorganize text content one last time --- .../docs/guides/coder-api.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api.md b/plugins/backstage-plugin-coder/docs/guides/coder-api.md index 0b930eb1..22dfdda1 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api.md @@ -71,7 +71,18 @@ The bottom of this document has examples of both queries and mutations. ### Grouping queries with the Coder query key prefix -The plugin exposes a `CODER_QUERY_KEY_PREFIX` constant that you can use to group all Coder queries together for `useCoderQuery` and `useQueryClient` (and `useQuery` if you need to escape out). All queries made by official Coder components put this as the first value of their query key. The `useCoderQuery` convenience hook also automatically injects this constant at the beginning of all query keys (if it isn't already there). +The plugin exposes a `CODER_QUERY_KEY_PREFIX` constant that you can use to group all Coder queries. `useCoderQuery` automatically injects this value into all its `queryKey` arrays. However, if you need to escape out with `useQuery`, you can import the constant and manually include it as the first value of your query key. + +```tsx +const customQuery = useQuery({ + queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces'], + queryFn: () => { + // Your custom API logic + }, +}); +``` + +In addition, all official Coder plugin components use this prefix internally. ```tsx // All grouped queries can be invalidated at once from the query client @@ -98,11 +109,11 @@ function LogOutButton() { ## Recommendations for accessing the API 1. If querying data, prefer `useCoderQuery`. It automatically wires up all auth logic to React Query (which includes pausing queries if the user is not authenticated). It also lets you access the Coder API via its query function. `useQuery` works as an escape hatch if `useCoderQuery` doesn't meet your needs, but it requires more work to wire up correctly. -2. If mutating data, you will need to call `useMutation`, `useQueryClient`, and `useCoderApi` in tandem\*. The plugin exposes a `CODER_QUERY_KEY_PREFIX` constant that you can use to group all Coder queries together. +2. If mutating data, you will need to call `useMutation`, `useQueryClient`, and `useCoderApi` in tandem\*. We highly recommend **not** fetching with `useState` + `useEffect`, or with `useAsync`. Both face performance issues when trying to share state. See [ui.dev](https://www.ui.dev/)'s wonderful [_The Story of React Query_ video](https://www.youtube.com/watch?v=OrliU0e09io) for more info on some of the problems they face. -\* `useCoderMutation` can be used once it is available. +\* `useCoderMutation` can be used instead of all three once that hook is available. ### Comparing query caching strategies From adad952c700dbe780fa5bef6c24f541927e43e36 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 13 Jun 2024 13:39:05 +0000 Subject: [PATCH 91/94] chore: group prefix examples --- .../docs/guides/coder-api.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api.md b/plugins/backstage-plugin-coder/docs/guides/coder-api.md index 22dfdda1..6c9ead2a 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api.md @@ -73,15 +73,6 @@ The bottom of this document has examples of both queries and mutations. The plugin exposes a `CODER_QUERY_KEY_PREFIX` constant that you can use to group all Coder queries. `useCoderQuery` automatically injects this value into all its `queryKey` arrays. However, if you need to escape out with `useQuery`, you can import the constant and manually include it as the first value of your query key. -```tsx -const customQuery = useQuery({ - queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces'], - queryFn: () => { - // Your custom API logic - }, -}); -``` - In addition, all official Coder plugin components use this prefix internally. ```tsx @@ -93,6 +84,14 @@ const invalidateAllCoderQueries = () => { }); }; +// The prefix is only needed when NOT using useCoderQuery +const customQuery = useQuery({ + queryKey: [CODER_QUERY_KEY_PREFIX, 'workspaces'], + queryFn: () => { + // Your custom API logic + }, +}); + // When the user unlinks their session token, all queries grouped under // CODER_QUERY_KEY_PREFIX are vacated from the active query cache function LogOutButton() { From 5012b010194f6040b5e2a8e38608846801a37a20 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Thu, 13 Jun 2024 16:14:44 +0000 Subject: [PATCH 92/94] chore: reorganize directory readme --- plugins/backstage-plugin-coder/docs/README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/plugins/backstage-plugin-coder/docs/README.md b/plugins/backstage-plugin-coder/docs/README.md index d9cc9d8b..95019233 100644 --- a/plugins/backstage-plugin-coder/docs/README.md +++ b/plugins/backstage-plugin-coder/docs/README.md @@ -1,10 +1,10 @@ -# Plugin API Reference – Coder for Backstage +# Documentation Directory – `backstage-plugin-coder` v0.3.0 -The Coder plugin for Backstage does follow semantic versioning. +This document lists core information for the Backstage Coder plugin. It is intended for users who have already set up the plugin and are looking to take it further. -For users who need more information about how to extend and modify the Coder plugin. For general setup, please see our main [README](../README.md). +For general setup, please see our [main README](../README.md). -## Documentation directory +## Documentation listing ### Guides @@ -16,3 +16,7 @@ For users who need more information about how to extend and modify the Coder plu - [Components](./api-reference/components.md) - [Custom React hooks](./api-reference/hooks.md) - [Important types](./api-reference/types.md) + +## Notes about semantic versioning + +We fully intend to follow semantic versioning with the Coder plugin for Backstage. Expect some pain points as we figure out the right abstractions needed to hit version 1, but we will try to minimize breaking changes as much as possible as the library gets ironed out. From b1b05d8da22f4fb8b8f3d7ddc8f66ab758777b68 Mon Sep 17 00:00:00 2001 From: Parkreiner Date: Mon, 17 Jun 2024 19:41:04 +0000 Subject: [PATCH 93/94] chore: add image of auth fallback --- .../docs/guides/coder-api-advanced.md | 2 +- .../screenshots/auth-fallback.png | Bin 0 -> 397274 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 plugins/backstage-plugin-coder/screenshots/auth-fallback.png diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md b/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md index 82b0065f..fb90ebe6 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api-advanced.md @@ -9,7 +9,7 @@ By default, `CoderProvider` is configured to display a fallback auth UI componen 1. The user is not authenticated 2. There are no official Coder components are being rendered to the screen. -<-- Add image of fallback --> +The Coder auth fallback UI All official Coder plugin components are configured to let the user add auth information if the user isn't already authenticated, so the fallback component only displays when there would be no other way to add the information. diff --git a/plugins/backstage-plugin-coder/screenshots/auth-fallback.png b/plugins/backstage-plugin-coder/screenshots/auth-fallback.png new file mode 100644 index 0000000000000000000000000000000000000000..d5b817ccdfa7a3512dd3d73602661eb48906c664 GIT binary patch literal 397274 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

$WeS|F$MO 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 Y(zoV Date: Mon, 17 Jun 2024 15:43:58 -0400 Subject: [PATCH 94/94] chore: add video of auth functionality --- plugins/backstage-plugin-coder/docs/guides/coder-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/backstage-plugin-coder/docs/guides/coder-api.md b/plugins/backstage-plugin-coder/docs/guides/coder-api.md index 6c9ead2a..04e8d10d 100644 --- a/plugins/backstage-plugin-coder/docs/guides/coder-api.md +++ b/plugins/backstage-plugin-coder/docs/guides/coder-api.md @@ -132,7 +132,7 @@ We highly recommend **not** fetching with `useState` + `useEffect`, or with `use All API calls to **any** of the Coder API functions will fail if you have not authenticated yet. Authentication can be handled via any of the official Coder components that can be imported via the plugin. However, if there are no Coder components on the screen, the `CoderProvider` component will automatically\* inject a fallback auth button for letting the user add their auth info. -<-- Add video of auth flow with fallback button --> +https://github.com/coder/backstage-plugins/assets/28937484/0ece4410-36fc-4b32-9223-66f35953eeab Once the user has been authenticated, all Coder API functions will become available. When the user unlinks their auth token (effectively logging out), all cached queries that start with `CODER_QUERY_KEY_PREFIX` will automatically be vacated.