fix: critical production issues for v4.1.0-rc.5

- Fixed Discord/Slack/Teams webhooks not persisting (Issue #272)
- Fixed email recipients not saving and Enter key issue (Issue #270)
- Fixed auto-update toggle not saving (Issue #269)
- Fixed false CPU alerts for stopped VMs/containers (Issue #273)
- Automatic alert clearing for stopped guests
- Preserve passwords when updating email config

chore: bump version to v4.1.0-rc.5
This commit is contained in:
Pulse Monitor 2025-08-09 18:27:30 +00:00
parent b4dd5937f9
commit 311ef7619e
10 changed files with 132 additions and 54 deletions

View file

@ -1 +1 @@
4.1.0-rc.4
4.1.0-rc.5

View file

@ -52,6 +52,7 @@ export interface Webhook {
headers: Record<string, string>;
template?: string;
enabled: boolean;
service?: string; // Added to support Discord, Slack, etc.
}
export interface NotificationTestRequest {

View file

@ -209,12 +209,20 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) {
<textarea
value={props.config.to.join('\n')}
onInput={(e) => {
const recipients = e.currentTarget.value
// Parse recipients but keep the raw value for better UX
const rawValue = e.currentTarget.value;
const recipients = rawValue
.split('\n')
.map(r => r.trim())
.filter(r => r.length > 0);
.filter(r => r.length > 0 && r.includes('@')); // Only keep valid email-like strings
props.onChange({ ...props.config, to: recipients });
}}
onKeyDown={(e) => {
// Allow Enter key in textarea without triggering form submission
if (e.key === 'Enter') {
e.stopPropagation();
}
}}
placeholder={`Leave empty to use ${props.config.from || 'From address'}\nOr add additional recipients:\nadmin@company.com\nops-team@company.com`}
rows="3"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"

View file

@ -1,15 +1,5 @@
import { createSignal, createEffect, Show, For } from 'solid-js';
import { NotificationsAPI } from '@/api/notifications';
interface Webhook {
id?: string;
name: string;
url: string;
method: string;
service: string;
headers: Record<string, string>;
enabled: boolean;
}
import { NotificationsAPI, Webhook } from '@/api/notifications';
interface WebhookTemplate {
service: string;
@ -23,7 +13,7 @@ interface WebhookTemplate {
interface WebhookConfigProps {
webhooks: Webhook[];
onAdd: (webhook: Webhook) => void;
onAdd: (webhook: Omit<Webhook, 'id'>) => void;
onUpdate: (webhook: Webhook) => void;
onDelete: (id: string) => void;
onTest: (id: string) => void;
@ -33,7 +23,7 @@ interface WebhookConfigProps {
export function WebhookConfig(props: WebhookConfigProps) {
const [adding, setAdding] = createSignal(false);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [formData, setFormData] = createSignal<Webhook>({
const [formData, setFormData] = createSignal<Omit<Webhook, 'id'> & { service: string }>({
name: '',
url: '',
method: 'POST',
@ -59,11 +49,20 @@ export function WebhookConfig(props: WebhookConfigProps) {
if (!data.name || !data.url) return;
if (editingId()) {
props.onUpdate({ ...data, id: editingId()! });
props.onUpdate({ ...data, id: editingId()!, service: data.service });
setEditingId(null);
setAdding(false);
} else {
props.onAdd(data);
// onAdd expects a webhook without id, but with service
const newWebhook: Omit<Webhook, 'id'> = {
name: data.name,
url: data.url,
method: data.method,
headers: data.headers,
enabled: data.enabled,
service: data.service
};
props.onAdd(newWebhook);
// Reset form but keep adding state true
setFormData({
name: '',
@ -91,7 +90,10 @@ export function WebhookConfig(props: WebhookConfigProps) {
const editWebhook = (webhook: Webhook) => {
setEditingId(webhook.id!);
setFormData(webhook);
setFormData({
...webhook,
service: webhook.service || 'generic'
});
setAdding(true);
};
@ -137,7 +139,7 @@ export function WebhookConfig(props: WebhookConfigProps) {
{webhook.name}
</span>
<span class="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300">
{serviceName(webhook.service)}
{serviceName(webhook.service || 'generic')}
</span>
<span class="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300">
{webhook.method}

View file

@ -309,6 +309,13 @@ const Settings: Component = () => {
setPollingInterval(systemSettings.pollingInterval || 5);
setAllowedOrigins(systemSettings.allowedOrigins || '*');
setConnectionTimeout(systemSettings.connectionTimeout || 10);
// Load auto-update settings
setAutoUpdateEnabled(systemSettings.autoUpdateEnabled || false);
setAutoUpdateCheckInterval(systemSettings.autoUpdateCheckInterval || 24);
setAutoUpdateTime(systemSettings.autoUpdateTime || '03:00');
if (systemSettings.updateChannel) {
setUpdateChannel(systemSettings.updateChannel as 'stable' | 'rc');
}
} else {
// Fallback to old endpoint
const response = await SettingsAPI.getSettings();

View file

@ -6,24 +6,13 @@ import { CustomRulesTab } from '@/components/Alerts/CustomRulesTab';
import { useWebSocket } from '@/App';
import { showSuccess, showError } from '@/utils/toast';
import { AlertsAPI } from '@/api/alerts';
import { NotificationsAPI } from '@/api/notifications';
import { NotificationsAPI, Webhook } from '@/api/notifications';
import type { EmailConfig } from '@/api/notifications';
import type { HysteresisThreshold, AlertThresholds } from '@/types/alerts';
import type { Alert, State } from '@/types/api';
type AlertTab = 'overview' | 'thresholds' | 'destinations' | 'schedule' | 'history' | 'custom-rules';
// Webhook interface matching WebhookConfig component
interface Webhook {
id?: string;
name: string;
url: string;
method: string;
service: string;
headers: Record<string, string>;
enabled: boolean;
}
// Store reference interfaces
interface DestinationsRef {
emailConfig?: () => EmailConfig;
@ -1391,10 +1380,10 @@ function DestinationsTab(props: DestinationsTabProps) {
onMount(async () => {
try {
const hooks = await NotificationsAPI.getWebhooks();
// Map to local Webhook type
// Map to local Webhook type - preserve the service type from backend
setWebhooks(hooks.map(h => ({
...h,
service: 'custom' // Default service type
service: h.service || 'generic' // Preserve service type or default to generic
})));
} catch (err) {
console.error('Failed to load webhooks:', err);
@ -1481,22 +1470,43 @@ function DestinationsTab(props: DestinationsTabProps) {
<WebhookConfig
webhooks={webhooks()}
onAdd={(webhook) => {
setWebhooks([...webhooks(), {
...webhook,
id: Date.now().toString()
}]);
props.setHasUnsavedChanges(true);
onAdd={async (webhook) => {
try {
// Save to backend immediately (including service field)
const created = await NotificationsAPI.createWebhook(webhook);
// Update local state with the created webhook
setWebhooks([...webhooks(), created]);
showSuccess('Webhook added successfully');
} catch (err) {
console.error('Failed to add webhook:', err);
showError(err instanceof Error ? err.message : 'Failed to add webhook');
}
}}
onUpdate={(webhook) => {
setWebhooks(webhooks().map(w =>
w.id === webhook.id ? webhook : w
));
props.setHasUnsavedChanges(true);
onUpdate={async (webhook) => {
try {
// Update on backend immediately (including service field)
const updated = await NotificationsAPI.updateWebhook(webhook.id!, webhook);
// Update local state
setWebhooks(webhooks().map(w =>
w.id === webhook.id ? updated : w
));
showSuccess('Webhook updated successfully');
} catch (err) {
console.error('Failed to update webhook:', err);
showError(err instanceof Error ? err.message : 'Failed to update webhook');
}
}}
onDelete={(id) => {
setWebhooks(webhooks().filter(w => w.id !== id));
props.setHasUnsavedChanges(true);
onDelete={async (id) => {
try {
// Delete from backend immediately
await NotificationsAPI.deleteWebhook(id);
// Update local state
setWebhooks(webhooks().filter(w => w.id !== id));
showSuccess('Webhook deleted successfully');
} catch (err) {
console.error('Failed to delete webhook:', err);
showError(err instanceof Error ? err.message : 'Failed to delete webhook');
}
}}
onTest={testWebhook}
testing={testingWebhook()}

View file

@ -373,7 +373,7 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) {
}
m.mu.RUnlock()
var guestID, name, node, guestType string
var guestID, name, node, guestType, status string
var cpu, memUsage, diskUsage float64
var diskRead, diskWrite, netIn, netOut int64
@ -383,6 +383,7 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) {
guestID = g.ID
name = g.Name
node = g.Node
status = g.Status
guestType = "VM"
cpu = g.CPU * 100 // Convert to percentage
memUsage = g.Memory.Usage
@ -395,6 +396,7 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) {
guestID = g.ID
name = g.Name
node = g.Node
status = g.Status
guestType = "Container"
cpu = g.CPU * 100 // Convert to percentage
memUsage = g.Memory.Usage
@ -407,6 +409,23 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) {
return
}
// Clear any alerts for stopped guests and skip threshold checks
if status == "stopped" {
// Clear all alerts for this guest if it's stopped
m.mu.Lock()
for alertID, alert := range m.activeAlerts {
if alert.ResourceID == guestID {
delete(m.activeAlerts, alertID)
log.Info().
Str("alertID", alertID).
Str("guest", name).
Msg("Cleared alert for stopped guest")
}
}
m.mu.Unlock()
return
}
// Get thresholds (check custom rules, then overrides, then defaults)
m.mu.RLock()
thresholds := m.getGuestThresholds(guest, guestID)

View file

@ -55,11 +55,18 @@ func (h *NotificationHandlers) UpdateEmailConfig(w http.ResponseWriter, r *http.
return
}
// If password is empty, preserve the existing password
if config.Password == "" {
existingConfig := h.monitor.GetNotificationManager().GetEmailConfig()
config.Password = existingConfig.Password
}
log.Info().
Bool("enabled", config.Enabled).
Str("smtp", config.SMTPHost).
Str("from", config.From).
Int("toCount", len(config.To)).
Bool("hasPassword", config.Password != "").
Msg("Parsed email config")
h.monitor.GetNotificationManager().SetEmailConfig(config)
@ -129,8 +136,15 @@ func (h *NotificationHandlers) UpdateWebhook(w http.ResponseWriter, r *http.Requ
}
webhookID := parts[len(parts)-1]
// Read the raw body to preserve all fields
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var webhook notifications.WebhookConfig
if err := json.NewDecoder(r.Body).Decode(&webhook); err != nil {
if err := json.Unmarshal(bodyBytes, &webhook); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
@ -147,8 +161,13 @@ func (h *NotificationHandlers) UpdateWebhook(w http.ResponseWriter, r *http.Requ
log.Error().Err(err).Msg("Failed to save webhooks")
}
// Return the full webhook data including any extra fields like 'service'
var responseData map[string]interface{}
json.Unmarshal(bodyBytes, &responseData)
responseData["id"] = webhookID
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(webhook)
json.NewEncoder(w).Encode(responseData)
}
// DeleteWebhook deletes a webhook

View file

@ -262,7 +262,7 @@ type SystemSettings struct {
AllowedOrigins string `json:"allowedOrigins,omitempty"`
ConnectionTimeout int `json:"connectionTimeout,omitempty"`
UpdateChannel string `json:"updateChannel,omitempty"`
AutoUpdateEnabled bool `json:"autoUpdateEnabled,omitempty"`
AutoUpdateEnabled bool `json:"autoUpdateEnabled"` // Removed omitempty so false is saved
AutoUpdateCheckInterval int `json:"autoUpdateCheckInterval,omitempty"`
AutoUpdateTime string `json:"autoUpdateTime,omitempty"`
}

View file

@ -859,6 +859,12 @@ func (m *Monitor) pollVMs(ctx context.Context, instanceName string, client PVECl
}
}
// Set CPU to 0 for stopped VMs to avoid false alerts
cpuUsage := safeFloat(vm.CPU)
if vm.Status == "stopped" {
cpuUsage = 0
}
modelVM := models.VM{
ID: guestID,
VMID: vm.VMID,
@ -867,7 +873,7 @@ func (m *Monitor) pollVMs(ctx context.Context, instanceName string, client PVECl
Instance: instanceName,
Status: vm.Status,
Type: "qemu",
CPU: safeFloat(vm.CPU), // Already in percentage
CPU: cpuUsage, // Already in percentage
CPUs: vm.CPUs,
Memory: models.Memory{
Total: int64(memTotal),
@ -955,6 +961,12 @@ func (m *Monitor) pollContainers(ctx context.Context, instanceName string, clien
}
diskReadRate, diskWriteRate, netInRate, netOutRate := m.rateTracker.CalculateRates(guestID, currentMetrics)
// Set CPU to 0 for stopped containers to avoid false alerts
cpuUsage := safeFloat(ct.CPU)
if ct.Status == "stopped" {
cpuUsage = 0
}
// Convert -1 to nil for I/O metrics when VM is not running
// We'll use -1 to indicate "no data" which will be converted to null for the frontend
modelCT := models.Container{
@ -965,7 +977,7 @@ func (m *Monitor) pollContainers(ctx context.Context, instanceName string, clien
Instance: instanceName,
Status: ct.Status,
Type: "lxc",
CPU: safeFloat(ct.CPU), // Already in percentage
CPU: cpuUsage, // Already in percentage
CPUs: int(ct.CPUs),
Memory: models.Memory{
Total: int64(ct.MaxMem),