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)
This commit is contained in:
rcourtman
2026-01-22 13:48:41 +00:00
parent 289d95374f
commit ad4acf1222
8 changed files with 313 additions and 0 deletions

4
.gitignore vendored
View File

@@ -192,3 +192,7 @@ start-pulse-agent.sh
# Husky
.husky/_/
measure_sessions.sh
# SQLite temp files
*.db-shm
*.db-wal

View File

@@ -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:<id>` (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.

View File

@@ -0,0 +1,69 @@
import { createSignal, onMount, onCleanup, type Accessor } from 'solid-js';
export interface UseResizeObserverResult {
/** Current width of the observed element */
width: Accessor<number>;
/** Current height of the observed element */
height: Accessor<number>;
/** 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 (
* <div ref={setRef}>
* Width: {width()}px
* </div>
* );
* }
* ```
*/
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 };
}

View File

@@ -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<boolean>;
/** Current tooltip position */
tooltipPos: Accessor<TooltipPosition>;
/** 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 (
* <>
* <div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
* Hover me
* </div>
* <Show when={showTooltip()}>
* <Portal mount={document.body}>
* <div style={{ left: `${tooltipPos().x}px`, top: `${tooltipPos().y - 8}px` }}>
* Tooltip content
* </div>
* </Portal>
* </Show>
* </>
* );
* }
* ```
*/
export function useTooltip(): UseTooltipResult {
const [showTooltip, setShowTooltip] = createSignal(false);
const [tooltipPos, setTooltipPos] = createSignal<TooltipPosition>({ 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,
};
}

View File

@@ -0,0 +1,18 @@
/**
* Anomaly severity color classes for Tailwind CSS.
* Used by metric bar components to display anomaly indicators.
*/
export const anomalySeverityClass: Record<string, string> = {
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';
}

View File

@@ -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<string> {
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;
}
}

View File

@@ -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;
}

View File

@@ -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<string, ThresholdConfig> = {
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<ThresholdColor, string> = {
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<ThresholdColor, string> = {
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;
}