Compare commits

...

5 Commits

Author SHA1 Message Date
moonrailgun
ee192d9686 feat: add click event for status page item which allow hide/show chart 2024-10-14 00:28:09 +08:00
moonrailgun
3d2971bce2 feat: add daily monitor data display for public 2024-10-14 00:24:16 +08:00
moonrailgun
1a012bb129 feat: add MonitorLatestResponse and up status summary 2024-10-13 23:19:40 +08:00
moonrailgun
6112642ad9 refactor: change public summary display logic
old is recent data, now is monthly data
2024-10-13 22:38:07 +08:00
moonrailgun
f9984a76fb feat: add monitor summary function 2024-10-13 21:55:58 +08:00
12 changed files with 543 additions and 73 deletions

View File

@ -1,10 +1,9 @@
import { useResizeObserver } from '@/hooks/useResizeObserver'; import { useResizeObserver } from '@/hooks/useResizeObserver';
import { getStatusBgColorClassName, HealthStatus } from '@/utils/health';
import { cn } from '@/utils/style'; import { cn } from '@/utils/style';
import clsx from 'clsx'; import clsx from 'clsx';
import React from 'react'; import React from 'react';
type HealthStatus = 'health' | 'error' | 'warning' | 'none';
export interface HealthBarBeat { export interface HealthBarBeat {
title?: string; title?: string;
status: HealthStatus; status: HealthStatus;
@ -52,12 +51,7 @@ export const HealthBar: React.FC<HealthBarProps> = React.memo((props) => {
'h-4 w-[5px]': size === 'small', 'h-4 w-[5px]': size === 'small',
'h-8 w-2': size === 'large', 'h-8 w-2': size === 'large',
}, },
{ getStatusBgColorClassName(beat.status)
'bg-green-500': beat.status === 'health',
'bg-red-600': beat.status === 'error',
'bg-yellow-400': beat.status === 'warning',
'bg-gray-400': beat.status === 'none',
}
)} )}
/> />
))} ))}

View File

@ -0,0 +1,49 @@
import { useTheme } from '@/hooks/useTheme';
import { get } from 'lodash-es';
import React from 'react';
import { useMemo } from 'react';
import { Rectangle } from 'recharts';
export const CustomizedErrorArea: React.FC = (props) => {
const { colors } = useTheme();
const y = get(props, 'offset.top', 10);
const height = get(props, 'offset.height', 160);
const points = get(props, 'formattedGraphicalItems.0.props.points', []) as {
x: number;
y: number | null;
}[];
const errorArea = useMemo(() => {
const _errorArea: { x: number; width: number }[] = [];
let prevX: number | null = null;
points.forEach((item, i, arr) => {
if (i === 0 && !item.y) {
prevX = 0;
} else if (!item.y && prevX === null && arr[i - 1].y) {
prevX = arr[i - 1].x;
} else if (item.y && prevX !== null) {
_errorArea.push({
x: prevX,
width: item.x - prevX,
});
prevX = null;
}
});
return _errorArea;
}, [points]);
return errorArea.map((area, i) => {
return (
<Rectangle
key={i}
width={area.width}
height={height}
x={area.x}
y={y}
fill={colors.chart.error}
/>
);
});
};
CustomizedErrorArea.displayName = 'CustomizedErrorArea';

View File

@ -195,47 +195,3 @@ export const MonitorDataChart: React.FC<{ monitorId: string }> = React.memo(
} }
); );
MonitorDataChart.displayName = 'MonitorDataChart'; MonitorDataChart.displayName = 'MonitorDataChart';
const CustomizedErrorArea: React.FC = (props) => {
const { colors } = useTheme();
const y = get(props, 'offset.top', 10);
const height = get(props, 'offset.height', 160);
const points = get(props, 'formattedGraphicalItems.0.props.points', []) as {
x: number;
y: number | null;
}[];
const errorArea = useMemo(() => {
const _errorArea: { x: number; width: number }[] = [];
let prevX: number | null = null;
points.forEach((item, i, arr) => {
if (i === 0 && !item.y) {
prevX = 0;
} else if (!item.y && prevX === null && arr[i - 1].y) {
prevX = arr[i - 1].x;
} else if (item.y && prevX !== null) {
_errorArea.push({
x: prevX,
width: item.x - prevX,
});
prevX = null;
}
});
return _errorArea;
}, [points]);
return errorArea.map((area, i) => {
return (
<Rectangle
key={i}
width={area.width}
height={height}
x={area.x}
y={y}
fill={colors.chart.error}
/>
);
});
};
CustomizedErrorArea.displayName = 'CustomizedErrorArea';

View File

@ -0,0 +1,146 @@
import dayjs from 'dayjs';
import { get } from 'lodash-es';
import React, { useMemo } from 'react';
import { trpc } from '../../api/trpc';
import { getMonitorProvider, getProviderDisplay } from './provider';
import { useTranslation } from '@i18next-toolkit/react';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '../ui/chart';
import {
Area,
AreaChart,
CartesianGrid,
Customized,
XAxis,
YAxis,
} from 'recharts';
import { useTheme } from '@/hooks/useTheme';
import { CustomizedErrorArea } from './CustomizedErrorArea';
const chartConfig = {
value: {
label: <span className="text-sm font-bold">Result</span>,
},
} satisfies ChartConfig;
interface MonitorPublicDataChartProps {
workspaceId: string;
monitorId: string;
className?: string;
}
export const MonitorPublicDataChart: React.FC<MonitorPublicDataChartProps> =
React.memo((props) => {
const { t } = useTranslation();
const { workspaceId, monitorId } = props;
const { colors } = useTheme();
const { data: monitorInfo } = trpc.monitor.getPublicInfo.useQuery(
{
monitorIds: [monitorId],
},
{
select(data) {
return data[0];
},
}
);
const { data: _data = [] } = trpc.monitor.publicData.useQuery({
workspaceId,
monitorId,
});
const providerInfo = getMonitorProvider(monitorInfo?.type ?? '');
const { data } = useMemo(() => {
const data = _data.map((d, i, arr) => {
const value = d.value > 0 ? d.value : null;
const time = dayjs(d.createdAt).valueOf();
return {
value,
time,
};
});
return { data };
}, [_data]);
const isTrendingMode = monitorInfo?.trendingMode ?? false; // if true, y axis not start from 0
return (
<div>
<ChartContainer className="h-[120px] w-full" config={chartConfig}>
<AreaChart
data={data}
margin={{ top: 10, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="color" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor={colors.chart.monitor}
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor={colors.chart.monitor}
stopOpacity={0}
/>
</linearGradient>
</defs>
<XAxis
dataKey="time"
type="number"
domain={['dataMin', 'dataMax']}
tickFormatter={(date) => dayjs(date).format('HH:mm')}
/>
<YAxis domain={[isTrendingMode ? 'dataMin' : 0, 'dataMax']} />
<CartesianGrid vertical={false} />
<ChartTooltip
labelFormatter={(label, payload) =>
dayjs(get(payload, [0, 'payload', 'time'])).format(
'YYYY-MM-DD HH:mm:ss'
)
}
formatter={(value, defaultText, item, index, payload) => {
if (typeof value !== 'number') {
return defaultText;
}
const { name, text } = getProviderDisplay(
Number(value),
providerInfo
);
return (
<div>
<span className="mr-2">{name}:</span>
<span>{text}</span>
</div>
);
}}
content={<ChartTooltipContent />}
/>
<Customized component={CustomizedErrorArea} />
<Area
type="monotone"
dataKey="value"
stroke={colors.chart.monitor}
fillOpacity={1}
fill="url(#color)"
strokeWidth={2}
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
);
});
MonitorPublicDataChart.displayName = 'MonitorPublicDataChart';

View File

@ -1,9 +1,21 @@
import { AppRouterOutput, trpc } from '@/api/trpc'; import { AppRouterOutput, trpc } from '@/api/trpc';
import React, { useMemo } from 'react'; import React, { useMemo, useReducer } from 'react';
import { bodySchema } from './schema'; import { bodySchema } from './schema';
import { Empty } from 'antd'; import { Empty } from 'antd';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
import { MonitorListItem } from '../MonitorListItem'; import { cn } from '@/utils/style';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { HealthBar } from '@/components/HealthBar';
import { getMonitorProvider, getProviderDisplay } from '../provider';
import {
getStatusBgColorClassName,
parseHealthStatusByPercent,
} from '@/utils/health';
import { MonitorPublicDataChart } from '../MonitorPublicDataChart';
interface StatusPageBodyProps { interface StatusPageBodyProps {
workspaceId: string; workspaceId: string;
@ -40,7 +52,7 @@ export const StatusPageBody: React.FC<StatusPageBodyProps> = React.memo(
<StatusItemMonitor <StatusItemMonitor
key={item.key} key={item.key}
workspaceId={props.workspaceId} workspaceId={props.workspaceId}
id={item.id} monitorId={item.id}
showCurrent={item.showCurrent ?? false} showCurrent={item.showCurrent ?? false}
/> />
); );
@ -58,33 +70,145 @@ export const StatusPageBody: React.FC<StatusPageBodyProps> = React.memo(
StatusPageBody.displayName = 'StatusPageBody'; StatusPageBody.displayName = 'StatusPageBody';
export const StatusItemMonitor: React.FC<{ export const StatusItemMonitor: React.FC<{
id: string; monitorId: string;
showCurrent: boolean; showCurrent: boolean;
workspaceId: string; workspaceId: string;
}> = React.memo((props) => { }> = React.memo((props) => {
const { data: list = [], isLoading } = trpc.monitor.getPublicInfo.useQuery({ const { data: info } = trpc.monitor.getPublicInfo.useQuery(
monitorIds: [props.id], {
monitorIds: [props.monitorId],
},
{
select: (data) => data[0],
}
);
const { data: list = [], isLoading } = trpc.monitor.publicSummary.useQuery({
workspaceId: props.workspaceId,
monitorId: props.monitorId,
}); });
const [showChart, toggleShowChart] = useReducer((state) => !state, false);
const { summaryStatus, summaryPercent } = useMemo(() => {
let upCount = 0;
let totalCount = 0;
list.forEach((item) => {
upCount += item.upCount;
totalCount += item.totalCount;
});
const percent = Number(((upCount / totalCount) * 100).toFixed(1));
return {
summaryPercent: percent,
summaryStatus: parseHealthStatusByPercent(percent, totalCount),
};
}, [list]);
if (isLoading) { if (isLoading) {
return null; return null;
} }
const item = list[0];
if (!item) {
return null;
}
return ( return (
<MonitorListItem <div>
key={item.id} <div
className={cn(
'mb-1 flex cursor-pointer items-center overflow-hidden rounded-lg bg-green-500 bg-opacity-0 px-4 py-3 hover:bg-opacity-10'
)}
onClick={toggleShowChart}
>
<div>
<span
className={cn(
'inline-block min-w-[62px] rounded-full p-0.5 text-center text-white',
getStatusBgColorClassName(summaryStatus)
)}
>
{summaryPercent}%
</span>
</div>
<div className="flex-1 pl-2">
<div className="text-nowrap text-base">{info?.name}</div>
</div>
{props.showCurrent && info && (
<MonitorLatestResponse
workspaceId={props.workspaceId} workspaceId={props.workspaceId}
monitorId={item.id} monitorId={info.id}
monitorName={item.name} monitorType={info.type}
monitorType={item.type}
showCurrentResponse={props.showCurrent}
/> />
)}
<div className="flex-shrink basis-[250px] items-center overflow-hidden px-1">
<HealthBar
className="justify-end"
size="small"
beats={[...list].reverse().map((item) => {
const status = parseHealthStatusByPercent(
item.upRate,
item.totalCount
);
return {
status,
title: `${item.day} | (${item.upCount}/${item.totalCount}) ${item.upRate}%`,
};
})}
/>
</div>
</div>
{showChart && (
<MonitorPublicDataChart
workspaceId={props.workspaceId}
monitorId={props.monitorId}
/>
)}
</div>
); );
}); });
StatusItemMonitor.displayName = 'StatusItemMonitor'; StatusItemMonitor.displayName = 'StatusItemMonitor';
const MonitorLatestResponse: React.FC<{
workspaceId: string;
monitorId: string;
monitorType: string;
}> = React.memo((props) => {
const { t } = useTranslation();
const { data: recentText } = trpc.monitor.recentData.useQuery(
{
workspaceId: props.workspaceId,
monitorId: props.monitorId,
take: 1,
},
{
select: (data) => {
const provider = getMonitorProvider(props.monitorType);
const value = data[0].value;
if (!value) {
return '';
}
const { text } = getProviderDisplay(value, provider);
return text;
},
}
);
return (
<Tooltip>
<TooltipTrigger asChild={true}>
<div className="px-2 text-sm text-gray-800 dark:text-gray-400">
{recentText}
</div>
</TooltipTrigger>
<TooltipContent>{t('Current')}</TooltipContent>
</Tooltip>
);
});
MonitorLatestResponse.displayName = 'MonitorLatestResponse';

View File

@ -13,6 +13,7 @@ module.exports = {
'./components/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}',
'./pages/**/*.{js,jsx,ts,tsx}', './pages/**/*.{js,jsx,ts,tsx}',
'./routes/**/*.{js,jsx,ts,tsx}', './routes/**/*.{js,jsx,ts,tsx}',
'./utils/health.ts',
], ],
}, },
theme: { theme: {

View File

@ -0,0 +1,45 @@
import { describe, test, expect } from 'vitest';
import {
parseHealthStatusByPercent,
getStatusBgColorClassName,
} from './health';
describe('parseHealthStatusByPercent', () => {
test('should return "health" when percent is 100', () => {
expect(parseHealthStatusByPercent(100, 0)).toEqual('health');
});
test('should return "none" when percent is 0 and count is 0', () => {
expect(parseHealthStatusByPercent(0, 0)).toEqual('none');
});
test('should return "error" when percent is 0 and count is not 0', () => {
expect(parseHealthStatusByPercent(0, 1)).toEqual('error');
});
test('should return "warning" for other cases', () => {
expect(parseHealthStatusByPercent(50, 1)).toEqual('warning');
});
});
describe('getStatusBgColorClassName', () => {
test('should return bg-green-500 for health status', () => {
expect(getStatusBgColorClassName('health')).toEqual('bg-green-500');
});
test('should return bg-red-600 for error status', () => {
expect(getStatusBgColorClassName('error')).toEqual('bg-red-600');
});
test('should return bg-yellow-400 for warning status', () => {
expect(getStatusBgColorClassName('warning')).toEqual('bg-yellow-400');
});
test('should return bg-gray-400 for none status', () => {
expect(getStatusBgColorClassName('none')).toEqual('bg-gray-400');
});
test('should return empty string for other status', () => {
expect(getStatusBgColorClassName('other' as any)).toEqual('');
});
});

View File

@ -0,0 +1,36 @@
export type HealthStatus = 'health' | 'error' | 'warning' | 'none';
/**
*
* @param percent 0 - 100
* @param count
* @returns
*/
export function parseHealthStatusByPercent(
percent: number,
count: number
): HealthStatus {
if (percent === 100) {
return 'health';
} else if (percent === 0 && count === 0) {
return 'none';
} else if (percent === 0 && count !== 0) {
return 'error';
} else {
return 'warning';
}
}
export function getStatusBgColorClassName(status: HealthStatus): string {
if (status === 'health') {
return 'bg-green-500';
} else if (status === 'error') {
return 'bg-red-600';
} else if (status === 'warning') {
return 'bg-yellow-400';
} else if (status === 'none') {
return 'bg-gray-400';
} else {
return '';
}
}

View File

@ -9,4 +9,5 @@ export const monitorPublicInfoSchema = MonitorModelSchema.pick({
id: true, id: true,
name: true, name: true,
type: true, type: true,
trendingMode: true,
}); });

View File

@ -1,3 +1,4 @@
import dayjs from 'dayjs';
import { prisma } from '../_client.js'; import { prisma } from '../_client.js';
import { monitorPublicInfoSchema } from '../_schema/monitor.js'; import { monitorPublicInfoSchema } from '../_schema/monitor.js';
import { MonitorManager } from './manager.js'; import { MonitorManager } from './manager.js';
@ -67,3 +68,57 @@ export function getMonitorRecentData(
}) })
.then((arr) => arr.reverse()); .then((arr) => arr.reverse());
} }
export async function getMonitorSummaryWithDay(
monitorId: string,
beforeDay: number = 30
) {
interface MonitorSummaryItem {
day: string;
total_count: number;
up_count: number;
up_rate: number;
}
const list = await prisma.$queryRaw<MonitorSummaryItem[]>`
SELECT
DATE("createdAt") AS day,
COUNT(1) AS total_count,
SUM(CASE WHEN "value" >= 0 THEN 1 ELSE 0 END) AS up_count,
(SUM(CASE WHEN "value" >= 0 THEN 1 ELSE 0 END) * 100.0 / COUNT(1)) AS up_rate
FROM
"MonitorData"
WHERE
"monitorId" = ${monitorId} AND
"createdAt" >= CURRENT_DATE - INTERVAL '${beforeDay} days'
GROUP BY
DATE("createdAt")
ORDER BY
day;`;
const map: Record<string, MonitorSummaryItem> = {};
for (const item of list) {
const date = dayjs(item.day).format('YYYY-MM-DD');
map[date] = item;
}
return Array.from({ length: beforeDay }).map((_, i) => {
const target = dayjs().subtract(i, 'days').format('YYYY-MM-DD');
if (map[target]) {
return {
day: target,
totalCount: Number(map[target].total_count),
upCount: Number(map[target].up_count),
upRate: Number(Number(map[target].up_rate).toFixed(1)),
};
} else {
return {
day: target,
totalCount: 0,
upCount: 0,
upRate: 0,
};
}
});
}

View File

@ -11,6 +11,7 @@ import {
getMonitorData, getMonitorData,
getMonitorPublicInfos, getMonitorPublicInfos,
getMonitorRecentData, getMonitorRecentData,
getMonitorSummaryWithDay,
monitorManager, monitorManager,
} from '../../model/monitor/index.js'; } from '../../model/monitor/index.js';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@ -330,6 +331,68 @@ export const monitorRouter = router({
return getMonitorRecentData(workspaceId, monitorId, take); return getMonitorRecentData(workspaceId, monitorId, take);
}), }),
publicSummary: publicProcedure
.meta(
buildMonitorOpenapi({
method: 'GET',
protect: false,
path: '/{monitorId}/publicSummary',
})
)
.input(
z.object({
workspaceId: z.string().cuid2(),
monitorId: z.string().cuid2(),
})
)
.output(
z.array(
z.object({
day: z.string(),
totalCount: z.number(),
upCount: z.number(),
upRate: z.number(),
})
)
)
.query(async ({ input }) => {
const { monitorId } = input;
const summary = await getMonitorSummaryWithDay(monitorId, 30);
return summary;
}),
publicData: publicProcedure
.meta(
buildMonitorOpenapi({
method: 'GET',
protect: false,
path: '/{monitorId}/publicData',
})
)
.input(
z.object({
workspaceId: z.string().cuid2(),
monitorId: z.string().cuid2(),
})
)
.output(
z.array(
z.object({
value: z.number(),
createdAt: z.date(),
})
)
)
.query(async ({ input }) => {
const { workspaceId, monitorId } = input;
return getMonitorData(
workspaceId,
monitorId,
dayjs().subtract(1, 'days').toDate(),
dayjs().toDate()
);
}),
dataMetrics: workspaceProcedure dataMetrics: workspaceProcedure
.meta( .meta(
buildMonitorOpenapi({ buildMonitorOpenapi({