mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
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:
parent
b1d79d8e25
commit
5d229bd7f9
12 changed files with 223 additions and 1829 deletions
|
|
@ -12,7 +12,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@solidjs/router": "^0.10.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"solid-js": "^1.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue