From 2bce357a843f3cf5ea66900d06016be0ee725320 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Thu, 12 Mar 2026 16:56:17 -0700 Subject: [PATCH 1/6] feat(dashboard): split single chart into stacked errors/warnings + traffic charts Errors and warnings were invisible when info/debug counts were 10-50x larger because all levels shared one y-axis. This splits the chart into two vertically stacked panels with independent y-axes: - Top panel "Errors & Warnings" (h-40): red + yellow datasets, x-axis labels hidden to avoid duplication - Bottom panel "Traffic Volume" (h-48): blue info + gray debug datasets, with x-axis labels visible Both panels share the same date labels for visual time-correlation. The new splitDailyStatsChartConfigs() helper in charts.ts returns two separate Chart.js config JSON strings; the existing dailyStatsChartConfig() is preserved. Co-Authored-By: Claude --- src/dashboard/components/charts.ts | 133 +++++++++++++++++++++++++++++ src/dashboard/pages/app-detail.ts | 36 +++++--- 2 files changed, 155 insertions(+), 14 deletions(-) diff --git a/src/dashboard/components/charts.ts b/src/dashboard/components/charts.ts index 5accbe8..3818448 100644 --- a/src/dashboard/components/charts.ts +++ b/src/dashboard/components/charts.ts @@ -126,6 +126,139 @@ export function dailyStatsChartConfig( return JSON.stringify(config) } +/** + * Generate two Chart.js configurations for split daily stats display: + * - errorsWarningsConfig: errors + warnings on their own small y-axis (top chart) + * - trafficConfig: info + debug traffic volume (bottom chart) + * Both share the same x-axis labels for visual correlation. + */ +export function splitDailyStatsChartConfigs( + labels: string[], + data: { errors: number[]; warnings: number[]; info: number[]; debug: number[] } +): { errorsWarningsConfig: string; trafficConfig: string } { + const sharedScaleOptions = { + x: { + grid: { color: 'rgba(255,255,255,0.06)' }, + ticks: { color: '#71717a' }, + }, + y: { + beginAtZero: true, + grid: { color: 'rgba(255,255,255,0.06)' }, + ticks: { color: '#71717a' }, + }, + } + + const sharedOptions = { + responsive: true, + maintainAspectRatio: false, + interaction: { + intersect: false, + mode: 'index' as const, + }, + plugins: { + legend: { + position: 'bottom' as const, + labels: { color: '#71717a' }, + }, + }, + } + + const errorsWarningsConfig = { + type: 'line', + data: { + labels, + datasets: [ + { + label: 'Errors', + data: data.errors, + borderColor: styles.logColors.ERROR, + backgroundColor: styles.logColors.ERROR + '20', + tension: 0.3, + fill: true, + pointRadius: 3, + pointHoverRadius: 5, + }, + { + label: 'Warnings', + data: data.warnings, + borderColor: styles.logColors.WARN, + backgroundColor: styles.logColors.WARN + '20', + tension: 0.3, + fill: true, + pointRadius: 3, + pointHoverRadius: 5, + }, + ], + }, + options: { + ...sharedOptions, + plugins: { + ...sharedOptions.plugins, + title: { + display: true, + text: 'Errors & Warnings', + color: '#71717a', + padding: { bottom: 8 }, + }, + }, + scales: { + ...sharedScaleOptions, + x: { + ...sharedScaleOptions.x, + ticks: { ...sharedScaleOptions.x.ticks, display: false }, + }, + }, + }, + } + + const trafficConfig = { + type: 'line', + data: { + labels, + datasets: [ + { + label: 'Info', + data: data.info, + borderColor: styles.logColors.INFO, + backgroundColor: styles.logColors.INFO + '20', + tension: 0.3, + fill: true, + pointRadius: 3, + pointHoverRadius: 5, + }, + { + label: 'Debug', + data: data.debug, + borderColor: styles.logColors.DEBUG, + backgroundColor: styles.logColors.DEBUG + '20', + tension: 0.3, + fill: true, + pointRadius: 3, + pointHoverRadius: 5, + }, + ], + }, + options: { + ...sharedOptions, + plugins: { + ...sharedOptions.plugins, + title: { + display: true, + text: 'Traffic Volume', + color: '#71717a', + padding: { bottom: 8 }, + }, + }, + scales: sharedScaleOptions, + }, + } + + return { + errorsWarningsConfig: JSON.stringify(errorsWarningsConfig), + trafficConfig: JSON.stringify(trafficConfig), + } +} + /** * Determine health status from recent checks */ diff --git a/src/dashboard/pages/app-detail.ts b/src/dashboard/pages/app-detail.ts index aac08f4..0574ffd 100644 --- a/src/dashboard/pages/app-detail.ts +++ b/src/dashboard/pages/app-detail.ts @@ -3,7 +3,7 @@ */ import { htmlDocument, header, statsCard } from '../components/layout' -import { dailyStatsChartConfig, formatHealthStatus, determineHealthStatus } from '../components/charts' +import { splitDailyStatsChartConfigs, formatHealthStatus, determineHealthStatus } from '../components/charts' import { escapeHtml, styles } from '../styles' import type { DailyStats, LogEntry, HealthCheck } from '../../types' import type { BrandConfig } from '../brand' @@ -30,11 +30,12 @@ export function appDetailPage(data: AppDetailData, apps: string[], brand: BrandC // 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 }, - ]) + const { errorsWarningsConfig, trafficConfig } = splitDailyStatsChartConfigs(chartLabels, { + errors: stats.map(s => s.error).reverse(), + warnings: stats.map(s => s.warn).reverse(), + info: stats.map(s => s.info).reverse(), + debug: stats.map(s => s.debug).reverse(), + }) // Group health checks by URL const healthByUrl = new Map() @@ -62,11 +63,14 @@ export function appDetailPage(data: AppDetailData, apps: string[], brand: BrandC ${statsCard('Error (7d)', totals.error, 'text-red-400')} - +
-

Log Activity (7 days)

-
- +

Log Activity (7 days)

+
+ +
+
+
@@ -415,11 +419,15 @@ export function appDetailPage(data: AppDetailData, apps: string[], brand: BrandC } } - // Initialize chart + // Initialize charts document.addEventListener('DOMContentLoaded', () => { - const ctx = document.getElementById('statsChart'); - if (ctx) { - new Chart(ctx, ${chartConfig}); + const ewCtx = document.getElementById('errorsWarningsChart'); + if (ewCtx) { + new Chart(ewCtx, ${errorsWarningsConfig}); + } + const trafficCtx = document.getElementById('trafficChart'); + if (trafficCtx) { + new Chart(trafficCtx, ${trafficConfig}); } }); ` From 0daa0c6d37415331522f4ba8bcbe8f27a2565d68 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Thu, 12 Mar 2026 16:59:30 -0700 Subject: [PATCH 2/6] feat(dashboard): add 7d/14d/30d time-range toggle to app detail charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three toggle buttons above the split Chart.js charts that let users switch the stats window without a page reload. Clicking a button calls loadStats() which fetches /dashboard/api/stats/:app_id?days=N and updates both chart instances in-place (data.labels + data.datasets + chart.update()). Stats cards and chart section title now reflect the active range dynamically via Alpine.js x-text bindings. statsTotals state recalculates from the API response on each range change so the four summary cards stay in sync. Module-level ewChart/trafficChart vars are assigned in DOMContentLoaded and closed over by the Alpine loadStats() method so no DOM query is needed on each fetch. No backend changes required — the DO already supports ?days=N. Co-Authored-By: Claude --- src/dashboard/pages/app-detail.ts | 118 ++++++++++++++++++++++++++---- 1 file changed, 103 insertions(+), 15 deletions(-) diff --git a/src/dashboard/pages/app-detail.ts b/src/dashboard/pages/app-detail.ts index 0574ffd..cae9058 100644 --- a/src/dashboard/pages/app-detail.ts +++ b/src/dashboard/pages/app-detail.ts @@ -2,7 +2,7 @@ * App detail page - single app view with advanced filtering */ -import { htmlDocument, header, statsCard } from '../components/layout' +import { htmlDocument, header } from '../components/layout' import { splitDailyStatsChartConfigs, formatHealthStatus, determineHealthStatus } from '../components/charts' import { escapeHtml, styles } from '../styles' import type { DailyStats, LogEntry, HealthCheck } from '../../types' @@ -20,7 +20,7 @@ export interface AppDetailData { export function appDetailPage(data: AppDetailData, apps: string[], brand: BrandConfig = DEFAULT_BRAND_CONFIG): string { const { appId, appName, stats, healthChecks, healthUrls } = data - // Calculate totals for the period + // Calculate totals for the initial period const totals = stats.reduce((acc, day) => ({ debug: acc.debug + day.debug, info: acc.info + day.info, @@ -55,22 +55,55 @@ export function appDetailPage(data: AppDetailData, apps: string[], brand: BrandC ${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')} +
+
Debug (7d)
+
${totals.debug}
+
+
+
Info (7d)
+
${totals.info}
+
+
+
Warn (7d)
+
${totals.warn}
+
+
+
Error (7d)
+
${totals.error}
+
-

Log Activity (7 days)

-
- +
+

Log Activity (7d)

+
+ + + +
-
- +
+ + + + +
+
+
+ +
+
+ +
@@ -275,6 +308,10 @@ export function appDetailPage(data: AppDetailData, apps: string[], brand: BrandC ` From ed833d47a541a65265e142bf6711783aafc78a7a Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Thu, 12 Mar 2026 17:04:17 -0700 Subject: [PATCH 3/6] feat(dashboard): add grouped bar chart to overview page with time-range toggle Extends the overview page with a grouped bar chart showing per-app errors and warnings over the selected time range (7d/14d/30d). The chart updates in-place when the range changes, reusing the same Alpine.js + Chart.js in-place update pattern from the app-detail page. - Add overviewBarChartConfig() to charts.ts for grouped bar chart config - Add AppChartData/OverviewChartResponse types to dashboard/types.ts - Add getOverviewChart() to api/overview.ts for multi-day per-day stats - Add /dashboard/api/overview/chart?days=N endpoint to index.ts - Update overview page with chart section, range buttons, and chart init Co-Authored-By: Claude --- src/dashboard/api/overview.ts | 63 +++++++++++++++++- src/dashboard/components/charts.ts | 69 ++++++++++++++++++++ src/dashboard/index.ts | 13 +++- src/dashboard/pages/overview.ts | 100 ++++++++++++++++++++++++++--- src/dashboard/types.ts | 18 ++++++ 5 files changed, 253 insertions(+), 10 deletions(-) diff --git a/src/dashboard/api/overview.ts b/src/dashboard/api/overview.ts index cd189c3..f047b65 100644 --- a/src/dashboard/api/overview.ts +++ b/src/dashboard/api/overview.ts @@ -4,7 +4,7 @@ import type { Context } from 'hono' import type { Env, DailyStats, LogEntry } from '../../types' -import type { OverviewResponse, AppSummary } from '../types' +import type { OverviewResponse, AppSummary, OverviewChartResponse, AppChartData } from '../types' import { calculateTrend, determineHealthStatus } from '../components/charts' import { getAppList, getAppName } from '../helpers' @@ -87,6 +87,67 @@ export async function getOverview(c: Context<{ Bindings: Env }>): Promise, + days: number +): Promise { + const apps = await getAppList(c) + + if (apps.length === 0) { + return { dates: [], apps: [] } + } + + // Fetch stats for all apps in parallel + const appStatsPromises = apps.map(async (appId) => { + try { + const name = await getAppName(c, appId) + 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}`)) + const data = await res.json() as { ok: boolean; data: DailyStats[] } + return { appId, name, stats: data.ok ? (data.data || []) : [] } + } catch { + return { appId, name: appId, stats: [] as DailyStats[] } + } + }) + + const appStats = await Promise.all(appStatsPromises) + + // Build a unified sorted date list across all apps + const dateSet = new Set() + for (const { stats } of appStats) { + for (const s of stats) { + dateSet.add(s.date) + } + } + const dates = Array.from(dateSet).sort() + + // Build per-app aligned arrays + const chartApps: AppChartData[] = appStats.map(({ appId, name, stats }) => { + const byDate = new Map() + for (const s of stats) { + byDate.set(s.date, s) + } + return { + id: appId, + name, + errors: dates.map(d => byDate.get(d)?.error ?? 0), + warnings: dates.map(d => byDate.get(d)?.warn ?? 0), + } + }) + + // Only include apps that have at least one error or warning to keep the chart readable + const activeApps = chartApps.filter(a => a.errors.some(v => v > 0) || a.warnings.some(v => v > 0)) + + return { + dates, + apps: activeApps.length > 0 ? activeApps : chartApps, + } +} + /** * Get aggregated data for a single app */ diff --git a/src/dashboard/components/charts.ts b/src/dashboard/components/charts.ts index 3818448..9af8da6 100644 --- a/src/dashboard/components/charts.ts +++ b/src/dashboard/components/charts.ts @@ -259,6 +259,75 @@ export function splitDailyStatsChartConfigs( } } +/** + * Generate Chart.js configuration for the overview grouped bar chart. + * Shows errors and warnings per app over time (one pair of bars per app per day). + */ +export function overviewBarChartConfig( + labels: string[], + apps: Array<{ id: string; name: string; errors: number[]; warnings: number[] }> +): string { + const datasets: object[] = [] + + for (const app of apps) { + datasets.push({ + label: `${app.name} Errors`, + data: app.errors, + backgroundColor: styles.logColors.ERROR + '99', + borderColor: styles.logColors.ERROR, + borderWidth: 1, + borderRadius: 2, + barThickness: 'flex' as const, + }) + datasets.push({ + label: `${app.name} Warns`, + data: app.warnings, + backgroundColor: styles.logColors.WARN + '99', + borderColor: styles.logColors.WARN, + borderWidth: 1, + borderRadius: 2, + barThickness: 'flex' as const, + }) + } + + const config = { + type: 'bar', + data: { + labels, + datasets, + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + intersect: false, + mode: 'index' as const, + }, + plugins: { + legend: { + position: 'bottom' as const, + labels: { color: '#71717a' }, + }, + }, + scales: { + x: { + stacked: false, + grid: { color: 'rgba(255,255,255,0.06)' }, + ticks: { color: '#71717a' }, + }, + y: { + stacked: false, + beginAtZero: true, + grid: { color: 'rgba(255,255,255,0.06)' }, + ticks: { color: '#71717a' }, + }, + }, + }, + } + + return JSON.stringify(config) +} + /** * Determine health status from recent checks */ diff --git a/src/dashboard/index.ts b/src/dashboard/index.ts index ca03d83..fac3301 100644 --- a/src/dashboard/index.ts +++ b/src/dashboard/index.ts @@ -13,7 +13,7 @@ import { import { loginPage } from './pages/login' import { overviewPage } from './pages/overview' import { appDetailPage, type AppDetailData } from './pages/app-detail' -import { getOverview } from './api/overview' +import { getOverview, getOverviewChart } from './api/overview' import { getAppList, getAppName, getHealthUrls } from './helpers' import { getBrandConfig, type BrandConfig } from './brand' @@ -105,6 +105,17 @@ dashboard.get('/api/overview', async (c) => { return c.json({ ok: true, data }) }) +// API: Get overview chart data (per-app daily errors/warnings) +dashboard.get('/api/overview/chart', async (c) => { + if (!await isAuthenticated(c as any)) { + return c.json({ ok: false, error: 'Unauthorized' }, 401) + } + + const days = Math.min(Math.max(parseInt(c.req.query('days') || '7', 10), 1), 90) + const data = await getOverviewChart(c as any, days) + return c.json({ ok: true, data }) +}) + // API: List apps dashboard.get('/api/apps', async (c) => { if (!await isAuthenticated(c as any)) { diff --git a/src/dashboard/pages/overview.ts b/src/dashboard/pages/overview.ts index d0508dc..3c94fdd 100644 --- a/src/dashboard/pages/overview.ts +++ b/src/dashboard/pages/overview.ts @@ -3,8 +3,8 @@ */ import { htmlDocument, header, statsCard } from '../components/layout' -import { sparkline, formatTrend, formatHealthStatus, dailyStatsChartConfig } from '../components/charts' -import { escapeHtml, styles } from '../styles' +import { formatTrend, formatHealthStatus, overviewBarChartConfig } from '../components/charts' +import { escapeHtml } from '../styles' import type { OverviewResponse } from '../types' import type { BrandConfig } from '../brand' import { DEFAULT_BRAND_CONFIG } from '../brand' @@ -16,11 +16,8 @@ export function overviewPage(data: OverviewResponse, apps: string[], brand: Bran 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, - ] + // Initial empty chart config — populated via loadChartData() on DOMContentLoaded + const initialChartConfig = overviewBarChartConfig([], []) const content = ` ${header({ currentView: 'overview', apps, brand })} @@ -34,6 +31,33 @@ export function overviewPage(data: OverviewResponse, apps: string[], brand: Bran ${statsCard('Total Info', totals.today.info, 'text-blue-400')}
+ +
+
+

Errors & Warnings by App (7d)

+
+ + + +
+
+
+ + + + +
+
+ +
+
+
@@ -160,24 +184,84 @@ export function overviewPage(data: OverviewResponse, apps: string[], brand: Bran ` return htmlDocument(content, { title: 'Worker Logs - Overview', brand }) diff --git a/src/dashboard/types.ts b/src/dashboard/types.ts index 9d0c44b..278d997 100644 --- a/src/dashboard/types.ts +++ b/src/dashboard/types.ts @@ -20,6 +20,24 @@ export interface AppSummary { } } +/** + * Per-app chart data for the overview bar chart + */ +export interface AppChartData { + id: string + name: string + errors: number[] + warnings: number[] +} + +/** + * Response for the overview chart endpoint + */ +export interface OverviewChartResponse { + dates: string[] + apps: AppChartData[] +} + /** * Overview response for the dashboard */ From 677a403254e9dba0b8e9b13cdb5151ce6beefe91 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Thu, 12 Mar 2026 17:07:24 -0700 Subject: [PATCH 4/6] feat(dashboard): wire sparklines and error rate into stats cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add sparkline trend visualizations to all stats cards on both overview and app-detail pages, pulling from existing sparkline() utility that was previously unused. Error cards now also display an error rate percentage ("X% of all logs") to give context alongside raw counts. - Extend statsCard() to accept optional sparklineHtml and subtext params - Overview: 2-point sparklines (yesterday→today) for errors/warnings/info - App-detail: full multi-day sparklines from stats[] history per level - App-detail stats cards now use card-glow class for consistency - Error rate shown on error card when >0% Co-Authored-By: Claude --- src/dashboard/components/layout.ts | 12 ++++++++++- src/dashboard/pages/app-detail.ts | 34 ++++++++++++++++++++++-------- src/dashboard/pages/overview.ts | 27 ++++++++++++++++++++---- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/dashboard/components/layout.ts b/src/dashboard/components/layout.ts index 47cb760..3967919 100644 --- a/src/dashboard/components/layout.ts +++ b/src/dashboard/components/layout.ts @@ -245,12 +245,22 @@ export function header(options: LayoutOptions = {}): string { /** * Stats card component with brand hover effect + * @param sparklineHtml - optional SVG sparkline HTML to show below the value + * @param subtext - optional small muted text below the value (e.g. "12% of all logs") */ -export function statsCard(label: string, value: number | string, colorClass: string = 'text-gray-100'): string { +export function statsCard( + label: string, + value: number | string, + colorClass: string = 'text-gray-100', + sparklineHtml?: string, + subtext?: string +): string { return `
${label}
${value}
+ ${subtext ? `
${subtext}
` : ''} + ${sparklineHtml ? `
${sparklineHtml}
` : ''}
` } diff --git a/src/dashboard/pages/app-detail.ts b/src/dashboard/pages/app-detail.ts index cae9058..44e7ce3 100644 --- a/src/dashboard/pages/app-detail.ts +++ b/src/dashboard/pages/app-detail.ts @@ -3,7 +3,7 @@ */ import { htmlDocument, header } from '../components/layout' -import { splitDailyStatsChartConfigs, formatHealthStatus, determineHealthStatus } from '../components/charts' +import { splitDailyStatsChartConfigs, formatHealthStatus, determineHealthStatus, sparkline } from '../components/charts' import { escapeHtml, styles } from '../styles' import type { DailyStats, LogEntry, HealthCheck } from '../../types' import type { BrandConfig } from '../brand' @@ -28,6 +28,17 @@ export function appDetailPage(data: AppDetailData, apps: string[], brand: BrandC error: acc.error + day.error, }), { debug: 0, info: 0, warn: 0, error: 0 }) + // Compute error rate for the initial period + const totalLogs = totals.debug + totals.info + totals.warn + totals.error + const errorRate = totalLogs > 0 ? Math.round((totals.error / totalLogs) * 100) : 0 + + // Build sparkline data arrays (oldest-first for left-to-right trend) + const statsOldestFirst = stats.slice().reverse() + const debugSparkline = sparkline(statsOldestFirst.map(s => s.debug), { color: '#9CA3AF', showArea: true, width: 100, height: 20 }) + const infoSparkline = sparkline(statsOldestFirst.map(s => s.info), { color: '#60A5FA', showArea: true, width: 100, height: 20 }) + const warnSparkline = sparkline(statsOldestFirst.map(s => s.warn), { color: '#FBBF24', showArea: true, width: 100, height: 20 }) + const errorSparkline = sparkline(statsOldestFirst.map(s => s.error), { color: '#F87171', showArea: true, width: 100, height: 20 }) + // Prepare chart data (reverse to show oldest first) const chartLabels = stats.map(s => s.date).reverse() const { errorsWarningsConfig, trafficConfig } = splitDailyStatsChartConfigs(chartLabels, { @@ -57,21 +68,26 @@ export function appDetailPage(data: AppDetailData, apps: string[], brand: BrandC
-
-
Debug (7d)
+
+
Debug (7d)
${totals.debug}
+
${debugSparkline}
-
-
Info (7d)
+
+
Info (7d)
${totals.info}
+
${infoSparkline}
-
-
Warn (7d)
+
+
Warn (7d)
${totals.warn}
+
${warnSparkline}
-
-
Error (7d)
+
+
Error (7d)
${totals.error}
+ ${errorRate > 0 ? `
${errorRate}% of all logs
` : ''} +
${errorSparkline}
diff --git a/src/dashboard/pages/overview.ts b/src/dashboard/pages/overview.ts index 3c94fdd..7ff1644 100644 --- a/src/dashboard/pages/overview.ts +++ b/src/dashboard/pages/overview.ts @@ -3,7 +3,7 @@ */ import { htmlDocument, header, statsCard } from '../components/layout' -import { formatTrend, formatHealthStatus, overviewBarChartConfig } from '../components/charts' +import { formatTrend, formatHealthStatus, overviewBarChartConfig, sparkline } from '../components/charts' import { escapeHtml } from '../styles' import type { OverviewResponse } from '../types' import type { BrandConfig } from '../brand' @@ -16,6 +16,25 @@ export function overviewPage(data: OverviewResponse, apps: string[], brand: Bran const appsWithErrors = appSummaries.filter(a => a.today_stats.error > 0).length const totalApps = appSummaries.length + // Compute total log volume for error rate calculation + const todayTotal = totals.today.debug + totals.today.info + totals.today.warn + totals.today.error + const errorRate = todayTotal > 0 ? Math.round((totals.today.error / todayTotal) * 100) : 0 + const warnRate = todayTotal > 0 ? Math.round((totals.today.warn / todayTotal) * 100) : 0 + + // 2-point sparklines: [yesterday, today] + const errorSparkline = sparkline( + [totals.yesterday.error, totals.today.error], + { color: '#F87171', showArea: true, width: 100, height: 20 } + ) + const warnSparkline = sparkline( + [totals.yesterday.warn, totals.today.warn], + { color: '#FBBF24', showArea: true, width: 100, height: 20 } + ) + const infoSparkline = sparkline( + [totals.yesterday.info, totals.today.info], + { color: '#60A5FA', showArea: true, width: 100, height: 20 } + ) + // Initial empty chart config — populated via loadChartData() on DOMContentLoaded const initialChartConfig = overviewBarChartConfig([], []) @@ -25,10 +44,10 @@ export function overviewPage(data: OverviewResponse, apps: string[], brand: Bran
- ${statsCard('Total Errors (24h)', totalErrors, 'text-red-400')} + ${statsCard('Total Errors (24h)', totalErrors, 'text-red-400', errorSparkline, errorRate > 0 ? `${errorRate}% of all logs` : undefined)} ${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')} + ${statsCard('Total Warnings', totals.today.warn, 'text-yellow-400', warnSparkline, warnRate > 0 ? `${warnRate}% of all logs` : undefined)} + ${statsCard('Total Info', totals.today.info, 'text-blue-400', infoSparkline)}
From 073331bc71bf52add599e298a7f12b48bd51db40 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Thu, 12 Mar 2026 17:09:25 -0700 Subject: [PATCH 5/6] feat(dashboard): add footer, improve empty states, and table hover transitions Final visual polish sweep to make the dashboard presentation-ready: - Add branded footer() to layout.ts, rendered in every page via htmlDocument() showing brand name, service name, and "Powered by Cloudflare Workers & Durable Objects" - Improve emptyState() helper to accept an optional hint text below the message - Overview: use emptyState() with SVG icons for "no apps" and "no recent errors" states - App-detail: upgrade "no logs found" state with clipboard icon, message, and filter hint - Add log-row CSS class with 0.1s ease background transition for smooth table hovers - Apply log-row class to all data rows in overview tables - Tune app-detail stats cards: use var(--text-muted) for labels and add card-glow class Co-Authored-By: Claude --- src/dashboard/components/layout.ts | 23 ++++++++++++++++++++--- src/dashboard/pages/app-detail.ts | 8 +++++++- src/dashboard/pages/overview.ts | 26 +++++++++++++------------- src/dashboard/styles.ts | 1 + 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/dashboard/components/layout.ts b/src/dashboard/components/layout.ts index 3967919..88849dc 100644 --- a/src/dashboard/components/layout.ts +++ b/src/dashboard/components/layout.ts @@ -192,6 +192,7 @@ export function htmlDocument(content: string, options: LayoutOptions = {}): stri ${content} +${footer(brand)} ` @@ -267,10 +268,26 @@ export function statsCard( /** * Empty state component */ -export function emptyState(icon: string, message: string): string { +export function emptyState(icon: string, message: string, hint?: string): string { return `
- ${icon} -

${message}

+
${icon}
+

${message}

+ ${hint ? `

${hint}

` : ''}
` } + +/** + * Branded footer component + */ +export function footer(brand: BrandConfig = DEFAULT_BRAND_CONFIG): string { + return ` +
+
+ ${escapeHtml(brand.name)} + ${escapeHtml(brand.name)} · Worker Logs + · + Powered by Cloudflare Workers & Durable Objects +
+
` +} diff --git a/src/dashboard/pages/app-detail.ts b/src/dashboard/pages/app-detail.ts index 44e7ce3..6254b28 100644 --- a/src/dashboard/pages/app-detail.ts +++ b/src/dashboard/pages/app-detail.ts @@ -273,7 +273,13 @@ export function appDetailPage(data: AppDetailData, apps: string[], brand: BrandC