mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
feat(enterprise): add Advanced Reporting and Audit Webhooks integration
This commit adds enterprise-grade reporting and audit capabilities: Reporting: - Refactored metrics store from internal/ to pkg/ for enterprise access - Added pkg/reporting with shared interfaces for report generation - Created API endpoint: GET /api/admin/reports/generate - New ReportingPanel.tsx for PDF/CSV report configuration Audit Webhooks: - Extended pkg/audit with webhook URL management interface - Added API endpoint: GET/POST /api/admin/webhooks/audit - New AuditWebhookPanel.tsx for webhook configuration - Updated Settings.tsx with Reporting and Webhooks tabs Server Hardening: - Enterprise hooks now execute outside mutex with panic recovery - Removed dbPath from metrics Stats API to prevent path disclosure - Added storage metrics persistence to polling loop Documentation: - Updated README.md feature table - Updated docs/API.md with new endpoints - Updated docs/PULSE_PRO.md with feature descriptions - Updated docs/WEBHOOKS.md with audit webhooks section
This commit is contained in:
@@ -103,6 +103,8 @@ Community-maintained integrations and addons:
|
||||
| Kubernetes AI analysis | — | ✅ |
|
||||
| Auto-fix + autonomous mode | — | ✅ |
|
||||
| Centralized agent profiles | — | ✅ |
|
||||
| **Advanced Reporting (PDF/CSV)** | — | ✅ |
|
||||
| **Audit Webhooks (SIEM integration)** | — | ✅ |
|
||||
| Priority support | — | ✅ |
|
||||
|
||||
AI Patrol runs on your schedule (every 10 minutes to every 7 days, default 6 hours) and finds:
|
||||
|
||||
@@ -144,6 +144,14 @@ Triggers a test alert to all configured channels.
|
||||
- `GET /api/notifications/email-providers` (admin)
|
||||
- `GET /api/notifications/health` (admin)
|
||||
|
||||
### Audit Webhooks (Pro)
|
||||
- `GET /api/admin/webhooks/audit` (admin, `settings:read`)
|
||||
- `POST /api/admin/webhooks/audit` (admin, `settings:write`)
|
||||
|
||||
### Advanced Reporting (Pro)
|
||||
- `GET /api/admin/reports/generate` (admin, `node:read`)
|
||||
- Query params: `format` (pdf/csv), `id` (resource ID), `type` (node/vm/container/storage), `metric` (cpu/mem/avg), `range` (1h/24h/7d)
|
||||
|
||||
### Queue and Dead-Letter Tools
|
||||
- `GET /api/notifications/queue/stats` (admin)
|
||||
- `GET /api/notifications/dlq` (admin)
|
||||
|
||||
@@ -12,6 +12,18 @@ Pulse Pro unlocks advanced AI automation features on top of the free Pulse platf
|
||||
- API reference: `docs/API.md`.
|
||||
- If no signing key is set, events are stored without signatures and verification will fail.
|
||||
|
||||
### Audit Webhooks
|
||||
- real-time delivery of audit events to external endpoints (SIEM, ELK, etc.).
|
||||
- Asynchronous dispatch to ensure zero impact on system latency.
|
||||
- Signature verification on ingest for secure integration.
|
||||
- Configurable via **Settings → Security → Webhooks**.
|
||||
|
||||
### Advanced Reporting
|
||||
- Generate comprehensive PDF/CSV reports for nodes, VMs, containers, and storage.
|
||||
- Includes key statistics, trends, and capacity projections.
|
||||
- Customizable time ranges and metric aggregation.
|
||||
- Access via **Settings → System → Reporting**.
|
||||
|
||||
### AI Patrol (LLM-Backed)
|
||||
Scheduled background analysis that correlates live state + metrics history to produce actionable findings.
|
||||
|
||||
@@ -31,6 +43,8 @@ Scheduled background analysis that correlates live state + metrics history to pr
|
||||
- **Autonomous mode**: optional diagnostic/fix commands through connected agents.
|
||||
- **Auto-fix**: guarded remediations when enabled.
|
||||
- **Kubernetes AI analysis**: deep cluster analysis beyond basic monitoring (Pro-only).
|
||||
- **Audit-triggered webhooks**: real-time delivery of security events to external systems.
|
||||
- **Advanced Reporting**: scheduled or on-demand PDF/CSV infrastructure health reports.
|
||||
- **Agent Profiles**: centralized configuration profiles for fleets of agents.
|
||||
|
||||
### What Free Users Still Get
|
||||
@@ -53,6 +67,8 @@ Pulse Pro licenses enable specific server-side features. These are enforced at t
|
||||
- `ai_autofix`: autonomous mode and auto-fix workflows.
|
||||
- `kubernetes_ai`: AI analysis for Kubernetes clusters (not basic monitoring).
|
||||
- `agent_profiles`: centralized agent configuration profiles.
|
||||
- `advanced_reporting`: infrastructure health report generation (PDF/CSV).
|
||||
- `audit_logging`: persistent audit trail and real-time webhook delivery.
|
||||
|
||||
## Why It Matters (Technical Value)
|
||||
|
||||
|
||||
@@ -58,3 +58,14 @@ For generic webhooks, use Go templates to format the JSON payload.
|
||||
|
||||
- **Private IPs**: By default, webhooks to private IPs are blocked. Allow them in **Settings → System → Network → Webhook Security**.
|
||||
- **Headers**: Add custom headers (e.g., `Authorization: Bearer ...`) in the webhook config.
|
||||
|
||||
## 🧾 Audit Webhooks (Pro)
|
||||
|
||||
Pulse Pro supports dedicated audit webhooks for security event compliance. Unlike alert notifications, these webhooks deliver the raw, signed JSON payload of every security-relevant action (login, config change, group mapping).
|
||||
|
||||
### Setup
|
||||
1. Go to **Settings → Security → Webhooks**.
|
||||
2. Add your endpoint URL (e.g., `https://siem.corp.local/ingest/pulse`).
|
||||
|
||||
### Security
|
||||
Audit webhooks are dispatched asynchronously. The payload includes a `signature` field which can be verified using your `PULSE_AUDIT_SIGNING_KEY` to ensure the event has not been tampered with in transit.
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createSignal, For, onMount, Show, createEffect } from 'solid-js';
|
||||
import Shield from 'lucide-solid/icons/shield';
|
||||
import Globe from 'lucide-solid/icons/globe';
|
||||
import Plus from 'lucide-solid/icons/plus';
|
||||
import Trash2 from 'lucide-solid/icons/trash-2';
|
||||
import ExternalLink from 'lucide-solid/icons/external-link';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
import { showSuccess, showWarning } from '@/utils/toast';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
import { isEnterprise, loadLicenseStatus } from '@/stores/license';
|
||||
|
||||
export function AuditWebhookPanel() {
|
||||
const [webhookUrls, setWebhookUrls] = createSignal<string[]>([]);
|
||||
const [newUrl, setNewUrl] = createSignal('');
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
onMount(() => {
|
||||
loadLicenseStatus();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (isEnterprise()) {
|
||||
fetchWebhooks();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const fetchWebhooks = async () => {
|
||||
try {
|
||||
const data = await apiFetchJSON<{ urls: string[] }>('/api/admin/webhooks/audit');
|
||||
setWebhookUrls(data.urls || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch audit webhooks:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddWebhook = async () => {
|
||||
const url = newUrl().trim();
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
new URL(url); // basic validation
|
||||
} catch {
|
||||
showWarning('Please enter a valid URL');
|
||||
return;
|
||||
}
|
||||
|
||||
if (webhookUrls().includes(url)) {
|
||||
showWarning('This URL is already configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = [...webhookUrls(), url];
|
||||
await saveWebhooks(updated);
|
||||
setNewUrl('');
|
||||
};
|
||||
|
||||
const handleRemoveWebhook = async (url: string) => {
|
||||
const updated = webhookUrls().filter(u => u !== url);
|
||||
await saveWebhooks(updated);
|
||||
};
|
||||
|
||||
const saveWebhooks = async (urls: string[]) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiFetchJSON('/api/admin/webhooks/audit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ urls }),
|
||||
});
|
||||
|
||||
setWebhookUrls(urls);
|
||||
showSuccess('Audit webhooks updated');
|
||||
} catch (err) {
|
||||
showWarning('Failed to save webhook configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<SectionHeader
|
||||
title={<>Audit Webhooks</>}
|
||||
description={<>Configure real-time delivery of audit events to external systems.</>}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<div class="p-6 space-y-6">
|
||||
<p class="text-slate-400 text-sm leading-relaxed">
|
||||
Whenever a security-relevant event occurs (login, config change, RBAC update),
|
||||
Pulse can send a POST request with the JSON-encoded event data to the following endpoints.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<For each={webhookUrls()}>
|
||||
{(url) => (
|
||||
<div class="flex items-center justify-between p-3 bg-slate-800/50 border border-slate-700 rounded-lg group">
|
||||
<div class="flex items-center gap-3 overflow-hidden">
|
||||
<div class="p-2 bg-blue-500/10 text-blue-400 rounded-md shrink-0">
|
||||
<ExternalLink size={16} />
|
||||
</div>
|
||||
<span class="text-sm font-medium text-slate-200 truncate">{url}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveWebhook(url)}
|
||||
class="p-2 text-slate-500 hover:text-red-400 hover:bg-red-400/10 rounded-md transition-colors"
|
||||
title="Remove Webhook"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={webhookUrls().length === 0 && !loading()}>
|
||||
<div class="py-12 flex flex-col items-center justify-center text-slate-500 border-2 border-dashed border-slate-800 rounded-xl">
|
||||
<Globe size={48} class="opacity-20 mb-4" />
|
||||
<p>No audit webhooks configured yet.</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4 border-t border-slate-800">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="https://your-api.com/webhook"
|
||||
class="flex-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2 text-slate-200 focus:outline-none focus:border-blue-500 transition-colors"
|
||||
value={newUrl()}
|
||||
onInput={(e) => setNewUrl(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddWebhook()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddWebhook}
|
||||
disabled={saving() || !newUrl()}
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 text-white rounded-lg flex items-center gap-2 transition-all shadow-lg shadow-blue-900/20"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Add Endpoint
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="bg-amber-900/10 border border-amber-900/20 rounded-xl p-6">
|
||||
<div class="flex gap-4">
|
||||
<div class="p-3 bg-amber-600/20 rounded-lg h-fit text-amber-500">
|
||||
<Shield size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-white mb-2">Security Note</h3>
|
||||
<p class="text-slate-400 text-sm leading-relaxed">
|
||||
Webhooks are dispatched asynchronously to ensure zero latency impact on operations.
|
||||
Each request includes the tamper-proof event payload, but endpoints should still
|
||||
verify source IP or implement an ingest secret for maximum security.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { createSignal, For, Show, JSX } from 'solid-js';
|
||||
import FileText from 'lucide-solid/icons/file-text';
|
||||
import Download from 'lucide-solid/icons/download';
|
||||
import BarChart from 'lucide-solid/icons/bar-chart';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
import { formField, formLabel, formHelpText, formControl, formSelect } from '@/components/shared/Form';
|
||||
import { showSuccess, showWarning } from '@/utils/toast';
|
||||
import { apiFetch } from '@/utils/apiClient';
|
||||
|
||||
interface FormFieldProps {
|
||||
label: string;
|
||||
helpText?: string;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
function FormField(props: FormFieldProps) {
|
||||
return (
|
||||
<div class={formField}>
|
||||
<label class={formLabel}>{props.label}</label>
|
||||
{props.children}
|
||||
{props.helpText && <span class={formHelpText}>{props.helpText}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportingPanel() {
|
||||
const [resourceType, setResourceType] = createSignal('node');
|
||||
const [resourceId, setResourceId] = createSignal('');
|
||||
const [metricType, setMetricType] = createSignal('');
|
||||
const [format, setFormat] = createSignal<'pdf' | 'csv'>('pdf');
|
||||
const [range, setRange] = createSignal('24h');
|
||||
const [generating, setGenerating] = createSignal(false);
|
||||
const [title, setTitle] = createSignal('');
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!resourceId()) {
|
||||
showWarning('Please enter a Resource ID');
|
||||
return;
|
||||
}
|
||||
|
||||
setGenerating(true);
|
||||
try {
|
||||
const end = new Date().toISOString();
|
||||
let start = new Date();
|
||||
if (range() === '24h') start.setHours(start.getHours() - 24);
|
||||
else if (range() === '7d') start.setDate(start.getDate() - 7);
|
||||
else if (range() === '30d') start.setDate(start.getDate() - 30);
|
||||
|
||||
const startStr = start.toISOString();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
resourceType: resourceType(),
|
||||
resourceId: resourceId(),
|
||||
format: format(),
|
||||
start: startStr,
|
||||
end: end,
|
||||
title: title() || `Infrastructure Report - ${resourceId()}`,
|
||||
});
|
||||
|
||||
if (metricType()) {
|
||||
params.append('metricType', metricType());
|
||||
}
|
||||
|
||||
const response = await apiFetch(`/api/admin/reports/generate?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || 'Failed to generate report');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `report-${resourceId()}-${new Date().toISOString().split('T')[0]}.${format()}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
showSuccess('Report generated successfully');
|
||||
} catch (err) {
|
||||
console.error('Report generation error:', err);
|
||||
showWarning(err instanceof Error ? err.message : 'Failed to generate report');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<SectionHeader
|
||||
title={<>Advanced Reporting</>}
|
||||
description={<>Generate detailed infrastructure reports in PDF or CSV format.</>}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<FormField label="Resource Type" helpText="Select the type of infrastructure resource">
|
||||
<select
|
||||
id="resource-type"
|
||||
class={formSelect}
|
||||
value={resourceType()}
|
||||
onChange={(e) => setResourceType(e.currentTarget.value)}
|
||||
>
|
||||
<option value="node">Proxmox Node</option>
|
||||
<option value="vm">Virtual Machine (QEMU)</option>
|
||||
<option value="container">Container (LXC)</option>
|
||||
<option value="storage">Storage</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Resource ID" helpText="Enter the full ID of the resource">
|
||||
<input
|
||||
id="resource-id"
|
||||
type="text"
|
||||
class={formControl}
|
||||
placeholder="e.g. pve1:node1 or pve1:node1:100"
|
||||
value={resourceId()}
|
||||
onInput={(e) => setResourceId(e.currentTarget.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<FormField label="Metric Type (Optional)" helpText="Filter by specific metric type">
|
||||
<input
|
||||
id="metric-type"
|
||||
type="text"
|
||||
class={formControl}
|
||||
placeholder="e.g. cpu, memory, netin (leave empty for all)"
|
||||
value={metricType()}
|
||||
onInput={(e) => setMetricType(e.currentTarget.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Report Title" helpText="Custom title for the PDF report">
|
||||
<input
|
||||
id="report-title"
|
||||
type="text"
|
||||
class={formControl}
|
||||
placeholder="Auto-generated if empty"
|
||||
value={title()}
|
||||
onInput={(e) => setTitle(e.currentTarget.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<FormField label="Time Range">
|
||||
<div class="flex gap-2">
|
||||
<For each={['24h', '7d', '30d']}>
|
||||
{(r) => (
|
||||
<button
|
||||
class={`px-4 py-2 rounded-lg border transition-all ${range() === r
|
||||
? 'bg-blue-600/20 border-blue-500 text-blue-400'
|
||||
: 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500'
|
||||
}`}
|
||||
onClick={() => setRange(r)}
|
||||
>
|
||||
{r === '24h' ? 'Last 24 Hours' : r === '7d' ? 'Last 7 Days' : 'Last 30 Days'}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Export Format">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class={`flex items-center gap-2 px-4 py-2 rounded-lg border transition-all ${format() === 'pdf'
|
||||
? 'bg-blue-600/20 border-blue-500 text-blue-400'
|
||||
: 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500'
|
||||
}`}
|
||||
onClick={() => setFormat('pdf')}
|
||||
>
|
||||
<FileText size={16} />
|
||||
PDF Report
|
||||
</button>
|
||||
<button
|
||||
class={`flex items-center gap-2 px-4 py-2 rounded-lg border transition-all ${format() === 'csv'
|
||||
? 'bg-blue-600/20 border-blue-500 text-blue-400'
|
||||
: 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500'
|
||||
}`}
|
||||
onClick={() => setFormat('csv')}
|
||||
>
|
||||
<BarChart size={16} />
|
||||
CSV Data
|
||||
</button>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4 border-t border-slate-800">
|
||||
<button
|
||||
class={`flex items-center gap-2 px-6 py-3 rounded-xl font-semibold transition-all ${generating()
|
||||
? 'bg-slate-700 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-white shadow-lg shadow-blue-900/20'
|
||||
}`}
|
||||
disabled={generating()}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
<Show when={generating()} fallback={<Download size={20} />}>
|
||||
<div class="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
</Show>
|
||||
{generating() ? 'Generating...' : 'Generate Report'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div class="bg-blue-900/10 border border-blue-900/20 rounded-xl p-6 mt-8">
|
||||
<div class="flex gap-4">
|
||||
<div class="p-3 bg-blue-600/20 rounded-lg h-fit text-blue-400">
|
||||
<BarChart size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-white mb-2">Enterprise Insights</h3>
|
||||
<p class="text-slate-400 leading-relaxed">
|
||||
Reports are generated directly from the historical metrics store. PDF reports provide a summarized view with average, minimum, and maximum values, while CSV exports provide raw granular data for external analysis in tools like Excel or BI suites.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,8 +37,10 @@ import { SecurityAuthPanel } from './SecurityAuthPanel';
|
||||
import { APIAccessPanel } from './APIAccessPanel';
|
||||
import { SecurityOverviewPanel } from './SecurityOverviewPanel';
|
||||
import AuditLogPanel from './AuditLogPanel';
|
||||
import { AuditWebhookPanel } from './AuditWebhookPanel';
|
||||
import RolesPanel from './RolesPanel';
|
||||
import UserAssignmentsPanel from './UserAssignmentsPanel';
|
||||
import { ReportingPanel } from './ReportingPanel';
|
||||
import {
|
||||
PveNodesTable,
|
||||
PbsNodesTable,
|
||||
@@ -69,6 +71,8 @@ import Sliders from 'lucide-solid/icons/sliders-horizontal';
|
||||
import RefreshCw from 'lucide-solid/icons/refresh-cw';
|
||||
import Clock from 'lucide-solid/icons/clock';
|
||||
import Sparkles from 'lucide-solid/icons/sparkles';
|
||||
import FileText from 'lucide-solid/icons/file-text';
|
||||
import Globe from 'lucide-solid/icons/globe';
|
||||
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
|
||||
import BadgeCheck from 'lucide-solid/icons/badge-check';
|
||||
import type { NodeConfig } from '@/types/nodes';
|
||||
@@ -308,7 +312,9 @@ type SettingsTab =
|
||||
| 'security-users'
|
||||
| 'security-audit'
|
||||
| 'diagnostics'
|
||||
| 'updates';
|
||||
| 'updates'
|
||||
| 'reporting'
|
||||
| 'security-webhooks';
|
||||
|
||||
type AgentKey = 'pve' | 'pbs' | 'pmg';
|
||||
|
||||
@@ -385,6 +391,10 @@ const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: st
|
||||
title: 'Audit Log',
|
||||
description: 'View security events, login attempts, and configuration changes.',
|
||||
},
|
||||
'security-webhooks': {
|
||||
title: 'Audit Webhooks',
|
||||
description: 'Configure real-time delivery of audit events to external systems.',
|
||||
},
|
||||
diagnostics: {
|
||||
title: 'Diagnostics',
|
||||
description:
|
||||
@@ -394,6 +404,10 @@ const SETTINGS_HEADER_META: Record<SettingsTab, { title: string; description: st
|
||||
title: 'Update History',
|
||||
description: 'Review past software updates, rollback events, and upgrade audit logs.',
|
||||
},
|
||||
reporting: {
|
||||
title: 'Detailed Reporting',
|
||||
description: 'Generate and export comprehensive infrastructure reports in PDF and CSV formats.',
|
||||
},
|
||||
};
|
||||
|
||||
const BACKUP_INTERVAL_OPTIONS = [
|
||||
@@ -465,6 +479,8 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
if (path.includes('/settings/security-audit')) return 'security-audit';
|
||||
if (path.includes('/settings/security')) return 'security-overview';
|
||||
if (path.includes('/settings/diagnostics')) return 'diagnostics';
|
||||
if (path.includes('/settings/reporting')) return 'reporting';
|
||||
if (path.includes('/settings/security-webhooks')) return 'security-webhooks';
|
||||
if (path.includes('/settings/updates')) return 'updates';
|
||||
// Legacy platform paths map to the Proxmox tab
|
||||
if (
|
||||
@@ -1052,6 +1068,8 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
iconProps?: { strokeWidth?: number };
|
||||
disabled?: boolean;
|
||||
badge?: string;
|
||||
features?: string[];
|
||||
permissions?: string[];
|
||||
}[];
|
||||
}[] = [
|
||||
{
|
||||
@@ -1115,6 +1133,13 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
icon: BadgeCheck,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
{
|
||||
id: 'reporting',
|
||||
label: 'Reporting',
|
||||
icon: FileText,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
features: ['advanced_reporting'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1157,6 +1182,13 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
icon: Activity,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
},
|
||||
{
|
||||
id: 'security-webhooks',
|
||||
label: 'Webhooks',
|
||||
icon: Globe,
|
||||
iconProps: { strokeWidth: 2 },
|
||||
features: ['audit_logging'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -3688,10 +3720,20 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||
<AuditLogPanel />
|
||||
</Show>
|
||||
|
||||
{/* Security Webhooks Tab */}
|
||||
<Show when={activeTab() === 'security-webhooks'}>
|
||||
<AuditWebhookPanel />
|
||||
</Show>
|
||||
|
||||
{/* Diagnostics Tab */}
|
||||
<Show when={activeTab() === 'diagnostics'}>
|
||||
<DiagnosticsPanel />
|
||||
</Show>
|
||||
|
||||
{/* Reporting Tab */}
|
||||
<Show when={activeTab() === 'reporting'}>
|
||||
<ReportingPanel />
|
||||
</Show>
|
||||
</div>
|
||||
</div >
|
||||
</Card >
|
||||
|
||||
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -162,6 +163,44 @@ func (h *AuditHandlers) HandleVerifyAuditEvent(w http.ResponseWriter, r *http.Re
|
||||
})
|
||||
}
|
||||
|
||||
// HandleGetWebhooks returns the audit webhook configuration.
|
||||
func (h *AuditHandlers) HandleGetWebhooks(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
logger := audit.GetLogger()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"urls": logger.GetWebhookURLs(),
|
||||
})
|
||||
}
|
||||
|
||||
// HandleUpdateWebhooks updates the audit webhook configuration.
|
||||
func (h *AuditHandlers) HandleUpdateWebhooks(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodPut {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
logger := audit.GetLogger()
|
||||
if err := logger.UpdateWebhookURLs(req.URLs); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to update webhooks: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// isPersistentLogger checks if we're using a persistent audit logger (enterprise).
|
||||
func isPersistentLogger() bool {
|
||||
logger := audit.GetLogger()
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
|
||||
)
|
||||
|
||||
// ReportingHandlers handles reporting-related requests
|
||||
type ReportingHandlers struct{}
|
||||
|
||||
// NewReportingHandlers creates a new ReportingHandlers
|
||||
func NewReportingHandlers() *ReportingHandlers {
|
||||
return &ReportingHandlers{}
|
||||
}
|
||||
|
||||
// HandleGenerateReport generates a report
|
||||
func (h *ReportingHandlers) HandleGenerateReport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
engine := reporting.GetEngine()
|
||||
if engine == nil {
|
||||
http.Error(w, "Reporting engine not initialized", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
format := reporting.ReportFormat(q.Get("format"))
|
||||
if format == "" {
|
||||
format = reporting.FormatPDF
|
||||
}
|
||||
|
||||
resourceType := q.Get("resourceType")
|
||||
resourceID := q.Get("resourceId")
|
||||
if resourceType == "" || resourceID == "" {
|
||||
http.Error(w, "resourceType and resourceId are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
metricType := q.Get("metricType")
|
||||
|
||||
// Parse range
|
||||
end := time.Now()
|
||||
if q.Get("end") != "" {
|
||||
if t, err := time.Parse(time.RFC3339, q.Get("end")); err == nil {
|
||||
end = t
|
||||
}
|
||||
}
|
||||
|
||||
start := end.Add(-24 * time.Hour)
|
||||
if q.Get("start") != "" {
|
||||
if t, err := time.Parse(time.RFC3339, q.Get("start")); err == nil {
|
||||
start = t
|
||||
}
|
||||
}
|
||||
|
||||
req := reporting.MetricReportRequest{
|
||||
ResourceType: resourceType,
|
||||
ResourceID: resourceID,
|
||||
MetricType: metricType,
|
||||
Start: start,
|
||||
End: end,
|
||||
Format: format,
|
||||
Title: q.Get("title"),
|
||||
}
|
||||
|
||||
data, contentType, err := engine.Generate(req)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to generate report: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
// Suggest a filename
|
||||
filename := fmt.Sprintf("report-%s-%s.%s", resourceID, time.Now().Format("20060102"), format)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
w.Write(data)
|
||||
}
|
||||
+14
-1
@@ -59,6 +59,7 @@ type Router struct {
|
||||
systemSettingsHandler *SystemSettingsHandler
|
||||
aiSettingsHandler *AISettingsHandler
|
||||
resourceHandlers *ResourceHandlers
|
||||
reportingHandlers *ReportingHandlers
|
||||
configProfileHandler *ConfigProfileHandler
|
||||
licenseHandlers *LicenseHandlers
|
||||
agentExecServer *agentexec.Server
|
||||
@@ -206,6 +207,7 @@ func (r *Router) setupRoutes() {
|
||||
r.resourceHandlers = NewResourceHandlers()
|
||||
r.configProfileHandler = NewConfigProfileHandler(r.persistence)
|
||||
r.licenseHandlers = NewLicenseHandlers(r.config.DataPath)
|
||||
r.reportingHandlers = NewReportingHandlers()
|
||||
rbacHandlers := NewRBACHandlers(r.config)
|
||||
|
||||
// API routes
|
||||
@@ -513,6 +515,18 @@ func (r *Router) setupRoutes() {
|
||||
r.mux.HandleFunc("/api/admin/users", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureRBAC, rbacHandlers.HandleGetUsers)))
|
||||
r.mux.HandleFunc("/api/admin/users/", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureRBAC, rbacHandlers.HandleUserRoleActions)))
|
||||
|
||||
// Advanced Reporting routes
|
||||
r.mux.HandleFunc("/api/admin/reports/generate", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceNodes, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAdvancedReporting, RequireScope(config.ScopeSettingsRead, r.reportingHandlers.HandleGenerateReport))))
|
||||
|
||||
// Audit Webhook routes
|
||||
r.mux.HandleFunc("/api/admin/webhooks/audit", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceAuditLogs, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAuditLogging, func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodGet {
|
||||
RequireScope(config.ScopeSettingsRead, auditHandlers.HandleGetWebhooks)(w, req)
|
||||
} else {
|
||||
RequireScope(config.ScopeSettingsWrite, auditHandlers.HandleUpdateWebhooks)(w, req)
|
||||
}
|
||||
})))
|
||||
|
||||
// Security routes
|
||||
r.mux.HandleFunc("/api/security/change-password", r.handleChangePassword)
|
||||
r.mux.HandleFunc("/api/logout", r.handleLogout)
|
||||
@@ -3729,7 +3743,6 @@ func (r *Router) handleMetricsStoreStats(w http.ResponseWriter, req *http.Reques
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"enabled": true,
|
||||
"dbPath": stats.DBPath,
|
||||
"dbSize": stats.DBSize,
|
||||
"rawCount": stats.RawCount,
|
||||
"minuteCount": stats.MinuteCount,
|
||||
|
||||
@@ -24,9 +24,10 @@ const (
|
||||
FeatureUnlimited = "unlimited" // Unlimited instances (explicit for contracts)
|
||||
|
||||
// Enterprise tier features
|
||||
FeatureAuditLogging = "audit_logging" // Persistent audit logs with signing
|
||||
FeatureSSO = "sso" // OIDC/SSO authentication (Basic)
|
||||
FeatureAdvancedSSO = "advanced_sso" // SAML, Multi-provider, Role Mapping
|
||||
FeatureAuditLogging = "audit_logging" // Persistent audit logs with signing
|
||||
FeatureSSO = "sso" // OIDC/SSO authentication (Basic)
|
||||
FeatureAdvancedSSO = "advanced_sso" // SAML, Multi-provider, Role Mapping
|
||||
FeatureAdvancedReporting = "advanced_reporting" // PDF/CSV reporting engine
|
||||
)
|
||||
|
||||
// Tier represents a license tier.
|
||||
@@ -105,6 +106,7 @@ var TierFeatures = map[Tier][]string{
|
||||
FeatureSSO,
|
||||
FeatureAdvancedSSO,
|
||||
FeatureRBAC,
|
||||
FeatureAdvancedReporting,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -173,6 +175,8 @@ func GetFeatureDisplayName(feature string) string {
|
||||
return "Basic SSO (OIDC)"
|
||||
case FeatureAdvancedSSO:
|
||||
return "Advanced SSO (SAML/Multi-Provider)"
|
||||
case FeatureAdvancedReporting:
|
||||
return "Advanced Infrastructure Reporting (PDF/CSV)"
|
||||
default:
|
||||
return feature
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/discovery"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/errors"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/logging"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/metrics"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
|
||||
@@ -38,6 +37,7 @@ import (
|
||||
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
|
||||
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/fsfilters"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/pbs"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/pmg"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
|
||||
|
||||
@@ -10,13 +10,13 @@ import (
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/metrics"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/resources"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
||||
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/pbs"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/pmg"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
|
||||
|
||||
@@ -1609,6 +1609,14 @@ func (m *Monitor) pollStorageWithNodes(ctx context.Context, instanceName string,
|
||||
m.metricsHistory.AddStorageMetric(storage.ID, "used", float64(storage.Used), timestamp)
|
||||
m.metricsHistory.AddStorageMetric(storage.ID, "total", float64(storage.Total), timestamp)
|
||||
m.metricsHistory.AddStorageMetric(storage.ID, "avail", float64(storage.Free), timestamp)
|
||||
|
||||
// Also write to persistent store for enterprise reporting
|
||||
if m.metricsStore != nil {
|
||||
m.metricsStore.Write("storage", storage.ID, "usage", storage.Usage, timestamp)
|
||||
m.metricsStore.Write("storage", storage.ID, "used", float64(storage.Used), timestamp)
|
||||
m.metricsStore.Write("storage", storage.ID, "total", float64(storage.Total), timestamp)
|
||||
m.metricsStore.Write("storage", storage.ID, "avail", float64(storage.Free), timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
if m.alertManager != nil {
|
||||
|
||||
@@ -54,6 +54,10 @@ type Logger interface {
|
||||
// Count returns the number of audit events matching the filter
|
||||
Count(filter QueryFilter) (int, error)
|
||||
|
||||
// Webhook Management (Optional, may return empty/not implemented for console logger)
|
||||
GetWebhookURLs() []string
|
||||
UpdateWebhookURLs(urls []string) error
|
||||
|
||||
// Close releases any resources held by the logger
|
||||
Close() error
|
||||
}
|
||||
@@ -158,6 +162,16 @@ func (c *ConsoleLogger) Count(filter QueryFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// GetWebhookURLs returns an empty slice for the console logger.
|
||||
func (c *ConsoleLogger) GetWebhookURLs() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// UpdateWebhookURLs returns an error for the console logger.
|
||||
func (c *ConsoleLogger) UpdateWebhookURLs(urls []string) error {
|
||||
return nil // Or return an error saying it's not supported
|
||||
}
|
||||
|
||||
// Close is a no-op for the console logger.
|
||||
func (c *ConsoleLogger) Close() error {
|
||||
return nil
|
||||
|
||||
@@ -520,7 +520,6 @@ func (s *Store) Close() error {
|
||||
|
||||
// Stats holds metrics store statistics
|
||||
type Stats struct {
|
||||
DBPath string `json:"dbPath"`
|
||||
DBSize int64 `json:"dbSize"`
|
||||
RawCount int64 `json:"rawCount"`
|
||||
MinuteCount int64 `json:"minuteCount"`
|
||||
@@ -535,9 +534,7 @@ type Stats struct {
|
||||
|
||||
// GetStats returns storage statistics
|
||||
func (s *Store) GetStats() Stats {
|
||||
stats := Stats{
|
||||
DBPath: s.config.DBPath,
|
||||
}
|
||||
stats := Stats{}
|
||||
|
||||
// Count by tier
|
||||
rows, err := s.db.Query(`SELECT tier, COUNT(*) FROM metrics GROUP BY tier`)
|
||||
@@ -95,7 +95,7 @@ func TestStoreSelectTierAndStats(t *testing.T) {
|
||||
if stats.RawCount != 1 || stats.MinuteCount != 1 || stats.HourlyCount != 1 || stats.DailyCount != 1 {
|
||||
t.Fatalf("unexpected tier counts: %+v", stats)
|
||||
}
|
||||
if stats.DBPath == "" || stats.DBSize <= 0 {
|
||||
if stats.DBSize <= 0 {
|
||||
t.Fatalf("expected stats DB info to be populated: %+v", stats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package reporting
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReportFormat represents the output format of a report
|
||||
type ReportFormat string
|
||||
|
||||
const (
|
||||
FormatCSV ReportFormat = "csv"
|
||||
FormatPDF ReportFormat = "pdf"
|
||||
)
|
||||
|
||||
// MetricReportRequest defines the parameters for generating a report
|
||||
type MetricReportRequest struct {
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
MetricType string // Optional, if empty all metrics for the resource are included
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Format ReportFormat
|
||||
Title string
|
||||
}
|
||||
|
||||
// Engine defines the interface for report generation.
|
||||
// This allows the enterprise version to provide PDF/CSV generation.
|
||||
type Engine interface {
|
||||
Generate(req MetricReportRequest) (data []byte, contentType string, err error)
|
||||
}
|
||||
|
||||
var (
|
||||
globalEngine Engine
|
||||
)
|
||||
|
||||
// SetEngine sets the global report engine.
|
||||
func SetEngine(e Engine) {
|
||||
globalEngine = e
|
||||
}
|
||||
|
||||
// GetEngine returns the current global report engine.
|
||||
func GetEngine() Engine {
|
||||
return globalEngine
|
||||
}
|
||||
+38
-1
@@ -9,6 +9,7 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -19,10 +20,10 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/license"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/logging"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/metrics"
|
||||
_ "github.com/rcourtman/pulse-go-rewrite/internal/mock" // Import for init() to run
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -31,6 +32,23 @@ var (
|
||||
MetricsPort = 9091
|
||||
)
|
||||
|
||||
// BusinessHooks allows enterprise features to hook into the server lifecycle.
|
||||
type BusinessHooks struct {
|
||||
OnMonitorInitialized func(m *monitoring.Monitor)
|
||||
}
|
||||
|
||||
var (
|
||||
globalHooks BusinessHooks
|
||||
globalHooksMu sync.Mutex
|
||||
)
|
||||
|
||||
// SetBusinessHooks registers hooks for the server.
|
||||
func SetBusinessHooks(h BusinessHooks) {
|
||||
globalHooksMu.Lock()
|
||||
defer globalHooksMu.Unlock()
|
||||
globalHooks = h
|
||||
}
|
||||
|
||||
// Run starts the Pulse monitoring server.
|
||||
func Run(ctx context.Context, version string) error {
|
||||
// Initialize logger with baseline defaults for early startup logs
|
||||
@@ -103,6 +121,25 @@ func Run(ctx context.Context, version string) error {
|
||||
return fmt.Errorf("failed to initialize monitoring system: %w", err)
|
||||
}
|
||||
|
||||
// Trigger enterprise hooks if registered
|
||||
var onMonitorInitialized func(*monitoring.Monitor)
|
||||
globalHooksMu.Lock()
|
||||
if globalHooks.OnMonitorInitialized != nil {
|
||||
onMonitorInitialized = globalHooks.OnMonitorInitialized
|
||||
}
|
||||
globalHooksMu.Unlock()
|
||||
|
||||
if onMonitorInitialized != nil {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error().Interface("panic", r).Msg("Enterprise OnMonitorInitialized hook panicked")
|
||||
}
|
||||
}()
|
||||
onMonitorInitialized(reloadableMonitor.GetMonitor())
|
||||
}()
|
||||
}
|
||||
|
||||
// Set state getter for WebSocket hub
|
||||
wsHub.SetStateGetter(func() interface{} {
|
||||
state := reloadableMonitor.GetMonitor().GetState()
|
||||
|
||||
Reference in New Issue
Block a user