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>
133 lines
5.6 KiB
C#
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;
|
|
}
|
|
}
|