diff --git a/.cargo-ok b/.cargo-ok new file mode 100644 index 000000000..e69de29bb diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..74b4951bc --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..57abef40d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..a06a385e3 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "semi": false, + "trailingComma": "all", + "tabWidth": 2, + "printWidth": 80 +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 000000000..def54e6d3 --- /dev/null +++ b/config.yaml @@ -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 + diff --git a/flareact.config.js b/flareact.config.js new file mode 100644 index 000000000..8bd3f87dc --- /dev/null +++ b/flareact.config.js @@ -0,0 +1,11 @@ +module.exports = { + webpack: (config, options) => { + config.module.rules.push({ + test: /\.ya?ml$/, + type: 'json', + use: 'yaml-loader', + }) + + return config + }, +} diff --git a/index.js b/index.js new file mode 100644 index 000000000..fd9daf4b8 --- /dev/null +++ b/index.js @@ -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)) +}) diff --git a/out/.gitkeep b/out/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/package.json b/package.json new file mode 100644 index 000000000..d1970a29f --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "cf-workers-status-page", + "version": "1.0.0", + "author": "Adam Janiš ", + "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" + } +} diff --git a/pages/api/triggerCron.js b/pages/api/triggerCron.js new file mode 100644 index 000000000..a64d33fa8 --- /dev/null +++ b/pages/api/triggerCron.js @@ -0,0 +1,5 @@ +import { processCronTrigger } from '../../src/functions/cronTrigger' + +export default async event => { + return processCronTrigger() +} diff --git a/pages/index.js b/pages/index.js new file mode 100644 index 000000000..995279f9c --- /dev/null +++ b/pages/index.js @@ -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 ( +
+ + {config.settings.title} + + + +
+

+ + {config.settings.title} +

+
+
+
+ {monitorsOperational + ? config.settings.allmonitorsOperational + : config.settings.notAllmonitorsOperational} +
+
+ checked {Math.round((Date.now() - kvLastUpdate) / 1000)} sec ago +
+
+
+ {config.monitors.map((monitor, key) => { + return ( +
+
+
+ + + +
{monitor.name}
+
+ +
+ + + +
+
{config.settings.daysInHistory} days ago
+
Today
+
+
+ ) + })} +
+
+ Powered by{' '} + + Cloudflare Workers{' '} + + &{' '} + + Flareact{' '} + +
+ +
+
+
+ ) +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 000000000..be229015f Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/logo-192x192.png b/public/logo-192x192.png new file mode 100644 index 000000000..e7c2fcd80 Binary files /dev/null and b/public/logo-192x192.png differ diff --git a/public/main.css b/public/main.css new file mode 100644 index 000000000..223c31b73 --- /dev/null +++ b/public/main.css @@ -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; +} diff --git a/src/components/monitorHistogram.js b/src/components/monitorHistogram.js new file mode 100644 index 000000000..29c5108c0 --- /dev/null +++ b/src/components/monitorHistogram.js @@ -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 ( +
+ {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 ( +
+
+
+ ) + })} +
+ ) + } else { + return ( +
+
Loading histogram ...
+
+ ) + } +} diff --git a/src/components/monitorStatusLabel.js b/src/components/monitorStatusLabel.js new file mode 100644 index 000000000..19b4957bf --- /dev/null +++ b/src/components/monitorStatusLabel.js @@ -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
{labelText}
+} diff --git a/src/css/index.css b/src/css/index.css new file mode 100644 index 000000000..223c31b73 --- /dev/null +++ b/src/css/index.css @@ -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; +} diff --git a/src/functions/cronTrigger.js b/src/functions/cronTrigger.js new file mode 100644 index 000000000..4f6b2f722 --- /dev/null +++ b/src/functions/cronTrigger.js @@ -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') +} diff --git a/src/functions/helpers.js b/src/functions/helpers.js new file mode 100644 index 000000000..7a19e5425 --- /dev/null +++ b/src/functions/helpers.js @@ -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' }, + }) +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 000000000..14a6c2994 --- /dev/null +++ b/wrangler.toml @@ -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 = "./"