diff --git a/frontend-modern/src/api/charts.ts b/frontend-modern/src/api/charts.ts index ced0d90..962d8ab 100644 --- a/frontend-modern/src/api/charts.ts +++ b/frontend-modern/src/api/charts.ts @@ -31,6 +31,7 @@ export interface ChartsResponse { data: Record; // VM/Container data keyed by ID nodeData: Record; // Node data keyed by ID storageData: Record; // Storage data keyed by ID + guestTypes?: Record; // Maps guest ID to type timestamp: number; stats: ChartStats; } diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 20c1ed9..055af95 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -1094,7 +1094,7 @@ export function Dashboard(props: DashboardProps) { return ( { + // Subscribe to version changes so we re-read when new data is seeded + getMetricsVersion(); if (viewMode() !== 'sparklines' || !props.resourceId) return []; return getMetricHistoryForRange(props.resourceId, timeRange()); }); diff --git a/frontend-modern/src/components/Dashboard/MetricBar.tsx b/frontend-modern/src/components/Dashboard/MetricBar.tsx index 7e62f5d..bd53bfa 100644 --- a/frontend-modern/src/components/Dashboard/MetricBar.tsx +++ b/frontend-modern/src/components/Dashboard/MetricBar.tsx @@ -1,7 +1,7 @@ import { Show, createMemo, createSignal, onMount, onCleanup } from 'solid-js'; import { Sparkline } from '@/components/shared/Sparkline'; import { useMetricsViewMode } from '@/stores/metricsViewMode'; -import { getMetricHistoryForRange } from '@/stores/metricsHistory'; +import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory'; interface MetricBarProps { value: number; @@ -86,7 +86,10 @@ export function MetricBar(props: MetricBarProps) { }); // Get metric history for sparkline + // Depends on metricsVersion to re-fetch when data is seeded (e.g., on time range change) const metricHistory = createMemo(() => { + // Subscribe to version changes so we re-read when new data is seeded + getMetricsVersion(); if (viewMode() !== 'sparklines' || !props.resourceId) return []; return getMetricHistoryForRange(props.resourceId, timeRange()); }); diff --git a/frontend-modern/src/components/shared/MetricsViewToggle.tsx b/frontend-modern/src/components/shared/MetricsViewToggle.tsx index 702d171..33b0219 100644 --- a/frontend-modern/src/components/shared/MetricsViewToggle.tsx +++ b/frontend-modern/src/components/shared/MetricsViewToggle.tsx @@ -15,25 +15,6 @@ export const MetricsViewToggle: Component = () => { return (
- {/* Time Range Selector - only shown in sparklines mode */} - -
- {TIME_RANGE_OPTIONS.map((option) => ( - - ))} -
-
- {/* View Mode Toggle */}
+ + {/* Time Range Selector - only shown in sparklines mode, appears to the right */} + +
+ {TIME_RANGE_OPTIONS.map((option) => ( + + ))} +
+
); }; diff --git a/frontend-modern/src/components/shared/NodeSummaryTable.tsx b/frontend-modern/src/components/shared/NodeSummaryTable.tsx index 6535edc..e9fc289 100644 --- a/frontend-modern/src/components/shared/NodeSummaryTable.tsx +++ b/frontend-modern/src/components/shared/NodeSummaryTable.tsx @@ -337,7 +337,8 @@ export const NodeSummaryTable: Component = (props) => { return sortDirection() === 'asc' ? '▲' : '▼'; }; - const thClass = "px-2 py-1 text-center text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"; + const thClassBase = "px-2 py-0.5 text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 whitespace-nowrap"; + const thClass = `${thClassBase} text-center`; return ( @@ -346,7 +347,7 @@ export const NodeSummaryTable: Component = (props) => { handleSort('name')} > {props.currentTab === 'backups' ? 'Node / PBS' : 'Node'} {renderSortIndicator('name')} @@ -481,7 +482,7 @@ export const NodeSummaryTable: Component = (props) => { onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')} > {/* Name */} - +
= (props) => { {/* Uptime */} - +
= (props) => { {/* CPU */} - +
@@ -589,7 +590,7 @@ export const NodeSummaryTable: Component = (props) => { {/* Memory */} - +
@@ -631,7 +632,7 @@ export const NodeSummaryTable: Component = (props) => { {/* Disk */} - + = (props) => { {/* Temperature */} - +
= (props) => { {/* Dashboard tab: VMs and CTs */} - +
{online ? getCountValue(item, 'vmCount') ?? '-' : '-'}
- +
{online ? getCountValue(item, 'containerCount') ?? '-' : '-'} @@ -714,14 +715,14 @@ export const NodeSummaryTable: Component = (props) => { {/* Storage tab: Storage and Disks */} - +
{online ? getCountValue(item, 'storageCount') ?? '-' : '-'}
- +
{online ? getCountValue(item, 'diskCount') ?? '-' : '-'} @@ -732,7 +733,7 @@ export const NodeSummaryTable: Component = (props) => { {/* Backups tab: Backups */} - +
{online ? getCountValue(item, 'backupCount') ?? '-' : '-'} diff --git a/frontend-modern/src/components/shared/Sparkline.tsx b/frontend-modern/src/components/shared/Sparkline.tsx index bfce324..b4d8367 100644 --- a/frontend-modern/src/components/shared/Sparkline.tsx +++ b/frontend-modern/src/components/shared/Sparkline.tsx @@ -106,6 +106,22 @@ export const Sparkline: Component = (props) => { // Clear canvas ctx.clearRect(0, 0, w, h); + // Detect dark mode for reference line color + const isDark = document.documentElement.classList.contains('dark'); + + // Draw subtle reference lines at 25%, 50%, 75% to help understand scale + // These provide visual anchors so users can distinguish 17% from 70% + ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.06)'; + ctx.lineWidth = 1; + ctx.setLineDash([]); + [0.25, 0.5, 0.75].forEach(pct => { + const y = h - (pct * h); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + }); + if (!data || data.length === 0) { // No data - show empty state ctx.strokeStyle = '#d1d5db'; // gray-300 @@ -173,6 +189,17 @@ export const Sparkline: Component = (props) => { // Redraw when data or dimensions change createEffect(() => { + // Read reactive values to track dependencies + // This ensures the effect re-runs when data, metric, or dimensions change + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _data = props.data; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _metric = props.metric; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _w = width(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _h = height(); + // Unregister previous draw callback if it exists if (unregister) { unregister(); diff --git a/frontend-modern/src/stores/metricsHistory.ts b/frontend-modern/src/stores/metricsHistory.ts index 8725511..d36159d 100644 --- a/frontend-modern/src/stores/metricsHistory.ts +++ b/frontend-modern/src/stores/metricsHistory.ts @@ -52,6 +52,17 @@ const metricsHistoryMap = new Map(); // Track last sample time per resource to enforce sampling interval const lastSampleTimes = new Map(); +// Reactive version counter - increments when data is seeded, used to trigger component re-renders +import { createSignal } from 'solid-js'; +const [metricsVersion, setMetricsVersion] = createSignal(0); + +/** + * Get the current metrics version (for reactivity) + */ +export function getMetricsVersion(): number { + return metricsVersion(); +} + /** * Create a new ring buffer */ @@ -262,22 +273,6 @@ export async function seedFromBackend(range: TimeRange = '1h'): Promise { logger.info('[MetricsHistory] Seeding from backend', { range }); const response = await ChartsAPI.getCharts(range); - // Get current state to determine guest types - // Import dynamically to avoid circular dependency - const { getGlobalWebSocketStore } = await import('./websocket-global'); - const wsStore = getGlobalWebSocketStore(); - - // Wait a bit for WebSocket state to populate if it's empty - let state = wsStore?.state; - if (!state?.vms?.length && !state?.containers?.length) { - // Wait up to 2 seconds for state to populate - for (let i = 0; i < 4; i++) { - await new Promise(resolve => setTimeout(resolve, 500)); - state = wsStore?.state; - if (state?.vms?.length || state?.containers?.length) break; - } - } - const now = Date.now(); const cutoff = now - MAX_AGE_MS; let seededCount = 0; @@ -342,33 +337,43 @@ export async function seedFromBackend(range: TimeRange = '1h'): Promise { } }; - // Process VMs and containers + // Process VMs and containers using backend-provided guest types + // This avoids race conditions with WebSocket state if (response.data) { - // Build a map from ID -> type using WebSocket state - const guestTypeMap = new Map(); - if (state?.vms) { - for (const vm of state.vms) { - if (vm.id) guestTypeMap.set(vm.id, 'vm'); + // Use guestTypes from backend response (available since backend update) + // Falls back to WebSocket state for backwards compatibility + let guestTypeMap: Map; + + if (response.guestTypes) { + // Preferred: Use backend-provided types (no race condition) + guestTypeMap = new Map(Object.entries(response.guestTypes) as [string, 'vm' | 'container'][]); + logger.debug('[MetricsHistory] Using backend-provided guestTypes', { count: guestTypeMap.size }); + } else { + // Fallback: Try WebSocket state (legacy backends without guestTypes) + guestTypeMap = new Map(); + try { + const { getGlobalWebSocketStore } = await import('./websocket-global'); + const wsStore = getGlobalWebSocketStore(); + const state = wsStore?.state; + + if (state?.vms) { + for (const vm of state.vms) { + if (vm.id) guestTypeMap.set(vm.id, 'vm'); + } + } + if (state?.containers) { + for (const ct of state.containers) { + if (ct.id) guestTypeMap.set(ct.id, 'container'); + } + } + } catch { + logger.warn('[MetricsHistory] Failed to load WebSocket state for guest types'); } + logger.debug('[MetricsHistory] Using WebSocket state for guestTypes', { count: guestTypeMap.size }); } - if (state?.containers) { - for (const ct of state.containers) { - if (ct.id) guestTypeMap.set(ct.id, 'container'); - } - } - - const backendIds = Object.keys(response.data); - const stateIds = Array.from(guestTypeMap.keys()); - - // Debug: Find IDs in backend but not in state - const missingInState = backendIds.filter(id => !guestTypeMap.has(id)); - - console.log('[SPARKLINE DEBUG] Backend chart IDs:', backendIds); - console.log('[SPARKLINE DEBUG] State guest IDs:', stateIds); - console.log('[SPARKLINE DEBUG] Missing in state (will be wrong type):', missingInState); for (const [id, chartData] of Object.entries(response.data)) { - // Look up the guest type from state, default to 'vm' if unknown + // Look up the guest type, default to 'vm' if unknown const guestType = guestTypeMap.get(id) ?? 'vm'; const resourceKey = buildMetricKey(guestType, id); processChartData(resourceKey, chartData as ChartData); @@ -387,6 +392,9 @@ export async function seedFromBackend(range: TimeRange = '1h'): Promise { hasSeededFromBackend = true; logger.info('[MetricsHistory] Seeded from backend', { seededCount, totalResources: metricsHistoryMap.size }); + // Increment version to trigger reactive component updates + setMetricsVersion(v => v + 1); + // Save to localStorage saveToLocalStorage(); } catch (error) { diff --git a/internal/api/router.go b/internal/api/router.go index 4734d34..20028e3 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -2859,10 +2859,20 @@ func (r *Router) handleCharts(w http.ResponseWriter, req *http.Request) { } } + // Build guest types map for frontend to correctly identify VM vs Container + guestTypes := make(map[string]string) + for _, vm := range state.VMs { + guestTypes[vm.ID] = "vm" + } + for _, ct := range state.Containers { + guestTypes[ct.ID] = "container" + } + response := ChartResponse{ ChartData: chartData, NodeData: nodeData, StorageData: storageData, + GuestTypes: guestTypes, Timestamp: currentTime, Stats: ChartStats{ OldestDataTimestamp: oldestTimestamp, diff --git a/internal/api/types.go b/internal/api/types.go index c2e34f9..3c6cfd4 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -65,6 +65,7 @@ type ChartResponse struct { ChartData map[string]VMChartData `json:"data"` NodeData map[string]NodeChartData `json:"nodeData"` StorageData map[string]StorageChartData `json:"storageData"` + GuestTypes map[string]string `json:"guestTypes"` // Maps guest ID to type ("vm" or "container") Timestamp int64 `json:"timestamp"` Stats ChartStats `json:"stats"` }