feat: Implement provisioning pipelines for subscription management

- 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.
This commit is contained in:
Matt Batchelder
2026-03-18 10:27:26 -04:00
parent c2e03de8bb
commit c6d46098dd
77 changed files with 9412 additions and 29 deletions

View File

@@ -0,0 +1,401 @@
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();
}
}