mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 11:13:26 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -614,4 +614,4 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;// Test hot-reload comment $(date)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user