From cbf2b107828ae63c4443305e1d1b3ca929abda43 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:06:15 +0200 Subject: [PATCH] Add notes to hosts, instances, users and other resources (#1000) --- docs/managing-a-cluster.md | 27 +++ packages/controller/src/ControlConnection.ts | 19 ++ packages/controller/src/Controller.ts | 65 ++++++ packages/ctl/src/commands.ts | 2 + packages/ctl/src/commands_note.ts | 72 ++++++ packages/lib/src/data/Note.ts | 74 +++++++ packages/lib/src/data/index.ts | 2 + packages/lib/src/data/messages_note.ts | 59 +++++ packages/lib/src/link/messages.ts | 5 + packages/lib/src/permissions.ts | 18 ++ .../web_ui/src/components/HostViewPage.tsx | 2 + .../src/components/InstanceViewPage.tsx | 2 + packages/web_ui/src/components/ModDetails.tsx | 4 + .../web_ui/src/components/ModPackViewPage.tsx | 2 + .../web_ui/src/components/ResourceNotes.tsx | 115 ++++++++++ .../web_ui/src/components/RoleViewPage.tsx | 3 + packages/web_ui/src/components/SavesList.tsx | 108 ++++----- .../web_ui/src/components/UserViewPage.tsx | 2 + packages/web_ui/src/index.ts | 2 + packages/web_ui/src/model/notes.ts | 16 ++ packages/web_ui/src/util/websocket.ts | 1 + test/integration/clusterio.js | 47 ++++ test/lib/data/Note.test.js | 26 +++ test/messages/note.test.js | 207 ++++++++++++++++++ test/mock.js | 2 + 25 files changed, 830 insertions(+), 52 deletions(-) create mode 100644 packages/ctl/src/commands_note.ts create mode 100644 packages/lib/src/data/Note.ts create mode 100644 packages/lib/src/data/messages_note.ts create mode 100644 packages/web_ui/src/components/ResourceNotes.tsx create mode 100644 packages/web_ui/src/model/notes.ts create mode 100644 test/lib/data/Note.test.js create mode 100644 test/messages/note.test.js diff --git a/docs/managing-a-cluster.md b/docs/managing-a-cluster.md index e1883443..7311ca25 100644 --- a/docs/managing-a-cluster.md +++ b/docs/managing-a-cluster.md @@ -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 ] + +Lists all notes, optionally limited to one resource type. + +### Set note + + ctl> note set + +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, `_` for mods and `/` for saves. + +### Delete note + + ctl> note delete + +Removes the note of a resource. +Notes are also removed when the resource they are attached to is deleted. + + ## Plugins (Local) To be written. diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts index f0a005cd..b5ff212d 100644 --- a/packages/controller/src/ControlConnection.ts +++ b/packages/controller/src/ControlConnection.ts @@ -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 { 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)); diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index c973b63d..84404f28 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -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(), /** Mapping of user id to user record */ users = new lib.SubscribableDatastore(), + /** Mapping of note id to note attached to a resource */ + public notes = new lib.SubscribableDatastore(), ) { 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) { diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts index 3bcfdb58..b5a342fd 100644 --- a/packages/ctl/src/commands.ts +++ b/packages/ctl/src/commands.ts @@ -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); diff --git a/packages/ctl/src/commands_note.ts b/packages/ctl/src/commands_note.ts new file mode 100644 index 00000000..b31ea531 --- /dev/null +++ b/packages/ctl/src/commands_note.ts @@ -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 ", "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 ", "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 ", "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, "")); + }, +})); diff --git a/packages/lib/src/data/Note.ts b/packages/lib/src/data/Note.ts new file mode 100644 index 00000000..cb3bad9a --- /dev/null +++ b/packages/lib/src/data/Note.ts @@ -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) { + 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; + + 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; + } +} diff --git a/packages/lib/src/data/index.ts b/packages/lib/src/data/index.ts index ff473748..19ce86eb 100644 --- a/packages/lib/src/data/index.ts +++ b/packages/lib/src/data/index.ts @@ -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"; diff --git a/packages/lib/src/data/messages_note.ts b/packages/lib/src/data/messages_note.ts new file mode 100644 index 00000000..368bd6e3 --- /dev/null +++ b/packages/lib/src/data/messages_note.ts @@ -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) { + 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) { + return new this(json.resourceType, json.resourceId, json.content); + } +} diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 448e4780..2ff37a55 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -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 | EventClass) role.RoleGrantDefaultPermissionsRequest, role.RoleDeleteRequest, + note.NoteListRequest, + note.NoteUpdatesEvent, + note.NoteSetRequest, + user.UserGetRequest, user.UserListRequest, user.UserCreateRequest, diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index 89b5ccf0..64e1ec4d 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -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", diff --git a/packages/web_ui/src/components/HostViewPage.tsx b/packages/web_ui/src/components/HostViewPage.tsx index 4d230ade..6e1cf16f 100644 --- a/packages/web_ui/src/components/HostViewPage.tsx +++ b/packages/web_ui/src/components/HostViewPage.tsx @@ -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 } + {account.hasPermission("core.instance.list") && <> Instances diff --git a/packages/web_ui/src/components/InstanceViewPage.tsx b/packages/web_ui/src/components/InstanceViewPage.tsx index ef55c97e..ab52b69a 100644 --- a/packages/web_ui/src/components/InstanceViewPage.tsx +++ b/packages/web_ui/src/components/InstanceViewPage.tsx @@ -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={} /> + {account.hasPermission("core.user.list") && } diff --git a/packages/web_ui/src/components/ModDetails.tsx b/packages/web_ui/src/components/ModDetails.tsx index 704365f3..02350f9b 100644 --- a/packages/web_ui/src/components/ModDetails.tsx +++ b/packages/web_ui/src/components/ModDetails.tsx @@ -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(props: && {mod.description} } {Boolean(mod.author) && {mod.author}} + + + diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index 19c8ae53..fd7c7986 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -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() { + ; } diff --git a/packages/web_ui/src/components/ResourceNotes.tsx b/packages/web_ui/src/components/ResourceNotes.tsx new file mode 100644 index 00000000..c28231d7 --- /dev/null +++ b/packages/web_ui/src/components/ResourceNotes.tsx @@ -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 ; +} + +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 + ? + + + + : ; + + const body = editing + ? 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 + ?
{note.content}
+ : {synced ? "No note" : "Loading"} + } + {note && (note.updatedBy || note.updatedAtMs) &&
+ + Last edited{note.updatedBy ? ` by ${note.updatedBy}` : ""} + {note.updatedAtMs ? ` on ${formatTimestamp(note.updatedAtMs)}` : ""} + +
} + ; + + if (props.compact) { + return + {body} + {canUpdate && buttons} + ; + } + + return <> + + {body} + ; +} diff --git a/packages/web_ui/src/components/RoleViewPage.tsx b/packages/web_ui/src/components/RoleViewPage.tsx index 9ff44c34..41d7eb26 100644 --- a/packages/web_ui/src/components/RoleViewPage.tsx +++ b/packages/web_ui/src/components/RoleViewPage.tsx @@ -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() { setDescription(e.target.value)} /> + +

Permissions

diff --git a/packages/web_ui/src/components/SavesList.tsx b/packages/web_ui/src/components/SavesList.tsx index 1848d4aa..9c7b79af 100644 --- a/packages/web_ui/src/components/SavesList.tsx +++ b/packages/web_ui/src/components/SavesList.tsx @@ -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 => - {account.hasPermission("core.instance.start") && } - {account.hasPermission("core.instance.save.rename") && } - {account.hasPermission("core.instance.save.copy") && } - {account.hasPermission("core.instance.save.download") && } - {account.hasPermission("core.instance.save.transfer") && } - {account.hasPermission("core.instance.save.delete") && { - control.send( - new lib.InstanceDeleteSaveRequest(props.instance.id!, save.name) - ).catch(notifyErrorHandler("Error deleting save")); - }} - > - - } + expandedRowRender: save => + + {account.hasPermission("core.instance.start") && } + {account.hasPermission("core.instance.save.rename") && } + {account.hasPermission("core.instance.save.copy") && } + {account.hasPermission("core.instance.save.download") && } + {account.hasPermission("core.instance.save.transfer") && } + {account.hasPermission("core.instance.save.delete") && { + control.send( + new lib.InstanceDeleteSaveRequest(props.instance.id!, save.name) + ).catch(notifyErrorHandler("Error deleting save")); + }} + > + + } + + , }} />; diff --git a/packages/web_ui/src/components/UserViewPage.tsx b/packages/web_ui/src/components/UserViewPage.tsx index 4a4bd97f..4c1b1048 100644 --- a/packages/web_ui/src/components/UserViewPage.tsx +++ b/packages/web_ui/src/components/UserViewPage.tsx @@ -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() { && {user.banReason} } + diff --git a/packages/web_ui/src/index.ts b/packages/web_ui/src/index.ts index aaf875b7..755b51f6 100644 --- a/packages/web_ui/src/index.ts +++ b/packages/web_ui/src/index.ts @@ -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"; diff --git a/packages/web_ui/src/model/notes.ts b/packages/web_ui/src/model/notes.ts new file mode 100644 index 00000000..f609dd92 --- /dev/null +++ b/packages/web_ui/src/model/notes.ts @@ -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()); +} diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index 6695880b..b98f0a72 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -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)); diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 6de2cc31..fa01eb8b 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -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"); diff --git a/test/lib/data/Note.test.js b/test/lib/data/Note.test.js new file mode 100644 index 00000000..12b9ea6f --- /dev/null +++ b/test/lib/data/Note.test.js @@ -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" }); + }); + }); +}); diff --git a/test/messages/note.test.js b/test/messages/note.test.js new file mode 100644 index 00000000..96cbf5cb --- /dev/null +++ b/test/messages/note.test.js @@ -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); + }); + }); +}); diff --git a/test/mock.js b/test/mock.js index 9b2d9ef6..3deff983 100644 --- a/test/mock.js +++ b/test/mock.js @@ -220,6 +220,8 @@ export class MockController { this.handles.set(eventClass, handler); } + deleteNote() { } + getControllerUrl() { return "http://controller.example/"; }