Skip to content

Commit ea9984d

Browse files
committed
Pagination, date filters and downloads
1 parent f9bcd54 commit ea9984d

File tree

11 files changed

+459
-25
lines changed

11 files changed

+459
-25
lines changed

components/dashboard/src/Insights.tsx

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { OrganizationMember } from "@gitpod/public-api/lib/gitpod/v1/organi
88
import { LoadingState } from "@podkit/loading/LoadingState";
99
import { Heading2, Subheading } from "@podkit/typography/Headings";
1010
import classNames from "classnames";
11-
import { useMemo } from "react";
11+
import { useCallback, useMemo, useState } from "react";
1212
import { Accordion } from "./components/accordion/Accordion";
1313
import Alert from "./components/Alert";
1414
import Header from "./components/Header";
@@ -17,19 +17,71 @@ import { useWorkspaceSessions } from "./data/insights/list-workspace-sessions-qu
1717
import { useListOrganizationMembers } from "./data/organizations/members-query";
1818
import { WorkspaceSessionGroup } from "./insights/WorkspaceSessionGroup";
1919
import { gitpodHostUrl } from "./service/service";
20+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@podkit/select/Select";
21+
import dayjs from "dayjs";
22+
import { Timestamp } from "@bufbuild/protobuf";
23+
import { LoadingButton } from "@podkit/buttons/LoadingButton";
24+
import { TextMuted } from "@podkit/typography/TextMuted";
25+
import { DownloadInsightsToast } from "./insights/download/DownloadInsights";
26+
import { useCurrentOrg } from "./data/organizations/orgs-query";
27+
import { useToast } from "./components/toasts/Toasts";
28+
import { useTemporaryState } from "./hooks/use-temporary-value";
29+
import { DownloadIcon } from "lucide-react";
30+
import { Button } from "@podkit/buttons/Button";
2031

2132
export const Insights = () => {
22-
const { data, error: errorMessage, isLoading } = useWorkspaceSessions();
33+
const [prebuildsFilter, setPrebuildsFilter] = useState<"week" | "month" | "year">("week");
34+
const [upperBound, lowerBound] = useMemo(() => {
35+
const from = dayjs().subtract(1, prebuildsFilter);
36+
37+
const fromTimestamp = Timestamp.fromDate(from.toDate());
38+
const toTimestamp = Timestamp.fromDate(new Date());
39+
return [fromTimestamp, toTimestamp];
40+
}, [prebuildsFilter]);
41+
const {
42+
data,
43+
error: errorMessage,
44+
isLoading,
45+
isFetchingNextPage,
46+
hasNextPage,
47+
fetchNextPage,
48+
} = useWorkspaceSessions({
49+
from: upperBound,
50+
to: lowerBound,
51+
});
2352
const membersQuery = useListOrganizationMembers();
2453
const members: OrganizationMember[] = useMemo(() => membersQuery.data || [], [membersQuery.data]);
2554

26-
const sessions = data ?? [];
55+
const hasMoreThanOnePage = (data?.pages.length ?? 0) > 1;
56+
const sessions = useMemo(() => data?.pages.flatMap((p) => p) ?? [], [data]);
2757
const grouped = Object.groupBy(sessions, (ws) => ws.workspace?.id ?? "unknown");
58+
const [page, setPage] = useState(0);
2859

2960
return (
3061
<>
3162
<Header title="Insights" subtitle="Insights into workspace sessions in your organization" />
3263
<div className="app-container pt-5">
64+
<div
65+
className={classNames(
66+
"flex flex-col items-start space-y-3 justify-between",
67+
"md:flex-row md:items-center md:space-x-4 md:space-y-0",
68+
)}
69+
>
70+
<Select value={prebuildsFilter} onValueChange={(v) => setPrebuildsFilter(v as any)}>
71+
<SelectTrigger className="w-[180px]">
72+
<SelectValue placeholder="Select time range" />
73+
</SelectTrigger>
74+
<SelectContent>
75+
<SelectItem value="day">Last 24 hours</SelectItem>{" "}
76+
{/* here for debugging, probably not useful */}
77+
<SelectItem value="week">Last 7 days</SelectItem>
78+
<SelectItem value="month">Last 30 days</SelectItem>
79+
<SelectItem value="year">Last 365 days</SelectItem>
80+
</SelectContent>
81+
</Select>
82+
<DownloadUsage from={upperBound} to={lowerBound} />
83+
</div>
84+
3385
<div
3486
className={classNames(
3587
"flex flex-col items-start space-y-3 justify-between px-3",
@@ -100,9 +152,63 @@ export const Insights = () => {
100152
)}
101153
</ItemsList>
102154
</div>
155+
156+
<div className="mt-4 mb-8 flex flex-row justify-center">
157+
{hasNextPage ? (
158+
<LoadingButton
159+
variant="secondary"
160+
onClick={() => {
161+
setPage(page + 1);
162+
fetchNextPage();
163+
}}
164+
loading={isFetchingNextPage}
165+
>
166+
Load more
167+
</LoadingButton>
168+
) : (
169+
hasMoreThanOnePage && <TextMuted>All workspace sessions are loaded</TextMuted>
170+
)}
171+
</div>
103172
</div>
104173
</>
105174
);
106175
};
107176

177+
type DownloadUsageProps = {
178+
from: Timestamp;
179+
to: Timestamp;
180+
};
181+
export const DownloadUsage = ({ from, to }: DownloadUsageProps) => {
182+
const { data: org } = useCurrentOrg();
183+
const { toast } = useToast();
184+
// When we start the download, we disable the button for a short time
185+
const [downloadDisabled, setDownloadDisabled] = useTemporaryState(false, 1000);
186+
187+
const handleDownload = useCallback(async () => {
188+
if (!org) {
189+
return;
190+
}
191+
192+
setDownloadDisabled(true);
193+
toast(
194+
<DownloadInsightsToast
195+
organizationName={org?.slug ?? org?.id}
196+
organizationId={org.id}
197+
from={from}
198+
to={to}
199+
/>,
200+
{
201+
autoHide: false,
202+
},
203+
);
204+
}, [org, setDownloadDisabled, toast, from, to]);
205+
206+
return (
207+
<Button variant="secondary" onClick={handleDownload} className="gap-1" disabled={downloadDisabled}>
208+
<DownloadIcon />
209+
<span>Export as CSV</span>
210+
</Button>
211+
);
212+
};
213+
108214
export default Insights;

components/dashboard/src/data/insights/list-workspace-sessions-query.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,51 @@
33
* Licensed under the GNU Affero General Public License (AGPL).
44
* See License.AGPL.txt in the project root for license information.
55
*/
6-
76
import { WorkspaceSession } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
8-
import { useQuery } from "@tanstack/react-query";
7+
import { useInfiniteQuery } from "@tanstack/react-query";
98
import { workspaceClient } from "../../service/public-api";
109
import { useCurrentOrg } from "../organizations/orgs-query";
10+
import { Timestamp } from "@bufbuild/protobuf";
11+
12+
const pageSize = 100;
1113

12-
export const useWorkspaceSessions = () => {
14+
type Params = {
15+
from?: Timestamp;
16+
to?: Timestamp;
17+
};
18+
export const useWorkspaceSessions = ({ from, to }: Params = {}) => {
1319
const { data: org } = useCurrentOrg();
1420

15-
const query = useQuery<WorkspaceSession[]>({
16-
queryKey: getAuthProviderDescriptionsQueryKey(org?.id),
17-
queryFn: async () => {
21+
const query = useInfiniteQuery<WorkspaceSession[]>({
22+
queryKey: getAuthProviderDescriptionsQueryKey(org?.id, from, to),
23+
queryFn: async ({ pageParam }) => {
1824
if (!org) {
1925
throw new Error("No org specified");
2026
}
27+
2128
const response = await workspaceClient.listWorkspaceSessions({
2229
organizationId: org.id,
30+
from,
31+
to,
32+
pagination: {
33+
page: pageParam ?? 0,
34+
pageSize,
35+
},
2336
});
37+
2438
return response.workspaceSessions;
2539
},
40+
getNextPageParam: (lastPage, pages) => {
41+
const hasMore = lastPage.length === pageSize;
42+
return hasMore ? pages.length : undefined;
43+
},
2644
enabled: !!org,
2745
});
46+
2847
return query;
2948
};
3049

31-
export const getAuthProviderDescriptionsQueryKey = (orgId?: string) => ["workspace-sessions", { orgId }];
50+
export const getAuthProviderDescriptionsQueryKey = (orgId?: string, from?: Timestamp, to?: Timestamp) => [
51+
"workspace-sessions",
52+
{ orgId, from, to },
53+
];

components/dashboard/src/insights/WorkspaceSessionGroup.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,11 @@ export const WorkspaceSessionGroup = ({ id, sessions, member }: Props) => {
6464
)}
6565
</span>
6666
</div>
67-
<AccordionTrigger className="w-full">
68-
<div className="flex flex-col col-span-2 my-auto">
67+
<div className="flex flex-col col-span-2 my-auto">
68+
<AccordionTrigger className="w-full">
6969
<span className="text-gray-400 dark:text-gray-500 truncate font-medium">{sessions.length}</span>
70-
</div>
71-
</AccordionTrigger>
70+
</AccordionTrigger>
71+
</div>
7272
</div>
7373
<AccordionContent>
7474
<div className="px-3 py-2 space-y-2">
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { useCallback, useEffect, useMemo, useState } from "react";
8+
import { AlertTriangle } from "lucide-react";
9+
import prettyBytes from "pretty-bytes";
10+
import { Button } from "@podkit/buttons/Button";
11+
import { useDownloadSessionsCSV } from "./download-sessions";
12+
import { Timestamp } from "@bufbuild/protobuf";
13+
import saveAs from "file-saver";
14+
15+
type Props = {
16+
from: Timestamp;
17+
to: Timestamp;
18+
organizationId: string;
19+
organizationName: string;
20+
};
21+
export const DownloadInsightsToast = ({ organizationId, from, to, organizationName }: Props) => {
22+
const [progress, setProgress] = useState(0);
23+
24+
const queryArgs = useMemo(
25+
() => ({
26+
organizationName,
27+
organizationId,
28+
from,
29+
to,
30+
onProgress: setProgress,
31+
}),
32+
[from, organizationId, organizationName, to],
33+
);
34+
const { data, error, isLoading, abort, remove } = useDownloadSessionsCSV(queryArgs);
35+
36+
const saveFile = useCallback(() => {
37+
if (!data || !data.blob) {
38+
return;
39+
}
40+
41+
saveAs(data.blob, data.filename);
42+
}, [data]);
43+
44+
useEffect(() => {
45+
return () => {
46+
abort();
47+
remove();
48+
};
49+
// eslint-disable-next-line react-hooks/exhaustive-deps
50+
}, []);
51+
52+
if (isLoading) {
53+
return (
54+
<div>
55+
<span>Preparing usage export</span>
56+
Exporting page {progress}
57+
</div>
58+
);
59+
}
60+
61+
if (error) {
62+
return (
63+
<div className="flex flex-row items-start space-x-2">
64+
<AlertTriangle className="w-5 h-5 mt-0.5" />
65+
<div>
66+
<span>Error exporting your usage data:</span>
67+
<pre className="mt-2 whitespace-normal text-sm">{error.message}</pre>
68+
</div>
69+
</div>
70+
);
71+
}
72+
73+
if (!data || !data.blob || data.count === 0) {
74+
return <span>No usage data for the selected period.</span>;
75+
}
76+
77+
const readableSize = prettyBytes(data.blob.size);
78+
const formattedCount = Intl.NumberFormat().format(data.count);
79+
80+
return (
81+
<div className="flex flex-row items-start justify-between space-x-2">
82+
<div>
83+
<span>Usage export complete.</span>
84+
<p className="dark:text-gray-500">
85+
{readableSize} &middot; {formattedCount} {data.count !== 1 ? "entries" : "entry"} exported
86+
</p>
87+
</div>
88+
<div>
89+
<Button onClick={saveFile} className="text-left text-base">
90+
Download CSV
91+
</Button>
92+
</div>
93+
</div>
94+
);
95+
};

0 commit comments

Comments
 (0)