@oimdb/mcp 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/bin/server.cjs +3 -1
- package/dist/index.cjs +70 -10
- package/dist/index.d.cts +36 -1
- package/dist/index.d.ts +36 -1
- package/dist/index.js +70 -10
- package/package.json +1 -1
- package/ui/app.css +97 -12
- package/ui/app.js +146 -10
- package/ui/index.html +10 -4
package/README.md
CHANGED
|
@@ -108,6 +108,49 @@ Call oimdb_inspect to see what's in the store.
|
|
|
108
108
|
| `oimdb_collection` | Details of one collection by name |
|
|
109
109
|
| `oimdb_computed` | Status + deps of one computed by name |
|
|
110
110
|
|
|
111
|
+
## Offline / static mode (no browser)
|
|
112
|
+
|
|
113
|
+
By default the tools read a **live browser tab**. You can instead point the
|
|
114
|
+
server at an **in-process model** so it answers offline — ideal for "what's the
|
|
115
|
+
data model?" questions without running the app.
|
|
116
|
+
|
|
117
|
+
**CLI — a model module.** Set `OIMDB_MODEL_MODULE` to a module that builds the
|
|
118
|
+
model in Node and exports a `registry` (or a default export, or a zero-arg
|
|
119
|
+
factory returning one):
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// oimdb-model.js (built JS, or run the server under a TS loader)
|
|
123
|
+
import { OIMEventQueue, OIMReactiveCollection } from '@oimdb/core';
|
|
124
|
+
import { OIMDevRegistry } from '@oimdb/devtools';
|
|
125
|
+
|
|
126
|
+
const queue = new OIMEventQueue();
|
|
127
|
+
const users = new OIMReactiveCollection(queue, { selectPk: u => u.id });
|
|
128
|
+
|
|
129
|
+
export const registry = new OIMDevRegistry();
|
|
130
|
+
registry.collection('users', users, { indexes: { /* ... */ } });
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
OIMDB_MODEL_MODULE=./oimdb-model.js npx @oimdb/mcp
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
No WebSocket port is opened in this mode — no browser required.
|
|
138
|
+
|
|
139
|
+
**Embedded — inject a registry.** When running the server from your own Node
|
|
140
|
+
process, pass the registry directly:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { createOIMDevMcpServer } from '@oimdb/mcp';
|
|
144
|
+
import { registry } from './oimdb-model';
|
|
145
|
+
|
|
146
|
+
await createOIMDevMcpServer({ registry }).start();
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
> **Fields offline:** structure (collections, indexes, relations) is available
|
|
150
|
+
> as soon as the model is constructed. Entity **field names** appear only when
|
|
151
|
+
> the model holds at least one sample entity (TypeScript types are erased at
|
|
152
|
+
> runtime) — seed a representative row in the model module if you want them.
|
|
153
|
+
|
|
111
154
|
## CDP Inspector (no browser-side code needed)
|
|
112
155
|
|
|
113
156
|
Launch Chrome with `--remote-debugging-port=9222`, then:
|
package/bin/server.cjs
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
const { OIMDevMcpServer } = require('../dist/index.cjs');
|
|
3
3
|
|
|
4
4
|
const wsPort = process.env.OIMDB_WS_PORT ? Number(process.env.OIMDB_WS_PORT) : 7432;
|
|
5
|
-
|
|
5
|
+
// Offline/static mode: point at a module that builds the model in-process.
|
|
6
|
+
const modelModulePath = process.env.OIMDB_MODEL_MODULE || undefined;
|
|
7
|
+
const server = new OIMDevMcpServer({ wsPort, modelModulePath });
|
|
6
8
|
server.start().catch((err) => {
|
|
7
9
|
process.stderr.write('[OIMDB MCP] Fatal: ' + err.message + '\n');
|
|
8
10
|
process.exit(1);
|
package/dist/index.cjs
CHANGED
|
@@ -31,36 +31,86 @@ module.exports = __toCommonJS(index_exports);
|
|
|
31
31
|
var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
32
32
|
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
33
33
|
var import_ws = require("ws");
|
|
34
|
+
var import_node_url = require("url");
|
|
35
|
+
var import_node_path = require("path");
|
|
34
36
|
var import_zod = require("zod");
|
|
37
|
+
async function loadRegistryFromModule(path) {
|
|
38
|
+
const url = (0, import_node_url.pathToFileURL)((0, import_node_path.resolve)(path)).href;
|
|
39
|
+
const mod = await import(url);
|
|
40
|
+
let candidate = mod.registry ?? mod.default ?? mod;
|
|
41
|
+
if (typeof candidate === "function") {
|
|
42
|
+
candidate = await candidate();
|
|
43
|
+
}
|
|
44
|
+
if (!candidate || typeof candidate.inspect !== "function" || typeof candidate.dumpString !== "function") {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`[OIMDB MCP] Module "${path}" must export a registry exposing inspect() and dumpString() (as a "registry" or default export, or a zero-arg factory returning one).`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return candidate;
|
|
50
|
+
}
|
|
35
51
|
var OIMDevMcpServer = class {
|
|
36
52
|
constructor(options = {}) {
|
|
37
53
|
this.wss = null;
|
|
38
54
|
this.latestBrowser = null;
|
|
39
55
|
this.pending = /* @__PURE__ */ new Map();
|
|
56
|
+
this.modelModuleLoaded = false;
|
|
40
57
|
this.wsPort = options.wsPort ?? 7432;
|
|
41
|
-
this.
|
|
58
|
+
this.inProcessRegistry = options.registry ?? null;
|
|
59
|
+
this.modelModulePath = options.modelModulePath;
|
|
60
|
+
this.mcpServer = new import_mcp.McpServer(
|
|
61
|
+
{ name: "oimdb", version: "1.0.0" },
|
|
62
|
+
{
|
|
63
|
+
instructions: "Before using these tools, read the OIMDB API reference so you know how to write correct code: node_modules/@oimdb/core/llms.txt. Then use oimdb_inspect to see the data model (collections, indexes, computeds). The source is either a live browser tab (WebSocket bridge) or an in-process model (when a registry / model module is provided) for offline/static introspection."
|
|
64
|
+
}
|
|
65
|
+
);
|
|
42
66
|
this.registerTools();
|
|
43
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Resolves the in-process registry, loading the model module on first use.
|
|
70
|
+
* Returns null when no in-process source is configured (browser-bridge mode).
|
|
71
|
+
*/
|
|
72
|
+
async getInProcessRegistry() {
|
|
73
|
+
if (this.inProcessRegistry) return this.inProcessRegistry;
|
|
74
|
+
if (this.modelModulePath && !this.modelModuleLoaded) {
|
|
75
|
+
this.modelModuleLoaded = true;
|
|
76
|
+
this.inProcessRegistry = await loadRegistryFromModule(
|
|
77
|
+
this.modelModulePath
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return this.inProcessRegistry;
|
|
81
|
+
}
|
|
82
|
+
/** Reads the inspect snapshot from the in-process model or the browser. */
|
|
83
|
+
async readInspect() {
|
|
84
|
+
const registry = await this.getInProcessRegistry();
|
|
85
|
+
if (registry) return registry.inspect();
|
|
86
|
+
return this.queryBrowser("inspect");
|
|
87
|
+
}
|
|
88
|
+
/** Reads the human-readable dump from the in-process model or the browser. */
|
|
89
|
+
async readDump() {
|
|
90
|
+
const registry = await this.getInProcessRegistry();
|
|
91
|
+
if (registry) return registry.dumpString();
|
|
92
|
+
return await this.queryBrowser("dump");
|
|
93
|
+
}
|
|
44
94
|
registerTools() {
|
|
45
95
|
const server = this.mcpServer;
|
|
46
96
|
const nameSchema = { name: import_zod.z.string().describe("Registered name") };
|
|
47
97
|
server.registerTool("oimdb_inspect", {
|
|
48
|
-
description: "Get full state of all registered OIMDB collections and computeds
|
|
98
|
+
description: "Get full state of all registered OIMDB collections and computeds. Returns structured JSON with entity counts, field names, index keys, relations, and computed statuses. Source is the connected browser tab, or an in-process model when one is configured (offline/static \u2014 field names appear only if the model holds sample entities)."
|
|
49
99
|
}, async () => {
|
|
50
|
-
const data = await this.
|
|
100
|
+
const data = await this.readInspect();
|
|
51
101
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
52
102
|
});
|
|
53
103
|
server.registerTool("oimdb_dump", {
|
|
54
|
-
description: "Get a human-readable text summary of OIMDB state. Same output as registry.dump()
|
|
104
|
+
description: "Get a human-readable text summary of OIMDB state. Same output as registry.dump(). Reads the connected browser tab or an in-process model when configured."
|
|
55
105
|
}, async () => {
|
|
56
|
-
const text = await this.
|
|
106
|
+
const text = await this.readDump();
|
|
57
107
|
return { content: [{ type: "text", text }] };
|
|
58
108
|
});
|
|
59
109
|
server.registerTool("oimdb_collection", {
|
|
60
110
|
description: "Get details of a specific registered collection: entity count, field names, index keys, and relations.",
|
|
61
111
|
inputSchema: nameSchema
|
|
62
112
|
}, async ({ name }) => {
|
|
63
|
-
const data = await this.
|
|
113
|
+
const data = await this.readInspect();
|
|
64
114
|
const info = data.collections[name];
|
|
65
115
|
if (!info) {
|
|
66
116
|
const available = Object.keys(data.collections).join(", ") || "(none registered)";
|
|
@@ -72,7 +122,7 @@ var OIMDevMcpServer = class {
|
|
|
72
122
|
description: "Get status and resolved dependency names of a specific registered computed value. Shows fresh/stale/pending status, current value, and which registered sources it depends on.",
|
|
73
123
|
inputSchema: nameSchema
|
|
74
124
|
}, async ({ name }) => {
|
|
75
|
-
const data = await this.
|
|
125
|
+
const data = await this.readInspect();
|
|
76
126
|
const info = data.computeds[name];
|
|
77
127
|
if (!info) {
|
|
78
128
|
const available = Object.keys(data.computeds).join(", ") || "(none registered)";
|
|
@@ -86,11 +136,14 @@ var OIMDevMcpServer = class {
|
|
|
86
136
|
throw new Error(
|
|
87
137
|
`No browser tab connected to the OIMDB MCP bridge.
|
|
88
138
|
|
|
89
|
-
|
|
139
|
+
Live mode \u2014 add to your debug entry point:
|
|
90
140
|
import { registry } from '@oimdb/devtools';
|
|
91
141
|
registry.connect(); // connects to ws://localhost:${this.wsPort}
|
|
142
|
+
Then reload the page.
|
|
92
143
|
|
|
93
|
-
|
|
144
|
+
Offline mode \u2014 point the server at a model module instead:
|
|
145
|
+
OIMDB_MODEL_MODULE=./path/to/model.js npx @oimdb/mcp
|
|
146
|
+
(the module exports a "registry" with inspect()/dumpString()).`
|
|
94
147
|
);
|
|
95
148
|
}
|
|
96
149
|
return new Promise((resolve, reject) => {
|
|
@@ -150,7 +203,14 @@ Set a different port: OIMDB_WS_PORT=7433 npx @oimdb/mcp
|
|
|
150
203
|
`);
|
|
151
204
|
}
|
|
152
205
|
async start() {
|
|
153
|
-
this.
|
|
206
|
+
const registry = await this.getInProcessRegistry();
|
|
207
|
+
if (registry) {
|
|
208
|
+
process.stderr.write(
|
|
209
|
+
"[OIMDB MCP] In-process model loaded \u2014 serving offline (no browser bridge).\n"
|
|
210
|
+
);
|
|
211
|
+
} else {
|
|
212
|
+
this.setupWebSocketServer();
|
|
213
|
+
}
|
|
154
214
|
const transport = new import_stdio.StdioServerTransport();
|
|
155
215
|
await this.mcpServer.connect(transport);
|
|
156
216
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The readable shape the MCP tools need from a registry — `OIMDevRegistry` from
|
|
3
|
+
* `@oimdb/devtools` already satisfies it. Declared structurally (following the
|
|
4
|
+
* existing `IOIMDev*Like` convention) so `@oimdb/mcp` need not depend on
|
|
5
|
+
* `@oimdb/devtools`; it is not a separate registry implementation.
|
|
6
|
+
*/
|
|
7
|
+
interface IOIMDevRegistryLike {
|
|
8
|
+
inspect(): unknown;
|
|
9
|
+
dumpString(): string;
|
|
10
|
+
}
|
|
1
11
|
type TOIMDevMcpOptions = {
|
|
2
12
|
/** Port for the WebSocket bridge that browser tabs connect to. Default: 7432 */
|
|
3
13
|
wsPort?: number;
|
|
14
|
+
/**
|
|
15
|
+
* An in-process registry to read directly (your `OIMDevRegistry`). When
|
|
16
|
+
* provided, the tools answer from it without a browser/WebSocket bridge
|
|
17
|
+
* (no `wsPort` server started).
|
|
18
|
+
*/
|
|
19
|
+
registry?: IOIMDevRegistryLike;
|
|
20
|
+
/**
|
|
21
|
+
* Path to a module that builds the model in this process for offline
|
|
22
|
+
* introspection. The module must export a `registry` (or a default export,
|
|
23
|
+
* or a zero-arg factory) exposing `inspect()` and `dumpString()`.
|
|
24
|
+
* Also settable via the `OIMDB_MODEL_MODULE` env var for the CLI.
|
|
25
|
+
*/
|
|
26
|
+
modelModulePath?: string;
|
|
4
27
|
};
|
|
5
28
|
|
|
6
29
|
declare class OIMDevMcpServer {
|
|
@@ -9,7 +32,19 @@ declare class OIMDevMcpServer {
|
|
|
9
32
|
private wss;
|
|
10
33
|
private latestBrowser;
|
|
11
34
|
private readonly pending;
|
|
35
|
+
private inProcessRegistry;
|
|
36
|
+
private readonly modelModulePath?;
|
|
37
|
+
private modelModuleLoaded;
|
|
12
38
|
constructor(options?: TOIMDevMcpOptions);
|
|
39
|
+
/**
|
|
40
|
+
* Resolves the in-process registry, loading the model module on first use.
|
|
41
|
+
* Returns null when no in-process source is configured (browser-bridge mode).
|
|
42
|
+
*/
|
|
43
|
+
private getInProcessRegistry;
|
|
44
|
+
/** Reads the inspect snapshot from the in-process model or the browser. */
|
|
45
|
+
private readInspect;
|
|
46
|
+
/** Reads the human-readable dump from the in-process model or the browser. */
|
|
47
|
+
private readDump;
|
|
13
48
|
private registerTools;
|
|
14
49
|
private queryBrowser;
|
|
15
50
|
private setupWebSocketServer;
|
|
@@ -41,4 +76,4 @@ declare class OIMDevUIServer {
|
|
|
41
76
|
declare function createOIMDevMcpServer(options?: TOIMDevMcpOptions): OIMDevMcpServer;
|
|
42
77
|
declare function createOIMDevUIServer(options: TOIMDevUIServerOptions): OIMDevUIServer;
|
|
43
78
|
|
|
44
|
-
export { OIMDevMcpServer, OIMDevUIServer, type TOIMDevMcpOptions, type TOIMDevUIServerOptions, createOIMDevMcpServer, createOIMDevUIServer };
|
|
79
|
+
export { type IOIMDevRegistryLike, OIMDevMcpServer, OIMDevUIServer, type TOIMDevMcpOptions, type TOIMDevUIServerOptions, createOIMDevMcpServer, createOIMDevUIServer };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The readable shape the MCP tools need from a registry — `OIMDevRegistry` from
|
|
3
|
+
* `@oimdb/devtools` already satisfies it. Declared structurally (following the
|
|
4
|
+
* existing `IOIMDev*Like` convention) so `@oimdb/mcp` need not depend on
|
|
5
|
+
* `@oimdb/devtools`; it is not a separate registry implementation.
|
|
6
|
+
*/
|
|
7
|
+
interface IOIMDevRegistryLike {
|
|
8
|
+
inspect(): unknown;
|
|
9
|
+
dumpString(): string;
|
|
10
|
+
}
|
|
1
11
|
type TOIMDevMcpOptions = {
|
|
2
12
|
/** Port for the WebSocket bridge that browser tabs connect to. Default: 7432 */
|
|
3
13
|
wsPort?: number;
|
|
14
|
+
/**
|
|
15
|
+
* An in-process registry to read directly (your `OIMDevRegistry`). When
|
|
16
|
+
* provided, the tools answer from it without a browser/WebSocket bridge
|
|
17
|
+
* (no `wsPort` server started).
|
|
18
|
+
*/
|
|
19
|
+
registry?: IOIMDevRegistryLike;
|
|
20
|
+
/**
|
|
21
|
+
* Path to a module that builds the model in this process for offline
|
|
22
|
+
* introspection. The module must export a `registry` (or a default export,
|
|
23
|
+
* or a zero-arg factory) exposing `inspect()` and `dumpString()`.
|
|
24
|
+
* Also settable via the `OIMDB_MODEL_MODULE` env var for the CLI.
|
|
25
|
+
*/
|
|
26
|
+
modelModulePath?: string;
|
|
4
27
|
};
|
|
5
28
|
|
|
6
29
|
declare class OIMDevMcpServer {
|
|
@@ -9,7 +32,19 @@ declare class OIMDevMcpServer {
|
|
|
9
32
|
private wss;
|
|
10
33
|
private latestBrowser;
|
|
11
34
|
private readonly pending;
|
|
35
|
+
private inProcessRegistry;
|
|
36
|
+
private readonly modelModulePath?;
|
|
37
|
+
private modelModuleLoaded;
|
|
12
38
|
constructor(options?: TOIMDevMcpOptions);
|
|
39
|
+
/**
|
|
40
|
+
* Resolves the in-process registry, loading the model module on first use.
|
|
41
|
+
* Returns null when no in-process source is configured (browser-bridge mode).
|
|
42
|
+
*/
|
|
43
|
+
private getInProcessRegistry;
|
|
44
|
+
/** Reads the inspect snapshot from the in-process model or the browser. */
|
|
45
|
+
private readInspect;
|
|
46
|
+
/** Reads the human-readable dump from the in-process model or the browser. */
|
|
47
|
+
private readDump;
|
|
13
48
|
private registerTools;
|
|
14
49
|
private queryBrowser;
|
|
15
50
|
private setupWebSocketServer;
|
|
@@ -41,4 +76,4 @@ declare class OIMDevUIServer {
|
|
|
41
76
|
declare function createOIMDevMcpServer(options?: TOIMDevMcpOptions): OIMDevMcpServer;
|
|
42
77
|
declare function createOIMDevUIServer(options: TOIMDevUIServerOptions): OIMDevUIServer;
|
|
43
78
|
|
|
44
|
-
export { OIMDevMcpServer, OIMDevUIServer, type TOIMDevMcpOptions, type TOIMDevUIServerOptions, createOIMDevMcpServer, createOIMDevUIServer };
|
|
79
|
+
export { type IOIMDevRegistryLike, OIMDevMcpServer, OIMDevUIServer, type TOIMDevMcpOptions, type TOIMDevUIServerOptions, createOIMDevMcpServer, createOIMDevUIServer };
|
package/dist/index.js
CHANGED
|
@@ -2,36 +2,86 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { WebSocketServer } from "ws";
|
|
5
|
+
import { pathToFileURL } from "url";
|
|
6
|
+
import { resolve as resolvePath } from "path";
|
|
5
7
|
import { z } from "zod";
|
|
8
|
+
async function loadRegistryFromModule(path) {
|
|
9
|
+
const url = pathToFileURL(resolvePath(path)).href;
|
|
10
|
+
const mod = await import(url);
|
|
11
|
+
let candidate = mod.registry ?? mod.default ?? mod;
|
|
12
|
+
if (typeof candidate === "function") {
|
|
13
|
+
candidate = await candidate();
|
|
14
|
+
}
|
|
15
|
+
if (!candidate || typeof candidate.inspect !== "function" || typeof candidate.dumpString !== "function") {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`[OIMDB MCP] Module "${path}" must export a registry exposing inspect() and dumpString() (as a "registry" or default export, or a zero-arg factory returning one).`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return candidate;
|
|
21
|
+
}
|
|
6
22
|
var OIMDevMcpServer = class {
|
|
7
23
|
constructor(options = {}) {
|
|
8
24
|
this.wss = null;
|
|
9
25
|
this.latestBrowser = null;
|
|
10
26
|
this.pending = /* @__PURE__ */ new Map();
|
|
27
|
+
this.modelModuleLoaded = false;
|
|
11
28
|
this.wsPort = options.wsPort ?? 7432;
|
|
12
|
-
this.
|
|
29
|
+
this.inProcessRegistry = options.registry ?? null;
|
|
30
|
+
this.modelModulePath = options.modelModulePath;
|
|
31
|
+
this.mcpServer = new McpServer(
|
|
32
|
+
{ name: "oimdb", version: "1.0.0" },
|
|
33
|
+
{
|
|
34
|
+
instructions: "Before using these tools, read the OIMDB API reference so you know how to write correct code: node_modules/@oimdb/core/llms.txt. Then use oimdb_inspect to see the data model (collections, indexes, computeds). The source is either a live browser tab (WebSocket bridge) or an in-process model (when a registry / model module is provided) for offline/static introspection."
|
|
35
|
+
}
|
|
36
|
+
);
|
|
13
37
|
this.registerTools();
|
|
14
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolves the in-process registry, loading the model module on first use.
|
|
41
|
+
* Returns null when no in-process source is configured (browser-bridge mode).
|
|
42
|
+
*/
|
|
43
|
+
async getInProcessRegistry() {
|
|
44
|
+
if (this.inProcessRegistry) return this.inProcessRegistry;
|
|
45
|
+
if (this.modelModulePath && !this.modelModuleLoaded) {
|
|
46
|
+
this.modelModuleLoaded = true;
|
|
47
|
+
this.inProcessRegistry = await loadRegistryFromModule(
|
|
48
|
+
this.modelModulePath
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return this.inProcessRegistry;
|
|
52
|
+
}
|
|
53
|
+
/** Reads the inspect snapshot from the in-process model or the browser. */
|
|
54
|
+
async readInspect() {
|
|
55
|
+
const registry = await this.getInProcessRegistry();
|
|
56
|
+
if (registry) return registry.inspect();
|
|
57
|
+
return this.queryBrowser("inspect");
|
|
58
|
+
}
|
|
59
|
+
/** Reads the human-readable dump from the in-process model or the browser. */
|
|
60
|
+
async readDump() {
|
|
61
|
+
const registry = await this.getInProcessRegistry();
|
|
62
|
+
if (registry) return registry.dumpString();
|
|
63
|
+
return await this.queryBrowser("dump");
|
|
64
|
+
}
|
|
15
65
|
registerTools() {
|
|
16
66
|
const server = this.mcpServer;
|
|
17
67
|
const nameSchema = { name: z.string().describe("Registered name") };
|
|
18
68
|
server.registerTool("oimdb_inspect", {
|
|
19
|
-
description: "Get full state of all registered OIMDB collections and computeds
|
|
69
|
+
description: "Get full state of all registered OIMDB collections and computeds. Returns structured JSON with entity counts, field names, index keys, relations, and computed statuses. Source is the connected browser tab, or an in-process model when one is configured (offline/static \u2014 field names appear only if the model holds sample entities)."
|
|
20
70
|
}, async () => {
|
|
21
|
-
const data = await this.
|
|
71
|
+
const data = await this.readInspect();
|
|
22
72
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
23
73
|
});
|
|
24
74
|
server.registerTool("oimdb_dump", {
|
|
25
|
-
description: "Get a human-readable text summary of OIMDB state. Same output as registry.dump()
|
|
75
|
+
description: "Get a human-readable text summary of OIMDB state. Same output as registry.dump(). Reads the connected browser tab or an in-process model when configured."
|
|
26
76
|
}, async () => {
|
|
27
|
-
const text = await this.
|
|
77
|
+
const text = await this.readDump();
|
|
28
78
|
return { content: [{ type: "text", text }] };
|
|
29
79
|
});
|
|
30
80
|
server.registerTool("oimdb_collection", {
|
|
31
81
|
description: "Get details of a specific registered collection: entity count, field names, index keys, and relations.",
|
|
32
82
|
inputSchema: nameSchema
|
|
33
83
|
}, async ({ name }) => {
|
|
34
|
-
const data = await this.
|
|
84
|
+
const data = await this.readInspect();
|
|
35
85
|
const info = data.collections[name];
|
|
36
86
|
if (!info) {
|
|
37
87
|
const available = Object.keys(data.collections).join(", ") || "(none registered)";
|
|
@@ -43,7 +93,7 @@ var OIMDevMcpServer = class {
|
|
|
43
93
|
description: "Get status and resolved dependency names of a specific registered computed value. Shows fresh/stale/pending status, current value, and which registered sources it depends on.",
|
|
44
94
|
inputSchema: nameSchema
|
|
45
95
|
}, async ({ name }) => {
|
|
46
|
-
const data = await this.
|
|
96
|
+
const data = await this.readInspect();
|
|
47
97
|
const info = data.computeds[name];
|
|
48
98
|
if (!info) {
|
|
49
99
|
const available = Object.keys(data.computeds).join(", ") || "(none registered)";
|
|
@@ -57,11 +107,14 @@ var OIMDevMcpServer = class {
|
|
|
57
107
|
throw new Error(
|
|
58
108
|
`No browser tab connected to the OIMDB MCP bridge.
|
|
59
109
|
|
|
60
|
-
|
|
110
|
+
Live mode \u2014 add to your debug entry point:
|
|
61
111
|
import { registry } from '@oimdb/devtools';
|
|
62
112
|
registry.connect(); // connects to ws://localhost:${this.wsPort}
|
|
113
|
+
Then reload the page.
|
|
63
114
|
|
|
64
|
-
|
|
115
|
+
Offline mode \u2014 point the server at a model module instead:
|
|
116
|
+
OIMDB_MODEL_MODULE=./path/to/model.js npx @oimdb/mcp
|
|
117
|
+
(the module exports a "registry" with inspect()/dumpString()).`
|
|
65
118
|
);
|
|
66
119
|
}
|
|
67
120
|
return new Promise((resolve, reject) => {
|
|
@@ -121,7 +174,14 @@ Set a different port: OIMDB_WS_PORT=7433 npx @oimdb/mcp
|
|
|
121
174
|
`);
|
|
122
175
|
}
|
|
123
176
|
async start() {
|
|
124
|
-
this.
|
|
177
|
+
const registry = await this.getInProcessRegistry();
|
|
178
|
+
if (registry) {
|
|
179
|
+
process.stderr.write(
|
|
180
|
+
"[OIMDB MCP] In-process model loaded \u2014 serving offline (no browser bridge).\n"
|
|
181
|
+
);
|
|
182
|
+
} else {
|
|
183
|
+
this.setupWebSocketServer();
|
|
184
|
+
}
|
|
125
185
|
const transport = new StdioServerTransport();
|
|
126
186
|
await this.mcpServer.connect(transport);
|
|
127
187
|
}
|
package/package.json
CHANGED
package/ui/app.css
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
--accent: #4fc3f7;
|
|
14
14
|
--green: #4caf50;
|
|
15
15
|
--yellow: #ffb74d;
|
|
16
|
+
--red: #ef5350;
|
|
16
17
|
--gray: #616161;
|
|
17
18
|
--tag-bg: #2d3f4a;
|
|
18
19
|
--tag-warn: #4a2d2d;
|
|
@@ -58,9 +59,7 @@ html, body {
|
|
|
58
59
|
|
|
59
60
|
#conn-label { font-size: 12px; color: var(--text-dim); }
|
|
60
61
|
|
|
61
|
-
.toolbar-actions {
|
|
62
|
-
display: flex; align-items: center; gap: 10px; margin-left: auto;
|
|
63
|
-
}
|
|
62
|
+
.toolbar-actions { display: flex; align-items: center; gap: 10px; margin-left: auto; }
|
|
64
63
|
#last-updated { font-size: 11px; color: var(--text-dim); }
|
|
65
64
|
|
|
66
65
|
#btn-refresh {
|
|
@@ -89,9 +88,7 @@ html, body {
|
|
|
89
88
|
background: var(--bg-panel); border: 1px solid var(--border);
|
|
90
89
|
border-radius: 8px; text-align: center;
|
|
91
90
|
}
|
|
92
|
-
.waiting-title {
|
|
93
|
-
font-size: 18px; font-weight: 600; color: var(--accent); margin-bottom: 14px;
|
|
94
|
-
}
|
|
91
|
+
.waiting-title { font-size: 18px; font-weight: 600; color: var(--accent); margin-bottom: 14px; }
|
|
95
92
|
.waiting-card p { color: var(--text-dim); margin-bottom: 10px; line-height: 1.6; }
|
|
96
93
|
.waiting-card code {
|
|
97
94
|
font-family: var(--mono); background: var(--bg-input);
|
|
@@ -119,6 +116,20 @@ html, body {
|
|
|
119
116
|
}
|
|
120
117
|
.col:last-child { border-right: none; }
|
|
121
118
|
|
|
119
|
+
/* Right column split into computeds + history */
|
|
120
|
+
#col-right { flex-direction: column; }
|
|
121
|
+
|
|
122
|
+
.col-section {
|
|
123
|
+
display: flex; flex-direction: column;
|
|
124
|
+
flex: 1; min-height: 0; overflow: hidden;
|
|
125
|
+
border-bottom: 1px solid var(--border);
|
|
126
|
+
}
|
|
127
|
+
.col-section:last-child { border-bottom: none; }
|
|
128
|
+
|
|
129
|
+
.col-section-history { flex: 0 0 220px; }
|
|
130
|
+
|
|
131
|
+
.col-body-flex { flex: 1; min-height: 0; overflow-y: auto; }
|
|
132
|
+
|
|
122
133
|
.col-header {
|
|
123
134
|
display: flex; align-items: center; gap: 7px;
|
|
124
135
|
height: 32px; padding: 0 14px; flex-shrink: 0;
|
|
@@ -160,7 +171,7 @@ html, body {
|
|
|
160
171
|
.c-count { font-size: 11px; color: var(--text-dim); }
|
|
161
172
|
|
|
162
173
|
.c-detail {
|
|
163
|
-
display: none; padding: 8px 14px
|
|
174
|
+
display: none; padding: 8px 14px 0 32px;
|
|
164
175
|
background: var(--bg); border-top: 1px solid var(--border);
|
|
165
176
|
font-size: 11px; font-family: var(--mono);
|
|
166
177
|
}
|
|
@@ -180,6 +191,56 @@ html, body {
|
|
|
180
191
|
.tag-warn { background: var(--tag-warn); color: #ffaa80; }
|
|
181
192
|
.tag-rel-arrow { color: var(--accent); margin: 0 3px; }
|
|
182
193
|
|
|
194
|
+
/* ── Entity table ─────────────────────────────── */
|
|
195
|
+
|
|
196
|
+
.entity-wrap {
|
|
197
|
+
margin: 8px 0 0 -18px;
|
|
198
|
+
border-top: 1px solid var(--border);
|
|
199
|
+
padding: 8px 0 10px 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.entity-table-wrap {
|
|
203
|
+
overflow-x: auto;
|
|
204
|
+
max-height: 280px;
|
|
205
|
+
overflow-y: auto;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.entity-table {
|
|
209
|
+
width: 100%;
|
|
210
|
+
border-collapse: collapse;
|
|
211
|
+
font-size: 11px;
|
|
212
|
+
font-family: var(--mono);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.entity-table th {
|
|
216
|
+
position: sticky; top: 0;
|
|
217
|
+
background: var(--bg-panel);
|
|
218
|
+
color: var(--text-dim);
|
|
219
|
+
font-weight: 600;
|
|
220
|
+
text-align: left;
|
|
221
|
+
padding: 4px 10px;
|
|
222
|
+
border-bottom: 1px solid var(--border);
|
|
223
|
+
white-space: nowrap;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.entity-table td {
|
|
227
|
+
padding: 3px 10px;
|
|
228
|
+
border-bottom: 1px solid rgba(255,255,255,0.04);
|
|
229
|
+
color: var(--text);
|
|
230
|
+
white-space: nowrap;
|
|
231
|
+
max-width: 200px;
|
|
232
|
+
overflow: hidden;
|
|
233
|
+
text-overflow: ellipsis;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.entity-table tbody tr:hover td { background: var(--bg-hover); }
|
|
237
|
+
|
|
238
|
+
.entity-note {
|
|
239
|
+
padding: 4px 10px;
|
|
240
|
+
font-size: 10px;
|
|
241
|
+
color: var(--text-dim);
|
|
242
|
+
}
|
|
243
|
+
|
|
183
244
|
/* ── Computed rows ────────────────────────────── */
|
|
184
245
|
|
|
185
246
|
.comp-row {
|
|
@@ -190,9 +251,7 @@ html, body {
|
|
|
190
251
|
|
|
191
252
|
.comp-top { display: flex; align-items: center; gap: 8px; }
|
|
192
253
|
|
|
193
|
-
.status-dot {
|
|
194
|
-
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
|
|
195
|
-
}
|
|
254
|
+
.status-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
|
196
255
|
.status-dot.fresh { background: var(--green); }
|
|
197
256
|
.status-dot.stale { background: var(--yellow); }
|
|
198
257
|
.status-dot.pending { background: var(--gray); }
|
|
@@ -223,9 +282,31 @@ html, body {
|
|
|
223
282
|
}
|
|
224
283
|
.comp-deps-label { font-size: 11px; color: var(--text-dim); margin-right: 4px; }
|
|
225
284
|
|
|
285
|
+
/* ── Flush history ────────────────────────────── */
|
|
286
|
+
|
|
287
|
+
.flush-row {
|
|
288
|
+
display: flex; align-items: center; gap: 8px;
|
|
289
|
+
padding: 4px 14px; border-bottom: 1px solid rgba(255,255,255,0.04);
|
|
290
|
+
font-size: 11px; font-family: var(--mono);
|
|
291
|
+
}
|
|
292
|
+
.flush-row:hover { background: var(--bg-hover); }
|
|
293
|
+
|
|
294
|
+
.flush-time { color: var(--text-dim); flex-shrink: 0; }
|
|
295
|
+
|
|
296
|
+
.flush-tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
297
|
+
|
|
298
|
+
.flush-tag {
|
|
299
|
+
padding: 1px 6px; border-radius: 3px;
|
|
300
|
+
background: var(--bg-input); color: var(--text-dim);
|
|
301
|
+
font-size: 10px;
|
|
302
|
+
}
|
|
303
|
+
.flush-add { background: #1a3a1c; color: #81c784; }
|
|
304
|
+
.flush-del { background: #3a1a1a; color: #ef9a9a; }
|
|
305
|
+
.flush-no-change { color: var(--text-dim); background: none; }
|
|
306
|
+
|
|
226
307
|
/* ── Scrollbar ────────────────────────────────── */
|
|
227
308
|
|
|
228
|
-
::-webkit-scrollbar { width: 6px; }
|
|
309
|
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
229
310
|
::-webkit-scrollbar-track { background: var(--bg); }
|
|
230
311
|
::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; }
|
|
231
312
|
::-webkit-scrollbar-thumb:hover { background: #555; }
|
|
@@ -234,5 +315,9 @@ html, body {
|
|
|
234
315
|
|
|
235
316
|
.empty {
|
|
236
317
|
padding: 20px 16px; color: var(--text-dim);
|
|
237
|
-
font-size: 12px; text-align: center;
|
|
318
|
+
font-size: 12px; text-align: center; line-height: 1.7;
|
|
319
|
+
}
|
|
320
|
+
.empty code {
|
|
321
|
+
font-family: var(--mono); background: var(--bg-input);
|
|
322
|
+
padding: 1px 5px; border-radius: 3px; color: var(--accent);
|
|
238
323
|
}
|
package/ui/app.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// ── State ──────────────────────────────────────────────────────
|
|
4
4
|
|
|
5
|
-
let autoTimer
|
|
6
|
-
let expanded
|
|
5
|
+
let autoTimer = null;
|
|
6
|
+
let expanded = new Set();
|
|
7
7
|
|
|
8
8
|
// ── Elements ───────────────────────────────────────────────────
|
|
9
9
|
|
|
@@ -16,6 +16,7 @@ const badgeCollections = document.getElementById('badge-collections');
|
|
|
16
16
|
const badgeComputeds = document.getElementById('badge-computeds');
|
|
17
17
|
const listCollections = document.getElementById('list-collections');
|
|
18
18
|
const listComputeds = document.getElementById('list-computeds');
|
|
19
|
+
const listHistory = document.getElementById('list-history');
|
|
19
20
|
const btnRefresh = document.getElementById('btn-refresh');
|
|
20
21
|
const chkAuto = document.getElementById('chk-auto');
|
|
21
22
|
|
|
@@ -23,7 +24,8 @@ const chkAuto = document.getElementById('chk-auto');
|
|
|
23
24
|
|
|
24
25
|
async function poll() {
|
|
25
26
|
try {
|
|
26
|
-
const
|
|
27
|
+
const base = location.origin !== 'null' ? location.origin : '';
|
|
28
|
+
const res = await fetch(base + '/api/inspect');
|
|
27
29
|
const json = await res.json();
|
|
28
30
|
|
|
29
31
|
if (!json.connected || !json.data) {
|
|
@@ -43,7 +45,7 @@ async function poll() {
|
|
|
43
45
|
// ── Layout ──────────────────────────────────────────────────────
|
|
44
46
|
|
|
45
47
|
function showWaiting() {
|
|
46
|
-
connDot.className
|
|
48
|
+
connDot.className = 'conn-dot disconnected';
|
|
47
49
|
connLabel.textContent = 'Waiting for browser…';
|
|
48
50
|
screenWaiting.classList.remove('hidden');
|
|
49
51
|
screenMain.classList.add('hidden');
|
|
@@ -56,7 +58,7 @@ function showMain() {
|
|
|
56
58
|
|
|
57
59
|
function setConnected(tab) {
|
|
58
60
|
connDot.className = 'conn-dot connected';
|
|
59
|
-
connLabel.textContent = tab ?
|
|
61
|
+
connLabel.textContent = tab ? tab.title : 'Connected';
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
// ── Render ──────────────────────────────────────────────────────
|
|
@@ -64,12 +66,14 @@ function setConnected(tab) {
|
|
|
64
66
|
function render(state) {
|
|
65
67
|
const collections = state.collections || {};
|
|
66
68
|
const computeds = state.computeds || {};
|
|
69
|
+
const history = state.history || [];
|
|
67
70
|
|
|
68
71
|
badgeCollections.textContent = Object.keys(collections).length;
|
|
69
72
|
badgeComputeds.textContent = Object.keys(computeds).length;
|
|
70
73
|
|
|
71
74
|
renderCollections(collections);
|
|
72
75
|
renderComputeds(computeds);
|
|
76
|
+
renderHistory(history);
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
// ── Collections ─────────────────────────────────────────────────
|
|
@@ -109,7 +113,11 @@ function buildCollRow(name, info) {
|
|
|
109
113
|
|
|
110
114
|
const detail = document.createElement('div');
|
|
111
115
|
detail.className = 'c-detail' + (isOpen ? ' open' : '');
|
|
112
|
-
detail.innerHTML =
|
|
116
|
+
detail.innerHTML = buildCollMeta(info);
|
|
117
|
+
|
|
118
|
+
// Entity table
|
|
119
|
+
const table = buildEntityTable(info);
|
|
120
|
+
if (table) detail.appendChild(table);
|
|
113
121
|
|
|
114
122
|
summary.addEventListener('click', () => {
|
|
115
123
|
const opening = detail.classList.toggle('open');
|
|
@@ -122,7 +130,7 @@ function buildCollRow(name, info) {
|
|
|
122
130
|
return row;
|
|
123
131
|
}
|
|
124
132
|
|
|
125
|
-
function
|
|
133
|
+
function buildCollMeta(info) {
|
|
126
134
|
const parts = [];
|
|
127
135
|
|
|
128
136
|
if (info.sampleEntity && typeof info.sampleEntity === 'object') {
|
|
@@ -152,7 +160,79 @@ function buildCollDetail(info) {
|
|
|
152
160
|
parts.push(drow('desc', `<span style="color:var(--text-dim)">${esc(info.description)}</span>`));
|
|
153
161
|
}
|
|
154
162
|
|
|
155
|
-
return parts.join('') || '
|
|
163
|
+
return parts.join('') || '';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Entity table ────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
const MAX_ENTITY_ROWS = 200;
|
|
169
|
+
|
|
170
|
+
function buildEntityTable(info) {
|
|
171
|
+
const entities = info.entities;
|
|
172
|
+
if (!Array.isArray(entities) || entities.length === 0) return null;
|
|
173
|
+
|
|
174
|
+
const first = entities[0];
|
|
175
|
+
if (!first || typeof first !== 'object') return null;
|
|
176
|
+
const cols = Object.keys(first);
|
|
177
|
+
if (cols.length === 0) return null;
|
|
178
|
+
|
|
179
|
+
const shown = entities.slice(0, MAX_ENTITY_ROWS);
|
|
180
|
+
|
|
181
|
+
const wrap = document.createElement('div');
|
|
182
|
+
wrap.className = 'entity-wrap';
|
|
183
|
+
|
|
184
|
+
const tableWrap = document.createElement('div');
|
|
185
|
+
tableWrap.className = 'entity-table-wrap';
|
|
186
|
+
|
|
187
|
+
const table = document.createElement('table');
|
|
188
|
+
table.className = 'entity-table';
|
|
189
|
+
|
|
190
|
+
// Head
|
|
191
|
+
const thead = document.createElement('thead');
|
|
192
|
+
const headRow = document.createElement('tr');
|
|
193
|
+
cols.forEach(col => {
|
|
194
|
+
const th = document.createElement('th');
|
|
195
|
+
th.textContent = col;
|
|
196
|
+
headRow.appendChild(th);
|
|
197
|
+
});
|
|
198
|
+
thead.appendChild(headRow);
|
|
199
|
+
table.appendChild(thead);
|
|
200
|
+
|
|
201
|
+
// Body
|
|
202
|
+
const tbody = document.createElement('tbody');
|
|
203
|
+
shown.forEach(entity => {
|
|
204
|
+
const tr = document.createElement('tr');
|
|
205
|
+
cols.forEach(col => {
|
|
206
|
+
const td = document.createElement('td');
|
|
207
|
+
const val = (entity)[col];
|
|
208
|
+
td.textContent = fmtCell(val);
|
|
209
|
+
td.title = JSON.stringify(val);
|
|
210
|
+
tr.appendChild(td);
|
|
211
|
+
});
|
|
212
|
+
tbody.appendChild(tr);
|
|
213
|
+
});
|
|
214
|
+
table.appendChild(tbody);
|
|
215
|
+
tableWrap.appendChild(table);
|
|
216
|
+
wrap.appendChild(tableWrap);
|
|
217
|
+
|
|
218
|
+
if (entities.length > MAX_ENTITY_ROWS) {
|
|
219
|
+
const note = document.createElement('div');
|
|
220
|
+
note.className = 'entity-note';
|
|
221
|
+
note.textContent = `showing ${MAX_ENTITY_ROWS} of ${entities.length}`;
|
|
222
|
+
wrap.appendChild(note);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return wrap;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function fmtCell(v) {
|
|
229
|
+
if (v === null || v === undefined) return '—';
|
|
230
|
+
if (typeof v === 'object') {
|
|
231
|
+
const s = JSON.stringify(v);
|
|
232
|
+
return s.length > 40 ? s.slice(0, 40) + '…' : s;
|
|
233
|
+
}
|
|
234
|
+
const s = String(v);
|
|
235
|
+
return s.length > 60 ? s.slice(0, 60) + '…' : s;
|
|
156
236
|
}
|
|
157
237
|
|
|
158
238
|
function drow(label, content) {
|
|
@@ -240,6 +320,62 @@ function buildCompRow(name, info) {
|
|
|
240
320
|
return row;
|
|
241
321
|
}
|
|
242
322
|
|
|
323
|
+
// ── Flush History ───────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
function renderHistory(history) {
|
|
326
|
+
if (!history.length) {
|
|
327
|
+
listHistory.innerHTML = '<div class="empty">No flushes recorded yet.<br>Call <code>registry.trackFlushes(...)</code> in debug.ts.</div>';
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
listHistory.innerHTML = '';
|
|
331
|
+
history.forEach((record, i) => {
|
|
332
|
+
const prev = history[i + 1];
|
|
333
|
+
listHistory.appendChild(buildFlushRow(record, prev));
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function buildFlushRow(record, prev) {
|
|
338
|
+
const row = document.createElement('div');
|
|
339
|
+
row.className = 'flush-row';
|
|
340
|
+
|
|
341
|
+
const time = document.createElement('span');
|
|
342
|
+
time.className = 'flush-time';
|
|
343
|
+
time.textContent = new Date(record.time).toLocaleTimeString('en', {
|
|
344
|
+
hour12: false,
|
|
345
|
+
hour: '2-digit',
|
|
346
|
+
minute: '2-digit',
|
|
347
|
+
second: '2-digit',
|
|
348
|
+
fractionalSecondDigits: 3,
|
|
349
|
+
});
|
|
350
|
+
row.appendChild(time);
|
|
351
|
+
|
|
352
|
+
const tags = document.createElement('span');
|
|
353
|
+
tags.className = 'flush-tags';
|
|
354
|
+
|
|
355
|
+
for (const [name, count] of Object.entries(record.counts)) {
|
|
356
|
+
const prevCount = prev ? (prev.counts[name] ?? 0) : 0;
|
|
357
|
+
const diff = count - prevCount;
|
|
358
|
+
const tag = document.createElement('span');
|
|
359
|
+
tag.className = 'flush-tag';
|
|
360
|
+
|
|
361
|
+
if (diff === 0 && i !== 0) continue; // skip unchanged (unless first flush)
|
|
362
|
+
const sign = diff > 0 ? '+' : '';
|
|
363
|
+
tag.textContent = diff !== 0 ? `${name} ${sign}${diff}` : `${name} ${count}`;
|
|
364
|
+
tag.className = 'flush-tag' + (diff > 0 ? ' flush-add' : diff < 0 ? ' flush-del' : '');
|
|
365
|
+
tags.appendChild(tag);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!tags.children.length) {
|
|
369
|
+
const tag = document.createElement('span');
|
|
370
|
+
tag.className = 'flush-tag flush-no-change';
|
|
371
|
+
tag.textContent = 'no changes';
|
|
372
|
+
tags.appendChild(tag);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
row.appendChild(tags);
|
|
376
|
+
return row;
|
|
377
|
+
}
|
|
378
|
+
|
|
243
379
|
// ── Helpers ─────────────────────────────────────────────────────
|
|
244
380
|
|
|
245
381
|
function esc(s) {
|
|
@@ -261,8 +397,8 @@ function fmt(v) {
|
|
|
261
397
|
|
|
262
398
|
// ── Auto-refresh ─────────────────────────────────────────────────
|
|
263
399
|
|
|
264
|
-
function startAuto()
|
|
265
|
-
function stopAuto()
|
|
400
|
+
function startAuto() { stopAuto(); autoTimer = setInterval(poll, 1000); }
|
|
401
|
+
function stopAuto() { if (autoTimer !== null) { clearInterval(autoTimer); autoTimer = null; } }
|
|
266
402
|
|
|
267
403
|
// ── Boot ────────────────────────────────────────────────────────
|
|
268
404
|
|
package/ui/index.html
CHANGED
|
@@ -44,11 +44,17 @@
|
|
|
44
44
|
<div id="list-collections" class="col-body"></div>
|
|
45
45
|
</div>
|
|
46
46
|
<div id="col-right" class="col">
|
|
47
|
-
<div class="col-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
<div class="col-section">
|
|
48
|
+
<div class="col-header">
|
|
49
|
+
Computeds
|
|
50
|
+
<span class="badge" id="badge-computeds">0</span>
|
|
51
|
+
</div>
|
|
52
|
+
<div id="list-computeds" class="col-body col-body-flex"></div>
|
|
53
|
+
</div>
|
|
54
|
+
<div class="col-section col-section-history">
|
|
55
|
+
<div class="col-header">Flushes</div>
|
|
56
|
+
<div id="list-history" class="col-body col-body-flex"></div>
|
|
50
57
|
</div>
|
|
51
|
-
<div id="list-computeds" class="col-body"></div>
|
|
52
58
|
</div>
|
|
53
59
|
</div>
|
|
54
60
|
|