Initial commit

This commit is contained in:
2026-07-29 11:35:11 +08:00
committed by GitHub
commit a158df3216
72 changed files with 24626 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.terraform/
terraform.tfstate*
/.wrangler
+32
View File
@@ -0,0 +1,32 @@
{
"extends": "next/core-web-vitals",
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"**/uptime.config"
],
"importNames": [
"workerConfig"
],
"message": "Do not import workerConfig in client-bundled files. See https://github.com/lyc8503/UptimeFlare/issues/198 for details."
}
]
}
]
},
"overrides": [
{
"files": [
"pages/api/data.ts",
"middleware.ts"
],
"rules": {
"no-restricted-imports": "off"
}
}
]
}
+2
View File
@@ -0,0 +1,2 @@
# GitHub Sponsor config
ko_fi: lyc8503
+109
View File
@@ -0,0 +1,109 @@
name: Deploy to Cloudflare
on:
push:
branches: ['main']
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Terraform
uses: hashicorp/setup-terraform@v2.0.3
with:
terraform_version: 1.6.4
- name: Use Node.js 22.x
uses: actions/setup-node@v3
with:
node-version: 22.x
cache: 'npm'
# Automatically get an account id via the API Token
# if secrets.CLOUDFLARE_ACCOUNT_ID is not set.
- name: Fetch Account ID
id: fetch_account_id
run: |
if [[ -n "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" ]]; then
ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"
echo "Using provided CLOUDFLARE_ACCOUNT_ID from secrets."
else
ACCOUNT_ID=$(curl -X GET "https://api.cloudflare.com/client/v4/accounts" -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" -H "Content-Type:application/json" | jq ".result[0].id" -r)
if [[ "$ACCOUNT_ID" == "null" ]]; then
echo "Failed to get an account id, please make sure you have set up CLOUDFLARE_API_TOKEN correctly!"
exit 1
fi
fi
echo 'account_id='$ACCOUNT_ID >> $GITHUB_OUTPUT
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Install packages
run: |
npm install
cd worker
npm install
- name: Build worker
run: |
cd worker
npx wrangler deploy src/index.ts --outdir dist --dry-run
- name: Build page
run: |
npx @cloudflare/next-on-pages
- name: Create D1 database and tables
run: |
python3 deploy/init_d1.py # This sets D1_ID in GITHUB_ENV
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ steps.fetch_account_id.outputs.account_id }}
- name: Migrate state from KV (if needed)
run: |
python3 deploy/migrate_kv.py
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ steps.fetch_account_id.outputs.account_id }}
D1_ID: ${{ env.D1_ID }}
- name: Deploy using Terraform
# As we don't save terraform state somewhere, we need to import the existing resources
run: |
terraform init
DO_RESP=$(curl "https://api.cloudflare.com/client/v4/accounts/$TF_VAR_CLOUDFLARE_ACCOUNT_ID/workers/durable_objects/namespaces?per_page=1000" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN")
if echo "$DO_RESP" | jq -e '.result[] | select(.script == "uptimeflare_worker" and .class == "RemoteChecker")' > /dev/null; then
echo "Existing Durable Object namespace found for uptimeflare_worker RemoteChecker. No migration needed."
else
echo "No existing Durable Object namespace found for uptimeflare_worker RemoteChecker. Need migration."
export TF_VAR_enable_do_migration=true
fi
echo "Try importing existing resources..."
terraform import cloudflare_d1_database.uptimeflare_d1 "$TF_VAR_CLOUDFLARE_ACCOUNT_ID/$D1_ID"
terraform import cloudflare_workers_script.uptimeflare_worker "$TF_VAR_CLOUDFLARE_ACCOUNT_ID/uptimeflare_worker" || echo "WARNING: Worker script import failed, continuing..."
terraform import cloudflare_workers_cron_trigger.uptimeflare_worker_cron "$TF_VAR_CLOUDFLARE_ACCOUNT_ID/uptimeflare_worker" || echo "WARNING: Cron trigger import failed, continuing..."
terraform import cloudflare_pages_project.uptimeflare "$TF_VAR_CLOUDFLARE_ACCOUNT_ID/uptimeflare" || echo "WARNING: Pages project import failed, continuing..."
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 }}
D1_ID: ${{ env.D1_ID }}
# Currently Terraform Cloudflare provider doesn't support direct upload, use wrangler to upload instead.
- name: Upload pages
run: |
npx wrangler pages deploy .vercel/output/static --project-name uptimeflare
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ steps.fetch_account_id.outputs.account_id }}
+18
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.
+57
View File
@@ -0,0 +1,57 @@
name: Upstream Sync
permissions:
contents: write
actions: write
on:
workflow_dispatch:
inputs:
manual-trigger:
description: 'This will pull the upstream code while preserving your configuration file. Configuration file compatibility is not guaranteed, and you may need to manually update the configuration file after pulling.'
type: boolean
required: true
default: false
override-token:
description: '[Optional] Paste a PAT here. If left blank, PAT_TOKEN secret will be used (if set). Used to solve `refusing to allow a GitHub App to create or update workflow... `, more info at https://github.com/lyc8503/UptimeFlare/wiki/Synchronize-updates-from-upstream#upgrade-methods'
required: false
jobs:
sync_latest_from_upstream:
name: Sync latest commits from upstream repo
runs-on: ubuntu-latest
steps:
- name: Checkout target repo
uses: actions/checkout@v3
with:
token: ${{ inputs.override-token || secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }}
- name: Current config
run: |
cat uptime.config.ts
cp uptime.config.ts /tmp/origin.config.ts
- name: Sync upstream changes
id: sync
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
# Clean current repo and update
git rm -rf '*'
cp -r /tmp/latest/. .
cp /tmp/origin.config.ts uptime.config.ts
git add .
git commit -m "Sync latest code from upstream"
git push
- name: Trigger deployment
if: ${{ inputs.override-token == '' }}
uses: benc-uk/workflow-dispatch@v1
with:
workflow: deploy.yml
+40
View File
@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.terraform/
terraform.tfstate*
/.wrangler
+5
View File
@@ -0,0 +1,5 @@
trailingComma: 'es5'
tabWidth: 2
semi: false
singleQuote: true
printWidth: 100
+19
View File
@@ -0,0 +1,19 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/cloudflare/cloudflare" {
version = "5.15.0"
constraints = "~> 5.0"
hashes = [
"h1:prHyv+irfadmobKZm1LKEBgJKSpcdWQtjUhyJdC9R1E=",
"zh:20a72bdbb28435f11d165b367732369e8f8163100a214e89ad720dae03fafa0c",
"zh:2eabd7a51fd7aafcab9861631d85c895914857e4fcd6fe2dd80bac22e74a1f47",
"zh:62828afbc1ba0e0a64bbb7d5d42ae3c2fbbaabb793010b07eba770ba91bae94f",
"zh:6693f1021e52c34a629300fbcd91f8bd4ca386fda3b45aec746b9c200c28a42c",
"zh:6873a15454b289e5baecc1d36ce8997266438761386a320753c63f13407f4a6b",
"zh:afbf4e56b3a5e5950b35b02b553313e4a2008415920b23f536682269c64ca549",
"zh:db367612900bc2e5a01c6a325e4cff9b1b04960ce9de3dd41671dda5a627ca1d",
"zh:eb7365eafc6160c3b304a9ce6a598e5400a2e779e9e2bd27976df244f79f774f",
"zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
]
}
+33
View File
@@ -0,0 +1,33 @@
# Stage 1: Build
FROM node:lts-bookworm AS builder
WORKDIR /app
COPY package*.json ./
COPY worker/package*.json ./worker/
RUN npm ci
RUN cd worker && npm ci
# Copy all source files
COPY . .
# Build the Next.js application
RUN npx @cloudflare/next-on-pages
# Stage 2: Production
FROM node:lts-bookworm AS production
# Install bash and curl for runtime
RUN apt-get update && apt-get install -y curl cron
# Copy runtime dependencies from builder stage
COPY --from=builder /app/ /app/
COPY --from=builder /app/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Expose the Pages port
EXPOSE 8788
ENTRYPOINT ["/entrypoint.sh"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+90
View File
@@ -0,0 +1,90 @@
<div align="right">
<a title="English" href="README.md"><img src="https://img.shields.io/badge/-English-A31F34?style=for-the-badge" alt="English" /></a>
<a title="简体中文" href="README_zh-CN.md"><img src="https://img.shields.io/badge/-%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-545759?style=for-the-badge" alt="简体中文"></a>
</div>
# ✔[UptimeFlare](https://github.com/lyc8503/UptimeFlare)
A more advanced, serverless, and free uptime monitoring & status page solution, powered by Cloudflare Workers, complete with a user-friendly interface.
📢 **[[SECURITY ADVISORY](https://github.com/lyc8503/UptimeFlare/security/advisories/GHSA-36q9-v7p3-vj6v) 2026/03/04]** A vulnerability (CVE-2026-29779) that could expose monitor configuration and credentials in `uptime.config.ts` to clients was fixed. Versions between 2025-09-21 (from commit `41257c6`) and 2026-03-04 are affected. **Affected users are strongly advised to upgrade to the latest version.**
🎉 **[UPDATE 2026/01/03]** I have just migrated UptimeFlare from KV to D1 Database. I also updated the Terraform Cloudflare provider to v5 and improved the deployment process. The data structure has been optimized to resolve long-standing performance issues.
New users can deploy directly, while existing users can have a simple auto migration process (upgrade docs below)! Feel free to open an issue if you run into any trouble deploying.
## ⭐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
- Geo-specific checks from over [310 cities](https://www.cloudflare.com/network/) worldwide
- Support for HTTP/HTTPS/TCP port monitoring
- Up to 90-day uptime history and uptime percentage tracking
- Customizable request methods, headers, and body for HTTP(s)
- Custom status code & keyword checks for HTTP(s)
- Downtime notification supporting [100+ notification channels](https://github.com/caronc/apprise/wiki)
- Customizable Webhook
- Multi-language support (English/Chinese)
- Status page
- Interactive ping (response time) chart for all types of monitors
- Scheduled maintenances alerts & Incident history page
- Responsive UI that adapts to your system theme
- Customizable status page
- Use your own domain with CNAME
- Optional password authentication (private status page)
- JSON API for fetching realtime status data
## 👀Demo
My status page (Online demo): https://uptimeflare.pages.dev/
Some screenshots:
![Desktop, Light theme](docs/desktop.png)
## ⚡Quickstart / 📄Documentation
Please refer to [Wiki](https://github.com/lyc8503/UptimeFlare/wiki)
## 🚀Upgrade existing deployments
Get the latest features right away with [simple upgrade process](https://github.com/lyc8503/UptimeFlare/wiki/Synchronize-updates-from-upstream)
## ⚙️Docs for developer
To contribute new features or customize your deployment furthermore, see [here](https://github.com/lyc8503/UptimeFlare/wiki/How-to-develop).
## New features (TODOs)
- [x] Specify region for monitors
- [x] TCP `opened` promise
- [x] Use apprise to support various notification channels
- [x] ~~Telegram example~~
- [x] ~~[Bark](https://bark.day.app) example~~
- [x] ~~Email notification via Cloudflare Email Workers~~
- [x] Improve docs by providing simple examples
- [x] Notification grace period
- [ ] SSL certificate checks
- [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
- [x] Add docs for dev
- [x] Migration to Terraform Cloudflare provider version 5.x
- [x] Cloudflare D1 database
- [x] Scheduled maintenances (via IIFE)
- [x] Simpler config example
- [x] Upcoming maintenances
- [x] Universal Webhook upgrade
- [x] i18n...? (maybe)
- [ ] ICMP via proxy?
- [x] Add default UA
- [x] Customizable footer
- [x] New header logo
- [x] Improve CPU time usage
- [x] Local deployment (docs WIP)
+41
View File
@@ -0,0 +1,41 @@
<div align="right">
<a title="English" href="README.md"><img src="https://img.shields.io/badge/-English-545759?style=for-the-badge" alt="English"></a>
<a title="简体中文" href="README_zh-CN.md"><img src="https://img.shields.io/badge/-%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-A31F34?style=for-the-badge" alt="简体中文"></a>
</div>
# ✔[UptimeFlare](https://github.com/lyc8503/UptimeFlare)
一个由 Cloudflare Workers 驱动的功能丰富、Serverless 且免费的 Uptime 监控及状态页面。
## ⭐功能
- 开源,易于部署(全程无需本地工具,耗时不到 10 分钟),且完全免费
- 监控功能
- 最多支持 50 个 1 分钟精度的检查
- 支持指定全球 [310+ 个城市](https://www.cloudflare.com/network/) 的监控节点
- 支持 HTTP/HTTPS/TCP 端口监控
- 最多 90 天的 uptime 历史记录和 uptime 百分比跟踪
- 可自定义的 HTTP(s) 请求方法、头和主体
- 可自定义的 HTTP(s) 状态码和关键字检查
- 支持 [100 多个通知渠道](https://github.com/caronc/apprise/wiki) 的宕机消息通知
- 可自定义的 Webhook
- 多语言支持 (中文/英文)
- 状态页面
- 所有类型监控的交互式 ping(响应时间)图表
- 响应式 UI,自适应PC/手机屏幕,及亮色/暗色系统主题
- 配置选项丰富的状态页面
- 可使用您自己的域名与 CNAME
- 可选的密码认证(私人状态页面)
- 用于获取实时状态数据的 JSON API
## 👀演示
我自己的状态页面(在线演示):https://uptimeflare.pages.dev/
一些截图:
![桌面,浅色主题](docs/desktop.png)
## ⚡快速入门 / 📄文档
请参阅 [Wiki](https://github.com/lyc8503/UptimeFlare/wiki)
+160
View File
@@ -0,0 +1,160 @@
import { MonitorState, MonitorTarget } from '@/types/config'
import { getColor } from '@/util/color'
import { Box, Tooltip, Modal } from '@mantine/core'
import { useResizeObserver } from '@mantine/hooks'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
const moment = require('moment')
require('moment-precise-range-plugin')
export default function DetailBar({
monitor,
state,
}: {
monitor: MonitorTarget
state: MonitorState
}) {
const { t } = useTranslation('common')
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))
}
const uptimePercentBars = []
const currentTime = Math.round(Date.now() / 1000)
const montiorStartTime = state.incident[monitor.id][0].start[0]
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
for (let i = 89; i >= 0; i--) {
const dayStart = Math.round(todayStart.getTime() / 1000) - i * 86400
const dayEnd = dayStart + 86400
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
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)
uptimePercentBars.push(
<Tooltip
multiline
key={i}
events={{ hover: true, focus: false, touch: true }}
label={
Number.isNaN(Number(dayPercent)) ? (
t('No Data')
) : (
<>
<div>
{t('percent at date', {
percent: dayPercent,
date: new Date(dayStart * 1000).toLocaleDateString(),
})}
</div>
{dayDownTime > 0 && (
<div>
{t('Down for', {
duration: moment.preciseDiff(moment(0), moment(dayDownTime * 1000)),
})}
</div>
)}
</>
)
}
>
<div
style={{
height: '20px',
width: '7px',
background: getColor(dayPercent, false),
borderRadius: '2px',
marginLeft: '1px',
marginRight: '1px',
}}
onClick={() => {
if (dayDownTime > 0) {
setModalTitle(
t('incidents at', {
name: monitor.name,
date: new Date(dayStart * 1000).toLocaleDateString(),
})
)
setModelContent(
<>
{incidentReasons.map((reason, index) => (
<div key={index}>{reason}</div>
))}
</>
)
setModalOpened(true)
}
}}
/>
</Tooltip>
)
}
return (
<>
<Modal
opened={modalOpened}
onClose={() => setModalOpened(false)}
title={modalTitle}
size={'40em'}
>
{modelContent}
</Modal>
<Box
style={{
display: 'flex',
flexWrap: 'nowrap',
marginTop: '10px',
marginBottom: '5px',
}}
visibleFrom="540"
ref={barRef}
>
{uptimePercentBars.slice(Math.floor(Math.max(9 * 90 - barRect.width, 0) / 9), 90)}
</Box>
</>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { Line } from 'react-chartjs-2'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip as ChartTooltip,
Legend,
TimeScale,
} from 'chart.js'
import 'chartjs-adapter-moment'
import { MonitorState, MonitorTarget } from '@/types/config'
import { codeToCountry } from '@/util/iata'
import { useTranslation } from 'react-i18next'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
ChartTooltip,
Legend,
TimeScale
)
export default function DetailChart({
monitor,
state,
}: {
monitor: MonitorTarget
state: MonitorState
}) {
const { t } = useTranslation('common')
const latencyData = state.latency[monitor.id].map((point) => ({
x: point.time * 1000,
y: point.ping,
loc: point.loc,
}))
let data = {
datasets: [
{
data: latencyData,
borderColor: 'rgb(112, 119, 140)',
borderWidth: 2,
radius: 0,
cubicInterpolationMode: 'monotone' as const,
tension: 0.4,
},
],
}
let options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index' as const,
intersect: false,
},
animation: {
duration: 0,
},
plugins: {
tooltip: {
callbacks: {
label: (item: any) => {
if (item.parsed.y) {
return `${item.parsed.y}ms (${codeToCountry(item.raw.loc)})`
}
},
},
},
legend: {
display: false,
},
title: {
display: true,
text: t('Response times'),
align: 'start' as const,
},
},
scales: {
x: {
type: 'time' as const,
ticks: {
source: 'auto' as const,
maxRotation: 0,
autoSkip: true,
},
},
},
}
return (
<div style={{ height: '150px' }}>
<Line options={options} data={data} />
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
import { Divider } from '@mantine/core'
import { pageConfig } from '@/uptime.config'
export default function Footer() {
const defaultFooter =
'<p style="text-align: center; font-size: 12px; margin-top: 10px;"> Open-source monitoring and status page powered by <a href="https://github.com/lyc8503/UptimeFlare" target="_blank">Uptimeflare</a>, made with ❤ by <a href="https://github.com/lyc8503" target="_blank">lyc8503</a>. </p>'
return (
<>
<Divider mt="lg" />
<div dangerouslySetInnerHTML={{ __html: pageConfig.customFooter ?? defaultFooter }} />
</>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { Container, Group, Image } from '@mantine/core'
import classes from '@/styles/Header.module.css'
import { pageConfig } from '@/uptime.config'
import { PageConfigLink } from '@/types/config'
import { useTranslation } from 'react-i18next'
export default function Header({ style }: { style?: React.CSSProperties }) {
const { t } = useTranslation('common')
const linkToElement = (link: PageConfigLink, i: number) => {
return (
<a
key={i}
href={link.link}
target={link.link.startsWith('/') ? undefined : '_blank'}
className={classes.link}
data-active={link.highlight}
>
{link.label}
</a>
)
}
const links = [{ label: t('Incidents'), link: '/incidents' }, ...(pageConfig.links || [])]
return (
<header className={classes.header} style={style}>
<Container size="md" className={classes.inner}>
<div>
<a
href={location.pathname == '/' ? 'https://github.com/lyc8503/UptimeFlare' : '/'}
target={location.pathname == '/' ? '_blank' : undefined}
>
<Image
src={pageConfig.logo ?? '/logo.svg'}
h={56}
w={{ base: 140, sm: 190 }}
fit="contain"
alt="logo"
/>
</a>
</div>
<Group gap={5} visibleFrom="sm">
{links?.map(linkToElement)}
</Group>
<Group gap={5} hiddenFrom="sm">
{links?.filter((link) => link.highlight || link.link.startsWith('/')).map(linkToElement)}
</Group>
</Container>
</header>
)
}
+95
View File
@@ -0,0 +1,95 @@
import { Alert, List, Text, useMantineTheme } from '@mantine/core'
import { useMediaQuery } from '@mantine/hooks'
import { IconAlertTriangle } from '@tabler/icons-react'
import { MaintenanceConfig, MonitorTarget } from '@/types/config'
import { pageConfig } from '@/uptime.config'
import { useTranslation } from 'react-i18next'
export default function MaintenanceAlert({
maintenance,
style,
upcoming = false,
}: {
maintenance: Omit<MaintenanceConfig, 'monitors'> & { monitors?: (MonitorTarget | undefined)[] }
style?: React.CSSProperties
upcoming?: boolean
}) {
const { t } = useTranslation('common')
const theme = useMantineTheme()
const isDesktop = useMediaQuery(`(min-width: ${theme.breakpoints.sm})`)
return (
<Alert
icon={<IconAlertTriangle />}
title={
<span
style={{
fontSize: '1rem',
fontWeight: 700,
}}
>
{(upcoming ? t('Upcoming') : '') + (maintenance.title || t('Scheduled Maintenance'))}
</span>
}
color={
upcoming ? pageConfig.maintenances?.upcomingColor ?? 'gray' : maintenance.color || 'yellow'
}
withCloseButton={false}
style={{ margin: '16px auto 0 auto', ...style }}
>
{/* Date range in top right (desktop) or inline (mobile) */}
<div
style={{
...{
top: 10,
fontSize: '0.85rem',
borderRadius: 6,
},
...(isDesktop
? {
position: 'absolute',
right: 10,
padding: '2px 8px',
textAlign: 'right',
}
: { marginBottom: 4 }),
}}
>
<div
style={{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gridColumnGap: '3px',
}}
>
<div style={{ textAlign: 'right', fontWeight: 'bold' }}>
{upcoming ? t('Scheduled for') : t('From')}
</div>
<div>{new Date(maintenance.start).toLocaleString()}</div>
<div style={{ textAlign: 'right', fontWeight: 'bold' }}>
{upcoming ? t('Expected end') : t('To')}
</div>
<div>
{maintenance.end
? new Date(maintenance.end).toLocaleString()
: t('Until further notice')}
</div>
</div>
</div>
<Text style={{ paddingTop: '3px', whiteSpace: 'pre-line' }}>{maintenance.body}</Text>
{maintenance.monitors && maintenance.monitors.length > 0 && (
<>
<Text mt="xs">
<b>{t('Affected components')}</b>
</Text>
<List size="sm" withPadding>
{maintenance.monitors.map((comp, compIdx) => (
<List.Item key={compIdx}>{comp?.name ?? t('MONITOR ID NOT FOUND')}</List.Item>
))}
</List>
</>
)}
</Alert>
)
}
+104
View File
@@ -0,0 +1,104 @@
import { Text, Tooltip } from '@mantine/core'
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'
import { useTranslation } from 'react-i18next'
export default function MonitorDetail({
monitor,
state,
}: {
monitor: MonitorTarget
state: MonitorState
}) {
const { t } = useTranslation('common')
if (!state.latency[monitor.id])
return (
<>
<Text mt="sm" fw={700}>
{monitor.name}
</Text>
<Text mt="sm" fw={700}>
{t('No data available')}
</Text>
</>
)
let statusIcon =
state.incident[monitor.id].slice(-1)[0].end === null ? (
<IconAlertCircle
style={{ width: '1.25em', height: '1.25em', color: '#b91c1c', marginRight: '3px' }}
/>
) : (
<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]
let downTime = 0
for (let incident of state.incident[monitor.id]) {
downTime += (incident.end ?? Date.now() / 1000) - incident.start[0]
}
const uptimePercent = (((totalTime - downTime) / totalTime) * 100).toPrecision(4)
// Conditionally render monitor name with or without hyperlink based on monitor.url presence
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' }}
>
{statusIcon} {monitor.name}
</a>
) : (
<>
{statusIcon} {monitor.name}
</>
)}
</Text>
)
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{monitor.tooltip ? (
<Tooltip label={monitor.tooltip}>{monitorNameElement}</Tooltip>
) : (
monitorNameElement
)}
<Text mt="sm" fw={700} style={{ display: 'inline', color: getColor(uptimePercent, true) }}>
{t('Overall', { percent: uptimePercent })}
</Text>
</div>
<DetailBar monitor={monitor} state={state} />
{!monitor.hideLatencyChart && <DetailChart monitor={monitor} state={state} />}
</>
)
}
+133
View File
@@ -0,0 +1,133 @@
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'
import { useTranslation } from 'react-i18next'
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 === null) {
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 { t } = useTranslation('common')
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} {t('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>
))
}
return (
<Center>
<Card
shadow="sm"
padding="lg"
radius="md"
ml="md"
mr="md"
mt="xl"
withBorder={!groupedMonitor}
style={{ width: groupedMonitor ? '897px' : '865px' }}
>
{content}
</Card>
</Center>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { Alert, Text } from '@mantine/core'
import { IconInfoCircle } from '@tabler/icons-react'
import { useTranslation } from 'react-i18next'
export default function NoIncidentsAlert({ style }: { style?: React.CSSProperties }) {
const { t } = useTranslation('common')
return (
<Alert
icon={<IconInfoCircle />}
title={
<span
style={{
fontSize: '1rem',
fontWeight: 700,
}}
>
{t('No incidents in this month')}
</span>
}
color="gray"
withCloseButton={false}
style={{
position: 'relative',
margin: '16px auto 0 auto',
...style,
}}
>
<Text>{t('There are no incidents for this month')}</Text>
</Alert>
)
}
+19
View File
@@ -0,0 +1,19 @@
import dynamic from 'next/dynamic'
import React from 'react'
const NoSsr = (props: {
children:
| string
| number
| boolean
| React.ReactElement<any, string | React.JSXElementConstructor<any>>
| Iterable<React.ReactNode>
| React.ReactPortal
| React.PromiseLikeOfReactNode
| null
| undefined
}) => <React.Fragment>{props.children}</React.Fragment>
export default dynamic(() => Promise.resolve(NoSsr), {
ssr: false,
})
+137
View File
@@ -0,0 +1,137 @@
import { MaintenanceConfig, MonitorTarget } from '@/types/config'
import { Center, Container, Title, Collapse, Button, Box } from '@mantine/core'
import { IconCircleCheck, IconAlertCircle, IconPlus, IconMinus } from '@tabler/icons-react'
import { useEffect, useState } from 'react'
import MaintenanceAlert from './MaintenanceAlert'
import { pageConfig } from '@/uptime.config'
import { useTranslation } from 'react-i18next'
function useWindowVisibility() {
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
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[]
}) {
const { t } = useTranslation('common')
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) {
statusString = t('No data yet')
} else if (state.overallUp === 0) {
statusString = t('All systems not operational')
} else if (state.overallDown === 0) {
statusString = t('All systems operational')
icon = <IconCircleCheck style={{ width: 64, height: 64, color: '#059669' }} />
} else {
statusString = t('Some systems not operational', {
down: state.overallDown,
total: 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 [expandUpcoming, setExpandUpcoming] = useState(false)
useEffect(() => {
const interval = setInterval(() => {
if (!isWindowVisible) return
if (currentTime - state.lastUpdate > 300 && currentTime - openTime > 30) {
window.location.reload()
}
setCurrentTime(Math.round(Date.now() / 1000))
}, 1000)
return () => clearInterval(interval)
})
const now = new Date()
const activeMaintenances: (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)!
),
}))
const upcomingMaintenances: (Omit<MaintenanceConfig, 'monitors'> & {
monitors?: (MonitorTarget | undefined)[]
})[] = maintenances
.filter((m) => now < new Date(m.start))
.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}>
{t('Last updated on', {
date: new Date(state.lastUpdate * 1000).toLocaleString(),
seconds: currentTime - state.lastUpdate,
})}
</Title>
{/* Upcoming Maintenance */}
{upcomingMaintenances.length > 0 && (
<>
<Title mt="4px" style={{ textAlign: 'center', color: '#70778c' }} order={5}>
{t('upcoming maintenance', { count: upcomingMaintenances.length })}{' '}
<span
style={{ textDecoration: 'underline', cursor: 'pointer' }}
onClick={() => setExpandUpcoming(!expandUpcoming)}
>
{expandUpcoming ? t('Hide') : t('Show')}
</span>
</Title>
<Collapse in={expandUpcoming}>
{upcomingMaintenances.map((maintenance, idx) => (
<MaintenanceAlert
key={`upcoming-${idx}`}
maintenance={maintenance}
style={{ maxWidth: groupedMonitor ? '897px' : '865px' }}
upcoming
/>
))}
</Collapse>
</>
)}
{/* Active Maintenance */}
{activeMaintenances.map((maintenance, idx) => (
<MaintenanceAlert
key={`active-${idx}`}
maintenance={maintenance}
style={{ maxWidth: groupedMonitor ? '897px' : '865px' }}
/>
))}
</Container>
)
}
+99
View File
@@ -0,0 +1,99 @@
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5"
}
}
}
provider "cloudflare" {
# read token from $CLOUDFLARE_API_TOKEN
}
variable "CLOUDFLARE_ACCOUNT_ID" {
# read account id from $TF_VAR_CLOUDFLARE_ACCOUNT_ID
type = string
}
variable "enable_do_migration" {
type = bool
default = false
}
resource "cloudflare_d1_database" "uptimeflare_d1" {
account_id = var.CLOUDFLARE_ACCOUNT_ID
name = "uptimeflare_d1"
read_replication = {
mode = "auto"
}
}
resource "cloudflare_workers_script" "uptimeflare_worker" {
account_id = var.CLOUDFLARE_ACCOUNT_ID
script_name = "uptimeflare_worker"
main_module = "worker/dist/index.js"
content_file = "worker/dist/index.js"
content_sha256 = filesha256("worker/dist/index.js")
compatibility_date = "2025-04-02"
compatibility_flags = ["nodejs_compat"]
observability = {
enabled = true
logs = {
enabled = true
invocation_logs = true
}
}
migrations = var.enable_do_migration ? {
new_tag = "v1"
new_sqlite_classes = ["RemoteChecker"]
} : null
bindings = [{
name = "REMOTE_CHECKER_DO"
class_name = "RemoteChecker"
type = "durable_object_namespace"
}, {
name = "UPTIMEFLARE_D1"
type = "d1"
id = cloudflare_d1_database.uptimeflare_d1.id
}]
}
resource "cloudflare_workers_cron_trigger" "uptimeflare_worker_cron" {
account_id = var.CLOUDFLARE_ACCOUNT_ID
script_name = cloudflare_workers_script.uptimeflare_worker.script_name
schedules = [{
cron = "* * * * *" # every 1 minute, you can reduce the write counts by increase the worker settings of `kvWriteCooldownMinutes`
}]
}
resource "cloudflare_pages_project" "uptimeflare" {
account_id = var.CLOUDFLARE_ACCOUNT_ID
name = "uptimeflare"
production_branch = "main"
deployment_configs = {
# SMH Cloudflare provider will throw an error without preview config
preview = {
fail_open = false
}
production = {
d1_databases = {
UPTIMEFLARE_D1 = {
id = cloudflare_d1_database.uptimeflare_d1.id
}
}
compatibility_date = "2025-04-02"
compatibility_flags = ["nodejs_compat"]
fail_open = false
}
}
# SMH it will error without this build_config
build_config = {
root_dir = "/"
}
}
+76
View File
@@ -0,0 +1,76 @@
# This is a script to create D1 database, initialize tables, and get D1_ID for later use.
import requests
import os
api_endpoint = f"https://api.cloudflare.com/client/v4/accounts/{os.environ['CLOUDFLARE_ACCOUNT_ID']}"
headers = {
"Authorization": f"Bearer {os.environ['CLOUDFLARE_API_TOKEN']}",
}
d1_name = "uptimeflare_d1"
with open('init.sql', 'r') as f:
init_sql = f.read()
# Try to create D1 database (if it doesn't exist)
r = requests.post(
api_endpoint + "/d1/database",
headers=headers,
json={
"name": d1_name,
"primary_location_hint": "wnam"
}
).json()
if not r['success']:
print("Error creating D1 database: ", r)
if r['errors'][0]['code'] == 7502:
print("D1 database already exists, skipping creation.")
elif r['errors'][0]['code'] == 10000:
print("Authentication error when creating D1 database. Please make sure your CLOUDFLARE_API_TOKEN has the necessary permissions for D1 Database. This is required for versions after 2026/01/02.")
exit(1)
else:
print("Unknown error creating D1 database: ", r)
print("Please report this issue at https://github.com/lyc8503/UptimeFlare/issues.")
exit(1)
else:
print("D1 database created successfully: ", r)
# Fetch D1 database ID
r = requests.get(
api_endpoint + "/d1/database?per_page=1000",
headers=headers
).json()
if not r['success']:
print("Error fetching D1 database info: ", r)
exit(1)
d1_id = ''
for db in r['result']:
if db['name'] == d1_name:
d1_id = db['uuid']
break
if d1_id == '':
print("D1 database not found after creation. Please report this issue at https://github.com/lyc8503/UptimeFlare/issues.")
print("Full response: ", r)
exit(1)
print(f"Got D1 database ID: {d1_id}")
# Create initial table in D1
r = requests.post(
api_endpoint + f"/d1/database/{d1_id}/query",
headers=headers,
json={
"sql": init_sql,
"params": []
}
).json()
print("Initialize D1 database response: ", r)
if not r['success']:
print("Error initializing D1 database.")
exit(1)
print("D1 database initialized successfully.")
with open(os.environ['GITHUB_ENV'], "a") as f:
f.write(f"D1_ID={d1_id}\n")
+98
View File
@@ -0,0 +1,98 @@
# This is a script to migrate state from KV to D1 database.
# It reads the state from KV namespace, compacts it, and writes it to D1 database.
# It also deletes the KV namespace after a successful migration.
import requests
import os
import json
api_endpoint = f"https://api.cloudflare.com/client/v4/accounts/{os.environ['CLOUDFLARE_ACCOUNT_ID']}"
headers = {
"Authorization": f"Bearer {os.environ['CLOUDFLARE_API_TOKEN']}",
}
d1_id = os.environ['D1_ID']
kv_name = "uptimeflare_kv"
# Fetch KV namespace ID
r = requests.get(
api_endpoint + "/storage/kv/namespaces?per_page=1000",
headers=headers
).json()
if not r['success']:
print("Error fetching KV namespace info: ", r)
exit(1)
kv_id = ''
for ns in r['result']:
if ns['title'] == kv_name:
kv_id = ns['id']
break
if kv_id == '':
print("KV namespace not found. Skipping migration.")
exit(0)
print(f"Got KV namespace ID: {kv_id}")
# Fetch state from KV
r = requests.post(
api_endpoint + f"/storage/kv/namespaces/{kv_id}/bulk/get",
headers=headers,
json={
"keys": ["state"]
}
).json()
if not r['success']:
print("Error fetching state from KV: ", r)
exit(1)
# Compact it
original_state = r['result']['values']['state']
state = json.loads(original_state)
compacted_state = {
'lastUpdate': state['lastUpdate'],
'overallUp': state['overallUp'],
'overallDown': state['overallDown'],
'incident': {
k: {
'start': [x['start'] for x in v],
'end': [x.get('end') for x in v],
'error': [x['error'] for x in v]
} for k, v in state['incident'].items()
},
'latency': {}
}
compacted_state_str = json.dumps(compacted_state)
print("Original state: ", original_state[:256] + "..." if len(original_state) > 256 else original_state)
print("Compacted state: ", compacted_state_str[:256] + "..." if len(compacted_state_str) > 256 else compacted_state_str)
# Write compacted state to D1
r = requests.post(
api_endpoint + f"/d1/database/{d1_id}/query",
headers=headers,
json={
"sql": "INSERT INTO uptimeflare (key, value) VALUES (?, ?)",
"params": ["state", compacted_state_str]
}
).json()
if not r['success']:
# UNIQUE constraint failed: uptimeflare.key: SQLITE_CONSTRAINT
if r['errors'][0]['code'] == 7500 and "UNIQUE" in r['errors'][0]['message']:
print("State probably already migrated to D1. Migration skipped.")
else:
print("Error writing state to D1: ", r)
print("Migration failed. Please report this issue at https://github.com/lyc8503/UptimeFlare/issues.")
exit(1)
print("State migrated to D1 successfully. Trying to delete unused KV namespace...")
r = requests.delete(
api_endpoint + f"/storage/kv/namespaces/{kv_id}",
headers=headers
).json()
if r['success']:
print("KV namespace deleted successfully.")
else:
print("Error deleting KV namespace: ", r)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
echo "Initializing database schema..."
cd /app
npx wrangler d1 execute uptimeflare_d1 --file=/app/init.sql
# Start Worker
echo "Starting Worker..."
cd /app/worker
npx wrangler dev --inspector-port 9229 --test-scheduled --persist-to ../.wrangler/state --ip 0.0.0.0 --port 8787 2>&1 > /app/worker.log &
# Start Pages
echo "Starting Pages..."
cd /app
npx wrangler pages dev .vercel/output/static --inspector-port 9230 --ip 0.0.0.0 --port 8788 2>&1 > /app/pages.log &
# CRON Loop
echo "Starting CRON loop..."
touch /app/scheduled.log
echo "* * * * * /usr/bin/curl -m 60 -s 'http://127.0.0.1:8787/__scheduled' >> /app/scheduled.log 2>&1" | crontab -
cron
exec tail -f /app/worker.log /app/pages.log /app/scheduled.log
Vendored
+9
View File
@@ -0,0 +1,9 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
UPTIMEFLARE_STATE: KVNamespace
}
}
}
export {}
+4
View File
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS uptimeflare (
key VARCHAR(255) PRIMARY KEY,
value BLOB NOT NULL
);
+37
View File
@@ -0,0 +1,37 @@
{
"Incidents": "Vorfälle",
"No data yet": "Noch keine Daten",
"All systems not operational": "Nicht alle Systeme funktionsfähig",
"All systems operational": "Alle Systeme funktionsfähig",
"Some systems not operational": "Manche Systeme sind nicht funktionsfähig ({{down}} von {{total}})",
"Last updated on": "Letzte Aktualisierung: {{date}} (vor {{seconds}} Sekunden)",
"upcoming maintenance": "{{count}} anstehende Wartung",
"upcoming maintenance_plural": "{{count}} anstehende Wartungen",
"Hide": "[verstecken]",
"Show": "[zeigen]",
"Operational": "Funktionsfähig",
"No data available": "Keine Daten verfügbar, stelle sicher dass der Worker mit der letzten Config-Version deployed wurde und prüfe den Worker-Status!",
"Overall": "Gesamt: {{percent}}%",
"No Data": "Keine Daten",
"percent at date": "{{percent}}% am {{date}}",
"Down for": "Ausfall für {{duration}} (Klick für Details)",
"incidents at": "🚨 {{name}} Vorfall am {{date}}",
"Response times": "Antwortzeiten (ms)",
"Upcoming": "[Geplant] ",
"Scheduled Maintenance": "Geplante Wartung",
"Scheduled for": "Geplant für:",
"From": "Von:",
"Expected end": "Voraussichtliches Ende:",
"To": "Bis:",
"Until further notice": "Bis auf Weiteres",
"Affected components": "Betroffene Komponenten:",
"MONITOR ID NOT FOUND": "[ERR: MONITOR ID NICHT GEFUNDEN]",
"No incidents in this month": "Keine Vorfälle diesen Monat",
"There are no incidents for this month": "Es gibt keine Vorfälle diesen Monat.",
"Monitor not found": "Monitor mit ID {{id}} nicht gefunden!",
"Monitor State not defined": "Monitor-Status ist nicht definiert. Prüfe den Worker-Status und das Binding!",
"All": "Alle",
"Select monitor": "Auswählen",
"Backwards": "← Zurück",
"Forward": "Vorwärts →"
}
+37
View File
@@ -0,0 +1,37 @@
{
"Incidents": "Incidents",
"No data yet": "No data yet",
"All systems not operational": "All systems not operational",
"All systems operational": "All systems operational",
"Some systems not operational": "Some systems not operational ({{down}} out of {{total}})",
"Last updated on": "Last updated on: {{date}} ({{seconds}} sec ago)",
"upcoming maintenance": "{{count}} upcoming maintenance",
"upcoming maintenance_plural": "{{count}} upcoming maintenances",
"Hide": "[Hide]",
"Show": "[Show]",
"Operational": "Operational",
"No data available": "No data available, please make sure you have deployed your workers with latest config and check your worker status!",
"Overall": "Overall: {{percent}}%",
"No Data": "No Data",
"percent at date": "{{percent}}% at {{date}}",
"Down for": "Down for {{duration}} (click for detail)",
"incidents at": "🚨 {{name}} incidents at {{date}}",
"Response times": "Response times(ms)",
"Upcoming": "[Upcoming] ",
"Scheduled Maintenance": "Scheduled Maintenance",
"Scheduled for": "Scheduled for:",
"From": "From:",
"Expected end": "Expected end:",
"To": "To:",
"Until further notice": "Until further notice",
"Affected components": "Affected components:",
"MONITOR ID NOT FOUND": "[ERR: MONITOR ID NOT FOUND]",
"No incidents in this month": "No incidents in this month",
"There are no incidents for this month": "There are no incidents for this month.",
"Monitor not found": "Monitor with id {{id}} not found!",
"Monitor State not defined": "Monitor State is not defined now, please check your worker's status and binding!",
"All": "All",
"Select monitor": "Select monitor",
"Backwards": "← Backwards",
"Forward": "Forward →"
}
+37
View File
@@ -0,0 +1,37 @@
{
"Incidents": "Incidents",
"No data yet": "Aucune donnée pour le moment",
"All systems not operational": "Aucun système n'est opérationnel",
"All systems operational": "Tous les systèmes sont opérationnels",
"Some systems not operational": "Certains systèmes ne fonctionnent pas ({{down}} sur {{total}})",
"Last updated on": "Dernière actualisation : {{date}} (il y a {{seconds}} secondes)",
"upcoming maintenance": "{{count}} maintenance à venir",
"upcoming maintenance_plural": "{{count}} maintenances à venir",
"Hide": "[Masquer]",
"Show": "[Afficher]",
"Operational": "Opérationnel",
"No data available": "Aucune donnée disponible. Veuillez vous assurer d'avoir déployé vos workers avec la dernière configuration et vérifiez leur statut !",
"Overall": "Moyenne : {{percent}}%",
"No Data": "Aucune donnée",
"percent at date": "{{percent}}% le {{date}}",
"Down for": "Interruption de {{duration}} (cliquez pour plus de détails)",
"incidents at": "🚨 Incidents sur {{name}} le {{date}}",
"Response times": "Temps de réponse (ms)",
"Upcoming": "[À venir] ",
"Scheduled Maintenance": "Maintenance planifiée",
"Scheduled for": "Planifiée pour :",
"From": "De :",
"Expected end": "Fin prévue :",
"To": "À :",
"Until further notice": "Jusqu'à nouvel ordre",
"Affected components": "Composants affectés :",
"MONITOR ID NOT FOUND": "[ERR : ID DU MONITEUR INTROUVABLE]",
"No incidents in this month": "Aucun incident ce mois-ci",
"There are no incidents for this month": "Il n'y a aucun incident pour ce mois-ci.",
"Monitor not found": "Le moniteur avec l'ID {{id}} est introuvable !",
"Monitor State not defined": "L'état du moniteur n'est pas défini actuellement, veuillez vérifier le statut de votre worker et sa liaison !",
"All": "Tous",
"Select monitor": "Sélectionner un moniteur",
"Backwards": "← Précédent",
"Forward": "Suivant →"
}
+37
View File
@@ -0,0 +1,37 @@
{
"Incidents": "历史故障",
"No data yet": "暂无数据",
"All systems not operational": "系统均离线",
"All systems operational": "系统一切正常",
"Some systems not operational": "部分系统离线 ({{down}} / {{total}})",
"Last updated on": "最后更新于: {{date}} ({{seconds}} 秒前)",
"upcoming maintenance": "{{count}} 个即将进行的维护",
"upcoming maintenance_plural": "{{count}} 个即将进行的维护",
"Hide": "[隐藏]",
"Show": "[显示]",
"Operational": "在线",
"No data available": "暂无数据,请确保您已使用最新配置部署了 Worker 并检查 Worker 状态!",
"Overall": "总可用率: {{percent}}%",
"No Data": "暂无数据",
"percent at date": "{{percent}}% 于 {{date}}",
"Down for": "故障持续 {{duration}} (点击查看详情)",
"incidents at": "🚨 {{name}} 于 {{date}} 不可用",
"Response times": "响应时间(ms)",
"Upcoming": "[即将开始] ",
"Scheduled Maintenance": "计划维护",
"Scheduled for": "计划开始:",
"From": "开始:",
"Expected end": "预计结束:",
"To": "结束:",
"Until further notice": "直到另行通知",
"Affected components": "受影响组件:",
"MONITOR ID NOT FOUND": "[错误: 未找到监控 ID]",
"No incidents in this month": "本月无故障",
"There are no incidents for this month": "本月没有发生任何故障。",
"Monitor not found": "未找到 ID 为 {{id}} 的监控!",
"Monitor State not defined": "监控状态未定义,请检查您的 Worker 状态和绑定!",
"All": "全部",
"Select monitor": "选择监控",
"Backwards": "← 上一月",
"Forward": "下一月 →"
}
+37
View File
@@ -0,0 +1,37 @@
{
"Incidents": "歷來事件",
"No data yet": "沒有資料",
"All systems not operational": "所有服務都已停止運作",
"All systems operational": "所有服務皆正常運作",
"Some systems not operational": "部分服務停止運作 ({{down}} / {{total}})",
"Last updated on": "最後更新於:{{date}}{{seconds}} 秒前)",
"upcoming maintenance": "{{count}} 個即將要進行的維護",
"upcoming maintenance_plural": "{{count}} 個即將要進行的維護",
"Hide": "[隱藏]",
"Show": "[顯示]",
"Operational": "運行中",
"No data available": "沒有資料,請確保已部署最新設定的 Worker 並檢查 Worker 狀態!",
"Overall": "總運行率:{{percent}}%",
"No Data": "沒有資料",
"percent at date": "{{percent}}% 於 {{date}}",
"Down for": "事件持續 {{duration}}(點擊查看詳情)",
"incidents at": "🚨 {{name}} 於 {{date}} 停運",
"Response times": "回應時間(毫秒)",
"Upcoming": "[即將開始] ",
"Scheduled Maintenance": "預定維護",
"Scheduled for": "預定開始:",
"From": "開始:",
"Expected end": "預計結束:",
"To": "結束:",
"Until further notice": "即日起持續生效",
"Affected components": "受影響的部份:",
"MONITOR ID NOT FOUND": "[錯誤:找不到站點 ID]",
"No incidents in this month": "本月無事件",
"There are no incidents for this month": "本月沒有任何事件發生。",
"Monitor not found": "找不到 ID 為 {{id}} 的站點!",
"Monitor State not defined": "站點狀態未定義,請檢查您的 Worker 狀態與 KV 綁定!",
"All": "全部",
"Select monitor": "選取站點",
"Backwards": "← 上個月",
"Forward": "下個月 →"
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { workerConfig } from './uptime.config'
export async function middleware(request: NextRequest) {
const passwordProtection = workerConfig.passwordProtection
if (passwordProtection) {
const authHeader = request.headers.get('Authorization')
let authenticated = false
const expected = 'Basic ' + btoa(passwordProtection)
if (authHeader && authHeader.length === expected.length) {
// a simple timing-safe compare
authenticated = true
for (let i = 0; i < authHeader.length; i++) {
if (authHeader[i] !== expected[i]) authenticated = false
}
}
if (!authenticated) {
return NextResponse.json(
{ code: 401, message: 'Not authenticated' },
{ status: 401, headers: { 'WWW-Authenticate': 'Basic' } }
)
}
}
}
+18
View File
@@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig
if (process.env.NODE_ENV === 'development') {
const { setupDevBindings } = require('@cloudflare/next-on-pages/next-dev')
setupDevBindings({
bindings: {
UPTIMEFLARE_STATE: {
type: 'kv',
id: 'UPTIMEFLARE_STATE',
},
},
})
}
+16099
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "uptimeflare",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"preview": "npx @cloudflare/next-on-pages && wrangler pages dev .vercel/output/static",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@cloudflare/workers-types": "^4.20250410.0",
"@mantine/core": "^7.1.3",
"@mantine/ds": "^7.1.3",
"@mantine/hooks": "^7.1.3",
"@tabler/icons-react": "^2.39.0",
"@types/moment-precise-range-plugin": "^0.2.2",
"chart.js": "^4.4.0",
"chartjs-adapter-moment": "^1.0.1",
"i18next": "^25.7.3",
"i18next-browser-languagedetector": "^8.2.0",
"moment": "^2.29.4",
"moment-precise-range-plugin": "^1.3.0",
"next": "^14.2.28",
"react": "^18.3.1",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
"react-i18next": "^16.5.0"
},
"devDependencies": {
"@cloudflare/next-on-pages": "^1.13.12",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"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": "^4.54.0"
}
}
+15
View File
@@ -0,0 +1,15 @@
import '@mantine/core/styles.css'
import type { AppProps } from 'next/app'
import { MantineProvider } from '@mantine/core'
import NoSsr from '@/components/NoSsr'
import '@/util/i18n'
export default function App({ Component, pageProps }: AppProps) {
return (
<NoSsr>
<MantineProvider defaultColorScheme="auto">
<Component {...pageProps} />
</MantineProvider>
</NoSsr>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { Html, Head, Main, NextScript } from 'next/document'
import { ColorSchemeScript } from '@mantine/core'
export default function Document() {
return (
<Html lang="en">
<Head>
<ColorSchemeScript defaultColorScheme="auto" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
+75
View File
@@ -0,0 +1,75 @@
import { NextRequest } from 'next/server'
import { CompactedMonitorStateWrapper, getFromStore } from '@/worker/src/store'
export const runtime = 'edge'
type BadgePayload = {
schemaVersion: 1
label: string
message: string
color: string
isError?: boolean
}
const jsonHeaders = {
'Content-Type': 'application/json',
'Cache-Control': 'no-store, max-age=0, must-revalidate',
}
function errorBadge(label: string, message: string): BadgePayload {
return {
schemaVersion: 1,
label,
message,
color: 'lightgrey',
isError: true,
}
}
export default async function handler(req: NextRequest): Promise<Response> {
try {
const url = new URL(req.url)
const monitorId = url.searchParams.get('id')
const label = url.searchParams.get('label') ?? monitorId ?? 'UptimeFlare'
const upMsg = url.searchParams.get('up') ?? 'UP'
const downMsg = url.searchParams.get('down') ?? 'DOWN'
const colorUp = url.searchParams.get('colorUp') ?? 'brightgreen'
const colorDown = url.searchParams.get('colorDown') ?? 'red'
if (!monitorId) {
return new Response(JSON.stringify(errorBadge(label, 'no-monitor')), {
headers: jsonHeaders,
status: 400,
})
}
const compactedState = new CompactedMonitorStateWrapper(
await getFromStore(process.env as any, 'state')
)
const lastIncident = compactedState.getIncident(
monitorId,
compactedState.incidentLen(monitorId) - 1
)
const isUp = lastIncident?.end !== null
const badge: BadgePayload = {
schemaVersion: 1,
label,
message: isUp ? upMsg : downMsg,
color: isUp ? colorUp : colorDown,
}
return new Response(JSON.stringify(badge), {
headers: jsonHeaders,
})
} catch (err) {
console.error('Error rendering badge API:', err)
return new Response(JSON.stringify(errorBadge('status', 'error')), {
headers: jsonHeaders,
status: 500,
})
}
}
+55
View File
@@ -0,0 +1,55 @@
import { maintenances, workerConfig } from '@/uptime.config'
import { NextRequest } from 'next/server'
import { CompactedMonitorStateWrapper, getFromStore } from '@/worker/src/store'
export const runtime = 'edge'
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
}
export default async function handler(req: NextRequest): Promise<Response> {
const compactedState = new CompactedMonitorStateWrapper(
await getFromStore(process.env as any, 'state')
)
if (compactedState.data.lastUpdate === 0) {
return new Response(JSON.stringify({ error: 'No data available' }), {
status: 500,
headers,
})
}
let monitors: any = {}
for (let monitor of workerConfig.monitors) {
const lastIncident = compactedState.getIncident(
monitor.id,
compactedState.incidentLen(monitor.id) - 1
)
const isUp = lastIncident?.end !== null
const latency = compactedState.getLastLatency(monitor.id)
monitors[monitor.id] = {
up: isUp,
latency: latency.ping,
location: latency.loc,
message: isUp ? 'OK' : lastIncident?.error[lastIncident.error.length - 1],
}
}
let ret = {
up: compactedState.data.overallUp,
down: compactedState.data.overallDown,
updatedAt: compactedState.data.lastUpdate,
monitors,
maintenances,
}
return new Response(JSON.stringify(ret), {
headers,
})
}
+144
View File
@@ -0,0 +1,144 @@
import Head from 'next/head'
import { Inter } from 'next/font/google'
import { MaintenanceConfig, MonitorTarget } from '@/types/config'
import { maintenances, pageConfig } from '@/uptime.config'
import Header from '@/components/Header'
import { Box, Button, Center, Container, Group, Select } from '@mantine/core'
import Footer from '@/components/Footer'
import { useEffect, useState } from 'react'
import MaintenanceAlert from '@/components/MaintenanceAlert'
import NoIncidentsAlert from '@/components/NoIncidents'
import { useTranslation } from 'react-i18next'
export const runtime = 'experimental-edge'
const inter = Inter({ subsets: ['latin'] })
function getSelectedMonth() {
const hash = window.location.hash.replace('#', '')
if (!hash) {
const now = new Date()
return now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0')
}
return hash.split('-').splice(0, 2).join('-')
}
function filterIncidentsByMonth(
incidents: MaintenanceConfig[],
monthStr: string,
monitors: MonitorTarget[]
): (Omit<MaintenanceConfig, 'monitors'> & { monitors: MonitorTarget[] })[] {
return incidents
.filter((incident) => {
const d = new Date(incident.start)
const incidentMonth = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
return incidentMonth === monthStr
})
.map((e) => ({
...e,
monitors: (e.monitors || []).map((e) => monitors.find((mon) => mon.id === e)!),
}))
.sort((a, b) => (new Date(a.start) > new Date(b.start) ? -1 : 1))
}
function getPrevNextMonth(monthStr: string) {
const [year, month] = monthStr.split('-').map(Number)
const date = new Date(year, month - 1)
const prev = new Date(date)
prev.setMonth(prev.getMonth() - 1)
const next = new Date(date)
next.setMonth(next.getMonth() + 1)
return {
prev: prev.getFullYear() + '-' + String(prev.getMonth() + 1).padStart(2, '0'),
next: next.getFullYear() + '-' + String(next.getMonth() + 1).padStart(2, '0'),
}
}
export default function IncidentsPage({ monitors }: { monitors: MonitorTarget[] }) {
const { t } = useTranslation('common')
const [selectedMonitor, setSelectedMonitor] = useState<string | null>('')
const [selectedMonth, setSelectedMonth] = useState(getSelectedMonth())
useEffect(() => {
const onHashChange = () => setSelectedMonth(getSelectedMonth())
window.addEventListener('hashchange', onHashChange)
return () => window.removeEventListener('hashchange', onHashChange)
}, [])
const filteredIncidents = filterIncidentsByMonth(maintenances, selectedMonth, monitors)
const monitorFilteredIncidents = selectedMonitor
? filteredIncidents.filter((i) => i.monitors.find((e) => e.id === selectedMonitor))
: filteredIncidents
const { prev, next } = getPrevNextMonth(selectedMonth)
const monitorOptions = [
{ value: '', label: t('All') },
...monitors.map((monitor) => ({
value: monitor.id,
label: monitor.name,
})),
]
return (
<>
<Head>
<title>{pageConfig.title}</title>
<link rel="icon" href={pageConfig.favicon ?? '/favicon.png'} />
</Head>
<main className={inter.className}>
<Header
style={{
marginBottom: '40px',
}}
/>
<Center>
<Container size="md" style={{ width: '100%' }}>
<Group justify="end" mb="md">
<Select
placeholder={t('Select monitor')}
data={monitorOptions}
value={selectedMonitor}
onChange={setSelectedMonitor}
clearable
style={{ maxWidth: 300, float: 'right' }}
/>
</Group>
<Box>
{monitorFilteredIncidents.length === 0 ? (
<NoIncidentsAlert />
) : (
monitorFilteredIncidents.map((incident, i) => (
<MaintenanceAlert key={i} maintenance={incident} />
))
)}
</Box>
<Group justify="space-between" mt="md">
<Button variant="default" onClick={() => (window.location.hash = prev)}>
{t('Backwards')}
</Button>
<Box style={{ alignSelf: 'center', fontWeight: 500, fontSize: 18 }}>
{selectedMonth}
</Box>
<Button variant="default" onClick={() => (window.location.hash = next)}>
{t('Forward')}
</Button>
</Group>
</Container>
</Center>
<Footer />
</main>
</>
)
}
export async function getServerSideProps() {
const { workerConfig } = await import('@/uptime.config')
// Only present these values to client
const monitors: MonitorTarget[] = workerConfig.monitors.map((monitor) => ({
id: monitor.id,
name: monitor.name,
})) as MonitorTarget[]
return { props: { monitors } }
}
+91
View File
@@ -0,0 +1,91 @@
import Head from 'next/head'
import { Inter } from 'next/font/google'
import { MonitorTarget } from '@/types/config'
import { maintenances, pageConfig } from '@/uptime.config'
import OverallStatus from '@/components/OverallStatus'
import Header from '@/components/Header'
import MonitorList from '@/components/MonitorList'
import { Center, Text } from '@mantine/core'
import MonitorDetail from '@/components/MonitorDetail'
import Footer from '@/components/Footer'
import { useTranslation } from 'react-i18next'
import { CompactedMonitorStateWrapper, getFromStore } from '@/worker/src/store'
export const runtime = 'experimental-edge'
const inter = Inter({ subsets: ['latin'] })
export default function Home({
compactedStateStr,
monitors,
}: {
compactedStateStr: string
monitors: MonitorTarget[]
tooltip?: string
statusPageLink?: string
}) {
const { t } = useTranslation('common')
let state = new CompactedMonitorStateWrapper(compactedStateStr).uncompact()
// Specify monitorId in URL hash to view a specific monitor (can be used in iframe)
const monitorId = window.location.hash.substring(1)
if (monitorId) {
const monitor = monitors.find((monitor) => monitor.id === monitorId)
if (!monitor || !state) {
return <Text fw={700}>{t('Monitor not found', { id: monitorId })}</Text>
}
return (
<div style={{ maxWidth: '810px' }}>
<MonitorDetail monitor={monitor} state={state} />
</div>
)
}
return (
<>
<Head>
<title>{pageConfig.title}</title>
<link rel="icon" href={pageConfig.favicon ?? '/favicon.png'} />
</Head>
<main className={inter.className}>
<Header />
{state.lastUpdate === 0 ? (
<Center>
<Text fw={700}>{t('Monitor State not defined')}</Text>
</Center>
) : (
<div>
<OverallStatus state={state} monitors={monitors} maintenances={maintenances} />
<MonitorList monitors={monitors} state={state} />
</div>
)}
<Footer />
</main>
</>
)
}
export async function getServerSideProps() {
const { workerConfig } = await import('@/uptime.config')
// Read state as string from storage, to avoid hitting server-side cpu time limit
const compactedStateStr = await getFromStore(process.env as any, 'state')
// Only present these values to client
const monitors = workerConfig.monitors.map((monitor) => {
return {
id: monitor.id,
name: monitor.name,
// @ts-ignore
tooltip: monitor?.tooltip,
// @ts-ignore
statusPageLink: monitor?.statusPageLink,
// @ts-ignore
hideLatencyChart: monitor?.hideLatencyChart,
}
})
return { props: { compactedStateStr, monitors } }
}
+14
View File
@@ -0,0 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
}
+1
View File
@@ -0,0 +1 @@
node_modules
+169
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
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "uptimeflare_proxy",
"version": "0.0.0",
"description": "",
"main": "api/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"express": "^5.1.0"
}
}
+1
View File
@@ -0,0 +1 @@
{ "version": 2, "rewrites": [{ "source": "/(.*)", "destination": "/api" }] }
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+154
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 149 KiB

+33
View File
@@ -0,0 +1,33 @@
.header {
height: rem(56px);
margin-bottom: rem(100px);
background-color: var(--mantine-color-body);
border-bottom: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.inner {
height: rem(56px);
display: flex;
justify-content: space-between;
align-items: center;
}
.link {
display: block;
line-height: 1;
padding: rem(8px) rem(12px);
border-radius: var(--mantine-radius-sm);
text-decoration: none;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
font-size: var(--mantine-font-size-sm);
font-weight: 500;
@mixin hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
[data-mantine-color-scheme] &[data-active] {
background-color: var(--mantine-color-blue-filled);
color: var(--mantine-color-white);
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./*"]
},
"types": ["@cloudflare/workers-types"]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "worker/**/*.ts"]
}
+155
View File
@@ -0,0 +1,155 @@
import type { Env } from '../worker/src'
export type PageConfig = {
title?: string
links?: PageConfigLink[]
group?: PageConfigGroup
favicon?: string
logo?: string
maintenances?: {
upcomingColor?: string
}
customFooter?: string
}
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 = {
webhook?: WebhookConfig
timeZone?: string
gracePeriod?: number
skipNotificationIds?: string[]
skipErrorChangeNotification?: boolean
}
type SingleWebhook = {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'PATCH'
headers?: { [key: string]: string | number }
payloadType: 'param' | 'json' | 'x-www-form-urlencoded'
payload: any
timeout?: number
}
export type WebhookConfig = SingleWebhook | SingleWebhook[]
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 IncidentRecord = {
start: number[]
end: number | null // null if it's still open
error: string[]
}
export type LatencyRecord = {
loc: string
ping: number
time: number
}
export type MonitorState = {
lastUpdate: number
overallUp: number
overallDown: number
incident: Record<string, IncidentRecord[]>
latency: Record<string, LatencyRecord[]> // recent 12 hour data, N min interval
}
// This is now the actual stored format (after 2026/01/01 D1 migration) to improve (de)serialization performance
// This gives a ~3.5x speedup in computing and a 40-60% reduction in size
// The CPULimitExceeded issue with 10+ monitors on free tier should be mitigated by this change
// local profiling result (1 op = parse + stringify):
// MonitorState (original): 277 ops/s, ±0.51% | slowest, 71.09% slower
// MonitorStateCompacted: 958 ops/s, ±1.17% | fastest
// Real world test with 8 monitors and a few hundred incidents and full latency data (status.lyc8503.net):
// original: 433KB size, 11.24ms P50 cpu time, 18.11ms P99 cpu time
// compacted: 181KB size (59% smaller), 6.36ms P50 cpu time (43% faster), 8.86ms P99 cpu time (51% faster)
export type MonitorStateCompacted = {
lastUpdate: number
overallUp: number
overallDown: number
// incident in stored in columnar format
incident: Record<
string, // monitor id
{
start: number[][]
end: (number | null)[]
error: string[][]
}
>
// latency in stored in columnar format
// also uses Run-length encoding for loc & Base64 encoding for number arrays
latency: Record<
string, // monitor id
{
loc: {
v: string[] // RLE values
c: number[] // RLE counts
}
// Hex results in a larger size and slower encoding/decoding than base64,
// but we can pop/append arbitrary number of bytes without decoding then re-encoding the whole string
// This is useful in Workers and shows a ~2% speedup comapred to base64, and it also simplifies the code
ping: string // Hex encoded Uint16Array
time: string // Hex encoded Uint32Array
}
>
}
+206
View File
@@ -0,0 +1,206 @@
import { MaintenanceConfig, PageConfig, WorkerConfig } from './types/config'
const pageConfig: PageConfig = {
// Title for your status page
title: "lyc8503's Status Page",
// Links shown at the header of your status page, could set `highlight` to `true`
links: [
{ link: 'https://github.com/lyc8503', label: 'GitHub' },
{ link: 'https://blog.lyc8503.net/', label: 'Blog' },
{ link: 'mailto:me@lyc8503.net', label: 'Email Me', 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: {
'🌐 Public (example group name)': ['foo_monitor', 'bar_monitor', 'more monitor ids...'],
'🔐 Private': ['test_tcp_monitor'],
},
// [OPTIONAL] Set the path to your favicon, default to '/favicon.png' if not specified
// favicon: 'https://example.com/favicon.ico',
// [OPTIONAL] Set the path to your logo, default to '/logo.svg' if not specified
// logo: 'https://example.com/logo.svg',
// [OPTIONAL] Maintenance related settings
maintenances: {
// [OPTIONAL] The color of upcoming maintenance alerts, default to 'gray'
// Active alerts will always use the color specified in the MaintenanceConfig
upcomingColor: 'gray',
},
// [OPTIONAL] Custom footer html
// customFooter: '',
}
const workerConfig: WorkerConfig = {
// [Optional] Write KV at most every N minutes unless the status changed, default to 3
kvWriteCooldownMinutes: 3,
// 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: [
// Example HTTP Monitor
{
// `id` should be unique, history will be kept if the `id` remains constant
id: 'foo_monitor',
// `name` is used at status page and callback message
name: 'My API Monitor',
// `method` should be a valid HTTP Method
method: 'POST',
// `target` is a valid URL
target: 'https://example.com',
// [OPTIONAL] `tooltip` is ONLY used at status page to show a tooltip
tooltip: 'This is a tooltip for this monitor',
// [OPTIONAL] `statusPageLink` is ONLY used for clickable link at status page
statusPageLink: 'https://example.com',
// [OPTIONAL] `hideLatencyChart` will hide status page latency chart if set to true
hideLatencyChart: false,
// [OPTIONAL] `expectedCodes` is an array of acceptable HTTP response codes, if not specified, default to 2xx
expectedCodes: [200],
// [OPTIONAL] `timeout` in millisecond, if not specified, default to 10000
timeout: 10000,
// [OPTIONAL] headers to be sent
headers: {
'User-Agent': 'Uptimeflare',
Authorization: 'Bearer YOUR_TOKEN_HERE',
},
// [OPTIONAL] body to be sent
body: 'Hello, world!',
// [OPTIONAL] if specified, the response must contains the keyword to be considered as operational.
responseKeyword: 'success',
// [OPTIONAL] if specified, the response must NOT contains the keyword to be considered as operational.
responseForbiddenKeyword: 'bad gateway',
// [OPTIONAL] if specified, will call the check proxy to check the monitor, mainly for geo-specific checks
// refer to docs https://github.com/lyc8503/UptimeFlare/wiki/Check-proxy-setup before setting this value
// currently supports `worker://`, `globalping://` and `http(s)://` proxies
checkProxy: 'https://xxx.example.com OR worker://weur',
// [OPTIONAL] if true, the check will fallback to local if the specified proxy is down
checkProxyFallback: true,
},
// Example TCP Monitor
{
id: 'test_tcp_monitor',
name: 'Example TCP Monitor',
// `method` should be `TCP_PING` for tcp monitors
method: 'TCP_PING',
// `target` should be `host:port` for tcp monitors
target: '1.2.3.4:22',
tooltip: 'My production server SSH',
statusPageLink: 'https://example.com',
timeout: 5000,
},
],
// [Optional] Notification settings
notification: {
// [Optional] Notification webhook settings, if not specified, no notification will be sent
// More info at Wiki: https://github.com/lyc8503/UptimeFlare/wiki/Setup-notification
webhook: {
// [Required] webhook URL (example: Telegram Bot API)
url: 'https://api.telegram.org/bot123456:ABCDEF/sendMessage',
// [Optional] HTTP method, default to 'GET' for payloadType=param, 'POST' otherwise
method: 'POST',
// [Optional] headers to be sent
headers: {
foo: 'bar',
},
// [Required] Specify how to encode the payload
// Should be one of 'param', 'json' or 'x-www-form-urlencoded'
// 'param': append url-encoded payload to URL search parameters
// 'json': POST json payload as body, set content-type header to 'application/json'
// 'x-www-form-urlencoded': POST url-encoded payload as body, set content-type header to 'x-www-form-urlencoded'
payloadType: 'x-www-form-urlencoded',
// [Required] payload to be sent
// $MSG will be replaced with the human-readable notification message
payload: {
chat_id: 12345678,
text: '$MSG',
},
// [Optional] timeout calling this webhook, in millisecond, default to 5000
timeout: 10000,
},
// [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'],
// [Optional] suppress extra notifications for error reason changes during an incident, default to false
skipErrorChangeNotification: true,
},
callbacks: {
onStatusChange: async (
env: any,
monitor: any,
isUp: boolean,
timeIncidentStart: number,
timeNow: number,
reason: string
) => {
// 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
},
onIncident: async (
env: any,
monitor: any,
timeIncidentStart: number,
timeNow: number,
reason: string
) => {
// This callback will be called EVERY 1 MINTUE if there's an on-going incident for any monitor
// Write any TypeScript code here
},
},
}
// 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',
},
// As this config file is a TypeScript file, you can even use IIFE to generate scheduled maintenances
// The following example shows a scheduled maintenance from 2 AM to 4 AM on the 15th of every month (UTC+8)
// This COULD BE DANGEROUS, as generating too many maintenance entries can lead to performance problems
// Undeterministic outputs may also lead to bugs or unexpected behavior
// If you don't know how to DEBUG, use this approach WITH CAUTION
...(function () {
const schedules = []
const today = new Date()
for (let i = -1; i <= 1; i++) {
// JavaScript's Date object will automatically handle year rollovers
const date = new Date(today.getFullYear(), today.getMonth() + i, 15)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
schedules.push({
title: `${year}/${parseInt(month)} - Test scheduled maintenance`,
monitors: ['foo_monitor'],
body: 'Monthly scheduled maintenance',
start: `${year}-${month}-15T02:00:00.000+08:00`,
end: `${year}-${month}-15T04:00:00.000+08:00`,
})
}
return schedules
})(),
]
// Don't forget this, otherwise compilation fails.
export { maintenances, pageConfig, workerConfig }
+134
View File
@@ -0,0 +1,134 @@
// This is a simplified example config file for quickstart
// Some not frequently used features are omitted/commented out here
// For a full-featured example, please refer to `uptime.config.full.ts`
// Don't edit this line
import { MaintenanceConfig, PageConfig, WorkerConfig } from './types/config'
const pageConfig: PageConfig = {
// Title for your status page
title: "lyc8503's Status Page",
// Links shown at the header of your status page, could set `highlight` to `true`
links: [
{ link: 'https://github.com/lyc8503', label: 'GitHub' },
{ link: 'https://blog.lyc8503.net/', label: 'Blog' },
{ link: 'mailto:me@lyc8503.net', label: 'Email Me', highlight: true },
],
}
const workerConfig: WorkerConfig = {
// Define all your monitors here
monitors: [
// Example HTTP Monitor
{
// `id` should be unique, history will be kept if the `id` remains constant
id: 'foo_monitor',
// `name` is used at status page and callback message
name: 'My API Monitor',
// `method` should be a valid HTTP Method
method: 'GET',
// `target` is a valid URL
target: 'https://example.com',
// [OPTIONAL] `tooltip` is ONLY used at status page to show a tooltip
tooltip: 'This is a tooltip for this monitor',
// [OPTIONAL] `statusPageLink` is ONLY used for clickable link at status page
statusPageLink: 'https://example.com',
// [OPTIONAL] `expectedCodes` is an array of acceptable HTTP response codes, if not specified, default to 2xx
expectedCodes: [200],
// [OPTIONAL] `timeout` in millisecond, if not specified, default to 10000
timeout: 10000,
// [OPTIONAL] headers to be sent
headers: {
'User-Agent': 'Uptimeflare',
Authorization: 'Bearer YOUR_TOKEN_HERE',
},
// [OPTIONAL] body to be sent (require POST/PUT/PATCH method)
// body: 'Hello, world!',
// [OPTIONAL] if specified, the response must contains the keyword to be considered as operational.
// responseKeyword: 'success',
// [OPTIONAL] if specified, the response must NOT contains the keyword to be considered as operational.
// responseForbiddenKeyword: 'bad gateway',
// [OPTIONAL] if specified, will call the check proxy to check the monitor, mainly for geo-specific checks
// refer to docs https://github.com/lyc8503/UptimeFlare/wiki/Check-proxy-setup before setting this value
// currently supports `worker://`, `globalping://` and `http(s)://` proxies
// checkProxy: 'worker://weur',
// [OPTIONAL] if true, the check will fallback to local if the specified proxy is down
// checkProxyFallback: true,
},
// Example TCP Monitor
{
id: 'test_tcp_monitor',
name: 'Example TCP Monitor',
// `method` should be `TCP_PING` for tcp monitors
method: 'TCP_PING',
// `target` should be `host:port` for tcp monitors
target: '1.2.3.4:22',
tooltip: 'My production server SSH',
statusPageLink: 'https://example.com',
timeout: 5000,
},
],
// [Optional] Notification settings
notification: {
// [Optional] Notification webhook settings, if not specified, no notification will be sent
// More info at Wiki: https://github.com/lyc8503/UptimeFlare/wiki/Setup-notification
webhook: {
// [Required] webhook URL (example: Telegram Bot API)
url: 'https://api.telegram.org/bot123456:ABCDEF/sendMessage',
// [Optional] HTTP method, default to 'GET' for payloadType=param, 'POST' otherwise
// method: 'POST',
// [Optional] headers to be sent
// headers: {
// foo: 'bar',
// },
// [Required] Specify how to encode the payload
// Should be one of 'param', 'json' or 'x-www-form-urlencoded'
// 'param': append url-encoded payload to URL search parameters
// 'json': POST json payload as body, set content-type header to 'application/json'
// 'x-www-form-urlencoded': POST url-encoded payload as body, set content-type header to 'x-www-form-urlencoded'
payloadType: 'x-www-form-urlencoded',
// [Required] payload to be sent
// $MSG will be replaced with the human-readable notification message
payload: {
chat_id: 12345678,
text: '$MSG',
},
// [Optional] timeout calling this webhook, in millisecond, default to 5000
timeout: 10000,
},
// [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,
},
}
// 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: '2020-01-01T00: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: '2050-01-01T00:00:00+08:00',
// [Optional] color of the maintenance alert at status page, default to "yellow"
color: 'blue',
},
]
// Don't edit this line
export { maintenances, pageConfig, workerConfig }
+16
View File
@@ -0,0 +1,16 @@
function getColor(percent: number | string, darker: boolean): string {
percent = Number(percent)
if (percent >= 99.9) {
return darker ? '#059669' : '#3bd671'
} else if (percent >= 99) {
return darker ? '#3bd671' : '#9deab8'
} else if (percent >= 95) {
return '#f29030'
} else if (Number.isNaN(percent)) {
return 'gray'
} else {
return '#df484a'
}
}
export { getColor }
+33
View File
@@ -0,0 +1,33 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import en from '../locales/en/common.json'
import zhCN from '../locales/zh-CN/common.json'
import zhTW from '../locales/zh-TW/common.json'
import frFR from '../locales/fr-FR/common.json'
import deDE from '../locales/de-DE/common.json'
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { common: en },
'zh-CN': { common: zhCN },
zh: { common: zhCN },
'zh-TW': { common: zhTW },
fr: { common: frFR },
'fr-FR': { common: frFR },
de: { common: deDE },
'de-DE': { common: deDE },
},
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
detection: {
order: ['navigator'],
},
})
export default i18n
+19
View File
File diff suppressed because one or more lines are too long
+172
View File
@@ -0,0 +1,172 @@
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
# wrangler project
.dev.vars
.wrangler/
+5
View File
@@ -0,0 +1,5 @@
trailingComma: 'es5'
tabWidth: 2
semi: false
singleQuote: true
printWidth: 100
+2406
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "uptimeworker",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev --test-scheduled --persist-to ../.wrangler/state",
"start": "wrangler dev"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250410.0",
"typescript": "^5.0.4",
"wrangler": "^4.54.0"
},
"dependencies": {
"p-limit": "^7.2.0"
}
}
+279
View File
@@ -0,0 +1,279 @@
import { DurableObject } from 'cloudflare:workers'
import { MonitorTarget } from '../../types/config'
import { workerConfig } from '../../uptime.config'
import { doMonitor, getStatus } from './monitor'
import { formatAndNotify, getWorkerLocation } from './util'
import { CompactedMonitorStateWrapper, getFromStore, setToStore } from './store'
import pLimit from 'p-limit'
export interface Env {
REMOTE_CHECKER_DO: DurableObjectNamespace<RemoteChecker>
UPTIMEFLARE_D1: D1Database
}
const Worker = {
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
const workerLocation = (await getWorkerLocation()) || 'ERROR'
console.log(`Running scheduled event on ${workerLocation}...`)
// Create a wrapped MonitorState from stored compacted state
const state = new CompactedMonitorStateWrapper(await getFromStore(env, 'state'))
state.data.overallDown = 0
state.data.overallUp = 0
let statusChanged = false
const currentTimeSecond = Math.round(Date.now() / 1000)
// Parallel check multiple monitors
// Max concurrent connection is 6 limited by Cloudflare Workers, we use 5 here to be safe
type CheckResult = { id: string; location: string; status: { ping: number; up: boolean; err: string } }
let checkQueue: Promise<CheckResult>[] = []
let checkResult: Record<string, CheckResult> = {};
const limit = pLimit(5);
for (const monitor of workerConfig.monitors) {
checkQueue.push(limit(() => doMonitor(monitor, workerLocation, env)))
}
for (const result of await Promise.all(checkQueue)) {
checkResult[result.id] = result
}
// Update each monitor's state based on check results
for (const monitor of workerConfig.monitors) {
console.log(`Processing monitor result: ${monitor.name} (${monitor.id})`)
let monitorStatusChanged = false
const { location: checkLocation, status } = checkResult[monitor.id]
// Update counters
status.up ? state.data.overallUp++ : state.data.overallDown++
// Update incidents
// Create a dummy incident to store the start time of the monitoring and simplify logic
if (state.incidentLen(monitor.id) === 0) {
state.appendIncident(monitor.id, {
start: [currentTimeSecond],
end: currentTimeSecond,
error: ['dummy'],
})
}
// Then lastIncident here must not be null
let lastIncident = state.getIncident(monitor.id, state.incidentLen(monitor.id) - 1)
if (status.up) {
// Current status is up
// close existing incident if any
if (lastIncident.end === null) {
lastIncident.end = currentTimeSecond
// write back the modified last incident
state.setIncident(monitor.id, state.incidentLen(monitor.id) - 1, lastIncident)
monitorStatusChanged = true
try {
if (
// 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
) {
await formatAndNotify(monitor, true, lastIncident.start[0], currentTimeSecond, 'OK')
} else {
console.log(
`grace period (${workerConfig.notification?.gracePeriod}m) not met, skipping webhook UP notification for ${monitor.name}`
)
}
console.log('Calling config onStatusChange callback...')
await workerConfig.callbacks?.onStatusChange?.(
env,
monitor,
true,
lastIncident.start[0],
currentTimeSecond,
'OK'
)
} catch (e) {
console.log('Error calling callback: ')
console.log(e)
}
}
} else {
// Current status is down
// open new incident if not already open
if (lastIncident.end !== null) {
state.appendIncident(monitor.id, {
start: [currentTimeSecond],
end: null,
error: [status.err],
})
monitorStatusChanged = true
} else if (lastIncident.end === null && lastIncident.error.slice(-1)[0] !== status.err) {
// append if the error message changes
lastIncident.start.push(currentTimeSecond)
lastIncident.error.push(status.err)
// write back the modified last incident
state.setIncident(monitor.id, state.incidentLen(monitor.id) - 1, lastIncident)
monitorStatusChanged = true
}
const currentIncident = state.getIncident(monitor.id, state.incidentLen(monitor.id) - 1)
try {
if (
// monitor status changed AND...
(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)
) {
if (
currentIncident.start[0] !== currentTimeSecond &&
workerConfig.notification?.skipErrorChangeNotification
) {
console.log(
'Skipping notification for following error reason change due to user config'
)
} else {
await formatAndNotify(
monitor,
false,
currentIncident.start[0],
currentTimeSecond,
status.err
)
}
} else {
console.log(
`Grace period (${workerConfig.notification
?.gracePeriod}m) not met or no change (currently down for ${
currentTimeSecond - currentIncident.start[0]
}s, changed ${monitorStatusChanged}), skipping webhook DOWN notification for ${
monitor.name
}`
)
}
if (monitorStatusChanged) {
console.log('Calling config onStatusChange callback...')
await workerConfig.callbacks?.onStatusChange?.(
env,
monitor,
false,
currentIncident.start[0],
currentTimeSecond,
status.err
)
}
} catch (e) {
console.log('Error calling callback: ')
console.log(e)
}
try {
console.log('Calling config onIncident callback...')
await workerConfig.callbacks?.onIncident?.(
env,
monitor,
currentIncident.start[0],
currentTimeSecond,
status.err
)
} catch (e) {
console.log('Error calling callback: ')
console.log(e)
}
}
// append to latency data
state.appendLatency(monitor.id, {
loc: checkLocation,
ping: status.ping,
time: currentTimeSecond,
})
// discard old data
while (state.getFirstLatency(monitor.id).time < currentTimeSecond - 12 * 60 * 60) {
state.unshiftLatency(monitor.id)
}
// discard old incidents
while (
state.incidentLen(monitor.id) > 0 &&
state.getIncident(monitor.id, 0).end &&
state.getIncident(monitor.id, 0).end! < currentTimeSecond - 90 * 24 * 60 * 60
) {
state.shiftIncident(monitor.id)
}
if (
state.incidentLen(monitor.id) === 0 ||
(state.getIncident(monitor.id, 0).start[0] > currentTimeSecond - 90 * 24 * 60 * 60 &&
state.getIncident(monitor.id, 0).error[0] != 'dummy')
) {
// put the dummy incident back
state.unshiftIncident(monitor.id, {
start: [currentTimeSecond - 90 * 24 * 60 * 60],
end: currentTimeSecond - 90 * 24 * 60 * 60,
error: ['dummy'],
})
}
statusChanged ||= monitorStatusChanged
}
console.log(
`statusChanged: ${statusChanged}, lastUpdate: ${state.data.lastUpdate}, currentTime: ${currentTimeSecond}`
)
// Update state
// Allow for a cooldown period before writing to storage
if (
statusChanged ||
currentTimeSecond - state.data.lastUpdate >=
(workerConfig.kvWriteCooldownMinutes ?? 3) * 60 - 10 // Allow for 10 seconds of clock drift
) {
console.log('Updating state...')
state.data.lastUpdate = currentTimeSecond
await setToStore(env, 'state', state.getCompactedStateStr())
} else {
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'
})
}
}
+414
View File
@@ -0,0 +1,414 @@
import { Env } from '.'
import { MonitorTarget } from '../../types/config'
import { withTimeout, fetchTimeout } from './util'
function isIpAddress(hostname: string): boolean {
// `URL.hostname` strips brackets for IPv6, so a `:` reliably indicates an IPv6 literal here.
if (hostname.includes(':')) return true
const parts = hostname.split('.')
if (parts.length !== 4) return false
return parts.every((part) => {
if (!/^\d{1,3}$/.test(part)) return false
const value = Number(part)
return value >= 0 && value <= 255
})
}
function getDomainOnlyIpVersionOption(hostname: string, gpUrl: URL): { ipVersion?: number } {
// Globalping only allows `measurementOptions.ipVersion` when `target` is a domain (it controls DNS resolution).
if (isIpAddress(hostname)) return {}
// Keep the original behavior for domain targets.
return { ipVersion: Number(gpUrl.searchParams.get('ipVersion') || 4) }
}
async function httpResponseBasicCheck(
monitor: MonitorTarget,
code: number,
bodyReader: () => Promise<string>
): Promise<string | null> {
if (monitor.expectedCodes) {
if (!monitor.expectedCodes.includes(code)) {
return `Expected codes: ${JSON.stringify(monitor.expectedCodes)}, Got: ${code}`
}
} else {
if (code < 200 || code > 299) {
return `Expected codes: 2xx, Got: ${code}`
}
}
if (monitor.responseKeyword || monitor.responseForbiddenKeyword) {
// Only read response body if we have a keyword to check
const responseBody = await bodyReader()
// 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)}`
)
return "HTTP response doesn't contain the configured keyword"
}
// 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)}`
)
return 'HTTP response contains the configured forbidden keyword'
}
}
return null
}
export async function getStatusWithGlobalPing(
monitor: MonitorTarget
): Promise<{ location: string; status: { ping: number; up: boolean; err: string } }> {
// TODO: should throw when there's error with globalping API
try {
if (monitor.checkProxy === undefined) {
throw "empty check proxy for globalping, shouldn't call this method"
}
const gpUrl = new URL(monitor.checkProxy)
if (gpUrl.protocol !== 'globalping:') {
throw 'incorrect check proxy protocol for globalping, got: ' + gpUrl.protocol
}
const token = gpUrl.hostname
let globalPingRequest = {}
if (monitor.method === 'TCP_PING') {
const targetUrl = new URL('https://' + monitor.target) // dummy https:// to parse hostname & port
const ipVersionOption = getDomainOnlyIpVersionOption(targetUrl.hostname, gpUrl)
globalPingRequest = {
type: 'ping',
target: targetUrl.hostname,
locations:
gpUrl.searchParams.get('magic') !== null
? [
{
magic: gpUrl.searchParams.get('magic'),
},
]
: undefined,
measurementOptions: {
port: targetUrl.port,
packets: 1,
protocol: 'tcp', // TODO: icmp?
...ipVersionOption,
},
}
} else {
const targetUrl = new URL(monitor.target)
const ipVersionOption = getDomainOnlyIpVersionOption(targetUrl.hostname, gpUrl)
if (monitor.body !== undefined) {
throw 'custom body not supported'
}
if (monitor.method && !['GET', 'HEAD', 'OPTIONS'].includes(monitor.method.toUpperCase())) {
throw 'only GET, HEAD, OPTIONS methods are supported'
}
globalPingRequest = {
type: 'http',
target: targetUrl.hostname,
locations:
gpUrl.searchParams.get('magic') !== null
? [
{
magic: gpUrl.searchParams.get('magic'),
},
]
: undefined,
measurementOptions: {
request: {
method: monitor.method,
path: targetUrl.pathname,
query: targetUrl.search === '' ? undefined : targetUrl.search,
headers: Object.fromEntries(
Object.entries(monitor.headers ?? {}).map(([key, value]) => [key, String(value)])
), // TODO: host header?
},
port:
targetUrl.port === ''
? targetUrl.protocol === 'http:'
? 80
: 443
: Number(targetUrl.port),
protocol: targetUrl.protocol.replace(':', ''),
...ipVersionOption,
},
}
}
const startTime = Date.now()
console.log(`Requesting the Global Ping API, payload: ${JSON.stringify(globalPingRequest)}`)
const measurement = await fetchTimeout('https://api.globalping.io/v1/measurements', 5000, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token,
},
body: JSON.stringify(globalPingRequest),
})
const measurementResponse = (await measurement.json()) as any
if (measurement.status !== 202) {
throw measurementResponse.error.message
}
const measurementId = measurementResponse.id
console.log(
`Measurement created successfully, id: ${measurementId}, time elapsed: ${
Date.now() - startTime
}ms`
)
const pollStart = Date.now()
let measurementResult: any
while (true) {
if (Date.now() - pollStart > (monitor.timeout ?? 10000) + 2000) {
// 2s extra buffer
throw 'api polling timeout'
}
measurementResult = (await (
await fetchTimeout(`https://api.globalping.io/v1/measurements/${measurementId}`, 5000)
).json()) as any
if (measurementResult.status !== 'in-progress') {
break
}
await new Promise((resolve) => setTimeout(resolve, 1000))
}
console.log(
`Measurement ${measurementId} finished with response: ${JSON.stringify(
measurementResult
)}, time elapsed: ${Date.now() - pollStart}ms`
)
if (
measurementResult.status !== 'finished' ||
measurementResult.results[0].result.status !== 'finished'
) {
console.log(
`measurement failed with status: ${measurementResult.status}, result status: ${measurementResult.results[0].result.status}`
)
// Truncate raw output to avoid huge error messages
throw `status [${measurementResult.status}|${
measurementResult.results[0].result.status
}]: ${measurementResult.results?.[0].result?.rawOutput?.slice(0, 64)}`
}
const country = measurementResult.results[0].probe.country
const city = measurementResult.results[0].probe.city
if (monitor.method === 'TCP_PING') {
const time = Math.round(measurementResult.results[0].result.stats.avg)
return {
location: country + '/' + city,
status: {
ping: time,
up: true,
err: '',
},
}
} else {
const time = measurementResult.results[0].result.timings.total
const code = measurementResult.results[0].result.statusCode
const body = measurementResult.results[0].result.rawBody
let err = await httpResponseBasicCheck(monitor, code, () => body)
if (err !== null) {
console.log(`${monitor.name} didn't pass response check: ${err}`)
}
if (
monitor.target.toLowerCase().startsWith('https') &&
!measurementResult.results[0].result.tls.authorized
) {
console.log(
`${monitor.name} TLS certificate not trusted: ${measurementResult.results[0].result.tls.error}`
)
err = 'TLS certificate not trusted: ' + measurementResult.results[0].result.tls.error
}
return {
location: country + '/' + city,
status: {
ping: time,
up: err === null,
err: err ?? '',
},
}
}
} catch (e: any) {
console.log(`Globalping ${monitor.name} errored with ${e}`)
return {
location: 'ERROR',
status: {
ping: e.toString().toLowerCase().includes('timeout') ? monitor.timeout ?? 10000 : 0,
up: false,
err: 'Globalping error: ' + e.toString(),
},
}
}
}
export async function getStatus(
monitor: MonitorTarget
): Promise<{ ping: number; up: boolean; err: string }> {
let status = {
ping: 0,
up: false,
err: 'Unknown',
}
const startTime = Date.now()
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 socket = connect({ hostname: parsed.hostname, port: Number(parsed.port) })
// Now we have an `opened` promise!
await withTimeout(monitor.timeout || 10000, socket.opened)
await socket.close()
console.log(`${monitor.name} connected to ${monitor.target}`)
status.ping = Date.now() - startTime
status.up = true
status.err = ''
} catch (e: Error | any) {
console.log(`${monitor.name} errored with ${e.name}: ${e.message}`)
if (e.message.includes('timed out')) {
status.ping = monitor.timeout || 10000
}
status.up = false
status.err = e.name + ': ' + e.message
}
} else {
// HTTP endpoint monitor
try {
let headers = new Headers(monitor.headers as any)
if (!headers.has('user-agent')) {
headers.set('user-agent', 'UptimeFlare/1.0 (+https://github.com/lyc8503/UptimeFlare)')
}
const response = await fetchTimeout(monitor.target, monitor.timeout || 10000, {
method: monitor.method,
headers: headers,
body: monitor.body,
cf: {
cacheTtlByStatus: {
'100-599': -1, // Don't cache any status code, from https://developers.cloudflare.com/workers/runtime-apis/request/#requestinitcfproperties
},
},
})
console.log(`${monitor.name} responded with ${response.status}`)
status.ping = Date.now() - startTime
const err = await httpResponseBasicCheck(
monitor,
response.status,
response.text.bind(response)
)
try {
await response.body?.cancel()
} catch (e) {} // Always try to cancel body, see issue #166
if (err !== null) {
console.log(`${monitor.name} didn't pass response check: ${err}`)
}
status.up = err === null
status.err = err ?? ''
} catch (e: any) {
console.log(`${monitor.name} errored with ${e.name}: ${e.message}`)
if (e.name === 'AbortError') {
status.ping = monitor.timeout || 10000
status.up = false
status.err = `Timeout after ${status.ping}ms`
} else {
status.up = false
status.err = e.name + ': ' + e.message
}
}
}
return status
}
export async function doMonitor(monitor: MonitorTarget, defaultLocation: string, env: Env) {
let checkLocation = defaultLocation
let status
if (monitor.checkProxy) {
// Initiate a check using proxy (Geo-specific monitoring)
try {
console.log(`[${monitor.id}] 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(monitor.id)
const doStub = env.REMOTE_CHECKER_DO.get(doId, {
locationHint: doLoc as DurableObjectLocationHint,
})
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 if (monitor.checkProxy.startsWith('globalping://')) {
resp = await getStatusWithGlobalPing(monitor)
} 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(`[${monitor.id}] Error calling proxy: ${err}`)
if (monitor.checkProxyFallback) {
console.log('Falling back to local check...')
status = await getStatus(monitor)
} else {
// TODO: more consistent error handling (throw or return?)
status = { ping: 0, up: false, err: 'Unknown check proxy error' }
}
}
} else {
// Initiate a check from the current location
status = await getStatus(monitor)
}
console.log(`[${monitor.id}] Check result from ${checkLocation}: up=${status.up}, ping=${status.ping}, err=${status.err}`)
return {
location: checkLocation,
status,
id: monitor.id,
}
}
+250
View File
@@ -0,0 +1,250 @@
import { Env } from '.'
import {
IncidentRecord,
LatencyRecord,
MonitorState,
MonitorStateCompacted,
} from '../../types/config'
export async function getFromStore(env: Env, key: string): Promise<string | null> {
const stmt = env.UPTIMEFLARE_D1.prepare('SELECT value FROM uptimeflare WHERE key = ?')
const result = await stmt.bind(key).first<{ value: string }>()
return result?.value || null
}
export async function setToStore(env: Env, key: string, value: string): Promise<void> {
const stmt = env.UPTIMEFLARE_D1.prepare(
'INSERT INTO uptimeflare (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value;'
)
await stmt.bind(key, value).run()
}
export class CompactedMonitorStateWrapper {
data: MonitorStateCompacted
constructor(compactedStateStr: string | null) {
if (!compactedStateStr) {
// Initialize empty state
this.data = {
lastUpdate: 0,
overallUp: 0,
overallDown: 0,
incident: {},
latency: {},
}
return
}
this.data = JSON.parse(compactedStateStr)
}
getCompactedStateStr(): string {
return JSON.stringify(this.data)
}
// Don't use this method at server-side
uncompact(): MonitorState {
let state: MonitorState = {
lastUpdate: this.data.lastUpdate,
overallUp: this.data.overallUp,
overallDown: this.data.overallDown,
incident: {},
latency: {},
}
const hex2Uint8Arr = (hex: string): Uint8Array => {
// @ts-expect-error This method is not available in Node.js 22.x, but available in Cloudflare Workers and new browsers
if (Uint8Array.fromHex) {
// @ts-expect-error
return Uint8Array.fromHex(hex)
} else {
console.warn('Uint8Array.fromHex is not available, using parseInt as fallback. Consider upgrading your browser.')
const ret = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
ret[i / 2] = parseInt(hex.slice(i, i + 2), 16)
}
return ret
}
}
Object.keys(this.data.incident).forEach((monitorId) => {
state.incident[monitorId] = []
const incidents = this.data.incident[monitorId]
if (
incidents.start.length !== incidents.end.length ||
incidents.start.length !== incidents.error.length
) {
throw new Error(
'Inconsistent incident data lengths, please report an issue at https://github.com/lyc8503/UptimeFlare'
)
}
for (let i = 0; i < incidents.start.length; i++) {
state.incident[monitorId].push({
start: incidents.start[i],
end: incidents.end[i],
error: incidents.error[i],
})
}
})
Object.keys(this.data.latency).forEach((monitorId) => {
state.latency[monitorId] = []
const latencies = this.data.latency[monitorId]
const locUncompacted: string[] = []
latencies.loc.c.forEach((count, index) => {
for (let i = 0; i < count; i++) {
locUncompacted.push(latencies.loc.v[index])
}
})
const timeArr = new Uint32Array(hex2Uint8Arr(latencies.time).buffer)
const pingArr = new Uint16Array(hex2Uint8Arr(latencies.ping).buffer)
if (timeArr.length !== pingArr.length || timeArr.length !== locUncompacted.length) {
throw new Error(
'Inconsistent latency data lengths, please report an issue at https://github.com/lyc8503/UptimeFlare.'
)
}
for (let i = 0; i < timeArr.length; i++) {
state.latency[monitorId].push({
time: timeArr[i],
ping: pingArr[i],
loc: locUncompacted[i],
})
}
})
return state
}
incidentLen(monitorId: string): number {
const incidents = this.data.incident[monitorId]
if (!incidents) return 0
return incidents.start.length
}
getIncident(monitorId: string, index: number): IncidentRecord {
const incidents = this.data.incident[monitorId]
if (!incidents || index < 0 || index >= incidents.start.length) {
throw new Error('Index out of bounds or monitor not found')
}
return {
start: incidents.start[index],
end: incidents.end[index],
error: incidents.error[index],
}
}
setIncident(monitorId: string, index: number, incident: IncidentRecord) {
const incidents = this.data.incident[monitorId]
if (!incidents || index < 0 || index >= incidents.start.length) {
throw new Error('Index out of bounds or monitor not found')
}
incidents.start[index] = incident.start
incidents.end[index] = incident.end
incidents.error[index] = incident.error
}
appendIncident(monitorId: string, incident: IncidentRecord) {
let incidents = this.data.incident[monitorId]
if (!incidents) {
// Initialize incident arrays
this.data.incident[monitorId] = {
start: [],
end: [],
error: [],
}
incidents = this.data.incident[monitorId]
}
incidents.start.push(incident.start)
incidents.end.push(incident.end)
incidents.error.push(incident.error)
}
shiftIncident(monitorId: string) {
const incidents = this.data.incident[monitorId]
incidents.start.shift()
incidents.end.shift()
incidents.error.shift()
}
unshiftIncident(monitorId: string, incident: IncidentRecord) {
const incidents = this.data.incident[monitorId]
incidents.start.unshift(incident.start)
incidents.end.unshift(incident.end)
incidents.error.unshift(incident.error)
}
latencyLen(monitorId: string): number {
const latencies = this.data.latency[monitorId]
if (!latencies) return 0
return latencies.ping.length / 4 // Uint16Array, 4 characters per entry in hex
}
appendLatency(monitorId: string, record: LatencyRecord) {
let latencies = this.data.latency[monitorId]
if (!latencies) {
// Initialize latency arrays
this.data.latency[monitorId] = {
time: '',
ping: '',
loc: {
c: [],
v: [],
},
}
latencies = this.data.latency[monitorId]
}
// @ts-expect-error
latencies.time += new Uint8Array(new Uint32Array([record.time]).buffer).toHex()
// @ts-expect-error
latencies.ping += new Uint8Array(new Uint16Array([record.ping]).buffer).toHex()
if (latencies.loc.v[latencies.loc.v.length - 1] !== record.loc) {
latencies.loc.c.push(1)
latencies.loc.v.push(record.loc)
} else {
latencies.loc.c[latencies.loc.c.length - 1] += 1
}
}
getFirstLatency(monitorId: string): LatencyRecord {
let latencies = this.data.latency[monitorId]
return {
// @ts-expect-error
time: new Uint32Array(Uint8Array.fromHex(latencies.time.slice(0, 8)).buffer)[0],
// @ts-expect-error
ping: new Uint16Array(Uint8Array.fromHex(latencies.ping.slice(0, 4)).buffer)[0],
loc: latencies.loc.v[0],
}
}
getLastLatency(monitorId: string): LatencyRecord {
let latencies = this.data.latency[monitorId]
return {
// @ts-expect-error
time: new Uint32Array(Uint8Array.fromHex(latencies.time.slice(-8)).buffer)[0],
// @ts-expect-error
ping: new Uint16Array(Uint8Array.fromHex(latencies.ping.slice(-4)).buffer)[0],
loc: latencies.loc.v[latencies.loc.v.length - 1],
}
}
unshiftLatency(monitorId: string) {
let latencies = this.data.latency[monitorId]
latencies.time = latencies.time.slice(8)
latencies.ping = latencies.ping.slice(4)
latencies.loc.c[0] -= 1
if (latencies.loc.c[0] === 0) {
latencies.loc.c.shift()
latencies.loc.v.shift()
}
}
}
+201
View File
@@ -0,0 +1,201 @@
import { MonitorTarget, WebhookConfig } from '../../types/config'
import { maintenances, workerConfig } from '../../uptime.config'
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: string,
ms: number,
{ signal, ...options }: RequestInit<RequestInitCfProperties> | undefined = {}
): Promise<Response> => {
const controller = new AbortController()
const promise = fetch(url, { signal: controller.signal, ...options })
if (signal) signal.addEventListener('abort', () => controller.abort())
const timeout = setTimeout(() => controller.abort(), ms)
return promise.finally(() => clearTimeout(timeout))
}
function withTimeout<T>(millis: number, promise: Promise<T>): Promise<T> {
const timeout = new Promise<T>((resolve, reject) =>
setTimeout(() => reject(new Error(`Promise timed out after ${millis}ms`)), millis)
)
return Promise.race([promise, timeout])
}
function formatStatusChangeNotification(
monitor: any,
isUp: boolean,
timeIncidentStart: number,
timeNow: number,
reason: string,
timeZone: string
) {
const dateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'numeric',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: timeZone,
})
let downtimeDuration = Math.round((timeNow - timeIncidentStart) / 60)
const timeNowFormatted = dateFormatter.format(new Date(timeNow * 1000))
const timeIncidentStartFormatted = dateFormatter.format(new Date(timeIncidentStart * 1000))
if (isUp) {
return `${monitor.name} is up! \nThe service is up again after being down for ${downtimeDuration} minutes.`
} else if (timeNow == timeIncidentStart) {
return `🔴 ${
monitor.name
} is currently down. \nService is unavailable at ${timeNowFormatted}. \nIssue: ${
reason || 'unspecified'
}`
} else {
return `🔴 ${
monitor.name
} is still down. \nService is unavailable since ${timeIncidentStartFormatted} (${downtimeDuration} minutes). \nIssue: ${
reason || 'unspecified'
}`
}
}
function templateWebhookPlayload(payload: any, message: string) {
for (const key in payload) {
if (Object.prototype.hasOwnProperty.call(payload, key)) {
if (payload[key] === '$MSG') {
payload[key] = message
} else if (typeof payload[key] === 'object' && payload[key] !== null) {
templateWebhookPlayload(payload[key], message)
}
}
}
}
async function webhookNotify(webhook: WebhookConfig, message: string) {
if (Array.isArray(webhook)) {
for (const w of webhook) {
await webhookNotify(w, message)
}
return
}
console.log(
'Sending webhook notification: ' + JSON.stringify(message) + ' to webhook ' + webhook.url
)
try {
let url = webhook.url
let method = webhook.method
let headers = new Headers(webhook.headers as any)
let payloadTemplated: { [key: string]: string | number } = JSON.parse(
JSON.stringify(webhook.payload)
)
templateWebhookPlayload(payloadTemplated, message)
let body = undefined
switch (webhook.payloadType) {
case 'param':
method = method ?? 'GET'
const urlTmp = new URL(url)
for (const [k, v] of Object.entries(payloadTemplated)) {
urlTmp.searchParams.append(k, v.toString())
}
url = urlTmp.toString()
break
case 'json':
method = method ?? 'POST'
if (headers.get('content-type') === null) {
headers.set('content-type', 'application/json')
}
body = JSON.stringify(payloadTemplated)
break
case 'x-www-form-urlencoded':
method = method ?? 'POST'
if (headers.get('content-type') === null) {
headers.set('content-type', 'application/x-www-form-urlencoded')
}
body = new URLSearchParams(payloadTemplated as any).toString()
break
default:
throw 'Unrecognized payload type: ' + webhook.payloadType
}
console.log(
`Webhook finalized parameters: ${method} ${url}, headers ${JSON.stringify(
Object.fromEntries(headers.entries())
)}, body ${JSON.stringify(body)}`
)
const resp = await fetchTimeout(url, webhook.timeout ?? 5000, { method, headers, body })
if (!resp.ok) {
console.log(
'Error calling webhook server, code: ' + resp.status + ', response: ' + (await resp.text())
)
} else {
console.log('Webhook notification sent successfully, code: ' + resp.status)
}
} catch (e) {
console.log('Error calling webhook server: ' + e)
}
}
// Auxiliary function to format notification and send it via webhook
const formatAndNotify = async (
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?.webhook) {
const notification = formatStatusChangeNotification(
monitor,
isUp,
timeIncidentStart,
timeNow,
reason,
workerConfig.notification?.timeZone ?? 'Etc/GMT'
)
await webhookNotify(workerConfig.notification.webhook, notification)
} else {
console.log(`Webhook not set, skipping notification for ${monitor.name}`)
}
}
export {
getWorkerLocation,
fetchTimeout,
withTimeout,
webhookNotify,
formatStatusChangeNotification,
formatAndNotify,
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es2021",
"lib": ["es2021"],
"jsx": "react",
"module": "es2022",
"moduleResolution": "node",
"types": ["@cloudflare/workers-types"],
"resolveJsonModule": true,
"allowJs": true,
"checkJs": false,
"noEmit": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
+9
View File
@@ -0,0 +1,9 @@
name = "uptimeflare_worker"
main = "src/index.ts"
compatibility_date = "2025-04-02"
compatibility_flags = [ "nodejs_compat" ]
[[d1_databases]]
binding = "UPTIMEFLARE_D1"
database_name = "uptimeflare_d1"
database_id = "00000000-0000-0000-0000-000000000000"
+8
View File
@@ -0,0 +1,8 @@
name = "uptimeflare"
compatibility_date = "2025-04-02"
compatibility_flags = [ "nodejs_compat" ]
[[d1_databases]]
binding = "UPTIMEFLARE_D1"
database_name = "uptimeflare_d1"
database_id = "00000000-0000-0000-0000-000000000000"