Files
OTSSignsOrchestrator/OTSSignsOrchestrator/Api/FleetApi.cs
Matt Batchelder 9a35e40083 feat: Add initial deployment setup for OTSSignsOrchestrator
- Create index.html for the web application interface.
- Implement deploy.sh script for building and deploying the application to a Docker Swarm manager.
- Add docker-compose.yml for defining application and PostgreSQL service configurations.
2026-03-23 21:28:14 -04:00

246 lines
9.8 KiB
C#

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<IResult> 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<IResult> 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<IResult> CreateJob(
CreateJobRequest req,
OrchestratorDbContext db,
IHubContext<FleetHub, IFleetClient> hub,
ILogger<CreateJobRequest> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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<IResult> 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);