From 9d2d87e7e1bdfd8c1d271c8c31a0cccd08edae00 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:26:26 +0200 Subject: [PATCH] feat: add treemap and punchcard heatmap to dashboard L1/L2 (#94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Treemap: rectangle area = transaction volume, color = SLA compliance (green→red). Shows apps at L1, routes at L2. Click navigates deeper. Punchcard heatmap: 7-day rolling weekday x 24-hour grid showing transaction volume and error patterns. Two side-by-side views (transactions + errors) reveal temporal clustering. Backend: new GET /search/stats/punchcard endpoint aggregating stats_1m_all/app by DOW x hour over rolling 7 days. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/controller/SearchController.java | 10 ++ .../app/storage/PostgresStatsStore.java | 26 +++ .../server/core/search/SearchService.java | 4 + .../server/core/storage/StatsStore.java | 5 + ui/src/api/queries/dashboard.ts | 19 +++ ui/src/pages/DashboardTab/DashboardL1.tsx | 36 +++- ui/src/pages/DashboardTab/DashboardL2.tsx | 66 ++++++-- .../pages/DashboardTab/PunchcardHeatmap.tsx | 125 ++++++++++++++ ui/src/pages/DashboardTab/Treemap.tsx | 159 ++++++++++++++++++ 9 files changed, 433 insertions(+), 17 deletions(-) create mode 100644 ui/src/pages/DashboardTab/PunchcardHeatmap.tsx create mode 100644 ui/src/pages/DashboardTab/Treemap.tsx diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java index cdeeada1..38cba95d 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java @@ -11,6 +11,7 @@ import com.cameleer3.server.core.search.SearchResult; import com.cameleer3.server.core.search.SearchService; import com.cameleer3.server.core.search.StatsTimeseries; import com.cameleer3.server.core.search.TopError; +import com.cameleer3.server.core.storage.StatsStore; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; @@ -162,6 +163,15 @@ public class SearchController { return ResponseEntity.ok(searchService.timeseriesGroupedByRoute(from, end, buckets, application)); } + @GetMapping("/stats/punchcard") + @Operation(summary = "Transaction punchcard: weekday x hour grid (rolling 7 days)") + public ResponseEntity> punchcard( + @RequestParam(required = false) String application) { + Instant to = Instant.now(); + Instant from = to.minus(java.time.Duration.ofDays(7)); + return ResponseEntity.ok(searchService.punchcard(from, to, application)); + } + @GetMapping("/errors/top") @Operation(summary = "Top N errors with velocity trend") public ResponseEntity> topErrors( diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresStatsStore.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresStatsStore.java index ee1b70b9..563c5893 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresStatsStore.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresStatsStore.java @@ -399,4 +399,30 @@ public class PostgresStatsStore implements StatsStore { Integer count = jdbc.queryForObject(sql, Integer.class, params.toArray()); return count != null ? count : 0; } + + // ── Punchcard ───────────────────────────────────────────────────────── + + @Override + public List punchcard(Instant from, Instant to, String applicationName) { + String view = applicationName != null ? "stats_1m_app" : "stats_1m_all"; + String sql = "SELECT EXTRACT(DOW FROM bucket) AS weekday, " + + "EXTRACT(HOUR FROM bucket) AS hour, " + + "COALESCE(SUM(total_count), 0) AS total_count, " + + "COALESCE(SUM(failed_count), 0) AS failed_count " + + "FROM " + view + " WHERE bucket >= ? AND bucket < ?"; + + List params = new ArrayList<>(); + params.add(Timestamp.from(from)); + params.add(Timestamp.from(to)); + if (applicationName != null) { + sql += " AND application_name = ?"; + params.add(applicationName); + } + sql += " GROUP BY weekday, hour ORDER BY weekday, hour"; + + return jdbc.query(sql, (rs, rowNum) -> new PunchcardCell( + rs.getInt("weekday"), rs.getInt("hour"), + rs.getLong("total_count"), rs.getLong("failed_count")), + params.toArray()); + } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java index 556160a1..ec6012f0 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java @@ -83,4 +83,8 @@ public class SearchService { public int activeErrorTypes(Instant from, Instant to, String applicationName) { return statsStore.activeErrorTypes(from, to, applicationName); } + + public List punchcard(Instant from, Instant to, String applicationName) { + return statsStore.punchcard(from, to, applicationName); + } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/StatsStore.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/StatsStore.java index f3267d81..1dcb1728 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/StatsStore.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/StatsStore.java @@ -60,4 +60,9 @@ public interface StatsStore { // Count of distinct error types in window int activeErrorTypes(Instant from, Instant to, String applicationName); + + // Punchcard: aggregate by weekday (0=Sun..6=Sat) x hour (0-23) over last 7 days + List punchcard(Instant from, Instant to, String applicationName); + + record PunchcardCell(int weekday, int hour, long totalCount, long failedCount) {} } diff --git a/ui/src/api/queries/dashboard.ts b/ui/src/api/queries/dashboard.ts index 012e13fb..38df830e 100644 --- a/ui/src/api/queries/dashboard.ts +++ b/ui/src/api/queries/dashboard.ts @@ -92,6 +92,25 @@ export function useTopErrors(from?: string, to?: string, application?: string, r }); } +// ── Punchcard (weekday x hour heatmap, rolling 7 days) ──────────────── + +export interface PunchcardCell { + weekday: number; + hour: number; + totalCount: number; + failedCount: number; +} + +export function usePunchcard(application?: string) { + const refetchInterval = useRefreshInterval(60_000); + return useQuery({ + queryKey: ['dashboard', 'punchcard', application], + queryFn: () => fetchJson('/search/stats/punchcard', { application }), + placeholderData: (prev: PunchcardCell[] | undefined) => prev, + refetchInterval, + }); +} + // ── App settings ────────────────────────────────────────────────────── export interface AppSettings { diff --git a/ui/src/pages/DashboardTab/DashboardL1.tsx b/ui/src/pages/DashboardTab/DashboardL1.tsx index a2aaa7a3..0e419391 100644 --- a/ui/src/pages/DashboardTab/DashboardL1.tsx +++ b/ui/src/pages/DashboardTab/DashboardL1.tsx @@ -15,8 +15,11 @@ import type { KpiItem, Column } from '@cameleer/design-system'; import { useGlobalFilters } from '@cameleer/design-system'; import { useRouteMetrics } from '../../api/queries/catalog'; import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions'; -import { useTimeseriesByApp, useTopErrors, useAllAppSettings } from '../../api/queries/dashboard'; +import { useTimeseriesByApp, useTopErrors, useAllAppSettings, usePunchcard } from '../../api/queries/dashboard'; import type { AppSettings } from '../../api/queries/dashboard'; +import { Treemap } from './Treemap'; +import type { TreemapItem } from './Treemap'; +import { PunchcardHeatmap } from './PunchcardHeatmap'; import type { RouteMetrics } from '../../api/types'; import { computeHealthDot, @@ -294,6 +297,7 @@ export default function DashboardL1() { const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo); const { data: timeseriesByApp } = useTimeseriesByApp(timeFrom, timeTo); const { data: topErrors } = useTopErrors(timeFrom, timeTo); + const { data: punchcardData } = usePunchcard(); const { data: allAppSettings } = useAllAppSettings(); // Build settings lookup map @@ -387,6 +391,12 @@ export default function DashboardL1() { })); }, [timeseriesByApp]); + // Treemap items: one per app, sized by exchange count, colored by SLA + const treemapItems: TreemapItem[] = useMemo( + () => appRows.map(r => ({ id: r.appId, label: r.appId, value: r.throughput, slaCompliance: r.slaCompliance })), + [appRows], + ); + return (
@@ -437,6 +447,30 @@ export default function DashboardL1() {
)} + + {/* Treemap: app volume vs SLA compliance */} + {treemapItems.length > 0 && ( + + navigate(`/dashboard/${id}`)} + /> + + )} + + {/* Punchcard heatmaps: transactions and errors by weekday x hour */} + {(punchcardData?.length ?? 0) > 0 && ( +
+ + + + + + +
+ )}
); } diff --git a/ui/src/pages/DashboardTab/DashboardL2.tsx b/ui/src/pages/DashboardTab/DashboardL2.tsx index 68bc9bbe..dfd080ee 100644 --- a/ui/src/pages/DashboardTab/DashboardL2.tsx +++ b/ui/src/pages/DashboardTab/DashboardL2.tsx @@ -18,8 +18,12 @@ import { useTimeseriesByRoute, useTopErrors, useAppSettings, + usePunchcard, } from '../../api/queries/dashboard'; import type { TopError } from '../../api/queries/dashboard'; +import { Treemap } from './Treemap'; +import type { TreemapItem } from './Treemap'; +import { PunchcardHeatmap } from './PunchcardHeatmap'; import type { RouteMetrics } from '../../api/types'; import { trendArrow, @@ -272,28 +276,34 @@ export default function DashboardL2() { const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId); const { data: timeseriesByRoute } = useTimeseriesByRoute(timeFrom, timeTo, appId); const { data: errors } = useTopErrors(timeFrom, timeTo, appId); + const { data: punchcardData } = usePunchcard(appId); const { data: appSettings } = useAppSettings(appId); const slaThresholdMs = appSettings?.slaThresholdMs ?? 300; // Route performance table rows const routeRows: RouteRow[] = useMemo(() => - (metrics || []).map((m: RouteMetrics) => { - const sla = m.p99DurationMs <= slaThresholdMs - ? 99.9 - : Math.max(0, 100 - ((m.p99DurationMs - slaThresholdMs) / slaThresholdMs) * 10); - return { - id: m.routeId, - routeId: m.routeId, - exchangeCount: m.exchangeCount, - successRate: m.successRate, - avgDurationMs: m.avgDurationMs, - p99DurationMs: m.p99DurationMs, - slaCompliance: sla, - sparkline: m.sparkline ?? [], - }; - }), - [metrics, slaThresholdMs], + (metrics || []).map((m: RouteMetrics) => ({ + id: m.routeId, + routeId: m.routeId, + exchangeCount: m.exchangeCount, + successRate: m.successRate, + avgDurationMs: m.avgDurationMs, + p99DurationMs: m.p99DurationMs, + slaCompliance: (m as Record).slaCompliance as number ?? -1, + sparkline: m.sparkline ?? [], + })), + [metrics], + ); + + // Treemap items: one per route, sized by exchange count, colored by SLA + const treemapItems: TreemapItem[] = useMemo( + () => routeRows.map(r => ({ + id: r.routeId, label: r.routeId, + value: r.exchangeCount, + slaCompliance: r.slaCompliance >= 0 ? r.slaCompliance : 100, + })), + [routeRows], ); // KPI sparklines from timeseries @@ -416,6 +426,30 @@ export default function DashboardL2() { /> )} + + {/* Treemap: route volume vs SLA compliance */} + {treemapItems.length > 0 && ( + + navigate(`/dashboard/${appId}/${id}`)} + /> + + )} + + {/* Punchcard heatmaps: transactions and errors by weekday x hour */} + {(punchcardData?.length ?? 0) > 0 && ( +
+ + + + + + +
+ )} ); } diff --git a/ui/src/pages/DashboardTab/PunchcardHeatmap.tsx b/ui/src/pages/DashboardTab/PunchcardHeatmap.tsx new file mode 100644 index 00000000..20378643 --- /dev/null +++ b/ui/src/pages/DashboardTab/PunchcardHeatmap.tsx @@ -0,0 +1,125 @@ +import { useMemo } from 'react'; + +export interface PunchcardCell { + weekday: number; + hour: number; + totalCount: number; + failedCount: number; +} + +interface PunchcardHeatmapProps { + cells: PunchcardCell[]; + mode: 'transactions' | 'errors'; + width: number; + height: number; +} + +const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const LEFT_MARGIN = 28; +const TOP_MARGIN = 18; +const BOTTOM_MARGIN = 4; +const RIGHT_MARGIN = 4; + +function transactionColor(ratio: number): string { + if (ratio === 0) return 'hsl(220, 15%, 95%)'; + const lightness = 90 - ratio * 55; + return `hsl(220, 60%, ${Math.round(lightness)}%)`; +} + +function errorColor(ratio: number): string { + if (ratio === 0) return 'hsl(0, 10%, 95%)'; + const lightness = 90 - ratio * 55; + return `hsl(0, 65%, ${Math.round(lightness)}%)`; +} + +export function PunchcardHeatmap({ cells, mode, width, height }: PunchcardHeatmapProps) { + const grid = useMemo(() => { + const map = new Map(); + for (const c of cells) { + map.set(`${c.weekday}-${c.hour}`, c); + } + + const values: number[] = []; + for (const c of cells) { + values.push(mode === 'errors' ? c.failedCount : c.totalCount); + } + const maxVal = Math.max(...values, 1); + + const gridWidth = width - LEFT_MARGIN - RIGHT_MARGIN; + const gridHeight = height - TOP_MARGIN - BOTTOM_MARGIN; + const cellW = gridWidth / 7; + const cellH = gridHeight / 24; + + const rects: { x: number; y: number; w: number; h: number; fill: string; value: number; day: string; hour: number }[] = []; + + for (let d = 0; d < 7; d++) { + for (let h = 0; h < 24; h++) { + const cell = map.get(`${d}-${h}`); + const val = cell ? (mode === 'errors' ? cell.failedCount : cell.totalCount) : 0; + const ratio = maxVal > 0 ? val / maxVal : 0; + const fill = mode === 'errors' ? errorColor(ratio) : transactionColor(ratio); + + rects.push({ + x: LEFT_MARGIN + d * cellW, + y: TOP_MARGIN + h * cellH, + w: cellW, + h: cellH, + fill, + value: val, + day: DAYS[d], + hour: h, + }); + } + } + return { rects, cellW, cellH }; + }, [cells, mode, width, height]); + + return ( + + {/* Day labels (top) */} + {DAYS.map((day, i) => ( + + {day} + + ))} + + {/* Hour labels (left, every 4 hours) */} + {[0, 4, 8, 12, 16, 20].map((h) => ( + + {String(h).padStart(2, '0')} + + ))} + + {/* Cells */} + {grid.rects.map((r) => ( + + {`${r.day} ${String(r.hour).padStart(2, '0')}:00 — ${r.value.toLocaleString()} ${mode}`} + + ))} + + ); +} diff --git a/ui/src/pages/DashboardTab/Treemap.tsx b/ui/src/pages/DashboardTab/Treemap.tsx new file mode 100644 index 00000000..01e88a0a --- /dev/null +++ b/ui/src/pages/DashboardTab/Treemap.tsx @@ -0,0 +1,159 @@ +import { useMemo } from 'react'; + +export interface TreemapItem { + id: string; + label: string; + value: number; + /** 0-100, drives green→yellow→red color */ + slaCompliance: number; +} + +interface TreemapProps { + items: TreemapItem[]; + width: number; + height: number; + onItemClick?: (id: string) => void; +} + +interface LayoutRect { + item: TreemapItem; + x: number; + y: number; + w: number; + h: number; +} + +function slaColor(pct: number): string { + if (pct >= 99) return 'hsl(120, 45%, 85%)'; + if (pct >= 97) return 'hsl(90, 45%, 85%)'; + if (pct >= 95) return 'hsl(60, 50%, 85%)'; + if (pct >= 90) return 'hsl(30, 55%, 85%)'; + return 'hsl(0, 55%, 85%)'; +} + +function slaBorderColor(pct: number): string { + if (pct >= 99) return 'hsl(120, 40%, 45%)'; + if (pct >= 97) return 'hsl(90, 40%, 50%)'; + if (pct >= 95) return 'hsl(60, 45%, 45%)'; + if (pct >= 90) return 'hsl(30, 50%, 45%)'; + return 'hsl(0, 50%, 45%)'; +} + +function slaTextColor(pct: number): string { + if (pct >= 95) return 'hsl(120, 20%, 25%)'; + return 'hsl(0, 40%, 30%)'; +} + +/** Squarified treemap layout */ +function layoutTreemap(items: TreemapItem[], x: number, y: number, w: number, h: number): LayoutRect[] { + if (items.length === 0) return []; + const total = items.reduce((s, i) => s + i.value, 0); + if (total === 0) return items.map((item, i) => ({ item, x: x + i, y, w: 1, h: 1 })); + + const sorted = [...items].sort((a, b) => b.value - a.value); + const rects: LayoutRect[] = []; + layoutSlice(sorted, total, x, y, w, h, rects); + return rects; +} + +function layoutSlice( + items: TreemapItem[], total: number, + x: number, y: number, w: number, h: number, + out: LayoutRect[], +) { + if (items.length === 0) return; + if (items.length === 1) { + out.push({ item: items[0], x, y, w, h }); + return; + } + + const isWide = w >= h; + let partialSum = 0; + let splitIndex = 0; + + // Find split point closest to half the total area + const halfTotal = total / 2; + for (let i = 0; i < items.length - 1; i++) { + partialSum += items[i].value; + if (partialSum >= halfTotal) { + splitIndex = i + 1; + break; + } + splitIndex = i + 1; + } + + const leftTotal = items.slice(0, splitIndex).reduce((s, i) => s + i.value, 0); + const ratio = total > 0 ? leftTotal / total : 0.5; + + if (isWide) { + const splitX = x + w * ratio; + layoutSlice(items.slice(0, splitIndex), leftTotal, x, y, w * ratio, h, out); + layoutSlice(items.slice(splitIndex), total - leftTotal, splitX, y, w * (1 - ratio), h, out); + } else { + const splitY = y + h * ratio; + layoutSlice(items.slice(0, splitIndex), leftTotal, x, y, w, h * ratio, out); + layoutSlice(items.slice(splitIndex), total - leftTotal, x, splitY, w, h * (1 - ratio), out); + } +} + +export function Treemap({ items, width, height, onItemClick }: TreemapProps) { + const rects = useMemo(() => layoutTreemap(items, 1, 1, width - 2, height - 2), [items, width, height]); + + if (items.length === 0) { + return ( + + No data + + ); + } + + return ( + + {rects.map(({ item, x, y, w, h }) => { + const pad = 1; + const rx = x + pad; + const ry = y + pad; + const rw = Math.max(w - pad * 2, 0); + const rh = Math.max(h - pad * 2, 0); + const showLabel = rw > 40 && rh > 20; + const showSla = rw > 60 && rh > 34; + + return ( + onItemClick?.(item.id)} + style={{ cursor: onItemClick ? 'pointer' : 'default' }} + > + + {showLabel && ( + + {item.label.length > rw / 6.5 ? item.label.slice(0, Math.floor(rw / 6.5)) + '\u2026' : item.label} + + )} + {showSla && ( + + {item.slaCompliance.toFixed(1)}% SLA + + )} + + ); + })} + + ); +}