diff --git a/app/api/log-metrics/route.ts b/app/api/log-metrics/route.ts new file mode 100644 index 00000000..6368c2e7 --- /dev/null +++ b/app/api/log-metrics/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' +import getReqAuthToken from '../../../utilities/getReqAuthToken' +import {fetchMetrics} from '../logs' + +export async function GET(req: Request) { + try { + const token = getReqAuthToken(req) + const data = await fetchMetrics(token) + return NextResponse.json(data) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch logs metrics' }, { status: 500 }) + } +} diff --git a/app/api/logs.ts b/app/api/logs.ts index 722c8526..9c1c2701 100644 --- a/app/api/logs.ts +++ b/app/api/logs.ts @@ -5,3 +5,25 @@ export const fetchLogMetrics = async (token: string) => fetchFromApi(`${backendUrl}/logs/metrics`, token) export const dismissLogAlert = async (token: string, index: string) => fetchFromApi(`${backendUrl}/logs/dismiss/${index}`, token) +export const fetchMetrics = async (token: string) => + fetchFromApi(`${backendUrl}/logs/log-metrics`, token) + +export interface fetchPriorityProps { + token: string + type?: string + limit?: string + order?: string + since?: string +} + +export const fetchPriorityLogs = async (props: fetchPriorityProps) => { + const { token, type, limit, order, since } = props + const params = new URLSearchParams() + + if (type) params.append('type', type) + if (limit) params.append('limit', limit) + if (order) params.append('order', order) + if (since) params.append('since', since) + + return await fetchFromApi(`${backendUrl}/logs/priority-logs?${params.toString()}`, token) +} diff --git a/app/api/priority-logs/route.ts b/app/api/priority-logs/route.ts index a27e7e9e..1652a0ca 100644 --- a/app/api/priority-logs/route.ts +++ b/app/api/priority-logs/route.ts @@ -1,11 +1,17 @@ import { NextResponse } from 'next/server' import getReqAuthToken from '../../../utilities/getReqAuthToken' -import { fetchLogMetrics } from '../logs' +import {fetchPriorityLogs} from '../logs' export async function GET(req: Request) { try { + const { searchParams } = new URL(req.url) + const type = searchParams.get('type') || undefined + const limit = searchParams.get('limit') || undefined + const order = searchParams.get('order') || undefined + const since = searchParams.get('since') || undefined + const token = getReqAuthToken(req) - const data = await fetchLogMetrics(token) + const data = await fetchPriorityLogs({token, type, limit, order, since}) return NextResponse.json(data) } catch (error) { return NextResponse.json({ error: 'Failed to fetch priority logs' }, { status: 500 }) diff --git a/app/dashboard/Main.tsx b/app/dashboard/Main.tsx index 1e5e99a5..706cb07c 100644 --- a/app/dashboard/Main.tsx +++ b/app/dashboard/Main.tsx @@ -17,7 +17,13 @@ import useLocalStorage from '../../src/hooks/useLocalStorage' import useNetworkMonitor from '../../src/hooks/useNetworkMonitor' import useSWRPolling from '../../src/hooks/useSWRPolling' import { exchangeRates, proposerDuties } from '../../src/recoil/atoms' -import { ActivityResponse, LogMetric, ProposerDuty, StatusColor } from '../../src/types' +import { + ActivityResponse, + LogData, + Metric, + ProposerDuty, + StatusColor +} from '../../src/types' import { BeaconNodeSpecResults, SyncData } from '../../src/types/beacon' import { Diagnostics, PeerDataResults } from '../../src/types/diagnostic' import { ValidatorCache, ValidatorInclusionData, ValidatorInfo } from '../../src/types/validator' @@ -35,8 +41,9 @@ export interface MainProps { initValCaches: ValidatorCache initInclusionRate: ValidatorInclusionData initProposerDuties: ProposerDuty[] - initLogMetrics: LogMetric initActivityData: ActivityResponse + initMetrics: Metric + initPriorityLogs: LogData[] } const Main: FC = (props) => { @@ -52,8 +59,9 @@ const Main: FC = (props) => { lighthouseVersion, genesisTime, initProposerDuties, - initLogMetrics, initActivityData, + initMetrics, + initPriorityLogs } = props const { t } = useTranslation() @@ -112,9 +120,9 @@ const Main: FC = (props) => { networkError, }) - const { data: logMetrics } = useSWRPolling('/api/priority-logs', { + const { data: metrics } = useSWRPolling('/api/log-metrics', { refreshInterval: slotInterval / 2, - fallbackData: initLogMetrics, + fallbackData: initMetrics, networkError, }) @@ -123,7 +131,7 @@ const Main: FC = (props) => { const { isReady } = executionSync const { connected } = peerData const { natOpen } = nodeHealth - const warningCount = logMetrics.warningLogs?.length || 0 + const warningCount = metrics.warningCount || 0 useEffect(() => { setDuties((prev) => formatUniqueObjectArray([...prev, ...valDuties])) @@ -252,7 +260,8 @@ const Main: FC = (props) => { /> ) } catch (e) { diff --git a/backend/package.json b/backend/package.json index f93f14e5..2a911ac0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -54,6 +54,7 @@ "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/cookie-parser": "^1.4.7", + "@types/eventsource": "^1.1.15", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", diff --git a/backend/src/activity/activity.controller.ts b/backend/src/activity/activity.controller.ts index 0eabbe8b..96c8157a 100644 --- a/backend/src/activity/activity.controller.ts +++ b/backend/src/activity/activity.controller.ts @@ -13,6 +13,7 @@ import { import { ActivityService } from './activity.service'; import { SessionGuard } from '../session.guard'; import { Request, Response } from 'express'; +import { KEEP_ALIVE_MESSAGE, SSE_HEADER } from '../../../src/constants/sse'; @Controller('activity') @UseGuards(SessionGuard) @@ -42,19 +43,14 @@ export class ActivityController { @Get('stream') sse(@Req() req: Request, @Res() res: Response) { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }); + res.writeHead(200, SSE_HEADER); res.flushHeaders(); this.activityService.addClient(res); const heartbeatInterval = setInterval(() => { - res.write(': keep-alive\n\n'); + res.write(KEEP_ALIVE_MESSAGE); }, 10000); req.on('close', () => { diff --git a/backend/src/activity/activity.service.ts b/backend/src/activity/activity.service.ts index e99f87b1..0c42a1c8 100644 --- a/backend/src/activity/activity.service.ts +++ b/backend/src/activity/activity.service.ts @@ -4,6 +4,7 @@ import { Activity } from './entities/activity.entity'; import { ActivityType } from '../../../src/types'; import { UpdateOptions, Op } from 'sequelize'; import { Response } from 'express'; +import { ClientManager } from '../utils/client-manager'; @Injectable() export class ActivityService { @@ -12,19 +13,18 @@ export class ActivityService { private activityRepository: typeof Activity, ) {} - private clients: Response[] = []; + private clientManager = new ClientManager(); public addClient(client: Response) { - this.clients.push(client); + this.clientManager.addClient(client); } public removeClient(client: Response) { - this.clients = this.clients.filter((c) => c !== client); + this.clientManager.removeClient(client); } public sendMessageToClients(data: any) { - const message = `data: ${JSON.stringify(data)}\n\n`; - this.clients.forEach((client) => client.write(message)); + this.clientManager.sendMessageToClients(data); } public async storeActivity(data: string, pubKey: string, type: ActivityType) { @@ -65,16 +65,6 @@ export class ActivityService { } : undefined; - if (whereClause) { - return { - count: await this.activityRepository.count(), - rows: await this.activityRepository.findAll({ - where: whereClause, - order: [['createdAt', orderQuery]], - }), - }; - } - return this.activityRepository.findAndCountAll({ limit: queryLimit === 0 ? undefined : queryLimit, offset: Number(offset) || 0, diff --git a/backend/src/logs/logs.controller.ts b/backend/src/logs/logs.controller.ts index fc10ae85..e5a678c6 100644 --- a/backend/src/logs/logs.controller.ts +++ b/backend/src/logs/logs.controller.ts @@ -1,8 +1,9 @@ -// src/logs/logs.controller.ts -import { Controller, Get, Res, Req, Param, UseGuards } from '@nestjs/common'; +import {Controller, Get, Res, Req, Param, UseGuards, Query} from '@nestjs/common'; import { Request, Response } from 'express'; import { LogsService } from './logs.service'; import { SessionGuard } from '../session.guard'; +import { KEEP_ALIVE_MESSAGE, SSE_HEADER } from '../../../src/constants/sse'; +import {LogType} from "../../../src/types"; @Controller('logs') @UseGuards(SessionGuard) @@ -26,6 +27,40 @@ export class LogsController { return this.logsService.readLogMetrics(); } + @Get('priority-logs') + getPriorityLogs( + @Query('type') type?: LogType, + @Query('limit') limit?: string, + @Query('order') order?: string, + @Query('since') since?: string, + ) { + return this.logsService.paginatedPriorityLogs(type, order, since, limit) + } + + @Get('log-metrics') + getMetrics() { + return this.logsService.readMetrics(); + } + + @Get('priority-log-stream') + sse(@Req() req: Request, @Res() res: Response) { + res.writeHead(200, SSE_HEADER); + + res.flushHeaders(); + + this.logsService.addClient(res); + + const heartbeatInterval = setInterval(() => { + res.write(KEEP_ALIVE_MESSAGE); + }, 10000); + + req.on('close', () => { + clearInterval(heartbeatInterval); + this.logsService.removeClient(res); + res.end(); + }); + } + @Get('dismiss/:index') dismissLogAlert(@Param('index') index: string) { return this.logsService.dismissLog(index); diff --git a/backend/src/logs/logs.service.ts b/backend/src/logs/logs.service.ts index d8ae12b8..c264269f 100644 --- a/backend/src/logs/logs.service.ts +++ b/backend/src/logs/logs.service.ts @@ -6,6 +6,7 @@ import { LogLevels, LogType, SSELog } from '../../../src/types'; import { InjectModel } from '@nestjs/sequelize'; import { Log } from './entities/log.entity'; import { Op } from 'sequelize'; +import { ClientManager } from '../utils/client-manager'; @Injectable() export class LogsService { @@ -20,6 +21,20 @@ export class LogsService { private sseStreams: Map> = new Map(); + private clientManager = new ClientManager(); + + public addClient(client: Response) { + this.clientManager.addClient(client); + } + + public removeClient(client: Response) { + this.clientManager.removeClient(client); + } + + public sendMessageToClients(data: any) { + this.clientManager.sendMessageToClients(data); + } + public async startSse(url: string, type: LogType) { console.log(`starting sse ${url}, ${type}...`); const eventSource = new EventSource(url); @@ -27,7 +42,7 @@ export class LogsService { const sseStream: Subject = new Subject(); this.sseStreams.set(url, sseStream); - eventSource.onmessage = (event) => { + eventSource.onmessage = async (event) => { let newData; try { @@ -39,10 +54,15 @@ export class LogsService { const { level } = newData; if (level !== LogLevels.INFO) { - this.logRepository.create( + const result = (await this.logRepository.create( { type, level, data: JSON.stringify(newData), isHidden: false }, { ignoreDuplicates: true }, - ); + )) as any; + + if(level === LogLevels.ERRO || level === LogLevels.CRIT) { + this.sendMessageToClients(result.dataValues); + } + if (this.isDebug) { console.log( newData, @@ -117,6 +137,97 @@ export class LogsService { }; } + async readMetrics(type?: LogType) { + if (type && !this.logTypes.includes(type)) { + throw new Error('Invalid log type'); + } + + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + + const warnOptions: any = { + where: { + level: LogLevels.WARN, + createdAt: { + [Op.gte]: oneHourAgo, + }, + }, + }; + const errorOptions: any = { + where: { + level: LogLevels.ERRO, + createdAt: { + [Op.gte]: oneHourAgo, + }, + }, + }; + const critOptions: any = { + where: { + level: LogLevels.CRIT, + createdAt: { + [Op.gte]: oneHourAgo, + }, + }, + }; + + if (type) { + warnOptions.where.type = { [Op.eq]: type }; + errorOptions.where.type = { [Op.eq]: type }; + critOptions.where.type = { [Op.eq]: type }; + } + + const [warningCount, errorCount, criticalCount] = await Promise.all([ + this.logRepository.count(warnOptions), + this.logRepository.count(errorOptions), + this.logRepository.count(critOptions), + ]); + + return { + warningCount, + errorCount, + criticalCount, + }; + + + } + + public async paginatedPriorityLogs( + type?: LogType, + order?: string | undefined, + since?: string | undefined, + limit?: string | undefined, + ) { + let orderQuery = order?.toUpperCase(); + const queryLimit = limit ? Number(limit) : 16; + + if (orderQuery !== 'ASC' && orderQuery !== 'DESC') { + orderQuery = 'DESC'; + } + + // Build a single where object + const whereClause: any = {}; + + // If we have a 'since' timestamp, fetch only items older than that + if (since) { + whereClause.createdAt = { + [Op.lt]: new Date(since), + }; + } + + // If 'type' is provided, match that type + if (type) { + whereClause.type = type; + } + + whereClause.level = { [Op.in]: [LogLevels.CRIT, LogLevels.ERRO] }; + whereClause.isHidden = false + + return this.logRepository.findAll({ + limit: queryLimit === 0 ? undefined : queryLimit, + where: whereClause, + order: [['createdAt', orderQuery]], + }); + } + async dismissLog(id: string) { return await this.logRepository.update( { isHidden: true }, diff --git a/backend/src/tasks/tasks.service.ts b/backend/src/tasks/tasks.service.ts index b9db8876..fd1ddd87 100644 --- a/backend/src/tasks/tasks.service.ts +++ b/backend/src/tasks/tasks.service.ts @@ -60,6 +60,10 @@ export class TasksService implements OnApplicationBootstrap { throw new CustomError('No api token found...', 'NO_API_TOKEN'); } + await this.logRepository.destroy({ + truncate: true + }); + await this.syncBeaconSpecs(); await this.initValidatorDataScheduler(); await this.initMetricDataScheduler(); diff --git a/backend/src/utils/client-manager.ts b/backend/src/utils/client-manager.ts new file mode 100644 index 00000000..66aabaf3 --- /dev/null +++ b/backend/src/utils/client-manager.ts @@ -0,0 +1,18 @@ +import { Response } from 'express'; + +export class ClientManager { + private clients: Response[] = []; + + public addClient(client: Response): void { + this.clients.push(client); + } + + public removeClient(client: Response): void { + this.clients = this.clients.filter((c) => c !== client); + } + + public sendMessageToClients(data: any): void { + const message = `data: ${JSON.stringify(data)}\n\n`; + this.clients.forEach((client) => client.write(message)); + } +} diff --git a/backend/yarn.lock b/backend/yarn.lock index a2824a9a..3608ea1b 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1068,6 +1068,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== +"@types/eventsource@^1.1.15": + version "1.1.15" + resolved "https://registry.yarnpkg.com/@types/eventsource/-/eventsource-1.1.15.tgz#949383d3482e20557cbecbf3b038368d94b6be27" + integrity sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA== + "@types/express-serve-static-core@^4.17.33": version "4.19.0" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz#3ae8ab3767d98d0b682cda063c3339e1e86ccfaa" @@ -6049,6 +6054,7 @@ wkx@^0.5.0: "@types/node" "*" "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + name wrap-ansi-cjs version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== diff --git a/package.json b/package.json index f544d274..0b251bf9 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@types/jest": "^29.5.12", "@types/js-cookie": "^3.0.6", "@types/jsonwebtoken": "^9.0.6", - "@types/react": "18.3.12", + "@types/react": "19.0.2", "@uiw/react-textarea-code-editor": "^2.1.1", "autoprefixer": "^10.4.19", "axios": "^0.27.2", @@ -85,7 +85,7 @@ ] }, "devDependencies": { - "@types/node": "22.10.1", + "@types/node": "22.10.2", "@typescript-eslint/eslint-plugin": "^7.1.0", "eslint": "8.57.0", "eslint-config-next": "^15.0.3", diff --git a/siren.js b/siren.js index b67de0b3..5809c10f 100644 --- a/siren.js +++ b/siren.js @@ -62,6 +62,9 @@ const handleSSe = (res, req, url) => { app.prepare().then(() => { const server = express() server.get('/activity-stream', (req, res) => handleSSe(res, req, `${backendUrl}/activity/stream`)) + server.get('/priority-log-stream', (req, res) => + handleSSe(res, req, `${backendUrl}/logs/priority-log-stream`), + ) server.get('/validator-logs', (req, res) => handleSSe(res, req, `${backendUrl}/logs/validator`)) server.get('/beacon-logs', (req, res) => handleSSe(res, req, `${backendUrl}/logs/beacon`)) diff --git a/src/components/AlertInfo/AlertInfo.tsx b/src/components/AlertInfo/AlertInfo.tsx index 585440e5..122d2b23 100644 --- a/src/components/AlertInfo/AlertInfo.tsx +++ b/src/components/AlertInfo/AlertInfo.tsx @@ -6,17 +6,18 @@ import useDiagnosticAlerts from '../../hooks/useDiagnosticAlerts' import useDivDimensions from '../../hooks/useDivDimensions' import useMediaQuery from '../../hooks/useMediaQuery' import { proposerDuties } from '../../recoil/atoms' -import { LogLevels, StatusColor } from '../../types' +import {LogData, StatusColor} from '../../types' import AlertCard from '../AlertCard/AlertCard' import AlertFilterSettings, { FilterValue } from '../AlertFilterSettings/AlertFilterSettings' -import { LogsInfoProps } from '../DiagnosticTable/LogsInfo' import ProposerAlerts, { ProposerAlertsProps } from '../ProposerAlerts/ProposerAlerts' import Typography from '../Typography/Typography' -import PriorityLogAlerts from './PriorityLogAlerts' +import PriorityLogAlerts from "./PriorityLogAlerts"; -export interface AlertInfoProps extends Omit, LogsInfoProps {} +export interface AlertInfoProps extends Omit { + priorityLogs: LogData[] +} -const AlertInfo: FC = ({ metrics, ...props }) => { +const AlertInfo: FC = ({ priorityLogs, ...props }) => { const { t } = useTranslation() const { alerts, dismissAlert, resetDismissed } = useDiagnosticAlerts() const { ref, dimensions } = useDivDimensions() @@ -24,13 +25,6 @@ const AlertInfo: FC = ({ metrics, ...props }) => { const [filter, setFilter] = useState('all') const duties = useRecoilValue(proposerDuties) - const priorityLogAlerts = useMemo(() => { - return Object.values(metrics) - .flat() - .filter(({ level }) => level === LogLevels.CRIT || level === LogLevels.ERRO) - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - }, [metrics]) - const setFilterValue = (value: FilterValue) => setFilter(value) const isMobile = useMediaQuery('(max-width: 425px)') @@ -47,8 +41,8 @@ const AlertInfo: FC = ({ metrics, ...props }) => { const isSeverFilter = filter === 'all' || filter === StatusColor.ERROR const isFiller = - formattedAlerts.length + (duties?.length || 0) + (priorityLogAlerts.length || 0) < 6 - const isPriorityAlerts = priorityLogAlerts.length > 0 + formattedAlerts.length + (duties?.length || 0) + (priorityLogs.length || 0) < 6 + const isPriorityAlerts = priorityLogs.length > 0 const isAlerts = formattedAlerts.length > 0 || duties?.length > 0 || isPriorityAlerts const isProposerAlerts = duties?.length > 0 && (filter === 'all' || filter === StatusColor.SUCCESS) @@ -86,7 +80,7 @@ const AlertInfo: FC = ({ metrics, ...props }) => { {isAlerts && (
{isPriorityAlerts && isSeverFilter && ( - + )} {formattedAlerts.map((alert) => { const { severity, subText, message, id } = alert diff --git a/src/components/AlertInfo/PriorityLogAlerts.tsx b/src/components/AlertInfo/PriorityLogAlerts.tsx index 52040323..27e2b8e5 100644 --- a/src/components/AlertInfo/PriorityLogAlerts.tsx +++ b/src/components/AlertInfo/PriorityLogAlerts.tsx @@ -1,10 +1,14 @@ import axios from 'axios' import moment from 'moment' -import { FC, useEffect, useMemo, useState } from 'react' +import { FC, useEffect, useMemo, useState, useCallback } from 'react' import { useTranslation } from 'react-i18next' import displayToast from '../../../utilities/displayToast' +import { FETCH_LOG_LIMIT } from '../../constants/constants' +import useSSEData from '../../hooks/useSSEData' import { LogData, StatusColor, ToastType } from '../../types' import AlertCard from '../AlertCard/AlertCard' +import Spinner from '../Spinner/Spinner' +import Typography from '../Typography/Typography' export interface LogAlertsProps { alerts: LogData[] @@ -12,55 +16,114 @@ export interface LogAlertsProps { const PriorityLogAlerts: FC = ({ alerts }) => { const { t } = useTranslation() + const [data, setData] = useState(alerts) + const [hasMoreLogs, setHasMoreLogs] = useState(alerts.length >= FETCH_LOG_LIMIT) + const [isLoading, setIsLoading] = useState(false) - const [data, setData] = useState(alerts) + const { data: streamedData } = useSSEData({ + url: '/priority-log-stream', + isReady: true, + isStateStore: true, + }) + + useEffect(() => { + if(!streamedData?.length) return + + setData((prev) => { + const combined = [...prev, ...streamedData] + return Array.from(new Map(combined.map((item) => [item.id, item])).values()) + }) + }, [streamedData]) useEffect(() => { - setData(alerts) + if (alerts.length >= FETCH_LOG_LIMIT) { + setHasMoreLogs(true) + } }, [alerts]) - const visibleAlerts = useMemo(() => { - return data.filter(({ isHidden }) => !isHidden) + const visibleOrderedAlerts = useMemo(() => { + return data + .filter(({ isHidden }) => !isHidden) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .map((alert) => ({ + ...alert, + data: JSON.parse(alert.data), + fromNowStamp: moment(alert.createdAt).fromNow(), + })) }, [data]) - const dismissAlert = async (id: number) => { + const dismissAlert = useCallback(async (id: number) => { try { - const { status } = await axios.put(`/api/dismiss-log/${id}`, undefined) + const { status } = await axios.put(`/api/dismiss-log/${id}`) - if (status === 200) { - setData((prev) => { - let log = prev.find((alert) => alert.id === id) + if(status !== 200) return - if (!log) { - return prev - } + setData((prev) => + prev.map((alert) => (alert.id === id ? { ...alert, isHidden: true } : alert)) + ) + displayToast(t('alertMessages.dismiss.success'), ToastType.SUCCESS) + } catch (error) { + console.error('Error updating log:', error) + displayToast(t('alertMessages.dismiss.error'), ToastType.ERROR) + } + }, [t]) - log.isHidden = true + const fetchOlderLogs = useCallback(async () => { + setIsLoading(true) + try { + const oldestLog = data.reduce((oldest, current) => + new Date(current.createdAt) < new Date(oldest.createdAt) ? current : oldest + ) + const oldestLogDate = oldestLog.createdAt - return [...prev.filter((alert) => alert.id !== id), log] as LogData[] - }) - displayToast(t('alertMessages.dismiss.success'), ToastType.SUCCESS) + const { data: fetchedData } = await axios.get(`/api/priority-logs?since=${oldestLogDate}`) + + const count = fetchedData?.length + + if(!count) return + + if (count < FETCH_LOG_LIMIT) { + setHasMoreLogs(false) } - } catch (e) { - console.error('error updating log...') - displayToast(t('alertMessages.dismiss.error'), ToastType.ERROR) + setData((prev) => { + const combined = [...prev, ...fetchedData] + return Array.from(new Map(combined.map((item) => [item.id, item])).values()) + }) + } catch (error) { + console.error('Error fetching older logs:', error) + } finally { + setIsLoading(false) } - } - - return visibleAlerts.map(({ id, data, createdAt }) => { - const alertData = JSON.parse(data) - const date = moment(createdAt).fromNow() - return ( - dismissAlert(id)} - subText={t('poor')} - text={`${alertData.msg} ${date}`} - /> - ) - }) + }, [data]) + + return ( + <> + {visibleOrderedAlerts.map(({ id, data: alertData, fromNowStamp }) => ( + dismissAlert(id)} + subText={t('poor')} + text={`${alertData.msg} ${fromNowStamp}`} + /> + ))} + {hasMoreLogs && ( +
+ {isLoading ? ( + + ) : ( + + {t('fetchMoreLogAlerts')} + + )} +
+ )} + + ) } export default PriorityLogAlerts diff --git a/src/components/DiagnosticTable/DiagnosticTable.tsx b/src/components/DiagnosticTable/DiagnosticTable.tsx index eccca47a..76d7dc4a 100644 --- a/src/components/DiagnosticTable/DiagnosticTable.tsx +++ b/src/components/DiagnosticTable/DiagnosticTable.tsx @@ -5,12 +5,12 @@ import LogsInfo, { LogsInfoProps } from './LogsInfo' export interface DiagnosticTableProps extends HardwareInfoProps, AlertInfoProps, LogsInfoProps {} -const DiagnosticTable: FC = ({ syncData, beanHealth, metrics, bnSpec }) => { +const DiagnosticTable: FC = ({ syncData, beanHealth, logMetrics, bnSpec, priorityLogs }) => { return (
- - + +
) } diff --git a/src/components/DiagnosticTable/LogsInfo.tsx b/src/components/DiagnosticTable/LogsInfo.tsx index b693d195..0631ef76 100644 --- a/src/components/DiagnosticTable/LogsInfo.tsx +++ b/src/components/DiagnosticTable/LogsInfo.tsx @@ -2,15 +2,15 @@ import Link from 'next/link' import { FC } from 'react' import { useTranslation } from 'react-i18next' import useMediaQuery from '../../hooks/useMediaQuery' -import { LogMetric } from '../../types' +import { Metric} from '../../types' import LogStats from '../LogStats/LogStats' import Typography from '../Typography/Typography' export interface LogsInfoProps { - metrics: LogMetric + logMetrics: Metric } -const LogsInfo: FC = ({ metrics }) => { +const LogsInfo: FC = ({ logMetrics }) => { const { t } = useTranslation() const isMobile = useMediaQuery('(max-width: 425px)') const size = isMobile ? 'health' : 'md' @@ -38,7 +38,7 @@ const LogsInfo: FC = ({ metrics }) => { errorToolTip={t('logs.tooltips.combinedError')} warnToolTip={t('logs.tooltips.combinedWarning')} size={size} - metrics={metrics} + logMetrics={logMetrics} />
) diff --git a/src/components/LogStats/LogStats.tsx b/src/components/LogStats/LogStats.tsx index 2a2e5fd3..b080f870 100644 --- a/src/components/LogStats/LogStats.tsx +++ b/src/components/LogStats/LogStats.tsx @@ -1,59 +1,42 @@ -import { FC, useMemo } from 'react' +import { FC } from 'react' import { useTranslation } from 'react-i18next' -import timeFilterObjArray from '../../../utilities/timeFilterObjArray' import toFixedIfNecessary from '../../../utilities/toFixedIfNecessary' -import { LogMetric, StatusColor } from '../../types' +import { Metric, StatusColor} from '../../types' import DiagnosticCard, { CardSize } from '../DiagnosticCard/DiagnosticCard' export interface LogStatsProps { critToolTip?: string warnToolTip?: string errorToolTip?: string - metrics: LogMetric + logMetrics: Metric size?: CardSize maxHeight?: string maxWidth?: string } const LogStats: FC = ({ - metrics, size, maxHeight = 'flex-1', maxWidth, critToolTip, warnToolTip, errorToolTip, + logMetrics }) => { const { t } = useTranslation() - const { criticalLogs, warningLogs, errorLogs } = metrics + const { errorCount, criticalCount, warningCount } = logMetrics - const hourlyCriticalLogs = useMemo(() => { - return timeFilterObjArray(criticalLogs, 'createdAt', 'minutes', 60) - }, [criticalLogs]) - - const hourlyWarningLogs = useMemo(() => { - return timeFilterObjArray(warningLogs, 'createdAt', 'minutes', 60) - }, [warningLogs]) - - const hourlyErrorLogs = useMemo(() => { - return timeFilterObjArray(errorLogs, 'createdAt', 'minutes', 60) - }, [errorLogs]) - - const criticalMetrics = hourlyCriticalLogs.length - const warningMetrics = hourlyWarningLogs.length - const errorMetrics = hourlyErrorLogs.length - - const critStatus = criticalMetrics > 0 ? StatusColor.ERROR : StatusColor.SUCCESS + const critStatus = criticalCount > 0 ? StatusColor.ERROR : StatusColor.SUCCESS const errorStatus = - errorMetrics <= 0 + errorCount <= 0 ? StatusColor.SUCCESS - : errorMetrics <= 2 + : errorCount <= 2 ? StatusColor.WARNING : StatusColor.ERROR const warnStatus = - warningMetrics < 5 + warningCount < 5 ? StatusColor.SUCCESS - : warningMetrics <= 50 + : warningCount <= 50 ? StatusColor.WARNING : StatusColor.ERROR @@ -68,7 +51,7 @@ const LogStats: FC = ({ size={size} border='border-t-0 md:border-l-0 border-style500' subTitle={t('critical')} - metric={`${toFixedIfNecessary(criticalMetrics, 2)} / HR`} + metric={`${toFixedIfNecessary(criticalCount, 2)} / HR`} /> = ({ size={size} border='border-t-0 md:border-l-0 border-style500' subTitle={t('logInfo.validatorLogs')} - metric={`${toFixedIfNecessary(errorMetrics, 2)} / HR`} + metric={`${toFixedIfNecessary(errorCount, 2)} / HR`} /> = ({ size={size} border='border-t-0 md:border-l-0 border-style500' subTitle={t('logInfo.validatorLogs')} - metric={`${toFixedIfNecessary(warningMetrics, 2)} / HR`} + metric={`${toFixedIfNecessary(warningCount, 2)} / HR`} /> ) diff --git a/src/constants/constants.ts b/src/constants/constants.ts index 0ee8c3d0..01b1989a 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -162,4 +162,5 @@ export const ALERT_ID = { export const DEVICE_NAME_TRUNCATE = 10 export const EFFECTIVE_BALANCE = 32 +export const FETCH_LOG_LIMIT = 15 export const CONSOLIDATION_CONTRACT = '0x01aBEa29659e5e97C95107F20bb753cD3e09bBBb' diff --git a/src/constants/sse.ts b/src/constants/sse.ts new file mode 100644 index 00000000..333c6895 --- /dev/null +++ b/src/constants/sse.ts @@ -0,0 +1,8 @@ +export const SSE_HEADER = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', +} + +export const KEEP_ALIVE_MESSAGE = ': keep-alive\n\n' diff --git a/src/locales/translations/en-US.json b/src/locales/translations/en-US.json index a4e2764c..f84ddf8e 100644 --- a/src/locales/translations/en-US.json +++ b/src/locales/translations/en-US.json @@ -74,6 +74,7 @@ "viewDisclosures": "view important disclosures", "learnMore": "Learn More", "tooManyValidatorWarning": "It is not recommended to add this many validators at once. You can more when finished with the ones below.", + "fetchMoreLogAlerts": "Load Older Log Alerts", "disclosure": { "healthCheck": { "title": "Health Check Disclosure", diff --git a/src/types/index.ts b/src/types/index.ts index 08987e4c..ba83bef6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -50,6 +50,12 @@ export type LogMetric = { criticalLogs: LogData[] } +export type Metric = { + warningCount: number + errorCount: number + criticalCount: number +} + export enum LogType { VALIDATOR = 'VALIDATOR', BEACON = 'BEACON', diff --git a/yarn.lock b/yarn.lock index 355efca9..952cc6a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2605,13 +2605,20 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== -"@types/node@*", "@types/node@22.10.1": +"@types/node@*": version "22.10.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.1.tgz#41ffeee127b8975a05f8c4f83fb89bcb2987d766" integrity sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ== dependencies: undici-types "~6.20.0" +"@types/node@22.10.2": + version "22.10.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9" + integrity sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ== + dependencies: + undici-types "~6.20.0" + "@types/node@22.7.5": version "22.7.5" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.7.5.tgz#cfde981727a7ab3611a481510b473ae54442b92b" @@ -2641,7 +2648,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@18.3.12": +"@types/react@*": version "18.3.12" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.12.tgz#99419f182ccd69151813b7ee24b792fe08774f60" integrity sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw== @@ -2649,6 +2656,13 @@ "@types/prop-types" "*" csstype "^3.0.2" +"@types/react@19.0.2": + version "19.0.2" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.2.tgz#9363e6b3ef898c471cb182dd269decc4afc1b4f6" + integrity sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg== + dependencies: + csstype "^3.0.2" + "@types/stack-utils@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8"