diff options
Diffstat (limited to 'src/zenserver/frontend/html/util')
| -rw-r--r-- | src/zenserver/frontend/html/util/compactbinary.js | 45 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/util/friendly.js | 74 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/util/sanitize.js | 9 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/util/widgets.js | 323 |
4 files changed, 447 insertions, 4 deletions
diff --git a/src/zenserver/frontend/html/util/compactbinary.js b/src/zenserver/frontend/html/util/compactbinary.js index 90e4249f6..270c96a2f 100644 --- a/src/zenserver/frontend/html/util/compactbinary.js +++ b/src/zenserver/frontend/html/util/compactbinary.js @@ -310,8 +310,8 @@ CbFieldView.prototype.as_value = function(int_type=BigInt) case CbFieldType.IntegerPositive: return VarInt.read_uint(this._data_view, int_type)[0]; case CbFieldType.IntegerNegative: return VarInt.read_int(this._data_view, int_type)[0]; - case CbFieldType.Float32: return new DataView(this._data_view.subarray(0, 4)).getFloat32(0, false); - case CbFieldType.Float64: return new DataView(this._data_view.subarray(0, 8)).getFloat64(0, false); + case CbFieldType.Float32: { const s = this._data_view; return new DataView(s.buffer, s.byteOffset, 4).getFloat32(0, false); } + case CbFieldType.Float64: { const s = this._data_view; return new DataView(s.buffer, s.byteOffset, 8).getFloat64(0, false); } case CbFieldType.BoolFalse: return false; case CbFieldType.BoolTrue: return true; @@ -369,6 +369,14 @@ CbObjectView.prototype[Symbol.iterator] = function() //////////////////////////////////////////////////////////////////////////////// CbObjectView.prototype.to_js_object = function() { + const TicksPerMs = 10000; + const UnixEpochTicks = 621355968000000000n; // .NET ticks at 1970-01-01 + + const readTicks = function(data) { + const dv = new DataView(data.buffer, data.byteOffset, 8); + return dv.getBigInt64(0, false); + }; + const impl = function(node) { if (node.is_object()) @@ -388,9 +396,40 @@ CbObjectView.prototype.to_js_object = function() } if (node.is_string()) return node.as_value(); - if (node.is_integer()) return node.as_value(); if (node.is_float()) return node.as_value(); + if (node.is_integer()) + { + const v = node.as_value(); + if (v >= -9007199254740991n && v <= 9007199254740991n) + return Number(v); + return v; + } + + const type = CbFieldTypeOps.get_type(node.get_type()); + + if (type == CbFieldType.DateTime) + { + const ticks = readTicks(node.as_value()); + const unixMs = Number((ticks - UnixEpochTicks) / BigInt(TicksPerMs)); + return new Date(unixMs).toISOString(); + } + + if (type == CbFieldType.TimeSpan) + { + const ticks = readTicks(node.as_value()); + const absTicks = ticks < 0n ? -ticks : ticks; + const totalMs = Number(absTicks / BigInt(TicksPerMs)); + const ms = totalMs % 1000; + const totalSec = Math.floor(totalMs / 1000); + const sec = totalSec % 60; + const totalMin = Math.floor(totalSec / 60); + const min = totalMin % 60; + const hours = Math.floor(totalMin / 60); + const sign = ticks < 0n ? "-" : ""; + return `${sign}${hours}:${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}.${String(ms).padStart(3, "0")}0000`; + } + var ret = node.as_value(); if (ret instanceof Uint8Array) { diff --git a/src/zenserver/frontend/html/util/friendly.js b/src/zenserver/frontend/html/util/friendly.js index a15252faf..f400bbce0 100644 --- a/src/zenserver/frontend/html/util/friendly.js +++ b/src/zenserver/frontend/html/util/friendly.js @@ -20,4 +20,78 @@ export class Friendly static kib(x, p=0) { return Friendly.sep((BigInt(x) + 1023n) / (1n << 10n)|0n, p) + " KiB"; } static mib(x, p=1) { return Friendly.sep( BigInt(x) / (1n << 20n), p) + " MiB"; } static gib(x, p=2) { return Friendly.sep( BigInt(x) / (1n << 30n), p) + " GiB"; } + + static duration(s) + { + const v = Number(s); + if (v >= 1) return Friendly.sep(v, 2) + " s"; + if (v >= 0.001) return Friendly.sep(v * 1000, 2) + " ms"; + if (v >= 0.000001) return Friendly.sep(v * 1000000, 1) + " µs"; + return Friendly.sep(v * 1000000000, 0) + " ns"; + } + + /** Format a .NET-style TimeSpan string (e.g. "0:05:23.4560000") as a human-readable duration. */ + static timespan(value) + { + if (typeof value === "number") + { + return Friendly._formatDurationMs(value); + } + + const str = String(value); + const match = str.match(/^[+-]?(?:(\d+)\.)?(\d+):(\d+):(\d+)(?:\.(\d+))?$/); + if (!match) + { + return str; + } + + const days = parseInt(match[1] || "0", 10); + const hours = parseInt(match[2], 10); + const minutes = parseInt(match[3], 10); + const seconds = parseInt(match[4], 10); + const frac = match[5] ? parseInt(match[5].substring(0, 3).padEnd(3, "0"), 10) : 0; + const totalMs = ((days * 86400 + hours * 3600 + minutes * 60 + seconds) * 1000) + frac; + + return Friendly._formatDurationMs(totalMs); + } + + static _formatDurationMs(ms) + { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`; + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + if (seconds > 0) return `${seconds}.${String(ms % 1000).padStart(3, "0")}s`; + return `${ms}ms`; + } + + /** Format an ISO / .NET datetime string as a friendly local date+time. */ + static datetime(value) + { + const d = new Date(value); + if (isNaN(d.getTime())) + { + return String(value); + } + return d.toLocaleString(undefined, { + year: "numeric", month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", second: "2-digit", + }); + } + + static bytes(x) + { + const v = BigInt(Math.trunc(Number(x))); + if (v >= (1n << 60n)) return Friendly.sep(Number(v) / Number(1n << 60n), 2) + " EiB"; + if (v >= (1n << 50n)) return Friendly.sep(Number(v) / Number(1n << 50n), 2) + " PiB"; + if (v >= (1n << 40n)) return Friendly.sep(Number(v) / Number(1n << 40n), 2) + " TiB"; + if (v >= (1n << 30n)) return Friendly.sep(Number(v) / Number(1n << 30n), 2) + " GiB"; + if (v >= (1n << 20n)) return Friendly.sep(Number(v) / Number(1n << 20n), 1) + " MiB"; + if (v >= (1n << 10n)) return Friendly.sep(Number(v) / Number(1n << 10n), 0) + " KiB"; + return Friendly.sep(Number(v), 0) + " B"; + } } diff --git a/src/zenserver/frontend/html/util/sanitize.js b/src/zenserver/frontend/html/util/sanitize.js new file mode 100644 index 000000000..1b0f32e38 --- /dev/null +++ b/src/zenserver/frontend/html/util/sanitize.js @@ -0,0 +1,9 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +// Shared utility functions for compute dashboard pages. + +function escapeHtml(text) { + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/src/zenserver/frontend/html/util/widgets.js b/src/zenserver/frontend/html/util/widgets.js index 32a3f4d28..651686a11 100644 --- a/src/zenserver/frontend/html/util/widgets.js +++ b/src/zenserver/frontend/html/util/widgets.js @@ -6,6 +6,58 @@ import { Component } from "./component.js" import { Friendly } from "../util/friendly.js" //////////////////////////////////////////////////////////////////////////////// +export function flash_highlight(element) +{ + if (!element) { return; } + element.classList.add("pager-search-highlight"); + setTimeout(() => { element.classList.remove("pager-search-highlight"); }, 1500); +} + +//////////////////////////////////////////////////////////////////////////////// +export function copy_button(value_or_fn) +{ + if (!navigator.clipboard) + { + const stub = document.createElement("span"); + stub.style.display = "none"; + return stub; + } + + let reset_timer = 0; + const btn = document.createElement("button"); + btn.className = "zen-copy-btn"; + btn.title = "Copy to clipboard"; + btn.textContent = "\u29C9"; + btn.addEventListener("click", async (e) => { + e.stopPropagation(); + const v = typeof value_or_fn === "function" ? value_or_fn() : value_or_fn; + if (!v) { return; } + try + { + await navigator.clipboard.writeText(v); + clearTimeout(reset_timer); + btn.classList.add("zen-copy-ok"); + btn.textContent = "\u2713"; + reset_timer = setTimeout(() => { btn.classList.remove("zen-copy-ok"); btn.textContent = "\u29C9"; }, 800); + } + catch (_e) { /* clipboard not available */ } + }); + return btn; +} + +// Wraps the existing children of `element` plus a copy button into an +// inline-flex nowrap container so the button never wraps to a new line. +export function add_copy_button(element, value_or_fn) +{ + if (!navigator.clipboard) { return; } + const wrap = document.createElement("span"); + wrap.className = "zen-copy-wrap"; + while (element.firstChild) { wrap.appendChild(element.firstChild); } + wrap.appendChild(copy_button(value_or_fn)); + element.appendChild(wrap); +} + +//////////////////////////////////////////////////////////////////////////////// class Widget extends Component { } @@ -54,6 +106,8 @@ export class Table extends Widget static Flag_PackRight = 1 << 1; static Flag_BiasLeft = 1 << 2; static Flag_FitLeft = 1 << 3; + static Flag_Sortable = 1 << 4; + static Flag_AlignNumeric = 1 << 5; constructor(parent, column_names, flags=Table.Flag_EvenSpacing, index_base=0) { @@ -81,11 +135,108 @@ export class Table extends Widget root.style("gridTemplateColumns", column_style); - this._add_row(column_names, false); + this._header_row = this._add_row(column_names, false); this._index = index_base; this._num_columns = column_names.length; this._rows = []; + this._flags = flags; + this._sort_column = -1; + this._sort_ascending = true; + + if (flags & Table.Flag_Sortable) + { + this._init_sortable(); + } + } + + _init_sortable() + { + const header_elem = this._element.firstElementChild; + if (!header_elem) + { + return; + } + + const cells = header_elem.children; + for (let i = 0; i < cells.length; i++) + { + const cell = cells[i]; + cell.style.cursor = "pointer"; + cell.style.userSelect = "none"; + cell.addEventListener("click", () => this._sort_by(i)); + } + } + + _sort_by(column_index) + { + if (this._sort_column === column_index) + { + this._sort_ascending = !this._sort_ascending; + } + else + { + this._sort_column = column_index; + this._sort_ascending = true; + } + + // Update header indicators + const header_elem = this._element.firstElementChild; + for (const cell of header_elem.children) + { + const text = cell.textContent.replace(/ [▲▼]$/, ""); + cell.textContent = text; + } + const active_cell = header_elem.children[column_index]; + active_cell.textContent += this._sort_ascending ? " ▲" : " ▼"; + + // Sort rows by comparing cell text content + const dir = this._sort_ascending ? 1 : -1; + const unit_multipliers = { "B": 1, "KiB": 1024, "MiB": 1048576, "GiB": 1073741824, "TiB": 1099511627776, "PiB": 1125899906842624, "EiB": 1152921504606846976 }; + const parse_sortable = (text) => { + // Try byte units first (e.g. "1,234 KiB", "1.5 GiB") + const byte_match = text.match(/^([\d,.]+)\s*(B|[KMGTPE]iB)/); + if (byte_match) + { + const num = parseFloat(byte_match[1].replace(/,/g, "")); + const mult = unit_multipliers[byte_match[2]] || 1; + return num * mult; + } + // Try percentage (e.g. "95.5%") + const pct_match = text.match(/^([\d,.]+)%/); + if (pct_match) + { + return parseFloat(pct_match[1].replace(/,/g, "")); + } + // Try plain number (possibly with commas/separators) + const num = parseFloat(text.replace(/,/g, "")); + if (!isNaN(num)) + { + return num; + } + return null; + }; + this._rows.sort((a, b) => { + const aElem = a.inner().children[column_index]; + const bElem = b.inner().children[column_index]; + const aText = aElem ? aElem.textContent : ""; + const bText = bElem ? bElem.textContent : ""; + + const aNum = parse_sortable(aText); + const bNum = parse_sortable(bText); + + if (aNum !== null && bNum !== null) + { + return (aNum - bNum) * dir; + } + return aText.localeCompare(bText) * dir; + }); + + // Re-order DOM elements + for (const row of this._rows) + { + this._element.appendChild(row.inner()); + } } *[Symbol.iterator]() @@ -121,6 +272,18 @@ export class Table extends Widget ret.push(new TableCell(leaf, row)); } + if ((this._flags & Table.Flag_AlignNumeric) && indexed) + { + for (const c of ret) + { + const t = c.inner().textContent; + if (t && /^\d/.test(t)) + { + c.style("textAlign", "right"); + } + } + } + if (this._index >= 0) ret.shift(); @@ -131,9 +294,38 @@ export class Table extends Widget { var row = this._add_row(args); this._rows.push(row); + + if (this._flags & Table.Flag_AlignNumeric) + { + this._align_header(); + } + return row; } + _align_header() + { + if (this._rows.length === 0) + { + return; + } + const header_elem = this._element.firstElementChild; + const header_cells = header_elem.children; + + // A column is numeric if any data row has right-aligned content in it. + for (const row of this._rows) + { + const data_cells = row.inner().children; + for (let i = 0; i < data_cells.length && i < header_cells.length; i++) + { + if (data_cells[i].style.textAlign === "right") + { + header_cells[i].style.textAlign = "right"; + } + } + } + } + clear(index=0) { const elem = this._element; @@ -262,6 +454,135 @@ export class ProgressBar extends Widget //////////////////////////////////////////////////////////////////////////////// +export class Pager +{ + constructor(section, page_size, on_change, search_fn) + { + this._page = 0; + this._page_size = page_size; + this._total = 0; + this._on_change = on_change; + this._search_fn = search_fn || null; + this._search_input = null; + + const pager = 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._label = document.createElement("span"); + this._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)); + + if (this._search_fn) + { + this._search_input = document.createElement("input"); + this._search_input.type = "text"; + this._search_input.className = "module-pager-search"; + this._search_input.placeholder = "Search\u2026"; + this._search_input.addEventListener("keydown", (e) => + { + if (e.key === "Enter") + { + this._do_search(this._search_input.value.trim()); + } + }); + pager.appendChild(this._search_input); + } + + pager.appendChild(this._btn_prev); + pager.appendChild(this._label); + pager.appendChild(this._btn_next); + this._pager = pager; + + this._update_ui(); + } + + prepend(element) + { + const ref = this._search_input || this._btn_prev; + this._pager.insertBefore(element, ref); + } + + set_total(n) + { + this._total = n; + const max_page = Math.max(0, Math.ceil(n / this._page_size) - 1); + if (this._page > max_page) + { + this._page = max_page; + } + this._update_ui(); + } + + page_range() + { + const start = this._page * this._page_size; + const end = Math.min(start + this._page_size, this._total); + return { start, end }; + } + + _go_page(n) + { + const max = Math.max(0, Math.ceil(this._total / this._page_size) - 1); + this._page = Math.max(0, Math.min(n, max)); + this._update_ui(); + this._on_change(); + } + + _do_search(term) + { + if (!term || !this._search_fn) + { + return; + } + const result = this._search_fn(term); + if (!result) + { + this._search_input.style.outline = "2px solid var(--theme_fail)"; + setTimeout(() => { this._search_input.style.outline = ""; }, 1000); + return; + } + this._go_page(Math.floor(result.index / this._page_size)); + flash_highlight(this._pager.parentNode.querySelector(`[zs_name="${CSS.escape(result.name)}"]`)); + } + + _update_ui() + { + const total = this._total; + 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._label.textContent = total === 0 + ? "No items" + : `${start}\u2013${end} of ${total}`; + } + + static make_search_fn(get_data, get_key) + { + return (term) => { + const t = term.toLowerCase(); + const data = get_data(); + const i = data.findIndex(item => get_key(item).toLowerCase().includes(t)); + return i < 0 ? null : { index: i, name: get_key(data[i]) }; + }; + } + + static loading(section) + { + return section.tag().classify("pager-loading").text("Loading\u2026").inner(); + } +} + + + +//////////////////////////////////////////////////////////////////////////////// export class WidgetHost { constructor(parent, depth=1) |