fix: comprehensive VM disk usage reporting improvements (addresses #414, #416, #348, #367, #425)

- Always query guest agent for running VMs (cluster/resources API always returns 0)
- Show allocated disk size when guest agent unavailable (instead of misleading 0%)
- Fix duplicate mount point counting issue (#425)
- Add comprehensive logging for guest agent queries
- Include diagnostic script for troubleshooting VM disk issues
- Update both monitor.go and monitor_optimized.go for consistency
This commit is contained in:
Pulse Monitor 2025-09-06 19:52:11 +00:00
parent 11541a1f6d
commit 5325ef481e
23 changed files with 539 additions and 181 deletions

36
.tmux.conf Normal file
View file

@ -0,0 +1,36 @@
# Mouse mode toggle with Ctrl+Space - direct color setting
bind-key -n C-Space if-shell "tmux show-options -g mouse | grep on" "set-option -g mouse off; set-option -g status-style bg=yellow,fg=black; display-message \"Text Mode\"" "set-option -g mouse on; set-option -g status-style bg=blue,fg=white; display-message \"Mouse Mode\""
# Status bar configuration
set-option -g status-position bottom
set-option -g status-style bg=blue,fg=white
set-option -g status-left "[#S] "
set-option -g status-right "Ctrl+Space: toggle mouse | Ctrl+Shift+Arrows: new pane | Ctrl+X: kill pane"
set-option -g status-left-length 20
set-option -g status-right-length 90
# Option+Shift+Arrow keys for pane creation
bind-key -n M-S-Right split-window -h
bind-key -n M-S-Down split-window -v
bind-key -n M-S-Left split-window -hb
bind-key -n M-S-Up split-window -vb
# Shift+Arrow for pane navigation
bind-key -n S-Right select-pane -R
bind-key -n S-Down select-pane -D
bind-key -n S-Left select-pane -L
bind-key -n S-Up select-pane -U
# Kill pane without confirmation
bind-key -n x kill-pane
# Enable mouse mode by default
set-option -g mouse on
# Pane highlighting
set-option -g window-style "bg=colour236"
set-option -g window-active-style "bg=black"
# Other useful settings
set-option -g history-limit 10000
set-window-option -g mode-keys vi

View file

@ -84,6 +84,25 @@ pveum aclmod / -user pulse-monitor@pam -role PulseMonitor
## Troubleshooting
### Quick Diagnostic Tool
Pulse includes a diagnostic script that can identify why a VM isn't showing disk usage:
```bash
# Run on your Proxmox host
curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/test-vm-disk.sh | bash
# Or if Pulse is installed locally:
/opt/pulse/scripts/test-vm-disk.sh
```
Enter the VM ID when prompted. The script will check:
- VM running status
- Guest agent configuration
- Guest agent runtime status
- Filesystem information
- API permissions
### Guest Agent Not Responding
**Check if agent is running inside VM:**

View file

@ -67,20 +67,20 @@ export class NotificationsAPI {
// Email configuration
static async getEmailConfig(): Promise<EmailConfig> {
const backendConfig = await apiFetchJSON(`${this.baseUrl}/email`);
const backendConfig = await apiFetchJSON<Record<string, unknown>>(`${this.baseUrl}/email`);
// Backend already returns fields with correct names (server, port)
return {
enabled: backendConfig.enabled || false,
provider: backendConfig.provider || '',
server: backendConfig.server || '',
port: backendConfig.port || 587,
username: backendConfig.username || '',
password: backendConfig.password || '',
from: backendConfig.from || '',
to: backendConfig.to || [],
tls: backendConfig.tls || false,
startTLS: backendConfig.startTLS || false
enabled: (backendConfig.enabled as boolean) || false,
provider: (backendConfig.provider as string) || '',
server: (backendConfig.server as string) || '',
port: (backendConfig.port as number) || 587,
username: (backendConfig.username as string) || '',
password: (backendConfig.password as string) || '',
from: (backendConfig.from as string) || '',
to: (backendConfig.to as string[]) || [],
tls: (backendConfig.tls as boolean) || false,
startTLS: (backendConfig.startTLS as boolean) || false
};
}

View file

@ -1,21 +1,33 @@
import { For, Show } from 'solid-js';
import type { Alert } from '@/types/api';
interface Resource {
id: string;
name: string;
node?: string;
instance?: string;
type?: string;
thresholds?: Record<string, number>;
disabled?: boolean;
nodeConnectivity?: boolean;
[key: string]: unknown;
}
interface ResourceTableProps {
title: string;
resources?: any[];
groupedResources?: Record<string, any[]>;
resources?: Resource[];
groupedResources?: Record<string, Resource[]>;
columns: string[];
activeAlerts?: Record<string, Alert>;
onEdit: (resourceId: string, thresholds: any, defaults: any) => void;
onEdit: (resourceId: string, thresholds: Record<string, number>, defaults: Record<string, number>) => void;
onSaveEdit: (resourceId: string) => void;
onCancelEdit: () => void;
onRemoveOverride: (resourceId: string) => void;
onToggleDisabled?: (resourceId: string) => void;
onToggleNodeConnectivity?: (nodeId: string) => void;
editingId: () => string | null;
editingThresholds: () => Record<string, any>;
setEditingThresholds: (value: Record<string, any>) => void;
editingThresholds: () => Record<string, number>;
setEditingThresholds: (value: Record<string, number>) => void;
formatMetricValue: (metric: string, value: number | undefined) => string;
hasActiveAlert: (resourceId: string, metric: string) => boolean;
}
@ -201,7 +213,7 @@ export function ResourceTable(props: ResourceTableProps) {
<td class="p-1 px-2 text-center">
<Show when={resource.type === 'guest' && props.onToggleDisabled}>
<button type="button"
onClick={() => props.onToggleDisabled!(resource.id)}
onClick={() => props.onToggleDisabled?.(resource.id)}
class={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${
resource.disabled
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800/50'
@ -213,7 +225,7 @@ export function ResourceTable(props: ResourceTableProps) {
</Show>
<Show when={resource.type === 'node' && props.onToggleNodeConnectivity}>
<button type="button"
onClick={() => props.onToggleNodeConnectivity!(resource.id)}
onClick={() => props.onToggleNodeConnectivity?.(resource.id)}
class={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${
resource.disableConnectivity
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800/50'
@ -226,7 +238,7 @@ export function ResourceTable(props: ResourceTableProps) {
</Show>
<Show when={resource.type === 'storage'}>
<button type="button"
onClick={() => props.onToggleDisabled!(resource.id)}
onClick={() => props.onToggleDisabled?.(resource.id)}
class={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${
resource.disabled
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800/50'
@ -238,7 +250,7 @@ export function ResourceTable(props: ResourceTableProps) {
</Show>
<Show when={resource.type === 'pbs' && props.onToggleDisabled}>
<button type="button"
onClick={() => props.onToggleDisabled!(resource.id)}
onClick={() => props.onToggleDisabled?.(resource.id)}
class={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${
resource.disabled
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800/50'
@ -422,7 +434,7 @@ export function ResourceTable(props: ResourceTableProps) {
<td class="p-1 px-2 text-center">
<Show when={resource.type === 'guest' && props.onToggleDisabled}>
<button type="button"
onClick={() => props.onToggleDisabled!(resource.id)}
onClick={() => props.onToggleDisabled?.(resource.id)}
class={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${
resource.disabled
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800/50'
@ -434,7 +446,7 @@ export function ResourceTable(props: ResourceTableProps) {
</Show>
<Show when={resource.type === 'node' && props.onToggleNodeConnectivity}>
<button type="button"
onClick={() => props.onToggleNodeConnectivity!(resource.id)}
onClick={() => props.onToggleNodeConnectivity?.(resource.id)}
class={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${
resource.disableConnectivity
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800/50'
@ -447,7 +459,7 @@ export function ResourceTable(props: ResourceTableProps) {
</Show>
<Show when={resource.type === 'storage'}>
<button type="button"
onClick={() => props.onToggleDisabled!(resource.id)}
onClick={() => props.onToggleDisabled?.(resource.id)}
class={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${
resource.disabled
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800/50'
@ -459,7 +471,7 @@ export function ResourceTable(props: ResourceTableProps) {
</Show>
<Show when={resource.type === 'pbs' && props.onToggleDisabled}>
<button type="button"
onClick={() => props.onToggleDisabled!(resource.id)}
onClick={() => props.onToggleDisabled?.(resource.id)}
class={`px-2 py-0.5 text-xs font-medium rounded transition-colors ${
resource.disabled
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-800/50'

View file

@ -1,5 +1,5 @@
import { createSignal, createMemo, Show, onMount, onCleanup } from 'solid-js';
import type { VM, Container, Node, Alert, Storage } from '@/types/api';
import type { VM, Container, Node, Alert, Storage, PBSInstance } from '@/types/api';
import { ResourceTable } from './ResourceTable';
interface Override {
@ -39,12 +39,12 @@ interface SimpleThresholds {
interface ThresholdsTableProps {
overrides: () => Override[];
setOverrides: (overrides: Override[]) => void;
rawOverridesConfig: () => Record<string, any>;
setRawOverridesConfig: (config: Record<string, any>) => void;
rawOverridesConfig: () => Record<string, unknown>;
setRawOverridesConfig: (config: Record<string, unknown>) => void;
allGuests: () => (VM | Container)[];
nodes: Node[];
storage: Storage[];
pbsInstances?: any[]; // PBS instances from state
pbsInstances?: PBSInstance[]; // PBS instances from state
guestDefaults: SimpleThresholds;
setGuestDefaults: (value: Record<string, number> | ((prev: Record<string, number>) => Record<string, number>)) => void;
nodeDefaults: SimpleThresholds;
@ -61,7 +61,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
const [searchTerm, setSearchTerm] = createSignal('');
const [showGlobalSettings, setShowGlobalSettings] = createSignal(false);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [editingThresholds, setEditingThresholds] = createSignal<Record<string, any>>({});
const [editingThresholds, setEditingThresholds] = createSignal<Record<string, number>>({});
let searchInputRef: HTMLInputElement | undefined;
@ -235,9 +235,9 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
const overridesMap = new Map(props.overrides().map(o => [o.id, o]));
// Get PBS instances from props
const pbsInstances = (props as any).pbsInstances || [];
const pbsInstances = props.pbsInstances || [];
const pbsServers = pbsInstances.filter((pbs: any) => pbs.cpu > 0 || pbs.memory > 0).map((pbs: any) => {
const pbsServers = pbsInstances.filter((pbs) => (pbs.cpu || 0) > 0 || (pbs.memory?.usage || 0) > 0).map((pbs) => {
const pbsId = `pbs-${pbs.id}`;
const override = overridesMap.get(pbsId);
@ -247,7 +247,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
const k = key as keyof typeof override.thresholds;
// PBS uses node defaults for CPU/Memory
return override.thresholds[k] !== undefined &&
override.thresholds[k] !== (props.nodeDefaults as any)[k];
override.thresholds[k] !== props.nodeDefaults[k as keyof typeof props.nodeDefaults];
});
return {
@ -273,7 +273,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
});
if (search) {
return pbsServers.filter((p: any) =>
return pbsServers.filter((p) =>
p.name.toLowerCase().includes(search) ||
p.host?.toLowerCase().includes(search)
);
@ -321,7 +321,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
});
const startEditing = (resourceId: string, currentThresholds: any, defaults: any) => {
const startEditing = (resourceId: string, currentThresholds: Record<string, number>, defaults: Record<string, number>) => {
setEditingId(resourceId);
// Merge defaults with overrides for editing
const mergedThresholds = { ...defaults, ...currentThresholds };
@ -339,7 +339,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
const defaultThresholds = resource.defaults;
// Only include values that differ from defaults
const overrideThresholds: Record<string, any> = {};
const overrideThresholds: Record<string, number> = {};
Object.keys(editedThresholds).forEach(key => {
const editedValue = editedThresholds[key];
const defaultValue = defaultThresholds[key as keyof typeof defaultThresholds];
@ -390,7 +390,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
// Update raw config
const newRawConfig = { ...props.rawOverridesConfig() };
const hysteresisThresholds: Record<string, any> = {};
const hysteresisThresholds: Record<string, number> = {};
Object.entries(overrideThresholds).forEach(([metric, value]) => {
if (value !== undefined && value !== null) {
hysteresisThresholds[metric] = {
@ -441,8 +441,8 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
const newDisabledState = forceState !== undefined ? forceState : !currentDisabledState;
// Clean the thresholds to exclude 'disabled' if it got in there
const cleanThresholds: any = { ...(existingOverride?.thresholds || {}) };
delete cleanThresholds.disabled;
const cleanThresholds: Record<string, number> = { ...(existingOverride?.thresholds || {}) };
delete (cleanThresholds as Record<string, unknown>).disabled;
// If enabling (disabled = false) and no custom thresholds exist, remove the override entirely
if (!newDisabledState && (!existingOverride || Object.keys(cleanThresholds).length === 0)) {
@ -478,7 +478,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
// Update raw config
const newRawConfig = { ...props.rawOverridesConfig() };
const hysteresisThresholds: Record<string, any> = {};
const hysteresisThresholds: Record<string, number> = {};
// Only add threshold overrides that differ from defaults
Object.entries(override.thresholds).forEach(([metric, value]) => {
@ -514,9 +514,9 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
const newDisableConnectivity = forceState !== undefined ? forceState : !currentDisableConnectivity;
// Clean the thresholds to exclude any unwanted fields
const cleanThresholds: any = { ...(existingOverride?.thresholds || {}) };
delete cleanThresholds.disabled;
delete cleanThresholds.disableConnectivity;
const cleanThresholds: Record<string, number> = { ...(existingOverride?.thresholds || {}) };
delete (cleanThresholds as Record<string, unknown>).disabled;
delete (cleanThresholds as Record<string, unknown>).disableConnectivity;
// If enabling connectivity alerts (disableConnectivity = false) and no custom thresholds exist, remove the override entirely
if (!newDisableConnectivity && Object.keys(cleanThresholds).length === 0) {
@ -550,7 +550,7 @@ export function ThresholdsTable(props: ThresholdsTableProps) {
// Update raw config
const newRawConfig = { ...props.rawOverridesConfig() };
const hysteresisThresholds: Record<string, any> = {};
const hysteresisThresholds: Record<string, number> = {};
// Add threshold configs
Object.entries(cleanThresholds).forEach(([metric, value]) => {

View file

@ -114,7 +114,9 @@ export function WebhookConfig(props: WebhookConfigProps) {
};
const editWebhook = (webhook: Webhook) => {
setEditingId(webhook.id!);
if (webhook.id) {
setEditingId(webhook.id);
}
setFormData({
...webhook,
service: webhook.service || 'generic',
@ -241,7 +243,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
{webhook.enabled ? 'Enabled' : 'Disabled'}
</button>
<button
onClick={() => props.onTest(webhook.id!)}
onClick={() => webhook.id && props.onTest(webhook.id)}
disabled={props.testing === webhook.id || !webhook.enabled}
class="px-3 py-1 text-xs text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 disabled:opacity-50"
>
@ -254,7 +256,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
Edit
</button>
<button
onClick={() => props.onDelete(webhook.id!)}
onClick={() => webhook.id && props.onDelete(webhook.id)}
class="px-3 py-1 text-xs text-red-600 hover:text-red-700 dark:text-red-400"
>
Delete

View file

@ -192,9 +192,10 @@ const UnifiedBackups: Component = () => {
// Check if any files have encryption
const isEncrypted = backup.files && Array.isArray(backup.files) &&
backup.files.some((file: any) => {
backup.files.some((file) => {
if (typeof file === 'string') return false;
return file.crypt || file.encrypted || (file.filename && file.filename.includes('.enc'));
const fileObj = file as Record<string, unknown>;
return fileObj.crypt || fileObj.encrypted || (typeof fileObj.filename === 'string' && fileObj.filename.includes('.enc'));
});
// Determine the display type based on VMID and backup type

View file

@ -76,7 +76,7 @@ export function GuestURLs(props: GuestURLsProps) {
setLoading(true);
try {
const metadata = guestMetadata();
const promises: Promise<any>[] = [];
const promises: Promise<void>[] = [];
// Update each guest that has changes
for (const [guestId, meta] of Object.entries(metadata)) {

View file

@ -2081,7 +2081,7 @@ const Settings: Component = () => {
{/* Helper function to sanitize sensitive data */}
{(() => {
const sanitizeForGitHub = (data: any) => {
const sanitizeForGitHub = (data: Record<string, unknown>) => {
// Deep clone the data
const sanitized = JSON.parse(JSON.stringify(data));
@ -2106,14 +2106,14 @@ const Settings: Component = () => {
// Sanitize nodes
if (sanitized.nodes) {
sanitized.nodes = sanitized.nodes.map((node: any, index: number) => ({
sanitized.nodes = (sanitized.nodes as Array<Record<string, unknown>>).map((node, index: number) => ({
...node,
id: `${node.type}-${index}`,
name: sanitizeHostname(node.name),
host: node.host ? node.host.replace(/https?:\/\/[^:\/]+/, 'https://REDACTED') : node.host,
tokenName: node.tokenName ? 'token-REDACTED' : node.tokenName,
clusterName: node.clusterName ? 'cluster-REDACTED' : node.clusterName,
clusterEndpoints: node.clusterEndpoints ? node.clusterEndpoints.map((ep: any, epIndex: number) => ({
clusterEndpoints: node.clusterEndpoints ? (node.clusterEndpoints as Array<Record<string, unknown>>).map((ep, epIndex: number) => ({
...ep,
NodeName: `node-${epIndex + 1}`,
Host: `node-${epIndex + 1}`,
@ -2124,7 +2124,7 @@ const Settings: Component = () => {
// Sanitize storage
if (sanitized.storage) {
sanitized.storage = sanitized.storage.map((s: any, index: number) => ({
sanitized.storage = (sanitized.storage as Array<Record<string, unknown>>).map((s, index: number) => ({
...s,
id: `storage-${index}`,
node: sanitizeHostname(s.node),
@ -2136,7 +2136,7 @@ const Settings: Component = () => {
if (sanitized.backups) {
// Sanitize PVE backup tasks
if (sanitized.backups.pveBackupTasks) {
sanitized.backups.pveBackupTasks = sanitized.backups.pveBackupTasks.map((b: any, index: number) => ({
sanitized.backups.pveBackupTasks = (sanitized.backups.pveBackupTasks as Array<Record<string, unknown>>).map((b, index: number) => ({
...b,
node: sanitizeHostname(b.node),
storage: `storage-${index}`,
@ -2146,7 +2146,7 @@ const Settings: Component = () => {
// Sanitize PVE storage backups
if (sanitized.backups.pveStorageBackups) {
sanitized.backups.pveStorageBackups = sanitized.backups.pveStorageBackups.map((b: any, index: number) => ({
sanitized.backups.pveStorageBackups = (sanitized.backups.pveStorageBackups as Array<Record<string, unknown>>).map((b, index: number) => ({
...b,
node: sanitizeHostname(b.node),
storage: `storage-${index}`,
@ -2157,7 +2157,7 @@ const Settings: Component = () => {
// Sanitize PBS backups
if (sanitized.backups.pbsBackups) {
sanitized.backups.pbsBackups = sanitized.backups.pbsBackups.map((b: any, index: number) => ({
sanitized.backups.pbsBackups = (sanitized.backups.pbsBackups as Array<Record<string, unknown>>).map((b, index: number) => ({
...b,
datastore: `datastore-${index}`,
backupId: b.backupId ? `backup-${index}` : b.backupId,
@ -2168,7 +2168,7 @@ const Settings: Component = () => {
// Sanitize active alerts
if (sanitized.activeAlerts) {
sanitized.activeAlerts = sanitized.activeAlerts.map((alert: any) => ({
sanitized.activeAlerts = (sanitized.activeAlerts as Array<Record<string, unknown>>).map((alert) => ({
...alert,
node: sanitizeHostname(alert.node),
details: alert.details ? alert.details.replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, 'xxx.xxx.xxx.xxx') : alert.details
@ -2187,7 +2187,7 @@ const Settings: Component = () => {
};
const exportDiagnostics = (sanitize: boolean) => {
let diagnostics: any = {
let diagnostics: Record<string, unknown> = {
timestamp: new Date().toISOString(),
version: '2.0.0',
environment: {
@ -2227,7 +2227,7 @@ const Settings: Component = () => {
shared: s.shared,
used: s.used,
total: s.total,
hasBackups: (state.pveBackups?.storageBackups?.filter((b: any) => b.storage === s.name).length || 0) > 0
hasBackups: (state.pveBackups?.storageBackups?.filter((b) => b.storage === s.name).length || 0) > 0
})) || [],
backups: {
pveBackupTasks: state.pveBackups?.backupTasks?.slice(0, 10) || [],

View file

@ -340,7 +340,7 @@ const Storage: Component = () => {
// Create row style with inset box-shadow for alert border
const rowStyle = createMemo(() => {
const styles: any = {};
const styles: Record<string, string> = {};
if (alertStyles.hasAlert) {
const color = alertStyles.severity === 'critical' ? '#ef4444' : '#eab308';
styles['box-shadow'] = `inset 4px 0 0 0 ${color}`;

View file

@ -179,7 +179,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
// Get row styles including box-shadow for alert border
const rowStyle = createMemo(() => {
const styles: any = {};
const styles: Record<string, string> = {};
if (isSelected()) {
styles['box-shadow'] = '0 0 0 1px rgba(59, 130, 246, 0.5), 0 2px 4px -1px rgba(0, 0, 0, 0.1)';
}

View file

@ -1,16 +1,17 @@
import { Component, createSignal, createEffect, createMemo, onMount, onCleanup } from 'solid-js';
import { useWebSocket } from '@/App';
import { NodeSummaryTable } from './NodeSummaryTable';
import type { Node, VM, Container, Storage, PBSBackup } from '@/types/api';
interface UnifiedNodeSelectorProps {
currentTab: 'dashboard' | 'storage' | 'backups';
onNodeSelect?: (nodeId: string | null, nodeType: 'pve' | 'pbs' | null) => void;
onNamespaceSelect?: (namespace: string) => void;
nodes?: any[];
filteredVms?: any[];
filteredContainers?: any[];
filteredStorage?: any[];
filteredBackups?: any[];
nodes?: Node[];
filteredVms?: VM[];
filteredContainers?: Container[];
filteredStorage?: Storage[];
filteredBackups?: PBSBackup[];
searchTerm?: string;
}
@ -50,7 +51,7 @@ export const UnifiedNodeSelector: Component<UnifiedNodeSelectorProps> = (props)
// Count PVE backups and snapshots by node
const nodes = props.nodes || state.nodes;
if (nodes) {
nodes.forEach((node: any) => {
nodes.forEach((node) => {
let count = 0;
// Count storage backups (excluding PBS backups which are counted separately)

View file

@ -17,4 +17,6 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
logger.info('Pulse monitoring dashboard starting');
render(() => <App />, root!);
if (root) {
render(() => <App />, root);
}

View file

@ -19,7 +19,6 @@ interface DestinationsRef {
}
// ScheduleConfig interface - used for loading schedule configuration
// @ts-ignore - used in type annotations
interface ScheduleConfig {
quietHours?: {
enabled: boolean;
@ -106,7 +105,7 @@ export function Alerts() {
let destinationsRef: DestinationsRef = {};
const [overrides, setOverrides] = createSignal<Override[]>([]);
const [rawOverridesConfig, setRawOverridesConfig] = createSignal<Record<string, any>>({}); // Store raw config
const [rawOverridesConfig, setRawOverridesConfig] = createSignal<Record<string, unknown>>({}); // Store raw config
// Email configuration state moved to parent to persist across tab changes
const [emailConfig, setEmailConfig] = createSignal<UIEmailConfig>({
@ -993,13 +992,13 @@ interface ThresholdsTabProps {
storageDefault: () => number;
timeThreshold: () => number;
overrides: () => Override[];
rawOverridesConfig: () => Record<string, any>;
rawOverridesConfig: () => Record<string, unknown>;
setGuestDefaults: (value: Record<string, number> | ((prev: Record<string, number>) => Record<string, number>)) => void;
setNodeDefaults: (value: Record<string, number> | ((prev: Record<string, number>) => Record<string, number>)) => void;
setStorageDefault: (value: number) => void;
setTimeThreshold: (value: number) => void;
setOverrides: (value: Override[]) => void;
setRawOverridesConfig: (value: Record<string, any>) => void;
setRawOverridesConfig: (value: Record<string, unknown>) => void;
activeAlerts: Record<string, Alert>;
setHasUnsavedChanges: (value: boolean) => void;
}
@ -1194,18 +1193,52 @@ function DestinationsTab(props: DestinationsTabProps) {
}
// History Tab - Alert history
interface QuietHoursConfig {
enabled: boolean;
start: string;
end: string;
timezone: string;
days: Record<string, boolean>;
}
interface CooldownConfig {
enabled: boolean;
minutes: number;
maxAlerts: number;
}
interface GroupingConfig {
enabled: boolean;
window: number;
maxGroupSize?: number;
byNode?: boolean;
byGuest?: boolean;
}
interface EscalationConfig {
enabled: boolean;
timeToEscalate?: number;
levels: Array<{
level?: number;
destinations?: string[];
after?: number;
notify?: string;
}>;
}
// Schedule Tab - Quiet hours, cooldown, and grouping
interface ScheduleTabProps {
hasUnsavedChanges: () => boolean;
setHasUnsavedChanges: (value: boolean) => void;
quietHours: () => any;
setQuietHours: (value: any) => void;
cooldown: () => any;
setCooldown: (value: any) => void;
grouping: () => any;
setGrouping: (value: any) => void;
escalation: () => any;
setEscalation: (value: any) => void;
quietHours: () => QuietHoursConfig;
setQuietHours: (value: QuietHoursConfig) => void;
cooldown: () => CooldownConfig;
setCooldown: (value: CooldownConfig) => void;
grouping: () => GroupingConfig;
setGrouping: (value: GroupingConfig) => void;
escalation: () => EscalationConfig;
setEscalation: (value: EscalationConfig) => void;
}
function ScheduleTab(props: ScheduleTabProps) {
@ -1639,7 +1672,7 @@ function ScheduleTab(props: ScheduleTabProps) {
</div>
<button type="button"
onClick={() => {
const newLevels = escalation().levels.filter((_: any, i: number) => i !== index());
const newLevels = escalation().levels.filter((_, i) => i !== index());
setEscalation({ ...escalation(), levels: newLevels });
props.setHasUnsavedChanges(true);
}}

View file

@ -199,6 +199,12 @@ export const updateStore = {
};
// Expose for testing in development
declare global {
interface Window {
updateStore?: typeof updateStore;
}
}
if (import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname.startsWith('192.168')) {
(window as any).updateStore = updateStore;
window.updateStore = updateStore;
}

View file

@ -1,6 +1,6 @@
import { createSignal, onCleanup } from 'solid-js';
import { createStore } from 'solid-js/store';
import type { State, WSMessage, Alert, ResolvedAlert, PVEBackups } from '@/types/api';
import type { State, WSMessage, Alert, ResolvedAlert, PVEBackups, VM, Container } from '@/types/api';
import { logger } from '@/utils/logger';
import { POLLING_INTERVALS, WEBSOCKET } from '@/constants';
import { notificationStore } from './notifications';
@ -47,7 +47,7 @@ export function createWebSocketStore(url: string) {
});
const [activeAlerts, setActiveAlerts] = createStore<Record<string, Alert>>({});
const [recentlyResolved, setRecentlyResolved] = createStore<Record<string, ResolvedAlert>>({});
const [updateProgress, setUpdateProgress] = createSignal<any>(null);
const [updateProgress, setUpdateProgress] = createSignal<unknown>(null);
// Track alerts with pending acknowledgment changes to prevent race conditions
const pendingAckChanges = new Set<string>();
@ -104,7 +104,7 @@ export function createWebSocketStore(url: string) {
}
if (message.data.vms !== undefined) {
// Transform tags from comma-separated strings to arrays
const transformedVMs = message.data.vms.map((vm: any) => {
const transformedVMs = message.data.vms.map((vm: VM) => {
const originalTags = vm.tags;
let transformedTags;
@ -113,7 +113,7 @@ export function createWebSocketStore(url: string) {
transformedTags = originalTags.split(',').map((t: string) => t.trim()).filter((t: string) => t.length > 0);
} else if (Array.isArray(originalTags)) {
// Already an array - filter out empty/whitespace-only tags
transformedTags = originalTags.filter((tag: any) =>
transformedTags = originalTags.filter((tag: string) =>
typeof tag === 'string' && tag.trim().length > 0
);
} else {
@ -130,7 +130,7 @@ export function createWebSocketStore(url: string) {
}
if (message.data.containers !== undefined) {
// Transform tags from comma-separated strings to arrays
const transformedContainers = message.data.containers.map((container: any) => {
const transformedContainers = message.data.containers.map((container: Container) => {
const originalTags = container.tags;
let transformedTags;
@ -139,7 +139,7 @@ export function createWebSocketStore(url: string) {
transformedTags = originalTags.split(',').map((t: string) => t.trim()).filter((t: string) => t.length > 0);
} else if (Array.isArray(originalTags)) {
// Already an array - filter out empty/whitespace-only tags
transformedTags = originalTags.filter((tag: any) =>
transformedTags = originalTags.filter((tag: string) =>
typeof tag === 'string' && tag.trim().length > 0
);
} else {
@ -178,7 +178,7 @@ export function createWebSocketStore(url: string) {
const currentAlertIds = Object.keys(activeAlerts);
currentAlertIds.forEach(id => {
if (!newAlerts[id]) {
setActiveAlerts(id, undefined!);
setActiveAlerts(id, undefined as unknown as Alert);
}
});
@ -216,7 +216,7 @@ export function createWebSocketStore(url: string) {
const currentResolvedIds = Object.keys(recentlyResolved);
currentResolvedIds.forEach(id => {
if (!newResolvedAlerts[id]) {
setRecentlyResolved(id, undefined!);
setRecentlyResolved(id, undefined as unknown as ResolvedAlert);
}
});

View file

@ -174,7 +174,7 @@ class ApiClient {
}
// Convenience method for JSON requests
async fetchJSON(url: string, options: FetchOptions = {}): Promise<any> {
async fetchJSON<T = unknown>(url: string, options: FetchOptions = {}): Promise<T> {
const response = await this.fetch(url, {
...options,
headers: {
@ -189,10 +189,10 @@ class ApiClient {
}
const text = await response.text();
if (!text) return null;
if (!text) return null as T;
try {
return JSON.parse(text);
return JSON.parse(text) as T;
} catch (e) {
console.error('Failed to parse JSON response:', text);
throw new Error('Invalid JSON response from server');
@ -234,7 +234,7 @@ export const apiClient = new ApiClient();
// Export convenience functions
export const apiFetch = (url: string, options?: FetchOptions) => apiClient.fetch(url, options);
export const apiFetchJSON = (url: string, options?: FetchOptions) => apiClient.fetchJSON(url, options);
export const apiFetchJSON = <T = unknown>(url: string, options?: FetchOptions) => apiClient.fetchJSON<T>(url, options);
export const setBasicAuth = (username: string, password: string) => apiClient.setBasicAuth(username, password);
export const setApiToken = (token: string) => apiClient.setApiToken(token);
export const clearAuth = () => apiClient.clearAuth();

View file

@ -1,4 +1,4 @@
import type { VM, Container } from '@/types/api';
import type { VM, Container, PBSBackup, StorageBackup, BackupTask } from '@/types/api';
export type ComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '==';
export type LogicalOperator = 'AND' | 'OR';
@ -186,28 +186,31 @@ export function parseSearchQuery(query: string): ParsedQuery {
};
}
function evaluateMetricCondition(guest: VM | Container | any, condition: MetricCondition): boolean {
type FilterableItem = VM | Container | PBSBackup | StorageBackup | BackupTask;
function evaluateMetricCondition(guest: FilterableItem, condition: MetricCondition): boolean {
let value: number;
switch (condition.field) {
case 'cpu':
// CPU is stored as decimal (0-1), convert to percentage
value = (guest.cpu || 0) * 100;
value = ('cpu' in guest ? (guest.cpu || 0) : 0) * 100;
break;
case 'memory':
value = guest.memory ? guest.memory.usage : 0;
value = 'memory' in guest && guest.memory ? guest.memory.usage : 0;
break;
case 'disk':
value = guest.disk ? guest.disk.usage : 0;
value = 'disk' in guest && guest.disk ? guest.disk.usage : 0;
break;
case 'uptime':
// Uptime in seconds (only for running VMs/containers)
value = guest.status === 'running' ? (guest.uptime || 0) : 0;
value = 'status' in guest && guest.status === 'running' && 'uptime' in guest ? (guest.uptime || 0) : 0;
break;
default:
// For backup-specific numeric fields like 'size'
if (guest[condition.field] !== undefined) {
value = Number(guest[condition.field]) || 0;
const fieldValue = (guest as Record<string, unknown>)[condition.field];
if (fieldValue !== undefined) {
value = Number(fieldValue) || 0;
} else {
return false;
}
@ -230,31 +233,31 @@ function evaluateMetricCondition(guest: VM | Container | any, condition: MetricC
}
}
function evaluateTextCondition(guest: VM | Container | any, condition: TextCondition): boolean {
function evaluateTextCondition(guest: FilterableItem, condition: TextCondition): boolean {
const searchValue = condition.value.toLowerCase();
switch (condition.field) {
case 'name':
return guest.name?.toLowerCase().includes(searchValue) || false;
return 'name' in guest && guest.name ? guest.name.toLowerCase().includes(searchValue) : false;
case 'node':
return guest.node?.toLowerCase().includes(searchValue) || false;
return 'node' in guest && guest.node ? guest.node.toLowerCase().includes(searchValue) : false;
case 'vmid':
return guest.vmid?.toString().includes(searchValue) || false;
return 'vmid' in guest && guest.vmid ? guest.vmid.toString().includes(searchValue) : false;
case 'tags':
// Check if guest has any tags that match the search value
if (!guest.tags || !Array.isArray(guest.tags) || guest.tags.length === 0) return false;
if (!('tags' in guest) || !guest.tags || !Array.isArray(guest.tags) || guest.tags.length === 0) return false;
// Support comma-separated tag searches (OR logic)
const searchTags = searchValue.split(',').map(t => t.trim()).filter(t => t.length > 0);
return searchTags.some(searchTag =>
guest.tags.some((tag: string) => {
guest.tags!.some((tag: string) => {
if (typeof tag !== 'string') return false;
return tag.toLowerCase().includes(searchTag.toLowerCase());
})
);
default:
// For backup-specific fields
if (guest[condition.field]) {
const fieldValue = guest[condition.field];
const fieldValue = (guest as Record<string, unknown>)[condition.field];
if (fieldValue) {
if (typeof fieldValue === 'string') {
return fieldValue.toLowerCase().includes(searchValue);
} else if (typeof fieldValue === 'number') {
@ -267,15 +270,15 @@ function evaluateTextCondition(guest: VM | Container | any, condition: TextCondi
}
}
export function evaluateSearchQuery(guest: VM | Container, query: ParsedQuery): boolean {
export function evaluateSearchQuery(guest: FilterableItem, query: ParsedQuery): boolean {
// If it's a simple text search
if (query.rawText) {
const searchTerms = query.rawText.toLowerCase().split(',').map(term => term.trim()).filter(term => term.length > 0);
return searchTerms.some(term =>
guest.name.toLowerCase().includes(term) ||
guest.vmid.toString().includes(term) ||
guest.node.toLowerCase().includes(term) ||
guest.status.toLowerCase().includes(term)
('name' in guest && guest.name && guest.name.toLowerCase().includes(term)) ||
('vmid' in guest && guest.vmid && guest.vmid.toString().includes(term)) ||
('node' in guest && guest.node && guest.node.toLowerCase().includes(term)) ||
('status' in guest && guest.status && guest.status.toLowerCase().includes(term))
);
}
@ -302,7 +305,7 @@ export function evaluateSearchQuery(guest: VM | Container, query: ParsedQuery):
}
// Evaluate a filter stack against a guest or backup item
export function evaluateFilterStack(guest: VM | Container | any, stack: FilterStack): boolean {
export function evaluateFilterStack(guest: FilterableItem, stack: FilterStack): boolean {
if (stack.filters.length === 0) {
return true;
}
@ -332,13 +335,13 @@ export function evaluateFilterStack(guest: VM | Container | any, stack: FilterSt
} else if (filter.type === 'raw' && filter.rawText) {
const term = filter.rawText.toLowerCase();
// Check name, vmid, node, status, and tags for raw text matches
const basicMatch = guest.name.toLowerCase().includes(term) ||
guest.vmid.toString().includes(term) ||
guest.node.toLowerCase().includes(term) ||
guest.status.toLowerCase().includes(term);
const basicMatch = ('name' in guest && guest.name && guest.name.toLowerCase().includes(term)) ||
('vmid' in guest && guest.vmid && guest.vmid.toString().includes(term)) ||
('node' in guest && guest.node && guest.node.toLowerCase().includes(term)) ||
('status' in guest && guest.status && guest.status.toLowerCase().includes(term));
// Also check if any tags contain the search term
const tagMatch = guest.tags && Array.isArray(guest.tags) &&
const tagMatch = 'tags' in guest && guest.tags && Array.isArray(guest.tags) &&
guest.tags.some((tag: string) => tag.toLowerCase().includes(term));
return basicMatch || tagMatch;

View file

@ -56,11 +56,22 @@ export function getTagColorDark(tag: string): { bg: string; text: string; border
};
}
interface TagColorStyle {
bg: string;
text: string;
border: string;
}
interface TagColorTheme {
light: TagColorStyle;
dark: TagColorStyle;
}
/**
* Proxmox's default tag colors for special tags
* These override the hash-based colors for specific tags
*/
const specialTagColors: Record<string, { light: any; dark: any }> = {
const specialTagColors: Record<string, TagColorTheme> = {
'production': {
light: { bg: 'rgb(254, 226, 226)', text: 'rgb(153, 27, 27)', border: 'rgb(239, 68, 68)' },
dark: { bg: 'rgb(127, 29, 29)', text: 'rgb(254, 202, 202)', border: 'rgb(185, 28, 28)' }

View file

@ -95,6 +95,7 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/storage-charts", r.handleStorageCharts)
r.mux.HandleFunc("/api/charts", r.handleCharts)
r.mux.HandleFunc("/api/diagnostics", RequireAuth(r.config, r.handleDiagnostics))
r.mux.HandleFunc("/api/diagnostics/vm", RequireAuth(r.config, r.handleVMDiagnostics))
r.mux.HandleFunc("/api/config", r.handleConfig)
r.mux.HandleFunc("/api/backups", r.handleBackups)
r.mux.HandleFunc("/api/backups/", r.handleBackups)

View file

@ -1081,15 +1081,11 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
Str("type", res.Type).
Msg("Processing cluster resource")
// Calculate I/O rates
currentMetrics := IOMetrics{
DiskRead: int64(res.DiskRead),
DiskWrite: int64(res.DiskWrite),
NetworkIn: int64(res.NetIn),
NetworkOut: int64(res.NetOut),
Timestamp: time.Now(),
}
diskReadRate, diskWriteRate, netInRate, netOutRate := m.rateTracker.CalculateRates(guestID, currentMetrics)
// Initialize I/O metrics from cluster resources (may be 0 for VMs)
diskReadBytes := int64(res.DiskRead)
diskWriteBytes := int64(res.DiskWrite)
networkInBytes := int64(res.NetIn)
networkOutBytes := int64(res.NetOut)
if res.Type == "qemu" {
@ -1123,6 +1119,12 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
Int("vmid", res.VMID).
Msg("Could not get VM status to check guest agent availability")
} else if vmStatus != nil {
// Use actual disk I/O values from detailed status
diskReadBytes = int64(vmStatus.DiskRead)
diskWriteBytes = int64(vmStatus.DiskWrite)
networkInBytes = int64(vmStatus.NetIn)
networkOutBytes = int64(vmStatus.NetOut)
// Always try to get filesystem info if VM is running and disk shows 0
// Even if agent flag is 0, the agent might still be available
if vmStatus.Agent > 0 || (diskUsed == 0 && diskTotal > 0) {
@ -1257,6 +1259,10 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
Float64("usage", diskUsage).
Msg("Successfully retrieved disk usage from guest agent (cluster/resources showed 0)")
} else {
// Only special filesystems found - show allocated disk size instead
if diskTotal > 0 {
diskUsage = -1 // Show as allocated size
}
log.Info().
Str("instance", instanceName).
Str("vm", res.Name).
@ -1265,6 +1271,10 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
}
}
} else {
// Agent disabled - show allocated disk size
if diskTotal > 0 {
diskUsage = -1 // Show as allocated size
}
log.Debug().
Str("instance", instanceName).
Str("vm", res.Name).
@ -1272,9 +1282,27 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam
Int("agent", vmStatus.Agent).
Msg("VM does not have guest agent enabled in config")
}
} else {
// No vmStatus available - show allocated disk size
if diskTotal > 0 {
diskUsage = -1 // Show as allocated size
}
}
} else if diskTotal > 0 {
// VM is not running - show allocated disk size
diskUsage = -1
}
// Calculate I/O rates after we have the actual values
currentMetrics := IOMetrics{
DiskRead: diskReadBytes,
DiskWrite: diskWriteBytes,
NetworkIn: networkInBytes,
NetworkOut: networkOutBytes,
Timestamp: time.Now(),
}
diskReadRate, diskWriteRate, netInRate, netOutRate := m.rateTracker.CalculateRates(guestID, currentMetrics)
vm := models.VM{
ID: guestID,
VMID: res.VMID,
@ -1507,7 +1535,6 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli
}
}
// Calculate I/O rates
// Avoid duplicating node name in ID when instance name equals node name
var guestID string
if instanceName == node.Node {
@ -1515,22 +1542,26 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli
} else {
guestID = fmt.Sprintf("%s-%s-%d", instanceName, node.Node, vm.VMID)
}
currentMetrics := IOMetrics{
DiskRead: int64(vm.DiskRead),
DiskWrite: int64(vm.DiskWrite),
NetworkIn: int64(vm.NetIn),
NetworkOut: int64(vm.NetOut),
Timestamp: time.Now(),
}
diskReadRate, diskWriteRate, netInRate, netOutRate := m.rateTracker.CalculateRates(guestID, currentMetrics)
// Initialize I/O metrics from VM listing (may be 0 for disk I/O)
diskReadBytes := int64(vm.DiskRead)
diskWriteBytes := int64(vm.DiskWrite)
networkInBytes := int64(vm.NetIn)
networkOutBytes := int64(vm.NetOut)
// For running VMs, try to get detailed status with balloon info
memUsed := uint64(0)
memTotal := vm.MaxMem
if vm.Status == "running" {
// Try to get detailed VM status for more accurate memory reporting
// Try to get detailed VM status for more accurate memory reporting and disk I/O
if vmStatus, err := client.GetVMStatus(ctx, node.Node, vm.VMID); err == nil {
// Use actual disk I/O values from detailed status
diskReadBytes = int64(vmStatus.DiskRead)
diskWriteBytes = int64(vmStatus.DiskWrite)
networkInBytes = int64(vmStatus.NetIn)
networkOutBytes = int64(vmStatus.NetOut)
// If balloon is enabled, use balloon as the total available memory
if vmStatus.Balloon > 0 && vmStatus.Balloon < vmStatus.MaxMem {
memTotal = vmStatus.Balloon
@ -1736,6 +1767,16 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli
}
}
// Calculate I/O rates after we have the actual values
currentMetrics := IOMetrics{
DiskRead: diskReadBytes,
DiskWrite: diskWriteBytes,
NetworkIn: networkInBytes,
NetworkOut: networkOutBytes,
Timestamp: time.Now(),
}
diskReadRate, diskWriteRate, netInRate, netOutRate := m.rateTracker.CalculateRates(guestID, currentMetrics)
modelVM := models.VM{
ID: guestID,
VMID: vm.VMID,

View file

@ -130,15 +130,11 @@ func (m *Monitor) pollVMsWithNodesOptimized(ctx context.Context, instanceName st
guestID = fmt.Sprintf("%s-%s-%d", instanceName, n.Node, vm.VMID)
}
// Calculate I/O rates
currentMetrics := IOMetrics{
DiskRead: int64(vm.DiskRead),
DiskWrite: int64(vm.DiskWrite),
NetworkIn: int64(vm.NetIn),
NetworkOut: int64(vm.NetOut),
Timestamp: time.Now(),
}
diskReadRate, diskWriteRate, netInRate, netOutRate := m.rateTracker.CalculateRates(guestID, currentMetrics)
// Initialize metrics from VM listing (may be 0 for disk I/O)
diskReadBytes := int64(vm.DiskRead)
diskWriteBytes := int64(vm.DiskWrite)
networkInBytes := int64(vm.NetIn)
networkOutBytes := int64(vm.NetOut)
// Get memory info for running VMs (and agent status for disk)
memUsed := uint64(0)
@ -158,37 +154,143 @@ func (m *Monitor) pollVMsWithNodesOptimized(ctx context.Context, instanceName st
} else if vmStatus.Mem > 0 {
memUsed = vmStatus.Mem
}
// Use actual disk I/O values from detailed status
diskReadBytes = int64(vmStatus.DiskRead)
diskWriteBytes = int64(vmStatus.DiskWrite)
networkInBytes = int64(vmStatus.NetIn)
networkOutBytes = int64(vmStatus.NetOut)
}
cancel()
}
// Calculate I/O rates after we have the actual values
currentMetrics := IOMetrics{
DiskRead: diskReadBytes,
DiskWrite: diskWriteBytes,
NetworkIn: networkInBytes,
NetworkOut: networkOutBytes,
Timestamp: time.Now(),
}
diskReadRate, diskWriteRate, netInRate, netOutRate := m.rateTracker.CalculateRates(guestID, currentMetrics)
// Debug log disk I/O rates
if diskReadRate > 0 || diskWriteRate > 0 {
log.Debug().
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Float64("diskReadRate", diskReadRate).
Float64("diskWriteRate", diskWriteRate).
Int64("diskReadBytes", diskReadBytes).
Int64("diskWriteBytes", diskWriteBytes).
Msg("VM disk I/O rates calculated")
}
// Set CPU to 0 for non-running VMs
cpuUsage := safeFloat(vm.CPU)
if vm.Status != "running" {
cpuUsage = 0
}
// Calculate disk usage
// Calculate disk usage - start with allocated disk size
// NOTE: The Proxmox cluster/resources API always returns 0 for VM disk usage
// We must query the guest agent to get actual disk usage
diskUsed := uint64(vm.Disk)
diskTotal := uint64(vm.MaxDisk)
diskFree := diskTotal - diskUsed
diskUsage := safePercentage(float64(diskUsed), float64(diskTotal))
diskStatusReason := ""
if diskUsed == 0 && diskTotal > 0 && vm.Status == "running" {
diskUsage = -1 // Unknown
// For stopped VMs, we can't get guest agent data
if vm.Status != "running" {
// Show allocated disk size for stopped VMs
if diskTotal > 0 {
diskUsage = -1 // Indicates "allocated size only"
diskStatusReason = "vm-stopped"
}
}
// For running VMs, try to get filesystem info from guest agent if disk is 0
// The cluster/resources endpoint often returns 0 for disk even when data is available
if vm.Status == "running" && diskUsed == 0 && vmStatus != nil {
// Use the vmStatus we already fetched above for balloon memory
// Try to get filesystem info if agent is enabled or disk shows 0
if vmStatus.Agent > 0 || diskUsed == 0 {
// For running VMs, ALWAYS try to get filesystem info from guest agent
// The cluster/resources endpoint always returns 0 for disk usage
if vm.Status == "running" && vmStatus != nil && diskTotal > 0 {
// Log the initial state
log.Debug().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Int("agent", vmStatus.Agent).
Uint64("diskUsed", diskUsed).
Uint64("diskTotal", diskTotal).
Msg("VM has 0 disk usage, checking guest agent")
// Check if agent is enabled
if vmStatus.Agent == 0 {
diskStatusReason = "agent-disabled"
log.Debug().
Str("instance", instanceName).
Str("vm", vm.Name).
Msg("Guest agent disabled in VM config")
} else if vmStatus.Agent > 0 || diskUsed == 0 {
log.Debug().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Msg("Guest agent enabled, fetching filesystem info")
statusCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
if fsInfo, err := client.GetVMFSInfo(statusCtx, n.Node, vm.VMID); err == nil && len(fsInfo) > 0 {
if fsInfo, err := client.GetVMFSInfo(statusCtx, n.Node, vm.VMID); err != nil {
// Handle errors
errStr := err.Error()
log.Warn().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Str("error", errStr).
Msg("Failed to get VM filesystem info from guest agent")
if strings.Contains(errStr, "QEMU guest agent is not running") {
diskStatusReason = "agent-not-running"
log.Info().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Msg("Guest agent enabled in VM config but not running inside guest OS. Install and start qemu-guest-agent in the VM")
} else if strings.Contains(err.Error(), "timeout") {
diskStatusReason = "agent-timeout"
} else if strings.Contains(err.Error(), "permission denied") || strings.Contains(err.Error(), "not allowed") {
diskStatusReason = "permission-denied"
} else {
diskStatusReason = "agent-error"
}
} else if len(fsInfo) == 0 {
diskStatusReason = "no-filesystems"
log.Warn().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Msg("Guest agent returned empty filesystem list")
} else {
log.Info().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Int("filesystems", len(fsInfo)).
Msg("Got filesystem info from guest agent")
// Aggregate disk usage from all filesystems
// Fix for #425: Track seen devices to avoid counting duplicates
var totalBytes, usedBytes uint64
seenDevices := make(map[string]bool)
for _, fs := range fsInfo {
// Log each filesystem for debugging
log.Debug().
Str("vm", vm.Name).
Str("mountpoint", fs.Mountpoint).
Str("type", fs.Type).
Str("disk", fs.Disk).
Uint64("total", fs.TotalBytes).
Uint64("used", fs.UsedBytes).
Msg("Processing filesystem from guest agent")
// Skip special filesystems and Windows System Reserved
if fs.Type == "tmpfs" || fs.Type == "devtmpfs" ||
strings.HasPrefix(fs.Mountpoint, "/dev") ||
@ -197,14 +299,47 @@ func (m *Monitor) pollVMsWithNodesOptimized(ctx context.Context, instanceName st
strings.HasPrefix(fs.Mountpoint, "/run") ||
fs.Mountpoint == "/boot/efi" ||
fs.Mountpoint == "System Reserved" ||
strings.Contains(fs.Mountpoint, "System Reserved") {
strings.Contains(fs.Mountpoint, "System Reserved") ||
strings.HasPrefix(fs.Mountpoint, "/snap") { // Skip snap mounts
log.Debug().
Str("vm", vm.Name).
Str("mountpoint", fs.Mountpoint).
Str("type", fs.Type).
Msg("Skipping special filesystem")
continue
}
// Skip if we've already seen this device (duplicate mount point)
if fs.Disk != "" && seenDevices[fs.Disk] {
log.Debug().
Str("vm", vm.Name).
Str("mountpoint", fs.Mountpoint).
Str("disk", fs.Disk).
Msg("Skipping duplicate mount of same device")
continue
}
// Only count real filesystems with valid data
if fs.TotalBytes > 0 {
// Mark this device as seen
if fs.Disk != "" {
seenDevices[fs.Disk] = true
}
totalBytes += fs.TotalBytes
usedBytes += fs.UsedBytes
log.Debug().
Str("vm", vm.Name).
Str("mountpoint", fs.Mountpoint).
Str("disk", fs.Disk).
Uint64("added_total", fs.TotalBytes).
Uint64("added_used", fs.UsedBytes).
Msg("Adding filesystem to total")
} else {
log.Debug().
Str("vm", vm.Name).
Str("mountpoint", fs.Mountpoint).
Msg("Skipping filesystem with 0 total bytes")
}
}
@ -214,31 +349,54 @@ func (m *Monitor) pollVMsWithNodesOptimized(ctx context.Context, instanceName st
diskUsed = usedBytes
diskFree = totalBytes - usedBytes
diskUsage = safePercentage(float64(usedBytes), float64(totalBytes))
diskStatusReason = "" // Clear reason on success
log.Debug().
log.Info().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("vmid", vm.VMID).
Uint64("totalBytes", totalBytes).
Uint64("usedBytes", usedBytes).
Float64("usage", diskUsage).
Msg("Successfully retrieved disk usage from guest agent")
Msg("✓ Successfully retrieved disk usage from guest agent")
} else {
// Only special filesystems found - show allocated disk size instead
diskStatusReason = "special-filesystems-only"
if diskTotal > 0 {
diskUsage = -1 // Show as allocated size
}
log.Info().
Str("instance", instanceName).
Str("vm", vm.Name).
Int("filesystems_found", len(fsInfo)).
Msg("Guest agent provided filesystem info but no usable filesystems found (all were special mounts)")
}
}
cancel()
} else {
// No vmStatus available or agent disabled - show allocated disk
if diskTotal > 0 {
diskUsage = -1 // Show as allocated size
diskStatusReason = "no-agent"
}
}
} else if vm.Status == "running" && diskTotal > 0 {
// Running VM but no vmStatus - show allocated disk
diskUsage = -1
diskStatusReason = "no-status"
}
// Create VM model
modelVM := models.VM{
ID: guestID,
VMID: vm.VMID,
Name: vm.Name,
Node: n.Node,
Instance: instanceName,
Status: vm.Status,
Type: "qemu",
CPU: cpuUsage,
CPUs: int(vm.CPUs),
ID: guestID,
VMID: vm.VMID,
Name: vm.Name,
Node: n.Node,
Instance: instanceName,
Status: vm.Status,
Type: "qemu",
CPU: cpuUsage,
CPUs: int(vm.CPUs),
Memory: models.Memory{
Total: int64(memTotal),
Used: int64(memUsed),
@ -251,14 +409,15 @@ func (m *Monitor) pollVMsWithNodesOptimized(ctx context.Context, instanceName st
Free: int64(diskFree),
Usage: diskUsage,
},
NetworkIn: maxInt64(0, int64(netInRate)),
NetworkOut: maxInt64(0, int64(netOutRate)),
DiskRead: maxInt64(0, int64(diskReadRate)),
DiskWrite: maxInt64(0, int64(diskWriteRate)),
Uptime: int64(vm.Uptime),
Template: vm.Template == 1,
LastSeen: time.Now(),
Tags: tags,
DiskStatusReason: diskStatusReason,
NetworkIn: maxInt64(0, int64(netInRate)),
NetworkOut: maxInt64(0, int64(netOutRate)),
DiskRead: maxInt64(0, int64(diskReadRate)),
DiskWrite: maxInt64(0, int64(diskWriteRate)),
Uptime: int64(vm.Uptime),
Template: vm.Template == 1,
LastSeen: time.Now(),
Tags: tags,
}
// Zero out metrics for non-running VMs

View file

@ -893,7 +893,8 @@ type VMFileSystem struct {
Mountpoint string `json:"mountpoint"`
TotalBytes uint64 `json:"total-bytes"`
UsedBytes uint64 `json:"used-bytes"`
Disk []interface{} `json:"disk"` // Disk device info, not needed for usage
Disk string // Extracted disk device name for duplicate detection
DiskRaw []interface{} `json:"disk"` // Raw disk device info from API
}
// GetVMFSInfo returns filesystem information from QEMU guest agent
@ -918,6 +919,36 @@ func (c *Client) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]VMFi
} `json:"data"`
}
if err := json.Unmarshal(bodyBytes, &arrayResult); err == nil && arrayResult.Data.Result != nil {
// Post-process to extract disk device names
for i := range arrayResult.Data.Result {
fs := &arrayResult.Data.Result[i]
// Extract disk device name from the DiskRaw field
if len(fs.DiskRaw) > 0 {
// The disk field usually contains device info as a map
if diskMap, ok := fs.DiskRaw[0].(map[string]interface{}); ok {
// Try to get the device name from various possible fields
if dev, ok := diskMap["dev"].(string); ok {
fs.Disk = dev
} else if serial, ok := diskMap["serial"].(string); ok {
fs.Disk = serial
} else if bus, ok := diskMap["bus-type"].(string); ok {
if target, ok := diskMap["target"].(float64); ok {
fs.Disk = fmt.Sprintf("%s-%d", bus, int(target))
}
}
}
}
// If we still don't have a disk identifier, use the mountpoint as a fallback
if fs.Disk == "" && fs.Mountpoint != "" {
// For root filesystem, use a special identifier
if fs.Mountpoint == "/" {
fs.Disk = "root-filesystem"
} else {
// Use mountpoint as unique identifier
fs.Disk = fs.Mountpoint
}
}
}
return arrayResult.Data.Result, nil
}