feat(webapp): runs live updating
## Summary The Runs list now updates live without requiring a page refresh. Status changes and other run fields are updated in place while runs are executing. When new runs matching the current filters are created, a "New runs created" refresh button appears above the list. Root runs now show a live child-run status breakdown directly in the status tooltip. ### List live update - Visible runs update in place while they are still running. - A "New runs created" refresh button appears when new matching runs are detected. - Polling stops when all visible runs have finished and a refresh button is already shown. - Polling pauses when the browser tab is not visible. - Runs list status updates and new-run detection share a single runs/live polling path. ### Child-status tooltip - Root run tooltips now display a breakdown of child run statuses. - Child statuses are loaded when the tooltip opens (after a 400ms hover delay). - The tooltip stays up to date while child runs are still changing state. - Handles cases where child runs continue running after their parent run has completed, or have not yet been created. ### Supporting changes - Added hidden-tab awareness to polling. - Added safeguards around polling inputs (`runIds` deduping and limits). ## Test plan - [x] pnpm run typecheck --filter webapp passes - [x] cd apps/webapp && pnpm run test ./test/presenters/mapRunToLiveFields.test.ts --run passes - [x] cd apps/webapp && pnpm run test ./test/runsRepository.part2.test.ts --run -t "hasNewRuns" passes ### Manual smoke: - [x] Active runs update without a page refresh. - [x] A new matching run shows the refresh banner and the banner actions work as expected. - [x] Root run tooltips show live child-status updates and stop polling once child runs settle. --------- Co-authored-by: Ekaterina Bulatova <kathiekiwi@Ekaterinas-MacBook-Pro.local>
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
getTaskRunsQueryBuilder,
|
||||
getTaskActivityQueryBuilder,
|
||||
getCurrentRunningStats,
|
||||
getChildRunStatusCounts,
|
||||
getAverageDurations,
|
||||
getTaskUsageByOrganization,
|
||||
getTaskRunsCountQueryBuilder,
|
||||
@@ -228,6 +229,7 @@ export class ClickHouse {
|
||||
pendingVersionIdsQueryBuilder: getPendingVersionIdsQueryBuilder(this.reader),
|
||||
getTaskActivity: getTaskActivityQueryBuilder(this.reader),
|
||||
getCurrentRunningStats: getCurrentRunningStats(this.reader),
|
||||
getChildRunStatusCounts: getChildRunStatusCounts(this.reader),
|
||||
getAverageDurations: getAverageDurations(this.reader),
|
||||
getTaskUsageByOrganization: getTaskUsageByOrganization(this.reader),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ import { clickhouseTest } from "@internal/testcontainers";
|
||||
import { z } from "zod";
|
||||
import { ClickhouseClient } from "./client/client.js";
|
||||
import {
|
||||
TASK_RUN_INDEX,
|
||||
getChildRunStatusCounts,
|
||||
getTaskRunsQueryBuilder,
|
||||
insertRawTaskRunPayloadsCompactArrays,
|
||||
insertTaskRunsCompactArrays,
|
||||
@@ -416,6 +418,311 @@ describe("Task Runs V2", () => {
|
||||
}
|
||||
);
|
||||
|
||||
clickhouseTest(
|
||||
"should aggregate child status counts with FINAL and ignore deleted rows",
|
||||
async ({ clickhouseContainer }) => {
|
||||
const client = new ClickhouseClient({
|
||||
name: "test",
|
||||
url: clickhouseContainer.getConnectionUrl(),
|
||||
});
|
||||
|
||||
const insert = insertTaskRunsCompactArrays(client, {
|
||||
async_insert: 0, // turn off async insert for this test
|
||||
});
|
||||
|
||||
const baseCreatedAt = new Date("2025-05-01T12:00:00.000Z").getTime();
|
||||
const oldCreatedAt = new Date("2025-04-15T12:00:00.000Z").getTime();
|
||||
const since = baseCreatedAt - 60_000;
|
||||
|
||||
const rootRun: TaskRunInsertArray = [
|
||||
"env_agg", // environment_id
|
||||
"org_agg", // organization_id
|
||||
"project_agg", // project_id
|
||||
"root_run_1", // run_id
|
||||
baseCreatedAt, // updated_at
|
||||
baseCreatedAt, // created_at
|
||||
"EXECUTING", // status
|
||||
"DEVELOPMENT", // environment_type
|
||||
"run_root_1", // friendly_id
|
||||
1, // attempt
|
||||
"V2", // engine
|
||||
"root-task", // task_identifier
|
||||
"task/root-task", // queue
|
||||
"", // schedule_id
|
||||
"", // batch_id
|
||||
null, // completed_at
|
||||
baseCreatedAt, // started_at
|
||||
null, // executed_at
|
||||
null, // delay_until
|
||||
baseCreatedAt, // queued_at
|
||||
null, // expired_at
|
||||
0, // usage_duration_ms
|
||||
0, // cost_in_cents
|
||||
0, // base_cost_in_cents
|
||||
{ data: null }, // output
|
||||
{ data: null }, // error
|
||||
"", // error_fingerprint
|
||||
[], // tags
|
||||
"", // task_version
|
||||
"", // sdk_version
|
||||
"", // cli_version
|
||||
"", // machine_preset
|
||||
"", // root_run_id
|
||||
"", // parent_run_id
|
||||
0, // depth
|
||||
"span_root", // span_id
|
||||
"trace_root", // trace_id
|
||||
"", // idempotency_key
|
||||
"", // idempotency_key_user
|
||||
"", // idempotency_key_scope
|
||||
"", // expiration_ttl
|
||||
true, // is_test
|
||||
"1", // _version
|
||||
0, // _is_deleted
|
||||
"", // concurrency_key
|
||||
[], // bulk_action_group_ids
|
||||
"", // worker_queue
|
||||
null, // max_duration_in_seconds
|
||||
"", // trigger_source
|
||||
"", // root_trigger_source
|
||||
"", // task_kind
|
||||
null, // is_warm_start
|
||||
];
|
||||
|
||||
const childA_v1: TaskRunInsertArray = [
|
||||
"env_agg",
|
||||
"org_agg",
|
||||
"project_agg",
|
||||
"child_a",
|
||||
baseCreatedAt + 1_000,
|
||||
baseCreatedAt + 1_000,
|
||||
"PENDING",
|
||||
"DEVELOPMENT",
|
||||
"run_child_a",
|
||||
1,
|
||||
"V2",
|
||||
"child-task",
|
||||
"task/child-task",
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
baseCreatedAt + 1_000,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
{ data: null },
|
||||
{ data: null },
|
||||
"",
|
||||
[],
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"root_run_1",
|
||||
"root_run_1",
|
||||
1,
|
||||
"span_child_a",
|
||||
"trace_root",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
"1",
|
||||
0,
|
||||
"",
|
||||
[],
|
||||
"",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
];
|
||||
|
||||
const childA_v2: TaskRunInsertArray = [
|
||||
...childA_v1,
|
||||
];
|
||||
childA_v2[TASK_RUN_INDEX.status] = "COMPLETED_SUCCESSFULLY";
|
||||
childA_v2[TASK_RUN_INDEX._version] = "2";
|
||||
|
||||
const childB: TaskRunInsertArray = [
|
||||
"env_agg",
|
||||
"org_agg",
|
||||
"project_agg",
|
||||
"child_b",
|
||||
baseCreatedAt + 2_000,
|
||||
baseCreatedAt + 2_000,
|
||||
"EXECUTING",
|
||||
"DEVELOPMENT",
|
||||
"run_child_b",
|
||||
1,
|
||||
"V2",
|
||||
"child-task",
|
||||
"task/child-task",
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
baseCreatedAt + 2_000,
|
||||
null,
|
||||
null,
|
||||
baseCreatedAt + 2_000,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
{ data: null },
|
||||
{ data: null },
|
||||
"",
|
||||
[],
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"root_run_1",
|
||||
"root_run_1",
|
||||
1,
|
||||
"span_child_b",
|
||||
"trace_root",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
"1",
|
||||
0,
|
||||
"",
|
||||
[],
|
||||
"",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
];
|
||||
|
||||
const childDeleted_v1: TaskRunInsertArray = [
|
||||
"env_agg",
|
||||
"org_agg",
|
||||
"project_agg",
|
||||
"child_deleted",
|
||||
baseCreatedAt + 3_000,
|
||||
baseCreatedAt + 3_000,
|
||||
"PENDING",
|
||||
"DEVELOPMENT",
|
||||
"run_child_deleted",
|
||||
1,
|
||||
"V2",
|
||||
"child-task",
|
||||
"task/child-task",
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
baseCreatedAt + 3_000,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
{ data: null },
|
||||
{ data: null },
|
||||
"",
|
||||
[],
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"root_run_1",
|
||||
"root_run_1",
|
||||
1,
|
||||
"span_child_deleted",
|
||||
"trace_root",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
"1",
|
||||
0,
|
||||
"",
|
||||
[],
|
||||
"",
|
||||
null,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
null,
|
||||
];
|
||||
|
||||
const childDeleted_v2: TaskRunInsertArray = [
|
||||
...childDeleted_v1,
|
||||
];
|
||||
childDeleted_v2[TASK_RUN_INDEX._version] = "2";
|
||||
childDeleted_v2[TASK_RUN_INDEX._is_deleted] = 1;
|
||||
|
||||
const childWrongRoot: TaskRunInsertArray = [
|
||||
...childB,
|
||||
];
|
||||
childWrongRoot[TASK_RUN_INDEX.run_id] = "child_wrong_root";
|
||||
childWrongRoot[TASK_RUN_INDEX.friendly_id] = "run_child_wrong_root";
|
||||
childWrongRoot[TASK_RUN_INDEX.root_run_id] = "other_root";
|
||||
childWrongRoot[TASK_RUN_INDEX.parent_run_id] = "other_root";
|
||||
childWrongRoot[TASK_RUN_INDEX.span_id] = "span_child_wrong_root";
|
||||
|
||||
const childOld: TaskRunInsertArray = [
|
||||
...childB,
|
||||
];
|
||||
childOld[TASK_RUN_INDEX.run_id] = "child_old";
|
||||
childOld[TASK_RUN_INDEX.created_at] = oldCreatedAt;
|
||||
childOld[TASK_RUN_INDEX.updated_at] = oldCreatedAt;
|
||||
childOld[TASK_RUN_INDEX.started_at] = oldCreatedAt;
|
||||
childOld[TASK_RUN_INDEX.queued_at] = oldCreatedAt;
|
||||
childOld[TASK_RUN_INDEX.friendly_id] = "run_child_old";
|
||||
childOld[TASK_RUN_INDEX.span_id] = "span_child_old";
|
||||
|
||||
const [insertError] = await insert([
|
||||
rootRun,
|
||||
childA_v1,
|
||||
childA_v2,
|
||||
childB,
|
||||
childDeleted_v1,
|
||||
childDeleted_v2,
|
||||
childWrongRoot,
|
||||
childOld,
|
||||
]);
|
||||
|
||||
expect(insertError).toBeNull();
|
||||
|
||||
const [queryError, result] = await getChildRunStatusCounts(client)({
|
||||
organizationId: "org_agg",
|
||||
projectId: "project_agg",
|
||||
environmentId: "env_agg",
|
||||
rootRunIds: ["root_run_1"],
|
||||
since,
|
||||
});
|
||||
|
||||
expect(queryError).toBeNull();
|
||||
expect(result).toEqual([
|
||||
{
|
||||
root_run_id: "root_run_1",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
root_run_id: "root_run_1",
|
||||
status: "EXECUTING",
|
||||
count: 1,
|
||||
},
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
clickhouseTest(
|
||||
"should be able to insert payloads with a duplicate path",
|
||||
async ({ clickhouseContainer }) => {
|
||||
|
||||
@@ -514,6 +514,51 @@ export function getCurrentRunningStats(ch: ClickhouseReader, settings?: ClickHou
|
||||
});
|
||||
}
|
||||
|
||||
export const ChildRunStatusCountsQueryResult = z.object({
|
||||
root_run_id: z.string(),
|
||||
status: z.string(),
|
||||
count: z.number().int(),
|
||||
});
|
||||
|
||||
export type ChildRunStatusCountsQueryResult = z.infer<typeof ChildRunStatusCountsQueryResult>;
|
||||
|
||||
export const ChildRunStatusCountsQueryParams = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
environmentId: z.string(),
|
||||
rootRunIds: z.array(z.string()).min(1),
|
||||
since: z.number().int(),
|
||||
});
|
||||
|
||||
export function getChildRunStatusCounts(ch: ClickhouseReader, settings?: ClickHouseSettings) {
|
||||
return ch.query({
|
||||
name: "getChildRunStatusCounts",
|
||||
query: `
|
||||
SELECT
|
||||
root_run_id,
|
||||
status,
|
||||
count() as count
|
||||
FROM trigger_dev.task_runs_v2 FINAL
|
||||
WHERE
|
||||
organization_id = {organizationId: String}
|
||||
AND project_id = {projectId: String}
|
||||
AND environment_id = {environmentId: String}
|
||||
AND root_run_id IN {rootRunIds: Array(String)}
|
||||
AND created_at >= fromUnixTimestamp64Milli({since: Int64})
|
||||
AND _is_deleted = 0
|
||||
GROUP BY
|
||||
root_run_id,
|
||||
status
|
||||
ORDER BY
|
||||
root_run_id ASC,
|
||||
status ASC
|
||||
`,
|
||||
schema: ChildRunStatusCountsQueryResult,
|
||||
params: ChildRunStatusCountsQueryParams,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const AverageDurationsQueryResult = z.object({
|
||||
task_identifier: z.string(),
|
||||
duration: z.number(),
|
||||
|
||||
Reference in New Issue
Block a user