Skip to content
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

[GEN-2187]: notify in UI about expiring/expired token #2208

Merged
merged 5 commits into from
Jan 13, 2025
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
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;
};
Loading