diff options
| author | Dan Engelbrecht <[email protected]> | 2026-03-22 13:13:13 +0100 |
|---|---|---|
| committer | GitHub Enterprise <[email protected]> | 2026-03-22 13:13:13 +0100 |
| commit | 365229bcb24426dc3e02cb632b865e79ba01a9f9 (patch) | |
| tree | 7c52640c73616e93bd993095e7a7295b1e1bcc22 /src | |
| parent | Upgrade mimalloc to v2.2.7 and log active memory allocator (#876) (diff) | |
| download | zen-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.js | 540 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/zen.css | 222 | ||||
| -rw-r--r-- | src/zenserver/hub/httphubservice.cpp | 1 | ||||
| -rw-r--r-- | src/zenserver/hub/hub.cpp | 2 | ||||
| -rw-r--r-- | src/zenserver/hub/hub.h | 1 |
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; }; /** |