mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-12 05:45:27 +00:00
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:
parent
b4dd5937f9
commit
311ef7619e
10 changed files with 132 additions and 54 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
4.1.0-rc.4
|
||||
4.1.0-rc.5
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue