using Microsoft.EntityFrameworkCore; using PdfSharpCore.Drawing; using PdfSharpCore.Pdf; using OTSSignsOrchestrator.Server.Data; using OTSSignsOrchestrator.Server.Data.Entities; namespace OTSSignsOrchestrator.Server.Reports; public class FleetHealthPdfService { private readonly IServiceProvider _services; private readonly ILogger _logger; // OTS Signs brand colours private static readonly XColor BrandAccent = XColor.FromArgb(0x3B, 0x82, 0xF6); private static readonly XColor HeaderBg = XColor.FromArgb(0x1E, 0x29, 0x3B); private static readonly XColor TextDark = XColor.FromArgb(0x1F, 0x28, 0x37); private static readonly XColor TextMuted = XColor.FromArgb(0x6B, 0x72, 0x80); private static readonly XColor TableBorder = XColor.FromArgb(0xD1, 0xD5, 0xDB); private static readonly XColor RowAlt = XColor.FromArgb(0xF9, 0xFA, 0xFB); private const double PageWidth = 595; // A4 points private const double PageHeight = 842; private const double MarginX = 50; private const double ContentWidth = PageWidth - 2 * MarginX; public FleetHealthPdfService(IServiceProvider services, ILogger logger) { _services = services; _logger = logger; } /// /// Generates a fleet-wide health PDF report for the given date range. /// Includes: branded header, fleet summary stats, per-instance health table, /// Authentik uptime percentage. /// public async Task GenerateFleetHealthPdfAsync(DateOnly from, DateOnly to) { await using var scope = _services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var fromDt = from.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); var toDt = to.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); var instances = await db.Instances .AsNoTracking() .Include(i => i.Customer) .ToListAsync(); var healthEvents = await db.HealthEvents .AsNoTracking() .Where(h => h.OccurredAt >= fromDt && h.OccurredAt <= toDt) .ToListAsync(); var screenSnapshots = await db.ScreenSnapshots .AsNoTracking() .Where(s => s.SnapshotDate >= from && s.SnapshotDate <= to) .ToListAsync(); var authentikMetrics = await db.AuthentikMetrics .AsNoTracking() .Where(m => m.CheckedAt >= fromDt && m.CheckedAt <= toDt) .ToListAsync(); // Compute stats var totalInstances = instances.Count; var totalScreens = screenSnapshots .GroupBy(s => s.InstanceId) .Sum(g => g.OrderByDescending(s => s.SnapshotDate).First().ScreenCount); var healthBreakdown = instances.GroupBy(i => i.HealthStatus) .ToDictionary(g => g.Key, g => g.Count()); var authentikUptime = ComputeAuthentikUptime(authentikMetrics); var latestHealthByInstance = healthEvents .GroupBy(h => h.InstanceId) .ToDictionary(g => g.Key, g => g.OrderByDescending(h => h.OccurredAt).First()); var p1CountByInstance = healthEvents .Where(h => h.Status == HealthEventStatus.Critical) .GroupBy(h => h.InstanceId) .ToDictionary(g => g.Key, g => g.Count()); // Build PDF using var doc = new PdfDocument(); doc.Info.Title = "OTS Signs — Fleet Health Report"; doc.Info.Author = "OTS Signs Orchestrator"; var page = doc.AddPage(); page.Width = PageWidth; page.Height = PageHeight; var gfx = XGraphics.FromPdfPage(page); var y = DrawBrandedHeader(gfx, "Fleet Health Report", from, to); // Summary stats var fontBold = new XFont("Helvetica", 12, XFontStyle.Bold); var fontNormal = new XFont("Helvetica", 10); y = DrawSectionTitle(gfx, "Fleet Summary", y); var summaryData = new (string Label, string Value)[] { ("Total Instances", totalInstances.ToString()), ("Total Screens", totalScreens.ToString()), ("Healthy", healthBreakdown.GetValueOrDefault(HealthStatus.Healthy).ToString()), ("Degraded", healthBreakdown.GetValueOrDefault(HealthStatus.Degraded).ToString()), ("Critical", healthBreakdown.GetValueOrDefault(HealthStatus.Critical).ToString()), ("Unknown", healthBreakdown.GetValueOrDefault(HealthStatus.Unknown).ToString()), ("Authentik Uptime", $"{authentikUptime:F1}%"), }; foreach (var (label, value) in summaryData) { gfx.DrawString(label + ":", fontBold, new XSolidBrush(TextDark), new XRect(MarginX, y, 150, 16), XStringFormats.TopLeft); gfx.DrawString(value, fontNormal, new XSolidBrush(TextDark), new XRect(MarginX + 160, y, 150, 16), XStringFormats.TopLeft); y += 18; } y += 10; // Per-instance health table y = DrawSectionTitle(gfx, "Per-Instance Health", y); var columns = new (string Header, double Width)[] { ("Abbrev", 60), ("Status", 70), ("Last Check", 110), ("P1 Inc.", 50), ("URL", ContentWidth - 290), }; y = DrawTableHeader(gfx, columns, y); var rowIndex = 0; foreach (var instance in instances.OrderBy(i => i.Customer.Abbreviation)) { if (y > PageHeight - 60) { page = doc.AddPage(); page.Width = PageWidth; page.Height = PageHeight; gfx = XGraphics.FromPdfPage(page); y = 50; y = DrawTableHeader(gfx, columns, y); rowIndex = 0; } latestHealthByInstance.TryGetValue(instance.Id, out var latestHealth); p1CountByInstance.TryGetValue(instance.Id, out var p1Count); var cellValues = new[] { instance.Customer.Abbreviation, instance.HealthStatus.ToString(), latestHealth?.OccurredAt.ToString("yyyy-MM-dd HH:mm") ?? "—", p1Count.ToString(), instance.XiboUrl, }; y = DrawTableRow(gfx, columns, cellValues, y, rowIndex % 2 == 1); rowIndex++; } _logger.LogInformation("Generated fleet health PDF: {Instances} instances, period {From} to {To}", totalInstances, from, to); return SavePdf(doc); } /// /// Generates a per-customer usage PDF for the given date range. /// public async Task GenerateCustomerUsagePdfAsync(Guid customerId, DateOnly from, DateOnly to) { await using var scope = _services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var customer = await db.Customers .AsNoTracking() .Include(c => c.Instances) .FirstOrDefaultAsync(c => c.Id == customerId) ?? throw new InvalidOperationException($"Customer {customerId} not found."); var snapshots = await db.ScreenSnapshots .AsNoTracking() .Where(s => s.Instance.CustomerId == customerId && s.SnapshotDate >= from && s.SnapshotDate <= to) .OrderBy(s => s.SnapshotDate) .ToListAsync(); using var doc = new PdfDocument(); doc.Info.Title = $"OTS Signs — Usage Report — {customer.CompanyName}"; doc.Info.Author = "OTS Signs Orchestrator"; var page = doc.AddPage(); page.Width = PageWidth; page.Height = PageHeight; var gfx = XGraphics.FromPdfPage(page); var y = DrawBrandedHeader(gfx, $"Customer Usage Report — {customer.CompanyName}", from, to); // Customer details y = DrawSectionTitle(gfx, "Customer Details", y); var fontBold = new XFont("Helvetica", 10, XFontStyle.Bold); var fontNormal = new XFont("Helvetica", 10); var details = new (string Label, string Value)[] { ("Company", customer.CompanyName), ("Abbreviation", customer.Abbreviation), ("Plan", customer.Plan.ToString()), ("Screen Quota", customer.ScreenCount.ToString()), ("Status", customer.Status.ToString()), }; var primaryInstance = customer.Instances.FirstOrDefault(); if (primaryInstance is not null) details = [.. details, ("Instance URL", primaryInstance.XiboUrl)]; foreach (var (label, value) in details) { gfx.DrawString(label + ":", fontBold, new XSolidBrush(TextDark), new XRect(MarginX, y, 120, 16), XStringFormats.TopLeft); gfx.DrawString(value, fontNormal, new XSolidBrush(TextDark), new XRect(MarginX + 130, y, 350, 16), XStringFormats.TopLeft); y += 18; } y += 10; // Screen count history y = DrawSectionTitle(gfx, "Screen Count History", y); if (snapshots.Count == 0) { var fontItalic = new XFont("Helvetica", 10, XFontStyle.Italic); gfx.DrawString("No screen snapshot data available for this period.", fontItalic, new XSolidBrush(TextMuted), new XRect(MarginX, y, ContentWidth, 16), XStringFormats.TopLeft); y += 20; } else { var byDate = snapshots .GroupBy(s => s.SnapshotDate) .OrderBy(g => g.Key) .Select(g => (Date: g.Key, Count: g.Sum(s => s.ScreenCount))) .ToList(); // Table var cols = new (string Header, double Width)[] { ("Date", 150), ("Screen Count", 150) }; y = DrawTableHeader(gfx, cols, y); var rowIdx = 0; foreach (var (date, count) in byDate) { if (y > PageHeight - 60) { page = doc.AddPage(); page.Width = PageWidth; page.Height = PageHeight; gfx = XGraphics.FromPdfPage(page); y = 50; y = DrawTableHeader(gfx, cols, y); rowIdx = 0; } y = DrawTableRow(gfx, cols, [date.ToString("yyyy-MM-dd"), count.ToString()], y, rowIdx % 2 == 1); rowIdx++; } y += 20; // Simple bar chart if (y + byDate.Count * 14 + 30 > PageHeight - 40) { page = doc.AddPage(); page.Width = PageWidth; page.Height = PageHeight; gfx = XGraphics.FromPdfPage(page); y = 50; } y = DrawSectionTitle(gfx, "Daily Screen Count", y); var maxCount = byDate.Max(d => d.Count); if (maxCount > 0) { var barMaxWidth = ContentWidth - 120; var fontSmall = new XFont("Helvetica", 8); var barBrush = new XSolidBrush(BrandAccent); foreach (var (date, count) in byDate) { var barWidth = Math.Max(2, (double)count / maxCount * barMaxWidth); gfx.DrawString(date.ToString("MM-dd"), fontSmall, new XSolidBrush(TextDark), new XRect(MarginX, y, 50, 12), XStringFormats.TopLeft); gfx.DrawRectangle(barBrush, MarginX + 55, y + 1, barWidth, 10); gfx.DrawString(count.ToString(), fontSmall, new XSolidBrush(TextDark), new XRect(MarginX + 60 + barWidth, y, 50, 12), XStringFormats.TopLeft); y += 14; } } } _logger.LogInformation("Generated customer usage PDF for {CustomerId} ({Abbrev}), period {From} to {To}", customerId, customer.Abbreviation, from, to); return SavePdf(doc); } // ── Drawing helpers ───────────────────────────────────────────────────── private static double DrawBrandedHeader(XGraphics gfx, string title, DateOnly from, DateOnly to) { var y = 40.0; var brandFont = new XFont("Helvetica", 22, XFontStyle.Bold); gfx.DrawString("OTS Signs", brandFont, new XSolidBrush(BrandAccent), new XRect(MarginX, y, ContentWidth, 28), XStringFormats.TopLeft); y += 30; var titleFont = new XFont("Helvetica", 14, XFontStyle.Bold); gfx.DrawString(title, titleFont, new XSolidBrush(TextDark), new XRect(MarginX, y, ContentWidth, 20), XStringFormats.TopLeft); y += 22; var dateFont = new XFont("Helvetica", 9); gfx.DrawString($"Period: {from:yyyy-MM-dd} to {to:yyyy-MM-dd} | Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC", dateFont, new XSolidBrush(TextMuted), new XRect(MarginX, y, ContentWidth, 14), XStringFormats.TopLeft); y += 18; // Separator gfx.DrawLine(new XPen(BrandAccent, 1.5), MarginX, y, PageWidth - MarginX, y); y += 15; return y; } private static double DrawSectionTitle(XGraphics gfx, string title, double y) { var font = new XFont("Helvetica", 13, XFontStyle.Bold); gfx.DrawString(title, font, new XSolidBrush(TextDark), new XRect(MarginX, y, ContentWidth, 18), XStringFormats.TopLeft); return y + 22; } private static double DrawTableHeader(XGraphics gfx, (string Header, double Width)[] columns, double y) { var font = new XFont("Helvetica", 9, XFontStyle.Bold); var brush = new XSolidBrush(XColors.White); var bgBrush = new XSolidBrush(HeaderBg); var x = MarginX; var totalWidth = columns.Sum(c => c.Width); gfx.DrawRectangle(bgBrush, x, y, totalWidth, 18); foreach (var (header, width) in columns) { gfx.DrawString(header, font, brush, new XRect(x + 4, y + 3, width - 8, 14), XStringFormats.TopLeft); x += width; } return y + 18; } private static double DrawTableRow(XGraphics gfx, (string Header, double Width)[] columns, string[] values, double y, bool alternate) { var font = new XFont("Helvetica", 8); var textBrush = new XSolidBrush(TextDark); var x = MarginX; var totalWidth = columns.Sum(c => c.Width); if (alternate) gfx.DrawRectangle(new XSolidBrush(RowAlt), x, y, totalWidth, 16); gfx.DrawLine(new XPen(TableBorder, 0.5), x, y + 16, x + totalWidth, y + 16); for (int i = 0; i < columns.Length && i < values.Length; i++) { gfx.DrawString(values[i], font, textBrush, new XRect(x + 4, y + 2, columns[i].Width - 8, 14), XStringFormats.TopLeft); x += columns[i].Width; } return y + 16; } private static double ComputeAuthentikUptime(List metrics) { if (metrics.Count == 0) return 100.0; var healthy = metrics.Count(m => m.Status == AuthentikMetricsStatus.Healthy); return (double)healthy / metrics.Count * 100; } private static byte[] SavePdf(PdfDocument doc) { using var stream = new MemoryStream(); doc.Save(stream, false); return stream.ToArray(); } }