enhance: improve mock data realism and alert system

- Add dynamic metric fluctuations for VMs and containers in mock data
- Fix alert acknowledgment to dim instead of hide alerts
- Implement unacknowledge functionality with backend persistence
- Simplify alert UI to single-click toggle (remove selection system)
- Add proper hysteresis for alert resolution when metrics drop
- Fix SVG icon boundaries in alert displays
- Add webhook disable toggles for testing without notifications
- Fix frontend directory duplication issue (addresses frontend-modern recreation)
- Improve alert sorting to show most recent first
- Make mock system generate realistic metric changes for proper alert lifecycle
This commit is contained in:
Pulse Monitor
2025-09-02 21:11:01 +00:00
parent 1812cd0375
commit 33bfb27f9a
23 changed files with 564 additions and 209 deletions
+2
View File
@@ -44,6 +44,8 @@ build/
pulse-fixes*.tar.gz
# Frontend copy for embedding (generated during build)
# Frontend build artifact for Go embedding
# This is auto-generated, DO NOT EDIT
internal/api/frontend-modern/
# Claude-specific files
+22 -4
View File
@@ -368,10 +368,28 @@ sudo systemctl start pulse-dev
# This is only needed at release time, not during development
```
### IMPORTANT: Frontend Embed Location (for production builds only)
**The Go binary embeds frontend files from `/opt/pulse/internal/api/frontend-modern/dist`**
- The build-release.sh script handles this automatically at release time
- Never manually copy files unless debugging
### IMPORTANT: Frontend Directory Structure (CLAUDE READ THIS FIRST!)
**⚠️ CLAUDE: The duplicate frontend directory is INTENTIONAL and REQUIRED ⚠️**
**When you see `/opt/pulse/internal/api/frontend-modern/`:**
- **DO NOT try to "fix" it** - It's supposed to exist
- **DO NOT remove it** - Builds will fail
- **DO NOT edit files there** - Only edit in `/opt/pulse/frontend-modern`
- **DO NOT worry about it** - It's a build artifact like .o files
**The ONLY frontend source**: `/opt/pulse/frontend-modern`
- Edit all frontend code here
- Vite dev server runs from here
- This is the source of truth
**Why the duplicate exists**:
- Go's `//go:embed` CANNOT access files outside the module (no `../` or symlinks)
- The Makefile copies the built frontend for embedding
- It's in .gitignore so it won't pollute git
- It gets recreated on every build (that's normal!)
**What to tell users who ask about it**:
"That's a build artifact required by Go's embed limitations. Only edit files in `/opt/pulse/frontend-modern`. The duplicate is automatically managed by the build process."
## Development Environment (Current Machine - debian-go)
- **Development Port**: 7655 (frontend with hot-reload)
+2 -1
View File
@@ -32,7 +32,8 @@ COPY pkg/ ./pkg/
COPY VERSION ./
# Copy built frontend from frontend-builder stage for embedding
COPY --from=frontend-builder /app/frontend-modern ./internal/api/frontend-modern
# Must be at internal/api/frontend-modern for Go embed
COPY --from=frontend-builder /app/frontend-modern/dist ./internal/api/frontend-modern/dist
# Build the binary with embedded frontend
RUN CGO_ENABLED=0 GOOS=linux go build \
+7 -2
View File
@@ -8,8 +8,14 @@ all: frontend backend
# Build frontend only
frontend:
cd frontend-modern && npm run build
rm -rf internal/api/frontend-modern/dist
@echo "================================================"
@echo "Copying frontend to internal/api/ for Go embed"
@echo "This is REQUIRED - Go cannot embed external paths"
@echo "================================================"
rm -rf internal/api/frontend-modern
mkdir -p internal/api/frontend-modern
cp -r frontend-modern/dist internal/api/frontend-modern/
@echo "✓ Frontend copied for embedding"
# Build backend only (includes embedded frontend)
backend:
@@ -30,7 +36,6 @@ dev: frontend backend
clean:
rm -f pulse
rm -rf frontend-modern/dist
rm -rf internal/api/frontend-modern/dist
# Quick rebuild and restart for development
restart: frontend backend
+1 -1
View File
@@ -614,4 +614,4 @@ function App() {
);
}
export default App;
export default App;// Test hot-reload comment $(date)
+6
View File
@@ -40,6 +40,12 @@ export class AlertsAPI {
});
}
static async unacknowledge(alertId: string): Promise<{ success: boolean }> {
return apiFetchJSON(`${this.baseUrl}/${alertId}/unacknowledge`, {
method: 'POST',
});
}
// Alert configuration methods
static async getConfig(): Promise<AlertConfig> {
return apiFetchJSON(`${this.baseUrl}/config`);
@@ -140,11 +140,40 @@ export function WebhookConfig(props: WebhookConfigProps) {
return names[service] || service;
};
const toggleAllWebhooks = (enabled: boolean) => {
props.webhooks.forEach(webhook => {
props.onUpdate({ ...webhook, enabled });
});
};
const allEnabled = () => props.webhooks.every(w => w.enabled);
const someEnabled = () => props.webhooks.some(w => w.enabled);
return (
<div class="space-y-6">
{/* Existing Webhooks List */}
<Show when={props.webhooks.length > 0}>
<div class="space-y-3">
{/* Quick Actions Bar */}
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div class="text-sm text-gray-600 dark:text-gray-400">
{props.webhooks.filter(w => w.enabled).length} of {props.webhooks.length} webhooks enabled
</div>
<div class="flex gap-2">
<button
onClick={() => toggleAllWebhooks(false)}
disabled={!someEnabled()}
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
Disable All
</button>
<button
onClick={() => toggleAllWebhooks(true)}
disabled={allEnabled()}
class="px-3 py-1 text-xs bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
Enable All
</button>
</div>
</div>
<For each={props.webhooks}>
{(webhook) => (
<div class="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
@@ -160,20 +189,25 @@ export function WebhookConfig(props: WebhookConfigProps) {
<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}
</span>
{!webhook.enabled && (
<span class="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400">
Disabled
</span>
)}
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono truncate">
{webhook.url}
</p>
</div>
<div class="flex items-center gap-2 ml-4">
<button
onClick={() => props.onUpdate({ ...webhook, enabled: !webhook.enabled })}
class={`px-3 py-1 text-xs rounded transition-colors ${
webhook.enabled
? 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
}`}
>
{webhook.enabled ? 'Enabled' : 'Disabled'}
</button>
<button
onClick={() => props.onTest(webhook.id!)}
disabled={props.testing === 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"
>
{props.testing === webhook.id ? 'Testing...' : 'Test'}
@@ -1746,7 +1746,7 @@ const Settings: Component = () => {
</Show>
{/* Authentication */}
<Show when={!securityStatusLoading() && (securityStatus()?.hasAuthentication || securityStatus()?.configured)}>
<Show when={!securityStatusLoading() && (securityStatus()?.hasAuthentication || securityStatus()?.apiTokenConfigured)}>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div class="bg-gradient-to-r from-gray-50 to-gray-50 dark:from-gray-900/20 dark:to-gray-900/20 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
+100 -167
View File
@@ -90,6 +90,7 @@ export function Alerts() {
const { state, activeAlerts, updateAlert } = useWebSocket();
const [activeTab, setActiveTab] = createSignal<AlertTab>('overview');
const [hasUnsavedChanges, setHasUnsavedChanges] = createSignal(false);
const [showAcknowledged, setShowAcknowledged] = createSignal(true);
// Quick tip visibility state
const [showQuickTip, setShowQuickTip] = createSignal(
@@ -647,6 +648,8 @@ export function Alerts() {
updateAlert={updateAlert}
showQuickTip={showQuickTip}
dismissQuickTip={dismissQuickTip}
showAcknowledged={showAcknowledged}
setShowAcknowledged={setShowAcknowledged}
/>
</Show>
@@ -722,14 +725,12 @@ function OverviewTab(props: {
updateAlert: (alertId: string, updates: Partial<Alert>) => void;
showQuickTip: () => boolean;
dismissQuickTip: () => void;
showAcknowledged: () => boolean;
setShowAcknowledged: (value: boolean) => void;
}) {
// Loading states for buttons
const [processingAlerts, setProcessingAlerts] = createSignal<Set<string>>(new Set());
// Selection state for bulk operations
const [selectedAlerts, setSelectedAlerts] = createSignal<Set<string>>(new Set());
const [processingBulk, setProcessingBulk] = createSignal(false);
// Get alert stats from actual active alerts
const alertStats = createMemo(() => {
// Access the store properly for reactivity
@@ -742,95 +743,23 @@ function OverviewTab(props: {
overrides: props.overrides.length
};
});
// Check if all alerts are selected
const allSelected = createMemo(() => {
const alertIds = Object.keys(props.activeAlerts);
return alertIds.length > 0 && alertIds.every(id => selectedAlerts().has(id));
const filteredAlerts = createMemo(() => {
const alerts = Object.values(props.activeAlerts);
// Sort: unacknowledged first, then by start time (newest first)
return alerts
.filter(alert => props.showAcknowledged() || !alert.acknowledged)
.sort((a, b) => {
// Acknowledged status comparison first
if (a.acknowledged !== b.acknowledged) {
return a.acknowledged ? 1 : -1; // Unacknowledged first
}
// Then by time
return new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
});
});
// Toggle select all
const toggleSelectAll = () => {
if (allSelected()) {
setSelectedAlerts(new Set<string>());
} else {
setSelectedAlerts(new Set(Object.keys(props.activeAlerts)));
}
};
// Toggle individual alert selection
const toggleAlertSelection = (alertId: string) => {
setSelectedAlerts(prev => {
const next = new Set(prev);
if (next.has(alertId)) {
next.delete(alertId);
} else {
next.add(alertId);
}
return next;
});
};
// Bulk acknowledge
const bulkAcknowledge = async () => {
if (selectedAlerts().size === 0) return;
setProcessingBulk(true);
const selected = Array.from(selectedAlerts());
try {
const response = await AlertsAPI.bulkAcknowledge(selected);
const successCount = response.results.filter(r => r.success).length;
const errorCount = response.results.filter(r => !r.success).length;
// Update local state for successfully acknowledged alerts
response.results.forEach((result: any) => {
if (result.success) {
props.updateAlert(result.id, { acknowledged: true });
}
});
if (successCount > 0) {
showSuccess(`Acknowledged ${successCount} alert${successCount > 1 ? 's' : ''}`);
}
if (errorCount > 0) {
showError(`Failed to acknowledge ${errorCount} alert${errorCount > 1 ? 's' : ''}`);
}
} catch (err) {
console.error('Failed to bulk acknowledge alerts:', err);
showError('Failed to acknowledge alerts');
}
setSelectedAlerts(new Set<string>());
setProcessingBulk(false);
};
// Bulk clear
const bulkClear = async () => {
if (selectedAlerts().size === 0) return;
setProcessingBulk(true);
const selected = Array.from(selectedAlerts());
try {
const response = await AlertsAPI.bulkClear(selected);
const successCount = response.results.filter(r => r.success).length;
const errorCount = response.results.filter(r => !r.success).length;
if (successCount > 0) {
showSuccess(`Cleared ${successCount} alert${successCount > 1 ? 's' : ''}`);
}
if (errorCount > 0) {
showError(`Failed to clear ${errorCount} alert${errorCount > 1 ? 's' : ''}`);
}
} catch (err) {
console.error('Failed to bulk clear alerts:', err);
showError('Failed to clear alerts');
}
setSelectedAlerts(new Set<string>());
setProcessingBulk(false);
};
return (
<div class="space-y-6">
@@ -899,33 +828,8 @@ function OverviewTab(props: {
{/* Recent Alerts */}
<div>
<div class="flex flex-col gap-2 mb-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Active Alerts</h3>
<Show when={Object.keys(props.activeAlerts).length > 0 && selectedAlerts().size > 0}>
<span class="text-xs text-gray-600 dark:text-gray-400">
{selectedAlerts().size} selected
</span>
</Show>
</div>
<Show when={Object.keys(props.activeAlerts).length > 0 && selectedAlerts().size > 0}>
<div class="flex gap-1 justify-start sm:justify-end max-w-full">
<button
class="flex-shrink px-2 py-1 text-xs bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={processingBulk()}
onClick={bulkAcknowledge}
>
Ack
</button>
<button
class="flex-shrink px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={processingBulk()}
onClick={bulkClear}
>
Clear
</button>
</div>
</Show>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Active Alerts</h3>
</div>
<Show
when={Object.keys(props.activeAlerts).length > 0}
@@ -936,36 +840,86 @@ function OverviewTab(props: {
</div>
}
>
{/* Select All Checkbox */}
<div class="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-800 rounded-t-lg border border-gray-200 dark:border-gray-700">
<input
type="checkbox"
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700"
checked={allSelected()}
onChange={toggleSelectAll}
/>
<label class="text-xs text-gray-600 dark:text-gray-400 select-none cursor-pointer" onClick={toggleSelectAll}>
Select All
</label>
</div>
{/* Simple View Toggle - only show if there are acknowledged alerts */}
<Show when={alertStats().acknowledged > 0}>
<div class="flex justify-end p-2 bg-gray-50 dark:bg-gray-800 rounded-t-lg border border-gray-200 dark:border-gray-700">
<button
onClick={() => props.setShowAcknowledged(!props.showAcknowledged())}
class="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
{props.showAcknowledged() ? 'Hide' : 'Show'} acknowledged
</button>
</div>
</Show>
<div class="space-y-2">
<For each={Object.values(props.activeAlerts)}>
<Show when={filteredAlerts().length === 0}>
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
{props.showAcknowledged() ? 'No active alerts' : 'No unacknowledged alerts'}
</div>
</Show>
<For each={filteredAlerts()}>
{(alert) => (
<div class={`border rounded-lg p-4 ${
alert.level === 'critical'
? 'border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
: 'border-yellow-300 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20'
} ${
selectedAlerts().has(alert.id) ? 'ring-2 ring-blue-500' : ''
}`}>
<div
onClick={async () => {
// Clicking always toggles acknowledge state
if (processingAlerts().has(alert.id)) return; // Prevent double-clicks
setProcessingAlerts(prev => new Set(prev).add(alert.id));
try {
if (alert.acknowledged) {
// Un-acknowledge
await AlertsAPI.unacknowledge(alert.id);
props.updateAlert(alert.id, { acknowledged: false });
showSuccess('Alert restored');
} else {
// Acknowledge
await AlertsAPI.acknowledge(alert.id);
props.updateAlert(alert.id, { acknowledged: true });
showSuccess('Alert acknowledged');
}
} catch (err) {
console.error('Failed to toggle alert state:', err);
showError('Failed to update alert');
} finally {
setProcessingAlerts(prev => {
const next = new Set(prev);
next.delete(alert.id);
return next;
});
}
}}
class={`border rounded-lg p-4 transition-all cursor-pointer hover:shadow-md ${
processingAlerts().has(alert.id) ? 'opacity-50 cursor-wait' : ''
} ${
alert.acknowledged
? 'opacity-60 border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/20 hover:opacity-80'
: alert.level === 'critical'
? 'border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
: 'border-yellow-300 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20'
}`}
title={alert.acknowledged ? 'Click to restore this alert' : 'Click to acknowledge'}>
<div class="flex flex-col sm:flex-row sm:items-start">
<div class="flex items-start flex-1">
<input
type="checkbox"
class="mt-1 mr-3 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700"
checked={selectedAlerts().has(alert.id)}
onChange={() => toggleAlertSelection(alert.id)}
/>
{/* Status icon */}
<div class={`mr-3 mt-0.5 transition-all ${
alert.acknowledged
? 'text-green-600 dark:text-green-400'
: alert.level === 'critical'
? 'text-red-600 dark:text-red-400'
: 'text-yellow-600 dark:text-yellow-400'
}`}>
{alert.acknowledged ? (
// Checkmark for acknowledged
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
// Warning/Alert icon
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
)}
</div>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class={`text-sm font-medium truncate ${
@@ -993,9 +947,10 @@ function OverviewTab(props: {
<div class="flex gap-2 mt-3 sm:mt-0 sm:ml-4 self-end sm:self-start">
<Show when={!alert.acknowledged}>
<button
class="px-3 py-1 text-xs bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
class="px-3 py-1.5 text-xs font-medium bg-white dark:bg-gray-700 text-yellow-700 dark:text-yellow-300 border border-yellow-300 dark:border-yellow-700 rounded-lg hover:bg-yellow-50 dark:hover:bg-yellow-900/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
disabled={processingAlerts().has(alert.id)}
onClick={async () => {
onClick={async (e) => {
e.stopPropagation();
setProcessingAlerts(prev => new Set(prev).add(alert.id));
try {
await AlertsAPI.acknowledge(alert.id);
@@ -1017,28 +972,6 @@ function OverviewTab(props: {
{processingAlerts().has(alert.id) ? 'Processing...' : 'Acknowledge'}
</button>
</Show>
<button
class="px-3 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={processingAlerts().has(alert.id)}
onClick={async () => {
setProcessingAlerts(prev => new Set(prev).add(alert.id));
try {
await AlertsAPI.clearAlert(alert.id);
showSuccess('Alert cleared');
} catch (err) {
console.error('Failed to clear alert:', err);
showError('Failed to clear alert');
} finally {
setProcessingAlerts(prev => {
const next = new Set(prev);
next.delete(alert.id);
return next;
});
}
}}
>
{processingAlerts().has(alert.id) ? 'Processing...' : 'Clear'}
</button>
</div>
</div>
</div>
+102 -2
View File
@@ -325,7 +325,10 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
}
m.config = config
log.Info().Msg("Alert configuration updated")
log.Info().
Bool("enabled", config.Enabled).
Interface("guestDefaults", config.GuestDefaults).
Msg("Alert configuration updated")
}
// isInQuietHours checks if the current time is within quiet hours
@@ -394,6 +397,7 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) {
m.mu.RLock()
if !m.config.Enabled {
m.mu.RUnlock()
log.Debug().Msg("CheckGuest: alerts disabled globally")
return
}
m.mu.RUnlock()
@@ -417,6 +421,15 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) {
diskWrite = g.DiskWrite
netIn = g.NetworkIn
netOut = g.NetworkOut
// Debug logging for high memory VMs
if memUsage > 85 {
log.Info().
Str("vm", name).
Float64("memUsage", memUsage).
Str("status", status).
Msg("VM with high memory detected in CheckGuest")
}
case models.Container:
guestID = g.ID
name = g.Name
@@ -431,6 +444,9 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) {
netIn = g.NetworkIn
netOut = g.NetworkOut
default:
log.Debug().
Str("type", fmt.Sprintf("%T", guest)).
Msg("CheckGuest: unsupported guest type")
return
}
@@ -953,11 +969,36 @@ func (m *Manager) AcknowledgeAlert(alertID, user string) error {
return nil
}
// UnacknowledgeAlert removes the acknowledged status from an alert
func (m *Manager) UnacknowledgeAlert(alertID string) error {
m.mu.Lock()
defer m.mu.Unlock()
alert, exists := m.activeAlerts[alertID]
if !exists {
return fmt.Errorf("alert not found: %s", alertID)
}
alert.Acknowledged = false
alert.AckTime = nil
alert.AckUser = ""
// Write the modified alert back to the map
m.activeAlerts[alertID] = alert
log.Info().
Str("alertID", alertID).
Msg("Alert unacknowledged")
return nil
}
// GetActiveAlerts returns all active alerts
func (m *Manager) GetActiveAlerts() []Alert {
m.mu.RLock()
defer m.mu.RUnlock()
log.Debug().Int("count", len(m.activeAlerts)).Msg("GetActiveAlerts called")
alerts := make([]Alert, 0, len(m.activeAlerts))
for _, alert := range m.activeAlerts {
alerts = append(alerts, *alert)
@@ -1361,11 +1402,12 @@ func (m *Manager) clearStorageOfflineAlert(storage models.Storage) {
Msg("Storage is back online")
}
// ClearAlert manually clears an alert
// ClearAlert removes an alert from active alerts (but keeps in history)
func (m *Manager) ClearAlert(alertID string) {
m.mu.Lock()
defer m.mu.Unlock()
// Remove from active alerts only
delete(m.activeAlerts, alertID)
if m.onResolved != nil {
@@ -1921,6 +1963,64 @@ func (m *Manager) LoadActiveAlerts() error {
return nil
}
// CleanupAlertsForNodes removes alerts for nodes that no longer exist
func (m *Manager) CleanupAlertsForNodes(existingNodes map[string]bool) {
m.mu.Lock()
defer m.mu.Unlock()
log.Info().
Int("totalAlerts", len(m.activeAlerts)).
Int("existingNodes", len(existingNodes)).
Interface("nodes", existingNodes).
Msg("Starting alert cleanup for non-existent nodes")
removedCount := 0
for alertID := range m.activeAlerts {
var node string
// Extract node from alert ID
// Format can be either "node:type/id-metric" or "node-storage-name-usage"
if strings.Contains(alertID, ":") {
// Guest alert format: "node:type/id-metric"
parts := strings.Split(alertID, ":")
if len(parts) >= 2 {
node = parts[0]
}
} else if strings.Contains(alertID, "-storage-") {
// Storage alert format: "node-storage-name-usage"
parts := strings.Split(alertID, "-storage-")
if len(parts) >= 1 {
node = parts[0]
}
} else if strings.HasPrefix(alertID, "node-offline-") {
// Node offline alert format: "node-offline-node/nodename"
// Extract the node name after the last slash
if idx := strings.LastIndex(alertID, "/"); idx != -1 {
node = alertID[idx+1:]
}
}
// If we couldn't extract a node or the node doesn't exist, remove the alert
if node == "" || !existingNodes[node] {
delete(m.activeAlerts, alertID)
removedCount++
log.Debug().Str("alertID", alertID).Str("node", node).Msg("Removed alert for non-existent node")
}
}
if removedCount > 0 {
log.Info().Int("removed", removedCount).Int("remaining", len(m.activeAlerts)).Msg("Cleaned up alerts for non-existent nodes")
// Save the cleaned up state
go func() {
if err := m.SaveActiveAlerts(); err != nil {
log.Error().Err(err).Msg("Failed to save alerts after cleanup")
}
}()
} else {
log.Info().Msg("No alerts needed cleanup")
}
}
// periodicSaveAlerts saves active alerts to disk periodically
func (m *Manager) periodicSaveAlerts() {
ticker := time.NewTicker(1 * time.Minute)
+22
View File
@@ -257,6 +257,28 @@ func (hm *HistoryManager) cleanOldEntries() {
}
}
// RemoveAlert removes a specific alert from history by ID
func (hm *HistoryManager) RemoveAlert(alertID string) {
hm.mu.Lock()
defer hm.mu.Unlock()
newHistory := make([]HistoryEntry, 0, len(hm.history))
removed := false
for _, entry := range hm.history {
if entry.Alert.ID != alertID {
newHistory = append(newHistory, entry)
} else {
removed = true
}
}
if removed {
hm.history = newHistory
log.Debug().Str("alertID", alertID).Msg("Removed alert from history")
}
}
// ClearAllHistory clears all alert history
func (hm *HistoryManager) ClearAllHistory() error {
hm.mu.Lock()
+23
View File
@@ -0,0 +1,23 @@
# ⚠️ DO NOT EDIT FRONTEND FILES HERE ⚠️
This `frontend-modern` directory is **AUTO-GENERATED** during builds.
## The REAL frontend location is:
### `/opt/pulse/frontend-modern`
## Why does this exist?
- Go's `embed` directive cannot access files outside the module
- The build process copies the frontend here for embedding
- This directory is in `.gitignore` and not committed
## What happens if you edit files here?
- **YOUR CHANGES WILL BE LOST** on the next build
- The Makefile deletes and recreates this directory
## How to edit frontend code:
1. Edit files in `/opt/pulse/frontend-modern/src/`
2. The dev server (port 7655) will hot-reload
3. When building for production, the Makefile copies it here
---
This file exists to prevent confusion. The directory structure is intentional and required by Go's limitations.
+22
View File
@@ -0,0 +1,22 @@
# Internal API Package
This directory contains the API server implementation for Pulse.
## Important Note About `frontend-modern/`
The `frontend-modern/` subdirectory that appears here is:
- **AUTO-GENERATED** during builds
- **NOT the source code** - just a build artifact
- **IN .gitignore** - never committed
- **REQUIRED BY GO** - The embed directive needs it here
### Frontend Development Location
👉 **Edit frontend files at: `/opt/pulse/frontend-modern/src/`**
### Why This Structure?
Go's `//go:embed` directive has limitations:
1. Cannot use `../` paths to access parent directories
2. Cannot follow symbolic links
3. Must embed files within the Go module
This is a known Go limitation and our structure works around it.
+56
View File
@@ -116,6 +116,60 @@ func (h *AlertHandlers) ClearAlertHistory(w http.ResponseWriter, r *http.Request
json.NewEncoder(w).Encode(map[string]string{"status": "success", "message": "Alert history cleared"})
}
// UnacknowledgeAlert removes acknowledged status from an alert
func (h *AlertHandlers) UnacknowledgeAlert(w http.ResponseWriter, r *http.Request) {
// Extract alert ID from URL path: /api/alerts/{id}/unacknowledge
path := strings.TrimPrefix(r.URL.Path, "/api/alerts/")
const suffix = "/unacknowledge"
if !strings.HasSuffix(path, suffix) {
log.Error().
Str("path", r.URL.Path).
Msg("Path does not end with /unacknowledge")
http.Error(w, "Invalid URL", http.StatusBadRequest)
return
}
// Extract alert ID by removing the suffix
alertID := strings.TrimSuffix(path, suffix)
if alertID == "" {
log.Error().
Str("path", r.URL.Path).
Msg("Empty alert ID")
http.Error(w, "Invalid URL", http.StatusBadRequest)
return
}
// Log the unacknowledge attempt
log.Debug().
Str("alertID", alertID).
Str("path", r.URL.Path).
Msg("Attempting to unacknowledge alert")
if err := h.monitor.GetAlertManager().UnacknowledgeAlert(alertID); err != nil {
log.Error().
Err(err).
Str("alertID", alertID).
Msg("Failed to unacknowledge alert")
http.Error(w, err.Error(), http.StatusNotFound)
return
}
log.Info().
Str("alertID", alertID).
Msg("Alert unacknowledged successfully")
// Broadcast updated state to all WebSocket clients
if h.wsHub != nil {
state := h.monitor.GetState()
h.wsHub.BroadcastState(state)
log.Debug().Msg("Broadcasted state after alert unacknowledgment")
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
// AcknowledgeAlert acknowledges an alert
func (h *AlertHandlers) AcknowledgeAlert(w http.ResponseWriter, r *http.Request) {
// Extract alert ID from URL path: /api/alerts/{id}/acknowledge
@@ -338,6 +392,8 @@ func (h *AlertHandlers) HandleAlerts(w http.ResponseWriter, r *http.Request) {
h.BulkClearAlerts(w, r)
case strings.HasSuffix(path, "/acknowledge") && r.Method == http.MethodPost:
h.AcknowledgeAlert(w, r)
case strings.HasSuffix(path, "/unacknowledge") && r.Method == http.MethodPost:
h.UnacknowledgeAlert(w, r)
case strings.HasSuffix(path, "/clear") && r.Method == http.MethodPost:
h.ClearAlert(w, r)
default:
+9 -2
View File
@@ -138,7 +138,11 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
return nil, err
}
// Ensure critical defaults are set if missing
// For empty config files ({}), enable alerts by default
// This handles the case where the file exists but is empty
if string(data) == "{}" {
config.Enabled = true
}
if config.StorageDefault.Trigger <= 0 {
config.StorageDefault.Trigger = 85
config.StorageDefault.Clear = 80
@@ -168,7 +172,10 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
config.GuestDefaults.NetworkOut = &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}
}
log.Info().Str("file", c.alertFile).Msg("Alert configuration loaded")
log.Info().
Str("file", c.alertFile).
Bool("enabled", config.Enabled).
Msg("Alert configuration loaded")
return &config, nil
}
+76 -6
View File
@@ -391,14 +391,33 @@ func generateVM(nodeName string, vmid int, config MockConfig) models.VM {
uptime := int64(0)
if status == "running" {
cpu = rand.Float64() * 0.95 // 0-95% CPU
// More realistic CPU usage: mostly low with occasional spikes
cpuRand := rand.Float64()
if cpuRand < 0.7 { // 70% of VMs have low CPU
cpu = rand.Float64() * 0.3 // 0-30%
} else if cpuRand < 0.9 { // 20% moderate CPU (0.7-0.9 range)
cpu = 0.3 + rand.Float64()*0.4 // 30-70%
} else { // 10% high CPU (0.9-1.0 range)
cpu = 0.7 + rand.Float64()*0.3 // 70-100% (can trigger alerts at 80%)
}
totalMem := int64((4 + rand.Intn(28)) * 1024 * 1024 * 1024) // 4-32 GB
usedMem := int64(float64(totalMem) * rand.Float64())
// More realistic memory usage: most VMs use 20-60% memory
var memUsage float64
memRand := rand.Float64()
if memRand < 0.7 { // 70% typical usage
memUsage = 0.2 + rand.Float64()*0.4 // 20-60%
} else if memRand < 0.9 { // 20% moderate usage
memUsage = 0.6 + rand.Float64()*0.2 // 60-80%
} else { // 10% high memory (can trigger alerts at 85%)
memUsage = 0.8 + rand.Float64()*0.2 // 80-100%
}
usedMem := int64(float64(totalMem) * memUsage)
mem = models.Memory{
Total: totalMem,
Used: usedMem,
Free: totalMem - usedMem,
Usage: float64(usedMem) / float64(totalMem) * 100,
Usage: memUsage * 100,
}
uptime = int64(3600 * (1 + rand.Intn(720))) // 1-720 hours
}
@@ -444,14 +463,33 @@ func generateContainer(nodeName string, vmid int, config MockConfig) models.Cont
uptime := int64(0)
if status == "running" {
cpu = rand.Float64() * 0.5 // Containers typically use less CPU
// More realistic CPU for containers: mostly very low
cpuRand := rand.Float64()
if cpuRand < 0.8 { // 80% of containers have minimal CPU
cpu = rand.Float64() * 0.15 // 0-15%
} else if cpuRand < 0.95 { // 15% moderate CPU (0.8-0.95 range)
cpu = 0.15 + rand.Float64()*0.25 // 15-40%
} else { // 5% higher CPU (0.95-1.0 range)
cpu = 0.4 + rand.Float64()*0.5 // 40-90% (can trigger alerts at 80%)
}
totalMem := int64((512 + rand.Intn(7680)) * 1024 * 1024) // 512 MB - 8 GB
usedMem := int64(float64(totalMem) * rand.Float64())
// More realistic memory for containers
var memUsage float64
memRand := rand.Float64()
if memRand < 0.8 { // 80% typical usage
memUsage = 0.3 + rand.Float64()*0.4 // 30-70%
} else if memRand < 0.95 { // 15% moderate usage
memUsage = 0.7 + rand.Float64()*0.15 // 70-85%
} else { // 5% high memory (can trigger alerts at 85%)
memUsage = 0.85 + rand.Float64()*0.15 // 85-100%
}
usedMem := int64(float64(totalMem) * memUsage)
mem = models.Memory{
Total: totalMem,
Used: usedMem,
Free: totalMem - usedMem,
Usage: float64(usedMem) / float64(totalMem) * 100,
Usage: memUsage * 100,
}
uptime = int64(3600 * (1 + rand.Intn(1440))) // 1-1440 hours (up to 60 days)
}
@@ -1083,6 +1121,22 @@ func UpdateMetrics(data *models.StateSnapshot, config MockConfig) {
vm.CPU += (rand.Float64() - 0.5) * 0.15
vm.CPU = math.Max(0.01, math.Min(0.99, vm.CPU))
// Update memory with realistic fluctuations
memChange := (rand.Float64() - 0.5) * 0.08 // 8% swing
vm.Memory.Usage += memChange * 100
vm.Memory.Usage = math.Max(10, math.Min(99, vm.Memory.Usage))
vm.Memory.Used = int64(float64(vm.Memory.Total) * (vm.Memory.Usage / 100))
vm.Memory.Free = vm.Memory.Total - vm.Memory.Used
// Update disk usage very slowly (disks fill up gradually)
if rand.Float64() < 0.1 { // 10% chance to change disk usage
diskChange := (rand.Float64() - 0.4) * 0.5 // Slight bias toward filling
vm.Disk.Usage += diskChange
vm.Disk.Usage = math.Max(10, math.Min(95, vm.Disk.Usage))
vm.Disk.Used = int64(float64(vm.Disk.Total) * (vm.Disk.Usage / 100))
vm.Disk.Free = vm.Disk.Total - vm.Disk.Used
}
// Update network/disk I/O with small chance of changing
if rand.Float64() < 0.2 { // 20% chance of I/O change
vm.NetworkIn = generateRealisticIO("network-in")
@@ -1106,6 +1160,22 @@ func UpdateMetrics(data *models.StateSnapshot, config MockConfig) {
ct.CPU += (rand.Float64() - 0.5) * 0.05
ct.CPU = math.Max(0.01, math.Min(0.50, ct.CPU))
// Update memory (containers are generally more stable)
memChange := (rand.Float64() - 0.5) * 0.05 // 5% swing
ct.Memory.Usage += memChange * 100
ct.Memory.Usage = math.Max(5, math.Min(98, ct.Memory.Usage))
ct.Memory.Used = int64(float64(ct.Memory.Total) * (ct.Memory.Usage / 100))
ct.Memory.Free = ct.Memory.Total - ct.Memory.Used
// Update disk usage very slowly
if rand.Float64() < 0.05 { // 5% chance (containers change disk less)
diskChange := (rand.Float64() - 0.45) * 0.3 // Very slight bias toward filling
ct.Disk.Usage += diskChange
ct.Disk.Usage = math.Max(5, math.Min(90, ct.Disk.Usage))
ct.Disk.Used = int64(float64(ct.Disk.Total) * (ct.Disk.Usage / 100))
ct.Disk.Free = ct.Disk.Total - ct.Disk.Used
}
// Update network/disk I/O with small chance of changing
if rand.Float64() < 0.15 { // 15% chance of I/O change (containers change less often)
ct.NetworkIn = generateRealisticIO("network-in-ct")
+8 -10
View File
@@ -11,7 +11,7 @@ import (
var (
mockData models.StateSnapshot
mockAlerts []models.Alert
// Removed mockAlerts - using real alert manager instead
mockAlertHistory []models.Alert
mockEnabled bool
lastUpdate time.Time
@@ -30,7 +30,7 @@ func init() {
// Generate initial mock data
mockData = GenerateMockData(config)
mockAlerts = GenerateAlerts(mockData.Nodes, mockData.VMs, mockData.Containers)
// Removed fake alert generation - real alert manager will handle this
mockAlertHistory = GenerateAlertHistory(mockData.Nodes, mockData.VMs, mockData.Containers)
lastUpdate = time.Now()
@@ -42,10 +42,7 @@ func init() {
for range ticker.C {
if mockEnabled {
UpdateMetrics(&mockData, config)
// Occasionally regenerate alerts
if time.Now().Unix()%30 == 0 {
mockAlerts = GenerateAlerts(mockData.Nodes, mockData.VMs, mockData.Containers)
}
// Removed fake alert regeneration
}
}
}()
@@ -106,8 +103,9 @@ func GetMockState() models.StateSnapshot {
return models.StateSnapshot{}
}
// Return the current mock data with alerts
mockData.ActiveAlerts = mockAlerts
// Return the current mock data
// Don't override alerts - let the real alert manager handle them
// mockData.ActiveAlerts = mockAlerts
return mockData
}
@@ -117,7 +115,7 @@ func ToggleMockMode(enable bool) {
mockEnabled = true
config := LoadMockConfig()
mockData = GenerateMockData(config)
mockAlerts = GenerateAlerts(mockData.Nodes, mockData.VMs, mockData.Containers)
// Removed fake alert generation
mockAlertHistory = GenerateAlertHistory(mockData.Nodes, mockData.VMs, mockData.Containers)
log.Info().
Int("history_count", len(mockAlertHistory)).
@@ -143,7 +141,7 @@ func SetMockConfig(nodeCount, vmsPerNode, lxcsPerNode int) {
}
mockData = GenerateMockData(config)
mockAlerts = GenerateAlerts(mockData.Nodes, mockData.VMs, mockData.Containers)
// Removed fake alert generation
mockAlertHistory = GenerateAlertHistory(mockData.Nodes, mockData.VMs, mockData.Containers)
log.Info().
+1
View File
@@ -36,6 +36,7 @@ func (s *State) ToFrontend() StateFrontend {
Containers: containers,
Storage: storage,
PBS: s.PBSInstances,
ActiveAlerts: s.ActiveAlerts,
Metrics: make(map[string]any),
PVEBackups: s.PVEBackups,
Performance: make(map[string]any),
+1
View File
@@ -106,6 +106,7 @@ type StateFrontend struct {
Containers []ContainerFrontend `json:"containers"`
Storage []StorageFrontend `json:"storage"`
PBS []PBSInstance `json:"pbs"` // Keep as is
ActiveAlerts []Alert `json:"activeAlerts"` // Active alerts
Metrics map[string]any `json:"metrics"` // Empty object for now
PVEBackups PVEBackups `json:"pveBackups"` // Keep as is
Performance map[string]any `json:"performance"` // Empty object for now
+1
View File
@@ -87,6 +87,7 @@ func (s StateSnapshot) ToFrontend() StateFrontend {
Containers: containers,
Storage: storage,
PBS: s.PBSInstances,
ActiveAlerts: s.ActiveAlerts,
Metrics: make(map[string]any),
PVEBackups: s.PVEBackups,
Performance: make(map[string]any),
+53 -1
View File
@@ -877,6 +877,15 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie
// Update state again with corrected disk metrics
m.state.UpdateNodesForInstance(instanceName, modelNodes)
// Clean up alerts for nodes that no longer exist
// Get all nodes from the global state (includes all instances)
existingNodes := make(map[string]bool)
allState := m.state.GetSnapshot()
for _, node := range allState.Nodes {
existingNodes[node.Name] = true
}
m.alertManager.CleanupAlertsForNodes(existingNodes)
// Update cluster endpoint online status if this is a cluster
if instanceCfg.IsCluster && len(instanceCfg.ClusterEndpoints) > 0 {
// Create a map of online nodes from our polling results
@@ -2306,7 +2315,29 @@ func (m *Monitor) pollPBSInstance(ctx context.Context, instanceName string, clie
func (m *Monitor) GetState() models.StateSnapshot {
// Check if mock mode is enabled
if mock.IsMockEnabled() {
return mock.GetMockState()
mockState := mock.GetMockState()
// Include real alerts from the alert manager
activeAlerts := m.alertManager.GetActiveAlerts()
log.Debug().Int("alertCount", len(activeAlerts)).Msg("GetState: fetching alerts for mock state")
modelAlerts := make([]models.Alert, 0, len(activeAlerts))
for _, alert := range activeAlerts {
modelAlerts = append(modelAlerts, models.Alert{
ID: alert.ID,
Type: alert.Type,
Level: string(alert.Level),
ResourceID: alert.ResourceID,
ResourceName: alert.ResourceName,
Node: alert.Node,
Instance: alert.Instance,
Message: alert.Message,
Value: alert.Value,
Threshold: alert.Threshold,
StartTime: alert.StartTime,
Acknowledged: alert.Acknowledged,
})
}
mockState.ActiveAlerts = modelAlerts
return mockState
}
return m.state.GetSnapshot()
}
@@ -2902,7 +2933,9 @@ func (m *Monitor) pollPBSBackups(ctx context.Context, instanceName string, clien
// checkMockAlerts checks alerts for mock data
func (m *Monitor) checkMockAlerts() {
log.Info().Bool("mockEnabled", mock.IsMockEnabled()).Msg("checkMockAlerts called")
if !mock.IsMockEnabled() {
log.Info().Msg("Mock mode not enabled, skipping mock alert check")
return
}
@@ -2912,8 +2945,27 @@ func (m *Monitor) checkMockAlerts() {
log.Info().
Int("vms", len(state.VMs)).
Int("containers", len(state.Containers)).
Int("nodes", len(state.Nodes)).
Msg("Checking alerts for mock data")
// Clean up alerts for nodes that no longer exist
// Use the mock state nodes since we haven't updated global state yet
existingNodes := make(map[string]bool)
for _, node := range state.Nodes {
existingNodes[node.Name] = true
}
// Also add any real nodes from the global state
allState := m.state.GetSnapshot()
for _, node := range allState.Nodes {
existingNodes[node.Name] = true
}
log.Info().
Int("mockNodes", len(state.Nodes)).
Int("stateNodes", len(allState.Nodes)).
Int("totalNodes", len(existingNodes)).
Msg("Collecting nodes for alert cleanup")
m.alertManager.CleanupAlertsForNodes(existingNodes)
// Check alerts for each VM
for _, vm := range state.VMs {
m.alertManager.CheckGuest(vm, "mock")
+5 -5
View File
@@ -27,11 +27,11 @@ npm ci
npm run build
cd ..
# Copy frontend to api directory for embedding
echo "Copying frontend for embedding..."
sudo rm -rf internal/api/frontend-modern
sleep 1 # Give filesystem time to sync
cp -r frontend-modern internal/api/
# Copy frontend dist for embedding (required for Go embed)
echo "Copying frontend dist for embedding..."
rm -rf internal/api/frontend-modern
mkdir -p internal/api/frontend-modern
cp -r frontend-modern/dist internal/api/frontend-modern/
# Build for different architectures
declare -A builds=(
+4 -1
View File
@@ -98,12 +98,15 @@ echo "Starting backend on port 7656..."
cd /opt/pulse
echo "Building backend (API-only mode for development)..."
# Always rebuild in dev mode to ensure we have the right build
echo "Building with PULSE_MOCK_MODE=${PULSE_MOCK_MODE}"
go build -tags "dev" -o pulse ./cmd/pulse
# Export all PULSE_MOCK_* variables for the backend
export PULSE_MOCK_MODE PULSE_MOCK_NODES PULSE_MOCK_VMS_PER_NODE PULSE_MOCK_LXCS_PER_NODE PULSE_MOCK_RANDOM_METRICS PULSE_MOCK_STOPPED_PERCENT
# Export auth variables if set
export PULSE_AUTH_USER PULSE_AUTH_PASS
PORT=7656 ./pulse &
# Export PORT as well
export PORT=7656
./pulse &
BACKEND_PID=$!
# Wait for backend to start