diff --git a/package.json b/package.json index 841808e..ff37e96 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "ts-node": "^10.9.1", "uuid": "^9.0.0", "vite-express": "^0.10.0", + "winston": "^3.11.0", "yup": "^1.2.0", "zod": "^3.22.2", "zustand": "^4.4.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a43475c..867e883 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ dependencies: vite-express: specifier: ^0.10.0 version: 0.10.0 + winston: + specifier: ^3.11.0 + version: 3.11.0 yup: specifier: ^1.2.0 version: 1.2.0 @@ -1361,6 +1364,11 @@ packages: to-fast-properties: 2.0.0 dev: true + /@colors/colors@1.6.0: + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + dev: false + /@cspotcode/source-map-support@0.8.1: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -1372,6 +1380,14 @@ packages: engines: {node: '>=10'} dev: false + /@dabh/diagnostics@2.0.3: + resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} + dependencies: + colorspace: 1.1.4 + enabled: 2.0.0 + kuler: 2.0.0 + dev: false + /@dagrejs/graphlib@2.1.4: resolution: {integrity: sha512-QCg9sL4uhjn468FDEsb/S9hS2xUZSrv/+dApb1Ze5VKO96pTXKNJZ6MGhIpgWkc1TVhbVGH9/7rq/Mf8/jWicw==} dependencies: @@ -2204,6 +2220,10 @@ packages: minipass: 4.2.8 dev: true + /@types/triple-beam@1.3.4: + resolution: {integrity: sha512-HlJjF3wxV4R2VQkFpKe0YqJLilYNgtRtsqqZtby7RkVsSs+i+vbyzjtUwpFEdUCKcrGzCiEJE7F/0mKjh0sunA==} + dev: false + /@types/uuid@9.0.3: resolution: {integrity: sha512-taHQQH/3ZyI3zP8M/puluDEIEvtQHVYcC6y3N8ijFtAd28+Ey/G4sg1u2gB01S8MwybLOKAp9/yCMu/uR5l3Ug==} dev: false @@ -2805,6 +2825,13 @@ packages: resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} dev: false + /colorspace@1.1.4: + resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==} + dependencies: + color: 3.2.1 + text-hex: 1.0.0 + dev: false + /combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -3295,6 +3322,10 @@ packages: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} dev: false + /enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + dev: false + /encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -3676,6 +3707,10 @@ packages: uglify-js: 2.8.29 dev: false + /fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + dev: false + /follow-redirects@1.15.2: resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} engines: {node: '>=4.0'} @@ -4196,6 +4231,11 @@ packages: call-bind: 1.0.2 dev: false + /is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + dev: false + /is-string@1.0.7: resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} engines: {node: '>= 0.4'} @@ -4339,6 +4379,10 @@ packages: is-buffer: 1.1.6 dev: false + /kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + dev: false + /l7-tiny-sdf@0.0.4: resolution: {integrity: sha512-hMuA5jolQCyhK+QufHMy+qxZQlc9uD/S7jVWFDyVy5TKb3HxMOGc1RcqMwcvlgDXzmVqNWkxAN8LracSEwqYIw==} dev: false @@ -4421,6 +4465,18 @@ packages: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} dev: false + /logform@2.6.0: + resolution: {integrity: sha512-1ulHeNPp6k/LD8H91o7VYFBng5i1BDE7HoKxVbZiGFidS1Rj65qcywLxX+pVfAPoQJEjRdvKcusKwOupHCVOVQ==} + engines: {node: '>= 12.0.0'} + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.4 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.4.3 + triple-beam: 1.4.1 + dev: false + /longest@1.0.1: resolution: {integrity: sha512-k+yt5n3l48JU4k8ftnKG6V7u32wyH2NfKzeMto9F/QRE0amxy/LayxwlvjjkZEIzqR+19IrtFO8p5kB9QaYUFg==} engines: {node: '>=0.10.0'} @@ -4857,6 +4913,12 @@ packages: dependencies: wrappy: 1.0.2 + /one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + dependencies: + fn.name: 1.1.0 + dev: false + /openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} dev: false @@ -6020,6 +6082,15 @@ packages: pify: 2.3.0 dev: true + /readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + dev: false + /readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -6169,6 +6240,11 @@ packages: is-regex: 1.1.4 dev: false + /safe-stable-stringify@2.4.3: + resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==} + engines: {node: '>=10'} + dev: false + /safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} dev: false @@ -6418,6 +6494,10 @@ packages: stackframe: 1.3.4 dev: false + /stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + dev: false + /stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} dev: false @@ -6491,6 +6571,12 @@ packages: es-abstract: 1.22.1 dev: false + /string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /strip-ansi@3.0.1: resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} engines: {node: '>=0.10.0'} @@ -6638,6 +6724,10 @@ packages: yallist: 4.0.0 dev: true + /text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + dev: false + /thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -6725,6 +6815,11 @@ packages: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} dev: false + /triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + dev: false + /trpc-openapi@1.2.0(@trpc/server@10.38.4)(zod@3.22.2): resolution: {integrity: sha512-pfYoCd/3KYXWXvUPZBKJw455OOwngKN/6SIcj7Yit19OMLJ+8yVZkEvGEeg5wUSwfsiTdRsKuvqkRPXVSwV7ew==} peerDependencies: @@ -6947,7 +7042,6 @@ packages: /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: true /utility-types@3.10.0: resolution: {integrity: sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==} @@ -7083,6 +7177,32 @@ packages: engines: {node: '>= 0.8.0'} dev: false + /winston-transport@4.6.0: + resolution: {integrity: sha512-wbBA9PbPAHxKiygo7ub7BYRiKxms0tpfU2ljtWzb3SjRjv5yl6Ozuy/TkXf00HTAt+Uylo3gSkNwzc4ME0wiIg==} + engines: {node: '>= 12.0.0'} + dependencies: + logform: 2.6.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + dev: false + + /winston@3.11.0: + resolution: {integrity: sha512-L3yR6/MzZAOl0DsysUXHVjOwv8mKZ71TrA/41EIduGpOOV5LQVodqN+QdQ6BS6PJ/RdIshZhq84P/fStEZkk7g==} + engines: {node: '>= 12.0.0'} + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.3 + async: 3.2.4 + is-stream: 2.0.1 + logform: 2.6.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.4.3 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.6.0 + dev: false + /wordwrap@0.0.2: resolution: {integrity: sha512-xSBsCeh+g+dinoBv3GAOWM4LcVVO68wLXRanibtBSdUvkGWQRGeE9P7IwU9EmDDi4jA6L44lz15CGMwdw9N5+Q==} engines: {node: '>=0.4.0'} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7ac3278..93ae2e0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -249,6 +249,7 @@ model Monitor { notifications Notification[] events MonitorEvent[] datas MonitorData[] + status MonitorStatus[] @@index([workspaceId]) } @@ -271,3 +272,16 @@ model MonitorData { monitor Monitor @relation(fields: [monitorId], references: [id], onUpdate: Cascade, onDelete: Cascade) } + +// Use for record latest monitor status, for example tls status +model MonitorStatus { + monitorId String @db.VarChar(30) + statusName String @db.VarChar(50) + payload Json @db.Json + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @updatedAt @db.Timestamptz(6) + + monitor Monitor @relation(fields: [monitorId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + @@id([monitorId, statusName]) +} diff --git a/src/client/components/modals/monitor/MonitorInfoEditor.tsx b/src/client/components/modals/monitor/MonitorInfoEditor.tsx index 4c3fda4..7e37159 100644 --- a/src/client/components/modals/monitor/MonitorInfoEditor.tsx +++ b/src/client/components/modals/monitor/MonitorInfoEditor.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import type { Monitor } from '@prisma/client'; import { Button, Form, Input, InputNumber, Select } from 'antd'; -import { monitorProviders } from './provider'; +import { getMonitorProvider, monitorProviders } from './provider'; import { useEvent } from '../../../hooks/useEvent'; import { NotificationPicker } from '../../notification/NotificationPicker'; @@ -31,7 +31,7 @@ export const MonitorInfoEditor: React.FC = React.memo( const typeValue = Form.useWatch('type', form); const formEl = useMemo(() => { - const provider = monitorProviders.find((s) => s.name === typeValue); + const provider = getMonitorProvider(typeValue); if (!provider) { return null; diff --git a/src/client/components/modals/monitor/provider/http.tsx b/src/client/components/modals/monitor/provider/http.tsx new file mode 100644 index 0000000..3bbec75 --- /dev/null +++ b/src/client/components/modals/monitor/provider/http.tsx @@ -0,0 +1,96 @@ +import { Form, Input, Select } from 'antd'; +import React from 'react'; +import { MonitorOverviewComponent, MonitorProvider } from './types'; +import { trpc } from '../../../../api/trpc'; +import { MonitorStatsBlock } from '../../../monitor/MonitorStatsBlock'; +import dayjs from 'dayjs'; +import { isEmpty } from 'lodash-es'; +import { useCurrentWorkspaceId } from '../../../../store/user'; + +const MonitorHttp: React.FC = React.memo(() => { + return ( + <> + + + + + + + + + + + + + + ); +}); +MonitorHttp.displayName = 'MonitorHttp'; + +export const MonitorHttpOverview: MonitorOverviewComponent = React.memo( + (props) => { + const workspaceId = useCurrentWorkspaceId(); + const { data } = trpc.monitor.getStatus.useQuery({ + workspaceId, + monitorId: props.monitorId, + statusName: 'tls', + }); + + if (!data || !data.payload || typeof data.payload !== 'object') { + return null; + } + + const payload = data.payload as Record; + + if (isEmpty(payload.certInfo)) { + return null; + } + + return ( + + ); + } +); +MonitorHttpOverview.displayName = 'MonitorHttpOverview'; + +export const httpProvider: MonitorProvider = { + label: 'HTTP', + name: 'http', + link: (info) => String(info.payload.url), + form: MonitorHttp, + overview: [MonitorHttpOverview], +}; diff --git a/src/client/components/modals/monitor/provider/index.ts b/src/client/components/modals/monitor/provider/index.ts index 88ad54f..5ab3704 100644 --- a/src/client/components/modals/monitor/provider/index.ts +++ b/src/client/components/modals/monitor/provider/index.ts @@ -1,25 +1,25 @@ import React from 'react'; import { MonitorInfo } from '../../../../../types'; -import { MonitorPing } from './ping'; - -interface MonitorProvider { - label: string; - name: string; - link: (info: MonitorInfo) => React.ReactNode; - form: React.ComponentType; -} +import { pingProvider } from './ping'; +import { httpProvider } from './http'; +import { MonitorProvider } from './types'; export const monitorProviders: MonitorProvider[] = [ - { - label: 'Ping', - name: 'ping', - link: (info) => String(info.payload.hostname), - form: MonitorPing, - }, + pingProvider, // ping + httpProvider, // http ]; +export function getMonitorProvider(type: string) { + const provider = monitorProviders.find((m) => m.name === type); + if (!provider) { + return null; + } + + return provider; +} + export function getMonitorLink(info: MonitorInfo): React.ReactNode { - const provider = monitorProviders.find((m) => m.name === info.type); + const provider = getMonitorProvider(info.type); if (!provider) { return null; } diff --git a/src/client/components/modals/monitor/provider/ping.tsx b/src/client/components/modals/monitor/provider/ping.tsx index 0bf3402..41bf584 100644 --- a/src/client/components/modals/monitor/provider/ping.tsx +++ b/src/client/components/modals/monitor/provider/ping.tsx @@ -1,5 +1,6 @@ import { Form, Input } from 'antd'; import React from 'react'; +import { MonitorProvider } from './types'; export const MonitorPing: React.FC = React.memo(() => { return ( @@ -15,3 +16,10 @@ export const MonitorPing: React.FC = React.memo(() => { ); }); MonitorPing.displayName = 'MonitorPing'; + +export const pingProvider: MonitorProvider = { + label: 'Ping', + name: 'ping', + link: (info) => String(info.payload.hostname), + form: MonitorPing, +}; diff --git a/src/client/components/modals/monitor/provider/types.ts b/src/client/components/modals/monitor/provider/types.ts new file mode 100644 index 0000000..58c7903 --- /dev/null +++ b/src/client/components/modals/monitor/provider/types.ts @@ -0,0 +1,13 @@ +import { MonitorInfo } from '../../../../../types'; + +export interface MonitorProvider { + label: string; + name: string; + link: (info: MonitorInfo) => React.ReactNode; + form: React.ComponentType; + overview?: MonitorOverviewComponent[]; +} + +export type MonitorOverviewComponent = React.ComponentType<{ + monitorId: string; +}>; diff --git a/src/client/components/monitor/MonitorEventList.tsx b/src/client/components/monitor/MonitorEventList.tsx index e94cf78..d7a4604 100644 --- a/src/client/components/monitor/MonitorEventList.tsx +++ b/src/client/components/monitor/MonitorEventList.tsx @@ -19,7 +19,7 @@ export const MonitorEventList: React.FC = React.memo( const navigate = useNavigate(); if (isLoading === false && data.length === 0) { - return ; + return ; } return ( diff --git a/src/client/components/monitor/MonitorInfo.tsx b/src/client/components/monitor/MonitorInfo.tsx index 72d116a..59e376e 100644 --- a/src/client/components/monitor/MonitorInfo.tsx +++ b/src/client/components/monitor/MonitorInfo.tsx @@ -4,7 +4,7 @@ import React, { useMemo, useState } from 'react'; import { trpc } from '../../api/trpc'; import { useCurrentWorkspaceId } from '../../store/user'; import { Loading } from '../Loading'; -import { getMonitorLink } from '../modals/monitor/provider'; +import { getMonitorLink, getMonitorProvider } from '../modals/monitor/provider'; import { NotFoundTip } from '../NotFoundTip'; import { MonitorInfo as MonitorInfoType } from '../../../types'; import { Area, AreaConfig } from '@ant-design/charts'; @@ -14,6 +14,7 @@ import { last, uniqBy } from 'lodash-es'; import { ErrorTip } from '../ErrorTip'; import { ColorTag } from '../ColorTag'; import { useNavigate } from 'react-router'; +import { MonitorStatsBlock } from './MonitorStatsBlock'; interface MonitorInfoProps { monitorId: string; @@ -88,6 +89,7 @@ export const MonitorInfo: React.FC = React.memo((props) => { @@ -103,14 +105,29 @@ MonitorInfo.displayName = 'MonitorInfo'; export const MonitorDataMetrics: React.FC<{ monitorId: string; + monitorType: string; currectResponse: number; }> = React.memo((props) => { const workspaceId = useCurrentWorkspaceId(); - const { monitorId, currectResponse } = props; + const { monitorId, monitorType, currectResponse } = props; const { data, isLoading } = trpc.monitor.dataMetrics.useQuery({ workspaceId, monitorId, }); + const providerOverview = useMemo(() => { + const provider = getMonitorProvider(monitorType); + if (!provider || !provider.overview) { + return null; + } + + return ( + <> + {provider.overview.map((Component) => ( + + ))} + + ); + }, [monitorType]); if (isLoading) { return ; @@ -122,44 +139,40 @@ export const MonitorDataMetrics: React.FC<{ return (
-
-
Response
-
(Current)
-
{currectResponse} ms
-
-
-
Avg. Response
-
(24 hour)
-
{parseFloat(data.recent1DayAvg.toFixed(0))} ms
-
-
-
Uptime
-
(24 hour)
-
- {parseFloat( - ( - (data.recent1DayOnlineCount / - (data.recent1DayOnlineCount + data.recent1DayOfflineCount)) * - 100 - ).toFixed(2) - )} - % -
-
-
-
Uptime
-
(30 days)
-
- {parseFloat( - ( - (data.recent30DayOnlineCount / - (data.recent30DayOnlineCount + data.recent30DayOfflineCount)) * - 100 - ).toFixed(2) - )} - % -
-
+ + + + + + {providerOverview}
); }); diff --git a/src/client/components/monitor/MonitorStatsBlock.tsx b/src/client/components/monitor/MonitorStatsBlock.tsx new file mode 100644 index 0000000..fdcb483 --- /dev/null +++ b/src/client/components/monitor/MonitorStatsBlock.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +interface MonitorStatsBlockProps { + title: string; + desc: string; + text: string; +} +export const MonitorStatsBlock: React.FC = React.memo( + (props) => { + return ( +
+
{props.title}
+
{props.desc}
+
{props.text}
+
+ ); + } +); +MonitorStatsBlock.displayName = 'MonitorStatsBlock'; diff --git a/src/server/model/monitor/provider/http.ts b/src/server/model/monitor/provider/http.ts new file mode 100644 index 0000000..b779d91 --- /dev/null +++ b/src/server/model/monitor/provider/http.ts @@ -0,0 +1,164 @@ +import { MonitorProvider } from './type'; +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { logger } from '../../../utils/logger'; +import dayjs from 'dayjs'; +import { prisma } from '../../_client'; + +export const http: MonitorProvider<{ + url: string; + method?: string; + timeout?: number; // second + contentType?: string; + bodyValue?: string; + maxRedirects?: number; +}> = { + run: async (monitor) => { + if (typeof monitor.payload !== 'object') { + throw new Error('monitor.payload should be object'); + } + + const { + url, + method = 'get', + timeout = 30, + contentType, + bodyValue, + maxRedirects, + } = monitor.payload; + + const config: AxiosRequestConfig = { + url: url, + method: (method || 'get').toLowerCase(), + timeout: timeout * 1000, + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + ...(contentType ? { 'Content-Type': contentType } : {}), + }, + maxRedirects: maxRedirects, + // validateStatus: (status) => { + // return checkStatusCode(status, this.getAcceptedStatuscodes()); + // }, + }; + + if (bodyValue) { + config.data = bodyValue; + } + + try { + const startTime = dayjs(); + const res = await axios(config); + + const diff = dayjs().diff(startTime, 'ms'); + + if (url.startsWith('https:')) { + try { + const { valid, certInfo } = checkCertificate(res); + + await prisma.monitorStatus.upsert({ + where: { + monitorId_statusName: { + monitorId: monitor.id, + statusName: 'tls', + }, + }, + update: { + payload: { + valid, + certInfo, + }, + }, + create: { + monitorId: monitor.id, + statusName: 'tls', + payload: { + valid, + certInfo, + }, + }, + }); + } catch (err) {} + } + + return diff; + } catch (err) { + logger.error('run monitor http error', err); + return -1; + } + }, +}; + +function checkCertificate(res: AxiosResponse) { + if (!res.request.res.socket) { + throw new Error('No socket found'); + } + + const info = res.request.res.socket.getPeerCertificate(true); + const valid = res.request.res.socket.authorized || false; + + logger.info('cert', 'Parsing Certificate Info', info); + + const parsedInfo = parseCertificateInfo(info); + + return { + valid: valid, + certInfo: parsedInfo, + }; +} + +function parseCertificateInfo(info: any) { + let link = info; + let i = 0; + + const existingList: Record = {}; + + while (link) { + logger.debug('cert', `[${i}] ${link.fingerprint}`); + + if (!link.valid_from || !link.valid_to) { + break; + } + link.validTo = new Date(link.valid_to); + link.validFor = link.subjectaltname + ?.replace(/DNS:|IP Address:/g, '') + .split(', '); + link.daysRemaining = getDaysRemaining(new Date(), link.validTo); + + existingList[link.fingerprint] = true; + + // Move up the chain until loop is encountered + if (link.issuerCertificate == null) { + link.certType = i === 0 ? 'self-signed' : 'root CA'; + break; + } else if (link.issuerCertificate.fingerprint in existingList) { + // a root CA certificate is typically "signed by itself" (=> "self signed certificate") and thus the "issuerCertificate" is a reference to itself. + logger.debug('cert', `[Last] ${link.issuerCertificate.fingerprint}`); + link.certType = i === 0 ? 'self-signed' : 'root CA'; + link.issuerCertificate = null; + break; + } else { + link.certType = i === 0 ? 'server' : 'intermediate CA'; + link = link.issuerCertificate; + } + + // Should be no use, but just in case. + if (i > 500) { + throw new Error('Dead loop occurred in parseCertificateInfo'); + } + i++; + } + + return info; +} + +/** + * Get days remaining from a time range + * @param {Date} validFrom Start date + * @param {Date} validTo End date + * @returns {number} Number of days remaining + */ +function getDaysRemaining(validFrom: Date, validTo: Date) { + const daysRemaining = dayjs(validTo).diff(validFrom, 'days'); + + return daysRemaining; +} diff --git a/src/server/model/monitor/provider/index.ts b/src/server/model/monitor/provider/index.ts index 902e5f0..b6840c5 100644 --- a/src/server/model/monitor/provider/index.ts +++ b/src/server/model/monitor/provider/index.ts @@ -1,6 +1,8 @@ +import { http } from './http'; import { ping } from './ping'; import type { MonitorProvider } from './type'; export const monitorProviders: Record> = { ping, + http, }; diff --git a/src/server/trpc/routers/monitor.ts b/src/server/trpc/routers/monitor.ts index cce8386..3a559b9 100644 --- a/src/server/trpc/routers/monitor.ts +++ b/src/server/trpc/routers/monitor.ts @@ -239,4 +239,23 @@ export const monitorRouter = router({ return list; }), + getStatus: workspaceProcedure + .input( + z.object({ + monitorId: z.string().cuid2(), + statusName: z.string(), + }) + ) + .query(async ({ input }) => { + const { monitorId, statusName } = input; + + return prisma.monitorStatus.findUnique({ + where: { + monitorId_statusName: { + monitorId, + statusName, + }, + }, + }); + }), }); diff --git a/src/server/utils/logger.ts b/src/server/utils/logger.ts new file mode 100644 index 0000000..ac1a697 --- /dev/null +++ b/src/server/utils/logger.ts @@ -0,0 +1,40 @@ +import winston, { format } from 'winston'; +import util from 'util'; + +type Format = ReturnType; + +function utilFormatter(): Format { + return { + transform(info) { + const args = info[Symbol.for('splat')]; + if (args) { + info.message = util.format(info.message, ...args); + } + return info; + }, + }; +} + +export const logger = winston.createLogger({ + level: 'info', + format: format.json(), + transports: [ + // + // - Write all logs with importance level of `error` or less to `error.log` + // - Write all logs with importance level of `info` or less to `combined.log` + // + new winston.transports.File({ filename: 'error.log', level: 'error' }), + new winston.transports.Console({ + format: format.combine( + format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), + utilFormatter(), + format.colorize(), + format.printf( + ({ level, message, label, timestamp }) => + `${timestamp} ${label || '-'} ${level}: ${message}` + ) + ), + level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', + }), + ], +});