feat: add template editor to the ui (#5963)

* Add initial editor

* Fix editor file being reset onChange

* Add updating the active build version

* Update nav height

* Add tabs

* Fix title

* Hide timestamps in build logs

* Add create file dialog

* Add validation for empty path

* Hide resources tab

* Fix label names

* Add rename and delete

* Improve UX

* Add padding to the editor

* Add dirty state

* Hide build logs until a build is made

* Add stories

* Add experiment to enable the template editor

* Fix linting errors

* Fix duplicate fields

* Fix theme type
This commit is contained in:
Kyle Carberry
2023-02-07 01:24:04 +00:00
committed by GitHub
parent 71a893764e
commit dd9e1f3d3f
27 changed files with 1751 additions and 40 deletions
+4 -2
View File
@@ -6380,10 +6380,12 @@ const docTemplate = `{
"codersdk.Experiment": {
"type": "string",
"enum": [
"authz_querier"
"authz_querier",
"template_editor"
],
"x-enum-varnames": [
"ExperimentAuthzQuerier"
"ExperimentAuthzQuerier",
"ExperimentTemplateEditor"
]
},
"codersdk.Feature": {
+2 -2
View File
@@ -5704,8 +5704,8 @@
},
"codersdk.Experiment": {
"type": "string",
"enum": ["authz_querier"],
"x-enum-varnames": ["ExperimentAuthzQuerier"]
"enum": ["authz_querier", "template_editor"],
"x-enum-varnames": ["ExperimentAuthzQuerier", "ExperimentTemplateEditor"]
},
"codersdk.Feature": {
"type": "object",
+5 -1
View File
@@ -421,6 +421,10 @@ const (
// interface for all RBAC operations. NOT READY FOR PRODUCTION USE.
ExperimentAuthzQuerier Experiment = "authz_querier"
// ExperimentTemplateEditor is an internal experiment that enables the template editor
// for all users.
ExperimentTemplateEditor Experiment = "template_editor"
// Add new experiments here!
// ExperimentExample Experiment = "example"
)
@@ -430,7 +434,7 @@ var (
// users to opt-in to via --experimental='*'.
// Experiments that are not ready for consumption by all users should
// not be included here and will be essentially hidden.
ExperimentsAll = Experiments{}
ExperimentsAll = Experiments{ExperimentTemplateEditor}
)
// Experiments is a list of experiments that are enabled for the deployment.
+4 -3
View File
@@ -2770,9 +2770,10 @@ CreateParameterRequest is a structure used to create a new parameter value for a
#### Enumerated Values
| Value |
| --------------- |
| `authz_querier` |
| Value |
| ----------------- |
| `authz_querier` |
| `template_editor` |
## codersdk.Feature
+3
View File
@@ -67,6 +67,7 @@
"react-chartjs-2": "4.3.1",
"react-color": "2.19.3",
"react-dom": "18.2.0",
"react-headless-tabs": "^6.0.3",
"react-helmet-async": "1.3.0",
"react-i18next": "12.1.1",
"react-markdown": "8.0.3",
@@ -75,6 +76,7 @@
"remark-gfm": "3.0.1",
"rollup-plugin-visualizer": "5.9.0",
"sourcemapped-stacktrace": "1.1.11",
"tar-js": "^0.3.0",
"ts-prune": "0.10.3",
"tzdata": "1.0.30",
"ua-parser-js": "1.0.33",
@@ -125,6 +127,7 @@
"jest-esm-transformer": "^1.0.0",
"jest-runner-eslint": "1.1.0",
"jest-websocket-mock": "2.4.0",
"monaco-editor": "^0.34.1",
"msw": "0.47.0",
"prettier": "2.8.1",
"resize-observer": "1.0.4",
+13 -1
View File
@@ -108,6 +108,12 @@ const GitAuthPage = lazy(() => import("./pages/GitAuthPage/GitAuthPage"))
const TemplateVersionPage = lazy(
() => import("./pages/TemplateVersionPage/TemplateVersionPage"),
)
const TemplateVersionEditorPage = lazy(
() =>
import(
"./pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage"
),
)
const StarterTemplatesPage = lazy(
() => import("./pages/StarterTemplatesPage/StarterTemplatesPage"),
)
@@ -155,7 +161,13 @@ export const AppRouter: FC = () => {
<Route path="workspace" element={<CreateWorkspacePage />} />
<Route path="settings" element={<TemplateSettingsPage />} />
<Route path="versions">
<Route path=":version" element={<TemplateVersionPage />} />
<Route path=":version">
<Route index element={<TemplateVersionPage />} />
<Route
path="edit"
element={<TemplateVersionEditorPage />}
/>
</Route>
</Route>
</Route>
</Route>
+20
View File
@@ -309,6 +309,17 @@ export const createTemplate = async (
return response.data
}
export const updateActiveTemplateVersion = async (
templateId: string,
data: TypesGen.UpdateActiveTemplateVersion,
): Promise<Types.Message> => {
const response = await axios.patch<Types.Message>(
`/api/v2/templates/${templateId}/versions`,
data,
)
return response.data
}
export const updateTemplateMeta = async (
templateId: string,
data: TypesGen.UpdateTemplateMeta,
@@ -433,6 +444,15 @@ export const cancelWorkspaceBuild = async (
return response.data
}
export const cancelTemplateVersionBuild = async (
templateVersionId: TypesGen.TemplateVersion["id"],
): Promise<Types.Message> => {
const response = await axios.patch(
`/api/v2/templateversions/${templateVersionId}/cancel`,
)
return response.data
}
export const createUser = async (
user: TypesGen.CreateUserRequest,
): Promise<TypesGen.User> => {
+2 -2
View File
@@ -1082,8 +1082,8 @@ export const Entitlements: Entitlement[] = [
]
// From codersdk/deployment.go
export type Experiment = "authz_querier"
export const Experiments: Experiment[] = ["authz_querier"]
export type Experiment = "authz_querier" | "template_editor"
export const Experiments: Experiment[] = ["authz_querier", "template_editor"]
// From codersdk/deployment.go
export type FeatureName =
@@ -71,6 +71,5 @@ const useStyles = makeStyles((theme) => ({
},
siteContent: {
flex: 1,
paddingBottom: theme.spacing(10),
},
}))
+10 -4
View File
@@ -13,10 +13,12 @@ interface Line {
export interface LogsProps {
lines: Line[]
hideTimestamps?: boolean
className?: string
}
export const Logs: FC<React.PropsWithChildren<LogsProps>> = ({
hideTimestamps,
lines,
className = "",
}) => {
@@ -27,10 +29,14 @@ export const Logs: FC<React.PropsWithChildren<LogsProps>> = ({
<div className={styles.scrollWrapper}>
{lines.map((line, idx) => (
<div className={combineClasses([styles.line, line.level])} key={idx}>
<span className={styles.time}>
{dayjs(line.time).format(`HH:mm:ss.SSS`)}
</span>
<span className={styles.space}>&nbsp;&nbsp;&nbsp;&nbsp;</span>
{!hideTimestamps && (
<>
<span className={styles.time}>
{dayjs(line.time).format(`HH:mm:ss.SSS`)}
</span>
<span className={styles.space}>&nbsp;&nbsp;&nbsp;&nbsp;</span>
</>
)}
<span>{line.output}</span>
</div>
))}
@@ -2,10 +2,11 @@ import { Theme, useTheme } from "@material-ui/core/styles"
import { useMonaco } from "@monaco-editor/react"
import { useEffect, useState } from "react"
import { hslToHex } from "util/colors"
import { editor } from "monaco-editor"
// Theme based on https://github.com/brijeshb42/monaco-themes/blob/master/themes/Dracula.json
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- The theme is not typed
export const coderTheme = (theme: Theme): Record<string, any> => ({
export const coderTheme = (theme: Theme): editor.IStandaloneThemeData => ({
base: "vs-dark",
inherit: true,
rules: [
@@ -31,6 +31,7 @@ import { Avatar } from "components/Avatar/Avatar"
const Language = {
settingsButton: "Settings",
editButton: "Edit",
createButton: "Create workspace",
noDescription: "",
}
@@ -0,0 +1,155 @@
import TextField from "@material-ui/core/TextField"
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
import { Stack } from "components/Stack/Stack"
import { ChangeEvent, FC, useState } from "react"
import Typography from "@material-ui/core/Typography"
export const CreateFileDialog: FC<{
onClose: () => void
checkExists: (path: string) => boolean
onConfirm: (path: string) => void
open: boolean
}> = ({ checkExists, onClose, onConfirm, open }) => {
const [pathValue, setPathValue] = useState("")
const [error, setError] = useState("")
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setPathValue(event.target.value)
}
const handleConfirm = () => {
if (pathValue === "") {
setError("You must enter a path!")
return
}
if (checkExists(pathValue)) {
setError("File already exists")
return
}
onConfirm(pathValue)
setPathValue("")
}
return (
<ConfirmDialog
open={open}
onClose={() => {
onClose()
setPathValue("")
}}
onConfirm={handleConfirm}
hideCancel={false}
type="success"
cancelText="Cancel"
confirmText="Create"
title="Create File"
description={
<Stack spacing={1}>
<Typography>
Specify the path to a file to be created. This path can contain
slashes too!
</Typography>
<TextField
autoFocus
onKeyDown={(event) => {
if (event.key === "Enter") {
handleConfirm()
}
}}
helperText={error}
name="file-path"
autoComplete="off"
id="file-path"
placeholder="main.tf"
value={pathValue}
onChange={handleChange}
label="File Path"
/>
</Stack>
}
/>
)
}
export const DeleteFileDialog: FC<{
onClose: () => void
onConfirm: () => void
open: boolean
filename: string
}> = ({ onClose, onConfirm, open, filename }) => {
return (
<ConfirmDialog
type="delete"
onClose={onClose}
open={open}
onConfirm={onConfirm}
title="Delete File"
description={`Are you sure you want to delete "${filename}"?`}
/>
)
}
export const RenameFileDialog: FC<{
onClose: () => void
onConfirm: (filename: string) => void
checkExists: (path: string) => boolean
open: boolean
filename: string
}> = ({ checkExists, onClose, onConfirm, open, filename }) => {
const [pathValue, setPathValue] = useState(filename)
const [error, setError] = useState("")
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setPathValue(event.target.value)
}
const handleConfirm = () => {
if (pathValue === "") {
setError("You must enter a path!")
return
}
if (checkExists(pathValue)) {
setError("File already exists")
return
}
onConfirm(pathValue)
setPathValue("")
}
return (
<ConfirmDialog
open={open}
onClose={() => {
onClose()
setPathValue("")
}}
onConfirm={handleConfirm}
hideCancel={false}
type="success"
cancelText="Cancel"
confirmText="Create"
title="Rename File"
description={
<Stack spacing={1}>
<Typography>
Rename {`"${filename}"`} to something else. This path can contain
slashes too!
</Typography>
<TextField
autoFocus
onKeyDown={(event) => {
if (event.key === "Enter") {
handleConfirm()
}
}}
helperText={error}
name="file-path"
autoComplete="off"
id="file-path"
placeholder="main.tf"
defaultValue={filename}
value={pathValue}
onChange={handleChange}
label="File Path"
/>
</Stack>
}
/>
)
}
@@ -0,0 +1,252 @@
import { makeStyles } from "@material-ui/core/styles"
import ChevronRightIcon from "@material-ui/icons/ChevronRight"
import ExpandMoreIcon from "@material-ui/icons/ExpandMore"
import TreeView from "@material-ui/lab/TreeView"
import TreeItem from "@material-ui/lab/TreeItem"
import Menu from "@material-ui/core/Menu"
import MenuItem from "@material-ui/core/MenuItem"
import { FC, useMemo, useState } from "react"
import { TemplateVersionFiles } from "util/templateVersion"
import { DockerIcon } from "components/Icons/DockerIcon"
export interface File {
path: string
content?: string
children: Record<string, File>
}
export const FileTree: FC<{
onSelect: (file: File) => void
onDelete: (file: File) => void
onRename: (file: File) => void
files: TemplateVersionFiles
activeFile?: File
}> = ({ activeFile, files, onDelete, onRename, onSelect }) => {
const styles = useStyles()
const fileTree = useMemo<Record<string, File>>(() => {
const paths = Object.keys(files)
const roots: Record<string, File> = {}
paths.forEach((path) => {
const pathParts = path.split("/")
const firstPart = pathParts.shift()
if (!firstPart) {
// Not possible!
return
}
let activeFile = roots[firstPart]
if (!activeFile) {
activeFile = {
path: firstPart,
children: {},
}
roots[firstPart] = activeFile
}
while (pathParts.length > 0) {
const pathPart = pathParts.shift()
if (!pathPart) {
continue
}
if (!activeFile.children[pathPart]) {
activeFile.children[pathPart] = {
path: activeFile.path + "/" + pathPart,
children: {},
}
}
activeFile = activeFile.children[pathPart]
}
activeFile.content = files[path]
activeFile.path = path
})
return roots
}, [files])
const [contextMenu, setContextMenu] = useState<
| {
file: File
clientX: number
clientY: number
}
| undefined
>()
const buildTreeItems = (name: string, file: File): JSX.Element => {
let icon: JSX.Element | null = null
if (file.path.endsWith(".tf")) {
icon = <FileTypeTerraform />
}
if (file.path.endsWith(".md")) {
icon = <FileTypeMarkdown />
}
if (file.path.endsWith("Dockerfile")) {
icon = <FileTypeDockerfile />
}
return (
<TreeItem
nodeId={file.path}
key={file.path}
label={name}
className={`${styles.fileTreeItem} ${
file.path === activeFile?.path ? "active" : ""
}`}
onClick={() => {
if (file.content) {
onSelect(file)
}
}}
onContextMenu={(event) => {
event.preventDefault()
if (!file.content) {
return
}
setContextMenu(
contextMenu
? undefined
: {
file: file,
clientY: event.clientY,
clientX: event.clientX,
},
)
}}
icon={icon}
>
{Object.entries(file.children || {}).map(([name, file]) => {
return buildTreeItems(name, file)
})}
</TreeItem>
)
}
return (
<TreeView
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
aria-label="Files"
className={styles.fileTree}
>
{Object.entries(fileTree).map(([name, file]) => {
return buildTreeItems(name, file)
})}
<Menu
onClose={() => setContextMenu(undefined)}
open={Boolean(contextMenu)}
anchorReference="anchorPosition"
anchorPosition={
contextMenu
? {
top: contextMenu.clientY,
left: contextMenu.clientX,
}
: undefined
}
anchorOrigin={{
vertical: "top",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
<MenuItem
onClick={() => {
if (!contextMenu) {
return
}
onRename(contextMenu.file)
setContextMenu(undefined)
}}
>
Rename...
</MenuItem>
<MenuItem
onClick={() => {
if (!contextMenu) {
return
}
onDelete(contextMenu.file)
setContextMenu(undefined)
}}
>
Delete Permanently
</MenuItem>
</Menu>
</TreeView>
)
}
const useStyles = makeStyles((theme) => ({
fileTree: {},
fileTreeItem: {
overflow: "hidden",
userSelect: "none",
"&:focus": {
"& > .MuiTreeItem-content": {
background: "rgba(255, 255, 255, 0.1)",
},
},
"& > .MuiTreeItem-content:hover": {
background: theme.palette.background.paperLight,
color: theme.palette.text.primary,
},
"& > .MuiTreeItem-content": {
padding: "1px 16px",
color: theme.palette.text.secondary,
"& svg": {
width: 16,
height: 16,
},
"& > .MuiTreeItem-label": {
marginLeft: 4,
fontSize: 14,
color: "inherit",
},
},
"&.active": {
background: theme.palette.background.paper,
"& > .MuiTreeItem-content": {
color: theme.palette.text.primary,
},
},
},
editor: {
flex: 1,
},
preview: {},
}))
const FileTypeTerraform = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="#813cf3">
<title>file_type_terraform</title>
<polygon points="12.042 6.858 20.071 11.448 20.071 20.462 12.042 15.868 12.042 6.858 12.042 6.858" />
<polygon points="20.5 20.415 28.459 15.84 28.459 6.887 20.5 11.429 20.5 20.415 20.5 20.415" />
<polygon points="3.541 11.01 11.571 15.599 11.571 6.59 3.541 2 3.541 11.01 3.541 11.01" />
<polygon points="12.042 25.41 20.071 30 20.071 20.957 12.042 16.368 12.042 25.41 12.042 25.41" />
</svg>
)
const FileTypeMarkdown = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="#755838">
<rect
x="2.5"
y="7.955"
width="27"
height="16.091"
style={{
fill: "none",
stroke: "#755838",
}}
/>
<polygon points="5.909 20.636 5.909 11.364 8.636 11.364 11.364 14.773 14.091 11.364 16.818 11.364 16.818 20.636 14.091 20.636 14.091 15.318 11.364 18.727 8.636 15.318 8.636 20.636 5.909 20.636" />
<polygon points="22.955 20.636 18.864 16.136 21.591 16.136 21.591 11.364 24.318 11.364 24.318 16.136 27.045 16.136 22.955 20.636" />
</svg>
)
const FileTypeDockerfile = () => <DockerIcon />
@@ -0,0 +1,134 @@
import { useTheme } from "@material-ui/core/styles"
import Editor from "@monaco-editor/react"
import { FC, useLayoutEffect, useMemo, useState } from "react"
import { MONOSPACE_FONT_FAMILY } from "theme/constants"
import { hslToHex } from "util/colors"
import type { editor } from "monaco-editor"
export const MonacoEditor: FC<{
value?: string
path?: string
onChange?: (value: string) => void
}> = ({ onChange, value, path }) => {
const theme = useTheme()
const [editor, setEditor] = useState<editor.IStandaloneCodeEditor>()
useLayoutEffect(() => {
if (!editor) {
return
}
const resizeListener = () => {
editor.layout({
height: 0,
width: 0,
})
}
window.addEventListener("resize", resizeListener)
return () => {
window.removeEventListener("resize", resizeListener)
}
}, [editor])
const language = useMemo(() => {
if (path?.endsWith(".tf")) {
return "hcl"
}
if (path?.endsWith(".md")) {
return "markdown"
}
if (path?.endsWith(".json")) {
return "json"
}
if (path?.endsWith(".yaml")) {
return "yaml"
}
if (path?.endsWith("Dockerfile")) {
return "dockerfile"
}
}, [path])
return (
<Editor
value={value}
language={language}
theme="vs-dark"
options={{
automaticLayout: true,
fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 16,
wordWrap: "on",
padding: {
top: 16,
bottom: 16,
},
}}
path={path}
onChange={(newValue) => {
if (onChange && newValue) {
onChange(newValue)
}
}}
onMount={(editor, monaco) => {
// This jank allows for Ctrl + Enter to work outside the editor.
// We use this keybind to trigger a build.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Private type in Monaco!
;(editor as any)._standaloneKeybindingService.addDynamicKeybinding(
`-editor.action.insertLineAfter`,
undefined,
() => {
//
},
)
setEditor(editor)
document.fonts.ready
.then(() => {
// Ensures that all text is measured properly.
// If this isn't done, there can be weird selection issues.
monaco.editor.remeasureFonts()
})
.catch(() => {
// Not a biggie!
})
monaco.editor.defineTheme("min", {
base: "vs-dark",
inherit: true,
rules: [
{
token: "comment",
foreground: "6B737C",
},
{
token: "type",
foreground: "B392F0",
},
{
token: "string",
foreground: "9DB1C5",
},
{
token: "variable",
foreground: "BBBBBB",
},
{
token: "identifier",
foreground: "B392F0",
},
{
token: "delimiter.curly",
foreground: "EBB325",
},
],
colors: {
"editor.foreground": hslToHex(theme.palette.text.primary),
"editor.background": hslToHex(theme.palette.background.paper),
},
})
editor.updateOptions({
theme: "min",
})
}}
/>
)
}
@@ -0,0 +1,56 @@
import { Story } from "@storybook/react"
import {
MockTemplate,
MockTemplateVersion,
MockTemplateVersionFiles,
MockWorkspaceBuildLogs,
MockWorkspaceResource,
MockWorkspaceResource2,
MockWorkspaceResource3,
} from "testHelpers/entities"
import {
TemplateVersionEditor,
TemplateVersionEditorProps,
} from "./TemplateVersionEditor"
export default {
title: "components/TemplateVersionEditor",
component: TemplateVersionEditor,
parameters: {
layout: "fullscreen",
},
}
const Template: Story<TemplateVersionEditorProps> = (
args: TemplateVersionEditorProps,
) => <TemplateVersionEditor {...args} />
export const Example = Template.bind({})
Example.args = {
template: MockTemplate,
templateVersion: MockTemplateVersion,
initialFiles: MockTemplateVersionFiles,
}
export const Logs = Template.bind({})
Logs.args = {
template: MockTemplate,
templateVersion: MockTemplateVersion,
initialFiles: MockTemplateVersionFiles,
buildLogs: MockWorkspaceBuildLogs,
}
export const Resources = Template.bind({})
Resources.args = {
template: MockTemplate,
templateVersion: MockTemplateVersion,
initialFiles: MockTemplateVersionFiles,
buildLogs: MockWorkspaceBuildLogs,
resources: [
MockWorkspaceResource,
MockWorkspaceResource2,
MockWorkspaceResource3,
],
}
@@ -0,0 +1,530 @@
import Button from "@material-ui/core/Button"
import IconButton from "@material-ui/core/IconButton"
import { makeStyles, Theme } from "@material-ui/core/styles"
import Tooltip from "@material-ui/core/Tooltip"
import CreateIcon from "@material-ui/icons/AddBox"
import BuildIcon from "@material-ui/icons/BuildOutlined"
import PreviewIcon from "@material-ui/icons/Visibility"
import {
ProvisionerJobLog,
Template,
TemplateVersion,
WorkspaceResource,
} from "api/typesGenerated"
import { Avatar } from "components/Avatar/Avatar"
import { AvatarData } from "components/AvatarData/AvatarData"
import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable"
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs"
import { FC, useCallback, useEffect, useRef, useState } from "react"
import { navHeight } from "theme/constants"
import { TemplateVersionFiles } from "util/templateVersion"
import {
CreateFileDialog,
DeleteFileDialog,
RenameFileDialog,
} from "./FileDialog"
import { FileTree } from "./FileTree"
import { MonacoEditor } from "./MonacoEditor"
import {
getStatus,
TemplateVersionStatusBadge,
} from "./TemplateVersionStatusBadge"
interface File {
path: string
content?: string
children: Record<string, File>
}
export interface TemplateVersionEditorProps {
template: Template
templateVersion: TemplateVersion
initialFiles: TemplateVersionFiles
buildLogs?: ProvisionerJobLog[]
resources?: WorkspaceResource[]
disablePreview: boolean
disableUpdate: boolean
onPreview: (files: TemplateVersionFiles) => void
onUpdate: () => void
}
const topbarHeight = navHeight
export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
disablePreview,
disableUpdate,
template,
templateVersion,
initialFiles,
onPreview,
onUpdate,
buildLogs,
resources,
}) => {
const [selectedTab, setSelectedTab] = useState(() => {
// If resources are provided, show them by default!
// This is for Storybook!
return resources ? 1 : 0
})
const [files, setFiles] = useState(initialFiles)
const [createFileOpen, setCreateFileOpen] = useState(false)
const [deleteFileOpen, setDeleteFileOpen] = useState<File>()
const [renameFileOpen, setRenameFileOpen] = useState<File>()
const [activeFile, setActiveFile] = useState<File | undefined>(() => {
const fileKeys = Object.keys(initialFiles)
for (let i = 0; i < fileKeys.length; i++) {
// Open a Terraform file by default!
if (fileKeys[i].endsWith(".tf")) {
return {
path: fileKeys[i],
content: initialFiles[fileKeys[i]],
children: {},
}
}
}
})
const triggerPreview = useCallback(() => {
onPreview(files)
// Switch to the build log!
setSelectedTab(0)
}, [files, onPreview])
// Stop ctrl+s from saving files and make ctrl+enter trigger a preview.
useEffect(() => {
const keyListener = (event: KeyboardEvent) => {
if (!(navigator.platform.match("Mac") ? event.metaKey : event.ctrlKey)) {
return
}
switch (event.key) {
case "s":
// Prevent opening the save dialog!
event.preventDefault()
break
case "Enter":
event.preventDefault()
triggerPreview()
break
}
}
document.addEventListener("keydown", keyListener)
return () => {
document.removeEventListener("keydown", keyListener)
}
}, [files, triggerPreview])
// Automatically switch to the template preview tab when the build succeeds.
const previousVersion = useRef<TemplateVersion>()
useEffect(() => {
if (!previousVersion.current) {
previousVersion.current = templateVersion
return
}
if (
previousVersion.current.job.status === "running" &&
templateVersion.job.status === "succeeded"
) {
setSelectedTab(1)
setDirty(false)
}
previousVersion.current = templateVersion
}, [templateVersion])
const [dirty, setDirty] = useState(false)
const hasIcon = template.icon && template.icon !== ""
const templateVersionSucceeded = templateVersion.job.status === "succeeded"
const showBuildLogs = Boolean(buildLogs)
useEffect(() => {
window.dispatchEvent(new Event("resize"))
}, [showBuildLogs])
const styles = useStyles({
templateVersionSucceeded,
showBuildLogs,
})
return (
<div className={styles.root}>
<div className={styles.topbar}>
<div className={styles.topbarSides}>
<AvatarData
title={template.display_name || template.name}
subtitle={template.description}
avatar={
hasIcon && (
<Avatar src={template.icon} variant="square" fitImage />
)
}
/>
<div>Used By: {template.active_user_count} developers</div>
</div>
<div className={styles.topbarSides}>
<div className={styles.buildStatus}>
Build Status:
<TemplateVersionStatusBadge version={templateVersion} />
</div>
<Button
size="small"
variant="outlined"
color="primary"
disabled={disablePreview}
onClick={() => {
triggerPreview()
}}
>
Build (Ctrl + Enter)
</Button>
<Tooltip
title={
dirty
? "You have edited files! Run another build before updating."
: templateVersion.job.status !== "succeeded"
? "Something"
: ""
}
>
<span>
<Button
size="small"
variant="contained"
color="primary"
disabled={dirty || disableUpdate}
onClick={() => {
onUpdate()
}}
>
Publish New Version
</Button>
</span>
</Tooltip>
</div>
</div>
<div className={styles.sidebarAndEditor}>
<div className={styles.sidebar}>
<div className={styles.sidebarTitle}>
Template Editor
<div className={styles.sidebarActions}>
<Tooltip title="Create File" placement="top">
<IconButton
size="small"
aria-label="Create File"
onClick={(event) => {
setCreateFileOpen(true)
event.currentTarget.blur()
}}
>
<CreateIcon />
</IconButton>
</Tooltip>
</div>
<CreateFileDialog
open={createFileOpen}
onClose={() => {
setCreateFileOpen(false)
}}
checkExists={(path) => Boolean(files[path])}
onConfirm={(path) => {
setFiles({
...files,
[path]: "",
})
setActiveFile({
path,
content: "",
children: {},
})
setCreateFileOpen(false)
setDirty(true)
}}
/>
<DeleteFileDialog
onConfirm={() => {
if (!deleteFileOpen) {
throw new Error("delete file must be set")
}
const deleted = { ...files }
delete deleted[deleteFileOpen.path]
setFiles(deleted)
setDeleteFileOpen(undefined)
if (activeFile?.path === deleteFileOpen.path) {
setActiveFile(undefined)
}
setDirty(true)
}}
open={Boolean(deleteFileOpen)}
onClose={() => setDeleteFileOpen(undefined)}
filename={deleteFileOpen?.path || ""}
/>
<RenameFileDialog
open={Boolean(renameFileOpen)}
onClose={() => {
setRenameFileOpen(undefined)
}}
filename={renameFileOpen?.path || ""}
checkExists={(path) => Boolean(files[path])}
onConfirm={(newPath) => {
if (!renameFileOpen) {
return
}
const renamed = { ...files }
renamed[newPath] = renamed[renameFileOpen.path]
delete renamed[renameFileOpen.path]
setFiles(renamed)
renameFileOpen.path = newPath
setActiveFile(renameFileOpen)
setRenameFileOpen(undefined)
setDirty(true)
}}
/>
</div>
<FileTree
files={files}
onDelete={(file) => setDeleteFileOpen(file)}
onSelect={(file) => setActiveFile(file)}
onRename={(file) => setRenameFileOpen(file)}
activeFile={activeFile}
/>
</div>
<div className={styles.editorPane}>
<div className={styles.editor}>
{activeFile ? (
<MonacoEditor
value={activeFile?.content}
path={activeFile?.path}
onChange={(value) => {
if (!activeFile) {
return
}
setFiles({
...files,
[activeFile.path]: value,
})
setDirty(true)
}}
/>
) : (
<div>No file opened</div>
)}
</div>
<div className={styles.panelWrapper}>
<div className={styles.tabs}>
<button
className={`${styles.tab} ${selectedTab === 0 ? "active" : ""}`}
onClick={() => {
setSelectedTab(0)
}}
>
{templateVersion.job.status !== "succeeded" ? (
getStatus(templateVersion).icon
) : (
<BuildIcon />
)}
Build Log
</button>
{!disableUpdate && (
<button
className={`${styles.tab} ${
selectedTab === 1 ? "active" : ""
}`}
onClick={() => {
setSelectedTab(1)
}}
>
<PreviewIcon />
Workspace Preview
</button>
)}
</div>
<div
className={`${styles.panel} ${styles.buildLogs} ${
selectedTab === 0 ? "" : "hidden"
}`}
>
{buildLogs && (
<WorkspaceBuildLogs
templateEditorPane
hideTimestamps
logs={buildLogs}
/>
)}
{templateVersion.job.error && (
<div className={styles.buildLogError}>
{templateVersion.job.error}
</div>
)}
</div>
<div
className={`${styles.panel} ${styles.resources} ${
selectedTab === 1 ? "" : "hidden"
}`}
>
{resources && (
<TemplateResourcesTable
resources={resources.filter(
(r) => r.workspace_transition === "start",
)}
/>
)}
</div>
</div>
{templateVersionSucceeded && (
<>
<div className={styles.panelDivider} />
</>
)}
</div>
</div>
</div>
)
}
const useStyles = makeStyles<
Theme,
{
templateVersionSucceeded: boolean
showBuildLogs: boolean
}
>((theme) => ({
root: {
height: `calc(100vh - ${navHeight}px)`,
background: theme.palette.background.default,
flex: 1,
display: "flex",
flexDirection: "column",
},
topbar: {
padding: theme.spacing(2),
borderBottom: `1px solid ${theme.palette.divider}`,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
height: topbarHeight,
},
topbarSides: {
display: "flex",
alignItems: "center",
gap: 16,
},
buildStatus: {
display: "flex",
alignItems: "center",
gap: 8,
},
sidebarAndEditor: {
display: "flex",
flex: 1,
},
sidebar: {
minWidth: 256,
},
sidebarTitle: {
fontSize: 12,
textTransform: "uppercase",
padding: "8px 16px",
color: theme.palette.text.hint,
display: "flex",
alignItems: "center",
},
sidebarActions: {
marginLeft: "auto",
"& svg": {
fill: theme.palette.text.hint,
},
},
editorPane: {
display: "grid",
width: "100%",
gridTemplateColumns: (props) =>
props.showBuildLogs ? "0.6fr 0.4fr" : "1fr 0fr",
height: `calc(100vh - ${navHeight + topbarHeight}px)`,
overflow: "hidden",
},
editor: {
flex: 1,
},
panelWrapper: {
flex: 1,
display: "flex",
flexDirection: "column",
borderLeft: `1px solid ${theme.palette.divider}`,
overflowY: "auto",
},
panel: {
"&.hidden": {
display: "none",
},
},
tabs: {
borderBottom: `1px solid ${theme.palette.divider}`,
display: "flex",
boxShadow: "#000000 0 6px 6px -6px inset",
"& .MuiTab-root": {
padding: 0,
fontSize: 14,
textTransform: "none",
letterSpacing: "unset",
},
},
tab: {
cursor: "pointer",
padding: "8px 12px",
fontSize: 14,
background: "transparent",
fontFamily: "inherit",
border: 0,
color: theme.palette.text.hint,
transition: "150ms ease all",
display: "flex",
gap: 8,
alignItems: "center",
justifyContent: "center",
"& svg": {
maxWidth: 16,
maxHeight: 16,
},
"&.active": {
color: "white",
background: theme.palette.background.paperLight,
},
},
tabBar: {
padding: "8px 16px",
position: "sticky",
top: 0,
background: theme.palette.background.default,
borderBottom: `1px solid ${theme.palette.divider}`,
color: theme.palette.text.hint,
textTransform: "uppercase",
fontSize: 12,
"&.top": {
borderTop: `1px solid ${theme.palette.divider}`,
},
},
buildLogs: {
display: "flex",
flexDirection: "column-reverse",
overflowY: "auto",
},
buildLogError: {
whiteSpace: "pre-wrap",
},
resources: {
// padding: 16,
},
}))
@@ -0,0 +1,66 @@
import { TemplateVersion } from "api/typesGenerated"
import { FC, ReactNode } from "react"
import { PaletteIndex } from "theme/palettes"
import CircularProgress from "@material-ui/core/CircularProgress"
import ErrorIcon from "@material-ui/icons/ErrorOutline"
import CheckIcon from "@material-ui/icons/CheckOutlined"
import { Pill } from "components/Pill/Pill"
export const TemplateVersionStatusBadge: FC<{
version: TemplateVersion
}> = ({ version }) => {
const { text, icon, type } = getStatus(version)
return <Pill icon={icon} text={text} type={type} />
}
const LoadingIcon: FC = () => {
return <CircularProgress size={10} style={{ color: "#FFF" }} />
}
export const getStatus = (
version: TemplateVersion,
): {
type?: PaletteIndex
text: string
icon: ReactNode
} => {
switch (version.job.status) {
case "running":
return {
type: "info",
text: "Running",
icon: <LoadingIcon />,
}
case "pending":
return {
text: "Pending",
icon: <LoadingIcon />,
type: "info",
}
case "canceling":
return {
type: "warning",
text: "Canceling",
icon: <LoadingIcon />,
}
case "canceled":
return {
type: "warning",
text: "Canceled",
icon: <ErrorIcon />,
}
case "failed":
return {
type: "error",
text: "Failed",
icon: <ErrorIcon />,
}
case "succeeded":
return {
type: "success",
text: "Success",
icon: <CheckIcon />,
}
}
}
@@ -1,4 +1,4 @@
import { makeStyles } from "@material-ui/core/styles"
import { makeStyles, Theme } from "@material-ui/core/styles"
import dayjs from "dayjs"
import { FC, Fragment } from "react"
import { ProvisionerJobLog } from "../../api/typesGenerated"
@@ -39,12 +39,21 @@ const getStageDurationInSeconds = (logs: ProvisionerJobLog[]) => {
export interface WorkspaceBuildLogsProps {
logs: ProvisionerJobLog[]
hideTimestamps?: boolean
// If true, render different styles that fit the template editor pane
// a bit better.
templateEditorPane?: boolean
}
export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({ logs }) => {
export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
hideTimestamps,
logs,
templateEditorPane,
}) => {
const groupedLogsByStage = groupLogsByStage(logs)
const stages = Object.keys(groupedLogsByStage)
const styles = useStyles()
const styles = useStyles({ templateEditorPane: Boolean(templateEditorPane) })
return (
<div className={styles.logs}>
@@ -69,7 +78,7 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({ logs }) => {
</div>
)}
</div>
{!isEmpty && <Logs lines={lines} />}
{!isEmpty && <Logs hideTimestamps={hideTimestamps} lines={lines} />}
</Fragment>
)
})}
@@ -77,10 +86,16 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({ logs }) => {
)
}
const useStyles = makeStyles((theme) => ({
const useStyles = makeStyles<
Theme,
{
templateEditorPane: boolean
}
>((theme) => ({
logs: {
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
borderRadius: (props) =>
props.templateEditorPane ? "0px" : theme.shape.borderRadius,
fontFamily: MONOSPACE_FONT_FAMILY,
},
@@ -0,0 +1,73 @@
import { useMachine } from "@xstate/react"
import { TemplateVersionEditor } from "components/TemplateVersionEditor/TemplateVersionEditor"
import { useOrganizationId } from "hooks/useOrganizationId"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useParams } from "react-router-dom"
import { pageTitle } from "util/page"
import { templateVersionMachine } from "xServices/templateVersion/templateVersionXService"
import { templateVersionEditorMachine } from "xServices/templateVersionEditor/templateVersionEditorXService"
type Params = {
version: string
template: string
}
export const TemplateVersionEditorPage: FC = () => {
const { version: versionName, template: templateName } = useParams() as Params
const orgId = useOrganizationId()
const [versionState] = useMachine(templateVersionMachine, {
context: { templateName, versionName, orgId },
})
const [editorState, sendEvent] = useMachine(templateVersionEditorMachine, {
context: { orgId },
})
return (
<>
<Helmet>
<title>{pageTitle(`${templateName} · Template Editor`)}</title>
</Helmet>
{versionState.context.template &&
versionState.context.currentFiles &&
versionState.context.currentVersion && (
<TemplateVersionEditor
template={versionState.context.template}
templateVersion={
editorState.context.version || versionState.context.currentVersion
}
initialFiles={versionState.context.currentFiles}
onPreview={(files) => {
if (!versionState.context.template) {
throw new Error("no template")
}
// Send a cancel just in case a version is already being created!
sendEvent({
type: "CANCEL_VERSION",
})
sendEvent({
type: "CREATE_VERSION",
files: files,
templateId: versionState.context.template.id,
})
}}
onUpdate={() => {
sendEvent({
type: "UPDATE_ACTIVE_VERSION",
})
}}
disablePreview={editorState.hasTag("loading")}
disableUpdate={
editorState.hasTag("loading") ||
editorState.context.version?.job.status !== "succeeded"
}
resources={editorState.context.resources}
buildLogs={editorState.context.buildLogs}
/>
)}
</>
)
}
export default TemplateVersionEditorPage
@@ -1,4 +1,5 @@
import { useMachine } from "@xstate/react"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { useOrganizationId } from "hooks/useOrganizationId"
import { useTab } from "hooks/useTab"
import { FC } from "react"
@@ -22,6 +23,7 @@ export const TemplateVersionPage: FC = () => {
})
const tab = useTab("file", "0")
const { t } = useTranslation("templateVersionPage")
const dashboard = useDashboard()
return (
<>
@@ -36,6 +38,7 @@ export const TemplateVersionPage: FC = () => {
versionName={versionName}
templateName={templateName}
tab={tab}
canEdit={dashboard.experiments.includes("template_editor")}
/>
</>
)
@@ -41,6 +41,7 @@ const defaultArgs = {
tab,
templateName: MockTemplate.name,
versionName: MockTemplateVersion.name,
canEdit: true,
context: {
orgId: MockOrganization.id,
versionName: MockTemplateVersion.name,
@@ -1,5 +1,9 @@
import Button from "@material-ui/core/Button"
import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles"
import EditIcon from "@material-ui/icons/Edit"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { DockerIcon } from "components/Icons/DockerIcon"
import { MarkdownIcon } from "components/Icons/MarkdownIcon"
import { TerraformIcon } from "components/Icons/TerraformIcon"
import { Loader } from "components/Loader/Loader"
@@ -11,16 +15,15 @@ import {
} from "components/PageHeader/PageHeader"
import { Stack } from "components/Stack/Stack"
import { Stats, StatsItem } from "components/Stats/Stats"
import { SyntaxHighlighter } from "components/SyntaxHighlighter/SyntaxHighlighter"
import { UseTabResult } from "hooks/useTab"
import { FC } from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { Link as RouterLink } from "react-router-dom"
import { combineClasses } from "util/combineClasses"
import { createDayString } from "util/createDayString"
import { TemplateVersionMachineContext } from "xServices/templateVersion/templateVersionXService"
import { TemplateVersionFiles } from "util/templateVersion"
import { SyntaxHighlighter } from "components/SyntaxHighlighter/SyntaxHighlighter"
import { DockerIcon } from "components/Icons/DockerIcon"
import { TemplateVersionMachineContext } from "xServices/templateVersion/templateVersionXService"
const iconByExtension: Record<string, JSX.Element> = {
tf: <TerraformIcon />,
@@ -101,6 +104,7 @@ export interface TemplateVersionPageViewProps {
*/
versionName: string
templateName: string
canEdit: boolean
tab: UseTabResult
context: TemplateVersionMachineContext
}
@@ -110,13 +114,26 @@ export const TemplateVersionPageView: FC<TemplateVersionPageViewProps> = ({
tab,
versionName,
templateName,
canEdit,
}) => {
const { currentFiles, error, currentVersion, previousFiles } = context
const { t } = useTranslation("templateVersionPage")
return (
<Margins>
<PageHeader>
<PageHeader
actions={
canEdit ? (
<Link
underline="none"
component={RouterLink}
to={`/templates/${templateName}/versions/${versionName}/edit`}
>
<Button startIcon={<EditIcon />}>Edit</Button>
</Link>
) : undefined
}
>
<PageHeaderCaption>{t("header.caption")}</PageHeaderCaption>
<PageHeaderTitle>{versionName}</PageHeaderTitle>
</PageHeader>
@@ -131,7 +148,9 @@ export const TemplateVersionPageView: FC<TemplateVersionPageViewProps> = ({
<StatsItem
label={t("stats.template")}
value={
<Link to={`/templates/${templateName}`}>{templateName}</Link>
<RouterLink to={`/templates/${templateName}`}>
{templateName}
</RouterLink>
}
/>
<StatsItem
+33
View File
@@ -5,6 +5,7 @@ import * as Types from "../api/types"
import * as TypesGen from "../api/typesGenerated"
import range from "lodash/range"
import { Permissions } from "xServices/auth/authXService"
import { TemplateVersionFiles } from "util/templateVersion"
export const MockTemplateDAUResponse: TypesGen.TemplateDAUsResponse = {
entries: [
@@ -270,6 +271,38 @@ export const MockTemplate: TypesGen.Template = {
allow_user_cancel_workspace_jobs: true,
}
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",
@@ -1,9 +1,10 @@
import {
getPreviousTemplateVersionByName,
GetPreviousTemplateVersionByNameResponse,
getTemplateByName,
getTemplateVersionByName,
} from "api/api"
import { TemplateVersion } from "api/typesGenerated"
import { Template, TemplateVersion } from "api/typesGenerated"
import {
getTemplateVersionFiles,
TemplateVersionFiles,
@@ -14,6 +15,7 @@ export interface TemplateVersionMachineContext {
orgId: string
templateName: string
versionName: string
template?: Template
currentVersion?: TemplateVersion
currentFiles?: TemplateVersionFiles
error?: Error | unknown
@@ -35,6 +37,11 @@ export const templateVersionMachine = createMachine(
previousVersion: GetPreviousTemplateVersionByNameResponse
}
}
loadTemplate: {
data: {
template: Template
}
}
loadFiles: {
data: {
currentFiles: TemplateVersionFiles
@@ -44,20 +51,53 @@ export const templateVersionMachine = createMachine(
},
},
tsTypes: {} as import("./templateVersionXService.typegen").Typegen0,
initial: "loadingVersions",
initial: "initialInfo",
states: {
loadingVersions: {
invoke: {
src: "loadVersions",
onDone: {
target: "loadingFiles",
actions: ["assignVersions"],
initialInfo: {
type: "parallel",
states: {
versions: {
initial: "loadingVersions",
states: {
loadingVersions: {
invoke: {
src: "loadVersions",
onDone: [
{
actions: "assignVersions",
target: "success",
},
],
},
},
success: {
type: "final",
},
},
},
onError: {
target: "done.error",
actions: ["assignError"],
template: {
initial: "loadingTemplate",
states: {
loadingTemplate: {
invoke: {
src: "loadTemplate",
onDone: [
{
actions: "assignTemplate",
target: "success",
},
],
},
},
success: {
type: "final",
},
},
},
},
onDone: {
target: "loadingFiles",
},
},
loadingFiles: {
invoke: {
@@ -85,6 +125,9 @@ export const templateVersionMachine = createMachine(
assignError: assign({
error: (_, { data }) => data,
}),
assignTemplate: assign({
template: (_, { data }) => data.template,
}),
assignVersions: assign({
currentVersion: (_, { data }) => data.currentVersion,
previousVersion: (_, { data }) => data.previousVersion,
@@ -106,6 +149,13 @@ export const templateVersionMachine = createMachine(
previousVersion,
}
},
loadTemplate: async ({ orgId, templateName }) => {
const template = await getTemplateByName(orgId, templateName)
return {
template,
}
},
loadFiles: async ({ currentVersion, previousVersion }) => {
if (!currentVersion) {
throw new Error("Version is not defined")
@@ -0,0 +1,260 @@
import {
ProvisionerJobLog,
TemplateVersion,
UploadResponse,
WorkspaceResource,
} from "api/typesGenerated"
import { assign, createMachine } from "xstate"
import * as API from "api/api"
import { TemplateVersionFiles } from "util/templateVersion"
import * as Tar from "tar-js"
export interface CreateVersionData {
file: File
}
export interface TemplateVersionEditorMachineContext {
orgId: string
templateId?: string
files?: TemplateVersionFiles
uploadResponse?: UploadResponse
version?: TemplateVersion
resources?: WorkspaceResource[]
buildLogs?: ProvisionerJobLog[]
}
export const templateVersionEditorMachine = createMachine(
{
predictableActionArguments: true,
id: "templateVersionEditor",
schema: {
context: {} as TemplateVersionEditorMachineContext,
events: {} as
| {
type: "CREATE_VERSION"
files: TemplateVersionFiles
templateId: string
}
| { type: "CANCEL_VERSION" }
| { type: "ADD_BUILD_LOG"; log: ProvisionerJobLog }
| { type: "UPDATE_ACTIVE_VERSION" },
services: {} as {
createBuild: {
data: TemplateVersion
}
cancelBuild: {
data: TemplateVersion
}
},
},
initial: "idle",
states: {
idle: {
on: {
CREATE_VERSION: {
actions: ["assignCreateBuild"],
target: "uploadTar",
},
UPDATE_ACTIVE_VERSION: {
target: "updatingActiveVersion",
},
},
},
updatingActiveVersion: {
tags: "loading",
invoke: {
id: "updateActiveVersion",
src: "updateActiveVersion",
onDone: {
target: "idle",
},
},
},
uploadTar: {
tags: "loading",
invoke: {
id: "uploadTar",
src: "uploadTar",
onDone: {
target: "creatingBuild",
actions: ["assignUploadResponse"],
},
},
},
creatingBuild: {
tags: "loading",
invoke: {
id: "createBuild",
src: "createBuild",
onDone: {
actions: ["assignBuild"],
target: "watchingBuildLogs",
},
},
},
watchingBuildLogs: {
tags: "loading",
invoke: {
id: "watchBuildLogs",
src: "watchBuildLogs",
onDone: {
target: "fetchingVersion",
},
},
on: {
ADD_BUILD_LOG: {
actions: ["addBuildLog"],
},
CANCEL_VERSION: {
actions: ["cancelBuild"],
target: "idle",
},
CREATE_VERSION: {
actions: ["cancelBuild", "assignCreateBuild"],
target: "uploadTar",
},
},
},
fetchingVersion: {
tags: "loading",
invoke: {
id: "fetchVersion",
src: "fetchVersion",
onDone: {
actions: ["assignBuild"],
target: "fetchResources",
},
},
},
fetchResources: {
tags: "loading",
invoke: {
id: "getResources",
src: "getResources",
onDone: {
actions: ["assignResources"],
target: "idle",
},
},
},
},
},
{
actions: {
assignCreateBuild: assign({
files: (_, event) => event.files,
templateId: (_, event) => event.templateId,
buildLogs: [],
resources: [],
}),
assignResources: assign({
resources: (_, event) => event.data,
}),
assignUploadResponse: assign({
uploadResponse: (_, event) => event.data,
}),
assignBuild: assign({
version: (_, event) => event.data,
}),
addBuildLog: assign({
buildLogs: (context, event) => {
const previousLogs = context.buildLogs ?? []
return [...previousLogs, event.log]
},
// Instead of periodically fetching the version,
// we just assume the state is running after the first log.
//
// The machine fetches the version after the log stream ends anyways!
version: (context) => {
if (!context.version || context.buildLogs?.length !== 0) {
return context.version
}
return {
...context.version,
job: {
...context.version.job,
status: "running",
},
}
},
}),
},
services: {
uploadTar: (ctx) => {
if (!ctx.files) {
throw new Error("files must be set")
}
const tar = new Tar()
let out: Uint8Array = new Uint8Array()
Object.entries(ctx.files).forEach(([path, content]) => {
out = tar.append(path, content)
})
return API.uploadTemplateFile(new File([out], "template.tar"))
},
createBuild: (ctx) => {
if (!ctx.uploadResponse) {
throw new Error("no upload response")
}
return API.createTemplateVersion(ctx.orgId, {
provisioner: "terraform",
storage_method: "file",
tags: {},
template_id: ctx.templateId,
file_id: ctx.uploadResponse.hash,
})
},
fetchVersion: (ctx) => {
if (!ctx.version) {
throw new Error("template version must be set")
}
return API.getTemplateVersion(ctx.version.id)
},
watchBuildLogs: (ctx) => async (callback) => {
return new Promise<void>((resolve, reject) => {
if (!ctx.version) {
return reject("version must be set")
}
const proto = location.protocol === "https:" ? "wss:" : "ws:"
const socket = new WebSocket(
`${proto}//${location.host}/api/v2/templateversions/${ctx.version?.id}/logs?follow=true`,
)
socket.binaryType = "blob"
socket.addEventListener("message", (event) => {
callback({ type: "ADD_BUILD_LOG", log: JSON.parse(event.data) })
})
socket.addEventListener("error", () => {
reject(new Error("socket errored"))
})
socket.addEventListener("close", () => {
// When the socket closes, logs have finished streaming!
resolve()
})
})
},
getResources: (ctx) => {
if (!ctx.version) {
throw new Error("template version must be set")
}
return API.getTemplateVersionResources(ctx.version.id)
},
cancelBuild: (ctx) => {
if (!ctx.version) {
throw new Error("template version must be set")
}
return API.cancelTemplateVersionBuild(ctx.version.id)
},
updateActiveVersion: (ctx) => {
if (!ctx.templateId) {
throw new Error("template must be set")
}
if (!ctx.version) {
throw new Error("template version must be set")
}
return API.updateActiveTemplateVersion(ctx.templateId, {
id: ctx.version.id,
})
},
},
},
)
+15
View File
@@ -10832,6 +10832,11 @@ mock-socket@^9.1.0:
resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-9.1.5.tgz#2c4e44922ad556843b6dfe09d14ed8041fa2cdeb"
integrity sha512-3DeNIcsQixWHHKk6NdoBhWI4t1VMj5/HzfnI1rE/pLl5qKx7+gd4DNA07ehTaZ6MoUU053si6Hd+YtiM/tQZfg==
monaco-editor@^0.34.1:
version "0.34.1"
resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.34.1.tgz#1b75c4ad6bc4c1f9da656d740d98e0b850a22f87"
integrity sha512-FKc80TyiMaruhJKKPz5SpJPIjL+dflGvz4CpuThaPMc94AyN7SeC9HQ8hrvaxX7EyHdJcUY5i4D0gNyJj1vSZQ==
moo-color@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74"
@@ -12207,6 +12212,11 @@ react-fast-compare@^3.2.0:
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
react-headless-tabs@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/react-headless-tabs/-/react-headless-tabs-6.0.3.tgz#c10c899befdaa4f3fb3a16422810254ecc8e668d"
integrity sha512-VBkN/P1Mpc/4WPcbl0Is+LvVzlqjWxX3G6kOxGQZX63zKaVpU0WP5NBS43JqqgZTG7nJ3JMYTHVXe79uTmRg7w==
react-helmet-async@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/react-helmet-async/-/react-helmet-async-1.3.0.tgz#7bd5bf8c5c69ea9f02f6083f14ce33ef545c222e"
@@ -13658,6 +13668,11 @@ tapable@^2.1.1, tapable@^2.2.0:
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0"
integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==
tar-js@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/tar-js/-/tar-js-0.3.0.tgz#6949aabfb0ba18bb1562ae51a439fd0f30183a17"
integrity sha512-9uqP2hJUZNKRkwPDe5nXxXdzo6w+BFBPq9x/tyi5/U/DneuSesO/HMb0y5TeWpfcv49YDJTs7SrrZeeu8ZHWDA==
tar-stream@^2.0.1:
version "2.2.0"
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"