mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-09 19:32:24 +00:00
- 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:
parent
11541a1f6d
commit
5325ef481e
23 changed files with 539 additions and 181 deletions
36
.tmux.conf
Normal file
36
.tmux.conf
Normal 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
|
||||
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)' }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue