diff --git a/assets/index.html b/assets/index.html
index a907111..490a194 100644
--- a/assets/index.html
+++ b/assets/index.html
@@ -15,15 +15,19 @@
<div class="overlay"></div>
<div class="main-container">
<div class="left-pane">
- <div class="logo-login-container">
- <div class="logo">
- <img class="logo" src="sai.svg"><br>
- <span class="logo"><a href="https://warmcat.com/git/sai">Sai git</a></span>
- <span class="logo" id="apirev"></span>
+ <div class="sidebar-pane" id="sai_sidebar">
+ <div class="sb-col sb-col-logo" id="sai_sb_logo">
+ <div class="logo">
+ <img class="logo" src="sai.svg"><br>
+ <span class="logo"><a href="https://warmcat.com/git/sai">Sai git</a></span>
+ <span class="logo" id="apirev"></span>
+ </div>
+ <div id="lws-login-status-container" class="login-status-container"></div>
</div>
- <div id="lws-login-status-container" class="login-status-container"></div>
+ <div class="sb-col sb-col-projects" id="sai_sb_projects"></div>
+ <div class="sb-col sb-col-branches" id="sai_sb_branches"></div>
+ <div class="sb-col sb-col-events" id="sai_sb_events"></div>
</div>
- <div class="event-decals-section" id="sai_event_decals"></div>
<div class="tasks-section" id="sai_event_tasks"></div>
<div class="resizer-h" id="resizer_h"></div>
<div class="logs-section" id="sai_task_logs_container">
diff --git a/assets/sai.css b/assets/sai.css
index d1be255..e0ba5c6 100644
--- a/assets/sai.css
+++ b/assets/sai.css
@@ -1198,15 +1198,7 @@ body.overlay-active {
flex-shrink: 0;
}
-.logo-login-container {
- display: flex;
- align-items: center;
- margin-bottom: 12px;
- flex-wrap: nowrap;
-}
-
.login-status-container {
- margin-left: 10px;
margin-top: 4px;
}
@@ -1774,88 +1766,170 @@ div.ibuil.vm-builder {
overflow: hidden;
}
-.event-decals-section {
+.sidebar-pane {
flex: 0 0 auto;
- height: 110px;
+ height: 150px;
box-sizing: border-box;
border-bottom: 1px solid rgba(0, 0, 0, 0.15);
background: #f8f9fa;
display: flex;
flex-direction: row;
- overflow-x: auto;
- align-items: center;
- padding: 8px 12px;
- gap: 12px;
+ align-items: stretch;
+ overflow: hidden;
scrollbar-width: thin;
}
-/* Event Decal Card styles */
-.event-decal-card {
- display: inline-block;
- flex: 0 0 auto;
- background: #f1f5f9;
- border: 1px solid #cbd5e1;
- border-radius: 6px;
- padding: 5px 10px 8px 10px;
+.sidebar-pane .sb-col {
+ box-sizing: border-box;
+ min-width: 0;
+ border-right: 1px solid rgba(0, 0, 0, 0.10);
+ padding: 4px 6px;
+ overflow-y: auto;
+ scrollbar-width: thin;
+}
+
+.sidebar-pane .sb-col:last-child {
+ border-right: none;
+}
+
+/* col 1: logo + login, centred */
+.sidebar-pane .sb-col-logo {
+ flex: 0 0 160px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ background: #ffffff;
+}
+
+.sidebar-pane .sb-col-logo .logo {
+ display: block;
+ margin: 0;
+ padding: 0;
+}
+
+.sidebar-pane .sb-col-logo img.logo {
+ width: 120px;
+ height: auto;
+}
+
+/* col 2 + col 3: selectable project / branch lists */
+.sidebar-pane .sb-col-projects,
+.sidebar-pane .sb-col-branches {
+ flex: 0 0 130px;
+}
+
+/* col 4: events (most-recent-first), takes the remaining space */
+.sidebar-pane .sb-col-events {
+ flex: 1 1 50%;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+/* selectable row in col 2 / col 3 */
+.sb-row {
+ display: block;
+ padding: 2px 6px;
+ margin: 1px 0;
+ border-radius: 3px;
cursor: pointer;
- transition: all 0.2s ease-in-out;
user-select: none;
- max-height: 98px;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
- box-sizing: border-box;
+ -webkit-user-select: none;
+ font-size: 9pt;
+ color: #1e293b;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
-.event-decal-card span.e1 {
- line-height: 1.0;
- margin-bottom: 1px;
- display: inline-block;
+.sb-row:hover {
+ background: #e2e8f0;
}
-.event-decal-card span.e2 {
- line-height: 1.0;
- display: inline-block;
+.sb-row.selected {
+ background: #334155;
+ color: #ffffff;
}
-.event-decal-card table.nomar td {
- padding-top: 1px !important;
- padding-bottom: 1px !important;
- line-height: 1.0;
+/* event rows in col 4 */
+.sb-event-row {
+ display: flex;
+ flex-direction: column;
+ padding: 3px 8px;
+ margin: 1px 0;
+ border-radius: 4px;
+ cursor: pointer;
+ user-select: none;
+ -webkit-user-select: none;
+ background: #f1f5f9;
+ border: 1px solid #cbd5e1;
+ font-size: 9pt;
+ color: #1e293b;
}
-.event-decal-card:hover {
+.sb-event-row:hover {
border-color: #94a3b8;
- transform: translateY(-1px);
- box-shadow: 0 3px 6px rgba(0, 0, 0, 0.08);
}
-.event-decal-card.comp_pass {
+.sb-event-row.comp_pass {
background: #01ff70;
+ border-color: #2ecc40;
}
-.event-decal-card.comp_fail {
+.sb-event-row.comp_fail {
background: #ff4136;
color: #ffffff;
+ border-color: #df3030;
}
-.event-decal-card.comp_fail span,
-.event-decal-card.comp_fail a {
- color: #ffffff !important;
+.sb-event-row.selected {
+ border: 2px solid #000000;
}
-.event-decal-card.selected {
- border: 2px solid #000000;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+.sb-event-row .sb-event-top {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ flex-wrap: wrap;
}
-.event-decal-card table.comp {
- background: transparent !important;
- border: none !important;
- margin: 0 !important;
- padding: 0 !important;
+.sb-event-row .sb-event-when {
+ font-family: 'OpenSans', sans-serif;
+ font-size: 8pt;
+ color: #64748b;
+ white-space: nowrap;
+}
+
+.sb-event-row.comp_fail .sb-event-when {
+ color: #fde2e0;
}
-.event-decal-card table.comp td {
- padding: 0 4px !important;
+.sb-event-row .sb-event-tag {
+ font-family: monospace;
+ font-size: 9pt;
+ white-space: nowrap;
+}
+
+.sb-event-row .sb-event-status {
+ font-size: 8.5pt;
+ white-space: nowrap;
+}
+
+.sb-event-row .sb-event-bar {
+ margin-top: 2px;
+}
+
+.sb-event-row .progress-bar {
+ width: 100%;
+}
+
+.sb-empty {
+ color: #64748b;
+ font-size: 9pt;
+ font-style: italic;
+ padding: 4px 6px;
}
.tasks-section {
@@ -1879,12 +1953,22 @@ div.ibuil.vm-builder {
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid #f1f5f9;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
}
.event-tasks-title {
font-weight: bold;
font-size: 11pt;
color: #1e293b;
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.event-tasks-header img.rebuild {
+ display: inline-block;
}
.tasks-table-display {
diff --git a/assets/sai.js b/assets/sai.js
index 0c81bb4..b7e912e 100644
--- a/assets/sai.js
+++ b/assets/sai.js
@@ -432,9 +432,7 @@ var current_overview_offset = 0;
window.change_page = function(new_offset) {
current_overview_offset = new_offset;
- sai.send("{\"schema\":" +
- "\"com.warmcat.sai.taskinfo\", \"js_api_version\": " + SAI_JS_API_VERSION +
- ", \"offset\": " + current_overview_offset + "}");
+ sai_sb_request_overview(current_overview_offset);
};
function createPconDiv(pcon) {
@@ -953,6 +951,39 @@ var pos = 0, lli = 1, lines = "", times = "", locked = 1, tfirst = 0,
var deleted_events_cache = new Set();
var loaded_events = [], selected_event_uuid = null, selected_task_uuid = null, total_events = 0, current_offset = 0;
+/*
+ * Sidebar (merged top pane) state. The user picks a project (col 2), then a
+ * branch (col 3); col 4 lists that project+branch's events newest-first.
+ * sb_projects / sb_branches are the unique values advertised by the server.
+ */
+var sb_projects = [], sb_branches = [];
+var sb_selected_project = null, sb_selected_ref = null;
+
+/*
+ * Send an overview (taskinfo) request scoped to the current sidebar
+ * selection. An empty project/ref means "no constraint".
+ */
+function sai_sb_request_overview(offset)
+{
+ var o = (typeof offset === 'number') ? offset : 0;
+ sai.send("{\"schema\":\"com.warmcat.sai.taskinfo\"," +
+ "\"js_api_version\": " + SAI_JS_API_VERSION + "," +
+ "\"offset\": " + o + "," +
+ "\"project\":" + JSON.stringify(sb_selected_project || "") + "," +
+ "\"ref\":" + JSON.stringify(sb_selected_ref || "") + "}");
+}
+
+function sai_sb_request_projects()
+{
+ sai.send("{\"schema\":\"com.warmcat.sai.projlist\"}");
+}
+
+function sai_sb_request_branches(project)
+{
+ sai.send("{\"schema\":\"com.warmcat.sai.branchlist\"," +
+ "\"project\":" + JSON.stringify(project || "") + "}");
+}
+
function sai_event_hash_display(hash) {
if (!hash) return "";
return "sai-" + hash.substring(0, 8);
@@ -1141,7 +1172,13 @@ function sai_taskinfo_render(t, now_ut)
function update_summary_and_progress(event_uuid) {
var sumbs = document.getElementById("sumbs-" + event_uuid);
- if (!sumbs)
+ /*
+ * The sidebar event row (col 4) keeps the status text in its own
+ * #sbsum-<uuid> span; the progress bar slot is the legacy #sumbs-<uuid>.
+ * Either or both may be present depending on what's rendered.
+ */
+ var sbs = document.getElementById("sbsum-" + event_uuid);
+ if (!sumbs && !sbs)
return;
var summary = summarize_build_situation(event_uuid);
@@ -1190,7 +1227,14 @@ function update_summary_and_progress(event_uuid) {
"<div class=\"progress-bar-failed float-right " + bad_cls + "\"></div>" +
"</div>";
}
- sumbs.innerHTML = summary_html;
+ if (sumbs)
+ sumbs.innerHTML = summary_html;
+ /*
+ * The sidebar status span shows just the text portion (the bar is in the
+ * separate #sumbs- slot below it).
+ */
+ if (sbs)
+ sbs.innerHTML = san(summary.text || "");
}
function summarize_build_situation(event_uuid)
@@ -1349,12 +1393,11 @@ function sai_event_summary_render(o, now_ut, reset_all_icon)
s += "<div class=\"" + cl + "\"><img src=\"/sai/failed.svg\"></div>";
s += "</a>";
- if (reset_all_icon && !gitohashi_integ && auth_state === SaiAuthState.LOGGED_IN_GRANT_ADMIN) {
- s += "<br><img class=\"rebuild\" alt=\"rebuild all\" src=\"/sai/rebuild.png\" " +
- "id=\"rebuild-ev-" + san(e.uuid) + "\"> ";
- s += "<img class=\"rebuild\" alt=\"delete event\" src=\"/sai/delete.png\" " +
- "id=\"delete-ev-" + san(e.uuid) + "\">";
- }
+ /*
+ * The per-event restart-all / delete-event buttons have moved to the
+ * tasks-section header (render_selected_event_tasks) and are no longer
+ * emitted inside the decal summary.
+ */
s += "</td>";
if (!gitohashi_integ) {
@@ -1417,59 +1460,190 @@ function find_event_by_task_uuid(taskUuid) {
return null;
}
-function render_event_decals() {
- var now_ut = Math.round((new Date().getTime() / 1000));
+/*
+ * ---------------------------------------------------------------------------
+ * Sidebar (merged top pane) rendering: projects (col 2), branches (col 3),
+ * and the per-project+branch event list (col 4). All build HTML via string
+ * concatenation + innerHTML and attach handlers with addEventListener (no
+ * inline styles or handlers, per the strict CSP). Dynamic text is escaped
+ * with san().
+ * ---------------------------------------------------------------------------
+ */
+
+function sai_sb_fmt_when(secs)
+{
+ if (!secs)
+ return "";
+ var d = new Date(secs * 1000);
+ /* compact "YYYY-MM-DD HH:MM" in UTC; the relative age is shown via agify */
+ function p(n, l) { var s = "" + n; while (s.length < l) s = "0" + s; return s; }
+ return p(d.getUTCFullYear(), 4) + "-" + p(d.getUTCMonth() + 1, 2) + "-" +
+ p(d.getUTCDate(), 2) + " " + p(d.getUTCHours(), 2) + ":" +
+ p(d.getUTCMinutes(), 2);
+}
+
+function sai_sb_short_ref(ref)
+{
+ if (!ref)
+ return "";
+ if (ref.substr(0, 11) === "refs/heads/")
+ return ref.substr(11);
+ if (ref.substr(0, 10) === "refs/tags/")
+ return ref.substr(10);
+ return ref;
+}
+
+function render_sb_projects()
+{
+ var c = document.getElementById("sai_sb_projects");
+ if (!c)
+ return;
var s = "";
-
- // Add "Newer" pagination button if applicable
- if (!gitohashi_integ && total_events > 6 && current_offset > 0) {
- s += "<div class=\"btn sai-pagination-btn pagination-card\" data-offset=\"" + Math.max(0, current_offset - 6) + "\">< Newer</div>";
+ if (!sb_projects || !sb_projects.length) {
+ s = "<div class=\"sb-empty\">No projects</div>";
+ } else {
+ sb_projects.forEach(function(name) {
+ var sel = (name === sb_selected_project) ? " selected" : "";
+ s += "<div class=\"sb-row" + sel + "\" data-project=\"" +
+ san(name) + "\">" + san(name) + "</div>";
+ });
}
+ c.innerHTML = s;
+ c.querySelectorAll(".sb-row").forEach(function(row) {
+ row.addEventListener("click", function() {
+ selectSbProject(row.getAttribute("data-project"));
+ });
+ });
+}
- if (loaded_events && loaded_events.length) {
- for (var n = loaded_events.length - 1; n >= 0; n--) {
- var o = loaded_events[n];
- var isSelected = (o.e.uuid === selected_event_uuid);
- var stateClass = "";
- if (o.e && o.e.state == 3) stateClass = "comp_pass";
- if (o.e && (o.e.state == 4 || o.e.state == 6)) stateClass = "comp_fail";
- s += "<div class=\"event-decal-card " + stateClass + (isSelected ? " selected" : "") + "\" data-uuid=\"" + san(o.e.uuid) + "\">";
- s += sai_event_summary_render(o, now_ut, 1);
- s += "</div>";
- }
+function render_sb_branches()
+{
+ var c = document.getElementById("sai_sb_branches");
+ if (!c)
+ return;
+ var s = "";
+ if (!sb_branches || !sb_branches.length) {
+ s = "<div class=\"sb-empty\">No branches</div>";
} else {
- s += "<div class=\"no-events\">No events found</div>";
+ 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>";
+ });
}
+ c.innerHTML = s;
+ c.querySelectorAll(".sb-row").forEach(function(row) {
+ row.addEventListener("click", function() {
+ selectSbBranch(row.getAttribute("data-ref"));
+ });
+ });
+}
- // Add "Older" pagination button if applicable
- if (!gitohashi_integ && total_events > 6 && current_offset + 6 < total_events) {
- s += "<div class=\"btn sai-pagination-btn pagination-card\" data-offset=\"" + (current_offset + 6) + "\">Older ></div>";
+/*
+ * The newest-first event list (col 4) for the selected project + branch.
+ * Filtered client-side from loaded_events, so a freshly-pushed matching
+ * event appears at the top automatically on the next render.
+ */
+function render_sb_events()
+{
+ var c = document.getElementById("sai_sb_events");
+ if (!c)
+ return;
+ var now_ut = Math.round((new Date().getTime() / 1000));
+
+ var matching = [];
+ if (loaded_events && loaded_events.length) {
+ loaded_events.forEach(function(o) {
+ if (!o || !o.e)
+ return;
+ if (sb_selected_project && o.e.repo_name !== sb_selected_project)
+ return;
+ if (sb_selected_ref && o.e.ref !== sb_selected_ref)
+ return;
+ matching.push(o);
+ });
}
- var container = document.getElementById("sai_event_decals");
- if (container) {
- container.innerHTML = s;
-
- container.querySelectorAll(".event-decal-card").forEach(function(card) {
- card.addEventListener("click", function() {
- var uuid = card.getAttribute("data-uuid");
- selectEvent(uuid);
- });
+ /* newest-first by creation time */
+ matching.sort(function(a, b) {
+ var ca = a.e.created || 0, cb = b.e.created || 0;
+ return cb - ca;
+ });
+
+ var s = "";
+ if (!matching.length) {
+ s = "<div class=\"sb-empty\">No events</div>";
+ } else {
+ matching.forEach(function(o) {
+ var e = o.e;
+ var stateClass = "";
+ if (e.state == 3) stateClass = " comp_pass";
+ if (e.state == 4 || e.state == 6) stateClass = " comp_fail";
+ var sel = (e.uuid === selected_event_uuid) ? " selected" : "";
+ s += "<div class=\"sb-event-row" + stateClass + sel +
+ "\" data-uuid=\"" + san(e.uuid) + "\">";
+ s += "<div class=\"sb-event-top\">";
+ s += "<span class=\"sb-event-when\">" + san(sai_sb_fmt_when(e.created)) +
+ " <span class='age-0' ut='" + e.created + "'>" +
+ agify(now_ut, e.created) + "</span></span>";
+ s += "<span class=\"sb-event-tag\">" + san(sai_event_hash_display(e.hash)) + "</span>";
+ s += "<span class=\"sb-event-status\" id=\"sbsum-" + san(e.uuid) + "\"></span>";
+ s += "</div>";
+ /* sumbs-<uuid> is what update_summary_and_progress() fills */
+ s += "<div class=\"sb-event-bar\" id=\"sumbs-" + san(e.uuid) + "\"></div>";
+ s += "</div>";
+ });
+ }
+ c.innerHTML = s;
+ c.querySelectorAll(".sb-event-row").forEach(function(row) {
+ row.addEventListener("click", function() {
+ selectEvent(row.getAttribute("data-uuid"));
});
+ });
+
+ /* Fill status text + progress bar for each visible event */
+ matching.forEach(function(o) {
+ update_summary_and_progress(o.e.uuid);
+ });
+}
- // Scroll the selected card into view
- var selectedCard = container.querySelector(".event-decal-card.selected");
- if (selectedCard) {
- selectedCard.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
+function selectSbProject(name)
+{
+ if (sb_selected_project === name)
+ return;
+ sb_selected_project = name;
+ sb_selected_ref = null;
+ render_sb_projects();
+ /* refresh the branch list for this project; auto-selects newest branch */
+ sai_sb_request_branches(name);
+}
+
+function selectSbBranch(ref)
+{
+ if (sb_selected_ref === ref)
+ return;
+ sb_selected_ref = ref;
+ render_sb_branches();
+ /* re-scope the event list + tasks pane to the new selection */
+ sai_sb_request_overview(0);
+ /* Clear any prior task selection that no longer applies */
+ if (selected_task_uuid) {
+ var ev = loaded_events.find(function(o) { return o.e.uuid === selected_event_uuid; });
+ var has = ev && ev.t && ev.t.some(function(t) { return t.uuid === selected_task_uuid; });
+ if (!has) {
+ selected_task_uuid = null;
+ window.current_task_run = null;
}
}
+ render_sb_events();
+}
- // Refresh summaries/progress bars
- if (loaded_events) {
- loaded_events.forEach(o => {
- update_summary_and_progress(o.e.uuid);
- });
- }
+function render_event_decals() {
+ /*
+ * The decal strip has been replaced by the merged 4-column sidebar
+ * pane. Existing call sites still invoke this; just refresh col 4.
+ */
+ render_sb_events();
}
function render_selected_event_tasks(o) {
@@ -1480,6 +1654,13 @@ function render_selected_event_tasks(o) {
s += "<div class=\"event-tasks-header\">";
var refName = e.ref.replace("refs/heads/", "").replace("refs/tags/", "");
s += "<span class=\"event-tasks-title\">" + san(e.repo_name) + " (" + san(refName) + ") - " + san(sai_event_hash_display(e.hash)) + "</span>";
+ /* admin-only restart-all / delete-event controls live here now */
+ if (!gitohashi_integ && auth_state === SaiAuthState.LOGGED_IN_GRANT_ADMIN) {
+ s += "<img class=\"rebuild\" alt=\"rebuild all\" src=\"/sai/rebuild.png\" " +
+ "id=\"rebuild-ev-" + san(e.uuid) + "\">";
+ s += "<img class=\"rebuild\" alt=\"delete event\" src=\"/sai/delete.png\" " +
+ "id=\"delete-ev-" + san(e.uuid) + "\">";
+ }
s += "</div>";
s += "<table class=\"tasks-table-display\"><tr><td class=\"tasks\" id=\"taskcont-" + san(e.uuid) + "\">";
@@ -1602,14 +1783,15 @@ function selectEvent(uuid) {
}
window.history.pushState({}, "", path + (qs ? ("?" + qs) : ""));
- // Highlight card
- var container = document.getElementById("sai_event_decals");
- if (container) {
- container.querySelectorAll(".event-decal-card").forEach(function(card) {
- if (card.getAttribute("data-uuid") === uuid) {
- card.classList.add("selected");
+ // Highlight the selected event in the sidebar (col 4)
+ var sbContainer = document.getElementById("sai_sb_events");
+ if (sbContainer) {
+ sbContainer.querySelectorAll(".sb-event-row").forEach(function(row) {
+ if (row.getAttribute("data-uuid") === uuid) {
+ row.classList.add("selected");
+ row.scrollIntoView({ behavior: "smooth", block: "nearest" });
} else {
- card.classList.remove("selected");
+ row.classList.remove("selected");
}
});
}
@@ -2550,14 +2732,17 @@ function ws_open_sai()
req += "\"run\":" + run_idx + ",";
else
req += "\"run\": -1,";
- req += "\"task_hash\":" + JSON.stringify(tid) + "}";
- sai.send(req);
-
- // Also request the overview
- sai.send("{\"schema\":" +
- "\"com.warmcat.sai.taskinfo\", \"js_api_version\": " + SAI_JS_API_VERSION +
- ", \"offset\": " + current_overview_offset + "}");
- return;
+ req += "\"task_hash\":" + JSON.stringify(tid) + "}";
+ sai.send(req);
+
+ // Also request the overview (unscoped, so the
+ // deep-linked task's event is included)
+ sai.send("{\"schema\":" +
+ "\"com.warmcat.sai.taskinfo\", \"js_api_version\": " + SAI_JS_API_VERSION +
+ ", \"offset\": " + current_overview_offset + "}");
+ // Populate the sidebar too
+ sai_sb_request_projects();
+ return;
}
if (eid) {
@@ -2570,20 +2755,24 @@ function ws_open_sai()
"\"event_hash\":" +
JSON.stringify(eid) + "}");
- // Also request the overview
+ // Also request the overview (unscoped, so the
+ // deep-linked event is included)
sai.send("{\"schema\":" +
"\"com.warmcat.sai.taskinfo\", \"js_api_version\": " + SAI_JS_API_VERSION +
", \"offset\": " + current_overview_offset + "}");
+ // Populate the sidebar too
+ sai_sb_request_projects();
return;
}
/*
- * request the overview schema
+ * No deep link: start the sidebar cascade by requesting
+ * the project list. Its handler auto-selects the first
+ * project -> branchlist -> auto-select newest branch ->
+ * scoped overview.
*/
- sai.send("{\"schema\":" +
- "\"com.warmcat.sai.taskinfo\", \"js_api_version\": " + SAI_JS_API_VERSION +
- ", \"offset\": " + current_overview_offset + "}");
+ sai_sb_request_projects();
};
sai.onmessage = function got_packet(msg) {
@@ -2838,13 +3027,44 @@ function ws_open_sai()
term.write(jso.channel, text);
}
}
- break;
+ break;
+
+ case "com.warmcat.sai.projlist":
+ /*
+ * Unique project names from the events db.
+ * On first receipt with no prior selection,
+ * auto-select the first project to drive the
+ * rest of the cascade. Skip the auto-select
+ * for deep links (?event=/?task=) so the
+ * deep-linked event isn't displaced.
+ */
+ sb_projects = (jso.projects && Array.isArray(jso.projects)) ? jso.projects : [];
+ if (!sb_selected_project && !selected_event_uuid &&
+ !selected_task_uuid && sb_projects.length)
+ selectSbProject(sb_projects[0]);
+ else
+ render_sb_projects();
+ break;
+
+ case "com.warmcat.sai.branchlist":
+ /*
+ * Unique refs for the selected project,
+ * newest-first. Auto-select the most recent
+ * branch so col 4 populates immediately.
+ */
+ sb_branches = (jso.branches && Array.isArray(jso.branches)) ? jso.branches : [];
+ if (!sb_selected_ref && sb_branches.length)
+ selectSbBranch(sb_branches[0]);
+ else
+ render_sb_branches();
+ break;
- case "sai.warmcat.com.overview":
+ case "sai.warmcat.com.overview":
/*
* Sent with an array of e[] to start, but also
* can send a single e[] if it just changed
- * state
+ * state. When scoped to the sidebar selection,
+ * the server only returns matching events.
*/
if (jso.overview) {
jso.overview = jso.overview.filter(o => !deleted_events_cache.has(o.e.uuid));
@@ -2853,7 +3073,26 @@ function ws_open_sai()
}
}
- var old_latest_uuid = loaded_events.length ? loaded_events[loaded_events.length - 1].e.uuid : null;
+ /*
+ * Track the newest matching event before merge, so
+ * we can detect a brand-new event arriving in the
+ * current selection.
+ */
+ var sb_newest_before = null;
+ if (loaded_events && loaded_events.length) {
+ var _tmp = loaded_events.filter(function(o) {
+ if (!o || !o.e) return false;
+ if (sb_selected_project && o.e.repo_name !== sb_selected_project) return false;
+ if (sb_selected_ref && o.e.ref !== sb_selected_ref) return false;
+ return true;
+ });
+ if (_tmp.length) {
+ _tmp.sort(function(a, b) {
+ return (b.e.created || 0) - (a.e.created || 0);
+ });
+ sb_newest_before = _tmp[0].e.uuid;
+ }
+ }
if (jso.overview) {
jso.overview.forEach(function(new_ev) {
@@ -2884,10 +3123,36 @@ function ws_open_sai()
}
render_event_decals();
- var new_latest_uuid = loaded_events.length ? loaded_events[loaded_events.length - 1].e.uuid : null;
- if (old_latest_uuid && new_latest_uuid !== old_latest_uuid) {
- /* A new event arrived! Select it and clear logs. */
- selectEvent(new_latest_uuid);
+ /*
+ * If a brand-new event for the current selection
+ * arrived, auto-select it (matches the "live"
+ * requirement: new matching event appears at the
+ * top of col 4 and is selected).
+ */
+ var sb_newest_after = null;
+ if (loaded_events && loaded_events.length) {
+ var _tmp2 = loaded_events.filter(function(o) {
+ if (!o || !o.e) return false;
+ if (sb_selected_project && o.e.repo_name !== sb_selected_project) return false;
+ if (sb_selected_ref && o.e.ref !== sb_selected_ref) return false;
+ return true;
+ });
+ if (_tmp2.length) {
+ _tmp2.sort(function(a, b) {
+ return (b.e.created || 0) - (a.e.created || 0);
+ });
+ sb_newest_after = _tmp2[0].e.uuid;
+ }
+ }
+ if (sb_newest_after && sb_newest_after !== sb_newest_before &&
+ (sb_selected_project || sb_selected_ref)) {
+ /*
+ * Only auto-jump to a brand-new event when
+ * we're in the sidebar-driven flow (a
+ * selection exists); deep links keep their
+ * explicitly-selected event.
+ */
+ selectEvent(sb_newest_after);
} else {
if (selected_event_uuid) {
var ev_obj = loaded_events.find(o => o.e.uuid === selected_event_uuid);
@@ -3206,6 +3471,17 @@ function ws_open_sai()
statusContainer.classList.add('grant-none');
}
}
+ /*
+ * The admin restart-all / delete-event buttons now
+ * live in the tasks-section header, which is gated
+ * on auth_state; re-render it so they appear /
+ * disappear on login state changes.
+ */
+ if (selected_event_uuid) {
+ var _ev = loaded_events.find(o => o.e.uuid === selected_event_uuid);
+ if (_ev)
+ render_selected_event_tasks(_ev);
+ }
break;
case "com.warmcat.sai.event_deleted":
@@ -3573,8 +3849,8 @@ window.addEventListener("load", function() {
console.log(rs);
sai.send(rs);
- // Remove / hide the decal card
- var card = document.querySelector(".event-decal-card[data-uuid='" + uuid + "']");
+ // Remove / hide the event row in the sidebar
+ var card = document.querySelector(".sb-event-row[data-uuid='" + uuid + "']");
if (card) {
card.style.transition = 'opacity 0.3s';
card.style.opacity = '0';
diff --git a/src/common/include/private.h b/src/common/include/private.h
index a74e9db..4edefc3 100644
--- a/src/common/include/private.h
+++ b/src/common/include/private.h
@@ -691,8 +691,23 @@ typedef struct sai_browse_rx_taskinfo {
unsigned int offset;
uint8_t logs;
int run;
+ /*
+ * When sent with an empty task_hash (overview request), the browser
+ * also passes the project + ref it currently has selected, so the
+ * server can return the overview scoped to that project + branch.
+ */
+ char project[65];
+ char ref[65];
} sai_browse_rx_taskinfo_t;
+/*
+ * Browser -> sai-web requests for the unique project list and the unique
+ * branch list for a given project. Both are read-only and need no auth.
+ */
+typedef struct sai_browse_rx_branchlist {
+ char project[65];
+} sai_browse_rx_branchlist_t;
+
/* sai-power -> sai-server, tells it that a platform is being powered up */
typedef struct sai_power_state {
lws_dll2_t list; /* for parser */
diff --git a/src/web/w-private.h b/src/web/w-private.h
index b9950bc..1aff632 100644
--- a/src/web/w-private.h
+++ b/src/web/w-private.h
@@ -123,6 +123,17 @@ struct pss {
char specific_project[96];
char selected_event_uuid[33];
+ /*
+ * Runtime project + branch selection coming from the browser's
+ * sidebar (com.warmcat.sai.taskinfo overview request). Unlike the
+ * specific_* fields above (which are pinned from the connect URL for
+ * the gitohashi /git/<project> mode), these are updated by the browser
+ * at any time and scope the overview / live pushes to its current
+ * selection.
+ */
+ char selected_project[65];
+ char selected_ref[65];
+
sqlite3 *pdb_artifact;
sqlite3_blob *blob_artifact;
diff --git a/src/web/w-ws-browser.c b/src/web/w-ws-browser.c
index 6240ab2..e8d1ff3 100644
--- a/src/web/w-ws-browser.c
+++ b/src/web/w-ws-browser.c
@@ -46,6 +46,10 @@ static lws_struct_map_t lsm_browser_evinfo[] = {
LSM_CARRAY (sai_browse_rx_evinfo_t, event_hash, "event_hash"),
};
+static lws_struct_map_t lsm_browser_branchlist[] = {
+ LSM_CARRAY (sai_browse_rx_branchlist_t, project, "project"),
+};
+
static lws_struct_map_t lsm_browser_taskreset[] = {
LSM_CARRAY (sai_browse_rx_evinfo_t, event_hash, "uuid"),
};
@@ -74,6 +78,9 @@ static lws_struct_map_t lsm_browser_taskinfo[] = {
LSM_UNSIGNED (sai_browse_rx_taskinfo_t, offset, "offset"),
LSM_UNSIGNED (sai_browse_rx_taskinfo_t, last_log_ts, "last_log_ts"),
LSM_SIGNED (sai_browse_rx_taskinfo_t, run, "run"),
+ /* Sidebar selection scoping the overview to project + branch */
+ LSM_CARRAY (sai_browse_rx_taskinfo_t, project, "project"),
+ LSM_CARRAY (sai_browse_rx_taskinfo_t, ref, "ref"),
};
/*
@@ -120,6 +127,11 @@ static const lws_struct_map_t lsm_schema_json_map_bwsrx[] = {
"com.warmcat.sai.ptydata"),
LSM_SCHEMA (sai_browse_rx_builder_visibility_t, NULL, lsm_browser_builder_visibility,
"com.warmcat.sai.builder_visibility"),
+ /* sidebar project / branch list requests (read-only, no auth) */
+ LSM_SCHEMA (sai_browse_rx_branchlist_t, NULL, lsm_browser_branchlist,
+ "com.warmcat.sai.projlist"),
+ LSM_SCHEMA (sai_browse_rx_branchlist_t, NULL, lsm_browser_branchlist,
+ "com.warmcat.sai.branchlist"),
};
enum {
@@ -142,6 +154,8 @@ enum {
SAIM_WS_BROWSER_RX_CLOSESHELL,
SAIM_WS_BROWSER_RX_PTYDATA,
SAIM_WS_BROWSER_RX_BUILDER_VISIBILITY,
+ SAIM_WS_BROWSER_RX_PROJLIST,
+ SAIM_WS_BROWSER_RX_BRANCHLIST,
};
@@ -743,6 +757,16 @@ saiw_ws_json_rx_browser(struct vhd *vhd, struct pss *pss, uint8_t *buf,
pss->js_api_version = ti->js_api_version;
pss->overview_offset = ti->offset;
+ /*
+ * Update the runtime sidebar selection so subsequent
+ * overview / live pushes are scoped to this project +
+ * branch. An empty value means "no constraint".
+ */
+ lws_strncpy(pss->selected_project, ti->project,
+ sizeof(pss->selected_project));
+ lws_strncpy(pss->selected_ref, ti->ref,
+ sizeof(pss->selected_ref));
+
saiw_browser_broadcast_queue_builders(pss->vhd, pss);
{
@@ -792,6 +816,129 @@ saiw_ws_json_rx_browser(struct vhd *vhd, struct pss *pss, uint8_t *buf,
goto ok;
+ case SAIM_WS_BROWSER_RX_PROJLIST:
+ {
+ /*
+ * Return the set of unique project (repo_name) values seen
+ * in the events db, ordered by name. Read-only.
+ */
+ sqlite3_stmt *stmt = NULL;
+ uint8_t buf[LWS_PRE + 4096], *start = buf + LWS_PRE,
+ *p = start, *end = buf + sizeof(buf);
+ char esc[96];
+ /* first_elem = first array entry (omit leading comma);
+ * sent_any = have we already tx'd a ws fragment of this msg */
+ int first_elem = 1, sent_any = 0, rc;
+
+ if (sqlite3_prepare_v2(vhd->pdb,
+ "SELECT DISTINCT repo_name FROM events "
+ "WHERE state != ? ORDER BY repo_name",
+ -1, &stmt, NULL) != SQLITE_OK) {
+ lwsl_notice("%s: projlist prepare failed\n", __func__);
+ goto soft_error;
+ }
+ sqlite3_bind_int(stmt, 1, SAIES_DELETED);
+
+ p += lws_snprintf((char *)p, lws_ptr_diff_size_t(end, p),
+ "{\"schema\":\"com.warmcat.sai.projlist\","
+ "\"projects\":[");
+ while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
+ const char *pn = (const char *)
+ sqlite3_column_text(stmt, 0);
+ if (!pn)
+ continue;
+ 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\"",
+ first_elem ? "" : ",",
+ lws_json_purify(esc, pn, sizeof(esc) - 1, NULL));
+ first_elem = 0;
+ }
+ sqlite3_finalize(stmt);
+
+ p += lws_snprintf((char *)p, lws_ptr_diff_size_t(end, p),
+ "]}");
+ saiw_ws_browser_queue_REQUIRES_LWS_PRE(pss, start,
+ lws_ptr_diff_size_t(p, start),
+ LWS_WRITE_TEXT);
+ goto ok;
+ }
+
+ case SAIM_WS_BROWSER_RX_BRANCHLIST:
+ {
+ /*
+ * 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
+ * interpolated.
+ */
+ sai_browse_rx_branchlist_t *bl =
+ (sai_browse_rx_branchlist_t *)a.dest;
+ 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;
+
+ if (sqlite3_prepare_v2(vhd->pdb,
+ "SELECT ref, MAX(created) AS mc FROM events "
+ "WHERE state != ? AND repo_name = ? "
+ "GROUP BY ref ORDER BY mc 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);
+
+ 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) {
+ const char *rn = (const char *)
+ sqlite3_column_text(stmt, 0);
+ if (!rn)
+ continue;
+ 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\"",
+ first_elem ? "" : ",",
+ lws_json_purify(esc, rn, sizeof(esc) - 1, NULL));
+ first_elem = 0;
+ }
+ sqlite3_finalize(stmt);
+
+ p += lws_snprintf((char *)p, lws_ptr_diff_size_t(end, p),
+ "]}");
+ saiw_ws_browser_queue_REQUIRES_LWS_PRE(pss, start,
+ lws_ptr_diff_size_t(p, start),
+ LWS_WRITE_TEXT);
+ goto ok;
+ }
+
case SAIM_WS_BROWSER_RX_TASKREMOVEALLTRIES:
case SAIM_WS_BROWSER_RX_TASKRESET:
@@ -1048,7 +1195,7 @@ saiw_browser_queue_overview(struct vhd *vhd, struct pss *pss)
{
char buf[4096 + LWS_PRE], *start = buf + LWS_PRE, *p = start,
*end = buf + sizeof(buf);
- char esc[256], filt[128], subsequent;
+ char esc[256], filt[256], subsequent;
struct lwsac *task_ac = NULL, *ac = NULL;
lws_dll2_owner_t task_owner, owner;
unsigned int task_index = 0;
@@ -1092,11 +1239,49 @@ saiw_browser_queue_overview(struct vhd *vhd, struct pss *pss)
}
if (pss->specific_project[0]) {
+ /*
+ * gitohashi /git/<project> URL-locked mode: lock to that one
+ * project, no row cap.
+ */
lws_sql_purify(esc, pss->specific_project, sizeof(esc) - 1);
- lws_snprintf(filt, sizeof(filt), " and state != %d and repo_name=\"%s\"", SAIES_DELETED, esc);
+ lws_snprintf(filt, sizeof(filt),
+ " and state != %d and repo_name=\"%s\"",
+ SAIES_DELETED, esc);
n = -1;
} else {
- lws_snprintf(filt, sizeof(filt), " and state != %d", SAIES_DELETED);
+ size_t fl;
+
+ /*
+ * Base filter: hide deleted events. We keep appending extra
+ * " and ..." clauses here; the COUNT query below skips the
+ * leading " and " with filt + 5 and uses the rest verbatim.
+ */
+ lws_snprintf(filt, sizeof(filt), " and state != %d",
+ SAIES_DELETED);
+
+ /*
+ * Sidebar runtime selection: scope to the browser's currently
+ * selected project and (if set) branch, capping at the newest
+ * 100 matching events.
+ */
+ if (pss->selected_project[0]) {
+ fl = strlen(filt);
+ lws_sql_purify(esc, pss->selected_project,
+ sizeof(esc) - 1);
+ lws_snprintf(filt + fl, sizeof(filt) - fl,
+ " and repo_name=\"%s\"", esc);
+ n = -100;
+ }
+
+ if (pss->selected_ref[0]) {
+ fl = strlen(filt);
+ lws_sql_purify(esc, pss->selected_ref,
+ sizeof(esc) - 1);
+ lws_snprintf(filt + fl, sizeof(filt) - fl,
+ " and ref=\"%s\"", esc);
+ if (n == -6)
+ n = -100;
+ }
}
unsigned int total_events = 0;