diff options
Diffstat (limited to 'src/zenserver/frontend/html/pages/storage.js')
| -rw-r--r-- | src/zenserver/frontend/html/pages/storage.js | 580 |
1 files changed, 580 insertions, 0 deletions
diff --git a/src/zenserver/frontend/html/pages/storage.js b/src/zenserver/frontend/html/pages/storage.js new file mode 100644 index 000000000..55f510ccd --- /dev/null +++ b/src/zenserver/frontend/html/pages/storage.js @@ -0,0 +1,580 @@ +// 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" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + generate_crumbs() {} + + async main() + { + this.set_title("storage"); + + const storage = await new Fetcher().resource("/admin/storage").json(); + const volumes = storage.volumes || []; + + for (let i = 0; i < volumes.length; i++) + { + const vol = volumes[i]; + const label = volumes.length === 1 ? "Volume" : `Volume ${i + 1}`; + const section = this.add_section(label); + const grid = section.tag().classify("grid").classify("info-tiles"); + + // Volume overview tile + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Disk"); + const list = tile.tag().classify("info-props"); + + this._prop(list, "total", Friendly.bytes(vol.total)); + this._prop(list, "used", Friendly.bytes(vol.used)); + this._prop(list, "free", Friendly.bytes(vol.free)); + + if (vol.total > 0) + { + const pct = ((vol.used / vol.total) * 100).toFixed(1) + "%"; + this._prop(list, "utilization", pct); + } + } + + // Per-directory tiles + const dirs = vol.directories || []; + for (const dir of dirs) + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text(dir.name); + const list = tile.tag().classify("info-props"); + + this._prop(list, "size", Friendly.bytes(dir.bytes)); + this._prop(list, "files", Friendly.sep(dir.files)); + this._prop(list, "path", dir.path); + } + } + + // Cache namespaces breakdown + try + { + const zcache = await new Fetcher().resource("/z$/").json(); + const namespaces = zcache["Namespaces"] || []; + + if (namespaces.length > 0) + { + const section = this.add_section("Cache Namespaces"); + const grid = section.tag().classify("grid").classify("info-tiles"); + + await Promise.all(namespaces.map(async (ns) => { + try + { + const data = await new Fetcher().resource(`/z$/${ns}/`).json(); + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text(ns); + const list = tile.tag().classify("info-props"); + + this._prop(list, "disk", Friendly.bytes(data["StorageSize"].DiskSize)); + this._prop(list, "memory", Friendly.bytes(data["StorageSize"].MemorySize)); + this._prop(list, "entries", Friendly.sep(data["EntryCount"])); + this._prop(list, "buckets", data["Buckets"].length); + } + catch (e) { /* skip failed namespace */ } + })); + } + } + catch (e) { /* cache service unavailable */ } + + // GC status + await this._render_gc_status(); + + // GC activity log + await this._render_gc_log(); + } + + async _render_gc_status() + { + try + { + const gc = await new Fetcher().resource("/admin/gc").json(); + if (!gc) + { + return; + } + + const section = this.add_section("Garbage Collection"); + const grid = section.tag().classify("grid").classify("info-tiles"); + + // Status tile + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Status"); + const list = tile.tag().classify("info-props"); + + this._prop(list, "status", gc.Status || "unknown"); + if (gc.AreDiskWritesBlocked !== undefined) + { + this._prop(list, "disk writes blocked", gc.AreDiskWritesBlocked ? "yes" : "no"); + } + if (gc.Config) + { + this._prop(list, "gc enabled", gc.Config.Enabled ? "yes" : "no"); + } + } + + // Full GC tile + if (gc.FullGC) + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Full GC"); + const list = tile.tag().classify("info-props"); + + if (gc.FullGC.TimeToNext) + { + this._prop(list, "next run", Friendly.timespan(gc.FullGC.TimeToNext)); + } + if (gc.FullGC.LastTime) + { + this._prop(list, "last run", Friendly.datetime(gc.FullGC.LastTime)); + } + if (gc.FullGC.Elapsed) + { + this._prop(list, "last duration", Friendly.timespan(gc.FullGC.Elapsed)); + } + if (gc.FullGC.RemovedDisk) + { + this._prop(list, "last disk freed", gc.FullGC.RemovedDisk); + } + if (gc.FullGC.FreedMemory) + { + this._prop(list, "last memory freed", gc.FullGC.FreedMemory); + } + if (gc.Config && gc.Config.Interval) + { + this._prop(list, "interval", Friendly.timespan(gc.Config.Interval)); + } + } + + // Lightweight GC tile + if (gc.LightweightGC) + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Lightweight GC"); + const list = tile.tag().classify("info-props"); + + if (gc.LightweightGC.TimeToNext) + { + this._prop(list, "next run", Friendly.timespan(gc.LightweightGC.TimeToNext)); + } + if (gc.LightweightGC.LastTime) + { + this._prop(list, "last run", Friendly.datetime(gc.LightweightGC.LastTime)); + } + if (gc.LightweightGC.Elapsed) + { + this._prop(list, "last duration", Friendly.timespan(gc.LightweightGC.Elapsed)); + } + if (gc.LightweightGC.RemovedDisk) + { + this._prop(list, "last disk freed", gc.LightweightGC.RemovedDisk); + } + if (gc.LightweightGC.FreedMemory) + { + this._prop(list, "last memory freed", gc.LightweightGC.FreedMemory); + } + if (gc.Config && gc.Config.LightweightInterval) + { + this._prop(list, "interval", Friendly.timespan(gc.Config.LightweightInterval)); + } + } + + // Expiration config tile + if (gc.Config) + { + const cfg = gc.Config; + const has_expiration = cfg.MaxCacheDuration || cfg.MaxProjectStoreDuration || cfg.MaxBuildStoreDuration; + if (has_expiration) + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Expiration"); + const list = tile.tag().classify("info-props"); + + if (cfg.MaxCacheDuration) + { + this._prop(list, "cache", Friendly.timespan(cfg.MaxCacheDuration)); + } + if (cfg.MaxProjectStoreDuration) + { + this._prop(list, "project store", Friendly.timespan(cfg.MaxProjectStoreDuration)); + } + if (cfg.MaxBuildStoreDuration) + { + this._prop(list, "build store", Friendly.timespan(cfg.MaxBuildStoreDuration)); + } + } + } + } + catch (e) { /* gc endpoint unavailable */ } + } + + async _render_gc_log() + { + try + { + const data = await new Fetcher().resource("/admin/gclog").json(); + const entries = data.entries || []; + if (entries.length === 0) + { + return; + } + + // Parse entries (oldest first in file), reverse to show newest first + const parsed = []; + for (const entry of entries) + { + const keys = Object.keys(entry); + if (keys.length === 0) + { + continue; + } + const id = keys[0]; + const run = entry[id]; + if (run) + { + parsed.push({ id, run }); + } + } + parsed.reverse(); + + this._gc_entries = parsed; + this._gc_page = 0; + this._gc_page_size = 20; + this._gc_sort_col = -1; + this._gc_sort_asc = true; + + const section = this.add_section("Garbage Collection History"); + this._gc_table_host = section.tag(); + this._gc_pager = section.tag().classify("sessions-pager"); + this._gc_detail = section.tag().classify("card").style("marginTop", "16px"); + this._gc_detail.inner().style.display = "none"; + this._gc_selected_row = null; + + this._gc_render_page(); + } + catch (e) { /* gc log unavailable */ } + } + + _gc_render_page() + { + const parsed = this._gc_entries; + const page = this._gc_page; + const pageSize = this._gc_page_size; + const totalPages = Math.ceil(parsed.length / pageSize); + const start = page * pageSize; + const end = Math.min(start + pageSize, parsed.length); + + // Clear previous table and detail + this._gc_table_host.inner().innerHTML = ""; + this._gc_detail.inner().style.display = "none"; + this._gc_selected_row = null; + + const columns = ["id", "started", "elapsed", "disk freed", "memory freed"]; + + // Dynamically import Table (cached by the module loader after first import) + import("../util/widgets.js").then(({ Table }) => { + const table = new Table(this._gc_table_host, columns, Table.Flag_FitLeft | Table.Flag_PackRight, -1); + + for (let i = start; i < end; i++) + { + const { id, run } = parsed[i]; + const result = run.Result || {}; + const compact = result.Compact || {}; + + const diskFreed = compact.RemovedDiskBytes; + const memFreed = result.Referencer + ? (result.Referencer.RemoveExpiredData || {}).FreedMemoryBytes || 0 + : 0; + + const row = table.add_row( + id, + run.StartTime ? Friendly.datetime(run.StartTime) : "-", + result.Elapsed ? Friendly.timespan(result.Elapsed) : "-", + diskFreed !== undefined ? Friendly.bytes(diskFreed) : "-", + memFreed ? Friendly.bytes(memFreed) : "-", + ); + row.get_cell(0).style("fontFamily", "'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace"); + row.get_cell(0).style("fontSize", "12px"); + for (let c = 0; c < columns.length; c++) + { + row.get_cell(c).style("textAlign", "right"); + } + + const rowElem = row.inner(); + rowElem.style.cursor = "pointer"; + rowElem.addEventListener("click", () => this._show_gc_detail(row, id, run)); + } + + // Sortable column headers + const headerCells = table.inner().firstElementChild.children; + for (let c = 0; c < headerCells.length; c++) + { + headerCells[c].style.textAlign = "right"; + headerCells[c].style.cursor = "pointer"; + headerCells[c].style.userSelect = "none"; + headerCells[c].addEventListener("click", () => this._gc_sort_by(c)); + } + this._gc_update_sort_indicator(headerCells); + + // Pager + this._gc_pager.inner().innerHTML = ""; + if (totalPages > 1) + { + const prev = this._gc_pager.tag("button").text("\u2039 Prev"); + prev.style("opacity", page === 0 ? "0.4" : "1"); + if (page > 0) + { + prev.on_click(() => { this._gc_page--; this._gc_render_page(); }); + } + + this._gc_pager.tag().classify("sessions-pager-label") + .text(`${start + 1}\u2013${end} of ${parsed.length}`); + + const next = this._gc_pager.tag("button").text("Next \u203A"); + next.style("opacity", page >= totalPages - 1 ? "0.4" : "1"); + if (page < totalPages - 1) + { + next.on_click(() => { this._gc_page++; this._gc_render_page(); }); + } + } + }); + } + + _gc_sort_by(col) + { + if (this._gc_sort_col === col) + { + this._gc_sort_asc = !this._gc_sort_asc; + } + else + { + this._gc_sort_col = col; + this._gc_sort_asc = true; + } + + const key_fns = [ + (e) => e.id, + (e) => e.run.StartTime || "", + (e) => e.run.Result?.Elapsed || "", + (e) => e.run.Result?.Compact?.RemovedDiskBytes ?? 0, + (e) => { + const ref = e.run.Result?.Referencer; + return ref ? (ref.RemoveExpiredData?.FreedMemoryBytes || 0) : 0; + }, + ]; + const key_fn = key_fns[col]; + const dir = this._gc_sort_asc ? 1 : -1; + + this._gc_entries.sort((a, b) => { + const ak = key_fn(a); + const bk = key_fn(b); + if (typeof ak === "number" && typeof bk === "number") + { + return (ak - bk) * dir; + } + return String(ak).localeCompare(String(bk)) * dir; + }); + + this._gc_page = 0; + this._gc_render_page(); + } + + _gc_update_sort_indicator(headerCells) + { + for (let c = 0; c < headerCells.length; c++) + { + const text = headerCells[c].textContent.replace(/ [▲▼]$/, ""); + headerCells[c].textContent = text; + if (c === this._gc_sort_col) + { + headerCells[c].textContent += this._gc_sort_asc ? " ▲" : " ▼"; + } + } + } + + _show_gc_detail(row, id, run) + { + // Toggle selection + if (this._gc_selected_row) + { + this._gc_selected_row.inner().classList.remove("sessions-selected"); + } + + if (this._gc_selected_row === row) + { + this._gc_selected_row = null; + this._gc_detail.inner().style.display = "none"; + return; + } + + this._gc_selected_row = row; + row.inner().classList.add("sessions-selected"); + this._gc_detail.inner().style.display = ""; + this._gc_detail.inner().innerHTML = ""; + + this._gc_detail.tag().classify("card-title").text(`GC Run: ${id}`); + + const result = run.Result || {}; + const settings = run.Settings || {}; + + // Timing section + { + const grid = this._gc_detail.tag().classify("grid").classify("info-tiles"); + + // Timing tile + { + const tile = grid.tag().classify("info-tile"); + tile.tag().style("fontWeight", "600").style("fontSize", "12px").style("color", "var(--theme_g1)") + .style("textTransform", "uppercase").style("letterSpacing", "0.5px").style("marginBottom", "8px") + .text("Timing"); + const list = tile.tag().classify("info-props"); + + this._prop(list, "started", run.StartTime ? Friendly.datetime(run.StartTime) : "-"); + this._prop(list, "total elapsed", result.Elapsed ? Friendly.timespan(result.Elapsed) : "-"); + this._prop(list, "write block", result.WriteBlock ? Friendly.timespan(result.WriteBlock) : "-"); + + const timings = [ + ["remove expired data", result.RemoveExpiredData], + ["create reference checkers", result.CreateReferenceCheckers], + ["pre-cache state", result.PreCacheState], + ["lock state", result.LockState], + ["update locked state", result.UpdateLockedState], + ["create reference pruners", result.CreateReferencePruners], + ["remove unreferenced data", result.RemoveUnreferencedData], + ["compact stores", result.CompactStores], + ["validate", result.Validate], + ["create reference validators", result.CreateReferenceValidators], + ]; + for (const [label, value] of timings) + { + if (value) + { + this._prop(list, label, Friendly.timespan(value)); + } + } + } + + // Settings tile + { + const tile = grid.tag().classify("info-tile"); + tile.tag().style("fontWeight", "600").style("fontSize", "12px").style("color", "var(--theme_g1)") + .style("textTransform", "uppercase").style("letterSpacing", "0.5px").style("marginBottom", "8px") + .text("Settings"); + const list = tile.tag().classify("info-props"); + + const fields = [ + ["cache expire", settings.CacheExpireTime], + ["project store expire", settings.ProjectStoreExpireTime], + ["build store expire", settings.BuildStoreExpireTime], + ["collect small objects", settings.CollectSmallObjects], + ["delete mode", settings.IsDeleteMode], + ["verbose", settings.Verbose], + ["single thread", settings.SingleThread], + ["compact threshold", settings.CompactBlockUsageThresholdPercent !== undefined + ? settings.CompactBlockUsageThresholdPercent + "%" : undefined], + ["validation", settings.EnableValidation], + ]; + for (const [label, value] of fields) + { + if (value !== undefined) + { + this._prop(list, label, String(value)); + } + } + } + } + + // Per-referencer breakdown + const referencers = result.Referencers || []; + if (referencers.length > 0) + { + import("../util/widgets.js").then(({ Table }) => { + const sub = this._gc_detail.tag().style("marginTop", "16px"); + sub.tag().style("fontWeight", "600").style("fontSize", "12px").style("color", "var(--theme_g1)") + .style("textTransform", "uppercase").style("letterSpacing", "0.5px").style("marginBottom", "8px") + .text("Per-Referencer Stats"); + + const table = new Table(sub, + ["name", "checked", "deleted", "memory freed", "disk freed", "elapsed"], + Table.Flag_FitLeft | Table.Flag_PackRight | Table.Flag_AlignNumeric, -1); + + for (const ref of referencers) + { + const expired = ref.RemoveExpired || {}; + const compact = ref.Compact || {}; + table.add_row( + ref.Name || "unknown", + expired.Checked !== undefined ? Friendly.sep(expired.Checked) : "-", + expired.Deleted !== undefined ? Friendly.sep(expired.Deleted) : "-", + expired.FreedMemoryBytes !== undefined ? Friendly.bytes(expired.FreedMemoryBytes) : "-", + compact.RemovedDiskBytes !== undefined ? Friendly.bytes(compact.RemovedDiskBytes) : "-", + ref.Elapsed ? Friendly.timespan(ref.Elapsed) : "-", + ); + } + }); + } + + // Per-reference-store breakdown + const refStores = result.ReferenceStores || []; + if (refStores.length > 0) + { + import("../util/widgets.js").then(({ Table }) => { + const sub = this._gc_detail.tag().style("marginTop", "16px"); + sub.tag().style("fontWeight", "600").style("fontSize", "12px").style("color", "var(--theme_g1)") + .style("textTransform", "uppercase").style("letterSpacing", "0.5px").style("marginBottom", "8px") + .text("Per-Reference Store Stats"); + + const table = new Table(sub, + ["name", "checked", "deleted", "disk freed", "elapsed"], + Table.Flag_FitLeft | Table.Flag_PackRight | Table.Flag_AlignNumeric, -1); + + for (const store of refStores) + { + const unref = store.RemoveUnreferenced || {}; + const compact = store.Compact || {}; + table.add_row( + store.Name || "unknown", + unref.Checked !== undefined ? Friendly.sep(unref.Checked) : "-", + unref.Deleted !== undefined ? Friendly.sep(unref.Deleted) : "-", + compact.RemovedDiskBytes !== undefined ? Friendly.bytes(compact.RemovedDiskBytes) : "-", + store.Elapsed ? Friendly.timespan(store.Elapsed) : "-", + ); + } + }); + } + + // Validation + const validator = result.ReferenceValidator || {}; + if (validator.Checked !== undefined) + { + const sub = this._gc_detail.tag().style("marginTop", "16px"); + sub.tag().style("fontWeight", "600").style("fontSize", "12px").style("color", "var(--theme_g1)") + .style("textTransform", "uppercase").style("letterSpacing", "0.5px").style("marginBottom", "8px") + .text("Validation"); + const list = sub.tag().classify("info-props"); + + this._prop(list, "checked", Friendly.sep(validator.Checked)); + if (validator.Missing !== undefined) + { + this._prop(list, "missing", Friendly.sep(validator.Missing)); + } + } + } + + _prop(parent, label, value) + { + const row = parent.tag().classify("info-prop"); + row.tag().classify("info-prop-label").text(label); + row.tag().classify("info-prop-value").text(String(value)); + } +} |