loops: iteration timeline — GET /api/topology-runs?loop_id=X
Extend the topology-runs list route with an optional loop_id filter that returns iterations for a single loop, newest-iteration-first. Adds the iteration and finished_at columns to the summary (skip-null on the JSON so compares stay compact). Backed by list_by_loop in the repo, which uses the existing topology_runs_loop_idx partial index. LoopsCanvas fetches the runs in parallel with the loop detail and renders an iteration timeline card (iteration #, status pill, start time, duration, run id prefix) between the graph section and the actions row.
This commit is contained in:
+60
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, task, status, kind, created_at, iteration, finished_at\n FROM topology_runs\n WHERE workspace_id = $1 AND loop_id = $2\n ORDER BY iteration DESC NULLS LAST, created_at DESC\n LIMIT $3",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "task",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "kind",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "created_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "iteration",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "finished_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a552e2bdbbcf567e1ce057acfe7e5395fd58dad9887dc0c26b87fb993ce2b769"
|
||||||
|
}
|
||||||
+15
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT id, task, status, kind, created_at FROM topology_runs\n WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
|
"query": "SELECT id, task, status, kind, created_at, iteration, finished_at\n FROM topology_runs\n WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -27,6 +27,16 @@
|
|||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type_info": "Timestamptz"
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "iteration",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "finished_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -40,8 +50,10 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false
|
false,
|
||||||
|
true,
|
||||||
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "c2ea3efe8d13800dc95fc21984a770281dce7d518cdc05941736e3bf628c4876"
|
"hash": "e7a8b969ddd7fa1e1cc72082e6c39c3295f60b30e274a02cb1d2e6c7aed3da8b"
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
@@ -216,6 +216,8 @@ pub async fn run_swarm(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A saved/queued run, summarized (now includes lifecycle status + kind).
|
/// A saved/queued run, summarized (now includes lifecycle status + kind).
|
||||||
|
/// `iteration` + `finished_at` populate for loop iterations / terminal runs;
|
||||||
|
/// they're skipped from the JSON when null to keep the compares path compact.
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct RunSummary {
|
pub struct RunSummary {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -223,15 +225,40 @@ pub struct RunSummary {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub iteration: Option<i32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub finished_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query params for `GET /api/topology-runs`. `loop_id` filters to a single
|
||||||
|
/// loop's iterations, ordered newest-iteration-first.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ListRunsQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
pub loop_id: Option<Uuid>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub limit: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/topology-runs` — recent runs for the workspace (compares + durable
|
/// `GET /api/topology-runs` — recent runs for the workspace (compares + durable
|
||||||
/// run jobs), newest first.
|
/// run jobs), newest first. `?loop_id=X` filters to iterations of one loop,
|
||||||
|
/// ordered by iteration DESC (uses `topology_runs_loop_idx`).
|
||||||
pub async fn list_runs(
|
pub async fn list_runs(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Authed(user): Authed,
|
Authed(user): Authed,
|
||||||
|
Query(q): Query<ListRunsQuery>,
|
||||||
) -> Result<Json<Vec<RunSummary>>, ApiError> {
|
) -> Result<Json<Vec<RunSummary>>, ApiError> {
|
||||||
let rows = cm_db::repo::topology_runs::list_recent(&state.pool, user.workspace_id, 20).await?;
|
let limit = q.limit.filter(|n| *n > 0 && *n <= 200).unwrap_or(20);
|
||||||
|
let rows = match q.loop_id {
|
||||||
|
Some(loop_id) => {
|
||||||
|
cm_db::repo::topology_runs::list_by_loop(&state.pool, user.workspace_id, loop_id, limit)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
cm_db::repo::topology_runs::list_recent(&state.pool, user.workspace_id, limit).await?
|
||||||
|
}
|
||||||
|
};
|
||||||
let out = rows
|
let out = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| RunSummary {
|
.map(|r| RunSummary {
|
||||||
@@ -240,6 +267,8 @@ pub async fn list_runs(
|
|||||||
status: r.status,
|
status: r.status,
|
||||||
kind: r.kind,
|
kind: r.kind,
|
||||||
created_at: r.created_at.format(&Rfc3339).unwrap_or_default(),
|
created_at: r.created_at.format(&Rfc3339).unwrap_or_default(),
|
||||||
|
iteration: r.iteration,
|
||||||
|
finished_at: r.finished_at.and_then(|t| t.format(&Rfc3339).ok()),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(Json(out))
|
Ok(Json(out))
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
//! (CAS) → checkpoint → complete, plus the stale-run resume sweep. This is the
|
//! (CAS) → checkpoint → complete, plus the stale-run resume sweep. This is the
|
||||||
//! foundation that lets long-horizon topology runs survive worker restarts.
|
//! foundation that lets long-horizon topology runs survive worker restarts.
|
||||||
|
|
||||||
use cm_db::repo::{topology_runs, workspaces};
|
use cm_db::repo::{loops, topology_runs, users, workspaces};
|
||||||
use cm_domain::{Workspace, WorkspaceId};
|
use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -131,3 +131,111 @@ async fn cancel_transitions_only_active_runs() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!topology_runs::cancel(&pool, id2, other).await.unwrap());
|
assert!(!topology_runs::cancel(&pool, id2, other).await.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_by_loop_returns_only_that_loops_iterations_newest_first() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let ws = seed_workspace(&pool).await;
|
||||||
|
|
||||||
|
// A loop needs a valid `created_by` user in the same workspace.
|
||||||
|
let user = User {
|
||||||
|
id: UserId::new(),
|
||||||
|
workspace_id: ws,
|
||||||
|
email: "[email protected]".into(),
|
||||||
|
role: Role::Owner,
|
||||||
|
display_name: "Hop".into(),
|
||||||
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
users::insert(&pool, &user).await.unwrap();
|
||||||
|
|
||||||
|
let graph = json!({"kind": "pipeline", "nodes": [{"id":"n1","role":"drafter"}], "edges": []});
|
||||||
|
let triggers = json!({});
|
||||||
|
let repeat = json!({"kind": "infinite"});
|
||||||
|
let loop_id = loops::create(
|
||||||
|
&pool,
|
||||||
|
loops::NewLoop {
|
||||||
|
workspace_id: ws.as_uuid(),
|
||||||
|
title: "L",
|
||||||
|
description: "d",
|
||||||
|
graph: &graph,
|
||||||
|
task_template: "task",
|
||||||
|
triggers: &triggers,
|
||||||
|
repeat_policy: &repeat,
|
||||||
|
enabled: true,
|
||||||
|
next_fire_at: None,
|
||||||
|
webhook_token: None,
|
||||||
|
webhook_signing_key: None,
|
||||||
|
created_by: user.id.as_uuid(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Three iterations of the target loop, plus a naked run + another loop's
|
||||||
|
// iteration that should be filtered out.
|
||||||
|
let it1 = loops::enqueue_iteration(&pool, loop_id, ws.as_uuid(), "t1", &graph, 1, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let it2 = loops::enqueue_iteration(&pool, loop_id, ws.as_uuid(), "t2", &graph, 2, Some(it1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let it3 = loops::enqueue_iteration(&pool, loop_id, ws.as_uuid(), "t3", &graph, 3, Some(it2))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
topology_runs::enqueue_run(&pool, Uuid::now_v7(), ws, "naked", &graph)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let other_loop = loops::create(
|
||||||
|
&pool,
|
||||||
|
loops::NewLoop {
|
||||||
|
workspace_id: ws.as_uuid(),
|
||||||
|
title: "L2",
|
||||||
|
description: "d2",
|
||||||
|
graph: &graph,
|
||||||
|
task_template: "task2",
|
||||||
|
triggers: &triggers,
|
||||||
|
repeat_policy: &repeat,
|
||||||
|
enabled: true,
|
||||||
|
next_fire_at: None,
|
||||||
|
webhook_token: None,
|
||||||
|
webhook_signing_key: None,
|
||||||
|
created_by: user.id.as_uuid(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let _other_it = loops::enqueue_iteration(
|
||||||
|
&pool,
|
||||||
|
other_loop,
|
||||||
|
ws.as_uuid(),
|
||||||
|
"other",
|
||||||
|
&graph,
|
||||||
|
1,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rows = topology_runs::list_by_loop(&pool, ws, loop_id, 20)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(rows.len(), 3, "only the target loop's iterations");
|
||||||
|
// Newest iteration first.
|
||||||
|
assert_eq!(rows[0].iteration, Some(3));
|
||||||
|
assert_eq!(rows[0].id, it3);
|
||||||
|
assert_eq!(rows[1].iteration, Some(2));
|
||||||
|
assert_eq!(rows[1].id, it2);
|
||||||
|
assert_eq!(rows[2].iteration, Some(1));
|
||||||
|
assert_eq!(rows[2].id, it1);
|
||||||
|
// Each iteration is still a durable run — finished_at is None until completion.
|
||||||
|
assert!(rows.iter().all(|r| r.finished_at.is_none()));
|
||||||
|
assert!(rows.iter().all(|r| r.kind == "run"));
|
||||||
|
|
||||||
|
// Wrong workspace: nothing.
|
||||||
|
let other_ws = seed_workspace(&pool).await;
|
||||||
|
let cross = topology_runs::list_by_loop(&pool, other_ws, loop_id, 20)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(cross.is_empty(), "loops are scoped by workspace");
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::DbError;
|
use crate::DbError;
|
||||||
|
|
||||||
/// A row summary for the recent-runs list.
|
/// A row summary for the recent-runs list. `iteration` and `finished_at`
|
||||||
|
/// are populated for loop iterations and for terminal runs respectively;
|
||||||
|
/// `None` for compares or still-in-flight runs.
|
||||||
pub struct TopologyRunSummary {
|
pub struct TopologyRunSummary {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub task: String,
|
pub task: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub created_at: OffsetDateTime,
|
pub created_at: OffsetDateTime,
|
||||||
|
pub iteration: Option<i32>,
|
||||||
|
pub finished_at: Option<OffsetDateTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A full saved comparison run.
|
/// A full saved comparison run.
|
||||||
@@ -271,7 +275,8 @@ pub async fn list_recent(
|
|||||||
limit: i64,
|
limit: i64,
|
||||||
) -> Result<Vec<TopologyRunSummary>, DbError> {
|
) -> Result<Vec<TopologyRunSummary>, DbError> {
|
||||||
let rows = sqlx::query!(
|
let rows = sqlx::query!(
|
||||||
"SELECT id, task, status, kind, created_at FROM topology_runs
|
"SELECT id, task, status, kind, created_at, iteration, finished_at
|
||||||
|
FROM topology_runs
|
||||||
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
|
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2",
|
||||||
workspace_id.as_uuid(),
|
workspace_id.as_uuid(),
|
||||||
limit,
|
limit,
|
||||||
@@ -286,6 +291,42 @@ pub async fn list_recent(
|
|||||||
status: r.status,
|
status: r.status,
|
||||||
kind: r.kind,
|
kind: r.kind,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
|
iteration: r.iteration,
|
||||||
|
finished_at: r.finished_at,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterations of a loop, newest first. Uses the partial index
|
||||||
|
/// `topology_runs_loop_idx` on `(loop_id, iteration DESC)`.
|
||||||
|
pub async fn list_by_loop(
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: WorkspaceId,
|
||||||
|
loop_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<TopologyRunSummary>, DbError> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
"SELECT id, task, status, kind, created_at, iteration, finished_at
|
||||||
|
FROM topology_runs
|
||||||
|
WHERE workspace_id = $1 AND loop_id = $2
|
||||||
|
ORDER BY iteration DESC NULLS LAST, created_at DESC
|
||||||
|
LIMIT $3",
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
loop_id,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| TopologyRunSummary {
|
||||||
|
id: r.id,
|
||||||
|
task: r.task,
|
||||||
|
status: r.status,
|
||||||
|
kind: r.kind,
|
||||||
|
created_at: r.created_at,
|
||||||
|
iteration: r.iteration,
|
||||||
|
finished_at: r.finished_at,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
disableLoop,
|
disableLoop,
|
||||||
enableLoop,
|
enableLoop,
|
||||||
getLoop,
|
getLoop,
|
||||||
|
listLoopRuns,
|
||||||
runLoopNow,
|
runLoopNow,
|
||||||
type Loop,
|
type Loop,
|
||||||
|
type RunSummary,
|
||||||
} from "@/lib/api/loops";
|
} from "@/lib/api/loops";
|
||||||
|
|
||||||
const mono =
|
const mono =
|
||||||
@@ -30,6 +32,8 @@ export function LoopsCanvas({
|
|||||||
onDeleted: () => void;
|
onDeleted: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [loop, setLoop] = useState<Loop | null>(null);
|
const [loop, setLoop] = useState<Loop | null>(null);
|
||||||
|
const [runs, setRuns] = useState<RunSummary[]>([]);
|
||||||
|
const [runsLoading, setRunsLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [acting, setActing] = useState(false);
|
const [acting, setActing] = useState(false);
|
||||||
@@ -39,18 +43,31 @@ export function LoopsCanvas({
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!selectedId) {
|
if (!selectedId) {
|
||||||
if (alive) setLoop(null);
|
if (alive) {
|
||||||
|
setLoop(null);
|
||||||
|
setRuns([]);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setRunsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const l = await getLoop(selectedId);
|
const [l, r] = await Promise.all([
|
||||||
if (alive) setLoop(l);
|
getLoop(selectedId),
|
||||||
|
listLoopRuns(selectedId).catch(() => [] as RunSummary[]),
|
||||||
|
]);
|
||||||
|
if (alive) {
|
||||||
|
setLoop(l);
|
||||||
|
setRuns(r);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (alive) setError(e instanceof Error ? e.message : "load failed");
|
if (alive) setError(e instanceof Error ? e.message : "load failed");
|
||||||
} finally {
|
} finally {
|
||||||
if (alive) setLoading(false);
|
if (alive) {
|
||||||
|
setLoading(false);
|
||||||
|
setRunsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
@@ -233,6 +250,11 @@ export function LoopsCanvas({
|
|||||||
</pre>
|
</pre>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Iterations timeline */}
|
||||||
|
<Section header={`Iterations${runs.length ? ` · ${runs.length}` : ""}`}>
|
||||||
|
<IterationsTimeline runs={runs} loading={runsLoading} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
|
||||||
<button
|
<button
|
||||||
@@ -289,6 +311,117 @@ export function LoopsCanvas({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IterationsTimeline({
|
||||||
|
runs,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
runs: RunSummary[];
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
if (loading && runs.length === 0) {
|
||||||
|
return <p style={hintStyle}>Loading iterations…</p>;
|
||||||
|
}
|
||||||
|
if (runs.length === 0) {
|
||||||
|
return <p style={hintStyle}>No iterations yet — hit "Run now" to fire one.</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
listStyle: "none",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{runs.map((r) => {
|
||||||
|
const started = new Date(r.created_at);
|
||||||
|
const finished = r.finished_at ? new Date(r.finished_at) : null;
|
||||||
|
const durationSec = finished
|
||||||
|
? Math.max(0, Math.round((finished.getTime() - started.getTime()) / 1000))
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={r.id}
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "48px 90px 1fr auto",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "rgba(255,255,255,.04)",
|
||||||
|
border: "1px solid rgba(255,255,255,.05)",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#eaeaee",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: "#5a5a62",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{r.iteration ?? "—"}
|
||||||
|
</span>
|
||||||
|
<StatusPill status={r.status} />
|
||||||
|
<span style={{ color: "#b5b5bd" }}>
|
||||||
|
{started.toLocaleString()}
|
||||||
|
{durationSec !== null && (
|
||||||
|
<span style={{ color: "#5a5a62" }}> · {formatDuration(durationSec)}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{ color: "#5a5a62", fontSize: 10.5, letterSpacing: ".05em" }}
|
||||||
|
title={r.id}
|
||||||
|
>
|
||||||
|
{r.id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const color =
|
||||||
|
status === "completed"
|
||||||
|
? "#5fd08a"
|
||||||
|
: status === "failed"
|
||||||
|
? "#ff8a7a"
|
||||||
|
: status === "cancelled"
|
||||||
|
? "#c8a464"
|
||||||
|
: status === "running"
|
||||||
|
? "#7fbcff"
|
||||||
|
: "#8a8a92";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color,
|
||||||
|
fontSize: 10.5,
|
||||||
|
letterSpacing: ".1em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(sec: number): string {
|
||||||
|
if (sec < 60) return `${sec}s`;
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
if (m < 60) return s ? `${m}m ${s}s` : `${m}m`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
const rm = m % 60;
|
||||||
|
return rm ? `${h}h ${rm}m` : `${h}h`;
|
||||||
|
}
|
||||||
|
|
||||||
function Section({
|
function Section({
|
||||||
header,
|
header,
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -93,3 +93,20 @@ export const enableLoop = (id: string) =>
|
|||||||
|
|
||||||
export const disableLoop = (id: string) =>
|
export const disableLoop = (id: string) =>
|
||||||
api<void>(`/api/loops/${id}/disable`, { method: "POST" });
|
api<void>(`/api/loops/${id}/disable`, { method: "POST" });
|
||||||
|
|
||||||
|
// One iteration/run of a loop as summarized by /api/topology-runs.
|
||||||
|
// `iteration`/`finished_at` are populated for loop iterations / terminal runs.
|
||||||
|
export interface RunSummary {
|
||||||
|
id: string;
|
||||||
|
task: string;
|
||||||
|
status: string;
|
||||||
|
kind: string;
|
||||||
|
created_at: string;
|
||||||
|
iteration?: number;
|
||||||
|
finished_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listLoopRuns = (loopId: string, limit = 20) =>
|
||||||
|
api<RunSummary[]>(
|
||||||
|
`/api/topology-runs?loop_id=${encodeURIComponent(loopId)}&limit=${limit}`,
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user