diff --git a/.vscode/settings.json b/.vscode/settings.json index a04dc17791..5c68867059 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "buildname", "circbuf", "cliflag", "cliui", @@ -55,6 +56,7 @@ "TCGETS", "tcpip", "TCSETS", + "testid", "tfexec", "tfjson", "tfstate", @@ -76,7 +78,7 @@ }, { "match": "provisionerd/proto/provisionerd.proto", - "cmd": "make provisionerd/proto/provisionerd.pb.go", + "cmd": "make provisionerd/proto/provisionerd.pb.go" } ] }, @@ -104,5 +106,5 @@ }, // We often use a version of TypeScript that's ahead of the version shipped // with VS Code. - "typescript.tsdk": "./site/node_modules/typescript/lib", + "typescript.tsdk": "./site/node_modules/typescript/lib" } diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index f0145fdde8..8e269f9251 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -15,6 +15,7 @@ import { SSHKeysPage } from "./pages/SettingsPages/SSHKeysPage/SSHKeysPage" import TemplatesPage from "./pages/TemplatesPage/TemplatesPage" import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage" import { UsersPage } from "./pages/UsersPage/UsersPage" +import { WorkspaceBuildPage } from "./pages/WorkspaceBuildPage/WorkspaceBuildPage" import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage" import { WorkspaceSettingsPage } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsPage" @@ -138,6 +139,15 @@ export const AppRouter: React.FC = () => ( + + + + } + /> + {/* Using path="*"" means "match anything", so this route acts like a catch-all for URLs that we don't have explicit routes for. */} diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 5f5786e9e4..1a234718b7 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -248,3 +248,13 @@ export const getWorkspaceBuilds = async (workspaceId: string): Promise(`/api/v2/workspaces/${workspaceId}/builds`) return response.data } + +export const getWorkspaceBuild = async (workspaceId: string): Promise => { + const response = await axios.get(`/api/v2/workspacebuilds/${workspaceId}`) + return response.data +} + +export const getWorkspaceBuildLogs = async (buildname: string): Promise => { + const response = await axios.get(`/api/v2/workspacebuilds/${buildname}/logs`) + return response.data +} diff --git a/site/src/components/BuildsTable/BuildsTable.tsx b/site/src/components/BuildsTable/BuildsTable.tsx index 3e26910894..0f800d0702 100644 --- a/site/src/components/BuildsTable/BuildsTable.tsx +++ b/site/src/components/BuildsTable/BuildsTable.tsx @@ -1,23 +1,18 @@ import Box from "@material-ui/core/Box" -import { Theme } from "@material-ui/core/styles" +import { makeStyles, Theme } from "@material-ui/core/styles" import Table from "@material-ui/core/Table" import TableBody from "@material-ui/core/TableBody" import TableCell from "@material-ui/core/TableCell" import TableHead from "@material-ui/core/TableHead" import TableRow from "@material-ui/core/TableRow" import useTheme from "@material-ui/styles/useTheme" -import dayjs from "dayjs" -import duration from "dayjs/plugin/duration" -import relativeTime from "dayjs/plugin/relativeTime" import React from "react" +import { useNavigate } from "react-router-dom" import * as TypesGen from "../../api/typesGenerated" -import { getDisplayStatus } from "../../util/workspace" +import { displayWorkspaceBuildDuration, getDisplayStatus } from "../../util/workspace" import { EmptyState } from "../EmptyState/EmptyState" import { TableLoader } from "../TableLoader/TableLoader" -dayjs.extend(relativeTime) -dayjs.extend(duration) - export const Language = { emptyMessage: "No builds found", inProgressLabel: "In progress", @@ -27,19 +22,6 @@ export const Language = { statusLabel: "Status", } -const getDurationInSeconds = (build: TypesGen.WorkspaceBuild) => { - let display = Language.inProgressLabel - - if (build.job.started_at && build.job.completed_at) { - const startedAt = dayjs(build.job.started_at) - const completedAt = dayjs(build.job.completed_at) - const diff = completedAt.diff(startedAt, "seconds") - display = `${diff} seconds` - } - - return display -} - export interface BuildsTableProps { builds?: TypesGen.WorkspaceBuild[] className?: string @@ -48,6 +30,8 @@ export interface BuildsTableProps { export const BuildsTable: React.FC = ({ builds, className }) => { const isLoading = !builds const theme: Theme = useTheme() + const navigate = useNavigate() + const styles = useStyles() return ( @@ -62,18 +46,35 @@ export const BuildsTable: React.FC = ({ builds, className }) = {isLoading && } {builds && - builds.map((b) => { - const status = getDisplayStatus(theme, b) - const duration = getDurationInSeconds(b) + builds.map((build) => { + const status = getDisplayStatus(theme, build) + + const navigateToBuildPage = () => { + navigate(`/builds/${build.id}`) + } return ( - - {b.transition} + { + if (event.key === "Enter") { + navigateToBuildPage() + } + }} + className={styles.clickableTableRow} + > + {build.transition} - {duration} + {displayWorkspaceBuildDuration(build)} - {new Date(b.created_at).toLocaleString()} + + {new Date(build.created_at).toLocaleString()} + {status.status} @@ -95,3 +96,17 @@ export const BuildsTable: React.FC = ({ builds, className }) =
) } + +const useStyles = makeStyles((theme) => ({ + clickableTableRow: { + cursor: "pointer", + + "&:hover td": { + backgroundColor: theme.palette.background.default, + }, + + "&:focus": { + outline: `1px solid ${theme.palette.primary.dark}`, + }, + }, +})) diff --git a/site/src/components/Loader/Loader.tsx b/site/src/components/Loader/Loader.tsx new file mode 100644 index 0000000000..465ee4455d --- /dev/null +++ b/site/src/components/Loader/Loader.tsx @@ -0,0 +1,11 @@ +import Box from "@material-ui/core/Box" +import CircularProgress from "@material-ui/core/CircularProgress" +import React from "react" + +export const Loader: React.FC<{ size?: number }> = ({ size = 26 }) => { + return ( + + + + ) +} diff --git a/site/src/components/Logs/Logs.stories.tsx b/site/src/components/Logs/Logs.stories.tsx new file mode 100644 index 0000000000..12ff5264a3 --- /dev/null +++ b/site/src/components/Logs/Logs.stories.tsx @@ -0,0 +1,20 @@ +import { ComponentMeta, Story } from "@storybook/react" +import React from "react" +import { MockWorkspaceBuildLogs } from "../../testHelpers/entities" +import { Logs, LogsProps } from "./Logs" + +export default { + title: "components/Logs", + component: Logs, +} as ComponentMeta + +const Template: Story = (args) => + +const lines = MockWorkspaceBuildLogs.map((log) => ({ + time: log.created_at, + output: log.output, +})) +export const Example = Template.bind({}) +Example.args = { + lines, +} diff --git a/site/src/components/Logs/Logs.tsx b/site/src/components/Logs/Logs.tsx new file mode 100644 index 0000000000..05541e79be --- /dev/null +++ b/site/src/components/Logs/Logs.tsx @@ -0,0 +1,53 @@ +import { makeStyles } from "@material-ui/core/styles" +import dayjs from "dayjs" +import React from "react" +import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" +import { combineClasses } from "../../util/combineClasses" + +interface Line { + time: string + output: string +} + +export interface LogsProps { + lines: Line[] + className?: string +} + +export const Logs: React.FC = ({ lines, className = "" }) => { + const styles = useStyles() + + return ( +
+ {lines.map((line, idx) => ( +
+
{dayjs(line.time).format(`HH:mm:ss.SSS`)}
+
{line.output}
+
+ ))} +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + root: { + minHeight: 156, + background: theme.palette.background.default, + color: theme.palette.text.primary, + fontFamily: MONOSPACE_FONT_FAMILY, + fontSize: 13, + wordBreak: "break-all", + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + overflowX: "auto", + }, + line: { + display: "flex", + alignItems: "baseline", + }, + time: { + width: theme.spacing(12.5), + marginRight: theme.spacing(3), + flexShrink: 0, + }, +})) diff --git a/site/src/components/TableLoader/TableLoader.tsx b/site/src/components/TableLoader/TableLoader.tsx index 938cf6c376..0caf1c8aa8 100644 --- a/site/src/components/TableLoader/TableLoader.tsx +++ b/site/src/components/TableLoader/TableLoader.tsx @@ -1,9 +1,8 @@ -import Box from "@material-ui/core/Box" -import CircularProgress from "@material-ui/core/CircularProgress" import { makeStyles } from "@material-ui/core/styles" import TableCell from "@material-ui/core/TableCell" import TableRow from "@material-ui/core/TableRow" import React from "react" +import { Loader } from "../Loader/Loader" export const TableLoader: React.FC = () => { const styles = useStyles() @@ -11,9 +10,7 @@ export const TableLoader: React.FC = () => { return ( - - - + ) diff --git a/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.stories.tsx b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.stories.tsx new file mode 100644 index 0000000000..73bb0e12ae --- /dev/null +++ b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.stories.tsx @@ -0,0 +1,16 @@ +import { ComponentMeta, Story } from "@storybook/react" +import React from "react" +import { MockWorkspaceBuildLogs } from "../../testHelpers/entities" +import { WorkspaceBuildLogs, WorkspaceBuildLogsProps } from "./WorkspaceBuildLogs" + +export default { + title: "components/WorkspaceBuildLogs", + component: WorkspaceBuildLogs, +} as ComponentMeta + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + logs: MockWorkspaceBuildLogs, +} diff --git a/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx new file mode 100644 index 0000000000..062408cb07 --- /dev/null +++ b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx @@ -0,0 +1,97 @@ +import { makeStyles } from "@material-ui/core/styles" +import dayjs from "dayjs" +import React from "react" +import { ProvisionerJobLog } from "../../api/typesGenerated" +import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" +import { Logs } from "../Logs/Logs" + +type Stage = ProvisionerJobLog["stage"] + +const groupLogsByStage = (logs: ProvisionerJobLog[]) => { + const logsByStage: Record = {} + + for (const log of logs) { + // If there is no log in the stage record, add an empty array + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (logsByStage[log.stage] === undefined) { + logsByStage[log.stage] = [] + } + + logsByStage[log.stage].push(log) + } + + return logsByStage +} + +const getStageDurationInSeconds = (logs: ProvisionerJobLog[]) => { + if (logs.length < 2) { + return + } + + const startedAt = dayjs(logs[0].created_at) + const completedAt = dayjs(logs[logs.length - 1].created_at) + return completedAt.diff(startedAt, "seconds") +} + +export interface WorkspaceBuildLogsProps { + logs: ProvisionerJobLog[] +} + +export const WorkspaceBuildLogs: React.FC = ({ logs }) => { + const groupedLogsByStage = groupLogsByStage(logs) + const stages = Object.keys(groupedLogsByStage) + const styles = useStyles() + + return ( +
+ {stages.map((stage) => { + const logs = groupedLogsByStage[stage] + const isEmpty = logs.every((log) => log.output === "") + const lines = logs.map((log) => ({ + time: log.created_at, + output: log.output, + })) + const duration = getStageDurationInSeconds(logs) + + return ( +
+
+
{stage}
+ {duration &&
{duration} seconds
} +
+ {!isEmpty && } +
+ ) + })} +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + logs: { + border: `1px solid ${theme.palette.divider}`, + borderRadius: 2, + fontFamily: MONOSPACE_FONT_FAMILY, + }, + + header: { + fontSize: theme.typography.body1.fontSize, + padding: theme.spacing(2), + paddingLeft: theme.spacing(4), + borderBottom: `1px solid ${theme.palette.divider}`, + backgroundColor: theme.palette.background.paper, + display: "flex", + alignItems: "center", + }, + + duration: { + marginLeft: "auto", + color: theme.palette.text.secondary, + fontSize: theme.typography.body2.fontSize, + }, + + codeBlock: { + padding: theme.spacing(2), + paddingLeft: theme.spacing(4), + }, +})) diff --git a/site/src/components/WorkspaceBuildStats/WorkspaceBuildStats.stories.tsx b/site/src/components/WorkspaceBuildStats/WorkspaceBuildStats.stories.tsx new file mode 100644 index 0000000000..bfa3e7244f --- /dev/null +++ b/site/src/components/WorkspaceBuildStats/WorkspaceBuildStats.stories.tsx @@ -0,0 +1,16 @@ +import { ComponentMeta, Story } from "@storybook/react" +import React from "react" +import { MockWorkspaceBuild } from "../../testHelpers/entities" +import { WorkspaceBuildStats, WorkspaceBuildStatsProps } from "./WorkspaceBuildStats" + +export default { + title: "components/WorkspaceBuildStats", + component: WorkspaceBuildStats, +} as ComponentMeta + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + build: MockWorkspaceBuild, +} diff --git a/site/src/components/WorkspaceBuildStats/WorkspaceBuildStats.tsx b/site/src/components/WorkspaceBuildStats/WorkspaceBuildStats.tsx new file mode 100644 index 0000000000..6d510955d0 --- /dev/null +++ b/site/src/components/WorkspaceBuildStats/WorkspaceBuildStats.tsx @@ -0,0 +1,101 @@ +import Link from "@material-ui/core/Link" +import { makeStyles, useTheme } from "@material-ui/core/styles" +import React from "react" +import { Link as RouterLink } from "react-router-dom" +import { WorkspaceBuild } from "../../api/typesGenerated" +import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" +import { combineClasses } from "../../util/combineClasses" +import { displayWorkspaceBuildDuration, getDisplayStatus } from "../../util/workspace" + +export interface WorkspaceBuildStatsProps { + build: WorkspaceBuild +} + +export const WorkspaceBuildStats: React.FC = ({ build }) => { + const styles = useStyles() + const theme = useTheme() + const status = getDisplayStatus(theme, build) + + return ( +
+
+ Workspace ID + + {build.workspace_id} + +
+
+ +
+ Duration + {displayWorkspaceBuildDuration(build)} +
+
+
+ Started at + {new Date(build.created_at).toLocaleString()} +
+
+
+ Status + + {status.status} + +
+
+
+ Action + {build.transition} +
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + stats: { + paddingTop: theme.spacing(3), + paddingBottom: theme.spacing(3), + display: "flex", + alignItems: "center", + color: theme.palette.text.secondary, + fontFamily: MONOSPACE_FONT_FAMILY, + }, + + statItem: { + minWidth: theme.spacing(20), + paddingRight: theme.spacing(3), + }, + + statsLabel: { + fontSize: 12, + textTransform: "uppercase", + display: "block", + fontWeight: 600, + }, + + statsValue: { + fontSize: 16, + marginTop: theme.spacing(0.25), + display: "block", + }, + + statsDivider: { + width: 1, + height: theme.spacing(5), + backgroundColor: theme.palette.divider, + marginRight: theme.spacing(3), + }, + + capitalize: { + textTransform: "capitalize", + }, + + link: { + color: theme.palette.text.primary, + fontWeight: 600, + }, +})) diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.test.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.test.tsx new file mode 100644 index 0000000000..492e3d8fe0 --- /dev/null +++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.test.tsx @@ -0,0 +1,13 @@ +import { screen } from "@testing-library/react" +import React from "react" +import { MockWorkspaceBuild, MockWorkspaceBuildLogs, renderWithAuth } from "../../testHelpers/renderHelpers" +import { WorkspaceBuildPage } from "./WorkspaceBuildPage" + +describe("WorkspaceBuildPage", () => { + it("renders the stats and logs", async () => { + renderWithAuth(, { route: `/builds/${MockWorkspaceBuild.id}`, path: "/builds/:buildId" }) + + await screen.findByText(MockWorkspaceBuild.workspace_id) + await screen.findByText(MockWorkspaceBuildLogs[0].stage) + }) +}) diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.tsx new file mode 100644 index 0000000000..d840ef32a7 --- /dev/null +++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPage.tsx @@ -0,0 +1,48 @@ +import { makeStyles } from "@material-ui/core/styles" +import Typography from "@material-ui/core/Typography" +import { useMachine } from "@xstate/react" +import React from "react" +import { useParams } from "react-router-dom" +import { Loader } from "../../components/Loader/Loader" +import { Margins } from "../../components/Margins/Margins" +import { Stack } from "../../components/Stack/Stack" +import { WorkspaceBuildLogs } from "../../components/WorkspaceBuildLogs/WorkspaceBuildLogs" +import { WorkspaceBuildStats } from "../../components/WorkspaceBuildStats/WorkspaceBuildStats" +import { workspaceBuildMachine } from "../../xServices/workspaceBuild/workspaceBuildXService" + +const useBuildId = () => { + const { buildId } = useParams() + + if (!buildId) { + throw new Error("buildId param is required.") + } + + return buildId +} + +export const WorkspaceBuildPage: React.FC = () => { + const buildId = useBuildId() + const [buildState] = useMachine(workspaceBuildMachine, { context: { buildId } }) + const { logs, build } = buildState.context + const styles = useStyles() + + return ( + + + + Logs + + + {build && } + {!logs && } + {logs && } + + + ) +} + +const useStyles = makeStyles((theme) => ({ + title: { + marginTop: theme.spacing(5), + }, +})) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index fc67c169c1..bd3c48c857 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -207,3 +207,272 @@ export const MockGitSSHKey: TypesGen.GitSSHKey = { updated_at: "2022-05-16T15:29:10.302441433Z", public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFJOQRIM7kE30rOzrfy+/+R+nQGCk7S9pioihy+2ARbq", } + +export const MockWorkspaceBuildLogs: TypesGen.ProvisionerJobLog[] = [ + { + id: "836f8ab6-5202-4711-afa5-293394ced011", + created_at: "2022-05-19T16:45:31.005Z", + log_source: "provisioner_daemon", + log_level: "info", + stage: "Setting up", + output: "", + }, + { + id: "2db0ae92-b310-4a6e-8b1f-23380b70ac7f", + created_at: "2022-05-19T16:45:31.006Z", + log_source: "provisioner_daemon", + log_level: "info", + stage: "Starting workspace", + output: "", + }, + { + id: "37a5b7b1-b3eb-47cf-b80b-bd16e2e08a3d", + created_at: "2022-05-19T16:45:31.072Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: "", + }, + { + id: "5e4e37a1-c217-48bc-84f5-7f1c3efbd042", + created_at: "2022-05-19T16:45:31.073Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: "Initializing the backend...", + }, + { + id: "060ed132-5d12-4584-9005-5c9557febe2f", + created_at: "2022-05-19T16:45:31.077Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: "", + }, + { + id: "b2e70a1c-1943-4616-8ac9-25326c9f7e7b", + created_at: "2022-05-19T16:45:31.078Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: "Initializing provider plugins...", + }, + { + id: "993107fe-6dfb-42ec-912a-b32f50e60d62", + created_at: "2022-05-19T16:45:31.078Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: '- Finding hashicorp/google versions matching "~\u003e 4.15"...', + }, + { + id: "2ad2e2a1-7a75-4827-8cb9-928acfc6fc07", + created_at: "2022-05-19T16:45:31.123Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: '- Finding coder/coder versions matching "0.3.4"...', + }, + { + id: "7c723a90-0190-4c2f-9d97-ede39ef3d55f", + created_at: "2022-05-19T16:45:31.137Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: "- Using hashicorp/google v4.21.0 from the shared cache directory", + }, + { + id: "3910144b-411b-4a53-9900-88d406ed9bf4", + created_at: "2022-05-19T16:45:31.344Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: "- Using coder/coder v0.3.4 from the shared cache directory", + }, + { + id: "e3a02ad4-edc0-442f-8b9a-39d01d56b43b", + created_at: "2022-05-19T16:45:31.388Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: "", + }, + { + id: "440cceb3-aabf-4838-979b-1fd37fe2d8d8", + created_at: "2022-05-19T16:45:31.388Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: "Terraform has created a lock file .terraform.lock.hcl to record the provider", + }, + { + id: "90e1f244-78ff-4d95-871e-b2bebcabc39a", + created_at: "2022-05-19T16:45:31.389Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: "selections it made above. Include this file in your version control repository", + }, + { + id: "e4527d6c-2412-452b-a946-5870787caf6b", + created_at: "2022-05-19T16:45:31.389Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: "so that Terraform can guarantee to make the same selections by default when", + }, + { + id: "02f96d19-d94b-4d0e-a1c4-313a0d2ff9e3", + created_at: "2022-05-19T16:45:31.39Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: 'you run "terraform init" in the future.', + }, + { + id: "667c03ca-1b24-4f36-a598-f0322cf3e2a1", + created_at: "2022-05-19T16:45:31.39Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: "", + }, + { + id: "48039d6a-9b21-460f-9ca3-4b0e2becfd18", + created_at: "2022-05-19T16:45:31.391Z", + log_source: "provisioner", + log_level: "debug", + stage: "Starting workspace", + output: "Terraform has been successfully initialized!", + }, + { + id: "6fe4b64f-3aa6-4850-96e9-6db8478a53be", + created_at: "2022-05-19T16:45:31.42Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "Terraform 1.1.9", + }, + { + id: "fa7b6321-7ecd-492d-a671-6366186fad08", + created_at: "2022-05-19T16:45:33.537Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "coder_agent.dev: Plan to create", + }, + { + id: "e677e49f-c5ba-417c-8c9d-78bdad744ce1", + created_at: "2022-05-19T16:45:33.537Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "google_compute_disk.root: Plan to create", + }, + { + id: "4b0e6168-29e4-4419-bf81-b57e31087666", + created_at: "2022-05-19T16:45:33.538Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "google_compute_instance.dev[0]: Plan to create", + }, + { + id: "5902f89c-8acd-45e2-9bd6-de4d6fd8fc9c", + created_at: "2022-05-19T16:45:33.539Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "Plan: 3 to add, 0 to change, 0 to destroy.", + }, + { + id: "a8107907-7c53-4aae-bb48-9a5f9759c7d5", + created_at: "2022-05-19T16:45:33.712Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "coder_agent.dev: Creating...", + }, + { + id: "aaf13503-2f1a-4f6c-aced-b8fc48304dc1", + created_at: "2022-05-19T16:45:33.719Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "coder_agent.dev: Creation complete after 0s [id=d07f5bdc-4a8d-4919-9cdb-0ac6ba9e64d6]", + }, + { + id: "4ada8886-f5b3-4fee-a1a3-72064b50d5ae", + created_at: "2022-05-19T16:45:34.139Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "google_compute_disk.root: Creating...", + }, + { + id: "8ffc59e8-a4d0-4ffe-9bcc-cb84ca51cc22", + created_at: "2022-05-19T16:45:44.14Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "google_compute_disk.root: Still creating... [10s elapsed]", + }, + { + id: "063189fd-75ad-415a-ac77-8c34b9e202b2", + created_at: "2022-05-19T16:45:47.106Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: + "google_compute_disk.root: Creation complete after 13s [id=projects/bruno-coder-v2/zones/europe-west4-b/disks/coder-developer-bruno-dev-123-root]", + }, + { + id: "6fd554a1-a7a2-439f-b8d8-369d6c1ead21", + created_at: "2022-05-19T16:45:47.118Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "google_compute_instance.dev[0]: Creating...", + }, + { + id: "87388f7e-ab01-44b1-b35e-8e06636164d3", + created_at: "2022-05-19T16:45:57.122Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "google_compute_instance.dev[0]: Still creating... [10s elapsed]", + }, + { + id: "baa40120-3f18-40d2-a35c-b11f421a1ce1", + created_at: "2022-05-19T16:46:00.837Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: + "google_compute_instance.dev[0]: Creation complete after 14s [id=projects/bruno-coder-v2/zones/europe-west4-b/instances/coder-developer-bruno-dev-123]", + }, + { + id: "00e18953-fba6-4b43-97a3-ecf376553c08", + created_at: "2022-05-19T16:46:00.846Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "Apply complete! Resources: 3 added, 0 changed, 0 destroyed.", + }, + { + id: "431811da-b534-4d92-b6e5-44814548c812", + created_at: "2022-05-19T16:46:00.847Z", + log_source: "provisioner", + log_level: "info", + stage: "Starting workspace", + output: "Outputs: 0", + }, + { + id: "70459334-4878-4bda-a546-98eee166c4c6", + created_at: "2022-05-19T16:46:02.283Z", + log_source: "provisioner_daemon", + log_level: "info", + stage: "Cleaning Up", + output: "", + }, +] diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 293b117077..48d35d8afa 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -118,7 +118,13 @@ export const handlers = [ }), // workspace builds + rest.get("/api/v2/workspacebuilds/:workspaceBuildId", (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockWorkspaceBuild)) + }), rest.get("/api/v2/workspacebuilds/:workspaceBuildId/resources", (req, res, ctx) => { return res(ctx.status(200), ctx.json([M.MockWorkspaceResource])) }), + rest.get("/api/v2/workspacebuilds/:workspaceBuildId/logs", (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockWorkspaceBuildLogs)) + }), ] diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index 1c36ce9583..fba0542a53 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -1,4 +1,5 @@ import { Theme } from "@material-ui/core/styles" +import dayjs from "dayjs" import { WorkspaceBuildTransition } from "../api/types" import { WorkspaceBuild } from "../api/typesGenerated" @@ -116,3 +117,20 @@ export const getDisplayStatus = ( } throw new Error("unknown status " + status) } + +export const getWorkspaceBuildDurationInSeconds = (build: WorkspaceBuild): number | undefined => { + const isCompleted = build.job.started_at && build.job.completed_at + + if (!isCompleted) { + return + } + + const startedAt = dayjs(build.job.started_at) + const completedAt = dayjs(build.job.completed_at) + return completedAt.diff(startedAt, "seconds") +} + +export const displayWorkspaceBuildDuration = (build: WorkspaceBuild, inProgressLabel = "In progress"): string => { + const duration = getWorkspaceBuildDurationInSeconds(build) + return duration ? `${duration} seconds` : inProgressLabel +} diff --git a/site/src/xServices/workspaceBuild/workspaceBuildXService.ts b/site/src/xServices/workspaceBuild/workspaceBuildXService.ts new file mode 100644 index 0000000000..b6cfa8bf3e --- /dev/null +++ b/site/src/xServices/workspaceBuild/workspaceBuildXService.ts @@ -0,0 +1,102 @@ +import { assign, createMachine } from "xstate" +import * as API from "../../api/api" +import { ProvisionerJobLog, WorkspaceBuild } from "../../api/typesGenerated" + +type LogsContext = { + // Build + buildId: string + build?: WorkspaceBuild + getBuildError?: Error | unknown + // Logs + logs?: ProvisionerJobLog[] + getBuildLogsError?: Error | unknown +} + +export const workspaceBuildMachine = createMachine( + { + id: "workspaceBuildState", + schema: { + context: {} as LogsContext, + services: {} as { + getWorkspaceBuild: { + data: WorkspaceBuild + } + getWorkspaceBuildLogs: { + data: ProvisionerJobLog[] + } + }, + }, + tsTypes: {} as import("./workspaceBuildXService.typegen").Typegen0, + type: "parallel", + states: { + build: { + initial: "gettingBuild", + states: { + gettingBuild: { + entry: "clearGetBuildError", + invoke: { + src: "getWorkspaceBuild", + onDone: { + target: "idle", + actions: "assignBuild", + }, + onError: { + target: "idle", + actions: "assignGetBuildError", + }, + }, + }, + idle: {}, + }, + }, + logs: { + initial: "gettingLogs", + states: { + gettingLogs: { + entry: "clearGetBuildLogsError", + invoke: { + src: "getWorkspaceBuildLogs", + onDone: { + target: "idle", + actions: "assignLogs", + }, + onError: { + target: "idle", + actions: "assignGetBuildLogsError", + }, + }, + }, + idle: {}, + }, + }, + }, + }, + { + actions: { + // Build + assignBuild: assign({ + build: (_, event) => event.data, + }), + assignGetBuildError: assign({ + getBuildError: (_, event) => event.data, + }), + clearGetBuildError: assign({ + getBuildError: (_) => undefined, + }), + // Logs + assignLogs: assign({ + logs: (_, event) => event.data, + }), + assignGetBuildLogsError: assign({ + getBuildLogsError: (_, event) => event.data, + }), + clearGetBuildLogsError: assign({ + getBuildLogsError: (_) => undefined, + }), + }, + services: { + getWorkspaceBuild: (ctx) => API.getWorkspaceBuild(ctx.buildId), + getWorkspaceBuildLogs: (ctx) => API.getWorkspaceBuildLogs(ctx.buildId), + }, + }, +)