diff --git a/components/dashboard/package.json b/components/dashboard/package.json index 2bf36d0f7cc3f5..d435735020e08a 100644 --- a/components/dashboard/package.json +++ b/components/dashboard/package.json @@ -9,6 +9,7 @@ "@gitpod/public-api": "0.1.5", "@stripe/react-stripe-js": "^1.7.2", "@stripe/stripe-js": "^1.29.0", + "@types/react-datepicker": "^4.8.0", "configcat-js": "^6.0.0", "countries-list": "^2.6.1", "dayjs": "^1.11.5", @@ -16,6 +17,7 @@ "monaco-editor": "^0.25.2", "query-string": "^7.1.1", "react": "^17.0.1", + "react-datepicker": "^4.8.0", "react-dom": "^17.0.1", "react-intl-tel-input": "^8.2.0", "react-router-dom": "^5.2.0", diff --git a/components/dashboard/src/components/UsageView.tsx b/components/dashboard/src/components/UsageView.tsx index 9e9c2758f707d0..1865a7c28d46e1 100644 --- a/components/dashboard/src/components/UsageView.tsx +++ b/components/dashboard/src/components/UsageView.tsx @@ -4,7 +4,7 @@ * See License-AGPL.txt in the project root for license information. */ -import { useEffect, useState } from "react"; +import { forwardRef, useEffect, useState } from "react"; import { getGitpodService, gitpodHostUrl } from "../service/service"; import { ListUsageRequest, @@ -25,6 +25,11 @@ import { toRemoteURL } from "../projects/render-utils"; import { WorkspaceType } from "@gitpod/gitpod-protocol"; import PillLabel from "./PillLabel"; import { SupportedWorkspaceClass } from "@gitpod/gitpod-protocol/lib/workspace-class"; +import DatePicker from "react-datepicker"; +import "react-datepicker/dist/react-datepicker.css"; +import "./react-datepicker.css"; +import { useLocation } from "react-router"; +import dayjs from "dayjs"; interface UsageViewProps { attributionId: AttributionId; @@ -33,25 +38,29 @@ interface UsageViewProps { function UsageView({ attributionId }: UsageViewProps) { const [usagePage, setUsagePage] = useState(undefined); const [errorMessage, setErrorMessage] = useState(""); - const today = new Date(); - const startOfCurrentMonth = new Date(today.getFullYear(), today.getMonth(), 1); - const timestampStartOfCurrentMonth = startOfCurrentMonth.getTime(); - const [startDateOfBillMonth, setStartDateOfBillMonth] = useState(timestampStartOfCurrentMonth); - const [endDateOfBillMonth, setEndDateOfBillMonth] = useState(Date.now()); + const startOfCurrentMonth = dayjs().startOf("month"); + const [startDate, setStartDate] = useState(startOfCurrentMonth); + const [endDate, setEndDate] = useState(dayjs()); const [totalCreditsUsed, setTotalCreditsUsed] = useState(0); const [isLoading, setIsLoading] = useState(true); const [supportedClasses, setSupportedClasses] = useState([]); + const location = useLocation(); useEffect(() => { + const match = /#(\d{4}-\d{2}-\d{2}):(\d{4}-\d{2}-\d{2})/.exec(location.hash); + if (match) { + try { + setStartDate(dayjs(match[1], "YYYY-MM-DD")); + setEndDate(dayjs(match[2], "YYYY-MM-DD")); + } catch (e) { + console.error(e); + } + } (async () => { const classes = await getGitpodService().server.getSupportedWorkspaceClasses(); setSupportedClasses(classes); })(); - }, []); - - useEffect(() => { - loadPage(1); - }, [startDateOfBillMonth, endDateOfBillMonth]); + }, [location]); const loadPage = async (page: number = 1) => { if (usagePage === undefined) { @@ -60,8 +69,8 @@ function UsageView({ attributionId }: UsageViewProps) { } const request: ListUsageRequest = { attributionId: AttributionId.render(attributionId), - from: startDateOfBillMonth, - to: endDateOfBillMonth, + from: startDate.startOf("day").valueOf(), + to: endDate.endOf("day").valueOf(), order: Ordering.ORDERING_DESCENDING, pagination: { perPage: 50, @@ -82,6 +91,18 @@ function UsageView({ attributionId }: UsageViewProps) { setIsLoading(false); } }; + useEffect(() => { + if (startDate.isAfter(endDate)) { + setErrorMessage("The start date needs to be before the end date."); + return; + } + if (startDate.add(300, "day").isBefore(endDate)) { + setErrorMessage("Range is too long. Max range is 300 days."); + return; + } + setErrorMessage(""); + loadPage(1); + }, [startDate, endDate]); const getType = (type: WorkspaceType) => { if (type === "regular") { @@ -118,27 +139,24 @@ function UsageView({ attributionId }: UsageViewProps) { return inMinutes + " min"; }; - const handleMonthClick = (start: any, end: any) => { - setStartDateOfBillMonth(start); - setEndDateOfBillMonth(end); + const handleMonthClick = (start: dayjs.Dayjs, end: dayjs.Dayjs) => { + setStartDate(start); + setEndDate(end); }; const getBillingHistory = () => { let rows = []; // This goes back 6 months from the current month for (let i = 1; i < 7; i++) { - const endDateVar = i - 1; - const startDate = new Date(today.getFullYear(), today.getMonth() - i); - const endDate = new Date(today.getFullYear(), today.getMonth() - endDateVar); - const timeStampOfStartDate = startDate.getTime(); - const timeStampOfEndDate = endDate.getTime(); + const startDate = dayjs().subtract(i, "month").startOf("month"); + const endDate = startDate.endOf("month"); rows.push(
handleMonthClick(timeStampOfStartDate, timeStampOfEndDate)} + onClick={() => handleMonthClick(startDate, endDate)} > - {startDate.toLocaleString("default", { month: "long" })} {startDate.getFullYear()} + {startDate.format("MMMM YYYY")}
, ); } @@ -160,13 +178,68 @@ function UsageView({ attributionId }: UsageViewProps) { const headerTitle = attributionId.kind === "team" ? "Team Usage" : "Personal Usage"; + const DateDisplay = forwardRef((arg: any, ref: any) => ( +
+
{arg.value}
+
+ + + Change Date + +
+
+ )); + return ( <>
+

{headerTitle}

+

(updated every 15 minutes).

+ + } + subtitle={ +
+

Showing usage from

+ date && setStartDate(dayjs(date))} + selectsStart + startDate={startDate.toDate()} + endDate={endDate.toDate()} + maxDate={endDate.toDate()} + customInput={} + dateFormat={"MMM d, yyyy"} + /> +

to

+ date && setEndDate(dayjs(date))} + selectsEnd + startDate={startDate.toDate()} + endDate={endDate.toDate()} + minDate={startDate.toDate()} + customInput={} + dateFormat={"MMM d, yyyy"} + /> +
+ } />
{errorMessage &&

{errorMessage}

} @@ -178,10 +251,9 @@ function UsageView({ attributionId }: UsageViewProps) {
Current Month
handleMonthClick(timestampStartOfCurrentMonth, Date.now())} + onClick={() => handleMonthClick(startOfCurrentMonth, dayjs())} > - {startOfCurrentMonth.toLocaleString("default", { month: "long" })}{" "} - {startOfCurrentMonth.getFullYear()} + {dayjs(startOfCurrentMonth).format("MMMM YYYY")}
Previous Months
{getBillingHistory()} @@ -189,7 +261,7 @@ function UsageView({ attributionId }: UsageViewProps) { {!isLoading && (
-
Total usage
+
Total Usage
{totalCreditsUsed.toLocaleString()} Credits @@ -235,11 +307,7 @@ function UsageView({ attributionId }: UsageViewProps) { {" "} workspaces {" "} - in{" "} - {new Date(startDateOfBillMonth).toLocaleString("default", { - month: "long", - })}{" "} - {new Date(startDateOfBillMonth).getFullYear()} or checked your other teams? + in {startDate.format("MMMM YYYY")} or checked your other teams?

)} diff --git a/components/dashboard/src/components/react-datepicker.css b/components/dashboard/src/components/react-datepicker.css new file mode 100644 index 00000000000000..6b06769689e067 --- /dev/null +++ b/components/dashboard/src/components/react-datepicker.css @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2022 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. + */ + +.react-datepicker-wrapper { + width: fit-content !important; +} + +.react-datepicker { + border: 0px !important; + border-radius: 1rem !important; +} + +.react-datepicker__month-container { + border-radius: 0.75rem !important; +} + +.react-datepicker div { + @apply bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-700 +} + +.react-datepicker div.react-datepicker__day--in-selecting-range { + @apply bg-gray-400 dark:bg-gray-600 text-gray-300 +} + +.react-datepicker div.react-datepicker__day--selected { + @apply dark:bg-gray-300 dark:text-gray-800 bg-gray-500 text-gray-100 +} + +.react-datepicker div.react-datepicker__day--selecting-range-start { + @apply dark:bg-gray-300 dark:text-gray-800 text-gray-200 +} + +.react-datepicker div.react-datepicker__day--selecting-range-end { + @apply dark:bg-gray-300 dark:text-gray-800 text-gray-200 +} + +.react-datepicker button { + @apply dark:bg-gray-800 dark:text-gray-200 +} + +.react-datepicker__triangle::before { + border-bottom-color: transparent !important; +} +.react-datepicker__triangle::after { + border-bottom-color: transparent !important; +} diff --git a/yarn.lock b/yarn.lock index bb4e9fcb5bf526..78bdb54ffc8429 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2099,6 +2099,11 @@ schema-utils "^2.6.5" source-map "^0.7.3" +"@popperjs/core@^2.9.2": + version "2.11.6" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45" + integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw== + "@probot/get-private-key@^1.1.0", "@probot/get-private-key@^1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@probot/get-private-key/-/get-private-key-1.1.1.tgz" @@ -3166,6 +3171,16 @@ resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== +"@types/react-datepicker@^4.8.0": + version "4.8.0" + resolved "https://registry.yarnpkg.com/@types/react-datepicker/-/react-datepicker-4.8.0.tgz#0221bd38725b7db64cd08a89f49a93d816c2f691" + integrity sha512-20uzZsIf4moPAjjHDfPvH8UaOHZBxrkiQZoLS3wgKq8Xhp+95gdercLEdoA7/I8nR9R5Jz2qQkdMIM+Lq4AS1A== + dependencies: + "@popperjs/core" "^2.9.2" + "@types/react" "*" + date-fns "^2.0.1" + react-popper "^2.2.5" + "@types/react-dom@^17.0.3": version "17.0.10" resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.10.tgz" @@ -5514,6 +5529,11 @@ classnames@^2.2.5, classnames@^2.3.1: resolved "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz" integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== +classnames@^2.2.6: + version "2.3.2" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" + integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== + clean-css@^4.2.3: version "4.2.4" resolved "https://registry.npmjs.org/clean-css/-/clean-css-4.2.4.tgz" @@ -6653,6 +6673,11 @@ date-fns@^2.0.1, date-fns@^2.16.1: resolved "https://registry.npmjs.org/date-fns/-/date-fns-2.25.0.tgz" integrity sha512-ovYRFnTrbGPD4nqaEqescPEv1mNwvt+UTqI3Ay9SzNtey9NZnYu6E2qCcBBgJ6/2VF1zGGygpyTDITqpQQ5e+w== +date-fns@^2.24.0: + version "2.29.3" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8" + integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA== + dateformat@^4.5.1: version "4.6.3" resolved "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz" @@ -11585,7 +11610,7 @@ longjohn@^0.2.12: dependencies: source-map-support "0.3.2 - 1.0.0" -loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -14927,6 +14952,18 @@ react-app-polyfill@^2.0.0: regenerator-runtime "^0.13.7" whatwg-fetch "^3.4.1" +react-datepicker@^4.8.0: + version "4.8.0" + resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-4.8.0.tgz#11b8918d085a1ce4781eee4c8e4641b3cd592010" + integrity sha512-u69zXGHMpxAa4LeYR83vucQoUCJQ6m/WBsSxmUMu/M8ahTSVMMyiyQzauHgZA2NUr9y0FUgOAix71hGYUb6tvg== + dependencies: + "@popperjs/core" "^2.9.2" + classnames "^2.2.6" + date-fns "^2.24.0" + prop-types "^15.7.2" + react-onclickoutside "^6.12.0" + react-popper "^2.2.5" + react-dev-utils@^11.0.3: version "11.0.4" resolved "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz" @@ -14971,6 +15008,11 @@ react-error-overlay@^6.0.9: resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz" integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew== +react-fast-compare@^3.0.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" + integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== + react-intl-tel-input@^8.2.0: version "8.2.0" resolved "https://registry.yarnpkg.com/react-intl-tel-input/-/react-intl-tel-input-8.2.0.tgz#9e830fbe3bcca5aa5e8cdd84bac80da13e3ab389" @@ -14992,6 +15034,19 @@ react-is@^17.0.1: resolved "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +react-onclickoutside@^6.12.0: + version "6.12.2" + resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.12.2.tgz#8e6cf80c7d17a79f2c908399918158a7b02dda01" + integrity sha512-NMXGa223OnsrGVp5dJHkuKxQ4czdLmXSp5jSV9OqiCky9LOpPATn3vLldc+q5fK3gKbEHvr7J1u0yhBh/xYkpA== + +react-popper@^2.2.5: + version "2.3.0" + resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.3.0.tgz#17891c620e1320dce318bad9fede46a5f71c70ba" + integrity sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q== + dependencies: + react-fast-compare "^3.0.1" + warning "^4.0.2" + react-refresh@^0.8.3: version "0.8.3" resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz" @@ -18059,6 +18114,13 @@ walker@^1.0.7, walker@~1.0.5: dependencies: makeerror "1.0.12" +warning@^4.0.2: + version "4.0.3" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + watch@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/watch/-/watch-1.0.2.tgz"