Add config cloning on instance creation (#747)

* Add instance config cloning on creation

* Add test for instance cloning

* Add tooltip to copy config button
This commit is contained in:
Cooldude2606 authored and GitHub committed 2025-04-06 02:00:08 +01:00
1 parent 0055f28f45
commit ce85149b4c
6 files changed
+63 -10

No files matched your search

@@ -251,6 +251,14 @@ export default class ControlConnection extends BaseConnection {
async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) {
const instanceConfig = new lib.InstanceConfig("controller");
if (request.cloneFromId) {
const baseInstance = this._controller.instances.get(request.cloneFromId);
if (!baseInstance) {
throw new lib.RequestError(`Instance with ID ${request.cloneFromId} does not exist`);
}
instanceConfig.update(baseInstance.config.toJSON(), false);
instanceConfig.set("instance.assigned_host", null); // New instances are unassigned
}
instanceConfig.update(request.config, false, "control");
await this._controller.instanceCreate(instanceConfig);
}
+8 -3
View File
@@ -484,16 +484,21 @@ instanceCommands.add(new lib.Command({
yargs.positional("name", { describe: "Instance name", type: "string" });
yargs.options({
"id": { type: "number", nargs: 1, describe: "Instance id" },
"from": { type: "number", nargs: 1, describe: "Clone config from instance id" },
});
}],
handler: async function(args: { name: string, id?: number }, control: Control) {
handler: async function(args: { name: string, id?: number, from?: number, }, control: Control) {
let instanceConfig = new lib.InstanceConfig("control");
if (args.id !== undefined) {
instanceConfig.set("instance.id", args.id);
}
instanceConfig.set("instance.name", args.name);
const serializedConfig = instanceConfig.toRemote("controller");
await control.send(new lib.InstanceCreateRequest(serializedConfig));
await control.send(new lib.InstanceCreateRequest(
instanceConfig.toRemote("controller", [
"instance.id", "instance.name",
]),
args.from
));
},
}));
+6 -1
View File
@@ -312,11 +312,16 @@ export class Config<
* Serialize the config to a plain JavaScript object
*
* @param remote - Location this serialised representation is for
* @param filter - When provided, only the given fields are serialized
* @returns JSON serializable representation of the config.
*/
toRemote(remote: ConfigLocation): Static<typeof Config.jsonSchema> {
toRemote(remote: ConfigLocation, filter?: (keyof Fields)[]): Static<typeof Config.jsonSchema> {
let fields: Record<string, FieldValue> = {};
for (let [name, value] of Object.entries(this.fields)) {
if (filter && !filter.includes(name as keyof Fields)) {
continue;
}
let def = this.constructor.fieldDefinitions[name];
if (
this.location !== "control" && !this._checkAccess(name, def, this.location, ConfigAccess.write, false)
+3 -1
View File
@@ -137,14 +137,16 @@ export class InstanceCreateRequest {
constructor(
public config: Static<typeof InstanceConfig.jsonSchema>,
public cloneFromId?: number,
) { }
static jsonSchema = Type.Object({
"config": InstanceConfig.jsonSchema,
"cloneFromId": Type.Optional(Type.Number()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.config);
return new this(json.config, json.cloneFromId);
}
}
@@ -1,6 +1,6 @@
import React, { useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Form, Input, Modal } from "antd";
import { Button, Form, Input, Modal, Select, Tooltip } from "antd";
import * as lib from "@clusterio/lib";
@@ -13,7 +13,7 @@ import { useInstances } from "../model/instance";
import InstanceList from "./InstanceList";
import { notifyErrorHandler } from "../util/notify";
function CreateInstanceButton() {
function CreateInstanceButton(props: { instances: ReturnType<typeof useInstances>[0] }) {
let control = useContext(ControlContext);
let navigate = useNavigate();
let [open, setOpen] = useState(false);
@@ -28,8 +28,14 @@ function CreateInstanceButton() {
let instanceConfig = new lib.InstanceConfig("control");
instanceConfig.set("instance.name", values.instanceName);
const serializedConfig = instanceConfig.toRemote("controller");
await control.send(new lib.InstanceCreateRequest(serializedConfig));
await control.send(new lib.InstanceCreateRequest(
instanceConfig.toRemote("controller", [
"instance.id", "instance.name",
]),
values.instanceClone >= 0 ? values.instanceClone : undefined,
));
setOpen(false);
navigate(`/instances/${instanceConfig.get("instance.id")}/view`);
}
@@ -53,6 +59,16 @@ function CreateInstanceButton() {
<Form.Item name="instanceName" label="Name">
<Input />
</Form.Item>
<Tooltip title="Perform a one time copy of an existing config (assigned host is not copied)">
<Form.Item name="instanceClone" label="Copy Config">
<Select
defaultValue={-1}
options={[{ id: -1, name: "Default Config" }, ...props.instances.values()]
.map(i => ({ value: i.id, label: i.name }))
}
/>
</Form.Item>
</Tooltip>
</Form>
</Modal>
</>;
@@ -67,7 +83,7 @@ export default function InstancesPage() {
<PageHeader
title="Instances"
extra={<>
{account.hasPermission("core.instance.create") && <CreateInstanceButton />}
{account.hasPermission("core.instance.create") && <CreateInstanceButton instances={instances}/>}
{account.hasPermission("core.instance.start")
&& <Button onClick={e => instances.forEach(instance => {
if (instance.status === "stopped") {
+17
View File
@@ -351,6 +351,23 @@ describe("Integration of Clusterio", function() {
await execCtl(`instance config set-prop test factorio.settings visibility "${value}"`);
await execCtl("instance config set-prop test factorio.settings require_user_verification false");
});
it("can clone an instance", async function() {
slowTest(this);
await execCtl("instance create testClone --id 440 --from 44");
const instances = await getInstances();
assert(instances.has(440), "instance was not created");
assert.equal(instances.get(440).status, "unassigned", "incorrect instance status");
const config = await getControl().send(new lib.InstanceConfigGetRequest(44));
assert.deepEqual(config["factorio.settings"]["visibility"], { lan: true, public: false });
assert.equal(config["factorio.settings"]["require_user_verification"], false);
});
it("errors when cloning an invalid instance", async function() {
slowTest(this);
assert.rejects(
execCtl("instance create testClone --id 440 --from 441"),
new lib.RequestError("Instance with ID 441 does not exist")
);
});
});
describe("instance assign", function() {