Skip to content

Commit f9bcd54

Browse files
committed
[dashboard] Org Insights page
1 parent 5a694a3 commit f9bcd54

File tree

11 files changed

+387
-27
lines changed

11 files changed

+387
-27
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",
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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 type { OrganizationMember } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
8+
import { LoadingState } from "@podkit/loading/LoadingState";
9+
import { Heading2, Subheading } from "@podkit/typography/Headings";
10+
import classNames from "classnames";
11+
import { useMemo } from "react";
12+
import { Accordion } from "./components/accordion/Accordion";
13+
import Alert from "./components/Alert";
14+
import Header from "./components/Header";
15+
import { Item, ItemField, ItemsList } from "./components/ItemsList";
16+
import { useWorkspaceSessions } from "./data/insights/list-workspace-sessions-query";
17+
import { useListOrganizationMembers } from "./data/organizations/members-query";
18+
import { WorkspaceSessionGroup } from "./insights/WorkspaceSessionGroup";
19+
import { gitpodHostUrl } from "./service/service";
20+
21+
export const Insights = () => {
22+
const { data, error: errorMessage, isLoading } = useWorkspaceSessions();
23+
const membersQuery = useListOrganizationMembers();
24+
const members: OrganizationMember[] = useMemo(() => membersQuery.data || [], [membersQuery.data]);
25+
26+
const sessions = data ?? [];
27+
const grouped = Object.groupBy(sessions, (ws) => ws.workspace?.id ?? "unknown");
28+
29+
return (
30+
<>
31+
<Header title="Insights" subtitle="Insights into workspace sessions in your organization" />
32+
<div className="app-container pt-5">
33+
<div
34+
className={classNames(
35+
"flex flex-col items-start space-y-3 justify-between px-3",
36+
"md:flex-row md:items-center md:space-x-4 md:space-y-0",
37+
)}
38+
></div>
39+
40+
{errorMessage && (
41+
<Alert type="error" className="mt-4">
42+
{errorMessage}
43+
</Alert>
44+
)}
45+
46+
<div className="flex flex-col w-full mb-8">
47+
<ItemsList className="mt-2 text-gray-400 dark:text-gray-500">
48+
<Item header={false} className="grid grid-cols-12 gap-x-3 bg-gray-100 dark:bg-gray-800">
49+
<ItemField className="col-span-2 my-auto">
50+
<span>Type</span>
51+
</ItemField>
52+
<ItemField className="col-span-5 my-auto">
53+
<span>ID</span>
54+
</ItemField>
55+
<ItemField className="col-span-3 my-auto">
56+
<span>User</span>
57+
</ItemField>
58+
<ItemField className="col-span-2 my-auto">
59+
<span>Sessions</span>
60+
</ItemField>
61+
</Item>
62+
63+
{isLoading && (
64+
<div className="flex items-center justify-center w-full space-x-2 text-gray-400 text-sm pt-16 pb-40">
65+
<LoadingState />
66+
<span>Loading usage...</span>
67+
</div>
68+
)}
69+
70+
{!isLoading && (
71+
<Accordion type="multiple" className="w-full">
72+
{Object.entries(grouped).map(([id, sessions]) => {
73+
if (!sessions?.length) {
74+
return null;
75+
}
76+
const member = members.find(
77+
(m) => m.userId === sessions[0]?.workspace?.metadata?.ownerId,
78+
);
79+
80+
return (
81+
<WorkspaceSessionGroup key={id} id={id} sessions={sessions} member={member} />
82+
);
83+
})}
84+
</Accordion>
85+
)}
86+
87+
{/* No results */}
88+
{!isLoading && sessions.length === 0 && !errorMessage && (
89+
<div className="flex flex-col w-full mb-8">
90+
<Heading2 className="text-center mt-8">No sessions found.</Heading2>
91+
<Subheading className="text-center mt-1">
92+
Have you started any
93+
<a className="gp-link" href={gitpodHostUrl.asWorkspacePage().toString()}>
94+
{" "}
95+
workspaces
96+
</a>{" "}
97+
in the last 30 days or checked your other organizations?
98+
</Subheading>
99+
</div>
100+
)}
101+
</ItemsList>
102+
</div>
103+
</div>
104+
</>
105+
);
106+
};
107+
108+
export default Insights;

components/dashboard/src/app/AppRoutes.tsx

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,9 @@ import {
1717
settingsPathPersonalAccessTokenCreate,
1818
settingsPathPersonalAccessTokenEdit,
1919
settingsPathPersonalAccessTokens,
20-
settingsPathPlans,
2120
settingsPathPreferences,
2221
settingsPathSSHKeys,
2322
settingsPathVariables,
24-
switchToPAYGPathMain,
2523
usagePathMain,
2624
} from "../user-settings/settings.routes";
2725
import { getURLHash, isGitpodIo } from "../utils";
@@ -69,6 +67,7 @@ const WorkspacesSearch = React.lazy(() => import(/* webpackPrefetch: true */ "..
6967
const ProjectsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "../admin/ProjectsSearch"));
7068
const TeamsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "../admin/TeamsSearch"));
7169
const Usage = React.lazy(() => import(/* webpackPrefetch: true */ "../Usage"));
70+
const Insights = React.lazy(() => import(/* webpackPrefetch: true */ "../Insights"));
7271
const ConfigurationListPage = React.lazy(
7372
() => import(/* webpackPrefetch: true */ "../repositories/list/RepositoryList"),
7473
);
@@ -125,24 +124,10 @@ export const AppRoutes = () => {
125124
<Route path="/open">
126125
<Redirect to="/new" />
127126
</Route>
128-
{/* TODO(gpl): Remove once we don't need the redirect anymore */}
129-
<Route
130-
path={[
131-
switchToPAYGPathMain,
132-
settingsPathPlans,
133-
"/old-team-plans",
134-
"/teams",
135-
"/subscription",
136-
"/upgrade-subscription",
137-
"/plans",
138-
]}
139-
exact
140-
>
141-
<Redirect to={"/billing"} />
142-
</Route>
143127
<Route path={workspacesPathMain} exact component={Workspaces} />
144128
<Route path={settingsPathAccount} exact component={Account} />
145129
<Route path={usagePathMain} exact component={Usage} />
130+
<Route path={"/insights"} exact component={Insights} />
146131
<Route path={settingsPathIntegrations} exact component={Integrations} />
147132
<Route path={settingsPathNotifications} exact component={Notifications} />
148133
<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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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 { WorkspaceSession } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
8+
import { useQuery } from "@tanstack/react-query";
9+
import { workspaceClient } from "../../service/public-api";
10+
import { useCurrentOrg } from "../organizations/orgs-query";
11+
12+
export const useWorkspaceSessions = () => {
13+
const { data: org } = useCurrentOrg();
14+
15+
const query = useQuery<WorkspaceSession[]>({
16+
queryKey: getAuthProviderDescriptionsQueryKey(org?.id),
17+
queryFn: async () => {
18+
if (!org) {
19+
throw new Error("No org specified");
20+
}
21+
const response = await workspaceClient.listWorkspaceSessions({
22+
organizationId: org.id,
23+
});
24+
return response.workspaceSessions;
25+
},
26+
enabled: !!org,
27+
});
28+
return query;
29+
};
30+
31+
export const getAuthProviderDescriptionsQueryKey = (orgId?: string) => ["workspace-sessions", { orgId }];
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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+
index: number;
13+
};
14+
export const WorkspaceSessionEntry = ({ session, index }: Props) => {
15+
const isRunning = session?.workspace?.status?.phase?.name === WorkspacePhase_Phase.RUNNING;
16+
17+
return (
18+
<li key={index} className="text-sm text-gray-600 dark:text-gray-300">
19+
{session.creationTime ? displayTime(session.creationTime) : "n/a"} (
20+
{session.workspace?.status?.instanceId.slice(0, 7) || "No instance ID"}){isRunning ? " - running" : ""}
21+
</li>
22+
);
23+
};
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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 { Timestamp } from "@bufbuild/protobuf";
8+
import { OrganizationMember } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
9+
import { WorkspaceSpec_WorkspaceType } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
10+
import { AccordionContent, AccordionItem, AccordionTrigger } from "../components/accordion/Accordion";
11+
import { ReactComponent as UsageIcon } from "../images/usage-default.svg";
12+
import { toRemoteURL } from "../projects/render-utils";
13+
import { DisplayName } from "../usage/UsageEntry";
14+
import { WorkspaceSessionEntry } from "./WorkspaceSession";
15+
16+
type Props = {
17+
id: string;
18+
sessions: any[];
19+
member?: OrganizationMember;
20+
};
21+
export const WorkspaceSessionGroup = ({ id, sessions, member }: Props) => {
22+
if (!sessions?.length) {
23+
return null;
24+
}
25+
const workspace = sessions[0].workspace!;
26+
27+
return (
28+
<AccordionItem key={id} value={id}>
29+
<div className="w-full p-3 grid grid-cols-12 gap-x-3 justify-between transition ease-in-out rounded-xl">
30+
<div className="flex flex-col col-span-2 my-auto">
31+
<span className="text-gray-600 dark:text-gray-100 text-md font-medium">
32+
{getType(workspace.spec?.type)}
33+
</span>
34+
<span className="text-sm text-gray-400 dark:text-gray-500">
35+
{workspace.spec?.class ? <DisplayName workspaceClass={workspace?.spec?.class} /> : "n/a"}
36+
</span>
37+
</div>
38+
<div className="flex flex-col col-span-5 my-auto">
39+
<div className="flex">
40+
<span className="truncate text-gray-600 dark:text-gray-100 text-md font-medium">
41+
{workspace.id}
42+
</span>
43+
</div>
44+
<span className="text-sm truncate text-gray-400 dark:text-gray-500">
45+
{workspace.metadata?.originalContextUrl && toRemoteURL(workspace.metadata?.originalContextUrl)}
46+
</span>
47+
</div>
48+
<div className="flex flex-col col-span-3 my-auto">
49+
<span className="text-right text-gray-500 dark:text-gray-400 font-medium">
50+
{workspace.spec?.type === WorkspaceSpec_WorkspaceType.PREBUILD ? (
51+
<>
52+
<UsageIcon className="my-auto w-4 h-4 mr-1" />
53+
<span className="text-sm text-gray-400 dark:text-gray-500">Gitpod</span>
54+
</>
55+
) : (
56+
<div className="flex">
57+
<img
58+
className="my-auto rounded-full w-4 h-4 inline-block align-text-bottom mr-1 overflow-hidden"
59+
src={member?.avatarUrl ?? ""}
60+
alt=""
61+
/>
62+
<span className="text-sm text-gray-400 dark:text-gray-500">{member?.fullName}</span>
63+
</div>
64+
)}
65+
</span>
66+
</div>
67+
<AccordionTrigger className="w-full">
68+
<div className="flex flex-col col-span-2 my-auto">
69+
<span className="text-gray-400 dark:text-gray-500 truncate font-medium">{sessions.length}</span>
70+
</div>
71+
</AccordionTrigger>
72+
</div>
73+
<AccordionContent>
74+
<div className="px-3 py-2 space-y-2">
75+
<h4 className="text-sm font-medium text-gray-500 dark:text-gray-400">Workspace starts:</h4>
76+
<ul className="space-y-1">
77+
{sessions.map((session, index) => (
78+
<WorkspaceSessionEntry key={session.id || index} session={session} index={index} />
79+
))}
80+
</ul>
81+
</div>
82+
</AccordionContent>
83+
</AccordionItem>
84+
);
85+
};
86+
87+
const getType = (type?: WorkspaceSpec_WorkspaceType) => {
88+
switch (type) {
89+
case WorkspaceSpec_WorkspaceType.PREBUILD:
90+
return "Prebuild";
91+
case WorkspaceSpec_WorkspaceType.REGULAR:
92+
return "Workspace";
93+
default:
94+
return "Unknown";
95+
}
96+
};
97+
98+
export const displayTime = (time: Timestamp) => {
99+
const options: Intl.DateTimeFormatOptions = {
100+
day: "numeric",
101+
month: "short",
102+
year: "numeric",
103+
hour: "numeric",
104+
minute: "numeric",
105+
};
106+
107+
return time.toDate().toLocaleDateString(undefined, options).replace("at ", "");
108+
};

0 commit comments

Comments
 (0)