Repository navigation
Expand file tree
/
Copy pathpage.tsx
More file actions
613 lines (590 loc) · 29.8 KB
/
Copy pathpage.tsx
File metadata and controls
613 lines (590 loc) · 29.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
import Link from "next/link";
import {
getAdhocRunListing,
getOverview,
getRunListing,
listRecentHarnesses,
type TagCount,
} from "@/lib/overview";
import { orderHarnesses, parseHarnessScope } from "@/lib/harness";
import { fmtDuration, fmtRunTime, fmtTimestamp } from "@/lib/format";
import { passClass } from "@/lib/pass-rate";
import { type Window } from "@/lib/reviews-types";
import { DailySuccessChart } from "./_overview/daily-chart";
import { TurnBudgetChart } from "./_overview/turn-budget-chart";
import { WindowSummary } from "./_overview/window-summary";
import { ChipLegend, MergedTagRail } from "./_overview/tag-rail";
import { TableScroll } from "./_components/scroll-table";
import { CollapsibleRail } from "./_components/collapsible-rail";
import { isInternal } from "@/lib/edition";
import { HarnessBadge, harnessShortLabel } from "@/app/_components/harness-badge";
import { HarnessSelector } from "@/app/_components/harness-selector";
export const dynamic = "force-dynamic";
const DEFAULT_LIMIT = 20;
const ADHOC_LIMIT = 10;
// The charts and the summary tiles cover a fixed 30 days. There is no window
// control: the run table pages back through all of history on its own (see
// getRunListing), which is what a shorter window was really being used for, and
// a 30-day chart is the one that shows a trend rather than a few points.
const WINDOW: Window = "30d";
function parseTag(raw: string | string[] | undefined): string | null {
const v = Array.isArray(raw) ? raw[0] : raw;
if (!v) return null;
const trimmed = v.trim();
if (!trimmed || trimmed.length > 80) return null;
// Charset matches what review.json emits today (word chars, plus
// `.`, `:`, `/`, `+`, `-`). Don't tighten without auditing real tags.
if (!/^[\w.:/+-]+$/.test(trimmed)) return null;
return trimmed;
}
function parseQ(raw: string | string[] | undefined): string | null {
const v = Array.isArray(raw) ? raw[0] : raw;
if (!v) return null;
const trimmed = v.trim();
return trimmed ? trimmed.slice(0, 200) : null;
}
// Hard ceiling on how far the tables can be paged out, since every row is
// another multi-MB run.json read. Well above the store's run count, so in
// practice you can keep expanding to the oldest run; a hand-typed `?limit=`
// above this clamps here.
const MAX_LIMIT = 500;
// The two sections page independently (`limit` / `alimit`) so expanding one
// doesn't reset the other.
function parsePagedLimit(
raw: string | string[] | undefined,
fallback: number,
): number {
const v = Array.isArray(raw) ? raw[0] : raw;
if (!v) return fallback;
const n = parseInt(v, 10);
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.min(n, MAX_LIMIT);
}
function fmtCost(c: number | null): string {
if (c == null) return "—";
return `$${c.toFixed(2)}`;
}
// Rail-level q filter: substring match on tag name only. This is narrower
// than getRunListing's q (which also matches taskId / humanized id) by
// design — rails are a tag namespace, the table is a task namespace.
function filterTagsByQuery(tags: TagCount[], q: string | null): TagCount[] {
if (!q) return tags;
const needle = q.toLowerCase();
return tags.filter((t) => t.tag.toLowerCase().includes(needle));
}
function buildHref(
params: Record<string, string | number | null | undefined>,
): string {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v == null || v === "") continue;
sp.set(k, String(v));
}
const qs = sp.toString();
return qs ? `/?${qs}` : "/";
}
export default async function Page({
searchParams,
}: {
searchParams: Promise<{
tag?: string;
q?: string;
h?: string;
limit?: string;
alimit?: string;
}>;
}) {
const params = await searchParams;
const activeTag = parseTag(params.tag);
const q = parseQ(params.q);
// null = every harness, and that is the default: the page opens on the
// cross-harness comparison and narrows from there.
const harness = parseHarnessScope(params.h);
const limit = parsePagedLimit(params.limit, DEFAULT_LIMIT);
const adhocLimit = parsePagedLimit(params.alimit, ADHOC_LIMIT);
// Tasks WITHIN a run are narrowed: drives the "Matching tasks" column header
// and the clear-filters link.
const isFiltered = activeTag != null || q != null;
// The set of RUNS is narrowed, harness scope included: drives every "n of N"
// count, since N only means "everything" when nothing is scoped.
const isNarrowed = isFiltered || harness != null;
// One harness scope drives everything on the page: the charts split into a
// line per harness in the window, and the summary tiles + run list are
// filtered to the same set. Picking a harness therefore recomputes the
// tiles and the charts and narrows the table, instead of re-scoping the
// analytics block while the table silently kept showing every harness.
const [overview, listing, adhoc, harnesses] = await Promise.all([
getOverview(WINDOW, activeTag, q, harness),
getRunListing(activeTag, q, limit, harness),
getAdhocRunListing(adhocLimit, harness),
listRecentHarnesses(),
]);
const skills = filterTagsByQuery(overview.skills, q);
const taskTags = filterTagsByQuery(overview.taskTags, q);
const reviewTags = filterTagsByQuery(overview.reviewTags, q);
const shownCount = listing.rows.length;
// Another page exists AND the page cap still has room for it. Under a
// filter there is no total to count against: proving how many older runs
// match would mean reading all of them, so the table reports what it has
// and offers another page while one is there.
const hasMore = listing.hasMore && shownCount < MAX_LIMIT;
const tableCountLabel = isNarrowed
? `${shownCount} matching`
: `${shownCount} of ${listing.totalCandidates}`;
// Current URL params, normalized to scalars. Spread as the base of every
// href so toggling one section (main `limit` or ad-hoc `alimit`) carries
// the other's state through instead of silently resetting it.
const rawLimit = Array.isArray(params.limit) ? params.limit[0] : params.limit;
const rawAlimit = Array.isArray(params.alimit)
? params.alimit[0]
: params.alimit;
// Omit the all-harness scope from URLs to keep them clean; carry a narrowed
// scope through every self-link so it isn't reset by pagination/clear.
const hParam = harness ?? undefined;
const base = {
tag: activeTag,
q,
h: hParam,
limit: rawLimit,
alimit: rawAlimit,
};
// Both tables grow one page at a time, up to MAX_LIMIT.
const nextPageSize = Math.min(DEFAULT_LIMIT, MAX_LIMIT - shownCount);
const showMoreHref = buildHref({
...base,
limit: shownCount + nextPageSize,
});
const clearAllHref = buildHref({ h: hParam });
// Ad-hoc section disclosure: rows are filtered (by `q`) then capped to
// adhocLimit; page in another ADHOC_LIMIT while more match than are shown,
// and offer a collapse back to the default once expanded past it.
const adhocRemaining = Math.min(adhoc.total, MAX_LIMIT) - adhoc.rows.length;
const adhocExpandable = adhocRemaining > 0;
const adhocNextPageSize = Math.min(ADHOC_LIMIT, adhocRemaining);
const adhocExpanded = adhoc.rows.length > ADHOC_LIMIT;
const adhocShowMoreHref = buildHref({
...base,
alimit: adhoc.rows.length + adhocNextPageSize,
});
const adhocShowLessHref = buildHref({ ...base, alimit: undefined });
return (
<div className="space-y-6">
{/* The harness scope sits in the PAGE header, above the tiles and
beside the page title, because that is what it governs: the
summary tiles, both charts, and the run list all recompute
together. Buried in the chart card it read as a chart control
while the numbers above it silently covered every harness. Same
position as the selector on Path to GA, trends, and the
watchlist. Shown in every edition, like the analytics block it
scopes — the charts below name a harness per line, so gating the
only control that isolates one would leave an OSS instance able
to see a harness but not select it. */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h1 className="text-xl font-semibold text-gray-900">
Recent runs
</h1>
<p className="text-sm text-gray-500">
Click a run to drill into tasks, criteria, artifacts,
and logs.
</p>
</div>
<HarnessSelector
current={harness}
// Union the recent-runs discovery list with every harness in
// this window. They cover different slices —
// listRecentHarnesses scans a fixed count of recent runs,
// windowHarnesses is windowed — so a harness that ran once
// three weeks ago gets a chip in the 30d view despite being
// absent from the discovery list, instead of being drawn on
// the chart with no way to isolate it. windowHarnesses is
// captured before the harness filter, so this set does not
// shift when a chip is selected.
harnesses={orderHarnesses([
...harnesses,
...overview.windowHarnesses,
])}
includeAll
/>
</div>
<WindowSummary
totals={overview.totals}
window={WINDOW}
runCount={overview.runCount}
isFiltered={isNarrowed}
/>
{/* The analytics block — daily success / turn-budget charts and the
colored skill/review/tag rail — renders in EVERY edition. It used
to sit behind isInternal, a gate that dated to the initial public
release rather than to anything in the block: nothing here is
UiPath-specific, it just charts whatever runs the instance is
pointed at, so an OSS clone charts its own results and a
default-configured instance no longer shows a bare run list.
The Harness column in the run table below stays gated separately,
so on an OSS instance the chart legend can name a harness that the
table does not have a column for. */}
<section className="border border-gray-200 rounded-lg bg-white p-4 space-y-4">
<div>
<h2 className="text-sm font-semibold text-gray-900">
Daily Success Rate (%)
</h2>
<p className="text-xs text-gray-500">
{activeTag || q ? (
<>
Scoped to{" "}
{activeTag && (
<>
tag{" "}
<span className="font-mono text-gray-700">
{activeTag}
</span>
</>
)}
{activeTag && q && " and "}
{q && (
<>
search{" "}
<span className="font-mono text-gray-700">
{q}
</span>
</>
)}{" "}
over the last {WINDOW} ·{" "}
{overview.runs.length} run
{overview.runs.length === 1 ? "" : "s"}
{" · "}
<Link
href={buildHref({ h: hParam })}
scroll={false}
className="text-studio-blue hover:underline"
>
clear
</Link>
</>
) : (
<>
Success rate per{" "}
{harness
? `${harnessShortLabel(harness)} run`
: "run, one line per harness,"}{" "}
across the last {WINDOW} ·{" "}
{overview.runs.length} run
{overview.runs.length === 1 ? "" : "s"}
</>
)}
</p>
</div>
<DailySuccessChart
data={overview.runs}
harnesses={overview.harnesses}
windowStart={overview.windowStart}
windowEnd={overview.windowEnd}
/>
<div className="pt-4 mt-2 border-t border-dashed border-gray-200 space-y-1">
<h2 className="text-sm font-semibold text-gray-900">
Within Expected Turns (%)
</h2>
<p className="text-xs text-gray-500">
% of budgeted tasks that stayed within 1.5× their
expected turns (a budgeted task that failed counts as
over budget) · runs with no budgeted task are omitted
rather than plotted at 0
{activeTag || q ? " · scoped to the active filter" : ""}
</p>
<TurnBudgetChart
data={overview.runs}
harnesses={overview.harnesses}
windowStart={overview.windowStart}
windowEnd={overview.windowEnd}
/>
</div>
<div className="pt-2 border-t border-gray-100 space-y-2">
<ChipLegend />
<CollapsibleRail id="home-tagrail">
<MergedTagRail
skills={skills}
taskTags={taskTags}
reviewTags={reviewTags}
activeTag={activeTag}
q={q}
harness={harness}
limit={24}
/>
</CollapsibleRail>
{q &&
skills.length === 0 &&
taskTags.length === 0 &&
reviewTags.length === 0 && (
<p className="text-xs text-gray-500">
No tags match{" "}
<span className="font-mono">{q}</span>.
</p>
)}
</div>
</section>
<div className="flex items-baseline justify-between gap-3 pt-1">
<div className="flex items-baseline gap-3 flex-wrap">
<h2 className="text-sm font-semibold text-gray-900">
Runs
</h2>
<span className="text-xs text-gray-500 tabular-nums">
{tableCountLabel}
</span>
{isFiltered && (
<Link
href={clearAllHref}
scroll={false}
className="text-xs text-gray-500 hover:text-gray-900 underline"
>
clear filter
</Link>
)}
</div>
</div>
<TableScroll
footer={
hasMore ? (
<div className="flex items-center justify-center gap-3 px-4 py-3 border-t border-gray-100 bg-gray-50 text-xs">
<Link
href={showMoreHref}
scroll={false}
className="text-studio-blue hover:underline"
>
Show {nextPageSize} more
</Link>
<span className="text-gray-400 tabular-nums">
{tableCountLabel}
</span>
</div>
) : undefined
}
>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200 text-left text-gray-600">
<th className="py-3 px-4 font-medium">Run</th>
{isInternal && (
<th className="py-3 px-4 font-medium">
Harness
</th>
)}
<th className="py-3 px-4 font-medium">
Pass rate
</th>
<th className="py-3 px-4 font-medium text-right">
{isFiltered ? "Matching tasks" : "Tasks"}
</th>
<th className="py-3 px-4 font-medium text-right">
Cost
</th>
<th className="py-3 px-4 font-medium text-right">
Duration
</th>
</tr>
</thead>
<tbody>
{listing.rows.map((r) => {
const total = r.tasksRun;
const pct = total
? (r.tasksSucceeded / total) * 100
: null;
return (
<tr
key={r.id}
className="border-b border-gray-100 last:border-b-0 hover:bg-gray-50 transition-colors"
>
{/* nowrap: the table already scrolls inside
its own container, so a narrow screen
should scroll it rather than break the
timestamp across three lines. */}
<td className="py-3 px-4 whitespace-nowrap">
<Link
href={`/runs/${r.id}`}
className="font-mono text-xs text-gray-900 hover:text-studio-blue font-semibold tabular-nums"
>
{fmtRunTime(r.id)}
</Link>
</td>
{isInternal && (
<td className="py-3 px-4">
<HarnessBadge
harness={r.harness}
/>
</td>
)}
<td className="py-3 px-4 tabular-nums">
<span
className={`font-medium ${passClass(pct)}`}
>
{pct != null
? `${pct.toFixed(0)}%`
: "—"}
</span>
<span className="text-xs text-gray-500 ml-2 tabular-nums">
{r.tasksSucceeded}/{total}
</span>
</td>
<td className="py-3 px-4 text-right tabular-nums text-gray-700">
{total}
</td>
<td className="py-3 px-4 text-right tabular-nums text-gray-700">
{fmtCost(r.totalCostUsd)}
</td>
<td className="py-3 px-4 text-right tabular-nums text-gray-700">
{fmtDuration(r.taskDurationSeconds)}
</td>
</tr>
);
})}
{listing.rows.length === 0 && (
<tr>
<td
colSpan={isInternal ? 6 : 5}
className="py-6 px-4 text-center text-sm text-gray-500"
>
{isFiltered
? "no runs match the current filter"
: "no runs yet"}
</td>
</tr>
)}
</tbody>
</table>
</TableScroll>
{adhoc.total > 0 && (
<div className="space-y-2 pt-2">
<div className="flex items-baseline gap-3 flex-wrap">
<h2 className="text-sm font-semibold text-gray-900">
Ad-hoc runs
</h2>
<span className="text-xs text-gray-500 tabular-nums">
{adhocExpandable
? `${adhoc.rows.length} of ${adhoc.total}`
: `${adhoc.total}`}
</span>
<span className="text-xs text-gray-500">
Manually uploaded · excluded from the chart, run
list, and trends above
</span>
</div>
<TableScroll
footer={
adhocExpandable || adhocExpanded ? (
<div className="flex items-center justify-center gap-3 px-4 py-3 border-t border-gray-100 bg-gray-50 text-xs">
{adhocExpandable && (
<Link
href={adhocShowMoreHref}
scroll={false}
className="text-studio-blue hover:underline"
>
Show {adhocNextPageSize} more
</Link>
)}
<span className="text-gray-400 tabular-nums">
{adhoc.rows.length} of {adhoc.total}
</span>
{adhocExpanded && (
<Link
href={adhocShowLessHref}
scroll={false}
className="text-studio-blue hover:underline"
>
Show fewer
</Link>
)}
</div>
) : undefined
}
>
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200 text-left text-gray-600">
<th className="py-3 px-4 font-medium">
Run
</th>
<th className="py-3 px-4 font-medium">
Date
</th>
<th className="py-3 px-4 font-medium">
Pass rate
</th>
<th className="py-3 px-4 font-medium text-right">
Tasks
</th>
<th className="py-3 px-4 font-medium text-right">
Cost
</th>
<th className="py-3 px-4 font-medium text-right">
Duration
</th>
</tr>
</thead>
<tbody>
{adhoc.rows.map((r) => {
const total = r.tasksRun;
const pct = total
? (r.tasksSucceeded / total) * 100
: null;
return (
<tr
key={r.id}
className="border-b border-gray-100 last:border-b-0 hover:bg-gray-50 transition-colors"
>
<td className="py-3 px-4">
<Link
href={`/runs/${r.id}`}
className="text-gray-900 hover:text-studio-blue"
>
{r.title ? (
<span className="font-medium">
{r.title}
</span>
) : (
<span className="font-mono text-xs font-semibold tabular-nums">
{r.id}
</span>
)}
</Link>
{r.title && (
<div className="font-mono text-[11px] text-gray-400">
{r.id}
</div>
)}
</td>
<td className="py-3 px-4 font-mono text-xs text-gray-700 tabular-nums whitespace-nowrap">
{fmtTimestamp(r.startedAt)}
</td>
<td className="py-3 px-4 tabular-nums">
<span
className={`font-medium ${passClass(pct)}`}
>
{pct != null
? `${pct.toFixed(0)}%`
: "—"}
</span>
<span className="text-xs text-gray-500 ml-2 tabular-nums">
{r.tasksSucceeded}/{total}
</span>
</td>
<td className="py-3 px-4 text-right tabular-nums text-gray-700">
{total}
</td>
<td className="py-3 px-4 text-right tabular-nums text-gray-700">
{fmtCost(r.totalCostUsd)}
</td>
<td className="py-3 px-4 text-right tabular-nums text-gray-700">
{fmtDuration(
r.taskDurationSeconds,
)}
</td>
</tr>
);
})}
</tbody>
</table>
</TableScroll>
</div>
)}
</div>
);
}