From 8dc0e3d44552d25cd5259be8b1c8defc0c8cbe5d Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Thu, 15 Jan 2026 06:34:25 -0700 Subject: [PATCH 1/6] feat: allow admin key to delete apps Previously DELETE /apps/:id required the app's own API key. Now admins can also delete apps using the X-Admin-Key header, consistent with other admin operations. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 +- src/index.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6973bad..b37ce4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ Tests use `@cloudflare/vitest-pool-workers` with `isolatedStorage: false` (requi |----------|---------------| | `POST /logs`, `GET /logs` | API Key | | `POST /apps/:id/prune`, `POST /apps/:id/health-urls` | API Key (matching app) | -| `DELETE /apps/:id` | API Key (matching app) | +| `DELETE /apps/:id` | API Key (matching app) OR Admin Key | | `GET /apps` | Admin Key | | `POST /apps` | Admin Key | | `GET /apps/:id`, `GET /stats/:id` | API Key (own app) OR Admin Key | diff --git a/src/index.ts b/src/index.ts index 2e21f88..e03d2c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,7 +52,7 @@ app.get('/', (c) => { 'GET /apps': 'List registered apps (requires admin key)', 'POST /apps': 'Register a new app (requires admin key)', 'GET /apps/:app_id': 'Get app details (requires API key or admin)', - 'DELETE /apps/:app_id': 'Delete an app (requires API key)', + 'DELETE /apps/:app_id': 'Delete an app (requires API key or admin)', }, }) ) @@ -266,12 +266,13 @@ app.get('/apps/:app_id', requireApiKeyOrAdmin, async (c) => { return c.json(Ok(safeData)) }) -// DELETE /apps/:app_id - Delete an app (requires API key) -app.delete('/apps/:app_id', requireApiKey, async (c) => { +// DELETE /apps/:app_id - Delete an app (requires API key or admin) +app.delete('/apps/:app_id', requireApiKeyOrAdmin, async (c) => { const appId = c.req.param('app_id') const authenticatedAppId = c.get('appId') - if (appId !== authenticatedAppId) { + // If using API key auth, must match the requested app + if (authenticatedAppId && appId !== authenticatedAppId) { return c.json(Err({ code: ErrorCode.UNAUTHORIZED, message: 'App ID mismatch' }), 403) } From 0a6c1c519a2c083d9dfeb025c7e40218a9fc39f3 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Thu, 15 Jan 2026 13:41:05 -0700 Subject: [PATCH 2/6] feat(dashboard): overhaul with unified view and advanced filtering (#3) - Add unified overview page showing all apps with error trends and health status - Add enhanced app detail page with 7-day stats chart (Chart.js) - Add advanced filtering: date range picker, request ID, context field filters - Add server-side search and context filtering to Durable Object - Modularize dashboard into separate files for maintainability - Integrate Alpine.js for declarative client-side state management - Add SVG sparklines and trend indicators for quick status overview New file structure: src/dashboard/ index.ts - Main router auth.ts - Session management styles.ts - Shared styles types.ts - Dashboard types pages/ - Login, overview, app-detail api/ - Overview aggregation endpoint components/ - Layout, charts utilities Co-authored-by: Claude Opus 4.5 --- package.json | 2 +- src/dashboard/api/overview.ts | 170 ++++++++++++ src/dashboard/auth.ts | 51 ++++ src/dashboard/components/charts.ts | 163 +++++++++++ src/dashboard/components/layout.ts | 119 ++++++++ src/dashboard/index.ts | 199 ++++++++++++++ src/dashboard/pages/app-detail.ts | 426 +++++++++++++++++++++++++++++ src/dashboard/pages/login.ts | 38 +++ src/dashboard/pages/overview.ts | 182 ++++++++++++ src/dashboard/styles.ts | 71 +++++ src/dashboard/types.ts | 74 +++++ src/durable-objects/app-logs-do.ts | 26 ++ src/index.ts | 2 +- src/types.ts | 2 + 14 files changed, 1523 insertions(+), 2 deletions(-) create mode 100644 src/dashboard/api/overview.ts create mode 100644 src/dashboard/auth.ts create mode 100644 src/dashboard/components/charts.ts create mode 100644 src/dashboard/components/layout.ts create mode 100644 src/dashboard/index.ts create mode 100644 src/dashboard/pages/app-detail.ts create mode 100644 src/dashboard/pages/login.ts create mode 100644 src/dashboard/pages/overview.ts create mode 100644 src/dashboard/styles.ts create mode 100644 src/dashboard/types.ts diff --git a/package.json b/package.json index 2e78e82..2e6ff7c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "npm run wrangler -- dev", + "dev": "npm run wrangler -- dev --ip 0.0.0.0", "deploy": "npm run wrangler -- deploy", "cf-typegen": "npm run wrangler -- types", "wrangler": "set -a && . ./.env && set +a && npx wrangler", diff --git a/src/dashboard/api/overview.ts b/src/dashboard/api/overview.ts new file mode 100644 index 0000000..d160590 --- /dev/null +++ b/src/dashboard/api/overview.ts @@ -0,0 +1,170 @@ +/** + * Overview API endpoint for multi-app aggregation + */ + +import type { Context } from 'hono' +import type { Env, DailyStats, LogEntry } from '../../types' +import type { OverviewResponse, AppSummary } from '../types' +import { calculateTrend, determineHealthStatus } from '../components/charts' + +/** + * Get overview data for all apps + */ +export async function getOverview(c: Context<{ Bindings: Env }>): Promise { + const apps = await getAppList(c) + + if (apps.length === 0) { + return { + apps: [], + totals: { + today: { debug: 0, info: 0, warn: 0, error: 0 }, + yesterday: { debug: 0, info: 0, warn: 0, error: 0 }, + }, + recent_errors: [], + } + } + + // Fetch data for all apps in parallel + const appDataPromises = apps.map(appId => getAppData(c, appId)) + const appData = await Promise.all(appDataPromises) + + // Aggregate totals + const totals = { + today: { debug: 0, info: 0, warn: 0, error: 0 }, + yesterday: { debug: 0, info: 0, warn: 0, error: 0 }, + } + + const appSummaries: AppSummary[] = [] + const allRecentErrors: Array = [] + + for (const data of appData) { + if (!data) continue + + // Aggregate totals + totals.today.debug += data.today_stats.debug + totals.today.info += data.today_stats.info + totals.today.warn += data.today_stats.warn + totals.today.error += data.today_stats.error + totals.yesterday.debug += data.yesterday_stats.debug + totals.yesterday.info += data.yesterday_stats.info + totals.yesterday.warn += data.yesterday_stats.warn + totals.yesterday.error += data.yesterday_stats.error + + // Calculate error trend + const errorTrend = calculateTrend(data.today_stats.error, data.yesterday_stats.error) + + // Get health status + const healthStatus = determineHealthStatus(data.health_checks) + + appSummaries.push({ + id: data.id, + name: data.name, + today_stats: data.today_stats, + yesterday_stats: data.yesterday_stats, + error_trend: errorTrend, + health_status: healthStatus, + last_error: data.last_error, + }) + + // Add recent errors with app_id + for (const error of data.recent_errors) { + allRecentErrors.push({ ...error, app_id: data.id }) + } + } + + // Sort apps by error count (descending) to show problematic apps first + appSummaries.sort((a, b) => b.today_stats.error - a.today_stats.error) + + // Sort recent errors by timestamp and take top 10 + allRecentErrors.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + const recent_errors = allRecentErrors.slice(0, 10) + + return { + apps: appSummaries, + totals, + recent_errors, + } +} + +/** + * Get list of registered app IDs + */ +async function getAppList(c: Context<{ Bindings: Env }>): Promise { + if (!c.env.LOGS_KV) return [] + + const data = await c.env.LOGS_KV.get('apps') + if (!data) return [] + + return JSON.parse(data) +} + +/** + * Get app name from KV + */ +async function getAppName(c: Context<{ Bindings: Env }>, appId: string): Promise { + if (!c.env.LOGS_KV) return appId + + const data = await c.env.LOGS_KV.get(`app:${appId}`) + if (!data) return appId + + const config = JSON.parse(data) + return config.name || appId +} + +/** + * Get aggregated data for a single app + */ +async function getAppData(c: Context<{ Bindings: Env }>, appId: string): Promise<{ + id: string + name: string + today_stats: DailyStats + yesterday_stats: DailyStats + health_checks: Array<{ status: number; checked_at: string }> + recent_errors: LogEntry[] + last_error?: { message: string; timestamp: string } +} | null> { + try { + const id = c.env.APP_LOGS_DO.idFromName(appId) + const stub = c.env.APP_LOGS_DO.get(id) + + // Fetch stats, health, and recent errors in parallel + const [statsRes, healthRes, errorsRes, name] = await Promise.all([ + stub.fetch(new Request('http://do/stats?days=2')), + stub.fetch(new Request('http://do/health?limit=10')), + stub.fetch(new Request('http://do/logs?level=ERROR&limit=5')), + getAppName(c, appId), + ]) + + const statsData = await statsRes.json() as { ok: boolean; data: DailyStats[] } + const healthData = await healthRes.json() as { ok: boolean; data: Array<{ status: number; checked_at: string }> } + const errorsData = await errorsRes.json() as { ok: boolean; data: LogEntry[] } + + const today_stats = statsData.ok && statsData.data?.[0] + ? statsData.data[0] + : { date: new Date().toISOString().split('T')[0], debug: 0, info: 0, warn: 0, error: 0 } + + const yesterday_stats = statsData.ok && statsData.data?.[1] + ? statsData.data[1] + : { date: new Date().toISOString().split('T')[0], debug: 0, info: 0, warn: 0, error: 0 } + + const health_checks = healthData.ok ? (healthData.data || []) : [] + const recent_errors = errorsData.ok ? (errorsData.data || []) : [] + + const last_error = recent_errors.length > 0 + ? { message: recent_errors[0].message, timestamp: recent_errors[0].timestamp } + : undefined + + return { + id: appId, + name, + today_stats, + yesterday_stats, + health_checks, + recent_errors, + last_error, + } + } catch (e) { + console.error(`Failed to get app data for ${appId}:`, e) + return null + } +} diff --git a/src/dashboard/auth.ts b/src/dashboard/auth.ts new file mode 100644 index 0000000..76d1d0f --- /dev/null +++ b/src/dashboard/auth.ts @@ -0,0 +1,51 @@ +/** + * Dashboard authentication utilities + */ + +import { getCookie, setCookie, deleteCookie } from 'hono/cookie' +import type { Context } from 'hono' +import type { Env } from '../types' + +export const SESSION_COOKIE = 'wl_session' + +/** + * Check if request has valid admin session + */ +export async function isAuthenticated(c: Context<{ Bindings: Env }>): Promise { + const session = getCookie(c, SESSION_COOKIE) + if (!session || !c.env.ADMIN_API_KEY) return false + + const expectedHash = await hashAdminKey(c.env.ADMIN_API_KEY) + return session === expectedHash +} + +/** + * Create session token from admin key + */ +export async function hashAdminKey(adminKey: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(adminKey) + const hash = await crypto.subtle.digest('SHA-256', data) + return Array.from(new Uint8Array(hash)) + .map(b => b.toString(16).padStart(2, '0')) + .join('') +} + +/** + * Set session cookie after successful login + */ +export function setSessionCookie(c: Context, sessionHash: string) { + setCookie(c, SESSION_COOKIE, sessionHash, { + httpOnly: true, + secure: true, + sameSite: 'Strict', + maxAge: 60 * 60 * 24 // 24 hours + }) +} + +/** + * Clear session cookie on logout + */ +export function clearSessionCookie(c: Context) { + deleteCookie(c, SESSION_COOKIE) +} diff --git a/src/dashboard/components/charts.ts b/src/dashboard/components/charts.ts new file mode 100644 index 0000000..606535f --- /dev/null +++ b/src/dashboard/components/charts.ts @@ -0,0 +1,163 @@ +/** + * Chart utilities for the dashboard + */ + +import { styles } from '../styles' + +/** + * Generate an SVG sparkline for trend visualization + */ +export function sparkline( + data: number[], + options: { + width?: number + height?: number + color?: string + showArea?: boolean + } = {} +): string { + const { width = 100, height = 30, color = 'currentColor', showArea = false } = options + + if (data.length < 2) { + return ` + No data + ` + } + + const max = Math.max(...data, 1) + const min = Math.min(...data, 0) + const range = max - min || 1 + const padding = 2 + + const points = data.map((v, i) => { + const x = padding + ((i / (data.length - 1)) * (width - padding * 2)) + const y = padding + ((1 - (v - min) / range) * (height - padding * 2)) + return `${x},${y}` + }) + + const areaPath = showArea + ? `` + : '' + + return ` + ${areaPath} + + ` +} + +/** + * Calculate trend (up, down, stable) between two values + */ +export function calculateTrend(current: number, previous: number): 'up' | 'down' | 'stable' { + if (previous === 0) return current > 0 ? 'up' : 'stable' + const change = ((current - previous) / previous) * 100 + if (change > 10) return 'up' + if (change < -10) return 'down' + return 'stable' +} + +/** + * Format a trend as percentage change with arrow + */ +export function formatTrend(current: number, previous: number): string { + const trend = calculateTrend(current, previous) + const color = styles.trendColors[trend] + + if (previous === 0) { + if (current === 0) return '-' + return `+${current}` + } + + const change = ((current - previous) / previous) * 100 + const arrow = trend === 'up' ? '↑' : trend === 'down' ? '↓' : '' + const sign = change > 0 ? '+' : '' + + return `${arrow} ${sign}${Math.round(change)}%` +} + +/** + * Generate Chart.js configuration for daily stats + */ +export function dailyStatsChartConfig( + labels: string[], + datasets: { label: string; data: number[]; color: string }[] +): string { + const config = { + type: 'line', + data: { + labels, + datasets: datasets.map(ds => ({ + label: ds.label, + data: ds.data, + borderColor: ds.color, + backgroundColor: ds.color + '20', + tension: 0.3, + fill: true, + pointRadius: 3, + pointHoverRadius: 5, + })), + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'bottom', + labels: { color: '#9CA3AF' }, + }, + }, + scales: { + x: { + grid: { color: '#374151' }, + ticks: { color: '#9CA3AF' }, + }, + y: { + beginAtZero: true, + grid: { color: '#374151' }, + ticks: { color: '#9CA3AF' }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + }, + } + return JSON.stringify(config) +} + +/** + * Determine health status from recent checks + */ +export function determineHealthStatus( + checks: { status: number; checked_at: string }[] +): 'healthy' | 'degraded' | 'down' | 'unknown' { + if (checks.length === 0) return 'unknown' + + // Look at last 5 checks + const recent = checks.slice(0, 5) + const failed = recent.filter(c => c.status === 0 || c.status >= 500).length + + if (failed === 0) return 'healthy' + if (failed < recent.length) return 'degraded' + return 'down' +} + +/** + * Format health status with icon + */ +export function formatHealthStatus(status: 'healthy' | 'degraded' | 'down' | 'unknown'): string { + const icons = { + healthy: '', + degraded: '', + down: '', + unknown: '', + } + const labels = { + healthy: 'Healthy', + degraded: 'Degraded', + down: 'Down', + unknown: 'Unknown', + } + return `${icons[status]} ${labels[status]}` +} diff --git a/src/dashboard/components/layout.ts b/src/dashboard/components/layout.ts new file mode 100644 index 0000000..1bc6eef --- /dev/null +++ b/src/dashboard/components/layout.ts @@ -0,0 +1,119 @@ +/** + * Shared layout components for the dashboard + */ + +import { logLevelCss } from '../styles' + +export interface LayoutOptions { + title?: string + currentView?: 'overview' | 'app' + currentApp?: string + apps?: string[] +} + +/** + * Generate the HTML document wrapper with head and scripts + */ +export function htmlDocument(content: string, options: LayoutOptions = {}): string { + const { title = 'Worker Logs' } = options + + return ` + + + + + ${title} + + + + + + +${content} + +` +} + +/** + * Dashboard header with navigation + */ +export function header(options: LayoutOptions = {}): string { + const { currentView = 'overview', currentApp, apps = [] } = options + + const appOptions = apps.map(app => + `` + ).join('\n') + + return ` +
+
+
+

Worker Logs

+ +
+ Logout +
+
` +} + +/** + * Stats card component + */ +export function statsCard(label: string, value: number | string, colorClass: string = 'text-gray-100'): string { + return ` +
+
${label}
+
${value}
+
` +} + +/** + * Empty state component + */ +export function emptyState(icon: string, message: string): string { + return ` +
+ ${icon} +

${message}

+
` +} + +/** + * Loading spinner component + */ +export function loadingSpinner(): string { + return ` +
+ + + + +
` +} diff --git a/src/dashboard/index.ts b/src/dashboard/index.ts new file mode 100644 index 0000000..31635af --- /dev/null +++ b/src/dashboard/index.ts @@ -0,0 +1,199 @@ +/** + * Dashboard router - combines all dashboard routes + */ + +import { Hono } from 'hono' +import type { Env, DailyStats, HealthCheck } from '../types' +import { + isAuthenticated, + hashAdminKey, + setSessionCookie, + clearSessionCookie, +} from './auth' +import { loginPage } from './pages/login' +import { overviewPage } from './pages/overview' +import { appDetailPage, type AppDetailData } from './pages/app-detail' +import { getOverview } from './api/overview' + +const dashboard = new Hono<{ Bindings: Env }>() + +/** + * Get list of registered apps from KV + */ +async function getAppList(c: any): Promise { + if (!c.env.LOGS_KV) return [] + const data = await c.env.LOGS_KV.get('apps') + if (!data) return [] + return JSON.parse(data) +} + +/** + * Get app name from KV + */ +async function getAppName(c: any, appId: string): Promise { + if (!c.env.LOGS_KV) return appId + const data = await c.env.LOGS_KV.get(`app:${appId}`) + if (!data) return appId + const config = JSON.parse(data) + return config.name || appId +} + +/** + * Get health URLs from KV config + */ +async function getHealthUrls(c: any, appId: string): Promise { + if (!c.env.LOGS_KV) return [] + const data = await c.env.LOGS_KV.get(`app:${appId}`) + if (!data) return [] + const config = JSON.parse(data) + return config.health_urls || [] +} + +// Main dashboard entry - shows overview or login +dashboard.get('/', async (c) => { + if (!await isAuthenticated(c)) { + return c.html(loginPage()) + } + + const apps = await getAppList(c) + const overviewData = await getOverview(c) + return c.html(overviewPage(overviewData, apps)) +}) + +// App detail page +dashboard.get('/app/:app_id', async (c) => { + if (!await isAuthenticated(c)) { + return c.html(loginPage()) + } + + const appId = c.req.param('app_id') + const apps = await getAppList(c) + + // Check if app exists + if (!apps.includes(appId)) { + return c.redirect('/dashboard') + } + + // Get app data + const id = c.env.APP_LOGS_DO.idFromName(appId) + const stub = c.env.APP_LOGS_DO.get(id) + + const [statsRes, healthRes, appName, healthUrls] = await Promise.all([ + stub.fetch(new Request('http://do/stats?days=7')), + stub.fetch(new Request('http://do/health?limit=50')), + getAppName(c, appId), + getHealthUrls(c, appId), + ]) + + const statsData = await statsRes.json() as { ok: boolean; data: DailyStats[] } + const healthData = await healthRes.json() as { ok: boolean; data: HealthCheck[] } + + const data: AppDetailData = { + appId, + appName, + stats: statsData.ok ? (statsData.data || []) : [], + healthChecks: healthData.ok ? (healthData.data || []) : [], + healthUrls, + } + + return c.html(appDetailPage(data, apps)) +}) + +// Login handler +dashboard.post('/login', async (c) => { + const body = await c.req.parseBody() + const adminKey = body.admin_key as string + + if (!adminKey || adminKey !== c.env.ADMIN_API_KEY) { + return c.html(loginPage('Invalid admin key')) + } + + const sessionHash = await hashAdminKey(adminKey) + setSessionCookie(c, sessionHash) + return c.redirect('/dashboard') +}) + +// Logout handler +dashboard.get('/logout', (c) => { + clearSessionCookie(c) + return c.redirect('/dashboard') +}) + +// API: Get overview data +dashboard.get('/api/overview', async (c) => { + if (!await isAuthenticated(c)) { + return c.json({ ok: false, error: 'Unauthorized' }, 401) + } + + const data = await getOverview(c) + return c.json({ ok: true, data }) +}) + +// API: List apps +dashboard.get('/api/apps', async (c) => { + if (!await isAuthenticated(c)) { + return c.json({ ok: false, error: 'Unauthorized' }, 401) + } + + const apps = await getAppList(c) + return c.json({ ok: true, data: apps }) +}) + +// API: Get logs for an app +dashboard.get('/api/logs/:app_id', async (c) => { + if (!await isAuthenticated(c)) { + return c.json({ ok: false, error: 'Unauthorized' }, 401) + } + + const appId = c.req.param('app_id') + const url = new URL(c.req.url) + + const id = c.env.APP_LOGS_DO.idFromName(appId) + const stub = c.env.APP_LOGS_DO.get(id) + + const res = await stub.fetch(new Request(`http://do/logs${url.search}`, { + method: 'GET', + })) + + return c.json(await res.json()) +}) + +// API: Get stats for an app +dashboard.get('/api/stats/:app_id', async (c) => { + if (!await isAuthenticated(c)) { + return c.json({ ok: false, error: 'Unauthorized' }, 401) + } + + const appId = c.req.param('app_id') + const days = c.req.query('days') || '7' + + const id = c.env.APP_LOGS_DO.idFromName(appId) + const stub = c.env.APP_LOGS_DO.get(id) + + const res = await stub.fetch(new Request(`http://do/stats?days=${days}`, { + method: 'GET', + })) + + return c.json(await res.json()) +}) + +// API: Get health checks for an app +dashboard.get('/api/health/:app_id', async (c) => { + if (!await isAuthenticated(c)) { + return c.json({ ok: false, error: 'Unauthorized' }, 401) + } + + const appId = c.req.param('app_id') + const url = new URL(c.req.url) + + const id = c.env.APP_LOGS_DO.idFromName(appId) + const stub = c.env.APP_LOGS_DO.get(id) + + const res = await stub.fetch(new Request(`http://do/health${url.search}`, { + method: 'GET', + })) + + return c.json(await res.json()) +}) + +export { dashboard } diff --git a/src/dashboard/pages/app-detail.ts b/src/dashboard/pages/app-detail.ts new file mode 100644 index 0000000..24e27ed --- /dev/null +++ b/src/dashboard/pages/app-detail.ts @@ -0,0 +1,426 @@ +/** + * App detail page - single app view with advanced filtering + */ + +import { htmlDocument, header, statsCard } from '../components/layout' +import { dailyStatsChartConfig, formatHealthStatus, determineHealthStatus } from '../components/charts' +import { escapeHtml, styles } from '../styles' +import type { DailyStats, LogEntry, HealthCheck } from '../../types' + +export interface AppDetailData { + appId: string + appName: string + stats: DailyStats[] + healthChecks: HealthCheck[] + healthUrls: string[] +} + +export function appDetailPage(data: AppDetailData, apps: string[]): string { + const { appId, appName, stats, healthChecks, healthUrls } = data + + // Calculate totals for the period + const totals = stats.reduce((acc, day) => ({ + debug: acc.debug + day.debug, + info: acc.info + day.info, + warn: acc.warn + day.warn, + error: acc.error + day.error, + }), { debug: 0, info: 0, warn: 0, error: 0 }) + + // Prepare chart data (reverse to show oldest first) + const chartLabels = stats.map(s => s.date).reverse() + const chartConfig = dailyStatsChartConfig(chartLabels, [ + { label: 'Errors', data: stats.map(s => s.error).reverse(), color: styles.logColors.ERROR }, + { label: 'Warnings', data: stats.map(s => s.warn).reverse(), color: styles.logColors.WARN }, + { label: 'Info', data: stats.map(s => s.info).reverse(), color: styles.logColors.INFO }, + ]) + + // Group health checks by URL + const healthByUrl = new Map() + for (const check of healthChecks) { + const existing = healthByUrl.get(check.url) || [] + existing.push(check) + healthByUrl.set(check.url, existing) + } + + const content = ` + ${header({ currentView: 'app', currentApp: appId, apps })} + +
+ +
+

${escapeHtml(appName)}

+ ${appName !== appId ? `
${escapeHtml(appId)}
` : ''} +
+ + +
+ ${statsCard('Debug (7d)', totals.debug, 'text-gray-400')} + ${statsCard('Info (7d)', totals.info, 'text-blue-400')} + ${statsCard('Warn (7d)', totals.warn, 'text-yellow-400')} + ${statsCard('Error (7d)', totals.error, 'text-red-400')} +
+ + +
+

Log Activity (7 days)

+
+ +
+
+ + + ${healthUrls.length > 0 ? ` +
+
+

Health Checks

+
+
+ + + + + + + + + + + ${healthUrls.map(url => { + const checks = healthByUrl.get(url) || [] + const status = determineHealthStatus(checks) + const lastCheck = checks[0] + const avgLatency = checks.length > 0 + ? Math.round(checks.reduce((sum, c) => sum + c.latency_ms, 0) / checks.length) + : null + return ` + + + + + + ` + }).join('')} + +
URLStatusLatencyLast Check
${escapeHtml(url)}${formatHealthStatus(status)}${avgLatency !== null ? avgLatency + 'ms' : '-'} + ${lastCheck ? new Date(lastCheck.checked_at).toLocaleString() : 'Never'} +
+
+
+ ` : ''} + + +
+
+ +
+ + +
+ + + + + +
+ +
+ + + + + +
+
+ + +
+ + +
+ + +
+ + +
+
+ + +
+
+ Context Filters + +
+ +
+ + +
+ + + +
+
+ + +
+ + + + + + + + + + + + + + +
TimestampLevelMessagePath
+ + +
+
+ Showing logs +
+
+ + +
+
+
+ + +
+
+
+

Log Details

+ +
+
+

+        
+
+
+
+ + ` + + return htmlDocument(content, { title: `Worker Logs - ${appName}` }) +} diff --git a/src/dashboard/pages/login.ts b/src/dashboard/pages/login.ts new file mode 100644 index 0000000..f72820a --- /dev/null +++ b/src/dashboard/pages/login.ts @@ -0,0 +1,38 @@ +/** + * Login page for the dashboard + */ + +import { htmlDocument } from '../components/layout' + +export function loginPage(error?: string): string { + const errorHtml = error + ? `
${error}
` + : '' + + const content = ` +
+
+

Worker Logs

+ ${errorHtml} +
+ + + +
+
+
` + + return htmlDocument(content, { title: 'Worker Logs - Login' }) +} diff --git a/src/dashboard/pages/overview.ts b/src/dashboard/pages/overview.ts new file mode 100644 index 0000000..8e4ee7e --- /dev/null +++ b/src/dashboard/pages/overview.ts @@ -0,0 +1,182 @@ +/** + * Overview page - unified view of all apps + */ + +import { htmlDocument, header, statsCard } from '../components/layout' +import { sparkline, formatTrend, formatHealthStatus, dailyStatsChartConfig } from '../components/charts' +import { escapeHtml, styles } from '../styles' +import type { OverviewResponse } from '../types' + +export function overviewPage(data: OverviewResponse, apps: string[]): string { + const { totals, apps: appSummaries, recent_errors } = data + + const totalErrors = totals.today.error + const appsWithErrors = appSummaries.filter(a => a.today_stats.error > 0).length + const totalApps = appSummaries.length + + // Generate sparkline data (last 7 days would need additional API call, using placeholder) + const errorTrendData = [ + totals.yesterday.error, + totals.today.error, + ] + + const content = ` + ${header({ currentView: 'overview', apps })} + +
+ +
+ ${statsCard('Total Errors (24h)', totalErrors, 'text-red-400')} + ${statsCard('Apps with Issues', `${appsWithErrors}/${totalApps}`, appsWithErrors > 0 ? 'text-yellow-400' : 'text-green-400')} + ${statsCard('Total Warnings', totals.today.warn, 'text-yellow-400')} + ${statsCard('Total Info', totals.today.info, 'text-blue-400')} +
+ + +
+
+

App Health Summary

+ +
+ ${appSummaries.length > 0 ? ` +
+ + + + + + + + + + + + + ${appSummaries.map(app => ` + + + + + + + + + `).join('')} + +
AppErrors (24h)TrendStatusLast Error
+ ${escapeHtml(app.name)} + ${app.name !== app.id ? `
${escapeHtml(app.id)}
` : ''} +
+ ${app.today_stats.error} + + ${formatTrend(app.today_stats.error, app.yesterday_stats.error)} + + ${formatHealthStatus(app.health_status)} + + ${app.last_error ? ` + ${escapeHtml(app.last_error.message.substring(0, 50))}${app.last_error.message.length > 50 ? '...' : ''} + ` : '-'} + + + + + + +
+
+ ` : ` +
+ No apps registered yet. Use the API to register your first app. +
+ `} +
+ + +
+
+

Recent Errors (All Apps)

+
+ ${recent_errors.length > 0 ? ` +
+ + + + + + + + + + + ${recent_errors.map(error => ` + + + + + + + `).join('')} + +
TimestampAppMessagePath
+ ${new Date(error.timestamp).toLocaleString()} + + + ${escapeHtml(error.app_id)} + + + ${escapeHtml(error.message)} + + ${error.context?.path ? escapeHtml(String(error.context.path)) : '-'} +
+
+ ` : ` +
+ No recent errors. Your apps are running smoothly! +
+ `} +
+ + +
+
+
+

Error Details

+ +
+
+

+        
+
+
+
+ + ` + + return htmlDocument(content, { title: 'Worker Logs - Overview' }) +} diff --git a/src/dashboard/styles.ts b/src/dashboard/styles.ts new file mode 100644 index 0000000..88471fd --- /dev/null +++ b/src/dashboard/styles.ts @@ -0,0 +1,71 @@ +/** + * Shared styles for the dashboard + */ + +export const styles = { + // Log level colors + logColors: { + DEBUG: '#9CA3AF', + INFO: '#60A5FA', + WARN: '#FBBF24', + ERROR: '#F87171', + }, + + // Badge background colors + badgeColors: { + DEBUG: { bg: '#374151', text: '#9CA3AF' }, + INFO: { bg: '#1E3A5F', text: '#60A5FA' }, + WARN: { bg: '#78350F', text: '#FBBF24' }, + ERROR: { bg: '#7F1D1D', text: '#F87171' }, + }, + + // Status colors for health checks + statusColors: { + healthy: '#10B981', + degraded: '#FBBF24', + down: '#F87171', + unknown: '#6B7280', + }, + + // Trend indicator colors + trendColors: { + up: '#F87171', // Red for increasing errors + down: '#10B981', // Green for decreasing errors + stable: '#6B7280', // Gray for stable + }, +} as const + +/** + * CSS styles for log levels (inline in - - - -
-
-

Worker Logs

-
- - Logout -
-
-
- - -
- - - - - - - - - - -
- - - -

Select an app to view logs

-
- - - -
- - - -` -} - -// Dashboard routes -dashboard.get('/', async (c) => { - if (!await isAuthenticated(c)) { - return c.html(loginPage()) - } - return c.html(dashboardPage()) -}) - -dashboard.post('/login', async (c) => { - const body = await c.req.parseBody() - const adminKey = body.admin_key as string - - if (!adminKey || adminKey !== c.env.ADMIN_API_KEY) { - return c.html(loginPage('Invalid admin key')) - } - - const session = await createSession(adminKey) - setCookie(c, SESSION_COOKIE, session, { - httpOnly: true, - secure: true, - sameSite: 'Strict', - maxAge: 60 * 60 * 24 // 24 hours - }) - - return c.redirect('/dashboard') -}) - -dashboard.get('/logout', (c) => { - deleteCookie(c, SESSION_COOKIE) - return c.redirect('/dashboard') -}) - -// API endpoint for fetching logs (requires auth) -dashboard.get('/api/logs/:app_id', async (c) => { - if (!await isAuthenticated(c)) { - return c.json({ ok: false, error: 'Unauthorized' }, 401) - } - - const appId = c.req.param('app_id') - const url = new URL(c.req.url) - - const id = c.env.APP_LOGS_DO.idFromName(appId) - const stub = c.env.APP_LOGS_DO.get(id) - - const res = await stub.fetch(new Request(`http://do/logs${url.search}`, { - method: 'GET', - })) - - return c.json(await res.json()) -}) - -// API endpoint for listing apps (requires auth) -dashboard.get('/api/apps', async (c) => { - if (!await isAuthenticated(c)) { - return c.json({ ok: false, error: 'Unauthorized' }, 401) - } - - if (!c.env.LOGS_KV) { - return c.json({ ok: false, error: 'KV namespace not configured' }, 500) - } - - const data = await c.env.LOGS_KV.get('apps') - if (!data) { - return c.json({ ok: true, data: [] }) - } - - return c.json({ ok: true, data: JSON.parse(data) }) -}) - -// API endpoint for fetching stats (requires auth) -dashboard.get('/api/stats/:app_id', async (c) => { - if (!await isAuthenticated(c)) { - return c.json({ ok: false, error: 'Unauthorized' }, 401) - } - - const appId = c.req.param('app_id') - const days = c.req.query('days') || '7' - - const id = c.env.APP_LOGS_DO.idFromName(appId) - const stub = c.env.APP_LOGS_DO.get(id) - - const res = await stub.fetch(new Request(`http://do/stats?days=${days}`, { - method: 'GET', - })) - - return c.json(await res.json()) -}) - -export { dashboard } From dc664a47f5f11e23c21ce01779185edd75031197 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Tue, 20 Jan 2026 12:43:01 -0700 Subject: [PATCH 4/6] refactor: code simplifier pass 1 - remove dead code and consolidate utilities (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: remove dead code and extract shared utilities - Delete unused src/services/stats.ts (legacy KV-based stats, replaced by DO SQLite) - Extract getAppDO and countByLevel to src/utils.ts - countByLevel now uses Map for O(n) instead of O(n²) array scanning Co-Authored-By: Claude Opus 4.5 * refactor: consolidate duplicated code and remove unused functions Dashboard: - Extract getAppList, getAppName, getHealthUrls to shared helpers.ts - Fix type safety: replace `any` with proper Context type Result utilities: - Add wrapError() for consistent error handling in catch blocks - Remove unused exports: isOk, isErr, getErrorStatus, ErrorStatusMap Registry service: - Use wrapError() instead of inline error handling (6 occurrences) - Remove unused validateApiKey and regenerateApiKey functions Co-Authored-By: Claude Opus 4.5 * chore: remove unused dashboard code - Remove unused loadingSpinner component from layout.ts - Remove unused appOptions variable from header function - Remove unused types: SavedFilter, FilterState, HealthSummary - Remove unused HealthCheck import from types.ts Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- src/dashboard/api/overview.ts | 26 +--- src/dashboard/components/layout.ts | 17 --- src/dashboard/helpers.ts | 40 +++++ src/dashboard/index.ts | 33 +--- src/dashboard/types.ts | 43 +----- src/index.ts | 19 +-- src/result.ts | 35 +---- src/rpc.ts | 24 +-- src/services/registry.ts | 73 +-------- src/services/stats.ts | 237 ----------------------------- src/utils.ts | 24 +++ test/result.test.ts | 47 ++---- 12 files changed, 101 insertions(+), 517 deletions(-) create mode 100644 src/dashboard/helpers.ts delete mode 100644 src/services/stats.ts create mode 100644 src/utils.ts diff --git a/src/dashboard/api/overview.ts b/src/dashboard/api/overview.ts index d160590..cd189c3 100644 --- a/src/dashboard/api/overview.ts +++ b/src/dashboard/api/overview.ts @@ -6,6 +6,7 @@ import type { Context } from 'hono' import type { Env, DailyStats, LogEntry } from '../../types' import type { OverviewResponse, AppSummary } from '../types' import { calculateTrend, determineHealthStatus } from '../components/charts' +import { getAppList, getAppName } from '../helpers' /** * Get overview data for all apps @@ -86,31 +87,6 @@ export async function getOverview(c: Context<{ Bindings: Env }>): Promise): Promise { - if (!c.env.LOGS_KV) return [] - - const data = await c.env.LOGS_KV.get('apps') - if (!data) return [] - - return JSON.parse(data) -} - -/** - * Get app name from KV - */ -async function getAppName(c: Context<{ Bindings: Env }>, appId: string): Promise { - if (!c.env.LOGS_KV) return appId - - const data = await c.env.LOGS_KV.get(`app:${appId}`) - if (!data) return appId - - const config = JSON.parse(data) - return config.name || appId -} - /** * Get aggregated data for a single app */ diff --git a/src/dashboard/components/layout.ts b/src/dashboard/components/layout.ts index 1bc6eef..d471eac 100644 --- a/src/dashboard/components/layout.ts +++ b/src/dashboard/components/layout.ts @@ -43,10 +43,6 @@ ${content} export function header(options: LayoutOptions = {}): string { const { currentView = 'overview', currentApp, apps = [] } = options - const appOptions = apps.map(app => - `` - ).join('\n') - return `
@@ -104,16 +100,3 @@ export function emptyState(icon: string, message: string): string {

${message}

` } - -/** - * Loading spinner component - */ -export function loadingSpinner(): string { - return ` -
- - - - -
` -} diff --git a/src/dashboard/helpers.ts b/src/dashboard/helpers.ts new file mode 100644 index 0000000..71e3912 --- /dev/null +++ b/src/dashboard/helpers.ts @@ -0,0 +1,40 @@ +/** + * Shared helper functions for dashboard routes + */ + +import type { Context } from 'hono' +import type { Env } from '../types' + +type DashboardContext = Context<{ Bindings: Env }> + +/** + * Get list of registered app IDs from KV + */ +export async function getAppList(c: DashboardContext): Promise { + if (!c.env.LOGS_KV) return [] + const data = await c.env.LOGS_KV.get('apps') + if (!data) return [] + return JSON.parse(data) +} + +/** + * Get app name from KV config + */ +export async function getAppName(c: DashboardContext, appId: string): Promise { + if (!c.env.LOGS_KV) return appId + const data = await c.env.LOGS_KV.get(`app:${appId}`) + if (!data) return appId + const config = JSON.parse(data) + return config.name || appId +} + +/** + * Get health URLs from KV config + */ +export async function getHealthUrls(c: DashboardContext, appId: string): Promise { + if (!c.env.LOGS_KV) return [] + const data = await c.env.LOGS_KV.get(`app:${appId}`) + if (!data) return [] + const config = JSON.parse(data) + return config.health_urls || [] +} diff --git a/src/dashboard/index.ts b/src/dashboard/index.ts index 31635af..19e4e14 100644 --- a/src/dashboard/index.ts +++ b/src/dashboard/index.ts @@ -14,41 +14,10 @@ import { loginPage } from './pages/login' import { overviewPage } from './pages/overview' import { appDetailPage, type AppDetailData } from './pages/app-detail' import { getOverview } from './api/overview' +import { getAppList, getAppName, getHealthUrls } from './helpers' const dashboard = new Hono<{ Bindings: Env }>() -/** - * Get list of registered apps from KV - */ -async function getAppList(c: any): Promise { - if (!c.env.LOGS_KV) return [] - const data = await c.env.LOGS_KV.get('apps') - if (!data) return [] - return JSON.parse(data) -} - -/** - * Get app name from KV - */ -async function getAppName(c: any, appId: string): Promise { - if (!c.env.LOGS_KV) return appId - const data = await c.env.LOGS_KV.get(`app:${appId}`) - if (!data) return appId - const config = JSON.parse(data) - return config.name || appId -} - -/** - * Get health URLs from KV config - */ -async function getHealthUrls(c: any, appId: string): Promise { - if (!c.env.LOGS_KV) return [] - const data = await c.env.LOGS_KV.get(`app:${appId}`) - if (!data) return [] - const config = JSON.parse(data) - return config.health_urls || [] -} - // Main dashboard entry - shows overview or login dashboard.get('/', async (c) => { if (!await isAuthenticated(c)) { diff --git a/src/dashboard/types.ts b/src/dashboard/types.ts index d4ac1bc..9d0c44b 100644 --- a/src/dashboard/types.ts +++ b/src/dashboard/types.ts @@ -2,7 +2,7 @@ * Dashboard-specific type definitions */ -import type { DailyStats, LogEntry, HealthCheck } from '../types' +import type { DailyStats, LogEntry } from '../types' /** * App summary for the overview page @@ -31,44 +31,3 @@ export interface OverviewResponse { } recent_errors: Array } - -/** - * Saved filter configuration - */ -export interface SavedFilter { - id: string - name: string - app_id?: string - level?: string - date_range?: { - preset?: 'today' | '7d' | '30d' | 'custom' - since?: string - until?: string - } - context_filters?: Record - search?: string - created_at: string -} - -/** - * Filter state for the app detail page - */ -export interface FilterState { - level: string - dateRange: 'today' | '7d' | '30d' | 'custom' - since: string - until: string - requestId: string - search: string - contextFilters: Array<{ key: string; value: string }> -} - -/** - * Health status summary - */ -export interface HealthSummary { - url: string - status: 'healthy' | 'degraded' | 'down' | 'unknown' - last_check?: HealthCheck - avg_latency_ms?: number -} diff --git a/src/index.ts b/src/index.ts index 92bfd6c..cf95670 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import type { Env, LogInput, LogBatchInput } from './types' import * as registry from './services/registry' import { requireApiKey, requireAdminKey, requireApiKeyOrAdmin } from './middleware/auth' import { dashboard } from './dashboard/index' +import { getAppDO, countByLevel } from './utils' // Re-export AppLogsDO for wrangler to find export { AppLogsDO } from './durable-objects/app-logs-do' @@ -26,14 +27,6 @@ app.use('*', cors()) // Mount dashboard routes app.route('/dashboard', dashboard) -/** - * Get a DO stub for the given app_id - */ -function getAppDO(env: Env, appId: string) { - const id = env.APP_LOGS_DO.idFromName(appId) - return env.APP_LOGS_DO.get(id) -} - // Service info app.get('/', (c) => { return c.json( @@ -75,15 +68,7 @@ app.post('/logs', requireApiKey, async (c) => { // Record stats in DO (atomic, no race condition) if (result.ok) { - const counts = body.logs.reduce((acc, log) => { - const existing = acc.find((c) => c.level === log.level) - if (existing) { - existing.count++ - } else { - acc.push({ level: log.level, count: 1 }) - } - return acc - }, [] as { level: string; count: number }[]) + const counts = countByLevel(body.logs) await stub.fetch(new Request('http://do/stats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/result.ts b/src/result.ts index fd54389..2f81022 100644 --- a/src/result.ts +++ b/src/result.ts @@ -77,36 +77,9 @@ export const ErrorCode = { export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode] /** - * Map error codes to HTTP status codes + * Wrap an unknown error as an Err result with INTERNAL_ERROR code */ -export const ErrorStatusMap: Record = { - [ErrorCode.BAD_REQUEST]: 400, - [ErrorCode.UNAUTHORIZED]: 401, - [ErrorCode.FORBIDDEN]: 403, - [ErrorCode.NOT_FOUND]: 404, - [ErrorCode.VALIDATION_ERROR]: 422, - [ErrorCode.INTERNAL_ERROR]: 500, - [ErrorCode.NOT_IMPLEMENTED]: 501, - [ErrorCode.SERVICE_UNAVAILABLE]: 503, -} - -/** - * Get HTTP status code for an error - */ -export function getErrorStatus(error: ApiError): number { - return ErrorStatusMap[error.code as ErrorCode] ?? 500 -} - -/** - * Type guard to check if result is Ok - */ -export function isOk(result: Result): result is Ok { - return result.ok === true -} - -/** - * Type guard to check if result is Err - */ -export function isErr(result: Result): result is Err { - return result.ok === false +export function wrapError(e: unknown): Err { + const message = e instanceof Error ? e.message : 'Unknown error' + return Err({ code: ErrorCode.INTERNAL_ERROR, message }) } diff --git a/src/rpc.ts b/src/rpc.ts index 2b13e77..e1f2ab6 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -7,6 +7,7 @@ import { WorkerEntrypoint } from 'cloudflare:workers' import type { Env, LogInput, LogEntry, QueryFilters, DailyStats } from './types' +import { getAppDO, countByLevel } from './utils' /** * RPC interface for worker-logs service binding. @@ -21,16 +22,15 @@ export class LogsRPC extends WorkerEntrypoint { /** * Get a Durable Object stub for the given app */ - private getAppDO(appId: string) { - const id = this.env.APP_LOGS_DO.idFromName(appId) - return this.env.APP_LOGS_DO.get(id) + private getStub(appId: string) { + return getAppDO(this.env, appId) } /** * Write a single log entry */ async log(appId: string, entry: LogInput): Promise { - const stub = this.getAppDO(appId) + const stub = this.getStub(appId) const res = await stub.fetch(new Request('http://do/log', { method: 'POST', @@ -56,7 +56,7 @@ export class LogsRPC extends WorkerEntrypoint { * Write multiple log entries in a batch */ async logBatch(appId: string, entries: LogInput[]): Promise<{ count: number }> { - const stub = this.getAppDO(appId) + const stub = this.getStub(appId) const res = await stub.fetch(new Request('http://do/logs', { method: 'POST', @@ -68,15 +68,7 @@ export class LogsRPC extends WorkerEntrypoint { // Record stats in DO (atomic, no race condition) if (result.ok) { - const counts = entries.reduce((acc, log) => { - const existing = acc.find((c) => c.level === log.level) - if (existing) { - existing.count++ - } else { - acc.push({ level: log.level, count: 1 }) - } - return acc - }, [] as { level: string; count: number }[]) + const counts = countByLevel(entries) await stub.fetch(new Request('http://do/stats', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -91,7 +83,7 @@ export class LogsRPC extends WorkerEntrypoint { * Query logs with optional filters */ async query(appId: string, filters?: QueryFilters): Promise { - const stub = this.getAppDO(appId) + const stub = this.getStub(appId) const params = new URLSearchParams() if (filters?.level) params.set('level', filters.level) @@ -114,7 +106,7 @@ export class LogsRPC extends WorkerEntrypoint { * Get daily stats for an app */ async getStats(appId: string, days: number = 7): Promise { - const stub = this.getAppDO(appId) + const stub = this.getStub(appId) const res = await stub.fetch(new Request(`http://do/stats?days=${days}`, { method: 'GET', diff --git a/src/services/registry.ts b/src/services/registry.ts index 9d122f5..c188243 100644 --- a/src/services/registry.ts +++ b/src/services/registry.ts @@ -2,7 +2,7 @@ * App Registry Service - manages app registrations in KV */ -import { Ok, Err, type Result, ErrorCode } from '../result' +import { Ok, type Result, wrapError } from '../result' import type { AppConfig } from '../types' const APPS_KEY = 'apps' @@ -29,8 +29,7 @@ export async function listApps(kv: KVNamespace): Promise> { } return Ok(JSON.parse(data) as string[]) } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - return Err({ code: ErrorCode.INTERNAL_ERROR, message }) + return wrapError(e) } } @@ -48,8 +47,7 @@ export async function getApp( } return Ok(JSON.parse(data) as AppConfig) } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - return Err({ code: ErrorCode.INTERNAL_ERROR, message }) + return wrapError(e) } } @@ -101,8 +99,7 @@ export async function registerApp( return Ok(config) } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - return Err({ code: ErrorCode.INTERNAL_ERROR, message }) + return wrapError(e) } } @@ -127,66 +124,6 @@ export async function deleteApp( return Ok({ deleted: true }) } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - return Err({ code: ErrorCode.INTERNAL_ERROR, message }) - } -} - -/** - * Validate an API key and return the associated app ID - */ -export async function validateApiKey( - kv: KVNamespace, - apiKey: string -): Promise> { - try { - // List all apps and check their API keys - const appsResult = await listApps(kv) - if (!appsResult.ok) { - return appsResult - } - - for (const appId of appsResult.data) { - const appResult = await getApp(kv, appId) - if (appResult.ok && appResult.data?.api_key === apiKey) { - return Ok(appId) - } - } - - return Ok(null) - } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - return Err({ code: ErrorCode.INTERNAL_ERROR, message }) - } -} - -/** - * Regenerate API key for an app - */ -export async function regenerateApiKey( - kv: KVNamespace, - appId: string -): Promise> { - try { - const appResult = await getApp(kv, appId) - if (!appResult.ok) { - return appResult - } - if (!appResult.data) { - return Err({ code: ErrorCode.NOT_FOUND, message: `App '${appId}' not found` }) - } - - const newKey = generateApiKey() - const config: AppConfig = { - ...appResult.data, - api_key: newKey, - } - - await kv.put(`${APP_PREFIX}${appId}`, JSON.stringify(config)) - - return Ok({ api_key: newKey }) - } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - return Err({ code: ErrorCode.INTERNAL_ERROR, message }) + return wrapError(e) } } diff --git a/src/services/stats.ts b/src/services/stats.ts deleted file mode 100644 index d0a34c4..0000000 --- a/src/services/stats.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Stats Service - manages daily log aggregations in KV - */ - -import { Ok, Err, type Result, ErrorCode } from '../result' -import type { DailyStats, LogLevel } from '../types' - -const STATS_PREFIX = 'stats:' - -/** - * Get the date string for today (or a specific date) - */ -function getDateKey(date?: Date): string { - const d = date ?? new Date() - return d.toISOString().split('T')[0] // YYYY-MM-DD -} - -/** - * Get the KV key for stats - */ -function getStatsKey(appId: string, date: string): string { - return `${STATS_PREFIX}${appId}:${date}` -} - -/** - * Get stats for a specific app and date - */ -export async function getStats( - kv: KVNamespace, - appId: string, - date?: string -): Promise> { - try { - const dateKey = date ?? getDateKey() - const key = getStatsKey(appId, dateKey) - const data = await kv.get(key) - - if (!data) { - // Return empty stats for the date - return Ok({ - date: dateKey, - debug: 0, - info: 0, - warn: 0, - error: 0, - }) - } - - return Ok(JSON.parse(data) as DailyStats) - } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - return Err({ code: ErrorCode.INTERNAL_ERROR, message }) - } -} - -/** - * Get stats for multiple days - */ -export async function getStatsRange( - kv: KVNamespace, - appId: string, - days: number = 7 -): Promise> { - try { - const stats: DailyStats[] = [] - const today = new Date() - - for (let i = 0; i < days; i++) { - const date = new Date(today) - date.setDate(date.getDate() - i) - const dateKey = getDateKey(date) - - const result = await getStats(kv, appId, dateKey) - if (result.ok) { - stats.push(result.data) - } - } - - return Ok(stats) - } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - return Err({ code: ErrorCode.INTERNAL_ERROR, message }) - } -} - -/** - * Increment stats for a log level - */ -export async function incrementStats( - kv: KVNamespace, - appId: string, - level: LogLevel, - count: number = 1 -): Promise> { - try { - const dateKey = getDateKey() - const key = getStatsKey(appId, dateKey) - - // Get current stats - const data = await kv.get(key) - let stats: DailyStats - - if (data) { - stats = JSON.parse(data) as DailyStats - } else { - stats = { - date: dateKey, - debug: 0, - info: 0, - warn: 0, - error: 0, - } - } - - // Increment the appropriate counter - switch (level) { - case 'DEBUG': - stats.debug += count - break - case 'INFO': - stats.info += count - break - case 'WARN': - stats.warn += count - break - case 'ERROR': - stats.error += count - break - } - - // Save updated stats with 30-day TTL - await kv.put(key, JSON.stringify(stats), { - expirationTtl: 30 * 24 * 60 * 60, // 30 days - }) - - return Ok(stats) - } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - return Err({ code: ErrorCode.INTERNAL_ERROR, message }) - } -} - -/** - * Increment stats for multiple log levels (batch) - */ -export async function incrementStatsBatch( - kv: KVNamespace, - appId: string, - counts: { level: LogLevel; count: number }[] -): Promise> { - try { - const dateKey = getDateKey() - const key = getStatsKey(appId, dateKey) - - // Get current stats - const data = await kv.get(key) - let stats: DailyStats - - if (data) { - stats = JSON.parse(data) as DailyStats - } else { - stats = { - date: dateKey, - debug: 0, - info: 0, - warn: 0, - error: 0, - } - } - - // Increment all counters - for (const { level, count } of counts) { - switch (level) { - case 'DEBUG': - stats.debug += count - break - case 'INFO': - stats.info += count - break - case 'WARN': - stats.warn += count - break - case 'ERROR': - stats.error += count - break - } - } - - // Save updated stats with 30-day TTL - await kv.put(key, JSON.stringify(stats), { - expirationTtl: 30 * 24 * 60 * 60, // 30 days - }) - - return Ok(stats) - } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - return Err({ code: ErrorCode.INTERNAL_ERROR, message }) - } -} - -/** - * Get total stats across all time (sum of all days) - */ -export async function getTotalStats( - kv: KVNamespace, - appId: string, - days: number = 30 -): Promise }>> { - try { - const rangeResult = await getStatsRange(kv, appId, days) - if (!rangeResult.ok) { - return rangeResult - } - - const totals = { - debug: 0, - info: 0, - warn: 0, - error: 0, - } - - for (const day of rangeResult.data) { - totals.debug += day.debug - totals.info += day.info - totals.warn += day.warn - totals.error += day.error - } - - return Ok({ - total: totals.debug + totals.info + totals.warn + totals.error, - by_level: totals, - }) - } catch (e) { - const message = e instanceof Error ? e.message : 'Unknown error' - return Err({ code: ErrorCode.INTERNAL_ERROR, message }) - } -} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..3265cb0 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,24 @@ +/** + * Shared utility functions + */ + +import type { Env } from './types' + +/** + * Get a Durable Object stub for the given app_id + */ +export function getAppDO(env: Env, appId: string) { + const id = env.APP_LOGS_DO.idFromName(appId) + return env.APP_LOGS_DO.get(id) +} + +/** + * Count log entries by level using an efficient Map-based approach + */ +export function countByLevel(logs: { level: string }[]): { level: string; count: number }[] { + const counts = new Map() + for (const log of logs) { + counts.set(log.level, (counts.get(log.level) ?? 0) + 1) + } + return Array.from(counts.entries()).map(([level, count]) => ({ level, count })) +} diff --git a/test/result.test.ts b/test/result.test.ts index c104235..0bec49a 100644 --- a/test/result.test.ts +++ b/test/result.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { Ok, Err, isOk, isErr, ErrorCode, getErrorStatus, ErrorStatusMap } from '../src/result' +import { Ok, Err, ErrorCode, wrapError } from '../src/result' describe('Result utilities', () => { describe('Ok', () => { @@ -34,18 +34,6 @@ describe('Result utilities', () => { }) }) - describe('Type guards', () => { - it('isOk correctly identifies Ok results', () => { - expect(isOk(Ok('test'))).toBe(true) - expect(isOk(Err({ code: 'ERROR', message: 'fail' }))).toBe(false) - }) - - it('isErr correctly identifies Err results', () => { - expect(isErr(Err({ code: 'ERROR', message: 'fail' }))).toBe(true) - expect(isErr(Ok('test'))).toBe(false) - }) - }) - describe('Error codes', () => { it('has all expected error codes', () => { expect(ErrorCode.BAD_REQUEST).toBe('BAD_REQUEST') @@ -59,29 +47,24 @@ describe('Result utilities', () => { }) }) - describe('getErrorStatus', () => { - it('maps error codes to HTTP status codes', () => { - expect(getErrorStatus({ code: ErrorCode.BAD_REQUEST, message: '' })).toBe(400) - expect(getErrorStatus({ code: ErrorCode.UNAUTHORIZED, message: '' })).toBe(401) - expect(getErrorStatus({ code: ErrorCode.FORBIDDEN, message: '' })).toBe(403) - expect(getErrorStatus({ code: ErrorCode.NOT_FOUND, message: '' })).toBe(404) - expect(getErrorStatus({ code: ErrorCode.VALIDATION_ERROR, message: '' })).toBe(422) - expect(getErrorStatus({ code: ErrorCode.INTERNAL_ERROR, message: '' })).toBe(500) - expect(getErrorStatus({ code: ErrorCode.NOT_IMPLEMENTED, message: '' })).toBe(501) - expect(getErrorStatus({ code: ErrorCode.SERVICE_UNAVAILABLE, message: '' })).toBe(503) + describe('wrapError', () => { + it('wraps Error instances with their message', () => { + const result = wrapError(new Error('Something went wrong')) + expect(result.ok).toBe(false) + expect(result.error.code).toBe('INTERNAL_ERROR') + expect(result.error.message).toBe('Something went wrong') }) - it('defaults to 500 for unknown error codes', () => { - expect(getErrorStatus({ code: 'UNKNOWN_CODE', message: '' })).toBe(500) + it('wraps unknown values with default message', () => { + const result = wrapError('string error') + expect(result.ok).toBe(false) + expect(result.error.code).toBe('INTERNAL_ERROR') + expect(result.error.message).toBe('Unknown error') }) - }) - describe('ErrorStatusMap', () => { - it('contains correct mappings', () => { - expect(ErrorStatusMap[ErrorCode.BAD_REQUEST]).toBe(400) - expect(ErrorStatusMap[ErrorCode.UNAUTHORIZED]).toBe(401) - expect(ErrorStatusMap[ErrorCode.NOT_FOUND]).toBe(404) - expect(ErrorStatusMap[ErrorCode.INTERNAL_ERROR]).toBe(500) + it('wraps null/undefined with default message', () => { + expect(wrapError(null).error.message).toBe('Unknown error') + expect(wrapError(undefined).error.message).toBe('Unknown error') }) }) }) From 5a2f4dccafd0e9ca72934bf6a92baf7dcc576dff Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Tue, 27 Jan 2026 14:36:29 -0700 Subject: [PATCH 5/6] feat(dashboard): make branding configurable via env vars (#7) Add BrandConfig type and getBrandConfig() that reads BRAND_* environment variables with automatic accent color derivation and CDN URL composition. Replace static CSS with buildBrandCss(config) generated from BrandConfig. Thread brand config through router middleware and all page functions. Includes comprehensive tests for brand config defaults, env overrides, hex color validation, and accent fallback behavior. Cherry-picked from aibtcdev/worker-logs#10 with conflict resolution to preserve upstream wrangler.jsonc (no fork-specific environments). Co-authored-by: Claude Opus 4.5 --- .gitignore | 1 + src/dashboard/brand.ts | 130 ++++++++++++++++++++++ src/dashboard/components/layout.ts | 169 ++++++++++++++++++++++++++++- src/dashboard/helpers.ts | 8 +- src/dashboard/index.ts | 47 ++++---- src/dashboard/pages/app-detail.ts | 8 +- src/dashboard/pages/login.ts | 14 ++- src/dashboard/pages/overview.ts | 8 +- src/types.ts | 10 ++ test/brand.test.ts | 139 ++++++++++++++++++++++++ 10 files changed, 496 insertions(+), 38 deletions(-) create mode 100644 src/dashboard/brand.ts create mode 100644 test/brand.test.ts diff --git a/.gitignore b/.gitignore index 70eec63..efafa61 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ *.log .DS_Store worker-configuration.d.ts +.planning/ diff --git a/src/dashboard/brand.ts b/src/dashboard/brand.ts new file mode 100644 index 0000000..6fb018c --- /dev/null +++ b/src/dashboard/brand.ts @@ -0,0 +1,130 @@ +/** + * Brand configuration for dashboard customization + */ + +const HEX_COLOR_RE = /^#?[0-9a-fA-F]{6}$/ + +/** + * Environment variables that control branding + */ +export interface BrandEnv { + BRAND_NAME?: string + BRAND_ACCENT?: string + BRAND_CDN_URL?: string + BRAND_FONT_NAME?: string + BRAND_LOGO_URL?: string + BRAND_FAVICON_URL?: string + BRAND_FONT_REGULAR_URL?: string + BRAND_FONT_MEDIUM_URL?: string + BRAND_PATTERN_URL?: string +} + +/** + * Complete brand configuration interface + */ +export interface BrandConfig { + name: string + accentColor: string + accentHoverColor: string + accentLightColor: string + accentDimColor: string + accentGlowColor: string + accentBorderColor: string + fontName: string + fontRegularUrl: string + fontMediumUrl: string + logoUrl: string + faviconUrl: string + patternImageUrl: string + cdnHost: string +} + +/** + * Default AIBTC brand configuration + */ +export const DEFAULT_BRAND_CONFIG: BrandConfig = { + name: 'AIBTC', + accentColor: '#FF4F03', + accentHoverColor: '#e54400', + accentLightColor: '#ff7033', + accentDimColor: 'rgba(255, 79, 3, 0.12)', + accentGlowColor: 'rgba(255, 79, 3, 0.08)', + accentBorderColor: 'rgba(255, 79, 3, 0.3)', + fontName: 'Roc Grotesk', + fontRegularUrl: 'https://aibtc.com/fonts/RocGrotesk-Regular.woff2', + fontMediumUrl: 'https://aibtc.com/fonts/RocGrotesk-WideMedium.woff2', + logoUrl: 'https://aibtc.com/Primary_Logo/SVG/AIBTC_PrimaryLogo_KO.svg', + faviconUrl: 'https://aibtc.com/favicon-32x32.png', + patternImageUrl: 'https://aibtc.com/Artwork/AIBTC_Pattern1_optimized.jpg', + cdnHost: 'https://aibtc.com', +} + +/** + * Convert hex color to rgba string + */ +export function hexToRgba(hex: string, alpha: number): string { + if (!HEX_COLOR_RE.test(hex)) { + return `rgba(0, 0, 0, ${alpha})` + } + + const cleanHex = hex.replace(/^#/, '') + const r = parseInt(cleanHex.substring(0, 2), 16) + const g = parseInt(cleanHex.substring(2, 4), 16) + const b = parseInt(cleanHex.substring(4, 6), 16) + + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +/** + * Get brand configuration from environment variables + * + * Supports: + * - BRAND_NAME: Brand display name + * - BRAND_ACCENT: Hex color (derives hover/dim/glow/border/light automatically) + * - BRAND_CDN_URL: Base URL for assets (derives logo/favicon/font/pattern paths) + * - BRAND_FONT_NAME: Font family name + * - BRAND_LOGO_URL: Full logo URL override + * - BRAND_FAVICON_URL: Full favicon URL override + * - BRAND_FONT_REGULAR_URL: Full font regular URL override + * - BRAND_FONT_MEDIUM_URL: Full font medium URL override + * - BRAND_PATTERN_URL: Full pattern image URL override + */ +export function getBrandConfig(env: BrandEnv): BrandConfig { + const cdnUrl = env.BRAND_CDN_URL || DEFAULT_BRAND_CONFIG.cdnHost + const rawAccent = env.BRAND_ACCENT || DEFAULT_BRAND_CONFIG.accentColor + const accentHex = HEX_COLOR_RE.test(rawAccent) ? rawAccent : DEFAULT_BRAND_CONFIG.accentColor + + // Derive accent variations from base accent color + const r = parseInt(accentHex.replace(/^#/, '').substring(0, 2), 16) + const g = parseInt(accentHex.replace(/^#/, '').substring(2, 4), 16) + const b = parseInt(accentHex.replace(/^#/, '').substring(4, 6), 16) + + // Generate hover color (darken by ~10%) + const hoverR = Math.max(0, Math.floor(r * 0.9)) + const hoverG = Math.max(0, Math.floor(g * 0.9)) + const hoverB = Math.max(0, Math.floor(b * 0.9)) + const accentHoverColor = `#${hoverR.toString(16).padStart(2, '0')}${hoverG.toString(16).padStart(2, '0')}${hoverB.toString(16).padStart(2, '0')}` + + // Generate light color (lighten by ~15%) + const lightR = Math.min(255, Math.floor(r + (255 - r) * 0.15)) + const lightG = Math.min(255, Math.floor(g + (255 - g) * 0.15)) + const lightB = Math.min(255, Math.floor(b + (255 - b) * 0.15)) + const accentLightColor = `#${lightR.toString(16).padStart(2, '0')}${lightG.toString(16).padStart(2, '0')}${lightB.toString(16).padStart(2, '0')}` + + return { + name: env.BRAND_NAME || DEFAULT_BRAND_CONFIG.name, + accentColor: accentHex, + accentHoverColor: accentHoverColor, + accentLightColor: accentLightColor, + accentDimColor: hexToRgba(accentHex, 0.12), + accentGlowColor: hexToRgba(accentHex, 0.08), + accentBorderColor: hexToRgba(accentHex, 0.3), + fontName: env.BRAND_FONT_NAME || DEFAULT_BRAND_CONFIG.fontName, + fontRegularUrl: env.BRAND_FONT_REGULAR_URL || `${cdnUrl}/fonts/${env.BRAND_FONT_NAME || 'RocGrotesk'}-Regular.woff2`, + fontMediumUrl: env.BRAND_FONT_MEDIUM_URL || `${cdnUrl}/fonts/${env.BRAND_FONT_NAME || 'RocGrotesk'}-WideMedium.woff2`, + logoUrl: env.BRAND_LOGO_URL || `${cdnUrl}/Primary_Logo/SVG/${env.BRAND_NAME || 'AIBTC'}_PrimaryLogo_KO.svg`, + faviconUrl: env.BRAND_FAVICON_URL || `${cdnUrl}/favicon-32x32.png`, + patternImageUrl: env.BRAND_PATTERN_URL || `${cdnUrl}/Artwork/${env.BRAND_NAME || 'AIBTC'}_Pattern1_optimized.jpg`, + cdnHost: cdnUrl, + } +} diff --git a/src/dashboard/components/layout.ts b/src/dashboard/components/layout.ts index d471eac..ff34009 100644 --- a/src/dashboard/components/layout.ts +++ b/src/dashboard/components/layout.ts @@ -2,20 +2,175 @@ * Shared layout components for the dashboard */ -import { logLevelCss } from '../styles' +import { logLevelCss, escapeHtml } from '../styles' +import { BrandConfig, DEFAULT_BRAND_CONFIG } from '../brand' export interface LayoutOptions { title?: string currentView?: 'overview' | 'app' currentApp?: string apps?: string[] + brand?: BrandConfig } +/** + * Generate brand CSS from BrandConfig + */ +export function buildBrandCss(config: BrandConfig): string { + return ` + @font-face { + font-family: '${config.fontName}'; + src: url('${config.fontRegularUrl}') format('woff2'); + font-weight: 400; + font-display: swap; + } + @font-face { + font-family: '${config.fontName}'; + src: url('${config.fontMediumUrl}') format('woff2'); + font-weight: 500; + font-display: swap; + } + :root { + --accent: ${config.accentColor}; + --accent-hover: ${config.accentHoverColor}; + --accent-dim: ${config.accentDimColor}; + --bg-primary: #000; + --bg-card: #0a0a0a; + --bg-table-header: #111111; + --bg-hover: #18181b; + --bg-input: #1a1a1a; + --bg-input-hover: #222222; + --bg-active-subtle: #252525; + --bg-active: #2a2a2a; + --border: rgba(255,255,255,0.06); + --border-hover: rgba(255,255,255,0.1); + --text-primary: #fafafa; + --text-secondary: #a1a1aa; + --text-muted: #71717a; + } + * { box-sizing: border-box; } + body { + font-family: '${config.fontName}', system-ui, -apple-system, sans-serif; + background: linear-gradient(135deg, #000000, #0a0a0a, #050208); + color: var(--text-primary); + min-height: 100vh; + -webkit-font-smoothing: antialiased; + } + body::before { + content: ''; + position: fixed; + inset: 0; + background: url('${config.patternImageUrl}') center/cover; + opacity: 0.12; + filter: saturate(1.3); + pointer-events: none; + z-index: -1; + } + /* Override Tailwind gray-900/800/700 with brand dark palette */ + .bg-gray-900, .bg-gray-800 { background-color: var(--bg-card) !important; } + .border-gray-700 { border-color: var(--border) !important; } + .bg-gray-750 { background-color: var(--bg-table-header) !important; } + .hover\\:bg-gray-750:hover { background-color: var(--bg-hover) !important; } + .bg-gray-700 { background-color: var(--bg-input) !important; } + .hover\\:bg-gray-700\\/50:hover { background-color: rgba(26,26,26,0.5) !important; } + .hover\\:bg-gray-700:hover { background-color: var(--bg-input-hover) !important; } + .hover\\:bg-gray-600:hover { background-color: var(--bg-active) !important; } + .bg-gray-600 { background-color: var(--bg-active-subtle) !important; } + /* Brand accent overrides: all .text-blue-400 except log-level indicators */ + .text-blue-400:not(.log-level) { color: var(--accent) !important; } + .text-blue-400:not(.log-level):hover, .hover\\:text-blue-300:not(.log-level):hover { color: ${config.accentLightColor} !important; } + .bg-blue-600 { background-color: var(--accent) !important; } + .hover\\:bg-blue-700:hover { background-color: var(--accent-hover) !important; } + .focus\\:border-blue-500:focus { border-color: var(--accent) !important; } + /* Focus ring styling */ + input:focus-visible, select:focus-visible, button:focus-visible:not(.log-level):not(.icon-button) { + outline: 2px solid var(--accent); + outline-offset: 1px; + } + .log-level:focus-visible, .icon-button:focus-visible { + outline: auto; + outline-offset: 0; + } + /* Login button hover */ + .btn-accent { background: var(--accent); color: white; } + .btn-accent:hover { background: var(--accent-hover); } + /* Brand card effects */ + .brand-card { + position: relative; + overflow: hidden; + background: var(--bg-card); + border: 1px solid var(--border); + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; + } + .brand-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 20px ${config.accentGlowColor}; + border-color: ${config.accentBorderColor}; + } + .brand-card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 2px; + background: linear-gradient(90deg, var(--accent), transparent); + opacity: 0; + transition: opacity 0.3s ease; + } + .brand-card:hover::before { opacity: 1; } + /* Card glow effect */ + .card-glow::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient( + circle at var(--mouse-x, 50%) var(--mouse-y, 50%), + var(--accent-dim) 0%, + transparent 60% + ); + opacity: 0; + transition: opacity 0.4s ease; + pointer-events: none; + z-index: 0; + } + .card-glow:hover::after { opacity: 1; } + /* Ensure card content is above glow */ + .brand-card > * { + position: relative; + z-index: 1; + } + /* Header brand styling */ + .brand-header { + background: rgba(10,10,10,0.8); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); + } + .header-logo { height: 28px; width: auto; } +` +} + +/** + * Card glow mouse tracking script + */ +const cardGlowScript = ` + (function() { + document.addEventListener('mousemove', function(e) { + if (!e.target || !e.target.closest) return; + var card = e.target.closest('.card-glow'); + if (!card) return; + var rect = card.getBoundingClientRect(); + card.style.setProperty('--mouse-x', ((e.clientX - rect.left) / rect.width * 100) + '%'); + card.style.setProperty('--mouse-y', ((e.clientY - rect.top) / rect.height * 100) + '%'); + }); + })(); +` + /** * Generate the HTML document wrapper with head and scripts */ export function htmlDocument(content: string, options: LayoutOptions = {}): string { const { title = 'Worker Logs' } = options + const brand = options.brand || DEFAULT_BRAND_CONFIG return ` @@ -23,10 +178,14 @@ export function htmlDocument(content: string, options: LayoutOptions = {}): stri ${title} + + + @@ -38,16 +197,20 @@ ${content} } /** - * Dashboard header with navigation + * Dashboard header with navigation and brand logo */ export function header(options: LayoutOptions = {}): string { const { currentView = 'overview', currentApp, apps = [] } = options + const brand = options.brand || DEFAULT_BRAND_CONFIG return `
-

Worker Logs

+ + + Worker Logs +