mirror of https://github.com/coder/coder.git
478 lines
14 KiB
TypeScript
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)}`,
|
|
},
|
|
},
|
|
}));
|