feat: Add links to the resource card for workspace applications (#2067)

* fix: Use proper webpack config for dev mode

This was broken when improving the build times. The typechecker
unfortunately missed it!

* feat: Add links to the resource card for workspace applications

Fixes #1907 and #805.

I'll make this pretty in another PR!

* Improve style
This commit is contained in:
Kyle Carberry
2022-06-06 09:50:07 -05:00
committed by GitHub
parent 722dbab337
commit ab8235f53e
9 changed files with 334 additions and 131 deletions
+132 -122
View File
@@ -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 = () => (
<Suspense fallback={<></>}>
<Routes>
<Route path="/">
<Route
index
element={
<RequireAuth>
<IndexPage />
</RequireAuth>
}
/>
<Route path="login" element={<LoginPage />} />
<Route path="healthz" element={<HealthzPage />} />
<Route
path="cli-auth"
element={
<RequireAuth>
<CliAuthenticationPage />
</RequireAuth>
}
/>
<Route path="workspaces">
<Route
index
element={
<RequireAuth>
<IndexPage />
</RequireAuth>
}
/>
<Route path="login" element={<LoginPage />} />
<Route path="healthz" element={<HealthzPage />} />
<Route
path="cli-auth"
element={
<RequireAuth>
<CliAuthenticationPage />
</RequireAuth>
}
/>
<Route path="workspaces">
<Route
index
element={
<AuthAndFrame>
<WorkspacesPage />
</AuthAndFrame>
}
/>
<Route
path="new"
element={
<RequireAuth>
<CreateWorkspacePage />
</RequireAuth>
}
/>
<Route path=":workspace">
<Route
index
element={
<AuthAndFrame>
<WorkspacePage />
</AuthAndFrame>
}
/>
<Route
path="schedule"
element={
<RequireAuth>
<WorkspaceSchedulePage />
</RequireAuth>
}
/>
</Route>
</Route>
<Route path="templates">
<Route
index
element={
<AuthAndFrame>
<TemplatesPage />
</AuthAndFrame>
}
/>
<Route
path=":template"
element={
<AuthAndFrame>
<TemplatePage />
</AuthAndFrame>
}
/>
</Route>
<Route path="users">
<Route
index
element={
<AuthAndFrame>
<UsersPage />
</AuthAndFrame>
}
/>
<Route
path="create"
element={
<RequireAuth>
<CreateUserPage />
</RequireAuth>
}
/>
</Route>
<Route path="settings" element={<SettingsLayout />}>
<Route path="account" element={<AccountPage />} />
<Route path="security" element={<SecurityPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
</Route>
<Route path=":username">
<Route path=":workspace">
<Route
path="terminal"
element={
<RequireAuth>
<TerminalPage />
</RequireAuth>
}
/>
</Route>
</Route>
<Route
path="builds/:buildId"
element={
<AuthAndFrame>
<WorkspaceBuildPage />
<WorkspacesPage />
</AuthAndFrame>
}
/>
{/* Using path="*"" means "match anything", so this route
<Route
path="new"
element={
<RequireAuth>
<CreateWorkspacePage />
</RequireAuth>
}
/>
<Route path=":workspace">
<Route
index
element={
<AuthAndFrame>
<WorkspacePage />
</AuthAndFrame>
}
/>
<Route
path="schedule"
element={
<RequireAuth>
<WorkspaceSchedulePage />
</RequireAuth>
}
/>
</Route>
</Route>
<Route path="templates">
<Route
index
element={
<AuthAndFrame>
<TemplatesPage />
</AuthAndFrame>
}
/>
<Route
path=":template"
element={
<AuthAndFrame>
<TemplatePage />
</AuthAndFrame>
}
/>
</Route>
<Route path="users">
<Route
index
element={
<AuthAndFrame>
<UsersPage />
</AuthAndFrame>
}
/>
<Route
path="create"
element={
<RequireAuth>
<CreateUserPage />
</RequireAuth>
}
/>
</Route>
<Route path="settings" element={<SettingsLayout />}>
<Route path="account" element={<AccountPage />} />
<Route path="security" element={<SecurityPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
</Route>
<Route
path="builds/:buildId"
element={
<AuthAndFrame>
<WorkspaceBuildPage />
</AuthAndFrame>
}
/>
<Route path="/@:username">
<Route path=":workspace">
<Route
path="terminal"
element={
<RequireAuth>
<TerminalPage />
</RequireAuth>
}
/>
<Route path="apps">
<Route
path=":app/*"
element={
<AuthAndFrame>
<WorkspaceAppErrorPage />
</AuthAndFrame>
}
/>
</Route>
</Route>
</Route>
{/* Using path="*"" means "match anything", so this route
acts like a catch-all for URLs that we don't have explicit
routes for. */}
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
)
@@ -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<AppLinkProps> = (args) => <AppLink {...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",
}
+48
View File
@@ -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<AppLinkProps> = ({ userName, workspaceName, appName, appIcon }) => {
const styles = useStyles()
const href = `/@${userName}/${workspaceName}/apps/${appName}`
return (
<Link href={href} target="_blank" className={styles.link}>
<img
className={combineClasses([styles.icon, appIcon === "" ? "empty" : ""])}
alt={`${appName} Icon`}
src={appIcon || ""}
/>
{appName}
</Link>
)
}
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,
},
},
}))
+22 -8
View File
@@ -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<ResourcesProps> = ({ resources, getResourcesError, wo
<span className={styles.operatingSystem}>{agent.operating_system}</span>
</TableCell>
<TableCell>
{agent.status === "connected" && (
<TerminalLink
className={styles.accessLink}
workspaceName={workspace.name}
agentName={agent.name}
userName={workspace.owner_name}
/>
)}
<Stack>
{agent.status === "connected" && (
<TerminalLink
className={styles.accessLink}
workspaceName={workspace.name}
agentName={agent.name}
userName={workspace.owner_name}
/>
)}
{agent.status === "connected" &&
agent.apps.map((app) => (
<AppLink
key={app.name}
appIcon={app.icon}
appName={app.name}
userName={workspace.owner_name}
workspaceName={workspace.name}
/>
))}
</Stack>
</TableCell>
<TableCell>
<span style={{ color: getDisplayAgentStatus(theme, agent).color }}>
@@ -26,7 +26,7 @@ export interface TerminalLinkProps {
*/
export const TerminalLink: FC<TerminalLinkProps> = ({ agentName, userName = "me", workspaceName, className }) => {
const styles = useStyles()
const href = `/${userName}/${workspaceName}${agentName ? `.${agentName}` : ""}/terminal`
const href = `/@${userName}/${workspaceName}${agentName ? `.${agentName}` : ""}/terminal`
return (
<Link
@@ -0,0 +1,18 @@
import { FC, useMemo } from "react"
import { useParams } from "react-router-dom"
import { WorkspaceAppErrorPageView } from "./WorkspaceAppErrorPageView"
const WorkspaceAppErrorView: FC = () => {
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 <WorkspaceAppErrorPageView appName={app as string} message={message} />
}
export default WorkspaceAppErrorView
@@ -0,0 +1,16 @@
import { Story } from "@storybook/react"
import { WorkspaceAppErrorPageView, WorkspaceAppErrorPageViewProps } from "./WorkspaceAppErrorPageView"
export default {
title: "pages/WorkspaceAppErrorPageView",
component: WorkspaceAppErrorPageView,
}
const Template: Story<WorkspaceAppErrorPageViewProps> = (args) => <WorkspaceAppErrorPageView {...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",
}
@@ -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<WorkspaceAppErrorPageViewProps> = (props) => {
const styles = useStyles()
return (
<div className={styles.root}>
<h1 className={styles.title}>{props.appName} is offline!</h1>
<p className={styles.message}>{props.message}</p>
</div>
)
}
const useStyles = makeStyles((theme) => ({
root: {
flex: 1,
padding: theme.spacing(10),
},
title: {
textAlign: "center",
},
message: {
textAlign: "center",
},
}))
+41
View File
@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.9119 99.3171C72.4869 99.9307 74.2828 99.8914 75.8725 99.1264L96.4608 89.2197C98.6242 88.1787 100 85.9892 100 83.5872V16.4133C100 14.0113 98.6243 11.8218 96.4609 10.7808L75.8725 0.873756C73.7862 -0.130129 71.3446 0.11576 69.5135 1.44695C69.252 1.63711 69.0028 1.84943 68.769 2.08341L29.3551 38.0415L12.1872 25.0096C10.589 23.7965 8.35363 23.8959 6.86933 25.2461L1.36303 30.2549C-0.452552 31.9064 -0.454633 34.7627 1.35853 36.417L16.2471 50.0001L1.35853 63.5832C-0.454633 65.2374 -0.452552 68.0938 1.36303 69.7453L6.86933 74.7541C8.35363 76.1043 10.589 76.2037 12.1872 74.9905L29.3551 61.9587L68.769 97.9167C69.3925 98.5406 70.1246 99.0104 70.9119 99.3171ZM75.0152 27.2989L45.1091 50.0001L75.0152 72.7012V27.2989Z" fill="white"/>
</mask>
<g mask="url(#mask0)">
<path d="M96.4614 10.7962L75.8569 0.875542C73.4719 -0.272773 70.6217 0.211611 68.75 2.08333L1.29858 63.5832C-0.515693 65.2373 -0.513607 68.0937 1.30308 69.7452L6.81272 74.754C8.29793 76.1042 10.5347 76.2036 12.1338 74.9905L93.3609 13.3699C96.086 11.3026 100 13.2462 100 16.6667V16.4275C100 14.0265 98.6246 11.8378 96.4614 10.7962Z" fill="#0065A9"/>
<g filter="url(#filter0_d)">
<path d="M96.4614 89.2038L75.8569 99.1245C73.4719 100.273 70.6217 99.7884 68.75 97.9167L1.29858 36.4169C-0.515693 34.7627 -0.513607 31.9063 1.30308 30.2548L6.81272 25.246C8.29793 23.8958 10.5347 23.7964 12.1338 25.0095L93.3609 86.6301C96.086 88.6974 100 86.7538 100 83.3334V83.5726C100 85.9735 98.6246 88.1622 96.4614 89.2038Z" fill="#007ACC"/>
</g>
<g filter="url(#filter1_d)">
<path d="M75.8578 99.1263C73.4721 100.274 70.6219 99.7885 68.75 97.9166C71.0564 100.223 75 98.5895 75 95.3278V4.67213C75 1.41039 71.0564 -0.223106 68.75 2.08329C70.6219 0.211402 73.4721 -0.273666 75.8578 0.873633L96.4587 10.7807C98.6234 11.8217 100 14.0112 100 16.4132V83.5871C100 85.9891 98.6234 88.1786 96.4586 89.2196L75.8578 99.1263Z" fill="#1F9CF0"/>
</g>
<g style="mix-blend-mode:overlay" opacity="0.25">
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.8511 99.3171C72.4261 99.9306 74.2221 99.8913 75.8117 99.1264L96.4 89.2197C98.5634 88.1787 99.9392 85.9892 99.9392 83.5871V16.4133C99.9392 14.0112 98.5635 11.8217 96.4001 10.7807L75.8117 0.873695C73.7255 -0.13019 71.2838 0.115699 69.4527 1.44688C69.1912 1.63705 68.942 1.84937 68.7082 2.08335L29.2943 38.0414L12.1264 25.0096C10.5283 23.7964 8.29285 23.8959 6.80855 25.246L1.30225 30.2548C-0.513334 31.9064 -0.515415 34.7627 1.29775 36.4169L16.1863 50L1.29775 63.5832C-0.515415 65.2374 -0.513334 68.0937 1.30225 69.7452L6.80855 74.754C8.29285 76.1042 10.5283 76.2036 12.1264 74.9905L29.2943 61.9586L68.7082 97.9167C69.3317 98.5405 70.0638 99.0104 70.8511 99.3171ZM74.9544 27.2989L45.0483 50L74.9544 72.7012V27.2989Z" fill="url(#paint0_linear)"/>
</g>
</g>
<defs>
<filter id="filter0_d" x="-8.39411" y="15.8291" width="116.727" height="92.2456" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.16667"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="filter1_d" x="60.4167" y="-8.07558" width="47.9167" height="116.151" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.16667"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<linearGradient id="paint0_linear" x1="49.9392" y1="0.257812" x2="49.9392" y2="99.7423" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB