|
@@ -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
-
-
- {hasMinMax && (
-
- CPU: {min}-{max}°C
-
- )}
- {hasGPU && gpus.map((gpu) => {
- const gpuTemp = gpu.edge ?? gpu.junction ?? gpu.mem ?? 0;
- return (
-
- GPU: {Math.round(gpuTemp)}°C
- {gpu.edge && ` E:${Math.round(gpu.edge)}`}
- {gpu.junction && ` J:${Math.round(gpu.junction)}`}
- {gpu.mem && ` M:${Math.round(gpu.mem)}`}
-
- );
- })}
-
-
+ `${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)
|