Skip to content

Update Usage page design #17807

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 10 commits into from
Jun 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion components/dashboard/src/Usage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
import UsageView from "./components/UsageView";
import { UsageView } from "./usage/UsageView";
import { useCurrentOrg } from "./data/organizations/orgs-query";
import { useCurrentUser } from "./user-context";

Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
type={htmlType}
className={classNames(
"cursor-pointer px-4 py-2 my-auto",
"text-sm font-medium",
"text-sm font-medium whitespace-nowrap",
"rounded-md focus:outline-none focus:ring transition ease-in-out",
type === "primary"
? [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,9 @@ export default function UsageBasedBillingConfig({ attributionId, hideSubheading
<Link
to={`/usage?org=${
attrId?.kind === "team" ? attrId.teamId : "0"
}#${billingCycleFrom.format("YYYY-MM-DD")}:${billingCycleTo.format("YYYY-MM-DD")}`}
}&start=${billingCycleFrom.format("YYYY-MM-DD")}&end=${billingCycleTo.format(
"YYYY-MM-DD",
)}`}
>
<button className="secondary">View Usage →</button>
</Link>
Expand Down
428 changes: 0 additions & 428 deletions components/dashboard/src/components/UsageView.tsx

This file was deleted.

8 changes: 2 additions & 6 deletions components/dashboard/src/data/usage/usage-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,16 @@ import { ListUsageRequest, ListUsageResponse } from "@gitpod/gitpod-protocol/lib
import { useQuery } from "@tanstack/react-query";
import { getGitpodService } from "../../service/service";

export function useListUsage(request?: ListUsageRequest) {
export function useListUsage(request: ListUsageRequest) {
const query = useQuery<ListUsageResponse, Error>(
["usage", request],
() => {
console.log("Fetching usage... ", request);
if (!request) {
throw new Error("request is required");
}
return getGitpodService().server.listUsage(request);
},
{
enabled: !!request,
cacheTime: 1000 * 60 * 10, // 10 minutes
staleTime: 1000 * 60 * 10, // 10 minutes
retry: false,
},
);
return query;
Expand Down
186 changes: 186 additions & 0 deletions components/dashboard/src/usage/UsageDateFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* Copyright (c) 2023 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 classNames from "classnames";
import { FC, forwardRef, useCallback, useMemo } from "react";
import ReactDatePicker from "react-datepicker";
import ContextMenu, { ContextMenuEntry } from "../components/ContextMenu";
import { Subheading } from "../components/typography/headings";
import dayjs, { Dayjs } from "dayjs";
import { useToast } from "../components/toasts/Toasts";

const MAX_HOURS = 300 * 24; // 300 days

type Props = {
startDate: Dayjs;
endDate: Dayjs;
onDateRangeChange: (params: { start?: Dayjs; end?: Dayjs }) => void;
};
export const UsageDateFilters: FC<Props> = ({ startDate, endDate, onDateRangeChange }) => {
const { toast } = useToast();

const handleStartDateChange = useCallback(
(date: Date | null) => {
if (!date) {
return;
}

const start = dayjs(date).startOf("day");

let end;

// Automatically adjust end date if > 300 days
if (endDate.endOf("day").diff(start.startOf("day"), "hours") >= MAX_HOURS) {
end = start.add(MAX_HOURS, "hours").subtract(1, "day").endOf("day");
toast("Usage date range is limited to 300 days. Your end date was adjusted for you automatically.");
}

onDateRangeChange({ start, end });
},
[endDate, onDateRangeChange, toast],
);

const handleEndDateChange = useCallback(
(date: Date | null) => {
if (!date) {
return;
}
let end = dayjs(date).endOf("day");

let start;

// Automatically adjust start date if > 300 days
if (end.diff(startDate, "hours") >= MAX_HOURS) {
start = end.subtract(MAX_HOURS, "hours").add(1, "day").startOf("day");
toast("Usage date range is limited to 300 days. Your start date was adjusted for you automatically.");
}

onDateRangeChange({ start, end });
},
[onDateRangeChange, startDate, toast],
);

return (
<div
className={classNames(
"flex flex-col items-start space-y-3",
"sm:flex-row sm:items-center sm:space-x-4 sm:space-y-0",
)}
>
<UsageDateRangePicker startDate={startDate} endDate={endDate} onChange={onDateRangeChange} />
<div className="flex items-center space-x-1">
<ReactDatePicker
selected={startDate.toDate()}
onChange={handleStartDateChange}
selectsStart
startDate={startDate.toDate()}
endDate={endDate.toDate()}
maxDate={endDate.toDate()}
customInput={<DateDisplay />}
dateFormat={"MMM d, yyyy"}
// tab loop enabled causes a bug w/ layout shift to the right of input when open
enableTabLoop={false}
/>
<Subheading>to</Subheading>
<ReactDatePicker
selected={endDate.toDate()}
onChange={handleEndDateChange}
selectsEnd
startDate={startDate.toDate()}
endDate={endDate.toDate()}
minDate={startDate.toDate()}
customInput={<DateDisplay />}
dateFormat={"MMM d, yyyy"}
enableTabLoop={false}
/>
</div>
</div>
);
};

type UsageDateRangePickerProps = {
startDate: Dayjs;
endDate: Dayjs;
onChange: (params: { start: Dayjs; end: Dayjs }) => void;
};
const UsageDateRangePicker: FC<UsageDateRangePickerProps> = ({ startDate, endDate, onChange }) => {
const entries = useMemo<ContextMenuEntry[]>(() => {
const now = dayjs();
const startOfCurrentMonth = now.startOf("month");

const entries: ContextMenuEntry[] = [
{
title: "Current month",
onClick: () => onChange({ start: startOfCurrentMonth, end: now }),
active: startDate.isSame(startOfCurrentMonth, "day") && endDate.isSame(now, "day"),
},
];

// This goes back 6 months from the current month
for (let i = 1; i < 7; i++) {
const entryStart = now.subtract(i, "month").startOf("month");
const entryEnd = entryStart.endOf("month");
entries.push({
title: entryStart.format("MMM YYYY"),
active: startDate.isSame(entryStart, "day") && endDate.isSame(entryEnd, "day"),
onClick: () => onChange({ start: entryStart, end: entryEnd }),
});
}

return entries;
}, [endDate, onChange, startDate]);

const selectedEntry = useMemo(() => entries.find((e) => e.active), [entries]);

return (
<ContextMenu menuEntries={entries} customClasses="left-0">
<DateDisplay value={selectedEntry?.title ?? "Custom range"} onClick={noop} />
</ContextMenu>
);
};

type DateDisplayProps = {
value?: string;
onClick?: () => void;
};
const DateDisplay = forwardRef<any, DateDisplayProps>(({ value, onClick }, ref) => {
return (
// TODO: Turn this into something like a <InputButton showIcon />
<button
onClick={onClick}
ref={ref}
className={classNames(
"w-40 bg-transparent",
"px-4 py-2 my-auto rounded-md",
"text-left",
"bg-white dark:bg-gray-800",
"text-gray-600 dark:text-gray-400",
"border border-gray-300 dark:border-gray-500",
"focus:border-gray-400 dark:focus:border-gray-400 focus:ring-0",
"hover:bg-gray-100 dark:hover:bg-gray-900",
)}
>
<span>{value}</span>
<svg
className="absolute -mt-2 top-1/2 right-2"
width="20"
height="20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
onClick={onClick}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.293 7.293a1 1 0 0 1 1.414 0L10 10.586l3.293-3.293a1 1 0 1 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 0-1.414Z"
/>
<title>Change Date</title>
</svg>
</button>
);
});

const noop = () => {};
125 changes: 125 additions & 0 deletions components/dashboard/src/usage/UsageEntry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Copyright (c) 2023 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 { WorkspaceType } from "@gitpod/gitpod-protocol";
import { Usage, WorkspaceInstanceUsageData } from "@gitpod/gitpod-protocol/lib/usage";
import { FC } from "react";
import { useWorkspaceClasses } from "../data/workspaces/workspace-classes-query";
import { ReactComponent as UsageIcon } from "../images/usage-default.svg";
import { toRemoteURL } from "../projects/render-utils";
// TODO: shift these into a DatePicker component that wraps react-datepicker
import "react-datepicker/dist/react-datepicker.css";
import "../components/react-datepicker.css";

type Props = {
usage: Usage;
};
export const UsageEntry: FC<Props> = ({ usage }) => {
const metadata = usage.metadata as WorkspaceInstanceUsageData;

return (
<div
key={usage.workspaceInstanceId}
className="flex p-3 grid grid-cols-12 gap-x-3 justify-between transition ease-in-out rounded-xl"
>
<div className="flex flex-col col-span-2 my-auto">
<span className="text-gray-600 dark:text-gray-100 text-md font-medium">
{getType(metadata.workspaceType)}
</span>
<span className="text-sm text-gray-400 dark:text-gray-500">
<DisplayName workspaceClass={metadata.workspaceClass} />
</span>
</div>
<div className="flex flex-col col-span-5 my-auto">
<div className="flex">
{isRunning(usage) && (
<div
className="rounded-full w-2 h-2 text-sm align-middle bg-green-500 my-auto mx-1"
title="Still running"
/>
)}
<span className="truncate text-gray-600 dark:text-gray-100 text-md font-medium">
{metadata.workspaceId}
</span>
</div>
<span className="text-sm truncate text-gray-400 dark:text-gray-500">
{metadata.contextURL && toRemoteURL(metadata.contextURL)}
</span>
</div>
<div className="flex flex-col my-auto">
<span className="text-right text-gray-500 dark:text-gray-400 font-medium">{usage.credits}</span>
<span className="text-right text-sm text-gray-400 dark:text-gray-500">{getMinutes(usage)}</span>
</div>
<div className="my-auto" />
<div className="flex flex-col col-span-3 my-auto">
<span className="text-gray-400 dark:text-gray-500 truncate font-medium">
{displayTime(usage.effectiveTime!)}
</span>
<div className="flex">
{metadata.workspaceType === "prebuild" ? <UsageIcon className="my-auto w-4 h-4 mr-1" /> : ""}
{metadata.workspaceType === "prebuild" ? (
<span className="text-sm text-gray-400 dark:text-gray-500">Gitpod</span>
) : (
<div className="flex">
<img
className="my-auto rounded-full w-4 h-4 inline-block align-text-bottom mr-1 overflow-hidden"
src={metadata.userAvatarURL || ""}
alt="user avatar"
/>
<span className="text-sm text-gray-400 dark:text-gray-500">{metadata.userName || ""}</span>
</div>
)}
</div>
</div>
</div>
);
};

const DisplayName: FC<{ workspaceClass: string }> = ({ workspaceClass }) => {
const supportedClasses = useWorkspaceClasses();

const workspaceDisplayName = supportedClasses.data?.find((wc) => wc.id === workspaceClass)?.displayName;

return <>{workspaceDisplayName || workspaceClass}</>;
};

const getType = (type: WorkspaceType) => {
if (type === "regular") {
return "Workspace";
}
return "Prebuild";
};

const isRunning = (usage: Usage) => {
if (usage.kind !== "workspaceinstance") {
return false;
}
const metaData = usage.metadata as WorkspaceInstanceUsageData;
return metaData.endTime === "" || metaData.endTime === undefined;
};

const getMinutes = (usage: Usage) => {
if (usage.kind !== "workspaceinstance") {
return "";
}
const metaData = usage.metadata as WorkspaceInstanceUsageData;
const end = metaData.endTime ? new Date(metaData.endTime).getTime() : Date.now();
const start = new Date(metaData.startTime).getTime();
const lengthOfUsage = Math.floor(end - start);
const inMinutes = (lengthOfUsage / (1000 * 60)).toFixed(1);
return inMinutes + " min";
};

const displayTime = (time: string | number) => {
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "short",
year: "numeric",
hour: "numeric",
minute: "numeric",
};
return new Date(time).toLocaleDateString(undefined, options).replace("at ", "");
};
Loading