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:
2026-09-04 02:43:20 +02:00
parent 1e2a849b90
commit eccfc70991
23 changed files with 897 additions and 2 deletions
+110
View File
@@ -0,0 +1,110 @@
// Grafana's API client — grafana's own code, living in the module (novox/hq ADR 0044). Ported from
// the shared hal sdk, where a change here rebuilt everything; here it rebuilds only grafana. Both
// this module's tools and its events entrypoint import it, and nothing outside grafana does.
export interface GrafanaHealth {
database: string;
version: string;
commit: string;
}
export interface GrafanaDatasource {
id: number;
uid: string;
name: string;
type: string;
url: string;
isDefault: boolean;
database?: string;
}
export interface GrafanaDashboard {
uid: string;
title: string;
url: string;
tags: string[];
folderTitle?: string;
}
export interface GrafanaAlert {
/** The rule name (labels.alertname), the stable identity a firing alert is diffed on. */
name: string;
/** Grafana unified-alerting state: "Normal" | "Pending" | "Alerting". */
state: string;
labels: Record<string, string>;
activeAt?: string;
}
export class GrafanaClient {
readonly baseUrl: string;
private readonly authHeader: string;
constructor(url: string, authHeader: string) {
this.baseUrl = url.replace(/\/$/, "");
this.authHeader = authHeader;
}
/**
* Build from the module's resolved environment. Auth is a service-account/API token
* (MESH_GRAFANA_TOKEN, sent as Bearer) when present, else HTTP basic with the admin password the
* module keeps as its own secret (MESH_GRAFANA_PASSWORD, user MESH_GRAFANA_USER, default admin).
* Throws when neither is configured — the module then contributes nothing rather than failing.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): GrafanaClient {
const url = env.MESH_GRAFANA_URL ?? `http://127.0.0.1:${env.GRAFANA_PORT ?? "3000"}`;
const token = env.MESH_GRAFANA_TOKEN;
if (token) return new GrafanaClient(url, `Bearer ${token}`);
const password = env.MESH_GRAFANA_PASSWORD;
if (password) {
const user = env.MESH_GRAFANA_USER ?? "admin";
return new GrafanaClient(url, `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`);
}
throw new Error("no Grafana auth — set MESH_GRAFANA_TOKEN or MESH_GRAFANA_PASSWORD");
}
private async get(path: string): Promise<any> {
const res = await fetch(`${this.baseUrl}${path}`, {
headers: { Authorization: this.authHeader, Accept: "application/json" },
});
if (!res.ok) throw new Error(`Grafana API ${path}: ${res.status} ${await res.text()}`);
return res.json();
}
async health(): Promise<GrafanaHealth> {
const h = await this.get("/api/health");
return { database: h.database ?? "unknown", version: h.version ?? "unknown", commit: h.commit ?? "unknown" };
}
async listDatasources(): Promise<GrafanaDatasource[]> {
const arr = (await this.get("/api/datasources")) as any[];
return arr.map((d) => ({
id: d.id, uid: d.uid, name: d.name, type: d.type, url: d.url,
isDefault: !!d.isDefault, database: d.database || undefined,
}));
}
async listDashboards(query?: string): Promise<GrafanaDashboard[]> {
const params = new URLSearchParams({ type: "dash-db" });
if (query) params.set("query", query);
const arr = (await this.get(`/api/search?${params.toString()}`)) as any[];
return arr.map((d) => ({
uid: d.uid, title: d.title, url: d.url, tags: d.tags ?? [], folderTitle: d.folderTitle || undefined,
}));
}
/**
* Active alert instances from unified alerting's Prometheus-compatible surface. Grafana without
* alerting configured answers this with an empty set (or a 404, surfaced by get) — callers treat
* "no alerts" and "no alerting" alike.
*/
async listAlerts(): Promise<GrafanaAlert[]> {
const data = (await this.get("/api/prometheus/grafana/api/v1/alerts")).data ?? {};
const alerts = (data.alerts ?? []) as any[];
return alerts.map((a) => ({
name: a.labels?.alertname ?? "unknown",
state: a.state ?? "unknown",
labels: a.labels ?? {},
activeAt: a.activeAt || undefined,
}));
}
}
+58
View File
@@ -0,0 +1,58 @@
// grafana's events. The tool runtime imports this once the broker is bound. It watches unified
// alerting and announces when an alert instance starts firing.
//
// Emits (novox/hq ADR 0046/0047):
// module.grafana.alert.firing — an alert instance entered the Alerting state
//
// A Grafana with no alerting configured simply never has a firing alert, so this observes nothing
// and emits nothing — no error, no noise.
import { emit } from "@novox/mesh-sdk/events";
import { GrafanaClient, type GrafanaAlert } from "./client.js";
// Constructed lazily so an unconfigured node (no auth) loads this entrypoint without crashing the
// events host — it simply watches nothing.
let grafana: GrafanaClient | undefined;
try {
grafana = GrafanaClient.fromEnv();
} catch (err) {
console.log(`[grafana] not configured, not watching alerts: ${err}`);
}
// Firing alerts, by diffing the set currently in the Alerting state. Primed silently on the first
// look so alerts already firing when this started are not announced as freshly firing.
const firing = new Set<string>();
let primed = false;
const alertKey = (a: GrafanaAlert): string =>
`${a.name}:${Object.entries(a.labels).sort().map(([k, v]) => `${k}=${v}`).join(",")}`;
async function pollAlerts(client: GrafanaClient): Promise<void> {
const now = new Set<string>();
const byKey = new Map<string, GrafanaAlert>();
for (const a of await client.listAlerts()) {
if (a.state.toLowerCase() !== "alerting") continue;
const key = alertKey(a);
now.add(key);
byKey.set(key, a);
}
if (primed) {
for (const key of now) {
if (!firing.has(key)) {
const a = byKey.get(key)!;
await emit("module.grafana.alert.firing", { name: a.name, labels: a.labels, activeAt: a.activeAt });
}
}
}
firing.clear();
for (const key of now) firing.add(key);
primed = true;
}
if (grafana) {
const client = grafana;
const run = (): void => void pollAlerts(client).catch((err) => console.error(`[grafana] ${err}`));
setInterval(run, 30_000);
run();
console.log("[grafana] watching for firing alerts");
}
+5 -1
View File
@@ -1,8 +1,12 @@
{
"module": "grafana",
"version": "1",
"emits": [
"module.grafana.alert.firing"
],
"own-secrets": {
"admin": "/var/lib/grafana-module/admin.secret"
"admin": "/var/lib/grafana-module/admin.secret",
"broker": "/var/lib/grafana-module/broker"
},
"capabilities": [
"container-runtime"
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@novox/module-grafana",
"version": "0.1.0",
"description": "grafana — monitoring dashboards. Its API client, tools and events 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"
}
}
+55
View File
@@ -0,0 +1,55 @@
// grafana's tools — moved here from the shared hal sdk (novox/hq ADR 0044), importing grafana's own
// client. They return structured data; the mesh serves them through the sdk's tool harness.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { GrafanaClient } from "../client.js";
export function getGrafanaTools(grafana: GrafanaClient): ToolDefinition[] {
return [
{
name: "grafana_status",
description: "Grafana server health — database state, version, build commit.",
input: {},
run: async () => grafana.health(),
},
{
name: "grafana_list_datasources",
description: "List Grafana data sources — name, type, backing URL, which is default.",
input: {},
run: async () => {
const datasources = await grafana.listDatasources();
return { count: datasources.length, datasources };
},
},
{
name: "grafana_list_dashboards",
description: "List Grafana dashboards, optionally filtered by a name query.",
input: { query: { type: "string", description: "filter dashboards by name (optional)" } },
run: async (args) => {
const query = args.query ? String(args.query) : undefined;
const dashboards = await grafana.listDashboards(query);
return { count: dashboards.length, dashboards };
},
},
{
name: "grafana_alerts",
description: "Active Grafana alert instances and their state (Alerting, Pending, Normal).",
input: {},
run: async () => {
const alerts = await grafana.listAlerts();
const firing = alerts.filter((a) => a.state.toLowerCase() === "alerting");
return { count: alerts.length, firing: firing.length, alerts };
},
},
];
}
// The tools exist only when Grafana auth can be resolved; without it, grafana contributes none
// rather than failing the whole tool runtime.
registerModuleTools("grafana", (env) => {
try {
return getGrafanaTools(GrafanaClient.fromEnv(env));
} catch {
return [];
}
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "index.ts", "tools/index.ts"]
}