From a4f93e80188e01ba538fd73486d15037865ea4f2 Mon Sep 17 00:00:00 2001 From: taskylizard <75871323+taskylizard@users.noreply.github.com> Date: Fri, 16 Feb 2024 18:28:41 +0000 Subject: [PATCH] feat: init --- .github/workflows/ci.yml | 18 ++ .gitignore | 174 ++++++++++++++++++ assets/theme.js | 18 ++ biome.json | 33 ++++ bun.lockb | Bin 0 -> 77257 bytes lefthook.yml | 8 + ...reate_events_and_probe_statuses_tables.sql | 22 +++ migrations/0001_create_log_lines_table.sql | 7 + package.json | 28 +++ src/db/conversion.ts | 5 + src/db/schema.ts | 35 ++++ src/db/sql-tag.ts | 27 +++ src/db/statements.ts | 102 ++++++++++ src/index.tsx | 59 ++++++ src/pages/EventHistory.tsx | 57 ++++++ src/pages/Logs.tsx | 31 ++++ src/pages/Page.tsx | 81 ++++++++ src/pages/ProbeCard.tsx | 67 +++++++ src/pages/ProbeOverview.tsx | 28 +++ src/pages/TimezoneSwitcher.tsx | 31 ++++ src/probes/configs.ts | 37 ++++ src/probes/executor.ts | 73 ++++++++ src/probes/probeAtlassianStatus.ts | 21 +++ src/probes/probeHttp.ts | 23 +++ src/probes/types.ts | 28 +++ src/styles.css | 34 ++++ src/utils/date.ts | 27 +++ src/utils/logger.ts | 17 ++ src/webhooks/webhook-configs.ts | 20 ++ src/webhooks/webhook-executor.ts | 42 +++++ src/webhooks/webhook-helper-github.ts | 17 ++ src/webhooks/webhook-helper-vercel.ts | 123 +++++++++++++ src/webhooks/webhook-types.ts | 13 ++ tailwind.config.ts | 9 + tsconfig.json | 18 ++ wrangler.toml | 17 ++ 36 files changed, 1350 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 assets/theme.js create mode 100644 biome.json create mode 100755 bun.lockb create mode 100644 lefthook.yml create mode 100644 migrations/0000_create_events_and_probe_statuses_tables.sql create mode 100644 migrations/0001_create_log_lines_table.sql create mode 100644 package.json create mode 100644 src/db/conversion.ts create mode 100644 src/db/schema.ts create mode 100644 src/db/sql-tag.ts create mode 100644 src/db/statements.ts create mode 100644 src/index.tsx create mode 100644 src/pages/EventHistory.tsx create mode 100644 src/pages/Logs.tsx create mode 100644 src/pages/Page.tsx create mode 100644 src/pages/ProbeCard.tsx create mode 100644 src/pages/ProbeOverview.tsx create mode 100644 src/pages/TimezoneSwitcher.tsx create mode 100644 src/probes/configs.ts create mode 100644 src/probes/executor.ts create mode 100644 src/probes/probeAtlassianStatus.ts create mode 100644 src/probes/probeHttp.ts create mode 100644 src/probes/types.ts create mode 100644 src/styles.css create mode 100644 src/utils/date.ts create mode 100644 src/utils/logger.ts create mode 100644 src/webhooks/webhook-configs.ts create mode 100644 src/webhooks/webhook-executor.ts create mode 100644 src/webhooks/webhook-helper-github.ts create mode 100644 src/webhooks/webhook-helper-vercel.ts create mode 100644 src/webhooks/webhook-types.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 wrangler.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..461742b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: Code quality + +on: + push: + pull_request: + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Biome + uses: biomejs/setup-biome@v2 + with: + version: latest + - name: Run Biome + run: biome ci . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84871a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,174 @@ +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +# wrangler project + +.dev.vars +.wrangler/ +worker-configuration.d.ts +assets/built.css diff --git a/assets/theme.js b/assets/theme.js new file mode 100644 index 0000000..ab035ad --- /dev/null +++ b/assets/theme.js @@ -0,0 +1,18 @@ +;(() => { + const v = localStorage.getItem('color-scheme') + const a = window.matchMedia('(prefers-color-scheme: dark)').matches + const cl = document.documentElement.classList + const setColorScheme = (v) => + (!v || v === 'auto' ? a : v === 'dark') ? cl.add('dark') : cl.remove('dark') + setColorScheme(v) + window.setColorScheme = (v) => { + setColorScheme(v) + localStorage.setItem('color-scheme', v) + } + window.toggleColorScheme = () => { + const cl = document.documentElement.classList + const currentScheme = cl.contains('dark') ? 'light' : 'dark' + cl.toggle('dark') + localStorage.setItem('color-scheme', currentScheme) + } +})() diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..f283c16 --- /dev/null +++ b/biome.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", + "organizeImports": { + "enabled": true + }, + "vcs": { + "enabled": true, + "clientKind": "git" + }, + "javascript": { + "formatter": { + "enabled": true, + "quoteStyle": "single", + "semicolons": "asNeeded", + "trailingComma": "none" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "enabled": true, + "lineWidth": 80 + }, + "css": { + "formatter": { + "enabled": true + } + } +} diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..9d5872a6ba0eb3ee2e144c2190d13df8a48127ec GIT binary patch literal 77257 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 = K & { __brand: T } + +export type EventId = Brand +export type LogLineId = Brand +export type ProbeId = Brand +export type WebhookId = Brand + +export interface DatabaseSchema { + events: { + id: EventId + created_at: string + probe_id: ProbeId | null + webhook_id: WebhookId | null + duration: number | null + result: 'success' | 'failure' + category: string + description: string | null + } + + log_lines: { + id: LogLineId + created_at: string + level: 'info' | 'warn' | 'error' + message: string + } + + probe_statuses: { + id: ProbeId + last_started_at: string + last_result: 'success' | 'failure' | '' + last_success_at: string | null + last_failure_at: string | null + same_result_since: string | null + } +} diff --git a/src/db/sql-tag.ts b/src/db/sql-tag.ts new file mode 100644 index 0000000..ebcb587 --- /dev/null +++ b/src/db/sql-tag.ts @@ -0,0 +1,27 @@ +import { createD1SqlTag, logQueryResults } from 'd1-sql-tag' +import { type Context } from 'hono' +import { endTime, setMetric, startTime } from 'hono/timing' +import type { Bindings } from '..' + +export function createSqlTag(c: Context<{ Bindings: Bindings }>) { + return createD1SqlTag(c.env.DB, { + beforeQuery(batchId, queries) { + startTime(c, `db-${batchId}`) + }, + afterQuery(batchId, queries, results, duration) { + endTime(c, `db-${batchId}`) + results.forEach((result, i) => { + setMetric(c, `db-${batchId}-query-${i + 1}`, result.meta.duration) + }) + logQueryResults(queries, results, duration) + } + }) +} + +export function createCronSqlTag(env: Bindings) { + return createD1SqlTag(env.DB, { + afterQuery(batchId, queries, results, duration) { + logQueryResults(queries, results, duration) + } + }) +} diff --git a/src/db/statements.ts b/src/db/statements.ts new file mode 100644 index 0000000..9d0cc3b --- /dev/null +++ b/src/db/statements.ts @@ -0,0 +1,102 @@ +import type { SqlTag } from 'd1-sql-tag' +import type { StatusEvent } from '../probes/types' +import { convertDate } from './conversion' +import type { DatabaseSchema as DB } from './schema' + +export function selectProbeStatuses(sql: SqlTag) { + type Row = Pick< + DB['probe_statuses'], + | 'id' + | 'last_result' + | 'last_success_at' + | 'last_failure_at' + | 'same_result_since' + > + return sql`SELECT id, last_result, last_success_at, last_failure_at, same_result_since FROM probe_statuses` + .build() + .map((row) => ({ + id: row.id, + lastResult: row.last_result, + lastSuccessAt: convertDate(row.last_success_at), + lastFailureAt: convertDate(row.last_failure_at), + sameResultSince: convertDate(row.same_result_since) + })) +} + +export function selectLatestEvents(sql: SqlTag) { + type Row = Pick< + DB['events'], + | 'category' + | 'created_at' + | 'duration' + | 'probe_id' + | 'webhook_id' + | 'result' + | 'description' + > + return sql`SELECT category, created_at, duration, probe_id, webhook_id, result, description FROM events ORDER BY id DESC LIMIT 30` + .build() + .map((row) => ({ + createdAt: convertDate(row.created_at), + category: row.category, + duration: row.duration, + probeId: row.probe_id, + webhookId: row.webhook_id, + result: row.result, + description: row.description + })) +} + +export function updateProbeLastStarted(sql: SqlTag, probeId: string) { + const now = new Date().toISOString() + return sql`INSERT INTO probe_statuses (id, last_result, last_started_at) VALUES (${probeId}, '', ${now}) + ON CONFLICT (id) DO UPDATE SET last_started_at = ${now}`.build() +} + +export function updateProbeStatus( + sql: SqlTag, + result: { probeId: string; result: 'success' | 'failure' } +) { + const now = new Date().toISOString() + + const resultColumn = + result.result === 'success' ? sql`last_success_at` : sql`last_failure_at` + + return sql`UPDATE probe_statuses + SET last_result = ${result.result}, + ${resultColumn} = ${now}, + same_result_since = CASE WHEN last_result = ${result.result} THEN same_result_since ELSE ${now} END + WHERE id = ${result.probeId}`.build() +} + +export function insertEvent( + sql: SqlTag, + event: Omit +) { + const now = new Date().toISOString() + + return sql`INSERT INTO events (created_at, probe_id, webhook_id, duration, result, category, description) + VALUES (${now}, ${event.probeId}, ${event.webhookId}, ${event.duration}, + ${event.result}, ${event.category}, ${event.description})`.build() +} + +export function insertLogLine( + sql: SqlTag, + level: DB['log_lines']['level'], + message: string +) { + const now = new Date().toISOString() + + return sql`INSERT INTO log_lines (created_at, level, message) VALUES (${now}, ${level}, ${message})`.build() +} + +export function selectLatestLogLines(sql: SqlTag) { + type Row = Pick + return sql`SELECT created_at, level, message FROM log_lines ORDER BY id DESC LIMIT 30` + .build() + .map((row) => ({ + createdAt: convertDate(row.created_at), + level: row.level, + message: row.message + })) +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..db414c4 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,59 @@ +import { Hono } from 'hono' +import { serveStatic } from 'hono/cloudflare-workers' +import { timing } from 'hono/timing' +import { createCronSqlTag, createSqlTag } from './db/sql-tag' +import { StatusPage } from './pages/Page' +import { defaultTimezone } from './pages/TimezoneSwitcher' +import { executeAllProbes, executeCronProbes } from './probes/executor' +import { createPersistentLogger } from './utils/logger' +import { executeWebhook } from './webhooks/webhook-executor' + +export type Bindings = Env & Record + +type CloudflareHono = Hono<{ Bindings: Bindings }> & { + scheduled?: ExportedHandlerScheduledHandler +} + +const app: CloudflareHono = new Hono() + +app.use('*', timing()) +app.use('/*', serveStatic()) + +app.get('/', async (c) => { + const sql = createSqlTag(c) + const timezone = c.req.query('tz') ?? defaultTimezone + const enableExecuteAllProbes = c.env.ENABLE_RUNNING_ALL_PROBES === '1' + return c.html( + + ) +}) + +app.get('/api/execute-all-probes', async (c) => { + if (c.env.ENABLE_RUNNING_ALL_PROBES !== '1') { + return c.text('Not found', 404) + } + const sql = createSqlTag(c) + const logger = createPersistentLogger(sql, c.executionCtx) + await executeAllProbes({ sql, logger }) + return c.text('done') +}) + +app.post('/api/webhook/:id', async (c) => { + const sql = createSqlTag(c) + const webhookId = c.req.param('id') + const logger = createPersistentLogger(sql, c.executionCtx) + await executeWebhook({ sql, logger }, webhookId, c.req) + return c.text('ok') +}) + +app.scheduled = async (event, env, context) => { + const sql = createCronSqlTag(env) + const logger = createPersistentLogger(sql, context) + await executeCronProbes({ sql, logger }, event.cron) +} + +export default app diff --git a/src/pages/EventHistory.tsx b/src/pages/EventHistory.tsx new file mode 100644 index 0000000..45866f0 --- /dev/null +++ b/src/pages/EventHistory.tsx @@ -0,0 +1,57 @@ +import type { RowType } from 'd1-sql-tag' +import type { selectLatestEvents } from '../db/statements' +import { probeConfigs } from '../probes/configs' +import { formatDate, formatTime } from '../utils/date' +import { webhooksConfigs } from '../webhooks/webhook-configs' + +export function EventHistory({ + events, + timezone +}: { + events: RowType[] + timezone: string +}) { + const groups: Record[]> = {} + for (const event of events) { + const date = formatDate(event.createdAt, timezone) + if (!groups[date]) { + groups[date] = [] + } + groups[date].push(event) + } + + return ( + <> +
+

Recent

+ {Object.entries(groups).map(([date, groupEvents]) => ( + <> +

{date}

+ {groupEvents.map((event) => { + const source = event.probeId + ? probeConfigs.find((it) => it.id === event.probeId) + : webhooksConfigs.find((it) => it.id === event.webhookId) + return ( +
+

{source?.title}

+

+ {event.description + ? event.description + : undefined || event.result === 'success' + ? 'All Systems Operational.' + : 'Service Outage.'} + {' • '} + {formatTime(event.createdAt, timezone)} + {event.duration === null + ? undefined + : ` • Took ${event.duration}ms`} +

+
+ ) + })} + + ))} +
+ + ) +} diff --git a/src/pages/Logs.tsx b/src/pages/Logs.tsx new file mode 100644 index 0000000..9643b3c --- /dev/null +++ b/src/pages/Logs.tsx @@ -0,0 +1,31 @@ +import type { RowType } from 'd1-sql-tag' +import type { selectLatestLogLines } from '../db/statements' +import { formatDateTime } from '../utils/date' + +export function Logs({ + logLines, + timezone +}: { + logLines: RowType[] + timezone: string +}) { + if (logLines.length === 0) { + return <> + } + return ( +
+

Logs

+ {logLines.map((logLine) => { + return ( +
+

+ {formatDateTime(logLine.createdAt, timezone)} +

+

{logLine.level}

+

{logLine.message}

+
+ ) + })} +
+ ) +} diff --git a/src/pages/Page.tsx b/src/pages/Page.tsx new file mode 100644 index 0000000..98a89eb --- /dev/null +++ b/src/pages/Page.tsx @@ -0,0 +1,81 @@ +import type { SqlTag } from 'd1-sql-tag' +import { + selectLatestEvents, + selectLatestLogLines, + selectProbeStatuses +} from '../db/statements' +import { EventHistory } from './EventHistory' +import { Logs } from './Logs' +import { ProbeOverview } from './ProbeOverview' +import { TimezoneSwitcher } from './TimezoneSwitcher' + +export async function StatusPage({ + sql, + timezone, + enableExecuteAllProbes +}: { + sql: SqlTag + timezone: string + enableExecuteAllProbes: boolean +}) { + const [ + { results: probeStatuses }, + { results: events }, + { results: logLines } + ] = await sql.batch([ + selectProbeStatuses(sql), + selectLatestEvents(sql), + selectLatestLogLines(sql) + ] as const) + + return ( + + + simulation + + + + + + + + + + + + `} + + ) : ( + (➠ {defaultTimezone}) + )} +

+ ) +} diff --git a/src/probes/configs.ts b/src/probes/configs.ts new file mode 100644 index 0000000..614fcce --- /dev/null +++ b/src/probes/configs.ts @@ -0,0 +1,37 @@ +import { probeHttp } from './probeHttp' +import type { ProbeConfig } from './types' + +// Must match the cron expressions in wrangler.toml +const cronHourly = '0 * * * *' + +export const probeConfigs: ProbeConfig[] = [ + { + id: 'site', + title: 'Website', + url: 'https://fmhy.net', + matchCron: (cron) => cron === cronHourly, + execute: (context) => probeHttp(context, 'https://fmhy.net') + }, + + { + id: 'searx', + title: 'SearX', + url: 'https://searx.fmhy.net', + matchCron: (cron) => cron === cronHourly, + execute: (context) => probeHttp(context, 'https://searx.fmhy.net/healthz') + }, + { + id: 'whoogle', + title: 'Whoogle', + url: 'https://whoogle.fmhy.net', + matchCron: (cron) => cron === cronHourly, + execute: (context) => probeHttp(context, 'https://whoogle.fmhy.net') + }, + { + id: 'api', + title: 'API', + matchCron: (cron) => cron === cronHourly, + execute: (context) => + probeHttp(context, 'https://feedback.tasky.workers.dev/test') + } +] diff --git a/src/probes/executor.ts b/src/probes/executor.ts new file mode 100644 index 0000000..b95ae7e --- /dev/null +++ b/src/probes/executor.ts @@ -0,0 +1,73 @@ +import type { SqlTag } from 'd1-sql-tag' +import { + insertEvent, + updateProbeLastStarted, + updateProbeStatus +} from '../db/statements' +import type { PersistentLogger } from '../utils/logger' +import { probeConfigs } from './configs' +import type { ProbeConfig } from './types' + +interface Context { + sql: SqlTag + logger: PersistentLogger +} + +export async function executeAllProbes(context: Context) { + for (const probe of probeConfigs) { + await executeProbe(context, probe) + } +} + +export async function executeCronProbes(context: Context, cron: string) { + const matchingProbes = probeConfigs.filter((probe) => probe.matchCron(cron)) + if (matchingProbes.length === 0) { + context.logger('warn', `No probes match cron ${cron}`) + return + } + + console.log(`Executing ${matchingProbes.length} probes (cron: ${cron})...`) + for (const probe of matchingProbes) { + await executeProbe(context, probe) + } + console.log('Done executing probes.') +} + +async function executeProbe({ sql, logger }: Context, probe: ProbeConfig) { + let startTime = null + let endTime = null + const probeContext = { + startTimer() { + startTime = Date.now() + }, + stopTimer() { + endTime = Date.now() + } + } + + updateProbeLastStarted(sql, probe.id).run().catch(console.warn) // fire and forget + + try { + console.log(`Executing probe ${probe.id}...`) + const result = await probe.execute(probeContext) + console.log('Result', result) + const duration = endTime && startTime ? endTime - startTime : null + + await sql.batch([ + insertEvent(sql, { + probeId: probe.id, + webhookId: null, + duration, + ...result + }), + updateProbeStatus(sql, { probeId: probe.id, result: result.result }) + ]) + } catch (error) { + logger( + 'error', + `Error executing probe ${probe.id}: ${ + error instanceof Error ? error.message : typeof error + }` + ) + } +} diff --git a/src/probes/probeAtlassianStatus.ts b/src/probes/probeAtlassianStatus.ts new file mode 100644 index 0000000..8e4e8d6 --- /dev/null +++ b/src/probes/probeAtlassianStatus.ts @@ -0,0 +1,21 @@ +import type { ProbeContext, ProbeResult } from './types' + +export async function probeAtlassianStatus( + context: ProbeContext, + url: string +): Promise { + try { + const response = await fetch(`${url}/api/v2/status.json`) + const json = (await response.json()) as { + status: { indicator: string; description: string } + } + return { + result: json.status.indicator === 'none' ? 'success' : 'failure', + category: json.status.indicator, + description: json.status.description + } + } catch (error) { + console.warn('Error probing', error) + return { result: 'failure', category: 'error', description: null } + } +} diff --git a/src/probes/probeHttp.ts b/src/probes/probeHttp.ts new file mode 100644 index 0000000..820524d --- /dev/null +++ b/src/probes/probeHttp.ts @@ -0,0 +1,23 @@ +import type { ProbeContext, ProbeResult } from './types' + +export async function probeHttp( + context: ProbeContext, + requestInfo: RequestInfo, + requestInit?: RequestInit +): Promise { + context.startTimer() + try { + const response = await fetch(requestInfo, requestInit) + await response.text() + context.stopTimer() + return { + result: response.ok ? 'success' : 'failure', + category: String(response.status), + description: null + } + } catch (error) { + context.stopTimer() + console.warn('Error probing', error) + return { result: 'failure', category: 'error', description: null } + } +} diff --git a/src/probes/types.ts b/src/probes/types.ts new file mode 100644 index 0000000..16ac7f3 --- /dev/null +++ b/src/probes/types.ts @@ -0,0 +1,28 @@ +export interface ProbeConfig { + id: string + title: string + url?: string + matchCron(cron: string): boolean + execute(context: ProbeContext): Promise +} + +export interface ProbeContext { + startTimer(): void + stopTimer(): void +} + +export interface ProbeResult { + result: 'success' | 'failure' + category: string + description: string | null +} + +export interface StatusEvent { + probeId: string | null + webhookId: string | null + duration: number | null + result: 'success' | 'failure' + category: string + description: string | null + createdAt: Date +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..70f4f99 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,34 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@media (prefers-reduced-motion: reduce) { + + *, + ::before, + ::after { + animation-delay: -1ms !important; + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + background-attachment: initial !important; + scroll-behavior: auto !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + } +} + +:root { + color-scheme: light; + + &.dark { + color-scheme: dark; + } +} + +a { + @apply text-sky-200 decoration-dashed hover:decoration-solid focus:decoration-solid; +} + +body { + @apply min-h-screen bg-neutral-300 dark:bg-neutral-900 focus:outline-none; +} diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000..12ea082 --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,27 @@ +export function formatDateTime(date: Date, timezone: string) { + return new Intl.DateTimeFormat('sv-SE', { + timeZone: timezone, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric' + }).format(date) +} + +export function formatDate(date: Date, timezone: string) { + return new Intl.DateTimeFormat('sv-SE', { + timeZone: timezone, + year: 'numeric', + month: 'numeric', + day: 'numeric' + }).format(date) +} + +export function formatTime(date: Date, timezone: string) { + return new Intl.DateTimeFormat('sv-SE', { + timeZone: timezone, + hour: 'numeric', + minute: 'numeric' + }).format(date) +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..c99ace1 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,17 @@ +import type { SqlTag } from 'd1-sql-tag' +import { insertLogLine } from '../db/statements' + +export type PersistentLogger = ( + level: 'info' | 'warn' | 'error', + message: string +) => void + +export function createPersistentLogger( + sql: SqlTag, + context: ExecutionContext +): PersistentLogger { + return (level, message) => { + console[level](message) + context.waitUntil(insertLogLine(sql, level, message).run()) + } +} diff --git a/src/webhooks/webhook-configs.ts b/src/webhooks/webhook-configs.ts new file mode 100644 index 0000000..6938082 --- /dev/null +++ b/src/webhooks/webhook-configs.ts @@ -0,0 +1,20 @@ +import { parseGithubWebhook } from './webhook-helper-github' +import { parseVercelWebhook } from './webhook-helper-vercel' +import type { WebhookConfig } from './webhook-types' + +export const webhooksConfigs: WebhookConfig[] = [ + { + id: 'github-webhook', + title: 'GitHub', + parseRequest(req) { + return parseGithubWebhook(req) + } + }, + { + id: 'vercel-webhook', + title: 'Vercel', + parseRequest(req) { + return parseVercelWebhook(req) + } + } +] diff --git a/src/webhooks/webhook-executor.ts b/src/webhooks/webhook-executor.ts new file mode 100644 index 0000000..1a4d6b3 --- /dev/null +++ b/src/webhooks/webhook-executor.ts @@ -0,0 +1,42 @@ +import type { SqlTag } from 'd1-sql-tag' +import type { HonoRequest } from 'hono' +import { HTTPException } from 'hono/http-exception' +import { insertEvent } from '../db/statements' +import type { PersistentLogger } from '../utils/logger' +import { webhooksConfigs } from './webhook-configs' + +interface Context { + sql: SqlTag + logger: PersistentLogger +} + +export async function executeWebhook( + { sql, logger }: Context, + webhookId: string, + req: HonoRequest +) { + const webhook = webhooksConfigs.find((it) => it.id === webhookId) + if (!webhook) { + logger('warn', `Webhook not found (${webhookId})`) + throw new HTTPException(404, { message: 'Webhook not found' }) + } + try { + const result = await webhook.parseRequest(req) + if (result) { + await insertEvent(sql, { + webhookId: webhookId, + probeId: null, + duration: null, + ...result + }).run() + } + } catch (error) { + logger( + 'error', + `Error while executing webhook (${webhookId}): ${ + error instanceof Error ? error.message : error + }` + ) + throw error + } +} diff --git a/src/webhooks/webhook-helper-github.ts b/src/webhooks/webhook-helper-github.ts new file mode 100644 index 0000000..01100d6 --- /dev/null +++ b/src/webhooks/webhook-helper-github.ts @@ -0,0 +1,17 @@ +import type { HonoRequest } from 'hono' +import type { WebhookResult } from './webhook-types' + +export async function parseGithubWebhook( + req: HonoRequest +): Promise { + // biome-ignore lint: I don't care + const type = req.header('x-github-event')! + + await req.text() + + return { + result: 'success', + category: type, + description: null + } +} diff --git a/src/webhooks/webhook-helper-vercel.ts b/src/webhooks/webhook-helper-vercel.ts new file mode 100644 index 0000000..7cb9401 --- /dev/null +++ b/src/webhooks/webhook-helper-vercel.ts @@ -0,0 +1,123 @@ +import type { HonoRequest } from 'hono' +import type { WebhookResult } from './webhook-types' + +// https://vercel.com/docs/observability/webhooks-overview/webhooks-api +// This is a best attempt at the Vercel webhook payload. It is not complete. + +type VercelWebhookPayload = { + id: string + createdAt: number +} & ( + | { + type: 'deployment.created' + payload: VercelSharedDeploymentPayload & { + alias: string[] + } + } + | { + type: 'deployment.succeeded' + payload: VercelSharedDeploymentPayload + } + | { + type: 'deployment.ready' + payload: VercelSharedDeploymentPayload + } + | { type: 'deployment.canceled'; payload: VercelSharedDeploymentPayload } + | { + type: 'deployment.error' + payload: VercelSharedDeploymentPayload + } + | { + type: 'deployment.check-rerequested' + payload: { + team: { id: string } + user: { id: string } + deployment: { id: string } + check: { id: string } + } + } + | { + type: 'project.created' + payload: { + team: { id: string } + user: { id: string } + project: { id: string; name: string } + } + } + | { + type: 'project.removed' + payload: { + team: { id: string } + user: { id: string } + project: { id: string; name: string } + } + } +) + +interface VercelSharedDeploymentPayload { + name: string + user: { id: string } + team: { id: string } + project: { id: string } + plan: 'pro' + regions: string[] + target: 'production' | 'staging' | null + type: 'LAMBDAS' + url: string + deployment: { + id: string + meta: { + githubCommitAuthorName: string + githubCommitMessage: string + githubCommitOrg: string + githubCommitRef: string + githubCommitRepo: string + githubCommitSha: string + githubDeployment: string + githubOrg: string + githubRepo: string + githubRepoOwnerType: string + githubCommitRepoId: string + githubRepoId: string + githubRepoVisibility: 'public' + githubCommitAuthorLogin: string + branchAlias: string + } + name: string + url: string + inspectorUrl: string + } + links: { + deployment: string + project: string + } +} + +export async function parseVercelWebhook( + req: HonoRequest +): Promise { + const body = await req.text() + const json = JSON.parse(body) as VercelWebhookPayload + + let description: string | null = null + const category = json.type + if (category.startsWith('deployment.')) { + const name = 'name' in json.payload ? json.payload.name : null + const target = 'target' in json.payload ? json.payload.target : null + description = name + + if (target !== 'production') { + console.log(`Ignoring ${category} ${name} for ${target}`) + return null + } + } + + const failure = ['.canceled', '.error'].some((suffix) => + category.endsWith(suffix) + ) + return { + result: failure ? 'failure' : 'success', + category, + description + } +} diff --git a/src/webhooks/webhook-types.ts b/src/webhooks/webhook-types.ts new file mode 100644 index 0000000..f807a39 --- /dev/null +++ b/src/webhooks/webhook-types.ts @@ -0,0 +1,13 @@ +import type { HonoRequest } from 'hono' + +export interface WebhookConfig { + id: string + title: string + parseRequest(req: HonoRequest): Promise +} + +export interface WebhookResult { + result: 'success' | 'failure' + category: string + description: string | null +} diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..ce8f438 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,9 @@ +import type { Config } from 'tailwindcss' + +export default { + content: ['./src/**/*.tsx'], + theme: { + extend: {} + }, + plugins: [] +} satisfies Config diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e80d0ad --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": ["ESNext"], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "moduleResolution": "node", + "types": ["@cloudflare/workers-types/2023-07-01"], + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + } +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..6228710 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,17 @@ +name = "simulation" +main = "src/index.tsx" +compatibility_date = "2024-02-11" + +[[d1_databases]] +binding = "DB" +database_name = "simulation" +database_id = "7a38d7c9-9f13-4f57-a551-c6cf03e39931" + +[site] +bucket = "./assets" + +[triggers] +crons = ["0 * * * *"] + +[dev] +ip = "0.0.0.0"