mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 18:53:37 +00:00
Polish node row styling and restore disk detail support
This commit is contained in:
@@ -956,7 +956,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
<td colspan="11" class="py-2 px-3">
|
||||
<div class="flex items-stretch gap-3 rounded-lg border border-slate-200 dark:border-slate-700 bg-white/90 dark:bg-slate-900/40 shadow-sm px-3 py-2">
|
||||
<div class={`w-1 rounded-full transition-colors ${getOfflineAccent(isNodeOnline)}`}></div>
|
||||
<div class="flex w-full flex-wrap items-center justify-between gap-4 text-[11px] sm:text-xs text-slate-600 dark:text-slate-200">
|
||||
<div class="flex w-full flex-wrap items-center gap-4 text-[11px] sm:text-xs text-slate-600 dark:text-slate-200">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href={nodeUrl}
|
||||
@@ -975,30 +975,10 @@ export function Dashboard(props: DashboardProps) {
|
||||
: 'bg-slate-200 text-slate-600 dark:bg-slate-700/60 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 text-slate-500 dark:text-slate-300">
|
||||
<span class="inline-flex items-center gap-1 font-medium">
|
||||
<span
|
||||
class={`h-2.5 w-2.5 rounded-full ${
|
||||
isNodeOnline ? 'bg-emerald-500' : 'bg-rose-500'
|
||||
}`}
|
||||
></span>
|
||||
{isNodeOnline ? 'Online' : 'Offline'}
|
||||
{node!.isClusterMember ? node!.clusterName : 'Standalone'}
|
||||
</span>
|
||||
<Show when={node && (node.uptime || 0) > 0}>
|
||||
<span class="text-slate-400">|</span>
|
||||
<span class="font-medium text-slate-500 dark:text-slate-300">
|
||||
Uptime {formatUptime(node!.uptime)}
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-slate-400">|</span>
|
||||
<span class="font-medium text-slate-600 dark:text-slate-200">
|
||||
{guestCount === 1 ? '1 guest' : `${guestCount} guests`}
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { For, Show, createSignal } from 'solid-js';
|
||||
import type { Disk } from '@/types/api';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { MetricBar } from './MetricBar';
|
||||
|
||||
interface DiskListProps {
|
||||
disks: Disk[];
|
||||
diskStatusReason?: string;
|
||||
}
|
||||
|
||||
export function DiskList(props: DiskListProps) {
|
||||
const [expanded, setExpanded] = createSignal(false);
|
||||
|
||||
const getDiskStatusTooltip = () => {
|
||||
const reason = props.diskStatusReason;
|
||||
|
||||
switch (reason) {
|
||||
case 'agent-not-running':
|
||||
return 'Guest agent not running. Install and start qemu-guest-agent in the VM.';
|
||||
case 'agent-timeout':
|
||||
return 'Guest agent timeout. Agent may need to be restarted.';
|
||||
case 'permission-denied':
|
||||
return 'Permission denied. Check that your Pulse user/token has VM.Monitor permission (PVE 8) or VM.GuestAgent.Audit permission (PVE 9).';
|
||||
case 'agent-disabled':
|
||||
return 'Guest agent is disabled in VM configuration. Enable it in VM Options.';
|
||||
case 'no-filesystems':
|
||||
return 'No filesystems found. VM may be booting or using a Live ISO.';
|
||||
case 'special-filesystems-only':
|
||||
return 'Only special filesystems detected (ISO/squashfs). This is normal for Live systems.';
|
||||
case 'agent-error':
|
||||
return 'Error communicating with guest agent.';
|
||||
case 'no-data':
|
||||
return 'No disk data available from Proxmox API.';
|
||||
default:
|
||||
return 'Disk stats unavailable. Guest agent may not be installed.';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.disks && props.disks.length > 0}
|
||||
fallback={
|
||||
<span class="text-gray-400 text-sm cursor-help" title={getDiskStatusTooltip()}>
|
||||
-
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
{/* Show first disk or aggregated view when collapsed */}
|
||||
<Show when={!expanded() && props.disks.length === 1}>
|
||||
<MetricBar
|
||||
value={(props.disks[0].used / props.disks[0].total) * 100}
|
||||
label={`${((props.disks[0].used / props.disks[0].total) * 100).toFixed(0)}%`}
|
||||
sublabel={`${formatBytes(props.disks[0].used)}/${formatBytes(props.disks[0].total)}`}
|
||||
type="disk"
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={!expanded() && props.disks.length > 1}>
|
||||
<div class="flex items-center gap-2">
|
||||
<MetricBar
|
||||
value={
|
||||
(props.disks.reduce((acc, d) => acc + d.used, 0) /
|
||||
props.disks.reduce((acc, d) => acc + d.total, 0)) *
|
||||
100
|
||||
}
|
||||
label={`${(
|
||||
(props.disks.reduce((acc, d) => acc + d.used, 0) /
|
||||
props.disks.reduce((acc, d) => acc + d.total, 0)) *
|
||||
100
|
||||
).toFixed(0)}%`}
|
||||
sublabel={`${formatBytes(
|
||||
props.disks.reduce((acc, d) => acc + d.used, 0)
|
||||
)}/${formatBytes(props.disks.reduce((acc, d) => acc + d.total, 0))}`}
|
||||
type="disk"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
class="text-xs text-blue-600 dark:text-blue-400 hover:underline whitespace-nowrap"
|
||||
title="Show individual disks"
|
||||
>
|
||||
{props.disks.length} disks
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Expanded view showing all individual disks */}
|
||||
<Show when={expanded()}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<For each={props.disks}>
|
||||
{(disk) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1">
|
||||
<MetricBar
|
||||
value={(disk.used / disk.total) * 100}
|
||||
label={`${((disk.used / disk.total) * 100).toFixed(0)}%`}
|
||||
sublabel={`${formatBytes(disk.used)}/${formatBytes(disk.total)}`}
|
||||
type="disk"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap"
|
||||
title={disk.type ? `${disk.type} filesystem` : undefined}
|
||||
>
|
||||
{disk.mountpoint || disk.device || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
class="text-xs text-blue-600 dark:text-blue-400 hover:underline text-left"
|
||||
>
|
||||
Collapse
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { formatBytes, formatUptime } from '@/utils/format';
|
||||
import { MetricBar } from './MetricBar';
|
||||
import { IOMetric } from './IOMetric';
|
||||
import { TagBadges } from './TagBadges';
|
||||
import { DiskList } from './DiskList';
|
||||
import { GuestMetadataAPI } from '@/api/guestMetadata';
|
||||
|
||||
type Guest = VM | Container;
|
||||
|
||||
@@ -29,6 +31,13 @@ interface GuestRowProps {
|
||||
|
||||
export function GuestRow(props: GuestRowProps) {
|
||||
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
|
||||
const guestId = createMemo(() => {
|
||||
if (props.guest.id) return props.guest.id;
|
||||
if (props.guest.instance === props.guest.node) {
|
||||
return `${props.guest.node}-${props.guest.vmid}`;
|
||||
}
|
||||
return `${props.guest.instance}-${props.guest.node}-${props.guest.vmid}`;
|
||||
});
|
||||
|
||||
// Update custom URL when prop changes
|
||||
createEffect(() => {
|
||||
@@ -236,22 +245,32 @@ export function GuestRow(props: GuestRowProps) {
|
||||
{/* Disk */}
|
||||
<td class="py-0.5 px-2 w-[140px]">
|
||||
<Show
|
||||
when={props.guest.disk && props.guest.disk.total > 0 && diskPercent() !== -1}
|
||||
when={props.guest.disks && props.guest.disks.length > 0}
|
||||
fallback={
|
||||
<span class="text-gray-400 text-sm cursor-help" title={getDiskStatusTooltip()}>
|
||||
-
|
||||
</span>
|
||||
<Show
|
||||
when={props.guest.disk && props.guest.disk.total > 0 && diskPercent() !== -1}
|
||||
fallback={
|
||||
<span class="text-gray-400 text-sm cursor-help" title={getDiskStatusTooltip()}>
|
||||
-
|
||||
</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>
|
||||
}
|
||||
>
|
||||
<MetricBar
|
||||
value={diskPercent()}
|
||||
label={`${diskPercent().toFixed(0)}%`}
|
||||
sublabel={
|
||||
props.guest.disk
|
||||
? `${formatBytes(props.guest.disk.used)}/${formatBytes(props.guest.disk.total)}`
|
||||
: undefined
|
||||
}
|
||||
type="disk"
|
||||
<DiskList
|
||||
disks={props.guest.disks!}
|
||||
diskStatusReason={isVM(props.guest) ? props.guest.diskStatusReason : undefined}
|
||||
/>
|
||||
</Show>
|
||||
</td>
|
||||
|
||||
@@ -572,24 +572,49 @@ const Storage: Component = () => {
|
||||
<>
|
||||
{/* Group Header */}
|
||||
<Show when={viewMode() === 'node' && node}>
|
||||
{(validNode) => (
|
||||
<tr class="bg-gray-50/50 dark:bg-gray-700/30">
|
||||
<td
|
||||
class="p-0.5 px-1.5 text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
colspan="9"
|
||||
>
|
||||
<a
|
||||
href={validNode().host || `https://${validNode().name}:8006`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer"
|
||||
title={`Open ${validNode().name} web interface`}
|
||||
>
|
||||
{validNode().name}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(validNode) => {
|
||||
const nodeData = validNode();
|
||||
const isOnline =
|
||||
nodeData.status === 'online' && (nodeData.uptime || 0) > 0;
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td colspan="9" class="py-2 px-3">
|
||||
<div class="flex items-stretch gap-3 rounded-lg border border-slate-200 dark:border-slate-700 bg-white/90 dark:bg-slate-900/40 shadow-sm px-3 py-2">
|
||||
<div
|
||||
class={`w-1 rounded-full transition-colors ${
|
||||
isOnline
|
||||
? 'bg-emerald-300 dark:bg-emerald-500/80'
|
||||
: 'bg-rose-300 dark:bg-rose-500/80'
|
||||
}`}
|
||||
></div>
|
||||
<div class="flex flex-wrap items-center gap-3 text-[11px] sm:text-xs text-slate-600 dark:text-slate-200">
|
||||
<a
|
||||
href={nodeData.host || `https://${nodeData.name}:8006`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-slate-800 dark:text-slate-50 hover:text-sky-600 dark:hover:text-sky-400 transition-colors duration-150 cursor-pointer font-semibold text-sm sm:text-base"
|
||||
title={`Open ${nodeData.name} web interface`}
|
||||
>
|
||||
{nodeData.name}
|
||||
</a>
|
||||
<Show when={nodeData.isClusterMember !== undefined}>
|
||||
<span
|
||||
class={`rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${
|
||||
nodeData.isClusterMember
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
: 'bg-slate-200 text-slate-600 dark:bg-slate-700/60 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{nodeData.isClusterMember ? nodeData.clusterName : 'Standalone'}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
|
||||
{/* Storage Rows */}
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface VM {
|
||||
cpus: number;
|
||||
memory: Memory;
|
||||
disk: Disk;
|
||||
disks?: Disk[];
|
||||
diskStatusReason?: string;
|
||||
networkIn: number;
|
||||
networkOut: number;
|
||||
@@ -77,6 +78,7 @@ export interface Container {
|
||||
cpus: number;
|
||||
memory: Memory;
|
||||
disk: Disk;
|
||||
disks?: Disk[];
|
||||
networkIn: number;
|
||||
networkOut: number;
|
||||
diskRead: number;
|
||||
@@ -248,6 +250,9 @@ export interface Disk {
|
||||
used: number;
|
||||
free: number;
|
||||
usage: number;
|
||||
mountpoint?: string;
|
||||
type?: string;
|
||||
device?: string;
|
||||
}
|
||||
|
||||
export interface PhysicalDisk {
|
||||
|
||||
@@ -85,6 +85,7 @@ type VM struct {
|
||||
CPUs int `json:"cpus"`
|
||||
Memory Memory `json:"memory"`
|
||||
Disk Disk `json:"disk"`
|
||||
Disks []Disk `json:"disks,omitempty"`
|
||||
DiskStatusReason string `json:"diskStatusReason,omitempty"` // Why disk stats are unavailable
|
||||
NetworkIn int64 `json:"networkIn"`
|
||||
NetworkOut int64 `json:"networkOut"`
|
||||
@@ -111,6 +112,7 @@ type Container struct {
|
||||
CPUs int `json:"cpus"`
|
||||
Memory Memory `json:"memory"`
|
||||
Disk Disk `json:"disk"`
|
||||
Disks []Disk `json:"disks,omitempty"`
|
||||
NetworkIn int64 `json:"networkIn"`
|
||||
NetworkOut int64 `json:"networkOut"`
|
||||
DiskRead int64 `json:"diskRead"`
|
||||
@@ -305,10 +307,13 @@ type Memory struct {
|
||||
|
||||
// Disk represents disk usage
|
||||
type Disk struct {
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
Free int64 `json:"free"`
|
||||
Usage float64 `json:"usage"`
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
Free int64 `json:"free"`
|
||||
Usage float64 `json:"usage"`
|
||||
Mountpoint string `json:"mountpoint,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Device string `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
// CPUInfo represents CPU information
|
||||
|
||||
@@ -2131,7 +2131,8 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli
|
||||
diskTotal := uint64(vm.MaxDisk)
|
||||
diskFree := diskTotal - diskUsed
|
||||
diskUsage := safePercentage(float64(diskUsed), float64(diskTotal))
|
||||
diskStatusReason := "" // Empty string means we have data
|
||||
diskStatusReason := "" // Empty string means we have data
|
||||
var individualDisks []models.Disk // Store individual filesystems for multi-disk monitoring
|
||||
|
||||
// If VM shows 0 disk usage but has allocated disk, it's likely guest agent issue
|
||||
// Set to -1 to indicate "unknown" rather than showing misleading 0%
|
||||
@@ -2239,6 +2240,15 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli
|
||||
if fs.TotalBytes > 0 {
|
||||
totalBytes += fs.TotalBytes
|
||||
usedBytes += fs.UsedBytes
|
||||
individualDisks = append(individualDisks, models.Disk{
|
||||
Total: int64(fs.TotalBytes),
|
||||
Used: int64(fs.UsedBytes),
|
||||
Free: int64(fs.TotalBytes - fs.UsedBytes),
|
||||
Usage: safePercentage(float64(fs.UsedBytes), float64(fs.TotalBytes)),
|
||||
Mountpoint: fs.Mountpoint,
|
||||
Type: fs.Type,
|
||||
Device: fs.Disk,
|
||||
})
|
||||
log.Debug().
|
||||
Str("instance", instanceName).
|
||||
Str("vm", vm.Name).
|
||||
@@ -2334,6 +2344,7 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli
|
||||
Free: int64(diskFree),
|
||||
Usage: diskUsage,
|
||||
},
|
||||
Disks: individualDisks,
|
||||
DiskStatusReason: diskStatusReason,
|
||||
NetworkIn: maxInt64(0, int64(netInRate)),
|
||||
NetworkOut: maxInt64(0, int64(netOutRate)),
|
||||
|
||||
@@ -199,6 +199,7 @@ func (m *Monitor) pollVMsWithNodesOptimized(ctx context.Context, instanceName st
|
||||
diskFree := diskTotal - diskUsed
|
||||
diskUsage := safePercentage(float64(diskUsed), float64(diskTotal))
|
||||
diskStatusReason := ""
|
||||
var individualDisks []models.Disk
|
||||
|
||||
// For stopped VMs, we can't get guest agent data
|
||||
if vm.Status != "running" {
|
||||
@@ -331,6 +332,15 @@ func (m *Monitor) pollVMsWithNodesOptimized(ctx context.Context, instanceName st
|
||||
|
||||
totalBytes += fs.TotalBytes
|
||||
usedBytes += fs.UsedBytes
|
||||
individualDisks = append(individualDisks, models.Disk{
|
||||
Total: int64(fs.TotalBytes),
|
||||
Used: int64(fs.UsedBytes),
|
||||
Free: int64(fs.TotalBytes - fs.UsedBytes),
|
||||
Usage: safePercentage(float64(fs.UsedBytes), float64(fs.TotalBytes)),
|
||||
Mountpoint: fs.Mountpoint,
|
||||
Type: fs.Type,
|
||||
Device: fs.Disk,
|
||||
})
|
||||
log.Debug().
|
||||
Str("vm", vm.Name).
|
||||
Str("mountpoint", fs.Mountpoint).
|
||||
@@ -412,6 +422,7 @@ func (m *Monitor) pollVMsWithNodesOptimized(ctx context.Context, instanceName st
|
||||
Free: int64(diskFree),
|
||||
Usage: diskUsage,
|
||||
},
|
||||
Disks: individualDisks,
|
||||
DiskStatusReason: diskStatusReason,
|
||||
NetworkIn: maxInt64(0, int64(netInRate)),
|
||||
NetworkOut: maxInt64(0, int64(netOutRate)),
|
||||
|
||||
Reference in New Issue
Block a user