format code

pull/5/head
lyc8503 2023-11-06 00:38:30 +08:00
parent 6157bc0879
commit eba65bd654
23 changed files with 2380 additions and 2192 deletions

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
trailingComma: 'es5'
tabWidth: 2
semi: false
singleQuote: true

View File

@ -1,10 +1,13 @@
# UptimeFlare
Another **serverless (and free!)** uptime monitoring & status page on Cloudflare Workers with more advanced features and nice UI.
## ⭐Features
- **Opensource** & Easy to deploy (in 10 minutes without local tools) & Free
#### Monitoring
- Up to 50 **2-minute** interval check on free plan (Up to unlimited 1-min check on paid Workers plan)
- Monitoring for **HTTP/HTTPS/TCP port**
- Up to 90-day uptime history and uptime percentage
@ -13,24 +16,24 @@ Another **serverless (and free!)** uptime monitoring & status page on Cloudflare
- Email Notifications & Customizable Webhook
#### Status page
- **Interactive ping(response time) chart** for all type of monitors
- Responsive UI & Follow system theme
- Customizable status page
- Use your own domain with CNAME
## Demo
My status page (Online demo): https://uptimeflare.pages.dev/
Some screenshots: TODO
## Quickstart
## More documentation
## TODOs
- SSL?
- Slack / Telegram
- Incident timeline

View File

@ -1,8 +1,13 @@
import { MonitorState, MonitorTarget } from "@/uptime.types";
import { Box, Tooltip } from "@mantine/core";
import { MonitorState, MonitorTarget } from '@/uptime.types'
import { Box, Tooltip } from '@mantine/core'
export default function DetailBar({ monitor, state }: { monitor: MonitorTarget, state: MonitorState }) {
export default function DetailBar({
monitor,
state,
}: {
monitor: MonitorTarget
state: MonitorState
}) {
// const day = new Date()
// day.setHours(0, 0, 0, 0)
// const dayStart = day.getTime()
@ -14,7 +19,6 @@ export default function DetailBar({ monitor, state }: { monitor: MonitorTarget,
const uptimePercentBars = []
for (let i = 89; i > 0; i--) {
let dayDownTime = 0
let dayTotalTime = 0
@ -22,14 +26,31 @@ export default function DetailBar({ monitor, state }: { monitor: MonitorTarget,
uptimePercentBars.push(
<Tooltip key={i} label="Tooltip">
<div style={{ height: '20px', width: '7px', background: 'green', borderRadius: '2px', marginLeft: '1px', marginRight: '1px' }}/>
<div
style={{
height: '20px',
width: '7px',
background: 'green',
borderRadius: '2px',
marginLeft: '1px',
marginRight: '1px',
}}
/>
</Tooltip>
)
}
return (
<>
<Box style={{ display: 'flex', flexWrap: 'nowrap', marginTop: '10px', marginBottom: '5px' }} visibleFrom='540'>
<Box
style={{
display: 'flex',
flexWrap: 'nowrap',
marginTop: '10px',
marginBottom: '5px',
}}
visibleFrom="540"
>
{uptimePercentBars}
</Box>

View File

@ -1,8 +1,18 @@
import { Line } from "react-chartjs-2"
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip as ChartTooltip, Legend, TimeScale } from 'chart.js'
import { Line } from 'react-chartjs-2'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip as ChartTooltip,
Legend,
TimeScale,
} from 'chart.js'
import 'chartjs-adapter-moment'
import { MonitorState, MonitorTarget } from "@/uptime.types"
import { iataToCountry } from "@/util/iata"
import { MonitorState, MonitorTarget } from '@/uptime.types'
import { iataToCountry } from '@/util/iata'
ChartJS.register(
CategoryScale,
@ -15,10 +25,18 @@ ChartJS.register(
TimeScale
)
export default function DetailChart({ monitor, state }: { monitor: MonitorTarget, state: MonitorState }) {
const latencyData = state.latency[monitor.id].recent.map((point) => ({ x: point.time * 1000, y: point.ping, loc: point.loc }))
export default function DetailChart({
monitor,
state,
}: {
monitor: MonitorTarget
state: MonitorState
}) {
const latencyData = state.latency[monitor.id].recent.map((point) => ({
x: point.time * 1000,
y: point.ping,
loc: point.loc,
}))
let data = {
datasets: [
@ -28,9 +46,9 @@ export default function DetailChart({ monitor, state }: { monitor: MonitorTarget
borderWidth: 2,
radius: 0,
cubicInterpolationMode: 'monotone' as const,
tension: 0.4
}
]
tension: 0.4,
},
],
}
let options = {
@ -41,7 +59,7 @@ export default function DetailChart({ monitor, state }: { monitor: MonitorTarget
intersect: false,
},
animation: {
duration: 0
duration: 0,
},
plugins: {
tooltip: {
@ -50,16 +68,16 @@ export default function DetailChart({ monitor, state }: { monitor: MonitorTarget
if (item.parsed.y) {
return `${item.parsed.y}ms (${iataToCountry(item.raw.loc)})`
}
}
}
},
},
},
legend: {
display: false
display: false,
},
title: {
display: true,
text: 'Response times(ms)',
align: 'start' as const
align: 'start' as const,
},
},
scales: {
@ -68,10 +86,10 @@ export default function DetailChart({ monitor, state }: { monitor: MonitorTarget
ticks: {
source: 'auto' as const,
maxRotation: 0,
autoSkip: true
}
}
}
autoSkip: true,
},
},
},
}
return (
@ -80,4 +98,3 @@ export default function DetailChart({ monitor, state }: { monitor: MonitorTarget
</div>
)
}

View File

@ -1,16 +1,18 @@
import { Container, Group, Text } from '@mantine/core';
import classes from '@/styles/Header.module.css';
import config from '@/uptime.config';
import { Container, Group, Text } from '@mantine/core'
import classes from '@/styles/Header.module.css'
import config from '@/uptime.config'
export default function Header() {
const linkToElement = (link: { label: string; link: string; highlight?: boolean; }) => {
const linkToElement = (link: {
label: string
link: string
highlight?: boolean
}) => {
return (
<a
key={link.label}
href={link.link}
target='_blank'
target="_blank"
className={classes.link}
data-active={link.highlight}
>
@ -23,21 +25,32 @@ export default function Header() {
<header className={classes.header}>
<Container size="md" className={classes.inner}>
<div>
<a href='https://github.com/lyc8503/UptimeFlare' target='_blank'>
<Text size='xl' span>🕒</Text>
<Text size='xl' span fw={700} variant="gradient" gradient={{ from: 'blue', to: 'cyan', deg: 90 }}>UptimeFlare</Text>
<a href="https://github.com/lyc8503/UptimeFlare" target="_blank">
<Text size="xl" span>
🕒
</Text>
<Text
size="xl"
span
fw={700}
variant="gradient"
gradient={{ from: 'blue', to: 'cyan', deg: 90 }}
>
UptimeFlare
</Text>
</a>
</div>
<Group gap={5} visibleFrom='sm'>
<Group gap={5} visibleFrom="sm">
{config.page.links.map(linkToElement)}
</Group>
<Group gap={5} hiddenFrom='sm'>
{config.page.links.filter(link => link.highlight).map(linkToElement)}
<Group gap={5} hiddenFrom="sm">
{config.page.links
.filter((link) => link.highlight)
.map(linkToElement)}
</Group>
</Container>
</header>
);
)
}

View File

@ -1,9 +1,8 @@
import { Text } from "@mantine/core"
import { MonitorState, MonitorTarget } from "@/uptime.types"
import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react"
import DetailChart from "./DetailChart"
import DetailBar from "./DetailBar"
import { Text } from '@mantine/core'
import { MonitorState, MonitorTarget } from '@/uptime.types'
import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-react'
import DetailChart from './DetailChart'
import DetailBar from './DetailBar'
function getColor(percent: number | string, darker: boolean): string {
percent = Number(percent)
@ -18,32 +17,67 @@ function getColor(percent: number | string, darker: boolean): string {
}
}
export default function MonitorDetail({ monitor, state }: { monitor: MonitorTarget, state: MonitorState }) {
if (!state.latency[monitor.id]) return (
export default function MonitorDetail({
monitor,
state,
}: {
monitor: MonitorTarget
state: MonitorState
}) {
if (!state.latency[monitor.id])
return (
<>
<Text mt='sm' fw={700}>{monitor.name}</Text>
<Text mt='sm' fw={700}>No data available, please make sure you have deployed your workers with latest config and check your worker status!</Text>
<Text mt="sm" fw={700}>
{monitor.name}
</Text>
<Text mt="sm" fw={700}>
No data available, please make sure you have deployed your workers
with latest config and check your worker status!
</Text>
</>
)
const statusIcon = state.incident[monitor.id].slice(-1)[0].end === undefined ? <IconAlertCircle style={{ width: '1.25em', height: '1.25em', color: '#b91c1c' }} /> : <IconCircleCheck style={{ width: '1.25em', height: '1.25em', color: '#059669' }} />
const statusIcon =
state.incident[monitor.id].slice(-1)[0].end === undefined ? (
<IconAlertCircle
style={{ width: '1.25em', height: '1.25em', color: '#b91c1c' }}
/>
) : (
<IconCircleCheck
style={{ width: '1.25em', height: '1.25em', color: '#059669' }}
/>
)
let totalTime = Date.now() / 1000 - state.incident[monitor.id][0].start[0]
let downTime = 0
for (let incident of state.incident[monitor.id]) {
downTime += (incident.end ?? (Date.now() / 1000)) - incident.start[0]
downTime += (incident.end ?? Date.now() / 1000) - incident.start[0]
}
console.log(totalTime)
console.log(downTime)
const uptimePercent = ((totalTime - downTime) / totalTime * 100).toPrecision(4)
const uptimePercent = (
((totalTime - downTime) / totalTime) *
100
).toPrecision(4)
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text mt='sm' fw={700} style={{ display: 'inline-flex', alignItems: 'center' }}>{statusIcon} {monitor.name}</Text>
<Text mt='sm' fw={700} style={{ display: 'inline', color: getColor(uptimePercent, true) }}>Overall: {uptimePercent}%</Text>
<Text
mt="sm"
fw={700}
style={{ display: 'inline-flex', alignItems: 'center' }}
>
{statusIcon} {monitor.name}
</Text>
<Text
mt="sm"
fw={700}
style={{ display: 'inline', color: getColor(uptimePercent, true) }}
>
Overall: {uptimePercent}%
</Text>
</div>
<DetailBar monitor={monitor} state={state} />

View File

@ -1,12 +1,26 @@
import { MonitorState, MonitorTarget } from "@/uptime.types";
import { Card, Center, Divider } from "@mantine/core";
import MonitorDetail from "./MonitorDetail";
export default function MonitorList({ config, state }: { config: any, state: MonitorState }) {
import { MonitorState, MonitorTarget } from '@/uptime.types'
import { Card, Center, Divider } from '@mantine/core'
import MonitorDetail from './MonitorDetail'
export default function MonitorList({
config,
state,
}: {
config: any
state: MonitorState
}) {
return (
<Center>
<Card shadow="sm" padding="lg" radius="md" ml="xl" mr="xl" mt="xl" withBorder style={{ width: '865px' }}>
<Card
shadow="sm"
padding="lg"
radius="md"
ml="xl"
mr="xl"
mt="xl"
withBorder
style={{ width: '865px' }}
>
{config.monitors.map((monitor: MonitorTarget) => (
<div key={monitor.id}>
<Card.Section ml="xs" mr="xs">
@ -19,4 +33,3 @@ export default function MonitorList({ config, state }: { config: any, state: Mon
</Center>
)
}

View File

@ -1,10 +1,19 @@
import dynamic from 'next/dynamic'
import React from 'react'
const NoSsr = (props: { children: string | number | boolean | React.ReactElement<any, string | React.JSXElementConstructor<any>> | Iterable<React.ReactNode> | React.ReactPortal | React.PromiseLikeOfReactNode | null | undefined }) => (
<React.Fragment>{props.children}</React.Fragment>
)
const NoSsr = (props: {
children:
| string
| number
| boolean
| React.ReactElement<any, string | React.JSXElementConstructor<any>>
| Iterable<React.ReactNode>
| React.ReactPortal
| React.PromiseLikeOfReactNode
| null
| undefined
}) => <React.Fragment>{props.children}</React.Fragment>
export default dynamic(() => Promise.resolve(NoSsr), {
ssr: false
ssr: false,
})

View File

@ -1,27 +1,46 @@
import { Center, Title } from '@mantine/core'
import { IconCircleCheck, IconAlertCircle } from '@tabler/icons-react'
export default function OverallStatus({ state }: { state: { overallUp: number, overallDown: number, lastUpdate: number }}) {
export default function OverallStatus({
state,
}: {
state: { overallUp: number; overallDown: number; lastUpdate: number }
}) {
let statusString = ''
let icon = <IconAlertCircle style={{ width: 64, height: 64, color: '#b91c1c' }} />
let icon = (
<IconAlertCircle style={{ width: 64, height: 64, color: '#b91c1c' }} />
)
if (state.overallUp === 0 && state.overallDown === 0) {
statusString = 'No data yet'
} else if (state.overallUp === 0) {
statusString = 'All systems not operational'
} else if (state.overallDown === 0) {
statusString = 'All systems operational'
icon = <IconCircleCheck style={{ width: 64, height: 64, color: '#059669' }} />
icon = (
<IconCircleCheck style={{ width: 64, height: 64, color: '#059669' }} />
)
} else {
statusString = `Some systems not operational (${state.overallDown} out of ${state.overallUp + state.overallDown})`
statusString = `Some systems not operational (${state.overallDown} out of ${
state.overallUp + state.overallDown
})`
}
return (<>
<Center>
{icon}
</Center>
<Title mt="sm" style={{ textAlign: 'center' }} order={1}>{statusString}</Title>
<Title mt="sm" style={{ textAlign: 'center', color: '#70778c' }} order={5}>Last updated on: {`${new Date(state.lastUpdate * 1000).toLocaleString()} (${Math.round(Date.now() / 1000) - state.lastUpdate} sec ago)`}</Title>
</>)
return (
<>
<Center>{icon}</Center>
<Title mt="sm" style={{ textAlign: 'center' }} order={1}>
{statusString}
</Title>
<Title
mt="sm"
style={{ textAlign: 'center', color: '#70778c' }}
order={5}
>
Last updated on:{' '}
{`${new Date(state.lastUpdate * 1000).toLocaleString()} (${
Math.round(Date.now() / 1000) - state.lastUpdate
} sec ago)`}
</Title>
</>
)
}

22
package-lock.json generated
View File

@ -30,6 +30,7 @@
"postcss": "^8.4.31",
"postcss-preset-mantine": "^1.8.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "3.0.3",
"typescript": "^5",
"wrangler": "^3.13.1"
}
@ -3988,6 +3989,21 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.0.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/prettier/-/prettier-3.0.3.tgz",
"integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/printable-characters": {
"version": "1.0.42",
"resolved": "https://mirrors.cloud.tencent.com/npm/printable-characters/-/printable-characters-1.0.42.tgz",
@ -7905,6 +7921,12 @@
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true
},
"prettier": {
"version": "3.0.3",
"resolved": "https://mirrors.cloud.tencent.com/npm/prettier/-/prettier-3.0.3.tgz",
"integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==",
"dev": true
},
"printable-characters": {
"version": "1.0.42",
"resolved": "https://mirrors.cloud.tencent.com/npm/printable-characters/-/printable-characters-1.0.42.tgz",

View File

@ -31,6 +31,7 @@
"postcss": "^8.4.31",
"postcss-preset-mantine": "^1.8.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "3.0.3",
"typescript": "^5",
"wrangler": "^3.13.1"
}

View File

@ -1,13 +1,12 @@
import '@mantine/core/styles.css';
import '@mantine/core/styles.css'
import type { AppProps } from 'next/app'
import { MantineProvider } from '@mantine/core';
import NoSsr from '@/components/NoSsr';
import { MantineProvider } from '@mantine/core'
import NoSsr from '@/components/NoSsr'
export default function App({ Component, pageProps }: AppProps) {
return (
<NoSsr>
<MantineProvider defaultColorScheme='auto'>
<MantineProvider defaultColorScheme="auto">
<Component {...pageProps} />
</MantineProvider>
</NoSsr>

View File

@ -1,5 +1,5 @@
import { Html, Head, Main, NextScript } from 'next/document'
import { ColorSchemeScript } from '@mantine/core';
import { ColorSchemeScript } from '@mantine/core'
export default function Document() {
return (

View File

@ -23,39 +23,51 @@ export default function Home({ state }: { state: MonitorState }) {
<main className={inter.className}>
<Header />
{
state === undefined ?
(
{state === undefined ? (
<Center>
<Text fw={700}>
Monitor State is not defined now, please check your worker&apos;s status and KV binding!
Monitor State is not defined now, please check your worker&apos;s
status and KV binding!
</Text>
</Center>
) :
(
) : (
<div>
<OverallStatus state={state} />
<MonitorList config={config} state={state} />
</div>
)
}
)}
<Divider mt='lg' />
<Divider mt="lg" />
<Center>
<Text size='xs' mt='xs' mb='xs'>
Open-source monitoring and status page powered by <a href='https://github.com/lyc8503/UptimeFlare' target='_blank'>Uptimeflare</a> and <a href='https://www.cloudflare.com/' target='_blank'>Cloudflare</a>, made with by <a href='https://github.com/lyc8503' target='_blank'>lyc8503</a>.
<Text size="xs" mt="xs" mb="xs">
Open-source monitoring and status page powered by{' '}
<a href="https://github.com/lyc8503/UptimeFlare" target="_blank">
Uptimeflare
</a>{' '}
and{' '}
<a href="https://www.cloudflare.com/" target="_blank">
Cloudflare
</a>
, made with by{' '}
<a href="https://github.com/lyc8503" target="_blank">
lyc8503
</a>
.
</Text>
</Center>
</main>
</>
)
}
export async function getServerSideProps() {
const { UPTIMEFLARE_STATE } = process.env as unknown as { UPTIMEFLARE_STATE: KVNamespace }
const state = await UPTIMEFLARE_STATE?.get('state', 'json') as unknown as MonitorState
const { UPTIMEFLARE_STATE } = process.env as unknown as {
UPTIMEFLARE_STATE: KVNamespace
}
const state = (await UPTIMEFLARE_STATE?.get(
'state',
'json'
)) as unknown as MonitorState
return { props: { state } }
}

View File

@ -2,7 +2,8 @@
height: rem(56px);
margin-bottom: rem(120px);
background-color: var(--mantine-color-body);
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
border-bottom: rem(1px) solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.inner {
@ -23,7 +24,10 @@
font-weight: 500;
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
[data-mantine-color-scheme] &[data-active] {

View File

@ -1,18 +1,18 @@
const config = {
dateLocale: "zh-CN",
timezone: "Asia/Shanghai",
dateLocale: 'zh-CN',
timezone: 'Asia/Shanghai',
page: {
title: "lyc8503's Status Page",
links: [
{ link: 'https://github.com/lyc8503', label: 'GitHub' },
{ link: 'https://blog.lyc8503.site/', label: 'Blog' },
{ link: 'mailto:me@lyc8503.site', label: 'Email Me', highlight: true }
]
{ link: 'mailto:me@lyc8503.site', label: 'Email Me', highlight: true },
],
},
callback: async (statusChangeMsg: string) => {
await fetch('https://server.lyc8503.site/wepush?key=wepushkey&msg=' + statusChangeMsg)
await fetch(
'https://server.lyc8503.site/wepush?key=wepushkey&msg=' + statusChangeMsg
)
},
monitors: [
{
@ -23,9 +23,9 @@ const config = {
expectedCode: [200],
timeout: 10000,
headers: {
"User-Agent": "Uptimeflare"
'User-Agent': 'Uptimeflare',
},
body: undefined
body: undefined,
},
{
id: 'cloudflare',
@ -35,30 +35,30 @@ const config = {
expectedCode: [200],
timeout: 10000,
headers: {
"User-Agent": "Uptimeflare"
'User-Agent': 'Uptimeflare',
},
body: undefined
body: undefined,
},
{
id: 'blog',
name: 'My Blog',
method: 'GET',
target: 'https://blog.lyc8503.site',
timeout: 10000
timeout: 10000,
},
{
id: 'pan',
name: 'My Fileshare',
method: 'GET',
target: 'https://pan.lyc8503.site',
timeout: 10000
timeout: 10000,
},
{
id: 'server',
name: 'My Aliyun Server',
method: 'GET',
target: 'https://server.lyc8503.site',
timeout: 10000
timeout: 10000,
},
{
id: 'homelab',
@ -67,15 +67,22 @@ const config = {
target: 'https://lyc8503.cn4.quickconnect.cn/webstation/hello.txt',
timeout: 10000,
headers: {
"Cookie": "type=tunnel;"
Cookie: 'type=tunnel;',
},
responseKeyword: 'Hello'
responseKeyword: 'Hello',
},
{
id: 'homessh',
name: 'HomeSSH',
method: 'TCP_PING',
target: '20.24.87.148:22',
timeout: 10000,
},
{
id: 'broken-test',
name: 'Ping 1.1.1.1',
method: 'TCP_PING',
target: '1.1.1.1:1234'
target: '1.1.1.1:1234',
},
{
id: '404test',
@ -88,7 +95,7 @@ const config = {
name: '404-test2',
method: 'GET',
target: 'https://www.baidu.com/404',
expectedCodes: [404, 405]
expectedCodes: [404, 405],
},
{
id: '404test3',
@ -96,9 +103,9 @@ const config = {
method: 'GET',
target: 'https://www.baidu.com/404',
expectedCodes: [404],
responseKeyword: 'Hello'
}
]
responseKeyword: 'Hello',
},
],
}
export default config

View File

@ -1,40 +1,45 @@
type MonitorState = {
lastUpdate: number,
overallUp: number,
overallDown: number,
incident: Record<string, {
start: number[],
end: number | undefined, // undefined if it's still open
lastUpdate: number
overallUp: number
overallDown: number
incident: Record<
string,
{
start: number[]
end: number | undefined // undefined if it's still open
error: string[]
}[]>,
}[]
>
latency: Record<string, {
latency: Record<
string,
{
recent: {
loc: string,
ping: number,
loc: string
ping: number
time: number
}[], // recent 12 hour data, 2 min interval
}[] // recent 12 hour data, 2 min interval
all: {
loc: string,
ping: number,
loc: string
ping: number
time: number
}[] // all data in 90 days, 1 hour interval
}>
}
>
}
type MonitorTarget = {
id: string,
name: string,
method: string, // "TCP_PING" or Http Method (e.g. GET, POST, OPTIONS, etc.)
target: string, // url for http, hostname:port for tcp
id: string
name: string
method: string // "TCP_PING" or Http Method (e.g. GET, POST, OPTIONS, etc.)
target: string // url for http, hostname:port for tcp
// HTTP Code
expectedCodes?: number[],
timeout?: number,
headers?: Record<string, string | undefined>,
body?: BodyInit,
expectedCodes?: number[]
timeout?: number
headers?: Record<string, string | undefined>
body?: BodyInit
responseKeyword?: string
}
export type { MonitorState, MonitorTarget }

File diff suppressed because one or more lines are too long

View File

@ -1,46 +1,45 @@
import { connect } from 'cloudflare:sockets'
import config from '../../uptime.config'
import { fetchTimeout, getWorkerLocation, withTimeout } from './util'
import { MonitorState, MonitorTarget } from "../../uptime.types"
import { connect } from 'cloudflare:sockets';
import config from '../../uptime.config';
import { fetchTimeout, getWorkerLocation, withTimeout } from './util';
import { MonitorState, MonitorTarget } from '../../uptime.types';
export interface Env {
UPTIMEFLARE_STATE: KVNamespace
UPTIMEFLARE_STATE: KVNamespace;
}
async function getStatus(monitor: MonitorTarget): Promise<{ ping: number; up: boolean; err: string }> {
let status = {
ping: 0,
up: false,
err: "Unknown"
}
err: 'Unknown',
};
const startTime = Date.now()
const startTime = Date.now();
if (monitor.method === "TCP_PING") {
if (monitor.method === 'TCP_PING') {
// TCP port endpoint monitor
// TODO: TCP timeout
try {
const [hostname, port] = monitor.target.split(":")
const [hostname, port] = monitor.target.split(':');
// Write "PING\n"
const socket = connect({ hostname: hostname, port: Number(port) })
const writer = socket.writable.getWriter()
await writer.write(new TextEncoder().encode("PING\n"))
const socket = connect({ hostname: hostname, port: Number(port) });
const writer = socket.writable.getWriter();
await writer.write(new TextEncoder().encode('PING\n'));
// Can't do this: await socket.close()
// https://github.com/cloudflare/workerd/issues/1305
await withTimeout(monitor.timeout || 10000, socket.closed)
await withTimeout(monitor.timeout || 10000, socket.closed);
console.log(`${monitor.name} connected to ${monitor.target}`)
console.log(`${monitor.name} connected to ${monitor.target}`);
status.ping = Date.now() - startTime
status.up = true
status.err = ""
status.ping = Date.now() - startTime;
status.up = true;
status.err = '';
} catch (e: Error | any) {
console.log(`${monitor.name} errored with ${e.name}: ${e.message}`)
status.up = false
status.err = e.name + ": " + e.message
console.log(`${monitor.name} errored with ${e.name}: ${e.message}`);
status.up = false;
status.err = e.name + ': ' + e.message;
}
} else {
// HTTP endpoint monitor
@ -51,84 +50,85 @@ async function getStatus(monitor: MonitorTarget): Promise<{ ping: number; up: bo
body: monitor.body,
cf: {
cacheTtlByStatus: {
'100-599': -1 // Don't cache any status code, from https://developers.cloudflare.com/workers/runtime-apis/request/#requestinitcfproperties
}
}
})
'100-599': -1, // Don't cache any status code, from https://developers.cloudflare.com/workers/runtime-apis/request/#requestinitcfproperties
},
},
});
console.log(`${monitor.name} responded with ${response.status}`)
status.ping = Date.now() - startTime
console.log(`${monitor.name} responded with ${response.status}`);
status.ping = Date.now() - startTime;
if (monitor.expectedCodes) {
if (!monitor.expectedCodes.includes(response.status)) {
status.up = false
status.err = `Expected codes: ${JSON.stringify(monitor.expectedCodes)}, Got: ${response.status}`
return status
status.up = false;
status.err = `Expected codes: ${JSON.stringify(monitor.expectedCodes)}, Got: ${response.status}`;
return status;
}
} else {
if (response.status < 200 || response.status > 299) {
status.up = false
status.err = `Expected codes: 2xx, Got: ${response.status}`
return status
status.up = false;
status.err = `Expected codes: 2xx, Got: ${response.status}`;
return status;
}
}
if (monitor.responseKeyword) {
const responseBody = await response.text()
const responseBody = await response.text();
if (!responseBody.includes(monitor.responseKeyword)) {
status.up = false
status.err = "HTTP response doesn't contain the configured keyword"
return status
status.up = false;
status.err = "HTTP response doesn't contain the configured keyword";
return status;
}
}
status.up = true
status.err = ""
status.up = true;
status.err = '';
} catch (e: any) {
console.log(`${monitor.name} errored with ${e.name}: ${e.message}`)
if (e.name === "AbortError") {
status.ping = monitor.timeout || 10000
status.up = false
status.err = `Timeout after ${status.ping}ms`
console.log(`${monitor.name} errored with ${e.name}: ${e.message}`);
if (e.name === 'AbortError') {
status.ping = monitor.timeout || 10000;
status.up = false;
status.err = `Timeout after ${status.ping}ms`;
} else {
status.up = false
status.err = e.name + ": " + e.message
status.up = false;
status.err = e.name + ': ' + e.message;
}
}
}
return status
return status;
}
export default {
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
const workerLocation = await getWorkerLocation() || "ERROR"
console.log(`Running scheduled event on ${workerLocation}...`)
const workerLocation = (await getWorkerLocation()) || 'ERROR';
console.log(`Running scheduled event on ${workerLocation}...`);
// Read state, set init state if it doesn't exist
let state = await env.UPTIMEFLARE_STATE.get("state", { type: 'json' }) as unknown as MonitorState || {
let state =
((await env.UPTIMEFLARE_STATE.get('state', { type: 'json' })) as unknown as MonitorState) ||
({
lastUpdate: 0,
overallUp: 0,
overallDown: 0,
incident: {},
latency: {}
} as MonitorState
state.overallDown = 0
state.overallUp = 0
latency: {},
} as MonitorState);
state.overallDown = 0;
state.overallUp = 0;
// Check each monitor
// TODO: callback exception handler
// TODO: advanced status check
// TODO: concurrent status check
for (const monitor of config.monitors) {
console.log(`[${workerLocation}] Checking ${monitor.name}...`)
console.log(`[${workerLocation}] Checking ${monitor.name}...`);
const status = await getStatus(monitor)
const currentTimeSecond = Math.round(Date.now() / 1000)
const status = await getStatus(monitor);
const currentTimeSecond = Math.round(Date.now() / 1000);
// Update counters
status.up ? state.overallUp++ : state.overallDown++
status.up ? state.overallUp++ : state.overallDown++;
// Update incidents
@ -137,19 +137,23 @@ export default {
{
start: [currentTimeSecond],
end: currentTimeSecond,
error: ['dummy']
}
]
error: ['dummy'],
},
];
// Then lastIncident here must not be undefined
const lastIncident = state.incident[monitor.id].slice(-1)[0]
const timeString = new Date().toLocaleString(config.dateLocale, { timeZone: config.timezone })
const lastIncident = state.incident[monitor.id].slice(-1)[0];
const timeString = new Date().toLocaleString(config.dateLocale, { timeZone: config.timezone });
if (status.up) {
// Current status is up
// close existing incident if any
if (lastIncident.end === undefined) {
lastIncident.end = currentTimeSecond
await config.callback(`✔️${monitor.name} came back up at ${timeString} after ${Math.round((lastIncident.end - lastIncident.start.slice(-1)[0]) / 60)} minutes of downtime`)
lastIncident.end = currentTimeSecond;
await config.callback(
`✔️${monitor.name} came back up at ${timeString} after ${Math.round(
(lastIncident.end - lastIncident.start.slice(-1)[0]) / 60,
)} minutes of downtime`,
);
}
} else {
// Current status is down
@ -158,45 +162,45 @@ export default {
state.incident[monitor.id].push({
start: [currentTimeSecond],
end: undefined,
error: [status.err]
})
await config.callback(`${monitor.name} went down at ${timeString} with error ${status.err}`)
error: [status.err],
});
await config.callback(`${monitor.name} went down at ${timeString} with error ${status.err}`);
} else if (lastIncident.end === undefined && lastIncident.error.slice(-1)[0] !== status.err) {
// append if the error message changes
lastIncident.start.push(currentTimeSecond)
lastIncident.error.push(status.err)
await config.callback(`${monitor.name} is still down at ${timeString} with error ${status.err}`)
lastIncident.start.push(currentTimeSecond);
lastIncident.error.push(status.err);
await config.callback(`${monitor.name} is still down at ${timeString} with error ${status.err}`);
}
}
// append to latency data
let latencyLists = state.latency[monitor.id] || {
recent: [],
all: []
}
all: [],
};
const record = {
loc: workerLocation,
ping: status.ping,
time: currentTimeSecond
}
latencyLists.recent.push(record)
time: currentTimeSecond,
};
latencyLists.recent.push(record);
if (latencyLists.all.length === 0 || currentTimeSecond - latencyLists.all[0].time > 60 * 60) {
latencyLists.all.push(record)
latencyLists.all.push(record);
}
// discard old data
while (latencyLists.recent[0]?.time < currentTimeSecond - 12 * 60 * 60) {
latencyLists.recent.shift()
latencyLists.recent.shift();
}
while (latencyLists.all[0]?.time < currentTimeSecond - 90 * 24 * 60 * 60) {
latencyLists.all.shift()
latencyLists.all.shift();
}
state.latency[monitor.id] = latencyLists
state.latency[monitor.id] = latencyLists;
}
// Update state
state.lastUpdate = Math.round(Date.now() / 1000)
await env.UPTIMEFLARE_STATE.put("state", JSON.stringify(state))
state.lastUpdate = Math.round(Date.now() / 1000);
await env.UPTIMEFLARE_STATE.put('state', JSON.stringify(state));
},
}
};

View File

@ -1,27 +1,27 @@
async function getWorkerLocation() {
const res = await fetch('https://cloudflare.com/cdn-cgi/trace')
const text = await res.text()
const res = await fetch('https://cloudflare.com/cdn-cgi/trace');
const text = await res.text();
const colo = /^colo=(.*)$/m.exec(text)?.[1]
return colo
const colo = /^colo=(.*)$/m.exec(text)?.[1];
return colo;
}
const fetchTimeout = (url: string, ms: number, { signal, ...options }: RequestInit<RequestInitCfProperties> | undefined = {}): Promise<Response> => {
const controller = new AbortController()
const promise = fetch(url, { signal: controller.signal, ...options })
if (signal) signal.addEventListener("abort", () => controller.abort())
const timeout = setTimeout(() => controller.abort(), ms)
return promise.finally(() => clearTimeout(timeout))
const fetchTimeout = (
url: string,
ms: number,
{ signal, ...options }: RequestInit<RequestInitCfProperties> | undefined = {},
): Promise<Response> => {
const controller = new AbortController();
const promise = fetch(url, { signal: controller.signal, ...options });
if (signal) signal.addEventListener('abort', () => controller.abort());
const timeout = setTimeout(() => controller.abort(), ms);
return promise.finally(() => clearTimeout(timeout));
};
function withTimeout<T>(millis: number, promise: Promise<T>): Promise<T> {
const timeout = new Promise<T>((resolve, reject) => setTimeout(() => reject(new Error(`Promise timed out after ${millis}ms`)), millis));
return Promise.race([promise, timeout]);
}
function withTimeout<T> (millis: number, promise: Promise<T>): Promise<T> {
const timeout = new Promise<T>((resolve, reject) =>
setTimeout(() => reject(new Error(`Promise timed out after ${millis}ms`)), millis))
return Promise.race([
promise,
timeout
])
}
export { getWorkerLocation, fetchTimeout, withTimeout }
export { getWorkerLocation, fetchTimeout, withTimeout };