refactor(site): do not block the screen when disconnected (#10057)

Close https://github.com/coder/coder/issues/9858

Demo:
https://github.com/coder/coder/assets/3165839/d6d4d2d7-487a-4739-86c9-042242d9820f
This commit is contained in:
Bruno Quaresma
2023-10-05 18:06:26 -03:00
committed by GitHub
parent 2d6c4fe90a
commit a5f8300c76
4 changed files with 154 additions and 278 deletions
@@ -0,0 +1,136 @@
import Button from "@mui/material/Button";
import Link from "@mui/material/Link";
import { Alert, AlertProps } from "components/Alert/Alert";
import { useState } from "react";
import { docs } from "utils/docs";
export const ErrorScriptAlert = () => {
return (
<TerminalAlert
severity="warning"
dismissible
actions={<RefreshSessionButton />}
>
The workspace{" "}
<Link
title="startup script has exited with an error"
href={docs("/templates#startup-script-exited-with-an-error")}
target="_blank"
rel="noreferrer"
>
startup script has exited with an error
</Link>
, we recommend reloading this session and{" "}
<Link
title=" debugging the startup script"
href={docs("/templates#debugging-the-startup-script")}
target="_blank"
rel="noreferrer"
>
debugging the startup script
</Link>{" "}
because{" "}
<Link
title="your workspace may be incomplete."
href={docs("/templates#your-workspace-may-be-incomplete")}
target="_blank"
rel="noreferrer"
>
your workspace may be incomplete.
</Link>{" "}
</TerminalAlert>
);
};
export const LoadingScriptsAlert = () => {
return (
<TerminalAlert
dismissible
severity="info"
actions={<RefreshSessionButton />}
>
Startup scripts are still running. You can continue using this terminal,
but{" "}
<Link
title="your workspace may be incomplete."
href={docs("/templates#your-workspace-may-be-incomplete")}
target="_blank"
rel="noreferrer"
>
{" "}
your workspace may be incomplete.
</Link>
</TerminalAlert>
);
};
export const LoadedScriptsAlert = () => {
return (
<TerminalAlert
severity="success"
dismissible
actions={<RefreshSessionButton />}
>
Startup scripts have completed successfully. The workspace is ready but
this{" "}
<Link
title="session was started before the startup scripts finished"
href={docs("/templates#your-workspace-may-be-incomplete")}
target="_blank"
rel="noreferrer"
>
session was started before the startup script finished.
</Link>{" "}
To ensure your shell environment is up-to-date, we recommend reloading
this session.
</TerminalAlert>
);
};
const TerminalAlert = (props: AlertProps) => {
return (
<Alert
{...props}
sx={{
borderRadius: 0,
borderWidth: 0,
borderBottomWidth: 1,
borderBottomColor: (theme) => theme.palette.divider,
backgroundColor: (theme) => theme.palette.background.paperLight,
borderLeft: (theme) =>
`3px solid ${theme.palette[props.severity!].light}`,
marginBottom: 1,
}}
/>
);
};
export const DisconnectedAlert = (props: AlertProps) => {
return (
<TerminalAlert
{...props}
severity="warning"
actions={<RefreshSessionButton />}
>
Disconnected
</TerminalAlert>
);
};
const RefreshSessionButton = () => {
const [isRefreshing, setIsRefreshing] = useState(false);
return (
<Button
disabled={isRefreshing}
size="small"
variant="text"
onClick={() => {
setIsRefreshing(true);
window.location.reload();
}}
>
{isRefreshing ? "Refreshing session..." : "Refresh session"}
</Button>
);
};
+18 -110
View File
@@ -1,6 +1,5 @@
import { makeStyles, useTheme } from "@mui/styles";
import { useMachine } from "@xstate/react";
import { Stack } from "components/Stack/Stack";
import { FC, useCallback, useEffect, useRef, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
@@ -18,12 +17,17 @@ import { terminalMachine } from "xServices/terminal/terminalXService";
import { useProxy } from "contexts/ProxyContext";
import Box from "@mui/material/Box";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { Region, WorkspaceAgent } from "api/typesGenerated";
import { Region } from "api/typesGenerated";
import { getLatencyColor } from "utils/latency";
import Popover from "@mui/material/Popover";
import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency";
import TerminalPageAlert, { TerminalPageAlertType } from "./TerminalPageAlert";
import { portForwardURL } from "utils/portForward";
import {
DisconnectedAlert,
ErrorScriptAlert,
LoadedScriptsAlert,
LoadingScriptsAlert,
} from "./TerminalAlerts";
export const Language = {
workspaceErrorMessagePrefix: "Unable to fetch workspace: ",
@@ -31,32 +35,6 @@ export const Language = {
websocketErrorMessagePrefix: "WebSocket failed: ",
};
const useTerminalWarning = ({ agent }: { agent?: WorkspaceAgent }) => {
const lifecycleState = agent?.lifecycle_state;
const [startupWarning, setStartupWarning] = useState<
TerminalPageAlertType | undefined
>(undefined);
useEffect(() => {
if (lifecycleState === "start_error") {
setStartupWarning("error");
} else if (lifecycleState === "starting") {
setStartupWarning("starting");
} else {
setStartupWarning((prev) => {
if (prev === "starting") {
return "success";
}
return undefined;
});
}
}, [lifecycleState]);
return {
startupWarning,
};
};
type TerminalPageProps = React.PropsWithChildren<{
renderer: "webgl" | "dom";
}>;
@@ -110,16 +88,18 @@ const TerminalPage: FC<TerminalPageProps> = ({ renderer }) => {
workspaceAgent,
websocketError,
} = terminalState.context;
const reloading = useReloading(isDisconnected);
const dashboard = useDashboard();
const proxyContext = useProxy();
const selectedProxy = proxyContext.proxy.proxy;
const latency = selectedProxy
? proxyContext.proxyLatencies[selectedProxy.id]
: undefined;
const { startupWarning } = useTerminalWarning({
agent: workspaceAgent,
});
const lifecycleState = workspaceAgent?.lifecycle_state;
const prevLifecycleState = useRef(lifecycleState);
useEffect(() => {
prevLifecycleState.current = lifecycleState;
}, [lifecycleState]);
// handleWebLink handles opening of URLs in the terminal!
const handleWebLink = useCallback(
@@ -326,29 +306,12 @@ const TerminalPage: FC<TerminalPageProps> = ({ renderer }) => {
: ""}
</title>
</Helmet>
{/* This overlay makes it more obvious that the terminal is disconnected. */}
{/* It's nice for situations where Coder restarts, and they are temporarily disconnected. */}
<div className={`${styles.overlay} ${isDisconnected ? "" : "connected"}`}>
{reloading.status === "reloading" ? (
<span className={styles.overlayText}>Reloading...</span>
) : (
<Stack spacing={0.5} alignItems="center">
<span className={styles.overlayText}>Disconnected</span>
<span className={styles.overlaySubtext}>
Press any key to retry
</span>
</Stack>
)}
</div>
<Box display="flex" flexDirection="column" height="100vh">
{startupWarning && (
<TerminalPageAlert
alertType={startupWarning}
onDismiss={() => {
fitAddon?.fit();
}}
/>
)}
{lifecycleState === "start_error" && <ErrorScriptAlert />}
{lifecycleState === "starting" && <LoadingScriptsAlert />}
{lifecycleState === "ready" &&
prevLifecycleState.current === "starting" && <LoadedScriptsAlert />}
{isDisconnected && <DisconnectedAlert />}
<div
className={styles.terminal}
ref={xtermRef}
@@ -463,61 +426,6 @@ const BottomBar = ({ proxy, latency }: { proxy: Region; latency?: number }) => {
);
};
const useReloading = (isDisconnected: boolean) => {
const [status, setStatus] = useState<"reloading" | "notReloading">(
"notReloading",
);
// Retry connection on key press when it is disconnected
useEffect(() => {
if (!isDisconnected || status === "reloading") {
return;
}
// Modifier keys should not trigger a reload.
const ignoredKeys = [
"Alt",
"AltGraph",
"CapsLock",
"Control",
"Fn",
"FnLock",
"Meta",
"NumLock",
"ScrollLock",
"Shift",
"Symbol",
"SymbolLock",
];
const keyDownHandler = (event: KeyboardEvent) => {
// In addition to ignored keys, avoid reloading while modifiers are held
// to cover cases where the terminal unexpectedly tries to reconnect like
// when pressing ctrl+w, ctrl+r, and so on.
if (
!ignoredKeys.includes(event.key) &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.shiftKey
) {
setStatus("reloading");
window.location.reload();
}
};
document.addEventListener("keydown", keyDownHandler, true);
return () => {
document.removeEventListener("keydown", keyDownHandler, true);
};
}, [status, isDisconnected]);
return {
status,
};
};
const useStyles = makeStyles((theme) => ({
overlay: {
position: "absolute",
@@ -1,37 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import TerminalPageAlert from "./TerminalPageAlert";
const meta: Meta<typeof TerminalPageAlert> = {
component: TerminalPageAlert,
title: "components/TerminalPageAlert",
argTypes: {
alertType: {
control: {
type: "radio",
},
options: ["error", "starting", "success"],
},
},
};
type Story = StoryObj<typeof TerminalPageAlert>;
export const Error: Story = {
args: {
alertType: "error",
},
};
export const Starting: Story = {
args: {
alertType: "starting",
},
};
export const Success: Story = {
args: {
alertType: "success",
},
};
export default meta;
@@ -1,131 +0,0 @@
import { AlertColor } from "@mui/material/Alert/Alert";
import Button from "@mui/material/Button";
import Link from "@mui/material/Link";
import { Alert } from "components/Alert/Alert";
import { ReactNode } from "react";
import { docs } from "utils/docs";
export type TerminalPageAlertType = "error" | "starting" | "success";
type MapAlertTypeToComponent = {
[key in TerminalPageAlertType]: {
severity: AlertColor;
children: ReactNode | undefined;
};
};
const mapAlertTypeToText: MapAlertTypeToComponent = {
error: {
severity: "warning",
children: (
<>
The workspace{" "}
<Link
title="startup script has exited with an error"
href={docs("/templates#startup-script-exited-with-an-error")}
target="_blank"
rel="noreferrer"
>
startup script has exited with an error
</Link>
, we recommend reloading this session and{" "}
<Link
title=" debugging the startup script"
href={docs("/templates#debugging-the-startup-script")}
target="_blank"
rel="noreferrer"
>
debugging the startup script
</Link>{" "}
because{" "}
<Link
title="your workspace may be incomplete."
href={docs("/templates#your-workspace-may-be-incomplete")}
target="_blank"
rel="noreferrer"
>
your workspace may be incomplete.
</Link>{" "}
</>
),
},
starting: {
severity: "info",
children: (
<>
Startup scripts are still running. You can continue using this terminal,
but{" "}
<Link
title="your workspace may be incomplete."
href={docs("/templates#your-workspace-may-be-incomplete")}
target="_blank"
rel="noreferrer"
>
{" "}
your workspace may be incomplete.
</Link>
</>
),
},
success: {
severity: "success",
children: (
<>
Startup scripts have completed successfully. The workspace is ready but
this{" "}
<Link
title="session was started before the startup scripts finished"
href={docs("/templates#your-workspace-may-be-incomplete")}
target="_blank"
rel="noreferrer"
>
session was started before the startup script finished.
</Link>{" "}
To ensure your shell environment is up-to-date, we recommend reloading
this session.
</>
),
},
};
export default ({
alertType,
onDismiss,
}: {
alertType: TerminalPageAlertType;
onDismiss: () => void;
}) => {
const severity = mapAlertTypeToText[alertType].severity;
return (
<Alert
severity={severity}
sx={{
borderRadius: 0,
borderWidth: 0,
borderBottomWidth: 1,
borderBottomColor: (theme) => theme.palette.divider,
backgroundColor: (theme) => theme.palette.background.paperLight,
borderLeft: (theme) => `3px solid ${theme.palette[severity].light}`,
marginBottom: 1,
}}
onDismiss={onDismiss}
dismissible
actions={[
<Button
key="refresh-session"
size="small"
variant="text"
onClick={() => {
// By redirecting the user without the session in the URL we
// create a new one
window.location.href = window.location.pathname;
}}
>
Refresh session
</Button>,
]}
>
{mapAlertTypeToText[alertType].children}
</Alert>
);
};