From 7ddfb48fa5d89e45c931ba99df87279d5bdd719f Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Fri, 22 Jul 2022 07:34:13 +0000 Subject: [PATCH 1/2] Add "spending limit is reached" notification --- .../gitpod-protocol/src/gitpod-service.ts | 5 +++++ .../ee/src/workspace/gitpod-server-impl.ts | 20 +++++++++++++++++++ components/server/src/auth/rate-limiter.ts | 1 + .../src/workspace/gitpod-server-impl.ts | 5 +++++ 4 files changed, 31 insertions(+) diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index 551cdd67089d6a..8be6f5339a45de 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -302,6 +302,11 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, trackEvent(event: RemoteTrackMessage): Promise; trackLocation(event: RemotePageMessage): Promise; identifyUser(event: RemoteIdentifyMessage): Promise; + + /** + * Frontend notifications + */ + getNotifications(): Promise; } export interface RateLimiterError { diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 54bb6985808b71..a15e5fc8a06fee 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -2096,6 +2096,26 @@ export class GitpodServerEEImpl extends GitpodServerImpl { }); } + async getNotifications(ctx: TraceContext): Promise { + const result = await super.getNotifications(ctx); + const user = this.checkAndBlockUser("getNotifications"); + if (user.usageAttributionId) { + const allSessions = await this.listBilledUsage(ctx, user.usageAttributionId); + const totalUsage = allSessions.map((s) => s.credits).reduce((a, b) => a + b, 0); + const costCenter = await this.costCenterDB.findById(user.usageAttributionId); + if (costCenter) { + if (totalUsage > costCenter.spendingLimit) { + result.unshift("The spending limit is reached."); + } else if (totalUsage > 0.8 * costCenter.spendingLimit * 0.8) { + result.unshift("The spending limit is almost reached."); + } + } else { + log.warn("No costcenter found.", { userId: user.id, attributionId: user.usageAttributionId }); + } + } + return result; + } + async listBilledUsage(ctx: TraceContext, attributionId: string): Promise { traceAPIParams(ctx, { attributionId }); const user = this.checkAndBlockUser("listBilledUsage"); diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index 756973c6ec07ca..ba0416f691bd22 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -219,6 +219,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig { setUsageAttribution: { group: "default", points: 1 }, getSpendingLimitForTeam: { group: "default", points: 1 }, setSpendingLimitForTeam: { group: "default", points: 1 }, + getNotifications: { group: "default", points: 1 }, }; return { diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 016cbee7c74dc9..7767c15a332d01 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -3243,4 +3243,9 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { return this.imagebuilderClientProvider.getClient(user, workspace, instance); } } + + async getNotifications(ctx: TraceContext): Promise { + this.checkAndBlockUser("getNotifications"); + return []; + } } From 85b7cf9846160a3cd80f29b7be31bc56c1334626 Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Fri, 22 Jul 2022 10:07:24 +0000 Subject: [PATCH 2/2] Show app-level notifications on dashboard --- components/dashboard/src/App.tsx | 2 + components/dashboard/src/AppNotifications.tsx | 76 +++++++++++++++++++ components/dashboard/src/components/Alert.tsx | 11 ++- 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 components/dashboard/src/AppNotifications.tsx diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index 346dbf3541e8f1..976f5aab892616 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -49,6 +49,7 @@ import SelectIDEModal from "./settings/SelectIDEModal"; import { StartPage, StartPhase } from "./start/StartPage"; import { isGitpodIo } from "./utils"; import { BlockedRepositories } from "./admin/BlockedRepositories"; +import { AppNotifications } from "./AppNotifications"; const Setup = React.lazy(() => import(/* webpackPrefetch: true */ "./Setup")); const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ "./workspaces/Workspaces")); @@ -346,6 +347,7 @@ function App() {
+ diff --git a/components/dashboard/src/AppNotifications.tsx b/components/dashboard/src/AppNotifications.tsx new file mode 100644 index 00000000000000..bdfc101474e97e --- /dev/null +++ b/components/dashboard/src/AppNotifications.tsx @@ -0,0 +1,76 @@ +/** + * 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. + */ + +import { useEffect, useState } from "react"; +import Alert from "./components/Alert"; +import { getGitpodService } from "./service/service"; + +const KEY_APP_NOTIFICATIONS = "KEY_APP_NOTIFICATIONS"; + +export function AppNotifications() { + const [notifications, setNotifications] = useState([]); + + useEffect(() => { + let localState = getLocalStorageObject(KEY_APP_NOTIFICATIONS); + if (Array.isArray(localState)) { + setNotifications(localState); + return; + } + (async () => { + const serverState = await getGitpodService().server.getNotifications(); + setNotifications(serverState); + setLocalStorageObject(KEY_APP_NOTIFICATIONS, serverState); + })(); + }, []); + + const topNotification = notifications[0]; + if (topNotification === undefined) { + return null; + } + + const dismissNotification = () => { + removeLocalStorageObject(KEY_APP_NOTIFICATIONS); + setNotifications([]); + }; + + return ( +
+ dismissNotification()} + showIcon={true} + className="flex rounded mb-2 w-full" + > + {topNotification} + +
+ ); +} + +function getLocalStorageObject(key: string): any { + try { + const string = window.localStorage.getItem(key); + if (!string) { + return; + } + return JSON.parse(string); + } catch (error) { + return; + } +} + +function removeLocalStorageObject(key: string): void { + window.localStorage.removeItem(key); +} + +function setLocalStorageObject(key: string, object: Object): void { + try { + window.localStorage.setItem(key, JSON.stringify(object)); + } catch (error) { + console.error("Setting localstorage item failed", key, object, error); + } +} diff --git a/components/dashboard/src/components/Alert.tsx b/components/dashboard/src/components/Alert.tsx index 63c3c50fe8df37..a74db4e67c4e21 100644 --- a/components/dashboard/src/components/Alert.tsx +++ b/components/dashboard/src/components/Alert.tsx @@ -26,6 +26,7 @@ export interface AlertProps { // Without background color, default false light?: boolean; closable?: boolean; + onClose?: () => void; showIcon?: boolean; icon?: React.ReactNode; children?: React.ReactNode; @@ -80,7 +81,15 @@ export default function Alert(props: AlertProps) { {props.children} {props.closable && ( - setVisible(false)} className="w-3 h-4 cursor-pointer"> + { + setVisible(false); + if (props.onClose) { + props.onClose(); + } + }} + className="w-3 h-4 cursor-pointer" + > )}