Skip to content

Commit

Permalink
Add insights page (#20437)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
filiptronicek and geropl authored Dec 12, 2024
1 parent 25d062a commit a303660
Show file tree
Hide file tree
Showing 29 changed files with 6,142 additions and 619 deletions.
1 change: 1 addition & 0 deletions components/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@gitpod/gitpod-protocol": "0.1.5",
"@gitpod/public-api": "0.1.5",
"@gitpod/public-api-common": "0.1.5",
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
Expand Down
203 changes: 203 additions & 0 deletions components/dashboard/src/Insights.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/**
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { LoadingState } from "@podkit/loading/LoadingState";
import { Heading2, Subheading } from "@podkit/typography/Headings";
import classNames from "classnames";
import { useCallback, useMemo, useState } from "react";
import { Accordion } from "./components/accordion/Accordion";
import Alert from "./components/Alert";
import Header from "./components/Header";
import { Item, ItemField, ItemsList } from "./components/ItemsList";
import { useWorkspaceSessions } from "./data/insights/list-workspace-sessions-query";
import { WorkspaceSessionGroup } from "./insights/WorkspaceSessionGroup";
import { gitpodHostUrl } from "./service/service";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@podkit/select/Select";
import dayjs from "dayjs";
import { Timestamp } from "@bufbuild/protobuf";
import { LoadingButton } from "@podkit/buttons/LoadingButton";
import { TextMuted } from "@podkit/typography/TextMuted";
import { DownloadInsightsToast } from "./insights/download/DownloadInsights";
import { useCurrentOrg } from "./data/organizations/orgs-query";
import { useToast } from "./components/toasts/Toasts";
import { useTemporaryState } from "./hooks/use-temporary-value";
import { DownloadIcon } from "lucide-react";
import { Button } from "@podkit/buttons/Button";

export const Insights = () => {
const [prebuildsFilter, setPrebuildsFilter] = useState<"week" | "month" | "year">("week");
const [upperBound, lowerBound] = useMemo(() => {
const from = dayjs().subtract(1, prebuildsFilter).startOf("day");

const fromTimestamp = Timestamp.fromDate(from.toDate());
const toTimestamp = Timestamp.fromDate(new Date());
return [fromTimestamp, toTimestamp];
}, [prebuildsFilter]);
const {
data,
error: errorMessage,
isLoading,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useWorkspaceSessions({
from: upperBound,
to: lowerBound,
});

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

return (
<>
<Header title="Insights" subtitle="Insights into workspace sessions in your organization" />
<div className="app-container pt-5">
<div
className={classNames(
"flex flex-col items-start space-y-3 justify-between",
"md:flex-row md:items-center md:space-x-4 md:space-y-0",
)}
>
<Select value={prebuildsFilter} onValueChange={(v) => setPrebuildsFilter(v as any)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="week">Last 7 days</SelectItem>
<SelectItem value="month">Last 30 days</SelectItem>
<SelectItem value="year">Last 365 days</SelectItem>
</SelectContent>
</Select>
<DownloadUsage from={upperBound} to={lowerBound} />
</div>

<div
className={classNames(
"flex flex-col items-start space-y-3 justify-between px-3",
"md:flex-row md:items-center md:space-x-4 md:space-y-0",
)}
></div>

{errorMessage && (
<Alert type="error" className="mt-4">
{errorMessage instanceof Error ? errorMessage.message : "An error occurred."}
</Alert>
)}

<div className="flex flex-col w-full mb-8">
<ItemsList className="mt-2 text-pk-content-secondary">
<Item header={false} className="grid grid-cols-12 gap-x-3 bg-pk-surface-tertiary">
<ItemField className="col-span-2 my-auto">
<span>Type</span>
</ItemField>
<ItemField className="col-span-5 my-auto">
<span>ID</span>
</ItemField>
<ItemField className="col-span-3 my-auto">
<span>User</span>
</ItemField>
<ItemField className="col-span-2 my-auto">
<span>Sessions</span>
</ItemField>
</Item>

{isLoading && (
<div className="flex items-center justify-center w-full space-x-2 text-pk-content-primary text-sm pt-16 pb-40">
<LoadingState />
<span>Loading usage...</span>
</div>
)}

{!isLoading && (
<Accordion type="multiple" className="w-full">
{Object.entries(grouped).map(([id, sessions]) => {
if (!sessions?.length) {
return null;
}

return <WorkspaceSessionGroup key={id} id={id} sessions={sessions} />;
})}
</Accordion>
)}

{/* No results */}
{!isLoading && sessions.length === 0 && !errorMessage && (
<div className="flex flex-col w-full mb-8">
<Heading2 className="text-center mt-8">No sessions found.</Heading2>
<Subheading className="text-center mt-1">
Have you started any
<a className="gp-link" href={gitpodHostUrl.asWorkspacePage().toString()}>
{" "}
workspaces
</a>{" "}
in the last 30 days or checked your other organizations?
</Subheading>
</div>
)}
</ItemsList>
</div>

<div className="mt-4 mb-8 flex flex-row justify-center">
{hasNextPage ? (
<LoadingButton
variant="secondary"
onClick={() => {
setPage(page + 1);
fetchNextPage();
}}
loading={isFetchingNextPage}
>
Load more
</LoadingButton>
) : (
hasMoreThanOnePage && <TextMuted>All workspace sessions are loaded</TextMuted>
)}
</div>
</div>
</>
);
};

type DownloadUsageProps = {
from: Timestamp;
to: Timestamp;
};
export const DownloadUsage = ({ from, to }: DownloadUsageProps) => {
const { data: org } = useCurrentOrg();
const { toast } = useToast();
// When we start the download, we disable the button for a short time
const [downloadDisabled, setDownloadDisabled] = useTemporaryState(false, 1000);

const handleDownload = useCallback(async () => {
if (!org) {
return;
}

setDownloadDisabled(true);
toast(
<DownloadInsightsToast
organizationName={org?.slug ?? org?.id}
organizationId={org.id}
from={from}
to={to}
/>,
{
autoHide: false,
},
);
}, [org, setDownloadDisabled, toast, from, to]);

return (
<Button variant="secondary" onClick={handleDownload} className="gap-1" disabled={downloadDisabled}>
<DownloadIcon strokeWidth={3} className="w-4" />
<span>Export as CSV</span>
</Button>
);
};

export default Insights;
3 changes: 2 additions & 1 deletion components/dashboard/src/app/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const WorkspacesSearch = React.lazy(() => import(/* webpackPrefetch: true */ "..
const ProjectsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "../admin/ProjectsSearch"));
const TeamsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "../admin/TeamsSearch"));
const Usage = React.lazy(() => import(/* webpackPrefetch: true */ "../Usage"));
const Insights = React.lazy(() => import(/* webpackPrefetch: true */ "../Insights"));
const ConfigurationListPage = React.lazy(
() => import(/* webpackPrefetch: true */ "../repositories/list/RepositoryList"),
);
Expand Down Expand Up @@ -125,7 +126,6 @@ export const AppRoutes = () => {
<Route path="/open">
<Redirect to="/new" />
</Route>
{/* TODO(gpl): Remove once we don't need the redirect anymore */}
<Route
path={[
switchToPAYGPathMain,
Expand All @@ -143,6 +143,7 @@ export const AppRoutes = () => {
<Route path={workspacesPathMain} exact component={Workspaces} />
<Route path={settingsPathAccount} exact component={Account} />
<Route path={usagePathMain} exact component={Usage} />
<Route path={"/insights"} exact component={Insights} />
<Route path={settingsPathIntegrations} exact component={Integrations} />
<Route path={settingsPathNotifications} exact component={Notifications} />
<Route path={settingsPathVariables} exact component={EnvironmentVariables} />
Expand Down
57 changes: 57 additions & 0 deletions components/dashboard/src/components/accordion/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { cn } from "@podkit/lib/cn";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import * as React from "react";

const Accordion = AccordionPrimitive.Root;

const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";

const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;

const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));

AccordionContent.displayName = AccordionPrimitive.Content.displayName;

export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/
import { WorkspaceSession } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
import { useInfiniteQuery } from "@tanstack/react-query";
import { workspaceClient } from "../../service/public-api";
import { useCurrentOrg } from "../organizations/orgs-query";
import { Timestamp } from "@bufbuild/protobuf";

const pageSize = 100;

type Params = {
from?: Timestamp;
to?: Timestamp;
};
export const useWorkspaceSessions = ({ from, to }: Params = {}) => {
const { data: org } = useCurrentOrg();

const query = useInfiniteQuery<WorkspaceSession[]>({
queryKey: getAuthProviderDescriptionsQueryKey(org?.id, from, to),
queryFn: async ({ pageParam }) => {
if (!org) {
throw new Error("No org specified");
}

const response = await workspaceClient.listWorkspaceSessions({
organizationId: org.id,
from,
to,
pagination: {
page: pageParam ?? 0,
pageSize,
},
});

return response.workspaceSessions;
},
getNextPageParam: (lastPage, pages) => {
const hasMore = lastPage.length === pageSize;
return hasMore ? pages.length : undefined;
},
enabled: !!org,
});

return query;
};

export const getAuthProviderDescriptionsQueryKey = (orgId?: string, from?: Timestamp, to?: Timestamp) => [
"workspace-sessions",
{ orgId, from, to },
];
22 changes: 22 additions & 0 deletions components/dashboard/src/insights/WorkspaceSession.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { WorkspacePhase_Phase, WorkspaceSession } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
import { displayTime } from "./WorkspaceSessionGroup";

type Props = {
session: WorkspaceSession;
};
export const WorkspaceSessionEntry = ({ session }: Props) => {
const isRunning = session?.workspace?.status?.phase?.name === WorkspacePhase_Phase.RUNNING;

return (
<li className="text-sm text-gray-600 dark:text-gray-300">
{session.creationTime ? displayTime(session.creationTime) : "n/a"} (
{session.id.slice(0, 7) || "No instance ID"}){isRunning ? " - running" : ""}
</li>
);
};
Loading

0 comments on commit a303660

Please sign in to comment.