-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
25d062a
commit a303660
Showing
29 changed files
with
6,142 additions
and
619 deletions.
There are no files selected for viewing
This file contains 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 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,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; |
This file contains 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 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 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 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,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> | ||
); | ||
}; |
Oops, something went wrong.