Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
82fea2b
Add WS image metrics to workspace instances
filiptronicek Dec 5, 2024
ad95ee6
Update tests
filiptronicek Dec 5, 2024
5a694a3
fix ws-manager-api field description
filiptronicek Dec 6, 2024
f9bcd54
[dashboard] Org Insights page
filiptronicek Dec 6, 2024
ea9984d
Pagination, date filters and downloads
filiptronicek Dec 9, 2024
ba550e1
Safety limits for pagination and prettier icons
filiptronicek Dec 9, 2024
9a1a72b
UI improvements
filiptronicek Dec 9, 2024
219a065
Merge branch 'main' into ft/add-insights-page
filiptronicek Dec 9, 2024
980616e
Enhance `from` date to capture whole day
filiptronicek Dec 9, 2024
100c003
some more props for the CSVs
filiptronicek Dec 10, 2024
ff6c8f6
Include git context with workspace responses
filiptronicek Dec 10, 2024
3718833
Context url segments in CSV
filiptronicek Dec 11, 2024
9806e27
ide => editor to align with papi convention
filiptronicek Dec 11, 2024
d7992c8
Remove duplicate fc
filiptronicek Dec 11, 2024
2a631eb
revert route deletion
filiptronicek Dec 11, 2024
bb7caae
Update papi converter tests and revert unecessary changes
filiptronicek Dec 11, 2024
e65cc4e
fix error rendering
filiptronicek Dec 11, 2024
a1fc587
partly revert ws api svc changes
filiptronicek Dec 11, 2024
6c9cb9c
Remove debug lines
filiptronicek Dec 12, 2024
5f6494e
fix proto typo
filiptronicek Dec 12, 2024
f77d7f8
Remove org member listing from frontend
filiptronicek Dec 12, 2024
f31df63
Shorter == better 😎
filiptronicek Dec 12, 2024
79a97aa
Move workspace.metadata.context onto a top-level `WorkspaceSession` p…
filiptronicek Dec 12, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Loading