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