diff --git a/README.md b/README.md index 62d91c7fb..866f5db53 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/API.md b/docs/API.md index 5b2fc7481..ed9b2f831 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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) diff --git a/docs/PULSE_PRO.md b/docs/PULSE_PRO.md index 6d6c3737b..4da0dbd1b 100644 --- a/docs/PULSE_PRO.md +++ b/docs/PULSE_PRO.md @@ -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) diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index 44f11e3cc..09bb5b278 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -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. diff --git a/frontend-modern/src/components/Settings/AuditWebhookPanel.tsx b/frontend-modern/src/components/Settings/AuditWebhookPanel.tsx new file mode 100644 index 000000000..a703e3614 --- /dev/null +++ b/frontend-modern/src/components/Settings/AuditWebhookPanel.tsx @@ -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([]); + 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 ( +
+ Audit Webhooks} + description={<>Configure real-time delivery of audit events to external systems.} + /> + + +
+

+ 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. +

+ +
+ + {(url) => ( +
+
+
+ +
+ {url} +
+ +
+ )} +
+ + +
+ +

No audit webhooks configured yet.

+
+
+
+ +
+ setNewUrl(e.currentTarget.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddWebhook()} + /> + +
+
+
+ +
+
+
+ +
+
+

Security Note

+

+ 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. +

+
+
+
+
+ ); +} diff --git a/frontend-modern/src/components/Settings/ReportingPanel.tsx b/frontend-modern/src/components/Settings/ReportingPanel.tsx new file mode 100644 index 000000000..b486e98de --- /dev/null +++ b/frontend-modern/src/components/Settings/ReportingPanel.tsx @@ -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 ( +
+ + {props.children} + {props.helpText && {props.helpText}} +
+ ); +} + +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 ( +
+ Advanced Reporting} + description={<>Generate detailed infrastructure reports in PDF or CSV format.} + /> + + +
+
+ + + + + + setResourceId(e.currentTarget.value)} + /> + +
+ +
+ + setMetricType(e.currentTarget.value)} + /> + + + + setTitle(e.currentTarget.value)} + /> + +
+ +
+ +
+ + {(r) => ( + + )} + +
+
+ + +
+ + +
+
+
+ +
+ +
+
+
+ +
+
+
+ +
+
+

Enterprise Insights

+

+ 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. +

+
+
+
+
+ ); +} diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 50025f49b..b23e8ec5b 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -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 = (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 = (props) => { iconProps?: { strokeWidth?: number }; disabled?: boolean; badge?: string; + features?: string[]; + permissions?: string[]; }[]; }[] = [ { @@ -1115,6 +1133,13 @@ const Settings: Component = (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 = (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 = (props) => { + {/* Security Webhooks Tab */} + + + + {/* Diagnostics Tab */} + + {/* Reporting Tab */} + + + diff --git a/internal/api/audit_handlers.go b/internal/api/audit_handlers.go index f7607ecb4..529468be0 100644 --- a/internal/api/audit_handlers.go +++ b/internal/api/audit_handlers.go @@ -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() diff --git a/internal/api/reporting_handlers.go b/internal/api/reporting_handlers.go new file mode 100644 index 000000000..4477e3251 --- /dev/null +++ b/internal/api/reporting_handlers.go @@ -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) +} diff --git a/internal/api/router.go b/internal/api/router.go index dc2b6be3f..df804182d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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, diff --git a/internal/license/features.go b/internal/license/features.go index 9bbc06c40..7b2720627 100644 --- a/internal/license/features.go +++ b/internal/license/features.go @@ -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 } diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 8c4d2e581..ae44c5aee 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -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" diff --git a/internal/monitoring/monitor_extra_coverage_test.go b/internal/monitoring/monitor_extra_coverage_test.go index 3481fe1a4..a3f79cb55 100644 --- a/internal/monitoring/monitor_extra_coverage_test.go +++ b/internal/monitoring/monitor_extra_coverage_test.go @@ -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" diff --git a/internal/monitoring/monitor_polling.go b/internal/monitoring/monitor_polling.go index 6b7daa86a..7ffb8a1cc 100644 --- a/internal/monitoring/monitor_polling.go +++ b/internal/monitoring/monitor_polling.go @@ -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 { diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index 6f39f6abd..f54a6b5dc 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -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 diff --git a/internal/metrics/alert_metrics.go b/pkg/metrics/alert_metrics.go similarity index 100% rename from internal/metrics/alert_metrics.go rename to pkg/metrics/alert_metrics.go diff --git a/internal/metrics/alert_metrics_test.go b/pkg/metrics/alert_metrics_test.go similarity index 100% rename from internal/metrics/alert_metrics_test.go rename to pkg/metrics/alert_metrics_test.go diff --git a/internal/metrics/store.go b/pkg/metrics/store.go similarity index 99% rename from internal/metrics/store.go rename to pkg/metrics/store.go index d07a2e47b..42b9a630a 100644 --- a/internal/metrics/store.go +++ b/pkg/metrics/store.go @@ -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`) diff --git a/internal/metrics/store_test.go b/pkg/metrics/store_test.go similarity index 98% rename from internal/metrics/store_test.go rename to pkg/metrics/store_test.go index 4869442b3..92e811e2f 100644 --- a/internal/metrics/store_test.go +++ b/pkg/metrics/store_test.go @@ -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) } } diff --git a/pkg/reporting/reporting.go b/pkg/reporting/reporting.go new file mode 100644 index 000000000..01e0022da --- /dev/null +++ b/pkg/reporting/reporting.go @@ -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 +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 43cee147d..c5382e9c7 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -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()