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:
rcourtman
2026-01-09 21:25:38 +00:00
parent 92c150e979
commit 2a8f55d719
21 changed files with 725 additions and 13 deletions
+2
View File
@@ -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:
+8
View File
@@ -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)
+16
View File
@@ -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)
+11
View File
@@ -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 >
+39
View File
@@ -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()
+83
View File
@@ -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
View File
@@ -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,
+7 -3
View File
@@ -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
}
+1 -1
View File
@@ -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"
+8
View File
@@ -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 {
+14
View File
@@ -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)
}
}
+44
View File
@@ -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
View File
@@ -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()