1
0
mirror of https://github.com/tormachris/cf-workers-status-page.git synced 2024-11-23 22:45:43 +01:00

Merge pull request #19 from eidam/develop

v1.1
This commit is contained in:
Adam Janiš 2020-11-22 15:44:20 +01:00 committed by GitHub
commit a89a3737e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 2299 additions and 480 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 450 KiB

View File

@ -28,11 +28,18 @@ jobs:
echo "[env.production]" >> wrangler.toml echo "[env.production]" >> wrangler.toml
echo "kv_namespaces = [{binding=\"KV_STATUS_PAGE\", id=\"${KV_NAMESPACE_ID}\"}]" >> wrangler.toml echo "kv_namespaces = [{binding=\"KV_STATUS_PAGE\", id=\"${KV_NAMESPACE_ID}\"}]" >> wrangler.toml
[ -z "$SECRET_SLACK_WEBHOOK_URL" ] && echo "Secret SECRET_SLACK_WEBHOOK_URL not set, creating dummy one..." && SECRET_SLACK_WEBHOOK_URL="default-gh-action-secret" || true [ -z "$SECRET_SLACK_WEBHOOK_URL" ] && echo "Secret SECRET_SLACK_WEBHOOK_URL not set, creating dummy one..." && SECRET_SLACK_WEBHOOK_URL="default-gh-action-secret" || true
[ -z "$SECRET_TELEGRAM_API_TOKEN" ] && echo "Secret SECRET_TELEGRAM_API_TOKEN not set, creating dummy one..." && SECRET_TELEGRAM_API_TOKEN="default-gh-action-secret" || true
[ -z "$SECRET_TELEGRAM_CHAT_ID" ] && echo "Secret SECRET_TELEGRAM_CHAT_ID not set, creating dummy one..." && SECRET_TELEGRAM_CHAT_ID="default-gh-action-secret" || true
postCommands: | postCommands: |
yarn kv-gc yarn kv-gc
secrets: | secrets: |
SECRET_SLACK_WEBHOOK_URL SECRET_SLACK_WEBHOOK_URL
SECRET_TELEGRAM_API_TOKEN
SECRET_TELEGRAM_CHAT_ID
environment: production environment: production
env: env:
NODE_ENV: production
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
SECRET_SLACK_WEBHOOK_URL: ${{secrets.SECRET_SLACK_WEBHOOK_URL}} SECRET_SLACK_WEBHOOK_URL: ${{secrets.SECRET_SLACK_WEBHOOK_URL}}
SECRET_TELEGRAM_API_TOKEN: ${{secrets.SECRET_TELEGRAM_API_TOKEN}}
SECRET_TELEGRAM_CHAT_ID: ${{secrets.SECRET_TELEGRAM_CHAT_ID}}

1
.gitignore vendored
View File

@ -133,3 +133,4 @@ worker/
.direnv/ .direnv/
out/ out/
package-lock.json package-lock.json
public/style.css

3
.prettierignore Normal file
View File

@ -0,0 +1,3 @@
# Ignore generated files
out
public

View File

@ -14,22 +14,22 @@ appearance, race, religion, or sexual identity and orientation.
Examples of behavior that contributes to creating a positive environment Examples of behavior that contributes to creating a positive environment
include: include:
* Using welcoming and inclusive language - Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences - Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism - Gracefully accepting constructive criticism
* Focusing on what is best for the community - Focusing on what is best for the community
* Showing empathy towards other community members - Showing empathy towards other community members
Examples of unacceptable behavior by participants include: Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or - The use of sexualized language or imagery and unwelcome sexual attention or
advances advances
* Trolling, insulting/derogatory comments, and personal or political attacks - Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment - Public or private harassment
* Publishing others' private information, such as a physical or electronic - Publishing others' private information, such as a physical or electronic
address, without explicit permission address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a - Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
## Our Responsibilities ## Our Responsibilities

View File

@ -10,15 +10,15 @@ Monitor your websites, showcase status including daily history, and get Slack no
You'll need a [Cloudflare Workers account](https://dash.cloudflare.com/sign-up/workers) with You'll need a [Cloudflare Workers account](https://dash.cloudflare.com/sign-up/workers) with
* A workers domain set up - A workers domain set up
* The Workers Bundled subscription \($5/mo\) - The Workers Bundled subscription \($5/mo\)
* [It works with Workers Free!](https://blog.cloudflare.com/workers-kv-free-tier/) Check [more info](#workers-kv-free-tier) on how to run on Workers Free. - [It works with Workers Free!](https://blog.cloudflare.com/workers-kv-free-tier/) Check [more info](#workers-kv-free-tier) on how to run on Workers Free.
* Some websites/APIs to watch 🙂 - Some websites/APIs to watch 🙂
Also, prepare the following secrets Also, prepare the following secrets
* Cloudflare API token with `Edit Cloudflare Workers` permissions - Cloudflare API token with `Edit Cloudflare Workers` permissions
* Slack incoming webhook \(optional\) - Slack incoming webhook \(optional\)
## Getting started ## Getting started
@ -39,6 +39,7 @@ You can either deploy with **Cloudflare Deploy Button** using GitHub Actions or
- Name: SECRET_SLACK_WEBHOOK_URL (optional) - Name: SECRET_SLACK_WEBHOOK_URL (optional)
- Value: your-slack-webhook-url - Value: your-slack-webhook-url
``` ```
3. Navigate to the **Actions** settings in your repository and enable them 3. Navigate to the **Actions** settings in your repository and enable them
4. Edit [config.yaml](./config.yaml) to adjust configuration and list all of your websites/APIs you want to monitor 4. Edit [config.yaml](./config.yaml) to adjust configuration and list all of your websites/APIs you want to monitor
@ -48,6 +49,7 @@ You can either deploy with **Cloudflare Deploy Button** using GitHub Actions or
url: 'https://status-page.eidam.dev' # used for Slack messages url: 'https://status-page.eidam.dev' # used for Slack messages
logo: logo-192x192.png # image in ./public/ folder logo: logo-192x192.png # image in ./public/ folder
daysInHistogram: 90 # number of days you want to display in histogram daysInHistogram: 90 # number of days you want to display in histogram
collectResponseTimes: false # experimental feature, enable only for <5 monitors or on paid plans
# configurable texts across the status page # configurable texts across the status page
allmonitorsOperational: 'All Systems Operational' allmonitorsOperational: 'All Systems Operational'
@ -73,32 +75,46 @@ You can either deploy with **Cloudflare Deploy Button** using GitHub Actions or
5. Push to `main` branch to trigger the deployment 5. Push to `main` branch to trigger the deployment
6. 🎉 6. 🎉
7. _\(optional\)_ Go to [Cloudflare Workers settings](https://dash.cloudflare.com/?to=/workers) and assign custom domain/route 7. _\(optional\)_ Go to [Cloudflare Workers settings](https://dash.cloudflare.com/?to=/workers) and assign custom domain/route
* e.g. `status-page.eidam.dev/*` _\(make sure you include `/*` as the Worker also serve static files\)_ - e.g. `status-page.eidam.dev/*` _\(make sure you include `/*` as the Worker also serve static files\)_
8. _\(optional\)_ Edit [wrangler.toml](./wrangler.toml) to adjust Worker settings or CRON Trigger schedule, especially if you are on [Workers Free plan](#workers-kv-free-tier) 8. _\(optional\)_ Edit [wrangler.toml](./wrangler.toml) to adjust Worker settings or CRON Trigger schedule, especially if you are on [Workers Free plan](#workers-kv-free-tier)
### Telegram notifications
To enable telegram notifications, you'll need to take a few additional steps.
1. [Create a new Bot](https://core.telegram.org/bots#creating-a-new-bot)
2. Set the api token you received when creating the bot as content of the `SECRET_TELEGRAM_API_TOKEN` secret in your github repository.
3. Send a message to the bot from the telegram account which should receive the alerts (Something more than `/start`)
4. Get the chat id with `curl https://api.telegram.org/bot<YOUR TELEGRAM API TOKEN>/getUpdates | jq '.result[0] .message .chat .id'`
5. Set the retrieved chat id in the `SECRET_TELEGRAM_CHAT_ID` secret variable
6. Redeploy the status page using the github action
### Deploy on your own ### Deploy on your own
You can clone the repository yourself and use Wrangler CLI to develop/deploy, extra list of things you need to take care of: You can clone the repository yourself and use Wrangler CLI to develop/deploy, extra list of things you need to take care of:
* create KV namespace and add the `KV_STATUS_PAGE` binding to [wrangler.toml](./wrangler.toml) - create KV namespace and add the `KV_STATUS_PAGE` binding to [wrangler.toml](./wrangler.toml)
* create Worker secrets _\(optional\)_ - create Worker secrets _\(optional\)_
* `SECRET_SLACK_WEBHOOK_URL` - `SECRET_SLACK_WEBHOOK_URL`
## Workers KV free tier ## Workers KV free tier
The Workers Free plan includes limited KV usage, but the quota is sufficient for 2-minute checks only
* Change the CRON trigger to 2 minutes interval (`crons = ["*/2 * * * *"]`) in [wrangler.toml](./wrangler.toml) The Workers Free plan includes limited KV usage, but the quota is sufficient for 2-minute checks only
- Change the CRON trigger to 2 minutes interval (`crons = ["*/2 * * * *"]`) in [wrangler.toml](./wrangler.toml)
## Known issues ## Known issues
* **Max 25 monitors to watch in case you are using Slack notifications**, due to the limit of subrequests Cloudflare Worker can make \(50\). - **Max 25 monitors to watch in case you are using Slack notifications**, due to the limit of subrequests Cloudflare Worker can make \(50\).
The plan is to support up to 49 by sending only one Slack notification per scheduled run. The plan is to support up to 49 by sending only one Slack notification per scheduled run.
* **KV replication lag** - You might get Slack notification instantly, however it may take couple of more seconds to see the change on your status page as [Cron Triggers are usually running on underutilized quiet hours machines](https://blog.cloudflare.com/introducing-cron-triggers-for-cloudflare-workers/#how-are-you-able-to-offer-this-feature-at-no-additional-cost). - **KV replication lag** - You might get Slack notification instantly, however it may take couple of more seconds to see the change on your status page as [Cron Triggers are usually running on underutilized quiet hours machines](https://blog.cloudflare.com/introducing-cron-triggers-for-cloudflare-workers/#how-are-you-able-to-offer-this-feature-at-no-additional-cost).
* **Initial delay (no data)** - It takes couple of minutes to schedule and run CRON Triggers for the first time - **Initial delay (no data)** - It takes couple of minutes to schedule and run CRON Triggers for the first time
## Future plans ## Future plans
Stay tuned for more features coming in, like leveraging the fact that CRON instances are scheduled around the world during the day Stay tuned for more features coming in, like leveraging the fact that CRON instances are scheduled around the world during the day
so we can monitor the response times. However, we will most probably wait for the [Durable Objects](https://blog.cloudflare.com/introducing-workers-durable-objects/) to be in open beta so we can monitor the response times. However, we will most probably wait for the [Durable Objects](https://blog.cloudflare.com/introducing-workers-durable-objects/) to be in open beta
as they are better fit to reliably store such info. as they are better fit to reliably store such info.

View File

@ -1,4 +1,3 @@
# Table of contents # Table of contents
* [Cloudflare Worker - Status Page](README.md) - [Cloudflare Worker - Status Page](README.md)

View File

@ -3,6 +3,7 @@ settings:
url: 'https://status-page.eidam.dev' # used for Slack messages url: 'https://status-page.eidam.dev' # used for Slack messages
logo: logo-192x192.png # image in ./public/ folder logo: logo-192x192.png # image in ./public/ folder
daysInHistogram: 90 # number of days you want to display in histogram daysInHistogram: 90 # number of days you want to display in histogram
collectResponseTimes: false # experimental feature, enable only for <5 monitors or on paid plans
allmonitorsOperational: 'All Systems Operational' allmonitorsOperational: 'All Systems Operational'
notAllmonitorsOperational: 'Not All Systems Operational' notAllmonitorsOperational: 'Not All Systems Operational'
@ -11,7 +12,7 @@ settings:
monitorLabelNoData: 'No data' monitorLabelNoData: 'No data'
dayInHistogramNoData: 'No data' dayInHistogramNoData: 'No data'
dayInHistogramOperational: 'All good' dayInHistogramOperational: 'All good'
dayInHistogramNotOperational: 'Some checks failed' dayInHistogramNotOperational: ' incident(s)' # xx incident(s) recorded
monitors: monitors:
- id: workers-cloudflare-com # unique identifier - id: workers-cloudflare-com # unique identifier

View File

@ -10,7 +10,7 @@ import { processCronTrigger } from './src/functions/cronTrigger'
*/ */
const DEBUG = false const DEBUG = false
addEventListener('fetch', event => { addEventListener('fetch', (event) => {
try { try {
event.respondWith( event.respondWith(
handleEvent(event, require.context('./pages/', true, /\.js$/), DEBUG), handleEvent(event, require.context('./pages/', true, /\.js$/), DEBUG),
@ -27,6 +27,6 @@ addEventListener('fetch', event => {
} }
}) })
addEventListener('scheduled', event => { addEventListener('scheduled', (event) => {
event.waitUntil(processCronTrigger(event)) event.waitUntil(processCronTrigger(event))
}) })

View File

@ -7,21 +7,29 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "flareact dev", "dev": "flareact dev",
"build": "flareact build", "build": "yarn css && flareact build",
"deploy": "flareact publish", "deploy": "yarn build && flareact publish",
"kv-gc": "node ./src/cli/gcMonitors.js", "kv-gc": "node ./src/cli/gcMonitors.js",
"format": "prettier --write '**/*.{js,css,json,md}'" "format": "prettier --write '**/*.{js,css,json,md}'",
"css": "postcss public/tailwind.css -o public/style.css",
"postinstall": "patch-package"
}, },
"dependencies": { "dependencies": {
"flareact": "^0.9.0", "flareact": "0.9.0",
"laco": "^1.2.1", "laco": "^1.2.1",
"laco-react": "^1.1.0", "laco-react": "^1.1.0",
"react": "^16.13.1", "react": "^17.0.1",
"react-dom": "^16.13.1" "react-dom": "^17.0.1"
}, },
"devDependencies": { "devDependencies": {
"autoprefixer": "^10.0.2",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"prettier": "^1.18.2", "postcss": "^8.1.8",
"yaml-loader": "^0.6.0" "postcss-cli": "^8.3.0",
"prettier": "^2.2.0",
"tailwindcss": "^2.0.1",
"yaml-loader": "^0.6.0",
"patch-package": "^6.2.2",
"postinstall-postinstall": "^2.1.0"
} }
} }

View File

@ -1,6 +1,6 @@
import { processCronTrigger } from '../../src/functions/cronTrigger' import { processCronTrigger } from '../../src/functions/cronTrigger'
export default async event => { export default async (event) => {
// used only for local debugging // used only for local debugging
//return processCronTrigger(event) //return processCronTrigger(event)
} }

View File

@ -1,115 +1,94 @@
import Head from 'flareact/head'
import MonitorHistogram from '../src/components/monitorHistogram'
import {
getMonitors,
useKeyPress,
} from '../src/functions/helpers'
import config from '../config.yaml'
import MonitorStatusLabel from '../src/components/monitorStatusLabel'
import MonitorStatusHeader from '../src/components/monitorStatusHeader'
import MonitorFilter from '../src/components/monitorFilter'
import { Store } from 'laco' import { Store } from 'laco'
import { useStore } from 'laco-react' import { useStore } from 'laco-react'
import Head from 'flareact/head'
const MonitorStore = new Store( import { getKVMonitors, useKeyPress } from '../src/functions/helpers'
{ import config from '../config.yaml'
monitors: config.monitors, import MonitorCard from '../src/components/monitorCard'
visible: config.monitors, import MonitorFilter from '../src/components/monitorFilter'
activeFilter: false import MonitorStatusHeader from '../src/components/monitorStatusHeader'
} import ThemeSwitcher from '../src/components/themeSwitcher'
)
const filterByTerm = (term) => MonitorStore.set( const MonitorStore = new Store({
state => ({ visible: state.monitors.filter((monitor) => monitor.name.toLowerCase().includes(term)) }) monitors: config.monitors,
) visible: config.monitors,
activeFilter: false,
})
const filterByTerm = (term) =>
MonitorStore.set((state) => ({
visible: state.monitors.filter((monitor) =>
monitor.name.toLowerCase().includes(term),
),
}))
export async function getEdgeProps() { export async function getEdgeProps() {
// get KV data // get KV data
const {value: kvMonitors, metadata: kvMonitorsMetadata } = await getMonitors() const kvMonitors = await getKVMonitors()
return { return {
props: { props: {
config, config,
kvMonitors: kvMonitors || {}, kvMonitors: kvMonitors ? kvMonitors.monitors : {},
kvMonitorsMetadata: kvMonitorsMetadata || {} kvMonitorsLastUpdate: kvMonitors ? kvMonitors.lastUpdate : {},
}, },
// Revalidate these props once every x seconds // Revalidate these props once every x seconds
revalidate: 5, revalidate: 5,
} }
} }
export default function Index({ export default function Index({ config, kvMonitors, kvMonitorsLastUpdate }) {
config,
kvMonitors,
kvMonitorsMetadata,
}) {
const state = useStore(MonitorStore) const state = useStore(MonitorStore)
const slash = useKeyPress('/') const slash = useKeyPress('/')
return ( return (
<div> <div className="min-h-screen">
<Head> <Head>
<title>{config.settings.title}</title> <title>{config.settings.title}</title>
<link <link rel="stylesheet" href="./style.css" />
rel="stylesheet" <script>
href="https://cdnjs.cloudflare.com/ajax/libs/fomantic-ui/2.8.7/semantic.min.css" {`
crossOrigin="anonymous" function setTheme(theme) {
/> document.documentElement.classList.remove("dark", "light")
<link rel="stylesheet" href="./main.css" /> document.documentElement.classList.add(theme)
localStorage.theme = theme
}
(() => {
const query = window.matchMedia("(prefers-color-scheme: dark)")
query.addListener(() => {
setTheme(query.matches ? "dark" : "light")
})
if (["dark", "light"].includes(localStorage.theme)) {
setTheme(localStorage.theme)
} else {
setTheme(query.matches ? "dark" : "light")
}
})()
`}
</script>
</Head> </Head>
<div className="ui basic segment container"> <div className="container mx-auto px-4">
<div className="horizontal flex between"> <div className="flex flex-row justify-between items-center p-4">
<h1 className="ui huge marginless title header"> <div className="flex flex-row items-center">
<img <img className="h-8 w-auto" src={config.settings.logo} />
className="ui middle aligned tiny image" <h1 className="ml-4 text-3xl">{config.settings.title}</h1>
src={config.settings.logo} </div>
/> <div className="flex flex-row items-center">
{config.settings.title} {typeof window !== 'undefined' && <ThemeSwitcher />}
</h1> <MonitorFilter active={slash} callback={filterByTerm} />
<MonitorFilter </div>
active={slash}
callback={filterByTerm}
/>
</div> </div>
<MonitorStatusHeader <MonitorStatusHeader kvMonitorsLastUpdate={kvMonitorsLastUpdate} />
kvMonitorsMetadata={kvMonitorsMetadata}
/>
{state.visible.map((monitor, key) => { {state.visible.map((monitor, key) => {
return ( return (
<div key={key} className="ui segment"> <MonitorCard
<div key={key}
className="ui horizontal flex between" monitor={monitor}
style={{ marginBottom: '8px' }} data={kvMonitors[monitor.id]}
> />
<div className="ui marginless header">
{monitor.description && (
<span data-tooltip={monitor.description}>
<i className="blue small info circle icon" />
</span>
)}
<div className="content">{monitor.name}</div>
</div>
<MonitorStatusLabel
kvMonitor={kvMonitors[monitor.id]}
/>
</div>
<MonitorHistogram
monitorId={monitor.id}
kvMonitor={kvMonitors[monitor.id]}
/>
<div className="horizontal flex between grey-text">
<div>{config.settings.daysInHistogram} days ago</div>
<div>Today</div>
</div>
</div>
) )
})} })}
<div className="horizontal flex between grey-text"> <div className="flex flex-row justify-between mt-4 text-sm">
<div> <div>
Powered by{' '} Powered by{' '}
<a href="https://workers.cloudflare.com/" target="_blank"> <a href="https://workers.cloudflare.com/" target="_blank">

View File

@ -0,0 +1,12 @@
diff --git a/node_modules/flareact/src/components/_document.js b/node_modules/flareact/src/components/_document.js
index 3494b60..206b493 100644
--- a/node_modules/flareact/src/components/_document.js
+++ b/node_modules/flareact/src/components/_document.js
@@ -61,6 +61,7 @@ export function FlareactHead({ helmet, page, buildManifest }) {
{helmet.title.toComponent()}
{helmet.meta.toComponent()}
{helmet.link.toComponent()}
+ {helmet.script.toComponent()}
{[...links].map((link) => (
<link href={`/_flareact/static/${link}`} rel="stylesheet" />

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,68 +0,0 @@
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 .25em .25em 0 !important;
}
.ui.title.header .ui.image {
margin-top: -.5em !important;
}

68
public/tailwind.css Normal file
View File

@ -0,0 +1,68 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-50;
}
a {
@apply text-blue-500 dark:text-blue-400;
}
}
@layer components {
.card {
@apply p-4 bg-white border border-gray-200 dark:bg-gray-700 dark:border-gray-600 shadow rounded-lg p-4 mb-2;
}
.pill {
@apply px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full;
}
.histogram {
@apply h-6 w-full mx-auto;
}
.hitbox {
align-items: flex-end;
box-sizing: border-box;
height: 100%;
width: 100%;
padding: 1px;
border-radius: 3.75px;
}
.bar {
@apply bg-gray-300 dark:bg-gray-600;
padding-bottom: 1px;
height: 100%;
width: 85%;
border-radius: 100px;
}
.bar.green {
@apply bg-green-400 dark:bg-green-700;
}
.bar.red {
@apply bg-red-400 dark:bg-red-700;
}
.bar.yellow {
@apply bg-yellow-400 dark:bg-yellow-700;
}
.tooltip {
@apply relative;
}
.tooltip .content {
@apply invisible absolute z-50 inline-block;
@apply rounded-lg py-1 px-2 bg-gray-100 dark:bg-gray-800 shadow;
@apply opacity-0 transition-all duration-200 scale-50;
}
.tooltip:hover .content {
@apply visible opacity-100 scale-100;
}
}

View File

@ -9,7 +9,9 @@ const apiToken = process.env.CF_API_TOKEN
const kvPrefix = 's_' const kvPrefix = 's_'
if (!accountId || !namespaceId || !apiToken) { if (!accountId || !namespaceId || !apiToken) {
console.error("Missing required environment variables: CF_ACCOUNT_ID, KV_NAMESPACE_ID, CF_API_TOKEN") console.error(
'Missing required environment variables: CF_ACCOUNT_ID, KV_NAMESPACE_ID, CF_API_TOKEN',
)
process.exit(0) process.exit(0)
} }
@ -51,24 +53,26 @@ function loadConfig() {
return JSON.parse(config) return JSON.parse(config)
} }
getKvMonitors(kvPrefix).then(async kvMonitors => { getKvMonitors(kvPrefix)
const config = loadConfig() .then(async (kvMonitors) => {
const monitors = config.monitors.map(key => { const config = loadConfig()
return key.id const monitors = config.monitors.map((key) => {
}) return key.id
const kvState = kvMonitors.map(key => { })
return key.name const kvState = kvMonitors.map((key) => {
}) return key.name
const keysForRemoval = kvState.filter( })
x => !monitors.includes(x.replace(kvPrefix, '')), const keysForRemoval = kvState.filter(
) (x) => !monitors.includes(x.replace(kvPrefix, '')),
if (keysForRemoval.length > 0) {
console.log(
`Removing following keys from KV storage as they are no longer in the config: ${keysForRemoval.join(
', ',
)}`,
) )
await deleteKvBulk(keysForRemoval)
} if (keysForRemoval.length > 0) {
}).catch(e => console.log(e)) console.log(
`Removing following keys from KV storage as they are no longer in the config: ${keysForRemoval.join(
', ',
)}`,
)
await deleteKvBulk(keysForRemoval)
}
})
.catch((e) => console.log(e))

View File

@ -0,0 +1,46 @@
import config from '../../config.yaml'
import MonitorStatusLabel from './monitorStatusLabel'
import MonitorHistogram from './monitorHistogram'
const infoIcon = (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-5 mr-2 mx-auto text-blue-500 dark:text-blue-400"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
)
export default function MonitorCard({ key, monitor, data }) {
return (
<div key={key} className="card">
<div className="flex flex-row justify-between items-center mb-2">
<div className="flex flex-row items-center align-center">
{monitor.description && (
<div className="tooltip">
{infoIcon}
<div className="content text-center transform -translate-y-1/2 top-1/2 ml-8 w-72 text-sm object-left">
{monitor.description}
</div>
</div>
)}
<div className="text-xl">{monitor.name}</div>
</div>
<MonitorStatusLabel kvMonitor={data} />
</div>
<MonitorHistogram monitorId={monitor.id} kvMonitor={data} />
<div className="flex flex-row justify-between items-center text-gray-400 text-sm">
<div>{config.settings.daysInHistogram} days ago</div>
<div>Today</div>
</div>
</div>
)
}

View File

@ -1,6 +1,21 @@
import config from '../../config.yaml'
import { useState } from 'react' import { useState } from 'react'
const searchIcon = (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
className="h-7 mx-auto text-gray-300 dark:text-gray-600"
fill="currentColor"
>
<path d="M9 9a2 2 0 114 0 2 2 0 01-4 0z" />
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a4 4 0 00-3.446 6.032l-2.261 2.26a1 1 0 101.414 1.415l2.261-2.261A4 4 0 1011 5z"
clipRule="evenodd"
/>
</svg>
)
export default function MonitorFilter({ active, callback }) { export default function MonitorFilter({ active, callback }) {
const [input, setInput] = useState('') const [input, setInput] = useState('')
@ -21,21 +36,19 @@ export default function MonitorFilter({ active, callback }) {
} }
return ( return (
<div className="ui search"> <div className="col-span-6 sm:col-span-3 relative">
<div className="ui icon input"> <input
<input className="block w-full py-2 px-3 border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 rounded-full shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
className="prompt" type="text"
type="text" value={input}
value={input} onInput={handleInput}
onInput={handleInput} onKeyDown={handleKeyDown}
onKeyDown={handleKeyDown} placeholder="Tap '/' to search"
placeholder="Tap '/' to search" tabIndex={0}
tabIndex={0} ref={(e) => e && active && e.focus()}
ref={ />
(e) => e && active && e.focus() <div className="absolute inset-y-1 right-1 flex z-1 items-center">
} {searchIcon}
/>
<i className="search icon"></i>
</div> </div>
</div> </div>
) )

View File

@ -1,56 +1,67 @@
import config from '../../config.yaml' import config from '../../config.yaml'
export default function MonitorHistogram({ export default function MonitorHistogram({ monitorId, kvMonitor }) {
monitorId,
kvMonitor,
}) {
// create date and set date - daysInHistogram for the first day of the histogram // create date and set date - daysInHistogram for the first day of the histogram
let date = new Date() let date = new Date()
date.setDate(date.getDate() - config.settings.daysInHistogram) date.setDate(date.getDate() - config.settings.daysInHistogram)
let content = null
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return ( content = Array.from(Array(config.settings.daysInHistogram).keys()).map(
<div (key) => {
key={`${monitorId}-histogram`} date.setDate(date.getDate() + 1)
className="horizontal flex histogram" const dayInHistogram = date.toISOString().split('T')[0]
>
{Array.from(Array(config.settings.daysInHistogram).keys()).map(key => {
date.setDate(date.getDate() + 1)
const dayInHistogram = date.toISOString().split('T')[0]
let bg = '' let bg = ''
let dayInHistogramLabel = config.settings.dayInHistogramNoData let dayInHistogramLabel = config.settings.dayInHistogramNoData
// filter all dates before first check, check the rest // filter all dates before first check, then check the rest
if (kvMonitor && kvMonitor.firstCheck <= dayInHistogram) { if (kvMonitor && kvMonitor.firstCheck <= dayInHistogram) {
if (!kvMonitor.failedDays.includes(dayInHistogram)) { if (
bg = 'green' kvMonitor.checks.hasOwnProperty(dayInHistogram) &&
dayInHistogramLabel = config.settings.dayInHistogramOperational kvMonitor.checks[dayInHistogram].fails > 0
} else { ) {
bg = 'orange' bg = 'yellow'
dayInHistogramLabel = config.settings.dayInHistogramNotOperational dayInHistogramLabel = `${kvMonitor.checks[dayInHistogram].fails} ${config.settings.dayInHistogramNotOperational}`
} } else {
bg = 'green'
dayInHistogramLabel = config.settings.dayInHistogramOperational
} }
}
return ( return (
<div key={key} className="hitbox"> <div key={key} className="hitbox tooltip">
<div <div className={`${bg} bar`} />
className={`${bg} bar`} <div className="content text-center py-1 px-2 mt-2 left-1/2 -ml-20 w-40 text-xs">
data-tooltip={`${dayInHistogram} - ${dayInHistogramLabel}`} {dayInHistogram}
/> <br />
<span className="font-semibold text-sm">
{dayInHistogramLabel}
</span>
{kvMonitor &&
kvMonitor.checks.hasOwnProperty(dayInHistogram) &&
Object.keys(kvMonitor.checks[dayInHistogram].res).map((key) => {
return (
<>
<br />
{key}: {kvMonitor.checks[dayInHistogram].res[key].a}ms
</>
)
})}
</div> </div>
) </div>
})} )
</div> },
)
} else {
return (
<div
key={`${monitorId}-histogram`}
className="horizontal flex histogram"
>
<div className="grey-text">Loading histogram ...</div>
</div>
) )
} }
return (
<div
key={`${monitorId}-histogram`}
className="flex flex-row items-center histogram"
>
{content}
</div>
)
} }

View File

@ -1,28 +1,32 @@
import config from '../../config.yaml' import config from '../../config.yaml'
export default function MonitorStatusHeader({kvMonitorsMetadata}) { const classes = {
let backgroundColor = 'green' green:
let headerText = config.settings.allmonitorsOperational 'bg-green-200 text-green-700 dark:bg-green-700 dark:text-green-200 border-green-300 dark:border-green-600',
let textColor = 'black' yellow:
'bg-yellow-200 text-yellow-700 dark:bg-yellow-700 dark:text-yellow-200 border-yellow-300 dark:border-yellow-600',
}
if (!kvMonitorsMetadata.monitorsOperational) { export default function MonitorStatusHeader({ kvMonitorsLastUpdate }) {
backgroundColor = 'yellow' let color = 'green'
headerText = config.settings.notAllmonitorsOperational let text = config.settings.allmonitorsOperational
if (!kvMonitorsLastUpdate.allOperational) {
color = 'yellow'
text = config.settings.notAllmonitorsOperational
} }
return ( return (
<div className={`ui inverted segment ${backgroundColor}`}> <div className={`card mb-4 font-semibold ${classes[color]}`}>
<div className="horizontal flex between"> <div className="flex flex-row justify-between items-center">
<div className={`ui marginless header ${textColor}-text`}> <div>{text}</div>
{headerText} {kvMonitorsLastUpdate.time && typeof window !== 'undefined' && (
</div> <div className="text-xs font-light">
{ checked{' '}
kvMonitorsMetadata.lastUpdate && typeof window !== 'undefined' && ( {Math.round((Date.now() - kvMonitorsLastUpdate.time) / 1000)} sec
<div className={`${textColor}-text`}> ago (from {kvMonitorsLastUpdate.loc})
checked {Math.round((Date.now() - kvMonitorsMetadata.lastUpdate.time) / 1000)} sec ago (from {kvMonitorsMetadata.lastUpdate.loc})
</div> </div>
) )}
}
</div> </div>
</div> </div>
) )

View File

@ -1,18 +1,25 @@
import config from '../../config.yaml' import config from '../../config.yaml'
const classes = {
gray: 'bg-gray-200 text-gray-800 dark:bg-gray-800 dark:text-gray-200',
green: 'bg-green-200 text-green-800 dark:bg-green-800 dark:text-green-200',
yellow:
'bg-yellow-200 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-200',
}
export default function MonitorStatusLabel({ kvMonitor }) { export default function MonitorStatusLabel({ kvMonitor }) {
let labelColor = 'grey' let color = 'gray'
let labelText = 'No data' let text = 'No data'
if (typeof kvMonitor !== 'undefined') { if (typeof kvMonitor !== 'undefined') {
if (kvMonitor.operational) { if (kvMonitor.lastCheck.operational) {
labelColor = 'green' color = 'green'
labelText = config.settings.monitorLabelOperational text = config.settings.monitorLabelOperational
} else { } else {
labelColor = 'orange' color = 'yellow'
labelText = config.settings.monitorLabelNotOperational text = config.settings.monitorLabelNotOperational
} }
} }
return <div className={`ui ${labelColor} horizontal label`}>{labelText}</div> return <div className={`pill leading-5 ${classes[color]}`}>{text}</div>
} }

View File

@ -0,0 +1,58 @@
import { useEffect, useState } from 'react'
const moonIcon = (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
className="h-5 mx-auto"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
)
const sunIcon = (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
className="h-5 mx-auto"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
)
export default function ThemeSwitcher() {
const [darkmode, setDark] = useState(localStorage.getItem('theme') === 'dark')
useEffect(() => {
setTheme(darkmode ? 'dark' : 'light')
}, [darkmode])
const changeTheme = () => {
setDark(!darkmode)
}
const buttonColor = darkmode ? 'bg-gray-700' : 'bg-gray-200'
return (
<button
className={`${buttonColor} rounded-full h-7 w-7 mr-4`}
onClick={changeTheme}
>
{darkmode ? sunIcon : moonIcon}
</button>
)
}

View File

@ -1,9 +1,11 @@
import config from '../../config.yaml' import config from '../../config.yaml'
import { import {
setKV,
getKVWithMetadata,
notifySlack, notifySlack,
notifyTelegram,
getCheckLocation,
getKVMonitors,
setKVMonitors,
} from './helpers' } from './helpers'
function getDate() { function getDate() {
@ -11,24 +13,29 @@ function getDate() {
} }
export async function processCronTrigger(event) { export async function processCronTrigger(event) {
// Get Worker PoP and save it to monitorsStateMetadata
const checkLocation = await getCheckLocation()
const checkDay = getDate()
// Get monitors state from KV // Get monitors state from KV
let {value: monitorsState, metadata: monitorsStateMetadata} = await getKVWithMetadata('monitors_data', 'json') let monitorsState = await getKVMonitors()
// Create empty state objects if not exists in KV storage yet // Create empty state objects if not exists in KV storage yet
if (!monitorsState) { if (!monitorsState) {
monitorsState = {} monitorsState = { lastUpdate: {}, monitors: {} }
}
if (!monitorsStateMetadata) {
monitorsStateMetadata = {}
} }
// Reset default all monitors state to true // Reset default all monitors state to true
monitorsStateMetadata.monitorsOperational = true monitorsState.lastUpdate.allOperational = true
for (const monitor of config.monitors) { for (const monitor of config.monitors) {
// Create default monitor state if does not exist yet // Create default monitor state if does not exist yet
if (typeof monitorsState[monitor.id] === 'undefined') { if (typeof monitorsState.monitors[monitor.id] === 'undefined') {
monitorsState[monitor.id] = {failedDays: []} monitorsState.monitors[monitor.id] = {
firstCheck: checkDay,
lastCheck: {},
checks: {},
}
} }
console.log(`Checking ${monitor.name} ...`) console.log(`Checking ${monitor.name} ...`)
@ -42,41 +49,101 @@ export async function processCronTrigger(event) {
}, },
} }
// Perform a check and measure time
const requestStartTime = Date.now()
const checkResponse = await fetch(monitor.url, init) const checkResponse = await fetch(monitor.url, init)
const monitorOperational = checkResponse.status === (monitor.expectStatus || 200) const requestTime = Math.round(Date.now() - requestStartTime)
// Send Slack message on monitor change // Determine whether operational and status changed
if (monitorsState[monitor.id].operational !== monitorOperational && typeof SECRET_SLACK_WEBHOOK_URL !== 'undefined' && SECRET_SLACK_WEBHOOK_URL !== 'default-gh-action-secret') { const monitorOperational =
event.waitUntil(notifySlack(monitor, monitorOperational)) checkResponse.status === (monitor.expectStatus || 200)
const monitorStatusChanged =
monitorsState.monitors[monitor.id].lastCheck.operational !==
monitorOperational
// Save monitor's last check response status
monitorsState.monitors[monitor.id].lastCheck = {
status: checkResponse.status,
statusText: checkResponse.statusText,
operational: monitorOperational,
} }
monitorsState[monitor.id].operational = checkResponse.status === (monitor.expectStatus || 200) // Send Slack message on monitor change
monitorsState[monitor.id].firstCheck = monitorsState[monitor.id].firstCheck || getDate() if (
monitorStatusChanged &&
typeof SECRET_SLACK_WEBHOOK_URL !== 'undefined' &&
SECRET_SLACK_WEBHOOK_URL !== 'default-gh-action-secret'
) {
event.waitUntil(notifySlack(monitor, monitorOperational))
}
// Set monitorsOperational and push current day to failedDays // Send Telegram message on monitor change
if (!monitorOperational) { if (
monitorsStateMetadata.monitorsOperational = false monitorStatusChanged &&
typeof SECRET_TELEGRAM_API_TOKEN !== 'undefined' &&
SECRET_TELEGRAM_API_TOKEN !== 'default-gh-action-secret' &&
typeof SECRET_TELEGRAM_CHAT_ID !== 'undefined' &&
SECRET_TELEGRAM_CHAT_ID !== 'default-gh-action-secret'
) {
event.waitUntil(notifyTelegram(monitor, monitorOperational))
}
const failedDay = getDate() // make sure checkDay exists in checks in cases when needed
if (!monitorsState[monitor.id].failedDays.includes(failedDay)) { if (
console.log('Saving new failed daily status ...') (config.settings.collectResponseTimes || !monitorOperational) &&
monitorsState[monitor.id].failedDays.push(failedDay) !monitorsState.monitors[monitor.id].checks.hasOwnProperty(checkDay)
) {
monitorsState.monitors[monitor.id].checks[checkDay] = {
fails: 0,
res: {},
}
}
if (config.settings.collectResponseTimes && monitorOperational) {
// make sure location exists in current checkDay
if (
!monitorsState.monitors[monitor.id].checks[checkDay].res.hasOwnProperty(
checkLocation,
)
) {
monitorsState.monitors[monitor.id].checks[checkDay].res[
checkLocation
] = {
n: 0,
ms: 0,
a: 0,
}
}
// increment number of checks and sum of ms
const no = ++monitorsState.monitors[monitor.id].checks[checkDay].res[
checkLocation
].n
const ms = (monitorsState.monitors[monitor.id].checks[checkDay].res[
checkLocation
].ms += requestTime)
// save new average ms
monitorsState.monitors[monitor.id].checks[checkDay].res[
checkLocation
].a = Math.round(ms / no)
} else if (!monitorOperational) {
// Save allOperational to false
monitorsState.lastUpdate.allOperational = false
// Increment failed checks, only on status change (maybe call it .incidents instead?)
if (monitorStatusChanged) {
monitorsState.monitors[monitor.id].checks[checkDay].fails++
} }
} }
} }
// Get Worker PoP and save it to monitorsStateMetadata // Save last update information
const res = await fetch('https://cloudflare-dns.com/dns-query', { monitorsState.lastUpdate.time = Date.now()
method: 'OPTIONS', monitorsState.lastUpdate.loc = checkLocation
})
const loc = res.headers.get('cf-ray').split('-')[1]
monitorsStateMetadata.lastUpdate = {
loc,
time: Date.now()
}
// Save monitorsState and monitorsStateMetadata to KV storage // Save monitorsState to KV storage
await setKV('monitors_data', JSON.stringify(monitorsState), monitorsStateMetadata) await setKVMonitors(monitorsState)
return new Response('OK') return new Response('OK')
} }

View File

@ -1,18 +1,28 @@
import config from '../../config.yaml' import config from '../../config.yaml'
import {useEffect, useState} from 'react' import { useEffect, useState } from 'react'
export async function getMonitors() { const kvDataKey = 'monitors_data_v1_1'
return await getKVWithMetadata('monitors_data', "json")
export async function getKVMonitors() {
// trying both to see performance difference
return KV_STATUS_PAGE.get(kvDataKey, 'json')
//return JSON.parse(await KV_STATUS_PAGE.get(kvDataKey, 'text'))
}
export async function setKVMonitors(data) {
return setKV(kvDataKey, JSON.stringify(data))
}
const getOperationalLabel = (operational) => {
return operational
? config.settings.monitorLabelOperational
: config.settings.monitorLabelNotOperational
} }
export async function setKV(key, value, metadata, expirationTtl) { export async function setKV(key, value, metadata, expirationTtl) {
return KV_STATUS_PAGE.put(key, value, { metadata, expirationTtl }) return KV_STATUS_PAGE.put(key, value, { metadata, expirationTtl })
} }
export async function getKVWithMetadata(key, type = 'text') {
return KV_STATUS_PAGE.getWithMetadata(key, type)
}
export async function notifySlack(monitor, operational) { export async function notifySlack(monitor, operational) {
const payload = { const payload = {
attachments: [ attachments: [
@ -23,11 +33,9 @@ export async function notifySlack(monitor, operational) {
type: 'section', type: 'section',
text: { text: {
type: 'mrkdwn', type: 'mrkdwn',
text: `Monitor *${monitor.name}* changed status to *${ text: `Monitor *${
operational monitor.name
? config.settings.monitorLabelOperational }* changed status to *${getOperationalLabel(operational)}*`,
: config.settings.monitorLabelNotOperational
}*`,
}, },
}, },
{ {
@ -35,9 +43,9 @@ export async function notifySlack(monitor, operational) {
elements: [ elements: [
{ {
type: 'mrkdwn', type: 'mrkdwn',
text: `${ text: `${operational ? ':white_check_mark:' : ':x:'} \`${
operational ? ':white_check_mark:' : ':x:' monitor.method ? monitor.method : 'GET'
} \`${monitor.method ? monitor.method : "GET"} ${monitor.url}\` - :eyes: <${ } ${monitor.url}\` - :eyes: <${
config.settings.url config.settings.url
}|Status Page>`, }|Status Page>`,
}, },
@ -54,30 +62,58 @@ export async function notifySlack(monitor, operational) {
}) })
} }
export async function notifyTelegram(monitor, operational) {
const text = `Monitor *${monitor.name.replace(
'-',
'\\-',
)}* changed status to *${getOperationalLabel(operational)}*
${operational ? '✅' : '❌'} \`${monitor.method ? monitor.method : 'GET'} ${
monitor.url
}\` \\- 👀 [Status Page](${config.settings.url})`
const payload = new FormData()
payload.append('chat_id', SECRET_TELEGRAM_CHAT_ID)
payload.append('parse_mode', 'MarkdownV2')
payload.append('text', text)
const telegramUrl = `https://api.telegram.org/bot${SECRET_TELEGRAM_API_TOKEN}/sendMessage`
return fetch(telegramUrl, {
body: payload,
method: 'POST',
})
}
export function useKeyPress(targetKey) { export function useKeyPress(targetKey) {
const [keyPressed, setKeyPressed] = useState(false) const [keyPressed, setKeyPressed] = useState(false)
function downHandler({ key }) { function downHandler({ key }) {
if (key === targetKey) { if (key === targetKey) {
setKeyPressed(true); setKeyPressed(true)
} }
} }
const upHandler = ({ key }) => { const upHandler = ({ key }) => {
if (key === targetKey) { if (key === targetKey) {
setKeyPressed(false); setKeyPressed(false)
} }
} }
useEffect(() => { useEffect(() => {
window.addEventListener('keydown', downHandler); window.addEventListener('keydown', downHandler)
window.addEventListener('keyup', upHandler); window.addEventListener('keyup', upHandler)
return () => { return () => {
window.removeEventListener('keydown', downHandler); window.removeEventListener('keydown', downHandler)
window.removeEventListener('keyup', upHandler); window.removeEventListener('keyup', upHandler)
}; }
}, []) }, [])
return keyPressed return keyPressed
} }
export async function getCheckLocation() {
const res = await fetch('https://cloudflare-dns.com/dns-query', {
method: 'OPTIONS',
})
return res.headers.get('cf-ray').split('-')[1]
}

918
tailwind.config.js Normal file
View File

@ -0,0 +1,918 @@
const colors = require('tailwindcss/colors')
module.exports = {
purge: {
content: ['./src/**/*.js', './pages/**/*.js', './public/tailwind.css'],
},
presets: [],
darkMode: 'class', // or 'media' or 'class'
theme: {
screens: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
},
colors: {
transparent: 'transparent',
current: 'currentColor',
black: colors.black,
white: colors.white,
gray: colors.coolGray,
red: colors.red,
yellow: colors.yellow,
green: colors.green,
blue: colors.lightBlue,
indigo: colors.indigo,
purple: colors.violet,
pink: colors.pink,
},
spacing: {
px: '1px',
0: '0px',
0.5: '0.125rem',
1: '0.25rem',
1.5: '0.375rem',
2: '0.5rem',
2.5: '0.625rem',
3: '0.75rem',
3.5: '0.875rem',
4: '1rem',
5: '1.25rem',
6: '1.5rem',
7: '1.75rem',
8: '2rem',
9: '2.25rem',
10: '2.5rem',
11: '2.75rem',
12: '3rem',
14: '3.5rem',
16: '4rem',
20: '5rem',
24: '6rem',
28: '7rem',
32: '8rem',
36: '9rem',
40: '10rem',
44: '11rem',
48: '12rem',
52: '13rem',
56: '14rem',
60: '15rem',
64: '16rem',
72: '18rem',
80: '20rem',
96: '24rem',
},
animation: {
none: 'none',
spin: 'spin 1s linear infinite',
ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite',
pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
bounce: 'bounce 1s infinite',
},
backgroundColor: (theme) => theme('colors'),
backgroundImage: {
none: 'none',
'gradient-to-t': 'linear-gradient(to top, var(--tw-gradient-stops))',
'gradient-to-tr':
'linear-gradient(to top right, var(--tw-gradient-stops))',
'gradient-to-r': 'linear-gradient(to right, var(--tw-gradient-stops))',
'gradient-to-br':
'linear-gradient(to bottom right, var(--tw-gradient-stops))',
'gradient-to-b': 'linear-gradient(to bottom, var(--tw-gradient-stops))',
'gradient-to-bl':
'linear-gradient(to bottom left, var(--tw-gradient-stops))',
'gradient-to-l': 'linear-gradient(to left, var(--tw-gradient-stops))',
'gradient-to-tl':
'linear-gradient(to top left, var(--tw-gradient-stops))',
},
backgroundOpacity: (theme) => theme('opacity'),
backgroundPosition: {
bottom: 'bottom',
center: 'center',
left: 'left',
'left-bottom': 'left bottom',
'left-top': 'left top',
right: 'right',
'right-bottom': 'right bottom',
'right-top': 'right top',
top: 'top',
},
backgroundSize: {
auto: 'auto',
cover: 'cover',
contain: 'contain',
},
borderColor: (theme) => ({
...theme('colors'),
DEFAULT: theme('colors.gray.200', 'currentColor'),
}),
borderOpacity: (theme) => theme('opacity'),
borderRadius: {
none: '0px',
sm: '0.125rem',
DEFAULT: '0.25rem',
md: '0.375rem',
lg: '0.5rem',
xl: '0.75rem',
'2xl': '1rem',
'3xl': '1.5rem',
full: '9999px',
},
borderWidth: {
DEFAULT: '1px',
0: '0px',
2: '2px',
4: '4px',
8: '8px',
},
boxShadow: {
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
DEFAULT:
'0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
md:
'0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
lg:
'0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
xl:
'0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
'2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)',
none: 'none',
},
container: {},
cursor: {
auto: 'auto',
default: 'default',
pointer: 'pointer',
wait: 'wait',
text: 'text',
move: 'move',
'not-allowed': 'not-allowed',
},
divideColor: (theme) => theme('borderColor'),
divideOpacity: (theme) => theme('borderOpacity'),
divideWidth: (theme) => theme('borderWidth'),
fill: { current: 'currentColor' },
flex: {
1: '1 1 0%',
auto: '1 1 auto',
initial: '0 1 auto',
none: 'none',
},
flexGrow: {
0: '0',
DEFAULT: '1',
},
flexShrink: {
0: '0',
DEFAULT: '1',
},
fontFamily: {
sans: [
'ui-sans-serif',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'"Noto Sans"',
'sans-serif',
'"Apple Color Emoji"',
'"Segoe UI Emoji"',
'"Segoe UI Symbol"',
'"Noto Color Emoji"',
],
serif: [
'ui-serif',
'Georgia',
'Cambria',
'"Times New Roman"',
'Times',
'serif',
],
mono: [
'ui-monospace',
'SFMono-Regular',
'Menlo',
'Monaco',
'Consolas',
'"Liberation Mono"',
'"Courier New"',
'monospace',
],
},
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.5rem' }],
lg: ['1.125rem', { lineHeight: '1.75rem' }],
xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
'5xl': ['3rem', { lineHeight: '1' }],
'6xl': ['3.75rem', { lineHeight: '1' }],
'7xl': ['4.5rem', { lineHeight: '1' }],
'8xl': ['6rem', { lineHeight: '1' }],
'9xl': ['8rem', { lineHeight: '1' }],
},
fontWeight: {
thin: '100',
extralight: '200',
light: '300',
normal: '400',
medium: '500',
semibold: '600',
bold: '700',
extrabold: '800',
black: '900',
},
gap: (theme) => theme('spacing'),
gradientColorStops: (theme) => theme('colors'),
gridAutoColumns: {
auto: 'auto',
min: 'min-content',
max: 'max-content',
fr: 'minmax(0, 1fr)',
},
gridAutoRows: {
auto: 'auto',
min: 'min-content',
max: 'max-content',
fr: 'minmax(0, 1fr)',
},
gridColumn: {
auto: 'auto',
'span-1': 'span 1 / span 1',
'span-2': 'span 2 / span 2',
'span-3': 'span 3 / span 3',
'span-4': 'span 4 / span 4',
'span-5': 'span 5 / span 5',
'span-6': 'span 6 / span 6',
'span-7': 'span 7 / span 7',
'span-8': 'span 8 / span 8',
'span-9': 'span 9 / span 9',
'span-10': 'span 10 / span 10',
'span-11': 'span 11 / span 11',
'span-12': 'span 12 / span 12',
'span-full': '1 / -1',
},
gridColumnEnd: {
auto: 'auto',
1: '1',
2: '2',
3: '3',
4: '4',
5: '5',
6: '6',
7: '7',
8: '8',
9: '9',
10: '10',
11: '11',
12: '12',
13: '13',
},
gridColumnStart: {
auto: 'auto',
1: '1',
2: '2',
3: '3',
4: '4',
5: '5',
6: '6',
7: '7',
8: '8',
9: '9',
10: '10',
11: '11',
12: '12',
13: '13',
},
gridRow: {
auto: 'auto',
'span-1': 'span 1 / span 1',
'span-2': 'span 2 / span 2',
'span-3': 'span 3 / span 3',
'span-4': 'span 4 / span 4',
'span-5': 'span 5 / span 5',
'span-6': 'span 6 / span 6',
'span-full': '1 / -1',
},
gridRowStart: {
auto: 'auto',
1: '1',
2: '2',
3: '3',
4: '4',
5: '5',
6: '6',
7: '7',
},
gridRowEnd: {
auto: 'auto',
1: '1',
2: '2',
3: '3',
4: '4',
5: '5',
6: '6',
7: '7',
},
transformOrigin: {
center: 'center',
top: 'top',
'top-right': 'top right',
right: 'right',
'bottom-right': 'bottom right',
bottom: 'bottom',
'bottom-left': 'bottom left',
left: 'left',
'top-left': 'top left',
},
gridTemplateColumns: {
none: 'none',
1: 'repeat(1, minmax(0, 1fr))',
2: 'repeat(2, minmax(0, 1fr))',
3: 'repeat(3, minmax(0, 1fr))',
4: 'repeat(4, minmax(0, 1fr))',
5: 'repeat(5, minmax(0, 1fr))',
6: 'repeat(6, minmax(0, 1fr))',
7: 'repeat(7, minmax(0, 1fr))',
8: 'repeat(8, minmax(0, 1fr))',
9: 'repeat(9, minmax(0, 1fr))',
10: 'repeat(10, minmax(0, 1fr))',
11: 'repeat(11, minmax(0, 1fr))',
12: 'repeat(12, minmax(0, 1fr))',
},
gridTemplateRows: {
none: 'none',
1: 'repeat(1, minmax(0, 1fr))',
2: 'repeat(2, minmax(0, 1fr))',
3: 'repeat(3, minmax(0, 1fr))',
4: 'repeat(4, minmax(0, 1fr))',
5: 'repeat(5, minmax(0, 1fr))',
6: 'repeat(6, minmax(0, 1fr))',
},
height: (theme) => ({
auto: 'auto',
...theme('spacing'),
'1/2': '50%',
'1/3': '33.333333%',
'2/3': '66.666667%',
'1/4': '25%',
'2/4': '50%',
'3/4': '75%',
'1/5': '20%',
'2/5': '40%',
'3/5': '60%',
'4/5': '80%',
'1/6': '16.666667%',
'2/6': '33.333333%',
'3/6': '50%',
'4/6': '66.666667%',
'5/6': '83.333333%',
full: '100%',
screen: '100vh',
}),
inset: (theme, { negative }) => ({
auto: 'auto',
...theme('spacing'),
...negative(theme('spacing')),
'1/2': '50%',
'1/3': '33.333333%',
'2/3': '66.666667%',
'1/4': '25%',
'2/4': '50%',
'3/4': '75%',
full: '100%',
'-1/2': '-50%',
'-1/3': '-33.333333%',
'-2/3': '-66.666667%',
'-1/4': '-25%',
'-2/4': '-50%',
'-3/4': '-75%',
'-full': '-100%',
}),
keyframes: {
spin: {
to: {
transform: 'rotate(360deg)',
},
},
ping: {
'75%, 100%': {
transform: 'scale(2)',
opacity: '0',
},
},
pulse: {
'50%': {
opacity: '.5',
},
},
bounce: {
'0%, 100%': {
transform: 'translateY(-25%)',
animationTimingFunction: 'cubic-bezier(0.8,0,1,1)',
},
'50%': {
transform: 'none',
animationTimingFunction: 'cubic-bezier(0,0,0.2,1)',
},
},
},
letterSpacing: {
tighter: '-0.05em',
tight: '-0.025em',
normal: '0em',
wide: '0.025em',
wider: '0.05em',
widest: '0.1em',
},
lineHeight: {
none: '1',
tight: '1.25',
snug: '1.375',
normal: '1.5',
relaxed: '1.625',
loose: '2',
3: '.75rem',
4: '1rem',
5: '1.25rem',
6: '1.5rem',
7: '1.75rem',
8: '2rem',
9: '2.25rem',
10: '2.5rem',
},
listStyleType: {
none: 'none',
disc: 'disc',
decimal: 'decimal',
},
margin: (theme, { negative }) => ({
auto: 'auto',
...theme('spacing'),
...negative(theme('spacing')),
}),
maxHeight: (theme) => ({
...theme('spacing'),
full: '100%',
screen: '100vh',
}),
maxWidth: (theme, { breakpoints }) => ({
none: 'none',
0: '0rem',
xs: '20rem',
sm: '24rem',
md: '28rem',
lg: '32rem',
xl: '36rem',
'2xl': '42rem',
'3xl': '48rem',
'4xl': '56rem',
'5xl': '64rem',
'6xl': '72rem',
'7xl': '80rem',
full: '100%',
min: 'min-content',
max: 'max-content',
prose: '65ch',
...breakpoints(theme('screens')),
}),
minHeight: {
0: '0px',
full: '100%',
screen: '100vh',
},
minWidth: {
0: '0px',
full: '100%',
min: 'min-content',
max: 'max-content',
},
objectPosition: {
bottom: 'bottom',
center: 'center',
left: 'left',
'left-bottom': 'left bottom',
'left-top': 'left top',
right: 'right',
'right-bottom': 'right bottom',
'right-top': 'right top',
top: 'top',
},
opacity: {
0: '0',
5: '0.05',
10: '0.1',
20: '0.2',
25: '0.25',
30: '0.3',
40: '0.4',
50: '0.5',
60: '0.6',
70: '0.7',
75: '0.75',
80: '0.8',
90: '0.9',
95: '0.95',
100: '1',
},
order: {
first: '-9999',
last: '9999',
none: '0',
1: '1',
2: '2',
3: '3',
4: '4',
5: '5',
6: '6',
7: '7',
8: '8',
9: '9',
10: '10',
11: '11',
12: '12',
},
outline: {
none: ['2px solid transparent', '2px'],
white: ['2px dotted white', '2px'],
black: ['2px dotted black', '2px'],
},
padding: (theme) => theme('spacing'),
placeholderColor: (theme) => theme('colors'),
placeholderOpacity: (theme) => theme('opacity'),
ringColor: (theme) => ({
DEFAULT: theme('colors.blue.500', '#3b82f6'),
...theme('colors'),
}),
ringOffsetColor: (theme) => theme('colors'),
ringOffsetWidth: {
0: '0px',
1: '1px',
2: '2px',
4: '4px',
8: '8px',
},
ringOpacity: (theme) => ({
DEFAULT: '0.5',
...theme('opacity'),
}),
ringWidth: {
DEFAULT: '3px',
0: '0px',
1: '1px',
2: '2px',
4: '4px',
8: '8px',
},
rotate: {
'-180': '-180deg',
'-90': '-90deg',
'-45': '-45deg',
'-12': '-12deg',
'-6': '-6deg',
'-3': '-3deg',
'-2': '-2deg',
'-1': '-1deg',
0: '0deg',
1: '1deg',
2: '2deg',
3: '3deg',
6: '6deg',
12: '12deg',
45: '45deg',
90: '90deg',
180: '180deg',
},
scale: {
0: '0',
50: '.5',
75: '.75',
90: '.9',
95: '.95',
100: '1',
105: '1.05',
110: '1.1',
125: '1.25',
150: '1.5',
},
skew: {
'-12': '-12deg',
'-6': '-6deg',
'-3': '-3deg',
'-2': '-2deg',
'-1': '-1deg',
0: '0deg',
1: '1deg',
2: '2deg',
3: '3deg',
6: '6deg',
12: '12deg',
},
space: (theme, { negative }) => ({
...theme('spacing'),
...negative(theme('spacing')),
}),
stroke: {
current: 'currentColor',
},
strokeWidth: {
0: '0',
1: '1',
2: '2',
},
textColor: (theme) => theme('colors'),
textOpacity: (theme) => theme('opacity'),
transitionDuration: {
DEFAULT: '150ms',
75: '75ms',
100: '100ms',
150: '150ms',
200: '200ms',
300: '300ms',
500: '500ms',
700: '700ms',
1000: '1000ms',
},
transitionDelay: {
75: '75ms',
100: '100ms',
150: '150ms',
200: '200ms',
300: '300ms',
500: '500ms',
700: '700ms',
1000: '1000ms',
},
transitionProperty: {
none: 'none',
all: 'all',
DEFAULT:
'background-color, border-color, color, fill, stroke, opacity, box-shadow, transform',
colors: 'background-color, border-color, color, fill, stroke',
opacity: 'opacity',
shadow: 'box-shadow',
transform: 'transform',
},
transitionTimingFunction: {
DEFAULT: 'cubic-bezier(0.4, 0, 0.2, 1)',
linear: 'linear',
in: 'cubic-bezier(0.4, 0, 1, 1)',
out: 'cubic-bezier(0, 0, 0.2, 1)',
'in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
},
translate: (theme, { negative }) => ({
...theme('spacing'),
...negative(theme('spacing')),
'1/2': '50%',
'1/3': '33.333333%',
'2/3': '66.666667%',
'1/4': '25%',
'2/4': '50%',
'3/4': '75%',
full: '100%',
'-1/2': '-50%',
'-1/3': '-33.333333%',
'-2/3': '-66.666667%',
'-1/4': '-25%',
'-2/4': '-50%',
'-3/4': '-75%',
'-full': '-100%',
}),
width: (theme) => ({
auto: 'auto',
...theme('spacing'),
'1/2': '50%',
'1/3': '33.333333%',
'2/3': '66.666667%',
'1/4': '25%',
'2/4': '50%',
'3/4': '75%',
'1/5': '20%',
'2/5': '40%',
'3/5': '60%',
'4/5': '80%',
'1/6': '16.666667%',
'2/6': '33.333333%',
'3/6': '50%',
'4/6': '66.666667%',
'5/6': '83.333333%',
'1/12': '8.333333%',
'2/12': '16.666667%',
'3/12': '25%',
'4/12': '33.333333%',
'5/12': '41.666667%',
'6/12': '50%',
'7/12': '58.333333%',
'8/12': '66.666667%',
'9/12': '75%',
'10/12': '83.333333%',
'11/12': '91.666667%',
full: '100%',
screen: '100vw',
min: 'min-content',
max: 'max-content',
}),
zIndex: {
auto: 'auto',
0: '0',
10: '10',
20: '20',
30: '30',
40: '40',
50: '50',
},
},
variantOrder: [
'first',
'last',
'odd',
'even',
'visited',
'checked',
'group-hover',
'group-focus',
'focus-within',
'hover',
'focus',
'focus-visible',
'active',
'disabled',
],
variants: {
accessibility: ['responsive', 'focus-within', 'focus'],
alignContent: ['responsive'],
alignItems: ['responsive'],
alignSelf: ['responsive'],
animation: ['responsive'],
appearance: ['responsive'],
backgroundAttachment: ['responsive'],
backgroundClip: ['responsive'],
backgroundColor: [
'responsive',
'dark',
'group-hover',
'focus-within',
'hover',
'focus',
],
backgroundImage: ['responsive'],
backgroundOpacity: [
'responsive',
'group-hover',
'focus-within',
'hover',
'focus',
],
backgroundPosition: ['responsive'],
backgroundRepeat: ['responsive'],
backgroundSize: ['responsive'],
borderCollapse: ['responsive'],
borderColor: [
'responsive',
'dark',
'group-hover',
'focus-within',
'hover',
'focus',
],
borderOpacity: [
'responsive',
'group-hover',
'focus-within',
'hover',
'focus',
],
borderRadius: ['responsive'],
borderStyle: ['responsive'],
borderWidth: ['responsive'],
boxShadow: ['responsive', 'group-hover', 'focus-within', 'hover', 'focus'],
boxSizing: ['responsive'],
clear: ['responsive'],
container: ['responsive'],
cursor: ['responsive'],
display: ['responsive'],
divideColor: ['responsive', 'dark'],
divideOpacity: ['responsive'],
divideStyle: ['responsive'],
divideWidth: ['responsive'],
fill: ['responsive'],
flex: ['responsive'],
flexDirection: ['responsive'],
flexGrow: ['responsive'],
flexShrink: ['responsive'],
flexWrap: ['responsive'],
float: ['responsive'],
fontFamily: ['responsive'],
fontSize: ['responsive'],
fontSmoothing: ['responsive'],
fontStyle: ['responsive'],
fontVariantNumeric: ['responsive'],
fontWeight: ['responsive'],
gap: ['responsive'],
gradientColorStops: ['responsive', 'dark', 'hover', 'focus'],
gridAutoColumns: ['responsive'],
gridAutoFlow: ['responsive'],
gridAutoRows: ['responsive'],
gridColumn: ['responsive'],
gridColumnEnd: ['responsive'],
gridColumnStart: ['responsive'],
gridRow: ['responsive'],
gridRowEnd: ['responsive'],
gridRowStart: ['responsive'],
gridTemplateColumns: ['responsive'],
gridTemplateRows: ['responsive'],
height: ['responsive'],
inset: ['responsive'],
justifyContent: ['responsive'],
justifyItems: ['responsive'],
justifySelf: ['responsive'],
letterSpacing: ['responsive'],
lineHeight: ['responsive'],
listStylePosition: ['responsive'],
listStyleType: ['responsive'],
margin: ['responsive'],
maxHeight: ['responsive'],
maxWidth: ['responsive'],
minHeight: ['responsive'],
minWidth: ['responsive'],
objectFit: ['responsive'],
objectPosition: ['responsive'],
opacity: ['responsive', 'group-hover', 'focus-within', 'hover', 'focus'],
order: ['responsive'],
outline: ['responsive', 'focus-within', 'focus'],
overflow: ['responsive'],
overscrollBehavior: ['responsive'],
padding: ['responsive'],
placeContent: ['responsive'],
placeItems: ['responsive'],
placeSelf: ['responsive'],
placeholderColor: ['responsive', 'dark', 'focus'],
placeholderOpacity: ['responsive', 'focus'],
pointerEvents: ['responsive'],
position: ['responsive'],
resize: ['responsive'],
ringColor: ['responsive', 'dark', 'focus-within', 'focus'],
ringOffsetColor: ['responsive', 'dark', 'focus-within', 'focus'],
ringOffsetWidth: ['responsive', 'focus-within', 'focus'],
ringOpacity: ['responsive', 'focus-within', 'focus'],
ringWidth: ['responsive', 'focus-within', 'focus'],
rotate: ['responsive', 'hover', 'focus'],
scale: ['responsive', 'hover', 'focus'],
skew: ['responsive', 'hover', 'focus'],
space: ['responsive'],
stroke: ['responsive'],
strokeWidth: ['responsive'],
tableLayout: ['responsive'],
textAlign: ['responsive'],
textColor: [
'responsive',
'dark',
'group-hover',
'focus-within',
'hover',
'focus',
],
textDecoration: [
'responsive',
'group-hover',
'focus-within',
'hover',
'focus',
],
textOpacity: [
'responsive',
'group-hover',
'focus-within',
'hover',
'focus',
],
textOverflow: ['responsive'],
textTransform: ['responsive'],
transform: ['responsive'],
transformOrigin: ['responsive'],
transitionDelay: ['responsive'],
transitionDuration: ['responsive'],
transitionProperty: ['responsive'],
transitionTimingFunction: ['responsive'],
translate: ['responsive', 'hover', 'focus'],
userSelect: ['responsive'],
verticalAlign: ['responsive'],
visibility: ['responsive'],
whitespace: ['responsive'],
width: ['responsive'],
wordBreak: ['responsive'],
zIndex: ['responsive', 'focus-within', 'focus'],
},
plugins: [],
}

873
yarn.lock

File diff suppressed because it is too large Load Diff