// Copyright Epic Games, Inc. All Rights Reserved. "use strict"; import { ZenPage } from "./page.js" import { Fetcher } from "../util/fetcher.js" import { Friendly } from "../util/friendly.js" import { Modal } from "../util/modal.js" import { flash_highlight, copy_button } from "../util/widgets.js" //////////////////////////////////////////////////////////////////////////////// const STABLE_STATES = new Set(["provisioned", "hibernated", "crashed"]); 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); } if (action === "obliterate") { 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 { async main() { this.set_title("hub"); // Capacity const stats_section = this._collapsible_section("Hub Service Stats"); this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles"); // Modules const mod_section = this.add_section("Modules"); 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("\u23F9", "Deprovision",() => this._confirm_deprovision([...this._selected])); this._btn_bulk_oblit = _make_bulk_btn("\uD83D\uDD25", "Obliterate", () => this._confirm_obliterate([...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("\u23F9", "Deprovision All",() => this._confirm_all("deprovision", "Deprovision All")); this._btn_oblit_all = _make_bulk_btn("\uD83D\uDD25", "Obliterate All", () => this._confirm_obliterate(this._modules_data.map(m => m.moduleId))); 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(this._btn_bulk_oblit); 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); this._bulk_bar.appendChild(this._btn_oblit_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)); this._btn_provision = _make_bulk_btn("+", "Provision", () => this._show_provision_modal()); this._btn_obliterate = _make_bulk_btn("\uD83D\uDD25", "Obliterate", () => this._show_obliterate_modal()); this._search_input = document.createElement("input"); this._search_input.type = "text"; this._search_input.className = "module-pager-search"; this._search_input.placeholder = "Search module\u2026"; this._search_input.addEventListener("keydown", (e) => { if (e.key === "Enter") { const term = this._search_input.value.trim().toLowerCase(); if (!term) { return; } const idx = this._modules_data.findIndex(m => (m.moduleId || "").toLowerCase().includes(term) ); if (idx >= 0) { const id = this._modules_data[idx].moduleId; this._navigate_to_module(id); this._flash_module(id); } else { this._search_input.style.outline = "2px solid var(--theme_fail)"; setTimeout(() => { this._search_input.style.outline = ""; }, 1000); } } }); pager.appendChild(this._btn_provision); pager.appendChild(this._btn_obliterate); pager.appendChild(this._search_input); 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 = 25; this._expanded = new Set(); // moduleIds with open metrics panel this._pending_highlight = null; // moduleId to navigate+flash after next poll this._pending_highlight_timer = null; this._loading = mod_section.tag().classify("pager-loading").text("Loading\u2026").inner(); await this._update(); this._poll_timer = setInterval(() => this._update(), 2000); } async _update() { if (this._updating) { return; } this._updating = true; try { const [stats, status] = await Promise.all([ new Fetcher().resource("stats", "hub").json(), new Fetcher().resource("/hub/status").json(), ]); this._render_capacity(stats); this._render_modules(status); if (this._loading) { this._loading.remove(); this._loading = null; } if (this._pending_highlight && this._module_map.has(this._pending_highlight)) { const id = this._pending_highlight; this._pending_highlight = null; clearTimeout(this._pending_highlight_timer); this._navigate_to_module(id); this._flash_module(id); } } catch (e) { /* service unavailable */ } finally { this._updating = false; } } _render_capacity(data) { const grid = this._stats_grid; grid.inner().innerHTML = ""; const current = data.currentInstanceCount || 0; const max = data.maxInstanceCount || 0; const limit = data.instanceLimit || 0; // HTTP Requests tile this._render_http_requests_tile(grid, data.requests); { const tile = grid.tag().classify("card").classify("stats-tile"); tile.tag().classify("card-title").text("Instances"); const body = tile.tag().classify("tile-metrics"); this._metric(body, Friendly.sep(current), "currently provisioned", true); this._metric(body, Friendly.sep(max), "high watermark"); this._metric(body, Friendly.sep(limit), "maximum allowed"); if (limit > 0) { const pct = ((current / limit) * 100).toFixed(0) + "%"; this._metric(body, pct, "utilization"); } } const machine = data.machine || {}; const limits = data.resource_limits || {}; if (machine.disk_total_bytes > 0 || machine.memory_total_mib > 0) { const disk_used = Math.max(0, (machine.disk_total_bytes || 0) - (machine.disk_free_bytes || 0)); const mem_used = Math.max(0, (machine.memory_total_mib || 0) - (machine.memory_avail_mib || 0)) * 1024 * 1024; const vmem_used = Math.max(0, (machine.virtual_memory_total_mib || 0) - (machine.virtual_memory_avail_mib || 0)) * 1024 * 1024; const disk_limit = limits.disk_bytes || 0; const mem_limit = limits.memory_bytes || 0; const disk_over = disk_limit > 0 && disk_used > disk_limit; const mem_over = mem_limit > 0 && mem_used > mem_limit; const tile = grid.tag().classify("card").classify("stats-tile"); if (disk_over || mem_over) { tile.inner().setAttribute("data-over", "true"); } tile.tag().classify("card-title").text("Resources"); const columns = tile.tag().classify("tile-columns"); const left = columns.tag().classify("tile-metrics"); this._metric(left, Friendly.bytes(disk_used), "disk used", true); this._metric(left, Friendly.bytes(machine.disk_total_bytes), "disk total"); if (disk_limit > 0) { this._metric(left, Friendly.bytes(disk_limit), "disk limit"); } const right = columns.tag().classify("tile-metrics"); this._metric(right, Friendly.bytes(mem_used), "memory used", true); this._metric(right, Friendly.bytes(machine.memory_total_mib * 1024 * 1024), "memory total"); if (mem_limit > 0) { this._metric(right, Friendly.bytes(mem_limit), "memory limit"); } if (machine.virtual_memory_total_mib > 0) { this._metric(right, Friendly.bytes(vmem_used), "vmem used", true); this._metric(right, Friendly.bytes(machine.virtual_memory_total_mib * 1024 * 1024), "vmem total"); } } } _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" || state === "obliterating") { 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.copy_port_btn.style.display = m.port ? "" : "none"; if (m.state_change_time) { const state_label = state.charAt(0).toUpperCase() + state.slice(1); row.state_since_label.textContent = state_label + " since"; row.state_age_label.textContent = state_label + " for"; row.state_since_node.nodeValue = m.state_change_time; row.state_age_node.nodeValue = Friendly.timespan(Date.now() - new Date(m.state_change_time).getTime()); } 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"); row.btn_oblit.disabled = !_btn_enabled(state, "obliterate"); 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)); const copy_id_btn = copy_button(id); id_wrap.appendChild(copy_id_btn); 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" || state === "obliterating") { 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); const copy_port_btn = copy_button(() => port_node.nodeValue); copy_port_btn.style.display = port ? "" : "none"; td_port.appendChild(copy_port_btn); 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(`/hub/proxy/${port}/dashboard/`, "_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("\u23F9", "Deprovision", () => this._confirm_deprovision([id])); const [wrap_x, btn_x] = _make_action_btn("\uD83D\uDD25", "Obliterate", () => this._confirm_obliterate([id])); btn_h.disabled = !_btn_enabled(state, "hibernate"); btn_w.disabled = !_btn_enabled(state, "wake"); btn_d.disabled = !_btn_enabled(state, "deprovision"); btn_x.disabled = !_btn_enabled(state, "obliterate"); td_action.appendChild(wrap_h); td_action.appendChild(wrap_w); td_action.appendChild(wrap_d); td_action.appendChild(wrap_x); td_action.appendChild(wrap_o); tr.appendChild(td_action); // Build metrics grid: fixed state-time rows followed by 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 _add_fixed_pair = (label, value_str) => { const label_el = document.createElement("span"); label_el.className = "module-metrics-label"; label_el.textContent = label; const value_node = document.createTextNode(value_str); 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); return { label_el, value_node }; }; const state_label = m.state ? m.state.charAt(0).toUpperCase() + m.state.slice(1) : "State"; const state_since_str = m.state_change_time || ""; const state_age_str = m.state_change_time ? Friendly.timespan(Date.now() - new Date(m.state_change_time).getTime()) : ""; const { label_el: state_since_label, value_node: state_since_node } = _add_fixed_pair(state_label + " since", state_since_str); const { label_el: state_age_label, value_node: state_age_node } = _add_fixed_pair(state_label + " for", state_age_str); 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, copy_port_btn, btn_expand, btn_open: btn_o, btn_hibernate: btn_h, btn_wake: btn_w, btn_deprov: btn_d, btn_oblit: btn_x, metric_nodes, state_since_node, state_age_node, state_since_label, state_age_label }; 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 || ""); } // 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) { 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._btn_bulk_oblit.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; this._btn_oblit_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._selected.clear(); } for (const cb of this._tbody.querySelectorAll("input[type=checkbox]")) { cb.checked = this._select_all_cb.checked; } this._update_selection_ui(); } _confirm_deprovision(ids) { let message; if (ids.length === 1) { 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_obliterate(ids) { const warn = "\uD83D\uDD25 WARNING: This action is irreversible! \uD83D\uDD25"; const detail = "All local and backend data will be permanently destroyed.\nThis cannot be undone."; let message; if (ids.length === 1) { const id = ids[0]; const state = this._module_state(id) || "unknown"; message = `${warn}\n\n${detail}\n\nModule ID: ${id}\nCurrent state: ${state}`; } else { message = `${warn}\n\nObliterate ${ids.length} modules.\n\n${detail}`; } new Modal() .title("\uD83D\uDD25 Obliterate") .message(message) .option("Cancel", null) .option("\uD83D\uDD25 Obliterate", () => this._exec_obliterate(ids)); } async _exec_obliterate(ids) { await Promise.allSettled(ids.map(id => fetch(`/hub/modules/${encodeURIComponent(id)}`, { method: "DELETE" }))); await this._update(); } _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" }); } _show_module_input_modal({ title, submit_label, warning, on_submit }) { const MODULE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/; const overlay = document.createElement("div"); overlay.className = "zen_modal"; const bg = document.createElement("div"); bg.className = "zen_modal_bg"; bg.addEventListener("click", () => overlay.remove()); overlay.appendChild(bg); const dialog = document.createElement("div"); overlay.appendChild(dialog); const title_el = document.createElement("div"); title_el.className = "zen_modal_title"; title_el.textContent = title; dialog.appendChild(title_el); const content = document.createElement("div"); content.className = "zen_modal_message"; content.style.textAlign = "center"; if (warning) { const warn = document.createElement("div"); warn.style.cssText = "color:var(--theme_fail);font-weight:bold;margin-bottom:12px;"; warn.textContent = warning; content.appendChild(warn); } const input = document.createElement("input"); input.type = "text"; input.placeholder = "module-name"; input.style.cssText = "width:100%;font-size:14px;padding:8px 12px;"; content.appendChild(input); const error_div = document.createElement("div"); error_div.style.cssText = "color:var(--theme_fail);font-size:12px;margin-top:8px;min-height:1.2em;"; content.appendChild(error_div); dialog.appendChild(content); const buttons = document.createElement("div"); buttons.className = "zen_modal_buttons"; const btn_cancel = document.createElement("div"); btn_cancel.textContent = "Cancel"; btn_cancel.addEventListener("click", () => overlay.remove()); const btn_submit = document.createElement("div"); btn_submit.textContent = submit_label; buttons.appendChild(btn_cancel); buttons.appendChild(btn_submit); dialog.appendChild(buttons); let submitting = false; const set_submit_enabled = (enabled) => { btn_submit.style.opacity = enabled ? "" : "0.4"; btn_submit.style.pointerEvents = enabled ? "" : "none"; }; set_submit_enabled(false); const validate = () => { if (submitting) { return false; } const val = input.value.trim(); if (val.length === 0) { error_div.textContent = ""; set_submit_enabled(false); return false; } if (!MODULE_ID_RE.test(val)) { error_div.textContent = "Only letters, numbers, and hyphens allowed (must start with a letter or number)"; set_submit_enabled(false); return false; } error_div.textContent = ""; set_submit_enabled(true); return true; }; input.addEventListener("input", validate); const submit = async () => { if (submitting) { return; } const moduleId = input.value.trim(); if (!MODULE_ID_RE.test(moduleId)) { return; } submitting = true; set_submit_enabled(false); error_div.textContent = ""; try { const ok = await on_submit(moduleId); if (ok) { overlay.remove(); await this._update(); return; } } catch (e) { error_div.textContent = e.message || "Request failed"; } submitting = false; set_submit_enabled(true); }; btn_submit.addEventListener("click", submit); input.addEventListener("keydown", (e) => { if (e.key === "Enter" && validate()) { submit(); } if (e.key === "Escape") { overlay.remove(); } }); document.body.appendChild(overlay); input.focus(); return { error_div }; } _show_provision_modal() { const { error_div } = this._show_module_input_modal({ title: "Provision Module", submit_label: "Provision", on_submit: async (moduleId) => { const resp = await fetch(`/hub/modules/${encodeURIComponent(moduleId)}/provision`, { method: "POST" }); if (!resp.ok) { const msg = await resp.text(); error_div.textContent = msg || ("HTTP " + resp.status); return false; } // Endpoint returns compact binary (CbObjectWriter), not text if (resp.status === 200 || resp.status === 202) { this._pending_highlight = moduleId; this._pending_highlight_timer = setTimeout(() => { this._pending_highlight = null; }, 5000); } return true; } }); } _show_obliterate_modal() { const { error_div } = this._show_module_input_modal({ title: "\uD83D\uDD25 Obliterate Module", submit_label: "\uD83D\uDD25 Obliterate", warning: "\uD83D\uDD25 WARNING: This action is irreversible! \uD83D\uDD25\nAll local and backend data will be permanently destroyed.", on_submit: async (moduleId) => { const resp = await fetch(`/hub/modules/${encodeURIComponent(moduleId)}`, { method: "DELETE" }); if (resp.ok) { return true; } const msg = await resp.text(); error_div.textContent = msg || ("HTTP " + resp.status); return false; } }); } _navigate_to_module(moduleId) { const idx = this._modules_data.findIndex(m => m.moduleId === moduleId); if (idx >= 0) { this._page = Math.floor(idx / this._page_size); this._render_page(); } } _flash_module(id) { const cached = this._row_cache.get(id); if (cached) { flash_highlight(cached.tr); } } }