- 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.
246 lines
9.8 KiB
C#
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);
|