Remove all chart functionality from the codebase

- Remove chart.js dependency from package.json
- Delete all chart-related components (DynamicChart, Sparkline)
- Remove chart stores (charts.ts, chartDimensions.ts)
- Remove chart types and API endpoints
- Clean up Dashboard and GuestRow components to remove chart UI
- Remove chart-related constants and configurations
- Simplify Storage component to only show progress bars

This completes the removal of all chart functionality as requested.
The application now uses only MetricBar components for visualization.
This commit is contained in:
Pulse Monitor 2025-08-02 07:34:14 +00:00
parent b1d79d8e25
commit 5d229bd7f9
12 changed files with 223 additions and 1829 deletions

View file

@ -12,7 +12,6 @@
},
"dependencies": {
"@solidjs/router": "^0.10.0",
"chart.js": "^4.4.0",
"solid-js": "^1.8.0"
},
"devDependencies": {

View file

@ -1,34 +0,0 @@
import type { ChartData, NodeChartData, StorageChartData, ChartStats } from '@/types/charts';
export interface ChartsResponse {
data: ChartData;
nodeData: NodeChartData;
storageData: StorageChartData;
timestamp: number;
stats: ChartStats;
}
export class ChartsAPI {
private static baseUrl = '/api/charts';
static async getCharts(timeRange: string = '1h'): Promise<ChartsResponse> {
const response = await fetch(`${this.baseUrl}?range=${timeRange}`);
if (!response.ok) {
throw new Error('Failed to fetch chart data');
}
return response.json();
}
static async getStorageCharts(rangeMinutes: number = 60): Promise<Record<string, {
usage: Array<{timestamp: number; value: number}>;
used: Array<{timestamp: number; value: number}>;
total: Array<{timestamp: number; value: number}>;
avail: Array<{timestamp: number; value: number}>;
}>> {
const response = await fetch(`/api/storage-charts?range=${rangeMinutes}`);
if (!response.ok) {
throw new Error('Failed to fetch storage chart data');
}
return response.json();
}
}

View file

@ -27,26 +27,6 @@ export class MonitoringAPI {
return response.json();
}
static async getChartData(params: {
id: string;
type: 'guest' | 'node' | 'storage';
metric: 'cpu' | 'memory' | 'disk' | 'network';
timeRange: '1h' | '6h' | '24h' | '7d' | '30d';
}): Promise<{
timestamps: string[];
values: number[];
unit: string;
}> {
const queryParams = new URLSearchParams(params);
const response = await fetch(`${this.baseUrl}/charts?${queryParams}`);
if (!response.ok) {
throw new Error('Failed to fetch chart data');
}
return response.json();
}
static async exportDiagnostics(): Promise<Blob> {
const response = await fetch(`${this.baseUrl}/diagnostics/export`);
if (!response.ok) {

View file

@ -1,12 +1,10 @@
import { createSignal, createMemo, createEffect, For, Show, onCleanup } from 'solid-js';
import { createSignal, createMemo, createEffect, For, Show } from 'solid-js';
import type { VM, Container, Node } from '@/types/api';
import { GuestRow } from './GuestRow';
import NodeCard from './NodeCard';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { fetchChartData, shouldFetchChartData } from '@/stores/charts';
import { createTooltipSystem, showTooltip, hideTooltip } from '@/components/shared/Tooltip';
import { POLLING_INTERVALS } from '@/constants';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { parseFilterStack, evaluateFilterStack } from '@/utils/searchQuery';
@ -19,8 +17,6 @@ interface DashboardProps {
type ViewMode = 'all' | 'vm' | 'lxc';
type StatusMode = 'all' | 'running' | 'stopped';
type DisplayMode = 'standard' | 'charts';
type TimeRange = '5m' | '15m' | '30m' | '1h' | '4h' | '12h' | '24h' | '7d';
export function Dashboard(props: DashboardProps) {
@ -38,19 +34,6 @@ export function Dashboard(props: DashboardProps) {
(storedStatusMode === 'all' || storedStatusMode === 'running' || storedStatusMode === 'stopped') ? storedStatusMode : 'all'
);
const storedDisplayMode = localStorage.getItem('dashboardDisplayMode');
const [displayMode, setDisplayMode] = createSignal<DisplayMode>(
(storedDisplayMode === 'standard' || storedDisplayMode === 'charts') ? storedDisplayMode : 'standard'
);
const storedTimeRange = localStorage.getItem('dashboardTimeRange');
const [timeRange, setTimeRange] = createSignal<TimeRange>(
(storedTimeRange === '5m' || storedTimeRange === '15m' || storedTimeRange === '30m' ||
storedTimeRange === '1h' || storedTimeRange === '4h' || storedTimeRange === '12h' ||
storedTimeRange === '24h' || storedTimeRange === '7d') ? storedTimeRange : '1h'
);
const [showCharts, setShowCharts] = createSignal(localStorage.getItem('dashboardShowCharts') === 'true');
const [showFilters, setShowFilters] = createSignal(
localStorage.getItem('dashboardShowFilters') !== null
? localStorage.getItem('dashboardShowFilters') === 'true'
@ -73,37 +56,12 @@ export function Dashboard(props: DashboardProps) {
localStorage.setItem('dashboardStatusMode', statusMode());
});
createEffect(() => {
localStorage.setItem('dashboardDisplayMode', displayMode());
});
createEffect(() => {
localStorage.setItem('dashboardTimeRange', timeRange());
});
createEffect(() => {
localStorage.setItem('dashboardShowCharts', showCharts().toString());
});
createEffect(() => {
localStorage.setItem('dashboardShowFilters', showFilters().toString());
});
// Chart update interval
let chartUpdateInterval: number | undefined;
// Track if chart data is loading (no longer used for blocking)
// Preload chart data when component mounts
createEffect(() => {
// Preload chart data immediately on mount for instant charts
if (shouldFetchChartData()) {
fetchChartData(timeRange()).catch(() => {
// Silently handle errors during preload
});
}
});
// Sort handler
const handleSort = (key: keyof (VM | Container)) => {
@ -139,10 +97,9 @@ export function Dashboard(props: DashboardProps) {
// Escape key behavior
if (e.key === 'Escape') {
// First check if we have search/filters to clear
if (search().trim() || showCharts() || sortKey() !== 'vmid' || sortDirection() !== 'asc') {
if (search().trim() || sortKey() !== 'vmid' || sortDirection() !== 'asc') {
// Clear search and reset filters
setSearch('');
setShowCharts(false);
setSortKey('vmid');
setSortDirection('asc');
@ -173,40 +130,6 @@ export function Dashboard(props: DashboardProps) {
return () => document.removeEventListener('keydown', handleKeyDown);
});
// Fetch chart data when in charts mode or time range changes
createEffect(() => {
if (displayMode() === 'charts') {
// Fetch data without blocking the UI
fetchChartData(timeRange());
// Setup periodic updates
chartUpdateInterval = window.setInterval(() => {
if (shouldFetchChartData()) {
fetchChartData(timeRange());
}
}, POLLING_INTERVALS.CHART_UPDATE);
} else {
// Clear interval when not in charts mode
if (chartUpdateInterval) {
window.clearInterval(chartUpdateInterval);
chartUpdateInterval = undefined;
}
}
});
// Update charts when time range changes
createEffect(() => {
if (displayMode() === 'charts') {
fetchChartData(timeRange());
}
});
// Cleanup on unmount
onCleanup(() => {
if (chartUpdateInterval) {
window.clearInterval(chartUpdateInterval);
}
});
// Combine VMs and containers into a single list
const allGuests = createMemo(() => {
@ -403,7 +326,7 @@ export function Dashboard(props: DashboardProps) {
<line x1="17" y1="16" x2="23" y2="16"></line>
</svg>
Filters & Search
<Show when={search() || viewMode() !== 'all' || statusMode() !== 'all' || showCharts()}>
<Show when={search() || viewMode() !== 'all' || statusMode() !== 'all'}>
<span class="text-xs bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded-full font-medium">
Active
</span>
@ -481,7 +404,6 @@ export function Dashboard(props: DashboardProps) {
<button
onClick={() => {
setSearch('');
setShowCharts(false);
setSortKey('vmid');
setSortDirection('asc');
setViewMode('all');
@ -505,39 +427,6 @@ export function Dashboard(props: DashboardProps) {
{/* Filters Row */}
<div class="flex flex-col sm:flex-row gap-2">
{/* View Mode Toggle */}
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 whitespace-nowrap">Display:</span>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
onClick={() => {
setDisplayMode('standard');
setShowCharts(false);
}}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
displayMode() === 'standard' ? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Standard
</button>
<button
onClick={() => {
setDisplayMode('charts');
setShowCharts(true);
}}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
showCharts()
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Charts
</button>
</div>
</div>
<div class="h-6 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Type Filter */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
@ -608,32 +497,6 @@ export function Dashboard(props: DashboardProps) {
Stopped
</button>
</div>
{/* Chart Time Range Controls - Show when charts enabled */}
<Show when={showCharts()}>
<>
<div class="h-6 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 whitespace-nowrap">Time Range:</span>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<For each={['5m', '15m', '30m', '1h', '4h', '12h'] as TimeRange[]}>
{(range) => (
<button
onClick={() => setTimeRange(range)}
class={`px-2 py-1.5 text-xs font-medium rounded-md transition-all ${
timeRange() === range
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
{range}
</button>
)}
</For>
</div>
</div>
</>
</Show>
</div>
</div>
</div>
@ -793,8 +656,6 @@ export function Dashboard(props: DashboardProps) {
<GuestRow
guest={guest}
showNode={false}
displayMode={displayMode()}
timeRange={timeRange()}
alertStyles={getAlertStyles(guest.id || `${guest.instance}-${guest.name}-${guest.vmid}`, activeAlerts)}
/>
</ComponentErrorBoundary>

View file

@ -4,8 +4,6 @@ import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndica
import { formatBytes, formatUptime } from '@/utils/format';
import { MetricBar } from './MetricBar';
import { IOMetric } from './IOMetric';
import { DynamicChart } from '@/components/shared/DynamicChart';
import { getGuestChartData } from '@/stores/charts';
import { getResourceAlerts } from '@/utils/alerts';
import { useWebSocket } from '@/App';
@ -16,15 +14,10 @@ const isVM = (guest: Guest): guest is VM => {
return guest.type === 'qemu';
};
type DisplayMode = 'standard' | 'charts' | 'alerts';
type TimeRange = '5m' | '15m' | '30m' | '1h' | '4h' | '12h' | '24h' | '7d';
interface GuestRowProps {
guest: Guest;
showNode?: boolean;
displayMode?: DisplayMode;
timeRange?: TimeRange;
chartDataLoading?: boolean;
alertStyles?: {
rowClass: string;
indicatorClass: string;
@ -57,20 +50,6 @@ export function GuestRow(props: GuestRowProps) {
return getResourceAlerts(guestId, activeAlerts);
});
// Get guest ID for chart data lookup - must match the format from the API
const guestId = createMemo(() => props.guest.id || `${props.guest.instance}-${props.guest.name}-${props.guest.vmid}`);
// Get chart data from store
const cpuHistory = createMemo(() => getGuestChartData(guestId(), 'cpu'));
const memHistory = createMemo(() => getGuestChartData(guestId(), 'memory'));
const diskHistory = createMemo(() => getGuestChartData(guestId(), 'disk'));
const diskReadHistory = createMemo(() => getGuestChartData(guestId(), 'diskread'));
const diskWriteHistory = createMemo(() => getGuestChartData(guestId(), 'diskwrite'));
const netInHistory = createMemo(() => getGuestChartData(guestId(), 'netin'));
const netOutHistory = createMemo(() => getGuestChartData(guestId(), 'netout'));
// Legacy fetch code removed - chart data is now fetched globally by the Dashboard
// Get row styling - include alert styles if present
const rowClass = createMemo(() => {
@ -141,234 +120,53 @@ export function GuestRow(props: GuestRowProps) {
{/* CPU */}
<td class="p-1 px-2 w-[140px]">
<Show when={props.displayMode === 'alerts'}>
<div class="flex items-center gap-2">
<input
type="range"
min="0"
max="100"
value={cpuPercent()}
disabled
class="w-16 h-1.5 opacity-50"
style={`background: linear-gradient(to right, #3b82f6 0%, #3b82f6 ${cpuPercent()}%, #e5e7eb ${cpuPercent()}%, #e5e7eb 100%)`}
/>
<input
type="number"
min="0"
max="100"
placeholder="0"
class="w-12 px-1 py-0.5 text-xs border rounded dark:bg-gray-700 dark:border-gray-600"
title="Set CPU alert threshold for this guest"
/>
</div>
</Show>
<Show when={props.displayMode === 'charts' && isRunning()}>
<DynamicChart
data={cpuHistory()}
metric="cpu"
guestId={guestId()}
chartType="mini"
paddingAdjustment={8}
forceGray={true}
/>
</Show>
<Show when={props.displayMode === 'standard'}>
<MetricBar
value={cpuPercent()}
label={`${cpuPercent().toFixed(0)}%`}
sublabel={props.guest.cpus ? `${(props.guest.cpu * props.guest.cpus).toFixed(1)}/${props.guest.cpus} cores` : undefined}
type="cpu"
/>
</Show>
<MetricBar
value={cpuPercent()}
label={`${cpuPercent().toFixed(0)}%`}
sublabel={props.guest.cpus ? `${(props.guest.cpu * props.guest.cpus).toFixed(1)}/${props.guest.cpus} cores` : undefined}
type="cpu"
/>
</td>
{/* Memory */}
<td class="p-1 px-2 w-[140px]">
<Show when={props.displayMode === 'alerts'}>
<div class="flex items-center gap-2">
<input
type="range"
min="0"
max="100"
value={memPercent()}
disabled
class="w-16 h-1.5 opacity-50"
style={`background: linear-gradient(to right, #8b5cf6 0%, #8b5cf6 ${memPercent()}%, #e5e7eb ${memPercent()}%, #e5e7eb 100%)`}
/>
<input
type="number"
min="0"
max="100"
placeholder="0"
class="w-12 px-1 py-0.5 text-xs border rounded dark:bg-gray-700 dark:border-gray-600"
title="Set memory alert threshold for this guest"
/>
</div>
</Show>
<Show when={props.displayMode === 'charts' && isRunning()}>
<DynamicChart
data={memHistory()}
metric="memory"
guestId={guestId()}
chartType="mini"
paddingAdjustment={8}
filled
forceGray={true}
/>
</Show>
<Show when={props.displayMode === 'standard'}>
<MetricBar
value={memPercent()}
label={`${memPercent().toFixed(0)}%`}
sublabel={props.guest.memory ? `${formatBytes(props.guest.memory.used)}/${formatBytes(props.guest.memory.total)}` : undefined}
type="memory"
/>
</Show>
<MetricBar
value={memPercent()}
label={`${memPercent().toFixed(0)}%`}
sublabel={props.guest.memory ? `${formatBytes(props.guest.memory.used)}/${formatBytes(props.guest.memory.total)}` : undefined}
type="memory"
/>
</td>
{/* Disk */}
<td class="p-1 px-2 w-[140px]">
<Show when={props.displayMode === 'alerts' && props.guest.disk && props.guest.disk.total > 0}>
<div class="flex items-center gap-2">
<input
type="range"
min="0"
max="100"
value={diskPercent()}
disabled
class="w-16 h-1.5 opacity-50"
style={`background: linear-gradient(to right, #f59e0b 0%, #f59e0b ${diskPercent()}%, #e5e7eb ${diskPercent()}%, #e5e7eb 100%)`}
/>
<input
type="number"
min="0"
max="100"
placeholder="0"
class="w-12 px-1 py-0.5 text-xs border rounded dark:bg-gray-700 dark:border-gray-600"
title="Set disk alert threshold for this guest"
/>
</div>
</Show>
<Show when={props.displayMode === 'charts' && isRunning() && props.guest.disk && props.guest.disk.total > 0}>
<DynamicChart
data={diskHistory()}
metric="disk"
guestId={guestId()}
chartType="mini"
paddingAdjustment={8}
filled
forceGray={true}
<Show
when={props.guest.disk && props.guest.disk.total > 0}
fallback={<span class="text-gray-400 text-sm">-</span>}
>
<MetricBar
value={diskPercent()}
label={`${diskPercent().toFixed(0)}%`}
sublabel={props.guest.disk ? `${formatBytes(props.guest.disk.used)}/${formatBytes(props.guest.disk.total)}` : undefined}
type="disk"
/>
</Show>
<Show when={props.displayMode === 'standard'}>
<Show
when={props.guest.disk && props.guest.disk.total > 0}
fallback={<span class="text-gray-400 text-sm">-</span>}
>
<MetricBar
value={diskPercent()}
label={`${diskPercent().toFixed(0)}%`}
sublabel={props.guest.disk ? `${formatBytes(props.guest.disk.used)}/${formatBytes(props.guest.disk.total)}` : undefined}
type="disk"
/>
</Show>
</Show>
</td>
{/* Disk I/O */}
<td class="p-1 px-2">
<Show when={props.displayMode === 'alerts'}>
<select class="w-full px-1 py-0.5 text-xs border rounded dark:bg-gray-700 dark:border-gray-600">
<option value="0">Off</option>
<option value="1">1 MB/s</option>
<option value="10">10 MB/s</option>
<option value="100">100 MB/s</option>
</select>
</Show>
<Show when={props.displayMode === 'charts' && isRunning()}>
<DynamicChart
data={diskReadHistory()}
metric="diskread"
guestId={guestId()}
chartType="sparkline"
paddingAdjustment={8}
forceGray={true}
/>
</Show>
<Show when={props.displayMode === 'standard'}>
<IOMetric value={props.guest.diskRead} disabled={!isRunning()} />
</Show>
<IOMetric value={props.guest.diskRead} disabled={!isRunning()} />
</td>
<td class="p-1 px-2">
<Show when={props.displayMode === 'alerts'}>
<select class="w-full px-1 py-0.5 text-xs border rounded dark:bg-gray-700 dark:border-gray-600">
<option value="0">Off</option>
<option value="1">1 MB/s</option>
<option value="10">10 MB/s</option>
<option value="100">100 MB/s</option>
</select>
</Show>
<Show when={props.displayMode === 'charts' && isRunning()}>
<DynamicChart
data={diskWriteHistory()}
metric="diskwrite"
guestId={guestId()}
chartType="sparkline"
paddingAdjustment={8}
forceGray={true}
/>
</Show>
<Show when={props.displayMode === 'standard'}>
<IOMetric value={props.guest.diskWrite} disabled={!isRunning()} />
</Show>
<IOMetric value={props.guest.diskWrite} disabled={!isRunning()} />
</td>
{/* Network I/O */}
<td class="p-1 px-2">
<Show when={props.displayMode === 'alerts'}>
<select class="w-full px-1 py-0.5 text-xs border rounded dark:bg-gray-700 dark:border-gray-600">
<option value="0">Off</option>
<option value="1">1 MB/s</option>
<option value="10">10 MB/s</option>
<option value="100">100 MB/s</option>
</select>
</Show>
<Show when={props.displayMode === 'charts' && isRunning()}>
<DynamicChart
data={netInHistory()}
metric="netin"
guestId={guestId()}
chartType="sparkline"
paddingAdjustment={8}
forceGray={true}
/>
</Show>
<Show when={props.displayMode === 'standard'}>
<IOMetric value={props.guest.networkIn} disabled={!isRunning()} />
</Show>
<IOMetric value={props.guest.networkIn} disabled={!isRunning()} />
</td>
<td class="p-1 px-2">
<Show when={props.displayMode === 'alerts'}>
<select class="w-full px-1 py-0.5 text-xs border rounded dark:bg-gray-700 dark:border-gray-600">
<option value="0">Off</option>
<option value="1">1 MB/s</option>
<option value="10">10 MB/s</option>
<option value="100">100 MB/s</option>
</select>
</Show>
<Show when={props.displayMode === 'charts' && isRunning()}>
<DynamicChart
data={netOutHistory()}
metric="netout"
guestId={guestId()}
chartType="sparkline"
paddingAdjustment={8}
forceGray={true}
/>
</Show>
<Show when={props.displayMode === 'standard'}>
<IOMetric value={props.guest.networkOut} disabled={!isRunning()} />
</Show>
<IOMetric value={props.guest.networkOut} disabled={!isRunning()} />
</td>
</tr>

View file

@ -1,289 +0,0 @@
import { Component, createMemo, createSignal, onCleanup } from 'solid-js';
import { CHART_CONFIG, processChartData, formatTimeAgo, formatChartValue } from '@/stores/charts';
import type { ChartDataPoint } from '@/stores/charts';
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
interface SparklineProps {
data: number[] | ChartDataPoint[];
width?: number;
height?: number;
color?: string;
strokeWidth?: number;
filled?: boolean;
metric?: string;
guestId?: string;
chartType?: 'mini' | 'sparkline' | 'storage';
showTooltip?: boolean;
responsive?: boolean;
forceGray?: boolean;
}
const Sparkline: Component<SparklineProps> = (props) => {
const chartType = () => props.chartType || 'mini';
const config = () => CHART_CONFIG[chartType()];
const width = () => props.width || config().width;
const height = () => props.height || config().height;
const strokeWidth = () => props.strokeWidth || CHART_CONFIG.strokeWidth;
const metric = () => props.metric || 'generic';
let svgRef: SVGSVGElement | undefined;
let tooltipTimeout: number | undefined;
const [isHovering, setIsHovering] = createSignal(false);
const [hoverPoint, setHoverPoint] = createSignal<{x: number, y: number, value: number, timestamp?: number} | null>(null);
const [, setTooltipContent] = createSignal<string>('');
const [, setTooltipPosition] = createSignal<{x: number, y: number}>({x: 0, y: 0});
// Convert data to ChartDataPoint format if needed
const chartData = createMemo(() => {
const rawData = props.data || [];
// If already ChartDataPoint format, use as-is
if (rawData.length > 0 && typeof rawData[0] === 'object' && 'timestamp' in rawData[0]) {
return rawData as ChartDataPoint[];
}
// Convert number array to ChartDataPoint array
return (rawData as number[]).map((value, index) => ({
timestamp: Date.now() - (rawData.length - index - 1) * 5000, // Assume 5s intervals
value
}));
});
// Process data with adaptive sampling if needed
const processedData = createMemo(() => {
const data = chartData();
// Skip processing if we don't have much data
if (data.length <= 10) return data;
if (!props.guestId || !props.metric) return data;
// For storage charts, just return the data as-is
const type = chartType();
if (type === 'storage') return data;
return processChartData(data, type as 'mini' | 'sparkline', props.guestId, props.metric);
});
// Get smart color based on data
const smartColor = createMemo(() => {
if (props.color) return props.color;
// Force gray for dashboard charts
if (props.forceGray) {
const isDarkMode = document.documentElement.classList.contains('dark');
return isDarkMode ? '#6b7280' : '#d1d5db'; // gray-500/gray-300
}
const data = processedData();
const values = data.map(d => d.value);
return CHART_CONFIG.getSmartColor(values, metric());
});
// Generate SVG path from data points
const pathData = createMemo(() => {
const data = processedData();
if (data.length < 2) return { line: '', area: '', points: [], minValue: 0, maxValue: 0 };
const w = width();
const h = height();
const padding = config().padding;
// Extract values for scaling
const values = data.map(d => d.value);
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
// const valueRange = maxValue - minValue; // unused
// Smart scaling: include 0 for percentage metrics if low values
let scalingMin = minValue;
let scalingMax = maxValue;
const isPercentageMetric = metric() === 'cpu' || metric() === 'memory' || metric() === 'disk';
if (isPercentageMetric && minValue < 20) {
scalingMin = 0;
} else if (!isPercentageMetric && minValue < maxValue * 0.01) {
scalingMin = 0;
}
const scalingRange = scalingMax - scalingMin || 1;
// Calculate dimensions
const chartAreaWidth = w - 2 * padding;
const chartAreaHeight = h - 2 * padding;
const yScale = chartAreaHeight / scalingRange;
const xStep = chartAreaWidth / Math.max(1, data.length - 1);
// Generate points with coordinates
const points = data.map((point, i) => {
const x = padding + i * xStep;
const y = h - padding - (scalingRange > 0 ? (point.value - scalingMin) * yScale : chartAreaHeight / 2);
return { x, y, value: point.value, timestamp: point.timestamp };
});
// Build line path - simplified without toFixed for performance
const lineData = points.map((point, index) =>
`${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`
).join(' ');
const baseY = h - padding;
const areaData = props.filled ?
`M ${points[0].x} ${baseY} ` +
points.map(p => `L ${p.x} ${p.y}`).join(' ') +
` L ${points[points.length - 1].x} ${baseY} Z`
: '';
return { line: lineData, area: areaData, points, minValue, maxValue };
});
// Generate stable gradient ID based on guest/metric
const gradientId = createMemo(() => {
if (props.guestId && props.metric) {
return `gradient-${props.guestId}-${props.metric}`.replace(/[^a-zA-Z0-9-]/g, '-');
}
return `gradient-${Math.random().toString(36).substr(2, 9)}`;
});
// Handle mouse events
const handleMouseMove = (e: MouseEvent) => {
if (!svgRef || !props.showTooltip) return;
const rect = svgRef.getBoundingClientRect();
const x = (e.clientX - rect.left) * (width() / rect.width);
const data = pathData();
if (!data.points || data.points.length === 0) return;
// Find closest point
const chartAreaWidth = width() - 2 * config().padding;
const relativeX = Math.max(0, Math.min(chartAreaWidth, x - config().padding));
let closestIndex = 0;
let closestDistance = Infinity;
data.points.forEach((point, i) => {
const distance = Math.abs(point.x - config().padding - relativeX);
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = i;
}
});
const point = data.points[closestIndex];
if (point) {
setHoverPoint(point);
// Update tooltip content
const value = formatChartValue(point.value, metric());
const timeAgo = point.timestamp ? formatTimeAgo(point.timestamp) : '';
let content = `${value}`;
if (timeAgo) content += `<br><small>${timeAgo}</small>`;
// Add range info
if (data.minValue !== data.maxValue) {
const minFormatted = formatChartValue(data.minValue, metric());
const maxFormatted = formatChartValue(data.maxValue, metric());
content += `<br><small>Range: ${minFormatted} - ${maxFormatted}</small>`;
}
setTooltipContent(content);
setTooltipPosition({ x: e.clientX, y: e.clientY });
// Show tooltip using global system
showTooltip(content, e.clientX, e.clientY);
}
};
const handleMouseEnter = () => {
setIsHovering(true);
};
const handleMouseLeave = () => {
setIsHovering(false);
setHoverPoint(null);
if (tooltipTimeout) {
clearTimeout(tooltipTimeout);
}
// Hide tooltip using global system
hideTooltip();
};
// Cleanup on unmount
onCleanup(() => {
if (tooltipTimeout) {
clearTimeout(tooltipTimeout);
}
});
return (
<svg
ref={svgRef}
width={width()}
height={height()}
class="sparkline"
style={{
display: 'block',
cursor: props.showTooltip ? 'crosshair' : 'default'
}}
onMouseMove={handleMouseMove}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{props.filled && (
<defs>
<linearGradient id={gradientId()} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style={`stop-color:${smartColor()};stop-opacity:0.3`} />
<stop offset="100%" style={`stop-color:${smartColor()};stop-opacity:0.1`} />
</linearGradient>
</defs>
)}
{/* Area fill (if enabled) */}
{props.filled && pathData().area && (
<path
d={pathData().area}
fill={`url(#${gradientId()})`}
/>
)}
{/* Main line */}
<path
d={pathData().line}
fill="none"
stroke={isHovering() ? (document.documentElement.classList.contains('dark') ? '#ffffff' : '#000000') : smartColor()}
stroke-width={strokeWidth()}
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
{/* Hover indicator */}
{isHovering() && hoverPoint() && (
<g class="hover-indicator-group">
<circle
cx={hoverPoint()!.x}
cy={hoverPoint()!.y}
r="2"
fill={document.documentElement.classList.contains('dark') ? '#000000' : '#ffffff'}
stroke={document.documentElement.classList.contains('dark') ? '#ffffff' : '#000000'}
stroke-width="1.5"
style={{ 'pointer-events': 'none' }}
/>
</g>
)}
{/* Invisible overlay for better mouse detection */}
{props.showTooltip && (
<rect
width={width()}
height={height()}
fill="transparent"
style={{ cursor: 'crosshair' }}
/>
)}
</svg>
);
};
export default Sparkline;

View file

@ -1,12 +1,9 @@
import { Component, For, Show, createSignal, createMemo, createEffect, onCleanup } from 'solid-js';
import { Component, For, Show, createSignal, createMemo, createEffect } from 'solid-js';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndicators';
import { formatBytes } from '@/utils/format';
import { DynamicChart } from '@/components/shared/DynamicChart';
import { createTooltipSystem } from '@/components/shared/Tooltip';
import { fetchChartData, getStorageChartData, shouldFetchChartData } from '@/stores/charts';
import { POLLING_INTERVALS } from '@/constants';
import type { Storage as StorageType } from '@/types/api';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
@ -14,8 +11,6 @@ import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
const Storage: Component = () => {
const { state, connected, activeAlerts } = useWebSocket();
const [viewMode, setViewMode] = createSignal<'node' | 'storage'>('node');
const [chartsEnabled, setChartsEnabled] = createSignal(false);
const [timeRange, setTimeRange] = createSignal<string>('1h');
const [searchTerm, setSearchTerm] = createSignal('');
const [showFilters, setShowFilters] = createSignal(
localStorage.getItem('storageShowFilters') !== null
@ -26,25 +21,10 @@ const Storage: Component = () => {
// Create tooltip system
const TooltipComponent = createTooltipSystem();
// Time range options - match dashboard format
const timeRanges = [
{ value: '5m', label: '5m' },
{ value: '15m', label: '15m' },
{ value: '30m', label: '30m' },
{ value: '1h', label: '1h' },
{ value: '4h', label: '4h' },
{ value: '12h', label: '12h' },
{ value: '24h', label: '24h' },
{ value: '7d', label: '7d' }
];
// Load preferences from localStorage
createEffect(() => {
const savedViewMode = localStorage.getItem('storageViewMode');
if (savedViewMode === 'storage') setViewMode('storage');
const savedChartsEnabled = localStorage.getItem('storageChartsEnabled');
if (savedChartsEnabled === 'true') setChartsEnabled(true);
});
// Save preferences to localStorage
@ -52,52 +32,10 @@ const Storage: Component = () => {
localStorage.setItem('storageViewMode', viewMode());
});
createEffect(() => {
localStorage.setItem('storageChartsEnabled', chartsEnabled().toString());
});
createEffect(() => {
localStorage.setItem('storageShowFilters', showFilters().toString());
});
// Chart update interval
let chartUpdateInterval: number | undefined;
// Fetch chart data when charts are enabled or time range changes
createEffect(() => {
if (chartsEnabled() && connected()) {
// Initial fetch
fetchChartData(timeRange());
// Setup periodic updates
chartUpdateInterval = window.setInterval(() => {
if (shouldFetchChartData()) {
fetchChartData(timeRange());
}
}, POLLING_INTERVALS.CHART_UPDATE);
} else {
// Clear interval when charts not enabled
if (chartUpdateInterval) {
window.clearInterval(chartUpdateInterval);
chartUpdateInterval = undefined;
}
}
});
// Update charts when time range changes
createEffect(() => {
if (chartsEnabled()) {
fetchChartData(timeRange());
}
});
// Cleanup on unmount
onCleanup(() => {
if (chartUpdateInterval) {
window.clearInterval(chartUpdateInterval);
}
});
// Filter storage - in storage view, filter out 0 capacity
const filteredStorage = createMemo(() => {
const storage = state.storage || [];
@ -156,14 +94,29 @@ const Storage: Component = () => {
// Match MetricBar component styling - use the same disk/generic logic
if (usage >= 90) return 'bg-red-500/60 dark:bg-red-500/50';
if (usage >= 80) return 'bg-yellow-500/60 dark:bg-yellow-500/50';
return 'bg-green-500/60 dark:bg-green-500/50';
if (usage >= 70) return 'bg-amber-500/60 dark:bg-amber-500/50';
if (usage >= 60) return 'bg-yellow-500/60 dark:bg-yellow-500/50';
return 'bg-emerald-500/60 dark:bg-emerald-500/50';
};
// Reset filters
const resetFilters = () => {
setSearchTerm('');
setViewMode('node');
setChartsEnabled(false);
};
const getTotalByNode = (storages: StorageType[]) => {
const totals = { used: 0, total: 0, free: 0 };
storages.forEach(s => {
totals.used += s.used || 0;
totals.total += s.total || 0;
totals.free += s.free || 0;
});
return totals;
};
const calculateOverallUsage = (storages: StorageType[]) => {
const totals = getTotalByNode(storages);
return totals.total > 0 ? (totals.used / totals.total * 100) : 0;
};
// Handle keyboard shortcuts
@ -171,7 +124,7 @@ const Storage: Component = () => {
createEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if user is typing in an input, textarea, or contenteditable
// Ignore if user is typing in an input
const target = e.target as HTMLElement;
const isInputField = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
@ -181,7 +134,7 @@ const Storage: Component = () => {
// Escape key behavior
if (e.key === 'Escape') {
// First check if we have search/filters to clear
if (searchTerm().trim() || viewMode() !== 'node' || chartsEnabled()) {
if (searchTerm().trim() || viewMode() !== 'node') {
// Clear search and reset filters
resetFilters();
@ -212,109 +165,90 @@ const Storage: Component = () => {
return () => document.removeEventListener('keydown', handleKeyDown);
});
return (
<div id="storage" class="tab-content bg-white dark:bg-gray-800 rounded-b rounded-tr shadow mb-2">
<div class="p-3">
{/* Filter Section */}
<div class="storage-filter bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm mb-3">
{/* Filter toggle - visible on all screen sizes */}
<button
onClick={() => setShowFilters(!showFilters())}
class="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 rounded-lg transition-colors"
>
<span class="flex items-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="4" y1="21" x2="4" y2="14"></line>
<line x1="4" y1="10" x2="4" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="3"></line>
<line x1="20" y1="21" x2="20" y2="16"></line>
<line x1="20" y1="12" x2="20" y2="3"></line>
<line x1="1" y1="14" x2="7" y2="14"></line>
<line x1="9" y1="8" x2="15" y2="8"></line>
<line x1="17" y1="16" x2="23" y2="16"></line>
</svg>
Filters & Search
<Show when={searchTerm() || viewMode() !== 'node' || chartsEnabled()}>
<span class="text-xs bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded-full font-medium">
Active
</span>
</Show>
</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class={`transform transition-transform ${showFilters() ? 'rotate-180' : ''}`}
>
<polyline points="6 9 12 15 18 9"></polyline>
<div>
{/* Filters and Search */}
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg mb-4 overflow-hidden">
<button
onClick={() => setShowFilters(!showFilters())}
class="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
>
<span class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="4" y1="21" x2="4" y2="14"></line>
<line x1="4" y1="10" x2="4" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="3"></line>
<line x1="20" y1="21" x2="20" y2="16"></line>
<line x1="20" y1="12" x2="20" y2="3"></line>
<line x1="1" y1="14" x2="7" y2="14"></line>
<line x1="9" y1="8" x2="15" y2="8"></line>
<line x1="17" y1="16" x2="23" y2="16"></line>
</svg>
</button>
<div class={`filter-controls-wrapper ${showFilters() ? 'block' : 'hidden'} p-3 border-t border-gray-200 dark:border-gray-700`}>
<div class="flex flex-col gap-3">
{/* Search Row */}
<div class="flex gap-2">
<div class="relative flex-1">
<input
ref={searchInputRef}
type="text"
placeholder="Search by name, node, type, or content..."
value={searchTerm()}
onInput={(e) => setSearchTerm(e.currentTarget.value)}
class="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
/>
<svg class="absolute left-3 top-2.5 h-4 w-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<button
onClick={resetFilters}
title="Reset all filters (Esc)"
class="flex items-center justify-center px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-400
bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600
rounded-lg transition-colors"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
<span class="ml-1.5 hidden sm:inline">Reset</span>
</button>
Filters & Search
<Show when={searchTerm() || viewMode() !== 'node'}>
<span class="text-xs bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded-full font-medium">
Active
</span>
</Show>
</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class={`transform transition-transform ${showFilters() ? 'rotate-180' : ''}`}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div class={`filter-controls-wrapper ${showFilters() ? 'block' : 'hidden'} p-3 lg:p-4 border-t border-gray-200 dark:border-gray-700`}>
<div class="flex flex-col gap-3">
{/* Search Bar Row */}
<div class="flex gap-2">
<div class="relative flex-1">
<input
ref={searchInputRef}
type="text"
placeholder="Search by name, node, type, content, or status..."
value={searchTerm()}
onInput={(e) => setSearchTerm(e.currentTarget.value)}
class="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
/>
<svg class="absolute left-3 top-2.5 h-4 w-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* View Controls Row */}
<div class="flex flex-col sm:flex-row gap-3">
{/* View Controls */}
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 whitespace-nowrap">Display:</span>
{/* Charts Toggle */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
onClick={() => setChartsEnabled(!chartsEnabled())}
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
chartsEnabled()
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
Charts {chartsEnabled() ? 'On' : 'Off'}
</button>
</div>
<div class="h-6 w-px bg-gray-200 dark:bg-gray-600"></div>
{/* View Mode Toggle */}
{/* Reset Button */}
<button
onClick={resetFilters}
title="Reset all filters"
class="flex items-center justify-center px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-400
bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600
rounded-lg transition-colors"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M8 16H3v5"/>
</svg>
<span class="ml-1.5 hidden sm:inline">Reset</span>
</button>
</div>
{/* Filters Row */}
<div class="flex flex-col sm:flex-row gap-2">
{/* View Mode Toggle */}
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Group by:</span>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
onClick={() => setViewMode('node')}
@ -324,7 +258,7 @@ const Storage: Component = () => {
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
By Node
Node
</button>
<button
onClick={() => setViewMode('storage')}
@ -334,99 +268,75 @@ const Storage: Component = () => {
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
By Storage
Storage
</button>
</div>
</div>
{/* Chart Time Range Controls - Show when charts enabled */}
<Show when={chartsEnabled()}>
<div class="flex items-center gap-2">
<div class="h-6 w-px bg-gray-200 dark:bg-gray-600"></div>
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">Time Range:</span>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<For each={timeRanges}>
{(range) => (
<button
onClick={() => setTimeRange(range.value)}
class={`px-2 py-1.5 text-xs font-medium rounded-md transition-all ${
timeRange() === range.value
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
>
{range.label}
</button>
)}
</For>
</div>
</div>
</Show>
</div>
</div>
</div>
</div>
</div>
{/* Table Section */}
<div class="px-3 pb-3">
{/* Empty State - No Storage Configured */}
<Show when={connected() && sortedStorage().length === 0}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">No storage found</h3>
<p class="text-xs text-gray-600 dark:text-gray-400">No storage repositories are configured or visible.</p>
</div>
</div>
</Show>
{/* Storage Table */}
<Show when={connected() && sortedStorage().length > 0}>
<ComponentErrorBoundary name="Storage Table">
<div class="table-container overflow-x-auto mb-2 border border-gray-200 dark:border-gray-700 rounded overflow-hidden scrollbar">
<table id="storage-table" class="w-full text-sm border-collapse min-w-full" style="table-layout: fixed;">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-600">
<th class="bg-gray-100 dark:bg-gray-700 p-1 px-2 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider" style="width: 130px;">
Storage
</th>
<Show when={viewMode() === 'storage'}>
<th class="bg-gray-100 dark:bg-gray-700 p-1 px-2 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider" style="width: 120px;">
Nodes
</th>
<th class="bg-gray-100 dark:bg-gray-700 p-1 px-2 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider" style="width: 80px;">
Type
</th>
</Show>
<Show when={viewMode() === 'node'}>
<th class="bg-gray-100 dark:bg-gray-700 p-1 px-2 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider" style="width: 180px;">
Content
</th>
<th class="bg-gray-100 dark:bg-gray-700 p-1 px-2 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider" style="width: 80px;">
Type
</th>
<th class="bg-gray-50 dark:bg-gray-700 p-1 px-2 text-center text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider" style="width: 50px;">
Shared
</th>
</Show>
<th class="bg-gray-100 dark:bg-gray-700 p-1 px-2 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider" style="width: 200px;">
Usage
</th>
<th class="bg-gray-100 dark:bg-gray-700 p-1 px-2 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider" style="width: 80px;">
Avail
</th>
<th class="bg-gray-100 dark:bg-gray-700 p-1 px-2 text-left text-xs font-medium text-gray-600 dark:text-gray-300 uppercase tracking-wider" style="width: 80px;">
Total
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-600">
<Show when={connected() && state.storage && state.storage.length > 0}>
<For each={Object.entries(groupedStorage()).sort(([a], [b]) => a.localeCompare(b))} fallback={<></>}>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 border-b border-gray-300 dark:border-gray-600">
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Storage</th>
<Show when={viewMode() === 'node'}>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Node</th>
</Show>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Type</th>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Content</th>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Status</th>
<Show when={viewMode() === 'node'}>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Shared</th>
</Show>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider w-[200px]">Usage</th>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Free</th>
<th class="p-1 px-2 text-left text-[10px] sm:text-xs font-medium uppercase tracking-wider">Total</th>
</tr>
</thead>
<tbody>
<For each={Object.entries(groupedStorage()).sort(([a], [b]) => a.localeCompare(b))}>
{([groupName, storages]) => (
<>
{/* Group Header for Node View */}
{/* Group Header */}
<Show when={viewMode() === 'node'}>
<tr class="node-storage-header bg-gray-50 dark:bg-gray-700/50 font-semibold text-gray-700 dark:text-gray-300 text-xs">
<td colspan="7" class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400">
<tr class="bg-gray-50 dark:bg-gray-700/50 font-semibold text-gray-700 dark:text-gray-300 text-xs">
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400">
<a
href={`https://${groupName}:8006`}
target="_blank"
rel="noopener noreferrer"
class="hover:text-blue-600 dark:hover:text-blue-400"
class="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer"
title={`Open ${groupName} web interface`}
>
{groupName}
</a>
<span class="text-[10px] text-gray-500 dark:text-gray-400 ml-2">
({storages.length} storage{storages.length !== 1 ? 's' : ''})
</td>
<td class="px-2 py-1 text-xs font-medium text-gray-500 dark:text-gray-400" colspan="8">
<span class="text-[10px]">
{getTotalByNode(storages).total > 0 && (
<span>
{formatBytes(getTotalByNode(storages).used)} / {formatBytes(getTotalByNode(storages).total)} ({calculateOverallUsage(storages).toFixed(1)}%)
</span>
)}
</span>
</td>
</tr>
@ -436,8 +346,6 @@ const Storage: Component = () => {
<For each={storages} fallback={<></>}>
{(storage) => {
const usagePercent = storage.total > 0 ? (storage.used / storage.total * 100) : 0;
// Get chart data from unified store
const storageChartData = createMemo(() => getStorageChartData(storage.id, 'disk'));
const isDisabled = storage.status !== 'available';
const alertStyles = getAlertStyles(storage.id || `${storage.instance}-${storage.name}`, activeAlerts);
@ -445,75 +353,60 @@ const Storage: Component = () => {
return (
<tr class={rowClass}>
<td class="p-1 px-2 text-xs font-medium">
<div class="flex items-center gap-1">
<span>{storage.name}</span>
<Show when={isDisabled}>
<span class="text-gray-500 dark:text-gray-400 text-[10px]">({storage.status})</span>
</Show>
<td class="p-1 px-2">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900 dark:text-gray-100">
{storage.name}
</span>
<Show when={alertStyles.hasAlert}>
<div class="flex items-center gap-1 ml-auto">
<AlertIndicator severity={alertStyles.severity} />
<div class="flex items-center gap-1">
<AlertIndicator severity={alertStyles.severity} alerts={[]} />
<Show when={alertStyles.alertCount > 1}>
<AlertCountBadge count={alertStyles.alertCount} severity={alertStyles.severity!} />
<AlertCountBadge count={alertStyles.alertCount} severity={alertStyles.severity!} alerts={[]} />
</Show>
</div>
</Show>
</div>
</td>
<Show when={viewMode() === 'storage'}>
<td class="p-1 px-2 text-xs text-gray-600 dark:text-gray-400">
<Show when={storage.shared} fallback={
<span>{storage.node} <span class="text-[10px] text-gray-500">(Local)</span></span>
}>
<span class="text-green-600 dark:text-green-400">
All Nodes
<Show when={storage.instance}>
<span class="text-[10px] text-gray-500 ml-1">({storage.instance})</span>
</Show>
</span>
</Show>
</td>
<td class="p-1 px-2 text-xs text-gray-600 dark:text-gray-400">
{storage.type}
</td>
</Show>
<Show when={viewMode() === 'node'}>
<td class="p-1 px-2 text-xs text-gray-600 dark:text-gray-400 truncate" style="max-width: 180px;" title={storage.content}>
{storage.content}
</td>
<td class="p-1 px-2 text-xs text-gray-600 dark:text-gray-400">
<td class="p-1 px-2 text-xs text-gray-600 dark:text-gray-400">{storage.node}</td>
</Show>
<td class="p-1 px-2">
<span class={`inline-block px-1.5 py-0.5 text-[10px] font-medium rounded ${
storage.type === 'dir' ? 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' :
storage.type === 'pbs' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300' :
'bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300'
}`}>
{storage.type}
</td>
<td class="p-1 px-2 text-xs text-center">
</span>
</td>
<td class="p-1 px-2 text-xs text-gray-600 dark:text-gray-400">
{storage.content || '-'}
</td>
<td class="p-1 px-2 text-xs">
<span class={`${
storage.status === 'available' ? 'text-green-600 dark:text-green-400' :
'text-red-600 dark:text-red-400'
}`}>
{storage.status || 'unknown'}
</span>
</td>
<Show when={viewMode() === 'node'}>
<td class="p-1 px-2 text-xs text-gray-600 dark:text-gray-400">
{storage.shared ? '✓' : '-'}
</td>
</Show>
<td class="p-1 px-2" style="width: 200px;">
<Show when={chartsEnabled()}>
<DynamicChart
data={storageChartData()}
metric="disk"
guestId={storage.id}
chartType="storage"
filled
forceGray
<div class="relative w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
<div
class={`absolute top-0 left-0 h-full ${getProgressBarColor(usagePercent)}`}
style={{ width: `${usagePercent}%` }}
/>
</Show>
<Show when={!chartsEnabled()}>
<div class="relative w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
<div
class={`absolute top-0 left-0 h-full ${getProgressBarColor(usagePercent)}`}
style={{ width: `${usagePercent}%` }}
/>
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
<span class="truncate px-1">{formatBytes(storage.used || 0)} / {formatBytes(storage.total || 0)} ({usagePercent.toFixed(1)}%)</span>
</span>
</div>
</Show>
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
<span class="truncate px-1">{formatBytes(storage.used || 0)} / {formatBytes(storage.total || 0)} ({usagePercent.toFixed(1)}%)</span>
</span>
</div>
</td>
<td class="p-1 px-2 text-xs">{formatBytes(storage.free || 0)}</td>
<td class="p-1 px-2 text-xs">{formatBytes(storage.total || 0)}</td>
@ -524,81 +417,11 @@ const Storage: Component = () => {
</>
)}
</For>
</Show>
{/* Empty State */}
<Show when={connected() && (!state.storage || state.storage.length === 0 || (viewMode() === 'storage' && filteredStorage().length === 0))}>
<tr>
<td colspan={viewMode() === 'node' ? 7 : 7} class="p-8 text-center">
<div class="text-gray-500 dark:text-gray-400">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p class="text-sm font-medium mb-1">No Active Storage</p>
<p class="text-xs">
{viewMode() === 'storage'
? 'No storage with capacity found in the cluster.'
: 'No storage configured.'}
</p>
</div>
</td>
</tr>
</Show>
{/* Disconnected State */}
<Show when={!connected()}>
<tr>
<td colspan={viewMode() === 'node' ? 7 : 7} class="p-8 text-center">
<div class="text-red-600 dark:text-red-400">
<svg class="mx-auto h-12 w-12 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm font-medium mb-1">Connection Lost</p>
<p class="text-xs">Unable to connect to the backend server.</p>
</div>
</td>
</tr>
</Show>
</tbody>
</table>
</div>
</tbody>
</table>
</div>
</ComponentErrorBoundary>
</div>
<style>{`
.node-storage-header {
font-weight: 600;
}
.table-container {
max-height: calc(100vh - 250px);
}
.scrollbar {
scrollbar-width: thin;
scrollbar-color: #6b7280 transparent;
}
.scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar::-webkit-scrollbar-thumb {
background-color: #6b7280;
border-radius: 4px;
}
.scrollbar::-webkit-scrollbar-thumb:hover {
background-color: #4b5563;
}
`}</style>
</Show>
{/* Tooltip System */}
<TooltipComponent />

View file

@ -1,119 +0,0 @@
import { Component, onMount, onCleanup, createMemo, createEffect, Show } from 'solid-js';
import Sparkline from '@/components/Dashboard/Sparkline';
import { observeChart, subscribeToChartDimension } from '@/stores/chartDimensions';
import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';
import type { ChartPoint } from '@/types/charts';
interface DynamicChartProps {
data: ChartPoint[] | undefined;
metric: string;
guestId: string;
chartType?: 'mini' | 'sparkline' | 'storage';
containerClass?: string;
paddingAdjustment?: number;
lazy?: boolean; // Enable lazy loading
filled?: boolean; // Pass through to Sparkline
forceGray?: boolean; // Force gray color
}
/**
* Optimized dynamic chart component that uses a shared ResizeObserver for better performance.
* This ensures charts fill their available space without stretching or distortion.
*/
export const DynamicChart: Component<DynamicChartProps> = (props) => {
const chartType = () => props.chartType || 'mini';
const chartId = () => `${props.guestId}-${props.metric}`;
const defaultWidth = () => {
switch (chartType()) {
case 'storage': return 184;
case 'mini': return 118;
case 'sparkline': return 66;
default: return 118;
}
};
let containerRef: HTMLDivElement | undefined;
let cleanupFn: (() => void) | undefined;
// Use intersection observer for lazy loading if enabled
const isVisible = props.lazy ? useIntersectionObserver(() => containerRef) : () => true;
// Create a memoized signal for the chart dimension
const containerWidth = createMemo(() => {
const dimension = subscribeToChartDimension(chartId())();
if (dimension > 0) {
return dimension - (props.paddingAdjustment || 0);
}
return defaultWidth();
});
onMount(() => {
if (containerRef && isVisible()) {
// Register with shared ResizeObserver only when visible
cleanupFn = observeChart(containerRef, chartId());
}
});
// Re-register when visibility changes
createEffect(() => {
if (containerRef && isVisible() && !cleanupFn) {
cleanupFn = observeChart(containerRef, chartId());
}
});
onCleanup(() => {
// Don't clean up if we're just re-rendering the same chart
// This prevents the dimension from resetting and causing animations
if (cleanupFn) {
cleanupFn();
}
});
const getHeight = createMemo(() => {
switch (chartType()) {
case 'storage': return 14;
case 'mini': return 20;
case 'sparkline': return 16;
default: return 20;
}
});
// Only render if we have valid data, width, and visibility
const shouldRender = createMemo(() => {
return isVisible() && containerWidth() > 0 && props.data && (Array.isArray(props.data) ? props.data.length > 0 : true);
});
// Show loading skeleton immediately when visible but no data
const showLoadingSkeleton = createMemo(() => {
return isVisible() && containerWidth() > 0 && (!props.data || (Array.isArray(props.data) && props.data.length === 0));
});
return (
<div ref={containerRef!} class={props.containerClass || "w-full h-full flex items-center justify-center"}>
<Show
when={shouldRender()}
fallback={
<Show when={showLoadingSkeleton()} fallback={null}>
<div
class="animate-pulse bg-gray-200 dark:bg-gray-700 rounded"
style={{ width: `${containerWidth()}px`, height: `${getHeight()}px` }}
/>
</Show>
}
>
<Sparkline
data={props.data!}
metric={props.metric}
guestId={props.guestId}
chartType={chartType()}
showTooltip={true}
filled={props.filled !== undefined ? props.filled : (chartType() === 'mini' || chartType() === 'storage')}
width={containerWidth()}
height={getHeight()}
forceGray={props.forceGray}
/>
</Show>
</div>
);
};

View file

@ -3,25 +3,12 @@
// Polling and update intervals (in milliseconds)
export const POLLING_INTERVALS = {
DEFAULT: 5000, // 5 seconds - default polling interval
CHART_UPDATE: 5000, // 5 seconds - chart data update interval
RECONNECT_BASE: 1000, // 1 second - base reconnect delay
RECONNECT_MAX: 30000, // 30 seconds - max reconnect delay
DATA_FLASH: 1000, // 1 second - data update indicator flash duration
TOAST_DURATION: 5000, // 5 seconds - default toast notification duration
} as const;
// Chart configuration
export const CHART_INTERVALS = {
'5m': 5 * 60 * 1000,
'15m': 15 * 60 * 1000,
'30m': 30 * 60 * 1000,
'1h': 60 * 60 * 1000,
'4h': 4 * 60 * 60 * 1000,
'12h': 12 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
} as const;
// Display thresholds (percentages)
export const THRESHOLDS = {
WARNING: 60, // Yellow warning threshold
@ -40,7 +27,6 @@ export const IO_THRESHOLDS = {
// Animation durations (in milliseconds)
export const ANIMATIONS = {
TOAST_SLIDE: 300, // Toast slide in/out animation
CHART_FADE: 150, // Chart fade in/out animation
} as const;
// UI configuration
@ -65,7 +51,6 @@ export const STORAGE_KEYS = {
DISPLAY_MODE: 'displayMode',
SORT_KEY: 'sortKey',
SORT_DIRECTION: 'sortDirection',
CHART_TIME_RANGE: 'chartTimeRange',
ALERT_THRESHOLDS: 'alertThresholds',
} as const;
@ -82,10 +67,3 @@ export const LOG_LEVELS = {
export type LogLevel = keyof typeof LOG_LEVELS;
// Chart dimensions
export const CHART_DIMENSIONS = {
SPARKLINE: { width: 66, height: 16, padding: 1 },
MINI: { width: 118, height: 20, padding: 2 },
STORAGE: { width: 200, height: 14, padding: 1 },
STROKE_WIDTH: 1.5,
} as const;

View file

@ -1,113 +0,0 @@
import { createSignal } from 'solid-js';
interface ChartDimensions {
[key: string]: number;
}
// Global store for chart dimensions
const [dimensions, setDimensions] = createSignal<ChartDimensions>({});
// Cache to persist dimensions between re-renders
const dimensionCache: ChartDimensions = {};
// Single ResizeObserver instance shared by all charts
let resizeObserver: ResizeObserver | null = null;
const observedElements = new Map<Element, string>();
// Debounce timer
let resizeTimeout: number | undefined;
// Initialize the shared ResizeObserver
function initResizeObserver() {
if (!resizeObserver && typeof window !== 'undefined') {
resizeObserver = new ResizeObserver((entries) => {
// Clear existing timeout
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
// Debounce resize events by 50ms
resizeTimeout = window.setTimeout(() => {
requestAnimationFrame(() => {
const updates: ChartDimensions = {};
for (const entry of entries) {
const chartId = observedElements.get(entry.target);
if (chartId) {
const width = Math.floor(entry.contentRect.width);
// Only update if width actually changed
if (dimensions()[chartId] !== width) {
updates[chartId] = width;
}
}
}
// Batch update all dimensions at once
if (Object.keys(updates).length > 0) {
// Update cache
Object.assign(dimensionCache, updates);
setDimensions(prev => ({ ...prev, ...updates }));
}
});
}, 50);
});
}
}
// Register a chart element for observation
export function observeChart(element: HTMLElement, chartId: string) {
initResizeObserver();
if (resizeObserver && element) {
// Store the association
observedElements.set(element, chartId);
resizeObserver.observe(element);
// Get initial dimension - use cached value if available to prevent animation
const cachedWidth = dimensionCache[chartId];
const width = cachedWidth || Math.floor(element.offsetWidth);
dimensionCache[chartId] = width;
setDimensions(prev => ({ ...prev, [chartId]: width }));
// Return cleanup function
return () => {
if (resizeObserver) {
resizeObserver.unobserve(element);
observedElements.delete(element);
// Don't clean up dimension entry - keep it cached
// This prevents re-animation on re-render
// setDimensions(prev => {
// const next = { ...prev };
// delete next[chartId];
// return next;
// });
}
};
}
return () => {};
}
// Get dimension for a specific chart
export function getChartDimension(chartId: string): number {
return dimensions()[chartId] || 0;
}
// Subscribe to dimension changes for a specific chart
export function subscribeToChartDimension(chartId: string) {
return () => dimensions()[chartId];
}
// Clean up on app unmount
if (typeof window !== 'undefined') {
window.addEventListener('unload', () => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
});
}

View file

@ -1,447 +0,0 @@
import { createSignal } from 'solid-js';
import { createStore } from 'solid-js/store';
import { logger } from '@/utils/logger';
import { ChartsAPI } from '@/api/charts';
import type { ChartPoint, MetricData, ChartData, NodeChartData, StorageChartData } from '@/types/charts';
// Chart configuration based on main branch
export const CHART_CONFIG = {
// Different sizes for different use cases
sparkline: { width: 66, height: 16, padding: 1 }, // For I/O metrics
mini: { width: 118, height: 20, padding: 2 }, // For usage metrics
storage: { width: 200, height: 14, padding: 1 }, // For storage tab - default width, will be overridden dynamically
strokeWidth: 1.5,
// Get optimal render points based on screen resolution
getRenderPoints: () => {
const screenWidth = window.screen.width;
const pixelRatio = window.devicePixelRatio || 1;
const effectiveWidth = screenWidth * pixelRatio;
if (effectiveWidth >= 3840) return 80; // 4K
else if (effectiveWidth >= 2560) return 60; // 2K
else if (effectiveWidth >= 1920) return 40; // 1080p
else if (effectiveWidth >= 1366) return 30; // 720p
else return 25; // Small screens
},
// Smart color coding based on data values
getSmartColor: (values: number[], metric: string): string => {
if (!values || values.length === 0) {
const isDarkMode = document.documentElement.classList.contains('dark');
return isDarkMode ? '#6b7280' : '#d1d5db'; // gray-500/gray-300
}
const currentValue = values[values.length - 1];
const maxValue = Math.max(...values);
if (metric === 'cpu' || metric === 'memory' || metric === 'disk') {
// Percentage-based metrics
if (metric === 'cpu') {
if (currentValue >= 90 || maxValue >= 95) return '#ef4444'; // red-500
if (currentValue >= 80 || maxValue >= 85) return '#f59e0b'; // amber-500
} else if (metric === 'memory') {
if (currentValue >= 85 || maxValue >= 90) return '#ef4444'; // red-500
if (currentValue >= 75 || maxValue >= 80) return '#f59e0b'; // amber-500
} else if (metric === 'disk') {
if (currentValue >= 90 || maxValue >= 95) return '#ef4444'; // red-500
if (currentValue >= 80 || maxValue >= 85) return '#f59e0b'; // amber-500
}
const isDarkMode = document.documentElement.classList.contains('dark');
return isDarkMode ? '#6b7280' : '#d1d5db'; // gray: normal operation
} else if (metric === 'diskread' || metric === 'diskwrite' || metric === 'netin' || metric === 'netout') {
// I/O metrics - use absolute thresholds
// const maxMBps = maxValue / (1024 * 1024); // unused
const avgValue = values.reduce((sum, v) => sum + v, 0) / values.length;
const avgMBps = avgValue / (1024 * 1024);
if (avgMBps > 50) return '#ef4444'; // red: >50 MB/s
if (avgMBps > 10) return '#f59e0b'; // amber: >10 MB/s
if (avgMBps > 1) return '#10b981'; // green: >1 MB/s
const isDarkMode = document.documentElement.classList.contains('dark');
return isDarkMode ? '#6b7280' : '#d1d5db'; // gray: minimal activity
} else {
// Unknown metric - use default color
const isDarkMode = document.documentElement.classList.contains('dark');
return isDarkMode ? '#6b7280' : '#d1d5db';
}
},
// Default colors for metrics
colors: {
cpu: '#ef4444', // red-500
memory: '#3b82f6', // blue-500
disk: '#8b5cf6', // violet-500
diskread: '#3b82f6', // blue-500
diskwrite: '#f97316', // orange-500
netin: '#10b981', // emerald-500
netout: '#f59e0b' // amber-500
}
};
// Chart data point interface
export interface ChartDataPoint {
timestamp: number;
value: number;
}
// Store for chart data
interface ChartStore {
guestData: ChartData;
nodeData: NodeChartData;
storageData: StorageChartData;
lastFetch: number;
timeRange: string;
stats: {
oldestDataTimestamp?: number;
};
}
const [chartStore, setChartStore] = createStore<ChartStore>({
guestData: {},
nodeData: {},
storageData: {},
lastFetch: 0,
timeRange: '60', // Default 1 hour
stats: {}
});
// Signal for current render points
const [renderPoints, setRenderPoints] = createSignal(CHART_CONFIG.getRenderPoints());
// Update render points on window resize
if (typeof window !== 'undefined') {
window.addEventListener('resize', () => {
const newPoints = CHART_CONFIG.getRenderPoints();
if (newPoints !== renderPoints()) {
setRenderPoints(newPoints);
// Clear processed data cache when resolution changes
processedDataCache.clear();
}
});
}
// Cache for processed chart data
const processedDataCache = new Map<string, {
data: ChartDataPoint[];
timestamp: number;
hash: string;
}>();
// Generate quick hash of data for cache validation
function generateDataHash(data: ChartDataPoint[]): string {
if (!data || data.length === 0) return '0';
const first = data[0];
const last = data[data.length - 1];
// const middle = data[Math.floor(data.length / 2)]; // unused
return `${data.length}-${first?.timestamp || 0}-${last?.timestamp || 0}-${first?.value?.toFixed(2) || 0}-${last?.value?.toFixed(2) || 0}`;
}
// Calculate importance scores for adaptive sampling
function calculateImportanceScores(data: ChartDataPoint[]): number[] {
const scores = new Array(data.length);
const values = data.map(d => d.value);
for (let i = 0; i < data.length; i++) {
let score = 0;
// Rate of change
if (i > 0 && i < data.length - 1) {
const change = Math.abs(values[i + 1] - values[i - 1]);
score += change;
}
// Peaks and valleys
if (i > 0 && i < data.length - 1) {
const isPeak = values[i] > values[i - 1] && values[i] > values[i + 1];
const isValley = values[i] < values[i - 1] && values[i] < values[i + 1];
if (isPeak || isValley) {
score += Math.abs(values[i] - values[i - 1]) + Math.abs(values[i] - values[i + 1]);
}
}
// Edge points get bonus
if (i === 0 || i === data.length - 1) {
score += 1000; // Ensure edges are always included
}
scores[i] = score;
}
return scores;
}
// Adaptive sampling algorithm
export function adaptiveSample(data: ChartDataPoint[], targetPoints: number): ChartDataPoint[] {
if (data.length <= targetPoints) return data;
const maxPoints = Math.max(2, targetPoints);
// Calculate importance scores
const importance = calculateImportanceScores(data);
// Always include first and last points
const selectedIndices = new Set([0, data.length - 1]);
const remainingPoints = maxPoints - 2;
if (remainingPoints <= 0) {
return [data[0], data[data.length - 1]];
}
// Create candidates array
const candidates = [];
for (let i = 1; i < data.length - 1; i++) {
candidates.push({ index: i, importance: importance[i] });
}
// Sort by importance
candidates.sort((a, b) => b.importance - a.importance);
// Add most important points up to limit
for (let i = 0; i < Math.min(remainingPoints, candidates.length); i++) {
selectedIndices.add(candidates[i].index);
}
// Convert to sorted array and extract data points
const sortedIndices = Array.from(selectedIndices).sort((a, b) => a - b);
return sortedIndices.map(i => data[i]);
}
// Process chart data with caching and adaptive sampling
export function processChartData(
serverData: ChartDataPoint[],
chartType: 'mini' | 'sparkline' = 'mini',
guestId: string,
metric: string
): ChartDataPoint[] {
if (!serverData || serverData.length === 0) {
return [];
}
// Generate cache key
const cacheKey = `${guestId}-${metric}-${chartType}`;
const dataHash = generateDataHash(serverData);
// Check cache
const cached = processedDataCache.get(cacheKey);
if (cached && cached.hash === dataHash && (Date.now() - cached.timestamp < 30000)) {
return cached.data;
}
let targetPoints = renderPoints();
// Sparklines need fewer points
if (chartType === 'sparkline') {
targetPoints = Math.round(targetPoints * 0.6);
}
let processedData: ChartDataPoint[];
if (serverData.length <= targetPoints) {
processedData = serverData;
} else {
processedData = adaptiveSample(serverData, targetPoints);
}
// Cache the result
processedDataCache.set(cacheKey, {
data: processedData,
timestamp: Date.now(),
hash: dataHash
});
// Clean up old cache entries
if (processedDataCache.size > 200) {
const now = Date.now();
const maxAge = 60000; // 1 minute
for (const [key, value] of processedDataCache.entries()) {
if (now - value.timestamp > maxAge) {
processedDataCache.delete(key);
}
}
}
return processedData;
}
// Fetch chart data from API
export async function fetchChartData(timeRange: string) {
try {
const data = await ChartsAPI.getCharts(timeRange);
// Calculate time offset between server and browser
const serverTime = data.timestamp || Date.now();
const browserTime = Date.now();
const timeOffset = browserTime - serverTime;
// Adjust all timestamps to compensate for time offset
if (data.data) {
for (const guestId in data.data) {
for (const metric in data.data[guestId]) {
if (Array.isArray(data.data[guestId][metric])) {
data.data[guestId][metric] = data.data[guestId][metric].map((point: ChartPoint) => ({
...point,
timestamp: point.timestamp + timeOffset
}));
}
}
}
}
// Adjust node data timestamps
if (data.nodeData) {
for (const nodeId in data.nodeData) {
for (const metric in data.nodeData[nodeId]) {
if (Array.isArray(data.nodeData[nodeId][metric])) {
data.nodeData[nodeId][metric] = data.nodeData[nodeId][metric].map((point: ChartPoint) => ({
...point,
timestamp: point.timestamp + timeOffset
}));
}
}
}
}
// Adjust storage data timestamps
if (data.storageData) {
for (const storageId in data.storageData) {
for (const metric in data.storageData[storageId]) {
if (Array.isArray(data.storageData[storageId][metric])) {
data.storageData[storageId][metric] = data.storageData[storageId][metric].map((point: ChartPoint) => ({
...point,
timestamp: point.timestamp + timeOffset
}));
}
}
}
}
// Update store
const guestCount = Object.keys(data.data || {}).length;
const nodeCount = Object.keys(data.nodeData || {}).length;
console.debug(`Chart data fetched: ${guestCount} guests, ${nodeCount} nodes`);
setChartStore({
guestData: data.data || {},
nodeData: data.nodeData || {},
storageData: data.storageData || {},
lastFetch: Date.now(),
timeRange,
stats: {
oldestDataTimestamp: data.stats?.oldestDataTimestamp
}
});
// Don't clear the entire cache - let individual entries expire naturally
// This prevents all charts from recalculating at once
// processedDataCache.clear();
return { guestData: data.data, nodeData: data.nodeData, storageData: data.storageData };
} catch (error) {
logger.error('Failed to fetch chart data', error);
return null;
}
}
// Empty array constant to avoid recreating arrays
const EMPTY_ARRAY: ChartDataPoint[] = [];
// Get chart data for a specific guest and metric
export function getGuestChartData(guestId: string, metric: string): ChartDataPoint[] {
const guestData = chartStore.guestData[guestId];
if (!guestData) {
console.debug(`No chart data for guest ${guestId}`);
return EMPTY_ARRAY;
}
const metricData = guestData[metric as keyof MetricData];
if (!metricData) {
console.debug(`No ${metric} data for guest ${guestId}`);
}
return metricData || EMPTY_ARRAY;
}
// Get chart data for a specific node and metric
export function getNodeChartData(nodeId: string, metric: string): ChartDataPoint[] {
const nodeData = chartStore.nodeData[nodeId];
if (!nodeData) return EMPTY_ARRAY;
const metricData = nodeData[metric as keyof MetricData];
return metricData || EMPTY_ARRAY;
}
// Get chart data for a specific storage and metric
export function getStorageChartData(storageId: string, metric: string): ChartDataPoint[] {
const storageData = chartStore.storageData[storageId];
if (!storageData) return EMPTY_ARRAY;
const metricData = storageData[metric as keyof MetricData];
return metricData || EMPTY_ARRAY;
}
// Check if we should fetch new data
export function shouldFetchChartData(): boolean {
const CHART_FETCH_INTERVAL = 5000; // 5 seconds
return !chartStore.lastFetch || (Date.now() - chartStore.lastFetch) > CHART_FETCH_INTERVAL;
}
// Format time ago for tooltips
export function formatTimeAgo(timestamp: number): string {
const now = Date.now();
const diffMs = now - timestamp;
if (diffMs < 0) return '0s ago';
const diffMinutes = Math.floor(diffMs / 60000);
const diffSeconds = Math.floor((diffMs % 60000) / 1000);
if (diffMinutes >= 60) {
const hours = Math.floor(diffMinutes / 60);
const minutes = diffMinutes % 60;
if (hours >= 24) {
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
if (remainingHours > 0) {
return `${days}d ${remainingHours}h ${minutes}m ago`;
} else if (minutes > 0) {
return `${days}d ${minutes}m ago`;
} else {
return `${days}d ago`;
}
}
return `${hours}h ${minutes}m ago`;
} else if (diffMinutes > 0) {
return `${diffMinutes}m ${diffSeconds}s ago`;
} else {
return `${diffSeconds}s ago`;
}
}
// Format value for tooltip display
export function formatChartValue(value: number, metric: string): string {
if (metric === 'cpu' || metric === 'memory' || metric === 'disk') {
return Math.round(value) + '%';
} else if (metric === 'diskread' || metric === 'diskwrite' || metric === 'netin' || metric === 'netout') {
// Format as speed
const mbps = value / (1024 * 1024);
if (mbps >= 100) return `${Math.round(mbps)} MB/s`;
if (mbps >= 10) return `${Math.round(mbps * 10) / 10} MB/s`;
if (mbps >= 1) return `${Math.round(mbps * 100) / 100} MB/s`;
const kbps = value / 1024;
if (kbps >= 1) return `${Math.round(kbps)} KB/s`;
return `${Math.round(value)} B/s`;
} else {
// Default formatting
return String(Math.round(value));
}
}
// Export store and utilities
export { chartStore, renderPoints };

View file

@ -1,43 +0,0 @@
export interface ChartPoint {
timestamp: number;
value: number;
}
export interface MetricData {
cpu: ChartPoint[];
memory: ChartPoint[];
disk: ChartPoint[];
diskread: ChartPoint[];
diskwrite: ChartPoint[];
netin: ChartPoint[];
netout: ChartPoint[];
[key: string]: ChartPoint[];
}
export interface ChartData {
[guestId: string]: MetricData;
}
export interface NodeMetricData {
cpu: ChartPoint[];
memory: ChartPoint[];
disk: ChartPoint[];
[key: string]: ChartPoint[];
}
export interface NodeChartData {
[nodeId: string]: NodeMetricData;
}
export interface StorageMetricData {
disk: ChartPoint[];
[key: string]: ChartPoint[];
}
export interface StorageChartData {
[storageId: string]: StorageMetricData;
}
export interface ChartStats {
oldestDataTimestamp: number;
}