diff --git a/assets/index.html b/assets/index.html
index ed488bc..45b540b 100644
--- a/assets/index.html
+++ b/assets/index.html
@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset=utf-8 http-equiv="Content-Language" content="en"/>
- <link rel="stylesheet" type="text/css" href="sai.css?v=10"/>
+ <link rel="stylesheet" type="text/css" href="sai.css?v=11"/>
<link rel="icon" href="sai-icon.svg" sizes="any" type="image/svg+xml"/>
- <script type='text/javascript' src='sai.js?v=10'></script>
+ <script type='text/javascript' src='sai.js?v=11'></script>
<script type='text/javascript' src='terminal-core.js'></script>
<script type='text/javascript' src='sai-terminal.js'></script>
<script type='text/javascript' src='lws-login.js'></script>
diff --git a/assets/sai.css b/assets/sai.css
index dc3345a..f274d74 100644
--- a/assets/sai.css
+++ b/assets/sai.css
@@ -1773,6 +1773,20 @@ div.ibuil.vm-builder {
overflow: hidden;
}
+/*
+ * Builder list pane: a flex child of .right-pane. Takes the remaining height
+ * beneath the power overview and scrolls independently when the builder list is
+ * taller than the viewport, so clipped builders stay reachable. min-height:0
+ * is required so the flex item can shrink below its content size and actually
+ * scroll instead of forcing the pane to grow.
+ */
+#sai_builders {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ scrollbar-width: thin;
+}
+
.sidebar-pane {
flex: 0 0 auto;
height: 150px;
@@ -1890,6 +1904,26 @@ div.ibuil.vm-builder {
font-weight: bold;
}
+/*
+ * Branch rows are coloured by the state of their newest event: green for a
+ * successful build, red for a failed one (states 3 / 4 / 6, matching the
+ * event-row semantics). Ongoing / waiting states keep the default styling.
+ * Brighter hues are used on the dark selected-row background so the colour
+ * stays readable whether or not the branch is the current selection.
+ */
+.sb-row.comp_pass {
+ color: #1b8f3a;
+}
+.sb-row.comp_fail {
+ color: #c3261d;
+}
+.sb-row.selected.comp_pass {
+ color: #2ecc40;
+}
+.sb-row.selected.comp_fail {
+ color: #ff6b61;
+}
+
/* event rows in col 4 */
/*
* Tabular event rows: CSS grid with fixed-width columns so the date/time,
diff --git a/assets/sai.js b/assets/sai.js
index b27831b..0cad16d 100644
--- a/assets/sai.js
+++ b/assets/sai.js
@@ -956,6 +956,13 @@ var loaded_events = [], selected_event_uuid = null, selected_task_uuid = null, t
* sb_projects / sb_branches are the unique values advertised by the server.
*/
var sb_projects = [], sb_branches = [];
+/*
+ * Latest non-deleted event state per ref for the selected project, as reported
+ * by the branchlist reply (ref -> state int). Used to colour branch rows by
+ * the build result of their newest event. Empty when no project is selected
+ * or when an older server omits branch_states.
+ */
+var sb_branch_states = {};
var sb_selected_project = null, sb_selected_ref = null;
/*
@@ -1578,6 +1585,52 @@ function render_sb_projects()
});
}
+/*
+ * Map a latest-event state to the same comp_pass / comp_fail class family the
+ * event rows use, so a branch name is coloured by its newest build result.
+ * Returns "" for ongoing / waiting states so they keep the default styling.
+ */
+function sai_sb_branch_state_class(ref)
+{
+ if (!sb_branch_states)
+ return "";
+ var st = sb_branch_states[ref];
+ if (st === 3)
+ return " comp_pass";
+ if (st === 4 || st === 6)
+ return " comp_fail";
+ return "";
+}
+
+/*
+ * Live-update the selected branch's colour: the server only pushes scoped
+ * events for the current selection, so only sb_selected_ref can have moved.
+ * Recompute the newest matching event's state from loaded_events; if it differs
+ * from the recorded sb_branch_states entry, update it and re-render col 3.
+ */
+function sai_sb_branch_state_livecheck()
+{
+ if (!sb_selected_ref || !sb_branch_states ||
+ !(sb_selected_ref in sb_branch_states) ||
+ !loaded_events || !loaded_events.length)
+ return;
+ var ml = loaded_events.filter(function(o) {
+ return o && o.e && o.e.ref === sb_selected_ref &&
+ (!sb_selected_project ||
+ o.e.repo_name === sb_selected_project);
+ });
+ if (!ml.length)
+ return;
+ ml.sort(function(a, b) {
+ return (b.e.created || 0) - (a.e.created || 0);
+ });
+ var nst = ml[0].e.state;
+ if (sb_branch_states[sb_selected_ref] !== nst) {
+ sb_branch_states[sb_selected_ref] = nst;
+ render_sb_branches();
+ }
+}
+
function render_sb_branches()
{
var c = document.getElementById("sai_sb_branches");
@@ -1589,8 +1642,10 @@ function render_sb_branches()
} else {
sb_branches.forEach(function(ref) {
var sel = (ref === sb_selected_ref) ? " selected" : "";
- s += "<div class=\"sb-row" + sel + "\" data-ref=\"" +
- san(ref) + "\">" + san(sai_sb_short_ref(ref)) + "</div>";
+ var stClass = sai_sb_branch_state_class(ref);
+ s += "<div class=\"sb-row" + stClass + sel +
+ "\" data-ref=\"" + san(ref) + "\">" +
+ san(sai_sb_short_ref(ref)) + "</div>";
});
}
c.innerHTML = s;
@@ -1684,6 +1739,8 @@ function selectSbProject(name)
return;
sb_selected_project = name;
sb_selected_ref = null;
+ /* clear the per-ref state map until the new project's branchlist arrives */
+ sb_branch_states = {};
render_sb_projects();
/* refresh the branch list for this project; auto-selects newest branch */
sai_sb_request_branches(name);
@@ -3212,8 +3269,12 @@ function ws_open_sai()
* Unique refs for the selected project,
* newest-first. Auto-select the most recent
* branch so col 4 populates immediately.
+ * branch_states (ref -> latest-event state) is
+ * optional; older servers omit it.
*/
sb_branches = (jso.branches && Array.isArray(jso.branches)) ? jso.branches : [];
+ sb_branch_states = (jso.branch_states && typeof jso.branch_states === 'object')
+ ? jso.branch_states : {};
if (!sb_selected_ref && sb_branches.length) {
selectSbBranch(sb_branches[0]);
} else {
@@ -3382,6 +3443,26 @@ function ws_open_sai()
sb_branches = Object.keys(_bset).sort(function(a, b) {
return _bset[b] - _bset[a];
});
+ /*
+ * Derive the per-ref latest state client-side too,
+ * using the newest event per ref, so the fallback
+ * path colours branches identically to a real
+ * branchlist reply.
+ */
+ sb_branch_states = {};
+ sb_branches.forEach(function(ref) {
+ var newest = null;
+ loaded_events.forEach(function(o) {
+ if (!o || !o.e || o.e.ref !== ref ||
+ o.e.repo_name !== sb_selected_project)
+ return;
+ if (!newest ||
+ (o.e.created || 0) > (newest.e.created || 0))
+ newest = o;
+ });
+ if (newest)
+ sb_branch_states[ref] = newest.e.state;
+ });
if (!sb_selected_ref && sb_branches.length)
sb_selected_ref = sb_branches[0];
render_sb_branches();
@@ -3428,6 +3509,14 @@ function ws_open_sai()
}
}
+ /*
+ * Keep the selected branch's colour live: the
+ * overview is scoped to the selection, so only the
+ * selected ref can have a newer event here; other
+ * branches refresh on the next branchlist fetch.
+ */
+ sai_sb_branch_state_livecheck();
+
aging();
break;
@@ -3457,6 +3546,12 @@ function ws_open_sai()
}
}
}
+ /*
+ * A task state change can flip the event's
+ * state; reflect it in the selected branch's
+ * colour without a full branchlist round-trip.
+ */
+ sai_sb_branch_state_livecheck();
}
if (document.getElementById("taskstate_" + jso.t.uuid)) {
diff --git a/src/web/w-ws-browser.c b/src/web/w-ws-browser.c
index f0dbed5..e6ca32b 100644
--- a/src/web/w-ws-browser.c
+++ b/src/web/w-ws-browser.c
@@ -847,45 +847,99 @@ saiw_ws_json_rx_browser(struct vhd *vhd, struct pss *pss, uint8_t *buf,
/*
* Return the set of unique refs for the given project, in
* most-recent-first order (the ref whose latest event is the
- * newest comes first). Read-only. project is bound, not
+ * newest comes first). A parallel "branch_states" object maps
+ * each ref to the state of its latest non-deleted event, so the
+ * browser can colour the branch list rows by build result.
+ * Read-only. project and the state filters are bound, not
* interpolated.
+ *
+ * We run the grouped query once into an lwsac snapshot, then
+ * emit both the "branches" array and the "branch_states" object
+ * from that single consistent result. The correlated subquery
+ * resolves the state of the newest non-deleted event for the
+ * group's ref within the same project. Bind order follows
+ * parameter appearance: project, SAIES_DELETED (subquery),
+ * then SAIES_DELETED, project (outer).
*/
sai_browse_rx_branchlist_t *bl =
(sai_browse_rx_branchlist_t *)a.dest;
+ struct bl_row {
+ struct lws_dll2 list;
+ char *ref;
+ int state;
+ };
+ struct lwsac *ac = NULL;
+ lws_dll2_owner_t owner;
sqlite3_stmt *stmt = NULL;
uint8_t buf[LWS_PRE + 4096], *start = buf + LWS_PRE,
*p = start, *end = buf + sizeof(buf);
char esc[96], pesc[96];
int first_elem = 1, sent_any = 0, rc;
+ lws_dll2_owner_init(&owner);
+
+ /*
+ * Belt-and-braces: also purify (the bound param already
+ * prevents injection, but this keeps the echoed project
+ * field safe to emit too).
+ */
+ lws_sql_purify(pesc, bl->project, sizeof(pesc) - 1);
+
if (sqlite3_prepare_v2(vhd->pdb,
- "SELECT ref, MAX(created) AS mc FROM events "
+ "SELECT ref, "
+ "(SELECT e2.state FROM events e2 "
+ " WHERE e2.ref = events.ref "
+ " AND e2.repo_name = ? AND e2.state != ? "
+ " ORDER BY e2.created DESC LIMIT 1) AS ls "
+ "FROM events "
"WHERE state != ? AND repo_name = ? "
- "GROUP BY ref ORDER BY mc DESC",
+ "GROUP BY ref ORDER BY MAX(created) DESC",
-1, &stmt, NULL) != SQLITE_OK) {
lwsl_notice("%s: branchlist prepare failed\n",
__func__);
goto soft_error;
}
- sqlite3_bind_int(stmt, 1, SAIES_DELETED);
- /*
- * Belt-and-braces: also purify (the bound param already
- * prevents injection, but this keeps the echoed project
- * field safe to emit too).
- */
- lws_sql_purify(pesc, bl->project, sizeof(pesc) - 1);
- sqlite3_bind_text(stmt, 2, pesc, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 1, pesc, -1, SQLITE_STATIC);
+ sqlite3_bind_int(stmt, 2, SAIES_DELETED);
+ sqlite3_bind_int(stmt, 3, SAIES_DELETED);
+ sqlite3_bind_text(stmt, 4, pesc, -1, SQLITE_STATIC);
- p += lws_snprintf((char *)p, lws_ptr_diff_size_t(end, p),
- "{\"schema\":\"com.warmcat.sai.branchlist\","
- "\"project\":\"%s\",\"branches\":[",
- lws_json_purify(esc, pesc,
- sizeof(esc) - 1, NULL));
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
+ struct bl_row *row;
const char *rn = (const char *)
sqlite3_column_text(stmt, 0);
+ size_t rn_len;
if (!rn)
continue;
+ rn_len = strlen(rn);
+ row = lwsac_use_zero(&ac, sizeof(*row), 0);
+ if (!row) {
+ sqlite3_finalize(stmt);
+ lwsac_free(&ac);
+ goto soft_error;
+ }
+ row->ref = lwsac_use(&ac, rn_len + 1, 0);
+ if (!row->ref) {
+ sqlite3_finalize(stmt);
+ lwsac_free(&ac);
+ goto soft_error;
+ }
+ memcpy(row->ref, rn, rn_len + 1);
+ row->state = sqlite3_column_int(stmt, 1);
+ lws_dll2_add_tail(&row->list, &owner);
+ }
+ sqlite3_finalize(stmt);
+
+ p += lws_snprintf((char *)p, lws_ptr_diff_size_t(end, p),
+ "{\"schema\":\"com.warmcat.sai.branchlist\","
+ "\"project\":\"%s\",\"branches\":[",
+ lws_json_purify(esc, pesc,
+ sizeof(esc) - 1, NULL));
+ first_elem = 1;
+ lws_start_foreach_dll_safe(struct lws_dll2 *, d, d1,
+ owner.head) {
+ struct bl_row *row = lws_container_of(d,
+ struct bl_row, list);
if (lws_ptr_diff_size_t(end, p) < 96) {
saiw_ws_browser_queue_REQUIRES_LWS_PRE(pss,
start, lws_ptr_diff_size_t(p, start),
@@ -897,13 +951,44 @@ saiw_ws_json_rx_browser(struct vhd *vhd, struct pss *pss, uint8_t *buf,
p += lws_snprintf((char *)p,
lws_ptr_diff_size_t(end, p), "%s\"%s\"",
first_elem ? "" : ",",
- lws_json_purify(esc, rn, sizeof(esc) - 1, NULL));
+ lws_json_purify(esc, row->ref,
+ sizeof(esc) - 1, NULL));
first_elem = 0;
- }
- sqlite3_finalize(stmt);
+ } lws_end_foreach_dll_safe(d, d1);
+ /*
+ * Parallel ref -> latest-state map. Kept as a separate object
+ * so the existing "branches" string array stays unchanged for
+ * older clients that ignore the extra field.
+ */
p += lws_snprintf((char *)p, lws_ptr_diff_size_t(end, p),
- "]}");
+ "],\"branch_states\":{");
+ first_elem = 1;
+ lws_start_foreach_dll_safe(struct lws_dll2 *, d, d1,
+ owner.head) {
+ struct bl_row *row = lws_container_of(d,
+ struct bl_row, list);
+ if (lws_ptr_diff_size_t(end, p) < 96) {
+ saiw_ws_browser_queue_REQUIRES_LWS_PRE(pss,
+ start, lws_ptr_diff_size_t(p, start),
+ lws_write_ws_flags(LWS_WRITE_TEXT,
+ !sent_any, 0));
+ sent_any = 1;
+ p = start;
+ }
+ p += lws_snprintf((char *)p,
+ lws_ptr_diff_size_t(end, p), "%s\"%s\":%d",
+ first_elem ? "" : ",",
+ lws_json_purify(esc, row->ref,
+ sizeof(esc) - 1, NULL),
+ row->state);
+ first_elem = 0;
+ } lws_end_foreach_dll_safe(d, d1);
+
+ p += lws_snprintf((char *)p, lws_ptr_diff_size_t(end, p),
+ "}}");
+ lwsac_free(&ac);
+
saiw_ws_browser_queue_REQUIRES_LWS_PRE(pss, start,
lws_ptr_diff_size_t(p, start),
LWS_WRITE_TEXT);