mirror of https://github.com/coder/coder.git
332 lines
9.3 KiB
TypeScript
332 lines
9.3 KiB
TypeScript
import { DeploymentStats, WorkspaceStatus } from "api/typesGenerated"
|
|
import { FC, useMemo, useEffect, useState } from "react"
|
|
import prettyBytes from "pretty-bytes"
|
|
import BuildingIcon from "@mui/icons-material/Build"
|
|
import { makeStyles } from "@mui/styles"
|
|
import { RocketIcon } from "components/Icons/RocketIcon"
|
|
import { MONOSPACE_FONT_FAMILY } from "theme/constants"
|
|
import Tooltip from "@mui/material/Tooltip"
|
|
import { Link as RouterLink } from "react-router-dom"
|
|
import Link from "@mui/material/Link"
|
|
import { VSCodeIcon } from "components/Icons/VSCodeIcon"
|
|
import DownloadIcon from "@mui/icons-material/CloudDownload"
|
|
import UploadIcon from "@mui/icons-material/CloudUpload"
|
|
import LatencyIcon from "@mui/icons-material/SettingsEthernet"
|
|
import WebTerminalIcon from "@mui/icons-material/WebAsset"
|
|
import { TerminalIcon } from "components/Icons/TerminalIcon"
|
|
import dayjs from "dayjs"
|
|
import CollectedIcon from "@mui/icons-material/Compare"
|
|
import RefreshIcon from "@mui/icons-material/Refresh"
|
|
import Button from "@mui/material/Button"
|
|
import { getDisplayWorkspaceStatus } from "utils/workspace"
|
|
|
|
export const bannerHeight = 36
|
|
|
|
export interface DeploymentBannerViewProps {
|
|
fetchStats?: () => void
|
|
stats?: DeploymentStats
|
|
}
|
|
|
|
export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
|
|
stats,
|
|
fetchStats,
|
|
}) => {
|
|
const styles = useStyles()
|
|
const aggregatedMinutes = useMemo(() => {
|
|
if (!stats) {
|
|
return
|
|
}
|
|
return dayjs(stats.collected_at).diff(stats.aggregated_from, "minutes")
|
|
}, [stats])
|
|
const displayLatency = stats?.workspaces.connection_latency_ms.P50 || -1
|
|
const [timeUntilRefresh, setTimeUntilRefresh] = useState(0)
|
|
useEffect(() => {
|
|
if (!stats || !fetchStats) {
|
|
return
|
|
}
|
|
|
|
let timeUntilRefresh = dayjs(stats.next_update_at).diff(
|
|
stats.collected_at,
|
|
"seconds",
|
|
)
|
|
setTimeUntilRefresh(timeUntilRefresh)
|
|
let canceled = false
|
|
const loop = () => {
|
|
if (canceled) {
|
|
return undefined
|
|
}
|
|
setTimeUntilRefresh(timeUntilRefresh--)
|
|
if (timeUntilRefresh > 0) {
|
|
return window.setTimeout(loop, 1000)
|
|
}
|
|
fetchStats()
|
|
}
|
|
const timeout = setTimeout(loop, 1000)
|
|
return () => {
|
|
canceled = true
|
|
clearTimeout(timeout)
|
|
}
|
|
}, [fetchStats, stats])
|
|
const lastAggregated = useMemo(() => {
|
|
if (!stats) {
|
|
return
|
|
}
|
|
if (!fetchStats) {
|
|
// Storybook!
|
|
return "just now"
|
|
}
|
|
return dayjs().to(dayjs(stats.collected_at))
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- We want this to periodically update!
|
|
}, [timeUntilRefresh, stats])
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
<Tooltip title="Status of your Coder deployment. Only visible for admins!">
|
|
<div className={styles.rocket}>
|
|
<RocketIcon />
|
|
</div>
|
|
</Tooltip>
|
|
<div className={styles.group}>
|
|
<div className={styles.category}>Workspaces</div>
|
|
<div className={styles.values}>
|
|
<WorkspaceBuildValue
|
|
status="pending"
|
|
count={stats?.workspaces.pending}
|
|
/>
|
|
<ValueSeparator />
|
|
<WorkspaceBuildValue
|
|
status="starting"
|
|
count={stats?.workspaces.building}
|
|
/>
|
|
<ValueSeparator />
|
|
<WorkspaceBuildValue
|
|
status="running"
|
|
count={stats?.workspaces.running}
|
|
/>
|
|
<ValueSeparator />
|
|
<WorkspaceBuildValue
|
|
status="stopped"
|
|
count={stats?.workspaces.stopped}
|
|
/>
|
|
<ValueSeparator />
|
|
<WorkspaceBuildValue
|
|
status="failed"
|
|
count={stats?.workspaces.failed}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className={styles.group}>
|
|
<Tooltip title={`Activity in the last ~${aggregatedMinutes} minutes`}>
|
|
<div className={styles.category}>Transmission</div>
|
|
</Tooltip>
|
|
|
|
<div className={styles.values}>
|
|
<Tooltip title="Data sent to workspaces">
|
|
<div className={styles.value}>
|
|
<DownloadIcon />
|
|
{stats ? prettyBytes(stats.workspaces.rx_bytes) : "-"}
|
|
</div>
|
|
</Tooltip>
|
|
<ValueSeparator />
|
|
<Tooltip title="Data sent from workspaces">
|
|
<div className={styles.value}>
|
|
<UploadIcon />
|
|
{stats ? prettyBytes(stats.workspaces.tx_bytes) : "-"}
|
|
</div>
|
|
</Tooltip>
|
|
<ValueSeparator />
|
|
<Tooltip
|
|
title={
|
|
displayLatency < 0
|
|
? "No recent workspace connections have been made"
|
|
: "The average latency of user connections to workspaces"
|
|
}
|
|
>
|
|
<div className={styles.value}>
|
|
<LatencyIcon />
|
|
{displayLatency > 0 ? displayLatency?.toFixed(2) + " ms" : "-"}
|
|
</div>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
<div className={styles.group}>
|
|
<div className={styles.category}>Active Connections</div>
|
|
|
|
<div className={styles.values}>
|
|
<Tooltip title="VS Code Editors with the Coder Remote Extension">
|
|
<div className={styles.value}>
|
|
<VSCodeIcon className={styles.iconStripColor} />
|
|
{typeof stats?.session_count.vscode === "undefined"
|
|
? "-"
|
|
: stats?.session_count.vscode}
|
|
</div>
|
|
</Tooltip>
|
|
<ValueSeparator />
|
|
<Tooltip title="SSH Sessions">
|
|
<div className={styles.value}>
|
|
<TerminalIcon />
|
|
{typeof stats?.session_count.ssh === "undefined"
|
|
? "-"
|
|
: stats?.session_count.ssh}
|
|
</div>
|
|
</Tooltip>
|
|
<ValueSeparator />
|
|
<Tooltip title="Web Terminal Sessions">
|
|
<div className={styles.value}>
|
|
<WebTerminalIcon />
|
|
{typeof stats?.session_count.reconnecting_pty === "undefined"
|
|
? "-"
|
|
: stats?.session_count.reconnecting_pty}
|
|
</div>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
<div className={styles.refresh}>
|
|
<Tooltip title="The last time stats were aggregated. Workspaces report statistics periodically, so it may take a bit for these to update!">
|
|
<div className={styles.value}>
|
|
<CollectedIcon />
|
|
{lastAggregated}
|
|
</div>
|
|
</Tooltip>
|
|
|
|
<Tooltip title="A countdown until stats are fetched again. Click to refresh!">
|
|
<Button
|
|
className={`${styles.value} ${styles.refreshButton}`}
|
|
onClick={() => {
|
|
if (fetchStats) {
|
|
fetchStats()
|
|
}
|
|
}}
|
|
variant="text"
|
|
>
|
|
<RefreshIcon />
|
|
{timeUntilRefresh}s
|
|
</Button>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const ValueSeparator: FC = () => {
|
|
const styles = useStyles()
|
|
return <div className={styles.valueSeparator}>/</div>
|
|
}
|
|
|
|
const WorkspaceBuildValue: FC<{
|
|
status: WorkspaceStatus
|
|
count?: number
|
|
}> = ({ status, count }) => {
|
|
const styles = useStyles()
|
|
const displayStatus = getDisplayWorkspaceStatus(status)
|
|
let statusText = displayStatus.text
|
|
let icon = displayStatus.icon
|
|
if (status === "starting") {
|
|
icon = <BuildingIcon />
|
|
statusText = "Building"
|
|
}
|
|
|
|
return (
|
|
<Tooltip title={`${statusText} Workspaces`}>
|
|
<Link
|
|
component={RouterLink}
|
|
to={`/workspaces?filter=${encodeURIComponent("status:" + status)}`}
|
|
>
|
|
<div className={styles.value}>
|
|
{icon}
|
|
{typeof count === "undefined" ? "-" : count}
|
|
</div>
|
|
</Link>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
|
|
const useStyles = makeStyles((theme) => ({
|
|
rocket: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
|
|
"& svg": {
|
|
width: 16,
|
|
height: 16,
|
|
},
|
|
|
|
[theme.breakpoints.down("lg")]: {
|
|
display: "none",
|
|
},
|
|
},
|
|
container: {
|
|
position: "sticky",
|
|
height: bannerHeight,
|
|
bottom: 0,
|
|
zIndex: 1,
|
|
padding: theme.spacing(1, 2),
|
|
backgroundColor: theme.palette.background.paper,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
fontFamily: MONOSPACE_FONT_FAMILY,
|
|
fontSize: 12,
|
|
gap: theme.spacing(4),
|
|
borderTop: `1px solid ${theme.palette.divider}`,
|
|
|
|
[theme.breakpoints.down("lg")]: {
|
|
flexDirection: "column",
|
|
gap: theme.spacing(1),
|
|
alignItems: "left",
|
|
},
|
|
},
|
|
group: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
},
|
|
category: {
|
|
marginRight: theme.spacing(2),
|
|
color: theme.palette.text.primary,
|
|
},
|
|
values: {
|
|
display: "flex",
|
|
gap: theme.spacing(1),
|
|
color: theme.palette.text.secondary,
|
|
},
|
|
valueSeparator: {
|
|
color: theme.palette.text.disabled,
|
|
},
|
|
value: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: theme.spacing(0.5),
|
|
|
|
"& svg": {
|
|
width: 12,
|
|
height: 12,
|
|
},
|
|
},
|
|
iconStripColor: {
|
|
"& *": {
|
|
fill: "currentColor",
|
|
},
|
|
},
|
|
refresh: {
|
|
color: theme.palette.text.primary,
|
|
marginLeft: "auto",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: theme.spacing(2),
|
|
},
|
|
refreshButton: {
|
|
margin: 0,
|
|
padding: "0px 8px",
|
|
height: "unset",
|
|
minHeight: "unset",
|
|
fontSize: "unset",
|
|
color: "unset",
|
|
border: 0,
|
|
minWidth: "unset",
|
|
fontFamily: "inherit",
|
|
|
|
"& svg": {
|
|
marginRight: theme.spacing(0.5),
|
|
},
|
|
},
|
|
}))
|