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:
parent
7627306800
commit
66660364ec
@ -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/
|
||||
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user