mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Add notes to hosts, instances, users and other resources (#1000)
This commit is contained in:
1 parent
ee39945c83
commit
cbf2b10782
25 files changed
+830
-52
No files matched your search
@@ -231,6 +231,33 @@ Note: If the player joins the cluster again a new account will be made for them
|
||||
To be written.
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
Free text notes can be attached to hosts, instances, users, roles, mod packs, mods and saves.
|
||||
They are stored on the controller and shown to everyone with access to the resource in the web interface, and can for example be used to record who runs a host or what an instance is meant for.
|
||||
|
||||
### List notes
|
||||
|
||||
ctl> note list [--type <type>]
|
||||
|
||||
Lists all notes, optionally limited to one resource type.
|
||||
|
||||
### Set note
|
||||
|
||||
ctl> note set <type> <id> <content>
|
||||
|
||||
Sets the note of a resource.
|
||||
The type is one of host, instance, user, role, mod_pack, mod or save.
|
||||
The id is the numeric id for hosts, instances, roles and mod packs, the lower case name for users, `<name>_<version>` for mods and `<instance id>/<file name>` for saves.
|
||||
|
||||
### Delete note
|
||||
|
||||
ctl> note delete <type> <id>
|
||||
|
||||
Removes the note of a resource.
|
||||
Notes are also removed when the resource they are attached to is deleted.
|
||||
|
||||
|
||||
## Plugins (Local)
|
||||
|
||||
To be written.
|
||||
|
||||
@@ -119,6 +119,8 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.RoleUpdateRequest, this.handleRoleUpdateRequest.bind(this));
|
||||
this.handle(lib.RoleGrantDefaultPermissionsRequest, this.handleRoleGrantDefaultPermissionsRequest.bind(this));
|
||||
this.handle(lib.RoleDeleteRequest, this.handleRoleDeleteRequest.bind(this));
|
||||
this.handle(lib.NoteListRequest, this.handleNoteListRequest.bind(this));
|
||||
this.handle(lib.NoteSetRequest, this.handleNoteSetRequest.bind(this));
|
||||
this.handle(lib.UserGetRequest, this.handleUserGetRequest.bind(this));
|
||||
this.handle(lib.UserListRequest, this.handleUserListRequest.bind(this));
|
||||
this.handle(lib.UserCreateRequest, this.handleUserCreateRequest.bind(this));
|
||||
@@ -313,6 +315,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
async handleInstanceDeleteRequest(request: lib.InstanceDeleteRequest) {
|
||||
await this._controller.instances.deleteInstance(request.instanceId);
|
||||
this._controller.deleteNote("instance", request.instanceId);
|
||||
}
|
||||
|
||||
async handleInstanceConfigGetRequest(request: lib.InstanceConfigGetRequest) {
|
||||
@@ -500,6 +503,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
throw new lib.RequestError(`Mod pack with ID ${id} does not exist`);
|
||||
}
|
||||
this._controller.modPacks.delete(modPack);
|
||||
this._controller.deleteNote("mod_pack", id);
|
||||
}
|
||||
|
||||
async handleModGetRequest(request: lib.ModGetRequest) {
|
||||
@@ -863,6 +867,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
|
||||
await this._controller.modStore.deleteMod(request.name, request.version);
|
||||
this._controller.deleteNote("mod", `${request.name}_${request.version}`);
|
||||
}
|
||||
|
||||
async handleLogSetSubscriptionsRequest(request: lib.LogSetSubscriptionsRequest) {
|
||||
@@ -973,6 +978,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
throw new lib.RequestError(`Role with ID ${id} does not exist`);
|
||||
}
|
||||
this._controller.roles.delete(role);
|
||||
this._controller.deleteNote("role", id);
|
||||
|
||||
for (const user of this._controller.users.values()) {
|
||||
if (user.removeRole(id)) {
|
||||
@@ -981,6 +987,18 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
}
|
||||
|
||||
async handleNoteListRequest() {
|
||||
return [...this._controller.notes.values()].filter(note => !note.isDeleted);
|
||||
}
|
||||
|
||||
async handleNoteSetRequest(request: lib.NoteSetRequest) {
|
||||
const { resourceType, resourceId, content } = request;
|
||||
if (!resourceType || !resourceId) {
|
||||
throw new lib.RequestError("resourceType and resourceId must not be empty");
|
||||
}
|
||||
this._controller.setNote(resourceType, resourceId, content, this.user.name);
|
||||
}
|
||||
|
||||
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.UserDetails> {
|
||||
let name = request.name;
|
||||
let user = this._controller.users.getByName(name);
|
||||
@@ -1095,6 +1113,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
controlConnection.connector.terminate();
|
||||
}
|
||||
}
|
||||
this._controller.deleteNote("user", user.id);
|
||||
|
||||
if (user.isAdmin) {
|
||||
this._controller.sendTo("allInstances", new lib.InstanceAdminlistUpdateEvent(name, false));
|
||||
|
||||
@@ -205,6 +205,11 @@ export default class Controller {
|
||||
undefined, this.finaliseUsers,
|
||||
).bootstrap());
|
||||
|
||||
const notes = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "notes.json"),
|
||||
lib.Note.fromJSON.bind(lib.Note),
|
||||
).bootstrap());
|
||||
|
||||
let modsDirectory = config.get("controller.mods_directory");
|
||||
await fs.mkdir(modsDirectory, { recursive: true });
|
||||
const modStore = await lib.ModStore.fromDirectory(modsDirectory);
|
||||
@@ -227,6 +232,7 @@ export default class Controller {
|
||||
modStore,
|
||||
roles,
|
||||
users,
|
||||
notes,
|
||||
] as const;
|
||||
}
|
||||
|
||||
@@ -260,6 +266,8 @@ export default class Controller {
|
||||
public roles = new lib.SubscribableDatastore<lib.Role>(),
|
||||
/** Mapping of user id to user record */
|
||||
users = new lib.SubscribableDatastore<UserRecord>(),
|
||||
/** Mapping of note id to note attached to a resource */
|
||||
public notes = new lib.SubscribableDatastore<lib.Note>(),
|
||||
) {
|
||||
this.clusterLogger = clusterLogger;
|
||||
this.pluginInfos = pluginInfos;
|
||||
@@ -291,6 +299,7 @@ export default class Controller {
|
||||
this.subscriptions.handle(lib.ModUpdatesEvent, this.handleModSubscription.bind(this));
|
||||
this.subscriptions.handle(lib.UserUpdatesEvent, this.handleUserSubscription.bind(this));
|
||||
this.subscriptions.handle(lib.RoleUpdatesEvent, this.handleRoleSubscription.bind(this));
|
||||
this.subscriptions.handle(lib.NoteUpdatesEvent, this.handleNoteSubscription.bind(this));
|
||||
|
||||
// Handle updates for datastores
|
||||
this.systems.on("update", this.systemsUpdated.bind(this));
|
||||
@@ -300,6 +309,7 @@ export default class Controller {
|
||||
this.modPacks.on("update", this.modPacksUpdated.bind(this));
|
||||
this.roles.on("update", this.rolesUpdated.bind(this));
|
||||
this.users.records.on("update", this.usersUpdated.bind(this));
|
||||
this.notes.on("update", this.notesUpdated.bind(this));
|
||||
}
|
||||
|
||||
get authSecret() {
|
||||
@@ -744,6 +754,7 @@ export default class Controller {
|
||||
this.roles.save(),
|
||||
this.users.records.save(),
|
||||
this.hooks.save.invoke(),
|
||||
this.notes.save(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1137,6 +1148,60 @@ export default class Controller {
|
||||
return roles.length ? new lib.RoleUpdatesEvent(roles) : null;
|
||||
}
|
||||
|
||||
notesUpdated(notes: lib.Note[]) {
|
||||
this.subscriptions.broadcast(new lib.NoteUpdatesEvent(notes));
|
||||
}
|
||||
|
||||
async handleNoteSubscription(request: lib.SubscriptionRequest) {
|
||||
if (request.action === "unsubscribe") {
|
||||
return null;
|
||||
}
|
||||
// Deleted notes are kept as tombstones so resubscribing clients learn of the deletion
|
||||
const notes = [...this.notes.values()].filter(
|
||||
note => note.updatedAtMs > request.lastRequestTimeMs && (request.lastRequestTimeMs > 0 || !note.isDeleted),
|
||||
);
|
||||
return notes.length ? new lib.NoteUpdatesEvent(notes) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or remove the note attached to a resource
|
||||
* @param resourceType - Type of resource the note belongs to.
|
||||
* @param resourceId - Id of the resource within its type.
|
||||
* @param content - New text of the note, empty removes the note.
|
||||
* @param updatedBy - Name of the user making the change.
|
||||
*/
|
||||
setNote(resourceType: string, resourceId: string, content: string, updatedBy = "") {
|
||||
const existing = this.notes.getMutable(lib.Note.idFor(resourceType, resourceId));
|
||||
if (content === "") {
|
||||
this.deleteNote(resourceType, resourceId, updatedBy);
|
||||
return;
|
||||
}
|
||||
if (existing) {
|
||||
existing.content = content;
|
||||
existing.updatedBy = updatedBy;
|
||||
existing.isDeleted = false;
|
||||
this.notes.set(existing);
|
||||
} else {
|
||||
this.notes.set(new lib.Note(resourceType, resourceId, content, updatedBy));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the note attached to a resource, if any
|
||||
* @param resourceType - Type of resource the note belongs to.
|
||||
* @param resourceId - Id of the resource within its type.
|
||||
* @param updatedBy - Name of the user making the change.
|
||||
*/
|
||||
deleteNote(resourceType: string, resourceId: string | number, updatedBy = "") {
|
||||
const note = this.notes.getMutable(lib.Note.idFor(resourceType, resourceId));
|
||||
if (note && !note.isDeleted) {
|
||||
note.content = "";
|
||||
note.updatedBy = updatedBy;
|
||||
note.isDeleted = true;
|
||||
this.notes.set(note);
|
||||
}
|
||||
}
|
||||
|
||||
async loadPlugins() {
|
||||
const context = { metrics, logger, controller: this };
|
||||
for (let pluginInfo of this.pluginInfos) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { modPackCommands } from "./commands_mod_pack.js";
|
||||
import { modCommands } from "./commands_mod.js";
|
||||
import { permissionCommands } from "./commands_permission.js";
|
||||
import { roleCommands } from "./commands_role.js";
|
||||
import { noteCommands } from "./commands_note.js";
|
||||
import { userCommands } from "./commands_user.js";
|
||||
import { logCommands } from "./commands_log.js";
|
||||
import { debugCommands } from "./commands_debug.js";
|
||||
@@ -22,6 +23,7 @@ export async function registerCommands(ctlHooks: CtlHooks, yargs: Argv) {
|
||||
rootCommands.add(modCommands);
|
||||
rootCommands.add(permissionCommands);
|
||||
rootCommands.add(roleCommands);
|
||||
rootCommands.add(noteCommands);
|
||||
rootCommands.add(userCommands);
|
||||
rootCommands.add(logCommands);
|
||||
rootCommands.add(debugCommands);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import asTableModule from "as-table";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { Ctl } from "../ctl.js";
|
||||
import { print } from "./command_ops.js";
|
||||
|
||||
const asTable = asTableModule.configure({ delimiter: " | " });
|
||||
|
||||
export const noteCommands = new lib.CommandTree({
|
||||
name: "note", description: "Notes attached to hosts, instances, users and other resources",
|
||||
});
|
||||
noteCommands.add(new lib.Command({
|
||||
definition: [["list", "l"], "List notes in the cluster", (yargs) => {
|
||||
yargs.options({
|
||||
"type": { describe: "Only list notes for this resource type", nargs: 1, type: "string" },
|
||||
});
|
||||
}],
|
||||
handler: async function(args: { type?: string }, ctl: Ctl) {
|
||||
let notes = await ctl.send(new lib.NoteListRequest());
|
||||
if (args.type !== undefined) {
|
||||
notes = notes.filter(note => note.resourceType === args.type);
|
||||
}
|
||||
print(asTable(notes.map(note => ({
|
||||
type: note.resourceType,
|
||||
id: note.resourceId,
|
||||
updatedBy: note.updatedBy,
|
||||
content: note.content,
|
||||
}))));
|
||||
},
|
||||
}));
|
||||
|
||||
noteCommands.add(new lib.Command({
|
||||
definition: ["get <type> <id>", "Show the note of a resource", (yargs) => {
|
||||
yargs.positional("type", {
|
||||
describe: "Resource type, one of host, instance, user, role, mod_pack, mod, save", type: "string",
|
||||
});
|
||||
yargs.positional("id", { describe: "Id of the resource", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { type: string, id: string }, ctl: Ctl) {
|
||||
const notes = await ctl.send(new lib.NoteListRequest());
|
||||
const note = notes.find(n => n.resourceType === args.type && n.resourceId === args.id);
|
||||
if (!note) {
|
||||
throw new lib.CommandError(`No note for ${args.type} ${args.id}`);
|
||||
}
|
||||
print(note.content);
|
||||
},
|
||||
}));
|
||||
|
||||
noteCommands.add(new lib.Command({
|
||||
definition: ["set <type> <id> <content>", "Set the note of a resource", (yargs) => {
|
||||
yargs.positional("type", {
|
||||
describe: "Resource type, one of host, instance, user, role, mod_pack, mod, save", type: "string",
|
||||
});
|
||||
yargs.positional("id", { describe: "Id of the resource", type: "string" });
|
||||
yargs.positional("content", { describe: "Text of the note", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { type: string, id: string, content: string }, ctl: Ctl) {
|
||||
await ctl.send(new lib.NoteSetRequest(args.type, args.id, args.content));
|
||||
},
|
||||
}));
|
||||
|
||||
noteCommands.add(new lib.Command({
|
||||
definition: ["delete <type> <id>", "Remove the note of a resource", (yargs) => {
|
||||
yargs.positional("type", {
|
||||
describe: "Resource type, one of host, instance, user, role, mod_pack, mod, save", type: "string",
|
||||
});
|
||||
yargs.positional("id", { describe: "Id of the resource", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { type: string, id: string }, ctl: Ctl) {
|
||||
await ctl.send(new lib.NoteSetRequest(args.type, args.id, ""));
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
/**
|
||||
* Free text note attached to a resource in the cluster.
|
||||
*
|
||||
* Notes are keyed by the type of resource and the id of the resource within
|
||||
* that type. Core resource types are "host", "instance", "user", "role",
|
||||
* "mod_pack", "mod" and "save", plugins may define their own.
|
||||
*/
|
||||
export default class Note {
|
||||
constructor(
|
||||
/** Type of resource this note is attached to. */
|
||||
public resourceType: string,
|
||||
/** Id of the resource within its type, as a string. */
|
||||
public resourceId: string,
|
||||
/** Text of the note. */
|
||||
public content: string,
|
||||
/** Name of the user that last changed the note. */
|
||||
public updatedBy = "",
|
||||
/** Millisecond Unix timestamp this entry was last updated at. */
|
||||
public updatedAtMs = 0,
|
||||
/** True if this note has been removed. */
|
||||
public isDeleted = false,
|
||||
) { }
|
||||
|
||||
/** Unique key for this note, resource type and id joined with a slash. */
|
||||
get id() {
|
||||
return Note.idFor(this.resourceType, this.resourceId);
|
||||
}
|
||||
|
||||
static idFor(resourceType: string, resourceId: string | number) {
|
||||
return `${resourceType}/${resourceId}`;
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"resourceType": Type.String(),
|
||||
"resourceId": Type.String(),
|
||||
"content": Type.String(),
|
||||
"updatedBy": Type.Optional(Type.String()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"isDeleted": Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.resourceType,
|
||||
json.resourceId,
|
||||
json.content,
|
||||
json.updatedBy,
|
||||
json.updatedAtMs,
|
||||
json.isDeleted,
|
||||
);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const json = {
|
||||
resourceType: this.resourceType,
|
||||
resourceId: this.resourceId,
|
||||
content: this.content,
|
||||
} as Static<typeof Note.jsonSchema>;
|
||||
|
||||
if (this.updatedBy !== "") {
|
||||
json.updatedBy = this.updatedBy;
|
||||
}
|
||||
if (this.updatedAtMs !== 0) {
|
||||
json.updatedAtMs = this.updatedAtMs;
|
||||
}
|
||||
if (this.isDeleted !== false) {
|
||||
json.isDeleted = this.isDeleted;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export { default as ModuleInfo } from "./ModuleInfo.js";
|
||||
export { default as Permission } from "./Permission.js";
|
||||
export { default as PlayerStats } from "./PlayerStats.js";
|
||||
export { default as Role } from "./Role.js";
|
||||
export { default as Note } from "./Note.js";
|
||||
export { default as InstanceDetails, InstanceStatus } from "./InstanceDetails.js";
|
||||
export { default as HostDetails } from "./HostDetails.js";
|
||||
export { default as UserDetails } from "./UserDetails.js";
|
||||
@@ -24,5 +25,6 @@ export * from "./messages_instance.js";
|
||||
export * from "./messages_mod.js";
|
||||
export * from "./messages_user.js";
|
||||
export * from "./messages_role.js";
|
||||
export * from "./messages_note.js";
|
||||
export * from "./messages_plugin.js";
|
||||
export * from "./version.js";
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import { jsonArray } from "./composites.js";
|
||||
import Note from "./Note.js";
|
||||
|
||||
export class NoteListRequest {
|
||||
declare ["constructor"]: typeof NoteListRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.note.list" as const;
|
||||
static Response = jsonArray(Note);
|
||||
}
|
||||
|
||||
export class NoteUpdatesEvent {
|
||||
declare ["constructor"]: typeof NoteUpdatesEvent;
|
||||
static type = "event" as const;
|
||||
static src = "controller" as const;
|
||||
static dst = "control" as const;
|
||||
static permission = "core.note.subscribe" as const;
|
||||
|
||||
constructor(
|
||||
public updates: Note[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"updates": Type.Array(Note.jsonSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.updates.map(update => Note.fromJSON(update)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the note of a resource. An empty content removes the note.
|
||||
*/
|
||||
export class NoteSetRequest {
|
||||
declare ["constructor"]: typeof NoteSetRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.note.update" as const;
|
||||
|
||||
constructor(
|
||||
public resourceType: string,
|
||||
public resourceId: string,
|
||||
public content: string,
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"resourceType": Type.String(),
|
||||
"resourceId": Type.String(),
|
||||
"content": Type.String(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.resourceType, json.resourceId, json.content);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import * as instance from "../data/messages_instance.js";
|
||||
import * as mod from "../data/messages_mod.js";
|
||||
import * as user from "../data/messages_user.js";
|
||||
import * as role from "../data/messages_role.js";
|
||||
import * as note from "../data/messages_note.js";
|
||||
import * as plugin from "../data/messages_plugin.js";
|
||||
import * as subscriptions from "../subscriptions.js";
|
||||
import type { RequestClass, EventClass } from "./link.js";
|
||||
@@ -126,6 +127,10 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
role.RoleGrantDefaultPermissionsRequest,
|
||||
role.RoleDeleteRequest,
|
||||
|
||||
note.NoteListRequest,
|
||||
note.NoteUpdatesEvent,
|
||||
note.NoteSetRequest,
|
||||
|
||||
user.UserGetRequest,
|
||||
user.UserListRequest,
|
||||
user.UserCreateRequest,
|
||||
|
||||
@@ -462,6 +462,24 @@ export const corePermissions = [
|
||||
description: "Permanently delete roles.",
|
||||
},
|
||||
|
||||
{
|
||||
name: "core.note.list",
|
||||
title: "List notes",
|
||||
description: "Get the full list of notes attached to resources in the cluster.",
|
||||
grantByDefault: true,
|
||||
},
|
||||
{
|
||||
name: "core.note.subscribe",
|
||||
title: "Subscribe to note updates",
|
||||
description: "Subscribe to be notified when notes attached to resources are updated.",
|
||||
grantByDefault: true,
|
||||
},
|
||||
{
|
||||
name: "core.note.update",
|
||||
title: "Update notes",
|
||||
description: "Write, edit and remove notes attached to resources in the cluster.",
|
||||
},
|
||||
|
||||
{
|
||||
name: "core.user.get",
|
||||
title: "Get user",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useHost } from "../model/host";
|
||||
import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import ResourceNotes from "./ResourceNotes";
|
||||
import {
|
||||
MetricCpuRatio, MetricCpuUsed, MetricMemoryRatio, MetricMemoryUsed,
|
||||
MetricDiskUsed, MetricDiskRatio, MetricRelativeDate,
|
||||
@@ -182,6 +183,7 @@ export default function HostViewPage() {
|
||||
: null
|
||||
}
|
||||
</Descriptions>
|
||||
<ResourceNotes resourceType="host" resourceId={hostId} />
|
||||
{account.hasPermission("core.instance.list") && <>
|
||||
<Title level={5} style={{ marginTop: 16 }}>Instances</Title>
|
||||
<InstanceList instances={hostInstances} size="small" hideAssignedHost />
|
||||
|
||||
@@ -15,6 +15,7 @@ import ControlContext from "./ControlContext";
|
||||
import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import ResourceNotes from "./ResourceNotes";
|
||||
import InstanceConfigTree from "./InstanceConfigTree";
|
||||
import LogConsole, { SelectMaxLogLevel } from "./LogConsole";
|
||||
import InstanceRcon from "./InstanceRcon";
|
||||
@@ -236,6 +237,7 @@ export default function InstanceViewPage() {
|
||||
extra={<InstanceButtons instance={instance} />}
|
||||
/>
|
||||
<InstanceDescription host={host} instance={instance} />
|
||||
<ResourceNotes resourceType="instance" resourceId={instanceId} />
|
||||
|
||||
{account.hasPermission("core.user.list") && <OnlinePlayersList instanceId={instanceId} />}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import FileExclamationOutlined from "@ant-design/icons/FileExclamationOutlined";
|
||||
import FileSyncOutlined from "@ant-design/icons/FileSyncOutlined";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import ResourceNotes from "./ResourceNotes";
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
@@ -58,6 +59,9 @@ export default function ModDetails<T extends lib.ModInfo | lib.ModRecord>(props:
|
||||
&& <Descriptions.Item label="Description" span={2}>{mod.description}</Descriptions.Item>
|
||||
}
|
||||
{Boolean(mod.author) && <Descriptions.Item label="Author" span={2}>{mod.author}</Descriptions.Item>}
|
||||
<Descriptions.Item label="Notes" span={2}>
|
||||
<ResourceNotes resourceType="mod" resourceId={`${mod.name}_${mod.version}`} compact />
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="Mod Portal" span={2}>
|
||||
<Typography.Link href={`https://mods.factorio.com/mod/${mod.name}`}>
|
||||
|
||||
@@ -26,6 +26,7 @@ import notify, { notifyErrorHandler } from "../util/notify";
|
||||
import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import ResourceNotes from "./ResourceNotes";
|
||||
import ModDetails from "./ModDetails";
|
||||
import { InputPartialVersion } from "./InputVersion";
|
||||
import Link from "./Link";
|
||||
@@ -1399,5 +1400,6 @@ export default function ModPackViewPage() {
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
<ResourceNotes resourceType="mod_pack" resourceId={modPackId} />
|
||||
</PageLayout>;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Button, Input, Space, Typography } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import { useAccount } from "../model/account";
|
||||
import { useNote } from "../model/notes";
|
||||
import { formatTimestamp } from "../util/time_format";
|
||||
import ControlContext from "./ControlContext";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ResourceNotesProps = {
|
||||
/** Type of resource, such as "host" or "instance". */
|
||||
resourceType: string;
|
||||
/** Id of the resource within its type. */
|
||||
resourceId: string | number;
|
||||
/** Render without the section header, for use inside tables and modals. */
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the note attached to a resource with an editor for users that
|
||||
* have the core.note.update permission.
|
||||
*/
|
||||
export default function ResourceNotes(props: ResourceNotesProps) {
|
||||
// Remount on resource change so a draft is never saved to another resource
|
||||
return <ResourceNotesEditor key={lib.Note.idFor(props.resourceType, props.resourceId)} {...props} />;
|
||||
}
|
||||
|
||||
function ResourceNotesEditor(props: ResourceNotesProps) {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const [note, synced] = useNote(props.resourceType, props.resourceId);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const canUpdate = account.hasPermission("core.note.update");
|
||||
const canView = account.hasPermission("core.note.subscribe");
|
||||
|
||||
// Pick up changes made by others while not editing
|
||||
const content = note?.content ?? "";
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setDraft(content);
|
||||
}
|
||||
}, [content, editing]);
|
||||
|
||||
if (!canView && !canUpdate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function save() {
|
||||
setSaving(true);
|
||||
control.send(new lib.NoteSetRequest(props.resourceType, String(props.resourceId), draft))
|
||||
.then(() => setEditing(false))
|
||||
.catch(notifyErrorHandler("Error saving note"))
|
||||
.finally(() => setSaving(false));
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setDraft(content);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
const buttons = editing
|
||||
? <Space>
|
||||
<Button size="small" onClick={cancel} disabled={saving}>Cancel</Button>
|
||||
<Button size="small" type="primary" onClick={save} loading={saving}>Save</Button>
|
||||
</Space>
|
||||
: <Button size="small" onClick={() => setEditing(true)} disabled={!synced}>
|
||||
{note ? "Edit" : "Add note"}
|
||||
</Button>;
|
||||
|
||||
const body = editing
|
||||
? <Input.TextArea
|
||||
autoSize={{ minRows: 3 }}
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Escape") {
|
||||
cancel();
|
||||
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
save();
|
||||
}
|
||||
}}
|
||||
placeholder="Write a note visible to everyone with access to this page"
|
||||
autoFocus
|
||||
/>
|
||||
: <>
|
||||
{note
|
||||
? <div style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{note.content}</div>
|
||||
: <Text type="secondary">{synced ? "No note" : "Loading"}</Text>
|
||||
}
|
||||
{note && (note.updatedBy || note.updatedAtMs) && <div>
|
||||
<Text type="secondary" style={{ fontSize: "0.85em" }}>
|
||||
Last edited{note.updatedBy ? ` by ${note.updatedBy}` : ""}
|
||||
{note.updatedAtMs ? ` on ${formatTimestamp(note.updatedAtMs)}` : ""}
|
||||
</Text>
|
||||
</div>}
|
||||
</>;
|
||||
|
||||
if (props.compact) {
|
||||
return <Space direction="vertical" style={{ width: "100%" }}>
|
||||
{body}
|
||||
{canUpdate && buttons}
|
||||
</Space>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<SectionHeader title="Notes" extra={canUpdate ? buttons : undefined} />
|
||||
{body}
|
||||
</>;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import ControlContext from "./ControlContext";
|
||||
import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import ResourceNotes from "./ResourceNotes";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
|
||||
|
||||
@@ -327,6 +328,8 @@ export default function RoleViewPage() {
|
||||
<Input value={description} disabled={!canUpdate} onChange={e => setDescription(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<ResourceNotes resourceType="role" resourceId={roleId} />
|
||||
|
||||
<PluginExtra component="RoleViewPageBeforePermissions" role={role} />
|
||||
|
||||
<h3>Permissions</h3>
|
||||
|
||||
@@ -13,6 +13,7 @@ import useTableQueryState from "../util/useTableQueryState";
|
||||
import ControlContext from "./ControlContext";
|
||||
import CreateSaveModal from "./CreateSaveModal";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import ResourceNotes from "./ResourceNotes";
|
||||
import { useInstances } from "../model/instance";
|
||||
import { useSavesOfInstance } from "../model/saves";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
@@ -249,58 +250,61 @@ export default function SavesList(props: { instance: lib.InstanceDetails }) {
|
||||
expandable={{
|
||||
columnWidth: 33,
|
||||
expandRowByClick: true,
|
||||
expandedRowRender: save => <Space wrap style={{ marginBottom: 0 }}>
|
||||
{account.hasPermission("core.instance.start") && <Button
|
||||
loading={starting}
|
||||
disabled={props.instance.status !== "stopped"}
|
||||
onClick={() => {
|
||||
setStarting(true);
|
||||
control.sendTo(
|
||||
{ instanceId: props.instance.id! },
|
||||
new lib.InstanceStartRequest(save.name),
|
||||
).catch(
|
||||
notifyErrorHandler("Error loading save")
|
||||
).finally(
|
||||
() => { setStarting(false); }
|
||||
);
|
||||
}}
|
||||
>Load save</Button>}
|
||||
{account.hasPermission("core.instance.save.rename") && <RenameModal
|
||||
disabled={hostOffline} instanceId={props.instance.id!} save={save}
|
||||
/>}
|
||||
{account.hasPermission("core.instance.save.copy") && <CopyModal
|
||||
disabled={hostOffline} instanceId={props.instance.id!} save={save}
|
||||
/>}
|
||||
{account.hasPermission("core.instance.save.download") && <Button
|
||||
disabled={hostOffline}
|
||||
onClick={() => {
|
||||
control.send(
|
||||
new lib.InstanceDownloadSaveRequest(props.instance.id!, save.name)
|
||||
).then(streamId => {
|
||||
let url = new URL(webRoot, document.location.href);
|
||||
url.pathname += `api/stream/${streamId}`;
|
||||
document.location.assign(url);
|
||||
}).catch(
|
||||
notifyErrorHandler("Error downloading save")
|
||||
);
|
||||
}}
|
||||
>Download</Button>}
|
||||
{account.hasPermission("core.instance.save.transfer") && <TransferModal
|
||||
disabled={hostOffline} instanceId={props.instance.id!} save={save}
|
||||
/>}
|
||||
{account.hasPermission("core.instance.save.delete") && <Popconfirm
|
||||
title="Permanently delete save?"
|
||||
okText="Delete"
|
||||
placement="top"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.send(
|
||||
new lib.InstanceDeleteSaveRequest(props.instance.id!, save.name)
|
||||
).catch(notifyErrorHandler("Error deleting save"));
|
||||
}}
|
||||
>
|
||||
<Button danger disabled={hostOffline}>Delete</Button>
|
||||
</Popconfirm>}
|
||||
expandedRowRender: save => <Space direction="vertical" style={{ width: "100%" }}>
|
||||
<Space wrap>
|
||||
{account.hasPermission("core.instance.start") && <Button
|
||||
loading={starting}
|
||||
disabled={props.instance.status !== "stopped"}
|
||||
onClick={() => {
|
||||
setStarting(true);
|
||||
control.sendTo(
|
||||
{ instanceId: props.instance.id! },
|
||||
new lib.InstanceStartRequest(save.name),
|
||||
).catch(
|
||||
notifyErrorHandler("Error loading save")
|
||||
).finally(
|
||||
() => { setStarting(false); }
|
||||
);
|
||||
}}
|
||||
>Load save</Button>}
|
||||
{account.hasPermission("core.instance.save.rename") && <RenameModal
|
||||
disabled={hostOffline} instanceId={props.instance.id!} save={save}
|
||||
/>}
|
||||
{account.hasPermission("core.instance.save.copy") && <CopyModal
|
||||
disabled={hostOffline} instanceId={props.instance.id!} save={save}
|
||||
/>}
|
||||
{account.hasPermission("core.instance.save.download") && <Button
|
||||
disabled={hostOffline}
|
||||
onClick={() => {
|
||||
control.send(
|
||||
new lib.InstanceDownloadSaveRequest(props.instance.id!, save.name)
|
||||
).then(streamId => {
|
||||
let url = new URL(webRoot, document.location.href);
|
||||
url.pathname += `api/stream/${streamId}`;
|
||||
document.location.assign(url);
|
||||
}).catch(
|
||||
notifyErrorHandler("Error downloading save")
|
||||
);
|
||||
}}
|
||||
>Download</Button>}
|
||||
{account.hasPermission("core.instance.save.transfer") && <TransferModal
|
||||
disabled={hostOffline} instanceId={props.instance.id!} save={save}
|
||||
/>}
|
||||
{account.hasPermission("core.instance.save.delete") && <Popconfirm
|
||||
title="Permanently delete save?"
|
||||
okText="Delete"
|
||||
placement="top"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.send(
|
||||
new lib.InstanceDeleteSaveRequest(props.instance.id!, save.name)
|
||||
).catch(notifyErrorHandler("Error deleting save"));
|
||||
}}
|
||||
>
|
||||
<Button danger disabled={hostOffline}>Delete</Button>
|
||||
</Popconfirm>}
|
||||
</Space>
|
||||
<ResourceNotes resourceType="save" resourceId={save.id} compact />
|
||||
</Space>,
|
||||
}}
|
||||
/>;
|
||||
|
||||
@@ -17,6 +17,7 @@ import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import ResourceNotes from "./ResourceNotes";
|
||||
import useRowNavigation from "../util/useRowNavigation";
|
||||
import Link from "./Link";
|
||||
import notify, { notifyErrorHandler } from "../util/notify";
|
||||
@@ -290,6 +291,7 @@ export default function UserViewPage() {
|
||||
&& <Form.Item label="Ban reason">{user.banReason}</Form.Item>
|
||||
}
|
||||
</Form>
|
||||
<ResourceNotes resourceType="user" resourceId={user.id} />
|
||||
<SectionHeader title="Player stats" />
|
||||
<Descriptions size="small" bordered column={{ xs: 1, sm: 2, md: 2, lg: 2, xl: 2, xxl: 2 }}>
|
||||
<Descriptions.Item label="Total online time">
|
||||
|
||||
@@ -12,6 +12,7 @@ export * from "./model/instance";
|
||||
export * from "./model/is_dragging";
|
||||
export * from "./model/mod_pack";
|
||||
export * from "./model/mods";
|
||||
export * from "./model/notes";
|
||||
export * from "./model/roles";
|
||||
export * from "./model/saves";
|
||||
export * from "./model/system";
|
||||
@@ -38,6 +39,7 @@ export { default as PageHeader } from "./components/PageHeader";
|
||||
export { default as PageLayout } from "./components/PageLayout";
|
||||
export { default as SavesList } from "./components/SavesList";
|
||||
export { default as SectionHeader } from "./components/SectionHeader";
|
||||
export { default as ResourceNotes } from "./components/ResourceNotes";
|
||||
|
||||
export { default as Link } from "./components/Link";
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useCallback, useContext, useSyncExternalStore } from "react";
|
||||
import ControlContext from "../components/ControlContext";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
export function useNote(resourceType: string, resourceId?: string | number) {
|
||||
const [notes, synced] = useNotes();
|
||||
const note = resourceId !== undefined ? notes.get(lib.Note.idFor(resourceType, resourceId)) : undefined;
|
||||
return [note, synced] as const;
|
||||
}
|
||||
|
||||
export function useNotes() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback((callback: () => void) => control.notes.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => control.notes.getSnapshot());
|
||||
}
|
||||
@@ -73,6 +73,7 @@ export class Control extends lib.Link {
|
||||
mods = new lib.MapSubscriber(lib.ModUpdatesEvent, this);
|
||||
users = new lib.MapSubscriber(lib.UserUpdatesEvent, this);
|
||||
roles = new lib.MapSubscriber(lib.RoleUpdatesEvent, this);
|
||||
notes = new lib.MapSubscriber(lib.NoteUpdatesEvent, this);
|
||||
|
||||
/** Cache for factorio versions to avoid repeat calls to the controller */
|
||||
factorioVersions = new lib.ValueCache(this.requestFactorioVersions.bind(this));
|
||||
|
||||
@@ -1841,6 +1841,53 @@ describe("Integration of Clusterio", function() {
|
||||
});
|
||||
});
|
||||
|
||||
describe("note set", function() {
|
||||
it("should set the note of the given resource", async function() {
|
||||
await execCtl("note set host 4 basement-box");
|
||||
const notes = await getControl().send(new lib.NoteListRequest());
|
||||
const note = notes.find(n => n.id === "host/4");
|
||||
assert.equal(note.content, "basement-box");
|
||||
assert.equal(note.updatedBy, "test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("note list", function() {
|
||||
it("runs", async function() {
|
||||
await execCtl("note list");
|
||||
});
|
||||
it("runs with --type", async function() {
|
||||
await execCtl("note list --type host");
|
||||
});
|
||||
});
|
||||
|
||||
describe("note get", function() {
|
||||
it("should print the note of the given resource", async function() {
|
||||
const lines = [];
|
||||
const originalLog = console.log; // eslint-disable-line no-console
|
||||
console.log = (...args) => lines.push(args.join(" ")); // eslint-disable-line no-console
|
||||
try {
|
||||
await execCtl("note get host 4");
|
||||
} finally {
|
||||
console.log = originalLog; // eslint-disable-line no-console
|
||||
}
|
||||
assert.deepEqual(lines, ["basement-box"]);
|
||||
});
|
||||
it("should fail for a resource without a note", async function() {
|
||||
await assert.rejects(
|
||||
execCtl("note get host 5"),
|
||||
new lib.CommandError("No note for host 5")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("note delete", function() {
|
||||
it("should remove the note of the given resource", async function() {
|
||||
await execCtl("note delete host 4");
|
||||
const notes = await getControl().send(new lib.NoteListRequest());
|
||||
assert(!notes.find(n => n.id === "host/4"), "Note was not deleted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("user list", function() {
|
||||
it("runs", async function() {
|
||||
await execCtl("user list");
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { testRoundTripJsonSerialisable } from "../../common.js";
|
||||
|
||||
describe("lib/data/Note", function() {
|
||||
describe("class Note", function() {
|
||||
it("should derive its id from the resource type and id", function() {
|
||||
const note = new lib.Note("host", "12", "Runs on the basement box");
|
||||
assert.equal(note.id, "host/12");
|
||||
assert.equal(lib.Note.idFor("instance", 4), "instance/4");
|
||||
});
|
||||
it("should round trip serialize", function() {
|
||||
testRoundTripJsonSerialisable(lib.Note, [
|
||||
["host", "1", "text"],
|
||||
["user", "player", "multi\nline", "admin"],
|
||||
["mod", "mod_1.0.0", "text", "admin", 1234],
|
||||
["save", "1/world.zip", "text", "", 1234, true],
|
||||
]);
|
||||
});
|
||||
it("should omit default optional fields from json", function() {
|
||||
const note = new lib.Note("role", "5", "text");
|
||||
assert.deepEqual(note.toJSON(), { resourceType: "role", resourceId: "5", content: "text" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from "node:assert/strict";
|
||||
import events from "node:events";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Controller, ControlConnection } from "@clusterio/controller";
|
||||
|
||||
import { testRoundTripJsonSerialisable } from "../common.js";
|
||||
|
||||
describe("messages/note", function() {
|
||||
/** @type {Controller} */
|
||||
let controller;
|
||||
/** @type {ControlConnection} */
|
||||
let controlConnection;
|
||||
let broadcasts;
|
||||
|
||||
beforeEach(function() {
|
||||
const controllerConfig = new lib.ControllerConfig("controller");
|
||||
const connection = new lib.VirtualConnector(
|
||||
lib.Address.fromShorthand("controller"),
|
||||
lib.Address.fromShorthand({ controlId: 1 }),
|
||||
);
|
||||
controller = new Controller(lib.logger, [], controllerConfig);
|
||||
broadcasts = [];
|
||||
controller.subscriptions.broadcast = event => {
|
||||
if (event instanceof lib.NoteUpdatesEvent) {
|
||||
broadcasts.push(event);
|
||||
}
|
||||
};
|
||||
const user = controller.users.getOrCreateUser("test");
|
||||
controlConnection = new ControlConnection({ version: "2.0.0" }, connection, controller, user, 1);
|
||||
});
|
||||
|
||||
describe("NoteSetRequest", function() {
|
||||
it("round trips", function() {
|
||||
testRoundTripJsonSerialisable(lib.NoteSetRequest, [["host", "1", "text"]]);
|
||||
});
|
||||
it("creates a note attributed to the requesting user", async function() {
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "text"));
|
||||
const note = controller.notes.get("host/1");
|
||||
assert.equal(note.content, "text");
|
||||
assert.equal(note.updatedBy, "test");
|
||||
assert(note.updatedAtMs > 0);
|
||||
assert.equal(broadcasts.length, 1);
|
||||
assert.deepEqual(broadcasts[0].updates, [note]);
|
||||
});
|
||||
it("updates an existing note in place", async function() {
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "first"));
|
||||
const first = controller.notes.get("host/1").updatedAtMs;
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "second"));
|
||||
assert.equal(controller.notes.size, 1);
|
||||
const note = controller.notes.get("host/1");
|
||||
assert.equal(note.content, "second");
|
||||
assert(note.updatedAtMs > first);
|
||||
});
|
||||
it("leaves a tombstone when content is empty", async function() {
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "text"));
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
|
||||
const note = controller.notes.get("host/1");
|
||||
assert.equal(note.isDeleted, true);
|
||||
assert.equal(note.content, "");
|
||||
assert.equal(broadcasts.length, 2);
|
||||
assert.equal(broadcasts[1].updates[0].isDeleted, true);
|
||||
});
|
||||
it("restores a deleted note", async function() {
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "text"));
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "again"));
|
||||
const note = controller.notes.get("host/1");
|
||||
assert.equal(note.isDeleted, false);
|
||||
assert.equal(note.content, "again");
|
||||
});
|
||||
it("ignores empty content for a deleted note", async function() {
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "text"));
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
|
||||
assert.equal(broadcasts.length, 2);
|
||||
});
|
||||
it("ignores empty content for a resource without a note", async function() {
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
|
||||
assert.equal(controller.notes.size, 0);
|
||||
assert.equal(broadcasts.length, 0);
|
||||
});
|
||||
it("rejects an empty resource type or id", async function() {
|
||||
await assert.rejects(
|
||||
controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("", "1", "text")),
|
||||
new lib.RequestError("resourceType and resourceId must not be empty"),
|
||||
);
|
||||
await assert.rejects(
|
||||
controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "", "text")),
|
||||
new lib.RequestError("resourceType and resourceId must not be empty"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NoteListRequest", function() {
|
||||
it("returns all notes", async function() {
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "a"));
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("user", "player", "b"));
|
||||
const notes = await controlConnection.handleNoteListRequest();
|
||||
assert.deepEqual(notes.map(n => n.id).sort(), ["host/1", "user/player"]);
|
||||
});
|
||||
it("leaves out deleted notes", async function() {
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "a"));
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
|
||||
assert.deepEqual(await controlConnection.handleNoteListRequest(), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NoteUpdatesEvent", function() {
|
||||
it("round trips", function() {
|
||||
testRoundTripJsonSerialisable(lib.NoteUpdatesEvent, [
|
||||
[[]],
|
||||
[[new lib.Note("host", "1", "text", "admin", 1234)]],
|
||||
]);
|
||||
});
|
||||
it("only returns notes changed since the last request on subscribe", async function() {
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "a"));
|
||||
const firstMs = controller.notes.get("host/1").updatedAtMs;
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "2", "b"));
|
||||
|
||||
const all = await controller.handleNoteSubscription({ action: "subscribe", lastRequestTimeMs: 0 });
|
||||
assert.equal(all.updates.length, 2);
|
||||
const recent = await controller.handleNoteSubscription({
|
||||
action: "subscribe", lastRequestTimeMs: firstMs,
|
||||
});
|
||||
assert.deepEqual(recent.updates.map(n => n.id), ["host/2"]);
|
||||
const none = await controller.handleNoteSubscription({ action: "unsubscribe", lastRequestTimeMs: 0 });
|
||||
assert.equal(none, null);
|
||||
});
|
||||
it("replays deletions only to clients that may have the note", async function() {
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "a"));
|
||||
const createdMs = controller.notes.get("host/1").updatedAtMs;
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
|
||||
|
||||
const fresh = await controller.handleNoteSubscription({ action: "subscribe", lastRequestTimeMs: -1 });
|
||||
assert.equal(fresh, null);
|
||||
const resumed = await controller.handleNoteSubscription({
|
||||
action: "subscribe", lastRequestTimeMs: createdMs,
|
||||
});
|
||||
assert.deepEqual(resumed.updates.map(n => [n.id, n.isDeleted]), [["host/1", true]]);
|
||||
});
|
||||
it("removes a note deleted while a subscriber was unsubscribed", async function() {
|
||||
let deliver;
|
||||
let lastRequest;
|
||||
const link = { sendTo: (address, event) => { deliver(event); } };
|
||||
const control = {
|
||||
connector: Object.assign(new events.EventEmitter(), { valid: true }),
|
||||
handle(Event, handler) { deliver = handler; },
|
||||
sendTo: (target, request) => {
|
||||
const src = lib.Address.fromShorthand({ controlId: 2 });
|
||||
const dst = lib.Address.fromShorthand("controller");
|
||||
lastRequest = controller.subscriptions.handleRequest(link, request, src, dst);
|
||||
return lastRequest;
|
||||
},
|
||||
};
|
||||
const subscriber = new lib.MapSubscriber(lib.NoteUpdatesEvent, control);
|
||||
// Unsubscribing is delayed by a timer, wait for it and the request it sends
|
||||
const tick = async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await lastRequest;
|
||||
};
|
||||
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", "text"));
|
||||
let unsubscribe = subscriber.subscribe(() => {});
|
||||
await tick();
|
||||
assert.equal(subscriber.values.get("host/1").content, "text");
|
||||
|
||||
unsubscribe();
|
||||
await tick();
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("host", "1", ""));
|
||||
|
||||
unsubscribe = subscriber.subscribe(() => {});
|
||||
await tick();
|
||||
assert.equal(subscriber.synced, true);
|
||||
assert.equal(subscriber.values.has("host/1"), false);
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resource deletion", function() {
|
||||
it("does nothing for a resource without a note", function() {
|
||||
controller.deleteNote("host", 1);
|
||||
assert.equal(broadcasts.length, 0);
|
||||
});
|
||||
it("removes the note of a deleted role", async function() {
|
||||
const id = await controlConnection.handleRoleCreateRequest(new lib.RoleCreateRequest("r", "", []));
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("role", String(id), "text"));
|
||||
await controlConnection.handleRoleDeleteRequest(new lib.RoleDeleteRequest(id));
|
||||
assert.equal(controller.notes.get(`role/${id}`).isDeleted, true);
|
||||
});
|
||||
it("removes the note of a deleted user", async function() {
|
||||
controller.users.getOrCreateUser("Player");
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("user", "player", "text"));
|
||||
controller.sendTo = () => {};
|
||||
await controlConnection.handleUserDeleteRequest(new lib.UserDeleteRequest("Player"));
|
||||
assert.equal(controller.notes.get("user/player").isDeleted, true);
|
||||
});
|
||||
it("removes the note of a deleted instance", async function() {
|
||||
const instanceConfig = new lib.InstanceConfig("controller");
|
||||
await controller.instances.createInstance(instanceConfig);
|
||||
const id = instanceConfig.get("instance.id");
|
||||
await controlConnection.handleNoteSetRequest(new lib.NoteSetRequest("instance", String(id), "text"));
|
||||
await controlConnection.handleInstanceDeleteRequest(new lib.InstanceDeleteRequest(id));
|
||||
assert.equal(controller.notes.get(`instance/${id}`).isDeleted, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -220,6 +220,8 @@ export class MockController {
|
||||
this.handles.set(eventClass, handler);
|
||||
}
|
||||
|
||||
deleteNote() { }
|
||||
|
||||
getControllerUrl() {
|
||||
return "http://controller.example/";
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user