Merge branch 'main' into main

pull/140/head
Wu Yuhan 2025-09-13 22:50:38 +08:00 committed by GitHub
commit 64323b6896
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 7975 additions and 4491 deletions

View File

@ -16,10 +16,10 @@ jobs:
with:
terraform_version: 1.6.4
- name: Use Node.js 18.x
- name: Use Node.js 22.x
uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: 22.x
cache: 'npm'
# Automatically get an account id via the API Token
@ -41,13 +41,13 @@ jobs:
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
# This is a temporary workaround to fix issue #13
# On a new Cloudflare account, the terraform apply will fail with `workers.api.error.subdomain_required`
# This may be due to the account not having a worker subdomain yet, so we create a dummy worker and then delete it.
# Cloudflare should allocate a worker subdomain after this.
# https://github.com/cloudflare/terraform-provider-cloudflare/issues/3304
- name: Create worker subdomain
- name: Create worker subdomain (temporary workaround)
id: create_dummy_worker
run: |
curl --request PUT --fail-with-body \
@ -55,7 +55,7 @@ jobs:
--header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN \
--header 'Content-Type: application/javascript' \
--data 'addEventListener('\''fetch'\'', (event) => event.respondWith(new Response('\''OK'\'')))'\
curl --request DELETE --fail-with-body \
--url https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/dummy-ib4db6ntj5csdef3 \
--header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN \
@ -79,23 +79,77 @@ jobs:
run: |
npx @cloudflare/next-on-pages
- name: Deploy using Terraform
# We're using terraform for first-time setup here,
# since we didn't setup a remote backend to store state,
# following runs will fail with name conflict, which is normal.
- name: Remove durable objects bindings (temporary workaround)
continue-on-error: true
# This is a workaround to fix Cloudflare provider 4.x import crash when there's a durable object binding.
run: |
# Get bindings without durable objects
NEW_BINDINGS=$(curl --request GET --fail-with-body \
--url https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/uptimeflare_worker/settings \
--header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN \
--header 'Content-Type: application/json' | jq '.result.bindings | map(select(.type != "durable_object_namespace"))' -jc)
echo "New bindings: $NEW_BINDINGS"
# Remove durable objects bindings
curl --request PATCH --fail-with-body \
--url https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/uptimeflare_worker/settings \
--header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN \
-F 'settings={"bindings":'$NEW_BINDINGS'}'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ steps.fetch_account_id.outputs.account_id }}
- name: Deploy using Terraform
# As we don't save terraform state somewhere, we need to import the existing resources
run: |
terraform init
KV_ID=$(curl https://api.cloudflare.com/client/v4/accounts/$TF_VAR_CLOUDFLARE_ACCOUNT_ID/storage/kv/namespaces\?per_page\=100 --header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN | jq -r '.result[] | select(.title == "uptimeflare_kv") | .id')
if [ -n "$KV_ID" ]; then
echo "Importing existing resources..."
terraform import cloudflare_workers_kv_namespace.uptimeflare_kv "$TF_VAR_CLOUDFLARE_ACCOUNT_ID/$KV_ID"
terraform import cloudflare_worker_script.uptimeflare "$TF_VAR_CLOUDFLARE_ACCOUNT_ID/uptimeflare_worker"
terraform import cloudflare_worker_cron_trigger.uptimeflare_worker_cron "$TF_VAR_CLOUDFLARE_ACCOUNT_ID/uptimeflare_worker"
terraform import cloudflare_pages_project.uptimeflare "$TF_VAR_CLOUDFLARE_ACCOUNT_ID/uptimeflare"
else
echo "KV namespace not found, first-time setup."
fi
terraform apply -auto-approve -input=false
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
TF_VAR_CLOUDFLARE_ACCOUNT_ID: ${{ steps.fetch_account_id.outputs.account_id }}
# Still need to upload worker to keep it up-to-date (Terraform will fail after first-time setup)
- name: Upload worker
# Terraform Cloudflare provider 4.x doesn't support durable objects, provider 5.x has unresolved issues blocking the deployment. (cloudflare/terraform-provider-cloudflare#5412)
# So I have to manually add durable objects bindings here.
- name: Add durable objects bindings
run: |
curl --fail-with-body -X PUT https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/uptimeflare_worker/content --header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN -F 'index.js=@worker/dist/index.js;type=application/javascript+module' -F 'metadata={"main_module": "index.js"}'
# Get current bindings
CURRENT_BINDINGS=$(curl --request GET --fail-with-body \
--url https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/uptimeflare_worker/settings \
--header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN \
--header 'Content-Type: application/json' | jq '.result.bindings' -jc)
CURRENT_BINDINGS="${CURRENT_BINDINGS:1:-1}"
echo "Current bindings: $CURRENT_BINDINGS"
# Try migration first (required for the new durable object class, ignore failures)
curl --request PATCH \
--url https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/uptimeflare_worker/settings \
--header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN \
-F 'settings={"bindings":[{"type":"durable_object_namespace","name":"REMOTE_CHECKER_DO","class_name":"RemoteChecker"},'$CURRENT_BINDINGS'],"migrations":{"new_sqlite_classes":["RemoteChecker"],"new_tag":"v1"}}'
# Actually add the binding
curl --request PATCH --fail-with-body \
--url https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/uptimeflare_worker/settings \
--header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN \
-F 'settings={"bindings":[{"type":"durable_object_namespace","name":"REMOTE_CHECKER_DO","class_name":"RemoteChecker"},'$CURRENT_BINDINGS']}'
# By the ways enable logs
curl --request PATCH \
--url https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts/uptimeflare_worker/script-settings \
--header 'Authorization: Bearer '$CLOUDFLARE_API_TOKEN \
--header 'Content-Type: application/json' \
-d '{"observability":{"enabled":true}}'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ steps.fetch_account_id.outputs.account_id }}

18
.github/workflows/issue_translate.yml vendored Normal file
View File

@ -0,0 +1,18 @@
name: 'issue-translator'
on:
issue_comment:
types: [created]
issues:
types: [opened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: usthe/issues-translate-action@v2.7
with:
IS_MODIFY_TITLE: false
# not require, default false, . Decide whether to modify the issue title
# if true, the robot account @Issues-translate-bot must have modification permissions, invite @Issues-translate-bot to your project or use your custom bot.
CUSTOM_BOT_NOTE: Bot detected the issue body's language is not English, translate it automatically. 👯👭🏻🧑‍🤝‍🧑👫🧑🏿‍🤝‍🧑🏻👩🏾‍🤝‍👨🏿👬🏿
# not require. Customize the translation robot prefix message.

View File

@ -37,7 +37,7 @@ jobs:
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Fetch latest code
git clone https://github.com/lyc8503/UptimeFlare /tmp/latest
rm -rf /tmp/latest/.git
@ -49,8 +49,8 @@ jobs:
git add .
git commit -m "Sync latest code from upstream"
git push
- name: Trigger deployment
uses: benc-uk/workflow-dispatch@v1
with:
workflow: deploy.yml
workflow: deploy.yml

View File

@ -2,4 +2,4 @@ trailingComma: 'es5'
tabWidth: 2
semi: false
singleQuote: true
printWidth: 100
printWidth: 100

View File

@ -8,6 +8,7 @@
A more advanced, serverless, and free uptime monitoring & status page solution, powered by Cloudflare Workers, complete with a user-friendly interface.
## ⭐Features
- Open-source, easy to deploy (in under 10 minutes, no local tools required), and free
- Monitoring capabilities
- Up to 50 checks at 1-minute intervals
@ -49,8 +50,14 @@ Please refer to [Wiki](https://github.com/lyc8503/UptimeFlare/wiki)
- [x] Improve docs by providing simple examples
- [x] Notification grace period
- [ ] SSL certificate checks
- [ ] Self-host Dockerfile
- [ ] Incident timeline
- [ ] Improve `checkLocationWorkerRoute` and fix possible `proxy failed`
- [ ] Groups
- [x] ~~Self-host Dockerfile~~
- [x] Incident history
- [x] Improve `checkLocationWorkerRoute` and fix possible `proxy failed`
- [x] Groups
- [x] Remove old incidents
- [x] ~~Known issue~~: `fetch` doesn't support non-standard port (resolved after CF update)
- [x] Compatibility date update
- [x] Scheduled Maintenance
- [ ] Update wiki/README and add docs for dev
- [ ] Migration to Terraform Cloudflare provider version 5.x
- [ ] Cloudflare D1 database

View File

@ -8,6 +8,7 @@
一个由 Cloudflare Workers 驱动的功能丰富、Serverless 且免费的 Uptime 监控及状态页面。
## ⭐功能
- 开源,易于部署(全程无需本地工具,耗时不到 10 分钟),且完全免费
- 监控功能
- 最多支持 50 个 1 分钟精度的检查

View File

@ -1,8 +1,8 @@
import { MonitorState, MonitorTarget } from '@/uptime.types'
import { MonitorState, MonitorTarget } from '@/types/config'
import { getColor } from '@/util/color'
import { Box, Tooltip } from '@mantine/core'
import { Box, Tooltip, Modal } from '@mantine/core'
import { useResizeObserver } from '@mantine/hooks'
import { useLayoutEffect, useRef, useState } from 'react'
import { useState } from 'react'
const moment = require('moment')
require('moment-precise-range-plugin')
@ -14,6 +14,9 @@ export default function DetailBar({
state: MonitorState
}) {
const [barRef, barRect] = useResizeObserver()
const [modalOpened, setModalOpened] = useState(false)
const [modalTitle, setModalTitle] = useState('')
const [modelContent, setModelContent] = useState(<div />)
const overlapLen = (x1: number, x2: number, y1: number, y2: number) => {
return Math.max(0, Math.min(x2, y2) - Math.max(x1, y1))
@ -34,11 +37,37 @@ export default function DetailBar({
const dayMonitorTime = overlapLen(dayStart, dayEnd, montiorStartTime, currentTime)
let dayDownTime = 0
let incidentReasons: string[] = []
for (let incident of state.incident[monitor.id]) {
const incidentStart = incident.start[0]
const incidentEnd = incident.end ?? currentTime
dayDownTime += overlapLen(dayStart, dayEnd, incidentStart, incidentEnd)
const overlap = overlapLen(dayStart, dayEnd, incidentStart, incidentEnd)
dayDownTime += overlap
// Incident history for the day
if (overlap > 0) {
for (let i = 0; i < incident.error.length; i++) {
let partStart = incident.start[i]
let partEnd =
i === incident.error.length - 1 ? incident.end ?? currentTime : incident.start[i + 1]
partStart = Math.max(partStart, dayStart)
partEnd = Math.min(partEnd, dayEnd)
if (overlapLen(dayStart, dayEnd, partStart, partEnd) > 0) {
const startStr = new Date(partStart * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
const endStr = new Date(partEnd * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
incidentReasons.push(`[${startStr}-${endStr}] ${incident.error[i]}`)
}
}
}
}
const dayPercent = (((dayMonitorTime - dayDownTime) / dayMonitorTime) * 100).toPrecision(4)
@ -55,9 +84,11 @@ export default function DetailBar({
<>
<div>{dayPercent + '% at ' + new Date(dayStart * 1000).toLocaleDateString()}</div>
{dayDownTime > 0 && (
<div>{`Down for ${moment.preciseDiff(moment(0), moment(dayDownTime * 1000))}`}</div>
<div>{`Down for ${moment.preciseDiff(
moment(0),
moment(dayDownTime * 1000)
)} (click for detail)`}</div>
)}
{/* TODO: lantency detail for each bar */}
</>
)
}
@ -71,6 +102,21 @@ export default function DetailBar({
marginLeft: '1px',
marginRight: '1px',
}}
onClick={() => {
if (dayDownTime > 0) {
setModalTitle(
`🚨 ${monitor.name} incidents at ${new Date(dayStart * 1000).toLocaleDateString()}`
)
setModelContent(
<>
{incidentReasons.map((reason, index) => (
<div key={index}>{reason}</div>
))}
</>
)
setModalOpened(true)
}
}}
/>
</Tooltip>
)
@ -78,6 +124,14 @@ export default function DetailBar({
return (
<>
<Modal
opened={modalOpened}
onClose={() => setModalOpened(false)}
title={modalTitle}
size={'40em'}
>
{modelContent}
</Modal>
<Box
style={{
display: 'flex',

View File

@ -11,7 +11,7 @@ import {
TimeScale,
} from 'chart.js'
import 'chartjs-adapter-moment'
import { MonitorState, MonitorTarget } from '@/uptime.types'
import { MonitorState, MonitorTarget } from '@/types/config'
import { iataToCountry } from '@/util/iata'
ChartJS.register(

View File

@ -1,9 +1,10 @@
import { Container, Group, Text } from '@mantine/core'
import classes from '@/styles/Header.module.css'
import { pageConfig } from '@/uptime.config'
import { PageConfigLink } from '@/types/config'
export default function Header() {
const linkToElement = (link: { label: string; link: string; highlight?: boolean }) => {
const linkToElement = (link: PageConfigLink) => {
return (
<a
key={link.label}
@ -35,11 +36,11 @@ export default function Header() {
</div>
<Group gap={5} visibleFrom="sm">
{pageConfig.links.map(linkToElement)}
{pageConfig.links?.map(linkToElement)}
</Group>
<Group gap={5} hiddenFrom="sm">
{pageConfig.links.filter((link) => (link as any).highlight).map(linkToElement)}
{pageConfig.links?.filter((link) => link.highlight).map(linkToElement)}
</Group>
</Container>
</header>

View File

@ -0,0 +1,53 @@
import { Alert, List, Text } from '@mantine/core'
import { IconAlertTriangle } from '@tabler/icons-react'
import { MaintenanceConfig, MonitorTarget } from '@/types/config'
export default function MaintenanceAlert({
maintenance,
style,
}: {
maintenance: Omit<MaintenanceConfig, 'monitors'> & { monitors?: MonitorTarget[] }
style?: React.CSSProperties
}) {
return (
<Alert
icon={<IconAlertTriangle />}
title={maintenance.title || 'Scheduled Maintenance'}
color={maintenance.color || 'yellow'}
withCloseButton={false}
style={{ position: 'relative', margin: '16px auto 0 auto', ...style }}
>
{/* Date range in top right */}
<div
style={{
position: 'absolute',
top: 10,
right: 10,
fontSize: '0.85rem',
textAlign: 'right',
padding: '2px 8px',
borderRadius: 6,
}}
>
<b>From:</b> {new Date(maintenance.start).toLocaleString()}
<br />
<b>To:</b>{' '}
{maintenance.end ? new Date(maintenance.end).toLocaleString() : 'Until further notice'}
</div>
<Text>{maintenance.body}</Text>
{maintenance.monitors && maintenance.monitors.length > 0 && (
<>
<Text mt="xs">
<b>Affected components:</b>
</Text>
<List size="sm" withPadding>
{maintenance.monitors.map((comp, compIdx) => (
<List.Item key={compIdx}>{comp.name}</List.Item>
))}
</List>
</>
)}
</Alert>
)
}

View File

@ -1,9 +1,10 @@
import { Text, Tooltip } from '@mantine/core'
import { MonitorState, MonitorTarget } from '@/uptime.types'
import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-react'
import { MonitorState, MonitorTarget } from '@/types/config'
import { IconAlertCircle, IconAlertTriangle, IconCircleCheck } from '@tabler/icons-react'
import DetailChart from './DetailChart'
import DetailBar from './DetailBar'
import { getColor } from '@/util/color'
import { maintenances } from '@/uptime.config'
export default function MonitorDetail({
monitor,
@ -24,11 +25,32 @@ export default function MonitorDetail({
</>
)
const statusIcon =
let statusIcon =
state.incident[monitor.id].slice(-1)[0].end === undefined ? (
<IconAlertCircle style={{ width: '1.25em', height: '1.25em', color: '#b91c1c' }} />
<IconAlertCircle
style={{ width: '1.25em', height: '1.25em', color: '#b91c1c', marginRight: '3px' }}
/>
) : (
<IconCircleCheck style={{ width: '1.25em', height: '1.25em', color: '#059669' }} />
<IconCircleCheck
style={{ width: '1.25em', height: '1.25em', color: '#059669', marginRight: '3px' }}
/>
)
// Hide real status icon if monitor is in maintenance
const now = new Date()
const hasMaintenance = maintenances
.filter((m) => now >= new Date(m.start) && (!m.end || now <= new Date(m.end)))
.find((maintenance) => maintenance.monitors?.includes(monitor.id))
if (hasMaintenance)
statusIcon = (
<IconAlertTriangle
style={{
width: '1.25em',
height: '1.25em',
color: '#fab005',
marginRight: '3px',
}}
/>
)
let totalTime = Date.now() / 1000 - state.incident[monitor.id][0].start[0]
@ -43,7 +65,11 @@ export default function MonitorDetail({
const monitorNameElement = (
<Text mt="sm" fw={700} style={{ display: 'inline-flex', alignItems: 'center' }}>
{monitor.statusPageLink ? (
<a href={monitor.statusPageLink} target="_blank" style={{ display: 'inline-flex', alignItems: 'center', color: 'inherit' }}>
<a
href={monitor.statusPageLink}
target="_blank"
style={{ display: 'inline-flex', alignItems: 'center', color: 'inherit' }}
>
{statusIcon} {monitor.name}
</a>
) : (
@ -69,7 +95,7 @@ export default function MonitorDetail({
</div>
<DetailBar monitor={monitor} state={state} />
<DetailChart monitor={monitor} state={state} />
{!monitor.hideLatencyChart && <DetailChart monitor={monitor} state={state} />}
</>
)
}

View File

@ -1,28 +1,128 @@
import { MonitorState, MonitorTarget } from '@/uptime.types'
import { Card, Center, Divider } from '@mantine/core'
import { MonitorState, MonitorTarget } from '@/types/config'
import { Accordion, Card, Center, Text } from '@mantine/core'
import MonitorDetail from './MonitorDetail'
import { pageConfig } from '@/uptime.config'
import { useEffect, useState } from 'react'
function countDownCount(state: MonitorState, ids: string[]) {
let downCount = 0
for (let id of ids) {
if (state.incident[id] === undefined || state.incident[id].length === 0) {
continue
}
if (state.incident[id].slice(-1)[0].end === undefined) {
downCount++
}
}
return downCount
}
function getStatusTextColor(state: MonitorState, ids: string[]) {
let downCount = countDownCount(state, ids)
if (downCount === 0) {
return '#059669'
} else if (downCount === ids.length) {
return '#df484a'
} else {
return '#f29030'
}
}
export default function MonitorList({
monitors,
state,
}: {
monitors: MonitorTarget[]
state: MonitorState
}) {
const group = pageConfig.group
const groupedMonitor = group && Object.keys(group).length > 0
let content
// Load expanded groups from localStorage
const savedExpandedGroups = localStorage.getItem('expandedGroups')
const expandedInitial = savedExpandedGroups ? JSON.parse(savedExpandedGroups) : Object.keys(group || {})
const [expandedGroups, setExpandedGroups] = useState<string[]>(expandedInitial)
useEffect(() => {
localStorage.setItem('expandedGroups', JSON.stringify(expandedGroups))
}, [expandedGroups])
if (groupedMonitor) {
// Grouped monitors
content = (
<Accordion
multiple
defaultValue={Object.keys(group)}
variant="contained"
value={expandedGroups}
onChange={(values) => setExpandedGroups(values)}
>
{Object.keys(group).map((groupName) => (
<Accordion.Item key={groupName} value={groupName}>
<Accordion.Control>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
width: '100%',
alignItems: 'center',
}}
>
<div>{groupName}</div>
<Text
fw={500}
style={{
display: 'inline',
paddingRight: '5px',
color: getStatusTextColor(state, group[groupName]),
}}
>
{group[groupName].length - countDownCount(state, group[groupName])}/
{group[groupName].length} Operational
</Text>
</div>
</Accordion.Control>
<Accordion.Panel>
{monitors
.filter((monitor) => group[groupName].includes(monitor.id))
.sort((a, b) => group[groupName].indexOf(a.id) - group[groupName].indexOf(b.id))
.map((monitor) => (
<div key={monitor.id}>
<Card.Section ml="xs" mr="xs">
<MonitorDetail monitor={monitor} state={state} />
</Card.Section>
</div>
))}
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
)
} else {
// Ungrouped monitors
content = monitors.map((monitor) => (
<div key={monitor.id}>
<Card.Section ml="xs" mr="xs">
<MonitorDetail monitor={monitor} state={state} />
</Card.Section>
</div>
))
}
export default function MonitorList({ monitors, state }: { monitors: any; state: MonitorState }) {
return (
<Center>
<Card
shadow="sm"
padding="lg"
radius="md"
ml="xl"
mr="xl"
ml="md"
mr="md"
mt="xl"
withBorder
style={{ width: '865px' }}
withBorder={!groupedMonitor}
style={{ width: groupedMonitor ? '897px' : '865px' }}
>
{monitors.map((monitor: MonitorTarget) => (
<div key={monitor.id}>
<Card.Section ml="xs" mr="xs">
<MonitorDetail monitor={monitor} state={state} />
</Card.Section>
<Divider />
</div>
))}
{content}
</Card>
</Center>
)

View File

@ -1,31 +1,32 @@
import { Center, Title } from '@mantine/core'
import { MaintenanceConfig, MonitorTarget } from '@/types/config'
import { Center, Container, Title } from '@mantine/core'
import { IconCircleCheck, IconAlertCircle } from '@tabler/icons-react'
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'react'
import MaintenanceAlert from './MaintenanceAlert'
import { pageConfig } from '@/uptime.config'
function useWindowVisibility() {
const [isVisible, setIsVisible] = useState(true);
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
const handleVisibilityChange = () => {
console.log('visibility change', document.visibilityState);
setIsVisible(document.visibilityState === 'visible');
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
return isVisible;
const handleVisibilityChange = () => setIsVisible(document.visibilityState === 'visible')
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
}, [])
return isVisible
}
export default function OverallStatus({
state,
maintenances,
monitors,
}: {
state: { overallUp: number; overallDown: number; lastUpdate: number }
maintenances: MaintenanceConfig[]
monitors: MonitorTarget[]
}) {
let group = pageConfig.group
let groupedMonitor = (group && Object.keys(group).length > 0) || false
let statusString = ''
let icon = <IconAlertCircle style={{ width: 64, height: 64, color: '#b91c1c' }} />
if (state.overallUp === 0 && state.overallDown === 0) {
@ -36,38 +37,57 @@ export default function OverallStatus({
statusString = '所有系统均正常运转'
icon = <IconCircleCheck style={{ width: 64, height: 64, color: '#059669' }} />
} else {
statusString = `部分系统未正常运转(${state.overallDown}个异常,共计${state.overallUp + state.overallDown}个)`
statusString = `部分系统未正常运转(${state.overallDown}个异常,共计${
state.overallUp + state.overallDown
}`
}
const [openTime] = useState(Math.round(Date.now() / 1000))
const [currentTime, setCurrentTime] = useState(Math.round(Date.now() / 1000))
const isWindowVisible = useWindowVisibility();
const isWindowVisible = useWindowVisibility()
useEffect(() => {
const interval = setInterval(() => {
if (!isWindowVisible) {
return
}
if (!isWindowVisible) return
if (currentTime - state.lastUpdate > 300 && currentTime - openTime > 30) {
// trigger a re-fetch
window.location.reload()
}
setCurrentTime(Math.round(Date.now() / 1000))
}, 1000)
return () => clearInterval(interval)
})
const now = new Date()
let filteredMaintenances: (Omit<MaintenanceConfig, 'monitors'> & { monitors?: MonitorTarget[] })[] =
maintenances
.filter((m) => now >= new Date(m.start) && (!m.end || now <= new Date(m.end)))
.map((maintenance) => ({
...maintenance,
monitors: maintenance.monitors?.map(
(monitorId) => monitors.find((mon) => monitorId === mon.id)!
),
}))
return (
<>
<Container size="md" mt="xl">
<Center>{icon}</Center>
<Title mt="sm" style={{ textAlign: 'center' }} order={1}>
{statusString}
</Title>
<Title mt="sm" style={{ textAlign: 'center', color: '#70778c' }} order={5}>
{' '}
{`${new Date(state.lastUpdate * 1000).toLocaleString()} ${currentTime - state.lastUpdate} 秒之前)`}
{`${new Date(state.lastUpdate * 1000).toLocaleString()} (${
currentTime - state.lastUpdate
} )`}
</Title>
</>
{filteredMaintenances.map((maintenance, idx) => (
<MaintenanceAlert
key={idx}
maintenance={maintenance}
style={{ maxWidth: groupedMonitor ? '897px' : '865px' }}
/>
))}
</Container>
)
}

View File

@ -26,7 +26,7 @@ resource "cloudflare_worker_script" "uptimeflare" {
name = "uptimeflare_worker"
content = file("worker/dist/index.js")
module = true
compatibility_date = "2023-11-08"
compatibility_date = "2025-04-02"
kv_namespace_binding {
name = "UPTIMEFLARE_STATE"
@ -52,7 +52,7 @@ resource "cloudflare_pages_project" "uptimeflare" {
kv_namespaces = {
UPTIMEFLARE_STATE = cloudflare_workers_kv_namespace.uptimeflare_kv.id
}
compatibility_date = "2023-11-08"
compatibility_date = "2025-04-02"
compatibility_flags = ["nodejs_compat"]
}
}

12
env.d.ts vendored
View File

@ -1,9 +1,9 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
UPTIMEFLARE_STATE: KVNamespace;
}
}
namespace NodeJS {
interface ProcessEnv {
UPTIMEFLARE_STATE: KVNamespace
}
}
}
export {};
export {}

View File

@ -1,9 +1,8 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { workerConfig } from './uptime.config'
export async function middleware(request: NextRequest) {
// @ts-ignore
const passwordProtection = workerConfig.passwordProtection
if (passwordProtection) {
const authHeader = request.headers.get('Authorization')
@ -12,15 +11,16 @@ export async function middleware(request: NextRequest) {
if (authHeader && authHeader.length === expected.length) {
// a simple timing-safe compare
authenticated = true;
authenticated = true
for (let i = 0; i < authHeader.length; i++) {
if (authHeader[i] !== expected[i]) authenticated = false;
if (authHeader[i] !== expected[i]) authenticated = false
}
}
if (!authenticated) {
return NextResponse.json(
{ code: 401, message: "Not authenticated" }, { status: 401, headers: { 'WWW-Authenticate': 'Basic' } }
{ code: 401, message: 'Not authenticated' },
{ status: 401, headers: { 'WWW-Authenticate': 'Basic' } }
)
}
}

View File

@ -11,8 +11,8 @@ if (process.env.NODE_ENV === 'development') {
bindings: {
UPTIMEFLARE_STATE: {
type: 'kv',
id: 'UPTIMEFLARE_STATE'
}
}
id: 'UPTIMEFLARE_STATE',
},
},
})
}

6973
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@
"lint": "next lint"
},
"dependencies": {
"@cloudflare/workers-types": "^4.20231010.0",
"@cloudflare/workers-types": "^4.20250410.0",
"@mantine/core": "^7.1.3",
"@mantine/ds": "^7.1.3",
"@mantine/hooks": "^7.1.3",
@ -20,23 +20,23 @@
"chartjs-adapter-moment": "^1.0.1",
"moment": "^2.29.4",
"moment-precise-range-plugin": "^1.3.0",
"next": "13.5.4",
"react": "^18",
"next": "^14.2.28",
"react": "^18.3.1",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18"
"react-dom": "^18.3.1"
},
"devDependencies": {
"@cloudflare/next-on-pages": "^1.8.5",
"@cloudflare/next-on-pages": "^1.13.12",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "13.5.4",
"eslint-config-next": "^14.2.28",
"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"
"wrangler": "^4.10.0"
}
}

View File

@ -1,11 +1,10 @@
import { workerConfig } from "@/uptime.config";
import { MonitorState } from "@/uptime.types";
import { NextRequest } from "next/server";
import { workerConfig } from '@/uptime.config'
import { MonitorState } from '@/types/config'
import { NextRequest } from 'next/server'
export const runtime = 'edge'
export default async function handler(req: NextRequest): Promise<Response> {
const { UPTIMEFLARE_STATE } = process.env as unknown as {
UPTIMEFLARE_STATE: KVNamespace
}
@ -15,11 +14,11 @@ export default async function handler(req: NextRequest): Promise<Response> {
return new Response(JSON.stringify({ error: 'No data available' }), {
status: 500,
headers: {
'Content-Type': 'application/json'
}
'Content-Type': 'application/json',
},
})
}
const state = (JSON.parse(stateStr)) as unknown as MonitorState
const state = JSON.parse(stateStr) as unknown as MonitorState
let monitors: any = {}
@ -29,7 +28,7 @@ export default async function handler(req: NextRequest): Promise<Response> {
up: isUp,
latency: state.latency[monitor.id].recent.slice(-1)[0].ping,
location: state.latency[monitor.id].recent.slice(-1)[0].loc,
message: isUp ? "OK" : state.incident[monitor.id].slice(-1)[0].error.slice(-1)[0]
message: isUp ? 'OK' : state.incident[monitor.id].slice(-1)[0].error.slice(-1)[0],
}
}
@ -37,12 +36,12 @@ export default async function handler(req: NextRequest): Promise<Response> {
up: state.overallUp,
down: state.overallDown,
updatedAt: state.lastUpdate,
monitors: monitors
monitors: monitors,
}
return new Response(JSON.stringify(ret), {
headers: {
'Content-Type': 'application/json'
}
'Content-Type': 'application/json',
},
})
}
}

View File

@ -1,9 +1,9 @@
import Head from 'next/head'
import { Inter } from 'next/font/google'
import { MonitorState, MonitorTarget } from '@/uptime.types'
import { MonitorState, MonitorTarget } from '@/types/config'
import { KVNamespace } from '@cloudflare/workers-types'
import { pageConfig, workerConfig } from '@/uptime.config'
import { maintenances, pageConfig, workerConfig } from '@/uptime.config'
import OverallStatus from '@/components/OverallStatus'
import Header from '@/components/Header'
import MonitorList from '@/components/MonitorList'
@ -22,21 +22,17 @@ export default function Home({
tooltip?: string
statusPageLink?: string
}) {
let state;
let state
if (stateStr !== undefined) {
state = JSON.parse(stateStr) as MonitorState
}
// Specify monitorId in URL hash to view a specific monitor (can be used in iframe)
const monitorId = window.location.hash.substring(1);
const monitorId = window.location.hash.substring(1)
if (monitorId) {
const monitor = monitors.find((monitor) => monitor.id === monitorId);
const monitor = monitors.find((monitor) => monitor.id === monitorId)
if (!monitor || !state) {
return (
<Text fw={700}>
Monitor with id {monitorId} not found!
</Text>
)
return <Text fw={700}>Monitor with id {monitorId} not found!</Text>
}
return (
<div style={{ maxWidth: '810px' }}>
@ -55,7 +51,7 @@ export default function Home({
<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
@ -64,16 +60,21 @@ export default function Home({
</Center>
) : (
<div>
<OverallStatus state={state} />
<OverallStatus state={state} monitors={monitors} maintenances={maintenances} />
<MonitorList monitors={monitors} state={state} />
</div>
)}
<Divider mt="lg" />
<Text size="xs" mt="xs" mb="xs" style={{
textAlign: 'center'
}}>
{' '}
<Text
size="xs"
mt="xs"
mb="xs"
style={{
textAlign: 'center',
}}
>
{' '}
<a href="https://github.com/lyc8503/UptimeFlare" target="_blank">
Uptimeflare
</a>{' '}
@ -112,7 +113,9 @@ export async function getServerSideProps() {
// @ts-ignore
tooltip: monitor?.tooltip,
// @ts-ignore
statusPageLink: monitor?.statusPageLink
statusPageLink: monitor?.statusPageLink,
// @ts-ignore
hideLatencyChart: monitor?.hideLatencyChart,
}
})

1
proxy/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

169
proxy/api/index.js Normal file
View File

@ -0,0 +1,169 @@
// This is a Node.js implementation of status monitoring
const location = ''
const defaultTimeout = 5000 // 5 seconds, a lower default for deployments on platforms like Vercel
const express = require('express')
const net = require('net')
const app = express()
const port = 3000
async function getWorkerLocation() {
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 fetchTimeout = (url, ms, options = {}) => {
const controller = new AbortController()
const promise = fetch(url, { signal: controller.signal, ...options })
const timeout = setTimeout(() => controller.abort(), ms)
return promise.finally(() => clearTimeout(timeout))
}
// TODO: More code reuse here
async function getStatus(monitor) {
let status = {
ping: 0,
up: false,
err: 'Unknown',
}
const startTime = Date.now()
if (monitor.method === 'TCP_PING') {
// TCP port endpoint monitor
let host, port
try {
// This is not a real https connection, but we need to add a dummy `https://` to parse the hostname & port
// TODO: ipv6 buggy
const parsed = new URL('https://' + monitor.target)
host = parsed.hostname
port = parsed.port
await new Promise((resolve, reject) => {
const socket = net.createConnection({ host: host, port: Number(port) })
const timer = setTimeout(() => {
socket.destroy()
reject(new Error(`Timeout after ${monitor.timeout || defaultTimeout}ms`))
}, monitor.timeout || defaultTimeout)
socket.on('connect', () => {
clearTimeout(timer)
socket.end()
resolve(null)
})
socket.on('error', (err) => {
clearTimeout(timer)
socket.destroy()
reject(err)
})
})
status.up = true
status.err = ''
status.ping = Date.now() - startTime
} catch (e) {
console.log(`${monitor.name} errored with ${e.name}: ${e.message}`)
status.up = false
status.err = e.name + ': ' + e.message.replace(host, '<redacted>').replace(port, '<redacted>')
status.ping = Date.now() - startTime
}
} else {
// HTTP endpoint monitor
try {
const response = await fetchTimeout(monitor.target, monitor.timeout || defaultTimeout, {
method: monitor.method,
headers: monitor.headers,
body: monitor.body,
})
console.log(`${monitor.name} responded with ${response.status}`)
status.ping = Date.now() - startTime
if (monitor.expectedCodes) {
if (!monitor.expectedCodes.includes(response.status)) {
console.log(`${monitor.name} expected ${monitor.expectedCodes}, got ${response.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) {
console.log(`${monitor.name} expected 2xx, got ${response.status}`)
status.up = false
status.err = `Expected codes: 2xx, Got: ${response.status}`
return status
}
}
if (monitor.responseKeyword || monitor.responseForbiddenKeyword) {
// Only read response body if we have a keyword to check
const responseBody = await response.text()
// MUST contain responseKeyword
if (monitor.responseKeyword && !responseBody.includes(monitor.responseKeyword)) {
console.log(
`${monitor.name} expected keyword ${
monitor.responseKeyword
}, not found in response (truncated to 100 chars): ${responseBody.slice(0, 100)}`
)
status.up = false
status.err = "HTTP response doesn't contain the configured keyword"
return status
}
// MUST NOT contain responseForbiddenKeyword
if (
monitor.responseForbiddenKeyword &&
responseBody.includes(monitor.responseForbiddenKeyword)
) {
console.log(
`${monitor.name} forbidden keyword ${
monitor.responseForbiddenKeyword
}, found in response (truncated to 100 chars): ${responseBody.slice(0, 100)}`
)
status.up = false
status.err = 'HTTP response contains the configured forbidden keyword'
return status
}
}
status.up = true
status.err = ''
} catch (e) {
console.log(`${monitor.name} errored with ${e.name}: ${e.message}`)
if (e.name === 'AbortError') {
status.ping = monitor.timeout || defaultTimeout
status.up = false
status.err = `Timeout after ${status.ping}ms`
} else {
status.up = false
status.err = e.name + ': ' + e.message
}
}
}
return status
}
app.use(express.json())
app.post('/', async (req, res) => {
res.json({
location: await getWorkerLocation(),
status: await getStatus(req.body),
})
})
app.listen(port, () => {
console.log(`App listening on port ${port}`)
})
module.exports = app

1261
proxy/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

12
proxy/package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "uptimeflare_proxy",
"version": "0.0.0",
"description": "",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"express": "^5.1.0"
}
}

1
proxy/vercel.json Normal file
View File

@ -0,0 +1 @@
{ "version": 2, "rewrites": [{ "source": "/(.*)", "destination": "/api" }] }

106
types/config.ts Normal file
View File

@ -0,0 +1,106 @@
import type { Env } from '../worker/src'
export type PageConfig = {
title?: string
links?: PageConfigLink[]
group?: PageConfigGroup
}
export type MaintenanceConfig = {
monitors?: string[]
title?: string
body: string
start: number | string
end?: number | string
color?: string
}
export type PageConfigGroup = { [key: string]: string[] }
export type PageConfigLink = {
link: string
label: string
highlight?: boolean
}
export type MonitorTarget = {
id: string
name: string
method: string
target: string
tooltip?: string
statusPageLink?: string
hideLatencyChart?: boolean
expectedCodes?: number[]
timeout?: number
headers?: { [key: string]: string | number }
body?: string
responseKeyword?: string
responseForbiddenKeyword?: string
checkProxy?: string
checkProxyFallback?: boolean
}
export type WorkerConfig<TEnv = Env> = {
kvWriteCooldownMinutes: number
passwordProtection?: string
monitors: MonitorTarget[]
notification?: Notification
callbacks?: Callbacks<TEnv>
}
export type Notification = {
appriseApiServer?: string
recipientUrl?: string
timeZone?: string
gracePeriod?: number
skipNotificationIds?: string[]
}
export type Callbacks<TEnv = Env> = {
onStatusChange?: (
env: TEnv,
monitor: MonitorTarget,
isUp: boolean,
timeIncidentStart: number,
timeNow: number,
reason: string
) => Promise<any> | any
onIncident?: (
env: TEnv,
monitor: MonitorTarget,
timeIncidentStart: number,
timeNow: number,
reason: string
) => Promise<any> | any
}
export type MonitorState = {
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,
{
recent: {
loc: string
ping: number
time: number
}[] // recent 12 hour data, 2 min interval
all: {
loc: string
ping: number
time: number
}[] // all data in 90 days, 1 hour interval
}
>
}

View File

@ -1,42 +1,86 @@
const pageConfig = {
import { MaintenanceConfig, PageConfig, WorkerConfig } from './types/config'
const pageConfig: PageConfig = {
// Title for your status page
title: "凌云·LinYun 服务状态",
// Links shown at the header of your status page, could set `highlight` to `true`
links: [
{ link: 'https://www.linyunlink.top/links/', label: '友情链接'},
{ link: 'https://www.linyunlink.top/', label: '博客', highlight: true },
],
// [OPTIONAL] Group your monitors
// If not specified, all monitors will be shown in a single list
// If specified, monitors will be grouped and ordered, not-listed monitors will be invisble (but still monitored)
group: {
'个人网站': ['linyun_homepage_monitor', 'linyun_blog_monitor', 'linyun_drive_monitor'],
'接口': ['linyun_image_monitor', 'linyun_twikoo_monitor'],
},
}
const workerConfig = {
// Write KV at most every 3 minutes unless the status changed.
kvWriteCooldownMinutes: 10,
const workerConfig: WorkerConfig = {
// Write KV at most every 3 minutes unless the status changed
kvWriteCooldownMinutes: 5,
// Enable HTTP Basic auth for status page & API by uncommenting the line below, format `<USERNAME>:<PASSWORD>`
// passwordProtection: 'username:password',
// Define all your monitors here
monitors: [
// ==========[凌云服务监控]==========
{
id: 'linyun_blog_monitor',
name: '凌云·LinYun 博客',
id: 'linyun_homepage_monitor',
name: '凌云·LinYun 主页',
method: 'GET',
target: 'https://www.linyunlink.top/',
tooltip: '凌云·LinYun 博客',
statusPageLink: 'https://www.linyunlink.top/',
},
{
id: 'linyun_blog_monitor',
name: '凌云·LinYun 博客',
method: 'GET',
target: 'https://blog.linyunlink.top/',
tooltip: '凌云·LinYun 博客',
statusPageLink: 'https://blog.linyunlink.top/',
},
{
id: 'linyun_drive_monitor',
name: '凌云·LinYun 网盘',
method: 'GET',
target: 'https://drive.linyunlink.top/',
tooltip: '凌云·LinYun 网盘',
statusPageLink: 'https://drive.linyunlink.top/',
},
{
id: 'linyun_twikoo_monitor',
name: '凌云·LinYun Twikoo评论系统',
method: 'POST',
target: 'https://twikoo.linyunlink.top/',
tooltip: '凌云·LinYun Twikoo 评论服务后端',
tooltip: '凌云·LinYun Twikoo 评论服务API',
statusPageLink: 'https://twikoo.linyunlink.top/',
},
{
id: 'linyun_image_monitor',
name: '凌云·LinYun 图片托管站',
name: '凌云·LinYun 图片托管',
method: 'GET',
target: 'https://image.linyunlink.top/',
tooltip: '凌云·LinYun 图片托管站',
tooltip: '凌云·LinYun 图片托管API',
statusPageLink: 'https://image.linyunlink.top/',
}
],
notification: {
// [Optional] apprise API server URL
// if not specified, no notification will be sent
appriseApiServer: 'https://apprise.example.com/notify',
// [Optional] recipient URL for apprise, refer to https://github.com/caronc/apprise
// if not specified, no notification will be sent
recipientUrl: 'tgram://bottoken/ChatID',
// [Optional] timezone used in notification messages, default to "Etc/GMT"
timeZone: 'Asia/Shanghai',
// [Optional] grace period in minutes before sending a notification
// notification will be sent only if the monitor is down for N continuous checks after the initial failure
// if not specified, notification will be sent immediately
gracePeriod: 5,
// [Optional] disable notification for monitors with specified ids
skipNotificationIds: ['foo_monitor', 'bar_monitor'],
},
callbacks: {
onStatusChange: async (
env: any,
@ -48,7 +92,6 @@ const workerConfig = {
) => {
// This callback will be called when there's a status change for any monitor
// Write any Typescript code here
// This will not follow the grace period settings and will be called immediately when the status changes
// You need to handle the grace period manually if you want to implement it
},
@ -65,5 +108,28 @@ const workerConfig = {
},
}
// You can define multiple maintenances here
// During maintenance, an alert will be shown at status page
// Also, related downtime notifications will be skipped (if any)
// Of course, you can leave it empty if you don't need this feature
// const maintenances: MaintenanceConfig[] = []
const maintenances: MaintenanceConfig[] = [
{
// [Optional] Monitor IDs to be affected by this maintenance
monitors: ['foo_monitor', 'bar_monitor'],
// [Optional] default to "Scheduled Maintenance" if not specified
title: 'Test Maintenance',
// Description of the maintenance, will be shown at status page
body: 'This is a test maintenance, server software upgrade',
// Start time of the maintenance, in UNIX timestamp or ISO 8601 format
start: '2025-04-27T00:00:00+08:00',
// [Optional] end time of the maintenance, in UNIX timestamp or ISO 8601 format
// if not specified, the maintenance will be considered as on-going
end: '2025-04-30T00:00:00+08:00',
// [Optional] color of the maintenance alert at status page, default to "yellow"
color: 'blue',
},
]
// Don't forget this, otherwise compilation fails.
export { pageConfig, workerConfig }
export { pageConfig, workerConfig, maintenances }

View File

@ -1,48 +0,0 @@
type MonitorState = {
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,
{
recent: {
loc: string
ping: number
time: number
}[] // recent 12 hour data, 2 min interval
all: {
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
tooltip?: string
statusPageLink?: string
checkLocationWorkerRoute?: string
// HTTP Code
expectedCodes?: number[]
timeout?: number
headers?: Record<string, string | undefined>
body?: BodyInit
responseKeyword?: string
}
export type { MonitorState, MonitorTarget }

View File

@ -2,4 +2,4 @@ trailingComma: 'es5'
tabWidth: 2
semi: false
singleQuote: true
printWidth: 100
printWidth: 100

2689
worker/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,8 +9,8 @@
"start": "wrangler dev"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20230419.0",
"@cloudflare/workers-types": "^4.20250410.0",
"typescript": "^5.0.4",
"wrangler": "^3.13.1"
"wrangler": "^4.10.0"
}
}

View File

@ -1,55 +1,51 @@
import { workerConfig } from '../../uptime.config'
import { workerConfig, maintenances } from '../../uptime.config'
import { formatStatusChangeNotification, getWorkerLocation, notifyWithApprise } from './util'
import { MonitorState } from '../../uptime.types'
import { MonitorState, MonitorTarget } from '../../types/config'
import { getStatus } from './monitor'
import { DurableObject } from 'cloudflare:workers'
export interface Env {
UPTIMEFLARE_STATE: KVNamespace
REMOTE_CHECKER_DO: DurableObjectNamespace<RemoteChecker>
}
export default {
async fetch(request: Request): Promise<Response> {
const workerLocation = request.cf?.colo
console.log(`Handling request event at ${workerLocation}...`)
if (request.method !== 'POST') {
return new Response('Remote worker is working...', { status: 405 })
}
const targetId = (await request.json<{ target: string }>())['target']
const target = workerConfig.monitors.find((m) => m.id === targetId)
if (target === undefined) {
return new Response('Target Not Found', { status: 404 })
}
const status = await getStatus(target)
return new Response(
JSON.stringify({
location: workerLocation,
status: status,
}),
{
headers: {
'content-type': 'application/json;charset=UTF-8',
},
}
)
},
const Worker = {
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
const workerLocation = (await getWorkerLocation()) || 'ERROR'
console.log(`Running scheduled event on ${workerLocation}...`)
// Auxiliary function to format notification and send it via apprise
let formatAndNotify = async (
monitor: any,
monitor: MonitorTarget,
isUp: boolean,
timeIncidentStart: number,
timeNow: number,
reason: string
) => {
// Skip notification if monitor is in the skip list
const skipList = workerConfig.notification?.skipNotificationIds
if (skipList && skipList.includes(monitor.id)) {
console.log(
`Skipping notification for ${monitor.name} (${monitor.id} in skipNotificationIds)`
)
return
}
// Skip notification if monitor is in maintenance
const maintenanceList = maintenances
.filter(
(m) =>
new Date(timeNow * 1000) >= new Date(m.start) &&
(!m.end || new Date(timeNow * 1000) <= new Date(m.end))
)
.map((e) => (e.monitors || []))
.flat()
if (maintenanceList.includes(monitor.id)) {
console.log(`Skipping notification for ${monitor.name} (in maintenance)`)
return
}
if (workerConfig.notification?.appriseApiServer && workerConfig.notification?.recipientUrl) {
const notification = formatStatusChangeNotification(
monitor,
@ -66,7 +62,9 @@ export default {
notification.body
)
} else {
console.log(`Apprise API server or recipient URL not set, skipping apprise notification for ${monitor.name}`)
console.log(
`Apprise API server or recipient URL not set, skipping apprise notification for ${monitor.name}`
)
}
}
@ -98,23 +96,43 @@ export default {
let checkLocation = workerLocation
let status
if (monitor.checkLocationWorkerRoute) {
// Initiate a check from a different location
if (monitor.checkProxy) {
// Initiate a check using proxy (Geo-specific monitoring)
try {
console.log('Calling worker: ' + monitor.checkLocationWorkerRoute)
const resp = await (
await fetch(monitor.checkLocationWorkerRoute, {
method: 'POST',
body: JSON.stringify({
target: monitor.id,
}),
console.log('Calling check proxy: ' + monitor.checkProxy)
let resp
if (monitor.checkProxy.startsWith('worker://')) {
const doLoc = monitor.checkProxy.replace('worker://', '')
const doId = env.REMOTE_CHECKER_DO.idFromName(doLoc)
const doStub = env.REMOTE_CHECKER_DO.get(doId, {
locationHint: doLoc as DurableObjectLocationHint,
})
).json<{ location: string; status: { ping: number; up: boolean; err: string } }>()
resp = await doStub.getLocationAndStatus(monitor)
try {
// Kill the DO instance after use, to avoid extra resource usage
await doStub.kill()
} catch (err) {
// An error here is expected, ignore it
}
} else {
resp = await (
await fetch(monitor.checkProxy, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(monitor),
})
).json<{ location: string; status: { ping: number; up: boolean; err: string } }>()
}
checkLocation = resp.location
status = resp.status
} catch (err) {
console.log('Error calling worker: ' + err)
status = { ping: 0, up: false, err: 'Error initiating check from remote worker' }
console.log('Error calling proxy: ' + err)
if (monitor.checkProxyFallback) {
console.log('Falling back to local check...')
status = await getStatus(monitor)
} else {
status = { ping: 0, up: false, err: 'Error initiating check from remote worker' }
}
}
} else {
// Initiate a check from the current location
@ -150,21 +168,18 @@ export default {
// grace period not set OR ...
workerConfig.notification?.gracePeriod === undefined ||
// only when we have sent a notification for DOWN status, we will send a notification for UP status (within 30 seconds of possible drift)
currentTimeSecond - lastIncident.start[0] >= (workerConfig.notification.gracePeriod + 1) * 60 - 30
currentTimeSecond - lastIncident.start[0] >=
(workerConfig.notification.gracePeriod + 1) * 60 - 30
) {
await formatAndNotify(
monitor,
true,
lastIncident.start[0],
currentTimeSecond,
'OK'
)
await formatAndNotify(monitor, true, lastIncident.start[0], currentTimeSecond, 'OK')
} else {
console.log(`grace period (${workerConfig.notification?.gracePeriod}m) not met, skipping apprise UP notification for ${monitor.name}`)
console.log(
`grace period (${workerConfig.notification?.gracePeriod}m) not met, skipping apprise UP notification for ${monitor.name}`
)
}
console.log('Calling config onStatusChange callback...')
await workerConfig.callbacks.onStatusChange(
await workerConfig.callbacks?.onStatusChange?.(
env,
monitor,
true,
@ -201,22 +216,20 @@ export default {
try {
if (
// monitor status changed AND...
(monitorStatusChanged && (
(monitorStatusChanged &&
// grace period not set OR ...
workerConfig.notification?.gracePeriod === undefined ||
// have sent a notification for DOWN status
currentTimeSecond - currentIncident.start[0] >= (workerConfig.notification.gracePeriod + 1) * 60 - 30
))
||
(
// grace period is set AND...
workerConfig.notification?.gracePeriod !== undefined &&
(
// grace period is met
currentTimeSecond - currentIncident.start[0] >= workerConfig.notification.gracePeriod * 60 - 30 &&
currentTimeSecond - currentIncident.start[0] < workerConfig.notification.gracePeriod * 60 + 30
)
)) {
(workerConfig.notification?.gracePeriod === undefined ||
// have sent a notification for DOWN status
currentTimeSecond - currentIncident.start[0] >=
(workerConfig.notification.gracePeriod + 1) * 60 - 30)) ||
// grace period is set AND...
(workerConfig.notification?.gracePeriod !== undefined &&
// grace period is met
currentTimeSecond - currentIncident.start[0] >=
workerConfig.notification.gracePeriod * 60 - 30 &&
currentTimeSecond - currentIncident.start[0] <
workerConfig.notification.gracePeriod * 60 + 30)
) {
await formatAndNotify(
monitor,
false,
@ -225,12 +238,19 @@ export default {
status.err
)
} else {
console.log(`Grace period (${workerConfig.notification?.gracePeriod}m) not met (currently down for ${currentTimeSecond - currentIncident.start[0]}s, changed ${monitorStatusChanged}), skipping apprise DOWN notification for ${monitor.name}`)
console.log(
`Grace period (${workerConfig.notification
?.gracePeriod}m) not met (currently down for ${
currentTimeSecond - currentIncident.start[0]
}s, changed ${monitorStatusChanged}), skipping apprise DOWN notification for ${
monitor.name
}`
)
}
if (monitorStatusChanged) {
console.log('Calling config onStatusChange callback...')
await workerConfig.callbacks.onStatusChange(
await workerConfig.callbacks?.onStatusChange?.(
env,
monitor,
false,
@ -246,7 +266,7 @@ export default {
try {
console.log('Calling config onIncident callback...')
await workerConfig.callbacks.onIncident(
await workerConfig.callbacks?.onIncident?.(
env,
monitor,
currentIncident.start[0],
@ -262,8 +282,8 @@ export default {
// append to latency data
let latencyLists = state.latency[monitor.id] || {
recent: [],
all: [],
}
latencyLists.all = []
const record = {
loc: checkLocation,
@ -271,55 +291,82 @@ export default {
time: currentTimeSecond,
}
latencyLists.recent.push(record)
if (latencyLists.all.length === 0 || currentTimeSecond - latencyLists.all.slice(-1)[0].time > 60 * 60) {
latencyLists.all.push(record)
}
// discard old data
while (latencyLists.recent[0]?.time < currentTimeSecond - 12 * 60 * 60) {
latencyLists.recent.shift()
}
while (latencyLists.all[0]?.time < currentTimeSecond - 90 * 24 * 60 * 60) {
latencyLists.all.shift()
}
state.latency[monitor.id] = latencyLists
// discard old incidents
let incidentList = state.incident[monitor.id]
while (incidentList.length > 0 && incidentList[0].end && incidentList[0].end < currentTimeSecond - 90 * 24 * 60 * 60) {
while (
incidentList.length > 0 &&
incidentList[0].end &&
incidentList[0].end < currentTimeSecond - 90 * 24 * 60 * 60
) {
incidentList.shift()
}
if (incidentList.length == 0 || (
incidentList[0].start[0] > currentTimeSecond - 90 * 24 * 60 * 60 &&
incidentList[0].error[0] != 'dummy'
)) {
if (
incidentList.length == 0 ||
(incidentList[0].start[0] > currentTimeSecond - 90 * 24 * 60 * 60 &&
incidentList[0].error[0] != 'dummy')
) {
// put the dummy incident back
incidentList.unshift(
{
start: [currentTimeSecond - 90 * 24 * 60 * 60],
end: currentTimeSecond - 90 * 24 * 60 * 60,
error: ['dummy'],
}
)
incidentList.unshift({
start: [currentTimeSecond - 90 * 24 * 60 * 60],
end: currentTimeSecond - 90 * 24 * 60 * 60,
error: ['dummy'],
})
}
state.incident[monitor.id] = incidentList
statusChanged ||= monitorStatusChanged
}
console.log(`statusChanged: ${statusChanged}, lastUpdate: ${state.lastUpdate}, currentTime: ${currentTimeSecond}`)
console.log(
`statusChanged: ${statusChanged}, lastUpdate: ${state.lastUpdate}, currentTime: ${currentTimeSecond}`
)
// Update state
// Allow for a cooldown period before writing to KV
if (
statusChanged ||
currentTimeSecond - state.lastUpdate >= workerConfig.kvWriteCooldownMinutes * 60 - 10 // Allow for 10 seconds of clock drift
currentTimeSecond - state.lastUpdate >= workerConfig.kvWriteCooldownMinutes * 60 - 10 // Allow for 10 seconds of clock drift
) {
console.log("Updating state...")
console.log('Updating state...')
state.lastUpdate = currentTimeSecond
await env.UPTIMEFLARE_STATE.put('state', JSON.stringify(state))
} else {
console.log("Skipping state update due to cooldown period.")
console.log('Skipping state update due to cooldown period.')
}
},
}
export default Worker
export class RemoteChecker extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env)
}
async getLocationAndStatus(
monitor: MonitorTarget
): Promise<{ location: string; status: { ping: number; up: boolean; err: string } }> {
const colo = (await getWorkerLocation()) as string
console.log(`Running remote checker (DurableObject) at ${colo}...`)
const status = await getStatus(monitor)
return {
location: colo,
status: status,
}
}
async kill() {
// Throwing an error in `blockConcurrencyWhile` will terminate the Durable Object instance
// https://developers.cloudflare.com/durable-objects/api/state/#blockconcurrencywhile
this.ctx.blockConcurrencyWhile(async () => {
throw 'killed'
})
}
}

View File

@ -1,6 +1,5 @@
import { connect } from "cloudflare:sockets";
import { MonitorTarget } from "../../uptime.types";
import { withTimeout, fetchTimeout } from "./util";
import { MonitorTarget } from '../../types/config'
import { withTimeout, fetchTimeout } from './util'
export async function getStatus(
monitor: MonitorTarget
@ -16,12 +15,14 @@ export async function getStatus(
if (monitor.method === 'TCP_PING') {
// TCP port endpoint monitor
try {
const connect = await import(/* webpackIgnore: true */ 'cloudflare:sockets').then(
(sockets) => sockets.connect
)
// This is not a real https connection, but we need to add a dummy `https://` to parse the hostname & port
const parsed = new URL("https://" + monitor.target)
const parsed = new URL('https://' + monitor.target)
const socket = connect({ hostname: parsed.hostname, port: Number(parsed.port) })
// Now we have an `opened` promise!
// @ts-ignore
await withTimeout(monitor.timeout || 10000, socket.opened)
await socket.close()
@ -59,8 +60,9 @@ export async function getStatus(
if (!monitor.expectedCodes.includes(response.status)) {
console.log(`${monitor.name} expected ${monitor.expectedCodes}, got ${response.status}`)
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
}
} else {
@ -72,14 +74,36 @@ export async function getStatus(
}
}
if (monitor.responseKeyword) {
if (monitor.responseKeyword || monitor.responseForbiddenKeyword) {
// Only read response body if we have a keyword to check
const responseBody = await response.text()
if (!responseBody.includes(monitor.responseKeyword)) {
console.log(`${monitor.name} expected keyword ${monitor.responseKeyword}, not found in response (truncated to 100 chars): ${responseBody.slice(0, 100)}`)
// MUST contain responseKeyword
if (monitor.responseKeyword && !responseBody.includes(monitor.responseKeyword)) {
console.log(
`${monitor.name} expected keyword ${
monitor.responseKeyword
}, not found in response (truncated to 100 chars): ${responseBody.slice(0, 100)}`
)
status.up = false
status.err = "HTTP response doesn't contain the configured keyword"
return status
}
// MUST NOT contain responseForbiddenKeyword
if (
monitor.responseForbiddenKeyword &&
responseBody.includes(monitor.responseForbiddenKeyword)
) {
console.log(
`${monitor.name} forbidden keyword ${
monitor.responseForbiddenKeyword
}, found in response (truncated to 100 chars): ${responseBody.slice(0, 100)}`
)
status.up = false
status.err = 'HTTP response contains the configured forbidden keyword'
return status
}
}
status.up = true

View File

@ -43,7 +43,7 @@ function formatStatusChangeNotification(
timeZone: timeZone,
})
let downtimeDuration = Math.round((timeNow - timeIncidentStart) / 60);
let downtimeDuration = Math.round((timeNow - timeIncidentStart) / 60)
const timeNowFormatted = dateFormatter.format(new Date(timeNow * 1000))
const timeIncidentStartFormatted = dateFormatter.format(new Date(timeIncidentStart * 1000))
@ -60,7 +60,9 @@ function formatStatusChangeNotification(
} else {
return {
title: `🔴 ${monitor.name} is still down.`,
body: `Service is unavailable since ${timeIncidentStartFormatted} (${downtimeDuration} minutes). Issue: ${reason || 'unspecified'}`,
body: `Service is unavailable since ${timeIncidentStartFormatted} (${downtimeDuration} minutes). Issue: ${
reason || 'unspecified'
}`,
}
}
}
@ -71,7 +73,16 @@ async function notifyWithApprise(
title: string,
body: string
) {
console.log('Sending Apprise notification: ' + title + '-' + body + ' to ' + recipientUrl + ' via ' + appriseApiServer)
console.log(
'Sending Apprise notification: ' +
title +
'-' +
body +
' to ' +
recipientUrl +
' via ' +
appriseApiServer
)
try {
const resp = await fetchTimeout(appriseApiServer, 5000, {
method: 'POST',
@ -83,12 +94,14 @@ async function notifyWithApprise(
title,
body,
type: 'warning',
format: 'text'
format: 'text',
}),
})
if (!resp.ok) {
console.log('Error calling apprise server, code: ' + resp.status + ', response: ' + await resp.text())
console.log(
'Error calling apprise server, code: ' + resp.status + ', response: ' + (await resp.text())
)
} else {
console.log('Apprise notification sent successfully, code: ' + resp.status)
}
@ -97,4 +110,10 @@ async function notifyWithApprise(
}
}
export { getWorkerLocation, fetchTimeout, withTimeout, notifyWithApprise, formatStatusChangeNotification }
export {
getWorkerLocation,
fetchTimeout,
withTimeout,
notifyWithApprise,
formatStatusChangeNotification,
}

View File

@ -1,105 +1,105 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": [
"es2021"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Language and Environment */
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"lib": [
"es2021"
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
"jsx": "react" /* Specify what JSX code is generated. */,
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "es2022" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
"types": [
"@cloudflare/workers-types"
] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
"resolveJsonModule": true /* Enable importing .json files */,
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* Modules */
"module": "es2022" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
"types": [
"@cloudflare/workers-types"
] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
"resolveJsonModule": true /* Enable importing .json files */,
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
"checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* JavaScript Support */
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
"checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
"noEmit": true /* Disable emitting files from a compilation. */,
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
"noEmit": true /* Disable emitting files from a compilation. */,
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
// "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Interop Constraints */
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
// "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

View File

@ -1,4 +1,4 @@
name = "uptimeflare_worker"
main = "src/index.ts"
compatibility_date = "2023-11-08"
compatibility_date = "2025-04-02"
kv_namespaces = [{ binding = "UPTIMEFLARE_STATE", id = "UPTIMEFLARE_STATE" }]

View File

@ -1,3 +1,3 @@
name = "uptimeflare_worker"
main = "src/index.ts"
compatibility_date = "2023-11-08"
compatibility_date = "2025-04-02"