| // Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file | |
| // for details. All rights reserved. Use of this source code is governed by a | |
| // BSD-style license that can be found in the LICENSE file. | |
| import 'dart:async'; | |
| import 'dart:convert'; | |
| import 'dart:js_interop'; | |
| import 'dart:math' as Math; | |
| import 'package:web/web.dart'; | |
| import '../../models.dart' as M; | |
| import 'containers/virtual_tree.dart'; | |
| import 'helpers/custom_element.dart'; | |
| import 'helpers/element_utils.dart'; | |
| import 'helpers/nav_bar.dart'; | |
| import 'helpers/nav_menu.dart'; | |
| import 'helpers/rendering_scheduler.dart'; | |
| import 'nav/notify.dart'; | |
| import 'nav/refresh.dart'; | |
| import 'nav/top_menu.dart'; | |
| import 'nav/vm_menu.dart'; | |
| import 'tree_map.dart'; | |
| import '../../utils.dart'; | |
| enum ProcessSnapshotTreeMode { treeMap, tree, treeMapDiff, treeDiff } | |
| // Note the order of these lists is reflected in the UI, and the first option | |
| // is the default. | |
| const _viewModes = [ | |
| ProcessSnapshotTreeMode.treeMap, | |
| ProcessSnapshotTreeMode.tree, | |
| ]; | |
| const _diffModes = [ | |
| ProcessSnapshotTreeMode.treeMapDiff, | |
| ProcessSnapshotTreeMode.treeDiff, | |
| ]; | |
| class ProcessItemTreeMap extends NormalTreeMap<Map> { | |
| ProcessSnapshotElement element; | |
| ProcessItemTreeMap(this.element); | |
| int getSize(Map node) => node["size"]; | |
| String getType(Map node) => node["name"]; | |
| String getName(Map node) => node["name"]; | |
| String getTooltip(Map node) => getLabel(node) + "\n" + node["description"]; | |
| Map getParent(Map node) => node["parent"]; | |
| Iterable<Map> getChildren(Map node) => new List<Map>.from(node["children"]); | |
| void onSelect(Map node) { | |
| element.selection = node; | |
| element._r.dirty(); | |
| } | |
| void onDetails(Map node) {} | |
| } | |
| class ProcessItemDiff { | |
| Map? _a; | |
| Map? _b; | |
| ProcessItemDiff? parent; | |
| List<ProcessItemDiff>? children; | |
| int retainedGain = -1; | |
| int retainedLoss = -1; | |
| int retainedCommon = -1; | |
| int get retainedSizeA => _a == null ? 0 : _a!["size"]; | |
| int get retainedSizeB => _b == null ? 0 : _b!["size"]; | |
| int get retainedSizeDiff => retainedSizeB - retainedSizeA; | |
| int get shallowSizeA { | |
| if (_a == null) return 0; | |
| var s = _a!["size"]; | |
| for (var c in _a!["children"]) { | |
| s -= c["size"]; | |
| } | |
| return s; | |
| } | |
| int get shallowSizeB { | |
| if (_b == null) return 0; | |
| var s = _b!["size"]; | |
| for (var c in _b!["children"]) { | |
| s -= c["size"]; | |
| } | |
| return s; | |
| } | |
| int get shallowSizeDiff => shallowSizeB - shallowSizeA; | |
| String get name => _a == null ? _b!["name"] : _a!["name"]; | |
| static ProcessItemDiff from(Map a, Map b) { | |
| var root = new ProcessItemDiff(); | |
| root._a = a; | |
| root._b = b; | |
| // We must use an explicit stack instead of the call stack because the | |
| // dominator tree can be arbitrarily deep. We need to compute the full | |
| // tree to compute areas, so we do this eagerly to avoid having to | |
| // repeatedly test for initialization. | |
| var worklist = <ProcessItemDiff>[]; | |
| worklist.add(root); | |
| // Compute children top-down. | |
| for (var i = 0; i < worklist.length; i++) { | |
| worklist[i]._computeChildren(worklist); | |
| } | |
| // Compute area bottom-up. | |
| for (var i = worklist.length - 1; i >= 0; i--) { | |
| worklist[i]._computeArea(); | |
| } | |
| return root; | |
| } | |
| void _computeChildren(List<ProcessItemDiff> worklist) { | |
| assert(children == null); | |
| children = <ProcessItemDiff>[]; | |
| // Matching children by name. | |
| final childrenB = <String, Map>{}; | |
| if (_b != null) | |
| for (var childB in _b!["children"]) { | |
| childrenB[childB["name"]] = childB; | |
| } | |
| if (_a != null) | |
| for (var childA in _a!["children"]) { | |
| var childDiff = new ProcessItemDiff(); | |
| childDiff.parent = this; | |
| childDiff._a = childA; | |
| var qualifiedName = childA["name"]; | |
| var childB = childrenB[qualifiedName]; | |
| if (childB != null) { | |
| childrenB.remove(qualifiedName); | |
| childDiff._b = childB; | |
| } | |
| children!.add(childDiff); | |
| worklist.add(childDiff); | |
| } | |
| for (var childB in childrenB.values) { | |
| var childDiff = new ProcessItemDiff(); | |
| childDiff.parent = this; | |
| childDiff._b = childB; | |
| children!.add(childDiff); | |
| worklist.add(childDiff); | |
| } | |
| if (children!.length == 0) { | |
| // Compress. | |
| children = const <ProcessItemDiff>[]; | |
| } | |
| } | |
| void _computeArea() { | |
| int g = 0; | |
| int l = 0; | |
| int c = 0; | |
| for (var child in children!) { | |
| g += child.retainedGain; | |
| l += child.retainedLoss; | |
| c += child.retainedCommon; | |
| } | |
| int d = shallowSizeDiff; | |
| if (d > 0) { | |
| g += d; | |
| c += shallowSizeA; | |
| } else { | |
| l -= d; | |
| c += shallowSizeB; | |
| } | |
| assert(retainedSizeA + g - l == retainedSizeB); | |
| retainedGain = g; | |
| retainedLoss = l; | |
| retainedCommon = c; | |
| } | |
| } | |
| class ProcessItemDiffTreeMap extends DiffTreeMap<ProcessItemDiff> { | |
| ProcessSnapshotElement element; | |
| ProcessItemDiffTreeMap(this.element); | |
| int getSizeA(ProcessItemDiff node) => node.retainedSizeA; | |
| int getSizeB(ProcessItemDiff node) => node.retainedSizeB; | |
| int getGain(ProcessItemDiff node) => node.retainedGain; | |
| int getLoss(ProcessItemDiff node) => node.retainedLoss; | |
| int getCommon(ProcessItemDiff node) => node.retainedCommon; | |
| String getType(ProcessItemDiff node) => node.name; | |
| String getName(ProcessItemDiff node) => node.name; | |
| ProcessItemDiff? getParent(ProcessItemDiff node) => node.parent; | |
| Iterable<ProcessItemDiff> getChildren(ProcessItemDiff node) => node.children!; | |
| void onSelect(ProcessItemDiff node) { | |
| element.diffSelection = node; | |
| element._r.dirty(); | |
| } | |
| void onDetails(ProcessItemDiff node) {} | |
| } | |
| class ProcessSnapshotElement extends CustomElement implements Renderable { | |
| late RenderingScheduler<ProcessSnapshotElement> _r; | |
| Stream<RenderedEvent<ProcessSnapshotElement>> get onRendered => _r.onRendered; | |
| late M.VM _vm; | |
| late M.EventRepository _events; | |
| late M.NotificationRepository _notifications; | |
| M.NotificationRepository get notifications => _notifications; | |
| M.VMRef get vm => _vm; | |
| List<Map> _loadedSnapshots = <Map>[]; | |
| Map? selection; | |
| ProcessItemDiff? diffSelection; | |
| Map? _snapshotA; | |
| Map? _snapshotB; | |
| ProcessSnapshotTreeMode _mode = ProcessSnapshotTreeMode.treeMap; | |
| factory ProcessSnapshotElement( | |
| M.VM vm, | |
| M.EventRepository events, | |
| M.NotificationRepository notifications, { | |
| RenderingQueue? queue, | |
| }) { | |
| ProcessSnapshotElement e = new ProcessSnapshotElement.created(); | |
| e._r = new RenderingScheduler<ProcessSnapshotElement>(e, queue: queue); | |
| e._vm = vm; | |
| e._events = events; | |
| e._notifications = notifications; | |
| return e; | |
| } | |
| ProcessSnapshotElement.created() : super.created('process-snapshot'); | |
| @override | |
| attached() { | |
| super.attached(); | |
| _r.enable(); | |
| _refresh(); | |
| } | |
| @override | |
| detached() { | |
| super.detached(); | |
| _r.disable(notify: true); | |
| removeChildren(); | |
| } | |
| void render() { | |
| final content = <HTMLElement>[ | |
| navBar(<HTMLElement>[ | |
| new NavTopMenuElement(queue: _r.queue).element, | |
| new NavVMMenuElement(_vm, _events, queue: _r.queue).element, | |
| navMenu('process snapshot'), | |
| (new NavRefreshElement(queue: _r.queue) | |
| ..onRefresh.listen((e) { | |
| _refresh(); | |
| })) | |
| .element, | |
| (new NavRefreshElement(label: 'save', queue: _r.queue) | |
| ..disabled = _snapshotA == null | |
| ..onRefresh.listen((e) { | |
| _save(); | |
| })) | |
| .element, | |
| (new NavRefreshElement(label: 'load', queue: _r.queue) | |
| ..onRefresh.listen((e) { | |
| _load(); | |
| })) | |
| .element, | |
| new NavNotifyElement(_notifications, queue: _r.queue).element, | |
| ]), | |
| ]; | |
| if (_snapshotA == null) { | |
| // Loading | |
| content.add(new HTMLSpanElement()..textContent = "Loading"); | |
| } else { | |
| // Loaded | |
| content.addAll(_createReport()); | |
| } | |
| children = content; | |
| } | |
| _refresh() async { | |
| Map snapshot = await (vm as dynamic).invokeRpcNoUpgrade( | |
| "getProcessMemoryUsage", | |
| {}, | |
| ); | |
| _snapshotLoaded(snapshot); | |
| } | |
| _save() { | |
| var blob = new Blob( | |
| [jsonEncode(_snapshotA).toJS].toJS, | |
| BlobPropertyBag(type: 'application/json'), | |
| ); | |
| var blobUrl = URL.createObjectURL(blob); | |
| var link = new HTMLAnchorElement(); | |
| link.href = blobUrl; | |
| var now = new DateTime.now(); | |
| link.download = 'dart-process-${now.year}-${now.month}-${now.day}.json'; | |
| link.click(); | |
| } | |
| _load() { | |
| var input = new HTMLInputElement(); | |
| input.type = 'file'; | |
| input.multiple = false; | |
| input.onChange.listen((event) { | |
| final file = input.files!.item(0); | |
| final reader = FileReader(); | |
| reader | |
| ..onLoadEnd.first.then((_) async { | |
| _snapshotLoaded(jsonDecode(reader.result as String)); | |
| }) | |
| ..readAsText(file as Blob); | |
| }); | |
| input.click(); | |
| } | |
| _snapshotLoaded(Map snapshot) { | |
| _loadedSnapshots.add(snapshot); | |
| _snapshotA = snapshot; | |
| _snapshotB = snapshot; | |
| selection = null; | |
| diffSelection = null; | |
| _r.dirty(); | |
| } | |
| void _createTreeMap<T>(List<HTMLElement> report, TreeMap<T> treemap, T root) { | |
| final content = new HTMLDivElement(); | |
| content.style.border = '1px solid black'; | |
| content.style.width = '100%'; | |
| content.style.height = '100%'; | |
| content.textContent = 'Performing layout...'; | |
| Timer.run(() { | |
| // Generate the treemap after the content div has been added to the | |
| // document so that we can ask the browser how much space is | |
| // available for treemap layout. | |
| treemap.showIn(root, content); | |
| }); | |
| final text = | |
| 'Double-click a tile to zoom in. Double-click the outermost tile to ' | |
| 'zoom out. Process memory that is not further subdivided is non-Dart ' | |
| 'memory not known to the VM.'; | |
| report.addAll([ | |
| new HTMLDivElement() | |
| ..className = 'content-centered-big explanation' | |
| ..textContent = text, | |
| new HTMLDivElement() | |
| ..className = 'content-centered-big' | |
| ..style.width = '100%' | |
| ..style.height = '100%' | |
| ..appendChild(content), | |
| ]); | |
| } | |
| List<HTMLElement> _createReport() { | |
| var report = <HTMLElement>[ | |
| new HTMLDivElement() | |
| ..className = 'content-centered-big' | |
| ..appendChildren(<HTMLElement>[ | |
| new HTMLDivElement() | |
| ..className = 'memberList' | |
| ..appendChildren(<HTMLElement>[ | |
| new HTMLDivElement() | |
| ..className = 'memberItem' | |
| ..appendChildren(<HTMLElement>[ | |
| new HTMLDivElement() | |
| ..className = 'memberName' | |
| ..textContent = 'Snapshot A', | |
| new HTMLDivElement() | |
| ..className = 'memberName' | |
| ..appendChildren(_createSnapshotSelectA()), | |
| ]), | |
| new HTMLDivElement() | |
| ..className = 'memberItem' | |
| ..appendChildren(<HTMLElement>[ | |
| new HTMLDivElement() | |
| ..className = 'memberName' | |
| ..textContent = 'Snapshot B', | |
| new HTMLDivElement() | |
| ..className = 'memberName' | |
| ..appendChildren(_createSnapshotSelectB()), | |
| ]), | |
| new HTMLDivElement() | |
| ..className = 'memberItem' | |
| ..appendChildren(<HTMLElement>[ | |
| new HTMLDivElement() | |
| ..className = 'memberName' | |
| ..textContent = (_snapshotA == _snapshotB) | |
| ? 'View ' | |
| : 'Compare ', | |
| new HTMLDivElement() | |
| ..className = 'memberName' | |
| ..appendChildren(_createModeSelect()), | |
| ]), | |
| ]), | |
| ]), | |
| ]; | |
| switch (_mode) { | |
| case ProcessSnapshotTreeMode.treeMap: | |
| if (selection == null) { | |
| selection = _snapshotA!["root"]; | |
| } | |
| _createTreeMap(report, new ProcessItemTreeMap(this), selection); | |
| break; | |
| case ProcessSnapshotTreeMode.tree: | |
| if (selection == null) { | |
| selection = _snapshotA!["root"]; | |
| } | |
| _tree = new VirtualTreeElement( | |
| _createItem, | |
| _updateItem, | |
| _getChildrenItem, | |
| items: [selection], | |
| queue: _r.queue, | |
| ); | |
| _tree!.expand(selection!); | |
| report.add(_tree!.element); | |
| break; | |
| case ProcessSnapshotTreeMode.treeMapDiff: | |
| if (diffSelection == null) { | |
| diffSelection = ProcessItemDiff.from( | |
| _snapshotA!["root"], | |
| _snapshotB!["root"], | |
| ); | |
| } | |
| _createTreeMap(report, new ProcessItemDiffTreeMap(this), diffSelection); | |
| break; | |
| case ProcessSnapshotTreeMode.treeDiff: | |
| var root = ProcessItemDiff.from( | |
| _snapshotA!["root"], | |
| _snapshotB!["root"], | |
| ); | |
| _tree = new VirtualTreeElement( | |
| _createItemDiff, | |
| _updateItemDiff, | |
| _getChildrenItemDiff, | |
| items: [root], | |
| queue: _r.queue, | |
| ); | |
| _tree!.expand(root); | |
| report.add(_tree!.element); | |
| break; | |
| } | |
| return report; | |
| } | |
| VirtualTreeElement? _tree; | |
| static HTMLElement _createItem(toggle) { | |
| return new HTMLDivElement() | |
| ..className = 'tree-item' | |
| ..appendChildren(<HTMLElement>[ | |
| new HTMLSpanElement() | |
| ..className = 'percentage' | |
| ..title = 'percentage of total', | |
| new HTMLSpanElement() | |
| ..className = 'size' | |
| ..title = 'retained size', | |
| new HTMLSpanElement()..className = 'lines', | |
| new HTMLButtonElement() | |
| ..className = 'expander' | |
| ..onClick.listen((_) => toggle(autoToggleSingleChildNodes: true)), | |
| new HTMLSpanElement()..className = 'name', | |
| ]); | |
| } | |
| static HTMLElement _createItemDiff(toggle) { | |
| return new HTMLDivElement() | |
| ..className = 'tree-item' | |
| ..appendChildren(<HTMLElement>[ | |
| new HTMLSpanElement() | |
| ..className = 'percentage' | |
| ..title = 'percentage of total', | |
| new HTMLSpanElement() | |
| ..className = 'size' | |
| ..title = 'retained size A', | |
| new HTMLSpanElement() | |
| ..className = 'percentage' | |
| ..title = 'percentage of total', | |
| new HTMLSpanElement() | |
| ..className = 'size' | |
| ..title = 'retained size B', | |
| new HTMLSpanElement() | |
| ..className = 'size' | |
| ..title = 'retained size change', | |
| new HTMLSpanElement()..className = 'lines', | |
| new HTMLButtonElement() | |
| ..className = 'expander' | |
| ..onClick.listen((_) => toggle(autoToggleSingleChildNodes: true)), | |
| new HTMLSpanElement()..className = 'name', | |
| ]); | |
| } | |
| void _updateItem(HTMLElement element, node, int depth) { | |
| var size = node["size"]; | |
| var rootSize = _snapshotA!["root"]["size"]; | |
| (element.children.item(0) as HTMLElement).textContent = | |
| Utils.formatPercentNormalized(size * 1.0 / rootSize); | |
| (element.children.item(1) as HTMLElement).textContent = Utils.formatSize( | |
| size, | |
| ); | |
| _updateLines(element.children.item(2) as HTMLElement, depth); | |
| if (_getChildrenItem(node).isNotEmpty) { | |
| (element.children.item(3) as HTMLElement).textContent = | |
| _tree!.isExpanded(node) ? '▼' : '►'; | |
| } else { | |
| (element.children.item(3) as HTMLElement).textContent = ''; | |
| } | |
| (element.children.item(4) as HTMLElement).textContent = node["name"]; | |
| (element.children.item(4) as HTMLElement).title = node["description"]; | |
| } | |
| void _updateItemDiff(HTMLElement element, nodeDynamic, int depth) { | |
| ProcessItemDiff node = nodeDynamic; | |
| (element.children.item(0) as HTMLElement).textContent = | |
| Utils.formatPercentNormalized( | |
| node.retainedSizeA * 1.0 / _snapshotA!["root"]["size"], | |
| ); | |
| (element.children.item(1) as HTMLElement).textContent = Utils.formatSize( | |
| node.retainedSizeA, | |
| ); | |
| (element.children.item(2) as HTMLElement).textContent = | |
| Utils.formatPercentNormalized( | |
| node.retainedSizeB * 1.0 / _snapshotB!["root"]["size"], | |
| ); | |
| (element.children.item(3) as HTMLElement).textContent = Utils.formatSize( | |
| node.retainedSizeB, | |
| ); | |
| (element.children.item(4) as HTMLElement).textContent = | |
| (node.retainedSizeDiff > 0 ? '+' : '') + | |
| Utils.formatSize(node.retainedSizeDiff); | |
| (element.children.item(4) as HTMLElement).style.color = | |
| node.retainedSizeDiff > 0 ? "red" : "green"; | |
| _updateLines(element.children.item(5) as HTMLElement, depth); | |
| if (_getChildrenItemDiff(node).isNotEmpty) { | |
| (element.children.item(6) as HTMLElement).textContent = | |
| _tree!.isExpanded(node) ? '▼' : '►'; | |
| } else { | |
| (element.children.item(6) as HTMLElement).textContent = ''; | |
| } | |
| (element.children.item(7) as HTMLElement)..textContent = node.name; | |
| } | |
| static Iterable _getChildrenItem(node) { | |
| return new List<Map>.from(node["children"]); | |
| } | |
| static Iterable _getChildrenItemDiff(nodeDynamic) { | |
| ProcessItemDiff node = nodeDynamic; | |
| final list = node.children!.toList(); | |
| list.sort((a, b) => b.retainedSizeDiff - a.retainedSizeDiff); | |
| return list; | |
| } | |
| static _updateLines(HTMLElement element, int n) { | |
| n = Math.max(0, n); | |
| while (element.children.length > n) { | |
| element.removeChild(element.lastChild!); | |
| } | |
| while (element.children.length < n) { | |
| element.appendChild(HTMLSpanElement()); | |
| } | |
| } | |
| static String modeToString(ProcessSnapshotTreeMode mode) { | |
| switch (mode) { | |
| case ProcessSnapshotTreeMode.treeMap: | |
| case ProcessSnapshotTreeMode.treeMapDiff: | |
| return 'Tree Map'; | |
| case ProcessSnapshotTreeMode.tree: | |
| case ProcessSnapshotTreeMode.treeDiff: | |
| return 'Tree'; | |
| } | |
| } | |
| List<HTMLElement> _createModeSelect() { | |
| var modes = _snapshotA == _snapshotB ? _viewModes : _diffModes; | |
| if (!modes.contains(_mode)) { | |
| _mode = modes[0]; | |
| _r.dirty(); | |
| } | |
| final s = new HTMLSelectElement() | |
| ..className = 'analysis-select' | |
| ..value = modeToString(_mode) | |
| ..appendChildren( | |
| modes.map( | |
| (mode) => HTMLOptionElement() | |
| ..value = modeToString(mode) | |
| ..selected = _mode == mode | |
| ..textContent = modeToString(mode), | |
| ), | |
| ); | |
| return [ | |
| s | |
| ..onChange.listen((_) { | |
| _mode = modes[s.selectedIndex]; | |
| _r.dirty(); | |
| }), | |
| ]; | |
| } | |
| String snapshotToString(snapshot) { | |
| if (snapshot == null) return "None"; | |
| return snapshot["root"]["name"] + | |
| " " + | |
| Utils.formatSize(snapshot["root"]["size"]); | |
| } | |
| List<HTMLElement> _createSnapshotSelectA() { | |
| final s = HTMLSelectElement() | |
| ..className = 'analysis-select' | |
| ..value = snapshotToString(_snapshotA) | |
| ..appendChildren( | |
| _loadedSnapshots.map( | |
| (snapshot) => HTMLOptionElement() | |
| ..value = snapshotToString(snapshot) | |
| ..selected = _snapshotA == snapshot | |
| ..textContent = snapshotToString(snapshot), | |
| ), | |
| ); | |
| return [ | |
| s | |
| ..onChange.listen((_) { | |
| _snapshotA = _loadedSnapshots[s.selectedIndex]; | |
| selection = null; | |
| diffSelection = null; | |
| _r.dirty(); | |
| }), | |
| ]; | |
| } | |
| List<HTMLElement> _createSnapshotSelectB() { | |
| final s = HTMLSelectElement() | |
| ..className = 'analysis-select' | |
| ..value = snapshotToString(_snapshotB) | |
| ..appendChildren( | |
| _loadedSnapshots.map( | |
| (snapshot) => HTMLOptionElement() | |
| ..value = snapshotToString(snapshot) | |
| ..selected = _snapshotB == snapshot | |
| ..textContent = snapshotToString(snapshot), | |
| ), | |
| ); | |
| return [ | |
| s | |
| ..onChange.listen((_) { | |
| _snapshotB = _loadedSnapshots[s.selectedIndex]; | |
| selection = null; | |
| diffSelection = null; | |
| _r.dirty(); | |
| }), | |
| ]; | |
| } | |
| } |