From c2f43c995eb93291d9cf706d2dcd29db0144ff35 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 22 Jan 2026 22:29:41 +0000 Subject: [PATCH] feat: enhance history charts with maxPoints and source tracking - Add maxPoints param for backend point limiting - Add source field to track data origin (store/memory/live) - Simplify GuestDrawer by removing split view toggle - Improve chart rendering and responsiveness --- frontend-modern/src/api/charts.ts | 7 +- .../src/components/Dashboard/GuestDrawer.tsx | 81 +------ .../src/components/shared/HistoryChart.tsx | 109 +++++++++- .../components/shared/UnifiedHistoryChart.tsx | 199 ++++++++++++++++-- 4 files changed, 295 insertions(+), 101 deletions(-) diff --git a/frontend-modern/src/api/charts.ts b/frontend-modern/src/api/charts.ts index ac2b3366a..7482176f8 100644 --- a/frontend-modern/src/api/charts.ts +++ b/frontend-modern/src/api/charts.ts @@ -55,6 +55,7 @@ export interface MetricsHistoryParams { resourceId: string; metric?: string; // Optional: 'cpu', 'memory', 'disk', etc. Omit for all metrics range?: HistoryTimeRange; // Default: '24h' + maxPoints?: number; // Optional cap on returned points (backend may downsample) } export interface SingleMetricHistoryResponse { @@ -65,6 +66,7 @@ export interface SingleMetricHistoryResponse { start: number; // Unix timestamp in milliseconds end: number; // Unix timestamp in milliseconds points: AggregatedMetricPoint[]; + source?: 'store' | 'memory' | 'live'; } export interface AllMetricsHistoryResponse { @@ -74,6 +76,7 @@ export interface AllMetricsHistoryResponse { start: number; // Unix timestamp in milliseconds end: number; // Unix timestamp in milliseconds metrics: Record; + source?: 'store' | 'memory' | 'live'; } export interface MetricsStoreStats { @@ -136,6 +139,9 @@ export class ChartsAPI { if (params.range) { searchParams.set('range', params.range); } + if (typeof params.maxPoints === 'number' && Number.isFinite(params.maxPoints) && params.maxPoints > 0) { + searchParams.set('maxPoints', Math.round(params.maxPoints).toString()); + } const url = `${this.baseUrl}/metrics-store/history?${searchParams.toString()}`; return apiFetchJSON(url); } @@ -148,4 +154,3 @@ export class ChartsAPI { return apiFetchJSON(url); } } - diff --git a/frontend-modern/src/components/Dashboard/GuestDrawer.tsx b/frontend-modern/src/components/Dashboard/GuestDrawer.tsx index 47105d80c..976d3ccdb 100644 --- a/frontend-modern/src/components/Dashboard/GuestDrawer.tsx +++ b/frontend-modern/src/components/Dashboard/GuestDrawer.tsx @@ -2,7 +2,6 @@ import { Component, Show, For, createSignal } from 'solid-js'; import { VM, Container } from '@/types/api'; import { formatBytes, formatUptime } from '@/utils/format'; import { DiskList } from './DiskList'; -import { HistoryChart } from '../shared/HistoryChart'; import { UnifiedHistoryChart } from '../shared/UnifiedHistoryChart'; import { HistoryTimeRange, ResourceType } from '@/api/charts'; @@ -98,7 +97,6 @@ export const GuestDrawer: Component = (props) => { const [activeTab, setActiveTab] = createSignal<'overview' | 'history'>('overview'); const [historyRange, setHistoryRange] = createSignal('24h'); - const [viewMode, setViewMode] = createSignal<'unified' | 'split'>('unified'); return (
@@ -313,32 +311,8 @@ export const GuestDrawer: Component = (props) => {
- {/* Toolbar: Range and View Toggle */} + {/* Toolbar: Range */}
-
- View -
- - -
-
-
Range
@@ -357,51 +331,14 @@ export const GuestDrawer: Component = (props) => {
- - - - - -
- - - -
-
+
diff --git a/frontend-modern/src/components/shared/HistoryChart.tsx b/frontend-modern/src/components/shared/HistoryChart.tsx index ef1b29531..8c872c07e 100644 --- a/frontend-modern/src/components/shared/HistoryChart.tsx +++ b/frontend-modern/src/components/shared/HistoryChart.tsx @@ -14,7 +14,7 @@ import { formatBytes } from '@/utils/format'; interface HistoryChartProps { resourceType: ResourceType; resourceId: string; - metric: 'cpu' | 'memory' | 'disk'; + metric: 'cpu' | 'memory' | 'disk' | 'diskread' | 'diskwrite' | 'netin' | 'netout'; height?: number; color?: string; label?: string; @@ -32,6 +32,23 @@ export const HistoryChart: Component = (props) => { const [data, setData] = createSignal([]); const [loading, setLoading] = createSignal(false); const [error, setError] = createSignal(null); + const [source, setSource] = createSignal<'store' | 'memory' | 'live' | null>(null); + const [maxPoints, setMaxPoints] = createSignal(null); + const [refreshTick, setRefreshTick] = createSignal(0); + + const refreshIntervalMs = createMemo(() => { + const r = range(); + switch (r) { + case '7d': + return 30000; + case '30d': + return 60000; + case '90d': + return 120000; + default: + return 10000; + } + }); // Load license status on mount to ensure hasFeature works correctly onMount(() => { @@ -82,39 +99,56 @@ export const HistoryChart: Component = (props) => { const id = props.resourceId; const metric = props.metric; const locked = isLocked(); + const pointsCap = maxPoints(); + refreshTick(); if (!id || !type) return; if (locked) { setLoading(false); setError(null); + setSource(null); return; } setLoading(true); setError(null); + setSource(null); try { const result = await ChartsAPI.getMetricsHistory({ resourceType: type, resourceId: id, metric: metric, - range: r + range: r, + maxPoints: pointsCap ?? undefined }); if ('points' in result) { setData(result.points || []); + setSource(result.source ?? 'store'); } else { // Should not happen as we request single metric setData([]); + setSource(result.source ?? 'store'); } } catch (err) { console.error('Failed to fetch metrics history:', err); setError('Failed to load history data'); + setSource(null); } finally { setLoading(false); } }); + createEffect(() => { + const interval = refreshIntervalMs(); + if (!interval || interval <= 0) return; + const timer = window.setInterval(() => { + setRefreshTick((t) => t + 1); + }, interval); + onCleanup(() => window.clearInterval(timer)); + }); + // Draw chart const drawChart = () => { if (!canvasRef) return; @@ -141,6 +175,7 @@ export const HistoryChart: Component = (props) => { const isDark = document.documentElement.classList.contains('dark'); const gridColor = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)'; const textColor = isDark ? '#9ca3af' : '#6b7280'; + const axisTextColor = isDark ? '#9ca3af' : '#6b7280'; // Dynamic color based on prop or default let mainColor = props.color || '#3b82f6'; // blue-500 @@ -219,6 +254,28 @@ export const HistoryChart: Component = (props) => { }); ctx.stroke(); + // X-axis time labels + const formatTimeLabel = (ts: number) => { + const date = new Date(ts); + const r = range(); + if (r === '30d' || r === '90d' || r === '7d') { + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); + } + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + ctx.fillStyle = axisTextColor; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + + const labelCount = 4; + for (let i = 0; i < labelCount; i++) { + const t = startTime + (timeSpan * i) / (labelCount - 1); + const x = getX(t); + ctx.fillText(formatTimeLabel(t), x, h - 2); + } + // Min/Max envelope (optional, for pro feel?) // Let's keep it clean for now, maybe add later. }; @@ -231,8 +288,28 @@ export const HistoryChart: Component = (props) => { // Resize observer createEffect(() => { if (!containerRef) return; - const resizeObserver = new ResizeObserver(() => drawChart()); + const computeMaxPoints = (width: number) => { + const safeWidth = Math.max(120, Math.floor(width)); + const dpr = window.devicePixelRatio || 1; + const points = Math.round(safeWidth * dpr); + return Math.min(1200, Math.max(180, points)); + }; + + const updateMaxPoints = () => { + const width = containerRef?.clientWidth || 0; + if (width <= 0) return; + const next = computeMaxPoints(width); + if (next !== maxPoints()) { + setMaxPoints(next); + } + }; + + const resizeObserver = new ResizeObserver(() => { + updateMaxPoints(); + drawChart(); + }); resizeObserver.observe(containerRef); + updateMaxPoints(); onCleanup(() => resizeObserver.disconnect()); }); @@ -293,6 +370,18 @@ export const HistoryChart: Component = (props) => { ({props.unit}) + + + {source() === 'live' ? 'Live' : 'Memory'} + +
{/* Time Range Selector */} @@ -390,14 +479,18 @@ export const HistoryChart: Component = (props) => { >
{new Date(point().timestamp).toLocaleString()}
- {props.unit === '%' ? - `${point().value.toFixed(1)}%` : - formatBytes(point().value)} + {props.unit === '%' + ? `${point().value.toFixed(1)}%` + : `${formatBytes(point().value)}${props.unit === 'B/s' ? '/s' : ''}`}
- Min: {props.unit === '%' ? point().min.toFixed(1) : formatBytes(point().min)} • - Max: {props.unit === '%' ? point().max.toFixed(1) : formatBytes(point().max)} + Min: {props.unit === '%' + ? point().min.toFixed(1) + : `${formatBytes(point().min)}${props.unit === 'B/s' ? '/s' : ''}`} • + Max: {props.unit === '%' + ? point().max.toFixed(1) + : `${formatBytes(point().max)}${props.unit === 'B/s' ? '/s' : ''}`}
diff --git a/frontend-modern/src/components/shared/UnifiedHistoryChart.tsx b/frontend-modern/src/components/shared/UnifiedHistoryChart.tsx index 06e690e95..8144bdd82 100644 --- a/frontend-modern/src/components/shared/UnifiedHistoryChart.tsx +++ b/frontend-modern/src/components/shared/UnifiedHistoryChart.tsx @@ -30,36 +30,66 @@ export const UnifiedHistoryChart: Component = (props) const [loading, setLoading] = createSignal(false); const [error, setError] = createSignal(null); const [hoveredPoint, setHoveredPoint] = createSignal(null); + const [source, setSource] = createSignal<'store' | 'memory' | 'live' | null>(null); + const [maxPoints, setMaxPoints] = createSignal(null); + const [group, setGroup] = createSignal<'utilization' | 'io'>('utilization'); + const [refreshTick, setRefreshTick] = createSignal(0); - const metricConfigs = { - cpu: { label: 'CPU', color: '#8b5cf6', unit: '%' }, // violet-500 - memory: { label: 'Memory', color: '#f59e0b', unit: '%' }, // amber-500 - disk: { label: 'Disk', color: '#10b981', unit: '%' } // emerald-500 + const metricGroups: Record<'utilization' | 'io', Record> = { + utilization: { + cpu: { label: 'CPU', color: '#8b5cf6', unit: '%' }, // violet-500 + memory: { label: 'Memory', color: '#f59e0b', unit: '%' }, // amber-500 + disk: { label: 'Disk', color: '#10b981', unit: '%' } // emerald-500 + }, + io: { + diskread: { label: 'Disk Read', color: '#3b82f6', unit: 'B/s' }, // blue-500 + diskwrite: { label: 'Disk Write', color: '#6366f1', unit: 'B/s' }, // indigo-500 + netin: { label: 'Net In', color: '#10b981', unit: 'B/s' }, // emerald-500 + netout: { label: 'Net Out', color: '#f59e0b', unit: 'B/s' } // amber-500 + } }; const isLocked = createMemo(() => (range() === '30d' || range() === '90d') && !hasFeature('long_term_metrics')); const lockDays = createMemo(() => (range() === '30d' ? '30' : '90')); + const refreshIntervalMs = createMemo(() => { + const r = range(); + switch (r) { + case '7d': + return 30000; + case '30d': + return 60000; + case '90d': + return 120000; + default: + return 10000; + } + }); - const loadData = async (resourceType: ResourceType, resourceId: string, rangeValue: HistoryTimeRange) => { + const loadData = async (resourceType: ResourceType, resourceId: string, rangeValue: HistoryTimeRange, pointsCap?: number | null) => { setLoading(true); setError(null); + setSource(null); try { // Fetch all metrics for the resource const response = await ChartsAPI.getMetricsHistory({ resourceType, resourceId, - range: rangeValue + range: rangeValue, + maxPoints: pointsCap ?? undefined }); if ('metrics' in response) { setMetricsData(response.metrics); + setSource(response.source ?? 'store'); } else { // Should not happen with multi-metric query, but handle fallback setMetricsData({ [response.metric]: response.points }); + setSource(response.source ?? 'store'); } } catch (err: any) { console.error('[UnifiedHistoryChart] Failed to load history:', err); setError('Failed to load history data'); + setSource(null); } finally { setLoading(false); } @@ -78,6 +108,8 @@ export const UnifiedHistoryChart: Component = (props) const resourceId = props.resourceId; const rangeValue = props.range ?? range(); const locked = isLocked(); + const pointsCap = maxPoints(); + refreshTick(); if (!resourceType || !resourceId) return; if (locked) { @@ -85,7 +117,16 @@ export const UnifiedHistoryChart: Component = (props) setError(null); return; } - loadData(resourceType, resourceId, rangeValue); + loadData(resourceType, resourceId, rangeValue, pointsCap); + }); + + createEffect(() => { + const interval = refreshIntervalMs(); + if (!interval || interval <= 0) return; + const timer = window.setInterval(() => { + setRefreshTick((t) => t + 1); + }, interval); + onCleanup(() => window.clearInterval(timer)); }); const drawChart = () => { @@ -95,6 +136,8 @@ export const UnifiedHistoryChart: Component = (props) const w = canvasRef.parentElement?.clientWidth || 300; const h = props.height || 200; + const activeGroup = group(); + const metricConfigs = metricGroups[activeGroup]; const dpr = window.devicePixelRatio || 1; canvasRef.width = w * dpr; @@ -108,6 +151,7 @@ export const UnifiedHistoryChart: Component = (props) const isDark = document.documentElement.classList.contains('dark'); const gridColor = isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)'; const textColor = isDark ? '#9ca3af' : '#6b7280'; + const axisTextColor = isDark ? '#9ca3af' : '#6b7280'; // Draw grid ctx.strokeStyle = gridColor; @@ -123,11 +167,36 @@ export const UnifiedHistoryChart: Component = (props) ctx.font = '10px sans-serif'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; - ctx.fillText(`${Math.round(pct * 100)}%`, 35, y); + if (activeGroup === 'utilization') { + ctx.fillText(`${Math.round(pct * 100)}%`, 35, y); + } }); // Plot each series const dataMap = metricsData(); + const pointsForAxis = Object.keys(metricConfigs) + .map(metricId => dataMap[metricId]) + .find(points => points && points.length > 0); + let axisStart = 0; + let axisEnd = 0; + if (pointsForAxis && pointsForAxis.length > 0) { + axisStart = pointsForAxis[0].timestamp; + axisEnd = pointsForAxis[pointsForAxis.length - 1].timestamp; + } + + let maxAxisValue = 100; + if (activeGroup === 'io') { + let maxValue = 0; + Object.keys(metricConfigs).forEach(metricId => { + const points = dataMap[metricId]; + if (!points || points.length === 0) return; + for (const p of points) { + const v = p.max || p.value || 0; + if (v > maxValue) maxValue = v; + } + }); + maxAxisValue = Math.max(1, maxValue * 1.1); + } Object.entries(metricConfigs).forEach(([metricId, config]) => { const points = dataMap[metricId]; @@ -141,7 +210,10 @@ export const UnifiedHistoryChart: Component = (props) const timeSpan = endTime - startTime || 1; const getX = (ts: number) => 40 + ((ts - startTime) / timeSpan) * (w - 40); - const getY = (val: number) => h - 20 - (Math.min(Math.max(val, 0), 100) / 100) * (h - 40); + const getY = (val: number) => { + const clamped = Math.max(0, Math.min(val, maxAxisValue)); + return h - 20 - (clamped / maxAxisValue) * (h - 40); + }; // Draw Area (Transparent) ctx.fillStyle = `${config.color}15`; // 15 order opacity @@ -162,6 +234,43 @@ export const UnifiedHistoryChart: Component = (props) }); ctx.stroke(); }); + + if (axisStart > 0 && axisEnd > axisStart) { + const timeSpan = axisEnd - axisStart; + const getAxisX = (ts: number) => 40 + ((ts - axisStart) / timeSpan) * (w - 40); + const formatTimeLabel = (ts: number) => { + const date = new Date(ts); + const r = range(); + if (r === '30d' || r === '90d' || r === '7d') { + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); + } + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + ctx.fillStyle = axisTextColor; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + + const labelCount = 4; + for (let i = 0; i < labelCount; i++) { + const t = axisStart + (timeSpan * i) / (labelCount - 1); + const x = getAxisX(t); + ctx.fillText(formatTimeLabel(t), x, h - 2); + } + } + + if (activeGroup === 'io') { + ctx.fillStyle = textColor; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + [0, 0.5, 1].forEach(pct => { + const y = h - 20 - (pct * (h - 40)); + const value = maxAxisValue * pct; + ctx.fillText(`${formatBytes(value)}/s`, 35, y); + }); + } }; createEffect(() => { @@ -171,8 +280,28 @@ export const UnifiedHistoryChart: Component = (props) createEffect(() => { if (!containerRef) return; - const ro = new ResizeObserver(() => drawChart()); + const computeMaxPoints = (width: number) => { + const safeWidth = Math.max(120, Math.floor(width)); + const dpr = window.devicePixelRatio || 1; + const points = Math.round(safeWidth * dpr); + return Math.min(1200, Math.max(180, points)); + }; + + const updateMaxPoints = () => { + const width = containerRef?.clientWidth || 0; + if (width <= 0) return; + const next = computeMaxPoints(width); + if (next !== maxPoints()) { + setMaxPoints(next); + } + }; + + const ro = new ResizeObserver(() => { + updateMaxPoints(); + drawChart(); + }); ro.observe(containerRef); + updateMaxPoints(); onCleanup(() => ro.disconnect()); }); @@ -186,8 +315,10 @@ export const UnifiedHistoryChart: Component = (props) } const dataMap = metricsData(); - const firstMetric = Object.keys(dataMap)[0]; - const points = dataMap[firstMetric]; + const activeGroup = group(); + const metricConfigs = metricGroups[activeGroup]; + const firstMetric = Object.keys(metricConfigs).find(metricId => dataMap[metricId]?.length); + const points = firstMetric ? dataMap[firstMetric] : undefined; if (!points || points.length === 0) return; const startTime = points[0].timestamp; @@ -242,8 +373,20 @@ export const UnifiedHistoryChart: Component = (props)
{props.label || 'Unified History'} + + + {source() === 'live' ? 'Live' : 'Memory'} + +
- + {(c) => (
@@ -254,21 +397,37 @@ export const UnifiedHistoryChart: Component = (props)
- +
- {(['24h', '7d', '30d', '90d'] as HistoryTimeRange[]).map(r => ( + {(['utilization', 'io'] as const).map(mode => ( ))}
- + +
+ {(['24h', '7d', '30d', '90d'] as HistoryTimeRange[]).map(r => ( + + ))} +
+
+
@@ -329,7 +488,7 @@ export const UnifiedHistoryChart: Component = (props) {m.label}
- {m.unit === '%' ? `${m.value.toFixed(1)}%` : formatBytes(m.value)} + {m.unit === '%' ? `${m.value.toFixed(1)}%` : `${formatBytes(m.value)}/s`}
)}