From 66660364ec3f41006496504b4a767e01fe8d8c05 Mon Sep 17 00:00:00 2001 From: Diseri Pearson Date: Mon, 18 May 2026 11:53:02 +0200 Subject: [PATCH] Cost-feature followups: libgssapi fix, daily CA, dashboard cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small polish items on top of the cost-compute feature. (A) Dockerfile: install libgssapi-krb5-2 in the runtime stage. Silences the "Cannot load library libgssapi_krb5.so.2" warning that Npgsql logs when probing for Kerberos auth on Linux. We don't actually use Kerberos; the lib is ~3 MB and removes confusing log noise. (B) FleetTimescaleBootstrapper: add hierarchical fleet.daily_per_customer continuous aggregate on top of fleet.hourly_per_device. Per-customer daily totals (samples, kwh imported/exported, avg/max/min kW). Realtime, materialized_only=false, 365-day start_offset, hourly refresh. Available for long-range dashboards / billing summaries that don't need device-level or hourly detail. Not yet consumed by any query — exists as a primitive for the next dashboard / report that wants it. (C) Fleet dashboard: include per-customer cost today. - FleetQueryService.GetDashboardAsync invokes FleetCostService.ComputeAsync per customer for today's UTC window. Failures for one customer don't break the dashboard (leave their CostToday null and continue). - FleetCustomerSummary gains nullable CostToday; FleetDashboardDto gains TotalCostToday (sum across customers). - AdminFleetDashboardPage adds a 5th Statistic card "Cost today" in green, and a "Today (cost)" column in the customer summary table. - Perf note added in service comment: per-load cost compute scales with N customers; revisit with a materialised daily_cost view when N grows beyond ~100. Verified on the running stack - Admin container logs no longer contain "libgssapi" anywhere. - timescaledb_information.continuous_aggregates shows both hourly_per_device and daily_per_customer. - /api/fleet/dashboard returns totalCostToday=161.00, the single customer (DEV0001) row shows costToday=161.00 matching the cost endpoint's total for the same UTC day. Deliberately not in this commit: per-customer Postgres RLS for multi-Admin-user setups. That needs design decisions (claim source, fleet-admin bypass model, connection-pooling interaction with session variables) — I'd rather pose those questions and ship it right than sneak in a half-baked version. Wrap-up message has the design qs. Co-Authored-By: Claude Opus 4.7 (1M context) --- portal/Dockerfile | 8 +++++ portal/frontend/src/api/fleet.ts | 2 ++ .../src/pages/AdminFleetDashboardPage.tsx | 30 ++++++++++++---- .../DTOs/FleetDashboardDtos.cs | 4 ++- .../Services/FleetQueryService.cs | 25 +++++++++++-- .../Services/FleetTimescaleBootstrapper.cs | 35 ++++++++++++++++++- 6 files changed, 94 insertions(+), 10 deletions(-) diff --git a/portal/Dockerfile b/portal/Dockerfile index 90d442c..ecb4235 100644 --- a/portal/Dockerfile +++ b/portal/Dockerfile @@ -15,6 +15,14 @@ RUN dotnet publish Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj \ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app + +# Npgsql probes for Kerberos auth on Linux and logs a noisy "Cannot load library +# libgssapi_krb5.so.2" error if missing. Installing the lib silences the warning; +# we don't actually use Kerberos auth. +RUN apt-get update \ + && apt-get install -y --no-install-recommends libgssapi-krb5-2 \ + && rm -rf /var/lib/apt/lists/* + COPY --from=build /app/publish . COPY --from=frontend /app/frontend/dist ./wwwroot/ diff --git a/portal/frontend/src/api/fleet.ts b/portal/frontend/src/api/fleet.ts index 2e28ca7..3f399c9 100644 --- a/portal/frontend/src/api/fleet.ts +++ b/portal/frontend/src/api/fleet.ts @@ -10,6 +10,7 @@ export interface FleetCustomerSummary { devices: number; measurementsToday: number; kwhImportedToday: number | null; + costToday: number | null; } export interface FleetDashboard { @@ -17,6 +18,7 @@ export interface FleetDashboard { activeCustomers: number; totalMeasurementsToday: number; totalKwhImportedToday: number; + totalCostToday: number; oldestActiveLastSeenAt: string | null; customers: FleetCustomerSummary[]; } diff --git a/portal/frontend/src/pages/AdminFleetDashboardPage.tsx b/portal/frontend/src/pages/AdminFleetDashboardPage.tsx index 25930a6..94c544b 100644 --- a/portal/frontend/src/pages/AdminFleetDashboardPage.tsx +++ b/portal/frontend/src/pages/AdminFleetDashboardPage.tsx @@ -1,6 +1,8 @@ import { Card, Col, Row, Statistic, Table, Tag, Typography, Empty } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import { ApartmentOutlined, ThunderboltOutlined, TeamOutlined, CheckCircleOutlined } from '@ant-design/icons'; +import { + ApartmentOutlined, ThunderboltOutlined, TeamOutlined, CheckCircleOutlined, DollarOutlined, +} from '@ant-design/icons'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { fetchFleetDashboard, type FleetCustomerSummary } from '../api/fleet'; @@ -36,17 +38,21 @@ export function AdminFleetDashboardPage() { title: 'Today (kWh imp.)', dataIndex: 'kwhImportedToday', key: 'kwh', render: (v: number | null) => v == null ? '—' : v.toFixed(2) }, + { + title: 'Today (cost)', dataIndex: 'costToday', key: 'cost', + render: (v: number | null) => v == null ? : {v.toFixed(2)} + }, ]; return (
- + } loading={isLoading} /> - + - + - + } @@ -78,6 +84,18 @@ export function AdminFleetDashboardPage() { /> + + + } + valueStyle={{ color: '#3f8600' }} + loading={isLoading} + /> + + diff --git a/portal/src/Tau.Acuvim.Portal/DTOs/FleetDashboardDtos.cs b/portal/src/Tau.Acuvim.Portal/DTOs/FleetDashboardDtos.cs index eb333c8..3faee5d 100644 --- a/portal/src/Tau.Acuvim.Portal/DTOs/FleetDashboardDtos.cs +++ b/portal/src/Tau.Acuvim.Portal/DTOs/FleetDashboardDtos.cs @@ -9,13 +9,15 @@ public sealed record FleetCustomerSummary( int Sites, int Devices, long MeasurementsToday, - double? KwhImportedToday); + double? KwhImportedToday, + decimal? CostToday); public sealed record FleetDashboardDto( int TotalCustomers, int ActiveCustomers, long TotalMeasurementsToday, double TotalKwhImportedToday, + decimal TotalCostToday, DateTime? OldestActiveLastSeenAt, IReadOnlyList Customers); diff --git a/portal/src/Tau.Acuvim.Portal/Services/FleetQueryService.cs b/portal/src/Tau.Acuvim.Portal/Services/FleetQueryService.cs index 64d8b2b..a3ef810 100644 --- a/portal/src/Tau.Acuvim.Portal/Services/FleetQueryService.cs +++ b/portal/src/Tau.Acuvim.Portal/Services/FleetQueryService.cs @@ -5,7 +5,7 @@ using Tau.Acuvim.Portal.DTOs; namespace Tau.Acuvim.Portal.Services; -public sealed class FleetQueryService(AdminDbContext db) +public sealed class FleetQueryService(AdminDbContext db, FleetCostService costs) { private static readonly TimeSpan ActiveWindow = TimeSpan.FromHours(1); @@ -14,6 +14,7 @@ public sealed class FleetQueryService(AdminDbContext db) var nowUtc = DateTime.UtcNow; var activeThreshold = nowUtc - ActiveWindow; var dayStart = DateTime.SpecifyKind(nowUtc.Date, DateTimeKind.Utc); + var dayEnd = dayStart.AddDays(1); var customers = await db.Customers.AsNoTracking() .OrderBy(c => c.Code) @@ -32,12 +33,31 @@ public sealed class FleetQueryService(AdminDbContext db) var todayStats = await GetTodayStatsAsync(dayStart, ct); + // Per-customer cost for today. Computed once per dashboard load. For larger + // fleets this becomes the bottleneck — consider a materialised daily-cost view + // or caching layer when N customers grows beyond ~100. + var costToday = new Dictionary(); + foreach (var c in customers) + { + try + { + var r = await costs.ComputeAsync(c.Id, dayStart, dayEnd, ct); + if (r.TotalCost > 0) costToday[c.Id] = r.TotalCost; + } + catch (Exception) + { + // Cost compute failure for one customer shouldn't break the whole + // dashboard — leave their CostToday null and continue. + } + } + 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 + todayStats.TryGetValue(c.Id, out s) ? s.KwhImported : null, + costToday.TryGetValue(c.Id, out var cost) ? cost : null )).ToList(); var activeCount = customers.Count(c => c.LastSeenAt >= activeThreshold); @@ -50,6 +70,7 @@ public sealed class FleetQueryService(AdminDbContext db) ActiveCustomers: activeCount, TotalMeasurementsToday: summaries.Sum(x => x.MeasurementsToday), TotalKwhImportedToday: summaries.Sum(x => x.KwhImportedToday ?? 0), + TotalCostToday: summaries.Sum(x => x.CostToday ?? 0m), OldestActiveLastSeenAt: oldestActive, Customers: summaries); } diff --git a/portal/src/Tau.Acuvim.Portal/Services/FleetTimescaleBootstrapper.cs b/portal/src/Tau.Acuvim.Portal/Services/FleetTimescaleBootstrapper.cs index 3b1ce12..5cf21e8 100644 --- a/portal/src/Tau.Acuvim.Portal/Services/FleetTimescaleBootstrapper.cs +++ b/portal/src/Tau.Acuvim.Portal/Services/FleetTimescaleBootstrapper.cs @@ -80,6 +80,39 @@ public sealed class FleetTimescaleBootstrapper( ); """, ct); - log.LogInformation("Fleet hypertable + compression + hourly_per_device CA ready."); + // Hierarchical CA: roll up the hourly per-device aggregate into a per-customer + // daily total. Cheap for long-range dashboards / billing summaries that don't + // need device-level or hourly detail. Hierarchical CAs (CA on CA) are supported + // in TimescaleDB 2.x. Realtime so back-fills appear without manual refresh. + await db.Database.ExecuteSqlRawAsync( + """ + CREATE MATERIALIZED VIEW IF NOT EXISTS fleet.daily_per_customer + WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS + SELECT + "CustomerId", + time_bucket(INTERVAL '1 day', bucket) AS day, + SUM(samples) AS samples, + SUM(COALESCE(kwh_imported_delta, 0)) AS kwh_imported_delta, + SUM(COALESCE(kwh_exported_delta, 0)) AS kwh_exported_delta, + AVG(avg_kw) AS avg_kw, + MAX(max_kw) AS max_kw, + MIN(min_kw) AS min_kw + FROM fleet.hourly_per_device + GROUP BY "CustomerId", day + WITH NO DATA; + """, ct); + + await db.Database.ExecuteSqlRawAsync( + """ + SELECT add_continuous_aggregate_policy( + 'fleet.daily_per_customer', + start_offset => INTERVAL '365 days', + end_offset => INTERVAL '1 day', + schedule_interval => INTERVAL '1 hour', + if_not_exists => TRUE + ); + """, ct); + + log.LogInformation("Fleet hypertable + compression + hourly_per_device + daily_per_customer CAs ready."); } }