diff --git a/frontend/webapp/app/(overview)/overview/page.tsx b/frontend/webapp/app/(overview)/overview/page.tsx index c0d926df46..8217514c4a 100644 --- a/frontend/webapp/app/(overview)/overview/page.tsx +++ b/frontend/webapp/app/(overview)/overview/page.tsx @@ -1,7 +1,7 @@ 'use client'; import React from 'react'; import dynamic from 'next/dynamic'; -import { usePaginatedSources, useSSE } from '@/hooks'; +import { usePaginatedSources, useSSE, useTokenTracker } from '@/hooks'; const ToastList = dynamic(() => import('@/components/notification/toast-list'), { ssr: false }); const AllDrawers = dynamic(() => import('@/components/overview/all-drawers'), { ssr: false }); @@ -10,6 +10,7 @@ const OverviewDataFlowContainer = dynamic(() => import('@/containers/main/overvi export default function MainPage() { useSSE(); + useTokenTracker(); // "usePaginatedSources" is here to fetch sources just once // (hooks run on every mount, we don't want that for pagination) diff --git a/frontend/webapp/components/main/header/index.tsx b/frontend/webapp/components/main/header/index.tsx index a3d5d0c397..92124261e6 100644 --- a/frontend/webapp/components/main/header/index.tsx +++ b/frontend/webapp/components/main/header/index.tsx @@ -3,7 +3,7 @@ import theme from '@/styles/theme'; import { FlexRow } from '@/styles'; import { SLACK_LINK } from '@/utils'; import styled from 'styled-components'; -import { PlatformTypes } from '@/types'; +import { NOTIFICATION_TYPE, PlatformTypes } from '@/types'; import { PlatformTitle } from './cp-title'; import { NotificationManager } from '@/components'; import { OdigosLogoText, SlackLogo, TerminalIcon } from '@/assets'; @@ -33,7 +33,7 @@ const AlignRight = styled(FlexRow)` export const MainHeader: React.FC = () => { const { setSelectedItem } = useDrawerStore(); - const { connecting, active, title, message } = useConnectionStore(); + const { title, message, sseConnecting, sseStatus, tokenExpired, tokenExpiring } = useConnectionStore(); const handleClickCli = () => setSelectedItem({ type: DRAWER_OTHER_TYPES.ODIGOS_CLI, id: DRAWER_OTHER_TYPES.ODIGOS_CLI }); const handleClickSlack = () => window.open(SLACK_LINK, '_blank', 'noopener noreferrer'); @@ -43,7 +43,7 @@ export const MainHeader: React.FC = () => { - {!connecting && } + {!sseConnecting && } diff --git a/frontend/webapp/components/overview/all-drawers/cli-drawer.tsx b/frontend/webapp/components/overview/all-drawers/cli-drawer.tsx index fe2772d159..65eff63400 100644 --- a/frontend/webapp/components/overview/all-drawers/cli-drawer.tsx +++ b/frontend/webapp/components/overview/all-drawers/cli-drawer.tsx @@ -3,8 +3,8 @@ import theme from '@/styles/theme'; import styled from 'styled-components'; import { NOTIFICATION_TYPE } from '@/types'; import { FlexColumn, FlexRow } from '@/styles'; -import { DATA_CARDS, getStatusIcon, safeJsonStringify } from '@/utils'; import OverviewDrawer from '@/containers/main/overview/overview-drawer'; +import { DATA_CARDS, getStatusIcon, isOverTime, safeJsonStringify, SEVEN_DAYS_IN_MS } from '@/utils'; import { useCopy, useDescribeOdigos, useKeyDown, useOnClickOutside, useTimeAgo, useTokenCRUD } from '@/hooks'; import { CheckIcon, CodeBracketsIcon, CodeIcon, CopyIcon, CrossIcon, EditIcon, KeyIcon, ListIcon } from '@/assets'; import { Button, DataCard, DataCardFieldTypes, Divider, IconButton, Input, Segment, Text, Tooltip } from '@/reuseable-components'; @@ -83,8 +83,17 @@ export const CliDrawer: React.FC = () => { rows: tokens.map(({ token, name, expiresAt }, idx) => [ { columnKey: 'icon', icon: KeyIcon }, { columnKey: 'name', value: name }, - { columnKey: 'expires_at', value: `${timeAgo.format(expiresAt)} (${new Date(expiresAt).toDateString().split(' ').slice(1).join(' ')})` }, { columnKey: 'token', value: `${new Array(15).fill('•').join('')}` }, + { + columnKey: 'expires_at', + component: () => { + return ( + + {timeAgo.format(expiresAt)} ({new Date(expiresAt).toDateString().split(' ').slice(1).join(' ')}) + + ); + }, + }, { columnKey: 'actions', component: () => { diff --git a/frontend/webapp/hooks/notification/useSSE.ts b/frontend/webapp/hooks/notification/useSSE.ts index 7e4e52d332..b499df9d02 100644 --- a/frontend/webapp/hooks/notification/useSSE.ts +++ b/frontend/webapp/hooks/notification/useSSE.ts @@ -5,10 +5,10 @@ import { useComputePlatform, usePaginatedSources } from '../compute-platform'; import { type NotifyPayload, useConnectionStore, useNotificationStore, usePendingStore } from '@/store'; export const useSSE = () => { + const { setSseStatus } = useConnectionStore(); const { setPendingItems } = usePendingStore(); const { fetchSources } = usePaginatedSources(); const { addNotification } = useNotificationStore(); - const { setConnectionStore } = useConnectionStore(); const { refetch: refetchComputePlatform } = useComputePlatform(); const retryCount = useRef(0); @@ -61,9 +61,9 @@ export const useSSE = () => { } else { console.error('Max retries reached. Could not reconnect to EventSource.'); - setConnectionStore({ - connecting: false, - active: false, + setSseStatus({ + sseConnecting: false, + sseStatus: NOTIFICATION_TYPE.ERROR, title: `Connection lost on ${new Date().toLocaleString()}`, message: 'Please reboot the application', }); @@ -75,9 +75,9 @@ export const useSSE = () => { } }; - setConnectionStore({ - connecting: false, - active: true, + setSseStatus({ + sseConnecting: false, + sseStatus: NOTIFICATION_TYPE.SUCCESS, title: 'Connection Alive', message: '', }); diff --git a/frontend/webapp/hooks/tokens/index.ts b/frontend/webapp/hooks/tokens/index.ts index 8c363c5b02..807aac0987 100644 --- a/frontend/webapp/hooks/tokens/index.ts +++ b/frontend/webapp/hooks/tokens/index.ts @@ -1 +1,2 @@ export * from './useTokenCRUD'; +export * from './useTokenTracker'; diff --git a/frontend/webapp/hooks/tokens/useTokenTracker.ts b/frontend/webapp/hooks/tokens/useTokenTracker.ts new file mode 100644 index 0000000000..c9c8915fa0 --- /dev/null +++ b/frontend/webapp/hooks/tokens/useTokenTracker.ts @@ -0,0 +1,52 @@ +import { useEffect } from 'react'; +import { useTokenCRUD } from '.'; +import { useTimeAgo } from '../common'; +import { NOTIFICATION_TYPE } from '@/types'; +import { isOverTime, SEVEN_DAYS_IN_MS } from '@/utils'; +import { useConnectionStore, useNotificationStore } from '@/store'; + +// This hook is responsible for tracking the tokens and their expiration times. +// When a token is about to expire or has expired, a notification is added to the notification store, and the connection status is updated accordingly. + +export const useTokenTracker = () => { + const timeago = useTimeAgo(); + const { tokens } = useTokenCRUD(); + const { setTokenStatus } = useConnectionStore(); + const { addNotification } = useNotificationStore(); + + useEffect(() => { + tokens.forEach(({ expiresAt, name }) => { + if (isOverTime(expiresAt)) { + const notif = { + type: NOTIFICATION_TYPE.WARNING, + title: 'API Token', + message: `The token "${name}" has expired ${timeago.format(expiresAt)}.`, + }; + + addNotification(notif); + setTokenStatus({ + tokenExpired: true, + tokenExpiring: false, + title: notif.title, + message: notif.message, + }); + } else if (isOverTime(expiresAt, SEVEN_DAYS_IN_MS)) { + const notif = { + type: NOTIFICATION_TYPE.WARNING, + title: 'API Token', + message: `The token "${name}" is about to expire ${timeago.format(expiresAt)}.`, + }; + + addNotification(notif); + setTokenStatus({ + tokenExpired: false, + tokenExpiring: true, + title: notif.title, + message: notif.message, + }); + } + }); + }, [tokens]); + + return {}; +}; diff --git a/frontend/webapp/reuseable-components/status/index.tsx b/frontend/webapp/reuseable-components/status/index.tsx index 37b6b44378..c0c54104b4 100644 --- a/frontend/webapp/reuseable-components/status/index.tsx +++ b/frontend/webapp/reuseable-components/status/index.tsx @@ -15,8 +15,9 @@ export interface StatusProps { subtitle?: string; size?: number; family?: 'primary' | 'secondary'; - isPale?: boolean; + status?: NOTIFICATION_TYPE; isActive?: boolean; + isPale?: boolean; withIcon?: boolean; withBorder?: boolean; withBackground?: boolean; @@ -25,7 +26,7 @@ export interface StatusProps { const StatusWrapper = styled.div<{ $size: number; $isPale: StatusProps['isPale']; - $isActive: StatusProps['isActive']; + $status: StatusProps['status']; $withIcon?: StatusProps['withIcon']; $withBorder?: StatusProps['withBorder']; $withBackground?: StatusProps['withBackground']; @@ -36,14 +37,17 @@ const StatusWrapper = styled.div<{ padding: ${({ $size, $withBorder, $withBackground }) => ($withBorder || $withBackground ? `${$size / ($withBorder ? 3 : 2)}px ${$size / ($withBorder ? 1.5 : 1)}px` : '0')}; width: fit-content; border-radius: 360px; - border: ${({ $withBorder, $isPale, $isActive, theme }) => ($withBorder ? `1px solid ${$isPale ? theme.colors.border : $isActive ? theme.colors.dark_green : theme.colors.dark_red}` : 'none')}; - background: ${({ $withBackground, $isPale, $isActive, theme }) => + border: ${({ $withBorder, $isPale, $status, theme }) => + $withBorder + ? `1px solid ${ + $isPale ? theme.colors.border : $status === NOTIFICATION_TYPE.SUCCESS ? theme.colors.dark_green : $status === NOTIFICATION_TYPE.ERROR ? theme.colors.dark_red : theme.colors.border + }` + : 'none'}; + background: ${({ $withBackground, $isPale, $status = NOTIFICATION_TYPE.DEFAULT, theme }) => $withBackground ? $isPale ? `linear-gradient(90deg, transparent 0%, ${theme.colors.info + hexPercentValues['080']} 50%, ${theme.colors.info} 100%)` - : $isActive - ? `linear-gradient(90deg, transparent 0%, ${theme.colors.success + hexPercentValues['080']} 50%, ${theme.colors.success} 100%)` - : `linear-gradient(90deg, transparent 0%, ${theme.colors.error + hexPercentValues['080']} 50%, ${theme.colors.error} 100%)` + : `linear-gradient(90deg, transparent 0%, ${theme.colors[$status] + hexPercentValues['080']} 50%, ${theme.colors[$status]} 100%)` : 'transparent'}; `; @@ -59,37 +63,48 @@ const TextWrapper = styled.div` const Title = styled(Text)<{ $isPale: StatusProps['isPale']; - $isActive: StatusProps['isActive']; + $status: StatusProps['status']; }>` - color: ${({ $isPale, $isActive, theme }) => ($isPale ? theme.text.secondary : $isActive ? theme.text.success : theme.text.error)}; + color: ${({ $isPale, $status = NOTIFICATION_TYPE.DEFAULT, theme }) => ($isPale ? theme.text.secondary : theme.text[$status])}; `; const SubTitle = styled(Text)<{ $isPale: StatusProps['isPale']; - $isActive: StatusProps['isActive']; + $status: StatusProps['status']; }>` - color: ${({ $isPale, $isActive }) => ($isPale ? theme.text.grey : $isActive ? '#51DB51' : '#DB5151')}; + color: ${({ $isPale, $status = NOTIFICATION_TYPE.DEFAULT }) => ($isPale ? theme.text.grey : theme.text[`${$status}_secondary`])}; `; -export const Status: React.FC = ({ title, subtitle, size = 12, family = 'secondary', isPale, isActive, withIcon, withBorder, withBackground }) => { - const StatusIcon = getStatusIcon(isActive ? NOTIFICATION_TYPE.SUCCESS : NOTIFICATION_TYPE.ERROR); +export const Status: React.FC = ({ title, subtitle, size = 12, family = 'secondary', status, isActive: oldStatus, isPale, withIcon, withBorder, withBackground }) => { + const statusType = status || (oldStatus ? NOTIFICATION_TYPE.SUCCESS : NOTIFICATION_TYPE.ERROR); + const StatusIcon = getStatusIcon(statusType); return ( - - {withIcon && {isPale && isActive ? : isPale && !isActive ? : }} + + {withIcon && ( + + {isPale && statusType === NOTIFICATION_TYPE.SUCCESS ? ( + + ) : isPale && statusType === NOTIFICATION_TYPE.ERROR ? ( + + ) : ( + + )} + + )} {(!!title || !!subtitle) && ( {!!title && ( - + <Title size={size} family={family} $isPale={isPale} $status={statusType}> {title} )} {!!subtitle && ( - - + + {subtitle} diff --git a/frontend/webapp/store/useConnectionStore.ts b/frontend/webapp/store/useConnectionStore.ts index 4f012cfebe..ed3cb02fcb 100644 --- a/frontend/webapp/store/useConnectionStore.ts +++ b/frontend/webapp/store/useConnectionStore.ts @@ -1,29 +1,36 @@ +import { NOTIFICATION_TYPE } from '@/types'; import { create } from 'zustand'; -interface StoreStateValues { - connecting: boolean; - active: boolean; +interface StateValues { title: string; message: string; } +interface SseStateValues extends StateValues { + sseConnecting: boolean; + sseStatus: NOTIFICATION_TYPE; +} + +interface TokenStateValues extends StateValues { + tokenExpired: boolean; + tokenExpiring: boolean; +} + interface StoreStateSetters { - setConnectionStore: (state: StoreStateValues) => void; - setConnecting: (bool: boolean) => void; - setActive: (bool: boolean) => void; - setTitle: (str: string) => void; - setMessage: (str: string) => void; + setSseStatus: (state: SseStateValues) => void; + setTokenStatus: (state: TokenStateValues) => void; } -export const useConnectionStore = create((set) => ({ - connecting: true, - active: false, +export const useConnectionStore = create((set) => ({ title: '', message: '', - setConnectionStore: (state) => set(state), - setConnecting: (bool) => set({ connecting: bool }), - setActive: (bool) => set({ active: bool }), - setTitle: (str) => set({ title: str }), - setMessage: (str) => set({ message: str }), + sseConnecting: true, + sseStatus: NOTIFICATION_TYPE.DEFAULT, + + tokenExpired: false, + tokenExpiring: false, + + setSseStatus: (state) => set(state), + setTokenStatus: (state) => set(state), })); diff --git a/frontend/webapp/store/useNotificationStore.ts b/frontend/webapp/store/useNotificationStore.ts index 8fa4173356..9b4920583d 100644 --- a/frontend/webapp/store/useNotificationStore.ts +++ b/frontend/webapp/store/useNotificationStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { isWithinTime } from '@/utils'; import type { Notification } from '@/types'; export type NotifyPayload = Omit; @@ -21,7 +22,7 @@ export const useNotificationStore = create((set, get) => ({ // This is to prevent duplicate notifications within a 10 second time-frame. // This is useful for notifications that are triggered multiple times in a short period, like failed API queries... - const foundThisNotif = !!get().notifications.find((n) => n.type === notif.type && n.title === notif.title && n.message === notif.message && date.getTime() - new Date(n.time).getTime() <= 10000); // 10 seconds + const foundThisNotif = !!get().notifications.find((n) => n.type === notif.type && n.title === notif.title && n.message === notif.message && isWithinTime(n.time, 10000)); // 10 seconds if (!foundThisNotif) { set((state) => ({ diff --git a/frontend/webapp/styles/theme.ts b/frontend/webapp/styles/theme.ts index 6e1e71ceef..d58820bc18 100644 --- a/frontend/webapp/styles/theme.ts +++ b/frontend/webapp/styles/theme.ts @@ -235,11 +235,15 @@ const text = { dark_button: '#0A1824', warning: '#E9CF35', + warning_secondary: '#FFA349', error: '#EF7676', error_secondary: '#DB5151', success: '#81AF65', + success_secondary: '#51DB51', info: '#B8B8B8', + info_secondary: '#CCDDDD', default: '#AABEF7', + default_secondary: '#8CBEFF', }; const font_family = { diff --git a/frontend/webapp/utils/constants/index.tsx b/frontend/webapp/utils/constants/index.tsx index 63ba9f4525..cb276c67a0 100644 --- a/frontend/webapp/utils/constants/index.tsx +++ b/frontend/webapp/utils/constants/index.tsx @@ -4,3 +4,4 @@ export * from './string'; export * from './urls'; export * from './programming-languages'; export * from './monitors'; +export * from './numbers'; diff --git a/frontend/webapp/utils/constants/numbers.ts b/frontend/webapp/utils/constants/numbers.ts new file mode 100644 index 0000000000..48b34a33ce --- /dev/null +++ b/frontend/webapp/utils/constants/numbers.ts @@ -0,0 +1 @@ +export const SEVEN_DAYS_IN_MS = 604800000; diff --git a/frontend/webapp/utils/functions/resolvers/index.ts b/frontend/webapp/utils/functions/resolvers/index.ts index 39f8fff97f..9c9c14f142 100644 --- a/frontend/webapp/utils/functions/resolvers/index.ts +++ b/frontend/webapp/utils/functions/resolvers/index.ts @@ -1,3 +1,5 @@ export * from './compare-condition'; export * from './get-value-for-range'; export * from './is-emtpy'; +export * from './is-over-time'; +export * from './is-within-time'; diff --git a/frontend/webapp/utils/functions/resolvers/is-over-time/index.ts b/frontend/webapp/utils/functions/resolvers/is-over-time/index.ts new file mode 100644 index 0000000000..f3affe2459 --- /dev/null +++ b/frontend/webapp/utils/functions/resolvers/is-over-time/index.ts @@ -0,0 +1,6 @@ +export const isOverTime = (originDate: Date | string | number, difference: number = 0) => { + const now = new Date().getTime(); + const compareWith = new Date(originDate).getTime(); + + return compareWith - now <= difference; +}; diff --git a/frontend/webapp/utils/functions/resolvers/is-within-time/index.ts b/frontend/webapp/utils/functions/resolvers/is-within-time/index.ts new file mode 100644 index 0000000000..6d2f25b858 --- /dev/null +++ b/frontend/webapp/utils/functions/resolvers/is-within-time/index.ts @@ -0,0 +1,6 @@ +export const isWithinTime = (originDate: Date | string | number, difference: number = 0) => { + const now = new Date().getTime(); + const compareWith = new Date(originDate).getTime(); + + return now - compareWith <= difference; +};