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
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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') ?? '-' : '-'}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue