mirror of https://github.com/coder/coder.git
509 lines
14 KiB
TypeScript
509 lines
14 KiB
TypeScript
import { css, type Interpolation, type Theme, useTheme } from "@emotion/react";
|
|
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
|
import MenuIcon from "@mui/icons-material/Menu";
|
|
import Button from "@mui/material/Button";
|
|
import Divider from "@mui/material/Divider";
|
|
import Drawer from "@mui/material/Drawer";
|
|
import IconButton from "@mui/material/IconButton";
|
|
import Menu from "@mui/material/Menu";
|
|
import MenuItem from "@mui/material/MenuItem";
|
|
import Skeleton from "@mui/material/Skeleton";
|
|
import { visuallyHidden } from "@mui/utils";
|
|
import { type FC, type ReactNode, useRef, useState } from "react";
|
|
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
|
import type * as TypesGen from "api/typesGenerated";
|
|
import { Abbr } from "components/Abbr/Abbr";
|
|
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
|
import { displayError } from "components/GlobalSnackbar/utils";
|
|
import { CoderIcon } from "components/Icons/CoderIcon";
|
|
import { Latency } from "components/Latency/Latency";
|
|
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
|
import type { ProxyContextValue } from "contexts/ProxyContext";
|
|
import { BUTTON_SM_HEIGHT, navHeight } from "theme/constants";
|
|
import { UserDropdown } from "./UserDropdown/UserDropdown";
|
|
|
|
export const USERS_LINK = `/users?filter=${encodeURIComponent(
|
|
"status:active",
|
|
)}`;
|
|
|
|
export interface NavbarViewProps {
|
|
logo_url?: string;
|
|
user?: TypesGen.User;
|
|
buildInfo?: TypesGen.BuildInfoResponse;
|
|
supportLinks?: readonly TypesGen.LinkConfig[];
|
|
onSignOut: () => void;
|
|
canViewAuditLog: boolean;
|
|
canViewDeployment: boolean;
|
|
canViewAllUsers: boolean;
|
|
canViewHealth: boolean;
|
|
proxyContextValue?: ProxyContextValue;
|
|
}
|
|
|
|
export const Language = {
|
|
workspaces: "Workspaces",
|
|
templates: "Templates",
|
|
users: "Users",
|
|
audit: "Audit",
|
|
deployment: "Deployment",
|
|
};
|
|
|
|
interface NavItemsProps {
|
|
children?: ReactNode;
|
|
className?: string;
|
|
canViewAuditLog: boolean;
|
|
canViewDeployment: boolean;
|
|
canViewAllUsers: boolean;
|
|
canViewHealth: boolean;
|
|
}
|
|
|
|
const NavItems: FC<NavItemsProps> = ({
|
|
className,
|
|
canViewAuditLog,
|
|
canViewDeployment,
|
|
canViewAllUsers,
|
|
canViewHealth,
|
|
}) => {
|
|
const location = useLocation();
|
|
const theme = useTheme();
|
|
|
|
return (
|
|
<nav className={className}>
|
|
<NavLink
|
|
css={[
|
|
styles.link,
|
|
location.pathname.startsWith("/@") && {
|
|
color: theme.palette.text.primary,
|
|
fontWeight: 500,
|
|
},
|
|
]}
|
|
to="/workspaces"
|
|
>
|
|
{Language.workspaces}
|
|
</NavLink>
|
|
<NavLink css={styles.link} to="/templates">
|
|
{Language.templates}
|
|
</NavLink>
|
|
{canViewAllUsers && (
|
|
<NavLink css={styles.link} to={USERS_LINK}>
|
|
{Language.users}
|
|
</NavLink>
|
|
)}
|
|
{canViewAuditLog && (
|
|
<NavLink css={styles.link} to="/audit">
|
|
{Language.audit}
|
|
</NavLink>
|
|
)}
|
|
{canViewDeployment && (
|
|
<NavLink css={styles.link} to="/deployment/general">
|
|
{Language.deployment}
|
|
</NavLink>
|
|
)}
|
|
{canViewHealth && (
|
|
<NavLink css={styles.link} to="/health">
|
|
Health
|
|
</NavLink>
|
|
)}
|
|
</nav>
|
|
);
|
|
};
|
|
|
|
export const NavbarView: FC<NavbarViewProps> = ({
|
|
user,
|
|
logo_url,
|
|
buildInfo,
|
|
supportLinks,
|
|
onSignOut,
|
|
canViewAuditLog,
|
|
canViewDeployment,
|
|
canViewAllUsers,
|
|
canViewHealth,
|
|
proxyContextValue,
|
|
}) => {
|
|
const theme = useTheme();
|
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
|
|
|
return (
|
|
<nav
|
|
css={{
|
|
height: navHeight,
|
|
backgroundColor: theme.palette.background.paper,
|
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
|
}}
|
|
>
|
|
<div css={styles.wrapper}>
|
|
<IconButton
|
|
aria-label="Open menu"
|
|
css={styles.mobileMenuButton}
|
|
onClick={() => {
|
|
setIsDrawerOpen(true);
|
|
}}
|
|
size="large"
|
|
>
|
|
<MenuIcon />
|
|
</IconButton>
|
|
|
|
<Drawer
|
|
anchor="left"
|
|
open={isDrawerOpen}
|
|
onClose={() => setIsDrawerOpen(false)}
|
|
>
|
|
<div css={{ width: 250 }}>
|
|
<div css={styles.drawerHeader}>
|
|
<div css={[styles.logo, styles.drawerLogo]}>
|
|
{logo_url ? (
|
|
<ExternalImage src={logo_url} alt="Custom Logo" />
|
|
) : (
|
|
<CoderIcon />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<NavItems
|
|
canViewAuditLog={canViewAuditLog}
|
|
canViewDeployment={canViewDeployment}
|
|
canViewAllUsers={canViewAllUsers}
|
|
canViewHealth={canViewHealth}
|
|
/>
|
|
</div>
|
|
</Drawer>
|
|
|
|
<NavLink css={styles.logo} to="/workspaces">
|
|
{logo_url ? (
|
|
<ExternalImage src={logo_url} alt="Custom Logo" />
|
|
) : (
|
|
<CoderIcon fill="white" opacity={1} width={125} />
|
|
)}
|
|
</NavLink>
|
|
|
|
<NavItems
|
|
css={styles.desktopNavItems}
|
|
canViewAuditLog={canViewAuditLog}
|
|
canViewDeployment={canViewDeployment}
|
|
canViewAllUsers={canViewAllUsers}
|
|
canViewHealth={canViewHealth}
|
|
/>
|
|
|
|
<div css={styles.navMenus}>
|
|
{proxyContextValue && (
|
|
<ProxyMenu proxyContextValue={proxyContextValue} />
|
|
)}
|
|
{user && (
|
|
<UserDropdown
|
|
user={user}
|
|
buildInfo={buildInfo}
|
|
supportLinks={supportLinks}
|
|
onSignOut={onSignOut}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
);
|
|
};
|
|
|
|
interface ProxyMenuProps {
|
|
proxyContextValue: ProxyContextValue;
|
|
}
|
|
|
|
const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
|
|
const theme = useTheme();
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [refetchDate, setRefetchDate] = useState<Date>();
|
|
const selectedProxy = proxyContextValue.proxy.proxy;
|
|
const refreshLatencies = proxyContextValue.refetchProxyLatencies;
|
|
const closeMenu = () => setIsOpen(false);
|
|
const navigate = useNavigate();
|
|
const latencies = proxyContextValue.proxyLatencies;
|
|
const isLoadingLatencies = Object.keys(latencies).length === 0;
|
|
const isLoading = proxyContextValue.isLoading || isLoadingLatencies;
|
|
const { permissions } = useAuthenticated();
|
|
|
|
const proxyLatencyLoading = (proxy: TypesGen.Region): boolean => {
|
|
if (!refetchDate) {
|
|
// Only show loading if the user manually requested a refetch
|
|
return false;
|
|
}
|
|
|
|
// Only show a loading spinner if:
|
|
// - A latency exists. This means the latency was fetched at some point, so
|
|
// the loader *should* be resolved.
|
|
// - The proxy is healthy. If it is not, the loader might never resolve.
|
|
// - The latency reported is older than the refetch date. This means the
|
|
// latency is stale and we should show a loading spinner until the new
|
|
// latency is fetched.
|
|
const latency = latencies[proxy.id];
|
|
return proxy.healthy && latency !== undefined && latency.at < refetchDate;
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Skeleton
|
|
width="110px"
|
|
height={BUTTON_SM_HEIGHT}
|
|
css={{ borderRadius: "9999px", transform: "none" }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
ref={buttonRef}
|
|
onClick={() => setIsOpen(true)}
|
|
size="small"
|
|
endIcon={<KeyboardArrowDownOutlined />}
|
|
css={{
|
|
borderRadius: "999px",
|
|
"& .MuiSvgIcon-root": { fontSize: 14 },
|
|
}}
|
|
>
|
|
<span css={{ ...visuallyHidden }}>
|
|
Latency for {selectedProxy?.display_name ?? "your region"}
|
|
</span>
|
|
|
|
{selectedProxy ? (
|
|
<div css={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
<div css={{ width: 16, height: 16, lineHeight: 0 }}>
|
|
<img
|
|
// Empty alt text used because we don't want to double up on
|
|
// screen reader announcements from visually-hidden span
|
|
alt=""
|
|
src={selectedProxy.icon_url}
|
|
css={{
|
|
objectFit: "contain",
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<Latency
|
|
latency={latencies?.[selectedProxy.id]?.latencyMS}
|
|
isLoading={proxyLatencyLoading(selectedProxy)}
|
|
/>
|
|
</div>
|
|
) : (
|
|
"Select Proxy"
|
|
)}
|
|
</Button>
|
|
|
|
<Menu
|
|
open={isOpen}
|
|
anchorEl={buttonRef.current}
|
|
onClick={closeMenu}
|
|
onClose={closeMenu}
|
|
css={{ "& .MuiMenu-paper": { paddingTop: 8, paddingBottom: 8 } }}
|
|
// autoFocus here does not affect modal focus; it affects whether the
|
|
// first item in the list will get auto-focus when the menu opens. Have
|
|
// to turn this off because otherwise, screen readers will skip over all
|
|
// the descriptive text and will only have access to the latency options
|
|
autoFocus={false}
|
|
>
|
|
<div
|
|
css={{
|
|
width: "100%",
|
|
maxWidth: "320px",
|
|
fontSize: 14,
|
|
padding: 16,
|
|
lineHeight: "140%",
|
|
}}
|
|
>
|
|
<h4
|
|
autoFocus
|
|
tabIndex={-1}
|
|
css={{
|
|
fontSize: "inherit",
|
|
fontWeight: 600,
|
|
lineHeight: "inherit",
|
|
margin: 0,
|
|
marginBottom: 4,
|
|
}}
|
|
>
|
|
Select a region nearest to you
|
|
</h4>
|
|
|
|
<p
|
|
css={{
|
|
fontSize: 13,
|
|
color: theme.palette.text.secondary,
|
|
lineHeight: "inherit",
|
|
marginTop: 0.5,
|
|
}}
|
|
>
|
|
Workspace proxies improve terminal and web app connections to
|
|
workspaces. This does not apply to{" "}
|
|
<Abbr title="Command-Line Interface" pronunciation="initialism">
|
|
CLI
|
|
</Abbr>{" "}
|
|
connections. A region must be manually selected, otherwise the
|
|
default primary region will be used.
|
|
</p>
|
|
</div>
|
|
|
|
<Divider css={{ borderColor: theme.palette.divider }} />
|
|
|
|
{proxyContextValue.proxies &&
|
|
[...proxyContextValue.proxies]
|
|
.sort((a, b) => {
|
|
const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity;
|
|
const latencyB = latencies?.[b.id]?.latencyMS ?? Infinity;
|
|
return latencyA - latencyB;
|
|
})
|
|
.map((proxy) => (
|
|
<MenuItem
|
|
key={proxy.id}
|
|
selected={proxy.id === selectedProxy?.id}
|
|
css={{ fontSize: 14 }}
|
|
onClick={() => {
|
|
if (!proxy.healthy) {
|
|
displayError("Please select a healthy workspace proxy.");
|
|
closeMenu();
|
|
return;
|
|
}
|
|
|
|
proxyContextValue.setProxy(proxy);
|
|
closeMenu();
|
|
}}
|
|
>
|
|
<div
|
|
css={{
|
|
display: "flex",
|
|
gap: 24,
|
|
alignItems: "center",
|
|
width: "100%",
|
|
}}
|
|
>
|
|
<div css={{ width: 14, height: 14, lineHeight: 0 }}>
|
|
<img
|
|
src={proxy.icon_url}
|
|
alt=""
|
|
css={{
|
|
objectFit: "contain",
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{proxy.display_name}
|
|
|
|
<Latency
|
|
latency={latencies?.[proxy.id]?.latencyMS}
|
|
isLoading={proxyLatencyLoading(proxy)}
|
|
/>
|
|
</div>
|
|
</MenuItem>
|
|
))}
|
|
|
|
<Divider css={{ borderColor: theme.palette.divider }} />
|
|
|
|
{Boolean(permissions.editWorkspaceProxies) && (
|
|
<MenuItem
|
|
css={{ fontSize: 14 }}
|
|
onClick={() => {
|
|
navigate("/deployment/workspace-proxies");
|
|
}}
|
|
>
|
|
Proxy settings
|
|
</MenuItem>
|
|
)}
|
|
|
|
<MenuItem
|
|
css={{ fontSize: 14 }}
|
|
onClick={(e) => {
|
|
// Stop the menu from closing
|
|
e.stopPropagation();
|
|
// Refresh the latencies.
|
|
const refetchDate = refreshLatencies();
|
|
setRefetchDate(refetchDate);
|
|
}}
|
|
>
|
|
Refresh Latencies
|
|
</MenuItem>
|
|
</Menu>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const styles = {
|
|
desktopNavItems: (theme) => css`
|
|
display: none;
|
|
|
|
${theme.breakpoints.up("md")} {
|
|
display: flex;
|
|
}
|
|
`,
|
|
mobileMenuButton: (theme) => css`
|
|
${theme.breakpoints.up("md")} {
|
|
display: none;
|
|
}
|
|
`,
|
|
navMenus: (theme) => ({
|
|
display: "flex",
|
|
gap: 16,
|
|
alignItems: "center",
|
|
paddingRight: 16,
|
|
|
|
[theme.breakpoints.up("md")]: {
|
|
marginLeft: "auto",
|
|
},
|
|
}),
|
|
wrapper: (theme) => css`
|
|
position: relative;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
|
|
${theme.breakpoints.up("md")} {
|
|
justify-content: flex-start;
|
|
}
|
|
`,
|
|
drawerHeader: {
|
|
padding: 16,
|
|
paddingTop: 32,
|
|
paddingBottom: 32,
|
|
},
|
|
logo: (theme) => css`
|
|
align-items: center;
|
|
display: flex;
|
|
height: ${navHeight}px;
|
|
color: ${theme.palette.text.primary};
|
|
padding: 16px;
|
|
|
|
// svg is for the Coder logo, img is for custom images
|
|
& svg,
|
|
& img {
|
|
height: 100%;
|
|
object-fit: contain;
|
|
}
|
|
`,
|
|
drawerLogo: {
|
|
padding: 0,
|
|
maxHeight: 40,
|
|
},
|
|
link: (theme) => css`
|
|
align-items: center;
|
|
color: ${theme.palette.text.secondary};
|
|
display: flex;
|
|
flex: 1;
|
|
font-size: 16px;
|
|
padding: 12px 16px;
|
|
text-decoration: none;
|
|
transition: background-color 0.15s ease-in-out;
|
|
|
|
&.active {
|
|
color: ${theme.palette.text.primary};
|
|
font-weight: 500;
|
|
}
|
|
|
|
&:hover {
|
|
background-color: ${theme.experimental.l2.hover.background};
|
|
}
|
|
|
|
${theme.breakpoints.up("md")} {
|
|
height: ${navHeight}px;
|
|
padding: 0 24px;
|
|
}
|
|
`,
|
|
} satisfies Record<string, Interpolation<Theme>>;
|