chore(site): Upgrade storybook to 7.0.4 (#7127)

This commit is contained in:
Bruno Quaresma
2023-04-13 21:36:20 +00:00
committed by GitHub
parent 085330ad96
commit 17bb1a1ff1
217 changed files with 2333 additions and 5784 deletions
+17
View File
@@ -0,0 +1,17 @@
import { firstOrItem } from "./array"
describe("array", () => {
describe("firstOrItem", () => {
it("returns null if empty array", () => {
expect(firstOrItem([], null)).toBeNull()
})
it("returns first item if array with more one item", () => {
expect(firstOrItem(["a", "b"], "c")).toEqual("a")
})
it("returns item if single item", () => {
expect(firstOrItem("c", "d")).toEqual("c")
})
})
})
+20
View File
@@ -0,0 +1,20 @@
/**
* Helper function that, given an array or a single item:
* - If an array with no elements, returns null
* - If an array with 1 or more elements, returns the first element
* - If a single item, returns that item
*/
export const firstOrItem = <T>(
itemOrItems: undefined | T | T[],
defaults: T,
): T => {
if (Array.isArray(itemOrItems)) {
return itemOrItems.length > 0 ? itemOrItems[0] : defaults
}
if (typeof itemOrItems === "undefined") {
return defaults
}
return itemOrItems
}
+23
View File
@@ -0,0 +1,23 @@
// Used to convert our theme colors to Hex since monaco theme only support hex colors
// From https://www.jameslmilner.com/posts/converting-rgb-hex-hsl-colors/
export function hslToHex(hsl: string): string {
const [h, s, l] = hsl
.replace("hsl(", "")
.replace(")", "")
.replaceAll("%", "")
.split(",")
.map(Number)
const hDecimal = l / 100
const a = (s * Math.min(hDecimal, 1 - hDecimal)) / 100
const f = (n: number) => {
const k = (n + h / 30) % 12
const color = hDecimal - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
// Convert to Hex and prefix with "0" if required
return Math.round(255 * color)
.toString(16)
.padStart(2, "0")
}
return `#${f(0)}${f(8)}${f(4)}`
}
+31
View File
@@ -0,0 +1,31 @@
import { combineClasses } from "./combineClasses"
const staticStyles = {
text: "MuiText",
success: "MuiText-Green",
warning: "MuiText-Red",
}
describe("combineClasses", () => {
it.each([
// Falsy
[undefined, undefined],
[{ [staticStyles.text]: false }, undefined],
[{ [staticStyles.text]: undefined }, undefined],
[[], undefined],
// Truthy
[{ [staticStyles.text]: true }, "MuiText"],
[
{ [staticStyles.text]: true, [staticStyles.warning]: true },
"MuiText MuiText-Red",
],
[[staticStyles.text], "MuiText"],
// Mixed
[{ [staticStyles.text]: true, [staticStyles.success]: false }, "MuiText"],
[[staticStyles.text, staticStyles.success], "MuiText MuiText-Green"],
])(`classNames(%p) returns %p`, (staticClasses, result) => {
expect(combineClasses(staticClasses)).toBe(result)
})
})
+47
View File
@@ -0,0 +1,47 @@
export const appendCSSString = (
cssString: string,
cssClass: string,
): string => {
if (cssString === "") {
return cssClass
}
return `${cssString} ${cssClass}`
}
/**
* @param classes May be an object or an array. When using an object, each key
* is a css class name and its associated value indicates whether it should be
* applied. When using an array, only truthy values are applied.
* @returns A string with each class that should be included. Classes are
* separated by two spaces. If no classes are included, returns `undefined`.
* @example
* combineClasses() // -> undefined
* combineClasses({ text: true }) // -> "text"
* combineClasses({ text: true, success: true }) // -> "text success"
* combineClasses({ text: true, success: false }) // -> "text"
* combineClasses([ "text", false, "" ]) // -> "text"
*/
export const combineClasses = (
classes:
| Array<string | false | undefined>
| Record<string, boolean | undefined> = {},
): string | undefined => {
let result = ""
if (Array.isArray(classes)) {
for (const cssClass of classes) {
if (cssClass) {
result = appendCSSString(result, cssClass)
}
}
} else {
for (const cssClass in classes) {
const useClass = classes[cssClass]
if (useClass) {
result = appendCSSString(result, cssClass)
}
}
}
return result.length > 0 ? result : undefined
}
+12
View File
@@ -0,0 +1,12 @@
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
dayjs.extend(relativeTime)
/**
* Returns a human-readable string describing the passing of time
* Broken into its own module for testing purposes
*/
export function createDayString(time: string): string {
return dayjs().to(dayjs(time))
}
+4
View File
@@ -0,0 +1,4 @@
export const delay = (ms: number): Promise<void> =>
new Promise((res) => {
setTimeout(res, ms)
})
+41
View File
@@ -0,0 +1,41 @@
import { useMemo } from "react"
import { DeploymentGroup, DeploymentOption } from "../api/types"
const deploymentOptions = (
options: DeploymentOption[],
...names: string[]
): DeploymentOption[] => {
const found: DeploymentOption[] = []
for (const name of names) {
const option = options.find((o) => o.name === name)
if (option) {
found.push(option)
} else {
throw new Error(`Deployment option ${name} not found`)
}
}
return found
}
export const useDeploymentOptions = (
options: DeploymentOption[],
...names: string[]
): DeploymentOption[] => {
return useMemo(() => deploymentOptions(options, ...names), [options, names])
}
export const deploymentGroupHasParent = (
group: DeploymentGroup | undefined,
parent: string,
): boolean => {
if (!group) {
return false
}
if (group.parent) {
return deploymentGroupHasParent(group.parent, parent)
}
if (group.name === parent) {
return true
}
return false
}
+13
View File
@@ -0,0 +1,13 @@
import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
import relativeTime from "dayjs/plugin/relativeTime"
dayjs.extend(duration)
dayjs.extend(relativeTime)
export const humanDuration = (
time: number,
durationUnitType?: duration.DurationUnitType | undefined,
) => {
return dayjs.duration(time, durationUnitType).humanize()
}
+21
View File
@@ -0,0 +1,21 @@
import { ellipsizeText } from "./ellipsizeText"
import { Nullable } from "./nullable"
describe("ellipsizeText", () => {
it.each([
[undefined, 10, undefined],
[null, 10, undefined],
["", 10, ""],
["Hello World", "Hello World".length, "Hello World"],
["Hello World", "Hello...".length, "Hello..."],
])(
`ellipsizeText(%p, %p) returns %p`,
(
str: Nullable<string>,
maxLength: number | undefined,
output: Nullable<string>,
) => {
expect(ellipsizeText(str, maxLength)).toBe(output)
},
)
})
+12
View File
@@ -0,0 +1,12 @@
import { Nullable } from "./nullable"
/** Truncates and ellipsizes text if it's longer than maxLength */
export const ellipsizeText = (
text: Nullable<string>,
maxLength = 80,
): string | undefined => {
if (typeof text !== "string") {
return
}
return text.length <= maxLength ? text : `${text.substr(0, maxLength - 3)}...`
}
+18
View File
@@ -0,0 +1,18 @@
import { dispatchCustomEvent, isCustomEvent } from "./events"
describe("events", () => {
describe("dispatchCustomEvent", () => {
it("dispatch a custom event", (done) => {
const eventDetail = { title: "Event title" }
window.addEventListener("eventType", (event) => {
if (isCustomEvent(event)) {
expect(event.detail).toEqual(eventDetail)
done()
}
})
dispatchCustomEvent("eventType", eventDetail)
})
})
})
+32
View File
@@ -0,0 +1,32 @@
/**
* Dispatches a custom event with descriptive type information.
*
* @param eventType a unique name defining the type of the event. e.g. `"coder:workspace:ready"`
* @param detail an optional payload accessible to an event listener.
* @param target an optional event target. Defaults to current `window`.
*/
export const dispatchCustomEvent = <D = unknown>(
eventType: string,
detail?: D,
target: EventTarget = window,
): CustomEvent<D> => {
const event = new CustomEvent<D>(eventType, { detail })
target.dispatchEvent(event)
return event
}
/** Annotates a custom event listener with descriptive type information. */
export type CustomEventListener<D = unknown> = (event: CustomEvent<D>) => void
/**
* Determines if an Event object is a CustomEvent.
*
* @remark this is especially necessary when an event originates from an iframe
* as `instanceof` will not match against another origin's prototype chain.
*/
export const isCustomEvent = <D = unknown>(
event: CustomEvent<D> | Event,
): event is CustomEvent<D> => {
return Boolean((event as CustomEvent).detail)
}
+127
View File
@@ -0,0 +1,127 @@
import {
existsFile,
FileTree,
getFileContent,
isFolder,
moveFile,
removeFile,
createFile,
traverse,
} from "./filetree"
test("createFile() set file into the file tree", () => {
let fileTree: FileTree = {
"main.tf": "terraform",
images: { "java.Dockerfile": "java dockerfile" },
}
fileTree = createFile(
"images/python.Dockerfile",
fileTree,
"python dockerfile",
)
expect((fileTree.images as FileTree)["python.Dockerfile"]).toEqual(
"python dockerfile",
)
})
test("getFileContent() return the file content from the file tree", () => {
const fileTree: FileTree = {
"main.tf": "terraform content",
images: { "java.Dockerfile": "java dockerfile" },
}
expect(getFileContent("images/java.Dockerfile", fileTree)).toEqual(
"java dockerfile",
)
})
test("removeFile() removes a file from a folder", () => {
let fileTree: FileTree = {
"main.tf": "terraform content",
images: {
"java.Dockerfile": "java dockerfile",
"python.Dockerfile": "python Dockerfile",
},
}
fileTree = removeFile("images/python.Dockerfile", fileTree)
const expectedFileTree = {
"main.tf": "terraform content",
images: {
"java.Dockerfile": "java dockerfile",
},
}
expect(expectedFileTree).toEqual(fileTree)
})
test("removeFile() removes a file from root", () => {
let fileTree: FileTree = {
"main.tf": "terraform content",
images: {
"java.Dockerfile": "java dockerfile",
"python.Dockerfile": "python Dockerfile",
},
}
fileTree = removeFile("main.tf", fileTree)
const expectedFileTree = {
images: {
"java.Dockerfile": "java dockerfile",
"python.Dockerfile": "python Dockerfile",
},
}
expect(expectedFileTree).toEqual(fileTree)
})
test("moveFile() moves a file from in file tree", () => {
let fileTree: FileTree = {
"main.tf": "terraform content",
images: {
"java.Dockerfile": "java dockerfile",
"python.Dockerfile": "python Dockerfile",
},
}
fileTree = moveFile(
"images/java.Dockerfile",
"other/java.Dockerfile",
fileTree,
)
const expectedFileTree = {
"main.tf": "terraform content",
images: {
"python.Dockerfile": "python Dockerfile",
},
other: {
"java.Dockerfile": "java dockerfile",
},
}
expect(fileTree).toEqual(expectedFileTree)
})
test("existsFile() returns if there is or not a file", () => {
const fileTree: FileTree = {
"main.tf": "terraform content",
images: { "java.Dockerfile": "java dockerfile" },
}
expect(existsFile("images/java.Dockerfile", fileTree)).toBeTruthy()
expect(existsFile("no-existent-path", fileTree)).toBeFalsy()
})
test("isFolder() returns when a path is a folder or not", () => {
const fileTree: FileTree = {
"main.tf": "terraform content",
images: { "java.Dockerfile": "java dockerfile" },
}
expect(isFolder("images", fileTree)).toBeTruthy()
expect(isFolder("images/java.Dockerfile", fileTree)).toBeFalsy()
})
test("traverse() go trough all the file tree files", () => {
const fileTree: FileTree = {
"main.tf": "terraform content",
images: { "java.Dockerfile": "java dockerfile" },
}
const filePaths: string[] = []
traverse(fileTree, (_content, _filename, fullPath) => {
filePaths.push(fullPath)
})
const expectedFilePaths = ["main.tf", "images", "images/java.Dockerfile"]
expect(filePaths).toEqual(expectedFilePaths)
})
+99
View File
@@ -0,0 +1,99 @@
import set from "lodash/set"
import has from "lodash/has"
import unset from "lodash/unset"
import get from "lodash/get"
export type FileTree = {
[key: string]: FileTree | string
}
export const createFile = (
path: string,
fileTree: FileTree,
value: string,
): FileTree => {
if (existsFile(path, fileTree)) {
throw new Error(`File ${path} already exists`)
}
const pathError = validatePath(path, fileTree)
if (pathError) {
throw new Error(pathError)
}
return set(fileTree, path.split("/"), value)
}
export const validatePath = (
path: string,
fileTree: FileTree,
): string | undefined => {
const paths = path.split("/")
paths.pop() // The last item is the filename
for (let i = 0; i <= paths.length; i++) {
const path = paths.slice(0, i + 1)
const pathStr = path.join("/")
if (existsFile(pathStr, fileTree) && !isFolder(pathStr, fileTree)) {
return `Invalid path. The path ${path} is not a folder`
}
}
}
export const updateFile = (
path: string,
content: FileTree | string,
fileTree: FileTree,
): FileTree => {
return set(fileTree, path.split("/"), content)
}
export const existsFile = (path: string, fileTree: FileTree) => {
return has(fileTree, path.split("/"))
}
export const removeFile = (path: string, fileTree: FileTree) => {
const updatedFileTree = { ...fileTree }
unset(updatedFileTree, path.split("/"))
return updatedFileTree
}
export const moveFile = (
currentPath: string,
newPath: string,
fileTree: FileTree,
) => {
const content = getFileContent(currentPath, fileTree)
if (typeof content !== "string") {
throw new Error("Move folders is not allowed")
}
fileTree = removeFile(currentPath, fileTree)
fileTree = createFile(newPath, fileTree, content)
return fileTree
}
export const getFileContent = (path: string, fileTree: FileTree) => {
return get(fileTree, path.split("/")) as string | FileTree
}
export const isFolder = (path: string, fileTree: FileTree) => {
const content = getFileContent(path, fileTree)
return typeof content === "object"
}
export const traverse = (
fileTree: FileTree,
callback: (
content: FileTree | string,
filename: string,
fullPath: string,
) => void,
parent?: string,
) => {
Object.keys(fileTree).forEach((filename) => {
const fullPath = parent ? `${parent}/${filename}` : filename
const content = fileTree[filename]
callback(content, filename, fullPath)
if (typeof content === "object") {
traverse(content, callback, fullPath)
}
})
}
+19
View File
@@ -0,0 +1,19 @@
import * as TypesGen from "../api/typesGenerated"
import { queryToFilter } from "./filters"
describe("queryToFilter", () => {
it.each<
[string | undefined, TypesGen.WorkspaceFilter | TypesGen.UsersRequest]
>([
[undefined, {}],
["", { q: "" }],
["asdkfvjn", { q: "asdkfvjn" }],
["owner:me", { q: "owner:me" }],
["owner:me owner:me2", { q: "owner:me owner:me2" }],
["me/dev", { q: "me/dev" }],
["me/", { q: "me/" }],
[" key:val owner:me ", { q: "key:val owner:me" }],
])(`query=%p, filter=%p`, (query, filter) => {
expect(queryToFilter(query)).toEqual(filter)
})
})
+21
View File
@@ -0,0 +1,21 @@
import * as TypesGen from "../api/typesGenerated"
export const queryToFilter = (
query?: string,
): TypesGen.WorkspaceFilter | TypesGen.UsersRequest => {
const preparedQuery = query?.trim().replace(/ +/g, " ")
return {
q: preparedQuery,
}
}
export const workspaceFilterQuery = {
me: "owner:me",
all: "",
running: "status:running",
}
export const userFilterQuery = {
active: "status:active",
all: "",
}
+141
View File
@@ -0,0 +1,141 @@
import { FormikContextType } from "formik/dist/types"
import { getFormHelpers, nameValidator, onChangeTrimmed } from "./formUtils"
interface TestType {
untouchedGoodField: string
untouchedBadField: string
touchedGoodField: string
touchedBadField: string
}
const mockHandleChange = jest.fn()
const form = {
errors: {
untouchedGoodField: undefined,
untouchedBadField: "oops!",
touchedGoodField: undefined,
touchedBadField: "oops!",
},
touched: {
untouchedGoodField: false,
untouchedBadField: false,
touchedGoodField: true,
touchedBadField: true,
},
handleChange: mockHandleChange,
handleBlur: jest.fn(),
getFieldProps: (name: string) => {
return {
name,
onBlur: jest.fn(),
onChange: jest.fn(),
value: "",
}
},
} as unknown as FormikContextType<TestType>
const nameSchema = nameValidator("name")
describe("form util functions", () => {
describe("getFormHelpers", () => {
describe("without API errors", () => {
const getFieldHelpers = getFormHelpers<TestType>(form)
const untouchedGoodResult = getFieldHelpers("untouchedGoodField")
const untouchedBadResult = getFieldHelpers("untouchedBadField")
const touchedGoodResult = getFieldHelpers("touchedGoodField")
const touchedBadResult = getFieldHelpers("touchedBadField")
it("populates the 'field props'", () => {
expect(untouchedGoodResult.name).toEqual("untouchedGoodField")
expect(untouchedGoodResult.onBlur).toBeDefined()
expect(untouchedGoodResult.onChange).toBeDefined()
expect(untouchedGoodResult.value).toBeDefined()
})
it("sets the id to the name", () => {
expect(untouchedGoodResult.id).toEqual("untouchedGoodField")
})
it("sets error to true if touched and invalid", () => {
expect(untouchedGoodResult.error).toBeFalsy
expect(untouchedBadResult.error).toBeFalsy
expect(touchedGoodResult.error).toBeFalsy
expect(touchedBadResult.error).toBeTruthy
})
it("sets helperText to the error message if touched and invalid", () => {
expect(untouchedGoodResult.helperText).toBeUndefined
expect(untouchedBadResult.helperText).toBeUndefined
expect(touchedGoodResult.helperText).toBeUndefined
expect(touchedBadResult.helperText).toEqual("oops!")
})
})
describe("with API errors", () => {
it("shows an error if there is only an API error", () => {
const getFieldHelpers = getFormHelpers<TestType>(form, {
touchedGoodField: "API error!",
})
const result = getFieldHelpers("touchedGoodField")
expect(result.error).toBeTruthy()
expect(result.helperText).toEqual("API error!")
})
it("shows an error if there is only a validation error", () => {
const getFieldHelpers = getFormHelpers<TestType>(form, {})
const result = getFieldHelpers("touchedBadField")
expect(result.error).toBeTruthy()
expect(result.helperText).toEqual("oops!")
})
it("shows the API error if both are present", () => {
const getFieldHelpers = getFormHelpers<TestType>(form, {
touchedBadField: "API error!",
})
const result = getFieldHelpers("touchedBadField")
expect(result.error).toBeTruthy()
expect(result.helperText).toEqual("API error!")
})
})
})
describe("onChangeTrimmed", () => {
it("calls handleChange with trimmed value", () => {
const event = {
target: { value: " hello " },
} as React.ChangeEvent<HTMLInputElement>
onChangeTrimmed<TestType>(form)(event)
expect(mockHandleChange).toHaveBeenCalledWith({
target: { value: "hello" },
})
})
})
describe("nameValidator", () => {
it("allows a 1-letter name", () => {
const validate = () => nameSchema.validateSync("a")
expect(validate).not.toThrow()
})
it("allows a 32-letter name", () => {
const input = Array(32).fill("a").join("")
const validate = () => nameSchema.validateSync(input)
expect(validate).not.toThrow()
})
it("allows 'test-3' to be used as name", () => {
const validate = () => nameSchema.validateSync("test-3")
expect(validate).not.toThrow()
})
it("allows '3-test' to be used as a name", () => {
const validate = () => nameSchema.validateSync("3-test")
expect(validate).not.toThrow()
})
it("disallows a 33-letter name", () => {
const input = Array(33).fill("a").join("")
const validate = () => nameSchema.validateSync(input)
expect(validate).toThrow()
})
it("disallows a space", () => {
const validate = () => nameSchema.validateSync("test 3")
expect(validate).toThrow()
})
})
})
+104
View File
@@ -0,0 +1,104 @@
import { isApiValidationError, mapApiErrorToFieldErrors } from "api/errors"
import { FormikContextType, FormikErrors, getIn } from "formik"
import {
ChangeEvent,
ChangeEventHandler,
FocusEventHandler,
ReactNode,
} from "react"
import * as Yup from "yup"
const Language = {
nameRequired: (name: string): string => {
return name ? `Please enter a ${name.toLowerCase()}.` : "Required"
},
nameInvalidChars: (name: string): string => {
return `${name} must start with a-Z or 0-9 and can contain a-Z, 0-9 or -`
},
nameTooLong: (name: string, len: number): string => {
return `${name} cannot be longer than ${len} characters`
},
templateDisplayNameInvalidChars: (name: string): string => {
return `${name} must start and end with non-whitespace character`
},
}
interface FormHelpers {
name: string
onBlur: FocusEventHandler
onChange: ChangeEventHandler
id: string
value?: string | number
error: boolean
helperText?: ReactNode
}
export const getFormHelpers =
<T>(form: FormikContextType<T>, error?: Error | unknown) =>
(
name: string,
HelperText: ReactNode = "",
backendErrorName?: string,
): FormHelpers => {
const apiValidationErrors = isApiValidationError(error)
? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors<T>)
: // This should not return the error since it is not and api validation error but I didn't have time to fix this and tests
error
if (typeof name !== "string") {
throw new Error(
`name must be type of string, instead received '${typeof name}'`,
)
}
const apiErrorName = backendErrorName ?? name
// getIn is a util function from Formik that gets at any depth of nesting
// and is necessary for the types to work
const touched = getIn(form.touched, name)
const apiError = getIn(apiValidationErrors, apiErrorName)
const frontendError = getIn(form.errors, name)
const returnError = apiError ?? frontendError
return {
...form.getFieldProps(name),
id: name,
error: touched && Boolean(returnError),
helperText: touched ? returnError || HelperText : HelperText,
}
}
export const onChangeTrimmed =
<T>(form: FormikContextType<T>, callback?: () => void) =>
(event: ChangeEvent<HTMLInputElement>): void => {
event.target.value = event.target.value.trim()
form.handleChange(event)
callback && callback()
}
// REMARK: Keep these consts in sync with coderd/httpapi/httpapi.go
const maxLenName = 32
const templateDisplayNameMaxLength = 64
const usernameRE = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/
const templateDisplayNameRE = /^[^\s](.*[^\s])?$/
// REMARK: see #1756 for name/username semantics
export const nameValidator = (name: string): Yup.StringSchema =>
Yup.string()
.required(Language.nameRequired(name))
.matches(usernameRE, Language.nameInvalidChars(name))
.max(maxLenName, Language.nameTooLong(name, maxLenName))
export const templateDisplayNameValidator = (
displayName: string,
): Yup.StringSchema =>
Yup.string()
.matches(
templateDisplayNameRE,
Language.templateDisplayNameInvalidChars(displayName),
)
.max(
templateDisplayNameMaxLength,
Language.nameTooLong(displayName, templateDisplayNameMaxLength),
)
.optional()
+23
View File
@@ -0,0 +1,23 @@
import { Group } from "api/typesGenerated"
export const everyOneGroup = (organizationId: string): Group => ({
id: organizationId,
name: "Everyone",
organization_id: organizationId,
members: [],
avatar_url: "",
quota_allowance: 0,
})
export const getGroupSubtitle = (group: Group): string => {
// It is the everyone group when a group id is the same of the org id
if (group.id === group.organization_id) {
return `All users`
}
if (group.members.length === 1) {
return `1 member`
}
return `${group.members.length} members`
}
+5
View File
@@ -0,0 +1,5 @@
/**
* A Nullable may be its concrete type, `null` or `undefined`
* @remark Exact opposite of the native TS type NonNullable<T>
*/
export type Nullable<T> = null | undefined | T
+4
View File
@@ -0,0 +1,4 @@
export const pageTitle = (prefix: string | string[]): string => {
const title = Array.isArray(prefix) ? prefix.join(" · ") : prefix
return `${title} - Coder`
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Generate a cryptographically secure random string using the specified number
* of bytes then encode with base64.
*
* Base64 encodes 6 bits per character and pads with = so the length will not
* equal the number of randomly generated bytes.
* @see <https://developer.mozilla.org/en-US/docs/Glossary/Base64#encoded_size_increase>
*/
export const generateRandomString = (bytes: number): string => {
const byteArr = window.crypto.getRandomValues(new Uint8Array(bytes))
// The types for `map` don't seem to support mapping from one array type to
// another and `String.fromCharCode.apply` wants `number[]` so loop like this
// instead.
const strArr: string[] = []
for (const byte of byteArr) {
strArr.push(String.fromCharCode(byte))
}
return btoa(strArr.join(""))
}
+20
View File
@@ -0,0 +1,20 @@
import { embedRedirect, retrieveRedirect } from "./redirect"
describe("redirect helper functions", () => {
describe("embedRedirect", () => {
it("embeds the page to return to in the URL", () => {
const result = embedRedirect("/workspaces", "/page")
expect(result).toEqual("/page?redirect=%2Fworkspaces")
})
it("defaults to navigating to the login page", () => {
const result = embedRedirect("/workspaces")
expect(result).toEqual("/login?redirect=%2Fworkspaces")
})
})
describe("retrieveRedirect", () => {
it("retrieves the page to return to from the URL", () => {
const result = retrieveRedirect("?redirect=%2Fworkspaces")
expect(result).toEqual("/workspaces")
})
})
})
+23
View File
@@ -0,0 +1,23 @@
/**
* Creates a url containing a page to navigate to now, and embedding another
* URL in the query string so you can return to it later.
* @param navigateTo page to navigate to now (by default, /login)
* @param returnTo page to redirect to later (for instance, after logging in)
* @returns URL containing a redirect query parameter
*/
export const embedRedirect = (
returnTo: string,
navigateTo = "/login",
): string => `${navigateTo}?redirect=${encodeURIComponent(returnTo)}`
/**
* Retrieves a url from the query string of the current URL
* @param search the query string in the current URL
* @returns the URL to redirect to
*/
export const retrieveRedirect = (search: string): string => {
const defaultRedirect = "/"
const searchParams = new URLSearchParams(search)
const redirect = searchParams.get("redirect")
return redirect ? redirect : defaultRedirect
}
+158
View File
@@ -0,0 +1,158 @@
import {
TemplateVersionParameter,
WorkspaceBuildParameter,
} from "api/typesGenerated"
import { useTranslation } from "react-i18next"
import * as Yup from "yup"
export const selectInitialRichParametersValues = (
templateParameters?: TemplateVersionParameter[],
defaultValuesFromQuery?: Record<string, string>,
): WorkspaceBuildParameter[] => {
const defaults: WorkspaceBuildParameter[] = []
if (!templateParameters) {
return defaults
}
templateParameters.forEach((parameter) => {
if (parameter.options.length > 0) {
let parameterValue = parameter.options[0].value
if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) {
parameterValue = defaultValuesFromQuery[parameter.name]
}
const buildParameter: WorkspaceBuildParameter = {
name: parameter.name,
value: parameterValue,
}
defaults.push(buildParameter)
return
}
let parameterValue = parameter.default_value
if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) {
parameterValue = defaultValuesFromQuery[parameter.name]
}
const buildParameter: WorkspaceBuildParameter = {
name: parameter.name,
value: parameterValue || "",
}
defaults.push(buildParameter)
})
return defaults
}
export const useValidationSchemaForRichParameters = (
ns: string,
templateParameters?: TemplateVersionParameter[],
lastBuildParameters?: WorkspaceBuildParameter[],
): Yup.AnySchema => {
const { t } = useTranslation(ns)
if (!templateParameters) {
return Yup.object()
}
return Yup.array()
.of(
Yup.object().shape({
name: Yup.string().required(),
value: Yup.string().test("verify with template", (val, ctx) => {
const name = ctx.parent.name
const templateParameter = templateParameters.find(
(parameter) => parameter.name === name,
)
if (templateParameter) {
switch (templateParameter.type) {
case "number":
if (
templateParameter.validation_min &&
templateParameter.validation_max
) {
if (
Number(val) < templateParameter.validation_min ||
templateParameter.validation_max < Number(val)
) {
return ctx.createError({
path: ctx.path,
message: t("validationNumberNotInRange", {
min: templateParameter.validation_min,
max: templateParameter.validation_max,
}),
})
}
}
if (
templateParameter.validation_monotonic &&
lastBuildParameters
) {
const lastBuildParameter = lastBuildParameters.find(
(last) => last.name === name,
)
if (lastBuildParameter) {
switch (templateParameter.validation_monotonic) {
case "increasing":
if (Number(lastBuildParameter.value) > Number(val)) {
return ctx.createError({
path: ctx.path,
message: t("validationNumberNotIncreasing", {
last: lastBuildParameter.value,
}),
})
}
break
case "decreasing":
if (Number(lastBuildParameter.value) < Number(val)) {
return ctx.createError({
path: ctx.path,
message: t("validationNumberNotDecreasing", {
last: lastBuildParameter.value,
}),
})
}
break
}
}
}
break
case "string":
{
if (
!templateParameter.validation_regex ||
templateParameter.validation_regex.length === 0
) {
return true
}
const regex = new RegExp(templateParameter.validation_regex)
if (val && !regex.test(val)) {
return ctx.createError({
path: ctx.path,
message: t("validationPatternNotMatched", {
error: templateParameter.validation_error,
pattern: templateParameter.validation_regex,
}),
})
}
}
break
}
}
return true
}),
}),
)
.required()
}
export const workspaceBuildParameterValue = (
workspaceBuildParameters: WorkspaceBuildParameter[],
parameter: TemplateVersionParameter,
): string => {
const buildParameter = workspaceBuildParameters.find((buildParameter) => {
return buildParameter.name === parameter.name
})
return (buildParameter && buildParameter.value) || ""
}
+147
View File
@@ -0,0 +1,147 @@
import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
import { emptySchedule } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule"
import { emptyTTL } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl"
import { Workspace } from "../api/typesGenerated"
import * as Mocks from "../testHelpers/entities"
import {
deadlineExtensionMax,
deadlineExtensionMin,
extractTimezone,
getMaxDeadline,
getMaxDeadlineChange,
getMinDeadline,
stripTimezone,
scheduleChanged,
} from "./schedule"
dayjs.extend(duration)
const now = dayjs()
const startTime = dayjs(Mocks.MockWorkspaceBuild.updated_at)
describe("util/schedule", () => {
describe("stripTimezone", () => {
it.each<[string, string]>([
["CRON_TZ=Canada/Eastern 30 9 1-5", "30 9 1-5"],
["CRON_TZ=America/Central 0 8 1,2,4,5", "0 8 1,2,4,5"],
["30 9 1-5", "30 9 1-5"],
])(`stripTimezone(%p) returns %p`, (input, expected) => {
expect(stripTimezone(input)).toBe(expected)
})
})
describe("extractTimezone", () => {
it.each<[string, string]>([
["CRON_TZ=Canada/Eastern 30 9 1-5", "Canada/Eastern"],
["CRON_TZ=America/Central 0 8 1,2,4,5", "America/Central"],
["30 9 1-5", "UTC"],
])(`extractTimezone(%p) returns %p`, (input, expected) => {
expect(extractTimezone(input)).toBe(expected)
})
})
})
describe("maxDeadline", () => {
const workspace: Workspace = {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
deadline: startTime.add(8, "hours").utc().format(),
},
}
it("should be 24 hours from the workspace start time", () => {
const delta = getMaxDeadline(workspace).diff(startTime)
expect(delta).toEqual(deadlineExtensionMax.asMilliseconds())
})
})
describe("minDeadline", () => {
it("should never be less than 30 minutes", () => {
const delta = getMinDeadline().diff(now)
expect(delta).toBeGreaterThanOrEqual(deadlineExtensionMin.asMilliseconds())
})
})
describe("getMaxDeadlineChange", () => {
it("should return the number of hours you can add before hitting the max deadline", () => {
const deadline = dayjs()
const maxDeadline = dayjs().add(1, "hour").add(40, "minutes")
// you can only add one hour even though the max is 1:40 away
expect(getMaxDeadlineChange(deadline, maxDeadline)).toEqual(1)
})
it("should return the number of hours you can subtract before hitting the min deadline", () => {
const deadline = dayjs().add(2, "hours").add(40, "minutes")
const minDeadline = dayjs()
// you can only subtract 2 hours even though the min is 2:40 less
expect(getMaxDeadlineChange(deadline, minDeadline)).toEqual(2)
})
})
describe("scheduleChanged", () => {
describe("autostart", () => {
it("should be true if toggle values are different", () => {
const autostart = { autostartEnabled: true, ...emptySchedule }
const formValues = {
autostartEnabled: false,
...emptySchedule,
autostopEnabled: false,
ttl: emptyTTL,
}
expect(scheduleChanged(autostart, formValues)).toBe(true)
})
it("should be true if schedule values are different", () => {
const autostart = { autostartEnabled: true, ...emptySchedule }
const formValues = {
autostartEnabled: true,
...{ ...emptySchedule, monday: true, startTime: "09:00" },
autostopEnabled: false,
ttl: emptyTTL,
}
expect(scheduleChanged(autostart, formValues)).toBe(true)
})
it("should be false if all autostart values are the same", () => {
const autostart = { autostartEnabled: true, ...emptySchedule }
const formValues = {
autostartEnabled: true,
...emptySchedule,
autostopEnabled: false,
ttl: emptyTTL,
}
expect(scheduleChanged(autostart, formValues)).toBe(false)
})
})
describe("autostop", () => {
it("should be true if toggle values are different", () => {
const autostop = { autostopEnabled: true, ttl: 1000 }
const formValues = {
autostartEnabled: false,
...emptySchedule,
autostopEnabled: false,
ttl: 1000,
}
expect(scheduleChanged(autostop, formValues)).toBe(true)
})
it("should be true if ttl values are different", () => {
const autostop = { autostopEnabled: true, ttl: 1000 }
const formValues = {
autostartEnabled: false,
...emptySchedule,
autostopEnabled: true,
ttl: 2000,
}
expect(scheduleChanged(autostop, formValues)).toBe(true)
})
it("should be false if all autostop values are the same", () => {
const autostop = { autostopEnabled: true, ttl: 1000 }
const formValues = {
autostartEnabled: false,
...emptySchedule,
autostopEnabled: true,
ttl: 1000,
}
expect(scheduleChanged(autostop, formValues)).toBe(false)
})
})
})
+176
View File
@@ -0,0 +1,176 @@
import map from "lodash/map"
import some from "lodash/some"
import cronstrue from "cronstrue"
import dayjs, { Dayjs } from "dayjs"
import advancedFormat from "dayjs/plugin/advancedFormat"
import duration from "dayjs/plugin/duration"
import relativeTime from "dayjs/plugin/relativeTime"
import timezone from "dayjs/plugin/timezone"
import utc from "dayjs/plugin/utc"
import { Workspace } from "../api/typesGenerated"
import { isWorkspaceOn } from "./workspace"
import { WorkspaceScheduleFormValues } from "components/WorkspaceScheduleForm/WorkspaceScheduleForm"
import { Autostop } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/ttl"
import { Autostart } from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule"
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
// sorted alphabetically.
dayjs.extend(utc)
dayjs.extend(advancedFormat)
dayjs.extend(duration)
dayjs.extend(relativeTime)
dayjs.extend(timezone)
/**
* @fileoverview Client-side counterpart of the coderd/autostart/schedule Go
* package. This package is a variation on crontab that uses minute, hour and
* day of week.
*/
/**
* DEFAULT_TIMEZONE is the default timezone that crontab assumes unless one is
* specified.
*/
const DEFAULT_TIMEZONE = "UTC"
/**
* stripTimezone strips a leading timezone from a schedule string
*/
export const stripTimezone = (raw: string): string => {
return raw.replace(/CRON_TZ=\S*\s/, "")
}
/**
* extractTimezone returns a leading timezone from a schedule string if one is
* specified; otherwise the specified defaultTZ
*/
export const extractTimezone = (
raw: string,
defaultTZ = DEFAULT_TIMEZONE,
): string => {
const matches = raw.match(/CRON_TZ=\S*\s/g)
if (matches && matches.length > 0) {
return matches[0].replace(/CRON_TZ=/, "").trim()
} else {
return defaultTZ
}
}
/** Language used in the schedule components */
export const Language = {
manual: "Manual",
workspaceShuttingDownLabel: "Workspace is shutting down",
afterStart: "after start",
autostartLabel: "Starts at",
autostopLabel: "Stops at",
}
export const autostartDisplay = (schedule: string | undefined): string => {
if (schedule) {
return (
cronstrue
.toString(stripTimezone(schedule), {
throwExceptionOnParseError: false,
})
// We don't want to keep the At because it is on the label
.replace("At", "")
)
} else {
return Language.manual
}
}
export const isShuttingDown = (
workspace: Workspace,
deadline?: Dayjs,
): boolean => {
if (!deadline) {
if (!workspace.latest_build.deadline) {
return false
}
deadline = dayjs(workspace.latest_build.deadline).utc()
}
const now = dayjs().utc()
return isWorkspaceOn(workspace) && now.isAfter(deadline)
}
export const autostopDisplay = (workspace: Workspace): string => {
const ttl = workspace.ttl_ms
if (isWorkspaceOn(workspace) && workspace.latest_build.deadline) {
// Workspace is on --> derive from latest_build.deadline. Note that the
// user may modify their workspace object (ttl) while the workspace is
// running and depending on system semantics, the deadline may still
// represent the previously defined ttl. Thus, we always derive from the
// deadline as the source of truth.
const deadline = dayjs(workspace.latest_build.deadline).utc()
if (isShuttingDown(workspace, deadline)) {
return Language.workspaceShuttingDownLabel
} else {
return deadline.tz(dayjs.tz.guess()).format("MMM D, YYYY h:mm A")
}
} else if (!ttl || ttl < 1) {
// If the workspace is not on, and the ttl is 0 or undefined, then the
// workspace is set to manually shutdown.
return Language.manual
} else {
// The workspace has a ttl set, but is either in an unknown state or is
// not running. Therefore, we derive from workspace.ttl.
const duration = dayjs.duration(ttl, "milliseconds")
return `${duration.humanize()} ${Language.afterStart}`
}
}
export const deadlineExtensionMin = dayjs.duration(30, "minutes")
export const deadlineExtensionMax = dayjs.duration(24, "hours")
/**
* Depends on the time the workspace was last updated and a global constant.
* @param ws workspace
* @returns the latest datetime at which the workspace can be automatically shut down.
*/
export function getMaxDeadline(ws: Workspace | undefined): dayjs.Dayjs {
// note: we count runtime from updated_at as started_at counts from the start of
// the workspace build process, which can take a while.
if (ws === undefined) {
throw Error("Cannot calculate max deadline because workspace is undefined")
}
const startedAt = dayjs(ws.latest_build.updated_at)
return startedAt.add(deadlineExtensionMax)
}
/**
* Depends on the current time and a global constant.
* @returns the earliest datetime at which the workspace can be automatically shut down.
*/
export function getMinDeadline(): dayjs.Dayjs {
return dayjs().add(deadlineExtensionMin)
}
export const getDeadline = (workspace: Workspace): dayjs.Dayjs =>
dayjs(workspace.latest_build.deadline).utc()
/**
* Get number of hours you can add or subtract to the current deadline before hitting the max or min deadline.
* @param deadline
* @param workspace
* @returns number, in hours
*/
export const getMaxDeadlineChange = (
deadline: dayjs.Dayjs,
extremeDeadline: dayjs.Dayjs,
): number => Math.abs(deadline.diff(extremeDeadline, "hours"))
export const scheduleChanged = (
initialValues: Autostart | Autostop,
formValues: WorkspaceScheduleFormValues,
): boolean =>
some(
map(
{ ...initialValues },
(v: boolean | string, k: keyof typeof initialValues) =>
formValues[k] !== v,
),
)
+24
View File
@@ -0,0 +1,24 @@
import { TemplateExample } from "api/typesGenerated"
export type StarterTemplatesByTag = Record<string, TemplateExample[]>
export const getTemplatesByTag = (
templates: TemplateExample[],
): StarterTemplatesByTag => {
const tags: StarterTemplatesByTag = {
all: templates,
}
templates.forEach((template) => {
template.tags.forEach((tag) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined
if (tags[tag]) {
tags[tag].push(template)
} else {
tags[tag] = [template]
}
})
})
return tags
}
+60
View File
@@ -0,0 +1,60 @@
import { TarReader, TarWriter, ITarFileInfo, TarFileType } from "./tar"
const mtime = 1666666666666
test("tar", async () => {
// Write
const writer = new TarWriter()
writer.addFile("a.txt", "hello", { mtime })
writer.addFile("b.txt", new Blob(["world"]), { mtime })
writer.addFile("c.txt", "", { mtime })
writer.addFolder("etc", { mtime })
writer.addFile("etc/d.txt", "Some text content", {
mtime,
user: "coder",
group: "codergroup",
mode: parseInt("777", 8),
})
const blob = await writer.write()
// Read
const reader = new TarReader()
const fileInfos = await reader.readFile(blob)
verifyFile(fileInfos[0], reader.getTextFile(fileInfos[0].name) as string, {
name: "a.txt",
content: "hello",
})
verifyFile(fileInfos[1], reader.getTextFile(fileInfos[1].name) as string, {
name: "b.txt",
content: "world",
})
verifyFile(fileInfos[2], reader.getTextFile(fileInfos[2].name) as string, {
name: "c.txt",
content: "",
})
verifyFolder(fileInfos[3], {
name: "etc",
})
verifyFile(fileInfos[4], reader.getTextFile(fileInfos[4].name) as string, {
name: "etc/d.txt",
content: "Some text content",
})
expect(fileInfos[4].group).toEqual("codergroup")
expect(fileInfos[4].user).toEqual("coder")
expect(fileInfos[4].mode).toEqual(parseInt("777", 8))
})
function verifyFile(
info: ITarFileInfo,
content: string,
expected: { name: string; content: string },
) {
expect(info.name).toEqual(expected.name)
expect(info.size).toEqual(expected.content.length)
expect(content).toEqual(expected.content)
}
function verifyFolder(info: ITarFileInfo, expected: { name: string }) {
expect(info.name).toEqual(expected.name)
expect(info.type).toEqual(TarFileType.Dir)
}
+349
View File
@@ -0,0 +1,349 @@
// Based on https://github.com/gera2ld/tarjs
// and https://github.com/ankitrohatgi/tarballjs/blob/master/tarball.js
export enum TarFileType {
File = "0",
Dir = "5",
}
const encoder = new TextEncoder()
const utf8Encode = (input: string) => encoder.encode(input)
const decoder = new TextDecoder()
const utf8Decode = (input: Uint8Array) => decoder.decode(input)
export interface ITarFileInfo {
name: string
type: TarFileType
size: number
mode: number
mtime: number
user: string
group: string
headerOffset: number
}
export interface ITarWriteItem {
name: string
type: TarFileType
data: ArrayBuffer | Promise<ArrayBuffer> | null
size: number
opts?: Partial<ITarWriteOptions>
}
export interface ITarWriteOptions {
uid: number
gid: number
mode: number
mtime: number
user: string
group: string
}
export class TarReader {
public fileInfo: ITarFileInfo[] = []
private _buffer: ArrayBuffer | null = null
constructor() {
this.reset()
}
get buffer() {
if (!this._buffer) {
throw new Error("Buffer is not set")
}
return this._buffer
}
reset() {
this.fileInfo = []
this._buffer = null
}
async readFile(file: ArrayBuffer | Uint8Array | Blob) {
this.reset()
this._buffer = await getArrayBuffer(file)
this.readFileInfo()
return this.fileInfo
}
private readFileInfo() {
this.fileInfo = []
let offset = 0
while (offset < this.buffer.byteLength - 512) {
const fileName = this.readFileName(offset)
if (!fileName) {
break
}
const fileType = this.readFileType(offset)
const fileSize = this.readFileSize(offset)
const fileMode = this.readFileMode(offset)
const fileMtime = this.readFileMtime(offset)
const fileUser = this.readFileUser(offset)
const fileGroup = this.readFileGroup(offset)
this.fileInfo.push({
name: fileName,
type: fileType,
size: fileSize,
headerOffset: offset,
mode: fileMode,
mtime: fileMtime,
user: fileUser,
group: fileGroup,
})
offset += 512 + 512 * Math.floor((fileSize + 511) / 512)
}
}
private readString(offset: number, maxSize: number) {
let size = 0
let view = new Uint8Array(this.buffer, offset, maxSize)
while (size < maxSize && view[size]) {
size += 1
}
view = new Uint8Array(this.buffer, offset, size)
return utf8Decode(view)
}
private readFileName(offset: number) {
return this.readString(offset, 100)
}
private readFileMode(offset: number) {
const mode = this.readString(offset + 100, 8)
return parseInt(mode, 8)
}
private readFileMtime(offset: number) {
const mtime = this.readString(offset + 136, 12)
return parseInt(mtime, 8)
}
private readFileUser(offset: number) {
return this.readString(offset + 265, 32)
}
private readFileGroup(offset: number) {
return this.readString(offset + 297, 32)
}
private readFileType(offset: number) {
const typeView = new Uint8Array(this.buffer, offset + 156, 1)
const typeStr = String.fromCharCode(typeView[0])
if (typeStr === "0") {
return TarFileType.File
} else if (typeStr === "5") {
return TarFileType.Dir
} else {
throw new Error("No supported file type")
}
}
private readFileSize(offset: number) {
// offset = 124, length = 12
const view = new Uint8Array(this.buffer, offset + 124, 12)
const sizeStr = utf8Decode(view)
return parseInt(sizeStr, 8)
}
private readFileBlob(offset: number, size: number, mimetype: string) {
const view = new Uint8Array(this.buffer, offset, size)
return new Blob([view], { type: mimetype })
}
private readTextFile(offset: number, size: number) {
const view = new Uint8Array(this.buffer, offset, size)
return utf8Decode(view)
}
getTextFile(filename: string) {
const item = this.fileInfo.find((info) => info.name === filename)
if (item) {
return this.readTextFile(item.headerOffset + 512, item.size)
}
}
getFileBlob(filename: string, mimetype = "") {
const item = this.fileInfo.find((info) => info.name === filename)
if (item) {
return this.readFileBlob(item.headerOffset + 512, item.size, mimetype)
}
}
}
export class TarWriter {
private fileData: ITarWriteItem[] = []
private _buffer: ArrayBuffer | null = null
get buffer() {
if (!this._buffer) {
throw new Error("Buffer is not set")
}
return this._buffer
}
addFile(
name: string,
file: string | ArrayBuffer | Uint8Array | Blob,
opts?: Partial<ITarWriteOptions>,
) {
const data = getArrayBuffer(file)
const size = (data as ArrayBuffer).byteLength ?? (file as Blob).size
const item: ITarWriteItem = {
name,
type: TarFileType.File,
data,
size,
opts,
}
this.fileData.push(item)
}
addFolder(name: string, opts?: Partial<ITarWriteOptions>) {
this.fileData.push({
name,
type: TarFileType.Dir,
data: null,
size: 0,
opts,
})
}
private createBuffer() {
const dataSize = this.fileData.reduce(
(prev, item) => prev + 512 + 512 * Math.floor((item.size + 511) / 512),
0,
)
const bufSize = 10240 * Math.floor((dataSize + 10240 - 1) / 10240)
this._buffer = new ArrayBuffer(bufSize)
}
async write() {
this.createBuffer()
const view = new Uint8Array(this.buffer)
let offset = 0
for (const item of this.fileData) {
// write header
this.writeFileName(item.name, offset)
this.writeFileType(item.type, offset)
this.writeFileSize(item.size, offset)
this.fillHeader(offset, item.opts as Partial<ITarWriteOptions>, item.type)
this.writeChecksum(offset)
// write data
const data = new Uint8Array((await item.data) as ArrayBuffer)
view.set(data, offset + 512)
offset += 512 + 512 * Math.floor((item.size + 511) / 512)
}
return new Blob([this.buffer], { type: "application/x-tar" })
}
private writeString(str: string, offset: number, size: number) {
const strView = utf8Encode(str)
const view = new Uint8Array(this.buffer, offset, size)
for (let i = 0; i < size; i += 1) {
view[i] = i < strView.length ? strView[i] : 0
}
}
private writeFileName(name: string, offset: number) {
// offset: 0
this.writeString(name, offset, 100)
}
private writeFileType(type: TarFileType, offset: number) {
// offset: 156
const typeView = new Uint8Array(this.buffer, offset + 156, 1)
typeView[0] = type.charCodeAt(0)
}
private writeFileSize(size: number, offset: number) {
// offset: 124
const sizeStr = size.toString(8).padStart(11, "0")
this.writeString(sizeStr, offset + 124, 12)
}
private writeFileMode(mode: number, offset: number) {
// offset: 100
this.writeString(mode.toString(8).padStart(7, "0"), offset + 100, 8)
}
private writeFileUid(uid: number, offset: number) {
// offset: 108
this.writeString(uid.toString(8).padStart(7, "0"), offset + 108, 8)
}
private writeFileGid(gid: number, offset: number) {
// offset: 116
this.writeString(gid.toString(8).padStart(7, "0"), offset + 116, 8)
}
private writeFileMtime(mtime: number, offset: number) {
// offset: 136
this.writeString(mtime.toString(8).padStart(11, "0"), offset + 136, 12)
}
private writeFileUser(user: string, offset: number) {
// offset: 265
this.writeString(user, offset + 265, 32)
}
private writeFileGroup(group: string, offset: number) {
// offset: 297
this.writeString(group, offset + 297, 32)
}
private writeChecksum(offset: number) {
// offset: 148
this.writeString(" ", offset + 148, 8) // first fill with spaces
// add up header bytes
const header = new Uint8Array(this.buffer, offset, 512)
let chksum = 0
for (let i = 0; i < 512; i += 1) {
chksum += header[i]
}
this.writeString(chksum.toString(8), offset + 148, 8)
}
private fillHeader(
offset: number,
opts: Partial<ITarWriteOptions>,
fileType: TarFileType,
) {
const { uid, gid, mode, mtime, user, group } = {
uid: 1000,
gid: 1000,
mode: fileType === TarFileType.File ? 0o664 : 0o775,
mtime: ~~(Date.now() / 1000),
user: "tarballjs",
group: "tarballjs",
...opts,
}
this.writeFileMode(mode, offset)
this.writeFileUid(uid, offset)
this.writeFileGid(gid, offset)
this.writeFileMtime(mtime, offset)
this.writeString("ustar", offset + 257, 6) // magic string
this.writeString("00", offset + 263, 2) // magic version
this.writeFileUser(user, offset)
this.writeFileGroup(group, offset)
}
}
function getArrayBuffer(file: string | ArrayBuffer | Uint8Array | Blob) {
if (typeof file === "string") {
return utf8Encode(file).buffer
}
if (file instanceof ArrayBuffer) {
return file
}
if (ArrayBuffer.isView(file)) {
return new Uint8Array(file).buffer
}
return file.arrayBuffer()
}
+46
View File
@@ -0,0 +1,46 @@
import * as API from "api/api"
import { TemplateVersion } from "api/typesGenerated"
import { FileTree, createFile } from "./filetree"
import { TarReader } from "./tar"
/**
* Content by filename
*/
export type TemplateVersionFiles = Record<string, string>
export const getTemplateVersionFiles = async (
version: TemplateVersion,
): Promise<TemplateVersionFiles> => {
const files: TemplateVersionFiles = {}
const tarFile = await API.getFile(version.job.file_id)
const tarReader = new TarReader()
await tarReader.readFile(tarFile)
for (const file of tarReader.fileInfo) {
if (isAllowedFile(file.name)) {
files[file.name] = tarReader.getTextFile(file.name) as string
}
}
return files
}
export const allowedExtensions = ["tf", "md", "Dockerfile"]
export const isAllowedFile = (name: string) => {
return allowedExtensions.some((ext) => name.endsWith(ext))
}
export const createTemplateVersionFileTree = async (
tarReader: TarReader,
): Promise<FileTree> => {
let fileTree: FileTree = {}
for (const file of tarReader.fileInfo) {
if (isAllowedFile(file.name)) {
fileTree = createFile(
file.name,
fileTree,
tarReader.getTextFile(file.name) as string,
)
}
}
return fileTree
}
+20
View File
@@ -0,0 +1,20 @@
import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
import relativeTime from "dayjs/plugin/relativeTime"
dayjs.extend(duration)
dayjs.extend(relativeTime)
export const formatTemplateActiveDevelopers = (num?: number): string => {
if (num === undefined || num < 0) {
// Loading
return "-"
}
return num.toString()
}
export const formatTemplateBuildTime = (buildTimeMs?: number): string => {
return buildTimeMs === undefined
? "Unknown"
: `${Math.round(dayjs.duration(buildTimeMs, "milliseconds").asSeconds())}s`
}
+142
View File
@@ -0,0 +1,142 @@
import dayjs from "dayjs"
import * as TypesGen from "../api/typesGenerated"
import * as Mocks from "../testHelpers/entities"
import {
defaultWorkspaceExtension,
getDisplayVersionStatus,
getDisplayWorkspaceBuildInitiatedBy,
getDisplayWorkspaceTemplateName,
isWorkspaceOn,
} from "./workspace"
describe("util > workspace", () => {
describe("isWorkspaceOn", () => {
it.each<
[TypesGen.WorkspaceTransition, TypesGen.ProvisionerJobStatus, boolean]
>([
["delete", "canceled", false],
["delete", "canceling", false],
["delete", "failed", false],
["delete", "pending", false],
["delete", "running", false],
["delete", "succeeded", false],
["stop", "canceled", false],
["stop", "canceling", false],
["stop", "failed", false],
["stop", "pending", false],
["stop", "running", false],
["stop", "succeeded", false],
["start", "canceled", false],
["start", "canceling", false],
["start", "failed", false],
["start", "pending", false],
["start", "running", false],
["start", "succeeded", true],
])(
`transition=%p, status=%p, isWorkspaceOn=%p`,
(transition, status, isOn) => {
const workspace: TypesGen.Workspace = {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
job: {
...Mocks.MockProvisionerJob,
status,
},
transition,
},
}
expect(isWorkspaceOn(workspace)).toBe(isOn)
},
)
})
describe("defaultWorkspaceExtension", () => {
it.each<[string, TypesGen.PutExtendWorkspaceRequest]>([
[
"2022-06-02T14:56:34Z",
{
deadline: "2022-06-02T18:56:34Z",
},
],
// This case is the same as above, but in a different timezone to prove
// that UTC conversion for deadline works as expected
[
"2022-06-02T10:56:20-04:00",
{
deadline: "2022-06-02T18:56:20Z",
},
],
])(`defaultWorkspaceExtension(%p) returns %p`, (startTime, request) => {
expect(defaultWorkspaceExtension(dayjs(startTime))).toEqual(request)
})
})
describe("getDisplayWorkspaceBuildInitiatedBy", () => {
it.each<[TypesGen.WorkspaceBuild, string]>([
[Mocks.MockWorkspaceBuild, "TestUser"],
[
{
...Mocks.MockWorkspaceBuild,
reason: "autostart",
},
"Coder",
],
[
{
...Mocks.MockWorkspaceBuild,
reason: "autostop",
},
"Coder",
],
])(
`getDisplayWorkspaceBuildInitiatedBy(%p) returns %p`,
(build, initiatedBy) => {
expect(getDisplayWorkspaceBuildInitiatedBy(build)).toEqual(initiatedBy)
},
)
})
describe("getDisplayVersionStatus", () => {
it.each<[string, string, string, boolean]>([
["", "", "Unknown", false],
["", "v1.2.3", "Unknown", false],
["v1.2.3", "", "v1.2.3", false],
["v1.2.3", "v1.2.3", "v1.2.3", false],
["v1.2.3", "v1.2.4", "v1.2.3", true],
["v1.2.4", "v1.2.3", "v1.2.4", false],
["foo", "bar", "foo", false],
])(
`getDisplayVersionStatus(theme, %p, %p) returns (%p, %p)`,
(agentVersion, serverVersion, expectedVersion, expectedOutdated) => {
const { displayVersion, outdated } = getDisplayVersionStatus(
agentVersion,
serverVersion,
)
expect(displayVersion).toEqual(expectedVersion)
expect(expectedOutdated).toEqual(outdated)
},
)
})
describe("getDisplayWorkspaceTemplateName", () => {
it("display name is not set", async () => {
const workspace: TypesGen.Workspace = {
...Mocks.MockWorkspace,
template_display_name: "",
}
const displayed = getDisplayWorkspaceTemplateName(workspace)
expect(displayed).toEqual(workspace.template_name)
})
it("display name is set", async () => {
const workspace: TypesGen.Workspace = {
...Mocks.MockWorkspace,
}
const displayed = getDisplayWorkspaceTemplateName(workspace)
expect(displayed).toEqual(workspace.template_display_name)
})
})
})
+192
View File
@@ -0,0 +1,192 @@
import { Theme } from "@material-ui/core/styles"
import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
import minMax from "dayjs/plugin/minMax"
import utc from "dayjs/plugin/utc"
import semver from "semver"
import { PaletteIndex } from "theme/palettes"
import * as TypesGen from "../api/typesGenerated"
dayjs.extend(duration)
dayjs.extend(utc)
dayjs.extend(minMax)
const DisplayWorkspaceBuildStatusLanguage = {
succeeded: "Succeeded",
pending: "Pending",
running: "Running",
canceling: "Canceling",
canceled: "Canceled",
failed: "Failed",
}
const DisplayAgentVersionLanguage = {
unknown: "Unknown",
}
export const getDisplayWorkspaceBuildStatus = (
theme: Theme,
build: TypesGen.WorkspaceBuild,
): {
color: string
status: string
type: PaletteIndex
} => {
switch (build.job.status) {
case "succeeded":
return {
type: "success",
color: theme.palette.success.main,
status: DisplayWorkspaceBuildStatusLanguage.succeeded,
}
case "pending":
return {
type: "secondary",
color: theme.palette.text.secondary,
status: DisplayWorkspaceBuildStatusLanguage.pending,
}
case "running":
return {
type: "info",
color: theme.palette.primary.main,
status: DisplayWorkspaceBuildStatusLanguage.running,
}
case "failed":
return {
type: "error",
color: theme.palette.text.secondary,
status: DisplayWorkspaceBuildStatusLanguage.failed,
}
case "canceling":
return {
type: "warning",
color: theme.palette.warning.light,
status: DisplayWorkspaceBuildStatusLanguage.canceling,
}
case "canceled":
return {
type: "secondary",
color: theme.palette.text.secondary,
status: DisplayWorkspaceBuildStatusLanguage.canceled,
}
}
}
export const getDisplayWorkspaceBuildInitiatedBy = (
build: TypesGen.WorkspaceBuild,
): string => {
switch (build.reason) {
case "initiator":
return build.initiator_name
case "autostart":
case "autostop":
return "Coder"
}
}
const getWorkspaceBuildDurationInSeconds = (
build: TypesGen.WorkspaceBuild,
): number | undefined => {
const isCompleted = build.job.started_at && build.job.completed_at
if (!isCompleted) {
return
}
const startedAt = dayjs(build.job.started_at)
const completedAt = dayjs(build.job.completed_at)
return completedAt.diff(startedAt, "seconds")
}
export const displayWorkspaceBuildDuration = (
build: TypesGen.WorkspaceBuild,
inProgressLabel = "In progress",
): string => {
const duration = getWorkspaceBuildDurationInSeconds(build)
return duration ? `${duration} seconds` : inProgressLabel
}
export const getDisplayVersionStatus = (
agentVersion: string,
serverVersion: string,
): { displayVersion: string; outdated: boolean } => {
if (!semver.valid(serverVersion) || !semver.valid(agentVersion)) {
return {
displayVersion: agentVersion || DisplayAgentVersionLanguage.unknown,
outdated: false,
}
} else if (semver.lt(agentVersion, serverVersion)) {
return {
displayVersion: agentVersion,
outdated: true,
}
} else {
return {
displayVersion: agentVersion,
outdated: false,
}
}
}
export const isWorkspaceOn = (workspace: TypesGen.Workspace): boolean => {
const transition = workspace.latest_build.transition
const status = workspace.latest_build.job.status
return transition === "start" && status === "succeeded"
}
export const defaultWorkspaceExtension = (
__startDate?: dayjs.Dayjs,
): TypesGen.PutExtendWorkspaceRequest => {
const now = __startDate ? dayjs(__startDate) : dayjs()
const fourHoursFromNow = now.add(4, "hours").utc()
return {
deadline: fourHoursFromNow.format(),
}
}
// You can see the favicon designs here: https://www.figma.com/file/YIGBkXUcnRGz2ZKNmLaJQf/Coder-v2-Design?node-id=560%3A620
type FaviconType =
| "favicon"
| "favicon-success"
| "favicon-error"
| "favicon-warning"
| "favicon-running"
export const getFaviconByStatus = (
build: TypesGen.WorkspaceBuild,
): FaviconType => {
switch (build.status) {
case undefined:
return "favicon"
case "running":
return "favicon-success"
case "starting":
return "favicon-running"
case "stopping":
return "favicon-running"
case "stopped":
return "favicon"
case "deleting":
return "favicon"
case "deleted":
return "favicon"
case "canceling":
return "favicon-warning"
case "canceled":
return "favicon"
case "failed":
return "favicon-error"
case "pending":
return "favicon"
}
}
export const getDisplayWorkspaceTemplateName = (
workspace: TypesGen.Workspace,
): string => {
return workspace.template_display_name.length > 0
? workspace.template_display_name
: workspace.template_name
}