UNPKG

openclaw-grafana-lens

Version:

OpenClaw plugin that gives AI agents full Grafana access — 18 composable tools for PromQL/LogQL/TraceQL queries, dashboard creation, alerting, SRE investigation, security monitoring, data collection pipeline management via Grafana Alloy (29 recipes), and

215 lines (214 loc) 9.05 kB
/** * Pipeline Store * * Manages the lifecycle of Alloy pipeline definitions: * - CRUD operations on pipeline records * - Persistence to ${stateDir}/alloy-pipelines.json * - Drift detection (file vs. state vs. Alloy components) * - Limit enforcement (max pipelines) * * Follows the same persistence pattern as CustomMetricsStore: * - Versioned JSON file in stateDir * - Load on start, save after each mutation * - Survives process restarts * * State is the source of truth for what Grafana Lens has created. * Drift detection reconciles state with the filesystem and Alloy runtime. */ import { readFile, writeFile, rename, readdir } from "node:fs/promises"; import { join } from "node:path"; import { createHash, randomUUID } from "node:crypto"; const STATE_FILE = "alloy-pipelines.json"; const DEFAULT_LIMITS = { maxPipelines: 20, }; export class PipelineStore { state = { version: 1, pipelines: [] }; stateFilePath; limits; configDir; filePrefix; constructor(opts) { this.stateFilePath = join(opts.stateDir, STATE_FILE); this.configDir = opts.configDir; this.filePrefix = opts.filePrefix ?? "lens-"; this.limits = { maxPipelines: opts.limits?.maxPipelines ?? DEFAULT_LIMITS.maxPipelines, }; } // ── Lifecycle ───────────────────────────────────────────────────── /** Load state from disk. Creates empty state if file doesn't exist. */ async load() { try { const raw = await readFile(this.stateFilePath, "utf-8"); const parsed = JSON.parse(raw); if (parsed.version === 1 && Array.isArray(parsed.pipelines)) { this.state = parsed; } } catch (err) { // File doesn't exist yet — start with empty state if (err.code === "ENOENT") { this.state = { version: 1, pipelines: [] }; return; } throw err; } } /** Save current state to disk (atomic: write tmp then rename). */ async save() { const tmp = `${this.stateFilePath}.tmp`; await writeFile(tmp, JSON.stringify(this.state, null, 2), "utf-8"); await rename(tmp, this.stateFilePath); } // ── CRUD Operations ─────────────────────────────────────────────── /** Generate a short unique pipeline ID (8 hex chars). */ generateId() { return randomUUID().replace(/-/g, "").slice(0, 8); } /** * Add a new pipeline definition. * Throws if the name already exists or limits are exceeded. */ add(pipeline) { // Check name uniqueness if (this.state.pipelines.some((p) => p.name === pipeline.name)) { throw new Error(`Pipeline "${pipeline.name}" already exists. Use a different name or delete the existing one.`); } // Check pipeline limit const active = this.state.pipelines.filter((p) => p.status !== "stopped" && p.status !== "failed"); if (active.length >= this.limits.maxPipelines) { throw new Error(`Cannot create pipeline — limit of ${this.limits.maxPipelines} managed pipelines reached. Delete unused pipelines with action 'delete'.`); } this.state.pipelines.push(pipeline); } /** Get a pipeline by name. Returns undefined if not found. */ get(name) { return this.state.pipelines.find((p) => p.name === name); } /** List all pipelines, optionally filtered by status. */ list(statusFilter) { if (statusFilter) { return this.state.pipelines.filter((p) => p.status === statusFilter); } return [...this.state.pipelines]; } /** Update a pipeline's fields. Returns false if not found. */ update(name, updates) { const pipeline = this.state.pipelines.find((p) => p.name === name); if (!pipeline) return false; Object.assign(pipeline, updates, { updatedAt: Date.now() }); return true; } /** Remove a pipeline by name. Returns false if not found. */ remove(name) { const idx = this.state.pipelines.findIndex((p) => p.name === name); if (idx === -1) return false; this.state.pipelines.splice(idx, 1); return true; } /** Get pipeline count and limits. */ usage() { const active = this.state.pipelines.filter((p) => p.status !== "stopped" && p.status !== "failed").length; return { count: active, max: this.limits.maxPipelines }; } // ── Config File Helpers ─────────────────────────────────────────── /** Generate the .alloy file path for a pipeline. */ configFilePath(pipelineId, name) { const sanitized = name.replace(/[^a-zA-Z0-9_-]/g, "-"); return join(this.configDir, `${this.filePrefix}${sanitized}-${pipelineId}.alloy`); } /** Compute SHA-256 hash of config content (for drift detection). */ static configHash(content) { return createHash("sha256").update(content).digest("hex"); } // ── Port Conflict Detection ─────────────────────────────────────── /** * Check if any of the given ports conflict with active pipelines. * Returns the first conflict found, or null if no conflicts. */ checkPortConflict(ports, excludePipeline) { for (const pipeline of this.state.pipelines) { if (pipeline.status === "stopped" || pipeline.status === "failed") continue; if (excludePipeline && pipeline.name === excludePipeline) continue; for (const port of ports) { if (pipeline.boundPorts?.includes(port)) { return { port, pipelineName: pipeline.name }; } } } return null; } // ── Drift Detection ─────────────────────────────────────────────── /** * Detect drift between stored state and filesystem. * Checks file existence and hash for all active pipelines, * plus scans for orphan files in the config directory. */ async detectFileDrift() { const report = { fileDrift: [], orphanFiles: [], }; // Check all active/pending pipelines have matching files (parallel reads) const activePipelines = this.state.pipelines.filter((p) => p.status !== "stopped" && p.status !== "failed"); const checks = await Promise.allSettled(activePipelines.map(async (pipeline) => { try { const content = await readFile(pipeline.filePath, "utf-8"); const hash = PipelineStore.configHash(content); if (hash !== pipeline.configHash) { return { name: pipeline.name, issue: "Config file content changed externally (hash mismatch)" }; } return null; } catch (err) { if (err.code === "ENOENT") { return { name: pipeline.name, issue: "Config file missing from disk" }; } throw err; } })); for (const check of checks) { if (check.status === "fulfilled" && check.value) { report.fileDrift.push(check.value); } } // Scan for orphan files (lens-*.alloy not tracked in state) try { const files = await readdir(this.configDir); const managedFiles = new Set(this.state.pipelines.map((p) => p.filePath.split("/").pop())); for (const file of files) { if (file.startsWith(this.filePrefix) && file.endsWith(".alloy") && !managedFiles.has(file)) { report.orphanFiles.push(file); } } } catch { // Config dir doesn't exist or unreadable — not a drift issue } return report; } /** * Mark pipelines as drifted based on drift report. * Call this after detectFileDrift() to update state. */ applyDriftReport(report) { let updated = 0; for (const drift of report.fileDrift) { const pipeline = this.state.pipelines.find((p) => p.name === drift.name); if (pipeline && pipeline.status !== "drift") { pipeline.status = "drift"; pipeline.lastError = drift.issue; pipeline.updatedAt = Date.now(); updated++; } } return updated; } }