From 15d8d8ca8c35b960d414559f43eef4083ca18d71 Mon Sep 17 00:00:00 2001 From: ManojKumar <124049089+ManojMJ17@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:09:46 +0530 Subject: [PATCH] feat: add dark mode support to dashboard with theme toggle and persistence --- src/routes/dashboard.ts | 1656 ++++++++++++++++++++++++++------------- 1 file changed, 1097 insertions(+), 559 deletions(-) diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 13cb0e4..3b9035b 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -1,36 +1,51 @@ -import { Hono } from 'hono'; -import type { Env, User, Check, Channel } from '../types'; -import { requireAuth } from '../middleware/session'; -import { generateCheckId, generateId } from '../utils/id'; -import { now, timeAgo, formatDuration, periodOptions, graceOptions, isInMaintSchedule, formatMaintSchedule } from '../utils/time'; -import { parseCronExpression } from '../utils/cron-parser'; -import { signWebhookPayload, generateSigningSecret } from '../utils/webhook-sign'; +import { Hono } from "hono"; +import type { Env, User, Check, Channel } from "../types"; +import { requireAuth } from "../middleware/session"; +import { generateCheckId, generateId } from "../utils/id"; +import { + now, + timeAgo, + formatDuration, + periodOptions, + graceOptions, + isInMaintSchedule, + formatMaintSchedule, +} from "../utils/time"; +import { parseCronExpression } from "../utils/cron-parser"; +import { + signWebhookPayload, + generateSigningSecret, +} from "../utils/webhook-sign"; type DashboardEnv = { Bindings: Env; Variables: { user: User } }; const dashboard = new Hono(); function parseTags(input: string | undefined | null): string { - if (!input) return ''; + if (!input) return ""; const tags = input - .split(',') - .map(t => t.trim().toLowerCase()) - .filter(t => t.length > 0); - return [...new Set(tags)].join(','); + .split(",") + .map((t) => t.trim().toLowerCase()) + .filter((t) => t.length > 0); + return [...new Set(tags)].join(","); } function renderTagPills(tags: string): string { - if (!tags) return ''; - return tags.split(',').map(t => - `${escapeHtml(t)}` - ).join(' '); + if (!tags) return ""; + return tags + .split(",") + .map( + (t) => + `${escapeHtml(t)}`, + ) + .join(" "); } -dashboard.use('*', requireAuth); +dashboard.use("*", requireAuth); // Dashboard home - Check list -dashboard.get('/', async (c) => { - const user = c.get('user'); - const tagFilter = (c.req.query('tag') || '').trim().toLowerCase(); +dashboard.get("/", async (c) => { + const user = c.get("user"); + const tagFilter = (c.req.query("tag") || "").trim().toLowerCase(); const timestamp = now(); const day1 = timestamp - 86400; @@ -38,14 +53,20 @@ dashboard.get('/', async (c) => { const [checks, uptimeRows, alertRows] = await Promise.all([ c.env.DB.prepare( - 'SELECT * FROM checks WHERE user_id = ? ORDER BY created_at DESC' - ).bind(user.id).all(), + "SELECT * FROM checks WHERE user_id = ? ORDER BY created_at DESC", + ) + .bind(user.id) + .all(), c.env.DB.prepare( - "SELECT check_id, COUNT(*) as total, SUM(CASE WHEN type = 'success' THEN 1 ELSE 0 END) as ok FROM pings WHERE check_id IN (SELECT id FROM checks WHERE user_id = ?) AND timestamp > ? GROUP BY check_id" - ).bind(user.id, day7).all<{ check_id: string; total: number; ok: number }>(), + "SELECT check_id, COUNT(*) as total, SUM(CASE WHEN type = 'success' THEN 1 ELSE 0 END) as ok FROM pings WHERE check_id IN (SELECT id FROM checks WHERE user_id = ?) AND timestamp > ? GROUP BY check_id", + ) + .bind(user.id, day7) + .all<{ check_id: string; total: number; ok: number }>(), c.env.DB.prepare( - "SELECT check_id, COUNT(*) as count FROM alerts WHERE check_id IN (SELECT id FROM checks WHERE user_id = ?) AND type = 'down' AND created_at > ? GROUP BY check_id" - ).bind(user.id, day7).all<{ check_id: string; count: number }>(), + "SELECT check_id, COUNT(*) as count FROM alerts WHERE check_id IN (SELECT id FROM checks WHERE user_id = ?) AND type = 'down' AND created_at > ? GROUP BY check_id", + ) + .bind(user.id, day7) + .all<{ check_id: string; count: number }>(), ]); const uptimeMap: Record = {}; @@ -55,14 +76,20 @@ dashboard.get('/', async (c) => { for (const row of uptimeRows.results) { uptimeRawMap[row.check_id] = { total: row.total, ok: row.ok }; - uptimeMap[row.check_id] = row.total > 0 ? ((row.ok / row.total) * 100).toFixed(1) + '%' : '—'; + uptimeMap[row.check_id] = + row.total > 0 ? ((row.ok / row.total) * 100).toFixed(1) + "%" : "—"; } for (const row of alertRows.results) { alertCountMap[row.check_id] = row.count; } for (const check of checks.results) { const raw = uptimeRawMap[check.id] || { total: 0, ok: 0 }; - healthMap[check.id] = calcHealthScore(raw.total, raw.ok, alertCountMap[check.id] || 0, check.status); + healthMap[check.id] = calcHealthScore( + raw.total, + raw.ok, + alertCountMap[check.id] || 0, + check.status, + ); } // Collect all unique tags and groups across checks @@ -70,145 +97,229 @@ dashboard.get('/', async (c) => { const allGroups = new Set(); for (const check of checks.results) { if (check.tags) { - for (const t of check.tags.split(',')) { + for (const t of check.tags.split(",")) { if (t.trim()) allTags.add(t.trim()); } } if (check.group_name) allGroups.add(check.group_name); } - const groupFilter = (c.req.query('group') || '').trim(); + const groupFilter = (c.req.query("group") || "").trim(); // Filter checks by tag and/or group let filteredChecks = checks.results; if (tagFilter) { - filteredChecks = filteredChecks.filter(check => check.tags && check.tags.split(',').map(t => t.trim()).includes(tagFilter)); + filteredChecks = filteredChecks.filter( + (check) => + check.tags && + check.tags + .split(",") + .map((t) => t.trim()) + .includes(tagFilter), + ); } if (groupFilter) { - filteredChecks = filteredChecks.filter(check => check.group_name === groupFilter); + filteredChecks = filteredChecks.filter( + (check) => check.group_name === groupFilter, + ); } // Import/export status messages - const imported = c.req.query('imported'); - const error = c.req.query('error'); - let message = ''; - if (imported) message = `
Successfully imported ${escapeHtml(imported)} checks.
`; - if (error === 'no-file') message = `
No file selected.
`; - if (error === 'invalid-format') message = `
Invalid file format. Expected CronPulse JSON export.
`; - if (error === 'parse-error') message = `
Could not parse the file. Please check the format.
`; - - return c.html(renderLayout(user, 'Checks', message + renderCheckList(filteredChecks, user, c.env.APP_URL, uptimeMap, [...allTags].sort(), tagFilter, healthMap, [...allGroups].sort(), groupFilter))); + const imported = c.req.query("imported"); + const error = c.req.query("error"); + let message = ""; + if (imported) + message = `
Successfully imported ${escapeHtml(imported)} checks.
`; + if (error === "no-file") + message = `
No file selected.
`; + if (error === "invalid-format") + message = `
Invalid file format. Expected CronPulse JSON export.
`; + if (error === "parse-error") + message = `
Could not parse the file. Please check the format.
`; + + return c.html( + renderLayout( + user, + "Checks", + message + + renderCheckList( + filteredChecks, + user, + c.env.APP_URL, + uptimeMap, + [...allTags].sort(), + tagFilter, + healthMap, + [...allGroups].sort(), + groupFilter, + ), + ), + ); }); // New check form -dashboard.get('/checks/new', async (c) => { - const user = c.get('user'); +dashboard.get("/checks/new", async (c) => { + const user = c.get("user"); const checkCount = await c.env.DB.prepare( - 'SELECT COUNT(*) as count FROM checks WHERE user_id = ?' - ).bind(user.id).first(); + "SELECT COUNT(*) as count FROM checks WHERE user_id = ?", + ) + .bind(user.id) + .first(); if ((checkCount?.count as number) >= user.check_limit) { - return c.html(renderLayout(user, 'Limit Reached', ` + return c.html( + renderLayout( + user, + "Limit Reached", + `

Check Limit Reached

You've used all ${user.check_limit} checks on your ${user.plan} plan.

Back to dashboard
- `)); + `, + ), + ); } - return c.html(renderLayout(user, 'New Check', renderCheckForm())); + return c.html(renderLayout(user, "New Check", renderCheckForm())); }); // Create check -dashboard.post('/checks', async (c) => { - const user = c.get('user'); +dashboard.post("/checks", async (c) => { + const user = c.get("user"); const body = await c.req.parseBody(); const checkCount = await c.env.DB.prepare( - 'SELECT COUNT(*) as count FROM checks WHERE user_id = ?' - ).bind(user.id).first(); + "SELECT COUNT(*) as count FROM checks WHERE user_id = ?", + ) + .bind(user.id) + .first(); if ((checkCount?.count as number) >= user.check_limit) { - return c.redirect('/dashboard'); + return c.redirect("/dashboard"); } const id = generateCheckId(); - const name = (body.name as string || '').trim() || 'Unnamed Check'; - const cronExpr = (body.cron_expression as string || '').trim(); + const name = ((body.name as string) || "").trim() || "Unnamed Check"; + const cronExpr = ((body.cron_expression as string) || "").trim(); let period = parseInt(body.period as string) || 3600; let grace = parseInt(body.grace as string) || 300; // If cron expression is provided, parse and use it to set period/grace if (cronExpr) { const parsed = parseCronExpression(cronExpr); - if (parsed.valid && parsed.periodSeconds >= 60 && parsed.periodSeconds <= 604800) { + if ( + parsed.valid && + parsed.periodSeconds >= 60 && + parsed.periodSeconds <= 604800 + ) { period = parsed.periodSeconds; grace = parsed.graceSeconds; } } const tags = parseTags(body.tags as string); - const groupName = (body.group_name as string || '').trim(); - const maintStart = (body.maint_start as string) ? Math.floor(new Date(body.maint_start as string).getTime() / 1000) : null; - const maintEnd = (body.maint_end as string) ? Math.floor(new Date(body.maint_end as string).getTime() / 1000) : null; - const maintSchedule = (body.maint_schedule as string || '').trim(); + const groupName = ((body.group_name as string) || "").trim(); + const maintStart = (body.maint_start as string) + ? Math.floor(new Date(body.maint_start as string).getTime() / 1000) + : null; + const maintEnd = (body.maint_end as string) + ? Math.floor(new Date(body.maint_end as string).getTime() / 1000) + : null; + const maintSchedule = ((body.maint_schedule as string) || "").trim(); const timestamp = now(); await c.env.DB.prepare( - 'INSERT INTO checks (id, user_id, name, period, grace, tags, group_name, cron_expression, maint_start, maint_end, maint_schedule, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' - ).bind(id, user.id, name, period, grace, tags, groupName, cronExpr, maintStart, maintEnd, maintSchedule, 'new', timestamp, timestamp).run(); + "INSERT INTO checks (id, user_id, name, period, grace, tags, group_name, cron_expression, maint_start, maint_end, maint_schedule, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind( + id, + user.id, + name, + period, + grace, + tags, + groupName, + cronExpr, + maintStart, + maintEnd, + maintSchedule, + "new", + timestamp, + timestamp, + ) + .run(); // Link to default channels const defaultChannels = await c.env.DB.prepare( - 'SELECT id FROM channels WHERE user_id = ? AND is_default = 1' - ).bind(user.id).all(); + "SELECT id FROM channels WHERE user_id = ? AND is_default = 1", + ) + .bind(user.id) + .all(); for (const ch of defaultChannels.results) { await c.env.DB.prepare( - 'INSERT INTO check_channels (check_id, channel_id) VALUES (?, ?)' - ).bind(id, (ch as any).id).run(); + "INSERT INTO check_channels (check_id, channel_id) VALUES (?, ?)", + ) + .bind(id, (ch as any).id) + .run(); } return c.redirect(`/dashboard/checks/${id}`); }); // Check detail -dashboard.get('/checks/:id', async (c) => { - const user = c.get('user'); - const checkId = c.req.param('id'); +dashboard.get("/checks/:id", async (c) => { + const user = c.get("user"); + const checkId = c.req.param("id"); const check = await c.env.DB.prepare( - 'SELECT * FROM checks WHERE id = ? AND user_id = ?' - ).bind(checkId, user.id).first(); + "SELECT * FROM checks WHERE id = ? AND user_id = ?", + ) + .bind(checkId, user.id) + .first(); - if (!check) return c.redirect('/dashboard'); + if (!check) return c.redirect("/dashboard"); const timestamp = now(); const day1 = timestamp - 86400; const day7 = timestamp - 7 * 86400; const day30 = timestamp - 30 * 86400; - const [pings, alerts, uptime24h, uptime7d, uptime30d, alertCount7d] = await Promise.all([ - c.env.DB.prepare( - 'SELECT * FROM pings WHERE check_id = ? ORDER BY timestamp DESC LIMIT 50' - ).bind(checkId).all(), - c.env.DB.prepare( - 'SELECT * FROM alerts WHERE check_id = ? ORDER BY created_at DESC LIMIT 20' - ).bind(checkId).all(), - c.env.DB.prepare( - 'SELECT COUNT(*) as total, SUM(CASE WHEN type = \'success\' THEN 1 ELSE 0 END) as ok FROM pings WHERE check_id = ? AND timestamp > ?' - ).bind(checkId, day1).first<{ total: number; ok: number }>(), - c.env.DB.prepare( - 'SELECT COUNT(*) as total, SUM(CASE WHEN type = \'success\' THEN 1 ELSE 0 END) as ok FROM pings WHERE check_id = ? AND timestamp > ?' - ).bind(checkId, day7).first<{ total: number; ok: number }>(), - c.env.DB.prepare( - 'SELECT COUNT(*) as total, SUM(CASE WHEN type = \'success\' THEN 1 ELSE 0 END) as ok FROM pings WHERE check_id = ? AND timestamp > ?' - ).bind(checkId, day30).first<{ total: number; ok: number }>(), - c.env.DB.prepare( - "SELECT COUNT(*) as count FROM alerts WHERE check_id = ? AND type = 'down' AND created_at > ?" - ).bind(checkId, day7).first<{ count: number }>(), - ]); + const [pings, alerts, uptime24h, uptime7d, uptime30d, alertCount7d] = + await Promise.all([ + c.env.DB.prepare( + "SELECT * FROM pings WHERE check_id = ? ORDER BY timestamp DESC LIMIT 50", + ) + .bind(checkId) + .all(), + c.env.DB.prepare( + "SELECT * FROM alerts WHERE check_id = ? ORDER BY created_at DESC LIMIT 20", + ) + .bind(checkId) + .all(), + c.env.DB.prepare( + "SELECT COUNT(*) as total, SUM(CASE WHEN type = 'success' THEN 1 ELSE 0 END) as ok FROM pings WHERE check_id = ? AND timestamp > ?", + ) + .bind(checkId, day1) + .first<{ total: number; ok: number }>(), + c.env.DB.prepare( + "SELECT COUNT(*) as total, SUM(CASE WHEN type = 'success' THEN 1 ELSE 0 END) as ok FROM pings WHERE check_id = ? AND timestamp > ?", + ) + .bind(checkId, day7) + .first<{ total: number; ok: number }>(), + c.env.DB.prepare( + "SELECT COUNT(*) as total, SUM(CASE WHEN type = 'success' THEN 1 ELSE 0 END) as ok FROM pings WHERE check_id = ? AND timestamp > ?", + ) + .bind(checkId, day30) + .first<{ total: number; ok: number }>(), + c.env.DB.prepare( + "SELECT COUNT(*) as count FROM alerts WHERE check_id = ? AND type = 'down' AND created_at > ?", + ) + .bind(checkId, day7) + .first<{ count: number }>(), + ]); const uptimeStats = { day1: calcUptime(uptime24h?.total ?? 0, uptime24h?.ok ?? 0), @@ -220,115 +331,170 @@ dashboard.get('/checks/:id', async (c) => { uptime7d?.total ?? 0, uptime7d?.ok ?? 0, alertCount7d?.count ?? 0, - check.status + check.status, ); - return c.html(renderLayout(user, check.name, renderCheckDetail(check, pings.results, alerts.results, c.env.APP_URL, uptimeStats, healthScore))); + return c.html( + renderLayout( + user, + check.name, + renderCheckDetail( + check, + pings.results, + alerts.results, + c.env.APP_URL, + uptimeStats, + healthScore, + ), + ), + ); }); // Edit check form -dashboard.get('/checks/:id/edit', async (c) => { - const user = c.get('user'); - const checkId = c.req.param('id'); +dashboard.get("/checks/:id/edit", async (c) => { + const user = c.get("user"); + const checkId = c.req.param("id"); const check = await c.env.DB.prepare( - 'SELECT * FROM checks WHERE id = ? AND user_id = ?' - ).bind(checkId, user.id).first(); + "SELECT * FROM checks WHERE id = ? AND user_id = ?", + ) + .bind(checkId, user.id) + .first(); - if (!check) return c.redirect('/dashboard'); + if (!check) return c.redirect("/dashboard"); - return c.html(renderLayout(user, `Edit ${check.name}`, renderCheckForm(check))); + return c.html( + renderLayout(user, `Edit ${check.name}`, renderCheckForm(check)), + ); }); // Update check -dashboard.post('/checks/:id', async (c) => { - const user = c.get('user'); - const checkId = c.req.param('id'); +dashboard.post("/checks/:id", async (c) => { + const user = c.get("user"); + const checkId = c.req.param("id"); const body = await c.req.parseBody(); const check = await c.env.DB.prepare( - 'SELECT * FROM checks WHERE id = ? AND user_id = ?' - ).bind(checkId, user.id).first(); + "SELECT * FROM checks WHERE id = ? AND user_id = ?", + ) + .bind(checkId, user.id) + .first(); - if (!check) return c.redirect('/dashboard'); + if (!check) return c.redirect("/dashboard"); - const name = (body.name as string || '').trim() || 'Unnamed Check'; - const cronExpr = (body.cron_expression as string || '').trim(); + const name = ((body.name as string) || "").trim() || "Unnamed Check"; + const cronExpr = ((body.cron_expression as string) || "").trim(); let period = parseInt(body.period as string) || 3600; let grace = parseInt(body.grace as string) || 300; // If cron expression is provided, parse and use it if (cronExpr) { const parsed = parseCronExpression(cronExpr); - if (parsed.valid && parsed.periodSeconds >= 60 && parsed.periodSeconds <= 604800) { + if ( + parsed.valid && + parsed.periodSeconds >= 60 && + parsed.periodSeconds <= 604800 + ) { period = parsed.periodSeconds; grace = parsed.graceSeconds; } } const tags = parseTags(body.tags as string); - const groupName = (body.group_name as string || '').trim(); - const maintStart = (body.maint_start as string) ? Math.floor(new Date(body.maint_start as string).getTime() / 1000) : null; - const maintEnd = (body.maint_end as string) ? Math.floor(new Date(body.maint_end as string).getTime() / 1000) : null; - const maintSchedule = (body.maint_schedule as string || '').trim(); + const groupName = ((body.group_name as string) || "").trim(); + const maintStart = (body.maint_start as string) + ? Math.floor(new Date(body.maint_start as string).getTime() / 1000) + : null; + const maintEnd = (body.maint_end as string) + ? Math.floor(new Date(body.maint_end as string).getTime() / 1000) + : null; + const maintSchedule = ((body.maint_schedule as string) || "").trim(); await c.env.DB.prepare( - 'UPDATE checks SET name = ?, period = ?, grace = ?, tags = ?, group_name = ?, cron_expression = ?, maint_start = ?, maint_end = ?, maint_schedule = ?, updated_at = ? WHERE id = ?' - ).bind(name, period, grace, tags, groupName, cronExpr, maintStart, maintEnd, maintSchedule, now(), checkId).run(); + "UPDATE checks SET name = ?, period = ?, grace = ?, tags = ?, group_name = ?, cron_expression = ?, maint_start = ?, maint_end = ?, maint_schedule = ?, updated_at = ? WHERE id = ?", + ) + .bind( + name, + period, + grace, + tags, + groupName, + cronExpr, + maintStart, + maintEnd, + maintSchedule, + now(), + checkId, + ) + .run(); // Invalidate KV cache - try { await c.env.KV.delete(`check:${checkId}`); } catch {} + try { + await c.env.KV.delete(`check:${checkId}`); + } catch {} return c.redirect(`/dashboard/checks/${checkId}`); }); // Delete check -dashboard.post('/checks/:id/delete', async (c) => { - const user = c.get('user'); - const checkId = c.req.param('id'); +dashboard.post("/checks/:id/delete", async (c) => { + const user = c.get("user"); + const checkId = c.req.param("id"); - await c.env.DB.prepare( - 'DELETE FROM checks WHERE id = ? AND user_id = ?' - ).bind(checkId, user.id).run(); + await c.env.DB.prepare("DELETE FROM checks WHERE id = ? AND user_id = ?") + .bind(checkId, user.id) + .run(); - try { await c.env.KV.delete(`check:${checkId}`); } catch {} + try { + await c.env.KV.delete(`check:${checkId}`); + } catch {} - return c.redirect('/dashboard'); + return c.redirect("/dashboard"); }); // Pause check -dashboard.post('/checks/:id/pause', async (c) => { - const user = c.get('user'); - const checkId = c.req.param('id'); +dashboard.post("/checks/:id/pause", async (c) => { + const user = c.get("user"); + const checkId = c.req.param("id"); await c.env.DB.prepare( - "UPDATE checks SET status = 'paused', updated_at = ? WHERE id = ? AND user_id = ?" - ).bind(now(), checkId, user.id).run(); + "UPDATE checks SET status = 'paused', updated_at = ? WHERE id = ? AND user_id = ?", + ) + .bind(now(), checkId, user.id) + .run(); - try { await c.env.KV.delete(`check:${checkId}`); } catch {} + try { + await c.env.KV.delete(`check:${checkId}`); + } catch {} return c.redirect(`/dashboard/checks/${checkId}`); }); // Resume check -dashboard.post('/checks/:id/resume', async (c) => { - const user = c.get('user'); - const checkId = c.req.param('id'); +dashboard.post("/checks/:id/resume", async (c) => { + const user = c.get("user"); + const checkId = c.req.param("id"); await c.env.DB.prepare( - "UPDATE checks SET status = 'new', updated_at = ? WHERE id = ? AND user_id = ?" - ).bind(now(), checkId, user.id).run(); + "UPDATE checks SET status = 'new', updated_at = ? WHERE id = ? AND user_id = ?", + ) + .bind(now(), checkId, user.id) + .run(); - try { await c.env.KV.delete(`check:${checkId}`); } catch {} + try { + await c.env.KV.delete(`check:${checkId}`); + } catch {} return c.redirect(`/dashboard/checks/${checkId}`); }); // Export checks as JSON -dashboard.get('/export/json', async (c) => { - const user = c.get('user'); +dashboard.get("/export/json", async (c) => { + const user = c.get("user"); const checks = await c.env.DB.prepare( - 'SELECT id, name, period, grace, status, tags, group_name, cron_expression, created_at FROM checks WHERE user_id = ? ORDER BY created_at DESC' - ).bind(user.id).all(); + "SELECT id, name, period, grace, status, tags, group_name, cron_expression, created_at FROM checks WHERE user_id = ? ORDER BY created_at DESC", + ) + .bind(user.id) + .all(); const data = { version: 1, @@ -337,47 +503,49 @@ dashboard.get('/export/json', async (c) => { name: ch.name, period: ch.period, grace: ch.grace, - tags: ch.tags || '', - group_name: ch.group_name || '', - cron_expression: ch.cron_expression || '', + tags: ch.tags || "", + group_name: ch.group_name || "", + cron_expression: ch.cron_expression || "", })), }; return c.json(data, 200, { - 'Content-Disposition': 'attachment; filename="cronpulse-checks.json"', + "Content-Disposition": 'attachment; filename="cronpulse-checks.json"', }); }); // Export checks as CSV -dashboard.get('/export/csv', async (c) => { - const user = c.get('user'); +dashboard.get("/export/csv", async (c) => { + const user = c.get("user"); const checks = await c.env.DB.prepare( - 'SELECT name, period, grace, status, tags, group_name, created_at FROM checks WHERE user_id = ? ORDER BY created_at DESC' - ).bind(user.id).all(); + "SELECT name, period, grace, status, tags, group_name, created_at FROM checks WHERE user_id = ? ORDER BY created_at DESC", + ) + .bind(user.id) + .all(); - const header = 'name,period_seconds,grace_seconds,status,tags,group'; + const header = "name,period_seconds,grace_seconds,status,tags,group"; const rows = checks.results.map((ch: any) => { - const name = '"' + (ch.name || '').replace(/"/g, '""') + '"'; - const tags = '"' + (ch.tags || '').replace(/"/g, '""') + '"'; - const group = '"' + (ch.group_name || '').replace(/"/g, '""') + '"'; + const name = '"' + (ch.name || "").replace(/"/g, '""') + '"'; + const tags = '"' + (ch.tags || "").replace(/"/g, '""') + '"'; + const group = '"' + (ch.group_name || "").replace(/"/g, '""') + '"'; return `${name},${ch.period},${ch.grace},${ch.status},${tags},${group}`; }); - const csv = [header, ...rows].join('\n'); + const csv = [header, ...rows].join("\n"); return c.text(csv, 200, { - 'Content-Type': 'text/csv', - 'Content-Disposition': 'attachment; filename="cronpulse-checks.csv"', + "Content-Type": "text/csv", + "Content-Disposition": 'attachment; filename="cronpulse-checks.csv"', }); }); // Import checks from JSON file -dashboard.post('/import', async (c) => { - const user = c.get('user'); +dashboard.post("/import", async (c) => { + const user = c.get("user"); const body = await c.req.parseBody(); const file = body.file; - if (!file || typeof file === 'string') { - return c.redirect('/dashboard?error=no-file'); + if (!file || typeof file === "string") { + return c.redirect("/dashboard?error=no-file"); } try { @@ -385,13 +553,15 @@ dashboard.post('/import', async (c) => { const data = JSON.parse(text); if (!data.checks || !Array.isArray(data.checks)) { - return c.redirect('/dashboard?error=invalid-format'); + return c.redirect("/dashboard?error=invalid-format"); } // Check limit const countResult = await c.env.DB.prepare( - 'SELECT COUNT(*) as count FROM checks WHERE user_id = ?' - ).bind(user.id).first(); + "SELECT COUNT(*) as count FROM checks WHERE user_id = ?", + ) + .bind(user.id) + .first(); const currentCount = (countResult?.count as number) || 0; const remaining = user.check_limit - currentCount; @@ -401,196 +571,264 @@ dashboard.post('/import', async (c) => { for (const ch of toImport) { if (!ch.name) continue; const id = generateCheckId(); - const name = String(ch.name).trim().slice(0, 200) || 'Imported Check'; - const period = Math.max(60, Math.min(604800, parseInt(ch.period) || 3600)); + const name = String(ch.name).trim().slice(0, 200) || "Imported Check"; + const period = Math.max( + 60, + Math.min(604800, parseInt(ch.period) || 3600), + ); const grace = Math.max(60, Math.min(3600, parseInt(ch.grace) || 300)); - const tags = parseTags(ch.tags || ''); - const groupName = (ch.group_name || '').trim().slice(0, 100); + const tags = parseTags(ch.tags || ""); + const groupName = (ch.group_name || "").trim().slice(0, 100); const timestamp = now(); await c.env.DB.prepare( - 'INSERT INTO checks (id, user_id, name, period, grace, tags, group_name, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' - ).bind(id, user.id, name, period, grace, tags, groupName, 'new', timestamp, timestamp).run(); + "INSERT INTO checks (id, user_id, name, period, grace, tags, group_name, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind( + id, + user.id, + name, + period, + grace, + tags, + groupName, + "new", + timestamp, + timestamp, + ) + .run(); imported++; } return c.redirect(`/dashboard?imported=${imported}`); } catch { - return c.redirect('/dashboard?error=parse-error'); + return c.redirect("/dashboard?error=parse-error"); } }); // Incident timeline -dashboard.get('/incidents', async (c) => { - const user = c.get('user'); - const page = Math.max(1, parseInt(c.req.query('page') || '1') || 1); - const checkFilter = (c.req.query('check') || '').trim(); - const typeFilter = (c.req.query('type') || '').trim().toLowerCase(); +dashboard.get("/incidents", async (c) => { + const user = c.get("user"); + const page = Math.max(1, parseInt(c.req.query("page") || "1") || 1); + const checkFilter = (c.req.query("check") || "").trim(); + const typeFilter = (c.req.query("type") || "").trim().toLowerCase(); const limit = 50; const offset = (page - 1) * limit; // Build WHERE clause dynamically - let where = 'c.user_id = ?'; + let where = "c.user_id = ?"; const params: any[] = [user.id]; if (checkFilter) { - where += ' AND a.check_id = ?'; + where += " AND a.check_id = ?"; params.push(checkFilter); } - if (typeFilter === 'down' || typeFilter === 'recovery') { - where += ' AND a.type = ?'; + if (typeFilter === "down" || typeFilter === "recovery") { + where += " AND a.type = ?"; params.push(typeFilter); } const [alerts, totalResult, userChecks] = await Promise.all([ - c.env.DB.prepare(` + c.env.DB.prepare( + ` SELECT a.*, c.name as check_name FROM alerts a INNER JOIN checks c ON a.check_id = c.id WHERE ${where} ORDER BY a.created_at DESC LIMIT ? OFFSET ? - `).bind(...params, limit, offset).all(), - c.env.DB.prepare(` + `, + ) + .bind(...params, limit, offset) + .all(), + c.env.DB.prepare( + ` SELECT COUNT(*) as total FROM alerts a INNER JOIN checks c ON a.check_id = c.id WHERE ${where} - `).bind(...params).first<{ total: number }>(), + `, + ) + .bind(...params) + .first<{ total: number }>(), c.env.DB.prepare( - 'SELECT id, name FROM checks WHERE user_id = ? ORDER BY name ASC' - ).bind(user.id).all<{ id: string; name: string }>(), + "SELECT id, name FROM checks WHERE user_id = ? ORDER BY name ASC", + ) + .bind(user.id) + .all<{ id: string; name: string }>(), ]); const total = totalResult?.total || 0; const totalPages = Math.ceil(total / limit); - return c.html(renderLayout(user, 'Incidents', renderIncidentTimeline(alerts.results, page, totalPages, total, userChecks.results, checkFilter, typeFilter))); + return c.html( + renderLayout( + user, + "Incidents", + renderIncidentTimeline( + alerts.results, + page, + totalPages, + total, + userChecks.results, + checkFilter, + typeFilter, + ), + ), + ); }); // Channels page -dashboard.get('/channels', async (c) => { - const user = c.get('user'); +dashboard.get("/channels", async (c) => { + const user = c.get("user"); const channels = await c.env.DB.prepare( - 'SELECT * FROM channels WHERE user_id = ? ORDER BY created_at DESC' - ).bind(user.id).all(); + "SELECT * FROM channels WHERE user_id = ? ORDER BY created_at DESC", + ) + .bind(user.id) + .all(); - const testStatus = c.req.query('test'); - const testCh = c.req.query('ch') || ''; - const testErr = c.req.query('err') || ''; + const testStatus = c.req.query("test"); + const testCh = c.req.query("ch") || ""; + const testErr = c.req.query("err") || ""; - let testMessage = ''; - if (testStatus === 'ok') { + let testMessage = ""; + if (testStatus === "ok") { testMessage = `
Test notification sent to ${escapeHtml(testCh)} successfully!
`; - } else if (testStatus === 'fail') { - testMessage = `
Failed to send test to ${escapeHtml(testCh)}${testErr ? `: ${escapeHtml(testErr)}` : ''}.
`; + } else if (testStatus === "fail") { + testMessage = `
Failed to send test to ${escapeHtml(testCh)}${testErr ? `: ${escapeHtml(testErr)}` : ""}.
`; } - return c.html(renderLayout(user, 'Notification Channels', testMessage + renderChannels(channels.results))); + return c.html( + renderLayout( + user, + "Notification Channels", + testMessage + renderChannels(channels.results), + ), + ); }); // Create channel -dashboard.post('/channels', async (c) => { - const user = c.get('user'); +dashboard.post("/channels", async (c) => { + const user = c.get("user"); const body = await c.req.parseBody(); const id = generateId(); - const kind = body.kind as string || 'email'; - const target = (body.target as string || '').trim(); - const name = (body.name as string || '').trim() || kind; + const kind = (body.kind as string) || "email"; + const target = ((body.target as string) || "").trim(); + const name = ((body.name as string) || "").trim() || kind; const isDefault = body.is_default ? 1 : 0; - if (!target) return c.redirect('/dashboard/channels'); + if (!target) return c.redirect("/dashboard/channels"); // Validate target based on channel kind - if (kind === 'email') { - if (!target.includes('@') || target.length > 320) { - return c.redirect('/dashboard/channels'); + if (kind === "email") { + if (!target.includes("@") || target.length > 320) { + return c.redirect("/dashboard/channels"); } - } else if (kind === 'webhook' || kind === 'slack') { - if (!target.startsWith('https://') || target.length > 2048) { - return c.redirect('/dashboard/channels'); + } else if (kind === "webhook" || kind === "slack") { + if (!target.startsWith("https://") || target.length > 2048) { + return c.redirect("/dashboard/channels"); } } await c.env.DB.prepare( - 'INSERT INTO channels (id, user_id, kind, target, name, is_default, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).bind(id, user.id, kind, target, name, isDefault, now()).run(); + "INSERT INTO channels (id, user_id, kind, target, name, is_default, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(id, user.id, kind, target, name, isDefault, now()) + .run(); - return c.redirect('/dashboard/channels'); + return c.redirect("/dashboard/channels"); }); // Test channel — send a test notification -dashboard.post('/channels/:id/test', async (c) => { - const user = c.get('user'); - const channelId = c.req.param('id'); +dashboard.post("/channels/:id/test", async (c) => { + const user = c.get("user"); + const channelId = c.req.param("id"); const channel = await c.env.DB.prepare( - 'SELECT * FROM channels WHERE id = ? AND user_id = ?' - ).bind(channelId, user.id).first(); + "SELECT * FROM channels WHERE id = ? AND user_id = ?", + ) + .bind(channelId, user.id) + .first(); - if (!channel) return c.redirect('/dashboard/channels'); + if (!channel) return c.redirect("/dashboard/channels"); let success = false; - let errorMsg = ''; + let errorMsg = ""; try { - if (channel.kind === 'email') { - const { sendEmail, htmlEmail } = await import('../services/email'); + if (channel.kind === "email") { + const { sendEmail, htmlEmail } = await import("../services/email"); const result = await sendEmail(c.env, { to: channel.target, - subject: '[CronPulse] Test Notification', - text: 'This is a test notification from CronPulse. If you received this, your email channel is working correctly!', + subject: "[CronPulse] Test Notification", + text: "This is a test notification from CronPulse. If you received this, your email channel is working correctly!", html: htmlEmail({ - title: 'Test Notification', - heading: 'Test Notification', + title: "Test Notification", + heading: "Test Notification", body: '

Your email channel is working correctly!

This is a test notification from CronPulse. No action is required.

', ctaUrl: `${c.env.APP_URL}/dashboard/channels`, - ctaText: 'View Channels', + ctaText: "View Channels", }), }); success = result.sent || result.demo; - if (!success) errorMsg = result.error || 'Unknown error'; - } else if (channel.kind === 'webhook') { + if (!success) errorMsg = result.error || "Unknown error"; + } else if (channel.kind === "webhook") { const body = JSON.stringify({ - event: 'test', - message: 'This is a test notification from CronPulse.', + event: "test", + message: "This is a test notification from CronPulse.", timestamp: now(), }); - const headers: Record = { 'Content-Type': 'application/json' }; + const headers: Record = { + "Content-Type": "application/json", + }; if (user.webhook_signing_secret) { - headers['X-CronPulse-Signature'] = await signWebhookPayload(body, user.webhook_signing_secret); + headers["X-CronPulse-Signature"] = await signWebhookPayload( + body, + user.webhook_signing_secret, + ); } const res = await fetch(channel.target, { - method: 'POST', + method: "POST", headers, body, signal: AbortSignal.timeout(5000), }); success = res.ok; if (!success) errorMsg = `HTTP ${res.status}`; - } else if (channel.kind === 'slack') { + } else if (channel.kind === "slack") { const res = await fetch(channel.target, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - text: 'CronPulse Test Notification', + text: "CronPulse Test Notification", blocks: [ { - type: 'header', - text: { type: 'plain_text', text: 'CronPulse Test Notification', emoji: true }, + type: "header", + text: { + type: "plain_text", + text: "CronPulse Test Notification", + emoji: true, + }, }, { - type: 'section', + type: "section", fields: [ - { type: 'mrkdwn', text: '*Status:*\nTest' }, - { type: 'mrkdwn', text: '*Result:*\nYour Slack channel is working!' }, + { type: "mrkdwn", text: "*Status:*\nTest" }, + { + type: "mrkdwn", + text: "*Result:*\nYour Slack channel is working!", + }, ], }, { - type: 'actions', + type: "actions", elements: [ { - type: 'button', - text: { type: 'plain_text', text: 'View Channels', emoji: true }, + type: "button", + text: { + type: "plain_text", + text: "View Channels", + emoji: true, + }, url: `${c.env.APP_URL}/dashboard/channels`, }, ], @@ -607,65 +845,83 @@ dashboard.post('/channels/:id/test', async (c) => { errorMsg = String(e); } - const status = success ? 'ok' : 'fail'; - return c.redirect(`/dashboard/channels?test=${status}&ch=${encodeURIComponent(channel.name || channel.kind)}${errorMsg ? `&err=${encodeURIComponent(errorMsg)}` : ''}`); + const status = success ? "ok" : "fail"; + return c.redirect( + `/dashboard/channels?test=${status}&ch=${encodeURIComponent(channel.name || channel.kind)}${errorMsg ? `&err=${encodeURIComponent(errorMsg)}` : ""}`, + ); }); // Delete channel -dashboard.post('/channels/:id/delete', async (c) => { - const user = c.get('user'); - const channelId = c.req.param('id'); +dashboard.post("/channels/:id/delete", async (c) => { + const user = c.get("user"); + const channelId = c.req.param("id"); - await c.env.DB.prepare( - 'DELETE FROM channels WHERE id = ? AND user_id = ?' - ).bind(channelId, user.id).run(); + await c.env.DB.prepare("DELETE FROM channels WHERE id = ? AND user_id = ?") + .bind(channelId, user.id) + .run(); - return c.redirect('/dashboard/channels'); + return c.redirect("/dashboard/channels"); }); // Billing page -dashboard.get('/billing', async (c) => { - const user = c.get('user'); - return c.html(renderLayout(user, 'Billing', renderBilling(user, c.env.LEMONSQUEEZY_STORE_URL))); +dashboard.get("/billing", async (c) => { + const user = c.get("user"); + return c.html( + renderLayout( + user, + "Billing", + renderBilling(user, c.env.LEMONSQUEEZY_STORE_URL), + ), + ); }); // Settings page -dashboard.get('/settings', async (c) => { - const user = c.get('user'); - const saved = c.req.query('saved'); - const err = c.req.query('err'); - let msg = ''; - if (saved === 'status-page') msg = '
Status page settings saved.
'; - if (err === 'logo-url') msg = '
Logo URL must start with https://
'; - return c.html(renderLayout(user, 'Settings', msg + renderSettings(user))); +dashboard.get("/settings", async (c) => { + const user = c.get("user"); + const saved = c.req.query("saved"); + const err = c.req.query("err"); + let msg = ""; + if (saved === "status-page") + msg = + '
Status page settings saved.
'; + if (err === "logo-url") + msg = + '
Logo URL must start with https://
'; + return c.html(renderLayout(user, "Settings", msg + renderSettings(user))); }); // Generate API key -dashboard.post('/settings/api-key', async (c) => { - const user = c.get('user'); +dashboard.post("/settings/api-key", async (c) => { + const user = c.get("user"); - if (user.plan !== 'pro' && user.plan !== 'business') { - return c.redirect('/dashboard/settings'); + if (user.plan !== "pro" && user.plan !== "business") { + return c.redirect("/dashboard/settings"); } // Generate a new API key - const { nanoid } = await import('nanoid'); + const { nanoid } = await import("nanoid"); const apiKey = `cpk_${nanoid(40)}`; // Hash and store const encoder = new TextEncoder(); const data = encoder.encode(apiKey); - const hash = await crypto.subtle.digest('SHA-256', data); + const hash = await crypto.subtle.digest("SHA-256", data); const hashHex = Array.from(new Uint8Array(hash)) - .map(b => b.toString(16).padStart(2, '0')) - .join(''); + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); await c.env.DB.prepare( - 'UPDATE users SET api_key_hash = ?, updated_at = ? WHERE id = ?' - ).bind(hashHex, now(), user.id).run(); + "UPDATE users SET api_key_hash = ?, updated_at = ? WHERE id = ?", + ) + .bind(hashHex, now(), user.id) + .run(); // Show the key once - return c.html(renderLayout(user, 'API Key Generated', ` + return c.html( + renderLayout( + user, + "API Key Generated", + `

API Key Generated

@@ -675,41 +931,55 @@ dashboard.post('/settings/api-key', async (c) => {
Back to Settings
- `)); + `, + ), + ); }); // Update status page settings -dashboard.post('/settings/status-page', async (c) => { - const user = c.get('user'); +dashboard.post("/settings/status-page", async (c) => { + const user = c.get("user"); const body = await c.req.parseBody(); - const title = (body.status_page_title as string || '').trim().slice(0, 200); - const logoUrl = (body.status_page_logo_url as string || '').trim().slice(0, 500); - const description = (body.status_page_description as string || '').trim().slice(0, 500); + const title = ((body.status_page_title as string) || "").trim().slice(0, 200); + const logoUrl = ((body.status_page_logo_url as string) || "") + .trim() + .slice(0, 500); + const description = ((body.status_page_description as string) || "") + .trim() + .slice(0, 500); const isPublic = body.status_page_public ? 1 : 0; // Basic URL validation for logo - if (logoUrl && !logoUrl.startsWith('https://')) { - return c.redirect('/dashboard/settings?err=logo-url'); + if (logoUrl && !logoUrl.startsWith("https://")) { + return c.redirect("/dashboard/settings?err=logo-url"); } await c.env.DB.prepare( - 'UPDATE users SET status_page_title = ?, status_page_logo_url = ?, status_page_description = ?, status_page_public = ?, updated_at = ? WHERE id = ?' - ).bind(title, logoUrl, description, isPublic, now(), user.id).run(); + "UPDATE users SET status_page_title = ?, status_page_logo_url = ?, status_page_description = ?, status_page_public = ?, updated_at = ? WHERE id = ?", + ) + .bind(title, logoUrl, description, isPublic, now(), user.id) + .run(); - return c.redirect('/dashboard/settings?saved=status-page'); + return c.redirect("/dashboard/settings?saved=status-page"); }); // Generate/regenerate webhook signing secret -dashboard.post('/settings/webhook-secret', async (c) => { - const user = c.get('user'); +dashboard.post("/settings/webhook-secret", async (c) => { + const user = c.get("user"); const secret = generateSigningSecret(); await c.env.DB.prepare( - 'UPDATE users SET webhook_signing_secret = ?, updated_at = ? WHERE id = ?' - ).bind(secret, now(), user.id).run(); - - return c.html(renderLayout(user, 'Webhook Signing Secret', ` + "UPDATE users SET webhook_signing_secret = ?, updated_at = ? WHERE id = ?", + ) + .bind(secret, now(), user.id) + .run(); + + return c.html( + renderLayout( + user, + "Webhook Signing Secret", + `

Webhook Signing Secret

@@ -719,60 +989,83 @@ dashboard.post('/settings/webhook-secret', async (c) => {
Back to Settings
- `)); + `, + ), + ); }); // --- Helpers --- function calcUptime(total: number, ok: number): string { - if (total === 0) return '—'; - return ((ok / total) * 100).toFixed(1) + '%'; + if (total === 0) return "—"; + return ((ok / total) * 100).toFixed(1) + "%"; } -function calcHealthScore(totalPings: number, okPings: number, alertCount: number, status: string): number { - if (status === 'paused') return -1; // Not applicable - if (status === 'new' || totalPings === 0) return -1; // No data yet +function calcHealthScore( + totalPings: number, + okPings: number, + alertCount: number, + status: string, +): number { + if (status === "paused") return -1; // Not applicable + if (status === "new" || totalPings === 0) return -1; // No data yet // Uptime component: 0-70 points - const uptimePct = totalPings > 0 ? (okPings / totalPings) : 1; + const uptimePct = totalPings > 0 ? okPings / totalPings : 1; const uptimeScore = Math.round(uptimePct * 70); // Alert frequency component: 0-30 points (0 alerts = 30, 5+ alerts = 0) - const alertScore = Math.max(0, 30 - (alertCount * 6)); + const alertScore = Math.max(0, 30 - alertCount * 6); return Math.min(100, uptimeScore + alertScore); } function healthScoreBadge(score: number): string { - if (score < 0) return 'N/A'; - let color = 'bg-green-100 text-green-800'; - let label = 'Excellent'; - if (score < 60) { color = 'bg-red-100 text-red-800'; label = 'Poor'; } - else if (score < 80) { color = 'bg-yellow-100 text-yellow-800'; label = 'Fair'; } - else if (score < 95) { color = 'bg-blue-100 text-blue-800'; label = 'Good'; } + if (score < 0) + return 'N/A'; + let color = "bg-green-100 text-green-800"; + let label = "Excellent"; + if (score < 60) { + color = "bg-red-100 text-red-800"; + label = "Poor"; + } else if (score < 80) { + color = "bg-yellow-100 text-yellow-800"; + label = "Fair"; + } else if (score < 95) { + color = "bg-blue-100 text-blue-800"; + label = "Good"; + } return `${score} ${label}`; } function uptimeColor(pct: string): string { - if (pct === '—') return 'text-gray-400'; + if (pct === "—") return "text-gray-400"; const n = parseFloat(pct); - if (n >= 99.5) return 'text-green-600'; - if (n >= 95) return 'text-yellow-600'; - return 'text-red-600'; + if (n >= 99.5) return "text-green-600"; + if (n >= 95) return "text-yellow-600"; + return "text-red-600"; } function renderSparkline(pings: any[]): string { // Take last 30 pings, oldest first const recent = pings.slice(0, 30).reverse(); - if (recent.length === 0) return '

No data yet

'; + if (recent.length === 0) + return '

No data yet

'; const barW = 8; const gap = 2; const h = 32; const totalW = recent.length * (barW + gap) - gap; - const bars = recent.map((p: any, i: number) => { - const x = i * (barW + gap); - const color = p.type === 'success' ? '#22c55e' : p.type === 'start' ? '#3b82f6' : '#ef4444'; - return `${new Date(p.timestamp * 1000).toISOString().replace('T', ' ').slice(0, 19)} — ${p.type}`; - }).join(''); + const bars = recent + .map((p: any, i: number) => { + const x = i * (barW + gap); + const color = + p.type === "success" + ? "#22c55e" + : p.type === "start" + ? "#3b82f6" + : "#ef4444"; + return `${new Date(p.timestamp * 1000).toISOString().replace("T", " ").slice(0, 19)} — ${p.type}`; + }) + .join(""); return `${bars}`; } @@ -781,34 +1074,98 @@ function renderSparkline(pings: any[]): string { function renderLayout(user: User, title: string, content: string): string { return ` - + ${title} - CronPulse + + + + + - -