Add notes to hosts, instances, users and other resources (#1000)

This commit is contained in:
Bastiaan authored and GitHub committed 2026-10-18 20:06:15 +03:00
1 parent ee39945c83
commit cbf2b10782
25 files changed
+830 -52

No files matched your search

+27
View File
@@ -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));
+65
View File
@@ -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) {
+2
View File
@@ -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);
+72
View File
@@ -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, ""));
},
}));
+74
View File
@@ -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;
}
}
+2
View File
@@ -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";
+59
View File
@@ -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
View File
@@ -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,
+18
View File
@@ -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&nbsp;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>
+56 -52
View File
@@ -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">
+2
View File
@@ -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";
+16
View File
@@ -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());
}
+1
View File
@@ -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));
+47
View File
@@ -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");
+26
View File
@@ -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" });
});
});
});
+207
View File
@@ -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);
});
});
});
+2
View File
@@ -220,6 +220,8 @@ export class MockController {
this.handles.set(eventClass, handler);
}
deleteNote() { }
getControllerUrl() {
return "http://controller.example/";
}