mirror of
https://github.com/ChenQihan666/Lolia-Nodes-Status-Pages.git
synced 2026-08-13 23:47:08 +08:00
Initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# GitHub Sponsor config
|
||||
ko_fi: lyc8503
|
||||
@@ -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 }}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
trailingComma: 'es5'
|
||||
tabWidth: 2
|
||||
semi: false
|
||||
singleQuote: true
|
||||
printWidth: 100
|
||||
Generated
+19
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||

|
||||
|
||||
## ⚡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)
|
||||
@@ -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/
|
||||
|
||||
一些截图:
|
||||
|
||||

|
||||
|
||||
## ⚡快速入门 / 📄文档
|
||||
|
||||
请参阅 [Wiki](https://github.com/lyc8503/UptimeFlare/wiki)
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 = "/"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 441 KiB |
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
UPTIMEFLARE_STATE: KVNamespace
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS uptimeflare (
|
||||
key VARCHAR(255) PRIMARY KEY,
|
||||
value BLOB NOT NULL
|
||||
);
|
||||
@@ -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 →"
|
||||
}
|
||||
@@ -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 →"
|
||||
}
|
||||
@@ -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 →"
|
||||
}
|
||||
@@ -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": "下一月 →"
|
||||
}
|
||||
@@ -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": "下個月 →"
|
||||
}
|
||||
@@ -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' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
Generated
+16099
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -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
|
||||
Generated
+1261
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "version": 2, "rewrites": [{ "source": "/(.*)", "destination": "/api" }] }
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
+154
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 149 KiB |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
>
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
File diff suppressed because one or more lines are too long
@@ -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/
|
||||
@@ -0,0 +1,5 @@
|
||||
trailingComma: 'es5'
|
||||
tabWidth: 2
|
||||
semi: false
|
||||
singleQuote: true
|
||||
printWidth: 100
|
||||
Generated
+2406
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user