diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 0b50c1c..eb136b7 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -4,6 +4,7 @@ import type { VM, Container } from '@/types/api'; import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus, formatPercent } from '@/utils/format'; import { TagBadges } from './TagBadges'; import { StackedDiskBar } from './StackedDiskBar'; +import { StackedMemoryBar } from './StackedMemoryBar'; import { StatusDot } from '@/components/shared/StatusDot'; import { getGuestPowerIndicator, isGuestRunning } from '@/utils/status'; @@ -673,15 +674,28 @@ export function GuestRow(props: GuestRowProps) { return (
- + +
+ +
+
+
); diff --git a/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx b/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx new file mode 100644 index 0000000..6640084 --- /dev/null +++ b/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx @@ -0,0 +1,217 @@ +import { Show, For, createMemo, createSignal, onMount, onCleanup } from 'solid-js'; +import { Portal } from 'solid-js/web'; +import { formatBytes, formatPercent } from '@/utils/format'; + +interface StackedMemoryBarProps { + used: number; + total: number; + swapUsed?: number; + swapTotal?: number; + balloon?: number; +} + +// Colors for memory segments +const MEMORY_COLORS = { + active: 'rgba(34, 197, 94, 0.6)', // green + balloon: 'rgba(234, 179, 8, 0.6)', // yellow + swap: 'rgba(168, 85, 247, 0.6)', // purple +}; + +export function StackedMemoryBar(props: StackedMemoryBarProps) { + const [containerWidth, setContainerWidth] = createSignal(100); + const [showTooltip, setShowTooltip] = createSignal(false); + const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); + let containerRef: HTMLDivElement | undefined; + + onMount(() => { + if (!containerRef) return; + setContainerWidth(containerRef.offsetWidth); + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + + observer.observe(containerRef); + onCleanup(() => observer.disconnect()); + }); + + // Calculate segments + const segments = createMemo(() => { + if (props.total <= 0) return []; + + const balloon = props.balloon || 0; + // Active memory is used minus balloon (since balloon is "used" but reclaimed) + // Note: This is a simplification. Proxmox reports 'used' including balloon. + const active = Math.max(0, props.used - balloon); + + // Calculate percentages relative to RAM total (swap is extra) + // We visualize swap as an overlay or separate segment if we want to show it relative to RAM + // But typically swap is separate. + // Let's stack RAM components (Active + Balloon) within the RAM bar. + // And maybe show Swap as a separate indicator or just in tooltip? + // The user asked for "Detailed Memory Composition". + // Let's stack Active and Balloon within the main bar. + // If Swap is used, we can perhaps show it as a segment that "overflows" or just a separate color if we treat total as RAM+Swap? + // Standard approach: Bar represents RAM. Segments are Active, Balloon. + // Swap is usually separate. But we can include it if we want to show "Memory Pressure". + + // Let's stick to RAM composition for the bar: Active (Green), Balloon (Yellow). + // Swap usage will be shown in tooltip and maybe change the bar color if critical? + // Actually, let's try to include Swap if it's significant, but it might be confusing if it exceeds 100% of RAM. + + // Alternative: The bar is RAM. + // Green: Active + // Yellow: Balloon + + const activePercent = (active / props.total) * 100; + const balloonPercent = (balloon / props.total) * 100; + + const segs = [ + { type: 'Active', bytes: active, percent: activePercent, color: MEMORY_COLORS.active }, + { type: 'Balloon', bytes: balloon, percent: balloonPercent, color: MEMORY_COLORS.balloon }, + ].filter(s => s.bytes > 0); + + return segs; + }); + + const swapPercent = createMemo(() => { + if (!props.swapTotal || props.swapTotal <= 0) return 0; + return (props.swapUsed || 0) / props.swapTotal * 100; + }); + + const hasSwap = createMemo(() => (props.swapTotal || 0) > 0); + + const displayLabel = createMemo(() => { + const percent = (props.used / props.total) * 100; + return formatPercent(percent); + }); + + const displaySublabel = createMemo(() => { + return `${formatBytes(props.used, 0)}/${formatBytes(props.total, 0)}`; + }); + + // Estimate text width for label fitting (approx 6px per char + padding) + const estimateTextWidth = (text: string): number => { + return text.length * 6 + 10; + }; + + const showSublabel = createMemo(() => { + const fullText = `${displayLabel()} (${displaySublabel()})`; + return containerWidth() >= estimateTextWidth(fullText); + }); + + const handleMouseEnter = (e: MouseEvent) => { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); + setShowTooltip(true); + }; + + const handleMouseLeave = () => { + setShowTooltip(false); + }; + + return ( +
+
+ {/* Stacked segments */} +
+ + {(segment, idx) => ( +
+ )} + +
+ + {/* Swap Indicator (Thin line at bottom if swap is used) */} + {/* Swap Indicator (Thin line at bottom if swap is used) */} + 0}> +
+ + + {/* Label overlay */} + + + {displayLabel()} + + + ({displaySublabel()}) + + + + +
+ + {/* Tooltip */} + + +
+
+
+ Memory Composition +
+ + {/* RAM Breakdown */} +
+ Active + + {formatBytes(Math.max(0, props.used - (props.balloon || 0)), 0)} + +
+ + 0}> +
+ Balloon + + {formatBytes(props.balloon || 0, 0)} + +
+
+ +
+ Free + + {formatBytes(props.total - props.used, 0)} + +
+ + {/* Swap Section */} + +
+
+ Swap + + {formatBytes(props.swapUsed || 0, 0)} / {formatBytes(props.swapTotal || 0, 0)} + +
+
+
+
+
+
+
+
+ ); +} diff --git a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx index a635ac7..ab1f1d2 100644 --- a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx +++ b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx @@ -1,9 +1,10 @@ import { Component, For, Show, createMemo, createSignal } from 'solid-js'; +import { StackedContainerBar } from './StackedContainerBar'; import type { DockerHost } from '@/types/api'; import { Card } from '@/components/shared/Card'; import { renderDockerStatusBadge } from './DockerStatusBadge'; import { resolveHostRuntime } from './runtimeDisplay'; -import { formatPercent, formatUptime } from '@/utils/format'; +import { formatUptime } from '@/utils/format'; import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { buildMetricKey } from '@/utils/metricsKeys'; import { StatusDot } from '@/components/shared/StatusDot'; @@ -20,6 +21,8 @@ export interface DockerHostSummary { diskLabel?: string; runningPercent: number; runningCount: number; + stoppedCount: number; + errorCount: number; totalCount: number; uptimeSeconds: number; lastSeenRelative: string; @@ -64,18 +67,7 @@ export const DockerHostSummaryTable: Component = (p return lastSeen; }; - const runningStatusClass = (summary: DockerHostSummary) => { - if (!summary.totalCount || summary.totalCount <= 0) { - return 'text-gray-400 dark:text-gray-500'; - } - if (summary.runningPercent >= 99) { - return 'text-green-600 dark:text-green-400'; - } - if (summary.runningPercent >= 70) { - return 'text-yellow-600 dark:text-yellow-400'; - } - return 'text-red-600 dark:text-red-400'; - }; + const sortedSummaries = createMemo(() => { const list = [...props.summaries()]; @@ -320,17 +312,17 @@ export const DockerHostSummaryTable: Component = (p /> -
+
0} fallback={} > - - {summary.runningCount}/{summary.totalCount} - +
diff --git a/frontend-modern/src/components/Docker/DockerHosts.tsx b/frontend-modern/src/components/Docker/DockerHosts.tsx index ee0bf62..9f6020a 100644 --- a/frontend-modern/src/components/Docker/DockerHosts.tsx +++ b/frontend-modern/src/components/Docker/DockerHosts.tsx @@ -84,6 +84,13 @@ export const DockerHosts: Component = (props) => { const totalContainers = host.containers?.length ?? 0; const runningContainers = host.containers?.filter((container) => container.state?.toLowerCase() === 'running').length ?? 0; + const stoppedContainers = + host.containers?.filter((container) => + ['exited', 'stopped', 'created'].includes(container.state?.toLowerCase() || '') + ).length ?? 0; + // Count anything that isn't running or stopped/created as an error/warning state (restarting, dead, paused, etc) + const errorContainers = totalContainers - runningContainers - stoppedContainers; + const runningPercent = totalContainers > 0 ? clampPercent((runningContainers / totalContainers) * 100) : 0; const cpuPercent = clampPercent(host.cpuUsagePercent ?? 0); @@ -128,6 +135,8 @@ export const DockerHosts: Component = (props) => { diskLabel, runningPercent, runningCount: runningContainers, + stoppedCount: stoppedContainers, + errorCount: errorContainers, totalCount: totalContainers, uptimeSeconds, lastSeenRelative, diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index e22d9c2..3a89229 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -22,6 +22,7 @@ import { } from '@/utils/status'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { ResponsiveMetricCell, useGridTemplate } from '@/components/shared/responsive'; +import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; import type { ColumnConfig } from '@/types/responsive'; const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => { @@ -1195,17 +1196,34 @@ const DockerContainerRow: Component<{
); case 'memory': + const memoryTotal = () => container.memoryLimitBytes && container.memoryLimitBytes > 0 + ? container.memoryLimitBytes + : host.totalMemoryBytes; + return (
- 0} - showMobile={props.isMobile()} - class="w-full" - /> + +
+ 0} + showMobile={true} + class="w-full" + /> +
+
+
); case 'disk': diff --git a/frontend-modern/src/components/Docker/StackedContainerBar.tsx b/frontend-modern/src/components/Docker/StackedContainerBar.tsx new file mode 100644 index 0000000..59f4e16 --- /dev/null +++ b/frontend-modern/src/components/Docker/StackedContainerBar.tsx @@ -0,0 +1,115 @@ +import { Show, For, createMemo, createSignal } from 'solid-js'; +import { Portal } from 'solid-js/web'; + +interface StackedContainerBarProps { + running: number; + stopped: number; + error: number; + total: number; +} + +// Colors for container states +const STATE_COLORS = { + running: 'rgba(34, 197, 94, 0.6)', // green + stopped: 'rgba(156, 163, 175, 0.6)', // gray + error: 'rgba(239, 68, 68, 0.6)', // red +}; + +export function StackedContainerBar(props: StackedContainerBarProps) { + const [showTooltip, setShowTooltip] = createSignal(false); + const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); + let containerRef: HTMLDivElement | undefined; + + // Calculate segments + const segments = createMemo(() => { + if (props.total <= 0) return []; + + const runningPercent = (props.running / props.total) * 100; + const stoppedPercent = (props.stopped / props.total) * 100; + const errorPercent = (props.error / props.total) * 100; + + return [ + { type: 'Running', count: props.running, percent: runningPercent, color: STATE_COLORS.running }, + { type: 'Stopped', count: props.stopped, percent: stoppedPercent, color: STATE_COLORS.stopped }, + { type: 'Error', count: props.error, percent: errorPercent, color: STATE_COLORS.error }, + ].filter(s => s.count > 0); + }); + + const handleMouseEnter = (e: MouseEvent) => { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); + setShowTooltip(true); + }; + + const handleMouseLeave = () => { + setShowTooltip(false); + }; + + return ( +
+
+ {/* Stacked segments */} +
+ + {(segment, idx) => ( +
+ )} + +
+ + {/* Label overlay */} + + + {props.running}/{props.total} + + +
+ + {/* Tooltip */} + + +
+
+
+ Container Status +
+ + {(item, idx) => ( +
0 }}> + + {item.type} + + + {item.count} + +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx index df807d4..229c3e2 100644 --- a/frontend-modern/src/components/Hosts/HostsOverview.tsx +++ b/frontend-modern/src/components/Hosts/HostsOverview.tsx @@ -12,6 +12,8 @@ import { useWebSocket } from '@/App'; import { StatusDot } from '@/components/shared/StatusDot'; import { getHostStatusIndicator } from '@/utils/status'; import { ResponsiveMetricCell, MetricText, useGridTemplate } from '@/components/shared/responsive'; +import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; +import { TemperatureGauge } from '@/components/shared/TemperatureGauge'; import type { ColumnConfig } from '@/types/responsive'; import { STANDARD_COLUMNS } from '@/types/responsive'; @@ -25,6 +27,7 @@ const HOST_COLUMNS: ColumnConfig[] = [ { id: 'platform', label: 'Platform', priority: 'primary', minWidth: '120px', flex: 1, sortable: true, sortKey: 'platform' }, { ...STANDARD_COLUMNS.cpu, maxWidth: '156px', sortKey: 'cpu' }, { ...STANDARD_COLUMNS.memory, maxWidth: '156px', sortKey: 'memory' }, + { id: 'temperature', label: 'Temp', priority: 'secondary', minWidth: '80px', maxWidth: '100px' }, { id: 'disk', label: 'Disk', minWidth: '140px', maxWidth: '156px', priority: 'secondary' }, { ...STANDARD_COLUMNS.uptime, maxWidth: '100px', align: 'right', sortKey: 'uptime' }, ]; @@ -153,8 +156,7 @@ export const HostsOverview: Component = (props) => { const renderCell = (column: ColumnConfig, host: Host) => { const cpuPercent = () => host.cpuUsage ?? 0; const memPercent = () => host.memory?.usage ?? 0; - const memUsed = () => formatBytes(host.memory?.used ?? 0, 0); - const memTotal = () => formatBytes(host.memory?.total ?? 0, 0); + const hostStatus = createMemo(() => getHostStatusIndicator(host)); switch (column.id) { @@ -215,15 +217,55 @@ export const HostsOverview: Component = (props) => { case 'memory': return (
- + +
+ +
+
+ +
+ ); + case 'temperature': + const tempValue = (() => { + if (!host.sensors?.temperatureCelsius) return null; + const temps = host.sensors.temperatureCelsius; + + // Try to find a "package" or "composite" temperature first + const packageKey = Object.keys(temps).find(k => + k.toLowerCase().includes('package') || + k.toLowerCase().includes('composite') || + k.toLowerCase().includes('tctl') + ); + + if (packageKey) return temps[packageKey]; + + // Fallback: average of all core temps + const coreKeys = Object.keys(temps).filter(k => k.toLowerCase().includes('core')); + if (coreKeys.length > 0) { + const sum = coreKeys.reduce((acc, k) => acc + temps[k], 0); + return sum / coreKeys.length; + } + + // Fallback: just take the first available value if any + const values = Object.values(temps); + if (values.length > 0) return values[0]; + + return null; + })(); + + return ( +
+ —}> + +
); case 'disk': diff --git a/frontend-modern/src/components/Replication/Replication.tsx b/frontend-modern/src/components/Replication/Replication.tsx index b3acbec..88eef06 100644 --- a/frontend-modern/src/components/Replication/Replication.tsx +++ b/frontend-modern/src/components/Replication/Replication.tsx @@ -5,7 +5,8 @@ import { useWebSocket } from '@/App'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format'; -import type { ReplicationJob } from '@/types/api'; +import { StatusDot } from '@/components/shared/StatusDot'; +import { getReplicationJobStatusIndicator } from '@/utils/status'; function formatDuration(durationSeconds?: number, durationHuman?: string): string { if (durationHuman && durationHuman.trim()) return durationHuman; @@ -24,29 +25,7 @@ function formatDuration(durationSeconds?: number, durationHuman?: string): strin return `${hours}:${minutes}:${seconds}`; } -function getStatusBadge(job: ReplicationJob) { - const status = (job.status || job.state || '').toLowerCase(); - const lastStatus = (job.lastSyncStatus || '').toLowerCase(); - if (status.includes('error') || lastStatus.includes('error')) { - return { - tone: 'danger' as const, - label: status || lastStatus || 'Error', - }; - } - - if (status.includes('sync')) { - return { - tone: 'warning' as const, - label: job.status || job.state || 'Syncing', - }; - } - - return { - tone: 'success' as const, - label: job.status || job.state || 'Idle', - }; -} function formatRate(limit?: number): string { if (!limit || limit <= 0) return '—'; @@ -119,7 +98,7 @@ const Replication: Component = () => { {(job) => { - const badge = getStatusBadge(job); + const indicator = getReplicationJobStatusIndicator(job); return ( @@ -173,16 +152,17 @@ const Replication: Component = () => { - - {badge.label} - +
+ + + {indicator.label} + +
{job.error} diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index 35a3b15..d4f46c3 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -8,6 +8,7 @@ import { ComponentErrorBoundary } from '@/components/ErrorBoundary'; import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector'; import { StorageFilter } from './StorageFilter'; import { DiskList } from './DiskList'; +import { ZFSHealthMap } from './ZFSHealthMap'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader'; @@ -1057,6 +1058,12 @@ const Storage: Component = () => { > {storage.name} + {/* ZFS Health Map */} + 0}> +
+ +
+
{/* ZFS Health Badge */} = (props) => { + const [hoveredDevice, setHoveredDevice] = createSignal(null); + const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); + + // Filter out non-disk devices if needed, or just show all top-level vdevs/disks + // Usually 'disk', 'mirror', 'raidz*' are top level. + // We want to visualize the health of the underlying storage units. + // If the API returns a flat list of all devices including children, we might want to filter. + // Assuming 'devices' contains the relevant units to display. + const devices = () => props.pool.devices || []; + + const getDeviceColor = (device: ZFSDevice) => { + const state = device.state?.toUpperCase(); + if (state === 'ONLINE') return 'bg-green-500/80 dark:bg-green-500/70 hover:bg-green-400'; + if (state === 'DEGRADED') return 'bg-yellow-500/80 dark:bg-yellow-500/70 hover:bg-yellow-400'; + if (state === 'FAULTED' || state === 'UNAVAIL' || state === 'OFFLINE') return 'bg-red-500/80 dark:bg-red-500/70 hover:bg-red-400'; + return 'bg-gray-400/80 dark:bg-gray-500/70 hover:bg-gray-300'; + }; + + const isResilvering = (device: ZFSDevice) => { + // Check pool scan status or device message/state for resilvering + const scan = props.pool.scan?.toLowerCase() || ''; + return scan.includes('resilver') || (device.message || '').toLowerCase().includes('resilver'); + }; + + const handleMouseEnter = (e: MouseEvent, device: ZFSDevice) => { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); + setHoveredDevice(device); + }; + + const handleMouseLeave = () => { + setHoveredDevice(null); + }; + + return ( +
+ + {(device) => ( +
handleMouseEnter(e, device)} + onMouseLeave={handleMouseLeave} + /> + )} + + + + +
+
+
+ {hoveredDevice()?.name} +
+
+ {hoveredDevice()?.type} +
+
+ + {hoveredDevice()?.state} + + + + (E: {hoveredDevice()?.readErrors}/{hoveredDevice()?.writeErrors}/{hoveredDevice()?.checksumErrors}) + + +
+ +
+ {hoveredDevice()?.message} +
+
+
+
+
+
+
+ ); +}; diff --git a/frontend-modern/src/components/shared/NodeSummaryTable.tsx b/frontend-modern/src/components/shared/NodeSummaryTable.tsx index 522830b..ee04b1c 100644 --- a/frontend-modern/src/components/shared/NodeSummaryTable.tsx +++ b/frontend-modern/src/components/shared/NodeSummaryTable.tsx @@ -11,7 +11,9 @@ import { buildMetricKey } from '@/utils/metricsKeys'; import { StatusDot } from '@/components/shared/StatusDot'; import { getNodeStatusIndicator, getPBSStatusIndicator } from '@/utils/status'; import { type ColumnPriority } from '@/hooks/useBreakpoint'; -import { ResponsiveMetricCell, useGridTemplate } from '@/components/shared/responsive'; +import { ResponsiveMetricCell, MetricText, useGridTemplate } from '@/components/shared/responsive'; +import { StackedMemoryBar } from '@/components/Dashboard/StackedMemoryBar'; +import { TemperatureGauge } from '@/components/shared/TemperatureGauge'; // Icons for mobile headers const ClockIcon = (props: { class?: string }) => ( @@ -712,21 +714,32 @@ export const NodeSummaryTable: Component = (props) => { case 'memory': return (
- + +
+ +
+
+
); @@ -760,13 +773,6 @@ export const NodeSummaryTable: Component = (props) => { > {(() => { const value = cpuTemperatureValue as number; - const severityClass = - value >= 80 - ? 'text-red-600 dark:text-red-400' - : value >= 70 - ? 'text-yellow-600 dark:text-yellow-400' - : 'text-green-600 dark:text-green-400'; - const temp = node!.temperature; const cpuMinValue = typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null; @@ -780,43 +786,23 @@ export const NodeSummaryTable: Component = (props) => { const hasGPU = gpus.length > 0; if (hasMinMax || hasGPU) { - const min = Math.round(cpuMinValue!); - const max = Math.round(cpuMaxValue!); - - const getTooltipColor = (temp: number) => { - if (temp >= 80) return 'text-red-400'; - if (temp >= 70) return 'text-yellow-400'; - return 'text-green-400'; - }; + const min = typeof cpuMinValue === 'number' ? Math.round(cpuMinValue) : undefined; + const max = typeof cpuMaxValue === 'number' ? Math.round(cpuMaxValue) : undefined; return ( - - - {value}°C - - - +
`${g.edge ?? g.junction ?? g.mem}°C`).join(', ')}` : ''}`}> + +
); } - return {value}°C; + return ( + + ); })()}
@@ -852,9 +838,9 @@ export const NodeSummaryTable: Component = (props) => {
); }} -
-
-
- + +
+ + ); }; diff --git a/frontend-modern/src/components/shared/TemperatureGauge.tsx b/frontend-modern/src/components/shared/TemperatureGauge.tsx new file mode 100644 index 0000000..bbb3b18 --- /dev/null +++ b/frontend-modern/src/components/shared/TemperatureGauge.tsx @@ -0,0 +1,67 @@ +import { Component, createMemo, Show } from 'solid-js'; + +interface TemperatureGaugeProps { + value: number; + min?: number | null; + max?: number | null; + critical?: number; + warning?: number; + label?: string; + class?: string; +} + +export const TemperatureGauge: Component = (props) => { + const critical = props.critical ?? 80; + const warning = props.warning ?? 70; + + // Calculate percentage (assuming 0-100°C range for simplicity, or slightly dynamic) + // Most CPUs idle around 30-40, max out at 100. + const percent = createMemo(() => Math.min(100, Math.max(0, props.value))); + + const colorClass = createMemo(() => { + if (props.value >= critical) return 'bg-red-500 dark:bg-red-500'; + if (props.value >= warning) return 'bg-yellow-500 dark:bg-yellow-500'; + return 'bg-green-500 dark:bg-green-500'; + }); + + const textColorClass = createMemo(() => { + if (props.value >= critical) return 'text-red-700 dark:text-red-400'; + if (props.value >= warning) return 'text-yellow-700 dark:text-yellow-400'; + return 'text-green-700 dark:text-green-400'; + }); + + return ( +
+ {/* Text Value */} + + {Math.round(props.value)}°C + + + {/* Bar */} +
+
+ + {/* Min Marker */} + +
+ + + {/* Max Marker */} + +
+ +
+
+ ); +}; diff --git a/frontend-modern/src/utils/status.ts b/frontend-modern/src/utils/status.ts index 168b054..279aefb 100644 --- a/frontend-modern/src/utils/status.ts +++ b/frontend-modern/src/utils/status.ts @@ -7,6 +7,7 @@ import type { DockerHost, DockerContainer, DockerService, + ReplicationJob, } from '@/types/api'; const ONLINE_STATUS = 'online'; @@ -244,3 +245,21 @@ export function getDockerServiceStatusIndicator( return { variant: 'warning', label: `Degraded (${running}/${desired})` }; } + +export function getReplicationJobStatusIndicator( + job: Partial | undefined | null, +): StatusIndicator { + if (!job) return defaultIndicator; + const status = normalize(job.status || job.state); + const lastStatus = normalize(job.lastSyncStatus); + + if (status.includes('error') || lastStatus.includes('error')) { + return { variant: 'danger', label: formatStatusLabel(status || lastStatus, 'Error') }; + } + + if (status.includes('sync')) { + return { variant: 'warning', label: formatStatusLabel(status, 'Syncing') }; + } + + return { variant: 'success', label: formatStatusLabel(status, 'Idle') }; +} diff --git a/internal/mock/generator.go b/internal/mock/generator.go index 0fee6de..62f99bb 100644 --- a/internal/mock/generator.go +++ b/internal/mock/generator.go @@ -874,9 +874,14 @@ func generateVM(nodeName string, instance string, vmid int, config MockConfig) m memUsage = 0.80 + rand.Float64()*0.1 // 80-90% } usedMem := int64(float64(totalMem) * memUsage) - balloon := totalMem - if rand.Float64() < 0.7 { - balloon = int64(float64(totalMem) * (0.65 + rand.Float64()*0.25)) + balloon := int64(0) + if rand.Float64() < 0.2 { + // Simulate ballooning active (10-30% of total memory) + balloon = int64(float64(totalMem) * (0.1 + rand.Float64()*0.2)) + // Ensure balloon doesn't exceed used memory (which would result in 0 active) + if balloon > usedMem { + balloon = int64(float64(usedMem) * 0.8) + } } swapTotal := int64(0) @@ -1877,10 +1882,8 @@ func generateContainer(nodeName string, instance string, vmid int, config MockCo memUsage = 0.82 + rand.Float64()*0.08 // 82-90% } usedMem := int64(float64(totalMem) * memUsage) - balloon := totalMem - if rand.Float64() < 0.5 { - balloon = int64(float64(totalMem) * (0.7 + rand.Float64()*0.2)) - } + balloon := int64(0) + // LXC containers typically don't use ballooning in the same way, keep it 0 for clear "Active" usage swapTotal := int64(0) swapUsed := int64(0)