aboutsummaryrefslogtreecommitdiff
path: root/src/zenserver/frontend/html/pages
diff options
context:
space:
mode:
authorMartin Ridgers <[email protected]>2024-11-18 08:41:46 +0100
committerGitHub Enterprise <[email protected]>2024-11-18 08:41:46 +0100
commitcca69117b7ffac5cdd8933148ed9c94dd241528d (patch)
treeba9dfce342e86d9cbdf6cf54059e1e7d618eecee /src/zenserver/frontend/html/pages
parentoplog prep gc fix (#216) (diff)
downloadzen-cca69117b7ffac5cdd8933148ed9c94dd241528d.tar.xz
zen-cca69117b7ffac5cdd8933148ed9c94dd241528d.zip
Dashboard: oplog tree view (#217)
* Turned tables and progress bars and friends into "widgets!" * A step to abstracting away a page's the internal DOM structure * Folded sector creation into Page and pivoted it to a widget host * Try and keep start/count as numbers regardless of input * No need for the entry table to be defined up front now * Add op count and log sixe to oplog list page * Cache left side toolbar object * Bounds count page start when building list of oplog entrie * Start/end navigation tools * Build rest of entry page while waiting for indexer to load * Consistent naming with other pages * Spacially consolidate fetching code * Hide fetch latency to speed up index generation workers * Extract dashboard structure from zen.js monolith * Fix breadcrumbs after restructuring * Add view link to actions cell of oplogs list * Generator to enumerate names of entries in indexer * Methods for simple traversal of component relations * is() to check if a component is of a certain type * Extend attr() to get and unset a component's attributes * Unsetting all styles of anchor tags was underisrable * Restore page name as id of container element * A tree view of an oplog * Move helper class out to private module scope * Small tweak to use left var that already exists * Changelog update * Updated frontend .zip archive
Diffstat (limited to 'src/zenserver/frontend/html/pages')
-rw-r--r--src/zenserver/frontend/html/pages/entry.js195
-rw-r--r--src/zenserver/frontend/html/pages/oplog.js176
-rw-r--r--src/zenserver/frontend/html/pages/page.js128
-rw-r--r--src/zenserver/frontend/html/pages/project.js92
-rw-r--r--src/zenserver/frontend/html/pages/start.js94
-rw-r--r--src/zenserver/frontend/html/pages/stat.js153
-rw-r--r--src/zenserver/frontend/html/pages/test.js147
-rw-r--r--src/zenserver/frontend/html/pages/tree.js118
8 files changed, 1103 insertions, 0 deletions
diff --git a/src/zenserver/frontend/html/pages/entry.js b/src/zenserver/frontend/html/pages/entry.js
new file mode 100644
index 000000000..b166d0a6f
--- /dev/null
+++ b/src/zenserver/frontend/html/pages/entry.js
@@ -0,0 +1,195 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+"use strict";
+
+import { ZenPage } from "./page.js"
+import { Fetcher } from "../util/fetcher.js"
+import { Table, PropTable, Toolbar, ProgressBar } from "../util/widgets.js"
+import { create_indexer } from "../indexer/indexer.js"
+
+////////////////////////////////////////////////////////////////////////////////
+export class Page extends ZenPage
+{
+ main()
+ {
+ this.set_title("oplog entry");
+
+ const project = this.get_param("project");
+ const oplog = this.get_param("oplog");
+ const opkey = this.get_param("opkey");
+
+ this._entry = new Fetcher()
+ .resource("prj", project, "oplog", oplog, "entries")
+ .param("opkey", opkey)
+ .cbo();
+
+ this._indexer = this.load_indexer(project, oplog);
+
+ this._build_page();
+ }
+
+ async load_indexer(project, oplog, loaded_cb)
+ {
+ const progress_bar = this.add_widget(ProgressBar);
+ progress_bar.set_progress("indexing");
+ const indexer = await create_indexer(project, oplog, (...args) => {
+ progress_bar.set_progress(...args);
+ });
+ progress_bar.destroy();
+ return indexer;
+ }
+
+ async _build_deps(section, tree)
+ {
+ const indexer = await this._indexer;
+
+ for (const dep_name in tree)
+ {
+ const dep_section = section.add_section(dep_name);
+ const table = dep_section.add_widget(Table, ["name", "id"], Table.Flag_PackRight);
+ for (const dep_id of tree[dep_name])
+ {
+ const cell_values = ["", dep_id.toString(16).padStart(16, "0")];
+ const row = table.add_row(...cell_values);
+
+ var opkey = indexer.lookup_id(dep_id);
+ row.get_cell(0).text(opkey).on_click((k) => this.view_opkey(k), opkey);
+ }
+ }
+ }
+
+ async _build_page()
+ {
+ var entry = await this._entry;
+ entry = entry.as_object().find("entry").as_object();
+
+ const name = entry.find("key").as_value();
+ var section = this.add_section(name);
+
+ // tree
+ {
+ var tree = entry.find("$tree");
+ if (tree == undefined)
+ tree = this._convert_legacy_to_tree(entry);
+
+ if (tree == undefined)
+ return this._display_unsupported(section, entry);
+
+ delete tree["$id"];
+
+ const sub_section = section.add_section("deps");
+ this._build_deps(sub_section, tree);
+ }
+
+ // data
+ {
+ const sub_section = section.add_section("data");
+ const table = sub_section.add_widget(Table, ["name", "actions"], Table.Flag_PackRight);
+ for (const field_name of ["packagedata", "bulkdata"])
+ {
+ var pkg_data = entry.find(field_name);
+ if (pkg_data == undefined)
+ continue;
+
+ for (const item of pkg_data.as_array())
+ {
+ var io_hash;
+ var file_name;
+ for (const field of item.as_object())
+ {
+ if (field.is_named("data")) io_hash = field.as_value();
+ else if (field.is_named("filename")) file_name = field.as_value();
+ }
+
+ if (io_hash instanceof Uint8Array)
+ {
+ var ret = "";
+ for (var x of io_hash)
+ ret += x.toString(16).padStart(2, "0");
+ io_hash = ret;
+ }
+
+ const row = table.add_row(file_name);
+
+ const project = this.get_param("project");
+ const oplog = this.get_param("oplog");
+ const link = row.get_cell(0).link(
+ "/" + ["prj", project, "oplog", oplog, io_hash].join("/")
+ );
+
+ const do_nothing = () => void(0);
+ const action_tb = new Toolbar(row.get_cell(-1), true);
+ action_tb.left().add("copy-hash").on_click(async (v) => {
+ await navigator.clipboard.writeText(v);
+ }, io_hash);
+ }
+ }
+ }
+
+ // props
+ {
+ const object = entry.to_js_object();
+ var sub_section = section.add_section("props");
+ sub_section.add_widget(PropTable).add_object(object);
+ }
+ }
+
+ _display_unsupported(section, entry)
+ {
+ const object = entry.to_js_object();
+ const text = JSON.stringify(object, null, " ");
+ section.tag("pre").text(text);
+ }
+
+ _convert_legacy_to_tree(entry)
+ {
+ const pkg_data = entry.find("packagedata");
+ if (pkg_data == undefined)
+ return
+
+ const tree = {};
+
+ var id = 0n;
+ for (var item of pkg_data.as_array())
+ {
+ var pkg_id = item.as_object().find("id");
+ if (pkg_id == undefined)
+ continue;
+
+ pkg_id = pkg_id.as_value().subarray(0, 8);
+ for (var i = 7; i >= 0; --i)
+ {
+ id <<= 8n;
+ id |= BigInt(pkg_id[i]);
+ }
+ break;
+ }
+ tree["$id"] = id;
+
+ const pkgst_entry = entry.find("packagestoreentry").as_object();
+
+ for (const field of pkgst_entry)
+ {
+ const field_name = field.get_name();
+ if (!field_name.endsWith("importedpackageids"))
+ continue;
+
+ var dep_name = field_name.slice(0, -18);
+ if (dep_name.length == 0)
+ dep_name = "imported";
+
+ var out = tree[dep_name] = [];
+ for (var item of field.as_array())
+ out.push(item.as_value(BigInt));
+ }
+
+ return tree;
+ }
+
+ view_opkey(opkey)
+ {
+ const params = this._params;
+ params.set("opkey", opkey);
+ window.location.search = params;
+ }
+}
diff --git a/src/zenserver/frontend/html/pages/oplog.js b/src/zenserver/frontend/html/pages/oplog.js
new file mode 100644
index 000000000..f22c2a58f
--- /dev/null
+++ b/src/zenserver/frontend/html/pages/oplog.js
@@ -0,0 +1,176 @@
+// 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 { Table, Toolbar, ProgressBar } from "../util/widgets.js"
+import { create_indexer } from "../indexer/indexer.js"
+
+////////////////////////////////////////////////////////////////////////////////
+export class Page extends ZenPage
+{
+ constructor(...args)
+ {
+ super(...args);
+
+ this._index_start = Number(this.get_param("start", 0)) || 0;
+ this._index_count = Number(this.get_param("count", 50)) || 0;
+ }
+
+ async main()
+ {
+ const project = this.get_param("project");
+ const oplog = this.get_param("oplog");
+
+ var oplog_info = new Fetcher()
+ .resource("prj", project, "oplog", oplog)
+ .json();
+
+ this._indexer = this._load_indexer(project, oplog);
+
+ this.set_title("oplog - " + oplog);
+
+ var section = this.add_section(project + " - " + oplog);
+
+ oplog_info = await oplog_info;
+ this._index_max = oplog_info["opcount"];
+ this._build_nav(section, oplog_info);
+
+ this._entry_table = section.add_widget(Table, ["key"]);
+ await this._build_table(this._index_start);
+ }
+
+ async _load_indexer(project, oplog)
+ {
+ const progress_bar = this.add_widget(ProgressBar);
+ progress_bar.set_progress("indexing");
+ var indexer = create_indexer(project, oplog, (...args) => {
+ progress_bar.set_progress(...args);
+ });
+ indexer = await indexer;
+ progress_bar.destroy();
+ return indexer;
+ }
+
+ _build_nav(section, oplog_info)
+ {
+ const nav = section.add_widget(Toolbar);
+ const left = nav.left();
+ left.add("|&lt;") .on_click(() => this._on_next_prev(-10e10));
+ left.add("&lt;&lt;").on_click(() => this._on_next_prev(-10));
+ left.add("prev") .on_click(() => this._on_next_prev( -1));
+ left.add("next") .on_click(() => this._on_next_prev( 1));
+ left.add("&gt;&gt;").on_click(() => this._on_next_prev( 10));
+ left.add("&gt;|") .on_click(() => this._on_next_prev( 10e10));
+
+ left.sep();
+ for (var count of [10, 25, 50, 100])
+ {
+ var handler = (n) => this._on_change_count(n);
+ left.add(count).on_click(handler, count);
+ }
+
+ left.sep();
+ left.add("tree").link("", {
+ "page" : "tree",
+ "project" : this.get_param("project"),
+ "oplog" : this.get_param("oplog"),
+ });
+
+ const right = nav.right();
+ right.add(Friendly.sep(oplog_info["opcount"]));
+ right.add("(" + Friendly.kib(oplog_info["totalsize"]) + ")");
+ right.sep();
+
+ var search_input = right.add("search:", "label").tag("input")
+ search_input.on("change", (x) => this._search(x.inner().value), search_input);
+ }
+
+ async _build_table(index)
+ {
+ this._index_count = Math.max(this._index_count, 1);
+ index = Math.min(index, this._index_max - this._index_count);
+ index = Math.max(index, 0);
+
+ const project = this.get_param("project");
+ const oplog = this.get_param("oplog");
+
+ var entries = new Fetcher()
+ .resource("prj", project, "oplog", oplog, "entries")
+ .param("start", index)
+ .param("count", this.set_param("count", this._index_count))
+ .json();
+
+ entries = (await entries)["entries"];
+ if (entries == undefined)
+ return;
+
+ if (entries.length == 0)
+ return;
+
+ this._entry_table.clear(index);
+ for (const entry of entries)
+ {
+ var row = this._entry_table.add_row(entry["key"]);
+
+ row.get_cell(0).link("", {
+ "page" : "entry",
+ "project" : project,
+ "oplog" : oplog,
+ "opkey" : entry["key"],
+ });
+ }
+
+ this.set_param("start", index);
+ this._index_start = index;
+ }
+
+ _on_change_count(value)
+ {
+ this._index_count = parseInt(value);
+ this._build_table(this._index_start);
+ }
+
+ _on_next_prev(direction)
+ {
+ var index = this._index_start + (this._index_count * direction);
+ index = Math.max(0, index);
+ this._build_table(index);
+ }
+
+ async _search(needle)
+ {
+ needle = needle.trim();
+ if (needle.length < 3)
+ return;
+
+ this._entry_table.clear(this._index_start);
+
+ const project = this.get_param("project");
+ const oplog = this.get_param("oplog");
+
+ const indexer = await this._indexer;
+
+ var added = 0;
+ const truncate_at = this.get_param("searchmax") || 250;
+ for (var name of indexer.search(needle))
+ {
+ var row = this._entry_table.add_row(name);
+
+ row.get_cell(0).link("", {
+ "page" : "entry",
+ "project" : project,
+ "oplog" : oplog,
+ "opkey" : name,
+ });
+
+ if (++added >= truncate_at)
+ {
+ this._entry_table.add_row("...truncated");
+ break;
+ }
+ }
+ }
+}
diff --git a/src/zenserver/frontend/html/pages/page.js b/src/zenserver/frontend/html/pages/page.js
new file mode 100644
index 000000000..9a9541904
--- /dev/null
+++ b/src/zenserver/frontend/html/pages/page.js
@@ -0,0 +1,128 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+"use strict";
+
+import { WidgetHost } from "../util/widgets.js"
+
+////////////////////////////////////////////////////////////////////////////////
+export class PageBase extends WidgetHost
+{
+ constructor(parent, params)
+ {
+ super(parent)
+ this._params = params;
+ }
+
+ set_title(name)
+ {
+ var value = document.title;
+ if (name.length && value.length)
+ name = value + " - " + name;
+ document.title = name;
+ }
+
+ get_param(name, fallback=undefined)
+ {
+ var ret = this._params.get(name);
+ if (ret != undefined)
+ return ret;
+
+ if (fallback != undefined)
+ this.set_param(name, fallback);
+
+ return fallback;
+ }
+
+ set_param(name, value, update=true)
+ {
+ this._params.set(name, value);
+ if (!update)
+ return value;
+
+ const url = new URL(window.location);
+ for (var [key, xfer] of this._params)
+ url.searchParams.set(key, xfer);
+ history.replaceState(null, "", url);
+
+ return value;
+ }
+
+ reload()
+ {
+ window.location.reload();
+ }
+}
+
+
+
+////////////////////////////////////////////////////////////////////////////////
+export class ZenPage extends PageBase
+{
+ constructor(parent, ...args)
+ {
+ super(parent, ...args);
+ super.set_title("zen");
+ this.add_branding(parent);
+ this.generate_crumbs();
+ }
+
+ add_branding(parent)
+ {
+ var root = parent.tag().id("branding");
+
+ const zen_store = root.tag("pre").id("logo").text(
+ "_________ _______ __\n" +
+ "\\____ /___ ___ / ___// |__ ___ ______ ____\n" +
+ " / __/ __ \\ / \\ \\___ \\\\_ __// \\\\_ \\/ __ \\\n" +
+ " / \\ __// | \\/ \\| | ( - )| |\\/\\ __/\n" +
+ "/______/\\___/\\__|__/\\______/|__| \\___/ |__| \\___|"
+ );
+ zen_store.tag().id("go_home").on_click(() => window.location.search = "");
+
+ root.tag("img").attr("src", "favicon.ico").id("ue_logo");
+
+ /*
+ _________ _______ __
+ \____ /___ ___ / ___// |__ ___ ______ ____
+ / __/ __ \ / \ \___ \\_ __// \\_ \/ __ \
+ / \ __// | \/ \| | ( - )| |\/\ __/
+ /______/\___/\__|__/\______/|__| \___/ |__| \___|
+ */
+ }
+
+ set_title(...args)
+ {
+ super.set_title(...args);
+ }
+
+ generate_crumbs()
+ {
+ const auto_name = this.get_param("page") || "start";
+ if (auto_name == "start")
+ return;
+
+ const crumbs = this.tag().id("crumbs");
+ const new_crumb = function(name, search=undefined) {
+ crumbs.tag();
+ var crumb = crumbs.tag().text(name);
+ if (search != undefined)
+ crumb.on_click((x) => window.location.search = x, search);
+ };
+
+ new_crumb("home", "");
+
+ var project = this.get_param("project");
+ if (project != undefined)
+ {
+ var oplog = this.get_param("oplog");
+ if (oplog != undefined)
+ {
+ new_crumb("project", `?page=project&project=${project}`);
+ if (this.get_param("opkey"))
+ new_crumb("oplog", `?page=oplog&project=${project}&oplog=${oplog}`);
+ }
+ }
+
+ new_crumb(auto_name.toLowerCase());
+ }
+}
diff --git a/src/zenserver/frontend/html/pages/project.js b/src/zenserver/frontend/html/pages/project.js
new file mode 100644
index 000000000..05e8efc9f
--- /dev/null
+++ b/src/zenserver/frontend/html/pages/project.js
@@ -0,0 +1,92 @@
+// 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 { Table, PropTable, Toolbar } from "../util/widgets.js"
+
+////////////////////////////////////////////////////////////////////////////////
+export class Page extends ZenPage
+{
+ async main()
+ {
+ // info
+ var section = this.add_section("info");
+
+ const project = this.get_param("project");
+
+ this.set_title("project - " + project);
+
+ var info = await new Fetcher().resource("prj", project).json();
+ var prop_table = section.add_widget(PropTable);
+ for (const key in info)
+ {
+ if (key == "oplogs")
+ continue;
+
+ prop_table.add_property(key, info[key]);
+ }
+
+ // oplog
+ section = this.add_section("oplogs");
+
+ var oplog_table = section.add_widget(
+ Table,
+ ["name", "marker", "size", "ops", "expired", "actions"],
+ Table.Flag_PackRight
+ )
+
+ var count = 0;
+ for (const oplog of info["oplogs"])
+ {
+ const name = oplog["id"];
+
+ var info = new Fetcher().resource("prj", project, "oplog", name).json();
+
+ var row = oplog_table.add_row(name);
+
+ var cell = row.get_cell(0);
+ this.as_link(cell, "oplog", name)
+
+ cell = row.get_cell(-1);
+ const action_tb = new Toolbar(cell, true).left();
+ this.as_link(action_tb.add("view"), "oplog", name);
+ this.as_link(action_tb.add("tree"), "tree", name);
+ action_tb.add("drop").on_click((x) => this.drop_oplog(x), name);
+
+ info = await info;
+ row.get_cell(1).text(info["markerpath"]);
+ row.get_cell(2).text(Friendly.kib(info["totalsize"]));
+ row.get_cell(3).text(Friendly.sep(info["opcount"]));
+ row.get_cell(4).text(info["expired"]);
+ }
+ }
+
+ as_link(component, page, oplog_id)
+ {
+ component.link("", {
+ "page" : page,
+ "project" : this.get_param("project"),
+ "oplog" : oplog_id,
+ });
+ }
+
+ drop_oplog(oplog_id)
+ {
+ const drop = async () => {
+ await new Fetcher()
+ .resource("prj", this.get_param("project"), "oplog", oplog_id)
+ .delete();
+ this.reload();
+ };
+
+ new Modal()
+ .title("Confirmation")
+ .message(`Drop oplog '${oplog_id}'?`)
+ .option("Yes", () => drop())
+ .option("No");
+ }
+}
diff --git a/src/zenserver/frontend/html/pages/start.js b/src/zenserver/frontend/html/pages/start.js
new file mode 100644
index 000000000..8c9df62f9
--- /dev/null
+++ b/src/zenserver/frontend/html/pages/start.js
@@ -0,0 +1,94 @@
+// 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 { Table, Toolbar } from "../util/widgets.js"
+
+////////////////////////////////////////////////////////////////////////////////
+export class Page extends ZenPage
+{
+ async main()
+ {
+ var section = this.add_section("projects");
+
+ // project list
+ var columns = [
+ "name",
+ "project_dir",
+ "engine_dir",
+ "actions",
+ ];
+ var table = section.add_widget(Table, columns);
+
+ for (const project of await new Fetcher().resource("/prj/list").json())
+ {
+ var row = table.add_row(
+ "",
+ project.ProjectRootDir,
+ project.EngineRootDir,
+ );
+
+ var cell = row.get_cell(0);
+ cell.tag().text(project.Id).on_click((x) => this.view_project(x), project.Id);
+
+ var cell = row.get_cell(-1);
+ var action_tb = new Toolbar(cell, true);
+ action_tb.left().add("view").on_click((x) => this.view_project(x), project.Id);
+ action_tb.left().add("drop").on_click((x) => this.drop_project(x), project.Id);
+ }
+
+ // stats
+ section = this.add_section("stats");
+ columns = [
+ "name",
+ "req count",
+ "size disk",
+ "size mem",
+ "cid total",
+ ];
+ const stats_table = section.add_widget(Table, columns, Table.Flag_PackRight);
+ var providers = new Fetcher().resource("stats").json();
+ for (var provider of (await providers)["providers"])
+ {
+ var stats = await new Fetcher().resource("stats", provider).json();
+ var values = [""];
+ try {
+ values.push(stats.requests.count);
+ const size_stat = (stats.store || stats.cache).size;
+ values.push(Friendly.kib(size_stat.disk));
+ values.push(Friendly.kib(size_stat.memory));
+ values.push(stats.cid.size.total);
+ }
+ catch {}
+ row = stats_table.add_row(...values);
+ row.get_cell(0).tag().text(provider).on_click((x) => this.view_stat(x), provider);
+ }
+ }
+
+ view_stat(provider)
+ {
+ window.location = "?page=stat&provider=" + provider;
+ }
+
+ view_project(project_id)
+ {
+ window.location = "?page=project&project=" + project_id;
+ }
+
+ drop_project(project_id)
+ {
+ const drop = async () => {
+ await new Fetcher().resource("prj", project_id).delete();
+ this.reload();
+ };
+
+ new Modal()
+ .title("Confirmation")
+ .message(`Drop project '${project_id}'?`)
+ .option("Yes", () => drop())
+ .option("No");
+ }
+}
diff --git a/src/zenserver/frontend/html/pages/stat.js b/src/zenserver/frontend/html/pages/stat.js
new file mode 100644
index 000000000..c7902d5ed
--- /dev/null
+++ b/src/zenserver/frontend/html/pages/stat.js
@@ -0,0 +1,153 @@
+// 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 { PropTable, Toolbar } from "../util/widgets.js"
+
+////////////////////////////////////////////////////////////////////////////////
+class TemporalStat
+{
+ constructor(data, as_bytes)
+ {
+ this._data = data;
+ this._as_bytes = as_bytes;
+ }
+
+ toString()
+ {
+ const columns = [
+ /* count */ {},
+ /* rate */ {},
+ /* t */ {}, {},
+ ];
+ const data = this._data;
+ for (var key in data)
+ {
+ var out = columns[0];
+ if (key.startsWith("rate_")) out = columns[1];
+ else if (key.startsWith("t_p")) out = columns[3];
+ else if (key.startsWith("t_")) out = columns[2];
+ out[key] = data[key];
+ }
+
+ var friendly = this._as_bytes ? Friendly.kib : Friendly.sep;
+
+ var content = "";
+ for (var i = 0; i < columns.length; ++i)
+ {
+ content += "<pre>";
+ const column = columns[i];
+ for (var key in column)
+ {
+ var value = column[key];
+ if (i)
+ {
+ value = Friendly.sep(value, 2);
+ key = key.padStart(9);
+ content += key + ": " + value;
+ }
+ else
+ content += friendly(value);
+ content += "\n";
+ }
+ content += "</pre>";
+ }
+
+ return content;
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////
+export class Page extends ZenPage
+{
+ async main()
+ {
+ const provider = this.get_param("provider", "z$");
+ var stats = new Fetcher()
+ .resource("stats", provider)
+ .param("cidstorestats", "true")
+ .param("cachestorestats", "true")
+ .json();
+
+ this.set_title("stat - " + provider);
+ const section = this.add_section(provider);
+
+ var toolbar = section.add_widget(Toolbar);
+ var tb_right = toolbar.right();
+ tb_right.add("filter:");
+ tb_right.add("-none-").on_click((x) => this.update_filter(""));
+ for (var preset of ["read.", "write.", ".request", ".bytes"])
+ tb_right.add(preset).on_click((x) => this.update_filter(x), preset);
+ this._filter_input = tb_right.add("", "label").tag("input");
+ this._filter_input.on("change", (x) => this.update_filter(x.inner().value), this._filter_input);
+
+ this._table = section.add_widget(PropTable);
+
+ this._stats = stats = await stats;
+ this._condense(stats);
+
+ var first = undefined;
+ for (var name in stats)
+ {
+ first = first || name;
+ toolbar.left().add(name).on_click((x) => this.view_category(x), name);
+ }
+
+ var filter = this.get_param("filter");
+
+ first = this.get_param("view", first);
+ this.view_category(first);
+
+ if (filter)
+ this.update_filter(filter);
+ }
+
+ view_category(name)
+ {
+ const friendly = (this.get_param("raw") == undefined);
+ this._table.clear();
+ this._table.add_object(this._stats[name], friendly, 3);
+ this.set_param("view", name);
+ this.update_filter("");
+ }
+
+ update_filter(needle)
+ {
+ this._filter_input.attr("value", needle);
+
+ this.set_param("filter", needle);
+ if (!needle)
+ return this._table.filter();
+
+ var needles = needle.split(" ");
+ this._table.filter(...needles);
+ }
+
+ _condense(stats)
+ {
+ const impl = function(node)
+ {
+ for (var name in node)
+ {
+ const candidate = node[name];
+ if (!(candidate instanceof Object))
+ continue;
+
+ if (candidate["rate_mean"] != undefined)
+ {
+ const as_bytes = (name.indexOf("bytes") >= 0);
+ node[name] = new TemporalStat(candidate, as_bytes);
+ continue;
+ }
+
+ impl(candidate);
+ }
+ }
+
+ for (var name in stats)
+ impl(stats[name]);
+ }
+}
diff --git a/src/zenserver/frontend/html/pages/test.js b/src/zenserver/frontend/html/pages/test.js
new file mode 100644
index 000000000..2a84ff163
--- /dev/null
+++ b/src/zenserver/frontend/html/pages/test.js
@@ -0,0 +1,147 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+"use strict";
+
+import { ZenPage } from "./page.js"
+import { Table, PropTable, Toolbar, ProgressBar } from "../util/widgets.js"
+import { Modal, } from "../util/modal.js"
+
+////////////////////////////////////////////////////////////////////////////////
+export class Page extends ZenPage
+{
+ main()
+ {
+ var gen_word = (function() {
+ var s = 0x314251;
+ var r = function(a, b) {
+ s = (s * 0x493) & 0x7fffffff;
+ return ((s >> 3) % (b - a)) + a;
+ };
+ return function(a=5, b=10) {
+ const co = "aeioubcdfghjklmnpqrstvwxyz";
+ var ret = "";
+ for (var i = 0, n = r(a,b); i < n; ++i)
+ ret += co[r(0, co.length)];
+ return ret;
+ };
+ })();
+ var gen_para = function(a=5, b=10, s=" ") {
+ var ret = gen_word(2, 9);
+ for (var i = 0; i < ((ret.length * 0x493) % (b - a)) + b; ++i)
+ ret += s + gen_word(2, 9);
+ return ret;
+ }
+
+ this.set_title("test");
+
+ // swatches
+ const swatches = this.tag()
+ .style("position", "absolute")
+ .style("top", "3.5em")
+ .style("left", "3.5em")
+ for (var suffix of ["g0", "g1", "g2", "g3", "g4",
+ "p0", "p1", "p2", "p3", "p4",
+ "ln", "er"])
+ {
+ swatches.tag()
+ .style("float", "left")
+ .style("width", "2em")
+ .style("height", "2em")
+ .style("background-color", `var(--theme_${suffix})`)
+ .text(suffix);
+ }
+
+ // section
+ var section0 = this.add_section("section");
+ var section1 = section0.add_section("sub-section");
+ var section2 = section1.add_section("sub-sub-section");
+
+ // table
+ const cols = [gen_word(), gen_word(), gen_word(), gen_word()];
+ var tables = [
+ section0.add_widget(Table, cols),
+ section1.add_widget(Table, cols, Table.Flag_EvenSpacing, 5),
+ section2.add_widget(Table, cols, Table.Flag_EvenSpacing, -1),
+ ];
+
+ for (const table of tables)
+ {
+ table.add_row(gen_word());
+ table.add_row(gen_word(), gen_word(), gen_word(), gen_word());
+ table.add_row(gen_word(), gen_word(), gen_para(15, 25), gen_word(), gen_word(), gen_word(), gen_word(), gen_word());
+ }
+
+ // spacing tests
+ {
+ const spacing_section = section0.add_section("spacing");
+ const flags = {
+ "EvenSpacing" : Table.Flag_EvenSpacing,
+ "EvenSpacing|BiasLeft" : Table.Flag_EvenSpacing | Table.Flag_BiasLeft,
+ "PackRight" : Table.Flag_PackRight,
+ };
+ for (const flag_name in flags)
+ {
+ const flag = flags[flag_name];
+ const another_table = spacing_section.add_widget(
+ Table,
+ [flag_name, gen_word(), gen_word(), gen_word(), gen_word()],
+ flag,
+ );
+ for (var i = 0; i < 3; ++i)
+ another_table.add_row(gen_para(1, 5), gen_para(1, 3), gen_word(), gen_word(), gen_word());
+ }
+ }
+
+ // prop-table
+ var pt_section = section0.add_section("prop-table")
+ var prop_table = pt_section.add_widget(PropTable);
+ for (var i = 0; i < 7; ++i)
+ prop_table.add_property(gen_word(), gen_para(1, 20, "/"));
+
+ // misc
+ const misc_section = section0.add_section("misc").add_section("misc");
+ misc_section.tag().text("just text");
+ misc_section.tag().text("this is a link").link();
+ misc_section.tag().text("MODAL DIALOG").on_click((e) => {
+ new Modal()
+ .title("modal")
+ .message("here is a message what I wrote")
+ .option("press me!", () => { alert("hi"); })
+ .option("cancel", () => void(0));
+ });
+
+ // toolbar
+ pt_section.add_section("toolbar");
+ var toolbar = pt_section.add_widget(Toolbar);
+ for (const side of [toolbar.left(), toolbar.right()])
+ {
+ side.add("tb_item0");
+ side.add("tb_item1");
+ side.sep();
+ side.add("tb_item2");
+ }
+
+ var tb_item_clicked = function(arg0, arg1) {
+ alert(arg0 + " != " + arg1);
+ };
+ var row = prop_table.add_property("toolbar", "");
+ toolbar = new Toolbar(row.get_cell(-1), true);
+ toolbar.left() .add("tbitem0").on_click(tb_item_clicked, 11, -22);
+ toolbar.left() .add("tbitem1").on_click(tb_item_clicked, 22, -33);
+ toolbar.right().add("tbitem2").on_click(tb_item_clicked, 33, -55);
+ toolbar.right().add("tbitem3").on_click(tb_item_clicked, 44, -88);
+
+ // progress bar
+ const progress_bar = this.add_widget(ProgressBar);
+ setInterval(function() {
+ var count = 0
+ return () => {
+ count = (count + 1) % 100;
+ progress_bar.set_progress("testing", count, 100);
+ };
+ }(), 49.3);
+
+ // error
+ throw Error("deliberate error");
+ }
+}
diff --git a/src/zenserver/frontend/html/pages/tree.js b/src/zenserver/frontend/html/pages/tree.js
new file mode 100644
index 000000000..c3cea0eb5
--- /dev/null
+++ b/src/zenserver/frontend/html/pages/tree.js
@@ -0,0 +1,118 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+"use strict";
+
+import { ZenPage } from "./page.js"
+import { ProgressBar } from "../util/widgets.js"
+import { create_indexer } from "../indexer/indexer.js"
+
+////////////////////////////////////////////////////////////////////////////////
+export class Page extends ZenPage
+{
+ main()
+ {
+ const project = this.get_param("project");
+ const oplog = this.get_param("oplog");
+
+ this._indexer = this._load_indexer(project, oplog);
+
+ this.set_title("tree - " + oplog);
+ const section = this.add_section(project + " - " + oplog);
+
+ const list = section.tag().id("tree_root").tag("ul");
+ const root = list.tag("li");
+ root.attr("part", "/");
+ root.tag().text("/");
+ this._expand(root);
+ }
+
+ async _load_indexer(project, oplog)
+ {
+ const progress_bar = this.add_widget(ProgressBar);
+ progress_bar.set_progress("indexing");
+ var indexer = create_indexer(project, oplog, (...args) => {
+ progress_bar.set_progress(...args);
+ });
+ indexer = await indexer;
+ progress_bar.destroy();
+ return indexer;
+ }
+
+ async _expand(node)
+ {
+ var prefix = "";
+ for (var item = node;; item = item.parent())
+ {
+ if (item.is("div")) break;
+ if (!item.is("li")) continue;
+ prefix = item.attr("part") + prefix;
+ }
+ console.log(prefix);
+
+ const indexer = await this._indexer;
+
+ const new_nodes = new Object();
+ for (var name of indexer.enum_names())
+ {
+ if (!name.startsWith(prefix))
+ continue;
+
+ name = name.substr(prefix.length);
+ const slash = name.indexOf("/");
+ if (slash != -1)
+ name = name.substr(0, slash + 1);
+
+ if (new_nodes[name] === undefined)
+ new_nodes[name] = 1;
+ else
+ new_nodes[name] += 1;
+ }
+
+ const by_count = this.get_param("bycount", 0)|0;
+ const sorted_keys = Object.keys(new_nodes).sort((l, r) => {
+ const is_node_l = l.endsWith("/");
+ const any_nodes = is_node_l + r.endsWith("/");
+ if (any_nodes == 0) return r < l;
+ if (any_nodes == 1) return is_node_l ? -1 : 1;
+ return by_count ? (new_nodes[r] - new_nodes[l]) : (r < l);
+ })
+
+ const list = node.tag("ul");
+ for (const name of sorted_keys)
+ {
+ const item = list.tag("li").attr("part", name);
+ const info = item.tag();
+ const label = info.tag().text(name);
+ if (name.endsWith("/"))
+ {
+ label.on_click((x) => this.expand_collapse(x), item);
+ info.tag().text(new_nodes[name]);
+ continue;
+ }
+
+ item.attr("leaf", "");
+ label.link("", {
+ "page" : "entry",
+ "project" : this.get_param("project"),
+ "oplog" : this.get_param("oplog"),
+ "opkey" : prefix + name,
+ });
+ info.tag();
+ }
+
+ node.attr("expanded", "")
+ }
+
+ _collapse(node)
+ {
+ node.first_child().next_sibling().destroy();
+ node.attr("expanded", null);
+ }
+
+ expand_collapse(node)
+ {
+ if (node.attr("expanded") === null)
+ return this._expand(node);
+ return this._collapse(node);
+ }
+}