grafana, tautulli, nextcloud, nodered: full nox modules (ADR 0044/0046)
grafana: status/datasources/dashboards/alerts tools, emits alert.firing. tautulli: activity/history/stats tools, emits watch.recorded. nextcloud: users/shares/apps/occ tools (occ via docker exec, shares over OCS), emits user.created/share.created. nodered: flows/nodes/deploy tools, emits flows.deployed inline from the deploy tool. All typecheck; manifests parse.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
// Node-RED's admin-API client — nodered's own code, living in the module (novox/hq ADR 0044). Only
|
||||
// this module's tools import it; nodered has nothing to poll, so there is no events entrypoint.
|
||||
//
|
||||
// Node-RED exposes a runtime admin API under its base URL: GET/POST /flows for the whole flow
|
||||
// configuration, GET /nodes for installed node modules. A default install has no auth; when
|
||||
// adminAuth is on, a bearer token (minted at /auth/token) is required.
|
||||
|
||||
export interface NodeRedFlow {
|
||||
/** The tab (flow) node id. */
|
||||
id: string;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface NodeRedNodeModule {
|
||||
name: string;
|
||||
version: string;
|
||||
types: string[];
|
||||
}
|
||||
|
||||
export class NodeRedClient {
|
||||
readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly token?: string,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. MESH_NODERED_URL locates the admin API and is the
|
||||
* "this node runs Node-RED" signal — throws when unset, and the module then contributes nothing
|
||||
* rather than failing on every node. MESH_NODERED_TOKEN is the bearer token when adminAuth is on;
|
||||
* a default install needs none.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): NodeRedClient {
|
||||
const url = env.MESH_NODERED_URL;
|
||||
if (!url) throw new Error("no Node-RED URL — set MESH_NODERED_URL");
|
||||
return new NodeRedClient(url, env.MESH_NODERED_TOKEN);
|
||||
}
|
||||
|
||||
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
||||
return { Accept: "application/json", ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), ...extra };
|
||||
}
|
||||
|
||||
private async req(path: string, init: RequestInit = {}): Promise<any> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, init);
|
||||
if (!res.ok) throw new Error(`Node-RED ${path}: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** The full flow configuration — the flat array of every node across every tab. */
|
||||
async getConfig(): Promise<any[]> {
|
||||
const body = await this.req("/flows", { headers: this.headers() });
|
||||
// /flows answers a bare array by default, or { rev, flows } to a v2-aware client.
|
||||
return Array.isArray(body) ? body : (body.flows ?? []);
|
||||
}
|
||||
|
||||
/** The tabs (flows), each a node of type "tab" in the configuration. */
|
||||
async listFlows(): Promise<{ flows: NodeRedFlow[]; nodeCount: number }> {
|
||||
const config = await this.getConfig();
|
||||
const flows = config
|
||||
.filter((n) => n.type === "tab")
|
||||
.map((n) => ({ id: n.id, label: n.label ?? "(unnamed)", disabled: !!n.disabled }));
|
||||
return { flows, nodeCount: config.length };
|
||||
}
|
||||
|
||||
async listNodes(): Promise<NodeRedNodeModule[]> {
|
||||
const modules = (await this.req("/nodes", { headers: this.headers() })) as any[];
|
||||
return modules.map((m) => ({ name: m.name, version: m.version, types: m.types ?? [] }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the whole flow configuration and deploy. Returns the new revision. `type` maps to
|
||||
* Node-RED's deployment types — "full" (default), "nodes", or "flows".
|
||||
*/
|
||||
async deployFlows(config: any[], type = "full"): Promise<{ rev?: string; nodeCount: number }> {
|
||||
const body = await this.req("/flows", {
|
||||
method: "POST",
|
||||
headers: this.headers({ "Content-Type": "application/json", "Node-RED-Deployment-Type": type }),
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
return { rev: body?.rev, nodeCount: config.length };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"module": "nodered",
|
||||
"version": "1",
|
||||
"emits": [
|
||||
"module.nodered.flows.deployed"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/nodered/broker"
|
||||
},
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-nodered",
|
||||
"version": "0.1.0",
|
||||
"description": "nodered — flow-based automation. Its client and tools live here (novox/hq ADR 0044).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// nodered's tools — importing nodered's own admin-API client (novox/hq ADR 0044). They return
|
||||
// structured data; the mesh serves them through the sdk's tool harness.
|
||||
//
|
||||
// The deploy tool is nodered's one event source (novox/hq ADR 0046/0047): a successful deploy
|
||||
// emits module.nodered.flows.deployed. nodered has nothing to observe on a timer, so there is no
|
||||
// separate events entrypoint — the emit rides the action that causes it. The emit is best-effort:
|
||||
// if no broker is bound, the deploy still succeeds and the announcement is simply skipped.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { NodeRedClient } from "../client.js";
|
||||
|
||||
export function getNodeRedTools(nodered: NodeRedClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "nodered_list_flows",
|
||||
description: "List Node-RED flows (tabs) — id, label, disabled state — and the total node count.",
|
||||
input: {},
|
||||
run: async () => nodered.listFlows(),
|
||||
},
|
||||
{
|
||||
name: "nodered_list_nodes",
|
||||
description: "List the Node-RED node modules installed in the runtime and their versions.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const nodes = await nodered.listNodes();
|
||||
return { count: nodes.length, nodes };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nodered_deploy",
|
||||
description:
|
||||
"Replace the whole Node-RED flow configuration and deploy it. `flows` is the full node " +
|
||||
"array (as GET /flows returns). Emits module.nodered.flows.deployed on success.",
|
||||
input: {
|
||||
flows: { type: "array", description: "the full flow configuration — every node across every tab" },
|
||||
type: { type: "string", description: "deployment type: full (default), nodes, or flows" },
|
||||
},
|
||||
run: async (args) => {
|
||||
const flows = args.flows as unknown[];
|
||||
if (!Array.isArray(flows)) throw new Error("flows must be an array of Node-RED nodes");
|
||||
const type = args.type ? String(args.type) : "full";
|
||||
const result = await nodered.deployFlows(flows, type);
|
||||
// Best-effort announcement — a deploy must not fail because the broker is unbound here.
|
||||
try {
|
||||
await emit("module.nodered.flows.deployed", { rev: result.rev, nodeCount: result.nodeCount, type });
|
||||
} catch (err) {
|
||||
console.error(`[nodered] deployed but could not emit: ${err}`);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when a Node-RED URL is configured; without one, nodered contributes none
|
||||
// rather than failing the whole tool runtime.
|
||||
registerModuleTools("nodered", (env) => {
|
||||
try {
|
||||
return getNodeRedTools(NodeRedClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "tools/index.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user