aboutsummaryrefslogtreecommitdiff
path: root/src/zenserver/frontend/html/pages/hub.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/zenserver/frontend/html/pages/hub.js')
-rw-r--r--src/zenserver/frontend/html/pages/hub.js375
1 files changed, 352 insertions, 23 deletions
diff --git a/src/zenserver/frontend/html/pages/hub.js b/src/zenserver/frontend/html/pages/hub.js
index 78e3a090c..b2bca9324 100644
--- a/src/zenserver/frontend/html/pages/hub.js
+++ b/src/zenserver/frontend/html/pages/hub.js
@@ -6,6 +6,7 @@ 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"]);
@@ -20,6 +21,7 @@ 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;
}
@@ -82,7 +84,7 @@ export class Page extends ZenPage
this.set_title("hub");
// Capacity
- const stats_section = this.add_section("Capacity");
+ const stats_section = this._collapsible_section("Hub Service Stats");
this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles");
// Modules
@@ -96,20 +98,24 @@ export class Page extends ZenPage
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]));
+ 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("\u2715", "Deprovision All",() => this._confirm_all("deprovision", "Deprovision 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
@@ -152,6 +158,38 @@ export class Page extends ZenPage
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);
@@ -164,8 +202,11 @@ export class Page extends ZenPage
this._row_cache = new Map(); // moduleId → row refs, for in-place DOM updates
this._updating = false;
this._page = 0;
- this._page_size = 50;
+ 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);
@@ -184,6 +225,15 @@ export class Page extends ZenPage
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; }
@@ -203,27 +253,48 @@ export class Page extends ZenPage
{
const tile = grid.tag().classify("card").classify("stats-tile");
- tile.tag().classify("card-title").text("Active Modules");
+ 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 tile = grid.tag().classify("card").classify("stats-tile");
- tile.tag().classify("card-title").text("Peak Modules");
- const body = tile.tag().classify("tile-metrics");
- this._metric(body, Friendly.sep(max), "high watermark", true);
- }
+ 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");
- tile.tag().classify("card-title").text("Instance Limit");
- const body = tile.tag().classify("tile-metrics");
- this._metric(body, Friendly.sep(limit), "maximum allowed", true);
- if (limit > 0)
+ 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)
{
- const pct = ((current / limit) * 100).toFixed(0) + "%";
- this._metric(body, pct, "utilization");
+ this._metric(right, Friendly.bytes(vmem_used), "vmem used", true);
+ this._metric(right, Friendly.bytes(machine.virtual_memory_total_mib * 1024 * 1024), "vmem total");
}
}
}
@@ -274,7 +345,7 @@ export class Page extends ZenPage
row.idx.textContent = i + 1;
row.cb.checked = this._selected.has(id);
row.dot.setAttribute("data-state", state);
- if (state === "deprovisioning")
+ if (state === "deprovisioning" || state === "obliterating")
{
row.dot.setAttribute("data-prev-state", prev);
}
@@ -284,10 +355,20 @@ export class Page extends ZenPage
}
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)
{
@@ -347,6 +428,8 @@ export class Page extends ZenPage
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);
@@ -354,7 +437,7 @@ export class Page extends ZenPage
const dot = document.createElement("span");
dot.className = "module-state-dot";
dot.setAttribute("data-state", state);
- if (state === "deprovisioning")
+ if (state === "deprovisioning" || state === "obliterating")
{
dot.setAttribute("data-prev-state", prev);
}
@@ -368,27 +451,33 @@ export class Page extends ZenPage
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(`${window.location.protocol}//${window.location.hostname}:${port}`, "_blank");
+ 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("\u2715", "Deprovision", () => this._confirm_deprovision([id]));
+ 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 from process_metrics keys.
+ // 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();
@@ -396,6 +485,28 @@ export class Page extends ZenPage
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) => {
@@ -423,7 +534,7 @@ export class Page extends ZenPage
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 };
+ 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);
}
@@ -533,6 +644,7 @@ export class Page extends ZenPage
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;
@@ -545,6 +657,7 @@ export class Page extends ZenPage
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()
@@ -590,6 +703,35 @@ export class Page extends ZenPage
.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
@@ -614,4 +756,191 @@ export class Page extends ZenPage
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); }
+ }
+
}