mirror of https://github.com/coder/coder.git
899 lines
25 KiB
TypeScript
899 lines
25 KiB
TypeScript
import { useTheme } from "@emotion/react";
|
|
import CancelOutlined from "@mui/icons-material/CancelOutlined";
|
|
import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined";
|
|
import LinkOutlined from "@mui/icons-material/LinkOutlined";
|
|
import LinearProgress from "@mui/material/LinearProgress";
|
|
import Link from "@mui/material/Link";
|
|
import Tooltip from "@mui/material/Tooltip";
|
|
import chroma from "chroma-js";
|
|
import {
|
|
subDays,
|
|
addWeeks,
|
|
format,
|
|
startOfDay,
|
|
startOfHour,
|
|
addHours,
|
|
} from "date-fns";
|
|
import {
|
|
type FC,
|
|
type HTMLAttributes,
|
|
type PropsWithChildren,
|
|
type ReactNode,
|
|
useId,
|
|
} from "react";
|
|
import { Helmet } from "react-helmet-async";
|
|
import { useQuery } from "react-query";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import { entitlements } from "api/queries/entitlements";
|
|
import {
|
|
insightsTemplate,
|
|
insightsUserActivity,
|
|
insightsUserLatency,
|
|
} from "api/queries/insights";
|
|
import type {
|
|
Entitlements,
|
|
Template,
|
|
TemplateAppUsage,
|
|
TemplateInsightsResponse,
|
|
TemplateParameterUsage,
|
|
TemplateParameterValue,
|
|
UserActivityInsightsResponse,
|
|
UserLatencyInsightsResponse,
|
|
} from "api/typesGenerated";
|
|
import {
|
|
ActiveUsersTitle,
|
|
ActiveUserChart,
|
|
} from "components/ActiveUserChart/ActiveUserChart";
|
|
import {
|
|
HelpTooltip,
|
|
HelpTooltipTitle,
|
|
HelpTooltipText,
|
|
HelpTooltipContent,
|
|
HelpTooltipTrigger,
|
|
} from "components/HelpTooltip/HelpTooltip";
|
|
import { Loader } from "components/Loader/Loader";
|
|
import { UserAvatar } from "components/UserAvatar/UserAvatar";
|
|
import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout";
|
|
import { getLatencyColor } from "utils/latency";
|
|
import { getTemplatePageTitle } from "../utils";
|
|
import { DateRange as DailyPicker, type DateRangeValue } from "./DateRange";
|
|
import { type InsightsInterval, IntervalMenu } from "./IntervalMenu";
|
|
import { lastWeeks } from "./utils";
|
|
import { WeekPicker, numberOfWeeksOptions } from "./WeekPicker";
|
|
|
|
const DEFAULT_NUMBER_OF_WEEKS = numberOfWeeksOptions[0];
|
|
|
|
export default function TemplateInsightsPage() {
|
|
const { template } = useTemplateLayoutContext();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
const defaultInterval = getDefaultInterval(template);
|
|
const interval =
|
|
(searchParams.get("interval") as InsightsInterval) || defaultInterval;
|
|
|
|
const dateRange = getDateRange(searchParams, interval);
|
|
const setDateRange = (newDateRange: DateRangeValue) => {
|
|
searchParams.set("startDate", newDateRange.startDate.toISOString());
|
|
searchParams.set("endDate", newDateRange.endDate.toISOString());
|
|
setSearchParams(searchParams);
|
|
};
|
|
|
|
// date ranges can have different offsets because of daylight savings so to
|
|
// avoid that we are going to use a common offset
|
|
const baseOffset = dateRange.endDate.getTimezoneOffset();
|
|
const commonFilters = {
|
|
template_ids: template.id,
|
|
start_time: toISOLocal(dateRange.startDate, baseOffset),
|
|
end_time: toISOLocal(dateRange.endDate, baseOffset),
|
|
};
|
|
|
|
const insightsFilter = { ...commonFilters, interval };
|
|
const { data: templateInsights } = useQuery(insightsTemplate(insightsFilter));
|
|
const { data: userLatency } = useQuery(insightsUserLatency(commonFilters));
|
|
const { data: userActivity } = useQuery(insightsUserActivity(commonFilters));
|
|
const { data: entitlementsQuery } = useQuery(entitlements());
|
|
|
|
return (
|
|
<>
|
|
<Helmet>
|
|
<title>{getTemplatePageTitle("Insights", template)}</title>
|
|
</Helmet>
|
|
<TemplateInsightsPageView
|
|
controls={
|
|
<>
|
|
<IntervalMenu
|
|
value={interval}
|
|
onChange={(interval) => {
|
|
// When going from daily to week we need to set a safe week range
|
|
if (interval === "week") {
|
|
setDateRange(lastWeeks(DEFAULT_NUMBER_OF_WEEKS));
|
|
}
|
|
searchParams.set("interval", interval);
|
|
setSearchParams(searchParams);
|
|
}}
|
|
/>
|
|
{interval === "day" ? (
|
|
<DailyPicker value={dateRange} onChange={setDateRange} />
|
|
) : (
|
|
<WeekPicker value={dateRange} onChange={setDateRange} />
|
|
)}
|
|
</>
|
|
}
|
|
templateInsights={templateInsights}
|
|
userLatency={userLatency}
|
|
userActivity={userActivity}
|
|
interval={interval}
|
|
entitlements={entitlementsQuery}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const getDefaultInterval = (template: Template) => {
|
|
const now = new Date();
|
|
const templateCreateDate = new Date(template.created_at);
|
|
const hasFiveWeeksOrMore = addWeeks(templateCreateDate, 5) < now;
|
|
return hasFiveWeeksOrMore ? "week" : "day";
|
|
};
|
|
|
|
const getDateRange = (
|
|
searchParams: URLSearchParams,
|
|
interval: InsightsInterval,
|
|
) => {
|
|
const startDate = searchParams.get("startDate");
|
|
const endDate = searchParams.get("endDate");
|
|
|
|
if (startDate && endDate) {
|
|
return {
|
|
startDate: new Date(startDate),
|
|
endDate: new Date(endDate),
|
|
};
|
|
}
|
|
|
|
if (interval === "day") {
|
|
// Only instantiate new Date once so that we don't get the wrong interval if
|
|
// start is 23:59:59.999 and the clock shifts to 00:00:00 before the second
|
|
// instantiation.
|
|
const today = new Date();
|
|
return {
|
|
startDate: startOfDay(subDays(today, 6)),
|
|
// Add one hour to endDate to include real-time data for today.
|
|
endDate: addHours(startOfHour(today), 1),
|
|
};
|
|
}
|
|
|
|
return lastWeeks(DEFAULT_NUMBER_OF_WEEKS);
|
|
};
|
|
|
|
interface TemplateInsightsPageViewProps {
|
|
templateInsights: TemplateInsightsResponse | undefined;
|
|
userLatency: UserLatencyInsightsResponse | undefined;
|
|
userActivity: UserActivityInsightsResponse | undefined;
|
|
entitlements: Entitlements | undefined;
|
|
controls: ReactNode;
|
|
interval: InsightsInterval;
|
|
}
|
|
|
|
export const TemplateInsightsPageView: FC<TemplateInsightsPageViewProps> = ({
|
|
templateInsights,
|
|
userLatency,
|
|
userActivity,
|
|
entitlements,
|
|
controls,
|
|
interval,
|
|
}) => {
|
|
return (
|
|
<>
|
|
<div
|
|
css={{
|
|
marginBottom: 32,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
{controls}
|
|
</div>
|
|
<div
|
|
css={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
|
gridTemplateRows: "440px 440px auto",
|
|
gap: 24,
|
|
}}
|
|
>
|
|
<ActiveUsersPanel
|
|
css={{ gridColumn: "span 2" }}
|
|
interval={interval}
|
|
userLimit={
|
|
entitlements?.features.user_limit.enabled
|
|
? entitlements?.features.user_limit.limit
|
|
: undefined
|
|
}
|
|
data={templateInsights?.interval_reports}
|
|
/>
|
|
<UsersLatencyPanel data={userLatency} />
|
|
<TemplateUsagePanel
|
|
css={{ gridColumn: "span 2" }}
|
|
data={templateInsights?.report?.apps_usage}
|
|
/>
|
|
<UsersActivityPanel data={userActivity} />
|
|
<TemplateParametersUsagePanel
|
|
css={{ gridColumn: "span 3" }}
|
|
data={templateInsights?.report?.parameters_usage}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
interface ActiveUsersPanelProps extends PanelProps {
|
|
data: TemplateInsightsResponse["interval_reports"] | undefined;
|
|
interval: InsightsInterval;
|
|
userLimit: number | undefined;
|
|
}
|
|
|
|
const ActiveUsersPanel: FC<ActiveUsersPanelProps> = ({
|
|
data,
|
|
interval,
|
|
userLimit,
|
|
...panelProps
|
|
}) => {
|
|
return (
|
|
<Panel {...panelProps}>
|
|
<PanelHeader>
|
|
<PanelTitle>
|
|
<ActiveUsersTitle />
|
|
</PanelTitle>
|
|
</PanelHeader>
|
|
<PanelContent>
|
|
{!data && <Loader css={{ height: "100%" }} />}
|
|
{data && data.length === 0 && <NoDataAvailable />}
|
|
{data && data.length > 0 && (
|
|
<ActiveUserChart
|
|
interval={interval}
|
|
userLimit={userLimit}
|
|
data={data.map((d) => ({
|
|
amount: d.active_users,
|
|
date: d.start_time,
|
|
}))}
|
|
/>
|
|
)}
|
|
</PanelContent>
|
|
</Panel>
|
|
);
|
|
};
|
|
|
|
interface UsersLatencyPanelProps extends PanelProps {
|
|
data: UserLatencyInsightsResponse | undefined;
|
|
}
|
|
|
|
const UsersLatencyPanel: FC<UsersLatencyPanelProps> = ({
|
|
data,
|
|
...panelProps
|
|
}) => {
|
|
const theme = useTheme();
|
|
const users = data?.report.users;
|
|
|
|
return (
|
|
<Panel {...panelProps} css={{ overflowY: "auto" }}>
|
|
<PanelHeader>
|
|
<PanelTitle css={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
Latency by user
|
|
<HelpTooltip>
|
|
<HelpTooltipTrigger size="small" />
|
|
<HelpTooltipContent>
|
|
<HelpTooltipTitle>How is latency calculated?</HelpTooltipTitle>
|
|
<HelpTooltipText>
|
|
The median round trip time of user connections to workspaces.
|
|
</HelpTooltipText>
|
|
</HelpTooltipContent>
|
|
</HelpTooltip>
|
|
</PanelTitle>
|
|
</PanelHeader>
|
|
|
|
<PanelContent>
|
|
{!data && <Loader css={{ height: "100%" }} />}
|
|
{users && users.length === 0 && <NoDataAvailable />}
|
|
{users &&
|
|
[...users]
|
|
.sort((a, b) => b.latency_ms.p50 - a.latency_ms.p50)
|
|
.map((row) => (
|
|
<div
|
|
key={row.user_id}
|
|
css={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
fontSize: 14,
|
|
paddingTop: 8,
|
|
paddingBottom: 8,
|
|
}}
|
|
>
|
|
<div css={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
<UserAvatar
|
|
username={row.username}
|
|
avatarURL={row.avatar_url}
|
|
/>
|
|
<div css={{ fontWeight: 500 }}>{row.username}</div>
|
|
</div>
|
|
<div
|
|
css={{
|
|
color: getLatencyColor(theme, row.latency_ms.p50),
|
|
fontWeight: 500,
|
|
fontSize: 13,
|
|
textAlign: "right",
|
|
}}
|
|
>
|
|
{row.latency_ms.p50.toFixed(0)}ms
|
|
</div>
|
|
</div>
|
|
))}
|
|
</PanelContent>
|
|
</Panel>
|
|
);
|
|
};
|
|
|
|
interface UsersActivityPanelProps extends PanelProps {
|
|
data: UserActivityInsightsResponse | undefined;
|
|
}
|
|
|
|
const UsersActivityPanel: FC<UsersActivityPanelProps> = ({
|
|
data,
|
|
...panelProps
|
|
}) => {
|
|
const theme = useTheme();
|
|
|
|
const users = data?.report.users;
|
|
|
|
return (
|
|
<Panel {...panelProps} css={{ overflowY: "auto" }}>
|
|
<PanelHeader>
|
|
<PanelTitle css={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
Activity by user
|
|
<HelpTooltip>
|
|
<HelpTooltipTrigger size="small" />
|
|
<HelpTooltipContent>
|
|
<HelpTooltipTitle>How is activity calculated?</HelpTooltipTitle>
|
|
<HelpTooltipText>
|
|
When a connection is initiated to a user's workspace they
|
|
are considered an active user. e.g. apps, web terminal, SSH
|
|
</HelpTooltipText>
|
|
</HelpTooltipContent>
|
|
</HelpTooltip>
|
|
</PanelTitle>
|
|
</PanelHeader>
|
|
<PanelContent>
|
|
{!data && <Loader css={{ height: "100%" }} />}
|
|
{users && users.length === 0 && <NoDataAvailable />}
|
|
{users &&
|
|
[...users]
|
|
.sort((a, b) => b.seconds - a.seconds)
|
|
.map((row) => (
|
|
<div
|
|
key={row.user_id}
|
|
css={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
fontSize: 14,
|
|
paddingTop: 8,
|
|
paddingBottom: 8,
|
|
}}
|
|
>
|
|
<div css={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
<UserAvatar
|
|
username={row.username}
|
|
avatarURL={row.avatar_url}
|
|
/>
|
|
<div css={{ fontWeight: 500 }}>{row.username}</div>
|
|
</div>
|
|
<div
|
|
css={{
|
|
color: theme.palette.text.secondary,
|
|
fontSize: 13,
|
|
textAlign: "right",
|
|
}}
|
|
>
|
|
{formatTime(row.seconds)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</PanelContent>
|
|
</Panel>
|
|
);
|
|
};
|
|
|
|
interface TemplateUsagePanelProps extends PanelProps {
|
|
data: readonly TemplateAppUsage[] | undefined;
|
|
}
|
|
|
|
const TemplateUsagePanel: FC<TemplateUsagePanelProps> = ({
|
|
data,
|
|
...panelProps
|
|
}) => {
|
|
const theme = useTheme();
|
|
const validUsage = data?.filter((u) => u.seconds > 0);
|
|
const totalInSeconds =
|
|
validUsage?.reduce((total, usage) => total + usage.seconds, 0) ?? 1;
|
|
const usageColors = chroma
|
|
.scale([theme.roles.success.fill.solid, theme.roles.notice.fill.solid])
|
|
.mode("lch")
|
|
.colors(validUsage?.length ?? 0);
|
|
// The API returns a row for each app, even if the user didn't use it.
|
|
const hasDataAvailable = validUsage && validUsage.length > 0;
|
|
|
|
return (
|
|
<Panel {...panelProps} css={{ overflowY: "auto" }}>
|
|
<PanelHeader>
|
|
<PanelTitle>App & IDE Usage</PanelTitle>
|
|
</PanelHeader>
|
|
<PanelContent>
|
|
{!data && <Loader css={{ height: "100%" }} />}
|
|
{data && !hasDataAvailable && <NoDataAvailable />}
|
|
{data && hasDataAvailable && (
|
|
<div
|
|
css={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 24,
|
|
}}
|
|
>
|
|
{validUsage
|
|
.sort((a, b) => b.seconds - a.seconds)
|
|
.map((usage, i) => {
|
|
const percentage = (usage.seconds / totalInSeconds) * 100;
|
|
return (
|
|
<div
|
|
key={usage.slug}
|
|
css={{ display: "flex", gap: 16, alignItems: "center" }}
|
|
>
|
|
<div
|
|
css={{ display: "flex", alignItems: "center", gap: 8 }}
|
|
>
|
|
<div
|
|
css={{
|
|
width: 20,
|
|
height: 20,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<img
|
|
src={usage.icon}
|
|
alt=""
|
|
style={{
|
|
objectFit: "contain",
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
/>
|
|
</div>
|
|
<div css={{ fontSize: 13, fontWeight: 500, width: 200 }}>
|
|
{usage.display_name}
|
|
</div>
|
|
</div>
|
|
<LinearProgress
|
|
value={percentage}
|
|
variant="determinate"
|
|
css={{
|
|
width: "100%",
|
|
height: 8,
|
|
backgroundColor: theme.palette.divider,
|
|
"& .MuiLinearProgress-bar": {
|
|
backgroundColor: usageColors[i],
|
|
borderRadius: 999,
|
|
},
|
|
}}
|
|
/>
|
|
<div
|
|
css={{
|
|
fontSize: 13,
|
|
color: theme.palette.text.secondary,
|
|
width: 120,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{formatTime(usage.seconds)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</PanelContent>
|
|
</Panel>
|
|
);
|
|
};
|
|
|
|
interface TemplateParametersUsagePanelProps extends PanelProps {
|
|
data: readonly TemplateParameterUsage[] | undefined;
|
|
}
|
|
|
|
const TemplateParametersUsagePanel: FC<TemplateParametersUsagePanelProps> = ({
|
|
data,
|
|
...panelProps
|
|
}) => {
|
|
const theme = useTheme();
|
|
|
|
return (
|
|
<Panel {...panelProps}>
|
|
<PanelHeader>
|
|
<PanelTitle>Parameters usage</PanelTitle>
|
|
</PanelHeader>
|
|
<PanelContent>
|
|
{!data && <Loader css={{ height: 200 }} />}
|
|
{data && data.length === 0 && <NoDataAvailable css={{ height: 200 }} />}
|
|
{data &&
|
|
data.length > 0 &&
|
|
data.map((parameter, parameterIndex) => {
|
|
const label =
|
|
parameter.display_name !== ""
|
|
? parameter.display_name
|
|
: parameter.name;
|
|
return (
|
|
<div
|
|
key={parameter.name}
|
|
css={{
|
|
display: "flex",
|
|
alignItems: "start",
|
|
padding: 24,
|
|
marginLeft: -24,
|
|
marginRight: -24,
|
|
borderTop: `1px solid ${theme.palette.divider}`,
|
|
width: "calc(100% + 48px)",
|
|
"&:first-child": {
|
|
borderTop: 0,
|
|
},
|
|
gap: 24,
|
|
}}
|
|
>
|
|
<div css={{ flex: 1 }}>
|
|
<div css={{ fontWeight: 500 }}>{label}</div>
|
|
<p
|
|
css={{
|
|
fontSize: 14,
|
|
color: theme.palette.text.secondary,
|
|
maxWidth: 400,
|
|
margin: 0,
|
|
}}
|
|
>
|
|
{parameter.description}
|
|
</p>
|
|
</div>
|
|
<div css={{ flex: 1, fontSize: 14, flexGrow: 2 }}>
|
|
<ParameterUsageRow
|
|
css={{
|
|
color: theme.palette.text.secondary,
|
|
fontWeight: 500,
|
|
fontSize: 13,
|
|
cursor: "default",
|
|
}}
|
|
>
|
|
<div>Value</div>
|
|
<Tooltip
|
|
title="The number of workspaces using this value"
|
|
placement="top"
|
|
>
|
|
<div>Count</div>
|
|
</Tooltip>
|
|
</ParameterUsageRow>
|
|
{[...parameter.values]
|
|
.sort((a, b) => b.count - a.count)
|
|
.filter((usage) => filterOrphanValues(usage, parameter))
|
|
.map((usage, usageIndex) => (
|
|
<ParameterUsageRow
|
|
key={`${parameterIndex}-${usageIndex}`}
|
|
>
|
|
<ParameterUsageLabel
|
|
usage={usage}
|
|
parameter={parameter}
|
|
/>
|
|
<div css={{ textAlign: "right" }}>{usage.count}</div>
|
|
</ParameterUsageRow>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</PanelContent>
|
|
</Panel>
|
|
);
|
|
};
|
|
|
|
const filterOrphanValues = (
|
|
usage: TemplateParameterValue,
|
|
parameter: TemplateParameterUsage,
|
|
) => {
|
|
if (parameter.options) {
|
|
return parameter.options.some((o) => o.value === usage.value);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const ParameterUsageRow: FC<HTMLAttributes<HTMLDivElement>> = ({
|
|
children,
|
|
...attrs
|
|
}) => {
|
|
return (
|
|
<div
|
|
css={{
|
|
display: "flex",
|
|
alignItems: "baseline",
|
|
justifyContent: "space-between",
|
|
padding: "4px 0",
|
|
}}
|
|
{...attrs}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface ParameterUsageLabelProps {
|
|
usage: TemplateParameterValue;
|
|
parameter: TemplateParameterUsage;
|
|
}
|
|
|
|
const ParameterUsageLabel: FC<ParameterUsageLabelProps> = ({
|
|
usage,
|
|
parameter,
|
|
}) => {
|
|
const ariaId = useId();
|
|
const theme = useTheme();
|
|
|
|
if (parameter.options) {
|
|
const option = parameter.options.find((o) => o.value === usage.value)!;
|
|
const icon = option.icon;
|
|
const label = option.name;
|
|
|
|
return (
|
|
<div
|
|
css={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 16,
|
|
}}
|
|
>
|
|
{icon && (
|
|
<div css={{ width: 16, height: 16, lineHeight: 1 }}>
|
|
<img
|
|
alt=""
|
|
src={icon}
|
|
css={{
|
|
objectFit: "contain",
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
aria-labelledby={ariaId}
|
|
/>
|
|
</div>
|
|
)}
|
|
<span id={ariaId}>{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (usage.value.startsWith("http")) {
|
|
return (
|
|
<Link
|
|
href={usage.value}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
css={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 1,
|
|
color: theme.palette.text.primary,
|
|
}}
|
|
>
|
|
<TextValue>{usage.value}</TextValue>
|
|
<LinkOutlined
|
|
css={{
|
|
width: 14,
|
|
height: 14,
|
|
color: theme.palette.primary.light,
|
|
}}
|
|
/>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
if (parameter.type === "list(string)") {
|
|
const values = JSON.parse(usage.value) as string[];
|
|
return (
|
|
<div css={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
{values.map((v, i) => (
|
|
<div
|
|
key={i}
|
|
css={{
|
|
padding: "2px 12px",
|
|
borderRadius: 999,
|
|
background: theme.palette.divider,
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{v}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (parameter.type === "bool") {
|
|
return (
|
|
<div
|
|
css={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
{usage.value === "false" ? (
|
|
<>
|
|
<CancelOutlined
|
|
css={{
|
|
width: 16,
|
|
height: 16,
|
|
color: theme.palette.error.light,
|
|
}}
|
|
/>
|
|
False
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircleOutlined
|
|
css={{
|
|
width: 16,
|
|
height: 16,
|
|
color: theme.palette.success.light,
|
|
}}
|
|
/>
|
|
True
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <TextValue>{usage.value}</TextValue>;
|
|
};
|
|
|
|
interface PanelProps extends HTMLAttributes<HTMLDivElement> {}
|
|
|
|
const Panel: FC<PanelProps> = ({ children, ...attrs }) => {
|
|
const theme = useTheme();
|
|
|
|
return (
|
|
<div
|
|
css={{
|
|
borderRadius: 8,
|
|
border: `1px solid ${theme.palette.divider}`,
|
|
backgroundColor: theme.palette.background.paper,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
{...attrs}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const PanelHeader: FC<HTMLAttributes<HTMLDivElement>> = ({
|
|
children,
|
|
...attrs
|
|
}) => {
|
|
return (
|
|
<div css={{ padding: "20px 24px 24px" }} {...attrs}>
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const PanelTitle: FC<HTMLAttributes<HTMLDivElement>> = ({
|
|
children,
|
|
...attrs
|
|
}) => {
|
|
return (
|
|
<div css={{ fontSize: 14, fontWeight: 500 }} {...attrs}>
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const PanelContent: FC<HTMLAttributes<HTMLDivElement>> = ({
|
|
children,
|
|
...attrs
|
|
}) => {
|
|
return (
|
|
<div css={{ padding: "0 24px 24px", flex: 1 }} {...attrs}>
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const NoDataAvailable = (props: HTMLAttributes<HTMLDivElement>) => {
|
|
const theme = useTheme();
|
|
|
|
return (
|
|
<div
|
|
{...props}
|
|
css={{
|
|
fontSize: 13,
|
|
color: theme.palette.text.secondary,
|
|
textAlign: "center",
|
|
height: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
No data available
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const TextValue: FC<PropsWithChildren> = ({ children }) => {
|
|
const theme = useTheme();
|
|
|
|
return (
|
|
<span>
|
|
<span
|
|
css={{
|
|
color: theme.palette.text.secondary,
|
|
weight: 600,
|
|
marginRight: 2,
|
|
}}
|
|
>
|
|
"
|
|
</span>
|
|
{children}
|
|
<span
|
|
css={{
|
|
color: theme.palette.text.secondary,
|
|
weight: 600,
|
|
marginLeft: 2,
|
|
}}
|
|
>
|
|
"
|
|
</span>
|
|
</span>
|
|
);
|
|
};
|
|
|
|
function formatTime(seconds: number): string {
|
|
if (seconds < 60) {
|
|
return seconds + " seconds";
|
|
} else if (seconds >= 60 && seconds < 3600) {
|
|
const minutes = Math.floor(seconds / 60);
|
|
return minutes + " minutes";
|
|
} else {
|
|
const hours = seconds / 3600;
|
|
const minutes = Math.floor(seconds % 3600);
|
|
if (minutes === 0) {
|
|
return hours.toFixed(0) + " hours";
|
|
}
|
|
|
|
return hours.toFixed(1) + " hours";
|
|
}
|
|
}
|
|
|
|
function toISOLocal(d: Date, offset: number) {
|
|
return format(d, `yyyy-MM-dd'T'HH:mm:ss${formatOffset(offset)}`);
|
|
}
|
|
|
|
function formatOffset(offset: number): string {
|
|
// A negative offset means that this is a positive timezone, e.g. GMT+2 = -120.
|
|
const isPositiveTimezone = offset <= 0;
|
|
const absoluteOffset = Math.abs(offset);
|
|
const hours = Math.floor(absoluteOffset / 60);
|
|
const minutes = Math.abs(offset) % 60;
|
|
const formattedHours = `${isPositiveTimezone ? "+" : "-"}${String(
|
|
hours,
|
|
).padStart(2, "0")}`;
|
|
const formattedMinutes = String(minutes).padStart(2, "0");
|
|
return `${formattedHours}:${formattedMinutes}`;
|
|
}
|