using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using OTSSignsOrchestrator.Data; using OTSSignsOrchestrator.Data.Entities; using OTSSignsOrchestrator.Hubs; using OTSSignsOrchestrator.Reports; namespace OTSSignsOrchestrator.Api; public static class FleetApi { public static void MapFleetEndpoints(this WebApplication app) { var fleet = app.MapGroup("/api/fleet").RequireAuthorization(); fleet.MapGet("/", GetFleetSummary); fleet.MapGet("/{id:guid}", GetFleetDetail); var jobs = app.MapGroup("/api/jobs").RequireAuthorization(); jobs.MapPost("/", CreateJob); jobs.MapGet("/{id:guid}", GetJob); app.MapGet("/api/health", () => Results.Ok(new { status = "healthy" })); // ── Report endpoints (admin only) ──────────────────────────────────── var reports = app.MapGroup("/api/reports").RequireAuthorization() .RequireAuthorization(policy => policy.RequireRole("admin")); reports.MapGet("/billing", GetBillingCsv); reports.MapGet("/version-drift", GetVersionDriftCsv); reports.MapGet("/fleet-health", GetFleetHealthPdf); reports.MapGet("/customer/{id:guid}/usage", GetCustomerUsagePdf); fleet.MapPost("/bulk/export-fleet-report", ExportFleetReport) .RequireAuthorization(policy => policy.RequireRole("admin")); } // ── GET /api/fleet ────────────────────────────────────────────────────── private static async Task GetFleetSummary(OrchestratorDbContext db) { var customers = await db.Customers .AsNoTracking() .Include(c => c.Instances) .Include(c => c.Jobs) .ToListAsync(); // Get latest health event per instance in one query var latestHealth = await db.HealthEvents .AsNoTracking() .GroupBy(h => h.InstanceId) .Select(g => g.OrderByDescending(h => h.OccurredAt).First()) .ToDictionaryAsync(h => h.InstanceId); var result = customers.Select(c => { var primaryInstance = c.Instances.FirstOrDefault(); HealthEvent? health = null; if (primaryInstance is not null) latestHealth.TryGetValue(primaryInstance.Id, out health); return new FleetSummaryDto { CustomerId = c.Id, Abbreviation = c.Abbreviation, CompanyName = c.CompanyName, Plan = c.Plan.ToString(), ScreenCount = c.ScreenCount, HealthStatus = health?.Status.ToString() ?? primaryInstance?.HealthStatus.ToString() ?? "Unknown", LastHealthCheck = health?.OccurredAt ?? primaryInstance?.LastHealthCheck, HasRunningJob = c.Jobs.Any(j => j.Status == JobStatus.Running || j.Status == JobStatus.Queued), }; }).ToArray(); return Results.Ok(result); } // ── GET /api/fleet/{id} ───────────────────────────────────────────────── private static async Task GetFleetDetail(Guid id, OrchestratorDbContext db) { var customer = await db.Customers .AsNoTracking() .Include(c => c.Instances) .Include(c => c.Jobs.Where(j => j.Status == JobStatus.Running || j.Status == JobStatus.Queued)) .FirstOrDefaultAsync(c => c.Id == id); if (customer is null) return Results.NotFound(); return Results.Ok(new { customer.Id, customer.Abbreviation, customer.CompanyName, customer.AdminEmail, Plan = customer.Plan.ToString(), customer.ScreenCount, Status = customer.Status.ToString(), customer.CreatedAt, Instances = customer.Instances.Select(i => new { i.Id, i.XiboUrl, i.DockerStackName, HealthStatus = i.HealthStatus.ToString(), i.LastHealthCheck, }), ActiveJobs = customer.Jobs.Select(j => new { j.Id, j.JobType, Status = j.Status.ToString(), j.CreatedAt, j.StartedAt, }), }); } // ── POST /api/jobs ────────────────────────────────────────────────────── private static async Task CreateJob( CreateJobRequest req, OrchestratorDbContext db, IHubContext hub, ILogger logger) { var customer = await db.Customers.FindAsync(req.CustomerId); if (customer is null) return Results.NotFound("Customer not found."); var job = new Job { Id = Guid.NewGuid(), CustomerId = req.CustomerId, JobType = req.JobType, Status = JobStatus.Queued, TriggeredBy = "operator", Parameters = req.Parameters, CreatedAt = DateTime.UtcNow, }; db.Jobs.Add(job); await db.SaveChangesAsync(); logger.LogInformation("Job created: {JobId} type={JobType} customer={CustomerId}", job.Id, job.JobType, job.CustomerId); await hub.Clients.All.SendJobCreated(job.Id.ToString(), customer.Abbreviation, job.JobType); return Results.Created($"/api/jobs/{job.Id}", new { job.Id, job.JobType, Status = job.Status.ToString() }); } // ── GET /api/jobs/{id} ────────────────────────────────────────────────── private static async Task GetJob(Guid id, OrchestratorDbContext db) { var job = await db.Jobs .AsNoTracking() .Include(j => j.Steps.OrderBy(s => s.StartedAt)) .FirstOrDefaultAsync(j => j.Id == id); if (job is null) return Results.NotFound(); return Results.Ok(new { job.Id, job.CustomerId, job.JobType, Status = job.Status.ToString(), job.TriggeredBy, job.Parameters, job.CreatedAt, job.StartedAt, job.CompletedAt, job.ErrorMessage, Steps = job.Steps.Select(s => new { s.Id, s.StepName, Status = s.Status.ToString(), s.LogOutput, s.StartedAt, s.CompletedAt, }), }); } // ── GET /api/reports/billing?from=&to= ────────────────────────────────── private static async Task GetBillingCsv( DateOnly from, DateOnly to, BillingReportService billing) { var csv = await billing.GenerateBillingCsvAsync(from, to); return Results.File(csv, "text/csv", $"billing-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.csv"); } // ── GET /api/reports/version-drift ────────────────────────────────────── private static async Task GetVersionDriftCsv(BillingReportService billing) { var csv = await billing.GenerateVersionDriftCsvAsync(); return Results.File(csv, "text/csv", $"version-drift-{DateTime.UtcNow:yyyy-MM-dd}.csv"); } // ── GET /api/reports/fleet-health?from=&to= ───────────────────────────── private static async Task GetFleetHealthPdf( DateOnly from, DateOnly to, FleetHealthPdfService pdfService) { var pdf = await pdfService.GenerateFleetHealthPdfAsync(from, to); return Results.File(pdf, "application/pdf", $"fleet-health-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf"); } // ── GET /api/reports/customer/{id}/usage?from=&to= ────────────────────── private static async Task GetCustomerUsagePdf( Guid id, DateOnly from, DateOnly to, FleetHealthPdfService pdfService) { try { var pdf = await pdfService.GenerateCustomerUsagePdfAsync(id, from, to); return Results.File(pdf, "application/pdf", $"customer-usage-{id}-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf"); } catch (InvalidOperationException ex) { return Results.NotFound(ex.Message); } } // ── POST /api/fleet/bulk/export-fleet-report ──────────────────────────── private static async Task ExportFleetReport(FleetHealthPdfService pdfService) { var to = DateOnly.FromDateTime(DateTime.UtcNow); var from = to.AddDays(-7); var pdf = await pdfService.GenerateFleetHealthPdfAsync(from, to); return Results.File(pdf, "application/pdf", $"fleet-health-{from:yyyy-MM-dd}-{to:yyyy-MM-dd}.pdf"); } } public record FleetSummaryDto { public Guid CustomerId { get; init; } public string Abbreviation { get; init; } = string.Empty; public string CompanyName { get; init; } = string.Empty; public string Plan { get; init; } = string.Empty; public int ScreenCount { get; init; } public string HealthStatus { get; init; } = "Unknown"; public DateTime? LastHealthCheck { get; init; } public bool HasRunningJob { get; init; } } public record CreateJobRequest(Guid CustomerId, string JobType, string? Parameters);