Project homepage Mailing List  Warmcat.com  API Docs  Github Mirror 
    npro  
 Modern all-safe Rust Network Protocol library supporting h1, h2, h3, ws, wt sans-IO and with socket IO + tls
git clone https://npro.rs/repo/npro
 
root / assets / linux-centos-7.svg
Author[]Andy Green <andy@warmcat.com> 2026-08-02 16:37 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-08-02 16:37 UTC
Tree9e0189b293eb3365ab8050c57305a1b5eadd24cb   Raw Patch
 
layout: project+branch
layout: project+branch
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) + "\">&nbsp;"; - 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) + "\">&lt; 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 &gt;</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;
Page fetched 0s ago, creation time: 34ms (vhost etag hits: 0%, cache hits: 0%)