diff --git a/src/client/components/monitor/StatusPage/Body.tsx b/src/client/components/monitor/StatusPage/Body.tsx index 2d1eb48..1d0f507 100644 --- a/src/client/components/monitor/StatusPage/Body.tsx +++ b/src/client/components/monitor/StatusPage/Body.tsx @@ -2,6 +2,7 @@ import { AppRouterOutput, trpc } from '@/api/trpc'; import React, { useMemo, useReducer } from 'react'; import { bodySchema } from './schema'; import { Empty } from 'antd'; +import { Separator } from '@/components/ui/separator'; import { useTranslation } from '@i18next-toolkit/react'; import { cn } from '@/utils/style'; import { @@ -36,12 +37,13 @@ export const StatusPageBody: React.FC = React.memo( }, [info.body]); return ( -
+
{body.groups.map((group) => ( -
-
{group.title}
- -
+
+
+ {group.title} +
+
{group.children.length === 0 && ( )} @@ -49,12 +51,15 @@ export const StatusPageBody: React.FC = React.memo( {group.children.map((item) => { if (item.type === 'monitor') { return ( - + <> + + + ); } @@ -121,7 +126,7 @@ export const StatusItemMonitor: React.FC<{
diff --git a/src/client/components/monitor/StatusPage/StatusHeader.tsx b/src/client/components/monitor/StatusPage/StatusHeader.tsx new file mode 100644 index 0000000..189d0e3 --- /dev/null +++ b/src/client/components/monitor/StatusPage/StatusHeader.tsx @@ -0,0 +1,174 @@ +import React, { useMemo } from 'react'; +import { cn } from '@/utils/style'; +import { bodySchema } from './schema'; +import { LuCheckCircle2, LuCircleSlash, LuAlertCircle } from 'react-icons/lu'; +import { trpc } from '../../../api/trpc'; +import { getMonitorProvider, getProviderDisplay } from '../provider'; +import { takeRight, last } from 'lodash-es'; +import dayjs from 'dayjs'; + +export const StatusPageHeader = ({ info, workspaceId }) => { + const body = useMemo(() => { + const res = bodySchema.safeParse(info.body); + return res.success ? res.data : { groups: [] }; + }, [info.body]); + const monitorContexts = useMemo(() => { + const contexts = []; + body.groups.forEach((group) => { + group.children.forEach((item) => { + if (item.type === 'monitor') { + contexts.push({ + id: item.id, + groupId: group.id, + groupName: group.name, + }); + } + }); + }); + if (Array.isArray(info.monitorList)) { + info.monitorList.forEach((monitor) => { + contexts.push({ + id: monitor.id, + groupId: 'deprecated', + groupName: 'Legacy Monitors', + }); + }); + } + return contexts; + }, [body, info.monitorList]); + + const recentDataQueries = monitorContexts.map((context) => { + const { data: recentData = [] } = trpc.monitor.recentData.useQuery({ + workspaceId, + monitorId: context.id, + take: 1, + }); + + const items = useMemo(() => { + return takeRight( + [...Array.from({ length: 1 }).map(() => null), ...recentData], + 1 + ); + }, [recentData]); + + const provider = useMemo( + () => getMonitorProvider(context.id), + [context.id] + ); + + const latestStatus = useMemo(() => { + const latestItem = last(items); + if (!latestItem) return 'none'; + + const { value, createdAt } = latestItem; + const { text } = getProviderDisplay(value, provider); + const title = `${dayjs(createdAt).format('YYYY-MM-DD HH:mm')} | ${text}`; + return value < 0 + ? { status: 'error', title } + : { status: 'health', title }; + }, [items, provider]); + + return { + id: context.id, + status: latestStatus.status, + timestamp: last(items)?.createdAt, + }; + }); + + const { overallStatus, servicesCount, lastChecked } = useMemo(() => { + let totalCount = 0; + let errorCount = 0; + let latestTimestamp = null; + + recentDataQueries.forEach((query) => { + if (!query) return; + + totalCount += 1; + + if (query.status != 'health') { + errorCount += 1; + } + + if ( + !latestTimestamp || + (query.timestamp && query.timestamp > latestTimestamp) + ) { + latestTimestamp = query.timestamp; + } + }); + + let status = 'unknown'; + let uprate = ((totalCount - errorCount) / totalCount) * 100; + + if (uprate > 90) { + status = 'operational'; + } else if (uprate > 50) { + status = 'degraded'; + } else if (uprate > 0) { + status = 'offline'; + } + + return { + overallStatus: status, + servicesCount: totalCount, + lastChecked: latestTimestamp, + }; + }, [recentDataQueries]); + + const statusConfig = { + operational: { + text: 'All Systems Operational', + icon: LuCheckCircle2, + iconColor: 'text-green-500', + }, + degraded: { + text: 'Partial System Outage', + icon: LuAlertCircle, + iconColor: 'text-yellow-500', + }, + offline: { + text: 'Major System Outage', + icon: LuCircleSlash, + iconColor: 'text-red-500', + }, + unknown: { + text: 'Status Unknown', + icon: LuAlertCircle, + iconColor: 'text-gray-500', + }, + }; + + const config = statusConfig[overallStatus]; + const StatusIcon = config.icon; + + const formatDate = (date) => { + const options = { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZone: 'UTC', + hour12: true, + }; + const formatted = new Date(date).toLocaleString('en-US', options); + const [monthDay, time] = formatted.split(','); + return `Last updated on ${monthDay} at${time} UTC`; + }; + + return ( +
+
+ ); +}; + +export default StatusPageHeader; diff --git a/src/client/components/monitor/StatusPage/index.tsx b/src/client/components/monitor/StatusPage/index.tsx index f0cf848..39389f0 100644 --- a/src/client/components/monitor/StatusPage/index.tsx +++ b/src/client/components/monitor/StatusPage/index.tsx @@ -9,6 +9,7 @@ import clsx from 'clsx'; import { useRequest } from '../../../hooks/useRequest'; import { ColorSchemeSwitcher } from '../../ColorSchemeSwitcher'; import { StatusPageServices } from './Services'; +import { StatusPageHeader } from './StatusHeader'; import { useTranslation } from '@i18next-toolkit/react'; import { Link, useNavigate } from '@tanstack/react-router'; import { Helmet } from 'react-helmet'; @@ -146,8 +147,14 @@ export const MonitorStatusPage: React.FC = React.memo(
)} + {info && ( +
+ +
+ )} + {/* Desc */} -
+