mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user