Additional updates

This commit is contained in:
rcourtman 2025-12-07 10:22:42 +00:00
parent 6988eb746a
commit f2e6927436
10 changed files with 127 additions and 73 deletions

View file

@ -31,6 +31,7 @@ export interface ChartsResponse {
data: Record<string, ChartData>; // VM/Container data keyed by ID
nodeData: Record<string, ChartData>; // Node data keyed by ID
storageData: Record<string, ChartData>; // Storage data keyed by ID
guestTypes?: Record<string, 'vm' | 'container'>; // Maps guest ID to type
timestamp: number;
stats: ChartStats;
}

View file

@ -1094,7 +1094,7 @@ export function Dashboard(props: DashboardProps) {
return (
<th
class={`py-2 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
class={`py-1 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
${isFirst() ? 'pl-4 pr-2 text-left' : 'px-2 text-center'}
${isSortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`}
style={{

View file

@ -2,7 +2,7 @@ import { Show, createMemo, createSignal } from 'solid-js';
import { Portal } from 'solid-js/web';
import { formatPercent } from '@/utils/format';
import { useMetricsViewMode } from '@/stores/metricsViewMode';
import { getMetricHistoryForRange } from '@/stores/metricsHistory';
import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory';
import { Sparkline } from '@/components/shared/Sparkline';
interface EnhancedCPUBarProps {
@ -38,7 +38,10 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) {
const { viewMode, timeRange } = useMetricsViewMode();
// 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());
});

View file

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

View file

@ -15,25 +15,6 @@ export const MetricsViewToggle: Component = () => {
return (
<div class="inline-flex items-center gap-2">
{/* Time Range Selector - only shown in sparklines mode */}
<Show when={viewMode() === 'sparklines'}>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
{TIME_RANGE_OPTIONS.map((option) => (
<button
type="button"
onClick={() => setTimeRange(option.value as TimeRange)}
class={`px-2 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${timeRange() === option.value
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
title={`Show last ${option.label} of data`}
>
{option.label}
</button>
))}
</div>
</Show>
{/* View Mode Toggle */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
@ -68,6 +49,25 @@ export const MetricsViewToggle: Component = () => {
Trends
</button>
</div>
{/* Time Range Selector - only shown in sparklines mode, appears to the right */}
<Show when={viewMode() === 'sparklines'}>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
{TIME_RANGE_OPTIONS.map((option) => (
<button
type="button"
onClick={() => setTimeRange(option.value as TimeRange)}
class={`px-2 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${timeRange() === option.value
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
title={`Show last ${option.label} of data`}
>
{option.label}
</button>
))}
</div>
</Show>
</div>
);
};

View file

@ -337,7 +337,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (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 (
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
@ -346,7 +347,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
<th
class={`${thClass} text-left pl-3`}
class={`${thClassBase} text-left pl-3`}
onClick={() => handleSort('name')}
>
{props.currentTab === 'backups' ? 'Node / PBS' : 'Node'} {renderSortIndicator('name')}
@ -481,7 +482,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
>
{/* Name */}
<td class={`pr-2 py-1 align-middle ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}>
<td class={`pr-2 py-0.5 align-middle ${showAlertHighlight() ? 'pl-4' : 'pl-3'}`}>
<div class="flex items-center gap-1.5">
<StatusDot
variant={statusIndicator().variant}
@ -543,7 +544,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</td>
{/* Uptime */}
<td class="px-2 py-1 align-middle">
<td class="px-2 py-0.5 align-middle">
<div class="flex justify-center">
<span
class={`text-xs whitespace-nowrap ${isPVEItem && (node?.uptime ?? 0) < 3600
@ -561,7 +562,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</td>
{/* CPU */}
<td class="px-2 py-1 align-middle" style={{ width: "200px", "min-width": "200px", "max-width": "200px" }}>
<td class="px-2 py-0.5 align-middle" style={{ width: "200px", "min-width": "200px", "max-width": "200px" }}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<MetricText value={cpuPercentValue} type="cpu" />
@ -589,7 +590,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</td>
{/* Memory */}
<td class="px-2 py-1 align-middle" style={{ width: "200px", "min-width": "200px", "max-width": "200px" }}>
<td class="px-2 py-0.5 align-middle" style={{ width: "200px", "min-width": "200px", "max-width": "200px" }}>
<Show when={isMobile()}>
<div class="md:hidden flex justify-center">
<MetricText value={memoryPercentValue} type="memory" />
@ -631,7 +632,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
</td>
{/* Disk */}
<td class="px-2 py-1 align-middle" style={{ width: "200px", "min-width": "200px", "max-width": "200px" }}>
<td class="px-2 py-0.5 align-middle" style={{ width: "200px", "min-width": "200px", "max-width": "200px" }}>
<ResponsiveMetricCell
value={diskPercentValue}
type="disk"
@ -644,7 +645,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
{/* Temperature */}
<Show when={hasAnyTemperatureData()}>
<td class="px-2 py-1 align-middle">
<td class="px-2 py-0.5 align-middle">
<div class="flex justify-center">
<Show
when={
@ -696,14 +697,14 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
{/* Dashboard tab: VMs and CTs */}
<Show when={props.currentTab === 'dashboard'}>
<td class="px-2 py-1 align-middle">
<td class="px-2 py-0.5 align-middle">
<div class="flex justify-center">
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
{online ? getCountValue(item, 'vmCount') ?? '-' : '-'}
</span>
</div>
</td>
<td class="px-2 py-1 align-middle">
<td class="px-2 py-0.5 align-middle">
<div class="flex justify-center">
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
{online ? getCountValue(item, 'containerCount') ?? '-' : '-'}
@ -714,14 +715,14 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
{/* Storage tab: Storage and Disks */}
<Show when={props.currentTab === 'storage'}>
<td class="px-2 py-1 align-middle">
<td class="px-2 py-0.5 align-middle">
<div class="flex justify-center">
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
{online ? getCountValue(item, 'storageCount') ?? '-' : '-'}
</span>
</div>
</td>
<td class="px-2 py-1 align-middle">
<td class="px-2 py-0.5 align-middle">
<div class="flex justify-center">
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
{online ? getCountValue(item, 'diskCount') ?? '-' : '-'}
@ -732,7 +733,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
{/* Backups tab: Backups */}
<Show when={props.currentTab === 'backups'}>
<td class="px-2 py-1 align-middle">
<td class="px-2 py-0.5 align-middle">
<div class="flex justify-center">
<span class={online ? 'text-xs text-gray-700 dark:text-gray-300' : 'text-xs text-gray-400 dark:text-gray-500'}>
{online ? getCountValue(item, 'backupCount') ?? '-' : '-'}

View file

@ -106,6 +106,22 @@ export const Sparkline: Component<SparklineProps> = (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<SparklineProps> = (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();

View file

@ -52,6 +52,17 @@ const metricsHistoryMap = new Map<string, RingBuffer>();
// Track last sample time per resource to enforce sampling interval
const lastSampleTimes = new Map<string, number>();
// 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<void> {
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<void> {
}
};
// 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<string, 'vm' | 'container'>();
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<string, 'vm' | 'container'>;
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<string, 'vm' | 'container'>();
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<void> {
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) {

View file

@ -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,

View file

@ -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"`
}