mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Add save listing and subscriptions
Add link messages for listing saves and listening for changes to the list of saves for instances along with a table in the instance view page showing the save list for the instance and a list-saves command to ctl.
This commit is contained in:
1 parent
b53ed7f6ca
commit
a96fae5ffd
16 files changed
+390
-3
No files matched your search
@@ -338,6 +338,21 @@ async function loadMapSettings(args) {
|
||||
};
|
||||
}
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["list-saves <instance>", "list saves on an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to list saves on", type: "string" });
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
let instanceId = await libCommand.resolveInstance(control, args.instance);
|
||||
let response = await libLink.messages.listSaves.send(control, { instance_id: instanceId });
|
||||
for (let entry of response.list) {
|
||||
entry.mtime = new Date(entry.mtime_ms).toLocaleString();
|
||||
delete entry.mtime_ms;
|
||||
}
|
||||
print(asTable(response.list));
|
||||
},
|
||||
}));
|
||||
|
||||
instanceCommands.add(new libCommand.Command({
|
||||
definition: ["create-save <instance> [name]", "Create a new save on an instance", (yargs) => {
|
||||
yargs.positional("instance", { describe: "Instance to create on", type: "string" });
|
||||
@@ -794,6 +809,8 @@ class Control extends libLink.Link {
|
||||
|
||||
async instanceUpdateEventHandler() { }
|
||||
|
||||
async saveListUpdateEventHandler() { }
|
||||
|
||||
async setLogSubscriptions({
|
||||
all = false,
|
||||
master = false,
|
||||
|
||||
@@ -493,10 +493,37 @@ messages.startInstance = new Request({
|
||||
forwardTo: "instance",
|
||||
});
|
||||
|
||||
let saveList = {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["type", "name", "size", "mtime_ms", "loaded"],
|
||||
properties: {
|
||||
"type": { enum: ["file", "directory", "special"] },
|
||||
"name": { type: "string" },
|
||||
"size": { type: "integer" },
|
||||
"mtime_ms": { type: "number" },
|
||||
"loaded": { type: "boolean" },
|
||||
"default": { type: "boolean" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
messages.listSaves = new Request({
|
||||
type: "list_saves",
|
||||
links: ["control-master", "master-slave", "slave-instance"],
|
||||
permission: "core.instance.save.list",
|
||||
forwardTo: "instance",
|
||||
responseProperties: {
|
||||
"list": saveList,
|
||||
},
|
||||
});
|
||||
|
||||
messages.createSave = new Request({
|
||||
type: "create_save",
|
||||
links: ["control-master", "master-slave", "slave-instance"],
|
||||
permission: "core.instance.create_save",
|
||||
permission: "core.instance.save.create",
|
||||
forwardTo: "instance",
|
||||
requestProperties: {
|
||||
"name": { type: "string" },
|
||||
@@ -506,6 +533,19 @@ messages.createSave = new Request({
|
||||
},
|
||||
});
|
||||
|
||||
messages.setSaveListSubscriptions = new Request({
|
||||
type: "set_save_list_subscriptions",
|
||||
links: ["control-master"],
|
||||
permission: "core.instance.save.list_subscribe",
|
||||
requestProperties: {
|
||||
"all": { type: "boolean" },
|
||||
"instance_ids": {
|
||||
type: "array",
|
||||
items: { type: "integer" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
messages.loadScenario = new Request({
|
||||
type: "load_scenario",
|
||||
links: ["control-master", "master-slave", "slave-instance"],
|
||||
@@ -1025,6 +1065,16 @@ messages.instanceUpdate = new Event({
|
||||
eventProperties: instanceProperties,
|
||||
});
|
||||
|
||||
messages.saveListUpdate = new Event({
|
||||
type: "save_list_update",
|
||||
links: ["instance-slave", "slave-master", "master-control"],
|
||||
forwardTo: "master",
|
||||
eventProperties: {
|
||||
"instance_id": { type: "integer" },
|
||||
"list": saveList,
|
||||
},
|
||||
});
|
||||
|
||||
messages.masterConnectionEvent = new Event({
|
||||
type: "master_connection_event",
|
||||
links: ["slave-instance"],
|
||||
|
||||
+11
-1
@@ -372,10 +372,20 @@ definePermission({
|
||||
description: "Assign or reassign instances to a slave.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.create_save",
|
||||
name: "core.instance.save.list",
|
||||
title: "List saves on instance",
|
||||
description: "List the saves currently on the instance.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.create",
|
||||
title: "Create new instance save",
|
||||
description: "Create new savegames on instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.save.list_subscribe",
|
||||
title: "Subscribe to save list updatess",
|
||||
description: "Subscribe to be notifed on updates to the list of saves of instances.",
|
||||
});
|
||||
definePermission({
|
||||
name: "core.instance.export_data",
|
||||
title: "Export locale and icons",
|
||||
|
||||
@@ -43,6 +43,11 @@ class ControlConnection extends BaseConnection {
|
||||
instance_ids: [],
|
||||
};
|
||||
|
||||
this.saveListSubscriptions = {
|
||||
all: false,
|
||||
instance_ids: [],
|
||||
};
|
||||
|
||||
this.logTransport = null;
|
||||
this.logSubscriptions = {
|
||||
all: false,
|
||||
@@ -326,6 +331,19 @@ class ControlConnection extends BaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
async setSaveListSubscriptionsRequestHandler(message) {
|
||||
this.saveListSubscriptions = message.data;
|
||||
}
|
||||
|
||||
saveListUpdate(data) {
|
||||
if (
|
||||
this.saveListSubscriptions.all
|
||||
|| this.saveListSubscriptions.instance_ids.includes(data.instance_id)
|
||||
) {
|
||||
libLink.messages.saveListUpdate.send(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
async setLogSubscriptionsRequestHandler(message) {
|
||||
this.logSubscriptions = message.data;
|
||||
this.updateLogSubscriptions();
|
||||
|
||||
@@ -383,6 +383,16 @@ class Master {
|
||||
}
|
||||
}
|
||||
|
||||
saveListUpdate(data) {
|
||||
for (let controlConnection of this.wsServer.controlConnections) {
|
||||
if (controlConnection.connector.closing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
controlConnection.saveListUpdate(data);
|
||||
}
|
||||
}
|
||||
|
||||
async loadPlugins() {
|
||||
for (let pluginInfo of this.pluginInfos) {
|
||||
if (!this.config.group(pluginInfo.name).get("load_plugin")) {
|
||||
|
||||
@@ -146,6 +146,10 @@ class SlaveConnection extends BaseConnection {
|
||||
libLink.messages.syncUserLists.send(this, { adminlist, banlist, whitelist });
|
||||
}
|
||||
|
||||
async saveListUpdateEventHandler(message) {
|
||||
this._master.saveListUpdate(message.data);
|
||||
}
|
||||
|
||||
async logMessageEventHandler(message) {
|
||||
this._master.clusterLogger.log({
|
||||
...message.data.info,
|
||||
|
||||
@@ -169,6 +169,7 @@ class Instance extends libLink.Link {
|
||||
|
||||
this._status = "stopped";
|
||||
this._running = false;
|
||||
this._loadedSave = null;
|
||||
this.server = new libFactorio.FactorioServer(
|
||||
factorioDir, this._dir, serverOptions
|
||||
);
|
||||
@@ -209,9 +210,50 @@ class Instance extends libLink.Link {
|
||||
}
|
||||
}
|
||||
|
||||
static async listSaves(savesDir, loadedSave) {
|
||||
let defaultSave = null;
|
||||
if (loadedSave === null) {
|
||||
defaultSave = await libFileOps.getNewestFile(
|
||||
savesDir, (name) => !name.endsWith(".tmp.zip")
|
||||
);
|
||||
}
|
||||
|
||||
let list = [];
|
||||
for (let name of await fs.readdir(savesDir)) {
|
||||
let type;
|
||||
let stat = await fs.stat(path.join(savesDir, name));
|
||||
if (stat.isFile()) {
|
||||
type = "file";
|
||||
} else if (stat.isDirectory()) {
|
||||
type = "directory";
|
||||
} else {
|
||||
type = "special";
|
||||
}
|
||||
|
||||
list.push({
|
||||
name,
|
||||
type,
|
||||
size: stat.size,
|
||||
mtime_ms: stat.mtimeMs,
|
||||
loaded: name === loadedSave,
|
||||
default: name === defaultSave,
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
async sendSaveListUpdate() {
|
||||
libLink.messages.saveListUpdate.send(this, {
|
||||
instance_id: this.id,
|
||||
list: await Instance.listSaves(this.server.writePath("saves"), this._loadedSave),
|
||||
});
|
||||
}
|
||||
|
||||
async _autosave(name) {
|
||||
let stat = await fs.stat(this.path("saves", `${name}.zip`));
|
||||
instanceFactorioAutosaveSize.labels(String(this.id)).set(stat.size);
|
||||
await this.sendSaveListUpdate();
|
||||
}
|
||||
|
||||
notifyStatus(status) {
|
||||
@@ -234,6 +276,7 @@ class Instance extends libLink.Link {
|
||||
|
||||
notifyExit() {
|
||||
this._running = false;
|
||||
this._loadedSave = null;
|
||||
this.notifyStatus("stopped");
|
||||
|
||||
this.config.off("fieldChanged", this._configFieldChanged);
|
||||
@@ -252,6 +295,10 @@ class Instance extends libLink.Link {
|
||||
for (let pluginInstance of this.plugins.values()) {
|
||||
pluginInstance.onExit();
|
||||
}
|
||||
|
||||
this.sendSaveListUpdate().catch(err => {
|
||||
this.logger.error(`Unexpected error updating save list:\n${err.stack}`);
|
||||
});
|
||||
}
|
||||
|
||||
async _loadPlugin(pluginInfo, slave) {
|
||||
@@ -496,6 +543,7 @@ class Instance extends libLink.Link {
|
||||
});
|
||||
|
||||
this.server.on("exit", () => this.notifyExit());
|
||||
this._loadedSave = saveName;
|
||||
await this.server.start(saveName);
|
||||
|
||||
if (this.config.get("factorio.enable_save_patching")) {
|
||||
@@ -503,6 +551,7 @@ class Instance extends libLink.Link {
|
||||
await this.updateInstanceData();
|
||||
}
|
||||
|
||||
await this.sendSaveListUpdate();
|
||||
await libPlugin.invokeHook(this.plugins, "onStart");
|
||||
|
||||
this._running = true;
|
||||
@@ -690,6 +739,12 @@ class Instance extends libLink.Link {
|
||||
}
|
||||
}
|
||||
|
||||
async listSavesRequestHandler(message) {
|
||||
return {
|
||||
list: await Instance.listSaves(this.server.writePath("saves"), this._loadedSave),
|
||||
};
|
||||
}
|
||||
|
||||
async createSaveRequestHandler(message) {
|
||||
this.notifyStatus("creating_save");
|
||||
try {
|
||||
@@ -1301,6 +1356,23 @@ class Slave extends libLink.Link {
|
||||
return await request.send(instanceConnection, message.data);
|
||||
}
|
||||
|
||||
async listSavesRequestHandler(message, request) {
|
||||
let instanceId = message.data.instance_id;
|
||||
let instanceConnection = this.instanceConnections.get(instanceId);
|
||||
if (instanceConnection) {
|
||||
return await request.send(instanceConnection, message.data);
|
||||
}
|
||||
|
||||
let instanceInfo = this.instanceInfos.get(instanceId);
|
||||
if (!instanceInfo) {
|
||||
throw new libErrors.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
|
||||
return {
|
||||
list: await Instance.listSaves(path.join(instanceInfo.path, "saves"), null),
|
||||
};
|
||||
}
|
||||
|
||||
async createSaveRequestHandler(message, request) {
|
||||
let instanceId = message.data.instance_id;
|
||||
let instanceConnection = await this._connectInstance(instanceId);
|
||||
|
||||
@@ -14,6 +14,8 @@ import AssignInstanceModal from "./AssignInstanceModal";
|
||||
import StartStopInstanceButton from "./StartStopInstanceButton";
|
||||
import CreateSaveModal from "./CreateSaveModal";
|
||||
import LoadScenarioModal from "./LoadScenarioModal";
|
||||
import SavesList from "./SavesList";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { useInstance } from "../model/instance";
|
||||
import { useSlave } from "../model/slave";
|
||||
@@ -47,7 +49,6 @@ export default function InstanceViewPage(props) {
|
||||
|
||||
let instanceButtons = <Space>
|
||||
<StartStopInstanceButton instance={instance} />
|
||||
<CreateSaveModal instance={instance} />
|
||||
<LoadScenarioModal instance={instance} />
|
||||
<Button
|
||||
loading={exportingData}
|
||||
@@ -115,6 +116,8 @@ export default function InstanceViewPage(props) {
|
||||
<Descriptions.Item label="Status">{instance["status"]}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<SectionHeader title="Saves" extra=<CreateSaveModal instance={instance} /> />
|
||||
<SavesList instance={instance} />
|
||||
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
|
||||
<InstanceConsole id={instanceId} />
|
||||
<InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Table, Tooltip } from "antd";
|
||||
import CaretLeftOutlined from "@ant-design/icons/CaretLeftOutlined";
|
||||
import LeftOutlined from "@ant-design/icons/LeftOutlined";
|
||||
|
||||
import ControlContext from "./ControlContext";
|
||||
import { useSaves } from "../model/saves";
|
||||
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) {
|
||||
return "0 Bytes";
|
||||
}
|
||||
|
||||
let units = [" Bytes", " kB", " MB", " GB", " TB"];
|
||||
let factor = 1000;
|
||||
let power = Math.min(Math.floor(Math.log(bytes) / Math.log(factor)), units.length);
|
||||
return (power > 0 ? (bytes / factor ** power).toFixed(2) : bytes) + units[power];
|
||||
}
|
||||
|
||||
export default function SavesList(props) {
|
||||
let control = useContext(ControlContext);
|
||||
let saves = useSaves(props.instance.id);
|
||||
|
||||
return <Table
|
||||
size="small"
|
||||
columns={[
|
||||
{
|
||||
title: "Name",
|
||||
render: save => <>
|
||||
{save.name}
|
||||
{save.loaded && <Tooltip title="Currently loaded save"><CaretLeftOutlined/></Tooltip>}
|
||||
{save.default && <Tooltip title="Save loaded by default"><LeftOutlined/></Tooltip>}
|
||||
</>,
|
||||
sorter: (a, b) => a.name.localeCompare(b.name),
|
||||
},
|
||||
{
|
||||
title: "Size",
|
||||
key: "size",
|
||||
responsive: ["sm"],
|
||||
render: save => formatBytes(save.size),
|
||||
align: "right",
|
||||
sorter: (a, b) => a.size - b.size,
|
||||
},
|
||||
{
|
||||
title: "Last Modified",
|
||||
key: "mtime_ms",
|
||||
render: save => new Date(save.mtime_ms).toLocaleString(),
|
||||
sorter: (a, b) => a.mtime_ms - b.mtime_ms,
|
||||
defaultSortOrder: "descend",
|
||||
},
|
||||
]}
|
||||
dataSource={saves}
|
||||
rowKey={save => save.name}
|
||||
pagination={false}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
|
||||
export default function SectionHeader(props) {
|
||||
return <div className="section-header">
|
||||
<div className="section-header-title">{props.title}</div>
|
||||
{props.extra && <div className="section-header-extra">{props.extra}</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -67,6 +67,27 @@ body {
|
||||
padding-right: 64px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-header-title {
|
||||
flex: auto;
|
||||
overflow: hidden;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
line-height: 1.5715;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.section-header-extra {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
|
||||
@@ -18,3 +18,5 @@ export { default as InstanceConsole } from "./components/InstanceConsole";
|
||||
export { default as InstanceRcon } from "./components/InstanceRcon";
|
||||
|
||||
export { default as PageLayout } from "./components/PageLayout";
|
||||
export { default as SavesList } from "./components/SavesList";
|
||||
export { default as SectionHeader } from "./components/SectionHeader";
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { useEffect, useContext, useState } from "react";
|
||||
import ControlContext from "../components/ControlContext";
|
||||
|
||||
import libLink from "@clusterio/lib/link";
|
||||
import { logger } from "@clusterio/lib/logging";
|
||||
|
||||
|
||||
export function useSaves(instanceId) {
|
||||
let control = useContext(ControlContext);
|
||||
let [saves, setSaves] = useState([]);
|
||||
|
||||
function updateSaves() {
|
||||
libLink.messages.listSaves.send(control, { instance_id: instanceId }).then(result => {
|
||||
setSaves(result.list);
|
||||
}).catch(err => {
|
||||
logger.error(`Failed to list instance saves: ${err}`);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateSaves();
|
||||
|
||||
function updateHandler(data) {
|
||||
setSaves(data.list);
|
||||
}
|
||||
|
||||
control.onSaveListUpdate(instanceId, updateHandler);
|
||||
return () => {
|
||||
control.offSaveListUpdate(instanceId, updateHandler);
|
||||
};
|
||||
}, [instanceId]);
|
||||
|
||||
return saves;
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export class Control extends libLink.Link {
|
||||
this.accountName = null;
|
||||
|
||||
this.instanceUpdateHandlers = new Map();
|
||||
this.saveListUpdateHandlers = new Map();
|
||||
this.instanceLogHandlers = new Map();
|
||||
|
||||
this.connector.on("connect", data => {
|
||||
@@ -70,6 +71,9 @@ export class Control extends libLink.Link {
|
||||
this.updateInstanceSubscriptions().catch(err => logger.error(
|
||||
`Unexpected error updating instance subscriptions:\n${err.stack}`
|
||||
));
|
||||
this.updateSaveListSubscriptions().catch(err => logger.error(
|
||||
`Unexpected error updating save list subscriptions:\n${err.stack}`
|
||||
));
|
||||
this.updateLogSubscriptions().catch(err => logger.error(
|
||||
`Unexpected error updating log subscriptions:\n${err.stack}`
|
||||
));
|
||||
@@ -142,6 +146,60 @@ export class Control extends libLink.Link {
|
||||
});
|
||||
}
|
||||
|
||||
async saveListUpdateEventHandler(message) {
|
||||
let handlers = this.saveListUpdateHandlers.get(message.data.instance_id);
|
||||
for (let handler of handlers || []) {
|
||||
handler(message.data);
|
||||
}
|
||||
};
|
||||
|
||||
async onSaveListUpdate(id, handler) {
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("Invalid instance id");
|
||||
}
|
||||
|
||||
let handlers = this.saveListUpdateHandlers.get(id);
|
||||
if (!handlers) {
|
||||
handlers = [];
|
||||
this.saveListUpdateHandlers.set(id, handlers);
|
||||
}
|
||||
|
||||
handlers.push(handler);
|
||||
|
||||
if (handlers.length === 1) {
|
||||
await this.updateSaveListSubscriptions();
|
||||
}
|
||||
}
|
||||
|
||||
async offSaveListUpdate(id, handler) {
|
||||
let handlers = this.saveListUpdateHandlers.get(id);
|
||||
if (!handlers) {
|
||||
throw new Error(`No handlers for instance ${id} exists`);
|
||||
}
|
||||
|
||||
let index = handlers.lastIndexOf(handler);
|
||||
if (index === -1) {
|
||||
throw new Error(`Given handler is not registered for instance ${id}`);
|
||||
|
||||
handlers.splice(index, 1);
|
||||
if (!handlers.length) {
|
||||
this.saveListUpdateHandlers.delete(id);
|
||||
await this.updateSaveListSubscriptions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateSaveListSubscriptions() {
|
||||
if (!this.connector.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
await libLink.messages.setSaveListSubscriptions.send(this, {
|
||||
all: false,
|
||||
instance_ids: [...this.saveListUpdateHandlers.keys()],
|
||||
});
|
||||
}
|
||||
|
||||
async logMessageEventHandler(message) {
|
||||
let info = message.data.info;
|
||||
|
||||
|
||||
@@ -146,6 +146,21 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveListUpdateEventHandler()", function() {
|
||||
it("should have triggered for the created save", function() {
|
||||
slowTest(this);
|
||||
assert.equal(getControl().saveListUpdates.slice(-1)[0].list[0].name, "world.zip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance list-saves", function() {
|
||||
it("lists the created save", async function() {
|
||||
slowTest(this);
|
||||
let result = await execCtl("instance list-saves test");
|
||||
assert(/world\.zip/.test(result.stdout), "world.zip not present in list save output");
|
||||
});
|
||||
});
|
||||
|
||||
describe("instance export-data", function() {
|
||||
it("exports the data", async function() {
|
||||
slowTest(this);
|
||||
|
||||
@@ -26,11 +26,15 @@ class TestControl extends libLink.Link {
|
||||
super("control", "master", connector);
|
||||
libLink.attachAllMessages(this);
|
||||
this.instanceUpdates = [];
|
||||
this.saveListUpdates = [];
|
||||
|
||||
this.connector.on("connect", () => {
|
||||
libLink.messages.setInstanceSubscriptions.send(
|
||||
this, { all: true, instance_ids: [] }
|
||||
).catch(err => logger.error(`Error setting instance subscriptions:\n${err.stack}`));
|
||||
libLink.messages.setSaveListSubscriptions.send(
|
||||
this, { all: true, instance_ids: [] }
|
||||
).catch(err => logger.error(`Error setting save list subscriptions:\n${err.stack}`));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,6 +49,10 @@ class TestControl extends libLink.Link {
|
||||
this.instanceUpdates.push(message.data);
|
||||
}
|
||||
|
||||
async saveListUpdateEventHandler(message) {
|
||||
this.saveListUpdates.push(message.data);
|
||||
}
|
||||
|
||||
async logMessageEventHandler() { }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user