Cost-feature followups: libgssapi fix, daily CA, dashboard cost

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) <noreply@anthropic.com>
This commit is contained in:
Diseri Pearson 2026-05-18 11:53:02 +02:00
parent 7627306800
commit 66660364ec
6 changed files with 94 additions and 10 deletions

View File

@ -15,6 +15,14 @@ RUN dotnet publish Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj \
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app 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=build /app/publish .
COPY --from=frontend /app/frontend/dist ./wwwroot/ COPY --from=frontend /app/frontend/dist ./wwwroot/

View File

@ -10,6 +10,7 @@ export interface FleetCustomerSummary {
devices: number; devices: number;
measurementsToday: number; measurementsToday: number;
kwhImportedToday: number | null; kwhImportedToday: number | null;
costToday: number | null;
} }
export interface FleetDashboard { export interface FleetDashboard {
@ -17,6 +18,7 @@ export interface FleetDashboard {
activeCustomers: number; activeCustomers: number;
totalMeasurementsToday: number; totalMeasurementsToday: number;
totalKwhImportedToday: number; totalKwhImportedToday: number;
totalCostToday: number;
oldestActiveLastSeenAt: string | null; oldestActiveLastSeenAt: string | null;
customers: FleetCustomerSummary[]; customers: FleetCustomerSummary[];
} }

View File

@ -1,6 +1,8 @@
import { Card, Col, Row, Statistic, Table, Tag, Typography, Empty } from 'antd'; import { Card, Col, Row, Statistic, Table, Tag, Typography, Empty } from 'antd';
import type { ColumnsType } from 'antd/es/table'; 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 { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { fetchFleetDashboard, type FleetCustomerSummary } from '../api/fleet'; import { fetchFleetDashboard, type FleetCustomerSummary } from '../api/fleet';
@ -36,17 +38,21 @@ export function AdminFleetDashboardPage() {
title: 'Today (kWh imp.)', dataIndex: 'kwhImportedToday', key: 'kwh', title: 'Today (kWh imp.)', dataIndex: 'kwhImportedToday', key: 'kwh',
render: (v: number | null) => v == null ? '—' : v.toFixed(2) render: (v: number | null) => v == null ? '—' : v.toFixed(2)
}, },
{
title: 'Today (cost)', dataIndex: 'costToday', key: 'cost',
render: (v: number | null) => v == null ? <Text type="secondary"></Text> : <Text strong>{v.toFixed(2)}</Text>
},
]; ];
return ( return (
<div> <div>
<Row gutter={16} style={{ marginBottom: 16 }}> <Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}> <Col span={5}>
<Card> <Card>
<Statistic title="Customers" value={data?.totalCustomers ?? 0} prefix={<TeamOutlined />} loading={isLoading} /> <Statistic title="Customers" value={data?.totalCustomers ?? 0} prefix={<TeamOutlined />} loading={isLoading} />
</Card> </Card>
</Col> </Col>
<Col span={6}> <Col span={5}>
<Card> <Card>
<Statistic <Statistic
title="Active (last hr)" title="Active (last hr)"
@ -57,7 +63,7 @@ export function AdminFleetDashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
<Col span={6}> <Col span={5}>
<Card> <Card>
<Statistic <Statistic
title="Measurements today" title="Measurements today"
@ -67,10 +73,10 @@ export function AdminFleetDashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
<Col span={6}> <Col span={4}>
<Card> <Card>
<Statistic <Statistic
title="kWh imported today" title="kWh today"
value={data?.totalKwhImportedToday ?? 0} value={data?.totalKwhImportedToday ?? 0}
precision={2} precision={2}
prefix={<ThunderboltOutlined />} prefix={<ThunderboltOutlined />}
@ -78,6 +84,18 @@ export function AdminFleetDashboardPage() {
/> />
</Card> </Card>
</Col> </Col>
<Col span={5}>
<Card>
<Statistic
title="Cost today"
value={data?.totalCostToday ?? 0}
precision={2}
prefix={<DollarOutlined />}
valueStyle={{ color: '#3f8600' }}
loading={isLoading}
/>
</Card>
</Col>
</Row> </Row>
<Card title="Customers"> <Card title="Customers">

View File

@ -9,13 +9,15 @@ public sealed record FleetCustomerSummary(
int Sites, int Sites,
int Devices, int Devices,
long MeasurementsToday, long MeasurementsToday,
double? KwhImportedToday); double? KwhImportedToday,
decimal? CostToday);
public sealed record FleetDashboardDto( public sealed record FleetDashboardDto(
int TotalCustomers, int TotalCustomers,
int ActiveCustomers, int ActiveCustomers,
long TotalMeasurementsToday, long TotalMeasurementsToday,
double TotalKwhImportedToday, double TotalKwhImportedToday,
decimal TotalCostToday,
DateTime? OldestActiveLastSeenAt, DateTime? OldestActiveLastSeenAt,
IReadOnlyList<FleetCustomerSummary> Customers); IReadOnlyList<FleetCustomerSummary> Customers);

View File

@ -5,7 +5,7 @@ using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services; 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); private static readonly TimeSpan ActiveWindow = TimeSpan.FromHours(1);
@ -14,6 +14,7 @@ public sealed class FleetQueryService(AdminDbContext db)
var nowUtc = DateTime.UtcNow; var nowUtc = DateTime.UtcNow;
var activeThreshold = nowUtc - ActiveWindow; var activeThreshold = nowUtc - ActiveWindow;
var dayStart = DateTime.SpecifyKind(nowUtc.Date, DateTimeKind.Utc); var dayStart = DateTime.SpecifyKind(nowUtc.Date, DateTimeKind.Utc);
var dayEnd = dayStart.AddDays(1);
var customers = await db.Customers.AsNoTracking() var customers = await db.Customers.AsNoTracking()
.OrderBy(c => c.Code) .OrderBy(c => c.Code)
@ -32,12 +33,31 @@ public sealed class FleetQueryService(AdminDbContext db)
var todayStats = await GetTodayStatsAsync(dayStart, ct); 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<Guid, decimal>();
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( var summaries = customers.Select(c => new FleetCustomerSummary(
c.Id, c.Code, c.Name, c.IsActive, c.LastSeenAt, c.Id, c.Code, c.Name, c.IsActive, c.LastSeenAt,
siteCounts.GetValueOrDefault(c.Id, 0), siteCounts.GetValueOrDefault(c.Id, 0),
deviceCounts.GetValueOrDefault(c.Id, 0), deviceCounts.GetValueOrDefault(c.Id, 0),
todayStats.TryGetValue(c.Id, out var s) ? s.Measurements : 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(); )).ToList();
var activeCount = customers.Count(c => c.LastSeenAt >= activeThreshold); var activeCount = customers.Count(c => c.LastSeenAt >= activeThreshold);
@ -50,6 +70,7 @@ public sealed class FleetQueryService(AdminDbContext db)
ActiveCustomers: activeCount, ActiveCustomers: activeCount,
TotalMeasurementsToday: summaries.Sum(x => x.MeasurementsToday), TotalMeasurementsToday: summaries.Sum(x => x.MeasurementsToday),
TotalKwhImportedToday: summaries.Sum(x => x.KwhImportedToday ?? 0), TotalKwhImportedToday: summaries.Sum(x => x.KwhImportedToday ?? 0),
TotalCostToday: summaries.Sum(x => x.CostToday ?? 0m),
OldestActiveLastSeenAt: oldestActive, OldestActiveLastSeenAt: oldestActive,
Customers: summaries); Customers: summaries);
} }

View File

@ -80,6 +80,39 @@ public sealed class FleetTimescaleBootstrapper(
); );
""", ct); """, 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.");
} }
} }