aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDan Engelbrecht <[email protected]>2026-03-22 13:13:13 +0100
committerGitHub Enterprise <[email protected]>2026-03-22 13:13:13 +0100
commit365229bcb24426dc3e02cb632b865e79ba01a9f9 (patch)
tree7c52640c73616e93bd993095e7a7295b1e1bcc22 /src
parentUpgrade mimalloc to v2.2.7 and log active memory allocator (#876) (diff)
downloadzen-365229bcb24426dc3e02cb632b865e79ba01a9f9.tar.xz
zen-365229bcb24426dc3e02cb632b865e79ba01a9f9.zip
hub web UI improvements (#878)
- Improvement: Hub dashboard module list improved - Animated state dots, with flashing transitions for hibernating, waking, provisioning, and deprovisioning - Per-row port used by the provisioned instance - Per-row hibernate, wake, and deprovision actions with confirmation for destructive operations - Per-row button to open the instance dashboard in a new window - Multi-select with bulk hibernate/wake/deprovision and select-all - Pagination at 50 lines per page - Inline fold-out panel per row with human-readable process metrics
Diffstat (limited to 'src')
-rw-r--r--src/zenserver/frontend/html/pages/hub.js540
-rw-r--r--src/zenserver/frontend/html/zen.css222
-rw-r--r--src/zenserver/hub/httphubservice.cpp1
-rw-r--r--src/zenserver/hub/hub.cpp2
-rw-r--r--src/zenserver/hub/hub.h1
5 files changed, 746 insertions, 20 deletions
diff --git a/src/zenserver/frontend/html/pages/hub.js b/src/zenserver/frontend/html/pages/hub.js
index 00d156c5e..3a5b67483 100644
--- a/src/zenserver/frontend/html/pages/hub.js
+++ b/src/zenserver/frontend/html/pages/hub.js
@@ -5,7 +5,74 @@
import { ZenPage } from "./page.js"
import { Fetcher } from "../util/fetcher.js"
import { Friendly } from "../util/friendly.js"
-import { Table } from "../util/widgets.js"
+import { Modal } from "../util/modal.js"
+
+////////////////////////////////////////////////////////////////////////////////
+const STABLE_STATES = new Set(["provisioned", "hibernated"]);
+
+function _is_actionable(state)
+{
+ return STABLE_STATES.has(state);
+}
+
+function _btn_enabled(state, action)
+{
+ if (action === "hibernate") { return state === "provisioned"; }
+ if (action === "wake") { return state === "hibernated"; }
+ if (action === "deprovision") { return _is_actionable(state); }
+ return false;
+}
+
+// Click listener is on the wrap and guarded by !btn.disabled, so the tooltip
+// (title on the span) shows even when the button is disabled.
+function _make_action_btn(icon, tooltip, on_click)
+{
+ const wrap = document.createElement("span");
+ wrap.className = "module-action-wrap";
+ wrap.title = tooltip;
+
+ const btn = document.createElement("button");
+ btn.className = "module-action-btn";
+ btn.textContent = icon;
+
+ wrap.appendChild(btn);
+ wrap.addEventListener("click", () => { if (!btn.disabled) { on_click(); } });
+ return [wrap, btn];
+}
+
+function _make_bulk_btn(icon, label, on_click)
+{
+ const btn = document.createElement("button");
+ btn.className = "module-bulk-btn";
+ btn.appendChild(document.createTextNode(icon + "\u00A0" + label));
+ btn.addEventListener("click", on_click);
+ return btn;
+}
+
+function _format_metric_name(key)
+{
+ // Strip unit suffix, then split CamelCase into words
+ const name = key.replace(/(Bytes|Size|Usage|Ms)$/, "");
+ return name.replace(/([A-Z])/g, " $1").trim();
+}
+
+function _format_metric_value(key, value)
+{
+ if (key.endsWith("Ms")) { return _format_ms(value); }
+ if (key.endsWith("Bytes") || key.endsWith("Size") || key.endsWith("Usage")) { return Friendly.bytes(value); }
+ return Friendly.sep(value);
+}
+
+function _format_ms(ms)
+{
+ if (ms < 1000) { return `${ms} ms`; }
+ if (ms < 60000) { return `${(ms / 1000).toFixed(3)} s`; }
+ const h = Math.floor(ms / 3600000);
+ const m = Math.floor((ms % 3600000) / 60000);
+ const s = Math.floor((ms % 60000) / 1000);
+ if (h > 0) { return `${Friendly.sep(h)}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; }
+ return `${m}:${String(s).padStart(2, "0")}`;
+}
////////////////////////////////////////////////////////////////////////////////
export class Page extends ZenPage
@@ -20,8 +87,85 @@ export class Page extends ZenPage
// Modules
const mod_section = this.add_section("Modules");
- this._mod_host = mod_section;
- this._mod_table = null;
+ const mod_host = mod_section.tag().inner();
+
+ // Toolbar: selection actions on left, "All" actions on right, always visible
+ this._bulk_bar = document.createElement("div");
+ this._bulk_bar.className = "module-bulk-bar";
+ this._bulk_label = document.createElement("span");
+ this._bulk_label.className = "module-bulk-label";
+ this._btn_bulk_hibernate = _make_bulk_btn("\u23F8", "Hibernate", () => this._exec_action("hibernate", [...this._selected]));
+ this._btn_bulk_wake = _make_bulk_btn("\u25B6", "Wake", () => this._exec_action("wake", [...this._selected]));
+ this._btn_bulk_deprov = _make_bulk_btn("\u2715", "Deprovision",() => this._confirm_deprovision([...this._selected]));
+ const bulk_sep = document.createElement("div");
+ bulk_sep.className = "module-bulk-sep";
+ this._btn_hibernate_all = _make_bulk_btn("\u23F8", "Hibernate All", () => this._confirm_all("hibernate", "Hibernate All"));
+ this._btn_wake_all = _make_bulk_btn("\u25B6", "Wake All", () => this._confirm_all("wake", "Wake All"));
+ this._btn_deprov_all = _make_bulk_btn("\u2715", "Deprovision All",() => this._confirm_all("deprovision", "Deprovision All"));
+ this._bulk_bar.appendChild(this._bulk_label);
+ this._bulk_bar.appendChild(this._btn_bulk_hibernate);
+ this._bulk_bar.appendChild(this._btn_bulk_wake);
+ this._bulk_bar.appendChild(this._btn_bulk_deprov);
+ this._bulk_bar.appendChild(bulk_sep);
+ this._bulk_bar.appendChild(this._btn_hibernate_all);
+ this._bulk_bar.appendChild(this._btn_wake_all);
+ this._bulk_bar.appendChild(this._btn_deprov_all);
+ mod_host.appendChild(this._bulk_bar);
+
+ // Module table
+ const table = document.createElement("table");
+ table.className = "module-table";
+ const thead = document.createElement("thead");
+ const hrow = document.createElement("tr");
+
+ const th_check = document.createElement("th");
+ th_check.style.width = "28px";
+ this._select_all_cb = document.createElement("input");
+ this._select_all_cb.type = "checkbox";
+ this._select_all_cb.addEventListener("change", () => this._on_select_all());
+ th_check.appendChild(this._select_all_cb);
+ hrow.appendChild(th_check);
+
+ for (const label of ["#", "MODULE ID", "STATUS", "PORT", "ACTIONS"])
+ {
+ const th = document.createElement("th");
+ th.textContent = label;
+ hrow.appendChild(th);
+ }
+
+ thead.appendChild(hrow);
+ table.appendChild(thead);
+
+ this._tbody = document.createElement("tbody");
+ table.appendChild(this._tbody);
+ mod_host.appendChild(table);
+
+ // Pagination controls — direct child of zen_sector so CSS can pin it beside the heading
+ const pager = mod_section.tag().classify("module-pager").inner();
+ this._btn_prev = document.createElement("button");
+ this._btn_prev.className = "module-pager-btn";
+ this._btn_prev.textContent = "\u2190 Prev";
+ this._btn_prev.addEventListener("click", () => this._go_page(this._page - 1));
+ this._pager_label = document.createElement("span");
+ this._pager_label.className = "module-pager-label";
+ this._btn_next = document.createElement("button");
+ this._btn_next.className = "module-pager-btn";
+ this._btn_next.textContent = "Next \u2192";
+ this._btn_next.addEventListener("click", () => this._go_page(this._page + 1));
+ pager.appendChild(this._btn_prev);
+ pager.appendChild(this._pager_label);
+ pager.appendChild(this._btn_next);
+
+ // State
+ this._selected = new Set();
+ this._modules_data = [];
+ this._module_map = new Map(); // moduleId → module, for O(1) lookup
+ this._prev_states = new Map(); // moduleId → last stable state, for deprovisioning dot
+ this._row_cache = new Map(); // moduleId → row refs, for in-place DOM updates
+ this._updating = false;
+ this._page = 0;
+ this._page_size = 50;
+ this._expanded = new Set(); // moduleIds with open metrics panel
await this._update();
this._poll_timer = setInterval(() => this._update(), 2000);
@@ -29,6 +173,8 @@ export class Page extends ZenPage
async _update()
{
+ if (this._updating) { return; }
+ this._updating = true;
try
{
const [stats, status] = await Promise.all([
@@ -40,6 +186,7 @@ export class Page extends ZenPage
this._render_modules(status);
}
catch (e) { /* service unavailable */ }
+ finally { this._updating = false; }
}
_render_capacity(data)
@@ -48,8 +195,8 @@ export class Page extends ZenPage
grid.inner().innerHTML = "";
const current = data.currentInstanceCount || 0;
- const max = data.maxInstanceCount || 0;
- const limit = data.instanceLimit || 0;
+ const max = data.maxInstanceCount || 0;
+ const limit = data.instanceLimit || 0;
{
const tile = grid.tag().classify("card").classify("stats-tile");
@@ -81,32 +228,387 @@ export class Page extends ZenPage
_render_modules(data)
{
const modules = data.modules || [];
+ this._modules_data = modules;
+ this._module_map = new Map(modules.map(m => [m.moduleId, m]));
+
+ // Update previous-state tracking (for deprovisioning dot animation)
+ for (const m of modules)
+ {
+ if (STABLE_STATES.has(m.state))
+ {
+ this._prev_states.set(m.moduleId, m.state);
+ }
+ }
+
+ this._prune_stale(this._selected);
+ this._prune_stale(this._prev_states);
+ this._prune_stale(this._expanded);
+
+ for (const [id, row] of this._row_cache)
+ {
+ if (!this._module_map.has(id))
+ {
+ row.tr.remove();
+ row.metrics_tr.remove();
+ this._row_cache.delete(id);
+ }
+ }
+
+ // Create or update rows, then reorder tbody to match response order.
+ // appendChild on an existing node moves it, so iterating in response order
+ // achieves a correct sort without touching rows already in the right position.
+ for (let i = 0; i < modules.length; i++)
+ {
+ const m = modules[i];
+ const id = m.moduleId || "";
+ const state = m.state || "unprovisioned";
+ const prev = this._prev_states.get(id) || "provisioned";
+
+ let row = this._row_cache.get(id);
+ if (row)
+ {
+ // Update in-place — preserves DOM node identity so CSS animations are not reset
+ row.idx.textContent = i + 1;
+ row.cb.checked = this._selected.has(id);
+ row.dot.setAttribute("data-state", state);
+ if (state === "deprovisioning")
+ {
+ row.dot.setAttribute("data-prev-state", prev);
+ }
+ else
+ {
+ row.dot.removeAttribute("data-prev-state");
+ }
+ row.state_text.nodeValue = state;
+ row.port_text.nodeValue = m.port ? String(m.port) : "";
+ row.btn_open.disabled = state !== "provisioned";
+ row.btn_hibernate.disabled = !_btn_enabled(state, "hibernate");
+ row.btn_wake.disabled = !_btn_enabled(state, "wake");
+ row.btn_deprov.disabled = !_btn_enabled(state, "deprovision");
+
+ if (m.process_metrics)
+ {
+ for (const [key, node] of row.metric_nodes)
+ {
+ const val = m.process_metrics[key];
+ if (val !== undefined) { node.nodeValue = _format_metric_value(key, val); }
+ }
+ }
+ }
+ else
+ {
+ // Create new row
+ const tr = document.createElement("tr");
+
+ // Metrics sub-row (created first so the expand handler can reference it)
+ const metrics_tr = document.createElement("tr");
+ metrics_tr.className = "module-metrics-row";
+ metrics_tr.style.display = this._expanded.has(id) ? "" : "none";
+
+ const td_check = document.createElement("td");
+ const cb = document.createElement("input");
+ cb.type = "checkbox";
+ cb.checked = this._selected.has(id);
+ cb.addEventListener("change", () => {
+ if (cb.checked) { this._selected.add(id); }
+ else { this._selected.delete(id); }
+ this._update_selection_ui();
+ });
+ td_check.appendChild(cb);
+ tr.appendChild(td_check);
+
+ const td_idx = document.createElement("td");
+ td_idx.textContent = i + 1;
+ tr.appendChild(td_idx);
+
+ const btn_expand = document.createElement("button");
+ btn_expand.className = "module-expand-btn";
+ btn_expand.textContent = this._expanded.has(id) ? "\u25BE" : "\u25B8";
+ btn_expand.addEventListener("click", () => {
+ if (this._expanded.has(id))
+ {
+ this._expanded.delete(id);
+ metrics_tr.style.display = "none";
+ btn_expand.textContent = "\u25B8";
+ }
+ else
+ {
+ this._expanded.add(id);
+ metrics_tr.style.display = "";
+ btn_expand.textContent = "\u25BE";
+ }
+ });
+
+ const td_id = document.createElement("td");
+ const id_wrap = document.createElement("span");
+ id_wrap.style.cssText = "display:inline-flex;align-items:center;font-family:monospace;font-size:14px;";
+ id_wrap.appendChild(btn_expand);
+ id_wrap.appendChild(document.createTextNode("\u00A0" + id));
+ td_id.appendChild(id_wrap);
+ tr.appendChild(td_id);
+
+ const td_status = document.createElement("td");
+ const dot = document.createElement("span");
+ dot.className = "module-state-dot";
+ dot.setAttribute("data-state", state);
+ if (state === "deprovisioning")
+ {
+ dot.setAttribute("data-prev-state", prev);
+ }
+ const state_node = document.createTextNode(state);
+ td_status.appendChild(dot);
+ td_status.appendChild(state_node);
+ tr.appendChild(td_status);
+
+ const port = m.port || 0;
+ const td_port = document.createElement("td");
+ td_port.style.cssText = "font-variant-numeric:tabular-nums;";
+ const port_node = document.createTextNode(port ? String(port) : "");
+ td_port.appendChild(port_node);
+ tr.appendChild(td_port);
+
+ const td_action = document.createElement("td");
+ td_action.className = "module-action-cell";
+ const [wrap_o, btn_o] = _make_action_btn("\u2197", "Open dashboard", () => {
+ window.open(`${window.location.protocol}//${window.location.hostname}:${port}`, "_blank");
+ });
+ btn_o.disabled = state !== "provisioned";
+ const [wrap_h, btn_h] = _make_action_btn("\u23F8", "Hibernate", () => this._post_module_action(id, "hibernate").then(() => this._update()));
+ const [wrap_w, btn_w] = _make_action_btn("\u25B6", "Wake", () => this._post_module_action(id, "wake").then(() => this._update()));
+ const [wrap_d, btn_d] = _make_action_btn("\u2715", "Deprovision", () => this._confirm_deprovision([id]));
+ btn_h.disabled = !_btn_enabled(state, "hibernate");
+ btn_w.disabled = !_btn_enabled(state, "wake");
+ btn_d.disabled = !_btn_enabled(state, "deprovision");
+ td_action.appendChild(wrap_h);
+ td_action.appendChild(wrap_w);
+ td_action.appendChild(wrap_d);
+ td_action.appendChild(wrap_o);
+ tr.appendChild(td_action);
+
+ // Build metrics grid from process_metrics keys.
+ // Keys are split into two halves and interleaved so the grid fills
+ // top-to-bottom in the left column before continuing in the right column.
+ const metric_nodes = new Map();
+ const metrics_td = document.createElement("td");
+ metrics_td.colSpan = 6;
+ const metrics_grid = document.createElement("div");
+ metrics_grid.className = "module-metrics-grid";
+ const keys = Object.keys(m.process_metrics || {});
+ const half = Math.ceil(keys.length / 2);
+ const add_metric_pair = (key) => {
+ const label_el = document.createElement("span");
+ label_el.className = "module-metrics-label";
+ label_el.textContent = _format_metric_name(key);
+ const value_node = document.createTextNode(_format_metric_value(key, m.process_metrics[key]));
+ const value_el = document.createElement("span");
+ value_el.className = "module-metrics-value";
+ value_el.appendChild(value_node);
+ metrics_grid.appendChild(label_el);
+ metrics_grid.appendChild(value_el);
+ metric_nodes.set(key, value_node);
+ };
+ for (let i = 0; i < half; i++)
+ {
+ add_metric_pair(keys[i]);
+ if (i + half < keys.length) { add_metric_pair(keys[i + half]); }
+ else
+ {
+ metrics_grid.appendChild(document.createElement("span"));
+ metrics_grid.appendChild(document.createElement("span"));
+ }
+ }
+ metrics_td.appendChild(metrics_grid);
+ metrics_tr.appendChild(metrics_td);
+
+ row = { tr, metrics_tr, idx: td_idx, cb, dot, state_text: state_node, port_text: port_node, btn_expand, btn_open: btn_o, btn_hibernate: btn_h, btn_wake: btn_w, btn_deprov: btn_d, metric_nodes };
+ this._row_cache.set(id, row);
+ }
+
+ }
+
+ // Clamp page in case the total shrank
+ const max_page = Math.max(0, Math.ceil(modules.length / this._page_size) - 1);
+ if (this._page > max_page) { this._page = max_page; }
+
+ this._render_page();
+ this._update_selection_ui();
+ this._update_all_toolbar();
+ }
+
+ // Removes IDs from a Set or Map that are no longer present in _module_map.
+ // _row_cache is handled separately because removal has DOM side effects.
+ _prune_stale(collection)
+ {
+ for (const id of collection.keys())
+ {
+ if (!this._module_map.has(id))
+ {
+ collection.delete(id);
+ }
+ }
+ }
+
+ _render_page()
+ {
+ const start = this._page * this._page_size;
+ const end = Math.min(start + this._page_size, this._modules_data.length);
+ const page_ids = new Set();
+ for (let i = start; i < end; i++)
+ {
+ page_ids.add(this._modules_data[i].moduleId || "");
+ }
- if (this._mod_table)
+ // Remove rows not on this page from tbody
+ for (const [id, row] of this._row_cache)
+ {
+ if (!page_ids.has(id) && row.tr.parentNode === this._tbody)
+ {
+ row.tr.remove();
+ row.metrics_tr.remove();
+ }
+ }
+
+ // Append/reorder current page rows in response order
+ for (let i = start; i < end; i++)
+ {
+ const id = this._modules_data[i].moduleId || "";
+ const row = this._row_cache.get(id);
+ if (row)
+ {
+ this._tbody.appendChild(row.tr);
+ this._tbody.appendChild(row.metrics_tr);
+ }
+ }
+
+ this._update_pagination_ui();
+ }
+
+ _go_page(n)
+ {
+ const max = Math.max(0, Math.ceil(this._modules_data.length / this._page_size) - 1);
+ this._page = Math.max(0, Math.min(n, max));
+ this._render_page();
+ }
+
+ _update_pagination_ui()
+ {
+ const total = this._modules_data.length;
+ const page_count = Math.max(1, Math.ceil(total / this._page_size));
+ const start = this._page * this._page_size + 1;
+ const end = Math.min(start + this._page_size - 1, total);
+
+ this._btn_prev.disabled = this._page === 0;
+ this._btn_next.disabled = this._page >= page_count - 1;
+ this._pager_label.textContent = total === 0
+ ? "No modules"
+ : `${start}\u2013${end} of ${total}`;
+ }
+
+ _module_state(id)
+ {
+ const m = this._module_map.get(id);
+ return m ? m.state : undefined;
+ }
+
+ _all_selected_in_state(state)
+ {
+ if (this._selected.size === 0) { return false; }
+ for (const id of this._selected)
{
- this._mod_table.clear();
+ if (this._module_state(id) !== state) { return false; }
+ }
+ return true;
+ }
+
+ _update_selection_ui()
+ {
+ const total = this._modules_data.length;
+ const selected = this._selected.size;
+
+ this._bulk_label.textContent = `${selected} module${selected !== 1 ? "s" : ""} selected`;
+
+ this._btn_bulk_hibernate.disabled = !this._all_selected_in_state("provisioned");
+ this._btn_bulk_wake.disabled = !this._all_selected_in_state("hibernated");
+ this._btn_bulk_deprov.disabled = selected === 0;
+
+ this._select_all_cb.disabled = total === 0;
+ this._select_all_cb.checked = selected === total && total > 0;
+ this._select_all_cb.indeterminate = selected > 0 && selected < total;
+ }
+
+ _update_all_toolbar()
+ {
+ const empty = this._modules_data.length === 0;
+ this._btn_hibernate_all.disabled = empty;
+ this._btn_wake_all.disabled = empty;
+ this._btn_deprov_all.disabled = empty;
+ }
+
+ _on_select_all()
+ {
+ if (this._select_all_cb.checked)
+ {
+ for (const m of this._modules_data)
+ {
+ this._selected.add(m.moduleId);
+ }
}
else
{
- this._mod_table = this._mod_host.add_widget(
- Table,
- ["module ID", "status"],
- Table.Flag_FitLeft|Table.Flag_PackRight
- );
+ this._selected.clear();
}
- if (modules.length === 0)
+ for (const cb of this._tbody.querySelectorAll("input[type=checkbox]"))
{
- return;
+ cb.checked = this._select_all_cb.checked;
}
- for (const m of modules)
+ this._update_selection_ui();
+ }
+
+ _confirm_deprovision(ids)
+ {
+ let message;
+ if (ids.length === 1)
{
- this._mod_table.add_row(
- m.moduleId || "",
- m.state || "unprovisioned",
- );
+ const id = ids[0];
+ const state = this._module_state(id) || "unknown";
+ message = `Are you sure you want to deprovision this module?\n\nModule ID: ${id}\nCurrent state: ${state}`;
}
+ else
+ {
+ message = `Deprovision ${ids.length} selected modules?`;
+ }
+
+ new Modal()
+ .title("Confirm Deprovision")
+ .message(message)
+ .option("Cancel", null)
+ .option("Deprovision", () => this._exec_action("deprovision", ids));
+ }
+
+ _confirm_all(action, label)
+ {
+ // Capture IDs at modal-open time so action targets the displayed list
+ const ids = this._modules_data.map(m => m.moduleId);
+ const n = ids.length;
+ const verb = action.charAt(0).toUpperCase() + action.slice(1);
+ new Modal()
+ .title(`Confirm ${label}`)
+ .message(`${verb} all ${n} module${n !== 1 ? "s" : ""}?`)
+ .option("Cancel", null)
+ .option(label, () => this._exec_action(action, ids));
+ }
+
+ async _exec_action(action, ids)
+ {
+ await Promise.allSettled(ids.map(id => this._post_module_action(id, action)));
+ await this._update();
+ }
+
+ async _post_module_action(moduleId, action)
+ {
+ await fetch(`/hub/modules/${moduleId}/${action}`, { method: "POST" });
}
_metric(parent, value, label, hero = false)
diff --git a/src/zenserver/frontend/html/zen.css b/src/zenserver/frontend/html/zen.css
index 74336f0e1..5ce60d2d2 100644
--- a/src/zenserver/frontend/html/zen.css
+++ b/src/zenserver/frontend/html/zen.css
@@ -471,7 +471,7 @@ a {
border-radius: 6px;
background-color: var(--theme_p4);
border: 1px solid var(--theme_g2);
- width: 6em;
+ min-width: 6em;
cursor: pointer;
font-weight: 500;
transition: background 0.15s;
@@ -486,6 +486,8 @@ a {
padding: 2em;
min-height: 8em;
align-content: center;
+ white-space: pre-wrap;
+ text-align: left;
}
}
@@ -1081,3 +1083,221 @@ tr:last-child td {
background: var(--theme_g2);
color: var(--theme_bright);
}
+
+/* module action controls --------------------------------------------------- */
+
+.module-state-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-right: 6px;
+ vertical-align: middle;
+ flex-shrink: 0;
+}
+
+.module-state-dot[data-state="provisioned"] { background: var(--theme_ok); }
+.module-state-dot[data-state="hibernated"] { background: var(--theme_warn); }
+.module-state-dot[data-state="unprovisioned"] { background: var(--theme_g1); }
+
+@keyframes module-dot-hibernating {
+ 0%, 59.9% { background: var(--theme_warn); }
+ 60%, 100% { background: var(--theme_ok); }
+}
+@keyframes module-dot-waking {
+ 0%, 59.9% { background: var(--theme_ok); }
+ 60%, 100% { background: var(--theme_warn); }
+}
+@keyframes module-dot-provisioning {
+ 0%, 59.9% { background: var(--theme_ok); }
+ 60%, 100% { background: var(--theme_g1); }
+}
+@keyframes module-dot-deprovisioning-from-provisioned {
+ 0%, 59.9% { background: var(--theme_fail); }
+ 60%, 100% { background: var(--theme_ok); }
+}
+@keyframes module-dot-deprovisioning-from-hibernated {
+ 0%, 59.9% { background: var(--theme_fail); }
+ 60%, 100% { background: var(--theme_warn); }
+}
+
+.module-state-dot[data-state="hibernating"] { animation: module-dot-hibernating 1s steps(1, end) infinite; }
+.module-state-dot[data-state="waking"] { animation: module-dot-waking 1s steps(1, end) infinite; }
+.module-state-dot[data-state="provisioning"] { animation: module-dot-provisioning 1s steps(1, end) infinite; }
+.module-state-dot[data-state="deprovisioning"][data-prev-state="provisioned"] {
+ animation: module-dot-deprovisioning-from-provisioned 1s steps(1, end) infinite;
+}
+.module-state-dot[data-state="deprovisioning"][data-prev-state="hibernated"] {
+ animation: module-dot-deprovisioning-from-hibernated 1s steps(1, end) infinite;
+}
+.module-state-dot[data-state="deprovisioning"] {
+ animation: module-dot-deprovisioning-from-provisioned 1s steps(1, end) infinite;
+}
+
+.module-action-cell {
+ white-space: nowrap;
+ display: flex;
+ gap: 4px;
+}
+
+.module-action-wrap {
+ display: inline-block;
+ cursor: default;
+}
+
+.module-action-wrap:has(button:disabled) {
+ cursor: default;
+}
+
+.module-action-btn {
+ background: transparent;
+ border: 1px solid var(--theme_g2);
+ border-radius: 4px;
+ color: var(--theme_g1);
+ cursor: pointer;
+ font-size: 13px;
+ line-height: 1;
+ padding: 3px 6px;
+ transition: background 0.1s, color 0.1s;
+}
+
+.module-action-btn:not(:disabled):hover {
+ background: var(--theme_g2);
+ color: var(--theme_bright);
+}
+
+.module-action-btn:disabled {
+ color: var(--theme_border_subtle);
+ border-color: var(--theme_border_subtle);
+ pointer-events: none;
+}
+
+.module-bulk-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ background: transparent;
+ border: 1px solid var(--theme_g2);
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 600;
+ padding: 4px 10px;
+ color: var(--theme_g1);
+ transition: background 0.1s, color 0.1s;
+}
+
+.module-bulk-btn:not(:disabled):hover {
+ background: var(--theme_p3);
+ color: var(--theme_bright);
+}
+
+.module-bulk-btn:disabled {
+ opacity: 0.4;
+}
+
+.module-bulk-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 10px;
+ margin-bottom: 8px;
+ background: var(--theme_g2);
+ border-radius: 4px;
+ font-size: 12px;
+}
+
+.module-bulk-label {
+ margin-right: 8px;
+ color: var(--theme_g1);
+}
+
+.module-bulk-sep {
+ flex: 1;
+}
+
+.module-pager {
+ position: absolute;
+ right: 0;
+ top: 0;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.module-pager-btn {
+ background: transparent;
+ border: 1px solid var(--theme_g2);
+ border-radius: 4px;
+ color: var(--theme_g1);
+ cursor: pointer;
+ font-size: 12px;
+ padding: 4px 10px;
+ transition: background 0.1s, color 0.1s;
+}
+
+.module-pager-btn:not(:disabled):hover {
+ background: var(--theme_g2);
+ color: var(--theme_bright);
+}
+
+.module-pager-btn:disabled {
+ opacity: 0.35;
+ cursor: default;
+}
+
+.module-pager-label {
+ font-size: 12px;
+ color: var(--theme_g1);
+ min-width: 8em;
+ text-align: center;
+}
+
+.module-table td, .module-table th {
+ padding-top: 4px;
+ padding-bottom: 4px;
+}
+
+.module-expand-btn {
+ background: transparent;
+ border: none;
+ color: var(--theme_g1);
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+ padding: 0 4px 0 0;
+ vertical-align: middle;
+}
+
+.module-expand-btn:hover {
+ color: var(--theme_bright);
+}
+
+.module-metrics-row td {
+ padding: 6px 10px 10px 42px;
+ background: var(--theme_g3);
+ border-bottom: 1px solid var(--theme_g2);
+}
+
+.module-metrics-grid {
+ display: grid;
+ grid-template-columns: max-content minmax(9em, 1fr) max-content minmax(9em, 1fr);
+ gap: 3px 12px;
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+}
+
+.module-metrics-label {
+ color: var(--theme_faint);
+ white-space: nowrap;
+}
+
+/* Right-column labels get extra left padding to visually separate the two pairs */
+.module-metrics-label:nth-child(4n+3) {
+ padding-left: 16px;
+}
+
+.module-metrics-value {
+ color: var(--theme_g0);
+ text-align: right;
+}
diff --git a/src/zenserver/hub/httphubservice.cpp b/src/zenserver/hub/httphubservice.cpp
index 03be6e85d..a91e36128 100644
--- a/src/zenserver/hub/httphubservice.cpp
+++ b/src/zenserver/hub/httphubservice.cpp
@@ -42,6 +42,7 @@ HttpHubService::HttpHubService(Hub& Hub) : m_Hub(Hub)
{
Obj << "moduleId" << ModuleId;
Obj << "state" << ToString(Info.State);
+ Obj << "port" << Info.Port;
Obj.BeginObject("process_metrics");
{
Obj << "MemoryBytes" << Info.Metrics.MemoryBytes;
diff --git a/src/zenserver/hub/hub.cpp b/src/zenserver/hub/hub.cpp
index 54f45e511..ebbb9432a 100644
--- a/src/zenserver/hub/hub.cpp
+++ b/src/zenserver/hub/hub.cpp
@@ -605,6 +605,7 @@ Hub::Find(std::string_view ModuleId, InstanceInfo* OutInstanceInfo)
std::chrono::system_clock::now() // TODO
};
Instance->GetProcessMetrics(Info.Metrics);
+ Info.Port = Instance->GetBasePort();
*OutInstanceInfo = Info;
}
@@ -628,6 +629,7 @@ Hub::EnumerateModules(std::function<void(std::string_view ModuleId, const Instan
std::chrono::system_clock::now() // TODO
};
Instance->GetProcessMetrics(Info.Metrics);
+ Info.Port = Instance->GetBasePort();
Infos.push_back(std::make_pair(std::string(Instance->GetModuleId()), Info));
}
diff --git a/src/zenserver/hub/hub.h b/src/zenserver/hub/hub.h
index 9a84f7744..8c4039c38 100644
--- a/src/zenserver/hub/hub.h
+++ b/src/zenserver/hub/hub.h
@@ -69,6 +69,7 @@ public:
HubInstanceState State = HubInstanceState::Unprovisioned;
std::chrono::system_clock::time_point ProvisionTime;
ProcessMetrics Metrics;
+ uint16_t Port = 0;
};
/**