Additional updates
This commit is contained in:
parent
6988eb746a
commit
f2e6927436
10 changed files with 127 additions and 73 deletions
|
|
@ -31,6 +31,7 @@ export interface ChartsResponse {
|
||||||
data: Record<string, ChartData>; // VM/Container data keyed by ID
|
data: Record<string, ChartData>; // VM/Container data keyed by ID
|
||||||
nodeData: Record<string, ChartData>; // Node data keyed by ID
|
nodeData: Record<string, ChartData>; // Node data keyed by ID
|
||||||
storageData: Record<string, ChartData>; // Storage 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;
|
timestamp: number;
|
||||||
stats: ChartStats;
|
stats: ChartStats;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1094,7 +1094,7 @@ export function Dashboard(props: DashboardProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<th
|
<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'}
|
${isFirst() ? 'pl-4 pr-2 text-left' : 'px-2 text-center'}
|
||||||
${isSortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`}
|
${isSortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Show, createMemo, createSignal } from 'solid-js';
|
||||||
import { Portal } from 'solid-js/web';
|
import { Portal } from 'solid-js/web';
|
||||||
import { formatPercent } from '@/utils/format';
|
import { formatPercent } from '@/utils/format';
|
||||||
import { useMetricsViewMode } from '@/stores/metricsViewMode';
|
import { useMetricsViewMode } from '@/stores/metricsViewMode';
|
||||||
import { getMetricHistoryForRange } from '@/stores/metricsHistory';
|
import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory';
|
||||||
import { Sparkline } from '@/components/shared/Sparkline';
|
import { Sparkline } from '@/components/shared/Sparkline';
|
||||||
|
|
||||||
interface EnhancedCPUBarProps {
|
interface EnhancedCPUBarProps {
|
||||||
|
|
@ -38,7 +38,10 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) {
|
||||||
const { viewMode, timeRange } = useMetricsViewMode();
|
const { viewMode, timeRange } = useMetricsViewMode();
|
||||||
|
|
||||||
// Get metric history for sparkline
|
// Get metric history for sparkline
|
||||||
|
// Depends on metricsVersion to re-fetch when data is seeded (e.g., on time range change)
|
||||||
const metricHistory = createMemo(() => {
|
const metricHistory = createMemo(() => {
|
||||||
|
// Subscribe to version changes so we re-read when new data is seeded
|
||||||
|
getMetricsVersion();
|
||||||
if (viewMode() !== 'sparklines' || !props.resourceId) return [];
|
if (viewMode() !== 'sparklines' || !props.resourceId) return [];
|
||||||
return getMetricHistoryForRange(props.resourceId, timeRange());
|
return getMetricHistoryForRange(props.resourceId, timeRange());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Show, createMemo, createSignal, onMount, onCleanup } from 'solid-js';
|
import { Show, createMemo, createSignal, onMount, onCleanup } from 'solid-js';
|
||||||
import { Sparkline } from '@/components/shared/Sparkline';
|
import { Sparkline } from '@/components/shared/Sparkline';
|
||||||
import { useMetricsViewMode } from '@/stores/metricsViewMode';
|
import { useMetricsViewMode } from '@/stores/metricsViewMode';
|
||||||
import { getMetricHistoryForRange } from '@/stores/metricsHistory';
|
import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory';
|
||||||
|
|
||||||
interface MetricBarProps {
|
interface MetricBarProps {
|
||||||
value: number;
|
value: number;
|
||||||
|
|
@ -86,7 +86,10 @@ export function MetricBar(props: MetricBarProps) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get metric history for sparkline
|
// Get metric history for sparkline
|
||||||
|
// Depends on metricsVersion to re-fetch when data is seeded (e.g., on time range change)
|
||||||
const metricHistory = createMemo(() => {
|
const metricHistory = createMemo(() => {
|
||||||
|
// Subscribe to version changes so we re-read when new data is seeded
|
||||||
|
getMetricsVersion();
|
||||||
if (viewMode() !== 'sparklines' || !props.resourceId) return [];
|
if (viewMode() !== 'sparklines' || !props.resourceId) return [];
|
||||||
return getMetricHistoryForRange(props.resourceId, timeRange());
|
return getMetricHistoryForRange(props.resourceId, timeRange());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,25 +15,6 @@ export const MetricsViewToggle: Component = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="inline-flex items-center gap-2">
|
<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 */}
|
{/* View Mode Toggle */}
|
||||||
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
|
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
|
||||||
<button
|
<button
|
||||||
|
|
@ -68,6 +49,25 @@ export const MetricsViewToggle: Component = () => {
|
||||||
Trends
|
Trends
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -337,7 +337,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
return sortDirection() === 'asc' ? '▲' : '▼';
|
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 (
|
return (
|
||||||
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
|
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
|
||||||
|
|
@ -346,7 +347,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
<thead>
|
<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">
|
<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
|
<th
|
||||||
class={`${thClass} text-left pl-3`}
|
class={`${thClassBase} text-left pl-3`}
|
||||||
onClick={() => handleSort('name')}
|
onClick={() => handleSort('name')}
|
||||||
>
|
>
|
||||||
{props.currentTab === 'backups' ? 'Node / PBS' : 'Node'} {renderSortIndicator('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')}
|
onClick={() => props.onNodeClick(nodeId, isPVEItem ? 'pve' : 'pbs')}
|
||||||
>
|
>
|
||||||
{/* Name */}
|
{/* 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">
|
<div class="flex items-center gap-1.5">
|
||||||
<StatusDot
|
<StatusDot
|
||||||
variant={statusIndicator().variant}
|
variant={statusIndicator().variant}
|
||||||
|
|
@ -543,7 +544,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Uptime */}
|
{/* Uptime */}
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-2 py-0.5 align-middle">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<span
|
<span
|
||||||
class={`text-xs whitespace-nowrap ${isPVEItem && (node?.uptime ?? 0) < 3600
|
class={`text-xs whitespace-nowrap ${isPVEItem && (node?.uptime ?? 0) < 3600
|
||||||
|
|
@ -561,7 +562,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* CPU */}
|
{/* 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()}>
|
<Show when={isMobile()}>
|
||||||
<div class="md:hidden flex justify-center">
|
<div class="md:hidden flex justify-center">
|
||||||
<MetricText value={cpuPercentValue} type="cpu" />
|
<MetricText value={cpuPercentValue} type="cpu" />
|
||||||
|
|
@ -589,7 +590,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Memory */}
|
{/* 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()}>
|
<Show when={isMobile()}>
|
||||||
<div class="md:hidden flex justify-center">
|
<div class="md:hidden flex justify-center">
|
||||||
<MetricText value={memoryPercentValue} type="memory" />
|
<MetricText value={memoryPercentValue} type="memory" />
|
||||||
|
|
@ -631,7 +632,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Disk */}
|
{/* 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
|
<ResponsiveMetricCell
|
||||||
value={diskPercentValue}
|
value={diskPercentValue}
|
||||||
type="disk"
|
type="disk"
|
||||||
|
|
@ -644,7 +645,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
|
|
||||||
{/* Temperature */}
|
{/* Temperature */}
|
||||||
<Show when={hasAnyTemperatureData()}>
|
<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">
|
<div class="flex justify-center">
|
||||||
<Show
|
<Show
|
||||||
when={
|
when={
|
||||||
|
|
@ -696,14 +697,14 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
|
|
||||||
{/* Dashboard tab: VMs and CTs */}
|
{/* Dashboard tab: VMs and CTs */}
|
||||||
<Show when={props.currentTab === 'dashboard'}>
|
<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">
|
<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'}>
|
<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') ?? '-' : '-'}
|
{online ? getCountValue(item, 'vmCount') ?? '-' : '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-2 py-0.5 align-middle">
|
||||||
<div class="flex justify-center">
|
<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'}>
|
<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') ?? '-' : '-'}
|
{online ? getCountValue(item, 'containerCount') ?? '-' : '-'}
|
||||||
|
|
@ -714,14 +715,14 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
|
|
||||||
{/* Storage tab: Storage and Disks */}
|
{/* Storage tab: Storage and Disks */}
|
||||||
<Show when={props.currentTab === 'storage'}>
|
<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">
|
<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'}>
|
<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') ?? '-' : '-'}
|
{online ? getCountValue(item, 'storageCount') ?? '-' : '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-1 align-middle">
|
<td class="px-2 py-0.5 align-middle">
|
||||||
<div class="flex justify-center">
|
<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'}>
|
<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') ?? '-' : '-'}
|
{online ? getCountValue(item, 'diskCount') ?? '-' : '-'}
|
||||||
|
|
@ -732,7 +733,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
|
||||||
|
|
||||||
{/* Backups tab: Backups */}
|
{/* Backups tab: Backups */}
|
||||||
<Show when={props.currentTab === '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">
|
<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'}>
|
<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') ?? '-' : '-'}
|
{online ? getCountValue(item, 'backupCount') ?? '-' : '-'}
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,22 @@ export const Sparkline: Component<SparklineProps> = (props) => {
|
||||||
// Clear canvas
|
// Clear canvas
|
||||||
ctx.clearRect(0, 0, w, h);
|
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) {
|
if (!data || data.length === 0) {
|
||||||
// No data - show empty state
|
// No data - show empty state
|
||||||
ctx.strokeStyle = '#d1d5db'; // gray-300
|
ctx.strokeStyle = '#d1d5db'; // gray-300
|
||||||
|
|
@ -173,6 +189,17 @@ export const Sparkline: Component<SparklineProps> = (props) => {
|
||||||
|
|
||||||
// Redraw when data or dimensions change
|
// Redraw when data or dimensions change
|
||||||
createEffect(() => {
|
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
|
// Unregister previous draw callback if it exists
|
||||||
if (unregister) {
|
if (unregister) {
|
||||||
unregister();
|
unregister();
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,17 @@ const metricsHistoryMap = new Map<string, RingBuffer>();
|
||||||
// Track last sample time per resource to enforce sampling interval
|
// Track last sample time per resource to enforce sampling interval
|
||||||
const lastSampleTimes = new Map<string, number>();
|
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
|
* 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 });
|
logger.info('[MetricsHistory] Seeding from backend', { range });
|
||||||
const response = await ChartsAPI.getCharts(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 now = Date.now();
|
||||||
const cutoff = now - MAX_AGE_MS;
|
const cutoff = now - MAX_AGE_MS;
|
||||||
let seededCount = 0;
|
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) {
|
if (response.data) {
|
||||||
// Build a map from ID -> type using WebSocket state
|
// Use guestTypes from backend response (available since backend update)
|
||||||
const guestTypeMap = new Map<string, 'vm' | 'container'>();
|
// Falls back to WebSocket state for backwards compatibility
|
||||||
if (state?.vms) {
|
let guestTypeMap: Map<string, 'vm' | 'container'>;
|
||||||
for (const vm of state.vms) {
|
|
||||||
if (vm.id) guestTypeMap.set(vm.id, 'vm');
|
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)) {
|
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 guestType = guestTypeMap.get(id) ?? 'vm';
|
||||||
const resourceKey = buildMetricKey(guestType, id);
|
const resourceKey = buildMetricKey(guestType, id);
|
||||||
processChartData(resourceKey, chartData as ChartData);
|
processChartData(resourceKey, chartData as ChartData);
|
||||||
|
|
@ -387,6 +392,9 @@ export async function seedFromBackend(range: TimeRange = '1h'): Promise<void> {
|
||||||
hasSeededFromBackend = true;
|
hasSeededFromBackend = true;
|
||||||
logger.info('[MetricsHistory] Seeded from backend', { seededCount, totalResources: metricsHistoryMap.size });
|
logger.info('[MetricsHistory] Seeded from backend', { seededCount, totalResources: metricsHistoryMap.size });
|
||||||
|
|
||||||
|
// Increment version to trigger reactive component updates
|
||||||
|
setMetricsVersion(v => v + 1);
|
||||||
|
|
||||||
// Save to localStorage
|
// Save to localStorage
|
||||||
saveToLocalStorage();
|
saveToLocalStorage();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -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{
|
response := ChartResponse{
|
||||||
ChartData: chartData,
|
ChartData: chartData,
|
||||||
NodeData: nodeData,
|
NodeData: nodeData,
|
||||||
StorageData: storageData,
|
StorageData: storageData,
|
||||||
|
GuestTypes: guestTypes,
|
||||||
Timestamp: currentTime,
|
Timestamp: currentTime,
|
||||||
Stats: ChartStats{
|
Stats: ChartStats{
|
||||||
OldestDataTimestamp: oldestTimestamp,
|
OldestDataTimestamp: oldestTimestamp,
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ type ChartResponse struct {
|
||||||
ChartData map[string]VMChartData `json:"data"`
|
ChartData map[string]VMChartData `json:"data"`
|
||||||
NodeData map[string]NodeChartData `json:"nodeData"`
|
NodeData map[string]NodeChartData `json:"nodeData"`
|
||||||
StorageData map[string]StorageChartData `json:"storageData"`
|
StorageData map[string]StorageChartData `json:"storageData"`
|
||||||
|
GuestTypes map[string]string `json:"guestTypes"` // Maps guest ID to type ("vm" or "container")
|
||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
Stats ChartStats `json:"stats"`
|
Stats ChartStats `json:"stats"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue