Skip to content

Commit

Permalink
[GEN-2187]: notify in UI about expiring/expired token (#2208)
Browse files Browse the repository at this point in the history
This pull request introduces several changes to enhance the token
tracking system, improve connection status handling, and refactor the
notification system. The most important changes include adding a new
hook for token tracking, updating the connection store, and modifying
the status component to handle different notification types.

### Token Tracking Enhancements:
* Added `useTokenTracker` hook to monitor token expiration and trigger
notifications (`frontend/webapp/hooks/tokens/useTokenTracker.ts`).
* Updated `useTokenCRUD` import to include `useTokenTracker`
(`frontend/webapp/hooks/tokens/index.ts`).

### Connection Store Updates:
* Refactored `useConnectionStore` to include new state values and
setters for SSE and token statuses
(`frontend/webapp/store/useConnectionStore.ts`).
* Modified `useSSE` hook to use the new `setSseStatus` method for
updating connection status
(`frontend/webapp/hooks/notification/useSSE.ts`)
[[1]](diffhunk://#diff-db9ebe0ce8cdabc0ede2f45de12661d55fb7aa50a5ecfc0787f5898c55b044d6R8-L11)
[[2]](diffhunk://#diff-db9ebe0ce8cdabc0ede2f45de12661d55fb7aa50a5ecfc0787f5898c55b044d6L64-R66)
[[3]](diffhunk://#diff-db9ebe0ce8cdabc0ede2f45de12661d55fb7aa50a5ecfc0787f5898c55b044d6L78-R80).

### Notification System Improvements:
* Updated `Status` component to handle different notification types and
statuses (`frontend/webapp/reuseable-components/status/index.tsx`)
[[1]](diffhunk://#diff-8828fa39eed82424bd382e5bd240ee5efc607723527e6dc649d92a3d0c32aed3L18-R20)
[[2]](diffhunk://#diff-8828fa39eed82424bd382e5bd240ee5efc607723527e6dc649d92a3d0c32aed3L28-R29)
[[3]](diffhunk://#diff-8828fa39eed82424bd382e5bd240ee5efc607723527e6dc649d92a3d0c32aed3L39-R50)
[[4]](diffhunk://#diff-8828fa39eed82424bd382e5bd240ee5efc607723527e6dc649d92a3d0c32aed3L62-R107).
* Added constants and utility functions for time calculations
(`frontend/webapp/utils/constants/numbers.ts`,
`frontend/webapp/utils/functions/resolvers/is-over-time/index.ts`,
`frontend/webapp/utils/functions/resolvers/is-within-time/index.ts`)
[[1]](diffhunk://#diff-8d9da7862db2daaa157f3e56ea67544a60558bbba464c46e09a4cccd7ac035ecR1)
[[2]](diffhunk://#diff-6aa0c63fcbc4f8f8d99955949445d606fa200f5f19944befe1e270d6c674b376R1-R6)
[[3]](diffhunk://#diff-d8c142b409a4755de8cc6254b2eb3d325a5f38fe98583539008a180c20fd8190R1-R6).

### Miscellaneous Changes:
* Added `useTokenTracker` to `MainPage` component to initialize token
tracking (`frontend/webapp/app/(overview)/overview/page.tsx`)
[[1]](diffhunk://#diff-b5ae53796816cc6e1e253bb851d6a2fa404789188ea7964c716ee2ebcbcb776eL4-R4)
[[2]](diffhunk://#diff-b5ae53796816cc6e1e253bb851d6a2fa404789188ea7964c716ee2ebcbcb776eR13).
* Updated various components to use the new notification types and token
tracking logic (`frontend/webapp/components/main/header/index.tsx`,
`frontend/webapp/components/overview/all-drawers/cli-drawer.tsx`)
[[1]](diffhunk://#diff-2c96f91ec30d2116981a9c0a562820ff9fd87c8292cb5dca11a45d6fb2ac6c04L6-R6)
[[2]](diffhunk://#diff-2c96f91ec30d2116981a9c0a562820ff9fd87c8292cb5dca11a45d6fb2ac6c04L36-R36)
[[3]](diffhunk://#diff-2c96f91ec30d2116981a9c0a562820ff9fd87c8292cb5dca11a45d6fb2ac6c04L46-R46)
[[4]](diffhunk://#diff-05c3af50f20a5a1195555bcff0f9a44be51f03ad2f4cd080dff2a3b2ee6ababcL6-R7)
[[5]](diffhunk://#diff-05c3af50f20a5a1195555bcff0f9a44be51f03ad2f4cd080dff2a3b2ee6ababcL86-R96).

These changes collectively improve the robustness of the token
management system and enhance the user experience by providing timely
notifications about token statuses.

---

In header and in notification manager:
<img width="1715" alt="Screenshot 2025-01-13 at 11 30 30"
src="https://github.com/user-attachments/assets/f2f260e9-98f1-466a-8b22-e1c358750cf1"
/>

In CLI drawer:
<img width="400" alt="Screenshot 2025-01-13 at 11 30 39"
src="https://github.com/user-attachments/assets/1e31c11a-8c77-441a-8cd0-ea9656092ffb"
/>
  • Loading branch information
BenElferink authored Jan 13, 2025
1 parent a88cd13 commit 7cc914b
Show file tree
Hide file tree
Showing 15 changed files with 154 additions and 48 deletions.
3 changes: 2 additions & 1 deletion frontend/webapp/app/(overview)/overview/page.tsx
Original file line number Diff line number Diff line change
@@ -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 });
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions frontend/webapp/components/main/header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -33,7 +33,7 @@ const AlignRight = styled(FlexRow)`

export const MainHeader: React.FC<MainHeaderProps> = () => {
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');
Expand All @@ -43,7 +43,7 @@ export const MainHeader: React.FC<MainHeaderProps> = () => {
<AlignLeft>
<OdigosLogoText size={80} />
<PlatformTitle type={PlatformTypes.K8S} />
{!connecting && <ConnectionStatus title={title} subtitle={message} isActive={active} />}
{!sseConnecting && <ConnectionStatus title={title} subtitle={message} status={tokenExpired ? NOTIFICATION_TYPE.ERROR : tokenExpiring ? NOTIFICATION_TYPE.WARNING : sseStatus} />}
</AlignLeft>

<AlignRight>
Expand Down
13 changes: 11 additions & 2 deletions frontend/webapp/components/overview/all-drawers/cli-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -83,8 +83,17 @@ export const CliDrawer: React.FC<Props> = () => {
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 (
<Text size={14} color={isOverTime(expiresAt, SEVEN_DAYS_IN_MS) ? theme.text.error : theme.text.success}>
{timeAgo.format(expiresAt)} ({new Date(expiresAt).toDateString().split(' ').slice(1).join(' ')})
</Text>
);
},
},
{
columnKey: 'actions',
component: () => {
Expand Down
14 changes: 7 additions & 7 deletions frontend/webapp/hooks/notification/useSSE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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',
});
Expand All @@ -75,9 +75,9 @@ export const useSSE = () => {
}
};

setConnectionStore({
connecting: false,
active: true,
setSseStatus({
sseConnecting: false,
sseStatus: NOTIFICATION_TYPE.SUCCESS,
title: 'Connection Alive',
message: '',
});
Expand Down
1 change: 1 addition & 0 deletions frontend/webapp/hooks/tokens/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './useTokenCRUD';
export * from './useTokenTracker';
52 changes: 52 additions & 0 deletions frontend/webapp/hooks/tokens/useTokenTracker.ts
Original file line number Diff line number Diff line change
@@ -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 {};
};
51 changes: 33 additions & 18 deletions frontend/webapp/reuseable-components/status/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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'];
Expand All @@ -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'};
`;

Expand All @@ -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<StatusProps> = ({ 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<StatusProps> = ({ 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 (
<StatusWrapper $size={size} $isPale={isPale} $isActive={isActive} $withIcon={withIcon} $withBorder={withBorder} $withBackground={withBackground}>
{withIcon && <IconWrapper>{isPale && isActive ? <CheckCircledIcon size={size + 2} /> : isPale && !isActive ? <CrossCircledIcon size={size + 2} /> : <StatusIcon size={size + 2} />}</IconWrapper>}
<StatusWrapper $size={size} $isPale={isPale} $status={statusType} $withIcon={withIcon} $withBorder={withBorder} $withBackground={withBackground}>
{withIcon && (
<IconWrapper>
{isPale && statusType === NOTIFICATION_TYPE.SUCCESS ? (
<CheckCircledIcon size={size + 2} />
) : isPale && statusType === NOTIFICATION_TYPE.ERROR ? (
<CrossCircledIcon size={size + 2} />
) : (
<StatusIcon size={size + 2} />
)}
</IconWrapper>
)}

{(!!title || !!subtitle) && (
<TextWrapper>
{!!title && (
<Title size={size} family={family} $isPale={isPale} $isActive={isActive}>
<Title size={size} family={family} $isPale={isPale} $status={statusType}>
{title}
</Title>
)}

{!!subtitle && (
<TextWrapper>
<Divider orientation='vertical' length={`${size - 2}px`} type={isPale ? undefined : isActive ? NOTIFICATION_TYPE.SUCCESS : NOTIFICATION_TYPE.ERROR} />
<SubTitle size={size - 2} family={family} $isPale={isPale} $isActive={isActive}>
<Divider orientation='vertical' length={`${size - 2}px`} type={isPale ? undefined : statusType} />
<SubTitle size={size - 2} family={family} $isPale={isPale} $status={statusType}>
{subtitle}
</SubTitle>
</TextWrapper>
Expand Down
39 changes: 23 additions & 16 deletions frontend/webapp/store/useConnectionStore.ts
Original file line number Diff line number Diff line change
@@ -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<StoreStateValues & StoreStateSetters>((set) => ({
connecting: true,
active: false,
export const useConnectionStore = create<SseStateValues & TokenStateValues & StoreStateSetters>((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),
}));
3 changes: 2 additions & 1 deletion frontend/webapp/store/useNotificationStore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { isWithinTime } from '@/utils';
import type { Notification } from '@/types';

export type NotifyPayload = Omit<Notification, 'id' | 'dismissed' | 'seen' | 'time'>;
Expand All @@ -21,7 +22,7 @@ export const useNotificationStore = create<StoreState>((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) => ({
Expand Down
4 changes: 4 additions & 0 deletions frontend/webapp/styles/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions frontend/webapp/utils/constants/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './string';
export * from './urls';
export * from './programming-languages';
export * from './monitors';
export * from './numbers';
1 change: 1 addition & 0 deletions frontend/webapp/utils/constants/numbers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const SEVEN_DAYS_IN_MS = 604800000;
2 changes: 2 additions & 0 deletions frontend/webapp/utils/functions/resolvers/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -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;
};

0 comments on commit 7cc914b

Please sign in to comment.