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,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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user