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
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/

View File

@ -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[];
}

View File

@ -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 ? <Text type="secondary"></Text> : <Text strong>{v.toFixed(2)}</Text>
},
];
return (
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Col span={5}>
<Card>
<Statistic title="Customers" value={data?.totalCustomers ?? 0} prefix={<TeamOutlined />} loading={isLoading} />
</Card>
</Col>
<Col span={6}>
<Col span={5}>
<Card>
<Statistic
title="Active (last hr)"
@ -57,7 +63,7 @@ export function AdminFleetDashboardPage() {
/>
</Card>
</Col>
<Col span={6}>
<Col span={5}>
<Card>
<Statistic
title="Measurements today"
@ -67,10 +73,10 @@ export function AdminFleetDashboardPage() {
/>
</Card>
</Col>
<Col span={6}>
<Col span={4}>
<Card>
<Statistic
title="kWh imported today"
title="kWh today"
value={data?.totalKwhImportedToday ?? 0}
precision={2}
prefix={<ThunderboltOutlined />}
@ -78,6 +84,18 @@ export function AdminFleetDashboardPage() {
/>
</Card>
</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>
<Card title="Customers">

View File

@ -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<FleetCustomerSummary> Customers);

View File

@ -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<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(
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);
}

View File

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