- Add ReactivatePipeline to handle subscription reactivation, including scaling Docker services, health verification, status updates, audit logging, and broadcasting status changes. - Introduce RotateCredentialsPipeline for OAuth2 credential rotation, managing the deletion of old apps, creation of new ones, credential storage, access verification, and audit logging. - Create StepRunner to manage job step execution, including lifecycle management and progress broadcasting via SignalR. - Implement SuspendPipeline for subscription suspension, scaling down services, updating statuses, logging audits, and broadcasting changes. - Add UpdateScreenLimitPipeline to update Xibo CMS screen limits and record snapshots. - Introduce XiboFeatureManifests for hardcoded feature ACLs per role. - Add docker-compose.dev.yml for local development with PostgreSQL setup.
402 lines
15 KiB
C#
402 lines
15 KiB
C#
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<FleetHealthPdfService> _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<FleetHealthPdfService> logger)
|
|
{
|
|
_services = services;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public async Task<byte[]> GenerateFleetHealthPdfAsync(DateOnly from, DateOnly to)
|
|
{
|
|
await using var scope = _services.CreateAsyncScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a per-customer usage PDF for the given date range.
|
|
/// </summary>
|
|
public async Task<byte[]> GenerateCustomerUsagePdfAsync(Guid customerId, DateOnly from, DateOnly to)
|
|
{
|
|
await using var scope = _services.CreateAsyncScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
|
|
|
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<AuthentikMetrics> 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();
|
|
}
|
|
}
|