├── site ├── src │ ├── app │ │ ├── robots.txt │ │ ├── favicon.ico │ │ ├── api │ │ │ ├── status │ │ │ │ └── route.ts │ │ │ └── kv │ │ │ │ └── [key] │ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ └── page.tsx │ ├── theme │ │ └── index.ts │ └── components │ │ ├── Link.tsx │ │ ├── RootProvider.tsx │ │ ├── ResponseGraph.tsx │ │ ├── OverallResponseGraph.tsx │ │ ├── AllStatus.tsx │ │ └── UptimeGraph.tsx ├── .eslintrc.json ├── next.config.js ├── tsconfig.json ├── package.json └── README.md ├── types ├── src │ ├── index.ts │ └── KvMonitors.ts ├── package.json └── tsconfig.json ├── .github ├── FUNDING.yml └── workflows │ └── deployPoller.yml ├── .yarnrc.yml ├── poller ├── wrangler.toml ├── package.json ├── .swcrc ├── src │ ├── functions │ │ ├── helpers.ts │ │ └── cronTrigger.ts │ ├── index.ts │ └── cli │ │ └── gcMonitors.js └── tsconfig.json ├── .vscode └── settings.json ├── scripts └── removeLocalDeps.js ├── package.json ├── .gitignore ├── LICENSE ├── README.md └── config.json /site/src/app/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: -------------------------------------------------------------------------------- /types/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './KvMonitors.js' -------------------------------------------------------------------------------- /site/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: H01001000 4 | -------------------------------------------------------------------------------- /site/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/H01001000/Cloudflare-Status-Page/HEAD/site/src/app/favicon.ico -------------------------------------------------------------------------------- /site/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /site/src/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { createTheme, responsiveFontSizes } from "@mui/material/styles"; 2 | 3 | export default responsiveFontSizes(createTheme()) -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: false 4 | 5 | nodeLinker: node-modules 6 | 7 | yarnPath: .yarn/releases/yarn-4.3.1.cjs 8 | -------------------------------------------------------------------------------- /poller/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "cf-workers-status-poller" 2 | workers_dev = true 3 | compatibility_date = "2021-07-23" 4 | main = "./dist/index.js" 5 | 6 | [triggers] 7 | crons = ["*/3 * * * *"] 8 | -------------------------------------------------------------------------------- /types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cf-status-page-types", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "src/index.ts", 6 | "type": "module", 7 | "devDependencies": { 8 | "typescript": "5.6.2" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "yaml.schemas": { 3 | "https://json.schemastore.org/github-workflow.json": "file:///mnt/c/Users/Heihe/Downloads/cf-workers-status-page/.github/workflows/deploy.yml" 4 | }, 5 | "typescript.tsdk": "node_modules\\typescript\\lib" 6 | } -------------------------------------------------------------------------------- /scripts/removeLocalDeps.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | 3 | const packageJson = JSON.parse(fs.readFileSync('./site/package.json', 'utf-8')) 4 | delete packageJson.devDependencies['cf-status-page-types'] 5 | fs.writeFileSync('./site/package.json', JSON.stringify(packageJson, undefined, 2)) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cf-status-page-monorepo", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "workspaces": [ 7 | "poller", 8 | "site", 9 | "types" 10 | ], 11 | "engines": { 12 | "node": ">=18.0.0" 13 | }, 14 | "packageManager": "yarn@4.3.1" 15 | } 16 | -------------------------------------------------------------------------------- /site/src/components/Link.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import MuiLink from "@mui/material/Link"; 4 | import NextLink from "next/link"; 5 | import { type ComponentProps, forwardRef } from "react"; 6 | 7 | export default forwardRef< 8 | HTMLAnchorElement, 9 | ComponentProps & ComponentProps 10 | >(function Link(props, ref) { 11 | return ( 12 | 13 | ); 14 | }); 15 | -------------------------------------------------------------------------------- /poller/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cf-status-page-poller", 3 | "version": "1.0.0", 4 | "author": "Adam Janiš ", 5 | "license": "MIT", 6 | "main": "./dist/index.js", 7 | "private": true, 8 | "scripts": { 9 | "build": "swc src --out-dir dist", 10 | "kv-gc": "node ./src/cli/gcMonitors.js" 11 | }, 12 | "devDependencies": { 13 | "@cloudflare/workers-types": "^4.20240925.0", 14 | "@swc/cli": "^0.1.65", 15 | "@swc/core": "^1.7.0", 16 | "cf-status-page-types": "*", 17 | "eslint": "^8.57.1", 18 | "typescript": "^5.6.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp.* 6 | .yarn/* 7 | !.yarn/patches 8 | !.yarn/plugins 9 | !.yarn/releases 10 | !.yarn/sdks 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | .next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | dist 23 | 24 | # misc 25 | .DS_Store 26 | *.pem 27 | 28 | # debug 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | 33 | # local env files 34 | .env*.local 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /poller/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "minify": true, 3 | "jsc": { 4 | "parser": { 5 | "syntax": "typescript", 6 | "decorators": true, 7 | "dynamicImport": true 8 | }, 9 | "transform": { 10 | "legacyDecorator": true, 11 | "decoratorMetadata": true 12 | }, 13 | "target": "es2022", 14 | "keepClassNames": true, 15 | "loose": true, 16 | "minify": { 17 | "compress": { 18 | "unused": true 19 | }, 20 | "mangle": true 21 | } 22 | }, 23 | "module": { 24 | "type": "es6", 25 | "strict": false, 26 | "strictMode": true, 27 | "lazy": false, 28 | "noInterop": false 29 | }, 30 | "sourceMaps": "inline" 31 | } -------------------------------------------------------------------------------- /site/src/components/RootProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ThemeProvider } from "@mui/material/styles"; 4 | import CssBaseline from "@mui/material/CssBaseline"; 5 | import theme from "@/theme"; 6 | import * as React from "react"; 7 | import { AppRouterCacheProvider } from "@mui/material-nextjs/v14-appRouter"; 8 | 9 | export default function ThemeRegistry({ 10 | children, 11 | }: { 12 | children: React.ReactNode; 13 | }) { 14 | return ( 15 | 16 | 17 | 18 | {children} 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /poller/src/functions/helpers.ts: -------------------------------------------------------------------------------- 1 | export async function getKVMonitors(key: string): Promise { 2 | // trying both to see performance difference 3 | //@ts-ignore 4 | return (KV_STATUS_PAGE as KVNamespace).get(key, 'json') 5 | //return JSON.parse(await KV_STATUS_PAGE.get(kvDataKey, 'text')) 6 | } 7 | 8 | export async function setKVMonitors(key: string, data: any) { 9 | return setKV(key, JSON.stringify(data)) 10 | } 11 | 12 | export async function setKV(key: string, value: string, metadata?: any | null, expirationTtl?: number) { 13 | //@ts-ignore 14 | return (KV_STATUS_PAGE as KVNamespace).put(key, value, { metadata, expirationTtl }) 15 | } 16 | 17 | export async function getCheckLocation() { 18 | const res = await fetch('https://cloudflare-dns.com/dns-query', { 19 | method: 'OPTIONS', 20 | }) 21 | return res.headers.get('cf-ray')!.split('-')[1] 22 | } 23 | -------------------------------------------------------------------------------- /site/src/app/api/status/route.ts: -------------------------------------------------------------------------------- 1 | 2 | // Next.js Edge API Route Handlers: https://nextjs.org/docs/app/building-your-application/routing/router-handlers#edge-and-nodejs-runtimes 3 | 4 | import type { NextRequest } from 'next/server' 5 | import type { KVNamespace } from '@cloudflare/workers-types' 6 | import { NextResponse } from 'next/server' 7 | 8 | export const runtime = 'edge' 9 | 10 | export async function GET(request: NextRequest) { 11 | const { KV_STATUS_PAGE } = (process.env as unknown as { KV_STATUS_PAGE: KVNamespace }); 12 | const data = await KV_STATUS_PAGE.get("monitors_data_v1_1", { type: 'text' }); 13 | return new NextResponse(data, { 14 | status: 200, 15 | headers: { 16 | 'Access-Control-Allow-Origin': '*', 17 | 'Access-Control-Allow-Methods': 'GET, OPTIONS', 18 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 19 | }, 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "es2022", 5 | "inlineSourceMap": true, 6 | "outDir": "./dist", 7 | "moduleResolution": "node", 8 | "removeComments": true, 9 | "noImplicitAny": true, 10 | "strictNullChecks": true, 11 | "strictFunctionTypes": true, 12 | "noImplicitThis": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "allowSyntheticDefaultImports": true, 18 | "esModuleInterop": true, 19 | "emitDecoratorMetadata": true, 20 | "experimentalDecorators": true, 21 | "resolveJsonModule": false, 22 | "incremental": true, 23 | "baseUrl": ".", 24 | "newLine": "lf" 25 | }, 26 | "exclude": [ 27 | "node_modules", 28 | "./test" 29 | ], 30 | "include": [ 31 | "./src/**/*.tsx", 32 | "./src/**/*.ts" 33 | ], 34 | "ts-node": { 35 | "swc": true, 36 | "esm": true, 37 | "experimentalSpecifierResolution": "node" 38 | } 39 | } -------------------------------------------------------------------------------- /poller/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "es2022", 5 | "inlineSourceMap": true, 6 | "outDir": "./dist", 7 | "moduleResolution": "node", 8 | "removeComments": true, 9 | "noImplicitAny": true, 10 | "strictNullChecks": true, 11 | "strictFunctionTypes": true, 12 | "noImplicitThis": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "allowSyntheticDefaultImports": true, 18 | "esModuleInterop": true, 19 | "emitDecoratorMetadata": true, 20 | "experimentalDecorators": true, 21 | "resolveJsonModule": true, 22 | "incremental": true, 23 | "baseUrl": ".", 24 | "newLine": "lf", 25 | "types": [ 26 | "@cloudflare/workers-types" 27 | ] 28 | }, 29 | "exclude": [ 30 | "node_modules", 31 | "./test" 32 | ], 33 | "include": [ 34 | "./src/**/*.tsx", 35 | "./src/**/*.ts" 36 | ], 37 | "ts-node": { 38 | "swc": true, 39 | "esm": true, 40 | "experimentalSpecifierResolution": "node" 41 | } 42 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Adam Janiš 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /poller/src/index.ts: -------------------------------------------------------------------------------- 1 | import { processCronTrigger } from './functions/cronTrigger.js' 2 | import type { addEventListener as AddEventListener } from '@cloudflare/workers-types' 3 | 4 | /** 5 | * The DEBUG flag will do two things that help during development: 6 | * 1. we will skip caching on the edge, which makes it easier to 7 | * debug. 8 | * 2. we will return an error message on exception in your Response rather 9 | * than the default 404.html page. 10 | */ 11 | //@ts-ignore 12 | const DEBUG = false; 13 | 14 | // addEventListener('fetch', (event) => { 15 | // try { 16 | // event.respondWith( 17 | // handleEvent(event, require.context('./pages/', true, /\.js$/), DEBUG), 18 | // ) 19 | // } catch (e) { 20 | // if (DEBUG) { 21 | // return event.respondWith( 22 | // new Response(e.message || e.toString(), { 23 | // status: 500, 24 | // }), 25 | // ) 26 | // } 27 | // event.respondWith(new Response('Internal Error', { status: 500 })) 28 | // } 29 | // }) 30 | 31 | (addEventListener as typeof AddEventListener)('scheduled', (event) => { 32 | event.waitUntil(processCronTrigger(event)) 33 | }) 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloudflare-Status-Page 2 | A website monitoring and status page application design to be deploy on cloudflare at no cost 3 | ![image](https://github.com/JH0project/Cloudflare-Status-Page/assets/48591478/e16d12eb-1985-423f-b2f5-1af6695e3aec) 4 | 5 | ## Installing 6 | 7 | ### Website 8 | 1. Fork this repo 9 | 2. Go to Cloudflare Workers& Pages, Create an application, Pages, Connect to Git 10 | 3. Choose that repo 11 | 4. Change setting Build settings 12 | - Framework preset: `Next.js` 13 | - Build command: `cp .yarnrc.yml site && cp yarn.lock site && node ./scripts/removeLocalDeps.js && cd site && yarn install --immutable=false --mode=update-lockfile && npx @cloudflare/next-on-pages@1` 14 | - Build output directory: `/site/.vercel/output/static` 15 | 16 | Environment variables (advanced) 17 | - NODE_VERSION `18` 18 | 5. Create and deploy 19 | 6. Go to Settings, Functions, Compatibility flags add `nodejs_compat` 20 | 21 | Monitoring app 22 | - Messure website response time at different locations 23 | - Cloudflare Worker 24 | - Cloudflare KV store 25 | 26 | Status/Performance website 27 | - Cloudflare Pages 28 | 29 | Inspired by https://github.com/eidam/cf-workers-status-page 30 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "title": "JH0project Status", 4 | "url": "https://status.jh0project.com", 5 | "description": "The status page of JH0project (JHOproject/JHo project), showing historical status and performane information.", 6 | "logo": "logo-192x192.png", 7 | "collectResponseTimes": true, 8 | "allmonitorsOperational": "All Systems Operational", 9 | "notAllmonitorsOperational": "Not All Systems Operational", 10 | "monitorLabelOperational": "Operational", 11 | "monitorLabelNotOperational": "Not Operational", 12 | "monitorLabelNoData": "No data", 13 | "dayInHistogramNoData": "No data", 14 | "dayInHistogramOperational": "All good", 15 | "dayInHistogramNotOperational": " incident(s)" 16 | }, 17 | "monitors": [ 18 | { 19 | "id": "main", 20 | "name": "Main Website", 21 | "description": "", 22 | "url": "https://www.jh0project.com", 23 | "method": "GET", 24 | "expectStatus": 200, 25 | "followRedirect": false 26 | }, 27 | { 28 | "id": "dn42", 29 | "name": "DN42 Website", 30 | "description": "", 31 | "url": "https://dn42.jh0project.com", 32 | "method": "GET", 33 | "expectStatus": 200, 34 | "followRedirect": false 35 | } 36 | ] 37 | } -------------------------------------------------------------------------------- /site/src/app/api/kv/[key]/route.ts: -------------------------------------------------------------------------------- 1 | 2 | // Next.js Edge API Route Handlers: https://nextjs.org/docs/app/building-your-application/routing/router-handlers#edge-and-nodejs-runtimes 3 | 4 | import type { NextRequest } from 'next/server' 5 | import type { KVNamespace } from '@cloudflare/workers-types' 6 | import { NextResponse } from 'next/server' 7 | import { NextApiRequest } from 'next'; 8 | 9 | export const runtime = 'edge' 10 | 11 | export async function GET(req: NextRequest, { params }: { params: { key: string } }) { 12 | const { KV_STATUS_PAGE } = (process.env as unknown as { KV_STATUS_PAGE: KVNamespace }); 13 | const { key } = params 14 | const data = await KV_STATUS_PAGE.get(key, { type: 'text' }); 15 | if (data === null) 16 | return new NextResponse('Not Found', { 17 | status: 404, 18 | headers: { 19 | 'Access-Control-Allow-Origin': '*', 20 | 'Access-Control-Allow-Methods': 'GET, OPTIONS', 21 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 22 | }, 23 | }) 24 | return new NextResponse(data, { 25 | status: 200, 26 | headers: { 27 | 'Access-Control-Allow-Origin': '*', 28 | 'Access-Control-Allow-Methods': 'GET, OPTIONS', 29 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 30 | }, 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/deployPoller.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Poller 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "poller/**" 9 | - ".github/workflows/deployPoller.yml" 10 | repository_dispatch: 11 | 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 10 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 18 21 | - run: yarn install 22 | - run: yarn build 23 | working-directory: poller 24 | - name: Publish 25 | uses: cloudflare/wrangler-action@2.0.0 26 | with: 27 | workingDirectory: poller 28 | apiToken: ${{ secrets.CF_API_TOKEN }} 29 | preCommands: | 30 | wrangler kv:namespace create KV_STATUS_PAGE || true 31 | apt-get update && apt-get install -y jq 32 | export KV_NAMESPACE_ID=$(wrangler kv:namespace list | jq -c 'map(select(.title | contains("KV_STATUS_PAGE")))' | jq -r ".[0].id") 33 | echo "[env.production]" >> wrangler.toml 34 | echo "kv_namespaces = [{binding=\"KV_STATUS_PAGE\", id=\"${KV_NAMESPACE_ID}\"}]" >> wrangler.toml 35 | postCommands: | 36 | yarn kv-gc 37 | environment: production 38 | env: 39 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} 40 | YARN_IGNORE_NODE: 1 41 | -------------------------------------------------------------------------------- /types/src/KvMonitors.ts: -------------------------------------------------------------------------------- 1 | export interface Check { 2 | incidents: number[], 3 | summery: { 4 | [index: string]: { 5 | n: number 6 | ms: number 7 | a: number 8 | } 9 | }, 10 | res: { 11 | t: number 12 | loc: string 13 | ms: number 14 | }[] 15 | } 16 | 17 | export interface KvMonitor { 18 | operational: boolean 19 | incidents: { 20 | start: number 21 | status: number 22 | statusText: string 23 | end?: number 24 | }[], 25 | checks: { 26 | [index: string]: Check 27 | } 28 | } 29 | 30 | export interface KvMonitors { 31 | lastCheck: number 32 | allOperational: boolean 33 | monitors: { 34 | [index: string]: KvMonitor 35 | } 36 | } 37 | 38 | export interface Checks { 39 | incidents: { 40 | [index: string]: { 41 | start: number 42 | status: number 43 | statusText: string 44 | end?: number 45 | }[] 46 | }, 47 | summery: { 48 | [index: string]: { 49 | [index: string]: { 50 | n: number 51 | ms: number 52 | a: number 53 | } 54 | } 55 | }, 56 | res: { 57 | t: number 58 | l: string 59 | ms: { 60 | [index: string]: number | null 61 | } 62 | }[] 63 | } 64 | 65 | export interface MonitorMonth { 66 | lastCheck: number, 67 | operational: { 68 | [index: string]: boolean 69 | }, 70 | checks: { 71 | [index: string]: Checks 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cf-status-page-site", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbo", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "pages:build": "yarn dlx @cloudflare/next-on-pages@1", 11 | "pages:deploy": "pages:build && wrangler pages publish .vercel/output/static", 12 | "pages:watch": "yarn dlx @cloudflare/next-on-pages@1 --watch", 13 | "pages:dev": "yarn dlx wrangler pages dev .vercel/output/static --compatibility-flag=nodejs_compat" 14 | }, 15 | "dependencies": { 16 | "@emotion/cache": "^11.13.1", 17 | "@emotion/react": "^11.13.3", 18 | "@emotion/styled": "^11.13.0", 19 | "@mui/material": "^6.1.2", 20 | "@mui/material-nextjs": "^6.1.2", 21 | "@next/third-parties": "^14.2.14", 22 | "@types/node": "20.16.10", 23 | "@types/react": "18.3.11", 24 | "@types/react-dom": "18.3.0", 25 | "eslint": "8.57.1", 26 | "eslint-config-next": "13.5.7", 27 | "next": "^14.2.14", 28 | "react": "^18.3.1", 29 | "react-dom": "^18.3.1", 30 | "recharts": "^2.13.0-alpha.5", 31 | "server-only": "^0.0.1", 32 | "typescript": "5.6.2" 33 | }, 34 | "devDependencies": { 35 | "@cloudflare/next-on-pages": "^1.13.3", 36 | "@cloudflare/workers-types": "^4.20240925.0", 37 | "cf-status-page-types": "*", 38 | "vercel": "^35.1.0" 39 | } 40 | } -------------------------------------------------------------------------------- /site/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 18 | 19 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /site/src/components/ResponseGraph.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React from 'react'; 4 | import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; 5 | 6 | export default function ResponseGraph({ data, day = 1, local = true }: { data: any[], day?: number, local?: boolean }) { 7 | const now = local ? Date.now() : new Date().setUTCHours(0, 0, 0, 0) + 86400000; 8 | return ( 9 | 10 | checkDay.t > now - 86400000 * day && checkDay.t < now - 86400000 * (day - 1)), [{ t: now - 86400000 * (day - 1) }])} 14 | > 15 | 16 | new Date(data.t)} scale='time' axisLine={false} tickLine={false} tick={false} tickMargin={0} /> 17 | 18 | new Date(label).toISOString().replace('T', ' ').split('.')[0]} /> 19 | {/* */} 20 | { 21 | if (!d.ms) return undefined 22 | const index = data.indexOf(d); 23 | let sum = 0; 24 | for (let i = -3; i < 4; i++) { 25 | sum += data[index + i] ? data[index + i].ms : d.ms; 26 | } 27 | return Math.round(sum / 7) 28 | }} stroke="#82ca9d" unit=' ms' /> 29 | 30 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /site/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Roboto } from 'next/font/google' 2 | import RootProvider from '@/components/RootProvider' 3 | import config from '../../../config.json' 4 | import { GoogleAnalytics } from "@next/third-parties/google"; 5 | import Container from '@mui/material/Container'; 6 | import Typography from '@mui/material/Typography'; 7 | import Link from '@/components/Link'; 8 | 9 | const inter = Roboto({ weight: ['300', '400', '500', '700'], display: 'swap', subsets: ['cyrillic', 'cyrillic-ext', 'greek', 'greek-ext', 'latin', 'latin-ext', 'vietnamese'] }) 10 | 11 | export const metadata = { 12 | title: config.settings.title, 13 | description: config.settings.description, 14 | } 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode 20 | }) { 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | {config.settings.title} 28 | 29 | {children} 30 | 31 | 32 | Cloudflare Status Page 33 | 34 | {' by '} 35 | 36 | H01001000 37 | 38 | 39 | {'Powered by '}Cloudflare{' and '}Next.js 40 | 41 | 42 | 43 | 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /site/src/components/OverallResponseGraph.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Checks } from 'cf-status-page-types'; 4 | import React from 'react'; 5 | import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; 6 | 7 | export default function ResponseGraph({ monitorName, checks, day = 90 }: { 8 | monitorName: string, checks: { 9 | [index: string]: Checks 10 | }, day?: number 11 | }) { 12 | const now = new Date().setUTCHours(0, 0, 0, 0) + 86400000; 13 | const locations: string[] = [] 14 | const processedData = [] 15 | const dataInMs = Object.keys(checks).map((checkDay) => ({ ...checks[checkDay], day: new Date(checkDay).getTime() })) 16 | for (let dayBefore = day - 1; dayBefore > -1; dayBefore--) { 17 | const item = dataInMs.find((checkDay) => checkDay.day === now - 86400000 * dayBefore) 18 | if (item === undefined) { 19 | processedData.push({ day: now - 86400000 * dayBefore }) 20 | continue 21 | } 22 | 23 | Object.keys(item.summery).forEach((location) => { 24 | if (!locations.includes(location)) locations.push(location) 25 | }); 26 | processedData.push(item) 27 | } 28 | 29 | return ( 30 | 31 | 36 | 37 | data.day} scale='time' axisLine={false} tickLine={false} tick={false} tickMargin={0} /> 38 | 39 | new Date(label).toISOString().slice(0, 10)} /> 40 | {/* */} 41 | {locations.map((location) => 42 | d.summery?.[location]?.[monitorName]?.a ?? null} stroke="#82ca9d" name={location} unit=' ms' key={location} /> 43 | )} 44 | 45 | 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /site/src/components/AllStatus.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React, { useEffect, useState } from 'react'; 4 | import type { MonitorMonth } from 'cf-status-page-types'; 5 | import { Paper, Skeleton, Stack, Typography } from '@mui/material'; 6 | 7 | export function AllStatus({ statusText, statusColorCode = '#2ecc71', lastCheck }: { statusText?: string, statusColorCode?: string, lastCheck?: number }) { 8 | return ( 9 | 10 | 14 | {statusText ? 15 | {statusText} 16 | : 17 | 18 | 19 | All Systems Operational 20 | 21 | 22 | } 23 | {lastCheck ? 24 | {lastCheck} Seconds ago 25 | : 26 | 27 | 28 | 29 | 30 | {lastCheck} Seconds ago 31 | 32 | } 33 | 34 | 35 | ) 36 | } 37 | 38 | export default function AllStatusWithData({ operational, lastCheck, defaultNow }: { operational: MonitorMonth["operational"], lastCheck: number, defaultNow: number }) { 39 | const [now, setNow] = useState(defaultNow) 40 | 41 | useEffect(() => { 42 | const interval = setInterval(() => { 43 | setNow(Date.now()) 44 | }, 1000) 45 | return () => clearInterval(interval) 46 | }, []) 47 | 48 | const allOperational = Object.keys(operational).map((monitor) => operational[monitor]).every(v => v === true) 49 | const allOutage = Object.keys(operational).map((monitor) => operational[monitor]).every(v => v === false) 50 | 51 | return ( 52 | 53 | ) 54 | } -------------------------------------------------------------------------------- /poller/src/cli/gcMonitors.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const accountId = process.env.CF_ACCOUNT_ID 4 | const namespaceId = process.env.KV_NAMESPACE_ID 5 | const apiToken = process.env.CF_API_TOKEN 6 | 7 | const kvMonitorsKey = 'monitors_data_v1_1' 8 | 9 | if (!accountId || !namespaceId || !apiToken) { 10 | console.error( 11 | 'Missing required environment variables: CF_ACCOUNT_ID, KV_NAMESPACE_ID, CF_API_TOKEN', 12 | ) 13 | process.exit(0) 14 | } 15 | 16 | async function getKvMonitors(kvMonitorsKey) { 17 | const init = { 18 | headers: { 19 | 'Content-Type': 'application/json', 20 | Authorization: `Bearer ${apiToken}`, 21 | }, 22 | } 23 | 24 | const res = await fetch( 25 | `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${kvMonitorsKey}`, 26 | init, 27 | ) 28 | const json = await res.json() 29 | return json 30 | } 31 | 32 | async function saveKVMonitors(kvMonitorsKey, data) { 33 | const init = { 34 | method: 'PUT', 35 | headers: { 36 | 'Content-Type': 'application/json', 37 | Authorization: `Bearer ${apiToken}`, 38 | }, 39 | body: JSON.stringify(data), 40 | } 41 | 42 | const res = await fetch( 43 | `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${kvMonitorsKey}`, 44 | init, 45 | ) 46 | 47 | return res 48 | } 49 | 50 | function loadConfig() { 51 | const configFile = fs.readFileSync('./config.json', 'utf8') 52 | const config = JSON.parse(configFile); 53 | return JSON.parse(config) 54 | } 55 | 56 | getKvMonitors(kvMonitorsKey) 57 | .then(async (kvMonitors) => { 58 | let stateMonitors = kvMonitors 59 | 60 | const config = loadConfig() 61 | const configMonitors = config.monitors.map((key) => { 62 | return key.id 63 | }) 64 | 65 | Object.keys(stateMonitors.monitors).map((monitor) => { 66 | // remove monitor data from state if missing in config 67 | if (!configMonitors.includes(monitor)) { 68 | delete stateMonitors.monitors[monitor] 69 | } 70 | 71 | // delete dates older than config.settings.daysInHistogram 72 | let date = new Date() 73 | date.setDate(date.getDate() - config.settings.daysInHistogram) 74 | date.toISOString().split('T')[0] 75 | const cleanUpDate = date.toISOString().split('T')[0] 76 | 77 | Object.keys(stateMonitors.monitors[monitor].checks).map((checkDay) => { 78 | if (checkDay < cleanUpDate) { 79 | delete stateMonitors.monitors[monitor].checks[checkDay] 80 | } 81 | }) 82 | }) 83 | 84 | // sanity check + if good save the KV 85 | if (configMonitors.length === Object.keys(stateMonitors.monitors).length) { 86 | await saveKVMonitors(kvMonitorsKey, stateMonitors) 87 | } 88 | }) 89 | .catch((e) => console.log(e)) 90 | -------------------------------------------------------------------------------- /site/src/components/UptimeGraph.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Container, Divider, Paper, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'; 4 | import React from 'react'; 5 | import ResponseGraph from '@/components/ResponseGraph'; 6 | import type { Checks, KvMonitor } from 'cf-status-page-types'; 7 | 8 | const getGradient = (UpPresentage: number) => { 9 | const toHex = (color: number) => { 10 | const hex = color.toString(16); 11 | return (hex.length == 1) ? '0' + hex : hex; 12 | }; 13 | 14 | const r = Math.ceil(59 * UpPresentage + 231 * (1 - UpPresentage)); 15 | const g = Math.ceil(165 * UpPresentage + 76 * (1 - UpPresentage)); 16 | const b = Math.ceil(92 * UpPresentage + 60 * (1 - UpPresentage)); 17 | 18 | return '#' + toHex(r) + toHex(g) + toHex(b); 19 | } 20 | 21 | const getDayIncidentInfo = (checks: { 22 | [index: string]: Checks 23 | }, day: string, monitorName: string) => { 24 | if (!checks[day]) return [undefined, undefined] 25 | 26 | const uptime = checks[day].res.filter((res) => res.ms[monitorName]).map((res) => res.ms[monitorName]) 27 | const downCount = uptime.filter((ms) => ms === null).length 28 | if (downCount === 0) return [1, 0]; 29 | const upCount = uptime.filter((ms) => ms !== null).length 30 | if (upCount === 0) return [0, 1]; 31 | 32 | const noOfincidents = uptime.filter((ms, i) => ms === null && (i === 0 ? true : ms !== null && uptime[i - 1] === null)).length 33 | const uptimePercent = upCount / (upCount + downCount) 34 | 35 | return [uptimePercent, noOfincidents] 36 | } 37 | 38 | export default function UptimeGraph({ monitorName, checks, day = 90 }: { 39 | monitorName: string, checks: { 40 | [index: string]: Checks 41 | }, day?: number 42 | }) { 43 | const theme = useTheme(); 44 | const mdUp = useMediaQuery(theme.breakpoints.up('md'), { defaultMatches: true }) 45 | const lastDays = useMediaQuery(theme.breakpoints.up('sm'), { defaultMatches: true }) ? mdUp ? 90 : 60 : 30 46 | 47 | let lastX0Uptime = 0; 48 | const lastX0Days = new Array(lastDays).fill(1).map((_, i) => { 49 | const day = new Date(Date.now() - 86400000 * (lastDays - 1) + 86400000 * i).toISOString().split('T')[0] 50 | const [upPresentage, noOfincidents] = getDayIncidentInfo(checks, day, monitorName) 51 | lastX0Uptime += (upPresentage ?? 1) / lastDays 52 | return { 53 | day, 54 | upPresentage, 55 | noOfincidents 56 | } 57 | }) 58 | return ( 59 | <> 60 | 61 | {lastX0Days.map(({ day, upPresentage, noOfincidents }, i) => 62 | 63 | {day} 64 |
65 | {upPresentage !== undefined && noOfincidents !== undefined ? `${upPresentage * 100}% ${noOfincidents} incident(s)` : 'No Data'} 66 |
67 | {/*
68 | kvMonitor.checks[day].res).flat()} day={lastDays - i} local={false} /> 69 |
*/} 70 | } arrow key={i} enterTouchDelay={0}> 71 | 72 | 73 | )} 74 | 75 |
76 | {lastDays} days ago 77 | {Math.round(lastX0Uptime * 10000) / 100} % uptime 78 | Today 79 |
80 | 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /poller/src/functions/cronTrigger.ts: -------------------------------------------------------------------------------- 1 | import config from '../../../config.json' 2 | import type { ScheduledEvent } from '@cloudflare/workers-types' 3 | import { MonitorMonth } from 'cf-status-page-types' 4 | 5 | import { 6 | getCheckLocation, 7 | getKVMonitors, 8 | setKVMonitors, 9 | } from './helpers' 10 | 11 | function getDate(time: number) { 12 | return new Date(time).toISOString().split('T')[0] 13 | } 14 | 15 | export async function processCronTrigger(_event: ScheduledEvent) { 16 | // Get Worker PoP and save it to monitorMonthMetadata 17 | const checkLocation = await getCheckLocation() 18 | const now = Date.now() 19 | const checkDay = getDate(now) 20 | 21 | // Get monitors state from KV 22 | let monitorMonth: MonitorMonth = await getKVMonitors(checkDay.slice(0, 7)) 23 | // Create empty state objects if not exists in KV storage yet 24 | if (!monitorMonth) { 25 | const lastDay = getDate(now - 86400000) 26 | const lastMonitorMonth: MonitorMonth = await getKVMonitors(lastDay.slice(0, 7)) 27 | 28 | monitorMonth = { 29 | lastCheck: now, 30 | operational: lastMonitorMonth ? lastMonitorMonth.operational : {}, 31 | checks: { 32 | // incidents: {}, 33 | } 34 | } 35 | } 36 | 37 | if (!monitorMonth.checks[checkDay]) { 38 | monitorMonth.checks[checkDay] = { 39 | summery: {}, 40 | res: [], 41 | incidents: {}, 42 | } 43 | } 44 | 45 | const res: { 46 | t: number 47 | l: string 48 | ms: { 49 | [index: string]: number | null 50 | } 51 | } = { t: now, l: checkLocation, ms: {} } 52 | 53 | for (const monitor of config.monitors) { 54 | 55 | console.log(`Checking ${monitor.name} ...`) 56 | 57 | // Fetch the monitors URL 58 | const init: Parameters[1] = { 59 | method: monitor.method || 'GET', 60 | redirect: monitor.followRedirect ? 'follow' : 'manual', 61 | headers: { 62 | //@ts-expect-error 63 | 'User-Agent': config.settings.user_agent || 'cf-workers-status-poller', 64 | }, 65 | } 66 | 67 | // Perform a check and measure time 68 | const requestStartTime = performance.now() 69 | const checkResponse = await fetch(monitor.url, init) 70 | const requestTime = Math.round(performance.now() - requestStartTime) 71 | 72 | // Determine whether operational and status changed 73 | const monitorOperational = checkResponse.status === (monitor.expectStatus || 200) 74 | // const monitorStatusChanged = monitorMonth.operational[monitor.id] ? monitorMonth.operational[monitor.id] !== monitorOperational : false 75 | 76 | // Save monitor's last check response status 77 | monitorMonth.operational[monitor.id] = monitorOperational; 78 | 79 | if (config.settings.collectResponseTimes && monitorOperational) { 80 | // make sure location exists in current checkDay 81 | if (!monitorMonth.checks[checkDay].summery[checkLocation]) 82 | monitorMonth.checks[checkDay].summery[checkLocation] = {} 83 | if (!monitorMonth.checks[checkDay].summery[checkLocation][monitor.id]) 84 | monitorMonth.checks[checkDay].summery[checkLocation][monitor.id] = { 85 | n: 0, 86 | ms: 0, 87 | a: 0, 88 | } 89 | 90 | // increment number of checks and sum of ms 91 | const no = ++monitorMonth.checks[checkDay].summery[checkLocation][monitor.id].n 92 | const ms = monitorMonth.checks[checkDay].summery[checkLocation][monitor.id].ms += requestTime 93 | 94 | // save new average ms 95 | monitorMonth.checks[checkDay].summery[checkLocation][monitor.id].a = Math.round(ms / no) 96 | 97 | // back online 98 | // if (monitorStatusChanged) { 99 | // monitorMonth.monitors[monitor.id].incidents.at(-1)!.end = now; 100 | // } 101 | } 102 | 103 | res.ms[monitor.id] = monitorOperational ? requestTime : null 104 | 105 | // go dark 106 | // if (!monitorOperational && monitorStatusChanged) { 107 | // monitorMonth.monitors[monitor.id].incidents.push({ start: now, status: checkResponse.status, statusText: checkResponse.statusText }) 108 | // const incidentNumber = monitorMonth.monitors[monitor.id].incidents.length - 1 109 | // monitorMonth.monitors[monitor.id].checks[checkDay].incidents.push(incidentNumber) 110 | // } 111 | } 112 | 113 | monitorMonth.checks[checkDay].res.push(res) 114 | monitorMonth.lastCheck = now 115 | 116 | // Save monitorMonth to KV storage 117 | await setKVMonitors(checkDay.slice(0, 7), monitorMonth) 118 | 119 | return new Response('OK') 120 | } 121 | -------------------------------------------------------------------------------- /site/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | //import 'server-only' 4 | import type { MonitorMonth } from 'cf-status-page-types' 5 | import config from '../../../config.json' 6 | import Container from '@mui/material/Container'; 7 | import Typography from '@mui/material/Typography'; 8 | import Paper from '@mui/material/Paper'; 9 | import Divider from '@mui/material/Divider'; 10 | import Box from '@mui/material/Box'; 11 | import Link from '@/components/Link'; 12 | import AllStatusWithData, { AllStatus } from '@/components/AllStatus'; 13 | import OverallResponseGraph from '@/components/OverallResponseGraph'; 14 | import UptimeGraph from '@/components/UptimeGraph'; 15 | import { useEffect, useState } from 'react'; 16 | 17 | export const dynamic = "force-dynamic" 18 | export const fetchCache = "force-no-store" 19 | export const runtime = 'edge' 20 | 21 | const getKvMonitors = async (key: string): Promise => { 22 | //if (!process.env.KV_STATUS_PAGE) 23 | return fetch(`${config.settings.url}/api/kv/${key}`).then((res) => { 24 | if (res.ok) { 25 | return res.json() 26 | } 27 | throw new Error('Failed to fetch') 28 | }); 29 | //const { KV_STATUS_PAGE } = (process.env as unknown as { KV_STATUS_PAGE: KVNamespace }); 30 | //return KV_STATUS_PAGE.get("monitors_data_v1_1", { type: 'json' }); 31 | } 32 | 33 | const getYearMonth = (date: Date) => { 34 | return date.toISOString().split('T')[0].slice(0, 7) 35 | } 36 | 37 | export default function Home() { 38 | const [data, setData] = useState({ 39 | checks: {}, 40 | lastCheck: 0, 41 | operational: {}, 42 | }) 43 | const [_dataLoaded, setDataLoaded] = useState([false, false, false]) 44 | 45 | useEffect(() => { 46 | getKvMonitors(getYearMonth(new Date())).then((res) => { 47 | setData(oldData => ({ 48 | checks: { ...oldData.checks, ...res.checks }, 49 | lastCheck: res.lastCheck > oldData.lastCheck ? res.lastCheck : oldData.lastCheck, 50 | operational: res.lastCheck > oldData.lastCheck ? res.operational : oldData.operational, 51 | })) 52 | setDataLoaded(oldData => [true, oldData[1], oldData[2]]) 53 | }).catch(() => { }); 54 | 55 | const lastMonth = new Date() 56 | lastMonth.setMonth(lastMonth.getMonth() - 1) 57 | getKvMonitors(getYearMonth(lastMonth)).then((res) => { 58 | setData(oldData => ({ 59 | checks: { ...oldData.checks, ...res.checks }, 60 | lastCheck: res.lastCheck > oldData.lastCheck ? res.lastCheck : oldData.lastCheck, 61 | operational: res.lastCheck > oldData.lastCheck ? res.operational : oldData.operational, 62 | })) 63 | setDataLoaded(oldData => [oldData[0], true, oldData[2]]) 64 | }).catch(() => { }); 65 | 66 | const last2Month = new Date() 67 | last2Month.setMonth(last2Month.getMonth() - 2) 68 | getKvMonitors(getYearMonth(last2Month)).then((res) => { 69 | setData(oldData => ({ 70 | checks: { ...oldData.checks, ...res.checks }, 71 | lastCheck: res.lastCheck > oldData.lastCheck ? res.lastCheck : oldData.lastCheck, 72 | operational: res.lastCheck > oldData.lastCheck ? res.operational : oldData.operational, 73 | })) 74 | setDataLoaded(oldData => [oldData[0], oldData[1], true]) 75 | }).catch(() => { }); 76 | }, []) 77 | 78 | return ( 79 | <> 80 | {data.lastCheck === 0 ? 81 | : 82 | } 83 | 84 | 85 | {config.monitors.map(({ id: monitorName, name, url }, i) => 86 | 87 | {i !== 0 && } 88 | 89 | 90 | {name} 91 | 92 | {data.operational[monitorName] ? 'Operational' : 'Outage'} 93 | 94 | 95 |
96 | 97 |
98 |
99 | )} 100 |
101 |
102 | 103 | ) 104 | } 105 | --------------------------------------------------------------------------------