coder/site/src/components/Dashboard/Navbar/NavbarView.tsx

478 lines
14 KiB
TypeScript

import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import { makeStyles } from "@mui/styles";
import MenuIcon from "@mui/icons-material/Menu";
import { CoderIcon } from "components/Icons/CoderIcon";
import { FC, useRef, useState } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { colors } from "theme/colors";
import * as TypesGen from "../../../api/typesGenerated";
import { navHeight } from "../../../theme/constants";
import { combineClasses } from "../../../utils/combineClasses";
import { UserDropdown } from "./UserDropdown/UserDropdown";
import Box from "@mui/material/Box";
import Menu from "@mui/material/Menu";
import Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem";
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import { ProxyContextValue } from "contexts/ProxyContext";
import { displayError } from "components/GlobalSnackbar/utils";
import Divider from "@mui/material/Divider";
import Skeleton from "@mui/material/Skeleton";
import { BUTTON_SM_HEIGHT } from "theme/theme";
import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency";
import { usePermissions } from "hooks/usePermissions";
import Typography from "@mui/material/Typography";
export const USERS_LINK = `/users?filter=${encodeURIComponent(
"status:active",
)}`;
export interface NavbarViewProps {
logo_url?: string;
user?: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: TypesGen.LinkConfig[];
onSignOut: () => void;
canViewAuditLog: boolean;
canViewDeployment: boolean;
canViewAllUsers: boolean;
proxyContextValue?: ProxyContextValue;
}
export const Language = {
workspaces: "Workspaces",
templates: "Templates",
users: "Users",
audit: "Audit",
deployment: "Deployment",
};
const NavItems: React.FC<
React.PropsWithChildren<{
className?: string;
canViewAuditLog: boolean;
canViewDeployment: boolean;
canViewAllUsers: boolean;
}>
> = ({ className, canViewAuditLog, canViewDeployment, canViewAllUsers }) => {
const styles = useStyles();
const location = useLocation();
return (
<List className={combineClasses([styles.navItems, className])}>
<ListItem button className={styles.item}>
<NavLink
className={combineClasses([
styles.link,
location.pathname.startsWith("/@") && "active",
])}
to="/workspaces"
>
{Language.workspaces}
</NavLink>
</ListItem>
<ListItem button className={styles.item}>
<NavLink className={styles.link} to="/templates">
{Language.templates}
</NavLink>
</ListItem>
{canViewAllUsers && (
<ListItem button className={styles.item}>
<NavLink className={styles.link} to={USERS_LINK}>
{Language.users}
</NavLink>
</ListItem>
)}
{canViewAuditLog && (
<ListItem button className={styles.item}>
<NavLink className={styles.link} to="/audit">
{Language.audit}
</NavLink>
</ListItem>
)}
{canViewDeployment && (
<ListItem button className={styles.item}>
<NavLink className={styles.link} to="/deployment/general">
{Language.deployment}
</NavLink>
</ListItem>
)}
</List>
);
};
export const NavbarView: FC<NavbarViewProps> = ({
user,
logo_url,
buildInfo,
supportLinks,
onSignOut,
canViewAuditLog,
canViewDeployment,
canViewAllUsers,
proxyContextValue,
}) => {
const styles = useStyles();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
return (
<nav className={styles.root}>
<div className={styles.wrapper}>
<IconButton
aria-label="Open menu"
className={styles.mobileMenuButton}
onClick={() => {
setIsDrawerOpen(true);
}}
size="large"
>
<MenuIcon />
</IconButton>
<Drawer
anchor="left"
open={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
>
<div className={styles.drawer}>
<div className={styles.drawerHeader}>
<div className={combineClasses([styles.logo, styles.drawerLogo])}>
{logo_url ? (
<img src={logo_url} alt="Custom Logo" />
) : (
<CoderIcon />
)}
</div>
</div>
<NavItems
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
canViewAllUsers={canViewAllUsers}
/>
</div>
</Drawer>
<NavLink className={styles.logo} to="/workspaces">
{logo_url ? (
<img src={logo_url} alt="Custom Logo" />
) : (
<CoderIcon fill="white" opacity={1} width={125} />
)}
</NavLink>
<NavItems
className={styles.desktopNavItems}
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
canViewAllUsers={canViewAllUsers}
/>
<Box
display="flex"
marginLeft={{ md: "auto" }}
gap={2}
alignItems="center"
paddingRight={2}
>
{proxyContextValue && (
<ProxyMenu proxyContextValue={proxyContextValue} />
)}
{user && (
<UserDropdown
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
onSignOut={onSignOut}
/>
)}
</Box>
</div>
</nav>
);
};
const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
proxyContextValue,
}) => {
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 = usePermissions();
const proxyLatencyLoading = (proxy: TypesGen.Region): boolean => {
if (!refetchDate) {
// Only show loading if the user manually requested a refetch
return false;
}
const latency = latencies?.[proxy.id];
// 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.
if (proxy.healthy && latency && latency.at < refetchDate) {
return true;
}
return false;
};
if (isLoading) {
return (
<Skeleton
width="160px"
height={BUTTON_SM_HEIGHT}
sx={{ borderRadius: "4px", transform: "none" }}
/>
);
}
return (
<>
<Button
ref={buttonRef}
onClick={() => setIsOpen(true)}
size="small"
endIcon={<KeyboardArrowDownOutlined />}
sx={{
borderRadius: "4px",
"& .MuiSvgIcon-root": { fontSize: 14 },
}}
>
{selectedProxy ? (
<Box display="flex" gap={2} alignItems="center">
<Box width={14} height={14} lineHeight={0}>
<Box
component="img"
src={selectedProxy.icon_url}
alt=""
sx={{ objectFit: "contain" }}
width="100%"
height="100%"
/>
</Box>
{selectedProxy.display_name}
<ProxyStatusLatency
latency={latencies?.[selectedProxy.id]?.latencyMS}
isLoading={proxyLatencyLoading(selectedProxy)}
/>
</Box>
) : (
"Select Proxy"
)}
</Button>
<Menu
open={isOpen}
anchorEl={buttonRef.current}
onClick={closeMenu}
onClose={closeMenu}
sx={{ "& .MuiMenu-paper": { py: 1 } }}
>
<Box
sx={{
w: "100%",
fontSize: 14,
padding: 2,
maxWidth: "320px",
lineHeight: "140%",
}}
>
<Typography
component="h4"
sx={{
fontSize: "inherit",
fontWeight: 600,
lineHeight: "inherit",
margin: 0,
}}
>
Select a region nearest to you
</Typography>
<Typography
component="p"
sx={{
fontSize: 13,
color: (theme) => theme.palette.text.secondary,
lineHeight: "inherit",
marginTop: 0.5,
}}
>
Workspace proxies improve terminal and web app connections to
workspaces. This does not apply to CLI connections. A region must be
manually selected, otherwise the default primary region will be
used.
</Typography>
</Box>
<Divider sx={{ borderColor: (theme) => theme.palette.divider }} />
{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
onClick={() => {
if (!proxy.healthy) {
displayError("Please select a healthy workspace proxy.");
closeMenu();
return;
}
proxyContextValue.setProxy(proxy);
closeMenu();
}}
key={proxy.id}
selected={proxy.id === selectedProxy?.id}
sx={{
fontSize: 14,
}}
>
<Box display="flex" gap={3} alignItems="center" width="100%">
<Box width={14} height={14} lineHeight={0}>
<Box
component="img"
src={proxy.icon_url}
alt=""
sx={{ objectFit: "contain" }}
width="100%"
height="100%"
/>
</Box>
{proxy.display_name}
<ProxyStatusLatency
latency={latencies?.[proxy.id]?.latencyMS}
isLoading={proxyLatencyLoading(proxy)}
/>
</Box>
</MenuItem>
))}
<Divider sx={{ borderColor: (theme) => theme.palette.divider }} />
{Boolean(permissions.editWorkspaceProxies) && (
<MenuItem
sx={{ fontSize: 14 }}
onClick={() => {
navigate("deployment/workspace-proxies");
}}
>
Proxy settings
</MenuItem>
)}
<MenuItem
sx={{ fontSize: 14 }}
onClick={(e) => {
// Stop the menu from closing
e.stopPropagation();
// Refresh the latencies.
const refetchDate = refreshLatencies();
setRefetchDate(refetchDate);
}}
>
Refresh Latencies
</MenuItem>
</Menu>
</>
);
};
const useStyles = makeStyles((theme) => ({
displayInitial: {
display: "initial",
},
root: {
height: navHeight,
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
},
wrapper: {
position: "relative",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
[theme.breakpoints.up("md")]: {
justifyContent: "flex-start",
},
},
drawer: {
width: 250,
},
drawerHeader: {
padding: theme.spacing(2),
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
navItems: {
padding: 0,
},
desktopNavItems: {
display: "none",
[theme.breakpoints.up("md")]: {
display: "flex",
},
},
mobileMenuButton: {
[theme.breakpoints.up("md")]: {
display: "none",
},
},
logo: {
alignItems: "center",
display: "flex",
height: navHeight,
color: theme.palette.text.primary,
padding: theme.spacing(2),
// svg is for the Coder logo, img is for custom images
"& svg, & img": {
height: "100%",
objectFit: "contain",
},
},
drawerLogo: {
padding: 0,
maxHeight: theme.spacing(5),
},
title: {
flex: 1,
textAlign: "center",
},
item: {
padding: 0,
},
link: {
alignItems: "center",
color: colors.gray[6],
display: "flex",
flex: 1,
fontSize: 16,
padding: `${theme.spacing(1.5)} ${theme.spacing(2)}`,
textDecoration: "none",
transition: "background-color 0.15s ease-in-out",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
// NavLink adds this class when the current route matches.
"&.active": {
color: theme.palette.text.primary,
fontWeight: 500,
},
[theme.breakpoints.up("md")]: {
height: navHeight,
padding: `0 ${theme.spacing(3)}`,
},
},
}));