This commit is contained in:
Adam Janis 2020-11-08 13:56:02 +01:00
commit e85c5766a7
20 changed files with 1013 additions and 0 deletions

0
.cargo-ok Normal file
View File

21
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: Deploy
on:
- repository_dispatch
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 12
- run: yarn install
- run: yarn build
- name: Publish
uses: cloudflare/wrangler-action@1.2.0
with:
apiToken: ${{ secrets.CF_API_TOKEN }}
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
IS_WORKER: true

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
out/*
!out/.gitkeep
# production
/dist/
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
/worker

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"singleQuote": true,
"semi": false,
"trailingComma": "all",
"tabWidth": 2,
"printWidth": 80
}

378
config.yaml Normal file
View File

@ -0,0 +1,378 @@
settings:
title: "Status Page"
logo: logo-192x192.png
daysInHistory: 90
allmonitorsOperational: "All Systems Operational"
notAllmonitorsOperational: "Not All Systems Operational"
monitorLabelOperational: "Operational"
monitorLabelNotOperational: "Not great not terrible"
monitorLabelNoData: "No data"
monitors:
- id: kiwi-com-homepage
name: Kiwi.com homepage
description: Kiwi.com en homepage
url: 'https://www.kiwi.com/en/'
method: GET
expectStatus: 200
followRedirect: false
- id: eidam-dev
name: Cheesy Status Page
description: 'status-page.eidam.dev'
url: 'https://status-page.eidam.dev/'
method: GET
expectStatus: 200
- id: google-com
name: Google.com
description: Google homepage
url: 'https://www.google.com'
method: GET
expectStatus: 200
- id: cf-workers-status-page
name: This Workers Status Page project made public
description: /shrug
url: 'https://github.com/adam-janis/cf-workers-status-page'
method: GET
expectStatus: 200
- id: kiwicomapi-cn
name: Some other site
description: Is this done yet?
url: 'http://kiwicomapi.cn/'
method: GET
expectStatus: 200
- id: testy-testy
name: Testy testy
description: Something /shrug
url: 'http://kiwicomapiiii.cn/'
method: GET
expectStatus: 200
- id: hello-world
name: Hello World
url: 'http://cnn.cn/'
method: GET
expectStatus: 200
- id: eidam-dev-2
name: Eidam.dev
description: 'Eidam.dev homepage, there is none'
url: 'https://eidam.dev'
method: GET
expectStatus: 403
- id: google-com-2
name: Google.com
description: Google homepage
url: 'https://www.google.com'
method: GET
expectStatus: 200
- id: cf-workers-status-page-2
name: This Workers Status Page project made public
description: /shrug
url: 'https://github.com/adam-janis/cf-workers-status-page'
method: GET
expectStatus: 200
- id: kiwicomapi-cn-2
name: Kiwi.com API CN
description: Is this done yet?
url: 'http://kiwicomapi.cn/'
method: GET
expectStatus: 200
- id: testy-testy-2
name: Testy testy
description: Something /shrug
url: 'http://kiwicomapiiii.cn/'
method: GET
expectStatus: 200
- id: hello-world-2
name: Hello World
url: 'http://cnn.cn/'
method: GET
expectStatus: 200
- id: eidam-dev-22
name: Eidam.dev
description: 'Eidam.dev homepage, there is none'
url: 'https://eidam.dev'
method: GET
expectStatus: 403
- id: google-com-22
name: Google.com
description: Google homepage
url: 'https://www.google.com'
method: GET
expectStatus: 200
- id: cf-workers-status-page-22
name: This Workers Status Page project made public
description: /shrug
url: 'https://github.com/adam-janis/cf-workers-status-page'
method: GET
expectStatus: 200
- id: kiwicomapi-cn-22
name: Kiwi.com API CN
description: Is this done yet?
url: 'http://kiwicomapi.cn/'
method: GET
expectStatus: 200
- id: testy-testy-22
name: Testy testy
description: Something /shrug
url: 'http://kiwicomapiiii.cn/'
method: GET
expectStatus: 200
- id: hello-world-22
name: Hello World
url: 'http://cnn.cn/'
method: GET
expectStatus: 200
- id: eidam-dev-333
name: Eidam.dev
description: 'Eidam.dev homepage, there is none'
url: 'https://eidam.dev'
method: GET
expectStatus: 403
- id: google-com-333
name: Google.com
description: Google homepage
url: 'https://www.google.com'
method: GET
expectStatus: 200
- id: cf-workers-status-page-333
name: This Workers Status Page project made public
description: /shrug
url: 'https://github.com/adam-janis/cf-workers-status-page'
method: GET
expectStatus: 200
- id: kiwicomapi-cn-333
name: Kiwi.com API CN
description: Is this done yet?
url: 'http://kiwicomapi.cn/'
method: GET
expectStatus: 200
- id: testy-testy-333
name: Testy testy
description: Something /shrug
url: 'http://kiwicomapiiii.cn/'
method: GET
expectStatus: 200
- id: hello-world-333
name: Hello World
url: 'http://cnn.cn/'
method: GET
expectStatus: 200
- id: 25-eidam-dev
name: Eidam.dev
description: 'Eidam.dev homepage, there is none'
url: 'https://eidam.dev'
method: GET
expectStatus: 403
- id: 25-google-com
name: Bing.com
description: Bing homepage
url: 'https://www.google.com'
method: GET
expectStatus: 200
- id: 25-cf-workers-status-page
name: This Workers Status Page project made public
description: /shrug
url: 'https://github.com/adam-janis/cf-workers-status-page'
method: GET
expectStatus: 200
- id: 25-kiwicomapi-cn
name: Kiwi.com API CN
description: Is this done yet?
url: 'http://kiwicomapi.cn/'
method: GET
expectStatus: 200
- id: 25-testy-testy
name: Testy testy
description: Something /shrug
url: 'http://kiwicomapiiii.cn/'
method: GET
expectStatus: 200
- id: 25-hello-world
name: Hello World
url: 'http://cnn.cn/'
method: GET
expectStatus: 200
- id: 25-eidam-dev-2
name: Seznam.cz
description: 'Just seznam'
url: 'https://eidam.dev'
method: GET
expectStatus: 403
- id: 25-google-com-2
name: Google.com
description: Google homepage
url: 'https://www.google.com'
method: GET
expectStatus: 200
- id: 25-cf-workers-status-page-2
name: This Workers Status Page project made public
description: /shrug
url: 'https://github.com/adam-janis/cf-workers-status-page'
method: GET
expectStatus: 200
- id: 25-kiwicomapi-cn-2
name: Kiwi.com API CN
description: Is this done yet?
url: 'http://kiwicomapi.cn/'
method: GET
expectStatus: 200
- id: 25-testy-testy-2
name: Testy testy
description: Something /shrug
url: 'http://kiwicomapiiii.cn/'
method: GET
expectStatus: 200
- id: 25-hello-world-2
name: Hello World
url: 'http://cnn.cn/'
method: GET
expectStatus: 200
- id: 25-eidam-dev-22
name: Eidam.dev
description: 'Eidam.dev homepage, there is none'
url: 'https://eidam.dev'
method: GET
expectStatus: 403
- id: 25-google-com-22
name: Google.com
description: Google homepage
url: 'https://www.google.com'
method: GET
expectStatus: 200
- id: 25-cf-workers-status-page-22
name: This Workers Status Page project made public
description: /shrug
url: 'https://github.com/adam-janis/cf-workers-status-page'
method: GET
expectStatus: 200
- id: 25-kiwicomapi-cn-22
name: Something totally different
description: Is this done yet?
url: 'http://kiwicomapi.cn/'
method: GET
expectStatus: 200
- id: 25-testy-testy-22
name: Testy testy
description: Something /shrug
url: 'http://kiwicomapiiii.cn/'
method: GET
expectStatus: 200
- id: 25-hello-world-22
name: Hello World
url: 'http://cnn.cn/'
method: GET
expectStatus: 200
- id: 25-eidam-dev-333
name: Eidam.dev
description: 'Eidam.dev homepage, there is none'
url: 'https://eidam.dev'
method: GET
expectStatus: 403
- id: 25-google-com-333
name: Google.com
description: Google homepage
url: 'https://www.google.com'
method: GET
expectStatus: 200
- id: 25-cf-workers-status-page-333
name: This Workers Status Page project made public
description: /shrug
url: 'https://github.com/adam-janis/cf-workers-status-page'
method: GET
expectStatus: 200
- id: 25-kiwicomapi-cn-333
name: Kiwi.com API CN
description: Is this done yet?
url: 'http://kiwicomapi.cn/'
method: GET
expectStatus: 200
- id: 25-testy-testy-333
name: Testy testy
description: Something /shrug
url: 'http://kiwicomapiiii.cn/'
method: GET
expectStatus: 200
- id: 25-hello-world-333
name: Hello World
url: 'http://cnn.cn/'
method: GET
expectStatus: 200
- id: 25-hello-world-333
name: Hello World
url: 'http://cnn.cn/'
method: GET
expectStatus: 200

11
flareact.config.js Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
webpack: (config, options) => {
config.module.rules.push({
test: /\.ya?ml$/,
type: 'json',
use: 'yaml-loader',
})
return config
},
}

32
index.js Normal file
View File

@ -0,0 +1,32 @@
import { handleEvent } from 'flareact'
import { processCronTrigger } from './src/functions/cronTrigger'
/**
* The DEBUG flag will do two things that help during development:
* 1. we will skip caching on the edge, which makes it easier to
* debug.
* 2. we will return an error message on exception in your Response rather
* than the default 404.html page.
*/
const DEBUG = false
addEventListener('fetch', event => {
try {
event.respondWith(
handleEvent(event, require.context('./pages/', true, /\.js$/), DEBUG),
)
} catch (e) {
if (DEBUG) {
return event.respondWith(
new Response(e.message || e.toString(), {
status: 500,
}),
)
}
event.respondWith(new Response('Internal Error', { status: 500 }))
}
})
addEventListener('scheduled', event => {
event.waitUntil(processCronTrigger(event))
})

0
out/.gitkeep Normal file
View File

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "cf-workers-status-page",
"version": "1.0.0",
"author": "Adam Janiš <adam.janis@gmail.com>",
"license": "MIT",
"main": "index.js",
"private": true,
"scripts": {
"dev": "flareact dev",
"build": "flareact build",
"deploy": "flareact publish",
"format": "prettier --write '**/*.{js,css,json,md}'"
},
"dependencies": {
"flareact": "^0.7.1",
"react": "^16.13.1",
"react-dom": "^16.13.1"
},
"devDependencies": {
"prettier": "^1.18.2",
"yaml-loader": "^0.6.0"
}
}

5
pages/api/triggerCron.js Normal file
View File

@ -0,0 +1,5 @@
import { processCronTrigger } from '../../src/functions/cronTrigger'
export default async event => {
return processCronTrigger()
}

141
pages/index.js Normal file
View File

@ -0,0 +1,141 @@
import Head from 'flareact/head'
import MonitorHistogram from '../src/components/monitorHistogram'
import {
getLastUpdate,
getMonitors,
getMonitorsHistory,
} from '../src/functions/helpers'
import config from '../config.yaml'
import MonitorStatusLabel from '../src/components/monitorStatusLabel'
export async function getEdgeProps() {
// get KV data
const kvMonitors = await getMonitors()
const kvMonitorsDays = await getMonitorsHistory()
const kvLastUpdate = await getLastUpdate()
// prepare data maps for components
let monitorsOperational = true
let kvMonitorsMap = {}
kvMonitors.forEach(x => {
kvMonitorsMap[x.metadata.id] = x.metadata
if (x.metadata.operational === false) monitorsOperational = false
})
let kvMonitorsDaysMap = {}
kvMonitorsDays.forEach(x => {
kvMonitorsDaysMap[x.name] = x.metadata.operational
})
return {
props: {
config,
kvMonitorsMap,
kvMonitorsDaysMap,
monitorsOperational,
kvLastUpdate,
},
// Revalidate these props once every x seconds
revalidate: 5,
}
}
export default function Index({
config,
kvMonitorsMap,
kvMonitorsDaysMap,
monitorsOperational,
kvLastUpdate,
}) {
return (
<div>
<Head>
<title>{config.settings.title}</title>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/fomantic-ui/2.8.7/semantic.min.css"
crossOrigin="anonymous"
/>
<link rel="stylesheet" href="./main.css" />
</Head>
<div className="ui basic segment container">
<h1 className="ui huge header">
<img
className="ui middle aligned tiny image"
src={config.settings.logo}
/>
{config.settings.title}
</h1>
<div
className={`ui inverted segment ${
monitorsOperational ? 'green' : 'yellow'
}`}
>
<div className="horizontal flex between">
<div className="ui marginless header black-text">
{monitorsOperational
? config.settings.allmonitorsOperational
: config.settings.notAllmonitorsOperational}
</div>
<div className="black-text">
checked {Math.round((Date.now() - kvLastUpdate) / 1000)} sec ago
</div>
</div>
</div>
{config.monitors.map((monitor, key) => {
return (
<div key={key} className="ui segment">
<div
className="ui horizontal flex between"
style={{ marginBottom: '8px' }}
>
<div className="ui marginless header">
<span data-tooltip={monitor.description}>
<i className="blue small info circle icon" />
</span>
<div className="content">{monitor.name}</div>
</div>
<MonitorStatusLabel
kvMonitorsMap={kvMonitorsMap}
monitor={monitor}
/>
</div>
<MonitorHistogram
kvMonitorsDaysMap={kvMonitorsDaysMap}
monitor={monitor}
/>
<div className="horizontal flex between grey-text">
<div>{config.settings.daysInHistory} days ago</div>
<div>Today</div>
</div>
</div>
)
})}
<div className="horizontal flex between grey-text">
<div>
Powered by{' '}
<a href="https://workers.cloudflare.com/" target="_blank">
Cloudflare Workers{' '}
</a>
&{' '}
<a href="https://flareact.com/" target="_blank">
Flareact{' '}
</a>
</div>
<div>
<a
href="https://github.com/adam-janis/cf-workers-status-page"
target="_blank"
>
Get Your Status Page
</a>
</div>
</div>
</div>
</div>
)
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/logo-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

66
public/main.css Normal file
View File

@ -0,0 +1,66 @@
body {
background: #eeeeee;
}
.flex {
display: flex;
justify-content: center;
align-content: center;
align-items: center;
}
.flex.horizontal {
flex-direction: row;
}
.flex.vertical {
flex-direction: column;
}
.flex.between {
justify-content: space-between;
}
.marginless {
margin: 0 !important;
}
.paddingless {
padding: 0 !important;
}
.black-text {
color: #000 !important;
}
.grey-text {
color: #a0a0a0 !important;
}
.white-text {
color: #fff !important;
}
.histogram {
height: 24px;
width: 100%;
margin: 0 auto;
}
.hitbox {
align-items: flex-end;
box-sizing: border-box;
height: 100%;
width: 100%;
padding: 1px;
border-radius: 3.75px;
}
.bar {
background: #dcddde;
padding-bottom: 1px;
height: 100%;
width: 85%;
border-radius: 100px;
}
.bar.green {
background: #21ba45;
}
.bar.red {
background: #db2828;
}
.bar.orange {
background: #f2711c;
}
span i.icon {
margin: 0 !important;
}

View File

@ -0,0 +1,49 @@
import config from '../../config.yaml'
export default function MonitorHistogram({ kvMonitorsDaysMap, monitor }) {
let date = new Date()
date.setDate(date.getDate() - config.settings.daysInHistory)
if (typeof window !== 'undefined') {
return (
<div
key={`${monitor.id}-histogram`}
className="horizontal flex histogram"
>
{Array.from(Array(config.settings.daysInHistory).keys()).map(key => {
date.setDate(date.getDate() + 1)
const dayInHistory = date.toISOString().split('T')[0]
const dayInHistoryKey = 'h_' + monitor.id + '_' + dayInHistory
let bg = ''
let dayInHistoryStatus = 'No data'
if (typeof kvMonitorsDaysMap[dayInHistoryKey] !== 'undefined') {
bg = kvMonitorsDaysMap[dayInHistoryKey] ? 'green' : 'orange'
dayInHistoryStatus = kvMonitorsDaysMap[dayInHistoryKey]
? 'No outages'
: 'Some outages'
}
return (
<div key={key} className="hitbox">
<div
className={`${bg} bar`}
data-tooltip={`${dayInHistory} - ${dayInHistoryStatus}`}
/>
</div>
)
})}
</div>
)
} else {
return (
<div
key={`${monitor.id}-histogram`}
className="horizontal flex histogram"
>
<div className="grey-text">Loading histogram ...</div>
</div>
)
}
}

View File

@ -0,0 +1,16 @@
export default function MonitorStatusLabel({ kvMonitorsMap, monitor }) {
let labelColor = 'grey'
let labelText = 'No data'
if (typeof kvMonitorsMap[monitor.id] !== 'undefined') {
if (kvMonitorsMap[monitor.id].operational) {
labelColor = 'green'
labelText = 'Operational'
} else {
labelColor = 'orange'
labelText = 'Not great not terrible'
}
}
return <div className={`ui ${labelColor} horizontal label`}>{labelText}</div>
}

66
src/css/index.css Normal file
View File

@ -0,0 +1,66 @@
body {
background: #eeeeee;
}
.flex {
display: flex;
justify-content: center;
align-content: center;
align-items: center;
}
.flex.horizontal {
flex-direction: row;
}
.flex.vertical {
flex-direction: column;
}
.flex.between {
justify-content: space-between;
}
.marginless {
margin: 0 !important;
}
.paddingless {
padding: 0 !important;
}
.black-text {
color: #000 !important;
}
.grey-text {
color: #a0a0a0 !important;
}
.white-text {
color: #fff !important;
}
.histogram {
height: 24px;
width: 100%;
margin: 0 auto;
}
.hitbox {
align-items: flex-end;
box-sizing: border-box;
height: 100%;
width: 100%;
padding: 1px;
border-radius: 3.75px;
}
.bar {
background: #dcddde;
padding-bottom: 1px;
height: 100%;
width: 85%;
border-radius: 100px;
}
.bar.green {
background: #21ba45;
}
.bar.red {
background: #db2828;
}
.bar.orange {
background: #f2711c;
}
span i.icon {
margin: 0 !important;
}

View File

@ -0,0 +1,57 @@
import config from '../../config.yaml'
import { setKV, getKV, getKVWithMetadata, gcMonitors } from './helpers'
export async function processCronTrigger(event) {
for (const monitor of config.monitors) {
console.log(`Checking ${monitor.name} ...`)
const init = {
method: monitor.method || 'GET',
redirect: monitor.followRedirect ? 'follow' : 'manual',
headers: {
'User-Agent': 'cf-worker-status-page',
},
}
const response = await fetch(monitor.url, init)
const monitorOperational = response.status === (monitor.expectStatus || 200)
const kvMonitor = await getKVWithMetadata('s_' + monitor.id)
// metadata from monitor settings
const metadata = {
operational: monitorOperational,
statusCode: response.status,
id: monitor.id,
}
// write current status if status changed or for first time
if (
!kvMonitor.metadata ||
kvMonitor.metadata.operational !== monitorOperational
) {
console.log('saving new results..')
if (typeof SECRET_SLACK_WEBHOOK !== 'undefined') {
await notifySlack(metadata)
}
await setKV('s_' + monitor.id, null, metadata)
}
// check day status, write only on not operational or for first time
const kvDayStatusKey =
'h_' + monitor.id + '_' + new Date().toISOString().split('T')[0]
//console.log(kvDayStatusKey)
const kvDayStatus = await getKV(kvDayStatusKey)
if (!kvDayStatus || (kvDayStatus && !monitorOperational)) {
await setKV(kvDayStatusKey, null, metadata)
}
await setKV('lastUpdate', Date.now())
}
await gcMonitors(config)
return new Response('OK')
}

89
src/functions/helpers.js Normal file
View File

@ -0,0 +1,89 @@
export async function getMonitors() {
const monitors = await listKV('s_')
return monitors.keys
}
export async function getMonitorsHistory() {
const monitorsHistory = await listKV('h_', 600)
return monitorsHistory.keys
}
export async function getLastUpdate() {
return await getKV('lastUpdate')
}
export async function listKV(prefix = '', cacheTtl = false) {
const cacheKey = 'list_' + prefix + '_' + process.env.BUILD_ID
const cachedResponse = await getKV(cacheKey)
if (cacheTtl && cachedResponse) {
return JSON.parse(cachedResponse)
}
let list = []
let cursor = null
let res = {}
do {
res = await KV_STATUS_PAGE.list({ prefix: prefix, cursor })
list = list.concat(res.keys)
cursor = res.cursor
} while (!res.list_complete)
if (cacheTtl) {
await setKV(cacheKey, JSON.stringify({ keys: list }), null, 600)
}
return { keys: list }
}
export async function setKV(key, value, metadata, expirationTtl) {
return KV_STATUS_PAGE.put(key, value, { metadata, expirationTtl })
}
export async function getKV(key, type = 'text') {
return KV_STATUS_PAGE.get(key, type)
}
export async function getKVWithMetadata(key) {
return KV_STATUS_PAGE.getWithMetadata(key)
}
export async function deleteKV(key) {
return KV_STATUS_PAGE.delete(key)
}
export async function gcMonitors(config) {
const checkKvPrefix = 's_'
const monitors = config.monitors.map(key => {
return key.id
})
const kvMonitors = await listKV(checkKvPrefix)
const kvState = kvMonitors.keys.map(key => {
return key.metadata.id
})
const keysForRemoval = kvState.filter(x => !monitors.includes(x))
keysForRemoval.forEach(key => {
console.log('gc: deleting ' + checkKvPrefix + key)
deleteKV(checkKvPrefix + key)
})
}
async function notifySlack(monitor, metadata) {
const blocks = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `Some monitor is now in :this: status`,
},
},
]
return fetch(SECRET_SLACK_WEBHOOK_URL, {
body: JSON.stringify({ blocks }),
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
}

23
wrangler.toml Normal file
View File

@ -0,0 +1,23 @@
name = "cf-workers-status-page"
type = "webpack"
account_id = ""
workers_dev = true
route = ""
zone_id = ""
webpack_config = "node_modules/flareact/webpack"
# uncomment and adjust following if you are not using GitHub Actions
# kv_namespaces = [{binding="KV_GITHUB_RELEASES", id="xxxx"}]
# preview_id = "9581809385634861ae93b0e01677b44d"
# delete afterwards
kv-namespaces = [
{ binding = "KV_STATUS_PAGE", id = "c27344947ebb476880fa2ba0ef9bbd10", preview_id = "c27344947ebb476880fa2ba0ef9bbd10" }
]
[triggers]
crons = ["* * * * *"]
[site]
bucket = "out"
entry-point = "./"