-
Couldn't load subscription status.
- Fork 1.3k
Add insights page #20437
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Add insights page #20437
Changes from 16 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 ad95ee6
Update tests
filiptronicek 5a694a3
fix ws-manager-api field description
filiptronicek f9bcd54
[dashboard] Org Insights page
filiptronicek ea9984d
Pagination, date filters and downloads
filiptronicek ba550e1
Safety limits for pagination and prettier icons
filiptronicek 9a1a72b
UI improvements
filiptronicek 219a065
Merge branch 'main' into ft/add-insights-page
filiptronicek 980616e
Enhance `from` date to capture whole day
filiptronicek 100c003
some more props for the CSVs
filiptronicek ff6c8f6
Include git context with workspace responses
filiptronicek 3718833
Context url segments in CSV
filiptronicek 9806e27
ide => editor to align with papi convention
filiptronicek d7992c8
Remove duplicate fc
filiptronicek 2a631eb
revert route deletion
filiptronicek bb7caae
Update papi converter tests and revert unecessary changes
filiptronicek e65cc4e
fix error rendering
filiptronicek a1fc587
partly revert ws api svc changes
filiptronicek 6c9cb9c
Remove debug lines
filiptronicek 5f6494e
fix proto typo
filiptronicek f77d7f8
Remove org member listing from frontend
filiptronicek f31df63
Shorter == better 😎
filiptronicek 79a97aa
Move workspace.metadata.context onto a top-level `WorkspaceSession` p…
filiptronicek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,214 @@ | ||
| /** | ||
| * 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 type { OrganizationMember } from "@gitpod/public-api/lib/gitpod/v1/organization_pb"; | ||
| 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 { useListOrganizationMembers } from "./data/organizations/members-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 membersQuery = useListOrganizationMembers(); | ||
| const members: OrganizationMember[] = useMemo(() => membersQuery.data || [], [membersQuery.data]); | ||
|
|
||
| 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> | ||
| {/* here for debugging, probably not useful */} | ||
| <SelectItem value="day">Last 24 hours</SelectItem>{" "} | ||
| <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} | ||
| </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; | ||
| } | ||
| const member = members.find( | ||
| (m) => m.userId === sessions[0]?.workspace?.metadata?.ownerId, | ||
| ); | ||
|
|
||
| return ( | ||
| <WorkspaceSessionGroup key={id} id={id} sessions={sessions} member={member} /> | ||
| ); | ||
| })} | ||
| </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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
57 changes: 57 additions & 0 deletions
57
components/dashboard/src/components/accordion/Accordion.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; |
53 changes: 53 additions & 0 deletions
53
components/dashboard/src/data/insights/list-workspace-sessions-query.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }, | ||
| ]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| /** | ||
| * 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; | ||
| index: number; | ||
| }; | ||
| export const WorkspaceSessionEntry = ({ session, index }: Props) => { | ||
| const isRunning = session?.workspace?.status?.phase?.name === WorkspacePhase_Phase.RUNNING; | ||
|
|
||
| return ( | ||
| <li key={index} 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> | ||
| ); | ||
| }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is https://ui.shadcn.com/docs/components/accordion