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

View File

@ -1,8 +1,13 @@
import { MonitorState, MonitorTarget } from "@/uptime.types"; import { MonitorState, MonitorTarget } from '@/uptime.types'
import { Box, Tooltip } from "@mantine/core"; import { Box, Tooltip } from '@mantine/core'
export default function DetailBar({
export default function DetailBar({ monitor, state }: { monitor: MonitorTarget, state: MonitorState }) { monitor,
state,
}: {
monitor: MonitorTarget
state: MonitorState
}) {
// const day = new Date() // const day = new Date()
// day.setHours(0, 0, 0, 0) // day.setHours(0, 0, 0, 0)
// const dayStart = day.getTime() // const dayStart = day.getTime()
@ -14,7 +19,6 @@ export default function DetailBar({ monitor, state }: { monitor: MonitorTarget,
const uptimePercentBars = [] const uptimePercentBars = []
for (let i = 89; i > 0; i--) { for (let i = 89; i > 0; i--) {
let dayDownTime = 0 let dayDownTime = 0
let dayTotalTime = 0 let dayTotalTime = 0
@ -22,14 +26,31 @@ export default function DetailBar({ monitor, state }: { monitor: MonitorTarget,
uptimePercentBars.push( uptimePercentBars.push(
<Tooltip key={i} label="Tooltip"> <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> </Tooltip>
) )
} }
return ( 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} {uptimePercentBars}
</Box> </Box>

View File

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

View File

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

View File

@ -1,9 +1,8 @@
import { Text } from '@mantine/core'
import { Text } from "@mantine/core" import { MonitorState, MonitorTarget } from '@/uptime.types'
import { MonitorState, MonitorTarget } from "@/uptime.types" import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-react'
import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react" import DetailChart from './DetailChart'
import DetailChart from "./DetailChart" import DetailBar from './DetailBar'
import DetailBar from "./DetailBar"
function getColor(percent: number | string, darker: boolean): string { function getColor(percent: number | string, darker: boolean): string {
percent = Number(percent) 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 }) { 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>
</>
)
if (!state.latency[monitor.id]) return ( const statusIcon =
<> state.incident[monitor.id].slice(-1)[0].end === undefined ? (
<Text mt='sm' fw={700}>{monitor.name}</Text> <IconAlertCircle
<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> style={{ width: '1.25em', height: '1.25em', color: '#b91c1c' }}
</> />
) ) : (
<IconCircleCheck
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' }} /> style={{ width: '1.25em', height: '1.25em', color: '#059669' }}
/>
)
let totalTime = Date.now() / 1000 - state.incident[monitor.id][0].start[0] let totalTime = Date.now() / 1000 - state.incident[monitor.id][0].start[0]
let downTime = 0 let downTime = 0
for (let incident of state.incident[monitor.id]) { 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(totalTime)
console.log(downTime) console.log(downTime)
const uptimePercent = ((totalTime - downTime) / totalTime * 100).toPrecision(4) const uptimePercent = (
((totalTime - downTime) / totalTime) *
100
).toPrecision(4)
return ( return (
<> <>
<div style={{ display: 'flex', justifyContent: 'space-between' }}> <div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text mt='sm' fw={700} style={{ display: 'inline-flex', alignItems: 'center' }}>{statusIcon} {monitor.name}</Text> <Text
<Text mt='sm' fw={700} style={{ display: 'inline', color: getColor(uptimePercent, true) }}>Overall: {uptimePercent}%</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> </div>
<DetailBar monitor={monitor} state={state} /> <DetailBar monitor={monitor} state={state} />

View File

@ -1,12 +1,26 @@
import { MonitorState, MonitorTarget } from "@/uptime.types"; import { MonitorState, MonitorTarget } from '@/uptime.types'
import { Card, Center, Divider } from "@mantine/core"; import { Card, Center, Divider } from '@mantine/core'
import MonitorDetail from "./MonitorDetail"; import MonitorDetail from './MonitorDetail'
export default function MonitorList({ config, state }: { config: any, state: MonitorState }) {
export default function MonitorList({
config,
state,
}: {
config: any
state: MonitorState
}) {
return ( return (
<Center> <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) => ( {config.monitors.map((monitor: MonitorTarget) => (
<div key={monitor.id}> <div key={monitor.id}>
<Card.Section ml="xs" mr="xs"> <Card.Section ml="xs" mr="xs">
@ -17,6 +31,5 @@ export default function MonitorList({ config, state }: { config: any, state: Mon
))} ))}
</Card> </Card>
</Center> </Center>
) )
} }

View File

@ -1,10 +1,19 @@
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import React from 'react' 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 }) => ( const NoSsr = (props: {
<React.Fragment>{props.children}</React.Fragment> 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), { export default dynamic(() => Promise.resolve(NoSsr), {
ssr: false ssr: false,
}) })

View File

@ -1,27 +1,46 @@
import { Center, Title } from '@mantine/core' import { Center, Title } from '@mantine/core'
import { IconCircleCheck, IconAlertCircle } from '@tabler/icons-react' 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 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) { if (state.overallUp === 0 && state.overallDown === 0) {
statusString = 'No data yet' statusString = 'No data yet'
} else if (state.overallUp === 0) { } else if (state.overallUp === 0) {
statusString = 'All systems not operational' statusString = 'All systems not operational'
} else if (state.overallDown === 0) { } else if (state.overallDown === 0) {
statusString = 'All systems operational' statusString = 'All systems operational'
icon = <IconCircleCheck style={{ width: 64, height: 64, color: '#059669' }} /> icon = (
<IconCircleCheck style={{ width: 64, height: 64, color: '#059669' }} />
)
} else { } 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 (<> return (
<Center> <>
{icon} <Center>{icon}</Center>
</Center> <Title mt="sm" style={{ textAlign: 'center' }} order={1}>
<Title mt="sm" style={{ textAlign: 'center' }} order={1}>{statusString}</Title> {statusString}
<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> </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": "^8.4.31",
"postcss-preset-mantine": "^1.8.0", "postcss-preset-mantine": "^1.8.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prettier": "3.0.3",
"typescript": "^5", "typescript": "^5",
"wrangler": "^3.13.1" "wrangler": "^3.13.1"
} }
@ -3988,6 +3989,21 @@
"node": ">= 0.8.0" "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": { "node_modules/printable-characters": {
"version": "1.0.42", "version": "1.0.42",
"resolved": "https://mirrors.cloud.tencent.com/npm/printable-characters/-/printable-characters-1.0.42.tgz", "resolved": "https://mirrors.cloud.tencent.com/npm/printable-characters/-/printable-characters-1.0.42.tgz",
@ -7905,6 +7921,12 @@
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"dev": true "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": { "printable-characters": {
"version": "1.0.42", "version": "1.0.42",
"resolved": "https://mirrors.cloud.tencent.com/npm/printable-characters/-/printable-characters-1.0.42.tgz", "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": "^8.4.31",
"postcss-preset-mantine": "^1.8.0", "postcss-preset-mantine": "^1.8.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prettier": "3.0.3",
"typescript": "^5", "typescript": "^5",
"wrangler": "^3.13.1" "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 type { AppProps } from 'next/app'
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core'
import NoSsr from '@/components/NoSsr'; import NoSsr from '@/components/NoSsr'
export default function App({ Component, pageProps }: AppProps) { export default function App({ Component, pageProps }: AppProps) {
return ( return (
<NoSsr> <NoSsr>
<MantineProvider defaultColorScheme='auto'> <MantineProvider defaultColorScheme="auto">
<Component {...pageProps} /> <Component {...pageProps} />
</MantineProvider> </MantineProvider>
</NoSsr> </NoSsr>

View File

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

View File

@ -23,39 +23,51 @@ export default function Home({ state }: { state: MonitorState }) {
<main className={inter.className}> <main className={inter.className}>
<Header /> <Header />
{ {state === undefined ? (
state === undefined ? <Center>
( <Text fw={700}>
<Center> Monitor State is not defined now, please check your worker&apos;s
<Text fw={700}> status and KV binding!
Monitor State is not defined now, please check your worker&apos;s status and KV binding! </Text>
</Text> </Center>
</Center> ) : (
) : <div>
( <OverallStatus state={state} />
<div> <MonitorList config={config} state={state} />
<OverallStatus state={state} /> </div>
<MonitorList config={config} state={state} /> )}
</div>
)
}
<Divider mt='lg' /> <Divider mt="lg" />
<Center> <Center>
<Text size='xs' mt='xs' mb='xs'> <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>. 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> </Text>
</Center> </Center>
</main> </main>
</> </>
) )
} }
export async function getServerSideProps() { export async function getServerSideProps() {
const { UPTIMEFLARE_STATE } = process.env as unknown as { UPTIMEFLARE_STATE: KVNamespace } const { UPTIMEFLARE_STATE } = process.env as unknown as {
const state = await UPTIMEFLARE_STATE?.get('state', 'json') as unknown as MonitorState UPTIMEFLARE_STATE: KVNamespace
}
const state = (await UPTIMEFLARE_STATE?.get(
'state',
'json'
)) as unknown as MonitorState
return { props: { state } } return { props: { state } }
} }

View File

@ -2,7 +2,8 @@
height: rem(56px); height: rem(56px);
margin-bottom: rem(120px); margin-bottom: rem(120px);
background-color: var(--mantine-color-body); 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 { .inner {
@ -23,7 +24,10 @@
font-weight: 500; font-weight: 500;
@mixin hover { @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] { [data-mantine-color-scheme] &[data-active] {

View File

@ -1,18 +1,18 @@
const config = { const config = {
dateLocale: "zh-CN", dateLocale: 'zh-CN',
timezone: "Asia/Shanghai", timezone: 'Asia/Shanghai',
page: { page: {
title: "lyc8503's Status Page", title: "lyc8503's Status Page",
links: [ links: [
{ link: 'https://github.com/lyc8503', label: 'GitHub' }, { link: 'https://github.com/lyc8503', label: 'GitHub' },
{ link: 'https://blog.lyc8503.site/', label: 'Blog' }, { 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) => { 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: [ monitors: [
{ {
@ -23,9 +23,9 @@ const config = {
expectedCode: [200], expectedCode: [200],
timeout: 10000, timeout: 10000,
headers: { headers: {
"User-Agent": "Uptimeflare" 'User-Agent': 'Uptimeflare',
}, },
body: undefined body: undefined,
}, },
{ {
id: 'cloudflare', id: 'cloudflare',
@ -35,30 +35,30 @@ const config = {
expectedCode: [200], expectedCode: [200],
timeout: 10000, timeout: 10000,
headers: { headers: {
"User-Agent": "Uptimeflare" 'User-Agent': 'Uptimeflare',
}, },
body: undefined body: undefined,
}, },
{ {
id: 'blog', id: 'blog',
name: 'My Blog', name: 'My Blog',
method: 'GET', method: 'GET',
target: 'https://blog.lyc8503.site', target: 'https://blog.lyc8503.site',
timeout: 10000 timeout: 10000,
}, },
{ {
id: 'pan', id: 'pan',
name: 'My Fileshare', name: 'My Fileshare',
method: 'GET', method: 'GET',
target: 'https://pan.lyc8503.site', target: 'https://pan.lyc8503.site',
timeout: 10000 timeout: 10000,
}, },
{ {
id: 'server', id: 'server',
name: 'My Aliyun Server', name: 'My Aliyun Server',
method: 'GET', method: 'GET',
target: 'https://server.lyc8503.site', target: 'https://server.lyc8503.site',
timeout: 10000 timeout: 10000,
}, },
{ {
id: 'homelab', id: 'homelab',
@ -67,15 +67,22 @@ const config = {
target: 'https://lyc8503.cn4.quickconnect.cn/webstation/hello.txt', target: 'https://lyc8503.cn4.quickconnect.cn/webstation/hello.txt',
timeout: 10000, timeout: 10000,
headers: { 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', id: 'broken-test',
name: 'Ping 1.1.1.1', name: 'Ping 1.1.1.1',
method: 'TCP_PING', method: 'TCP_PING',
target: '1.1.1.1:1234' target: '1.1.1.1:1234',
}, },
{ {
id: '404test', id: '404test',
@ -88,7 +95,7 @@ const config = {
name: '404-test2', name: '404-test2',
method: 'GET', method: 'GET',
target: 'https://www.baidu.com/404', target: 'https://www.baidu.com/404',
expectedCodes: [404, 405] expectedCodes: [404, 405],
}, },
{ {
id: '404test3', id: '404test3',
@ -96,9 +103,9 @@ const config = {
method: 'GET', method: 'GET',
target: 'https://www.baidu.com/404', target: 'https://www.baidu.com/404',
expectedCodes: [404], expectedCodes: [404],
responseKeyword: 'Hello' responseKeyword: 'Hello',
} },
] ],
} }
export default config export default config

View File

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

File diff suppressed because one or more lines are too long

3752
worker/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,16 @@
{ {
"name": "uptimeworker", "name": "uptimeworker",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"vercel-build": "next build", "vercel-build": "next build",
"deploy": "wrangler deploy", "deploy": "wrangler deploy",
"dev": "wrangler dev", "dev": "wrangler dev",
"start": "wrangler dev" "start": "wrangler dev"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20230419.0", "@cloudflare/workers-types": "^4.20230419.0",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"wrangler": "^3.13.1" "wrangler": "^3.13.1"
} }
} }

View File

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

View File

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