feat: setup oxlint and prettier for the project

This commit is contained in:
2026-08-28 20:03:19 +07:00
parent 9766edad03
commit 3dcc812cd4
65 changed files with 3940 additions and 1741 deletions
+10 -20
View File
@@ -1,18 +1,13 @@
import { and, eq, isNull, sql } from "drizzle-orm";
import type { Database } from "../db/client";
import { checks, incidents, monitors } from "../db/schema";
import type { CheckResult, Monitor } from "./run-check";
import { and, eq, isNull, sql } from 'drizzle-orm';
import type { Database } from '../db/client';
import { checks, incidents, monitors } from '../db/schema';
import type { CheckResult, Monitor } from './run-check';
export type IncidentTransition = "opened" | "resolved" | null;
export type IncidentTransition = 'opened' | 'resolved' | null;
type BatchStatement = Parameters<Database["batch"]>[0][number];
type BatchStatement = Parameters<Database['batch']>[0][number];
export function buildResultStatements(
db: Database,
monitor: Monitor,
result: CheckResult,
checkedAt: Date,
) {
export function buildResultStatements(db: Database, monitor: Monitor, result: CheckResult, checkedAt: Date) {
const statements: BatchStatement[] = [
db.insert(checks).values({
monitorId: monitor.id,
@@ -47,7 +42,7 @@ export function buildResultStatements(
updatedAt: checkedAt,
}),
);
transition = "opened";
transition = 'opened';
} else if (monitor.lastOk === false && result.ok) {
statements.push(
db
@@ -57,14 +52,9 @@ export function buildResultStatements(
durationMs: sql`${checkedAt.getTime()} - ${incidents.startedAt}`,
updatedAt: checkedAt,
})
.where(
and(
eq(incidents.monitorId, monitor.id),
isNull(incidents.resolvedAt),
),
),
.where(and(eq(incidents.monitorId, monitor.id), isNull(incidents.resolvedAt))),
);
transition = "resolved";
transition = 'resolved';
}
return { statements, transition };
+5 -9
View File
@@ -1,4 +1,4 @@
import type { monitors } from "../db/schema";
import type { monitors } from '../db/schema';
export type Monitor = typeof monitors.$inferSelect;
@@ -14,9 +14,9 @@ export async function runCheck(monitor: Monitor): Promise<CheckResult> {
try {
const response = await fetch(monitor.url, {
method: monitor.method,
redirect: "follow",
redirect: 'follow',
signal: AbortSignal.timeout(monitor.timeoutMs),
headers: { "User-Agent": "Upwatch/1.0 (+uptime monitor)" },
headers: { 'User-Agent': 'Upwatch/1.0 (+uptime monitor)' },
});
await response.body?.cancel();
const ok = response.status === monitor.expectedStatus;
@@ -24,18 +24,14 @@ export async function runCheck(monitor: Monitor): Promise<CheckResult> {
ok,
statusCode: response.status,
latencyMs: Date.now() - startedAt,
error: ok
? null
: `Expected HTTP ${monitor.expectedStatus}, received ${response.status}`,
error: ok ? null : `Expected HTTP ${monitor.expectedStatus}, received ${response.status}`,
};
} catch (error) {
return {
ok: false,
statusCode: null,
latencyMs: Date.now() - startedAt,
error: error instanceof Error
? error.message.slice(0, 200)
: "Request failed",
error: error instanceof Error ? error.message.slice(0, 200) : 'Request failed',
};
}
}
+20 -19
View File
@@ -1,9 +1,9 @@
import { and, eq, sql } from "drizzle-orm";
import { getDb } from "../db/client";
import { monitors } from "../db/schema";
import { sendIncidentAlert } from "../notifications/webhook";
import { buildResultStatements } from "./persist-result";
import { runCheck } from "./run-check";
import { and, eq, sql } from 'drizzle-orm';
import { getDb } from '../db/client';
import { monitors } from '../db/schema';
import { sendIncidentAlert } from '../notifications/webhook';
import { buildResultStatements } from './persist-result';
import { runCheck } from './run-check';
const MAX_MONITORS_PER_RUN = 40;
const CONCURRENCY = 10;
@@ -14,10 +14,7 @@ export type DueCheckSummary = {
down: number;
};
export async function runDueChecks(
env: Env,
ctx?: Pick<ExecutionContext, "waitUntil">,
): Promise<DueCheckSummary> {
export async function runDueChecks(env: Env, ctx?: Pick<ExecutionContext, 'waitUntil'>): Promise<DueCheckSummary> {
const db = getDb(env);
const now = Date.now();
const due = await db
@@ -60,16 +57,20 @@ export async function runDueChecks(
}));
const statements = persisted.flatMap((item) => item.statements);
await db.batch(statements as [typeof statements[number], ...typeof statements]);
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
const notifications = persisted.flatMap((item) => item.transition === null ? [] : [
sendIncidentAlert(env, {
monitor: item.monitor,
kind: item.transition,
result: item.result,
at: item.checkedAt,
}),
]);
const notifications = persisted.flatMap((item) =>
item.transition === null
? []
: [
sendIncidentAlert(env, {
monitor: item.monitor,
kind: item.transition,
result: item.result,
at: item.checkedAt,
}),
],
);
if (notifications.length > 0) {
const notificationWork = Promise.all(notifications).then(() => undefined);
if (ctx) ctx.waitUntil(notificationWork);
+2 -2
View File
@@ -1,5 +1,5 @@
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
import { drizzle } from 'drizzle-orm/d1';
import * as schema from './schema';
export const getDb = (env: Env) => drizzle(env.DB, { schema });
+74 -88
View File
@@ -1,125 +1,111 @@
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
export const adminCredentials = sqliteTable("admin_credentials", {
id: integer("id").primaryKey(),
passwordHash: text("password_hash").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
export const adminCredentials = sqliteTable('admin_credentials', {
id: integer('id').primaryKey(),
passwordHash: text('password_hash').notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const notificationSettings = sqliteTable("notification_settings", {
id: integer("id").primaryKey(),
webhookUrl: text("webhook_url"),
webhookEnabled: integer("webhook_enabled", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
export const notificationSettings = sqliteTable('notification_settings', {
id: integer('id').primaryKey(),
webhookUrl: text('webhook_url'),
webhookEnabled: integer('webhook_enabled', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
});
export const sessions = sqliteTable(
"sessions",
'sessions',
{
id: text("id").primaryKey(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
userAgent: text("user_agent"),
id: text('id').primaryKey(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
userAgent: text('user_agent'),
},
(table) => [index("sessions_expires_at_idx").on(table.expiresAt)],
(table) => [index('sessions_expires_at_idx').on(table.expiresAt)],
);
export const loginAttempts = sqliteTable(
"login_attempts",
'login_attempts',
{
id: integer("id").primaryKey({ autoIncrement: true }),
ipAddress: text("ip_address").notNull(),
attemptedAt: integer("attempted_at", { mode: "timestamp_ms" }).notNull(),
id: integer('id').primaryKey({ autoIncrement: true }),
ipAddress: text('ip_address').notNull(),
attemptedAt: integer('attempted_at', { mode: 'timestamp_ms' }).notNull(),
},
(table) => [index("login_attempts_ip_attempted_at_idx").on(table.ipAddress, table.attemptedAt)],
(table) => [index('login_attempts_ip_attempted_at_idx').on(table.ipAddress, table.attemptedAt)],
);
export const monitors = sqliteTable(
"monitors",
'monitors',
{
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
url: text("url").notNull(),
method: text("method").notNull().default("GET"),
expectedStatus: integer("expected_status").notNull().default(200),
intervalSeconds: integer("interval_seconds").notNull().default(300),
timeoutMs: integer("timeout_ms").notNull().default(10_000),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
alertsEnabled: integer("alerts_enabled", { mode: "boolean" }).notNull().default(true),
lastOk: integer("last_ok", { mode: "boolean" }),
lastStatusCode: integer("last_status_code"),
lastLatencyMs: integer("last_latency_ms"),
lastError: text("last_error"),
lastCheckedAt: integer("last_checked_at", { mode: "timestamp_ms" }),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
url: text('url').notNull(),
method: text('method').notNull().default('GET'),
expectedStatus: integer('expected_status').notNull().default(200),
intervalSeconds: integer('interval_seconds').notNull().default(300),
timeoutMs: integer('timeout_ms').notNull().default(10_000),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
alertsEnabled: integer('alerts_enabled', { mode: 'boolean' }).notNull().default(true),
lastOk: integer('last_ok', { mode: 'boolean' }),
lastStatusCode: integer('last_status_code'),
lastLatencyMs: integer('last_latency_ms'),
lastError: text('last_error'),
lastCheckedAt: integer('last_checked_at', { mode: 'timestamp_ms' }),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
},
(table) => [
index("monitors_enabled_last_checked_at_idx").on(
table.enabled,
table.lastCheckedAt,
),
],
(table) => [index('monitors_enabled_last_checked_at_idx').on(table.enabled, table.lastCheckedAt)],
);
export const checks = sqliteTable(
"checks",
'checks',
{
id: integer("id").primaryKey({ autoIncrement: true }),
monitorId: integer("monitor_id")
id: integer('id').primaryKey({ autoIncrement: true }),
monitorId: integer('monitor_id')
.notNull()
.references(() => monitors.id, { onDelete: "cascade" }),
ok: integer("ok", { mode: "boolean" }).notNull(),
statusCode: integer("status_code"),
latencyMs: integer("latency_ms"),
error: text("error"),
checkedAt: integer("checked_at", { mode: "timestamp_ms" }).notNull(),
.references(() => monitors.id, { onDelete: 'cascade' }),
ok: integer('ok', { mode: 'boolean' }).notNull(),
statusCode: integer('status_code'),
latencyMs: integer('latency_ms'),
error: text('error'),
checkedAt: integer('checked_at', { mode: 'timestamp_ms' }).notNull(),
},
(table) => [
index("checks_monitor_id_checked_at_idx").on(
table.monitorId,
table.checkedAt,
),
],
(table) => [index('checks_monitor_id_checked_at_idx').on(table.monitorId, table.checkedAt)],
);
export const incidents = sqliteTable(
"incidents",
'incidents',
{
id: integer("id").primaryKey({ autoIncrement: true }),
monitorId: integer("monitor_id")
id: integer('id').primaryKey({ autoIncrement: true }),
monitorId: integer('monitor_id')
.notNull()
.references(() => monitors.id, { onDelete: "cascade" }),
startedAt: integer("started_at", { mode: "timestamp_ms" }).notNull(),
resolvedAt: integer("resolved_at", { mode: "timestamp_ms" }),
startStatusCode: integer("start_status_code"),
startError: text("start_error"),
durationMs: integer("duration_ms"),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
.references(() => monitors.id, { onDelete: 'cascade' }),
startedAt: integer('started_at', { mode: 'timestamp_ms' }).notNull(),
resolvedAt: integer('resolved_at', { mode: 'timestamp_ms' }),
startStatusCode: integer('start_status_code'),
startError: text('start_error'),
durationMs: integer('duration_ms'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
},
(table) => [
index("incidents_monitor_id_started_at_idx").on(table.monitorId, table.startedAt),
],
(table) => [index('incidents_monitor_id_started_at_idx').on(table.monitorId, table.startedAt)],
);
export const monitorDailyStats = sqliteTable(
"monitor_daily_stats",
'monitor_daily_stats',
{
id: integer("id").primaryKey({ autoIncrement: true }),
monitorId: integer("monitor_id")
id: integer('id').primaryKey({ autoIncrement: true }),
monitorId: integer('monitor_id')
.notNull()
.references(() => monitors.id, { onDelete: "cascade" }),
day: integer("day", { mode: "timestamp_ms" }).notNull(),
totalChecks: integer("total_checks").notNull(),
upChecks: integer("up_checks").notNull(),
avgLatencyMs: integer("avg_latency_ms"),
minLatencyMs: integer("min_latency_ms"),
maxLatencyMs: integer("max_latency_ms"),
.references(() => monitors.id, { onDelete: 'cascade' }),
day: integer('day', { mode: 'timestamp_ms' }).notNull(),
totalChecks: integer('total_checks').notNull(),
upChecks: integer('up_checks').notNull(),
avgLatencyMs: integer('avg_latency_ms'),
minLatencyMs: integer('min_latency_ms'),
maxLatencyMs: integer('max_latency_ms'),
},
(table) => [
uniqueIndex("monitor_daily_stats_monitor_id_day_uidx").on(table.monitorId, table.day),
],
(table) => [uniqueIndex('monitor_daily_stats_monitor_id_day_uidx').on(table.monitorId, table.day)],
);
+26 -24
View File
@@ -1,19 +1,19 @@
import { Hono } from "hono";
import { csrf } from "hono/csrf";
import { runDueChecks } from "./checks/run-due-checks";
import authRoutes from "./routes/auth";
import monitorRoutes from "./routes/monitors";
import settingsRoutes from "./routes/settings";
import statusRoutes from "./routes/status";
import { cleanupExpiredAuthRecords } from "./scheduled/cleanup";
import { runDailyRollup } from "./scheduled/rollup";
import { Hono } from 'hono';
import { csrf } from 'hono/csrf';
import { runDueChecks } from './checks/run-due-checks';
import authRoutes from './routes/auth';
import monitorRoutes from './routes/monitors';
import settingsRoutes from './routes/settings';
import statusRoutes from './routes/status';
import { cleanupExpiredAuthRecords } from './scheduled/cleanup';
import { runDailyRollup } from './scheduled/rollup';
const app = new Hono<{ Bindings: Env }>();
app.use("/api/*", csrf());
app.use('/api/*', csrf());
app.get("/api/health", async (context) => {
const db = await context.env.DB.prepare("SELECT 1 AS ok").first<{
app.get('/api/health', async (context) => {
const db = await context.env.DB.prepare('SELECT 1 AS ok').first<{
ok: number;
}>();
@@ -24,22 +24,24 @@ app.get("/api/health", async (context) => {
});
});
app.route("/", authRoutes);
app.route("/api/monitors", monitorRoutes);
app.route("/api/settings", settingsRoutes);
app.route("/api/status", statusRoutes);
app.route('/', authRoutes);
app.route('/api/monitors', monitorRoutes);
app.route('/api/settings', settingsRoutes);
app.route('/api/status', statusRoutes);
export default {
fetch: app.fetch,
async scheduled(controller, env, ctx) {
if (controller.cron === "5 0 * * *") {
if (controller.cron === '5 0 * * *') {
const result = await runDailyRollup(env, new Date(controller.scheduledTime));
console.log(JSON.stringify({
message: "daily rollup completed",
cron: controller.cron,
scheduledTime: controller.scheduledTime,
...result,
}));
console.log(
JSON.stringify({
message: 'daily rollup completed',
cron: controller.cron,
scheduledTime: controller.scheduledTime,
...result,
}),
);
return;
}
@@ -47,7 +49,7 @@ export default {
const result = await runDueChecks(env, ctx);
console.log(
JSON.stringify({
message: "scheduled run completed",
message: 'scheduled run completed',
cron: controller.cron,
scheduledTime: controller.scheduledTime,
...result,
+8 -18
View File
@@ -1,4 +1,4 @@
const HASH_ALGORITHM = "SHA-256";
const HASH_ALGORITHM = 'SHA-256';
const HASH_BYTES = 32;
const SALT_BYTES = 16;
@@ -7,7 +7,7 @@ const SALT_BYTES = 16;
export const PBKDF2_ITERATIONS = 25_000;
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
@@ -18,18 +18,12 @@ function base64ToBytes(value: string) {
}
async function derivePassword(plain: string, salt: ArrayBuffer, iterations: number) {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(plain),
"PBKDF2",
false,
["deriveBits"],
);
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(plain), 'PBKDF2', false, ['deriveBits']);
return new Uint8Array(
await crypto.subtle.deriveBits(
{
name: "PBKDF2",
name: 'PBKDF2',
hash: HASH_ALGORITHM,
salt,
iterations,
@@ -48,12 +42,12 @@ export async function hashPassword(plain: string) {
}
export async function verifyPassword(plain: string, stored: string) {
const [scheme, digest, iterationValue, saltValue, hashValue, ...extra] = stored.split("$");
const [scheme, digest, iterationValue, saltValue, hashValue, ...extra] = stored.split('$');
const iterations = Number(iterationValue);
if (
scheme !== "pbkdf2" ||
digest !== "sha256" ||
scheme !== 'pbkdf2' ||
digest !== 'sha256' ||
extra.length > 0 ||
!Number.isSafeInteger(iterations) ||
iterations < 1 ||
@@ -69,11 +63,7 @@ export async function verifyPassword(plain: string, stored: string) {
const expected = base64ToBytes(hashValue);
if (salt.length !== SALT_BYTES || expected.length !== HASH_BYTES) return false;
const actual = await derivePassword(
plain,
Uint8Array.from(salt).buffer,
iterations,
);
const actual = await derivePassword(plain, Uint8Array.from(salt).buffer, iterations);
const subtle = crypto.subtle as SubtleCrypto & {
timingSafeEqual(a: ArrayBufferView, b: ArrayBufferView): boolean;
};
+7 -7
View File
@@ -1,7 +1,7 @@
import { getCookie } from "hono/cookie";
import { createMiddleware } from "hono/factory";
import { getDb } from "../db/client";
import { hasValidSession, SESSION_COOKIE } from "./session";
import { getCookie } from 'hono/cookie';
import { createMiddleware } from 'hono/factory';
import { getDb } from '../db/client';
import { hasValidSession, SESSION_COOKIE } from './session';
export type AuthVariables = {
authenticated: true;
@@ -12,11 +12,11 @@ export const requireAuth = createMiddleware<{
Variables: AuthVariables;
}>(async (context, next) => {
const token = getCookie(context, SESSION_COOKIE);
if (!token) return context.json({ message: "Authentication required" }, 401);
if (!token) return context.json({ message: 'Authentication required' }, 401);
const authenticated = await hasValidSession(getDb(context.env), token);
if (!authenticated) return context.json({ message: "Authentication required" }, 401);
if (!authenticated) return context.json({ message: 'Authentication required' }, 401);
context.set("authenticated", true);
context.set('authenticated', true);
await next();
});
+15 -28
View File
@@ -1,38 +1,33 @@
import { and, eq, gt } from "drizzle-orm";
import type { CookieOptions } from "hono/utils/cookie";
import type { Database } from "../db/client";
import { sessions } from "../db/schema";
import { and, eq, gt } from 'drizzle-orm';
import type { CookieOptions } from 'hono/utils/cookie';
import type { Database } from '../db/client';
import { sessions } from '../db/schema';
export const SESSION_COOKIE = "upwatch_session";
export const SESSION_COOKIE = 'upwatch_session';
export const SESSION_DURATION_SECONDS = 7 * 24 * 60 * 60;
function bytesToBase64Url(bytes: Uint8Array) {
let binary = "";
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
}
async function sha256Hex(value: string) {
const digest = new Uint8Array(
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)),
);
return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)));
return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
export function sessionCookieOptions(requestUrl: string): CookieOptions {
return {
httpOnly: true,
sameSite: "Lax",
path: "/",
sameSite: 'Lax',
path: '/',
maxAge: SESSION_DURATION_SECONDS,
secure: new URL(requestUrl).protocol === "https:",
secure: new URL(requestUrl).protocol === 'https:',
};
}
export async function createSession(
db: Database,
userAgent: string | null,
) {
export async function createSession(db: Database, userAgent: string | null) {
const token = bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32)));
const now = new Date();
@@ -46,19 +41,11 @@ export async function createSession(
return token;
}
export async function hasValidSession(
db: Database,
token: string,
): Promise<boolean> {
export async function hasValidSession(db: Database, token: string): Promise<boolean> {
const [result] = await db
.select({ id: sessions.id })
.from(sessions)
.where(
and(
eq(sessions.id, await sha256Hex(token)),
gt(sessions.expiresAt, new Date()),
),
)
.where(and(eq(sessions.id, await sha256Hex(token)), gt(sessions.expiresAt, new Date())))
.limit(1);
return result !== undefined;
+41 -42
View File
@@ -1,17 +1,17 @@
import { eq } from "drizzle-orm";
import type { CheckResult, Monitor } from "../checks/run-check";
import { getDb } from "../db/client";
import { notificationSettings } from "../db/schema";
import { eq } from 'drizzle-orm';
import type { CheckResult, Monitor } from '../checks/run-check';
import { getDb } from '../db/client';
import { notificationSettings } from '../db/schema';
export type IncidentAlert = {
monitor: Monitor;
kind: "opened" | "resolved";
kind: 'opened' | 'resolved';
result: CheckResult;
at: Date;
};
type WebhookPayload = {
event: "down" | "recovered" | "test";
event: 'down' | 'recovered' | 'test';
monitor: { id: number; name: string; url: string };
statusCode: number | null;
error: string | null;
@@ -20,10 +20,10 @@ type WebhookPayload = {
async function postWebhook(url: string, payload: WebhookPayload): Promise<boolean> {
const response = await fetch(url, {
method: "POST",
method: 'POST',
headers: {
"Content-Type": "application/json",
"User-Agent": "Upwatch/1.0 (+incident webhook)",
'Content-Type': 'application/json',
'User-Agent': 'Upwatch/1.0 (+incident webhook)',
},
body: JSON.stringify(payload),
});
@@ -34,59 +34,58 @@ async function postWebhook(url: string, payload: WebhookPayload): Promise<boolea
export async function sendTestWebhook(url: string): Promise<boolean> {
try {
return await postWebhook(url, {
event: "test",
monitor: { id: 0, name: "Upwatch test", url: "https://example.com/health" },
event: 'test',
monitor: { id: 0, name: 'Upwatch test', url: 'https://example.com/health' },
statusCode: 200,
error: null,
at: new Date().toISOString(),
});
} catch (error) {
console.error(JSON.stringify({
message: "test webhook failed",
error: error instanceof Error ? error.message : String(error),
}));
console.error(
JSON.stringify({
message: 'test webhook failed',
error: error instanceof Error ? error.message : String(error),
}),
);
return false;
}
}
export async function sendIncidentAlert(
env: Env,
alert: IncidentAlert,
): Promise<boolean> {
export async function sendIncidentAlert(env: Env, alert: IncidentAlert): Promise<boolean> {
if (!alert.monitor.alertsEnabled) return false;
try {
const [settings] = await getDb(env)
.select()
.from(notificationSettings)
.where(eq(notificationSettings.id, 1))
.limit(1);
const [settings] = await getDb(env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
if (!settings?.webhookEnabled || !settings.webhookUrl) return false;
const ok = await postWebhook(settings.webhookUrl, {
event: alert.kind === "opened" ? "down" : "recovered",
monitor: {
id: alert.monitor.id,
name: alert.monitor.name,
url: alert.monitor.url,
},
statusCode: alert.result.statusCode,
error: alert.result.error,
at: alert.at.toISOString(),
event: alert.kind === 'opened' ? 'down' : 'recovered',
monitor: {
id: alert.monitor.id,
name: alert.monitor.name,
url: alert.monitor.url,
},
statusCode: alert.result.statusCode,
error: alert.result.error,
at: alert.at.toISOString(),
});
if (!ok) {
console.warn(JSON.stringify({
message: "incident webhook returned an error",
monitorId: alert.monitor.id,
}));
console.warn(
JSON.stringify({
message: 'incident webhook returned an error',
monitorId: alert.monitor.id,
}),
);
}
return ok;
} catch (error) {
console.error(JSON.stringify({
message: "incident webhook failed",
error: error instanceof Error ? error.message : String(error),
monitorId: alert.monitor.id,
}));
console.error(
JSON.stringify({
message: 'incident webhook failed',
error: error instanceof Error ? error.message : String(error),
monitorId: alert.monitor.id,
}),
);
return false;
}
}
+23 -50
View File
@@ -1,17 +1,11 @@
import { and, count, eq, gte } from "drizzle-orm";
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { getDb } from "../db/client";
import { adminCredentials, loginAttempts } from "../db/schema";
import { verifyPassword } from "../lib/password";
import { requireAuth, type AuthVariables } from "../lib/require-auth";
import {
createSession,
hasValidSession,
revokeSession,
SESSION_COOKIE,
sessionCookieOptions,
} from "../lib/session";
import { and, count, eq, gte } from 'drizzle-orm';
import { Hono } from 'hono';
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
import { getDb } from '../db/client';
import { adminCredentials, loginAttempts } from '../db/schema';
import { verifyPassword } from '../lib/password';
import { requireAuth, type AuthVariables } from '../lib/require-auth';
import { createSession, hasValidSession, revokeSession, SESSION_COOKIE, sessionCookieOptions } from '../lib/session';
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const MAX_FAILED_ATTEMPTS = 10;
@@ -23,42 +17,28 @@ type LoginBody = {
const authRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
authRoutes.post("/api/auth/login", async (context) => {
authRoutes.post('/api/auth/login', async (context) => {
let body: LoginBody;
try {
body = await context.req.json<LoginBody>();
} catch {
return context.json({ message: "Invalid request body" }, 400);
return context.json({ message: 'Invalid request body' }, 400);
}
if (
typeof body.password !== "string" ||
body.password.length < 8
) {
return context.json(
{ message: "Enter a password of at least 8 characters" },
400,
);
if (typeof body.password !== 'string' || body.password.length < 8) {
return context.json({ message: 'Enter a password of at least 8 characters' }, 400);
}
const db = getDb(context.env);
const ipAddress = context.req.header("CF-Connecting-IP") ?? "unknown";
const ipAddress = context.req.header('CF-Connecting-IP') ?? 'unknown';
const cutoff = new Date(Date.now() - LOGIN_WINDOW_MS);
const [attemptResult] = await db
.select({ value: count() })
.from(loginAttempts)
.where(
and(
eq(loginAttempts.ipAddress, ipAddress),
gte(loginAttempts.attemptedAt, cutoff),
),
);
.where(and(eq(loginAttempts.ipAddress, ipAddress), gte(loginAttempts.attemptedAt, cutoff)));
if ((attemptResult?.value ?? 0) >= MAX_FAILED_ATTEMPTS) {
return context.json(
{ message: "Too many login attempts. Try again later" },
429,
);
return context.json({ message: 'Too many login attempts. Try again later' }, 429);
}
const [credential] = await db
@@ -68,41 +48,34 @@ authRoutes.post("/api/auth/login", async (context) => {
.from(adminCredentials)
.where(eq(adminCredentials.id, ADMIN_CREDENTIAL_ID))
.limit(1);
const passwordMatches = credential
? await verifyPassword(body.password, credential.passwordHash)
: false;
const passwordMatches = credential ? await verifyPassword(body.password, credential.passwordHash) : false;
if (!credential || !passwordMatches) {
await db.insert(loginAttempts).values({ ipAddress, attemptedAt: new Date() });
return context.json({ message: "Password is incorrect" }, 401);
return context.json({ message: 'Password is incorrect' }, 401);
}
await db.delete(loginAttempts).where(eq(loginAttempts.ipAddress, ipAddress));
const token = await createSession(
db,
context.req.header("User-Agent") ?? null,
);
const token = await createSession(db, context.req.header('User-Agent') ?? null);
setCookie(context, SESSION_COOKIE, token, sessionCookieOptions(context.req.url));
return context.json({ authenticated: true });
});
authRoutes.post("/api/auth/logout", requireAuth, async (context) => {
authRoutes.post('/api/auth/logout', requireAuth, async (context) => {
const token = getCookie(context, SESSION_COOKIE);
if (token) await revokeSession(getDb(context.env), token);
deleteCookie(context, SESSION_COOKIE, {
path: "/",
secure: new URL(context.req.url).protocol === "https:",
path: '/',
secure: new URL(context.req.url).protocol === 'https:',
});
return context.json({ ok: true });
});
authRoutes.get("/api/auth/me", async (context) => {
authRoutes.get('/api/auth/me', async (context) => {
const token = getCookie(context, SESSION_COOKIE);
const authenticated = token
? await hasValidSession(getDb(context.env), token)
: false;
const authenticated = token ? await hasValidSession(getDb(context.env), token) : false;
return context.json({ authenticated });
});
+147 -154
View File
@@ -1,13 +1,13 @@
import { and, desc, eq, gte, isNull, lt, or, sql } from "drizzle-orm";
import { Hono } from "hono";
import { buildResultStatements } from "../checks/persist-result";
import { runCheck } from "../checks/run-check";
import { getDb } from "../db/client";
import { checks, incidents, monitorDailyStats, monitors } from "../db/schema";
import { requireAuth, type AuthVariables } from "../lib/require-auth";
import { sendIncidentAlert } from "../notifications/webhook";
import { and, desc, eq, gte, isNull, or, sql } from 'drizzle-orm';
import { Hono } from 'hono';
import { buildResultStatements } from '../checks/persist-result';
import { runCheck } from '../checks/run-check';
import { getDb } from '../db/client';
import { checks, incidents, monitors } from '../db/schema';
import { requireAuth, type AuthVariables } from '../lib/require-auth';
import { sendIncidentAlert } from '../notifications/webhook';
type MonitorMethod = "GET" | "HEAD" | "POST";
type MonitorMethod = 'GET' | 'HEAD' | 'POST';
type ParsedMonitorInput = {
name?: string;
@@ -20,11 +20,9 @@ type ParsedMonitorInput = {
alertsEnabled?: boolean;
};
type ParseResult =
| { ok: true; value: ParsedMonitorInput }
| { ok: false; message: string };
type ParseResult = { ok: true; value: ParsedMonitorInput } | { ok: false; message: string };
const METHODS = new Set<MonitorMethod>(["GET", "HEAD", "POST"]);
const METHODS = new Set<MonitorMethod>(['GET', 'HEAD', 'POST']);
const FAVICON_CACHE_SECONDS = 86_400;
const FAVICON_FETCH_TIMEOUT_MS = 5_000;
const MAX_FAVICON_BYTES = 1024 * 1024;
@@ -42,31 +40,33 @@ type EdgeCache = {
};
function isPrivateHostname(rawHostname: string): boolean {
const hostname = rawHostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
if (hostname === "localhost" || hostname.endsWith(".localhost")) return true;
const hostname = rawHostname
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.$/, '');
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
const ipv4 = hostname.split(".").map(Number);
const ipv4 = hostname.split('.').map(Number);
if (ipv4.length === 4 && ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
const [first, second] = ipv4;
return first === 0
|| first === 10
|| first === 127
|| (first === 169 && second === 254)
|| (first === 172 && second >= 16 && second <= 31)
|| (first === 192 && second === 168);
return (
first === 0 ||
first === 10 ||
first === 127 ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
);
}
if (hostname === "::" || hostname === "::1") return true;
if (hostname === '::' || hostname === '::1') return true;
if (/^f[cd][0-9a-f]{2}(?::|$)/i.test(hostname) || /^fe[89ab][0-9a-f](?::|$)/i.test(hostname)) return true;
const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
return mappedIpv4 ? isPrivateHostname(mappedIpv4[1]) : false;
}
function isSafeRemoteUrl(url: URL) {
return (url.protocol === "http:" || url.protocol === "https:")
&& !url.username
&& !url.password
&& !isPrivateHostname(url.hostname);
return (url.protocol === 'http:' || url.protocol === 'https:') && !url.username && !url.password && !isPrivateHostname(url.hostname);
}
async function readBodyLimited(response: Response, maximum: number, truncate: boolean) {
@@ -117,16 +117,16 @@ async function fetchRemote(url: URL, maximumBytes: number, truncate = false) {
if (!isSafeRemoteUrl(currentUrl)) return null;
const response = await fetch(currentUrl, {
headers: {
Accept: "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,text/html;q=0.5,*/*;q=0.1",
"User-Agent": "Upwatch Favicon Proxy/1.0",
Accept: 'image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,text/html;q=0.5,*/*;q=0.1',
'User-Agent': 'Upwatch Favicon Proxy/1.0',
},
redirect: "manual",
redirect: 'manual',
signal: controller.signal,
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
if (response.body) await response.body.cancel().catch(() => undefined);
const location = response.headers.get("Location");
const location = response.headers.get('Location');
if (!location || redirects === MAX_REDIRECTS) return null;
try {
currentUrl = new URL(location, currentUrl);
@@ -141,7 +141,7 @@ async function fetchRemote(url: URL, maximumBytes: number, truncate = false) {
return null;
}
const declaredLength = Number(response.headers.get("Content-Length"));
const declaredLength = Number(response.headers.get('Content-Length'));
if (!truncate && Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
if (response.body) await response.body.cancel().catch(() => undefined);
return null;
@@ -161,9 +161,9 @@ async function fetchRemote(url: URL, maximumBytes: number, truncate = false) {
}
function imageContentType(headers: Headers) {
const contentType = headers.get("Content-Type")?.split(";", 1)[0].trim().toLowerCase();
if (!contentType || contentType === "application/octet-stream") return "image/x-icon";
if (!contentType.startsWith("image/")) {
const contentType = headers.get('Content-Type')?.split(';', 1)[0].trim().toLowerCase();
if (!contentType || contentType === 'application/octet-stream') return 'image/x-icon';
if (!contentType.startsWith('image/')) {
return null;
}
return contentType;
@@ -172,7 +172,7 @@ function imageContentType(headers: Headers) {
function readTagAttribute(tag: string, name: string) {
const attributes = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
for (const match of tag.matchAll(attributes)) {
if (match[1].toLowerCase() === name) return match[2] ?? match[3] ?? match[4] ?? "";
if (match[1].toLowerCase() === name) return match[2] ?? match[3] ?? match[4] ?? '';
}
return null;
}
@@ -182,7 +182,7 @@ function findFaviconUrl(html: string, pageUrl: URL) {
const head = closingHead >= 0 ? html.slice(0, closingHead) : html;
let baseUrl = pageUrl;
const baseTag = head.match(/<base\b[^>]*>/i)?.[0];
const baseHref = baseTag ? readTagAttribute(baseTag, "href") : null;
const baseHref = baseTag ? readTagAttribute(baseTag, 'href') : null;
if (baseHref) {
try {
const candidate = new URL(baseHref, pageUrl);
@@ -193,9 +193,9 @@ function findFaviconUrl(html: string, pageUrl: URL) {
}
for (const match of head.matchAll(/<link\b[^>]*>/gi)) {
const rel = readTagAttribute(match[0], "rel")?.toLowerCase().split(/\s+/) ?? [];
if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) continue;
const href = readTagAttribute(match[0], "href");
const rel = readTagAttribute(match[0], 'rel')?.toLowerCase().split(/\s+/) ?? [];
if (!rel.includes('icon') && !rel.includes('apple-touch-icon')) continue;
const href = readTagAttribute(match[0], 'href');
if (!href) continue;
try {
const faviconUrl = new URL(href, baseUrl);
@@ -217,7 +217,7 @@ export async function resolveFavicon(siteUrl: string): Promise<FaviconResult | n
if (!isSafeRemoteUrl(site)) return null;
const origin = new URL(site.origin);
const defaultIcon = await fetchRemote(new URL("/favicon.ico", origin), MAX_FAVICON_BYTES);
const defaultIcon = await fetchRemote(new URL('/favicon.ico', origin), MAX_FAVICON_BYTES);
if (defaultIcon && defaultIcon.body.byteLength > 0) {
const contentType = imageContentType(defaultIcon.headers);
if (contentType) return { body: defaultIcon.body, contentType };
@@ -225,8 +225,8 @@ export async function resolveFavicon(siteUrl: string): Promise<FaviconResult | n
const page = await fetchRemote(origin, MAX_HEAD_BYTES, true);
if (!page || page.body.byteLength === 0) return null;
const pageContentType = page.headers.get("Content-Type")?.toLowerCase();
if (pageContentType && !pageContentType.includes("text/html") && !pageContentType.includes("application/xhtml+xml")) {
const pageContentType = page.headers.get('Content-Type')?.toLowerCase();
if (pageContentType && !pageContentType.includes('text/html') && !pageContentType.includes('application/xhtml+xml')) {
return null;
}
const faviconUrl = findFaviconUrl(new TextDecoder().decode(page.body), page.url);
@@ -239,7 +239,7 @@ export async function resolveFavicon(siteUrl: string): Promise<FaviconResult | n
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseInteger(
@@ -258,40 +258,40 @@ function parseInteger(
}
export function parseMonitorInput(body: unknown, partial = false): ParseResult {
if (!isRecord(body)) return { ok: false, message: "Invalid request body" };
if (!isRecord(body)) return { ok: false, message: 'Invalid request body' };
const value: ParsedMonitorInput = {};
if (!partial || "name" in body) {
if (typeof body.name !== "string" || body.name.trim().length < 1 || body.name.trim().length > 100) {
return { ok: false, message: "Name must be between 1 and 100 characters" };
if (!partial || 'name' in body) {
if (typeof body.name !== 'string' || body.name.trim().length < 1 || body.name.trim().length > 100) {
return { ok: false, message: 'Name must be between 1 and 100 characters' };
}
value.name = body.name.trim();
}
if (!partial || "url" in body) {
if (typeof body.url !== "string") {
return { ok: false, message: "Enter a valid http or https URL" };
if (!partial || 'url' in body) {
if (typeof body.url !== 'string') {
return { ok: false, message: 'Enter a valid http or https URL' };
}
try {
const url = new URL(body.url);
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Invalid protocol");
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Invalid protocol');
value.url = url.toString();
} catch {
return { ok: false, message: "Enter a valid http or https URL" };
return { ok: false, message: 'Enter a valid http or https URL' };
}
}
if (!partial || "method" in body) {
if (typeof body.method !== "string" || !METHODS.has(body.method as MonitorMethod)) {
return { ok: false, message: "Method must be GET, HEAD, or POST" };
if (!partial || 'method' in body) {
if (typeof body.method !== 'string' || !METHODS.has(body.method as MonitorMethod)) {
return { ok: false, message: 'Method must be GET, HEAD, or POST' };
}
value.method = body.method as MonitorMethod;
}
for (const [key, label, minimum, maximum] of [
["expectedStatus", "expectedStatus", 100, 599],
["intervalSeconds", "intervalSeconds", 300, 86_400],
["timeoutMs", "timeoutMs", 1_000, 30_000],
['expectedStatus', 'expectedStatus', 100, 599],
['intervalSeconds', 'intervalSeconds', 300, 86_400],
['timeoutMs', 'timeoutMs', 1_000, 30_000],
] as const) {
if (!partial || key in body) {
const parsed = parseInteger(body[key], label, minimum, maximum);
@@ -300,15 +300,15 @@ export function parseMonitorInput(body: unknown, partial = false): ParseResult {
}
}
if ("enabled" in body) {
if (typeof body.enabled !== "boolean") {
return { ok: false, message: "enabled must be a boolean" };
if ('enabled' in body) {
if (typeof body.enabled !== 'boolean') {
return { ok: false, message: 'enabled must be a boolean' };
}
value.enabled = body.enabled;
}
if ("alertsEnabled" in body) {
if (typeof body.alertsEnabled !== "boolean") {
return { ok: false, message: "alertsEnabled must be a boolean" };
if ('alertsEnabled' in body) {
if (typeof body.alertsEnabled !== 'boolean') {
return { ok: false, message: 'alertsEnabled must be a boolean' };
}
value.alertsEnabled = body.alertsEnabled;
}
@@ -335,10 +335,7 @@ type StatsWindow = {
incidentCount: number;
};
function asStatsWindow(
row: { totalChecks: number; upChecks: number; avgLatencyMs: number | null },
incidentCount: number,
): StatsWindow {
function asStatsWindow(row: { totalChecks: number; upChecks: number; avgLatencyMs: number | null }, incidentCount: number): StatsWindow {
return {
uptimePct: row.totalChecks > 0 ? Math.round((row.upChecks / row.totalChecks) * 100_000) / 1_000 : null,
totalChecks: row.totalChecks,
@@ -350,38 +347,33 @@ function asStatsWindow(
const monitorRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
monitorRoutes.use("*", requireAuth);
monitorRoutes.use('*', requireAuth);
monitorRoutes.get("/", async (context) => {
monitorRoutes.get('/', async (context) => {
const rows = await getDb(context.env).select().from(monitors).orderBy(monitors.createdAt);
return context.json({ monitors: rows });
});
monitorRoutes.get("/:id", async (context) => {
const id = parseId(context.req.param("id"));
if (id === null) return context.json({ message: "Monitor not found" }, 404);
monitorRoutes.get('/:id', async (context) => {
const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const [monitor] = await getDb(context.env).select().from(monitors).where(eq(monitors.id, id)).limit(1);
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
return context.json({ monitor });
});
monitorRoutes.get("/:id/checks", async (context) => {
const id = parseId(context.req.param("id"));
if (id === null) return context.json({ message: "Monitor not found" }, 404);
const limit = parseLimit(context.req.query("limit"), 100, 500);
const rows = await getDb(context.env)
.select()
.from(checks)
.where(eq(checks.monitorId, id))
.orderBy(desc(checks.checkedAt))
.limit(limit);
monitorRoutes.get('/:id/checks', async (context) => {
const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const limit = parseLimit(context.req.query('limit'), 100, 500);
const rows = await getDb(context.env).select().from(checks).where(eq(checks.monitorId, id)).orderBy(desc(checks.checkedAt)).limit(limit);
return context.json({ checks: rows });
});
monitorRoutes.get("/:id/incidents", async (context) => {
const id = parseId(context.req.param("id"));
if (id === null) return context.json({ message: "Monitor not found" }, 404);
const limit = parseLimit(context.req.query("limit"), 50, 200);
monitorRoutes.get('/:id/incidents', async (context) => {
const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const limit = parseLimit(context.req.query('limit'), 50, 200);
const rows = await getDb(context.env)
.select()
.from(incidents)
@@ -391,34 +383,38 @@ monitorRoutes.get("/:id/incidents", async (context) => {
return context.json({ incidents: rows });
});
monitorRoutes.get("/:id/stats", async (context) => {
const id = parseId(context.req.param("id"));
if (id === null) return context.json({ message: "Monitor not found" }, 404);
monitorRoutes.get('/:id/stats', async (context) => {
const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const db = getDb(context.env);
const [monitor] = await db.select({ id: monitors.id }).from(monitors).where(eq(monitors.id, id)).limit(1);
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
const now = Date.now();
const currentDayMs = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate());
const windows = [
{ key: "24h", start: now - 24 * 60 * 60 * 1000, raw: true },
{ key: "7d", start: now - 7 * 24 * 60 * 60 * 1000, raw: true },
{ key: "30d", start: currentDayMs - 29 * 24 * 60 * 60 * 1000, raw: false },
{ key: "90d", start: currentDayMs - 89 * 24 * 60 * 60 * 1000, raw: false },
{ key: '24h', start: now - 24 * 60 * 60 * 1000, raw: true },
{ key: '7d', start: now - 7 * 24 * 60 * 60 * 1000, raw: true },
{ key: '30d', start: currentDayMs - 29 * 24 * 60 * 60 * 1000, raw: false },
{ key: '90d', start: currentDayMs - 89 * 24 * 60 * 60 * 1000, raw: false },
] as const;
const results = await Promise.all(windows.map(async (window) => {
const [aggregate] = window.raw
? await db.select({
totalChecks: sql<number>`count(*)`,
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
avgLatencyMs: sql<number | null>`round(avg(${checks.latencyMs}))`,
}).from(checks).where(and(eq(checks.monitorId, id), gte(checks.checkedAt, new Date(window.start))))
: await db.select({
totalChecks: sql<number>`coalesce(sum(total_checks), 0)`,
upChecks: sql<number>`coalesce(sum(up_checks), 0)`,
avgLatencyMs: sql<number | null>`round(sum(avg_latency_ms * total_checks) / nullif(sum(total_checks), 0))`,
}).from(sql`(
const results = await Promise.all(
windows.map(async (window) => {
const [aggregate] = window.raw
? await db
.select({
totalChecks: sql<number>`count(*)`,
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
avgLatencyMs: sql<number | null>`round(avg(${checks.latencyMs}))`,
})
.from(checks)
.where(and(eq(checks.monitorId, id), gte(checks.checkedAt, new Date(window.start))))
: await db.select({
totalChecks: sql<number>`coalesce(sum(total_checks), 0)`,
upChecks: sql<number>`coalesce(sum(up_checks), 0)`,
avgLatencyMs: sql<number | null>`round(sum(avg_latency_ms * total_checks) / nullif(sum(total_checks), 0))`,
}).from(sql`(
select total_checks, up_checks, avg_latency_ms
from monitor_daily_stats
where monitor_id = ${id} and day >= ${window.start} and day < ${currentDayMs}
@@ -427,28 +423,28 @@ monitorRoutes.get("/:id/stats", async (context) => {
from checks
where monitor_id = ${id} and checked_at >= ${currentDayMs}
)`);
const [incidentAggregate] = await db.select({ count: sql<number>`count(*)` })
.from(incidents)
.where(and(
eq(incidents.monitorId, id),
gte(incidents.startedAt, new Date(window.start)),
or(isNull(incidents.resolvedAt), gte(incidents.resolvedAt, new Date(window.start))),
));
return [window.key, asStatsWindow(aggregate, incidentAggregate.count)] as const;
}));
const [incidentAggregate] = await db
.select({ count: sql<number>`count(*)` })
.from(incidents)
.where(
and(
eq(incidents.monitorId, id),
gte(incidents.startedAt, new Date(window.start)),
or(isNull(incidents.resolvedAt), gte(incidents.resolvedAt, new Date(window.start))),
),
);
return [window.key, asStatsWindow(aggregate, incidentAggregate.count)] as const;
}),
);
return context.json({ windows: Object.fromEntries(results) as Record<(typeof windows)[number]["key"], StatsWindow> });
return context.json({ windows: Object.fromEntries(results) as Record<(typeof windows)[number]['key'], StatsWindow> });
});
monitorRoutes.get("/:id/favicon", async (context) => {
const id = parseId(context.req.param("id"));
if (id === null) return context.json({ message: "Monitor not found" }, 404);
const [monitor] = await getDb(context.env)
.select({ url: monitors.url })
.from(monitors)
.where(eq(monitors.id, id))
.limit(1);
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
monitorRoutes.get('/:id/favicon', async (context) => {
const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const [monitor] = await getDb(context.env).select({ url: monitors.url }).from(monitors).where(eq(monitors.id, id)).limit(1);
if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
const cacheKey = new Request(`${new URL(context.req.url).origin}/api/monitors/${id}/favicon`);
let cache: EdgeCache | null = null;
@@ -462,14 +458,14 @@ monitorRoutes.get("/:id/favicon", async (context) => {
}
const favicon = await resolveFavicon(monitor.url);
if (!favicon) return context.json({ message: "No favicon" }, 404);
if (!favicon) return context.json({ message: 'No favicon' }, 404);
const response = new Response(favicon.body, {
headers: {
"Cache-Control": `public, max-age=${FAVICON_CACHE_SECONDS}`,
"Content-Length": String(favicon.body.byteLength),
"Content-Type": favicon.contentType,
"X-Content-Type-Options": "nosniff",
'Cache-Control': `public, max-age=${FAVICON_CACHE_SECONDS}`,
'Content-Length': String(favicon.body.byteLength),
'Content-Type': favicon.contentType,
'X-Content-Type-Options': 'nosniff',
},
});
if (cache) {
@@ -478,12 +474,12 @@ monitorRoutes.get("/:id/favicon", async (context) => {
return response;
});
monitorRoutes.post("/", async (context) => {
monitorRoutes.post('/', async (context) => {
let body: unknown;
try {
body = await context.req.json();
} catch {
return context.json({ message: "Invalid request body" }, 400);
return context.json({ message: 'Invalid request body' }, 400);
}
const parsed = parseMonitorInput(body);
@@ -509,21 +505,21 @@ monitorRoutes.post("/", async (context) => {
return context.json({ monitor });
});
monitorRoutes.patch("/:id", async (context) => {
const id = parseId(context.req.param("id"));
if (id === null) return context.json({ message: "Monitor not found" }, 404);
monitorRoutes.patch('/:id', async (context) => {
const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
let body: unknown;
try {
body = await context.req.json();
} catch {
return context.json({ message: "Invalid request body" }, 400);
return context.json({ message: 'Invalid request body' }, 400);
}
const parsed = parseMonitorInput(body, true);
if (!parsed.ok) return context.json({ message: parsed.message }, 400);
if (Object.keys(parsed.value).length === 0) {
return context.json({ message: "Provide at least one field to update" }, 400);
return context.json({ message: 'Provide at least one field to update' }, 400);
}
const [monitor] = await getDb(context.env)
@@ -531,38 +527,35 @@ monitorRoutes.patch("/:id", async (context) => {
.set({ ...parsed.value, updatedAt: new Date() })
.where(eq(monitors.id, id))
.returning();
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
return context.json({ monitor });
});
monitorRoutes.delete("/:id", async (context) => {
const id = parseId(context.req.param("id"));
if (id === null) return context.json({ message: "Monitor not found" }, 404);
monitorRoutes.delete('/:id', async (context) => {
const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const db = getDb(context.env);
const [monitor] = await db.select({ id: monitors.id }).from(monitors).where(eq(monitors.id, id)).limit(1);
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
await db.batch([
db.delete(checks).where(eq(checks.monitorId, id)),
db.delete(monitors).where(eq(monitors.id, id)),
]);
await db.batch([db.delete(checks).where(eq(checks.monitorId, id)), db.delete(monitors).where(eq(monitors.id, id))]);
return context.json({ ok: true });
});
monitorRoutes.post("/:id/check", async (context) => {
const id = parseId(context.req.param("id"));
if (id === null) return context.json({ message: "Monitor not found" }, 404);
monitorRoutes.post('/:id/check', async (context) => {
const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: 'Monitor not found' }, 404);
const db = getDb(context.env);
const [monitor] = await db.select().from(monitors).where(eq(monitors.id, id)).limit(1);
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
if (!monitor) return context.json({ message: 'Monitor not found' }, 404);
const result = await runCheck(monitor);
const checkedAt = new Date();
const { statements, transition } = buildResultStatements(db, monitor, result, checkedAt);
await db.batch(statements as [typeof statements[number], ...typeof statements]);
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
if (transition) {
await sendIncidentAlert(context.env, { monitor, kind: transition, result, at: checkedAt });
}
+25 -33
View File
@@ -1,9 +1,9 @@
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { getDb } from "../db/client";
import { notificationSettings } from "../db/schema";
import { requireAuth, type AuthVariables } from "../lib/require-auth";
import { sendTestWebhook } from "../notifications/webhook";
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { getDb } from '../db/client';
import { notificationSettings } from '../db/schema';
import { requireAuth, type AuthVariables } from '../lib/require-auth';
import { sendTestWebhook } from '../notifications/webhook';
type NotificationInput = {
webhookUrl: string | null;
@@ -11,52 +11,48 @@ type NotificationInput = {
};
function parseNotificationInput(value: unknown): NotificationInput | string {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return "Invalid request body";
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return 'Invalid request body';
}
const body = value as Record<string, unknown>;
if (typeof body.webhookEnabled !== "boolean") {
return "webhookEnabled must be a boolean";
if (typeof body.webhookEnabled !== 'boolean') {
return 'webhookEnabled must be a boolean';
}
const rawUrl = typeof body.webhookUrl === "string" ? body.webhookUrl.trim() : body.webhookUrl;
if (rawUrl !== null && typeof rawUrl !== "string") return "webhookUrl must be a URL or null";
const rawUrl = typeof body.webhookUrl === 'string' ? body.webhookUrl.trim() : body.webhookUrl;
if (rawUrl !== null && typeof rawUrl !== 'string') return 'webhookUrl must be a URL or null';
let webhookUrl = rawUrl || null;
if (webhookUrl) {
try {
const url = new URL(webhookUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("protocol");
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('protocol');
webhookUrl = url.toString();
} catch {
return "Enter a valid http or https webhook URL";
return 'Enter a valid http or https webhook URL';
}
}
if (body.webhookEnabled && !webhookUrl) return "A webhook URL is required when alerts are enabled";
if (body.webhookEnabled && !webhookUrl) return 'A webhook URL is required when alerts are enabled';
return { webhookUrl, webhookEnabled: body.webhookEnabled };
}
const settingsRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>();
settingsRoutes.use("*", requireAuth);
settingsRoutes.use('*', requireAuth);
settingsRoutes.get("/notifications", async (context) => {
const [settings] = await getDb(context.env)
.select()
.from(notificationSettings)
.where(eq(notificationSettings.id, 1))
.limit(1);
settingsRoutes.get('/notifications', async (context) => {
const [settings] = await getDb(context.env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
return context.json({
settings: settings ?? { id: 1, webhookUrl: null, webhookEnabled: false, createdAt: null, updatedAt: null },
});
});
settingsRoutes.put("/notifications", async (context) => {
settingsRoutes.put('/notifications', async (context) => {
let body: unknown;
try {
body = await context.req.json();
} catch {
return context.json({ message: "Invalid request body" }, 400);
return context.json({ message: 'Invalid request body' }, 400);
}
const input = parseNotificationInput(body);
if (typeof input === "string") return context.json({ message: input }, 400);
if (typeof input === 'string') return context.json({ message: input }, 400);
const db = getDb(context.env);
const now = new Date();
@@ -71,15 +67,11 @@ settingsRoutes.put("/notifications", async (context) => {
return context.json({ settings });
});
settingsRoutes.post("/notifications/test", async (context) => {
const [settings] = await getDb(context.env)
.select()
.from(notificationSettings)
.where(eq(notificationSettings.id, 1))
.limit(1);
if (!settings?.webhookUrl) return context.json({ message: "Save a webhook URL first" }, 400);
settingsRoutes.post('/notifications/test', async (context) => {
const [settings] = await getDb(context.env).select().from(notificationSettings).where(eq(notificationSettings.id, 1)).limit(1);
if (!settings?.webhookUrl) return context.json({ message: 'Save a webhook URL first' }, 400);
const delivered = await sendTestWebhook(settings.webhookUrl);
if (!delivered) return context.json({ message: "Webhook delivery failed" }, 502);
if (!delivered) return context.json({ message: 'Webhook delivery failed' }, 502);
return context.json({ ok: true });
});
+47 -46
View File
@@ -1,14 +1,14 @@
import { and, eq, gte, inArray, lt, sql } from "drizzle-orm";
import { Hono } from "hono";
import { getDb } from "../db/client";
import { checks, monitorDailyStats, monitors } from "../db/schema";
import { resolveFavicon } from "./monitors";
import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm';
import { Hono } from 'hono';
import { getDb } from '../db/client';
import { checks, monitorDailyStats, monitors } from '../db/schema';
import { resolveFavicon } from './monitors';
const DAY_MS = 24 * 60 * 60 * 1000;
const FAVICON_CACHE_SECONDS = 86_400;
type ServiceStatus = "up" | "down" | "unknown";
type OverallStatus = "operational" | "degraded" | "down";
type ServiceStatus = 'up' | 'down' | 'unknown';
type OverallStatus = 'operational' | 'degraded' | 'down';
type HistoryEntry = {
day: number;
@@ -37,27 +37,27 @@ function roundUptime(upChecks: number, totalChecks: number) {
}
function serviceStatus(lastOk: boolean | null): ServiceStatus {
if (lastOk === true) return "up";
if (lastOk === false) return "down";
return "unknown";
if (lastOk === true) return 'up';
if (lastOk === false) return 'down';
return 'unknown';
}
function overallStatus(statuses: ServiceStatus[]): OverallStatus {
let checked = 0;
let down = 0;
for (const status of statuses) {
if (status === "unknown") continue;
if (status === 'unknown') continue;
checked += 1;
if (status === "down") down += 1;
if (status === 'down') down += 1;
}
if (down === 0) return "operational";
if (down === checked) return "down";
return "degraded";
if (down === 0) return 'operational';
if (down === checked) return 'down';
return 'degraded';
}
const statusRoutes = new Hono<{ Bindings: Env }>();
statusRoutes.get("/", async (context) => {
statusRoutes.get('/', async (context) => {
const db = getDb(context.env);
const monitorRows = await db
.select({
@@ -79,30 +79,31 @@ statusRoutes.get("/", async (context) => {
if (monitorIds.length > 0) {
[historicalRows, todayRows] = await Promise.all([
db.select({
monitorId: monitorDailyStats.monitorId,
day: monitorDailyStats.day,
totalChecks: monitorDailyStats.totalChecks,
upChecks: monitorDailyStats.upChecks,
})
db
.select({
monitorId: monitorDailyStats.monitorId,
day: monitorDailyStats.day,
totalChecks: monitorDailyStats.totalChecks,
upChecks: monitorDailyStats.upChecks,
})
.from(monitorDailyStats)
.where(and(
inArray(monitorDailyStats.monitorId, monitorIds),
gte(monitorDailyStats.day, new Date(cutoff)),
lt(monitorDailyStats.day, new Date(today)),
))
.where(
and(
inArray(monitorDailyStats.monitorId, monitorIds),
gte(monitorDailyStats.day, new Date(cutoff)),
lt(monitorDailyStats.day, new Date(today)),
),
)
.orderBy(monitorDailyStats.day),
db.select({
monitorId: checks.monitorId,
day: sql<Date>`cast(${today} as integer)`,
totalChecks: sql<number>`count(*)`,
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
})
db
.select({
monitorId: checks.monitorId,
day: sql<Date>`cast(${today} as integer)`,
totalChecks: sql<number>`count(*)`,
upChecks: sql<number>`coalesce(sum(case when ${checks.ok} = 1 then 1 else 0 end), 0)`,
})
.from(checks)
.where(and(
inArray(checks.monitorId, monitorIds),
gte(checks.checkedAt, new Date(today)),
))
.where(and(inArray(checks.monitorId, monitorIds), gte(checks.checkedAt, new Date(today))))
.groupBy(checks.monitorId),
]);
}
@@ -144,15 +145,15 @@ statusRoutes.get("/", async (context) => {
});
});
statusRoutes.get("/:id/favicon", async (context) => {
const id = parseId(context.req.param("id"));
if (id === null) return context.json({ message: "Service not found" }, 404);
statusRoutes.get('/:id/favicon', async (context) => {
const id = parseId(context.req.param('id'));
if (id === null) return context.json({ message: 'Service not found' }, 404);
const [monitor] = await getDb(context.env)
.select({ url: monitors.url })
.from(monitors)
.where(and(eq(monitors.id, id), eq(monitors.enabled, true)))
.limit(1);
if (!monitor) return context.json({ message: "Service not found" }, 404);
if (!monitor) return context.json({ message: 'Service not found' }, 404);
const cacheKey = new Request(`${new URL(context.req.url).origin}/api/status/${id}/favicon`);
let cache: EdgeCache | null = null;
@@ -166,14 +167,14 @@ statusRoutes.get("/:id/favicon", async (context) => {
}
const favicon = await resolveFavicon(monitor.url);
if (!favicon) return context.json({ message: "No favicon" }, 404);
if (!favicon) return context.json({ message: 'No favicon' }, 404);
const response = new Response(favicon.body, {
headers: {
"Cache-Control": `public, max-age=${FAVICON_CACHE_SECONDS}`,
"Content-Length": String(favicon.body.byteLength),
"Content-Type": favicon.contentType,
"X-Content-Type-Options": "nosniff",
'Cache-Control': `public, max-age=${FAVICON_CACHE_SECONDS}`,
'Content-Length': String(favicon.body.byteLength),
'Content-Type': favicon.contentType,
'X-Content-Type-Options': 'nosniff',
},
});
if (cache) {
+6 -27
View File
@@ -1,6 +1,6 @@
import { lt } from "drizzle-orm";
import { getDb } from "../db/client";
import { checks, loginAttempts, monitorDailyStats, sessions } from "../db/schema";
import { lt } from 'drizzle-orm';
import { getDb } from '../db/client';
import { checks, loginAttempts, monitorDailyStats, sessions } from '../db/schema';
const LOGIN_ATTEMPT_RETENTION_MS = 60 * 60 * 1000;
const CHECK_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
@@ -12,29 +12,8 @@ export async function cleanupExpiredAuthRecords(env: Env) {
await db.batch([
db.delete(sessions).where(lt(sessions.expiresAt, now)),
db
.delete(loginAttempts)
.where(
lt(
loginAttempts.attemptedAt,
new Date(now.getTime() - LOGIN_ATTEMPT_RETENTION_MS),
),
),
db
.delete(checks)
.where(
lt(
checks.checkedAt,
new Date(now.getTime() - CHECK_RETENTION_MS),
),
),
db
.delete(monitorDailyStats)
.where(
lt(
monitorDailyStats.day,
new Date(now.getTime() - DAILY_STATS_RETENTION_MS),
),
),
db.delete(loginAttempts).where(lt(loginAttempts.attemptedAt, new Date(now.getTime() - LOGIN_ATTEMPT_RETENTION_MS))),
db.delete(checks).where(lt(checks.checkedAt, new Date(now.getTime() - CHECK_RETENTION_MS))),
db.delete(monitorDailyStats).where(lt(monitorDailyStats.day, new Date(now.getTime() - DAILY_STATS_RETENTION_MS))),
]);
}
+23 -24
View File
@@ -1,16 +1,13 @@
import { and, gte, lt, sql } from "drizzle-orm";
import { getDb } from "../db/client";
import { checks, monitorDailyStats } from "../db/schema";
import { and, gte, lt, sql } from 'drizzle-orm';
import { getDb } from '../db/client';
import { checks, monitorDailyStats } from '../db/schema';
export type DailyRollupSummary = {
day: string;
monitors: number;
};
export async function runDailyRollup(
env: Env,
now = new Date(),
): Promise<DailyRollupSummary> {
export async function runDailyRollup(env: Env, now = new Date()): Promise<DailyRollupSummary> {
const db = getDb(env);
const currentUtcDay = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const dayStart = new Date(currentUtcDay - 24 * 60 * 60 * 1000);
@@ -30,28 +27,30 @@ export async function runDailyRollup(
.groupBy(checks.monitorId);
if (rows.length > 0) {
const statements = rows.map((row) => db
.insert(monitorDailyStats)
.values({
monitorId: row.monitorId,
day: dayStart,
totalChecks: row.totalChecks,
upChecks: row.upChecks,
avgLatencyMs: row.avgLatencyMs,
minLatencyMs: row.minLatencyMs,
maxLatencyMs: row.maxLatencyMs,
})
.onConflictDoUpdate({
target: [monitorDailyStats.monitorId, monitorDailyStats.day],
set: {
const statements = rows.map((row) =>
db
.insert(monitorDailyStats)
.values({
monitorId: row.monitorId,
day: dayStart,
totalChecks: row.totalChecks,
upChecks: row.upChecks,
avgLatencyMs: row.avgLatencyMs,
minLatencyMs: row.minLatencyMs,
maxLatencyMs: row.maxLatencyMs,
},
}));
await db.batch(statements as [typeof statements[number], ...typeof statements]);
})
.onConflictDoUpdate({
target: [monitorDailyStats.monitorId, monitorDailyStats.day],
set: {
totalChecks: row.totalChecks,
upChecks: row.upChecks,
avgLatencyMs: row.avgLatencyMs,
minLatencyMs: row.minLatencyMs,
maxLatencyMs: row.maxLatencyMs,
},
}),
);
await db.batch(statements as [(typeof statements)[number], ...typeof statements]);
}
return { day: dayStart.toISOString().slice(0, 10), monitors: rows.length };