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; }
|
||||
}
|
||||
Reference in New Issue
Block a user