diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx
index 6633ef917b7ec..e2b1a828da266 100644
--- a/site/src/AppRouter.tsx
+++ b/site/src/AppRouter.tsx
@@ -19,6 +19,7 @@ import { WorkspaceBuildPage } from "./pages/WorkspaceBuildPage/WorkspaceBuildPag
import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage"
import { WorkspaceSchedulePage } from "./pages/WorkspaceSchedulePage/WorkspaceSchedulePage"
+const WorkspaceAppErrorPage = lazy(() => import("./pages/WorkspaceAppErrorPage/WorkspaceAppErrorPage"))
const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage"))
const WorkspacesPage = lazy(() => import("./pages/WorkspacesPage/WorkspacesPage"))
const CreateWorkspacePage = lazy(() => import("./pages/CreateWorkspacePage/CreateWorkspacePage"))
@@ -26,138 +27,147 @@ const CreateWorkspacePage = lazy(() => import("./pages/CreateWorkspacePage/Creat
export const AppRouter: FC = () => (
>}>
-
+
+
+
+ }
+ />
+
+ } />
+ } />
+
+
+
+ }
+ />
+
+
-
-
+
+
+
}
/>
- } />
- } />
-
+
}
/>
-
+
-
+
}
/>
-
-
+
}
/>
-
-
-
-
-
- }
- />
-
-
-
- }
- />
-
+
-
-
-
-
- }
- />
+
+
+
+
+ }
+ />
-
-
-
- }
- />
-
+
+
+
+ }
+ />
+
-
-
-
-
- }
- />
+
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+ }>
+ } />
+ } />
+ } />
+
+
+
+
+
+ }
+ />
+
+
+
-
+
}
/>
-
- }>
- } />
- } />
- } />
-
-
-
-
+
-
-
+
+
+
}
/>
+
-
-
-
- }
- />
-
- {/* Using path="*"" means "match anything", so this route
+ {/* 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/components/AppLink/AppLink.stories.tsx b/site/src/components/AppLink/AppLink.stories.tsx
new file mode 100644
index 0000000000000..1b67d439784e6
--- /dev/null
+++ b/site/src/components/AppLink/AppLink.stories.tsx
@@ -0,0 +1,25 @@
+import { Story } from "@storybook/react"
+import { MockWorkspace } from "../../testHelpers/renderHelpers"
+import { AppLink, AppLinkProps } from "./AppLink"
+
+export default {
+ title: "components/AppLink",
+ component: AppLink,
+}
+
+const Template: Story = (args) =>
+
+export const WithIcon = Template.bind({})
+WithIcon.args = {
+ userName: "developer",
+ workspaceName: MockWorkspace.name,
+ appName: "code-server",
+ appIcon: "/code.svg",
+}
+
+export const WithoutIcon = Template.bind({})
+WithoutIcon.args = {
+ userName: "developer",
+ workspaceName: MockWorkspace.name,
+ appName: "code-server",
+}
diff --git a/site/src/components/AppLink/AppLink.tsx b/site/src/components/AppLink/AppLink.tsx
new file mode 100644
index 0000000000000..425e60fd63bda
--- /dev/null
+++ b/site/src/components/AppLink/AppLink.tsx
@@ -0,0 +1,48 @@
+import Link from "@material-ui/core/Link"
+import { makeStyles } from "@material-ui/core/styles"
+import React, { FC } from "react"
+import * as TypesGen from "../../api/typesGenerated"
+import { combineClasses } from "../../util/combineClasses"
+
+export interface AppLinkProps {
+ userName: TypesGen.User["username"]
+ workspaceName: TypesGen.Workspace["name"]
+ appName: TypesGen.WorkspaceApp["name"]
+ appIcon: TypesGen.WorkspaceApp["icon"]
+}
+
+export const AppLink: FC = ({ userName, workspaceName, appName, appIcon }) => {
+ const styles = useStyles()
+ const href = `/@${userName}/${workspaceName}/apps/${appName}`
+
+ return (
+
+
+ {appName}
+
+ )
+}
+
+const useStyles = makeStyles((theme) => ({
+ link: {
+ color: theme.palette.text.secondary,
+ display: "flex",
+ alignItems: "center",
+ },
+
+ icon: {
+ width: 16,
+ height: 16,
+ marginRight: theme.spacing(1.5),
+
+ // If no icon is provided we still want the padding on the left
+ // to occur.
+ "&.empty": {
+ opacity: 0,
+ },
+ },
+}))
diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx
index d959d53a501a9..73ae16713d1f5 100644
--- a/site/src/components/Resources/Resources.tsx
+++ b/site/src/components/Resources/Resources.tsx
@@ -8,6 +8,8 @@ import useTheme from "@material-ui/styles/useTheme"
import { FC } from "react"
import { Workspace, WorkspaceResource } from "../../api/typesGenerated"
import { getDisplayAgentStatus } from "../../util/workspace"
+import { AppLink } from "../AppLink/AppLink"
+import { Stack } from "../Stack/Stack"
import { TableHeaderRow } from "../TableHeaders/TableHeaders"
import { TerminalLink } from "../TerminalLink/TerminalLink"
import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
@@ -83,14 +85,26 @@ export const Resources: FC = ({ resources, getResourcesError, wo
{agent.operating_system}
- {agent.status === "connected" && (
-
- )}
+
+ {agent.status === "connected" && (
+
+ )}
+ {agent.status === "connected" &&
+ agent.apps.map((app) => (
+
+ ))}
+
diff --git a/site/src/components/TerminalLink/TerminalLink.tsx b/site/src/components/TerminalLink/TerminalLink.tsx
index dbe7941151ad7..fbfc2d07eb183 100644
--- a/site/src/components/TerminalLink/TerminalLink.tsx
+++ b/site/src/components/TerminalLink/TerminalLink.tsx
@@ -26,7 +26,7 @@ export interface TerminalLinkProps {
*/
export const TerminalLink: FC = ({ agentName, userName = "me", workspaceName, className }) => {
const styles = useStyles()
- const href = `/${userName}/${workspaceName}${agentName ? `.${agentName}` : ""}/terminal`
+ const href = `/@${userName}/${workspaceName}${agentName ? `.${agentName}` : ""}/terminal`
return (
{
+ const { app } = useParams()
+ const message = useMemo(() => {
+ const tag = document.getElementById("api-response")
+ if (!tag) {
+ throw new Error("dev error: api-response meta tag not found")
+ }
+ return tag.getAttribute("data-message") as string
+ }, [])
+
+ return
+}
+
+export default WorkspaceAppErrorView
diff --git a/site/src/pages/WorkspaceAppErrorPage/WorkspaceAppErrorPageView.stories.tsx b/site/src/pages/WorkspaceAppErrorPage/WorkspaceAppErrorPageView.stories.tsx
new file mode 100644
index 0000000000000..e8f0023f62d9f
--- /dev/null
+++ b/site/src/pages/WorkspaceAppErrorPage/WorkspaceAppErrorPageView.stories.tsx
@@ -0,0 +1,16 @@
+import { Story } from "@storybook/react"
+import { WorkspaceAppErrorPageView, WorkspaceAppErrorPageViewProps } from "./WorkspaceAppErrorPageView"
+
+export default {
+ title: "pages/WorkspaceAppErrorPageView",
+ component: WorkspaceAppErrorPageView,
+}
+
+const Template: Story = (args) =>
+
+export const NotRunning = Template.bind({})
+NotRunning.args = {
+ appName: "code-server",
+ // This is a real message copied and pasted from the backend!
+ message: "remote dial error: dial 'tcp://localhost:13337': dial tcp 127.0.0.1:13337: connect: connection refused",
+}
diff --git a/site/src/pages/WorkspaceAppErrorPage/WorkspaceAppErrorPageView.tsx b/site/src/pages/WorkspaceAppErrorPage/WorkspaceAppErrorPageView.tsx
new file mode 100644
index 0000000000000..b84e02465678e
--- /dev/null
+++ b/site/src/pages/WorkspaceAppErrorPage/WorkspaceAppErrorPageView.tsx
@@ -0,0 +1,31 @@
+import { makeStyles } from "@material-ui/core/styles"
+import { FC } from "react"
+
+export interface WorkspaceAppErrorPageViewProps {
+ appName: string
+ message: string
+}
+
+export const WorkspaceAppErrorPageView: FC = (props) => {
+ const styles = useStyles()
+
+ return (
+
+
{props.appName} is offline!
+
{props.message}
+
+ )
+}
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ flex: 1,
+ padding: theme.spacing(10),
+ },
+ title: {
+ textAlign: "center",
+ },
+ message: {
+ textAlign: "center",
+ },
+}))
diff --git a/site/static/code.svg b/site/static/code.svg
new file mode 100644
index 0000000000000..c6ee366939ba4
--- /dev/null
+++ b/site/static/code.svg
@@ -0,0 +1,41 @@
+
diff --git a/site/webpack.dev.ts b/site/webpack.dev.ts
index c51658e8e670d..1e3b80f2de873 100644
--- a/site/webpack.dev.ts
+++ b/site/webpack.dev.ts
@@ -14,7 +14,7 @@ const commonPlugins = commonWebpackConfig.plugins || []
const commonRules = commonWebpackConfig.module?.rules || []
const config: Configuration = {
- ...createCommonWebpackConfig,
+ ...commonWebpackConfig,
// devtool controls how source maps are generated. In development, we want
// more details (less optimized) for more readability and an easier time