mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
Generated
+4
-2
@@ -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": {
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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}> </span>
|
||||
{!hideTimestamps && (
|
||||
<>
|
||||
<span className={styles.time}>
|
||||
{dayjs(line.time).format(`HH:mm:ss.SSS`)}
|
||||
</span>
|
||||
<span className={styles.space}> </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,
|
||||
},
|
||||
|
||||
|
||||
+73
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user