Files
2026-07-13 13:32:57 +08:00

158 lines
4.7 KiB
TypeScript

/** Shared bucketing + zero-fill helpers for the task/agent activity bar charts. */
// Snap bucket intervals to human-friendly values (1s…7d) so tick boundaries stay readable.
const NICE_BUCKET_SECONDS = [
1, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 10800, 21600, 43200, 86400, 172800,
604800,
] as const;
export type ChooseBucketOptions = {
/** Bucket count to aim for (a "full"-looking chart). */
targetBuckets?: number;
/** Hard ceiling so we never emit sub-pixel bars or huge result sets. */
maxBuckets?: number;
};
/**
* Pick a bucket interval (seconds): the nice value whose bucket count is closest
* to `targetBuckets` without exceeding `maxBuckets`. Keeps a 5-minute range from
* collapsing to a single 1-hour bar.
*/
export function chooseBucketSeconds(
rangeMs: number,
{ targetBuckets = 72, maxBuckets = 120 }: ChooseBucketOptions = {}
): number {
const rangeSeconds = Math.max(1, Math.ceil(rangeMs / 1000));
let best: number | null = null;
let bestScore = Infinity;
for (const secs of NICE_BUCKET_SECONDS) {
const count = rangeSeconds / secs;
if (count > maxBuckets) continue;
const score = Math.abs(count - targetBuckets);
if (score < bestScore) {
bestScore = score;
best = secs;
}
}
// Range larger than the ladder: derive an interval that respects the ceiling.
if (best === null) {
return Math.ceil(rangeSeconds / maxBuckets);
}
return best;
}
export const RUN_STATUS_GROUPS = ["COMPLETED", "FAILED", "CANCELED", "RUNNING"] as const;
export type RunStatusGroup = (typeof RUN_STATUS_GROUPS)[number];
const TERMINAL_GROUPS: Record<RunStatusGroup, readonly string[]> = {
COMPLETED: ["COMPLETED_SUCCESSFULLY"],
FAILED: ["COMPLETED_WITH_ERRORS", "SYSTEM_FAILURE", "CRASHED", "INTERRUPTED", "TIMED_OUT"],
CANCELED: ["CANCELED", "EXPIRED"],
RUNNING: [
"EXECUTING",
"DEQUEUED",
"PENDING_EXECUTING",
"WAITING_TO_RESUME",
"QUEUED_EXECUTING",
"PENDING",
"PENDING_VERSION",
"DELAYED",
"WAITING_FOR_DEPLOY",
],
};
/** Map a raw TaskRun status to one of the four chart groups. */
export function groupRunStatus(status: string): RunStatusGroup | undefined {
for (const label of RUN_STATUS_GROUPS) {
if (TERMINAL_GROUPS[label].includes(status)) return label;
}
return undefined;
}
export type ActivitySeriesPoint = { bucket: number } & Record<string, number>;
function bucketBounds(from: Date, to: Date, bucketSeconds: number) {
const bucketMs = bucketSeconds * 1000;
return {
bucketMs,
start: Math.floor(from.getTime() / bucketMs) * bucketMs,
end: Math.ceil(to.getTime() / bucketMs) * bucketMs,
};
}
/**
* Zero-filled grouped series: every bucket in [from, to) is emitted and every
* `orderedKeys` entry is present on each point, for contiguous bars and a stable
* legend.
*/
export function zeroFillGroupedSeries<K extends string>({
rows,
from,
to,
bucketSeconds,
orderedKeys,
groupFn,
fallbackKey,
}: {
rows: Array<{ bucket: number; status: string; val: number }>;
from: Date;
to: Date;
bucketSeconds: number;
orderedKeys: readonly K[];
/** Maps a raw status to a key; defaults to identity. */
groupFn?: (status: string) => K | undefined;
/** Key for statuses groupFn doesn't map (e.g. unknown statuses). */
fallbackKey?: K;
}): ActivitySeriesPoint[] {
const bucketMap = new Map<number, Record<string, number>>();
for (const row of rows) {
const key = (groupFn ? groupFn(row.status) : (row.status as K)) ?? fallbackKey;
if (!key) continue;
const ts = row.bucket * 1000;
const existing = bucketMap.get(ts) ?? {};
existing[key] = (existing[key] ?? 0) + row.val;
bucketMap.set(ts, existing);
}
const { bucketMs, start, end } = bucketBounds(from, to, bucketSeconds);
const points: ActivitySeriesPoint[] = [];
for (let ts = start; ts < end; ts += bucketMs) {
const existing = bucketMap.get(ts) ?? {};
const point: ActivitySeriesPoint = { bucket: ts };
for (const k of orderedKeys) point[k] = existing[k] ?? 0;
points.push(point);
}
return points;
}
/** Zero-filled single-series (scalar) time series. */
export function zeroFillScalarSeries({
rows,
from,
to,
bucketSeconds,
seriesKey,
}: {
rows: Array<{ bucket: number; val: number }>;
from: Date;
to: Date;
bucketSeconds: number;
seriesKey: string;
}): ActivitySeriesPoint[] {
const bucketMap = new Map<number, number>();
for (const row of rows) {
const ts = row.bucket * 1000;
bucketMap.set(ts, (bucketMap.get(ts) ?? 0) + row.val);
}
const { bucketMs, start, end } = bucketBounds(from, to, bucketSeconds);
const points: ActivitySeriesPoint[] = [];
for (let ts = start; ts < end; ts += bucketMs) {
points.push({ bucket: ts, [seriesKey]: bucketMap.get(ts) ?? 0 });
}
return points;
}