Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 72 additions & 9 deletions src/dashboard/api/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@

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'

/** Add stats counts to an accumulator in-place */
function addStats(acc: { debug: number; info: number; warn: number; error: number }, s: DailyStats): void {
acc.debug += s.debug
acc.info += s.info
acc.warn += s.warn
acc.error += s.error
}

/**
* Get overview data for all apps
*/
Expand Down Expand Up @@ -42,14 +50,8 @@ export async function getOverview(c: Context<{ Bindings: Env }>): Promise<Overvi
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
addStats(totals.today, data.today_stats)
addStats(totals.yesterday, data.yesterday_stats)

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

/**
* Get per-app daily stats for the overview bar chart
*/
export async function getOverviewChart(
c: Context<{ Bindings: Env }>,
days: number
): Promise<OverviewChartResponse> {
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<string>()
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<string, DailyStats>()
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
*/
Expand Down
174 changes: 142 additions & 32 deletions src/dashboard/components/charts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,48 +82,158 @@ export function dailyStatsChartConfig(
labels: string[],
datasets: { label: string; data: number[]; color: string }[]
): string {
const config = {
return JSON.stringify({
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,
})),
datasets: datasets.map(ds => lineDataset(ds.label, ds.data, ds.color)),
},
options: {
responsive: true,
maintainAspectRatio: false,
...CHART_BASE_OPTIONS,
scales: CHART_SCALES,
},
})
}

/**
* Build a single line-chart dataset entry
*/
function lineDataset(label: string, data: number[], color: string) {
return {
label,
data,
borderColor: color,
backgroundColor: color + '20',
tension: 0.3,
fill: true,
pointRadius: 3,
pointHoverRadius: 5,
}
}

/** Shared scale options used across chart configurations */
const CHART_SCALES = {
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' },
},
}

/** Shared base options used across chart configurations */
const CHART_BASE_OPTIONS = {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index' as const,
},
plugins: {
legend: {
position: 'bottom' as const,
labels: { color: '#71717a' },
},
},
}

/**
* Build a titled line chart config (serialized to JSON)
*/
function titledLineChart(
labels: string[],
datasets: ReturnType<typeof lineDataset>[],
title: string,
scaleOverrides?: Record<string, unknown>
): string {
return JSON.stringify({
type: 'line',
data: { labels, datasets },
options: {
...CHART_BASE_OPTIONS,
plugins: {
legend: {
position: 'bottom',
labels: { color: '#71717a' },
},
...CHART_BASE_OPTIONS.plugins,
title: { display: true, text: title, color: '#71717a', padding: { bottom: 8 } },
},
scales: { ...CHART_SCALES, ...scaleOverrides },
},
})
}

/**
* 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 errorsWarningsConfig = titledLineChart(
labels,
[
lineDataset('Errors', data.errors, styles.logColors.ERROR),
lineDataset('Warnings', data.warnings, styles.logColors.WARN),
],
'Errors & Warnings',
{ x: { ...CHART_SCALES.x, ticks: { ...CHART_SCALES.x.ticks, display: false } } }
)

const trafficConfig = titledLineChart(
labels,
[
lineDataset('Info', data.info, styles.logColors.INFO),
lineDataset('Debug', data.debug, styles.logColors.DEBUG),
],
'Traffic Volume'
)

return { errorsWarningsConfig, trafficConfig }
}

/**
* Build a single bar-chart dataset entry
*/
function barDataset(label: string, data: number[], color: string) {
return {
label,
data,
backgroundColor: color + '99',
borderColor: color,
borderWidth: 1,
borderRadius: 2,
barThickness: 'flex' as const,
}
}

/**
* 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 = apps.flatMap(app => [
barDataset(`${app.name} Errors`, app.errors, styles.logColors.ERROR),
barDataset(`${app.name} Warns`, app.warnings, styles.logColors.WARN),
])

return JSON.stringify({
type: 'bar',
data: { labels, datasets },
options: {
...CHART_BASE_OPTIONS,
scales: {
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' },
},
},
interaction: {
intersect: false,
mode: 'index',
x: { ...CHART_SCALES.x, stacked: false },
y: { ...CHART_SCALES.y, stacked: false },
},
},
}
return JSON.stringify(config)
})
}

/**
Expand Down
35 changes: 31 additions & 4 deletions src/dashboard/components/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export function htmlDocument(content: string, options: LayoutOptions = {}): stri
</head>
<body class="min-h-screen">
${content}
${footer(brand)}
<script>${cardGlowScript}</script>
</body>
</html>`
Expand Down Expand Up @@ -245,22 +246,48 @@ 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 `
<div class="brand-card card-glow rounded-lg p-4">
<div class="text-sm mb-1" style="color: var(--text-muted);">${label}</div>
<div class="text-2xl font-bold ${colorClass}">${value}</div>
${subtext ? `<div class="text-xs mt-1" style="color: var(--text-muted);">${subtext}</div>` : ''}
${sparklineHtml ? `<div class="mt-2 h-5 w-full opacity-70">${sparklineHtml}</div>` : ''}
</div>`
}

/**
* Empty state component
*/
export function emptyState(icon: string, message: string): string {
export function emptyState(icon: string, message: string, hint?: string): string {
return `
<div class="text-center py-12" style="color: var(--text-muted);">
${icon}
<p>${message}</p>
<div class="flex justify-center mb-3">${icon}</div>
<p class="font-medium">${message}</p>
${hint ? `<p class="text-xs mt-1 opacity-70">${hint}</p>` : ''}
</div>`
}

/**
* Branded footer component
*/
export function footer(brand: BrandConfig = DEFAULT_BRAND_CONFIG): string {
return `
<footer class="py-6 text-center text-xs" style="color: var(--text-muted); border-top: 1px solid var(--border);">
<div class="max-w-7xl mx-auto px-6 flex items-center justify-center gap-3">
<img src="${escapeHtml(brand.faviconUrl)}" alt="${escapeHtml(brand.name)}" style="height: 14px; width: auto; opacity: 0.5;">
<span>${escapeHtml(brand.name)} · Worker Logs</span>
<span style="opacity: 0.4;">·</span>
<span>Powered by Cloudflare Workers &amp; Durable Objects</span>
</div>
</footer>`
}
13 changes: 12 additions & 1 deletion src/dashboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)) {
Expand Down
Loading
Loading