Commit 89e0d45c authored by Camillo Bruni's avatar Camillo Bruni Committed by Commit Bot

[tools] Add markers to system-analyzer source panel

- Create SourcePosition objects for Map and IC log entries
- Display source code with markers for SourcePositions
- Avoid some try-catches for a better debugging experience

Bug: v8:10644
Change-Id: I559b0eaeaa1442986a00d2ef720d19ba85178509
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/2424258Reviewed-by: 's avatarSathya Gunasekaran  <gsathya@chromium.org>
Commit-Queue: Camillo Bruni <cbruni@chromium.org>
Auto-Submit: Camillo Bruni <cbruni@chromium.org>
Cr-Commit-Position: refs/heads/master@{#70091}
parent 607414e9
...@@ -25,6 +25,52 @@ ...@@ -25,6 +25,52 @@
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
// TODO: move to separate modules
class SourcePosition {
constructor(script, line, column) {
this.script = script;
this.line = line;
this.column = column;
this.entries = [];
}
addEntry(entry) {
this.entries.push(entry);
}
}
class Script {
constructor(id, name, source) {
this.id = id;
this.name = name;
this.source = source;
this.sourcePositions = [];
// Map<line, Map<column, SourcePosition>>
this.lineToColumn = new Map();
}
addSourcePosition(line, column, entry) {
let sourcePosition = this.lineToColumn.get(line)?.get(column);
if (sourcePosition === undefined) {
sourcePosition = new SourcePosition(this, line, column, )
this.#addSourcePosition(line, column, sourcePosition);
}
sourcePosition.addEntry(entry);
return sourcePosition;
}
#addSourcePosition(line, column, sourcePosition) {
let columnToSourcePosition;
if (this.lineToColumn.has(line)) {
columnToSourcePosition = this.lineToColumn.get(line);
} else {
columnToSourcePosition = new Map();
this.lineToColumn.set(line, columnToSourcePosition);
}
this.sourcePositions.push(sourcePosition);
columnToSourcePosition.set(column, sourcePosition);
}
}
/** /**
* Creates a profile object for processing profiling-related events * Creates a profile object for processing profiling-related events
...@@ -228,13 +274,10 @@ Profile.prototype.addSourcePositions = function ( ...@@ -228,13 +274,10 @@ Profile.prototype.addSourcePositions = function (
/** /**
* Adds script source code. * Adds script source code.
*/ */
Profile.prototype.addScriptSource = function (scriptId, url, source) { Profile.prototype.addScriptSource = function (id, url, source) {
this.scripts_[scriptId] = { const script = new Script(id, url, source);
scriptId: scriptId, this.scripts_[id] = script;
name: url, this.urlToScript_.set(url, script);
source: source
};
this.urlToScript_.set(url, source);
}; };
...@@ -1033,12 +1076,8 @@ JsonProfile.prototype.addSourcePositions = function ( ...@@ -1033,12 +1076,8 @@ JsonProfile.prototype.addSourcePositions = function (
}; };
}; };
JsonProfile.prototype.addScriptSource = function (scriptId, url, source) { JsonProfile.prototype.addScriptSource = function (id, url, source) {
this.scripts_[scriptId] = { this.scripts_[id] = new Script(id, url, source);
scriptId: scriptId,
name: url,
source: source
};
}; };
......
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
import { Group } from './ic-model.mjs'; import { Group } from './ic-model.mjs';
import Processor from "./processor.mjs"; import Processor from "./processor.mjs";
import { SourcePositionLogEvent } from "./log/sourcePosition.mjs";
import { MapLogEvent } from "./log/map.mjs"; import { MapLogEvent } from "./log/map.mjs";
import { FocusEvent, SelectTimeEvent, SelectionEvent } from './events.mjs'; import { FocusEvent, SelectTimeEvent, SelectionEvent } from './events.mjs';
import { defineCustomElement, V8CustomElement } from './helper.mjs'; import { defineCustomElement, V8CustomElement } from './helper.mjs';
...@@ -122,18 +121,7 @@ defineCustomElement('ic-panel', (templateText) => ...@@ -122,18 +121,7 @@ defineCustomElement('ic-panel', (templateText) =>
//TODO(zcankara) Handle in the processor for events with source positions. //TODO(zcankara) Handle in the processor for events with source positions.
handleFilePositionClick(e) { handleFilePositionClick(e) {
const entry = e.target.parentNode.entry; const entry = e.target.parentNode.entry;
const filePosition = this.dispatchEvent(new FocusEvent(entry.filePosition));
this.createSourcePositionLogEvent(
entry.entries[0].type, entry.entries[0].time, entry.key,
entry.entries[0].script);
this.dispatchEvent(new FocusEvent(filePosition));
}
createSourcePositionLogEvent(type, time, filePositionLine, script) {
const [file, line, col] = filePositionLine.split(':');
const filePosition = new SourcePositionLogEvent(type, time,
file, line, col, script);
return filePosition
} }
render(entries, parent) { render(entries, parent) {
......
...@@ -140,7 +140,9 @@ button { ...@@ -140,7 +140,9 @@ button {
color: var(--on-primary-color); color: var(--on-primary-color);
} }
.clickable:hover, .clickable:hover,
.clickable:active { .mark:hover,
.clickable:active,
.mark:active {
background-color: var(--primary-color); background-color: var(--primary-color);
color: var(--on-primary-color); color: var(--on-primary-color);
cursor: pointer; cursor: pointer;
......
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
import { SelectionEvent, FocusEvent, SelectTimeEvent } from "./events.mjs"; import { SelectionEvent, FocusEvent, SelectTimeEvent } from "./events.mjs";
import { State } from "./app-model.mjs"; import { State } from "./app-model.mjs";
import { SourcePositionLogEvent } from "./log/sourcePosition.mjs";
import { MapLogEvent } from "./log/map.mjs"; import { MapLogEvent } from "./log/map.mjs";
import { IcLogEvent } from "./log/ic.mjs"; import { IcLogEvent } from "./log/ic.mjs";
import Processor from "./processor.mjs"; import Processor from "./processor.mjs";
...@@ -55,10 +54,10 @@ class App { ...@@ -55,10 +54,10 @@ class App {
this.showMapEntries(e.entries); this.showMapEntries(e.entries);
} else if (e.entries[0] instanceof IcLogEvent) { } else if (e.entries[0] instanceof IcLogEvent) {
this.showIcEntries(e.entries); this.showIcEntries(e.entries);
} else if (e.entries[0] instanceof SourcePositionLogEvent) { } else if (e.entries[0] instanceof SourcePosition) {
this.showSourcePositionEntries(e.entries); this.showSourcePositionEntries(e.entries);
} else { } else {
console.error("Undefined selection!"); throw new Error("Unknown selection type!");
} }
} }
showMapEntries(entries) { showMapEntries(entries) {
...@@ -82,10 +81,10 @@ class App { ...@@ -82,10 +81,10 @@ class App {
this.selectMapLogEvent(e.entry); this.selectMapLogEvent(e.entry);
} else if (e.entry instanceof IcLogEvent) { } else if (e.entry instanceof IcLogEvent) {
this.selectICLogEvent(e.entry); this.selectICLogEvent(e.entry);
} else if (e.entry instanceof SourcePositionLogEvent) { } else if (e.entry instanceof SourcePosition) {
this.selectSourcePositionEvent(e.entry); this.selectSourcePositionEvent(e.entry);
} else { } else {
console.log("undefined"); throw new Error("Unknown selection type!");
} }
} }
selectTimeRange(start, end) { selectTimeRange(start, end) {
...@@ -108,7 +107,6 @@ class App { ...@@ -108,7 +107,6 @@ class App {
} }
selectSourcePositionEvent(sourcePositions) { selectSourcePositionEvent(sourcePositions) {
if (!sourcePositions.script) return; if (!sourcePositions.script) return;
console.log("source positions: ", sourcePositions);
this.#view.sourcePanel.selectedSourcePositions = [sourcePositions]; this.#view.sourcePanel.selectedSourcePositions = [sourcePositions];
} }
...@@ -133,26 +131,22 @@ class App { ...@@ -133,26 +131,22 @@ class App {
$("#container").className = "loaded"; $("#container").className = "loaded";
// instantiate the app logic // instantiate the app logic
let fileData = e.detail; let fileData = e.detail;
try { const processor = this.handleLoadTextProcessor(fileData.chunk);
const processor = this.handleLoadTextProcessor(fileData.chunk); const mapTimeline = processor.mapTimeline;
const mapTimeline = processor.mapTimeline; const icTimeline = processor.icTimeline;
const icTimeline = processor.icTimeline; //TODO(zcankara) Make sure only one instance of src event map ic id match
//TODO(zcankara) Make sure only one instance of src event map ic id match // Load map log events timeline.
// Load map log events timeline. this.#state.mapTimeline = mapTimeline;
this.#state.mapTimeline = mapTimeline; // Transitions must be set before timeline for stats panel.
// Transitions must be set before timeline for stats panel. this.#view.mapPanel.transitions = this.#state.mapTimeline.transitions;
this.#view.mapPanel.transitions = this.#state.mapTimeline.transitions; this.#view.mapTrack.data = mapTimeline;
this.#view.mapTrack.data = mapTimeline; this.#state.chunks = this.#view.mapTrack.chunks;
this.#state.chunks = this.#view.mapTrack.chunks; this.#view.mapPanel.timeline = mapTimeline;
this.#view.mapPanel.timeline = mapTimeline; // Load ic log events timeline.
// Load ic log events timeline. this.#state.icTimeline = icTimeline;
this.#state.icTimeline = icTimeline; this.#view.icPanel.timeline = icTimeline;
this.#view.icPanel.timeline = icTimeline; this.#view.icTrack.data = icTimeline;
this.#view.icTrack.data = icTimeline; this.#view.sourcePanel.data = processor.scripts
// TODO(zcankara) Load source position log events timeline.
} catch (error) {
console.log(error);
}
this.fileLoaded = true; this.fileLoaded = true;
} }
......
// Copyright 2020 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import { Event } from './log.mjs';
class SourcePositionLogEvent extends Event {
constructor(type, time, file, line, col, script) {
super(type, time);
this.file = file;
this.line = line;
this.col = col;
this.script = script;
}
}
export { SourcePositionLogEvent };
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
// found in the LICENSE file. // found in the LICENSE file.
import { V8CustomElement, defineCustomElement } from "../helper.mjs"; import { V8CustomElement, defineCustomElement } from "../helper.mjs";
import { FocusEvent } from "../events.mjs"; import { FocusEvent } from "../events.mjs";
import { SourcePositionLogEvent } from "../log/sourcePosition.mjs";
defineCustomElement( defineCustomElement(
"./map-panel/map-details", "./map-panel/map-details",
...@@ -43,21 +42,7 @@ defineCustomElement( ...@@ -43,21 +42,7 @@ defineCustomElement(
} }
handleFilePositionClick() { handleFilePositionClick() {
let filePosition = this.dispatchEvent(new FocusEvent(this.selectedMap.sourcePosition));
this.createSourcePositionLogEvent(
this.selectedMap.type, this.selectedMap.time,
this.selectedMap.filePosition, this.selectedMap.script);
this.dispatchEvent(new FocusEvent(filePosition));
}
createSourcePositionLogEvent(type, time, filePositionLine, script) {
// remove token
if (!(/\s/.test(filePositionLine))) return;
filePositionLine = filePositionLine.split(' ');
let [file, line, col] = filePositionLine[1].split(':');
let filePosition = new SourcePositionLogEvent(type, time,
file, line, col, script);
return filePosition
} }
} }
); );
...@@ -52,7 +52,7 @@ class Processor extends LogReader { ...@@ -52,7 +52,7 @@ class Processor extends LogReader {
'map': { 'map': {
parsers: [ parsers: [
parseString, parseInt, parseString, parseString, parseInt, parseInt, parseString, parseInt, parseString, parseString, parseInt, parseInt,
parseString, parseString, parseString parseInt, parseString, parseString
], ],
processor: this.processMap processor: this.processMap
}, },
...@@ -154,10 +154,6 @@ class Processor extends LogReader { ...@@ -154,10 +154,6 @@ class Processor extends LogReader {
}); });
} }
addEntry(entry) {
this.entries.push(entry);
}
/** /**
* Parser for dynamic code optimization state. * Parser for dynamic code optimization state.
*/ */
...@@ -227,10 +223,13 @@ class Processor extends LogReader { ...@@ -227,10 +223,13 @@ class Processor extends LogReader {
let parts = fnName.split(' '); let parts = fnName.split(' ');
let fileName = parts[1]; let fileName = parts[1];
let script = this.getScript(fileName); let script = this.getScript(fileName);
// TODO: Use SourcePosition here directly
let entry = new IcLogEvent( let entry = new IcLogEvent(
type, fnName, time, line, column, key, old_state, new_state, map, type, fnName, time, line, column, key, old_state, new_state, map,
slow_reason, script); slow_reason, script);
//TODO(zcankara) Process sourcePosition if (script) {
entry.sourcePosition = script.addSourcePosition(line, column, entry);
}
this.#icTimeline.push(entry); this.#icTimeline.push(entry);
} }
...@@ -266,11 +265,14 @@ class Processor extends LogReader { ...@@ -266,11 +265,14 @@ class Processor extends LogReader {
if (type === 'Deprecate') return this.deprecateMap(type, time_, from); if (type === 'Deprecate') return this.deprecateMap(type, time_, from);
let from_ = this.getExistingMap(from, time_); let from_ = this.getExistingMap(from, time_);
let to_ = this.getExistingMap(to, time_); let to_ = this.getExistingMap(to, time_);
//TODO(zcankara) Process sourcePosition // TODO: use SourcePosition directly.
let edge = new Edge(type, name, reason, time, from_, to_); let edge = new Edge(type, name, reason, time, from_, to_);
to_.filePosition = this.formatPC(pc, line, column); to_.filePosition = this.formatPC(pc, line, column);
let fileName = this.processFileName(to_.filePosition); let fileName = this.processFileName(to_.filePosition);
to_.script = this.getScript(fileName); to_.script = this.getScript(fileName);
if (to_.script) {
to_.sourcePosition = to_.script.addSourcePosition(line, column, to_)
}
edge.finishSetup(); edge.finishSetup();
} }
...@@ -292,7 +294,6 @@ class Processor extends LogReader { ...@@ -292,7 +294,6 @@ class Processor extends LogReader {
createMap(id, time) { createMap(id, time) {
let map = new MapLogEvent(id, time); let map = new MapLogEvent(id, time);
//TODO(zcankara) Process sourcePosition
this.#mapTimeline.push(map); this.#mapTimeline.push(map);
return map; return map;
} }
...@@ -309,7 +310,12 @@ class Processor extends LogReader { ...@@ -309,7 +310,12 @@ class Processor extends LogReader {
} }
getScript(url) { getScript(url) {
return this.#profile.getScript(url); const script = this.#profile.getScript(url);
// TODO create placeholder script for empty urls.
if (script === undefined) {
console.error(`Could not find script for url: '${url}'`)
}
return script;
} }
get icTimeline() { get icTimeline() {
...@@ -320,7 +326,9 @@ class Processor extends LogReader { ...@@ -320,7 +326,9 @@ class Processor extends LogReader {
return this.#mapTimeline; return this.#mapTimeline;
} }
get scripts() {
return this.#profile.scripts_.filter(script => script !== undefined);
}
} }
Processor.kProperties = [ Processor.kProperties = [
......
...@@ -2,32 +2,50 @@ ...@@ -2,32 +2,50 @@
Use of this source code is governed by a BSD-style license that can be Use of this source code is governed by a BSD-style license that can be
found in the LICENSE file. --> found in the LICENSE file. -->
<head>
<link href="./index.css" rel="stylesheet">
</head>
<style> <style>
@import "./index.css";
pre.scriptNode { pre.scriptNode {
white-space: pre-wrap; white-space: pre-wrap;
} }
pre.scriptNode:before { pre.scriptNode:before {
counter-reset: listing; counter-reset: sourceLineCounter;
} }
pre.scriptNode code { pre.scriptNode span {
counter-increment: listing; counter-increment: sourceLineCounter;
} }
pre.scriptNode code::before { pre.scriptNode span::before {
content: counter(listing) ". "; content: counter(sourceLineCounter) " ";
display: inline-block; display: inline-block;
width: 4em; width: 4em;
padding-left: auto; padding-left: auto;
margin-left: auto; margin-left: auto;
text-align: left; text-align: right;
} }
mark {
width: 1ch;
height: 1lh;
border-radius: 0.1lh;
border: 0.5px var(--background-color) solid;
cursor: pointer;
}
.marked {
background-color: var(--primary-color);
color: var(--on-primary-color);
}
</style> </style>
<div class="panel"> <div class="panel">
<h2>Source Panel</h2> <h2>Source Panel</h2>
<div class="script-dropdown">
<label for="scripts-label">Scripts:</label>
<select id="script-dropdown"></select>
</div>
<div id="script"> <div id="script">
<pre class="scripNode"></pre> <pre class="scripNode"></pre>
</div> </div>
......
...@@ -2,14 +2,21 @@ ...@@ -2,14 +2,21 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import { V8CustomElement, defineCustomElement } from "./helper.mjs"; import { V8CustomElement, defineCustomElement } from "./helper.mjs";
import { SelectionEvent, FocusEvent } from "./events.mjs";
import { MapLogEvent } from "./log/map.mjs";
import { IcLogEvent } from "./log/ic.mjs";
defineCustomElement( defineCustomElement(
"source-panel", "source-panel",
(templateText) => (templateText) =>
class SourcePanel extends V8CustomElement { class SourcePanel extends V8CustomElement {
#selectedSourcePositions; #selectedSourcePositions;
#scripts = [];
#script;
constructor() { constructor() {
super(templateText); super(templateText);
this.scriptDropdown.addEventListener(
'change', e => this.handleSelectScript(e));
} }
get script() { get script() {
return this.$('#script'); return this.$('#script');
...@@ -18,56 +25,165 @@ defineCustomElement( ...@@ -18,56 +25,165 @@ defineCustomElement(
return this.$('.scriptNode'); return this.$('.scriptNode');
} }
set script(script) { set script(script) {
this.renderSourcePanel(script); this.#script = script;
this.renderSourcePanel();
} }
set selectedSourcePositions(sourcePositions) { set selectedSourcePositions(sourcePositions) {
this.#selectedSourcePositions = sourcePositions; this.#selectedSourcePositions = sourcePositions;
this.renderSourcePanelSelectedHighlight();
} }
get selectedSourcePositions() { get selectedSourcePositions() {
return this.#selectedSourcePositions; return this.#selectedSourcePositions;
} }
highlightSourcePosition(line, col, script) { set data(value) {
//TODO(zcankara) change setting source to support multiple files this.#scripts = value;
this.script = script; this.initializeScriptDropdown();
let codeNodes = this.scriptNode.children; this.script = this.#scripts[0];
for (let index = 1; index <= codeNodes.length; index++) {
if (index != line) continue;
let lineText = codeNodes[index - 1].innerHTML;
for (let char = 1; char <= lineText.length; char++) {
if (char != col) continue;
let target = char - 1;
codeNodes[line - 1].innerHTML = lineText.slice(0, target) +
"<span class='highlight'> </span>" +
lineText.slice(target, lineText.length);
}
}
} }
createScriptNode() { get scriptDropdown() {
let scriptNode = document.createElement("pre"); return this.$("#script-dropdown");
scriptNode.classList.add('scriptNode');
return scriptNode;
} }
renderSourcePanel(source) { initializeScriptDropdown() {
let scriptNode = this.createScriptNode(); this.#scripts.sort((a, b) => a.name.localeCompare(b.name));
let sourceLines = source.split("\n"); let select = this.scriptDropdown;
for (let line = 1; line <= sourceLines.length; line++) { select.options.length = 0;
let codeNode = document.createElement("code"); for (const script of this.#scripts) {
codeNode.classList.add("line" + line); const option = document.createElement("option");
codeNode.innerHTML = sourceLines[line - 1] + "\n"; option.text = `${script.name} (id=${script.id})`;
scriptNode.appendChild(codeNode); option.script = script;
select.add(option);
} }
let oldScriptNode = this.script.childNodes[1]; }
renderSourcePanel() {
const builder = new LineBuilder(this, this.#script);
const scriptNode = builder.createScriptNode();
const oldScriptNode = this.script.childNodes[1];
this.script.replaceChild(scriptNode, oldScriptNode); this.script.replaceChild(scriptNode, oldScriptNode);
} }
renderSourcePanelSelectedHighlight() {
for (const sourcePosition of this.selectedSourcePositions) { handleSelectScript(e) {
let line = sourcePosition.line; const option = this.scriptDropdown.options[this.scriptDropdown.selectedIndex];
let col = sourcePosition.col; this.script = option.script;
let script = sourcePosition.script; }
if (!(line && col && script)) continue;
this.highlightSourcePosition(line, col, script); handleSourcePositionClick(e) {
let icLogEvents = [];
let mapLogEvents = [];
for (const entry of e.target.sourcePosition.entries) {
if (entry instanceof MapLogEvent) {
mapLogEvents.push(entry);
} else if (entry instanceof IcLogEvent) {
icLogEvents.push(entry);
}
}
if (icLogEvents.length > 0 ) {
this.dispatchEvent(new SelectionEvent(icLogEvents));
this.dispatchEvent(new FocusEvent(icLogEvents[0]));
}
if (mapLogEvents.length > 0) {
this.dispatchEvent(new SelectionEvent(mapLogEvents));
this.dispatchEvent(new FocusEvent(mapLogEvents[0]));
} }
} }
} }
); );
class SourcePositionIterator {
#entries;
#index = 0;
constructor(sourcePositions) {
this.#entries = sourcePositions;
}
*forLine(lineIndex) {
while(!this.#done() && this.#current().line === lineIndex) {
yield this.#current();
this.#next();
}
}
#current() {
return this.#entries[this.#index];
}
#done() {
return this.#index + 1 >= this.#entries.length;
}
#next() {
this.#index++;
}
}
function * lineIterator(source) {
let current = 0;
let line = 1;
while(current < source.length) {
const next = source.indexOf("\n", current);
if (next === -1) break;
yield [line, source.substring(current, next)];
line++;
current = next + 1;
}
if (current < source.length) yield [line, source.substring(current)];
}
class LineBuilder {
#script
#clickHandler
#sourcePositions
constructor(panel, script) {
this.#script = script;
this.#clickHandler = panel.handleSourcePositionClick.bind(panel);
// TODO: sort on script finalization.
script.sourcePositions.sort((a, b) => {
if (a.line === b.line) return a.column - b.column;
return a.line - b.line;
})
this.#sourcePositions
= new SourcePositionIterator(script.sourcePositions);
}
createScriptNode() {
const scriptNode = document.createElement("pre");
scriptNode.classList.add('scriptNode');
for (let [lineIndex, line] of lineIterator(this.#script.source)) {
scriptNode.appendChild(this.#createLineNode(lineIndex, line));
}
return scriptNode;
}
#createLineNode(lineIndex, line) {
const lineNode = document.createElement("span");
let columnIndex = 0;
for (const sourcePosition of this.#sourcePositions.forLine(lineIndex)) {
const nextColumnIndex = sourcePosition.column - 1;
lineNode.appendChild(
document.createTextNode(
line.substring(columnIndex, nextColumnIndex)));
columnIndex = nextColumnIndex;
lineNode.appendChild(
this.#createMarkerNode(line[columnIndex], sourcePosition));
columnIndex++;
}
lineNode.appendChild(
document.createTextNode(line.substring(columnIndex) + "\n"));
return lineNode;
}
#createMarkerNode(text, sourcePosition) {
const marker = document.createElement("mark");
marker.classList.add('marked');
marker.textContent = text;
marker.sourcePosition = sourcePosition;
marker.onclick = this.#clickHandler;
return marker;
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment