Skip to content

Commit 5a090eb

Browse files
committed
fix(cloud-agent-next): show current health outcomes
1 parent 2855d25 commit 5a090eb

6 files changed

Lines changed: 138 additions & 60 deletions

File tree

apps/web/src/app/admin/api/cloud-agent-next/hooks.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export function useCloudAgentNextHealthOverview(
3838
return useQuery({
3939
...trpc.admin.cloudAgentNext.getHealthOverview.queryOptions(params),
4040
enabled: enabled && enabledForInterval(params),
41+
refetchOnReconnect: false,
42+
refetchOnWindowFocus: false,
4143
});
4244
}
4345

apps/web/src/app/admin/components/CloudAgentNextTelemetry/CloudAgentNextOutcomesPage.tsx

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from '@/components/ui/select';
4343
import { Skeleton } from '@/components/ui/skeleton';
4444
import { cn } from '@/lib/utils';
45+
import { rollingHealthInterval } from './health-interval';
4546
import { getOperationalFailureStats } from './health-summary';
4647
import {
4748
DEFAULT_HEALTH_PERIOD,
@@ -70,11 +71,11 @@ type RangeOption = {
7071
};
7172

7273
const RANGE_OPTIONS = [
73-
{ value: '1h', label: 'Last complete hour', durationMs: 60 * 60 * 1000, bucket: 'hour' },
74-
{ value: '3h', label: 'Last 3 complete hours', durationMs: 3 * 60 * 60 * 1000, bucket: 'hour' },
74+
{ value: '1h', label: 'Last hour', durationMs: 60 * 60 * 1000, bucket: 'hour' },
75+
{ value: '3h', label: 'Last 3 hours', durationMs: 3 * 60 * 60 * 1000, bucket: 'hour' },
7576
{
7677
value: '24h',
77-
label: 'Last 24 complete hours',
78+
label: 'Last 24 hours',
7879
durationMs: 24 * 60 * 60 * 1000,
7980
bucket: 'hour',
8081
},
@@ -131,13 +132,8 @@ function intervalForRange(
131132
): CloudAgentNextHealthFilters {
132133
const selectedRange = RANGE_OPTIONS.find(option => option.value === range) ?? RANGE_OPTIONS[3];
133134
const createdOnPlatform = createdOnPlatformForSelection(platformSelection);
134-
const end = new Date();
135-
if (selectedRange.bucket === 'day') end.setUTCHours(0, 0, 0, 0);
136-
else end.setUTCMinutes(0, 0, 0);
137135
return {
138-
startDate: new Date(end.getTime() - selectedRange.durationMs).toISOString(),
139-
endDate: end.toISOString(),
140-
bucket: selectedRange.bucket,
136+
...rollingHealthInterval(selectedRange),
141137
...(createdOnPlatform === undefined ? {} : { createdOnPlatform }),
142138
};
143139
}
@@ -206,22 +202,16 @@ function DashboardSkeleton() {
206202
);
207203
}
208204

209-
function HealthSummary({
210-
summary,
211-
bucket,
212-
}: {
213-
summary: HealthData['summary'];
214-
bucket: HealthBucket;
215-
}) {
205+
function HealthSummary({ summary }: { summary: HealthData['summary'] }) {
216206
const operationalFailures = getOperationalFailureStats(summary);
217207
const failureRate = operationalFailures.failureRatePercent;
218208
return (
219209
<Card>
220210
<CardHeader>
221211
<CardTitle>Observed health</CardTitle>
222212
<CardDescription>
223-
Events observed during complete UTC {bucket === 'day' ? 'days' : 'hours'} in the selected
224-
period. Interruptions are excluded from failure rate.
213+
Events observed in the selected rolling period. Interruptions are excluded from failure
214+
rate.
225215
</CardDescription>
226216
</CardHeader>
227217
<CardContent className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
@@ -304,8 +294,8 @@ function OutcomeTrendChart({
304294
<CardHeader>
305295
<CardTitle>{label} outcomes</CardTitle>
306296
<CardDescription>
307-
Completed, failed, setup-failed, and interrupted events in complete UTC-
308-
{bucket === 'day' ? 'day' : 'hour'} buckets.
297+
Completed, failed, setup-failed, and interrupted events in UTC-
298+
{bucket === 'day' ? 'day' : 'hour'} buckets. Edge buckets may be partial.
309299
</CardDescription>
310300
</CardHeader>
311301
<CardContent>
@@ -646,8 +636,8 @@ export default function CloudAgentNextOutcomesPage() {
646636
</div>
647637
</div>
648638
<p className="text-muted-foreground text-xs">
649-
Reporting is best-effort, so totals can undercount execution. Periods use complete UTC
650-
{bucket === 'day' ? ' days' : ' hours'}.
639+
Reporting is best-effort, so totals can undercount execution. Periods end at refresh time;
640+
edge UTC {bucket === 'day' ? 'days' : 'hours'} may be partial.
651641
</p>
652642
{health.error && (
653643
<Alert variant="destructive">
@@ -665,7 +655,7 @@ export default function CloudAgentNextOutcomesPage() {
665655
<DashboardSkeleton />
666656
) : health.data ? (
667657
<>
668-
<HealthSummary summary={health.data.summary} bucket={bucket} />
658+
<HealthSummary summary={health.data.summary} />
669659
<OutcomeTrendChart data={health.data.series} range={range} bucket={bucket} />
670660
<TopErrors errors={health.data.topErrors} interval={interval} />
671661
</>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { rollingHealthInterval } from './health-interval';
2+
3+
describe('rollingHealthInterval', () => {
4+
it('ends an hourly range at the exact refresh time', () => {
5+
expect(
6+
rollingHealthInterval(
7+
{ durationMs: 3 * 60 * 60 * 1000, bucket: 'hour' },
8+
new Date('2035-01-10T12:34:56.789Z')
9+
)
10+
).toEqual({
11+
startDate: '2035-01-10T09:34:56.789Z',
12+
endDate: '2035-01-10T12:34:56.789Z',
13+
bucket: 'hour',
14+
});
15+
});
16+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export type HealthBucket = 'hour' | 'day';
2+
3+
type HealthRange = {
4+
durationMs: number;
5+
bucket: HealthBucket;
6+
};
7+
8+
type HealthInterval = {
9+
startDate: string;
10+
endDate: string;
11+
bucket: HealthBucket;
12+
};
13+
14+
export function rollingHealthInterval(range: HealthRange, now = new Date()): HealthInterval {
15+
return {
16+
startDate: new Date(now.getTime() - range.durationMs).toISOString(),
17+
endDate: now.toISOString(),
18+
bucket: range.bucket,
19+
};
20+
}

apps/web/src/routers/admin-cloud-agent-next-router.test.ts

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -361,22 +361,90 @@ describe('adminCloudAgentNextRouter', () => {
361361
]);
362362
});
363363

364-
it('rejects health intervals that do not align to UTC bucket boundaries', async () => {
364+
it('summarizes rolling health intervals across partial daily UTC buckets', async () => {
365365
const caller = await createCallerForUser(adminUser.id);
366-
await expect(
367-
caller.admin.cloudAgentNext.getHealthOverview({
368-
startDate: at(0, 30),
369-
endDate: at(1, 30),
370-
bucket: 'hour',
371-
})
372-
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
373-
await expect(
374-
caller.admin.cloudAgentNext.getHealthOverview({
375-
startDate: START_DATE,
376-
endDate: at(23),
377-
bucket: 'day',
378-
})
379-
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
366+
const health = await caller.admin.cloudAgentNext.getHealthOverview({
367+
startDate: '2035-01-09T12:00:00.000Z',
368+
endDate: at(5, 30),
369+
bucket: 'day',
370+
});
371+
372+
expect(health.series).toEqual([
373+
{
374+
bucketStart: '2035-01-09T00:00:00.000Z',
375+
completedRuns: 0,
376+
failedRuns: 0,
377+
interruptedRuns: 0,
378+
setupFailures: 0,
379+
},
380+
{
381+
bucketStart: START_DATE,
382+
completedRuns: 1,
383+
failedRuns: 2,
384+
interruptedRuns: 1,
385+
setupFailures: 2,
386+
},
387+
]);
388+
});
389+
390+
it('summarizes rolling health intervals across partial hourly UTC buckets', async () => {
391+
const caller = await createCallerForUser(adminUser.id);
392+
const health = await caller.admin.cloudAgentNext.getHealthOverview({
393+
startDate: at(0, 30),
394+
endDate: at(5, 30),
395+
bucket: 'hour',
396+
});
397+
398+
expect(health.summary).toEqual({
399+
completedRuns: 1,
400+
failedRuns: 2,
401+
interruptedRuns: 1,
402+
setupFailures: 1,
403+
});
404+
expect(health.series).toEqual([
405+
{
406+
bucketStart: at(0),
407+
completedRuns: 0,
408+
failedRuns: 0,
409+
interruptedRuns: 0,
410+
setupFailures: 0,
411+
},
412+
{
413+
bucketStart: at(1),
414+
completedRuns: 1,
415+
failedRuns: 0,
416+
interruptedRuns: 0,
417+
setupFailures: 0,
418+
},
419+
{
420+
bucketStart: at(2),
421+
completedRuns: 0,
422+
failedRuns: 1,
423+
interruptedRuns: 0,
424+
setupFailures: 0,
425+
},
426+
{
427+
bucketStart: at(3),
428+
completedRuns: 0,
429+
failedRuns: 1,
430+
interruptedRuns: 0,
431+
setupFailures: 0,
432+
},
433+
{
434+
bucketStart: at(4),
435+
completedRuns: 0,
436+
failedRuns: 0,
437+
interruptedRuns: 1,
438+
setupFailures: 0,
439+
},
440+
{
441+
bucketStart: at(5),
442+
completedRuns: 0,
443+
failedRuns: 0,
444+
interruptedRuns: 0,
445+
setupFailures: 1,
446+
},
447+
]);
380448
});
381449

382450
it('lists affected sessions for an exact top-error source and occurrence interval', async () => {

apps/web/src/routers/admin-cloud-agent-next-router.ts

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,22 +38,6 @@ function hasBoundedInterval(input: { startDate: string; endDate: string }) {
3838
return new Date(input.endDate).getTime() - new Date(input.startDate).getTime() <= MAX_INTERVAL_MS;
3939
}
4040

41-
function hasAlignedBucketBounds(input: {
42-
startDate: string;
43-
endDate: string;
44-
bucket: z.infer<typeof healthBucketSchema>;
45-
}) {
46-
const dates = [new Date(input.startDate), new Date(input.endDate)];
47-
return dates.every(date =>
48-
input.bucket === 'day'
49-
? date.getUTCHours() === 0 &&
50-
date.getUTCMinutes() === 0 &&
51-
date.getUTCSeconds() === 0 &&
52-
date.getUTCMilliseconds() === 0
53-
: date.getUTCMinutes() === 0 && date.getUTCSeconds() === 0 && date.getUTCMilliseconds() === 0
54-
);
55-
}
56-
5741
const HealthOverviewFilterSchema = z
5842
.object({
5943
...intervalShape,
@@ -67,10 +51,6 @@ const HealthOverviewFilterSchema = z
6751
.refine(input => hasBoundedInterval(input), {
6852
message: 'Date interval cannot exceed 90 days',
6953
path: ['endDate'],
70-
})
71-
.refine(input => hasAlignedBucketBounds(input), {
72-
message: 'Date interval must align to UTC bucket boundaries',
73-
path: ['startDate'],
7454
});
7555
const HealthErrorSessionsFilterSchema = z
7656
.object({
@@ -173,11 +153,13 @@ type HealthError = {
173153
};
174154

175155
function emptyHealthSeries(input: HealthOverviewFilter): HealthSeriesPoint[] {
176-
const start = new Date(input.startDate).getTime();
156+
const firstBucket = new Date(input.startDate);
157+
if (input.bucket === 'day') firstBucket.setUTCHours(0, 0, 0, 0);
158+
else firstBucket.setUTCMinutes(0, 0, 0);
177159
const end = new Date(input.endDate).getTime();
178160
const bucketMs = input.bucket === 'day' ? DAY_MS : HOUR_MS;
179161
const series: HealthSeriesPoint[] = [];
180-
for (let timestamp = start; timestamp < end; timestamp += bucketMs) {
162+
for (let timestamp = firstBucket.getTime(); timestamp < end; timestamp += bucketMs) {
181163
series.push({
182164
bucketStart: new Date(timestamp).toISOString(),
183165
completedRuns: 0,

0 commit comments

Comments
 (0)