mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -192,3 +192,7 @@ start-pulse-agent.sh
|
||||
# Husky
|
||||
.husky/_/
|
||||
measure_sessions.sh
|
||||
|
||||
# SQLite temp files
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
28
docs/monitoring/METRICS_DATA_FLOW.md
Normal file
28
docs/monitoring/METRICS_DATA_FLOW.md
Normal 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.
|
||||
69
frontend-modern/src/hooks/useResizeObserver.ts
Normal file
69
frontend-modern/src/hooks/useResizeObserver.ts
Normal 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 };
|
||||
}
|
||||
67
frontend-modern/src/hooks/useTooltip.ts
Normal file
67
frontend-modern/src/hooks/useTooltip.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
18
frontend-modern/src/utils/anomalyColors.ts
Normal file
18
frontend-modern/src/utils/anomalyColors.ts
Normal 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';
|
||||
}
|
||||
41
frontend-modern/src/utils/extractErrorMessage.ts
Normal file
41
frontend-modern/src/utils/extractErrorMessage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
12
frontend-modern/src/utils/textWidth.ts
Normal file
12
frontend-modern/src/utils/textWidth.ts
Normal 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;
|
||||
}
|
||||
74
frontend-modern/src/utils/thresholdColors.ts
Normal file
74
frontend-modern/src/utils/thresholdColors.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user