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."); } }