Tau.Acuvim/portal/src/Tau.Acuvim.Portal/Services/FleetQueryService.cs
Diseri Pearson c5787a7a7f Phase 15: Admin operator surface + fleet dashboards + onboarding docs
The Admin stack now has a usable operator UI for managing the fleet.
End-to-end verified locally: Client pushes → Admin dashboard reflects
the activity within the CA refresh window.

Backend (Admin-only)
- FleetQueryService: dashboard headline (totals, active count, today's
  measurements + kWh from the hourly_per_device CA) and per-customer
  detail (sites, devices, last 50 measurements, last 20 ingest events).
- /api/fleet/dashboard and /api/fleet/customers/{id}/detail endpoints.
- DTOs added; Program.cs wires the service + endpoints under RunMode=Admin.

Frontend
- DashboardPage now branches on RunMode — Admin renders the fleet
  headline (statistic cards + customer summary table with lag tags),
  Client keeps the existing placeholder.
- AdminCustomerDetailPage drills into one customer: descriptions card +
  tabs for Recent ingest (with rejection counts, batch sizes, time-spread
  for visible firmware-replay waves), Recent measurements, Sites, Devices.
- AdminCustomersPage rows are clickable → /admin/customers/:id (skips
  the click when target is a button/popover so action buttons still work).
- App.tsx adds the /admin/customers/:id route, RequireRole-gated.

Grafana
- grafana/dashboards-admin/fleet-overview.json — 4 stat panels (active
  customers, total, last-24h samples, today's kWh) plus 2 time series
  (per-customer active power, per-customer hourly kWh). Reads from
  fleet.hourly_per_device CA.
- grafana/dashboards-admin/customer-drilldown.json — parameterized by
  $customer (template variable querying fleet.Customers). Per-device
  active power, cumulative kWh, recent ingest events table.

Docs
- README: Phase 15 section describing the new admin UI surface +
  pointer to dashboard-admin folder.
- OPERATIONS: new "Fleet aggregator (Admin stack)" section covering
  one-time provisioning (Admin portal + Admin Grafana), end-to-end
  customer-onboarding workflow (register on Admin → drop token in
  customer .env → restart → verify in UI/SQL), common ops (rotate
  token, disable, investigate, compression stats, force CA refresh,
  decommission), and Admin-DB backup notes.
- README decommissioning note now mentions deleting from fleet.Customers
  if the customer was registered for aggregation.

Verified end-to-end
- Phase 14's Client + Admin stacks rebuilt with Phase 15 code.
- /api/fleet/dashboard returns correct totals (1 customer, 1 active,
  measurements + kWh derived from CA).
- /api/fleet/customers/{id}/detail returns sites, devices, recent
  measurements, recent ingest events.
- Ingested a fresh measurement on Client → after CA refresh, totals
  in Admin dashboard advance correctly.
- All 53 tests still passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:27:55 +02:00

133 lines
5.6 KiB
C#

using Microsoft.EntityFrameworkCore;
using Npgsql;
using Tau.Acuvim.Portal.Data;
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services;
public sealed class FleetQueryService(AdminDbContext db)
{
private static readonly TimeSpan ActiveWindow = TimeSpan.FromHours(1);
public async Task<FleetDashboardDto> GetDashboardAsync(CancellationToken ct = default)
{
var nowUtc = DateTime.UtcNow;
var activeThreshold = nowUtc - ActiveWindow;
var dayStart = DateTime.SpecifyKind(nowUtc.Date, DateTimeKind.Utc);
var customers = await db.Customers.AsNoTracking()
.OrderBy(c => c.Code)
.Select(c => new { c.Id, c.Code, c.Name, c.IsActive, c.LastSeenAt })
.ToListAsync(ct);
var siteCounts = await db.FleetSites.AsNoTracking()
.GroupBy(s => s.CustomerId)
.Select(g => new { CustomerId = g.Key, N = g.Count() })
.ToDictionaryAsync(x => x.CustomerId, x => x.N, ct);
var deviceCounts = await db.FleetDevices.AsNoTracking()
.GroupBy(d => d.CustomerId)
.Select(g => new { CustomerId = g.Key, N = g.Count() })
.ToDictionaryAsync(x => x.CustomerId, x => x.N, ct);
var todayStats = await GetTodayStatsAsync(dayStart, ct);
var summaries = customers.Select(c => new FleetCustomerSummary(
c.Id, c.Code, c.Name, c.IsActive, c.LastSeenAt,
siteCounts.GetValueOrDefault(c.Id, 0),
deviceCounts.GetValueOrDefault(c.Id, 0),
todayStats.TryGetValue(c.Id, out var s) ? s.Measurements : 0,
todayStats.TryGetValue(c.Id, out s) ? s.KwhImported : null
)).ToList();
var activeCount = customers.Count(c => c.LastSeenAt >= activeThreshold);
var oldestActive = customers
.Where(c => c.LastSeenAt is not null && c.LastSeenAt >= activeThreshold)
.Min(c => c.LastSeenAt);
return new FleetDashboardDto(
TotalCustomers: customers.Count,
ActiveCustomers: activeCount,
TotalMeasurementsToday: summaries.Sum(x => x.MeasurementsToday),
TotalKwhImportedToday: summaries.Sum(x => x.KwhImportedToday ?? 0),
OldestActiveLastSeenAt: oldestActive,
Customers: summaries);
}
public async Task<FleetCustomerDetailDto?> GetCustomerDetailAsync(Guid id, CancellationToken ct = default)
{
var c = await db.Customers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
if (c is null) return null;
var sites = await db.FleetSites.AsNoTracking()
.Where(s => s.CustomerId == id)
.OrderBy(s => s.Name)
.Select(s => new FleetSiteDto(s.Id, s.Name, s.Address, s.LocalMunicipalityId, s.IsActive))
.ToListAsync(ct);
var devices = await db.FleetDevices.AsNoTracking()
.Where(d => d.CustomerId == id)
.OrderBy(d => d.Name)
.Select(d => new FleetDeviceDto(d.Id, d.SiteId, d.Name, d.ExternalId, d.Description, d.IsActive))
.ToListAsync(ct);
var deviceNameMap = devices.ToDictionary(d => d.Id, d => d.Name);
var recent = await db.FleetPowerMeasurements.AsNoTracking()
.Where(m => m.CustomerId == id)
.OrderByDescending(m => m.Time)
.Take(50)
.Select(m => new { m.Time, m.DeviceId, m.ActivePowerKw, m.EnergyImportedKwh })
.ToListAsync(ct);
var recentDtos = recent.Select(r => new FleetRecentMeasurementDto(
r.Time, r.DeviceId, deviceNameMap.GetValueOrDefault(r.DeviceId, "(unknown)"),
r.ActivePowerKw, r.EnergyImportedKwh)).ToList();
var events = await db.IngestEvents.AsNoTracking()
.Where(e => e.CustomerId == id)
.OrderByDescending(e => e.ReceivedAt)
.Take(20)
.Select(e => new FleetIngestEventDto(
e.ReceivedAt, e.BatchType, e.RowsAccepted, e.RowsRejected,
e.BatchBytes, e.ClientHwm, e.TimeSpread, e.Error))
.ToListAsync(ct);
return new FleetCustomerDetailDto(
c.Id, c.Code, c.Name, c.IsActive,
c.FirstSeenAt, c.LastSeenAt, c.CreatedAt,
sites, devices, recentDtos, events);
}
// Per-customer today: measurement count + kWh imported delta via raw SQL hitting
// the realtime continuous aggregate (falls back to live raw scan for the unmaterialized tail).
private async Task<Dictionary<Guid, (long Measurements, double? KwhImported)>> GetTodayStatsAsync(
DateTime dayStartUtc, CancellationToken ct)
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT "CustomerId",
SUM(samples)::bigint AS rows,
SUM(COALESCE(kwh_imported_delta, 0))::double precision AS kwh
FROM fleet.hourly_per_device
WHERE bucket >= @dayStart
GROUP BY "CustomerId";
""";
cmd.Parameters.Add(new NpgsqlParameter("@dayStart", DateTime.SpecifyKind(dayStartUtc, DateTimeKind.Utc)));
var map = new Dictionary<Guid, (long, double?)>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var custId = reader.GetGuid(0);
var rows = reader.GetInt64(1);
var kwh = reader.IsDBNull(2) ? (double?)null : reader.GetDouble(2);
map[custId] = (rows, kwh);
}
return map;
}
}