diff --git a/Cargo.lock b/Cargo.lock index 71667f2..2c14f72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2206,7 +2206,7 @@ dependencies = [ [[package]] name = "pm-agent-client" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "chrono", @@ -2223,7 +2223,7 @@ dependencies = [ [[package]] name = "pm-auth" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "argon2", @@ -2250,7 +2250,7 @@ dependencies = [ [[package]] name = "pm-ca" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "chrono", @@ -2273,7 +2273,7 @@ dependencies = [ [[package]] name = "pm-core" -version = "0.1.2" +version = "0.1.3" dependencies = [ "aes-gcm", "anyhow", @@ -2297,7 +2297,7 @@ dependencies = [ [[package]] name = "pm-reports" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "chrono", @@ -2318,7 +2318,7 @@ dependencies = [ [[package]] name = "pm-web" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "axum", @@ -2355,7 +2355,7 @@ dependencies = [ [[package]] name = "pm-worker" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "chrono", diff --git a/crates/pm-reports/src/csv.rs b/crates/pm-reports/src/csv.rs index 0820113..e9a7637 100644 --- a/crates/pm-reports/src/csv.rs +++ b/crates/pm-reports/src/csv.rs @@ -27,10 +27,10 @@ SELECT h.fqdn, h.health_status::text AS health_status, h.last_patch_at, - COALESCE(pd.total_packages, 0) AS total_packages, - COALESCE(pd.pending_patches, 0) AS pending_patches, - CASE WHEN COALESCE(pd.total_packages,0) = 0 THEN 100.0 - ELSE ROUND((1.0 - pd.pending_patches::float / NULLIF(pd.total_packages,0)) * 100, 1) + COALESCE(jsonb_array_length(pd.installed_packages), 0) AS total_packages, + COALESCE(pd.patch_count, 0) AS pending_patches, + CASE WHEN COALESCE(jsonb_array_length(pd.installed_packages), 0) = 0 THEN 100.0 + ELSE ROUND((1.0 - pd.patch_count::float / NULLIF(jsonb_array_length(pd.installed_packages), 0)) * 100, 1) END AS compliance_pct, COALESCE(string_agg(DISTINCT g.name, ', '), '') AS group_names FROM hosts h @@ -40,7 +40,7 @@ LEFT JOIN groups g ON g.id = hg.group_id WHERE h.id IN ( SELECT host_id FROM host_groups WHERE group_id = $1 ) -GROUP BY h.id, pd.total_packages, pd.pending_patches +GROUP BY h.id, pd.installed_packages, pd.patch_count ORDER BY compliance_pct ASC ", ) @@ -57,17 +57,17 @@ SELECT h.fqdn, h.health_status::text AS health_status, h.last_patch_at, - COALESCE(pd.total_packages, 0) AS total_packages, - COALESCE(pd.pending_patches, 0) AS pending_patches, - CASE WHEN COALESCE(pd.total_packages,0) = 0 THEN 100.0 - ELSE ROUND((1.0 - pd.pending_patches::float / NULLIF(pd.total_packages,0)) * 100, 1) + COALESCE(jsonb_array_length(pd.installed_packages), 0) AS total_packages, + COALESCE(pd.patch_count, 0) AS pending_patches, + CASE WHEN COALESCE(jsonb_array_length(pd.installed_packages), 0) = 0 THEN 100.0 + ELSE ROUND((1.0 - pd.patch_count::float / NULLIF(jsonb_array_length(pd.installed_packages), 0)) * 100, 1) END AS compliance_pct, COALESCE(string_agg(DISTINCT g.name, ', '), '') AS group_names FROM hosts h LEFT JOIN host_patch_data pd ON pd.host_id = h.id LEFT JOIN host_groups hg ON hg.host_id = h.id LEFT JOIN groups g ON g.id = hg.group_id -GROUP BY h.id, pd.total_packages, pd.pending_patches +GROUP BY h.id, pd.installed_packages, pd.patch_count ORDER BY compliance_pct ASC ", ) @@ -220,19 +220,19 @@ SELECT h.id::text AS host_id, h.display_name, h.fqdn, - cve.cve_id, - cve.package_name, - cve.severity, - cve.available_version, - pd.updated_at AS last_seen_at + cve_id, + patch->>'name' AS package_name, + patch->>'severity' AS severity, + patch->>'available_version' AS available_version, + pd.polled_at AS last_seen_at FROM hosts h JOIN host_patch_data pd ON pd.host_id = h.id -CROSS JOIN LATERAL jsonb_to_recordset(COALESCE(pd.cve_data, '[]'::jsonb)) - AS cve(cve_id text, package_name text, severity text, available_version text) -WHERE ($1::timestamptz IS NULL OR pd.updated_at >= $1) - AND ($2::timestamptz IS NULL OR pd.updated_at <= $2) +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(pd.available_patches, '[]'::jsonb)) AS patch +CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(patch->'cve_ids', '[]'::jsonb)) AS cve_id +WHERE ($1::timestamptz IS NULL OR pd.polled_at >= $1) + AND ($2::timestamptz IS NULL OR pd.polled_at <= $2) ORDER BY - CASE cve.severity + CASE patch->>'severity' WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 @@ -274,18 +274,8 @@ ORDER BY } }, Err(e) => { - tracing::warn!(error = %e, "vulnerability query failed — returning empty rows"); - // write a comment row indicating empty data - wtr.write_record(&[ - "(no data)", - "", - "", - "", - "", - "", - "", - &format!("query error: {}", e), - ])?; + tracing::warn!(error = %e, "vulnerability query failed — returning header-only CSV"); + // Return header-only CSV (no invalid comment rows) }, } diff --git a/crates/pm-reports/src/pdf.rs b/crates/pm-reports/src/pdf.rs index c8d9c7c..40d6083 100644 --- a/crates/pm-reports/src/pdf.rs +++ b/crates/pm-reports/src/pdf.rs @@ -250,15 +250,15 @@ async fn compliance_pdf(pool: &sqlx::PgPool, params: &ReportParams) -> anyhow::R sqlx::query( " SELECT h.display_name, h.fqdn, - COALESCE(pd.total_packages,0) AS total_packages, - COALESCE(pd.pending_patches,0) AS pending_patches, - CASE WHEN COALESCE(pd.total_packages,0)=0 THEN 100.0 - ELSE ROUND((1.0-pd.pending_patches::float/NULLIF(pd.total_packages,0))*100,1) + COALESCE(jsonb_array_length(pd.installed_packages),0) AS total_packages, + COALESCE(pd.patch_count,0) AS pending_patches, + CASE WHEN COALESCE(jsonb_array_length(pd.installed_packages),0)=0 THEN 100.0 + ELSE ROUND((1.0-pd.patch_count::float/NULLIF(jsonb_array_length(pd.installed_packages),0))*100,1) END AS compliance_pct, h.health_status::text AS health_status FROM hosts h LEFT JOIN host_patch_data pd ON pd.host_id=h.id WHERE h.id IN (SELECT host_id FROM host_groups WHERE group_id=$1) -GROUP BY h.id,pd.total_packages,pd.pending_patches +GROUP BY h.id, pd.installed_packages, pd.patch_count ORDER BY compliance_pct ASC", ) .bind(gid) @@ -269,14 +269,14 @@ ORDER BY compliance_pct ASC", sqlx::query( " SELECT h.display_name, h.fqdn, - COALESCE(pd.total_packages,0) AS total_packages, - COALESCE(pd.pending_patches,0) AS pending_patches, - CASE WHEN COALESCE(pd.total_packages,0)=0 THEN 100.0 - ELSE ROUND((1.0-pd.pending_patches::float/NULLIF(pd.total_packages,0))*100,1) + COALESCE(jsonb_array_length(pd.installed_packages),0) AS total_packages, + COALESCE(pd.patch_count,0) AS pending_patches, + CASE WHEN COALESCE(jsonb_array_length(pd.installed_packages),0)=0 THEN 100.0 + ELSE ROUND((1.0-pd.patch_count::float/NULLIF(jsonb_array_length(pd.installed_packages),0))*100,1) END AS compliance_pct, h.health_status::text AS health_status FROM hosts h LEFT JOIN host_patch_data pd ON pd.host_id=h.id -GROUP BY h.id,pd.total_packages,pd.pending_patches +GROUP BY h.id, pd.installed_packages, pd.patch_count ORDER BY compliance_pct ASC", ) .fetch_all(pool) @@ -333,7 +333,7 @@ ORDER BY compliance_pct ASC", Ok((raw, w, h)) => { pdf.new_page(); pdf.write_text("Compliance Chart", 16.0, MARGIN, 200.0, true); - if let Err(e) = pdf.embed_image(raw, w, h, MARGIN, 10.0, 0.18, 0.18) { + if let Err(e) = pdf.embed_image(raw, w, h, MARGIN, 10.0, 0.28, 0.28) { tracing::warn!(error = %e, "chart embed failed"); } }, @@ -432,7 +432,7 @@ ORDER BY pjh.started_at DESC", Ok((raw, w, h)) => { pdf.new_page(); pdf.write_text("Patch Activity Chart", 16.0, MARGIN, 200.0, true); - if let Err(e) = pdf.embed_image(raw, w, h, MARGIN, 10.0, 0.18, 0.18) { + if let Err(e) = pdf.embed_image(raw, w, h, MARGIN, 10.0, 0.28, 0.28) { tracing::warn!(error = %e, "chart embed failed"); } }, @@ -451,14 +451,17 @@ async fn vulnerability_pdf(pool: &sqlx::PgPool, params: &ReportParams) -> anyhow // Query DB FIRST (before creating any non-Send PdfBuilder) let query_result = sqlx::query(" SELECT h.display_name, h.fqdn, - cve.cve_id, cve.package_name, cve.severity, cve.available_version, - pd.updated_at AS last_seen_at + cve_id, + patch->>'name' AS package_name, + patch->>'severity' AS severity, + patch->>'available_version' AS available_version, + pd.polled_at AS last_seen_at FROM hosts h JOIN host_patch_data pd ON pd.host_id=h.id -CROSS JOIN LATERAL jsonb_to_recordset(COALESCE(pd.cve_data,'[]'::jsonb)) - AS cve(cve_id text,package_name text,severity text,available_version text) -WHERE ($1::timestamptz IS NULL OR pd.updated_at>=$1) - AND ($2::timestamptz IS NULL OR pd.updated_at<=$2) -ORDER BY CASE cve.severity WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END, +CROSS JOIN LATERAL jsonb_array_elements(COALESCE(pd.available_patches,'[]'::jsonb)) AS patch +CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(patch->'cve_ids','[]'::jsonb)) AS cve_id +WHERE ($1::timestamptz IS NULL OR pd.polled_at>=$1) + AND ($2::timestamptz IS NULL OR pd.polled_at<=$2) +ORDER BY CASE patch->>'severity' WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END, h.display_name") .bind(params.from).bind(params.to).fetch_all(pool).await; // Now create PdfBuilder (non-Send Rc types) after all awaits diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx index e077255..3f74755 100644 --- a/frontend/src/pages/ReportsPage.tsx +++ b/frontend/src/pages/ReportsPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Alert, Box, @@ -22,8 +22,8 @@ import { import DescriptionIcon from '@mui/icons-material/Description' import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf' import VerifiedUserIcon from '@mui/icons-material/VerifiedUser' -import { reportsApi, settingsApi } from '../api/client' -import type { ReportType, ReportFormat, AuditIntegrityResult } from '../types' +import { reportsApi, settingsApi, apiClient } from '../api/client' +import type { ReportType, ReportFormat, AuditIntegrityResult, Group } from '../types' // ── Report metadata ─────────────────────────────────────────────────────────── @@ -97,11 +97,20 @@ export default function ReportsPage() { const [fromDate, setFromDate] = useState(defaultFromDate()) const [toDate, setToDate] = useState(defaultToDate()) const [groupId, setGroupId] = useState('') + const [groups, setGroups] = useState([]) const [downloading, setDownloading] = useState(false) const [error, setError] = useState(null) const [verifyingIntegrity, setVerifyingIntegrity] = useState(false) const [integrityResult, setIntegrityResult] = useState(null) + useEffect(() => { + apiClient.get('/groups').then((res) => { + setGroups(res.data) + }).catch(() => { + // Groups fetch is optional; silently ignore errors + }) + }, []) + const info = REPORT_INFO[reportType] const handleDownload = async (format: ReportFormat) => { @@ -111,7 +120,7 @@ export default function ReportsPage() { const params: Record = {} if (fromDate) params.from = new Date(fromDate).toISOString() if (toDate) params.to = new Date(toDate + 'T23:59:59Z').toISOString() - if (reportType === 'compliance' && groupId.trim()) params.group_id = groupId.trim() + if (reportType === 'compliance' && groupId) params.group_id = groupId const res = await reportsApi.download(reportType, format, params) @@ -203,13 +212,21 @@ export default function ReportsPage() { {/* Group Filter — compliance only */} {reportType === 'compliance' && ( - Group (optional) + + Filter compliance report by a specific group )} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a9e9979..7df809d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -261,10 +261,6 @@ export interface SmtpConfig { tls_mode: string } -export interface PollingConfig { - health_poll_interval_secs: number - patch_poll_interval_secs: number -} export interface PollingConfig { health_poll_interval_secs: number patch_poll_interval_secs: number