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
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
+19
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:**
+11 -11
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
};
}
@@ -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'
@@ -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]) => {
@@ -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
@@ -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
@@ -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)) {
@@ -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) || [],
@@ -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}`;
@@ -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)';
}
@@ -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)
+3 -1
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);
}
+46 -13
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);
}}
+7 -1
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;
}
+8 -8
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);
}
});
+4 -4
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();
+30 -27
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;
+12 -1
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)' }
+1
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)
+60 -19
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,
+198 -39
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
+32 -1
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
}