Skip to content

Commit a303660

Browse files
Add insights page (#20437)
* Add WS image metrics to workspace instances * Update tests * fix ws-manager-api field description * [dashboard] Org Insights page * Pagination, date filters and downloads * Safety limits for pagination and prettier icons * UI improvements * Enhance `from` date to capture whole day * some more props for the CSVs * Include git context with workspace responses * Context url segments in CSV * ide => editor to align with papi convention * Remove duplicate fc * revert route deletion * Update papi converter tests and revert unecessary changes * fix error rendering * partly revert ws api svc changes * Remove debug lines * fix proto typo Co-authored-by: Gero Posmyk-Leinemann <[email protected]> * Remove org member listing from frontend * Shorter == better 😎 * Move workspace.metadata.context onto a top-level `WorkspaceSession` property --------- Co-authored-by: Gero Posmyk-Leinemann <[email protected]>
1 parent 25d062a commit a303660

29 files changed

+6142
-619
lines changed

components/dashboard/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"@gitpod/gitpod-protocol": "0.1.5",
1111
"@gitpod/public-api": "0.1.5",
1212
"@gitpod/public-api-common": "0.1.5",
13+
"@radix-ui/react-accordion": "^1.2.1",
1314
"@radix-ui/react-dropdown-menu": "^2.0.6",
1415
"@radix-ui/react-label": "^2.0.2",
1516
"@radix-ui/react-popover": "^1.0.7",

components/dashboard/src/Insights.tsx

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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 { LoadingState } from "@podkit/loading/LoadingState";
8+
import { Heading2, Subheading } from "@podkit/typography/Headings";
9+
import classNames from "classnames";
10+
import { useCallback, useMemo, useState } from "react";
11+
import { Accordion } from "./components/accordion/Accordion";
12+
import Alert from "./components/Alert";
13+
import Header from "./components/Header";
14+
import { Item, ItemField, ItemsList } from "./components/ItemsList";
15+
import { useWorkspaceSessions } from "./data/insights/list-workspace-sessions-query";
16+
import { WorkspaceSessionGroup } from "./insights/WorkspaceSessionGroup";
17+
import { gitpodHostUrl } from "./service/service";
18+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@podkit/select/Select";
19+
import dayjs from "dayjs";
20+
import { Timestamp } from "@bufbuild/protobuf";
21+
import { LoadingButton } from "@podkit/buttons/LoadingButton";
22+
import { TextMuted } from "@podkit/typography/TextMuted";
23+
import { DownloadInsightsToast } from "./insights/download/DownloadInsights";
24+
import { useCurrentOrg } from "./data/organizations/orgs-query";
25+
import { useToast } from "./components/toasts/Toasts";
26+
import { useTemporaryState } from "./hooks/use-temporary-value";
27+
import { DownloadIcon } from "lucide-react";
28+
import { Button } from "@podkit/buttons/Button";
29+
30+
export const Insights = () => {
31+
const [prebuildsFilter, setPrebuildsFilter] = useState<"week" | "month" | "year">("week");
32+
const [upperBound, lowerBound] = useMemo(() => {
33+
const from = dayjs().subtract(1, prebuildsFilter).startOf("day");
34+
35+
const fromTimestamp = Timestamp.fromDate(from.toDate());
36+
const toTimestamp = Timestamp.fromDate(new Date());
37+
return [fromTimestamp, toTimestamp];
38+
}, [prebuildsFilter]);
39+
const {
40+
data,
41+
error: errorMessage,
42+
isLoading,
43+
isFetchingNextPage,
44+
hasNextPage,
45+
fetchNextPage,
46+
} = useWorkspaceSessions({
47+
from: upperBound,
48+
to: lowerBound,
49+
});
50+
51+
const hasMoreThanOnePage = (data?.pages.length ?? 0) > 1;
52+
const sessions = useMemo(() => data?.pages.flatMap((p) => p) ?? [], [data]);
53+
const grouped = Object.groupBy(sessions, (ws) => ws.workspace?.id ?? "unknown");
54+
const [page, setPage] = useState(0);
55+
56+
return (
57+
<>
58+
<Header title="Insights" subtitle="Insights into workspace sessions in your organization" />
59+
<div className="app-container pt-5">
60+
<div
61+
className={classNames(
62+
"flex flex-col items-start space-y-3 justify-between",
63+
"md:flex-row md:items-center md:space-x-4 md:space-y-0",
64+
)}
65+
>
66+
<Select value={prebuildsFilter} onValueChange={(v) => setPrebuildsFilter(v as any)}>
67+
<SelectTrigger className="w-[180px]">
68+
<SelectValue placeholder="Select time range" />
69+
</SelectTrigger>
70+
<SelectContent>
71+
<SelectItem value="week">Last 7 days</SelectItem>
72+
<SelectItem value="month">Last 30 days</SelectItem>
73+
<SelectItem value="year">Last 365 days</SelectItem>
74+
</SelectContent>
75+
</Select>
76+
<DownloadUsage from={upperBound} to={lowerBound} />
77+
</div>
78+
79+
<div
80+
className={classNames(
81+
"flex flex-col items-start space-y-3 justify-between px-3",
82+
"md:flex-row md:items-center md:space-x-4 md:space-y-0",
83+
)}
84+
></div>
85+
86+
{errorMessage && (
87+
<Alert type="error" className="mt-4">
88+
{errorMessage instanceof Error ? errorMessage.message : "An error occurred."}
89+
</Alert>
90+
)}
91+
92+
<div className="flex flex-col w-full mb-8">
93+
<ItemsList className="mt-2 text-pk-content-secondary">
94+
<Item header={false} className="grid grid-cols-12 gap-x-3 bg-pk-surface-tertiary">
95+
<ItemField className="col-span-2 my-auto">
96+
<span>Type</span>
97+
</ItemField>
98+
<ItemField className="col-span-5 my-auto">
99+
<span>ID</span>
100+
</ItemField>
101+
<ItemField className="col-span-3 my-auto">
102+
<span>User</span>
103+
</ItemField>
104+
<ItemField className="col-span-2 my-auto">
105+
<span>Sessions</span>
106+
</ItemField>
107+
</Item>
108+
109+
{isLoading && (
110+
<div className="flex items-center justify-center w-full space-x-2 text-pk-content-primary text-sm pt-16 pb-40">
111+
<LoadingState />
112+
<span>Loading usage...</span>
113+
</div>
114+
)}
115+
116+
{!isLoading && (
117+
<Accordion type="multiple" className="w-full">
118+
{Object.entries(grouped).map(([id, sessions]) => {
119+
if (!sessions?.length) {
120+
return null;
121+
}
122+
123+
return <WorkspaceSessionGroup key={id} id={id} sessions={sessions} />;
124+
})}
125+
</Accordion>
126+
)}
127+
128+
{/* No results */}
129+
{!isLoading && sessions.length === 0 && !errorMessage && (
130+
<div className="flex flex-col w-full mb-8">
131+
<Heading2 className="text-center mt-8">No sessions found.</Heading2>
132+
<Subheading className="text-center mt-1">
133+
Have you started any
134+
<a className="gp-link" href={gitpodHostUrl.asWorkspacePage().toString()}>
135+
{" "}
136+
workspaces
137+
</a>{" "}
138+
in the last 30 days or checked your other organizations?
139+
</Subheading>
140+
</div>
141+
)}
142+
</ItemsList>
143+
</div>
144+
145+
<div className="mt-4 mb-8 flex flex-row justify-center">
146+
{hasNextPage ? (
147+
<LoadingButton
148+
variant="secondary"
149+
onClick={() => {
150+
setPage(page + 1);
151+
fetchNextPage();
152+
}}
153+
loading={isFetchingNextPage}
154+
>
155+
Load more
156+
</LoadingButton>
157+
) : (
158+
hasMoreThanOnePage && <TextMuted>All workspace sessions are loaded</TextMuted>
159+
)}
160+
</div>
161+
</div>
162+
</>
163+
);
164+
};
165+
166+
type DownloadUsageProps = {
167+
from: Timestamp;
168+
to: Timestamp;
169+
};
170+
export const DownloadUsage = ({ from, to }: DownloadUsageProps) => {
171+
const { data: org } = useCurrentOrg();
172+
const { toast } = useToast();
173+
// When we start the download, we disable the button for a short time
174+
const [downloadDisabled, setDownloadDisabled] = useTemporaryState(false, 1000);
175+
176+
const handleDownload = useCallback(async () => {
177+
if (!org) {
178+
return;
179+
}
180+
181+
setDownloadDisabled(true);
182+
toast(
183+
<DownloadInsightsToast
184+
organizationName={org?.slug ?? org?.id}
185+
organizationId={org.id}
186+
from={from}
187+
to={to}
188+
/>,
189+
{
190+
autoHide: false,
191+
},
192+
);
193+
}, [org, setDownloadDisabled, toast, from, to]);
194+
195+
return (
196+
<Button variant="secondary" onClick={handleDownload} className="gap-1" disabled={downloadDisabled}>
197+
<DownloadIcon strokeWidth={3} className="w-4" />
198+
<span>Export as CSV</span>
199+
</Button>
200+
);
201+
};
202+
203+
export default Insights;

components/dashboard/src/app/AppRoutes.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const WorkspacesSearch = React.lazy(() => import(/* webpackPrefetch: true */ "..
6969
const ProjectsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "../admin/ProjectsSearch"));
7070
const TeamsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "../admin/TeamsSearch"));
7171
const Usage = React.lazy(() => import(/* webpackPrefetch: true */ "../Usage"));
72+
const Insights = React.lazy(() => import(/* webpackPrefetch: true */ "../Insights"));
7273
const ConfigurationListPage = React.lazy(
7374
() => import(/* webpackPrefetch: true */ "../repositories/list/RepositoryList"),
7475
);
@@ -125,7 +126,6 @@ export const AppRoutes = () => {
125126
<Route path="/open">
126127
<Redirect to="/new" />
127128
</Route>
128-
{/* TODO(gpl): Remove once we don't need the redirect anymore */}
129129
<Route
130130
path={[
131131
switchToPAYGPathMain,
@@ -143,6 +143,7 @@ export const AppRoutes = () => {
143143
<Route path={workspacesPathMain} exact component={Workspaces} />
144144
<Route path={settingsPathAccount} exact component={Account} />
145145
<Route path={usagePathMain} exact component={Usage} />
146+
<Route path={"/insights"} exact component={Insights} />
146147
<Route path={settingsPathIntegrations} exact component={Integrations} />
147148
<Route path={settingsPathNotifications} exact component={Notifications} />
148149
<Route path={settingsPathVariables} exact component={EnvironmentVariables} />
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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 { cn } from "@podkit/lib/cn";
8+
import * as AccordionPrimitive from "@radix-ui/react-accordion";
9+
import { ChevronDown } from "lucide-react";
10+
import * as React from "react";
11+
12+
const Accordion = AccordionPrimitive.Root;
13+
14+
const AccordionItem = React.forwardRef<
15+
React.ElementRef<typeof AccordionPrimitive.Item>,
16+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
17+
>(({ className, ...props }, ref) => (
18+
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
19+
));
20+
AccordionItem.displayName = "AccordionItem";
21+
22+
const AccordionTrigger = React.forwardRef<
23+
React.ElementRef<typeof AccordionPrimitive.Trigger>,
24+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
25+
>(({ className, children, ...props }, ref) => (
26+
<AccordionPrimitive.Header className="flex">
27+
<AccordionPrimitive.Trigger
28+
ref={ref}
29+
className={cn(
30+
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
31+
className,
32+
)}
33+
{...props}
34+
>
35+
{children}
36+
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
37+
</AccordionPrimitive.Trigger>
38+
</AccordionPrimitive.Header>
39+
));
40+
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
41+
42+
const AccordionContent = React.forwardRef<
43+
React.ElementRef<typeof AccordionPrimitive.Content>,
44+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
45+
>(({ className, children, ...props }, ref) => (
46+
<AccordionPrimitive.Content
47+
ref={ref}
48+
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
49+
{...props}
50+
>
51+
<div className={cn("pb-4 pt-0", className)}>{children}</div>
52+
</AccordionPrimitive.Content>
53+
));
54+
55+
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
56+
57+
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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+
import { WorkspaceSession } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
7+
import { useInfiniteQuery } from "@tanstack/react-query";
8+
import { workspaceClient } from "../../service/public-api";
9+
import { useCurrentOrg } from "../organizations/orgs-query";
10+
import { Timestamp } from "@bufbuild/protobuf";
11+
12+
const pageSize = 100;
13+
14+
type Params = {
15+
from?: Timestamp;
16+
to?: Timestamp;
17+
};
18+
export const useWorkspaceSessions = ({ from, to }: Params = {}) => {
19+
const { data: org } = useCurrentOrg();
20+
21+
const query = useInfiniteQuery<WorkspaceSession[]>({
22+
queryKey: getAuthProviderDescriptionsQueryKey(org?.id, from, to),
23+
queryFn: async ({ pageParam }) => {
24+
if (!org) {
25+
throw new Error("No org specified");
26+
}
27+
28+
const response = await workspaceClient.listWorkspaceSessions({
29+
organizationId: org.id,
30+
from,
31+
to,
32+
pagination: {
33+
page: pageParam ?? 0,
34+
pageSize,
35+
},
36+
});
37+
38+
return response.workspaceSessions;
39+
},
40+
getNextPageParam: (lastPage, pages) => {
41+
const hasMore = lastPage.length === pageSize;
42+
return hasMore ? pages.length : undefined;
43+
},
44+
enabled: !!org,
45+
});
46+
47+
return query;
48+
};
49+
50+
export const getAuthProviderDescriptionsQueryKey = (orgId?: string, from?: Timestamp, to?: Timestamp) => [
51+
"workspace-sessions",
52+
{ orgId, from, to },
53+
];
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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 { WorkspacePhase_Phase, WorkspaceSession } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
8+
import { displayTime } from "./WorkspaceSessionGroup";
9+
10+
type Props = {
11+
session: WorkspaceSession;
12+
};
13+
export const WorkspaceSessionEntry = ({ session }: Props) => {
14+
const isRunning = session?.workspace?.status?.phase?.name === WorkspacePhase_Phase.RUNNING;
15+
16+
return (
17+
<li className="text-sm text-gray-600 dark:text-gray-300">
18+
{session.creationTime ? displayTime(session.creationTime) : "n/a"} (
19+
{session.id.slice(0, 7) || "No instance ID"}){isRunning ? " - running" : ""}
20+
</li>
21+
);
22+
};

0 commit comments

Comments
 (0)