mirror of
https://gitlab.winehq.org/wine/wine-gecko.git
synced 2024-09-13 09:24:08 -07:00
Bug 1215953 - Add feature for importing heap snapshots into the memory
tool. r=fitzgen,ntim
This commit is contained in:
parent
225e172a8e
commit
322a1fc861
@ -32,6 +32,10 @@ snapshot.io.save=Save
|
||||
# saving a snapshot to disk.
|
||||
snapshot.io.save.window=Save Heap Snapshot
|
||||
|
||||
# LOCALIZATION NOTE (snapshot.io.import.window): The title for the window displayed when
|
||||
# importing a snapshot form disk.
|
||||
snapshot.io.import.window=Import Heap Snapshot
|
||||
|
||||
# LOCALIZATION NOTE (snapshot.io.filter): The title for the filter used to
|
||||
# filter file types (*.fxsnapshot)
|
||||
snapshot.io.filter=Firefox Heap Snapshots
|
||||
@ -60,6 +64,10 @@ toolbar.breakdownBy=Group by:
|
||||
# taking a snapshot, either as the main label, or a tooltip.
|
||||
take-snapshot=Take snapshot
|
||||
|
||||
# LOCALIZATION NOTE (import-snapshot): The label describing the button that initiates
|
||||
# importing a snapshot.
|
||||
import-snapshot=Import…
|
||||
|
||||
# LOCALIZATION NOTE (filter.placeholder): The placeholder text used for the
|
||||
# memory tool's filter search box.
|
||||
filter.placeholder=Filter
|
||||
@ -89,6 +97,11 @@ tree-item.percent=%S%
|
||||
# state SAVING, used in the main heap view.
|
||||
snapshot.state.saving.full=Saving snapshot…
|
||||
|
||||
# LOCALIZATION NOTE (snapshot.state.importing.full): The label describing the snapshot
|
||||
# state IMPORTING, used in the main heap view. %S represents the basename of the file
|
||||
# imported.
|
||||
snapshot.state.importing.full=Importing %S…
|
||||
|
||||
# LOCALIZATION NOTE (snapshot.state.reading.full): The label describing the snapshot
|
||||
# state READING, and SAVED, due to these states being combined visually, used
|
||||
# in the main heap view.
|
||||
@ -106,6 +119,10 @@ snapshot.state.error.full=There was an error processing this snapshot.
|
||||
# state SAVING, used in the snapshot list view
|
||||
snapshot.state.saving=Saving snapshot…
|
||||
|
||||
# LOCALIZATION NOTE (snapshot.state.importing): The label describing the snapshot
|
||||
# state IMPORTING, used in the snapshot list view
|
||||
snapshot.state.importing=Importing snapshot…
|
||||
|
||||
# LOCALIZATION NOTE (snapshot.state.reading): The label describing the snapshot
|
||||
# state READING, and SAVED, due to these states being combined visually, used
|
||||
# in the snapshot list view.
|
||||
|
@ -3,9 +3,10 @@
|
||||
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
"use strict";
|
||||
|
||||
const { assert } = require("devtools/shared/DevToolsUtils");
|
||||
const { reportException, assert } = require("devtools/shared/DevToolsUtils");
|
||||
const { snapshotState: states, actions } = require("../constants");
|
||||
const { L10N, openFilePicker } = require("../utils");
|
||||
const { L10N, openFilePicker, createSnapshot } = require("../utils");
|
||||
const { readSnapshot, takeCensus, selectSnapshot } = require("./snapshot");
|
||||
const { OS } = require("resource://gre/modules/osfile.jsm");
|
||||
const VALID_EXPORT_STATES = [states.SAVED, states.READ, states.SAVING_CENSUS, states.SAVED_CENSUS];
|
||||
|
||||
@ -14,7 +15,8 @@ exports.pickFileAndExportSnapshot = function (snapshot) {
|
||||
let outputFile = yield openFilePicker({
|
||||
title: L10N.getFormatStr("snapshot.io.save.window"),
|
||||
defaultName: OS.Path.basename(snapshot.path),
|
||||
filters: [[L10N.getFormatStr("snapshot.io.filter"), "*.fxsnapshot"]]
|
||||
filters: [[L10N.getFormatStr("snapshot.io.filter"), "*.fxsnapshot"]],
|
||||
mode: "save",
|
||||
});
|
||||
|
||||
if (!outputFile) {
|
||||
@ -43,3 +45,43 @@ const exportSnapshot = exports.exportSnapshot = function (snapshot, dest) {
|
||||
dispatch({ type: actions.EXPORT_SNAPSHOT_END, snapshot });
|
||||
};
|
||||
};
|
||||
|
||||
const pickFileAndImportSnapshotAndCensus = exports.pickFileAndImportSnapshotAndCensus = function (heapWorker) {
|
||||
return function* (dispatch, getState) {
|
||||
let input = yield openFilePicker({
|
||||
title: L10N.getFormatStr("snapshot.io.import.window"),
|
||||
filters: [[L10N.getFormatStr("snapshot.io.filter"), "*.fxsnapshot"]],
|
||||
mode: "open",
|
||||
});
|
||||
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
yield dispatch(importSnapshotAndCensus(heapWorker, input.path));
|
||||
};
|
||||
};
|
||||
|
||||
const importSnapshotAndCensus = exports.importSnapshotAndCensus = function (heapWorker, path) {
|
||||
return function* (dispatch, getState) {
|
||||
let snapshot = createSnapshot();
|
||||
|
||||
// Override the defaults for a new snapshot
|
||||
snapshot.path = path;
|
||||
snapshot.state = states.IMPORTING;
|
||||
snapshot.imported = true;
|
||||
|
||||
dispatch({ type: actions.IMPORT_SNAPSHOT_START, snapshot });
|
||||
dispatch(selectSnapshot(snapshot));
|
||||
|
||||
try {
|
||||
yield dispatch(readSnapshot(heapWorker, snapshot));
|
||||
yield dispatch(takeCensus(heapWorker, snapshot));
|
||||
} catch (error) {
|
||||
reportException("importSnapshot", error);
|
||||
dispatch({ type: actions.IMPORT_SNAPSHOT_ERROR, error, snapshot });
|
||||
}
|
||||
|
||||
dispatch({ type: actions.IMPORT_SNAPSHOT_END, snapshot });
|
||||
};
|
||||
};
|
||||
|
@ -72,7 +72,7 @@ const takeSnapshot = exports.takeSnapshot = function (front) {
|
||||
*/
|
||||
const readSnapshot = exports.readSnapshot = function readSnapshot (heapWorker, snapshot) {
|
||||
return function *(dispatch, getState) {
|
||||
assert(snapshot.state === states.SAVED,
|
||||
assert([states.SAVED, states.IMPORTING].includes(snapshot.state),
|
||||
`Should only read a snapshot once. Found snapshot in state ${snapshot.state}`);
|
||||
|
||||
let creationTime;
|
||||
|
@ -9,7 +9,7 @@ const { toggleRecordingAllocationStacks } = require("./actions/allocations");
|
||||
const { setBreakdownAndRefresh } = require("./actions/breakdown");
|
||||
const { toggleInvertedAndRefresh } = require("./actions/inverted");
|
||||
const { setFilterStringAndRefresh } = require("./actions/filter");
|
||||
const { pickFileAndExportSnapshot } = require("./actions/io");
|
||||
const { pickFileAndExportSnapshot, pickFileAndImportSnapshotAndCensus } = require("./actions/io");
|
||||
const { selectSnapshotAndRefresh, takeSnapshotAndCensus } = require("./actions/snapshot");
|
||||
const { breakdownNameToSpec, getBreakdownDisplayData } = require("./utils");
|
||||
const Toolbar = createFactory(require("./components/toolbar"));
|
||||
@ -61,6 +61,7 @@ const App = createClass({
|
||||
|
||||
Toolbar({
|
||||
breakdowns: getBreakdownDisplayData(),
|
||||
onImportClick: () => dispatch(pickFileAndImportSnapshotAndCensus(heapWorker)),
|
||||
onTakeSnapshotClick: () => dispatch(takeSnapshotAndCensus(front, heapWorker)),
|
||||
onBreakdownChange: breakdown =>
|
||||
dispatch(setBreakdownAndRefresh(heapWorker, breakdownNameToSpec(breakdown))),
|
||||
|
@ -113,6 +113,7 @@ const Heap = module.exports = createClass({
|
||||
dom.pre({}, safeErrorString(snapshot.error))
|
||||
];
|
||||
break;
|
||||
case states.IMPORTING:
|
||||
case states.SAVING:
|
||||
case states.SAVED:
|
||||
case states.READING:
|
||||
|
@ -14,6 +14,7 @@ const Toolbar = module.exports = createClass({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
})).isRequired,
|
||||
onTakeSnapshotClick: PropTypes.func.isRequired,
|
||||
onImportClick: PropTypes.func.isRequired,
|
||||
onBreakdownChange: PropTypes.func.isRequired,
|
||||
onToggleRecordAllocationStacks: PropTypes.func.isRequired,
|
||||
allocations: models.allocations,
|
||||
@ -26,6 +27,7 @@ const Toolbar = module.exports = createClass({
|
||||
render() {
|
||||
let {
|
||||
onTakeSnapshotClick,
|
||||
onImportClick,
|
||||
onBreakdownChange,
|
||||
breakdowns,
|
||||
onToggleRecordAllocationStacks,
|
||||
@ -45,6 +47,14 @@ const Toolbar = module.exports = createClass({
|
||||
title: L10N.getStr("take-snapshot")
|
||||
}),
|
||||
|
||||
dom.button({
|
||||
id: "import-snapshot",
|
||||
className: "devtools-toolbarbutton import-snapshot devtools-button",
|
||||
onClick: onImportClick,
|
||||
title: L10N.getStr("import-snapshot"),
|
||||
"data-text-only": true,
|
||||
}, L10N.getStr("import-snapshot")),
|
||||
|
||||
dom.div({ className: "toolbar-group" },
|
||||
dom.label({ className: "breakdown-by" },
|
||||
L10N.getStr("toolbar.breakdownBy"),
|
||||
|
@ -29,6 +29,12 @@ actions.EXPORT_SNAPSHOT_START = "export-snapshot-start";
|
||||
actions.EXPORT_SNAPSHOT_END = "export-snapshot-end";
|
||||
actions.EXPORT_SNAPSHOT_ERROR = "export-snapshot-error";
|
||||
|
||||
// When a heap snapshot is being read from a user selected file,
|
||||
// and represents the entire state until the census is available.
|
||||
actions.IMPORT_SNAPSHOT_START = "import-snapshot-start";
|
||||
actions.IMPORT_SNAPSHOT_END = "import-snapshot-end";
|
||||
actions.IMPORT_SNAPSHOT_ERROR = "import-snapshot-error";
|
||||
|
||||
// Fired by UI to select a snapshot to view.
|
||||
actions.SELECT_SNAPSHOT = "select-snapshot";
|
||||
|
||||
@ -93,14 +99,15 @@ const snapshotState = exports.snapshotState = {};
|
||||
* Various states a snapshot can be in.
|
||||
* An FSM describing snapshot states:
|
||||
*
|
||||
* SAVING -> SAVED -> READING -> READ <- <- <- SAVED_CENSUS
|
||||
* ↘ ↗
|
||||
* SAVING_CENSUS
|
||||
* SAVING -> SAVED -> READING -> READ SAVED_CENSUS
|
||||
* IMPORTING ↗ ↘ ↑ ↓
|
||||
* SAVING_CENSUS
|
||||
*
|
||||
* Any of these states may go to the ERROR state, from which they can never
|
||||
* leave (mwah ha ha ha!)
|
||||
*/
|
||||
snapshotState.ERROR = "snapshot-state-error";
|
||||
snapshotState.IMPORTING = "snapshot-state-importing";
|
||||
snapshotState.SAVING = "snapshot-state-saving";
|
||||
snapshotState.SAVED = "snapshot-state-saved";
|
||||
snapshotState.READING = "snapshot-state-reading";
|
||||
|
@ -39,13 +39,15 @@ let snapshotModel = exports.snapshot = PropTypes.shape({
|
||||
filter: PropTypes.string,
|
||||
// If an error was thrown while processing this snapshot, the `Error` instance is attached here.
|
||||
error: PropTypes.object,
|
||||
// Boolean indicating whether or not this snapshot was imported.
|
||||
imported: PropTypes.bool.isRequired,
|
||||
// The creation time of the snapshot; required after the snapshot has been read.
|
||||
creationTime: PropTypes.number,
|
||||
// The current state the snapshot is in.
|
||||
// @see ./constants.js
|
||||
state: function (snapshot, propName) {
|
||||
let current = snapshot.state;
|
||||
let shouldHavePath = [states.SAVED, states.READ, states.SAVING_CENSUS, states.SAVED_CENSUS];
|
||||
let shouldHavePath = [states.IMPORTING, states.SAVED, states.READ, states.SAVING_CENSUS, states.SAVED_CENSUS];
|
||||
let shouldHaveCreationTime = [states.READ, states.SAVING_CENSUS, states.SAVED_CENSUS];
|
||||
let shouldHaveCensus = [states.SAVED_CENSUS];
|
||||
|
||||
|
@ -28,6 +28,8 @@ handlers[actions.TAKE_SNAPSHOT_END] = function (snapshots, action) {
|
||||
});
|
||||
};
|
||||
|
||||
handlers[actions.IMPORT_SNAPSHOT_START] = handlers[actions.TAKE_SNAPSHOT_START];
|
||||
|
||||
handlers[actions.READ_SNAPSHOT_START] = function (snapshots, action) {
|
||||
let snapshot = getSnapshot(snapshots, action.snapshot);
|
||||
snapshot.state = states.READING;
|
||||
|
@ -119,3 +119,12 @@ function isBreakdownType (census, type) {
|
||||
throw new Error(`isBreakdownType does not yet support ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function *createTempFile () {
|
||||
let file = FileUtils.getFile("TmpD", ["tmp.fxsnapshot"]);
|
||||
file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
|
||||
let destPath = file.path;
|
||||
let stat = yield OS.File.stat(destPath);
|
||||
ok(stat.size === 0, "new file is 0 bytes at start");
|
||||
return destPath;
|
||||
}
|
||||
|
@ -18,12 +18,7 @@ add_task(function *() {
|
||||
let store = Store();
|
||||
const { getState, dispatch } = store;
|
||||
|
||||
let file = FileUtils.getFile("TmpD", ["tmp.fxsnapshot"]);
|
||||
file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
|
||||
let destPath = file.path;
|
||||
let stat = yield OS.File.stat(destPath);
|
||||
ok(stat.size === 0, "new file is 0 bytes at start");
|
||||
|
||||
let destPath = yield createTempFile();
|
||||
dispatch(takeSnapshotAndCensus(front, heapWorker));
|
||||
yield waitUntilSnapshotState(store, [states.SAVED_CENSUS]);
|
||||
|
||||
|
@ -0,0 +1,85 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/**
|
||||
* Tests the task creator `importSnapshotAndCensus()` for the whole flow of
|
||||
* importing a snapshot, and its sub-actions.
|
||||
*/
|
||||
|
||||
let { actions, snapshotState: states } = require("devtools/client/memory/constants");
|
||||
let { breakdownEquals } = require("devtools/client/memory/utils");
|
||||
let { exportSnapshot, importSnapshotAndCensus } = require("devtools/client/memory/actions/io");
|
||||
let { takeSnapshotAndCensus } = require("devtools/client/memory/actions/snapshot");
|
||||
|
||||
function run_test() {
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
add_task(function *() {
|
||||
let front = new StubbedMemoryFront();
|
||||
let heapWorker = new HeapAnalysesClient();
|
||||
yield front.attach();
|
||||
let store = Store();
|
||||
let { subscribe, dispatch, getState } = store;
|
||||
|
||||
let destPath = yield createTempFile();
|
||||
dispatch(takeSnapshotAndCensus(front, heapWorker));
|
||||
yield waitUntilSnapshotState(store, [states.SAVED_CENSUS]);
|
||||
|
||||
let exportEvents = Promise.all([
|
||||
waitUntilAction(store, actions.EXPORT_SNAPSHOT_START),
|
||||
waitUntilAction(store, actions.EXPORT_SNAPSHOT_END)
|
||||
]);
|
||||
dispatch(exportSnapshot(getState().snapshots[0], destPath));
|
||||
yield exportEvents;
|
||||
|
||||
// Now import our freshly exported snapshot
|
||||
let i = 0;
|
||||
let expected = ["IMPORTING", "READING", "READ", "SAVING_CENSUS", "SAVED_CENSUS"];
|
||||
let expectStates = () => {
|
||||
let snapshot = getState().snapshots[1];
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
let isCorrectState = snapshot.state === states[expected[i]];
|
||||
if (isCorrectState) {
|
||||
ok(true, `Found expected state ${expected[i]}`);
|
||||
i++;
|
||||
}
|
||||
};
|
||||
|
||||
let unsubscribe = subscribe(expectStates);
|
||||
dispatch(importSnapshotAndCensus(heapWorker, destPath));
|
||||
|
||||
yield waitUntilState(store, () => i === 5);
|
||||
unsubscribe();
|
||||
equal(i, 5, "importSnapshotAndCensus() produces the correct sequence of states in a snapshot");
|
||||
equal(getState().snapshots[1].state, states.SAVED_CENSUS, "imported snapshot is in SAVED_CENSUS state");
|
||||
ok(getState().snapshots[1].selected, "imported snapshot is selected");
|
||||
|
||||
// Check snapshot data
|
||||
let snapshot1 = getState().snapshots[0];
|
||||
let snapshot2 = getState().snapshots[1];
|
||||
|
||||
ok(breakdownEquals(snapshot1.breakdown, snapshot2.breakdown),
|
||||
"imported snapshot has correct breakdown");
|
||||
|
||||
// Clone the census data so we can destructively remove the ID/parents to compare
|
||||
// equal census data
|
||||
let census1 = stripUnique(JSON.parse(JSON.stringify(snapshot1.census)));
|
||||
let census2 = stripUnique(JSON.parse(JSON.stringify(snapshot2.census)));
|
||||
|
||||
equal(JSON.stringify(census1), JSON.stringify(census2), "Imported snapshot has correct census");
|
||||
|
||||
function stripUnique (obj) {
|
||||
let children = obj.children || [];
|
||||
for (let child of children) {
|
||||
delete child.id;
|
||||
delete child.parent;
|
||||
stripUnique(child);
|
||||
}
|
||||
delete obj.id;
|
||||
delete obj.parent;
|
||||
return obj;
|
||||
}
|
||||
});
|
@ -9,6 +9,7 @@ skip-if = toolkit == 'android' || toolkit == 'gonk'
|
||||
[test_action-filter-01.js]
|
||||
[test_action-filter-02.js]
|
||||
[test_action-filter-03.js]
|
||||
[test_action-import-snapshot-and-census.js]
|
||||
[test_action-select-snapshot.js]
|
||||
[test_action-set-breakdown.js]
|
||||
[test_action-set-breakdown-and-refresh-01.js]
|
||||
|
@ -9,6 +9,7 @@ const STRINGS_URI = "chrome://devtools/locale/memory.properties"
|
||||
const L10N = exports.L10N = new ViewHelpers.L10N(STRINGS_URI);
|
||||
|
||||
const { URL } = require("sdk/url");
|
||||
const { OS } = require("resource://gre/modules/osfile.jsm");
|
||||
const { assert } = require("devtools/shared/DevToolsUtils");
|
||||
const { Preferences } = require("resource://gre/modules/Preferences.jsm");
|
||||
const CUSTOM_BREAKDOWN_PREF = "devtools.memory.custom-breakdowns";
|
||||
@ -27,6 +28,11 @@ exports.getSnapshotTitle = function (snapshot) {
|
||||
return L10N.getStr("snapshot-title.loading");
|
||||
}
|
||||
|
||||
if (snapshot.imported) {
|
||||
// Strip out the extension if it's the expected ".fxsnapshot"
|
||||
return OS.Path.basename(snapshot.path.replace(/\.fxsnapshot$/, ""));
|
||||
}
|
||||
|
||||
let date = new Date(snapshot.creationTime / 1000);
|
||||
return date.toLocaleTimeString(void 0, {
|
||||
year: "2-digit",
|
||||
@ -124,6 +130,8 @@ exports.getSnapshotStatusText = function (snapshot) {
|
||||
return L10N.getStr("snapshot.state.error");
|
||||
case states.SAVING:
|
||||
return L10N.getStr("snapshot.state.saving");
|
||||
case states.IMPORTING:
|
||||
return L10N.getStr("snapshot.state.importing");
|
||||
case states.SAVED:
|
||||
case states.READING:
|
||||
return L10N.getStr("snapshot.state.reading");
|
||||
@ -155,6 +163,8 @@ exports.getSnapshotStatusTextFull = function (snapshot) {
|
||||
return L10N.getStr("snapshot.state.error.full");
|
||||
case states.SAVING:
|
||||
return L10N.getStr("snapshot.state.saving.full");
|
||||
case states.IMPORTING:
|
||||
return L10N.getFormatStr("snapshot.state.importing", OS.Path.basename(snapshot.path));
|
||||
case states.SAVED:
|
||||
case states.READING:
|
||||
return L10N.getStr("snapshot.state.reading.full");
|
||||
@ -198,6 +208,8 @@ exports.createSnapshot = function createSnapshot () {
|
||||
state: states.SAVING,
|
||||
census: null,
|
||||
path: null,
|
||||
imported: false,
|
||||
selected: false,
|
||||
};
|
||||
};
|
||||
|
||||
@ -327,12 +339,21 @@ exports.parseSource = function (source) {
|
||||
* (like "*.json").
|
||||
* @param {String} .defaultName
|
||||
* The default name chosen by the file picker window.
|
||||
* @param {String} .mode
|
||||
* The mode that this filepicker should open in. Can be "open" or "save".
|
||||
* @return {Promise<?nsILocalFile>}
|
||||
* The file selected by the user, or null, if cancelled.
|
||||
*/
|
||||
exports.openFilePicker = function({ title, filters, defaultName }) {
|
||||
exports.openFilePicker = function({ title, filters, defaultName, mode }) {
|
||||
mode = mode === "save" ? Ci.nsIFilePicker.modeSave :
|
||||
mode === "open" ? Ci.nsIFilePicker.modeOpen : null;
|
||||
|
||||
if (mode == void 0) {
|
||||
throw new Error("No valid mode specified for nsIFilePicker.");
|
||||
}
|
||||
|
||||
let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
|
||||
fp.init(window, title, Ci.nsIFilePicker.modeSave);
|
||||
fp.init(window, title, mode);
|
||||
|
||||
for (let filter of (filters || [])) {
|
||||
fp.appendFilter(filter[0], filter[1]);
|
||||
|
@ -64,9 +64,10 @@ html, body, #app, #memory-tool {
|
||||
/**
|
||||
* We want this to be exactly at a --sidebar-width distance from the
|
||||
* toolbar's start boundary. A .devtools-toolbar has a 3px start padding
|
||||
* and the preceeding .take-snapshot button is exactly 32px.
|
||||
* and the preceeding .take-snapshot button is exactly 32px, and the import
|
||||
* button 78px.
|
||||
*/
|
||||
margin-inline-start: calc(var(--sidebar-width) - 3px - 32px);
|
||||
margin-inline-start: calc(var(--sidebar-width) - 3px - 32px - 78px);
|
||||
border-inline-start: 1px solid var(--theme-splitter-color);
|
||||
padding-inline-start: 5px;
|
||||
}
|
||||
@ -96,6 +97,14 @@ html, body, #app, #memory-tool {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Due to toolbar styles of `.devtools-toolbarbutton:not([label])` which overrides
|
||||
* .devtools-toolbarbutton's min-width of 78px, reset the min-width.
|
||||
*/
|
||||
#import-snapshot {
|
||||
min-width: 78px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user