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:
179
OTSSignsOrchestrator.Server/Reports/BillingReportService.cs
Normal file
179
OTSSignsOrchestrator.Server/Reports/BillingReportService.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OTSSignsOrchestrator.Server.Data;
|
||||
using OTSSignsOrchestrator.Server.Data.Entities;
|
||||
|
||||
namespace OTSSignsOrchestrator.Server.Reports;
|
||||
|
||||
public class BillingReportService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<BillingReportService> _logger;
|
||||
|
||||
public BillingReportService(IServiceProvider services, ILogger<BillingReportService> logger)
|
||||
{
|
||||
_services = services;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a billing CSV for the given date range.
|
||||
/// Columns: customer_abbrev, company_name, date, screen_count, plan
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateBillingCsvAsync(DateOnly from, DateOnly to)
|
||||
{
|
||||
await using var scope = _services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||
|
||||
var rows = await db.ScreenSnapshots
|
||||
.AsNoTracking()
|
||||
.Include(s => s.Instance)
|
||||
.ThenInclude(i => i.Customer)
|
||||
.Where(s => s.SnapshotDate >= from && s.SnapshotDate <= to)
|
||||
.Select(s => new
|
||||
{
|
||||
Abbrev = s.Instance.Customer.Abbreviation,
|
||||
CompanyName = s.Instance.Customer.CompanyName,
|
||||
Plan = s.Instance.Customer.Plan,
|
||||
s.SnapshotDate,
|
||||
s.ScreenCount,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
// Group by customer abbreviation + date, sum screen counts across instances
|
||||
var grouped = rows
|
||||
.GroupBy(r => new { r.Abbrev, r.SnapshotDate })
|
||||
.Select(g => new BillingCsvRow
|
||||
{
|
||||
CustomerAbbrev = g.Key.Abbrev,
|
||||
CompanyName = g.First().CompanyName,
|
||||
Date = g.Key.SnapshotDate,
|
||||
ScreenCount = g.Sum(r => r.ScreenCount),
|
||||
Plan = g.First().Plan.ToString(),
|
||||
})
|
||||
.OrderBy(r => r.CustomerAbbrev)
|
||||
.ThenBy(r => r.Date)
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("Generated billing CSV: {RowCount} rows for {From} to {To}",
|
||||
grouped.Count, from, to);
|
||||
|
||||
return WriteCsv(grouped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a version drift CSV showing current vs latest Xibo version and OAuth credential age.
|
||||
/// Columns: abbrev, current_version, latest_version, credential_age_days
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateVersionDriftCsvAsync()
|
||||
{
|
||||
await using var scope = _services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||||
|
||||
// Latest health event per instance where check_name = "xibo-version"
|
||||
var versionEvents = await db.HealthEvents
|
||||
.AsNoTracking()
|
||||
.Include(h => h.Instance)
|
||||
.ThenInclude(i => i.Customer)
|
||||
.Where(h => h.CheckName == "xibo-version")
|
||||
.GroupBy(h => h.InstanceId)
|
||||
.Select(g => g.OrderByDescending(h => h.OccurredAt).First())
|
||||
.ToListAsync();
|
||||
|
||||
// Latest OauthAppRegistry per instance
|
||||
var latestOauth = await db.OauthAppRegistries
|
||||
.AsNoTracking()
|
||||
.GroupBy(o => o.InstanceId)
|
||||
.Select(g => g.OrderByDescending(o => o.CreatedAt).First())
|
||||
.ToDictionaryAsync(o => o.InstanceId);
|
||||
|
||||
var rows = versionEvents.Select(h =>
|
||||
{
|
||||
var parts = ParseVersionMessage(h.Message);
|
||||
latestOauth.TryGetValue(h.InstanceId, out var oauth);
|
||||
var credAgeDays = oauth is not null
|
||||
? (int)(DateTime.UtcNow - oauth.CreatedAt).TotalDays
|
||||
: -1;
|
||||
|
||||
return new VersionDriftCsvRow
|
||||
{
|
||||
Abbrev = h.Instance.Customer.Abbreviation,
|
||||
CurrentVersion = parts.Current,
|
||||
LatestVersion = parts.Latest,
|
||||
CredentialAgeDays = credAgeDays,
|
||||
};
|
||||
})
|
||||
.OrderBy(r => r.Abbrev)
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("Generated version drift CSV: {RowCount} rows", rows.Count);
|
||||
|
||||
return WriteCsv(rows);
|
||||
}
|
||||
|
||||
private static byte[] WriteCsv<T>(IEnumerable<T> records)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
using var writer = new StreamWriter(stream, Encoding.UTF8);
|
||||
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HasHeaderRecord = true,
|
||||
});
|
||||
csv.WriteRecords(records);
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the health event message to extract current/latest version strings.
|
||||
/// Expected format from XiboVersionHealthCheck: "Current: X.Y.Z, Latest: A.B.C" or similar.
|
||||
/// Returns ("unknown", "unknown") if parsing fails.
|
||||
/// </summary>
|
||||
private static (string Current, string Latest) ParseVersionMessage(string? message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
return ("unknown", "unknown");
|
||||
|
||||
var current = "unknown";
|
||||
var latest = "unknown";
|
||||
|
||||
// Try to extract "Current: X.Y.Z" pattern
|
||||
var currentIdx = message.IndexOf("Current:", StringComparison.OrdinalIgnoreCase);
|
||||
if (currentIdx >= 0)
|
||||
{
|
||||
var afterCurrent = message[(currentIdx + 8)..].Trim();
|
||||
var end = afterCurrent.IndexOfAny([',', ' ', '\n']);
|
||||
current = end > 0 ? afterCurrent[..end].Trim() : afterCurrent.Trim();
|
||||
}
|
||||
|
||||
var latestIdx = message.IndexOf("Latest:", StringComparison.OrdinalIgnoreCase);
|
||||
if (latestIdx >= 0)
|
||||
{
|
||||
var afterLatest = message[(latestIdx + 7)..].Trim();
|
||||
var end = afterLatest.IndexOfAny([',', ' ', '\n']);
|
||||
latest = end > 0 ? afterLatest[..end].Trim() : afterLatest.Trim();
|
||||
}
|
||||
|
||||
return (current, latest);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class BillingCsvRow
|
||||
{
|
||||
public string CustomerAbbrev { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public DateOnly Date { get; set; }
|
||||
public int ScreenCount { get; set; }
|
||||
public string Plan { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class VersionDriftCsvRow
|
||||
{
|
||||
public string Abbrev { get; set; } = string.Empty;
|
||||
public string CurrentVersion { get; set; } = string.Empty;
|
||||
public string LatestVersion { get; set; } = string.Empty;
|
||||
public int CredentialAgeDays { get; set; }
|
||||
}
|
||||
401
OTSSignsOrchestrator.Server/Reports/FleetHealthPdfService.cs
Normal file
401
OTSSignsOrchestrator.Server/Reports/FleetHealthPdfService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user