From ad4acf1222da471dbf6296d27cabcec7f146e320 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 22 Jan 2026 13:48:41 +0000 Subject: [PATCH] chore: add frontend utilities and metrics documentation - Add useResizeObserver and useTooltip React hooks - Add utility functions for anomaly colors, error extraction, text width, and threshold colors - Add METRICS_DATA_FLOW.md documentation - Ignore SQLite temp files (*.db-shm, *.db-wal) --- .gitignore | 4 + docs/monitoring/METRICS_DATA_FLOW.md | 28 +++++++ .../src/hooks/useResizeObserver.ts | 69 +++++++++++++++++ frontend-modern/src/hooks/useTooltip.ts | 67 +++++++++++++++++ frontend-modern/src/utils/anomalyColors.ts | 18 +++++ .../src/utils/extractErrorMessage.ts | 41 ++++++++++ frontend-modern/src/utils/textWidth.ts | 12 +++ frontend-modern/src/utils/thresholdColors.ts | 74 +++++++++++++++++++ 8 files changed, 313 insertions(+) create mode 100644 docs/monitoring/METRICS_DATA_FLOW.md create mode 100644 frontend-modern/src/hooks/useResizeObserver.ts create mode 100644 frontend-modern/src/hooks/useTooltip.ts create mode 100644 frontend-modern/src/utils/anomalyColors.ts create mode 100644 frontend-modern/src/utils/extractErrorMessage.ts create mode 100644 frontend-modern/src/utils/textWidth.ts create mode 100644 frontend-modern/src/utils/thresholdColors.ts diff --git a/.gitignore b/.gitignore index 1ba109825..2f9f0e6a7 100644 --- a/.gitignore +++ b/.gitignore @@ -192,3 +192,7 @@ start-pulse-agent.sh # Husky .husky/_/ measure_sessions.sh + +# SQLite temp files +*.db-shm +*.db-wal diff --git a/docs/monitoring/METRICS_DATA_FLOW.md b/docs/monitoring/METRICS_DATA_FLOW.md new file mode 100644 index 000000000..2bc3810c3 --- /dev/null +++ b/docs/monitoring/METRICS_DATA_FLOW.md @@ -0,0 +1,28 @@ +# Metrics Data Flow (Sparklines vs History) + +## Quick summary +- Sparklines/trends toggle: client ring buffer + short-term in-memory server history via `/api/charts` (fast, not durable). +- Guest History tab: persistent SQLite metrics store via `/api/metrics-store/history` (durable, long-range, downsampled). + +## Path A: Sparklines ("Trends" toggle) +1. Server polling writes to in-memory history: `monitor.go` -> `metricsHistory.AddGuestMetric/AddNodeMetric`. +2. `/api/charts` (`handleCharts`) reads from `metricsHistory` via `monitor.GetGuestMetrics/GetNodeMetrics`. +3. Client toggles to sparklines: `metricsViewMode.ts` -> `seedFromBackend()` -> `ChartsAPI.getCharts()` -> ring buffer in `metricsHistory.ts`. +4. While in sparklines mode, `metricsSampler.ts` samples websocket state every 30s and appends to the ring buffer; localStorage saves periodically. + +## Path B: Guest drawer History tab +1. Server polling writes to SQLite store: `monitor.go` -> `metricsStore.Write(resourceType, ...)`. +2. `/api/metrics-store/history` (`handleMetricsHistory`) queries `metrics.Store` (`Query/QueryAll`) with tiered downsampling and license gating. +3. `GuestDrawer` History charts call `ChartsAPI.getMetricsHistory()` for CPU/memory/disk and ranges `24h/7d/30d/90d`. + +## Audit notes / inconsistencies +- In-memory retention is `NewMetricsHistory(1000, 24h)` (`monitor.go`). At 30s samples, 1000 points is ~8.3h, so sparklines now cap at 8h to avoid over-promising. +- Sparkline UI ranges (`15m/1h/4h/8h`) are a subset of `TimeRange` support (`5m/15m/30m/1h/4h/8h/12h/7d`) and differ from History tab ranges (`24h/7d/30d/90d`). +- Sparkline ring buffer keeps 7d locally, but server seeding is effectively ~8h at 30s sampling (1000-point cap); longer spans require staying in sparklines mode without reload. +- Docker resource keys differ: in-memory uses `docker:` (via `handleCharts`), persistent store uses `resourceType=dockerContainer`. Mapping is handled client-side when building metric keys; keep consistent when adding resource types. + +## DB-backed `/api/charts` assessment +- Feasible approach: add a `source=metrics-store` param to `/api/charts`, enumerate resources from state, then query `metrics.Store` per resource. +- Cost: `N resources x M metric types` → `N*M` queries + SQLite I/O (single-writer). For large fleets this is likely heavier than the current in-memory path. +- Optimization needed for viability: add a bulk store query keyed by resource type/time range (grouped by `resource_id`, `metric_type`) or cache pre-aggregated slices. +- Recommendation: keep `/api/charts` in-memory for table-wide sparklines; use the metrics-store path for per-resource charts or small, explicit batches. diff --git a/frontend-modern/src/hooks/useResizeObserver.ts b/frontend-modern/src/hooks/useResizeObserver.ts new file mode 100644 index 000000000..0b4b5f49a --- /dev/null +++ b/frontend-modern/src/hooks/useResizeObserver.ts @@ -0,0 +1,69 @@ +import { createSignal, onMount, onCleanup, type Accessor } from 'solid-js'; + +export interface UseResizeObserverResult { + /** Current width of the observed element */ + width: Accessor; + /** Current height of the observed element */ + height: Accessor; + /** Ref setter for the element to observe */ + setRef: (el: HTMLElement | undefined) => void; +} + +/** + * Hook to track element dimensions using ResizeObserver. + * Provides reactive width and height signals that update on resize. + * + * @param initialWidth - Initial width value (default: 100) + * @param initialHeight - Initial height value (default: 0) + * @returns Object with width, height signals and a ref setter + * + * @example + * ```tsx + * function MyComponent() { + * const { width, setRef } = useResizeObserver(); + * + * return ( + *
+ * Width: {width()}px + *
+ * ); + * } + * ``` + */ +export function useResizeObserver( + initialWidth = 100, + initialHeight = 0 +): UseResizeObserverResult { + const [width, setWidth] = createSignal(initialWidth); + const [height, setHeight] = createSignal(initialHeight); + let elementRef: HTMLElement | undefined; + let observer: ResizeObserver | undefined; + + const setRef = (el: HTMLElement | undefined) => { + elementRef = el; + }; + + onMount(() => { + if (!elementRef) return; + + // Set initial dimensions + setWidth(elementRef.offsetWidth); + setHeight(elementRef.offsetHeight); + + // Create observer + observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setWidth(entry.contentRect.width); + setHeight(entry.contentRect.height); + } + }); + + observer.observe(elementRef); + }); + + onCleanup(() => { + observer?.disconnect(); + }); + + return { width, height, setRef }; +} diff --git a/frontend-modern/src/hooks/useTooltip.ts b/frontend-modern/src/hooks/useTooltip.ts new file mode 100644 index 000000000..8260b0bdf --- /dev/null +++ b/frontend-modern/src/hooks/useTooltip.ts @@ -0,0 +1,67 @@ +import { createSignal, type Accessor } from 'solid-js'; + +export interface TooltipPosition { + x: number; + y: number; +} + +export interface UseTooltipResult { + /** Whether the tooltip is currently visible */ + showTooltip: Accessor; + /** Current tooltip position */ + tooltipPos: Accessor; + /** Handler for mouse enter - calculates position and shows tooltip */ + handleMouseEnter: (e: MouseEvent) => void; + /** Handler for mouse leave - hides tooltip */ + handleMouseLeave: () => void; +} + +/** + * Hook to manage tooltip visibility and positioning. + * Positions tooltip centered above the target element. + * + * @returns Object with tooltip state and event handlers + * + * @example + * ```tsx + * function MyComponent() { + * const { showTooltip, tooltipPos, handleMouseEnter, handleMouseLeave } = useTooltip(); + * + * return ( + * <> + *
+ * Hover me + *
+ * + * + *
+ * Tooltip content + *
+ *
+ *
+ * + * ); + * } + * ``` + */ +export function useTooltip(): UseTooltipResult { + const [showTooltip, setShowTooltip] = createSignal(false); + const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); + + const handleMouseEnter = (e: MouseEvent) => { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); + setShowTooltip(true); + }; + + const handleMouseLeave = () => { + setShowTooltip(false); + }; + + return { + showTooltip, + tooltipPos, + handleMouseEnter, + handleMouseLeave, + }; +} diff --git a/frontend-modern/src/utils/anomalyColors.ts b/frontend-modern/src/utils/anomalyColors.ts new file mode 100644 index 000000000..9ab72aa3a --- /dev/null +++ b/frontend-modern/src/utils/anomalyColors.ts @@ -0,0 +1,18 @@ +/** + * Anomaly severity color classes for Tailwind CSS. + * Used by metric bar components to display anomaly indicators. + */ +export const anomalySeverityClass: Record = { + critical: 'text-red-400', + high: 'text-orange-400', + medium: 'text-yellow-400', + low: 'text-blue-400', +}; + +/** + * Get the appropriate CSS class for an anomaly severity level. + * Falls back to yellow-400 if severity is unknown. + */ +export function getAnomalySeverityClass(severity: string): string { + return anomalySeverityClass[severity] || 'text-yellow-400'; +} diff --git a/frontend-modern/src/utils/extractErrorMessage.ts b/frontend-modern/src/utils/extractErrorMessage.ts new file mode 100644 index 000000000..4d704c5e4 --- /dev/null +++ b/frontend-modern/src/utils/extractErrorMessage.ts @@ -0,0 +1,41 @@ +/** + * Extract a user-friendly error message from an API response. + * Handles various response formats including JSON with error/message fields. + * + * @param response - The Response object from fetch + * @param defaultMessage - Default message if extraction fails + * @returns The extracted error message + */ +export async function extractErrorMessage( + response: Response, + defaultMessage?: string +): Promise { + const fallback = defaultMessage || `Failed with status ${response.status}`; + + try { + const text = await response.text(); + if (!text?.trim()) { + return fallback; + } + + // Try to parse as JSON first + try { + const parsed = JSON.parse(text); + // Check for common error field names + if (typeof parsed?.error === 'string' && parsed.error.trim()) { + return parsed.error.trim(); + } + if (typeof parsed?.message === 'string' && parsed.message.trim()) { + return parsed.message.trim(); + } + } catch { + // Not JSON, fall through to use raw text + } + + // Use raw text if it's non-empty + return text.trim(); + } catch { + // Body read failed + return fallback; + } +} diff --git a/frontend-modern/src/utils/textWidth.ts b/frontend-modern/src/utils/textWidth.ts new file mode 100644 index 000000000..4b1c4ee6f --- /dev/null +++ b/frontend-modern/src/utils/textWidth.ts @@ -0,0 +1,12 @@ +/** + * Estimate text width based on character count. + * Uses an approximation for 10px font size (~5.5-6px per character). + * + * @param text - The text to estimate width for + * @param charWidth - Average character width (default: 5.5) + * @param padding - Additional padding to add (default: 8) + * @returns Estimated width in pixels + */ +export function estimateTextWidth(text: string, charWidth = 5.5, padding = 8): number { + return text.length * charWidth + padding; +} diff --git a/frontend-modern/src/utils/thresholdColors.ts b/frontend-modern/src/utils/thresholdColors.ts new file mode 100644 index 000000000..a2db6f5fa --- /dev/null +++ b/frontend-modern/src/utils/thresholdColors.ts @@ -0,0 +1,74 @@ +/** + * Threshold-based color utilities for metric bars. + * Provides consistent coloring across CPU, memory, and disk usage displays. + */ + +export type ThresholdColor = 'red' | 'yellow' | 'green'; + +export interface ThresholdConfig { + critical: number; // >= this value = red + warning: number; // >= this value = yellow +} + +/** + * Default thresholds for different metric types. + */ +export const METRIC_THRESHOLDS: Record = { + cpu: { critical: 90, warning: 80 }, + memory: { critical: 85, warning: 75 }, + disk: { critical: 90, warning: 80 }, + generic: { critical: 90, warning: 75 }, +}; + +/** + * Determine the threshold color based on percentage and metric type. + */ +export function getThresholdColor( + percentage: number, + metricType: 'cpu' | 'memory' | 'disk' | 'generic' = 'generic' +): ThresholdColor { + const thresholds = METRIC_THRESHOLDS[metricType] || METRIC_THRESHOLDS.generic; + + if (percentage >= thresholds.critical) return 'red'; + if (percentage >= thresholds.warning) return 'yellow'; + return 'green'; +} + +/** + * CSS classes for threshold-based bar colors. + */ +export const THRESHOLD_BAR_CLASSES: Record = { + red: 'bg-red-500/60 dark:bg-red-500/50', + yellow: 'bg-yellow-500/60 dark:bg-yellow-500/50', + green: 'bg-green-500/60 dark:bg-green-500/50', +}; + +/** + * Get the CSS class for a bar based on percentage and metric type. + */ +export function getThresholdBarClass( + percentage: number, + metricType: 'cpu' | 'memory' | 'disk' | 'generic' = 'generic' +): string { + const color = getThresholdColor(percentage, metricType); + return THRESHOLD_BAR_CLASSES[color] || THRESHOLD_BAR_CLASSES.green; +} + +/** + * RGBA colors for threshold-based fills (for inline styles). + */ +export const THRESHOLD_RGBA_COLORS: Record = { + red: 'rgba(239, 68, 68, 0.6)', // red-500 + yellow: 'rgba(234, 179, 8, 0.6)', // yellow-500 + green: 'rgba(34, 197, 94, 0.6)', // green-500 +}; + +/** + * Get the RGBA color for a bar based on percentage. + * Used for inline styles in stacked bars. + */ +export function getThresholdRgbaColor(percentage: number): string { + if (percentage >= 90) return THRESHOLD_RGBA_COLORS.red; + if (percentage >= 80) return THRESHOLD_RGBA_COLORS.yellow; + return THRESHOLD_RGBA_COLORS.green; +}