mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat(site): add date range picker for the template insights (#8976)
This commit is contained in:
@@ -48,6 +48,7 @@
|
||||
"@types/color-convert": "2.0.0",
|
||||
"@types/lodash": "4.14.196",
|
||||
"@types/react-color": "3.0.6",
|
||||
"@types/react-date-range": "1.4.4",
|
||||
"@types/semver": "7.5.0",
|
||||
"@vitejs/plugin-react": "4.0.1",
|
||||
"@xstate/inspect": "0.8.0",
|
||||
@@ -77,6 +78,7 @@
|
||||
"react-chartjs-2": "5.2.0",
|
||||
"react-color": "2.19.3",
|
||||
"react-confetti": "6.1.0",
|
||||
"react-date-range": "1.4.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-headless-tabs": "6.0.3",
|
||||
"react-helmet-async": "1.3.0",
|
||||
|
||||
Generated
+44
@@ -60,6 +60,9 @@ dependencies:
|
||||
'@types/react-color':
|
||||
specifier: 3.0.6
|
||||
version: 3.0.6
|
||||
'@types/react-date-range':
|
||||
specifier: 1.4.4
|
||||
version: 1.4.4
|
||||
'@types/semver':
|
||||
specifier: 7.5.0
|
||||
version: 7.5.0
|
||||
@@ -147,6 +150,9 @@ dependencies:
|
||||
react-confetti:
|
||||
specifier: 6.1.0
|
||||
version: 6.1.0(react@18.2.0)
|
||||
react-date-range:
|
||||
specifier: 1.4.0
|
||||
version: 1.4.0(date-fns@2.30.0)(react@18.2.0)
|
||||
react-dom:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0(react@18.2.0)
|
||||
@@ -4952,6 +4958,13 @@ packages:
|
||||
'@types/reactcss': 1.2.6
|
||||
dev: false
|
||||
|
||||
/@types/react-date-range@1.4.4:
|
||||
resolution: {integrity: sha512-9Y9NyNgaCsEVN/+O4HKuxzPbVjRVBGdOKRxMDcsTRWVG62lpYgnxefNckTXDWup8FvczoqPW0+ESZR6R1yymDg==}
|
||||
dependencies:
|
||||
'@types/react': 18.2.6
|
||||
date-fns: 2.30.0
|
||||
dev: false
|
||||
|
||||
/@types/react-dom@18.2.4:
|
||||
resolution: {integrity: sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==}
|
||||
dependencies:
|
||||
@@ -6226,6 +6239,10 @@ packages:
|
||||
resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==}
|
||||
dev: true
|
||||
|
||||
/classnames@2.3.2:
|
||||
resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==}
|
||||
dev: false
|
||||
|
||||
/clean-regexp@1.0.0:
|
||||
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -11179,6 +11196,20 @@ packages:
|
||||
tween-functions: 1.2.0
|
||||
dev: false
|
||||
|
||||
/react-date-range@1.4.0(date-fns@2.30.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-+9t0HyClbCqw1IhYbpWecjsiaftCeRN5cdhsi9v06YdimwyMR2yYHWcgVn3URwtN/txhqKpEZB6UX1fHpvK76w==}
|
||||
peerDependencies:
|
||||
date-fns: 2.0.0-alpha.7 || >=2.0.0
|
||||
react: ^0.14 || ^15.0.0-rc || >=15.0
|
||||
dependencies:
|
||||
classnames: 2.3.2
|
||||
date-fns: 2.30.0
|
||||
prop-types: 15.8.1
|
||||
react: 18.2.0
|
||||
react-list: 0.8.17(react@18.2.0)
|
||||
shallow-equal: 1.2.1
|
||||
dev: false
|
||||
|
||||
/react-docgen-typescript@2.2.2(typescript@5.1.6):
|
||||
resolution: {integrity: sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==}
|
||||
peerDependencies:
|
||||
@@ -11313,6 +11344,15 @@ packages:
|
||||
/react-is@18.2.0:
|
||||
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
|
||||
|
||||
/react-list@0.8.17(react@18.2.0):
|
||||
resolution: {integrity: sha512-pgmzGi0G5uGrdHzMhgO7KR1wx5ZXVvI3SsJUmkblSAKtewIhMwbQiMuQiTE83ozo04BQJbe0r3WIWzSO0dR1xg==}
|
||||
peerDependencies:
|
||||
react: 0.14 || 15 - 18
|
||||
dependencies:
|
||||
prop-types: 15.8.1
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/react-markdown@8.0.3(@types/react@18.2.6)(react@18.2.0):
|
||||
resolution: {integrity: sha512-We36SfqaKoVNpN1QqsZwWSv/OZt5J15LNgTLWynwAN5b265hrQrsjMtlRNwUvS+YyR3yDM8HpTNc4pK9H/Gc0A==}
|
||||
peerDependencies:
|
||||
@@ -12001,6 +12041,10 @@ packages:
|
||||
kind-of: 6.0.3
|
||||
dev: true
|
||||
|
||||
/shallow-equal@1.2.1:
|
||||
resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==}
|
||||
dev: false
|
||||
|
||||
/shallowequal@1.1.0:
|
||||
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
|
||||
dev: false
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import Box from "@mui/material/Box"
|
||||
import { styled } from "@mui/material/styles"
|
||||
import { ComponentProps, useRef, useState } from "react"
|
||||
import "react-date-range/dist/styles.css"
|
||||
import "react-date-range/dist/theme/default.css"
|
||||
import Button from "@mui/material/Button"
|
||||
import ArrowRightAltOutlined from "@mui/icons-material/ArrowRightAltOutlined"
|
||||
import Popover from "@mui/material/Popover"
|
||||
import { DateRangePicker, createStaticRanges } from "react-date-range"
|
||||
import { format, subDays } from "date-fns"
|
||||
|
||||
// The type definition from @types is wrong
|
||||
declare module "react-date-range" {
|
||||
export function createStaticRanges(
|
||||
ranges: Omit<StaticRange, "isSelected">[],
|
||||
): StaticRange[]
|
||||
}
|
||||
|
||||
export type DateRangeValue = {
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
}
|
||||
|
||||
type RangesState = NonNullable<ComponentProps<typeof DateRangePicker>["ranges"]>
|
||||
|
||||
export const DateRange = ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: DateRangeValue
|
||||
onChange: (value: DateRangeValue) => void
|
||||
}) => {
|
||||
const selectionStatusRef = useRef<"idle" | "selecting">("idle")
|
||||
const anchorRef = useRef<HTMLButtonElement>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [ranges, setRanges] = useState<RangesState>([
|
||||
{
|
||||
...value,
|
||||
key: "selection",
|
||||
},
|
||||
])
|
||||
const currentRange = {
|
||||
startDate: ranges[0].startDate as Date,
|
||||
endDate: ranges[0].endDate as Date,
|
||||
}
|
||||
const handleClose = () => {
|
||||
onChange({
|
||||
startDate: currentRange.startDate,
|
||||
endDate: currentRange.endDate,
|
||||
})
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button ref={anchorRef} onClick={() => setIsOpen(true)}>
|
||||
<span>{format(currentRange.startDate, "MMM d, Y")}</span>
|
||||
<ArrowRightAltOutlined sx={{ width: 16, height: 16, mx: 1 }} />
|
||||
<span>{format(currentRange.endDate, "MMM d, Y")}</span>
|
||||
</Button>
|
||||
<Popover
|
||||
anchorEl={anchorRef.current}
|
||||
open={isOpen}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
sx={{
|
||||
"& .MuiPaper-root": {
|
||||
marginTop: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DateRangePickerWrapper
|
||||
component={DateRangePicker}
|
||||
onChange={(item) => {
|
||||
const range = item.selection
|
||||
setRanges([range])
|
||||
|
||||
// When it is the first selection, we don't want to close the popover
|
||||
// We have to do that ourselves because the library doesn't provide a way to do it
|
||||
if (selectionStatusRef.current === "idle") {
|
||||
selectionStatusRef.current = "selecting"
|
||||
return
|
||||
}
|
||||
|
||||
selectionStatusRef.current = "idle"
|
||||
const startDate = range.startDate as Date
|
||||
const endDate = range.endDate as Date
|
||||
onChange({
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
setIsOpen(false)
|
||||
}}
|
||||
moveRangeOnFirstSelection={false}
|
||||
months={2}
|
||||
ranges={ranges}
|
||||
maxDate={new Date()}
|
||||
direction="horizontal"
|
||||
staticRanges={createStaticRanges([
|
||||
{
|
||||
label: "Today",
|
||||
range: () => ({
|
||||
startDate: new Date(),
|
||||
endDate: new Date(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Yesterday",
|
||||
range: () => ({
|
||||
startDate: subDays(new Date(), 1),
|
||||
endDate: subDays(new Date(), 1),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last 7 days",
|
||||
range: () => ({
|
||||
startDate: subDays(new Date(), 6),
|
||||
endDate: new Date(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last 14 days",
|
||||
range: () => ({
|
||||
startDate: subDays(new Date(), 13),
|
||||
endDate: new Date(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last 30 days",
|
||||
range: () => ({
|
||||
startDate: subDays(new Date(), 29),
|
||||
endDate: new Date(),
|
||||
}),
|
||||
},
|
||||
])}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const DateRangePickerWrapper: typeof Box = styled(Box)(({ theme }) => ({
|
||||
"& .rdrDefinedRangesWrapper": {
|
||||
background: theme.palette.background.paper,
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
|
||||
"& .rdrStaticRange": {
|
||||
background: theme.palette.background.paper,
|
||||
border: 0,
|
||||
fontSize: 14,
|
||||
color: theme.palette.text.secondary,
|
||||
|
||||
"&:hover .rdrStaticRangeLabel": {
|
||||
background: theme.palette.background.paperLight,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
|
||||
"&.rdrStaticRangeSelected": {
|
||||
color: `${theme.palette.text.primary} !important`,
|
||||
},
|
||||
},
|
||||
|
||||
"& .rdrInputRanges": {
|
||||
display: "none",
|
||||
},
|
||||
|
||||
"& .rdrDateDisplayWrapper": {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
|
||||
"& .rdrCalendarWrapper": {
|
||||
backgroundColor: theme.palette.background.paperLight,
|
||||
},
|
||||
|
||||
"& .rdrDateDisplayItem": {
|
||||
background: "transparent",
|
||||
borderColor: theme.palette.divider,
|
||||
|
||||
"& input": {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
|
||||
"&.rdrDateDisplayItemActive": {
|
||||
borderColor: theme.palette.text.primary,
|
||||
backgroundColor: theme.palette.background.paperLight,
|
||||
|
||||
"& input": {
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"& .rdrMonthPicker select, & .rdrYearPicker select": {
|
||||
color: theme.palette.text.primary,
|
||||
appearance: "auto",
|
||||
background: "transparent",
|
||||
},
|
||||
|
||||
"& .rdrMonthName, & .rdrWeekDay": {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
|
||||
"& .rdrDayPassive .rdrDayNumber span": {
|
||||
color: theme.palette.text.disabled,
|
||||
},
|
||||
|
||||
"& .rdrDayNumber span": {
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
|
||||
"& .rdrDayToday .rdrDayNumber span": {
|
||||
fontWeight: 900,
|
||||
|
||||
"&:after": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
|
||||
"& .rdrInRange, & .rdrEndEdge, & .rdrStartEdge": {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
|
||||
"& .rdrDayDisabled": {
|
||||
backgroundColor: "transparent",
|
||||
|
||||
"& .rdrDayNumber span": {
|
||||
color: theme.palette.text.disabled,
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -25,28 +25,40 @@ import {
|
||||
TemplateParameterValue,
|
||||
UserLatencyInsightsResponse,
|
||||
} from "api/typesGenerated"
|
||||
import { ComponentProps } from "react"
|
||||
import { subDays, addHours, startOfHour } from "date-fns"
|
||||
import { ComponentProps, ReactNode, useState } from "react"
|
||||
import { subDays, isToday } from "date-fns"
|
||||
import "react-date-range/dist/styles.css"
|
||||
import "react-date-range/dist/theme/default.css"
|
||||
import { DateRange, DateRangeValue } from "./DateRange"
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"
|
||||
import Link from "@mui/material/Link"
|
||||
import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined"
|
||||
import CancelOutlined from "@mui/icons-material/CancelOutlined"
|
||||
import { getDateRangeFilter } from "./utils"
|
||||
|
||||
export default function TemplateInsightsPage() {
|
||||
const now = new Date()
|
||||
const [dateRangeValue, setDateRangeValue] = useState<DateRangeValue>({
|
||||
startDate: subDays(now, 6),
|
||||
endDate: now,
|
||||
})
|
||||
const { template } = useTemplateLayoutContext()
|
||||
const insightsFilter = {
|
||||
template_ids: template.id,
|
||||
start_time: toStartTimeFilter(subDays(now, 7)),
|
||||
end_time: startOfHour(addHours(now, 1)).toISOString(),
|
||||
...getDateRangeFilter({
|
||||
startDate: dateRangeValue.startDate,
|
||||
endDate: dateRangeValue.endDate,
|
||||
now,
|
||||
isToday,
|
||||
}),
|
||||
}
|
||||
const { data: templateInsights } = useQuery({
|
||||
queryKey: ["templates", template.id, "usage"],
|
||||
queryKey: ["templates", template.id, "usage", insightsFilter],
|
||||
queryFn: () => getInsightsTemplate(insightsFilter),
|
||||
})
|
||||
const { data: userLatency } = useQuery({
|
||||
queryKey: ["templates", template.id, "user-latency"],
|
||||
queryKey: ["templates", template.id, "user-latency", insightsFilter],
|
||||
queryFn: () => getInsightsUserLatency(insightsFilter),
|
||||
})
|
||||
const dashboard = useDashboard()
|
||||
@@ -60,6 +72,9 @@ export default function TemplateInsightsPage() {
|
||||
<title>{getTemplatePageTitle("Insights", template)}</title>
|
||||
</Helmet>
|
||||
<TemplateInsightsPageView
|
||||
dateRange={
|
||||
<DateRange value={dateRangeValue} onChange={setDateRangeValue} />
|
||||
}
|
||||
templateInsights={templateInsights}
|
||||
userLatency={userLatency}
|
||||
shouldDisplayParameters={shouldDisplayParameters}
|
||||
@@ -72,36 +87,41 @@ export const TemplateInsightsPageView = ({
|
||||
templateInsights,
|
||||
userLatency,
|
||||
shouldDisplayParameters,
|
||||
dateRange,
|
||||
}: {
|
||||
templateInsights: TemplateInsightsResponse | undefined
|
||||
userLatency: UserLatencyInsightsResponse | undefined
|
||||
shouldDisplayParameters: boolean
|
||||
dateRange: ReactNode
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
gridTemplateRows: "440px auto",
|
||||
gap: (theme) => theme.spacing(3),
|
||||
}}
|
||||
>
|
||||
<DailyUsersPanel
|
||||
sx={{ gridColumn: "span 2" }}
|
||||
data={templateInsights?.interval_reports}
|
||||
/>
|
||||
<UserLatencyPanel data={userLatency} />
|
||||
<TemplateUsagePanel
|
||||
sx={{ gridColumn: "span 3" }}
|
||||
data={templateInsights?.report.apps_usage}
|
||||
/>
|
||||
{shouldDisplayParameters && (
|
||||
<TemplateParametersUsagePanel
|
||||
sx={{ gridColumn: "span 3" }}
|
||||
data={templateInsights?.report.parameters_usage}
|
||||
<>
|
||||
<Box sx={{ mb: 4 }}>{dateRange}</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
gridTemplateRows: "440px auto",
|
||||
gap: (theme) => theme.spacing(3),
|
||||
}}
|
||||
>
|
||||
<DailyUsersPanel
|
||||
sx={{ gridColumn: "span 2" }}
|
||||
data={templateInsights?.interval_reports}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<UserLatencyPanel data={userLatency} />
|
||||
<TemplateUsagePanel
|
||||
sx={{ gridColumn: "span 3" }}
|
||||
data={templateInsights?.report.apps_usage}
|
||||
/>
|
||||
{shouldDisplayParameters && (
|
||||
<TemplateParametersUsagePanel
|
||||
sx={{ gridColumn: "span 3" }}
|
||||
data={templateInsights?.report.parameters_usage}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -551,20 +571,12 @@ function mapToDAUsResponse(
|
||||
entries: data.map((d) => {
|
||||
return {
|
||||
amount: d.active_users,
|
||||
date: d.end_time,
|
||||
date: d.start_time,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function toStartTimeFilter(date: Date) {
|
||||
date.setHours(0, 0, 0, 0)
|
||||
const year = date.getUTCFullYear()
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getUTCDate()).padStart(2, "0")
|
||||
return `${year}-${month}-${day}T00:00:00Z`
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return seconds + " seconds"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getDateRangeFilter } from "./utils"
|
||||
|
||||
describe("getDateRangeFilter", () => {
|
||||
it("returns the start time at the start of the day", () => {
|
||||
const date = new Date("2020-01-01T12:00:00.000Z")
|
||||
const { start_time } = getDateRangeFilter({
|
||||
startDate: date,
|
||||
endDate: date,
|
||||
now: date,
|
||||
isToday: () => false,
|
||||
})
|
||||
expect(start_time).toEqual("2020-01-01T00:00:00+00:00")
|
||||
})
|
||||
|
||||
it("returns the end time at the start of the next day", () => {
|
||||
const date = new Date("2020-01-01T12:00:00.000Z")
|
||||
const { end_time } = getDateRangeFilter({
|
||||
startDate: date,
|
||||
endDate: date,
|
||||
now: date,
|
||||
isToday: () => false,
|
||||
})
|
||||
expect(end_time).toEqual("2020-01-02T00:00:00+00:00")
|
||||
})
|
||||
|
||||
it("returns the end time at the start of the next hour if the end date is today", () => {
|
||||
const date = new Date("2020-01-01T12:00:00.000Z")
|
||||
const { end_time } = getDateRangeFilter({
|
||||
startDate: date,
|
||||
endDate: date,
|
||||
now: date,
|
||||
isToday: () => true,
|
||||
})
|
||||
expect(end_time).toEqual("2020-01-01T13:00:00+00:00")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { addDays, addHours, format, startOfDay, startOfHour } from "date-fns"
|
||||
|
||||
export function getDateRangeFilter({
|
||||
startDate,
|
||||
endDate,
|
||||
now,
|
||||
isToday,
|
||||
}: {
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
now: Date
|
||||
isToday: (date: Date) => boolean
|
||||
}) {
|
||||
return {
|
||||
start_time: toISOLocal(startOfDay(startDate)),
|
||||
end_time: toISOLocal(
|
||||
isToday(endDate)
|
||||
? startOfHour(addHours(now, 1))
|
||||
: startOfDay(addDays(endDate, 1)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function toISOLocal(d: Date) {
|
||||
return format(d, "yyyy-MM-dd'T'HH:mm:ssxxx")
|
||||
}
|
||||
Reference in New Issue
Block a user