Skip to content

Commit b27b3a7

Browse files
whoabuddyclaude
andauthored
feat(dashboard): polish charts, add time ranges, and visual refinements (#17)
* 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 <noreply@anthropic.com> * feat(dashboard): add 7d/14d/30d time-range toggle to app detail charts 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * feat(dashboard): wire sparklines and error rate into stats cards 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * refactor(dashboard): simplify chart configs, reduce duplication Extract shared chart helpers (lineDataset, barDataset, CHART_SCALES, CHART_BASE_OPTIONS), consolidate repeated sparkline options, remove dead code in overview refresh, and add addStats helper. Net -91 lines. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 78863d4 commit b27b3a7

8 files changed

Lines changed: 548 additions & 98 deletions

File tree

src/dashboard/api/overview.ts

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,18 @@
44

55
import type { Context } from 'hono'
66
import type { Env, DailyStats, LogEntry } from '../../types'
7-
import type { OverviewResponse, AppSummary } from '../types'
7+
import type { OverviewResponse, AppSummary, OverviewChartResponse, AppChartData } from '../types'
88
import { calculateTrend, determineHealthStatus } from '../components/charts'
99
import { getAppList, getAppName } from '../helpers'
1010

11+
/** Add stats counts to an accumulator in-place */
12+
function addStats(acc: { debug: number; info: number; warn: number; error: number }, s: DailyStats): void {
13+
acc.debug += s.debug
14+
acc.info += s.info
15+
acc.warn += s.warn
16+
acc.error += s.error
17+
}
18+
1119
/**
1220
* Get overview data for all apps
1321
*/
@@ -42,14 +50,8 @@ export async function getOverview(c: Context<{ Bindings: Env }>): Promise<Overvi
4250
if (!data) continue
4351

4452
// Aggregate totals
45-
totals.today.debug += data.today_stats.debug
46-
totals.today.info += data.today_stats.info
47-
totals.today.warn += data.today_stats.warn
48-
totals.today.error += data.today_stats.error
49-
totals.yesterday.debug += data.yesterday_stats.debug
50-
totals.yesterday.info += data.yesterday_stats.info
51-
totals.yesterday.warn += data.yesterday_stats.warn
52-
totals.yesterday.error += data.yesterday_stats.error
53+
addStats(totals.today, data.today_stats)
54+
addStats(totals.yesterday, data.yesterday_stats)
5355

5456
// Calculate error trend
5557
const errorTrend = calculateTrend(data.today_stats.error, data.yesterday_stats.error)
@@ -87,6 +89,67 @@ export async function getOverview(c: Context<{ Bindings: Env }>): Promise<Overvi
8789
}
8890
}
8991

92+
/**
93+
* Get per-app daily stats for the overview bar chart
94+
*/
95+
export async function getOverviewChart(
96+
c: Context<{ Bindings: Env }>,
97+
days: number
98+
): Promise<OverviewChartResponse> {
99+
const apps = await getAppList(c)
100+
101+
if (apps.length === 0) {
102+
return { dates: [], apps: [] }
103+
}
104+
105+
// Fetch stats for all apps in parallel
106+
const appStatsPromises = apps.map(async (appId) => {
107+
try {
108+
const name = await getAppName(c, appId)
109+
const id = c.env.APP_LOGS_DO.idFromName(appId)
110+
const stub = c.env.APP_LOGS_DO.get(id)
111+
const res = await stub.fetch(new Request(`http://do/stats?days=${days}`))
112+
const data = await res.json() as { ok: boolean; data: DailyStats[] }
113+
return { appId, name, stats: data.ok ? (data.data || []) : [] }
114+
} catch {
115+
return { appId, name: appId, stats: [] as DailyStats[] }
116+
}
117+
})
118+
119+
const appStats = await Promise.all(appStatsPromises)
120+
121+
// Build a unified sorted date list across all apps
122+
const dateSet = new Set<string>()
123+
for (const { stats } of appStats) {
124+
for (const s of stats) {
125+
dateSet.add(s.date)
126+
}
127+
}
128+
const dates = Array.from(dateSet).sort()
129+
130+
// Build per-app aligned arrays
131+
const chartApps: AppChartData[] = appStats.map(({ appId, name, stats }) => {
132+
const byDate = new Map<string, DailyStats>()
133+
for (const s of stats) {
134+
byDate.set(s.date, s)
135+
}
136+
return {
137+
id: appId,
138+
name,
139+
errors: dates.map(d => byDate.get(d)?.error ?? 0),
140+
warnings: dates.map(d => byDate.get(d)?.warn ?? 0),
141+
}
142+
})
143+
144+
// Only include apps that have at least one error or warning to keep the chart readable
145+
const activeApps = chartApps.filter(a => a.errors.some(v => v > 0) || a.warnings.some(v => v > 0))
146+
147+
return {
148+
dates,
149+
apps: activeApps.length > 0 ? activeApps : chartApps,
150+
}
151+
}
152+
90153
/**
91154
* Get aggregated data for a single app
92155
*/

src/dashboard/components/charts.ts

Lines changed: 142 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -82,48 +82,158 @@ export function dailyStatsChartConfig(
8282
labels: string[],
8383
datasets: { label: string; data: number[]; color: string }[]
8484
): string {
85-
const config = {
85+
return JSON.stringify({
8686
type: 'line',
8787
data: {
8888
labels,
89-
datasets: datasets.map(ds => ({
90-
label: ds.label,
91-
data: ds.data,
92-
borderColor: ds.color,
93-
backgroundColor: ds.color + '20',
94-
tension: 0.3,
95-
fill: true,
96-
pointRadius: 3,
97-
pointHoverRadius: 5,
98-
})),
89+
datasets: datasets.map(ds => lineDataset(ds.label, ds.data, ds.color)),
9990
},
10091
options: {
101-
responsive: true,
102-
maintainAspectRatio: false,
92+
...CHART_BASE_OPTIONS,
93+
scales: CHART_SCALES,
94+
},
95+
})
96+
}
97+
98+
/**
99+
* Build a single line-chart dataset entry
100+
*/
101+
function lineDataset(label: string, data: number[], color: string) {
102+
return {
103+
label,
104+
data,
105+
borderColor: color,
106+
backgroundColor: color + '20',
107+
tension: 0.3,
108+
fill: true,
109+
pointRadius: 3,
110+
pointHoverRadius: 5,
111+
}
112+
}
113+
114+
/** Shared scale options used across chart configurations */
115+
const CHART_SCALES = {
116+
x: {
117+
grid: { color: 'rgba(255,255,255,0.06)' },
118+
ticks: { color: '#71717a' },
119+
},
120+
y: {
121+
beginAtZero: true,
122+
grid: { color: 'rgba(255,255,255,0.06)' },
123+
ticks: { color: '#71717a' },
124+
},
125+
}
126+
127+
/** Shared base options used across chart configurations */
128+
const CHART_BASE_OPTIONS = {
129+
responsive: true,
130+
maintainAspectRatio: false,
131+
interaction: {
132+
intersect: false,
133+
mode: 'index' as const,
134+
},
135+
plugins: {
136+
legend: {
137+
position: 'bottom' as const,
138+
labels: { color: '#71717a' },
139+
},
140+
},
141+
}
142+
143+
/**
144+
* Build a titled line chart config (serialized to JSON)
145+
*/
146+
function titledLineChart(
147+
labels: string[],
148+
datasets: ReturnType<typeof lineDataset>[],
149+
title: string,
150+
scaleOverrides?: Record<string, unknown>
151+
): string {
152+
return JSON.stringify({
153+
type: 'line',
154+
data: { labels, datasets },
155+
options: {
156+
...CHART_BASE_OPTIONS,
103157
plugins: {
104-
legend: {
105-
position: 'bottom',
106-
labels: { color: '#71717a' },
107-
},
158+
...CHART_BASE_OPTIONS.plugins,
159+
title: { display: true, text: title, color: '#71717a', padding: { bottom: 8 } },
108160
},
161+
scales: { ...CHART_SCALES, ...scaleOverrides },
162+
},
163+
})
164+
}
165+
166+
/**
167+
* Generate two Chart.js configurations for split daily stats display:
168+
* - errorsWarningsConfig: errors + warnings on their own small y-axis (top chart)
169+
* - trafficConfig: info + debug traffic volume (bottom chart)
170+
* Both share the same x-axis labels for visual correlation.
171+
*/
172+
export function splitDailyStatsChartConfigs(
173+
labels: string[],
174+
data: { errors: number[]; warnings: number[]; info: number[]; debug: number[] }
175+
): { errorsWarningsConfig: string; trafficConfig: string } {
176+
const errorsWarningsConfig = titledLineChart(
177+
labels,
178+
[
179+
lineDataset('Errors', data.errors, styles.logColors.ERROR),
180+
lineDataset('Warnings', data.warnings, styles.logColors.WARN),
181+
],
182+
'Errors & Warnings',
183+
{ x: { ...CHART_SCALES.x, ticks: { ...CHART_SCALES.x.ticks, display: false } } }
184+
)
185+
186+
const trafficConfig = titledLineChart(
187+
labels,
188+
[
189+
lineDataset('Info', data.info, styles.logColors.INFO),
190+
lineDataset('Debug', data.debug, styles.logColors.DEBUG),
191+
],
192+
'Traffic Volume'
193+
)
194+
195+
return { errorsWarningsConfig, trafficConfig }
196+
}
197+
198+
/**
199+
* Build a single bar-chart dataset entry
200+
*/
201+
function barDataset(label: string, data: number[], color: string) {
202+
return {
203+
label,
204+
data,
205+
backgroundColor: color + '99',
206+
borderColor: color,
207+
borderWidth: 1,
208+
borderRadius: 2,
209+
barThickness: 'flex' as const,
210+
}
211+
}
212+
213+
/**
214+
* Generate Chart.js configuration for the overview grouped bar chart.
215+
* Shows errors and warnings per app over time (one pair of bars per app per day).
216+
*/
217+
export function overviewBarChartConfig(
218+
labels: string[],
219+
apps: Array<{ id: string; name: string; errors: number[]; warnings: number[] }>
220+
): string {
221+
const datasets = apps.flatMap(app => [
222+
barDataset(`${app.name} Errors`, app.errors, styles.logColors.ERROR),
223+
barDataset(`${app.name} Warns`, app.warnings, styles.logColors.WARN),
224+
])
225+
226+
return JSON.stringify({
227+
type: 'bar',
228+
data: { labels, datasets },
229+
options: {
230+
...CHART_BASE_OPTIONS,
109231
scales: {
110-
x: {
111-
grid: { color: 'rgba(255,255,255,0.06)' },
112-
ticks: { color: '#71717a' },
113-
},
114-
y: {
115-
beginAtZero: true,
116-
grid: { color: 'rgba(255,255,255,0.06)' },
117-
ticks: { color: '#71717a' },
118-
},
119-
},
120-
interaction: {
121-
intersect: false,
122-
mode: 'index',
232+
x: { ...CHART_SCALES.x, stacked: false },
233+
y: { ...CHART_SCALES.y, stacked: false },
123234
},
124235
},
125-
}
126-
return JSON.stringify(config)
236+
})
127237
}
128238

129239
/**

src/dashboard/components/layout.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ export function htmlDocument(content: string, options: LayoutOptions = {}): stri
192192
</head>
193193
<body class="min-h-screen">
194194
${content}
195+
${footer(brand)}
195196
<script>${cardGlowScript}</script>
196197
</body>
197198
</html>`
@@ -245,22 +246,48 @@ export function header(options: LayoutOptions = {}): string {
245246

246247
/**
247248
* Stats card component with brand hover effect
249+
* @param sparklineHtml - optional SVG sparkline HTML to show below the value
250+
* @param subtext - optional small muted text below the value (e.g. "12% of all logs")
248251
*/
249-
export function statsCard(label: string, value: number | string, colorClass: string = 'text-gray-100'): string {
252+
export function statsCard(
253+
label: string,
254+
value: number | string,
255+
colorClass: string = 'text-gray-100',
256+
sparklineHtml?: string,
257+
subtext?: string
258+
): string {
250259
return `
251260
<div class="brand-card card-glow rounded-lg p-4">
252261
<div class="text-sm mb-1" style="color: var(--text-muted);">${label}</div>
253262
<div class="text-2xl font-bold ${colorClass}">${value}</div>
263+
${subtext ? `<div class="text-xs mt-1" style="color: var(--text-muted);">${subtext}</div>` : ''}
264+
${sparklineHtml ? `<div class="mt-2 h-5 w-full opacity-70">${sparklineHtml}</div>` : ''}
254265
</div>`
255266
}
256267

257268
/**
258269
* Empty state component
259270
*/
260-
export function emptyState(icon: string, message: string): string {
271+
export function emptyState(icon: string, message: string, hint?: string): string {
261272
return `
262273
<div class="text-center py-12" style="color: var(--text-muted);">
263-
${icon}
264-
<p>${message}</p>
274+
<div class="flex justify-center mb-3">${icon}</div>
275+
<p class="font-medium">${message}</p>
276+
${hint ? `<p class="text-xs mt-1 opacity-70">${hint}</p>` : ''}
265277
</div>`
266278
}
279+
280+
/**
281+
* Branded footer component
282+
*/
283+
export function footer(brand: BrandConfig = DEFAULT_BRAND_CONFIG): string {
284+
return `
285+
<footer class="py-6 text-center text-xs" style="color: var(--text-muted); border-top: 1px solid var(--border);">
286+
<div class="max-w-7xl mx-auto px-6 flex items-center justify-center gap-3">
287+
<img src="${escapeHtml(brand.faviconUrl)}" alt="${escapeHtml(brand.name)}" style="height: 14px; width: auto; opacity: 0.5;">
288+
<span>${escapeHtml(brand.name)} · Worker Logs</span>
289+
<span style="opacity: 0.4;">·</span>
290+
<span>Powered by Cloudflare Workers &amp; Durable Objects</span>
291+
</div>
292+
</footer>`
293+
}

src/dashboard/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import { loginPage } from './pages/login'
1414
import { overviewPage } from './pages/overview'
1515
import { appDetailPage, type AppDetailData } from './pages/app-detail'
16-
import { getOverview } from './api/overview'
16+
import { getOverview, getOverviewChart } from './api/overview'
1717
import { getAppList, getAppName, getHealthUrls } from './helpers'
1818
import { getBrandConfig, type BrandConfig } from './brand'
1919

@@ -105,6 +105,17 @@ dashboard.get('/api/overview', async (c) => {
105105
return c.json({ ok: true, data })
106106
})
107107

108+
// API: Get overview chart data (per-app daily errors/warnings)
109+
dashboard.get('/api/overview/chart', async (c) => {
110+
if (!await isAuthenticated(c as any)) {
111+
return c.json({ ok: false, error: 'Unauthorized' }, 401)
112+
}
113+
114+
const days = Math.min(Math.max(parseInt(c.req.query('days') || '7', 10), 1), 90)
115+
const data = await getOverviewChart(c as any, days)
116+
return c.json({ ok: true, data })
117+
})
118+
108119
// API: List apps
109120
dashboard.get('/api/apps', async (c) => {
110121
if (!await isAuthenticated(c as any)) {

0 commit comments

Comments
 (0)